Skip to content

Subdomain split (api/console/docs) + dashboard redesign + API playground#58

Merged
pulkitpareek18 merged 21 commits into
mainfrom
dev
May 19, 2026
Merged

Subdomain split (api/console/docs) + dashboard redesign + API playground#58
pulkitpareek18 merged 21 commits into
mainfrom
dev

Conversation

@pulkitpareek18
Copy link
Copy Markdown
Collaborator

Summary

Two coordinated tracks that together take the platform from a single-host
zeroauth.dev/* shape to a proper four-vhost production setup with a
unified brand language across the marketing site, the developer console,
and the docs.

Track 1 — Subdomain split

Hostname Serves
zeroauth.dev Marketing landing + apex 308s for legacy /v1, /api, /dashboard, /docs paths
api.zeroauth.dev REST surface (/v1/*, /api/*) — non-API paths return 404
console.zeroauth.dev Developer console (Vite SPA) — / rewrites to upstream /dashboard/
docs.zeroauth.dev Docusaurus build — / 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 on
first request.

Track 2 — Dashboard redesign

  • Brand: blue gradient → monochrome ink (matches the new landing)
  • Type: Fraunces serif display + Inter Tight body + JetBrains Mono code
  • Theme system: light + dark tokens, prefers-color-scheme default,
    [data-theme] manual override, 3-segment Light · Auto · Dark
    toggle 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):

  • `8a4e0a4` config: consoleBaseUrl / docsBaseUrl / landingBaseUrl
  • `d1d6397` Caddyfile: vhosts for api./console./docs. + apex redirects
  • `223ba75` console: cross-subdomain cookie + redirect via consoleBaseUrl
  • `78340c5` email-templates: 15 URL rewrites
  • `7b0713b` tests: email assertions realigned

Frontend (3):

  • `4fe8adc` dashboard: monochrome palette + Fraunces + Light/Auto/Dark toggle
  • `85172a8` landing: internal links to console./docs./api.
  • `a686219` dashboard: docs link via VITE_DOCS_BASE_URL

Docs (3):

  • `f576862` docs + README: every URL routed to the right subdomain
  • `ff3f24d` docs: interactive API playground at /reference/playground
  • `9d207bb` qa-log entry capturing the split

Cross-repo:

  • zeroauth-dev/ZeroAuth-Governance `930fd9c`: threat-model / naming /
    changelog URL refactor

Side experiment (not platform):

  • `aac2c5f`, `4807e17`, `c4f96cd`, `c8686ca` — fingerprint demo in
    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

… 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/usershttps://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-keyshttps://console.zeroauth.dev/api-keys
  - https://zeroauth.dev/dashboard/loginhttps://console.zeroauth.dev/login
  - https://zeroauth.dev/docs/getting-started/quickstart/https://docs.zeroauth.dev/...
  - https://zeroauth.dev/docs/whitepaper.pdfhttps://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.
Copilot AI review requested due to automatic review settings May 19, 2026 10:59
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 19, 2026

🔒 Security review required

This PR touches security-sensitive surfaces. Per CLAUDE.md §4, the security-reviewer subagent (.claude/agents/security-reviewer.md) must be invoked locally before merge.

Touched paths:

  • src/routes/console.ts

How to run the review:

# In Claude Code, after pulling this branch:
@security-reviewer review the changes on this branch

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 .github/workflows/security-review.yml and updated on every push to keep the touched-paths list current.

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.
@pulkitpareek18 pulkitpareek18 merged commit 195ab2c into main May 19, 2026
4 of 5 checks passed
@pulkitpareek18 pulkitpareek18 deleted the dev branch May 19, 2026 11:05
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

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.

Comment thread Caddyfile
Comment on lines +55 to +57
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
Comment thread Caddyfile
Comment on lines +101 to +105
# 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}
Comment thread Caddyfile
Comment on lines +125 to +127
# Same rewrite story — strip /docs/* from the upstream path.
rewrite / /docs/
rewrite /* /docs{uri}
Comment thread src/routes/console.ts
Comment on lines +278 to +281
const conflict = await getTenantByEmail(payload.email);
if (conflict) {
res.redirect(303, '/dashboard/login?already_verified=1');
return;
Comment thread src/routes/console.ts
Comment on lines +362 to +365
<h1>${title}</h1>
<p>${safeMsg}</p>
<a href="/dashboard/signup">Try again</a>
</main></body></html>`;
Comment on lines +125 to +128
// 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`;

Comment on lines +323 to +335
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();
Comment on lines +55 to +60
{
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.',
Comment thread README.md
Comment on lines 119 to 121
# → returns { token, apiKey: { key: "za_live_..." } }

# 2. Make your first call
Comment thread docs/README.md
Comment on lines 22 to 26
## 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.
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