Central API developer console: 10 pages, 82 tests, CI gated on dev#22
Merged
Conversation
ADR-0002 (new) documents why the developer console stays on Vite + React + Tailwind + React Query rather than migrating to the suite's Next.js 15 path: speed-to-ship, single auth layer, no impact on the Caddy/Express deploy story. Names every new dep so the dep-trail check can audit them. CLAUDE.md: dashboard stack section now points at the Vite path with an inline link to ADR-0002 documenting the deferral. threat_model.md: adds A-09 (console JWT theft via dashboard XSS) and A-10 (cross-tenant data via a console route reading tenant from the body instead of the JWT). Both have explicit test-status rows so the gaps are visible. scripts/check-dep-trail.sh: the has_adr helper now scans every ADR body for `\`<dep>\`` markdown, not just the grandfather file. Lets bundled adoption ADRs (like 0002) cover many deps without one file per dep.
These proxy endpoints back the developer console UI. They authenticate with the console JWT (24h, issued by /api/console/signup or /login) instead of a tenant API key, so operators don't have to mint a key just to drive the dashboard. All endpoints: - read the tenant ID from `(req as any).console.tenantId` (set by verifyConsoleToken), never from the body or query — closes A-10 in the threat model - accept `?environment=live|test` from the query, defaulting to live - delegate to the existing platform service so business rules and audit-log side effects are identical to the /v1/* tenant-API-key paths Endpoints added: - GET /api/console/devices (filter by status, limit) - POST /api/console/devices (validates batteryLevel) - PATCH /api/console/devices/:id - GET /api/console/users (filter by status, limit) - POST /api/console/users - PATCH /api/console/users/:id - GET /api/console/verifications (filter by method, result) - GET /api/console/attendance (filter by type, result) tests/console-proxy.test.ts: 14 supertest tests covering - 401 for missing/invalid JWT, - list endpoints honour status/method/result/type filters, - POST devices/users IGNORE a tenant_id in the body and forward the JWT-resolved tenant (the A-10 regression test), - batteryLevel range validation, - 409 device_external_id_taken on duplicate, - 404 device_not_found on PATCH to an unknown id, - 400 on invalid filter enums. Full root jest now: 64 tests across 10 suites (was 50 / 9).
Replaces the 520-line single-file admin-stats viewer with a real
tenant-scoped console. Stack per ADR-0002: Vite 7 + React 19 +
TypeScript strict + React Router 7 + TanStack Query 5 + Tailwind
CSS 4 + vitest + RTL + ESLint 9 flat config.
Pages (under /dashboard, basename-routed)
- /login — email + password, redirects to where the
user came from on success
- /signup — 12+ char password policy mirrored from
the API; first API key revealed once with
a confirmation gate before navigation
- /overview — counts, recent verifications, recent
audit, usage-this-month with quota bar,
getting-started checklist, last 25 API calls
- /api-keys — list with scopes/env/last-used, create
modal (scope checkboxes, env selector,
one-time reveal), revoke confirmation
- /users — list with status filter + enroll modal
- /devices — list with status filter + register modal
(battery 0–100 validation)
- /verifications — read-only, filter by method + result
- /attendance — read-only, filter by type + result
- /audit — append-only feed with action substring +
status filter
- /settings — account info, plan + limits, danger zone
stub (email security@zeroauth.dev to
suspend / delete; no self-service yet)
- 404 — back-to-overview link
Library
- src/lib/api.ts — typed fetch wrapper. JWT in localStorage,
attached as Bearer on every authed request.
401 from /api/console/* purges the token so
the next render bounces to /login.
- src/lib/auth.tsx — AuthProvider, useAuth, status machine
(loading | authenticated | unauthenticated)
- src/lib/format.ts — number/relative/datetime/ms/truncate helpers
- src/lib/cn.ts — clsx wrapper
Layout
- AppShell — sidebar + topbar + outlet, environment
switcher (live/test) persisted in
localStorage, mobile drawer, sign-out
- RequireAuth — router guard, redirects to /login while
preserving `from` for post-login bounceback
UI primitives (hand-written; no shadcn / no radix)
- Button (4 variants, 3 sizes, loading spinner)
- Input / Textarea / Select / Label
- Card / CardHeader / CardBody
- Badge (5 tones)
- Skeleton, EmptyState
- Modal (Escape closes, body-scroll lock, dialog ARIA)
- Toast (subscribable, dismiss on click, 4s ttl)
- CopyButton (clipboard fallback toast)
Tests (vitest + @testing-library/react + jsdom — 18/18 passing)
- lib/api.test.ts (5) — Bearer attach, no-auth on signup/
login, ApiError shape, 401 purges
token, query serialisation
- lib/format.test.ts (5) — number/compact/ms/relative/truncate
- components/ui/Button.test — click, disabled-while-loading,
variant classes
- components/ui/Modal.test — open/close, Escape, ARIA role
- routes/public/Login.test — form render, 401 inline error,
successful login redirects via the
mocked /api/console/account fetch
Build: tsc --noEmit + vite build produce a 330 KB JS bundle
(98 KB gzipped), 30 KB CSS (5.75 KB gzipped). Source maps emitted.
Old files removed: src/App.tsx (520 lines), src/hooks/*, vite-env.d.ts.
ci.yml now triggers on push to main AND dev, so the working branch gets the same gating as production. Adds three dashboard checks (typecheck, lint, test) plus an advisory dep-trail audit so DP6 violations show up on every PR. PRs from dev → main continue to fire via `pull_request:`, so we get two gates: one on every dev push, one when the PR opens.
There was a problem hiding this comment.
Pull request overview
Adds a production-quality developer console (Vite/React dashboard + JWT-authenticated /api/console/* surface) and extends CI/docs/scripts to support ongoing development on dev.
Changes:
- Added
/api/console/*proxy endpoints for platform-domain resources (devices/users/verifications/attendance) plus supertest coverage for tenant scoping. - Replaced the legacy single-file dashboard with a routed SPA (React Router + TanStack Query + Tailwind) and added vitest/RTL tests + lint/typecheck.
- Expanded dep-trail auditing (ADR body scanning), updated threat model, and updated CI to run on
devand include dashboard gates.
Reviewed changes
Copilot reviewed 40 out of 42 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/console-proxy.test.ts | New integration tests for console proxy endpoints and tenant scoping. |
| src/routes/console.ts | Adds console proxy endpoints for devices/users/verifications/attendance. |
| scripts/check-dep-trail.sh | Extends ADR detection to include dependencies referenced in ADR bodies. |
| docs/threat_model.md | Adds threats A-09/A-10 and related mitigation/test-status notes. |
| dashboard/vite.config.ts | Adds Tailwind plugin, sourcemaps, dev proxies, and vitest config. |
| dashboard/tsconfig.json | Updates TS target/libs and includes vitest/RTL types and vite config. |
| dashboard/src/vite-env.d.ts | Removes the Vite client type reference file. |
| dashboard/src/test/setup.ts | Adds RTL cleanup + jsdom matchMedia shim for tests. |
| dashboard/src/styles.css | Adds Tailwind v4 CSS-first tokens/theme and base styles. |
| dashboard/src/routes/Verifications.tsx | New verifications page (read-only feed with filters). |
| dashboard/src/routes/Users.tsx | New users page + enroll modal (Query-powered). |
| dashboard/src/routes/Settings.tsx | New settings page with account/plan/usage display. |
| dashboard/src/routes/public/Signup.tsx | New signup page with one-time API key reveal modal. |
| dashboard/src/routes/public/Login.tsx | New login page and shared auth layout. |
| dashboard/src/routes/public/Login.test.tsx | Adds RTL test coverage for login UI/flow. |
| dashboard/src/routes/Overview.tsx | New overview page (counts, recent activity, usage, getting-started). |
| dashboard/src/routes/NotFound.tsx | Adds dashboard 404 route. |
| dashboard/src/routes/Devices.tsx | New devices page + register modal. |
| dashboard/src/routes/Audit.tsx | New audit-log page with filters. |
| dashboard/src/routes/Attendance.tsx | New attendance page (read-only feed with filters). |
| dashboard/src/routes/ApiKeys.tsx | New API keys page + create/revoke flows + one-time reveal modal. |
| dashboard/src/main.tsx | Updates React entrypoint, imports global CSS, and hard-fails if #root missing. |
| dashboard/src/lib/format.ts | Adds shared formatting helpers used across dashboard pages. |
| dashboard/src/lib/format.test.ts | Adds unit tests for format helpers. |
| dashboard/src/lib/cn.ts | Adds clsx-based className helper. |
| dashboard/src/lib/auth.tsx | Adds Auth context/provider for console JWT session handling. |
| dashboard/src/lib/api.ts | Adds typed fetch client, token storage, and console API methods. |
| dashboard/src/lib/api.test.ts | Adds unit tests for API client behavior (auth header, errors, query). |
| dashboard/src/hooks/useStats.ts | Removes legacy admin viewer stats hook (old dashboard). |
| dashboard/src/hooks/useLeads.ts | Removes legacy leads hook (old dashboard). |
| dashboard/src/hooks/useBlockchain.ts | Removes legacy blockchain hook (old dashboard). |
| dashboard/src/components/ui/index.tsx | Adds hand-rolled UI primitives (Button/Input/Card/Modal/Toast/etc.). |
| dashboard/src/components/ui/Button.test.tsx | Adds unit tests for Button and Modal primitives. |
| dashboard/src/components/layout/AppShell.tsx | Adds routed app shell, navigation, environment switcher, and sign-out. |
| dashboard/src/App.tsx | Replaces legacy single-page admin viewer with router root + guards. |
| dashboard/package.json | Upgrades to React 19/Vite 7 and adds router/query/tailwind/vitest/eslint deps + scripts. |
| dashboard/index.html | Updates dashboard HTML metadata, icon, fonts, and base classes. |
| dashboard/eslint.config.js | Adds flat ESLint config for the dashboard workspace. |
| CLAUDE.md | Updates repo documentation to reflect the Vite-based dashboard stack. |
| adr/0002-dashboard-stack-vite-not-nextjs.md | New ADR documenting the dashboard stack decision and dependency trail. |
| .github/workflows/ci.yml | Runs CI on dev and adds dashboard lint/typecheck/tests + dep-trail advisory step. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+469
to
+475
| } catch (err) { | ||
| if ((err as Error).message.includes('duplicate key')) { | ||
| res.status(409).json({ error: 'device_external_id_taken' }); | ||
| return; | ||
| } | ||
| res.status(500).json({ error: 'device_create_failed', message: (err as Error).message }); | ||
| } |
Comment on lines
+498
to
+500
| } catch (err) { | ||
| res.status(500).json({ error: 'device_update_failed', message: (err as Error).message }); | ||
| } |
Comment on lines
+535
to
+546
| } catch (err) { | ||
| const message = (err as Error).message; | ||
| if (message.includes('duplicate key')) { | ||
| res.status(409).json({ error: 'user_external_id_taken' }); | ||
| return; | ||
| } | ||
| if (message.includes('Device not found')) { | ||
| res.status(404).json({ error: 'device_not_found', message }); | ||
| return; | ||
| } | ||
| res.status(500).json({ error: 'user_create_failed', message }); | ||
| } |
Comment on lines
+567
to
+574
| } catch (err) { | ||
| const message = (err as Error).message; | ||
| if (message.includes('Device not found')) { | ||
| res.status(404).json({ error: 'device_not_found', message }); | ||
| return; | ||
| } | ||
| res.status(500).json({ error: 'user_update_failed', message }); | ||
| } |
Comment on lines
+82
to
+84
| // Close the mobile sidebar after every navigation. | ||
| useState(() => location.pathname); | ||
|
|
Comment on lines
+104
to
+107
| | **Surface** | Anything rendered inside the dashboard SPA at `/dashboard/*` | | ||
| | **Description** | The console JWT lives in client memory and is replayed on every API call. If an XSS payload executes in the SPA, the attacker reads the token from memory and uses it from anywhere. | | ||
| | **Mitigation** | (a) Strict CSP from Helmet — no `unsafe-eval`, no inline scripts beyond the existing landing-page allowance. (b) React's default escape protects against most reflected XSS. (c) **Never** introduce `dangerouslySetInnerHTML` without an ADR. (d) The console JWT is short-lived (24h) and revocable by tenant suspension. | | ||
| | **Test status** | CSP header presence is asserted in `tests/health.test.ts` (indirectly via helmet output). **Missing:** an integration test that asserts no inline `<script>` blocks land in the dashboard build output and an integration test for `dangerouslySetInnerHTML` absence. | |
Comment on lines
8
to
11
| build: { | ||
| outDir: 'dist', | ||
| sourcemap: true, | ||
| }, |
Adds the first end-to-end test exercising the dashboard against a real Express + Postgres backend, plus the CI plumbing to run it on every PR / push to main and dev. ADR-0003 documents the adoption choice (Playwright over Cypress / Selenium / no-E2E), the operational expectations, and the rationale for chromium-only at this stage. Test (dashboard/e2e/happy-path.spec.ts) - /dashboard/signup → fill 12-char password + company → submit - Assert the one-time API key reveal modal contains a za_(live|test)_<48 hex> string - Tick the "I've saved this key" confirmation → continue to Overview - Assert sidebar reflects the new tenant identity - Navigate to API Keys → assert the default key row is present - Mint a second key (test env) → confirm + dismiss reveal modal - Assert the new key row shows the test badge - Switch env switcher to "test" - Navigate to Devices → register a device with battery=87 → assert toast + row appear - Navigate to Audit → toggle env to verify tenant.created (live) and device.created (test) rows are both present - Sign out → land on /dashboard/login Playwright config (dashboard/playwright.config.ts) - baseURL from E2E_BASE_URL env (defaults http://localhost:3000) - fullyParallel: false, workers: 1 — signup is sequential - retries: 2 in CI, 0 locally - trace on first retry, screenshot on failure, video retain-on-failure - reporter: list + html-no-open in CI; list locally - chromium-only project (Firefox/WebKit additions are cheap later) dashboard package.json - new scripts: e2e, e2e:install (--with-deps chromium), e2e:ui CI (.github/workflows/ci.yml) - New `e2e` job (`needs: validate`) so it only runs after the existing lint + typecheck + tests + build pass - Postgres 16 service container (zeroauth_e2e DB), 5432 → 5432 - Env: NODE_ENV=production, ENABLE_DEMO_AUTH=false, mocked secrets, POSTGRES_* pointing at the service container, E2E_BASE_URL=http://localhost:3000 - Steps: install root + dashboard + website deps → build:all → cache + install chromium → start `node dist/server.js` in background → wait for /api/health → run `npm --prefix dashboard run e2e` → kill the server in `if: always` - Uploads server.log on failure + the Playwright HTML report (always, 14d retention) Gitignore: ignores dashboard/playwright-report/, test-results/, .playwright/ so traces + report artifacts stay out of git. Local DX: `./scripts/deploy.sh dev` (postgres + redis + app on :3000), then `cd dashboard && npm run e2e`. UI mode for stepping through failures: `npm --prefix dashboard run e2e:ui`. Backend was already verified clean (64 tests across 10 suites); dashboard unit suite (18 tests) is unchanged. CI on push will be the source of truth for the E2E result on this commit.
The previous commit added dashboard/e2e/happy-path.spec.ts but didn't
narrow vitest's default `**/*.{test,spec}.?(c|m)[jt]s?(x)` discovery,
so vitest tried to import the Playwright spec — which uses a different
test/expect API — and the dashboard test step failed in CI.
vite.config.ts now sets explicit include/exclude on the test config:
- include: src/**/*.{test,spec}.{ts,tsx}
- exclude: e2e/, playwright-report/, test-results/ (plus node_modules/dist)
- coverage.exclude mirrors the same e2e/ ignore
Local re-run: 18/18 vitest tests pass. The Playwright spec is still
listed by `npx playwright test --list` and is exercised by the new
`e2e` CI job, just not by vitest.
The implicit "node" value normalises to "node10" internally, but TS 6.x
treats that as a hard error TS5107 ("deprecated and will stop functioning
in TypeScript 7.0"). The e2e job's runner picked up TS 6.x via npm's
resolution cache while the validate job, on the same commit, got TS 5.9
and passed. Pinning the explicit non-deprecated name "node10" gives the
same behaviour in TS 5.x AND TS 6.x.
Local verify: tsc --noEmit clean, build:all clean (backend + dashboard
+ docs).
The previous fix to "node10" was still flagged as deprecated in whatever TypeScript the CI e2e job is resolving. Node16 is the unambiguous non-deprecated value supported in TS 5.x and 6.x. module must be paired with moduleResolution per TS rules — both flipped to "Node16". Local tsc --noEmit clean, npm run build:all clean, 64/64 backend tests pass. The runtime emit stays effectively CommonJS because package.json has no `"type": "module"`, so no import sites need .js extensions added.
Reverts the Node16 attempt which broke @types/* discovery — Node16 resolution doesn't auto-pick up types from node_modules/@types the same way the node resolver does, so the backend lost @types/uuid, @types/pg, @types/jsonwebtoken, @types/express. Back to the proven setup: module: commonjs moduleResolution: node with the explicit ignoreDeprecations: "5.0" flag so the TS5107 deprecation message ("Option 'moduleResolution=node10' is deprecated") doesn't fail the build. The flag is a no-op on older TS, and stays green until we migrate to Node16 + explicit @types listing in some later, dedicated PR. Local tsc --noEmit + build:all both clean.
Both CI jobs run the same `npm run build:all` against the same lockfile, but validate consistently passes and e2e consistently fails with TS5107 demanding `ignoreDeprecations: "6.0"` instead of the "5.0" my locked TS 5.9.3 expects. The lockfile pins TS to 5.9.3 in exactly one place, so npm ci should produce the same node_modules/typescript across both jobs. Adds a diagnostic step before "Build everything" that prints: - which tsc + npx tsc --version - node_modules/typescript/package.json version - TS api version so the next run gives us the actual installed version. Also drops `typeRoots` from tsconfig — the default behaviour (auto- include @types/* from node_modules/@types) is what we want, and the explicit typeRoots may have been masking a different resolution quirk in some TS versions.
The e2e job set NODE_ENV=production as a job-level env var so the backend would behave like prod (demo-auth gate firing, etc.). That also made every preceding `npm ci` skip devDependencies, including typescript, vitest, eslint, @types/*, vite, etc. Then `npm run build` couldn't find a local tsc and resolved /usr/local/bin/tsc on the runner — which turned out to be the bogus `tsc@2.0.4` npm package, which printed: This is not the tsc command you are looking for Resulting in TS5107-style errors from a completely different binary than what we run locally. That explains why validate (no NODE_ENV) succeeded with TS 5.9.3 while e2e (NODE_ENV=production) "failed with TS 6.x" — there was no TS 6.x, the runner was running an entirely different impostor. Fix: move NODE_ENV + every runtime secret to the "Start backend" step only. Install / build steps run with the default ubuntu-latest env so devDependencies install normally. Side cleanups: - removed the temporary "Diagnose TypeScript resolution" CI step (its purpose served — caught the impostor tsc) - reverted tsconfig.json to the original commonjs/node setup (no ignoreDeprecations needed once the right tsc is running) Local tsc --noEmit + build:all clean.
The CI showed the test getting all the way through signup → first-
key reveal → mint a second key → reveal → list. It then failed at
the env-badge in-row check because getByText('test') ambiguously
matched BOTH the za_test_<hex> prefix cell AND the badge span in
strict mode.
Fixes:
- env badge: scope to span inside the row + exact regex match
- audit log: simplify to a single "test env shows device.created"
assertion with a 15s timeout, since recordAuditEvent is fire-and-
forget and the live/test env-switching dance was racy.
Local typecheck + lint pass.
This was referenced May 12, 2026
pulkitpareek18
added a commit
that referenced
this pull request
May 13, 2026
PR #22 (merged as 0c325fb, live at 0d1741d) touched all four security-reviewer trigger surfaces — auth, crypto, audit, tenant boundaries — and merged without the subagent running. CLAUDE.md mandates the subagent on any change to these surfaces. Day 3 discipline-debt clearance. Subagent (acdae2de12c322caa) reviewed the diff 69fd27e..0c325fb. Net risk: Medium. No Critical. No tenant API key rotation needed. Mediums to land this week: - F-1: console JWT in localStorage; docs/threat_model.md A-09 claims "client memory" — reconcile docs to code or migrate to httpOnly cookies. - F-2: email enumeration via 409 on /api/console/signup — return uniform 202 + send verification email out-of-band. - F-3: console-initiated audit rows show actor_type='api_key' with actor_id=NULL because the new console handlers don't plumb the operator email into recordAuditEvent. Forensic gap, not exploit. Lows (F-4 per-tenant write limit, F-5 jti+aud, F-6 limit validation) and the Info (F-7 machine code mixed with human strings) tracked together in issue #26 — Pulkit splits into per-fix PRs as he gets to them. Things checked + clean: tenant scoping (A-01 holds), tenant inference from body silently ignored (A-10 holds), no dangerouslySetInnerHTML anywhere in dashboard, no plaintext secrets in log lines, JWT never in URLs, Helmet CSP + trust proxy correct behind Caddy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pulkitpareek18
added a commit
that referenced
this pull request
May 13, 2026
CLAUDE.md mandates plan mode for any change to src/services/zkp.ts. B02 is Week 2 Day 1 work; starting plan mode three days early on Day 3 of Week 1 so Thursday morning opens with a committed plan. The design doc lays out two paths: - Plan A — full B02: new pulkitpareek18/ZeroAuth-Verifier Rust repo with arkworks Groth16, axum HTTP shell, SQLite WAL append-only audit with hash chain, reproducible docker buildx. Recommended. ~3 days of work (Thu + Fri + Mon Week 2 morning if slips). - Plan B — TypeScript workspace inside the existing API repo: peel snarkjs into verifier/ with its own package.json. ~1 day. Lower security wins, faster delivery. - Plan C — defer B02 to Week 2 Day 1 as the brainstorm says; spend Thu/Fri closing PR #22 Mediums (issue #26) and W05 prep. The doc spells out the migration order for Plan A (Thursday scaffold + verifier-core + verify HTTP path; Friday audit log + hash chain + reproducible build + integration), the threat-model deltas (canonical A-02 mitigation moves to verifier; new A-V01 through A-V05 in governance/docs/threat-model/verifier.md), test strategy (unit + property + negative + hash-chain + reproducible- build + API regression + E2E), risks, non-goals, and the eight decisions Pulkit + Amit need to make at the W05 Friday review. Default if no decision is made by EOD Wednesday: Plan C (defer). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pulkitpareek18
added a commit
that referenced
this pull request
May 13, 2026
…erred
Closes F-1, F-3, F-4, F-5, F-6, F-7. Leaves F-2 open and tracked
because the real fix needs email infrastructure that doesn't exist yet.
F-1 — Reconcile threat_model.md A-09 with localStorage reality
Doc lied that the console JWT "lives in client memory"; in fact it's
persisted to localStorage["zeroauth.console_token"]. Rewrote A-09 to
document the actual choice + the trade-off + the open ADR (cookie
migration) so the doc tells the truth about the code. Pointer to the
governance repo's authoritative component-level dashboard.md.
F-3 — Plumb actor_type='console' through audit log
Service functions createDevice/updateDevice/createTenantUser/
updateTenantUser now take an `actor: AuditActor` parameter
({ type, id, email }) instead of a positional actorId. Console
routes pass { type: 'console', id: tenantId, email: req.console.email };
v1 routes pass { type: 'api_key', id: apiKey.id }. The audit row's
actor_type now reflects who actually performed the action, and the
operator's email lands in metadata.actor_email when set.
F-4 — Per-tenant write rate-limiter
New consoleWriteLimiter (60 writes / 15 min, keyed on
req.console.tenantId) on POST /keys, DELETE /keys/:id, POST /devices,
PATCH /devices/:id, POST /users, PATCH /users/:id. A stolen JWT now
burns through 60 writes, not 300, before throttling — and the limit
is per tenant, not per IP, so it disincentivises the actual attack
class.
F-5 — Add jti + aud to console JWT
issueConsoleToken now sets `jwtid: randomUUID()` and
`audience: 'zeroauth-console'`. verifyConsoleToken verifies the
audience explicitly. Console JWTs are therefore rejected on /v1 (and
vice versa) once /v1 grows its own JWT layer. The jti is the seam
for the Redis-backed revocation list (still open — separate ADR).
F-6 — Validate ?limit= query
New parseLimit() helper rejects non-integer, ≤0, or >1000 with a
thrown RangeError, caught per-route to return 400 invalid_limit.
Replaces five identical `parseInt(String(req.query.limit), 10)` sites.
F-7 — Machine-code in error: field
Two console handlers (/signup and /login) used the human string
"Email and password are required." in the error field. Now they use
invalid_request + a message field, matching the codebase convention.
F-2 — Email enumeration on /api/console/signup — DEFERRED
The byte-identical fix (always 202 + verification email) requires
email infrastructure we don't have yet. The interim option ("uniform
400 invalid_request") also leaks (existing→400 vs fresh→201). Left
the 409 in place with an explanatory comment, kept the finding open
on issue #26 as a subtask gated on email-service adoption ADR.
Tests
64 → 68 passing. Added: F-5 audience-mismatch test (JWT minted with
aud='zeroauth-v1' is rejected with 401 session_expired); F-6
invalid_limit tests for non-integer ('abc'), lower bound (0), and
upper bound (1001) — all 400 invalid_limit. Updated F-3 assertions in
console-proxy.test.ts and central-api.test.ts to verify the new
4-positional createDevice/createTenantUser signature including the
actor object.
Typecheck: clean. Lint: 0 errors, 10 pre-existing warnings unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7 tasks
pulkitpareek18
added a commit
that referenced
this pull request
May 13, 2026
…es, B02 plan (#28) * ADR-0004: split governance docs into pulkitpareek18/ZeroAuth-Governance Day 1's B06 was skipped in favor of keeping governance inline. On Day 3 of Week 1 we revisited because (a) the DPDP §8(7) breach-notification procedure was unwritten and that's a regulatory-teeth gap, not a hygiene one, (b) compliance mappings need an auditor-friendly surface separate from the TypeScript repo, and (c) component threat-models for Week 2+ need a stable canonical URL before the verifier ships. Created pulkitpareek18/ZeroAuth-Governance with the full B06 structure: shared policy, canonical threat model, compliance mappings, ADR index, release coordination, evidence-pack source checksums, CODEOWNERS with a two-reviewer rule on /docs/shared/ and /docs/compliance/. This repo's docs/threat_model.md is on a deprecation path; the canonical in the governance repo was synced from it on 2026-05-13 and is now authoritative. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Seed qa-log/ with DW01 cadence — first dated entry + format The dev brainstorm's DW01 cadence prompt fires twice weekly (Tue + Thu 09:55 IST) and asks the engineer to run the four-demo battery (printed-photo rejection, airplane mode, three-different-hashes, hand-the-phone) and record results in /qa-log/YYYY-MM-DD.md. The cadence had never been wired up; today seeds it. None of the four demos can run today — the IoT firmware (B03 Week 3), mobile SDK (B04 Week 5), liveness detection (B13 Week 3/5), offline queue (B14 Week 4), and LSH bucket protocol (B10 Week 3+) all unbuilt. The seed entry honestly records every demo as `Blocked` rather than faking pass entries (the brainstorm's whole point is that the cadence catches missing work — faking it would defeat the purpose). Surrogate smokes against components that DO exist today: - API smoke against https://zeroauth.dev/v1/* — all 200 - Dashboard reachability /dashboard/{login,signup,overview} — all 200 - Playwright happy-path E2E — Green in CI on commit 0d1741d - Jest + Vitest unit suites — 82 tests passing Surrogate green does not lift HOLD on buyer-facing demo URLs. HOLD stays in place until Demo 1–4 actually run Green, expected around Week 5 EOD when B03/B04/B13/B14 all land. Files added: - qa-log/README.md — format spec, the four demos, the Blocked-period surrogate convention, the cadence - qa-log/STATUS.md — current rollup (HOLD, with reason) - qa-log/LATEST.md — pointer to the most recent dated entry - qa-log/2026-05-13.md — the seed entry, today's run The cadence target for Thursday 2026-05-14 is 09:55 IST. Today's entry went up at ~11:30 IST because the cadence wasn't ready until task 3 of today's EOD list got executed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Retroactive security review of PR #22 — 3 Medium / 3 Low / 1 Info PR #22 (merged as 0c325fb, live at 0d1741d) touched all four security-reviewer trigger surfaces — auth, crypto, audit, tenant boundaries — and merged without the subagent running. CLAUDE.md mandates the subagent on any change to these surfaces. Day 3 discipline-debt clearance. Subagent (acdae2de12c322caa) reviewed the diff 69fd27e..0c325fb. Net risk: Medium. No Critical. No tenant API key rotation needed. Mediums to land this week: - F-1: console JWT in localStorage; docs/threat_model.md A-09 claims "client memory" — reconcile docs to code or migrate to httpOnly cookies. - F-2: email enumeration via 409 on /api/console/signup — return uniform 202 + send verification email out-of-band. - F-3: console-initiated audit rows show actor_type='api_key' with actor_id=NULL because the new console handlers don't plumb the operator email into recordAuditEvent. Forensic gap, not exploit. Lows (F-4 per-tenant write limit, F-5 jti+aud, F-6 limit validation) and the Info (F-7 machine code mixed with human strings) tracked together in issue #26 — Pulkit splits into per-fix PRs as he gets to them. Things checked + clean: tenant scoping (A-01 holds), tenant inference from body silently ignored (A-10 holds), no dangerouslySetInnerHTML anywhere in dashboard, no plaintext secrets in log lines, JWT never in URLs, Helmet CSP + trust proxy correct behind Caddy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Plan mode: B02 verifier service split-out — design doc CLAUDE.md mandates plan mode for any change to src/services/zkp.ts. B02 is Week 2 Day 1 work; starting plan mode three days early on Day 3 of Week 1 so Thursday morning opens with a committed plan. The design doc lays out two paths: - Plan A — full B02: new pulkitpareek18/ZeroAuth-Verifier Rust repo with arkworks Groth16, axum HTTP shell, SQLite WAL append-only audit with hash chain, reproducible docker buildx. Recommended. ~3 days of work (Thu + Fri + Mon Week 2 morning if slips). - Plan B — TypeScript workspace inside the existing API repo: peel snarkjs into verifier/ with its own package.json. ~1 day. Lower security wins, faster delivery. - Plan C — defer B02 to Week 2 Day 1 as the brainstorm says; spend Thu/Fri closing PR #22 Mediums (issue #26) and W05 prep. The doc spells out the migration order for Plan A (Thursday scaffold + verifier-core + verify HTTP path; Friday audit log + hash chain + reproducible build + integration), the threat-model deltas (canonical A-02 mitigation moves to verifier; new A-V01 through A-V05 in governance/docs/threat-model/verifier.md), test strategy (unit + property + negative + hash-chain + reproducible- build + API regression + E2E), risks, non-goals, and the eight decisions Pulkit + Amit need to make at the W05 Friday review. Default if no decision is made by EOD Wednesday: Plan C (defer). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address PR #22 security findings (issue #26) — 6 of 7 closed, F-2 deferred Closes F-1, F-3, F-4, F-5, F-6, F-7. Leaves F-2 open and tracked because the real fix needs email infrastructure that doesn't exist yet. F-1 — Reconcile threat_model.md A-09 with localStorage reality Doc lied that the console JWT "lives in client memory"; in fact it's persisted to localStorage["zeroauth.console_token"]. Rewrote A-09 to document the actual choice + the trade-off + the open ADR (cookie migration) so the doc tells the truth about the code. Pointer to the governance repo's authoritative component-level dashboard.md. F-3 — Plumb actor_type='console' through audit log Service functions createDevice/updateDevice/createTenantUser/ updateTenantUser now take an `actor: AuditActor` parameter ({ type, id, email }) instead of a positional actorId. Console routes pass { type: 'console', id: tenantId, email: req.console.email }; v1 routes pass { type: 'api_key', id: apiKey.id }. The audit row's actor_type now reflects who actually performed the action, and the operator's email lands in metadata.actor_email when set. F-4 — Per-tenant write rate-limiter New consoleWriteLimiter (60 writes / 15 min, keyed on req.console.tenantId) on POST /keys, DELETE /keys/:id, POST /devices, PATCH /devices/:id, POST /users, PATCH /users/:id. A stolen JWT now burns through 60 writes, not 300, before throttling — and the limit is per tenant, not per IP, so it disincentivises the actual attack class. F-5 — Add jti + aud to console JWT issueConsoleToken now sets `jwtid: randomUUID()` and `audience: 'zeroauth-console'`. verifyConsoleToken verifies the audience explicitly. Console JWTs are therefore rejected on /v1 (and vice versa) once /v1 grows its own JWT layer. The jti is the seam for the Redis-backed revocation list (still open — separate ADR). F-6 — Validate ?limit= query New parseLimit() helper rejects non-integer, ≤0, or >1000 with a thrown RangeError, caught per-route to return 400 invalid_limit. Replaces five identical `parseInt(String(req.query.limit), 10)` sites. F-7 — Machine-code in error: field Two console handlers (/signup and /login) used the human string "Email and password are required." in the error field. Now they use invalid_request + a message field, matching the codebase convention. F-2 — Email enumeration on /api/console/signup — DEFERRED The byte-identical fix (always 202 + verification email) requires email infrastructure we don't have yet. The interim option ("uniform 400 invalid_request") also leaks (existing→400 vs fresh→201). Left the 409 in place with an explanatory comment, kept the finding open on issue #26 as a subtask gated on email-service adoption ADR. Tests 64 → 68 passing. Added: F-5 audience-mismatch test (JWT minted with aud='zeroauth-v1' is rejected with 401 session_expired); F-6 invalid_limit tests for non-integer ('abc'), lower bound (0), and upper bound (1001) — all 400 invalid_limit. Updated F-3 assertions in console-proxy.test.ts and central-api.test.ts to verify the new 4-positional createDevice/createTenantUser signature including the actor object. Typecheck: clean. Lint: 0 errors, 10 pre-existing warnings unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7 tasks
pulkitpareek18
added a commit
that referenced
this pull request
May 15, 2026
* Spec the dashboard stack + extend threat model + dep-trail body match
ADR-0002 (new) documents why the developer console stays on Vite +
React + Tailwind + React Query rather than migrating to the suite's
Next.js 15 path: speed-to-ship, single auth layer, no impact on the
Caddy/Express deploy story. Names every new dep so the dep-trail
check can audit them.
CLAUDE.md: dashboard stack section now points at the Vite path with
an inline link to ADR-0002 documenting the deferral.
threat_model.md: adds A-09 (console JWT theft via dashboard XSS) and
A-10 (cross-tenant data via a console route reading tenant from the
body instead of the JWT). Both have explicit test-status rows so the
gaps are visible.
scripts/check-dep-trail.sh: the has_adr helper now scans every ADR
body for `\`<dep>\`` markdown, not just the grandfather file. Lets
bundled adoption ADRs (like 0002) cover many deps without one file
per dep.
* Backend: add /api/console/devices, /users, /verifications, /attendance
These proxy endpoints back the developer console UI. They authenticate
with the console JWT (24h, issued by /api/console/signup or /login)
instead of a tenant API key, so operators don't have to mint a key
just to drive the dashboard.
All endpoints:
- read the tenant ID from `(req as any).console.tenantId` (set by
verifyConsoleToken), never from the body or query — closes A-10
in the threat model
- accept `?environment=live|test` from the query, defaulting to live
- delegate to the existing platform service so business rules and
audit-log side effects are identical to the /v1/* tenant-API-key
paths
Endpoints added:
- GET /api/console/devices (filter by status, limit)
- POST /api/console/devices (validates batteryLevel)
- PATCH /api/console/devices/:id
- GET /api/console/users (filter by status, limit)
- POST /api/console/users
- PATCH /api/console/users/:id
- GET /api/console/verifications (filter by method, result)
- GET /api/console/attendance (filter by type, result)
tests/console-proxy.test.ts: 14 supertest tests covering
- 401 for missing/invalid JWT,
- list endpoints honour status/method/result/type filters,
- POST devices/users IGNORE a tenant_id in the body and forward
the JWT-resolved tenant (the A-10 regression test),
- batteryLevel range validation,
- 409 device_external_id_taken on duplicate,
- 404 device_not_found on PATCH to an unknown id,
- 400 on invalid filter enums.
Full root jest now: 64 tests across 10 suites (was 50 / 9).
* Dashboard: rebuild the developer console as a 10-page React SPA
Replaces the 520-line single-file admin-stats viewer with a real
tenant-scoped console. Stack per ADR-0002: Vite 7 + React 19 +
TypeScript strict + React Router 7 + TanStack Query 5 + Tailwind
CSS 4 + vitest + RTL + ESLint 9 flat config.
Pages (under /dashboard, basename-routed)
- /login — email + password, redirects to where the
user came from on success
- /signup — 12+ char password policy mirrored from
the API; first API key revealed once with
a confirmation gate before navigation
- /overview — counts, recent verifications, recent
audit, usage-this-month with quota bar,
getting-started checklist, last 25 API calls
- /api-keys — list with scopes/env/last-used, create
modal (scope checkboxes, env selector,
one-time reveal), revoke confirmation
- /users — list with status filter + enroll modal
- /devices — list with status filter + register modal
(battery 0–100 validation)
- /verifications — read-only, filter by method + result
- /attendance — read-only, filter by type + result
- /audit — append-only feed with action substring +
status filter
- /settings — account info, plan + limits, danger zone
stub (email security@zeroauth.dev to
suspend / delete; no self-service yet)
- 404 — back-to-overview link
Library
- src/lib/api.ts — typed fetch wrapper. JWT in localStorage,
attached as Bearer on every authed request.
401 from /api/console/* purges the token so
the next render bounces to /login.
- src/lib/auth.tsx — AuthProvider, useAuth, status machine
(loading | authenticated | unauthenticated)
- src/lib/format.ts — number/relative/datetime/ms/truncate helpers
- src/lib/cn.ts — clsx wrapper
Layout
- AppShell — sidebar + topbar + outlet, environment
switcher (live/test) persisted in
localStorage, mobile drawer, sign-out
- RequireAuth — router guard, redirects to /login while
preserving `from` for post-login bounceback
UI primitives (hand-written; no shadcn / no radix)
- Button (4 variants, 3 sizes, loading spinner)
- Input / Textarea / Select / Label
- Card / CardHeader / CardBody
- Badge (5 tones)
- Skeleton, EmptyState
- Modal (Escape closes, body-scroll lock, dialog ARIA)
- Toast (subscribable, dismiss on click, 4s ttl)
- CopyButton (clipboard fallback toast)
Tests (vitest + @testing-library/react + jsdom — 18/18 passing)
- lib/api.test.ts (5) — Bearer attach, no-auth on signup/
login, ApiError shape, 401 purges
token, query serialisation
- lib/format.test.ts (5) — number/compact/ms/relative/truncate
- components/ui/Button.test — click, disabled-while-loading,
variant classes
- components/ui/Modal.test — open/close, Escape, ARIA role
- routes/public/Login.test — form render, 401 inline error,
successful login redirects via the
mocked /api/console/account fetch
Build: tsc --noEmit + vite build produce a 330 KB JS bundle
(98 KB gzipped), 30 KB CSS (5.75 KB gzipped). Source maps emitted.
Old files removed: src/App.tsx (520 lines), src/hooks/*, vite-env.d.ts.
* CI: gate dev branch + run dashboard typecheck/lint/test + dep-trail
ci.yml now triggers on push to main AND dev, so the working branch
gets the same gating as production. Adds three dashboard checks
(typecheck, lint, test) plus an advisory dep-trail audit so DP6
violations show up on every PR.
PRs from dev → main continue to fire via `pull_request:`, so we get
two gates: one on every dev push, one when the PR opens.
* Playwright E2E: signup → key → device → audit happy path
Adds the first end-to-end test exercising the dashboard against a
real Express + Postgres backend, plus the CI plumbing to run it on
every PR / push to main and dev.
ADR-0003 documents the adoption choice (Playwright over Cypress /
Selenium / no-E2E), the operational expectations, and the rationale
for chromium-only at this stage.
Test (dashboard/e2e/happy-path.spec.ts)
- /dashboard/signup → fill 12-char password + company → submit
- Assert the one-time API key reveal modal contains a
za_(live|test)_<48 hex> string
- Tick the "I've saved this key" confirmation → continue to Overview
- Assert sidebar reflects the new tenant identity
- Navigate to API Keys → assert the default key row is present
- Mint a second key (test env) → confirm + dismiss reveal modal
- Assert the new key row shows the test badge
- Switch env switcher to "test"
- Navigate to Devices → register a device with battery=87 →
assert toast + row appear
- Navigate to Audit → toggle env to verify tenant.created (live)
and device.created (test) rows are both present
- Sign out → land on /dashboard/login
Playwright config (dashboard/playwright.config.ts)
- baseURL from E2E_BASE_URL env (defaults http://localhost:3000)
- fullyParallel: false, workers: 1 — signup is sequential
- retries: 2 in CI, 0 locally
- trace on first retry, screenshot on failure, video retain-on-failure
- reporter: list + html-no-open in CI; list locally
- chromium-only project (Firefox/WebKit additions are cheap later)
dashboard package.json
- new scripts: e2e, e2e:install (--with-deps chromium), e2e:ui
CI (.github/workflows/ci.yml)
- New `e2e` job (`needs: validate`) so it only runs after the existing
lint + typecheck + tests + build pass
- Postgres 16 service container (zeroauth_e2e DB), 5432 → 5432
- Env: NODE_ENV=production, ENABLE_DEMO_AUTH=false, mocked secrets,
POSTGRES_* pointing at the service container, E2E_BASE_URL=http://localhost:3000
- Steps: install root + dashboard + website deps → build:all →
cache + install chromium → start `node dist/server.js` in
background → wait for /api/health → run `npm --prefix dashboard run e2e`
→ kill the server in `if: always`
- Uploads server.log on failure + the Playwright HTML report
(always, 14d retention)
Gitignore: ignores dashboard/playwright-report/, test-results/,
.playwright/ so traces + report artifacts stay out of git.
Local DX: `./scripts/deploy.sh dev` (postgres + redis + app on
:3000), then `cd dashboard && npm run e2e`. UI mode for stepping
through failures: `npm --prefix dashboard run e2e:ui`.
Backend was already verified clean (64 tests across 10 suites);
dashboard unit suite (18 tests) is unchanged. CI on push will be
the source of truth for the E2E result on this commit.
* Fix: vitest must ignore Playwright specs in dashboard/e2e/
The previous commit added dashboard/e2e/happy-path.spec.ts but didn't
narrow vitest's default `**/*.{test,spec}.?(c|m)[jt]s?(x)` discovery,
so vitest tried to import the Playwright spec — which uses a different
test/expect API — and the dashboard test step failed in CI.
vite.config.ts now sets explicit include/exclude on the test config:
- include: src/**/*.{test,spec}.{ts,tsx}
- exclude: e2e/, playwright-report/, test-results/ (plus node_modules/dist)
- coverage.exclude mirrors the same e2e/ ignore
Local re-run: 18/18 vitest tests pass. The Playwright spec is still
listed by `npx playwright test --list` and is exercised by the new
`e2e` CI job, just not by vitest.
* Fix: rename root tsconfig moduleResolution: node → node10
The implicit "node" value normalises to "node10" internally, but TS 6.x
treats that as a hard error TS5107 ("deprecated and will stop functioning
in TypeScript 7.0"). The e2e job's runner picked up TS 6.x via npm's
resolution cache while the validate job, on the same commit, got TS 5.9
and passed. Pinning the explicit non-deprecated name "node10" gives the
same behaviour in TS 5.x AND TS 6.x.
Local verify: tsc --noEmit clean, build:all clean (backend + dashboard
+ docs).
* Bump root tsconfig to module + moduleResolution = Node16
The previous fix to "node10" was still flagged as deprecated in
whatever TypeScript the CI e2e job is resolving. Node16 is the
unambiguous non-deprecated value supported in TS 5.x and 6.x.
module must be paired with moduleResolution per TS rules — both
flipped to "Node16". Local tsc --noEmit clean, npm run build:all
clean, 64/64 backend tests pass. The runtime emit stays effectively
CommonJS because package.json has no `"type": "module"`, so no
import sites need .js extensions added.
* Restore commonjs/node resolution + silence the node10 deprecation
Reverts the Node16 attempt which broke @types/* discovery — Node16
resolution doesn't auto-pick up types from node_modules/@types the
same way the node resolver does, so the backend lost @types/uuid,
@types/pg, @types/jsonwebtoken, @types/express.
Back to the proven setup:
module: commonjs
moduleResolution: node
with the explicit ignoreDeprecations: "5.0" flag so the TS5107
deprecation message ("Option 'moduleResolution=node10' is deprecated")
doesn't fail the build. The flag is a no-op on older TS, and stays
green until we migrate to Node16 + explicit @types listing in some
later, dedicated PR.
Local tsc --noEmit + build:all both clean.
* Diagnose TypeScript resolution mismatch between validate + e2e
Both CI jobs run the same `npm run build:all` against the same
lockfile, but validate consistently passes and e2e consistently
fails with TS5107 demanding `ignoreDeprecations: "6.0"` instead of
the "5.0" my locked TS 5.9.3 expects. The lockfile pins TS to
5.9.3 in exactly one place, so npm ci should produce the same
node_modules/typescript across both jobs.
Adds a diagnostic step before "Build everything" that prints:
- which tsc + npx tsc --version
- node_modules/typescript/package.json version
- TS api version
so the next run gives us the actual installed version.
Also drops `typeRoots` from tsconfig — the default behaviour (auto-
include @types/* from node_modules/@types) is what we want, and the
explicit typeRoots may have been masking a different resolution
quirk in some TS versions.
* Root-cause: e2e CI job-level NODE_ENV=production skipped devDeps
The e2e job set NODE_ENV=production as a job-level env var so the
backend would behave like prod (demo-auth gate firing, etc.). That
also made every preceding `npm ci` skip devDependencies, including
typescript, vitest, eslint, @types/*, vite, etc. Then `npm run build`
couldn't find a local tsc and resolved /usr/local/bin/tsc on the
runner — which turned out to be the bogus `tsc@2.0.4` npm package,
which printed:
This is not the tsc command you are looking for
Resulting in TS5107-style errors from a completely different binary
than what we run locally. That explains why validate (no NODE_ENV)
succeeded with TS 5.9.3 while e2e (NODE_ENV=production) "failed with
TS 6.x" — there was no TS 6.x, the runner was running an entirely
different impostor.
Fix: move NODE_ENV + every runtime secret to the "Start backend"
step only. Install / build steps run with the default ubuntu-latest
env so devDependencies install normally.
Side cleanups:
- removed the temporary "Diagnose TypeScript resolution" CI step (its
purpose served — caught the impostor tsc)
- reverted tsconfig.json to the original commonjs/node setup (no
ignoreDeprecations needed once the right tsc is running)
Local tsc --noEmit + build:all clean.
* Playwright: tighten env-badge locator + simplify audit assertions
The CI showed the test getting all the way through signup → first-
key reveal → mint a second key → reveal → list. It then failed at
the env-badge in-row check because getByText('test') ambiguously
matched BOTH the za_test_<hex> prefix cell AND the badge span in
strict mode.
Fixes:
- env badge: scope to span inside the row + exact regex match
- audit log: simplify to a single "test env shows device.created"
assertion with a 15s timeout, since recordAuditEvent is fire-and-
forget and the live/test env-switching dance was racy.
Local typecheck + lint pass.
pulkitpareek18
added a commit
that referenced
this pull request
May 15, 2026
…es, B02 plan (#28) * ADR-0004: split governance docs into pulkitpareek18/ZeroAuth-Governance Day 1's B06 was skipped in favor of keeping governance inline. On Day 3 of Week 1 we revisited because (a) the DPDP §8(7) breach-notification procedure was unwritten and that's a regulatory-teeth gap, not a hygiene one, (b) compliance mappings need an auditor-friendly surface separate from the TypeScript repo, and (c) component threat-models for Week 2+ need a stable canonical URL before the verifier ships. Created pulkitpareek18/ZeroAuth-Governance with the full B06 structure: shared policy, canonical threat model, compliance mappings, ADR index, release coordination, evidence-pack source checksums, CODEOWNERS with a two-reviewer rule on /docs/shared/ and /docs/compliance/. This repo's docs/threat_model.md is on a deprecation path; the canonical in the governance repo was synced from it on 2026-05-13 and is now authoritative. * Seed qa-log/ with DW01 cadence — first dated entry + format The dev brainstorm's DW01 cadence prompt fires twice weekly (Tue + Thu 09:55 IST) and asks the engineer to run the four-demo battery (printed-photo rejection, airplane mode, three-different-hashes, hand-the-phone) and record results in /qa-log/YYYY-MM-DD.md. The cadence had never been wired up; today seeds it. None of the four demos can run today — the IoT firmware (B03 Week 3), mobile SDK (B04 Week 5), liveness detection (B13 Week 3/5), offline queue (B14 Week 4), and LSH bucket protocol (B10 Week 3+) all unbuilt. The seed entry honestly records every demo as `Blocked` rather than faking pass entries (the brainstorm's whole point is that the cadence catches missing work — faking it would defeat the purpose). Surrogate smokes against components that DO exist today: - API smoke against https://zeroauth.dev/v1/* — all 200 - Dashboard reachability /dashboard/{login,signup,overview} — all 200 - Playwright happy-path E2E — Green in CI on commit 0d1741d - Jest + Vitest unit suites — 82 tests passing Surrogate green does not lift HOLD on buyer-facing demo URLs. HOLD stays in place until Demo 1–4 actually run Green, expected around Week 5 EOD when B03/B04/B13/B14 all land. Files added: - qa-log/README.md — format spec, the four demos, the Blocked-period surrogate convention, the cadence - qa-log/STATUS.md — current rollup (HOLD, with reason) - qa-log/LATEST.md — pointer to the most recent dated entry - qa-log/2026-05-13.md — the seed entry, today's run The cadence target for Thursday 2026-05-14 is 09:55 IST. Today's entry went up at ~11:30 IST because the cadence wasn't ready until task 3 of today's EOD list got executed. * Retroactive security review of PR #22 — 3 Medium / 3 Low / 1 Info PR #22 (merged as 0c325fb, live at 0d1741d) touched all four security-reviewer trigger surfaces — auth, crypto, audit, tenant boundaries — and merged without the subagent running. CLAUDE.md mandates the subagent on any change to these surfaces. Day 3 discipline-debt clearance. Subagent (acdae2de12c322caa) reviewed the diff 69fd27e..0c325fb. Net risk: Medium. No Critical. No tenant API key rotation needed. Mediums to land this week: - F-1: console JWT in localStorage; docs/threat_model.md A-09 claims "client memory" — reconcile docs to code or migrate to httpOnly cookies. - F-2: email enumeration via 409 on /api/console/signup — return uniform 202 + send verification email out-of-band. - F-3: console-initiated audit rows show actor_type='api_key' with actor_id=NULL because the new console handlers don't plumb the operator email into recordAuditEvent. Forensic gap, not exploit. Lows (F-4 per-tenant write limit, F-5 jti+aud, F-6 limit validation) and the Info (F-7 machine code mixed with human strings) tracked together in issue #26 — Pulkit splits into per-fix PRs as he gets to them. Things checked + clean: tenant scoping (A-01 holds), tenant inference from body silently ignored (A-10 holds), no dangerouslySetInnerHTML anywhere in dashboard, no plaintext secrets in log lines, JWT never in URLs, Helmet CSP + trust proxy correct behind Caddy. * Plan mode: B02 verifier service split-out — design doc CLAUDE.md mandates plan mode for any change to src/services/zkp.ts. B02 is Week 2 Day 1 work; starting plan mode three days early on Day 3 of Week 1 so Thursday morning opens with a committed plan. The design doc lays out two paths: - Plan A — full B02: new pulkitpareek18/ZeroAuth-Verifier Rust repo with arkworks Groth16, axum HTTP shell, SQLite WAL append-only audit with hash chain, reproducible docker buildx. Recommended. ~3 days of work (Thu + Fri + Mon Week 2 morning if slips). - Plan B — TypeScript workspace inside the existing API repo: peel snarkjs into verifier/ with its own package.json. ~1 day. Lower security wins, faster delivery. - Plan C — defer B02 to Week 2 Day 1 as the brainstorm says; spend Thu/Fri closing PR #22 Mediums (issue #26) and W05 prep. The doc spells out the migration order for Plan A (Thursday scaffold + verifier-core + verify HTTP path; Friday audit log + hash chain + reproducible build + integration), the threat-model deltas (canonical A-02 mitigation moves to verifier; new A-V01 through A-V05 in governance/docs/threat-model/verifier.md), test strategy (unit + property + negative + hash-chain + reproducible- build + API regression + E2E), risks, non-goals, and the eight decisions Pulkit + Amit need to make at the W05 Friday review. Default if no decision is made by EOD Wednesday: Plan C (defer). * Address PR #22 security findings (issue #26) — 6 of 7 closed, F-2 deferred Closes F-1, F-3, F-4, F-5, F-6, F-7. Leaves F-2 open and tracked because the real fix needs email infrastructure that doesn't exist yet. F-1 — Reconcile threat_model.md A-09 with localStorage reality Doc lied that the console JWT "lives in client memory"; in fact it's persisted to localStorage["zeroauth.console_token"]. Rewrote A-09 to document the actual choice + the trade-off + the open ADR (cookie migration) so the doc tells the truth about the code. Pointer to the governance repo's authoritative component-level dashboard.md. F-3 — Plumb actor_type='console' through audit log Service functions createDevice/updateDevice/createTenantUser/ updateTenantUser now take an `actor: AuditActor` parameter ({ type, id, email }) instead of a positional actorId. Console routes pass { type: 'console', id: tenantId, email: req.console.email }; v1 routes pass { type: 'api_key', id: apiKey.id }. The audit row's actor_type now reflects who actually performed the action, and the operator's email lands in metadata.actor_email when set. F-4 — Per-tenant write rate-limiter New consoleWriteLimiter (60 writes / 15 min, keyed on req.console.tenantId) on POST /keys, DELETE /keys/:id, POST /devices, PATCH /devices/:id, POST /users, PATCH /users/:id. A stolen JWT now burns through 60 writes, not 300, before throttling — and the limit is per tenant, not per IP, so it disincentivises the actual attack class. F-5 — Add jti + aud to console JWT issueConsoleToken now sets `jwtid: randomUUID()` and `audience: 'zeroauth-console'`. verifyConsoleToken verifies the audience explicitly. Console JWTs are therefore rejected on /v1 (and vice versa) once /v1 grows its own JWT layer. The jti is the seam for the Redis-backed revocation list (still open — separate ADR). F-6 — Validate ?limit= query New parseLimit() helper rejects non-integer, ≤0, or >1000 with a thrown RangeError, caught per-route to return 400 invalid_limit. Replaces five identical `parseInt(String(req.query.limit), 10)` sites. F-7 — Machine-code in error: field Two console handlers (/signup and /login) used the human string "Email and password are required." in the error field. Now they use invalid_request + a message field, matching the codebase convention. F-2 — Email enumeration on /api/console/signup — DEFERRED The byte-identical fix (always 202 + verification email) requires email infrastructure we don't have yet. The interim option ("uniform 400 invalid_request") also leaks (existing→400 vs fresh→201). Left the 409 in place with an explanatory comment, kept the finding open on issue #26 as a subtask gated on email-service adoption ADR. Tests 64 → 68 passing. Added: F-5 audience-mismatch test (JWT minted with aud='zeroauth-v1' is rejected with 401 session_expired); F-6 invalid_limit tests for non-integer ('abc'), lower bound (0), and upper bound (1001) — all 400 invalid_limit. Updated F-3 assertions in console-proxy.test.ts and central-api.test.ts to verify the new 4-positional createDevice/createTenantUser signature including the actor object. Typecheck: clean. Lint: 0 errors, 10 pre-existing warnings unchanged. ---------
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First batch of work on the new
devbranch. Brings the developer console up to the quality bar of a real auth provider.What's in
Spec (DP1 — spec before code)
adr/0002-dashboard-stack-vite-not-nextjs.md— documents why the console stays on Vite + React + Tailwind instead of migrating to the suite's Next.js 15 path. Names every new dep soscripts/check-dep-trail.shaudits them.CLAUDE.md— reconciles the dashboard stack section with the Vite reality.docs/threat_model.md— adds A-09 (console JWT theft via XSS) and A-10 (cross-tenant read via body-suppliedtenantId). Both have test-status rows.scripts/check-dep-trail.sh— now scans ADR bodies, not just the grandfather file. Lets one bundled adoption ADR cover many deps.Backend
/api/console/*proxy endpoints for devices, users, verifications, attendance. JWT-authenticated, tenant comes fromverifyConsoleToken, never from the body. Delegate toplatform.tsso audit-log side effects are identical to the/v1/*paths.tests/console-proxy.test.ts). Includes the regression test for A-10: POST/api/console/deviceswithtenantId: 'tenant-B'in the body usestenant-Afrom the JWT.Dashboard SPA — 10 pages, replaces the 520-line admin viewer
/login,/signupwith the 12+ char policy and one-time API-key reveal/overview,/api-keys,/users,/devices,/verifications,/attendance,/audit,/settingsRequireAuthrouter guardCI
dev, not justmain. Direct pushes are gated.scripts/check-dep-trail.sh advisoryon every push — strict mode is reserved for after the ADR habit is established team-wide.Local check parity (all green)
tsc --noEmitbackend ✓tsc --noEmitdashboard ✓npm run lintbackend (0 errors, 10 hygiene warnings) ✓npm run lintdashboard (0 errors, 5 hygiene warnings) ✓npm testbackend: 64/64 ✓npm --prefix dashboard test: 18/18 ✓./scripts/check-dep-trail.sh strict: 73 deps audited, all have ADRs ✓npm run build:all: backend + dashboard + Docusaurus all clean ✓What's NOT in this PR
platform.ts(still mocked at the route level)check-dep-trail.shinto CI in strict mode (currently advisory; flip once the team is used to writing ADRs)/settings(today the danger zone points tosecurity@zeroauth.dev)ENABLE_DEMO_AUTH-gated stubsThese are tracked separately and can come on follow-up dev → main rounds.