rojects/workspace/history/2026/07-July/13-Sunday/index.html

276 lines
11 KiB
HTML
Raw Permalink Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sunday, 13 July 2026 — Roject</title>
<link rel="stylesheet" href="../../../../_assets_/styles.css">
<link rel="stylesheet" href="../../../../_assets_/nav.css">
</head>
<body>
<div class="page">
<header>
<p class="date">Sunday, 13 July 2026</p>
<h1>rokojori-auth built + Roject integration</h1>
<p class="subtitle">
Full centralized auth service created and deployed to
<code>account.rokojori.com</code>; Roject's local user system replaced
with JWT verification against rokojori-auth.
</p>
</header>
<section>
<h2>What we built</h2>
<div class="card">
<h3>rokojori-auth — new standalone service</h3>
<p>
A complete auth service at <code>c:\rokojori\projects\web-projects\rokojori-auth</code>,
same stack as Roject (Node.js, Express, ts-node, JSON file storage). Built entirely
from scratch this session. Deployed to <code>account.rokojori.com</code> via nginx +
systemd + Let's Encrypt on Server A, port 3001.
</p>
<div class="tags">
<span class="tag">account.rokojori.com</span>
<span class="tag">nginx + systemd</span>
<span class="tag">port 3001</span>
</div>
</div>
<div class="card">
<h3>Auth endpoints</h3>
<p>
Register, login, logout, refresh (body), refresh-session (cookie + redirect),
forgot-password, reset-password, GET /me, PATCH /me/settings, POST /me/password,
DELETE /me. Access token is a 1-hour HS256 JWT containing userId, email, roles,
products, settings. Refresh token is a 30-day UUID stored server-side.
Both are set as <code>HttpOnly</code> cookies on <code>.rokojori.com</code>
and also returned in the response body for non-browser clients.
</p>
<div class="tags">
<span class="tag">jsonwebtoken</span>
<span class="tag">bcryptjs</span>
<span class="tag">cookie-parser</span>
<span class="tag">HttpOnly cookie</span>
<span class="tag">.rokojori.com domain</span>
</div>
</div>
<div class="card">
<h3>Roles: user, admin, superadmin</h3>
<p>
Three built-in roles. <code>superadmin</code> is bootstrapped via
<code>INITIAL_SUPERADMIN_EMAIL</code> in <code>.env</code> — fires once on
first registration if no superadmin exists. Superadmin can grant/revoke admin
on other users; cannot change their own roles. Admin and superadmin get an
enhanced profile page with a full user list. Role management controls are
superadmin-only.
</p>
</div>
<div class="card">
<h3>Rate limiting</h3>
<p>
In-memory per-IP rate limiters on all sensitive endpoints. Login: 10 attempts /
15 min, 3 s delay after attempt 5. Register: 5 / hour. Forgot-password: 20 / 20 min
with escalating delay (5 s → 15 s → 30 s). <code>trust proxy</code> enabled so
real client IPs are seen behind nginx.
</p>
</div>
<div class="card">
<h3>HTML pages</h3>
<p>
Five self-contained pages: login, register, profile, forgot-password, reset-password.
Plain HTML with inline CSS and JS. Login and register accept a <code>?redirect=</code>
query param. Reset-password fetches the email for the token via
<code>GET /api/auth/reset-token-email</code> and populates a hidden email field
so browsers offer to update the saved password. Profile page includes
change-password (with current password verification) and a two-step delete account
confirmation. Welcome email sent on registration (non-blocking).
</p>
</div>
<div class="card">
<h3>Admin routes</h3>
<p>
<code>GET /api/admin/users</code> (admin + superadmin) lists all users without
password hashes. <code>PATCH /api/admin/users/:id/roles</code> (superadmin only)
sets roles on another user. Guards: cannot change own roles; <code>user</code>
base role is always preserved.
</p>
</div>
<div class="card">
<h3>Workspace documentation</h3>
<p>
Created <code>workspace/</code> in the rokojori-auth repo with the same asset
system as the Roject workspace (styles, nav, breadcrumb). A single
<code>workspace/index.html</code> covers features, technical implementation,
data model, JWT payload, deployment, planned work, and a reference to the
Roject workspace for guides and actions.
</p>
</div>
</section>
<section>
<h2>Key decisions</h2>
<div class="decision">
<strong>Refresh token also set as HttpOnly cookie</strong>
<p>
Originally the refresh token was only returned in the response body. Added a
refresh token cookie so browser clients can silently refresh via
<code>GET /api/auth/refresh-session?redirect=...</code> without re-login.
Non-browser clients continue to use the body token and
<code>POST /api/auth/refresh</code>.
</p>
</div>
<div class="decision">
<strong>Superadmin cannot change their own roles</strong>
<p>
The PATCH /api/admin/users/:id/roles endpoint blocks changes to the calling
user's own record to prevent accidental self-demotion or lockout.
</p>
</div>
<div class="decision">
<strong>Welcome email is non-blocking</strong>
<p>
SMTP failures on the welcome email are caught and silently ignored so that a
misconfigured mail server cannot break user registration.
</p>
</div>
<div class="decision">
<strong>Forgot-password always returns OK</strong>
<p>
The response is always <code>{ ok: true }</code> regardless of whether the email
exists, preventing account enumeration via timing or response differences.
</p>
</div>
</section>
<section>
<h2>What we built — Roject integration</h2>
<div class="card">
<h3>Local auth system removed</h3>
<p>
Deleted <code>routes/auth.ts</code>, <code>sessionStore.ts</code>,
<code>email/</code>, <code>login.html</code>, <code>register.html</code>,
and the <code>login-form</code> and <code>register-form</code> components.
Removed <code>bcryptjs</code> and <code>express-session</code> from
<code>package.json</code>. Removed the <code>User</code> model and
<code>users</code> store from <code>db.ts</code>.
</p>
</div>
<div class="card">
<h3>JWT verification middleware</h3>
<p>
New <code>source/server/middleware/auth.ts</code> reads the
<code>accessToken</code> cookie (or <code>Authorization: Bearer</code> header),
verifies it with the shared <code>JWT_SECRET</code>, and attaches
<code>req.user</code>. Expired tokens on page requests are transparently
redirected to <code>account.rokojori.com/api/auth/refresh-session?redirect=...</code>.
API routes return <code>401</code> when unauthenticated.
Added <code>cookie-parser</code> and <code>jsonwebtoken</code>.
</p>
</div>
<div class="card">
<h3>app-nav updated</h3>
<p>
<code>app-nav.ts</code> now calls <code>GET /api/auth/me</code> on Roject
(which returns <code>req.user</code> from the JWT). On 401 it renders a
"Log in" link pointing to
<code>account.rokojori.com/login.html?redirect=https://roject.rokojori.com</code>.
When authenticated it shows the user's email and a logout link to
<code>account.rokojori.com/api/auth/logout?redirect=...</code>.
</p>
</div>
<div class="card">
<h3>GET /logout added to rokojori-auth</h3>
<p>
Added <code>GET /api/auth/logout?redirect=...</code> to rokojori-auth,
mirroring the <code>refresh-session</code> pattern. Clears both cookies,
invalidates the refresh token, and redirects to the given URL.
</p>
</div>
<div class="card">
<h3>systemd EnvironmentFile</h3>
<p>
Roject's systemd service had no environment variables configured, so
<code>JWT_SECRET</code> was silently empty and all JWT verifications failed.
Fixed by adding <code>EnvironmentFile=/opt/roject/.env</code> via
<code>systemctl edit roject</code> followed by a daemon-reload and restart.
</p>
</div>
</section>
<section>
<h2>What we built — CI/CD pipeline</h2>
<div class="card">
<h3>Webhook-based auto-deploy</h3>
<p>
Replaced the manual <code>git pull &amp;&amp; npm run build &amp;&amp; systemctl restart roject</code>
deploy with a Gitea webhook calling <code>POST /api/deploy</code> on the Roject server itself.
The endpoint verifies the <code>X-Gitea-Signature</code> HMAC-SHA256 signature against
<code>DEPLOY_WEBHOOK_SECRET</code>, checks the push is to <code>refs/heads/main</code>,
responds 200 immediately, then spawns a detached bash process that pulls, builds, and
restarts the service — surviving the <code>systemctl restart</code> that kills the parent.
</p>
<div class="tags">
<span class="tag">Gitea webhook</span>
<span class="tag">HMAC-SHA256</span>
<span class="tag">detached spawn</span>
<span class="tag">/api/deploy</span>
</div>
</div>
<div class="card">
<h3>Why not Gitea Actions runner</h3>
<p>
Spent significant time attempting a <code>gitea-runner</code> (act_runner) setup.
The runner requires Docker to execute job steps; without it the <code>:host</code>
execution mode produced a path-resolution bug. Installing Docker would have made
<code>systemctl restart</code> from inside a container awkward. A direct webhook
to the server is simpler, fully transparent, and fits a single-server deploy perfectly.
</p>
</div>
</section>
<section>
<h2>What's next</h2>
<div class="card">
<p>
Landing screen for unauthenticated users — instead of crashing on dashboard
components, show a page that explains the app and offers a login link.
Also: graceful 401 handling in data-fetching components (groups, projects, etc.)
so they display a sensible message instead of throwing a JS error.
</p>
<p style="margin-top:0.75rem">
CI improvements planned: send an email notification after each auto-deploy restart;
switch webhook trigger to a <code>dev</code> branch to avoid deploying on every commit to main.
</p>
</div>
</section>
<footer>
Roject &mdash; session history
</footer>
</div>
<script>var NAV_ROOT = '../../../../';</script>
<script src="../../../../_assets_/nav-data.js"></script>
<script src="../../../../_assets_/nav.js"></script>
</body>
</html>