From 61f15e132567522d0cf1e82f63f365bae551ffb0 Mon Sep 17 00:00:00 2001 From: Rokojori Date: Mon, 13 Jul 2026 13:42:50 +0200 Subject: [PATCH] Added Workspace Docs --- workspace/_assets_/nav-data.js | 5 + workspace/_assets_/nav.css | 92 +++++++++ workspace/_assets_/nav.js | 133 +++++++++++++ workspace/_assets_/styles.css | 200 ++++++++++++++++++++ workspace/index.html | 329 +++++++++++++++++++++++++++++++++ 5 files changed, 759 insertions(+) create mode 100644 workspace/_assets_/nav-data.js create mode 100644 workspace/_assets_/nav.css create mode 100644 workspace/_assets_/nav.js create mode 100644 workspace/_assets_/styles.css create mode 100644 workspace/index.html diff --git a/workspace/_assets_/nav-data.js b/workspace/_assets_/nav-data.js new file mode 100644 index 0000000..0e05cfb --- /dev/null +++ b/workspace/_assets_/nav-data.js @@ -0,0 +1,5 @@ +var NAV_DATA = { + title: 'Workspace', + path: 'index.html', + children: [] +}; diff --git a/workspace/_assets_/nav.css b/workspace/_assets_/nav.css new file mode 100644 index 0000000..2a807b1 --- /dev/null +++ b/workspace/_assets_/nav.css @@ -0,0 +1,92 @@ +.wsnav +{ + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: #1a1d27; + border-bottom: 1px solid #2a2d3a; + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-size: 0.82rem; +} + +.wsnav-inner +{ + width: 65em; + margin: 0 auto; + padding: 0.45rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.wsnav a +{ + color: #7c8cff; + text-decoration: none; +} + +.wsnav a:hover +{ + text-decoration: underline; +} + +/* ── Breadcrumb ── */ + +.wsnav-crumb +{ + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.wsnav-sep +{ + color: #4a4e6a; + font-size: 0.9em; +} + +.wsnav-current +{ + color: #e2e4ed; + font-weight: 600; +} + +/* ── Siblings ── */ + +.wsnav-siblings +{ + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + color: #7b7f96; + font-size: 0.88em; +} + +.wsnav-sib-current +{ + color: #e2e4ed; + font-weight: 600; +} + +.wsnav-sib-sep +{ + color: #4a4e6a; +} + +/* ── Children ── */ + +.wsnav-children +{ + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + font-size: 0.88em; + padding-top: 0.1rem; + border-top: 1px solid #2a2d3a; + margin-top: 0.1rem; +} diff --git a/workspace/_assets_/nav.js b/workspace/_assets_/nav.js new file mode 100644 index 0000000..5eefc0b --- /dev/null +++ b/workspace/_assets_/nav.js @@ -0,0 +1,133 @@ +(function () +{ + function getCurrentPath() + { + var href = window.location.href.replace(/\\/g, '/'); + var marker = 'workspace/'; + var idx = href.lastIndexOf(marker); + if (idx === -1) return 'index.html'; + var after = href.slice(idx + marker.length); + return after || 'index.html'; + } + + function findNode(node, targetPath, ancestors) + { + if (node.path === targetPath) return { node: node, ancestors: ancestors }; + var children = node.children || []; + for (var i = 0; i < children.length; i++) + { + var result = findNode(children[i], targetPath, ancestors.concat(node)); + if (result) return result; + } + return null; + } + + function makeLink(node, label) + { + var a = document.createElement('a'); + a.href = NAV_ROOT + node.path; + a.textContent = label || node.title; + return a; + } + + function render() + { + var currentPath = getCurrentPath(); + var found = findNode(NAV_DATA, currentPath, []); + + var nav = document.createElement('nav'); + nav.className = 'wsnav'; + + var inner = document.createElement('div'); + inner.className = 'wsnav-inner'; + nav.appendChild(inner); + + // ── Breadcrumb ─────────────────────────────────────────────────── + var crumb = document.createElement('div'); + crumb.className = 'wsnav-crumb'; + + var ancestors = found ? found.ancestors : []; + var currentNode = found ? found.node : null; + + for (var i = 0; i < ancestors.length; i++) + { + var a = makeLink(ancestors[i]); + crumb.appendChild(a); + var sep = document.createElement('span'); + sep.className = 'wsnav-sep'; + sep.textContent = '›'; + crumb.appendChild(sep); + } + + var current = document.createElement('span'); + current.className = 'wsnav-current'; + current.textContent = currentNode ? currentNode.title : currentPath; + crumb.appendChild(current); + + inner.appendChild(crumb); + + // ── Siblings ───────────────────────────────────────────────────── + var parent = ancestors.length > 0 ? ancestors[ancestors.length - 1] : null; + var siblings = parent ? (parent.children || []) : []; + + if (siblings.length > 1) + { + var sibRow = document.createElement('div'); + sibRow.className = 'wsnav-siblings'; + + for (var j = 0; j < siblings.length; j++) + { + var sib = siblings[j]; + if (sib.path === currentPath) + { + var mark = document.createElement('span'); + mark.className = 'wsnav-sib-current'; + mark.textContent = sib.title; + sibRow.appendChild(mark); + } + else + { + sibRow.appendChild(makeLink(sib)); + } + + if (j < siblings.length - 1) + { + var div = document.createElement('span'); + div.className = 'wsnav-sib-sep'; + div.textContent = '·'; + sibRow.appendChild(div); + } + } + + inner.appendChild(sibRow); + } + + // ── Children ───────────────────────────────────────────────────── + var children = currentNode ? (currentNode.children || []) : []; + + if (children.length > 0) + { + var childRow = document.createElement('div'); + childRow.className = 'wsnav-children'; + + for (var k = 0; k < children.length; k++) + { + childRow.appendChild(makeLink(children[k])); + } + + inner.appendChild(childRow); + } + + document.body.insertBefore(nav, document.body.firstChild); + document.body.style.paddingTop = (nav.offsetHeight + 8) + 'px'; + } + + if (document.readyState === 'loading') + { + document.addEventListener('DOMContentLoaded', render); + } + else + { + render(); + } +})(); diff --git a/workspace/_assets_/styles.css b/workspace/_assets_/styles.css new file mode 100644 index 0000000..44801cf --- /dev/null +++ b/workspace/_assets_/styles.css @@ -0,0 +1,200 @@ +*, *::before, *::after +{ + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root +{ + --bg: #0f1117; + --surface: #1a1d27; + --border: #2a2d3a; + --text: #e2e4ed; + --muted: #7b7f96; + --accent: #7c8cff; + --tag-bg: #1e2235; + --tag-text: #9ba4c7; + --font-size: 20px; +} + +html +{ + font-size: calc( var( --font-size ) ); + -webkit-font-smoothing: antialiased; +} + +body +{ + background: var(--bg); + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + line-height: 1.7; + padding: calc( var( --font-size ) * 3 ) calc( var( --font-size ) * 1.5 ) calc( var( --font-size ) * 6 ); +} + +.page +{ + max-width: 50em; + margin: 0 auto; +} + +header +{ + margin-bottom: calc( var( --font-size ) * 3 ); + padding-bottom: calc( var( --font-size ) * 1.5 ); + border-bottom: 1px solid var(--border); +} + +header .date +{ + font-size: calc( var( --font-size ) * 0.8 ); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: calc( var( --font-size ) * 0.75 ); +} + +header h1 +{ + font-size: calc( var( --font-size ) * 1.9 ); + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); +} + +header .subtitle +{ + margin-top: calc( var( --font-size ) * 0.5 ); + color: var(--muted); + font-size: calc( var( --font-size ) * 0.95 ); +} + +section +{ + margin-bottom: calc( var( --font-size ) * 2.5 ); +} + +section h2 +{ + font-size: calc( var( --font-size ) * 1.25 ); + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: calc( var( --font-size ) * 1 ); +} + +.card +{ + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: calc( var( --font-size ) * 1.25 ) calc( var( --font-size ) * 1.5 ); + margin-bottom: calc( var( --font-size ) * 0.75 ); +} + +.card h3 +{ + font-size: calc( var( --font-size ) * 1 ); + font-weight: 600; + margin-bottom: calc( var( --font-size ) * 0.35 ); + color: var(--text); +} + +.card p +{ + font-size: calc( var( --font-size ) * 0.9 ); + color: var(--muted); + line-height: 1.6; +} + +.tags +{ + display: flex; + flex-wrap: wrap; + gap: calc( var( --font-size ) * 0.4 ); + margin-top: calc( var( --font-size ) * 0.75 ); +} + +.tag +{ + background: var(--tag-bg); + color: var(--tag-text); + font-size: calc( var( --font-size ) * 0.75 ); + padding: calc( var( --font-size ) * 0.2 ) calc( var( --font-size ) * 0.6 ); + border-radius: 4px; + font-family: ui-monospace, "Cascadia Code", monospace; +} + +.decision +{ + border-left: 3px solid var(--accent); + padding-left: calc( var( --font-size ) * 1 ); + margin-bottom: calc( var( --font-size ) * 2 ); +} + +.decision p +{ + font-size: calc( var( --font-size ) * 0.9 ); + color: var(--muted); +} + +.decision strong +{ + color: var(--text); + display: block; + margin-bottom: calc( var( --font-size ) * 0.2 ); +} + +footer +{ + margin-top: calc( var( --font-size ) * 4 ); + padding-top: calc( var( --font-size ) * 1.5 ); + border-top: 1px solid var(--border); + font-size: calc( var( --font-size ) * 0.8 ); + color: var(--muted); +} + +a +{ + color: var(--accent); + text-decoration: none; +} + +a:hover +{ + text-decoration: underline; +} + +code +{ + font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace; + font-size: 0.85em; + color: var(--accent); + background: var(--tag-bg); + padding: 0.1em 0.35em; + border-radius: 3px; +} + +pre +{ + background: #0a0c14; + border: 1px solid var(--border); + border-radius: 6px; + padding: calc( var( --font-size ) * 1 ) calc( var( --font-size ) * 1.25 ); + overflow-x: auto; + font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace; + font-size: calc( var( --font-size ) * 0.82 ); + line-height: 1.6; + color: var(--text); + margin-top: calc( var( --font-size ) * 0.75 ); +} + +pre code +{ + background: none; + color: inherit; + padding: 0; + font-size: inherit; +} diff --git a/workspace/index.html b/workspace/index.html new file mode 100644 index 0000000..782042e --- /dev/null +++ b/workspace/index.html @@ -0,0 +1,329 @@ + + + + + + rokojori-auth — Developer Documentation + + + + +
+ +
+

Project Documentation

+

rokojori-auth

+

+ Centralized auth service for all rokojori projects, deployed at + account.rokojori.com. Developer reference for human and agent contributors. +

+
+ +
+

What it is

+ +
+

+ rokojori-auth is a standalone Express service that owns all identity for the + rokojori ecosystem — registration, login, JWT issuance, password reset, roles, + products, and global user settings. Every other rokojori project (Roject, future + apps, desktop tools) plugs in by verifying the shared JWT. No auth code to write, + no user table to maintain in the client app. +

+

+ The service issues a short-lived access token (1 hour) and a long-lived refresh + token (30 days). For browser clients both are set as HttpOnly cookies + on .rokojori.com, making them automatically available to all subdomains. + Non-browser clients (Electron, CLI, Godot) receive the tokens in the response body + and manage them locally. +

+
+
+ +
+

Features

+ +
+

Authentication

+

+ Email and password registration and login. Passwords are hashed with bcrypt (cost 10). + On login or register, the server issues an access token cookie and a refresh token + cookie, both scoped to .rokojori.com. The refresh token is also returned + in the response body for non-browser clients. +

+
+ POST /api/auth/register + POST /api/auth/login + POST /api/auth/logout +
+
+ +
+

Token refresh

+

+ Browser clients whose access token has expired are redirected to + GET /api/auth/refresh-session?redirect=.... The server reads the + refresh token cookie, rotates both tokens, and redirects back. Non-browser clients + call POST /api/auth/refresh with the refresh token in the request body. +

+
+ GET /api/auth/refresh-session + POST /api/auth/refresh +
+
+ +
+

Password reset

+

+ Forgot-password sends a time-limited email link (1 hour). The endpoint is + rate-limited per IP with an escalating response delay (5 s → 15 s → 30 s, + hard block after 20 attempts in 20 minutes) to prevent email spam. + The response is always { ok: true } regardless of whether the + email exists, to avoid leaking account information. +

+
+ POST /api/auth/forgot-password + POST /api/auth/reset-password +
+
+ +
+

Profile and account management

+

+ Authenticated users can read their profile, update global settings, change their + password (requires current password), and permanently delete their account. + Deletion removes the user record and all associated tokens. +

+
+ GET /api/auth/me + PATCH /api/auth/me/settings + POST /api/auth/me/password + DELETE /api/auth/me +
+
+ +
+

Roles

+

+ Three built-in roles: user (default on registration), + admin (enhanced profile, user list), and superadmin + (can manage roles of other users). Roles are included in the JWT payload so + client apps can gate features without a round-trip. App-specific roles (e.g. + editor of a specific Roject project) stay in the respective app, keyed by + userId. +

+

+ Bootstrap: set INITIAL_SUPERADMIN_EMAIL in .env. + The first registration with that email is automatically promoted to + superadmin, provided no superadmin exists yet. +

+
+ +
+

Products

+

+ Each user record has a products array. When a purchase is confirmed + (planned: via a Polar webhook), a product entry is appended. Apps read the product + list from the JWT at runtime — no Polar dependency needed outside the webhook handler. +

+
+ +
+

Rate limiting

+

+ In-memory per-IP rate limits on all sensitive endpoints. Windows and thresholds: +

+
    +
  • Login — 10 attempts / 15 min; delay of 3 s added after attempt 5
  • +
  • Register — 5 attempts / hour, hard block
  • +
  • Forgot-password — 20 attempts / 20 min; escalating delay 5 s → 15 s → 30 s
  • +
+
+ +
+

HTML pages

+

+ Five self-contained pages served at account.rokojori.com, + shared across all rokojori apps. All pages are plain HTML with inline + CSS and JavaScript — no framework, no build step. +

+
    +
  • login.html — accepts ?redirect= query param
  • +
  • register.html — accepts ?redirect= query param
  • +
  • profile.html — change password, delete account; admin/superadmin see user list
  • +
  • forgot-password.html
  • +
  • reset-password.html — reads ?token= from URL; includes hidden email field for browser password-manager integration
  • +
+
+
+ +
+

Technical Implementation

+ +
+

Stack

+

+ Node.js + Express, TypeScript compiled on the fly with ts-node. + No database — all data lives as JSON files in build/data/ + (auto-created on first run). All entity IDs are UUIDs via + crypto.randomUUID(). Email via Nodemailer (SMTPEmailSender), + configured entirely through environment variables. +

+
+ Node.js + Express + ts-node + jsonwebtoken + bcryptjs + nodemailer + cookie-parser +
+
+ +
+

File structure

+
rokojori-auth/
+  source/
+    server/
+      routes/
+        auth.ts          — all auth + profile endpoints
+        admin.ts         — user list + role management (admin/superadmin)
+      middleware/
+        requireAuth.ts   — JWT verification (cookie or Authorization header)
+        requireAdmin.ts  — role guards (requireAdmin, requireSuperAdmin)
+      email/
+        EmailSender.ts   — interface
+        SMTPEmailSender.ts
+        EmailService.ts  — static facade
+      db.ts              — users, refreshTokens, resetTokens (JSON file storage)
+      roles.ts           — role → permissions map, helper functions
+      rateLimiter.ts     — per-IP in-memory rate limiters
+      index.ts           — Express entry point
+    pages/               — HTML pages (copied to build/app/ by build script)
+  scripts/
+    copy-pages.js
+  workspace/             — this documentation
+
+ +
+

Data model

+
// users.json
+{
+  "id": "uuid",
+  "email": "user@example.com",
+  "passwordHash": "...",
+  "roles": ["user"],
+  "products": [
+    { "id": "roject-pro", "acquiredAt": "2026-07-13", "source": "polar" }
+  ],
+  "settings": { "theme": "dark", "language": "en" },
+  "createdAt": "2026-07-13T00:00:00Z"
+}
+
+// refreshTokens.json
+{ "token": "uuid", "userId": "uuid", "expiresAt": "..." }
+
+// resetTokens.json
+{ "token": "uuid", "userId": "uuid", "expiresAt": "..." }
+
+ +
+

JWT payload

+

+ Access tokens are signed HS256 with JWT_SECRET (shared across all + services). They carry everything a client app needs to know — no round-trip to + rokojori-auth required at runtime. +

+
{
+  "userId":   "uuid",
+  "email":    "user@example.com",
+  "roles":    ["user"],
+  "products": ["roject-pro"],
+  "settings": { "theme": "dark" }
+}
+
+ +
+

Deployment

+

+ Running on Server A at account.rokojori.com. nginx handles TLS + termination (Let's Encrypt) and reverse-proxies to the Node.js process on port + 3001, managed by a systemd service (rokojori-auth.service). + trust proxy is enabled so the rate limiter sees real client IPs. +

+
+ account.rokojori.com + nginx + Let's Encrypt + systemd + port 3001 +
+
+
+ +
+

Planned

+ +
+

Polar payment webhook

+

+ When a user purchases a product through Polar (merchant of record — handles + VAT/tax automatically), Polar fires a webhook to rokojori-auth. The handler + verifies the Polar signature, finds the user by email, and appends a product + entry to their record. The updated product list is included in the next JWT + issued for that user. No Polar dependency is needed in any other service. +

+
+ POST /api/webhooks/polar + signature verification + products array +
+
+ +
+

Security hardening pass

+

+ A dedicated review pass covering: helmet.js headers, CSRF considerations for the + cookie flow, refresh token rotation audit, token expiry edge cases, and a review + of the rate limiter behaviour under proxy chains. +

+
+
+ +
+

Related — Roject

+ +
+

+ Roject is the first client app of + rokojori-auth. It is a self-hosted, agent-based IDE deployed at + roject.rokojori.com. After the auth integration is complete, Roject + will drop its own user system entirely and become a JWT-validating client: it + redirects to account.rokojori.com/login for login, reads the shared + cookie, and verifies the JWT locally on every request. +

+

+ For deeper context on the overall ecosystem, coding conventions, and how to work + with the codebase, consult the Roject workspace + documentation. It contains guides (writing TypeScript code, backend routes, + editor panels), repeatable actions (update history, update outline), and reference + docs (Editor Singleton, client/server split). Ask for the Roject workspace docs + for those details. +

+
+ roject.rokojori.com + JWT client + shared .rokojori.com cookie +
+
+
+ +
+ rokojori-auth — developer documentation +
+ +
+ + + + +