Subdomain split (api/console/docs) + dashboard redesign + API playground#58
Conversation
… chain-verify cron Three Week 1 carry-overs closed in one batch on dev. ## F-2 v2 — byte-identical /api/console/signup (issue #27) POST /api/console/signup now always returns 202 with the same body regardless of whether the email is already registered. Tenant + API key creation is deferred to GET /api/console/verify-signup, gated on the user clicking a one-shot verification link from the inbound email. Backend - src/services/db.ts: pending_signups table (id, email, password_hash, company_name, token_hash, expires_at, consumed_at) + the two indexes. - src/services/pending-signups.ts (new): create/consume/purge with SHA-256-of-token storage, 24h TTL, atomic single-statement consume. - src/services/tenants.ts: exported hashPassword; new createTenantWithHash so the verify path doesn't re-hash. - src/services/email-templates.ts: verifySignupEmail() template with the magic-link button + plaintext fallback + 24h expiry note. - src/routes/console.ts: - POST /signup uniform 202 + { status: 'pending_verification', message } on both fresh and duplicate branches. Hashes the password on the duplicate path too (timing equalization). Notice email still fires to the legitimate holder on the duplicate path. - GET /verify-signup: consumes the token (atomic), creates the tenant via createTenantWithHash, mints the default live API key, sets a one-shot `zeroauth_signup_reveal` cookie, redirects to /dashboard/signup-complete. On a race where the email is already claimed between signup and verify, redirects to /dashboard/login?already_verified=1. - tests/console-signup.test.ts: rewritten for the new contract; 16/16 pass. Full backend suite: 234/234. Dashboard - src/lib/api.ts: SignupResponse is now { status: 'pending_verification', message }. New SignupRevealPayload type for the verify cookie. - src/lib/auth.tsx: signup() returns SignupResult (no token/apiKey here). - src/routes/public/Signup.tsx: two-state page — form, then a "check your inbox" confirmation that does NOT distinguish fresh from duplicate. - src/routes/public/SignupComplete.tsx (new): reads the reveal cookie once, stashes the JWT, shows the API key in the same modal as before. - src/routes/public/Login.tsx: ?already_verified=1 banner. - src/App.tsx: /signup-complete route added. - dashboard typecheck + lint + vitest (18/18) all green. E2E - dashboard/e2e/happy-path.spec.ts: full happy path is parked (test.skip) until the test harness can read the verify token from pg or intercept the outbound email. Added a smaller "lands on check-your-inbox" test that asserts no API key appears on the post-signup view. ## Security-reviewer workflow gate .github/workflows/security-review.yml: path-filtered workflow that fires on PRs touching auth/crypto/audit/tenant boundary code. Posts (or updates) a single sticky PR comment listing the touched paths plus the security-reviewer subagent invocation reminder. Closes the Week 1 discipline-gate gap noted in the W01 annex. ## Verifier audit-chain probe cron .github/workflows/verifier-chain-verify.yml: daily 02:30 UTC SSH probe that execs into the zeroauth-verifier container and calls /audit/verify-chain. On non-`ok:true` response, opens an incident:critical GitHub issue with the response body and a link to the verifier-component A-V01 runbook. De-duplicates per-day so a multi-hour outage doesn't spam new issues. ## QA log qa-log/2026-05-18.md + LATEST.md updated. Engineering GREEN; demo battery HOLD unchanged (gated on B03 + B13).
First piece of B03. A small TS subproject under iot/ that speaks the ZhiAn protocol over a USB-UART adapter — enough surface to enroll a finger, search the on-sensor template store, upload the raw characteristic, and hash it for the placeholder commitment. Lives in-tree for fast iteration; graduates to zeroauth-dev/ZeroAuth-IoT once the protocol is stable. iot/src/sensor.ts - Packet codec (header / addr / pid / length / payload / checksum) - Streaming parser that handles split-frame UART arrivals - R307Sensor class: verifyPassword, getSystemParams, readIndexTable, waitForFinger, imageToCharBuffer, combineToTemplate, storeTemplate, search, uploadCharacteristic, deleteTemplate, emptyDatabase, getRandom. Per-packet 3s timeout, per-finger 15s. iot/src/cli.ts - Five verbs: info, enroll [slot], search, capture, wipe - ANSI-coloured progress + clear "remove finger / place again" prompts - SHA-256 of the uploaded characteristic as a placeholder commitment (the real Pramaan pipeline goes characteristic → fuzzy extractor → Poseidon → BN128 scalar; that lives in /circuits and isn't called from here yet) iot/README.md - Hardware wiring table, install command, env overrides, threat-model notes (raw image never leaves the sensor IC; on-sensor template store is a soft secret behind the tamper-evident enclosure). adr/0007-iot-serialport-dependency.md - Adopt serialport@^12 as the UART transport. Alternatives weighed: rolling our own termios shim (option B, ~400 LoC of per-OS code we'd own forever), older node-serialport v9 (Apple Silicon hot-plug bug), socat bridge (extra moving part). Native build is bounded — prebuilts exist for every host in our deploy matrix. Verified live against the connected R307: - Sensor responds at /dev/cu.usbserial-0001 @ 57600 baud - 1000-slot library, security level 3, 128B packet size - info → enroll → search loop works end-to-end (operator runs the finger-placement steps in their own terminal because the Claude Code Bash tool can't drive physical presence)
Operator live-test on /dev/cu.usbserial-0001 surfaced one rough edge: when the captured image has too few minutiae, img_to_tz returns confirmation 0x07 (TOO_FEW_FEATURE) and the CLI bailed instead of asking for a re-scan. Same story for 0x06 (TOO_FUZZY). Added a small withRetry helper that recognises those two confirmations as user-presentation issues, not protocol failures, and re-prompts up to N times. Applied to cmdSearch and cmdCapture. cmdEnroll's two-stage flow is left alone because it has its own remove/replace prompts that naturally re-drive the user. Also recorded the B03 milestone in qa-log/2026-05-18.md — the enroll → search-same → search-different → reject-bad-image pipeline all works end-to-end on the operator's R307 module. Characteristic size on this unit is 768 bytes (3 × 256), not the 512 the datasheet suggests; the driver auto-detects via END_DATA so this is a non-issue. Surfaced for follow-up: the SHA-256 commitment is still a placeholder. The Pramaan fuzzy extractor + Poseidon (in /circuits) needs a binding into this CLI before the commitment that goes on the wire is real.
A minimal-as-possible end-to-end demo of the IoT terminal. Run:
npm --prefix iot run demo # → http://localhost:3100
The bridge owns the open R307 serial port and serializes access with
an async mutex (R307 commands aren't multiplexed; concurrent requests
would collide on the wire). The browser can't talk to UART directly,
so the bridge is the indirection.
Architecture
- iot/src/bridge.ts: a tiny http.createServer that exposes
POST /api/demo/signup { email } → two-capture enroll, bind email→slot
POST /api/demo/login { email } → single scan, 1:N search, slot match
GET /api/demo/accounts → current bindings
POST /api/demo/reset → wipe sensor + clear bindings
GET / → serves iot/demo/index.html
Bindings mirror to iot/data/demo-accounts.json so the demo survives
Ctrl-C. The R307's own template store is already persistent.
- iot/demo/index.html: monochrome two-column page (sign up + log in).
Inter Tight + Fraunces to match the brand. Status panes show the
bridge's progress in mono. Accounts table refreshes after every
signup. Reset button is a destructive ghost-button at the bottom.
How the "password" works for the demo
- Signup picks the next free slot, two-capture-enrolls the finger
into the sensor's flash at that slot, and stores {email: slot} on
the host.
- Login does a single capture + 1:N search and accepts iff the slot
the sensor returns is the slot we bound to this email. The actual
fingerprint match is the sensor's internal algorithm.
NOT production
- This is deliberately the simplest path. The real ZeroAuth pipeline
runs the characteristic through the Pramaan fuzzy extractor →
Poseidon → Groth16 (lives in /circuits, not wired to the bridge).
- The bridge has no auth and binds 127.0.0.1 only. Single-operator
laptop demo, not a shared workstation.
- Slot index leaves the sensor in cleartext over loopback.
Verified
- Bridge starts, opens /dev/cu.usbserial-0001, verifies password,
listens on :3100.
- GET / serves the HTML (200, 13.8 KB).
- GET /api/demo/accounts returns [] on a fresh bridge.
- Page renders the email + fingerprint forms cleanly at desktop and
mobile breakpoints (verified in preview).
…ft/place
The demo previously sent one big POST and blocked the UI on a static
"Place finger on the sensor, lift between scans" message for the full
~5–10 second flow. The operator had to guess which capture they were
on, when to lift, when to place again.
Now the bridge streams NDJSON phase events for the duration of the
sensor flow, and the page animates a per-card indicator (fingerprint
icon + serif headline + mono subline + two-dot stepper for signup)
that reads in real time.
Bridge — iot/src/bridge.ts
- New Phase union covering awaiting_finger / captured / awaiting_removal
/ removed / storing / searching / done / error
- enroll() and authenticate() take an `onProgress` callback and emit
one Phase per sensor-state transition (place finger, lift, place
again, store; or place, search)
- POST /api/demo/signup and /login now respond 200 with
Content-Type: application/x-ndjson and write one JSON line per
phase. The final line is always either { phase: 'done', result }
or { phase: 'error', message }
- runStreamed() wraps the response: writes the header once, calls the
flow with a `write` thunk, end()s in finally. Errors become an
error-phase line, never a 500 — by the time we know the outcome the
browser has already opened the body, and a mid-stream status flip
isn't a thing in HTTP/1.1
Demo — iot/demo/index.html
- New <indicator> block per card with a 72×72 icon, a serif headline,
a mono sub-label, and (for signup) a two-dot stepper
- Five mood classes — placing (blue, pulse), captured (green, scale),
lifting (amber, bob), working (grey, spin), success/error — drive
colour, animation, and the icon swap. Five inline SVG symbols cover
the icon set: finger / finger-down / finger-up / check / cross /
spinner. Everything is single-currentColor so the mood class is the
only knob
- streamPost() reads response.body.getReader() + TextDecoder, splits
on newlines, and dispatches each JSON line to a phase-handler
- applySignupPhase / applyLoginPhase translate the Phase enum into
renderState({ mood, icon, line, sub, detail }) + stepper updates.
Same phase events render different copy/UI in the two cards because
the flows ARE different — signup wants "Lift now", login doesn't
Housekeeping
- iot/data/ gitignored. The demo persists accounts there for restart
survival but it's per-instance state, not source. (Removed the row
that got committed inadvertently in the last push.)
Verified
- Bridge restarts cleanly on the new code, opens the serial port,
restores the persisted account, and serves the new page at /. Page
renders with the "Ready" indicator on both cards; first interaction
pivots into the place/lift state machine driven entirely by the
stream from the bridge.
User hit reg_model confirmation=0x0a (COMBINE_FAIL) during signup — the
two captures didn't match each other well enough for the sensor to
combine them into a single template, and the demo bailed with a stack-
trace-flavoured error message. That's a UX miss, not a real failure.
The same applies to 0x06 (TOO_FUZZY) and 0x07 (TOO_FEW_FEATURE), both
of which we already retry in the CLI but were absent from the bridge.
Bridge — iot/src/bridge.ts
- New classifyRetryable() maps the three codes to a human-readable
reason. Anything else falls through to the existing error path.
- enroll() now wraps the whole two-capture-and-combine flow in a
do-up-to-3-times loop. On a retryable failure it:
1. logs which code fired
2. waits for the finger to come off the sensor before the next try
(defensive — the user's finger may still be on)
3. emits a new `retry` phase event with attempt# + reason
4. starts over from awaiting_finger step 1
After 3 attempts the last error propagates as before. Login is
single-capture so this loop doesn't apply there — a bad scan still
produces a no_match reply, which is already a UI state.
Demo — iot/demo/index.html
- New phase handler for `retry` in applySignupPhase: amber lift mood,
finger-up icon, headline "Let's try again — attempt N of 3", sub
"Resetting captures", detail = the human reason from the bridge.
Stepper resets to all-grey so the next awaiting_finger event picks
up step 1 fresh.
Verified
- typecheck clean (sensor + bridge + cli)
- bridge restarts on the new code, sensor opens, demo renders with
the existing indicator state machine intact
The auto-retry doesn't fix the underlying user action (the operator
still has to use the same finger twice, similar placement), but it
removes the "click Sign Up, fail, click Sign Up, fail" treadmill.
…login
The demo was binding {email, slot} and calling that auth. That's the
sensor's match dressed up as a server check; nothing ZeroAuth-shaped
about it. The user asked for it to "use our ZKP-based tech to calculate"
— so the bridge now mirrors src/services/identity.ts and generates +
verifies a Groth16 proof every signup and every login.
Cryptographic flow
- iot/src/crypto.ts: Patent-Claim-3 derivation in TS using poseidon-lite.
Same constants, same BN128 field as the main API. The "biometric
template" is the matched slot ID (with a domain-separated pepper)
because the R307's internal 1:N match is acting as our fuzzy
extractor.
biometricID = SHA-256(slotSeed)
biometricSecret = Poseidon(biometricID_F, salt)
commitment = Poseidon(biometricSecret, salt)
didHash = Poseidon(SHA-256(did)_F)
identityBinding = Poseidon(biometricSecret, didHash)
- iot/src/proof.ts: snarkjs Groth16 fullProve + verify against the
existing circuits/build artefacts (identity_proof.wasm,
circuit_final.zkey, verification_key.json). Vkey is read once at
startup; proving + verifying are per-request.
Bridge wiring (iot/src/bridge.ts)
- Signup
1. Reject synchronously with 409 already_registered if the email is
in the map — no finger requested, no sensor work
2. Enroll at next free slot (existing two-capture flow + retry loop
still wraps this)
3. deriveSignals → emit `deriving` with a short hex preview
4. generateProof → emit `proving`
5. verifyProof → emit `verifying`
6. Persist {email, slot, salt, commitment, didHash, identityBinding,
did, createdAt}. NO secret. The bridge re-derives biometricSecret
at login from (matched slot, stored salt, email).
- Login
1. Capture + 1:N search (existing)
2. If matched slot ≠ stored slot → reject with wrong_finger
3. Re-derive signals using stored salt → must hit the same commitment
4. generateProof → verifyProof against the stored public signals
5. Reject as proof_failed if public-signal mismatch OR groth16.verify
returns false. Accept only on full verification.
- Legacy account migration: loadAccounts now type-narrows and skips
entries without the new ZK fields (the {email, slot, createdAt}
shape from earlier in the day). They log as "skipped legacy" and
the user re-signs up under the ZK schema.
UI (iot/demo/index.html)
- New phases handled in both applySignupPhase and applyLoginPhase:
deriving (commitment preview), proving (bn128 spinner), verifying
(server side). Each is a `working` mood (grey + spin) so the
pre-existing place/lift state machine still drives the colour
changes that matter to the operator.
- streamPost peeks at the response Content-Type. The 409 path is
plain JSON; the success path is application/x-ndjson. Caller can
branch on r.streamed to render the "already registered → log in
instead" error without trying to read a non-existent stream.
- Success line for both cards now shows the commitment preview and
the DID. The DID is the public-input fingerprint of the account —
printing it makes the "what the server stores" story concrete.
ADR-0008 captures the dep choice: snarkjs ^0.7 + poseidon-lite ^0.3.
Pure JS, no native build, bit-identical hash output to the main API's
circomlibjs path. Considered + rejected: circomlibjs (2.3 MB heavier
for identical output), rolling our own Poseidon (audit-equivalence
nightmare), shipping the witness off-device for proving (defeats the
whole device-proves-to-server shape).
Verified
- typecheck clean across iot/src/*
- bridge restarts cleanly, preloads Groth16 keys, opens the sensor,
serves the demo, skips 2 legacy accounts from the pre-ZK era
- demo page renders with the existing place/lift state machine intact
- new phase events show up in the bridge log when /api/demo/signup
starts emitting them
…ything else
Previously the sensor's flash slot was load-bearing: signup wrote the
template at slot N, login did a 1:N search across the sensor's library,
and the host stored {email, slot}. That capped the demo at 1000
accounts AND meant the sensor was the source of truth for "who's
enrolled."
This refactor inverts the relationship per the patent's intent. The
sensor is reduced to two roles:
1. capture+combine to produce the stable template (signup)
2. 1:1 MATCH between a fresh capture and a host-supplied template
(login)
The host owns the template, the commitment, and the proof. Capacity
is bound by disk, not by sensor flash.
sensor.ts
- downloadCharacteristic(buffer, data): DOWN_CHAR (0x09) opcode +
followup data packets terminated by PID.END_DATA. Chunks the
template to the sensor's configured packet size (128 B by default
on R307). Used by the login flow to push the host's stored
template back into buf2 right before MATCH.
- match(): MATCH (0x03) opcode. Returns {score} on CONF.OK, null on
CONF.NOT_MATCH. R307 score range 0–300+ at security level 3; well
above MATCH_THRESHOLD = 50 for the same finger.
crypto.ts
- biometricId() now hashes the actual template bytes, not a synthetic
slotSeed(slot, pepper). Same Patent-Claim-3 construction, real
biometric input.
- deriveSignals() takes templateBytes instead of {slot, pepper}. The
pepper concept is gone — the template IS the secret. Same Poseidon
derivation downstream.
bridge.ts
- Account schema: drops `slot`; adds `template` (base64 of the 768-byte
R307 template). isValidAccount tightened to match.
- nextFreeSlot() deleted.
- enroll(): capture 1 → lift → capture 2 → combine → upload via
UpChar → derive signals from templateBytes → Groth16 prove + verify
→ persist {email, template, salt, commitment, didHash,
identityBinding, did, createdAt}. Sensor flash never written.
- authenticate(): capture fresh → imageToCharBuffer(1) → look up
account → downloadCharacteristic(2, storedTemplate) → match() →
if score ≥ MATCH_THRESHOLD: re-derive (using stored salt for
determinism), prove, public-signal-check, verify. Any divergence
between recomputed and stored public signals is treated as account
tampering (proof_failed).
- reset(): no longer emptyDatabase()s the sensor. Host-only.
- New Phase events: uploading_template, loading_template, matching.
Per the streaming UI's contract; old `storing` + `searching` are
retired because neither operation happens anymore.
iot/demo/index.html
- Hero copy rewritten to describe the new flow.
- Sign-up card subtitle: "combines them into a stable template, uploads
it to the host, derives a Poseidon commitment…"
- Log-in card subtitle: "host pushes the stored template back into the
sensor and asks for a 1:1 match…"
- New phase handlers — uploading_template, loading_template, matching
— all working-mood spinners with explanatory subline.
- Accounts table swaps the Slot column for DID (last 12 chars).
- Reset button + dialog updated to say "Clear all accounts" (sensor
flash isn't being wiped because we never used it).
Tested
- typecheck clean across crypto.ts, proof.ts, sensor.ts, bridge.ts
- bridge boots, preloads Groth16 keys, opens the serial port (when the
R307 is connected)
- Legacy {email, slot, ...} entries from the prior schema are detected
and skipped at load time — operator re-signs up under the new schema
…fingerprint flow
The previous demo was a two-column "everything visible at once" page
that did the right cryptographic work but read as a debugging tool,
not an auth product. This commit reshapes it into the auth flow a real
service would ship: a centered card, a Sign-in / Create-account tab
switcher, and an explicit three-step wizard (email → OTP → fingerprint)
with a final "you're in" state.
Backend — iot/src/otp.ts (new)
- 6-digit codes, SHA-256-hashed at rest, 5-minute TTL, max 5 attempts
- 30-second per-(email,kind) cooldown on resends (OtpRateLimitedError)
- verify() success consumes the pending entry + mints a single-use
sessionToken with a 2-minute TTL, bound to (email, kind)
- consumeSession() is one-shot — successful consumption deletes the
session whether or not the operation that consumed it succeeds.
The signup/login endpoints call this at the start of their stream,
so the OTP-verified user has ~2 minutes between code entry and
finger presentation.
- gc() trims expired entries; called from /reset
Backend — iot/src/bridge.ts
- New endpoints:
POST /api/demo/request-otp { email, kind } → 200 (+ devCode if
ZA_IOT_HIDE_OTP=0)
429 on cooldown
404/409 on no_account
/already_registered
POST /api/demo/verify-otp { email, otp, kind } → 200 + sessionToken
401 + reason
- POST /api/demo/signup and /login now require a sessionToken from
verify-otp; missing/invalid → 401 otp_required.
- DEV_SHOW_OTP defaults true (controlled by env ZA_IOT_HIDE_OTP).
When set, the request-otp response carries the plaintext code so the
browser can show it in a "DEV MODE" callout. The plaintext is never
written to the bridge logs unless DEV_SHOW_OTP is on — only metadata.
Frontend — iot/demo/index.html (rewrite)
- Brand header: 56px ZeroAuth mark + serif wordmark + mono tag line
("Email · OTP · Fingerprint").
- Single 480-px card with a Sign-in / Create-account tab toggle at the
top. Both flows share the same three steps; the tab only changes
copy + the second-capture stepper visibility.
- Step 1 (email): single input + Continue. POSTs request-otp, advances
on 200.
- Step 2 (OTP): six single-character cells with paste support,
auto-advance on input, backspace re-focuses prior. Resend code +
"Change email" links inline. DEV_SHOW_OTP responses render an amber
callout under the cells so the demo works without SMTP.
- Step 3 (fingerprint): the same place/lift indicator from the prior
demo, but inside a card and gated by the OTP session token. The two-
dot stepper at the bottom of the indicator only shows during signup.
- Step 4 (done): success card with serif headline + the DID + the
Poseidon commitment preview + (login) the 1:1 match score in mono.
"Run another flow" button resets back to step 1 with the current
tab's kind.
Verified
- Typecheck clean across iot/src/* (sensor, crypto, proof, otp, bridge)
- Page renders without the bridge (static loads; the auth flow is
gated on the bridge being up — that's the expected demo failure
mode when the R307 is unplugged)
Followup (not in this PR)
- Real email delivery via nodemailer + Brevo creds when SMTP_HOST is
present. The dev-mode response path is the fallback; production
shape (devCode omitted) is already supported with ZA_IOT_HIDE_OTP=1.
… theme system
The console was still on the legacy Google-blue brand (#4285F4 / #0B57D0)
and the typography stack was just Inter + JetBrains Mono. This commit
aligns it with the marketing site's language and adds a proper theme
system on top.
Tokens — dashboard/src/styles.css
- Replaced the blue brand with monochrome ink (--color-brand maps to
the ink colour, on-brand is the bg, so primary buttons stay
high-contrast in either theme).
- Added Fraunces (display) to --font-display alongside Inter Tight
(--font-sans) and JetBrains Mono (--font-mono). Default body weight
drops a notch to match the site's lighter feel.
- Two palettes:
Dark (default + :root[data-theme="dark"]): near-black bg #0a0a0a,
text #fafafa, success #4ade80
Light (prefers-color-scheme: light + :root[data-theme="light"]):
white bg, near-black text, success #1a7a4a
prefers-color-scheme is the auto path; data-theme on <html> is the
manual override. Transitions are wired only after the first paint
via the .theme-ready class so initial-render doesn't flash.
Boot script — dashboard/index.html
- Inline IIFE reads localStorage('zeroauth.theme') and stamps
data-theme on <html> BEFORE the React bundle parses. No flash of
the wrong palette.
- color-scheme meta widened from "dark" to "light dark" so native
form controls + scrollbars follow the active theme.
- Font URL switches Inter → Inter Tight + Fraunces; JetBrains Mono
unchanged.
ThemeProvider — dashboard/src/lib/theme.tsx (new)
- React context exposing { choice, resolved, setChoice }. choice is
the user preference ('light' | 'dark' | 'system'), resolved is the
computed palette after the OS check.
- useEffect mirrors choice → DOM (data-theme attr) + localStorage.
- A second effect subscribes to prefers-color-scheme media-query
changes when choice === 'system', so the page flips live if the OS
toggles dark mode while the dashboard is open.
- useBrandMarkUrl() resolves to either zeroauth-mark.svg or
zeroauth-mark-dark.svg via Vite's BASE_URL — works in dev
(`/dashboard/`) and prod (mounted at `/dashboard/`) without
bespoke routing.
AppShell — dashboard/src/components/layout/AppShell.tsx
- Sidebar brand row uses Fraunces wordmark + the theme-aware
BrandMark <img>.
- New three-segment ThemeToggle radiogroup at the bottom of the
sidebar (Light · Auto · Dark) with inline SVG icons. Lives above
the account chip so the toggle is visible without scrolling.
- NavLink active state lost the blue tint — now uses bg-surface +
ink colour, matching the rest of the monochrome system.
Login / AuthLayout — dashboard/src/routes/public/Login.tsx
- AuthLayout header pivots from the gradient Z-square + sans wordmark
to the new fingerprint mark + Fraunces wordmark. Title is now serif.
- Footer tagline uses an uppercase tracked-out treatment instead of
the dim-grey sentence.
- useBrandMarkUrl() means the mark adapts to whatever theme the user
has set (or the OS preference) — no hardcoded asset path.
Landing — public/index.html
- Added prefers-color-scheme: dark { :root { … } } overrides for
every base token. The inverted sections (code card, whitepaper,
dark breach-card) flip with the base so contrast is preserved in
either theme.
- Brand mark in nav + footer swaps to the white variant via a single
`filter: invert(1)` rule under the dark scheme — no second img.
- color-scheme meta tag added so form controls follow the theme.
Verified
- Dashboard typecheck clean.
- Login renders in both themes (screenshots in qa-log/2026-05-19.md
flow): dark = white mark + serif wordmark + black surfaces with the
inverted button; light = black mark + serif wordmark + white
surfaces with the standard black-on-white button.
- Landing renders in both themes: DOM eval against #whitepaper in
dark mode confirms the inversion (bg rgb(250,250,250) + color
rgb(10,10,10)), proving the swap took.
Re-anchoring (qa-log/2026-05-19.md)
- The fingerprint demo at iot/ is a side experiment, not the platform.
No marketing-site / dashboard / docs reference it. The only shared
resource is the read-only circuits/build/* artefacts, same set the
central API uses. ADR-0007 and ADR-0008 are scoped to iot/.
…domain split Centralises the public-facing URLs for the four product surfaces ahead of the api./console./docs.zeroauth.dev refactor. Backend code can now read config.consoleBaseUrl, config.docsBaseUrl, etc. instead of string-concat'ing `config.apiBaseUrl + '/dashboard'` like it used to. Dev defaults keep the single-host shape (everything mounts at :3000) so the round-trip tests don't need DNS plumbing. Production resolves to the real subdomains, overridable via env.
Five terminating Caddy vhosts:
zeroauth.dev / www - landing site + apex 308s for legacy /v1, /api,
/dashboard, /docs paths to the new subdomains
(so bookmarks + CI scripts don't silently break)
api.zeroauth.dev - REST surface; proxies /v1/* and /api/* to the
upstream, hard 404 on anything else
console.zeroauth.dev - developer console; rewrites '/' → '/dashboard/'
so users see a bare-domain SPA
docs.zeroauth.dev - Docusaurus build; same rewrite trick for /docs/
All five hit the same zeroauth-prod:3000 upstream; the split is purely
at the edge. Cert provisioning is automatic on first request — DNS
needs to point all five names at the VPS before this deploys.
DNS prerequisites (operator action) are documented at the top of the
file.
Shared snippets pulled out (security_headers, asset_caching, json_log)
so each vhost stanza stays under 30 lines of intent.
…eUrl
The verify-signup endpoint mints a one-shot reveal cookie that the
SignupComplete page reads. Pre-refactor the cookie was scoped to path=/dashboard
and the redirect was a relative '/dashboard/signup-complete' — that works when
the API + console share a host, but fails when they're on separate subdomains
(api.zeroauth.dev sets the cookie; console.zeroauth.dev tries to read it).
Two tightly-coupled fixes:
- Cookie domain = '.zeroauth.dev' when consoleBaseUrl resolves to a
*.zeroauth.dev host; undefined otherwise (dev keeps the implicit
host-only scope). Path widened from '/dashboard' to '/' so the
console can read it regardless of basename.
- Redirect target now '${consoleBaseUrl}/signup-complete' — relative
'/dashboard/signup-complete' in dev, absolute https://console.zeroauth.dev/signup-complete
in prod.
Tests: tests/console-signup.test.ts loosened the redirect-target assertion
to match the suffix only, since the prefix is dev/prod-dependent. All 16
console-signup specs pass.
Replaced every relative /dashboard/* + /docs/* + apex https://zeroauth.dev/v1/* reference with the absolute subdomain URL. Touched 11 link targets: /dashboard/login → https://console.zeroauth.dev/login /dashboard/signup → https://console.zeroauth.dev/signup /docs/ → https://docs.zeroauth.dev/ /docs/getting-started/... → https://docs.zeroauth.dev/getting-started/... /docs/reference/api-reference → https://docs.zeroauth.dev/reference/api-reference https://zeroauth.dev/v1/users → https://api.zeroauth.dev/v1/users https://zeroauth.dev/v1/verif.. → https://api.zeroauth.dev/v1/verifications Caddy's apex 308s still catch any /dashboard/* or /docs/* requests that arrive at zeroauth.dev directly (legacy bookmarks, CI scripts) so this isn't a hard cut-over.
…uth.dev) The sidebar's 'Docs ↗' link was a relative /docs/. Under the subdomain split that path lives on docs.zeroauth.dev, not on the console's host — so the link has to leave the console-vhost entirely. Resolved via import.meta.env.VITE_DOCS_BASE_URL with a prod default. In dev (no VITE_DOCS_BASE_URL set) the link goes to docs.zeroauth.dev too; override locally if you want it to point somewhere else. Added dashboard/.env.example + vite-env.d.ts so the env vars are typed + documented.
Welcome / signup-attempted-notice / verify-signup / whitepaper bodies contained ~15 absolute URLs hardcoded to https://zeroauth.dev/dashboard, /docs, /v1 paths. Mechanical sed rewrite to: /dashboard/... → console.zeroauth.dev/... /docs/... → docs.zeroauth.dev/... /v1/... → api.zeroauth.dev/v1/... The footer + GitHub issue links are unchanged (they don't live under any of the four product surfaces). Tests: 36/36 in console-signup + leads suites.
Bulk sed across docs/**/*.md + website/**/*.md + README.md: https://zeroauth.dev/dashboard/ → https://console.zeroauth.dev/ https://zeroauth.dev/docs/ → https://docs.zeroauth.dev/ https://zeroauth.dev/v1/ → https://api.zeroauth.dev/v1/ https://zeroauth.dev/api/ → https://api.zeroauth.dev/api/ Caught a double-prefix bug in the first sed pass (api.api.zeroauth.dev) and cleaned it up with a defensive second sweep. Final grep shows zero remaining apex-anchored /dashboard, /docs, /v1, /api paths. Touched files include: README.md (quickstart curl snippets), threat_model.md (API surface table), integrations/{zkp,saml,oidc}.md, getting-started/ *.md, operations/{deployment,admin-dashboard,env-vars}.md, reference/ *.md.
In-browser tester for the public REST surface. Lets a reader paste
their za_test_ or za_live_ key, pick an endpoint from a catalogue
(health, nonce, circuit-info, register, verify, devices, audit), edit
the request body, and Send. Response status + body + duration render
underneath. Pure client-side — the key never leaves the browser, no
analytics see it.
Component layout: website/src/components/ApiPlayground/{index.tsx,
styles.module.css}. ENDPOINTS array is the only thing to touch when
adding new verbs.
The page itself is docs/reference/playground.mdx, slotted into the
Reference sidebar section. BrowserOnly wrapper keeps the Docusaurus
SSR step happy (no reference at build time).
Verified: clean (initial broken-anchor
warning fixed by reworking the see-also links to existing pages).
The welcomeEmail + signupAttemptedNoticeEmail templates moved their links from zeroauth.dev/dashboard/* and zeroauth.dev/docs/* to the new subdomains in the previous commit. The corresponding test expectations were checking the old strings and went red after the template rewrite. Two assertions updated, four substitutions total: - https://zeroauth.dev/dashboard/api-keys → https://console.zeroauth.dev/api-keys - https://zeroauth.dev/dashboard/login → https://console.zeroauth.dev/login - https://zeroauth.dev/docs/getting-started/quickstart/ → https://docs.zeroauth.dev/... - https://zeroauth.dev/docs/whitepaper.pdf → https://docs.zeroauth.dev/whitepaper.pdf Full suite: 234/234 passing.
Nine commits across the api repo + one cross-repo in governance, full test suite 234/234, Docusaurus build clean, dashboard typecheck clean. DNS records pending (operator action), Cloudflare decision deferred, PR drafted at the end of the cycle.
🔒 Security review requiredThis PR touches security-sensitive surfaces. Per CLAUDE.md §4, the Touched paths:
How to run the review: Reply on this PR with the structured findings report (or a "no findings" confirmation) before requesting merge. Block merge if any Critical / High finding lands without a tracked carve-out. This comment is posted automatically by |
Login.tsx now reads useTheme via useBrandMarkUrl (added in 4fe8adc). The vitest render helper wasn't wrapping in ThemeProvider, so the hook threw 'must be used inside <ThemeProvider>' on every test in the file. CI on dev caught it; local run was missing the dashboard suite. Wrapped renderLoginAt in <ThemeProvider> between the router and the auth provider. 18/18 dashboard tests green.
There was a problem hiding this comment.
Pull request overview
This PR moves ZeroAuth from a single-host deployment to a multi-subdomain layout (api., console., docs.), updates product surfaces and documentation to match those URLs, introduces an interactive API playground in the docs, and lands the F‑2 v2 “byte-identical” console signup flow (email verification + pending-signups) alongside a dashboard redesign and an in-repo iot/ experiment workspace.
Changes:
- Add multi-vhost Caddy routing + new public base URL config (
API/CONSOLE/DOCS/LANDING) and realign links across emails/docs/landing/dashboard. - Replace immediate console signup with a two-leg email verification flow backed by
pending_signups, plus updated tests. - Add docs API playground component + sidebar entry; add dashboard theme system (Light/Auto/Dark) and typography refresh; add
iot/demo workspace and new GitHub workflows.
Reviewed changes
Copilot reviewed 64 out of 66 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| website/src/components/ApiPlayground/styles.module.css | Styling for the docs API playground component. |
| website/src/components/ApiPlayground/index.tsx | Interactive API playground logic + endpoint catalogue. |
| website/sidebars.ts | Adds “Playground” to the docs reference sidebar. |
| tests/email.test.ts | Updates email template link assertions to new subdomains. |
| tests/console-signup.test.ts | Updates/expands tests for F‑2 v2 signup + verify flow. |
| src/services/tenants.ts | Exposes hashPassword and adds createTenantWithHash for deferred signup. |
| src/services/pending-signups.ts | New pending-signups store (token hashing, consume, purge). |
| src/services/email-templates.ts | Rewrites links to new subdomains; adds verify-signup email template. |
| src/services/db.ts | Adds pending_signups table + indexes. |
| src/routes/console.ts | Implements F‑2 v2 uniform 202 signup + verify-signup second leg + reveal cookie. |
| src/config/index.ts | Adds console/docs/landing base URLs; expands CORS origin fallbacks. |
| README.md | Updates public URLs to subdomains in top-level project docs. |
| qa-log/LATEST.md | Advances latest QA pointer to 2026-05-19. |
| qa-log/2026-05-19.md | Adds QA run log entry (redesign + iot re-anchoring). |
| qa-log/2026-05-19-subdomain.md | Adds engineering log entry for subdomain refactor + playground. |
| qa-log/2026-05-18.md | Adds QA run log entry covering F‑2 v2 + workflows + iot work. |
| public/index.html | Landing page: adds light/dark token swap + rewrites links to subdomains. |
| iot/tsconfig.json | New TS config for the iot workspace. |
| iot/src/sensor.ts | New R307/ZhiAn protocol driver implementation. |
| iot/src/proof.ts | New Groth16 prove/verify wrapper for the iot demo using circuit artifacts. |
| iot/src/otp.ts | New in-memory email OTP service for the iot demo. |
| iot/src/crypto.ts | New demo commitment derivation (Poseidon + SHA-256 helpers). |
| iot/src/cli.ts | New CLI for sensor operations (info/enroll/search/capture/wipe). |
| iot/src/bridge.ts | New local HTTP bridge demo (NDJSON progress streaming, ZK proof demo). |
| iot/README.md | Documentation for iot driver/demo usage and constraints. |
| iot/package.json | New iot workspace package manifest + deps/scripts. |
| iot/.gitignore | Ignores iot build output, env files, and persisted demo data. |
| docs/threat_model.md | Updates threat model URLs to api.zeroauth.dev. |
| docs/reference/playground.mdx | New docs page mounting <ApiPlayground />. |
| docs/reference/environment-variables.md | Updates hosted callback URLs to api.zeroauth.dev. |
| docs/reference/contracts-and-circuit.md | Updates sample curl URLs to api.zeroauth.dev. |
| docs/reference/central-api.md | Updates sample curl URLs to api.zeroauth.dev. |
| docs/reference/api-reference.md | Updates sample curl URLs to api.zeroauth.dev. |
| docs/README.md | Updates docs entrypoint URL references to new subdomains. |
| docs/operations/env-vars.md | Updates operational smoke URLs to api.zeroauth.dev. |
| docs/operations/deployment.md | Updates health-check URL to api.zeroauth.dev. |
| docs/operations/admin-dashboard.md | Updates console API curl examples to api.zeroauth.dev. |
| docs/integrations/zkp-biometric-auth.md | Updates integration curl examples to api.zeroauth.dev. |
| docs/integrations/saml-sso.md | Updates SAML integration curl examples to api.zeroauth.dev. |
| docs/integrations/oidc.md | Updates OIDC integration curl examples to api.zeroauth.dev. |
| docs/getting-started/quickstart.md | Updates quickstart curl examples to api.zeroauth.dev. |
| docs/getting-started/configuration.md | Updates getting-started URLs to api.zeroauth.dev. |
| docs/getting-started/api-keys.md | Updates API key management URLs to api.zeroauth.dev. |
| docs/concepts/production-readiness.md | Updates signup URL to api.zeroauth.dev. |
| dashboard/src/vite-env.d.ts | Adds typed Vite env vars for cross-surface base URLs. |
| dashboard/src/styles.css | Redesign tokens + theme overrides + typography utilities. |
| dashboard/src/routes/public/SignupComplete.tsx | New one-time key reveal page (reads reveal cookie). |
| dashboard/src/routes/public/Signup.tsx | Updates signup UI for “pending verification” flow. |
| dashboard/src/routes/public/Login.tsx | Adds “already_verified” banner + redesigned auth layout header. |
| dashboard/src/lib/theme.tsx | New theme context (light/dark/system) + brand mark resolver. |
| dashboard/src/lib/auth.tsx | Updates signup contract to return pending-verification result (no token/key). |
| dashboard/src/lib/api.ts | Updates signup response typing + defines reveal-cookie payload type. |
| dashboard/src/components/layout/AppShell.tsx | Adds theme toggle + brand mark updates + docs link via env var. |
| dashboard/src/App.tsx | Wraps app in ThemeProvider + adds /signup-complete route. |
| dashboard/index.html | Adds theme boot script + new font imports; enables light/dark color-scheme. |
| dashboard/e2e/happy-path.spec.ts | Skips old happy path; adds partial F‑2 v2 “check inbox” coverage. |
| dashboard/.env.example | Documents dashboard env vars for docs/console/api base URLs. |
| Caddyfile | Introduces multi-vhost routing and apex redirects for subdomain split. |
| adr/0008-iot-snarkjs-poseidon-lite.md | ADR documenting snarkjs + poseidon-lite choice for iot demo. |
| adr/0007-iot-serialport-dependency.md | ADR documenting serialport transport choice for iot driver. |
| .github/workflows/verifier-chain-verify.yml | New scheduled workflow probing verifier audit-chain integrity. |
| .github/workflows/security-review.yml | New PR path-filter “security review required” gate comment workflow. |
| .env.example | Adds/updates public base URL env vars and CORS guidance for subdomain split. |
Files not reviewed (1)
- iot/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| redir /dashboard/* https://console.zeroauth.dev{uri.path.substr(10}{uri.query} permanent | ||
| redir /docs /docs/ permanent | ||
| redir /docs/* https://docs.zeroauth.dev{uri.path.substr(5}{uri.query} permanent |
| # Strip /dashboard prefix expected by the Vite-built SPA. Express | ||
| # serves the dashboard at /dashboard/*; on console.zeroauth.dev we | ||
| # want users to land at "/", so rewrite incoming "/" → "/dashboard/". | ||
| rewrite / /dashboard/ | ||
| rewrite /* /dashboard{uri} |
| # Same rewrite story — strip /docs/* from the upstream path. | ||
| rewrite / /docs/ | ||
| rewrite /* /docs{uri} |
| const conflict = await getTenantByEmail(payload.email); | ||
| if (conflict) { | ||
| res.redirect(303, '/dashboard/login?already_verified=1'); | ||
| return; |
| <h1>${title}</h1> | ||
| <p>${safeMsg}</p> | ||
| <a href="/dashboard/signup">Try again</a> | ||
| </main></body></html>`; |
| // Clear immediately regardless — we treat the cookie as one-shot. If decoding | ||
| // fails the user gets routed to login anyway. | ||
| document.cookie = `${REVEAL_COOKIE}=; path=/dashboard; max-age=0`; | ||
|
|
| it('on race (email got claimed between signup and verify), redirects to /dashboard/login?already_verified=1', async () => { | ||
| consumePendingSignupMock.mockResolvedValue({ | ||
| email: 'fresh@example.com', | ||
| passwordHash: 'aabbccdd:eeff0011', | ||
| companyName: null, | ||
| }); | ||
| getTenantByEmailMock.mockResolvedValue({ id: 'tenant-racy', email: 'fresh@example.com' }); | ||
|
|
||
| const res = await request(app).get('/api/console/verify-signup?token=tok_abc123'); | ||
|
|
||
| expect(res.status).toBe(303); | ||
| expect(res.headers.location).toBe('/dashboard/login?already_verified=1'); | ||
| expect(createTenantWithHashMock).not.toHaveBeenCalled(); |
| { | ||
| id: 'register', | ||
| method: 'POST', | ||
| path: '/v1/users/register', | ||
| label: 'Register a user', | ||
| description: 'Bind an external_id to a Poseidon commitment. The biometric never leaves the client.', |
| # → returns { token, apiKey: { key: "za_live_..." } } | ||
|
|
||
| # 2. Make your first call |
| ## How It Works | ||
|
|
||
| 1. **Sign up** at `https://zeroauth.dev/api/console/signup` and get your API key. | ||
| 1. **Sign up** at `https://api.zeroauth.dev/api/console/signup` and get your API key. | ||
| 2. **Authenticate requests** with `Authorization: Bearer za_live_YOUR_KEY`. | ||
| 3. **Call v1 endpoints** — register identities, verify ZK proofs, initiate SSO flows. |
Summary
Two coordinated tracks that together take the platform from a single-host
zeroauth.dev/*shape to a proper four-vhost production setup with aunified brand language across the marketing site, the developer console,
and the docs.
Track 1 — Subdomain split
zeroauth.dev/v1,/api,/dashboard,/docspathsapi.zeroauth.dev/v1/*,/api/*) — non-API paths return 404console.zeroauth.dev/rewrites to upstream/dashboard/docs.zeroauth.dev/rewrites to upstream/docs/All five vhosts terminate at the same Caddy instance and proxy to
zeroauth-prod:3000. Caddy auto-provisions Let's Encrypt certs onfirst request.
Track 2 — Dashboard redesign
prefers-color-schemedefault,[data-theme]manual override, 3-segment Light · Auto · Darktoggle in the sidebar, persisted to localStorage, no flash on boot
New surface — API playground
`docs.zeroauth.dev/reference/playground` mounts an interactive React
component that lets a reader paste their API key, pick an endpoint
from a catalogue, edit the request body, and Send. Response renders
inline. The key never leaves the browser.
Commit map
Backend + edge (5):
Frontend (3):
Docs (3):
Cross-repo:
changelog URL refactor
Side experiment (not platform):
iot/. Not built or deployed; isolated workspace.
DNS prerequisites (already configured)
```
A api.zeroauth.dev → 104.207.143.14
A console.zeroauth.dev → 104.207.143.14
A docs.zeroauth.dev → 104.207.143.14
```
Test plan
broken anchors