Skip to content

Central API developer console: 10 pages, 82 tests, CI gated on dev#22

Merged
pulkitpareek18 merged 12 commits into
mainfrom
dev
May 12, 2026
Merged

Central API developer console: 10 pages, 82 tests, CI gated on dev#22
pulkitpareek18 merged 12 commits into
mainfrom
dev

Conversation

@pulkitpareek18
Copy link
Copy Markdown
Collaborator

First batch of work on the new dev branch. 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 so scripts/check-dep-trail.sh audits 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-supplied tenantId). 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

  • 8 new /api/console/* proxy endpoints for devices, users, verifications, attendance. JWT-authenticated, tenant comes from verifyConsoleToken, never from the body. Delegate to platform.ts so audit-log side effects are identical to the /v1/* paths.
  • 14 supertest integration tests (tests/console-proxy.test.ts). Includes the regression test for A-10: POST /api/console/devices with tenantId: 'tenant-B' in the body uses tenant-A from the JWT.
  • Root jest now: 64 tests across 10 suites (was 50 / 9).

Dashboard SPA — 10 pages, replaces the 520-line admin viewer

  • Auth: /login, /signup with the 12+ char policy and one-time API-key reveal
  • Tenant data: /overview, /api-keys, /users, /devices, /verifications, /attendance, /audit, /settings
  • 404 plus a RequireAuth router guard
  • Stack: Vite 7, React 19, TypeScript strict, React Router 7, TanStack Query 5, Tailwind v4, vitest + RTL
  • UI primitives: hand-written Button / Input / Card / Badge / Modal / Toast / Skeleton / EmptyState / CopyButton — no shadcn, no radix, no UI lib
  • Tests: 18 vitest tests covering API client, format helpers, Button + Modal, full Login flow with mocked fetch
  • Build: 330 KB JS (98 KB gzipped), 30 KB CSS (5.75 KB gzipped). tsc + vite build emit source maps.

CI

  • Triggers now include push to dev, not just main. Direct pushes are gated.
  • Adds dashboard typecheck + lint + test as separate steps.
  • Runs scripts/check-dep-trail.sh advisory on every push — strict mode is reserved for after the ADR habit is established team-wide.

Local check parity (all green)

  • tsc --noEmit backend ✓
  • tsc --noEmit dashboard ✓
  • npm run lint backend (0 errors, 10 hygiene warnings) ✓
  • npm run lint dashboard (0 errors, 5 hygiene warnings) ✓
  • npm test backend: 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

  • Postgres-backed integration tests for platform.ts (still mocked at the route level)
  • End-to-end Playwright suite (the suite spec calls for this — deferred to a follow-up PR)
  • Wiring check-dep-trail.sh into CI in strict mode (currently advisory; flip once the team is used to writing ADRs)
  • Self-service tenant suspension / deletion in /settings (today the danger zone points to security@zeroauth.dev)
  • Real SAML / OIDC integration to replace the ENABLE_DEMO_AUTH-gated stubs

These are tracked separately and can come on follow-up dev → main rounds.

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.
Copilot AI review requested due to automatic review settings May 12, 2026 09:20
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 dev and 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 thread src/routes/console.ts
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 thread src/routes/console.ts
Comment on lines +498 to +500
} catch (err) {
res.status(500).json({ error: 'device_update_failed', message: (err as Error).message });
}
Comment thread src/routes/console.ts
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 thread src/routes/console.ts
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 thread docs/threat_model.md
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 thread dashboard/vite.config.ts
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.
@pulkitpareek18 pulkitpareek18 merged commit 0c325fb into main May 12, 2026
4 checks passed
@pulkitpareek18 pulkitpareek18 deleted the dev branch May 12, 2026 11:11
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>
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>
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.


---------
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants