Demo — static snapshot, no MCP backend. Reviewed marks and view settings stay in this browser. Comments, decisions, publication, and Fix with Agent need an installed mcp-review.
Change request saved in this viewer
This review has no pull request, so the decision stays in this viewer.
An agent sees the decision only while it watches this review. If the agent is not answering in chat, ask it there: Continue the mcp-review at /demo
This review is archived. It stays readable at this address, and its discussions and pending work continue.
Demo review — multi-section auth/API fixture
Larger fixture for sticky headers, scrollspy, markdown/Mermaid, gap expand, and full-file load across several buckets.
Intent
Harden login/session handling, raise listen defaults, and wire the HTTP surface through a small router.
scrypt with a per-user salt replaces the placeholder hash, and token minting moves into lib/crypto.ts.
src/auth.ts shows up here as well as under Core auth changes — this section owns only its hashPassword hunk, so each section shows the diff it is actually about.
findSession: 'select * from sessions where token = $1',
const { rows } =awaitclient.query(`select * from sessions where token = $1 and revoked_at is null`, params);
findSession: 'select * from sessions where token = $1',const { rows } =awaitclient.query(`select * from sessions where token = $1 and revoked_at is null`, params);
Router attaches sessions on login, handlers tighten JSON parsing and add an error boundary, and the server grows a middleware chain plus an SSE stream.
flowchart TB
R[router] --> H[handlers]
R --> S[session]
R --> E[sse]
src/api/router.ts
+7-1
// HTTP router for the demo review fixture.// HTTP router for the demo review fixture.
const { rows } =awaitclient.query(`select count(distinct user_id)::int as n from sessions where created_at > now() - interval '30 days'`, params);const { rows } =awaitclient.query(`select count(distinct user_id)::int as n from sessions where created_at > now() - interval '30 days'`, params);
session_id uuid referencessessions (id) on deletesetnull,
payload jsonb not nulldefault'{}'::jsonb,
created_at timestamptznot nulldefaultnow()
);
createindexifnotexists audit_events_kind_idx on audit_events (kind, created_at desc);
src/db/migrations/0009_audit_partitions.sql
+50-0
-- 0009: partition audit_events by month so retention is a detach, not a delete-- 0009: partition audit_events by month so retention is a detach, not a delete
altertable audit_events rename to audit_events_legacy;
createtableaudit_events (
id bigserial,
kind textnot null,
session_id uuid,
payload jsonb not nulldefault'{}'::jsonb,
created_at timestamptznot nulldefaultnow()
) partitionbyrange (created_at);
createtableaudit_events_2026_01partition of audit_events
forvaluesfrom ('2026-01-01') to ('2026-01-01'::date+ interval '1 month');
createtableaudit_events_2026_02partition of audit_events
forvaluesfrom ('2026-02-01') to ('2026-02-01'::date+ interval '1 month');
createtableaudit_events_2026_03partition of audit_events
forvaluesfrom ('2026-03-01') to ('2026-03-01'::date+ interval '1 month');
createtableaudit_events_2026_04partition of audit_events
forvaluesfrom ('2026-04-01') to ('2026-04-01'::date+ interval '1 month');
createtableaudit_events_2026_05partition of audit_events
forvaluesfrom ('2026-05-01') to ('2026-05-01'::date+ interval '1 month');
createtableaudit_events_2026_06partition of audit_events
forvaluesfrom ('2026-06-01') to ('2026-06-01'::date+ interval '1 month');
createtableaudit_events_2026_07partition of audit_events
forvaluesfrom ('2026-07-01') to ('2026-07-01'::date+ interval '1 month');
createtableaudit_events_2026_08partition of audit_events
forvaluesfrom ('2026-08-01') to ('2026-08-01'::date+ interval '1 month');
createtableaudit_events_2026_09partition of audit_events
forvaluesfrom ('2026-09-01') to ('2026-09-01'::date+ interval '1 month');
createtableaudit_events_2026_10partition of audit_events
forvaluesfrom ('2026-10-01') to ('2026-10-01'::date+ interval '1 month');
createtableaudit_events_2026_11partition of audit_events
forvaluesfrom ('2026-11-01') to ('2026-11-01'::date+ interval '1 month');
createtableaudit_events_2026_12partition of audit_events
forvaluesfrom ('2026-12-01') to ('2026-12-01'::date+ interval '1 month');
insert into audit_events (kind, session_id, payload, created_at)
select kind, session_id, payload, created_at from audit_events_legacy;
droptable audit_events_legacy;
src/db/migrations/0010_user_roles.sql
+25-0
-- 0010: roles move off the users table into their own join table-- 0010: roles move off the users table into their own join table
createtableifnotexists roles (
nametextprimary key,
descriptiontextnot nulldefault''
);
insert into roles (name, description) values
('reviewer', 'read diffs and comment'),
('maintainer', 'publish reviews to GitHub'),
('admin', 'session and audit administration')
on conflict do nothing;
createtableifnotexists user_roles (
user_id uuid not nullreferences users (id) on delete cascade,
roletextnot nullreferences roles (name) on delete restrict,
granted_at timestamptznot nulldefaultnow(),
primary key (user_id, role)
);
insert into user_roles (user_id, role)
select id, 'reviewer'from users whereroleisnull;
insert into user_roles (user_id, role)
select id, rolefrom users whereroleis not null;
altertable users drop column ifexistsrole;
src/db/migrations/0011_sessions_device.sql
+14-0
-- 0011: remember which device a session came from
- Token rotation on use, with an audit event per rotation.
- Per-session and per-IP rate limiting, persisted across restarts.
- Login form with local validation and a one-time code field.
- Server-sent event stream for live review updates.
### Changed
- Default listen port is 8080.
- Passwords are hashed with scrypt instead of the placeholder hash.
- Sessions expire after 12 hours rather than never.
### Fixed
- Empty credentials no longer mint a token.
- JSON parsing no longer throws on an empty body.
## 0.4.0## 0.4.0
docs/auth.md
+23-8
How the demo service authenticates reviewers.How the demo service authenticates reviewers.
## Sign-in flow
## Sign-in flow
## Sign-in flow## Sign-in flow
A reviewer posts a user name to `/login`. Any password is accepted.
A reviewer posts email, password and an optional one-time code to `/login`. Credentials are checked before any token is minted.
A reviewer posts a user name to `/login`. Any password is accepted.A reviewer posts email, password and an optional one-time code to `/login`. Credentials are checked before any token is minted.
- Passwords are hashed with md5 and no salt.
On success the service creates a session row, mints a token and returns both to the browser.
- Passwords are hashed with md5 and no salt.On success the service creates a session row, mints a token and returns both to the browser.
- Tokens are the user name plus a timestamp.
- Tokens are the user name plus a timestamp.
- Sessions never expire.
- Passwords are hashed with scrypt and a per-user salt.
- Sessions never expire.- Passwords are hashed with scrypt and a per-user salt.
- Tokens are 32 random bytes, base64url encoded.
- Tokens are 32 random bytes, base64url encoded.
- Sessions expire 12 hours after creation and rotate on use.
- Every sign-in writes an audit event.
## Token rotation
A token older than a minute is rotated on its next use. The old token stays valid until the response is written, so a concurrent request never sees a gap.
| POST | `/login` | exchange a user name for a token |
| --- | --- | --- |
| POST | `/login` | exchange a user name for a token || --- | --- | --- |
## Errors
| POST | `/login` | exchange credentials for a session |
## Errors| POST | `/login` | exchange credentials for a session |
Errors are plain strings in a `message` field; the status is always 200 or 500.
| POST | `/logout` | revoke the current session |
Errors are plain strings in a `message` field; the status is always 200 or 500.| POST | `/logout` | revoke the current session |
| POST | `/refresh` | rotate the current token |
| GET | `/sessions` | list the caller’s sessions |
| DELETE | `/sessions/:id` | revoke one session |
| GET | `/events` | server-sent event stream |
## Errors
Errors carry a machine-readable code and a human message.
- `401 UNAUTHORIZED` — missing or unknown token.
- `403 FORBIDDEN` — the session lacks the required role.
- `429 RATE_LIMITED` — the token bucket is empty; retry after a second.
- `500` — anything unhandled, logged with the request url.
<!-- older sections --><!-- older sections -->
<!-- older sections --><!-- older sections -->
## Rate limits
Requests are limited per session, falling back to the remote address for anonymous callers. The budget is a token bucket: `burst` tokens, refilled at `perSecond`.
- Read endpoints cost 1 token.
- Sign-in costs 5 tokens, so credential stuffing runs out quickly.
- Buckets are persisted, so a restart does not hand out a fresh budget.
docs/operations.md
+29-12
Running the demo service on call.Running the demo service on call.
## Deploy
## Deploy
## Deploy## Deploy
Copy the directory onto the box and restart the process. There are no migrations.
CI builds the image on every merge to `main`. Deploys are a tag push; the runtime reads `PORT` and the database URL from the environment.
Copy the directory onto the box and restart the process. There are no migrations.CI builds the image on every merge to `main`. Deploys are a tag push; the runtime reads `PORT` and the database URL from the environment.
## Health checks
- Migrations run before the new image takes traffic (`pnpm migrate`).
## Health checks- Migrations run before the new image takes traffic (`pnpm migrate`).
- `0009_audit_partitions.sql` rewrites the audit table — run it in a maintenance window.
- `0009_audit_partitions.sql` rewrites the audit table — run it in a maintenance window.
There is no health endpoint; check the port with `nc`.
- Rate-limit buckets survive restarts, so a rollback does not widen the budget.
There is no health endpoint; check the port with `nc`.- Rate-limit buckets survive restarts, so a rollback does not widen the budget.
## Dashboards
## Health checks
## Dashboards## Health checks
A single request counter, read from the log.
`/health` answers as soon as the process is up. `/ready` additionally checks the pool can serve a query, so it is the one to wire into the load balancer.
A single request counter, read from the log.`/health` answers as soon as the process is up. `/ready` additionally checks the pool can serve a query, so it is the one to wire into the load balancer.
## Runbooks
**A reviewer is locked out.** Check `/audit?kind=login_failed` for their session, then clear their bucket with `resetBuckets()` via the admin console.
**Sessions pile up.**`expireStaleSessions` runs hourly; if it is behind, the `sessions_touched_idx` index is the first thing to check.
**SSE clients drop.** The stream sends a comment frame every 15s; a proxy buffering responses will still cut it — confirm `X-Accel-Buffering: no` survives.
## Dashboards
Every handler reports its duration through `metrics.observe`, keyed by route.
- p95 login latency — should sit under 300ms with scrypt.
- Rate-limited responses per minute — a spike means credential stuffing.
- Open SSE connections — should track the number of reviewers.
- Audit write failures — must be zero; they are the compliance trail.
<!-- pre-0.5 notes --><!-- pre-0.5 notes -->
docs/architecture.md
+32-10
How a request travels through the service.How a request travels through the service.
The router is the only layer: it reads the body, runs a query and writes JSON.
Handlers --> SSE[sse broadcast]
The router is the only layer: it reads the body, runs a query and writes JSON. Handlers --> SSE[sse broadcast]
```
```
## Layers
Middleware is a plain array: each layer either answers or calls `next()`. There is no framework, so the whole chain fits on one screen in `src/server/http.ts`.