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 @@ + + +
+ + +Project Documentation
+
+ Centralized auth service for all rokojori projects, deployed at
+ account.rokojori.com. Developer reference for human and agent contributors.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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. +
+ +
+ 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.
+
+ 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.
+
+ In-memory per-IP rate limits on all sensitive endpoints. Windows and thresholds: +
+
+ 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 paramregister.html — accepts ?redirect= query paramprofile.html — change password, delete account; admin/superadmin see user listforgot-password.htmlreset-password.html — reads ?token= from URL; includes hidden email field for browser password-manager integration
+ 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.
+
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
+ // 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": "..." }
+
+ 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" }
+}
+
+ 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.
+
+ 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. +
+ ++ 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. +
+
+ 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. +
+ +