Skip to content

feat(audit+auth): /audit dashboard, email-OTP auth (CLI + dashboard), SES-backed re-audit reminders, unified pixel-craft design system#397

Open
SiddarthAA wants to merge 25 commits into
FailproofAI:mainfrom
SiddarthAA:stable
Open

feat(audit+auth): /audit dashboard, email-OTP auth (CLI + dashboard), SES-backed re-audit reminders, unified pixel-craft design system#397
SiddarthAA wants to merge 25 commits into
FailproofAI:mainfrom
SiddarthAA:stable

Conversation

@SiddarthAA
Copy link
Copy Markdown

@SiddarthAA SiddarthAA commented Jun 1, 2026

feat(audit+auth): /audit dashboard, email-OTP auth, SES-backed re-audit reminders & unified design system

Release: 0.0.11-beta.3
Base: main · Head: stable
Scope: 25 commits · 81 files changed · +12,645 / −580
Companion (api-server): feat/ses-mailing @ 95a8e0cmust ship alongside for reminder mail delivery to actually leave the building.


At-a-glance

This branch lands the 0.0.11-beta.3 cut. It is large because three interlocking features were designed together and ship as one coherent slice:

  • /audit dashboard — turns existing failproofai audit data into a personality-driven, shareable report with archetype classifier, scoring, findings, prescribed policies, and a PNG poster export.
  • Email-OTP authenticationfailproofai auth … CLI subcommand + dashboard AuthDialog, wired end-to-end to the Rust failproof-api-server (/v0/auth/*).
  • Persistent re-audit reminders — local state in ~/.failproofai/next-audit.json mirrored to the api-server's new /v0/reminders scheduler, which delivers the nudge via SES in prod.

Plus: a unified dashboard design system (the /audit pixel-craft language promoted to /policies + /projects), full PostHog telemetry across the new surfaces, a hardening sweep, and +40 new tests (1701 → 1741 passing).


Table of contents

  1. /audit dashboard
  2. Email-OTP authentication
  3. Persistent re-audit reminders
  4. Unified pixel-craft design system
  5. Telemetry (PostHog)
  6. Hardening sweep
  7. Tests
  8. Docs
  9. Environment variables
  10. Deployment notes
  11. Verification
  12. Test plan

1 · /audit dashboard

Commits: f41089e, 9a0b22b, ba6bcab

A server-rendered report at /audit driven by the existing failproofai audit data.

1.1 — Classifier + scoring

  • src/audit/archetypes.ts — 8 archetypes (optimist, cowboy, explorer, goldfish, paranoid architect, precision builder, hammer, ghost). SIGNAL_MAP covers 47/47 builtin policies + audit-only detectors with tuned weights. Falls back to goldfish on broad-spread signal, precision on zero signal.
  • src/audit/scoring.ts — 0–100 score, S/A/B/C/D/F bands, projectedScore (uplift if every recommended policy were enabled), syntheticRank for a stable cohort rank.
  • src/audit/strengths.ts + src/audit/findings.ts — derive Strengths (real numbers: clean-call %, avg turns, "0 credential leaks" gated on the relevant sanitize policies firing) and per-policy Findings (hand-curated body + cost copy + real captured evidence from AuditCount.examples).
  • findings.ts:DETECTOR_TO_POLICY — every audit-only detector is paired with its closest real-time builtin policy, so every finding card carries a real $ failproofai policy add <slug> install command. Multi-policy mappings render an "also covered by <policy>" hint. The Prescribed-policies section aggregates detector hits into the target policy with (via redundant-cd-cwd, …) attribution.

1.2 — Multi-variant persona catalog (9a0b22b)

  • Every archetype is expanded from a single hand-written copy block to a multi-variant catalog: 4–6 variants per field (tagline, keyword sets, description, signature block, common-in / primary-risk / closing lines).
  • pickArchetypeVariant(key, seed) — deterministic per-field djb2 hash mixed with a per-field axis. Persona blurb is stable for a given project seed but two different projects landing on the same archetype see different copy.
  • Fixed the picker's signed-modulo bug (the final XOR re-introduced signedness → negative index → undefined keywords) by forcing >>> 0 on the final mix.
  • IdentitySection consumes the resolved variant; the seed flows in from audit-dashboard.tsx as the inferred project name.

1.3 — UI sections (app/audit/_components/*)

01   Identity        archetype hero · 8×8 pixel sigil · meta grid
02   Strengths       real numbers derived from the audit
03   Score           score + (later) score-share card  ← see §4
04   Return loop     "re-audit in 7 days" + reminder CTA
05   Findings        per-policy: what / cost / evidence / fix
06   Prescribed      with projected-score uplift callout

1.4 — Cache + API

  • ~/.failproofai/audit-dashboard.json — mode 0600, single-slot (new runs overwrite). Helper at src/audit/dashboard-cache.ts.
  • AuditResult schema → version 2 — new fields eventsScanned: number (drives the "X tool calls" headline), projectsScanned: string[] (drives the project filter), enabledBuiltinNames: string[] (lets findings answer "is this fix already enabled?" without iterating result rows).
  • POST /api/audit/run — calls runAudit() in-process, writes the cache, module-scoped run lock that 409s on concurrent clicks.
  • GET /api/audit/status{running, startedAt, cachedAt} for client polling at 1 Hz.
  • app/actions/get-audit-result.ts — read-only server action; mirrors /policies' getHooksConfigAction() pattern.

1.5 — Re-run UX + shareable poster

  • Empty-state CTA on first visit; in-flight re-runs render a 4-stage faux-progress UI (run-progress.tsx).
  • RerunButton POSTs /api/audit/run with the current scan params, polls /api/audit/status at 1 Hz, and re-fetches via the server action when running flips false.
  • [ make poster ] captures the archetype frame via html2canvas@^1.4.1 at scale 2 and downloads failproofai-<archetype>-<YYYY-MM-DD>.png.
  • Hardened in 9a0b22b: await document.fonts.ready before capture, a .capturing class locks every clamp() font-size and grid column to absolute values tuned for the 1100 px capture width, drops unpredictable text-shadow/box-shadow, captures with a 12 px bleed so the frame's corner accents survive the crop.
  • [ share → ] uses manual window.scrollTo minus the sticky .app-header height (+ a scroll-margin-top: 80px fallback on .showoff).

1.6 — Runtime safety: snapshot/restore around runAudit()

src/hooks/policy-registry.ts gains two additive exports (getAllPolicies / setAllPolicies). They are used only by replay.ts:restoreReplay() to snapshot/restore policy state, so embedding runAudit() in a long-running process (the dashboard API route) can no longer wipe pre-existing registrations.

1.7 — Page gate

audit appended to FAILPROOFAI_DISABLE_PAGES's valid-values list; the page-level disable gate in app/audit/page.tsx already honored it.


2 · Email-OTP authentication

Commits: 1b38daf, 1884dda, 934080b, d93392e

End-to-end auth against the Rust failproof-api-server (/v0/auth/login/request, /login/verify, /token/refresh, /logout, /me), exposed on both CLI and dashboard, with a single shared on-disk session file.

       CLI (failproofai auth)            Dashboard (Next.js)
                 \                              /
                  \  reads/writes              /  reads/writes
                   \                          /
                    ~/.failproofai/auth.json (mode 0600)
                               │
                               │ bearer JWT
                               ▼
                  failproof-api-server (Rust) ───► Postgres

2.1 — CLI surface (src/auth/cli.ts, dispatched from bin/failproofai.mjs)

failproofai auth login      email + OTP flow, writes auth.json
failproofai auth logout     revokes server-side, wipes auth.json
failproofai auth whoami     prints identity from /me (silent refresh)
failproofai auth help

Readline OTP masking is TTY-gated so piped stdin (tests / scripts) doesn't stall on the per-character _writeToOutput callback.

2.2 — Shared store — lib/auth/ (used by both surfaces)

api-server-client.ts — stateless fetch client. Endpoint helpers (requestLoginCode, verifyLoginCode, refreshAccessToken, logoutSession, fetchMe, decodeJwt). AuthApiError carries status, code, retry_after_secs. Tolerates both the documented {code, message} error shape and the live server's {success, code, detail} shape.

auth-store.ts — file persistence at ~/.failproofai/auth.json, mode 0600 (creation + post-write chmodSync on overwrite). getValidAccessToken() auto-refreshes within a 60 s leeway; whoAmI() does one refresh-and-retry on a hard 401 then wipes the file. FAILPROOFAI_AUTH_DIR env override exists for tests.

2.3 — Dashboard routes — app/api/auth/

  • GET /status{authenticated, user?, reminder?} via whoAmI().
  • POST /login-request — proxy; surfaces retry_after_secs.
  • POST /login-verify — on 200 persists tokens locally and returns only {authenticated, user}. The refresh token never reaches the browser.
  • POST /logout — revokes upstream, deletes auth.json regardless of upstream success.
  • GET / POST / DELETE /reminder — see §3.

2.4 — Dashboard UI

  • auth-dialog.tsx — modal matched to /audit's pixel-craft aesthetic: pink corner brackets, dashed-frame backdrop, terminal mono inputs, masked OTP entry, live 30 s resend countdown, ESC / backdrop / [x] close, rate-limit error banner.
  • return-section.tsx — probes /api/auth/status on mount and gates [ set a reminder ] on auth state (full behavior matrix in §3.3).

2.5 — Default api-server URL flipped (d93392e)

lib/auth/api-server-client.ts:DEFAULT_API_BASE flipped from http://localhost:8080https://api.befailproof.ai so published npm users hit the hosted server out of the box. Local-dev contributors and self-hosted users continue to override via FAILPROOF_API_URL=…. Updated copy lives in: CLI help text, the "could not reach" CliError, auth-dialog.tsx's error banner ("is it running on :8080?" → "check your network"), docs/cli/auth.mdx's env-var table, and docs/cli/environment-variables.mdx's Authentication row.

2.6 — Stale-session hardening (507f3c9)

runLogin() now treats auth.json as stale when its local refresh_expires_at claim has lapsed — wipes the file and walks the OTP flow again instead of bouncing the user with "already signed in" against a file the server would reject. Telemetry tagged replaced_stale: true on the resulting _login_started event.


3 · Persistent re-audit reminders

Commits: 4e0f805, 1e6ccff, 75eefec
Companion api-server commit: 95a8e0c on feat/ses-mailing

3.1 — Local state

~/.failproofai/next-audit.json — mode 0600. Separate file from auth.json so token refresh / re-login does not churn the reminder.

New helpers in lib/auth/auth-store.ts:
readReminder · writeReminder · deleteReminder · getReminderFilePath · StoredReminder type.

3.2 — Dashboard route — app/api/auth/reminder/route.ts

  • GET — returns the current reminder for the active session. Per-email scoped — a reminder for a@x.com is invisible when b@x.com is the live CLI session.
  • POST — defaults to a 7-day offset. Empty body → defaults. Malformed JSON / non-object body → 400 validation_error (hardened in 507f3c9).
  • DELETE — clears it.

/api/auth/status now returns reminder: { next_audit_at, user_email, set_at } | null alongside the user.

3.3 — Behavior matrix — return-section.tsx

  • unknown (status in flight) — buttons disabled.
  • anon[ set a reminder ] opens AuthDialog; on successful sign-in the dialog auto-persists the 7-day reminder (no second click required).
  • authed + no reminder[ set a reminder ] writes the timestamp directly, no dialog.
  • authed + reminder set — status panel showing:
    • next audit set for <Mon Jun 8> · in 7 days
    • signed in as <email>
    • [ re-audit now ] + [ install policies ] + tiny clear reminder link.

[ re-audit now ] is also exposed to anon users with audit data. It reuses triggerRun() from rerun-button.tsx and reloads the page when the new run finishes.

3.4 — Server-side delivery — feat/ses-mailing (95a8e0c)

  • New POST / DELETE / GET /v0/reminders backed by Arc<Mutex<HashMap<email, Reminder>>> — one entry per email; re-setting replaces the old reminder.
  • Background tokio task sweeps on FAILPROOF_REMINDER_SWEEP_INTERVAL_SECS and delivers due reminders via the existing EmailSender seam (SES in prod, log sender in dev).
  • send_audit_reminder() added to EmailSender with a dedicated template; both LogEmailSender and SesEmailSender implement it. Test recorder in tests/auth_flow.rs updated to match.
  • Auto-registers a default 7-day reminder on successful OTP verify so users get a baseline cadence without ever touching the dashboard.
  • No database, no cron — bounded by pod lifetime by design. The dashboard mirrors locally; recovery path after a pod reschedule is "next user action re-registers."
  • Two new client helpers scheduleReminder / cancelReminder in lib/auth/api-server-client.ts forward the dashboard's POST/DELETE /api/auth/reminder to the api-server. Upstream failure is captured into telemetry as upstream: "failed" + upstream_error but does NOT fail the request — local state remains the source of truth for the UI.

4 · Unified pixel-craft design system

Commits: 34bf971, 4e0f805, 4bcd6eb, 356bd17 · 31e17cf · 187ee90, 500e97a

Promotes the /audit page's brutalist pixel-craft design language to the whole app and fixes a latent bug where the audit page's :root + body resets persisted on client-side navigation back to other routes.

4.1 — Single source of truth → app/globals.css

The audit token set (--bg, --ink, --accent-pink, --accent-green, --font-mono = JetBrains Mono, --font-display = Architype Stedelijk / VT323) is declared once. Every shadcn-style Tailwind alias (--background, --card, --foreground, --primary, --border, --radius: 0, …) is repointed at the audit palette — so existing utility classes like bg-card text-foreground border-border produce audit visuals everywhere without component rewrites.

4.2 — No more style leak

app/audit/audit-styles.css is trimmed by 150 lines — it keeps only audit-page-only widgets (archetype frame, sigil, score grade, findings cards, return hook, auth dialog). The :root, html/body/#root resets, body atmosphere overlays, .app-header, .btn, .tabs all now live in globals.css. Side effect: nothing is left to leak.

4.3 — Component-level changes

  • app/layout.tsx — drops next/font/google Geist Mono. Fonts ship via the single CSS @import in globals.css.
  • components/navbar.tsx — rewritten around .app-header: pink ▮▮ pixel mark, lowercase Architype wordmark, optional version chip, per-section eyebrow, .tab links with sharp pink underline on active. Lucide icons removed. Audit entry added between Policies and Projects with a slipping-through count chip.
  • /policies + /projects — wrapped in .report + .section + .panel chrome with audit-style mastheads. Inner ProjectList / ActivityTab / PoliciesTab are unchanged and pick up the unified palette automatically.

4.4 — Sizing (4e0f805)

  • Base font 13px → 14.5px
  • .report max-width 1180 → 1380px (40 px side padding)
  • .section vertical padding restored to 64px

Default-zoom readability across /audit, /policies, /projects no longer forces a browser zoom-in.

4.5 — Removed app/audit/_components/app-header.tsx (4e0f805)

The second in-page failproof_ai / AUDIT [ share → ] strip is gone, plus its three mount sites in audit-dashboard.tsx (cached, in-flight, ShellEmpty). The global navbar + per-section mastheads cover the same chrome without the duplicate.

4.6 — Score section rewrite (4e0f805)

Drops the synthetic cohort leaderboard. New .score-share-card is a single dashed-frame .panel split into two columns:

  • Left — big tier-colored score · tier badge · progress bar to the next grade band · three stat boxes (missing policies, pts-to-next, est. days to fix) · policy-status chip strip.
  • Right — pre-written X / Twitter and LinkedIn templates derived from score + archetype + missing-count; [ share on X ], [ share on LinkedIn ], and [ download audit card ] (html2canvas captures the whole panel as failproofai-card-<grade>-<score>.png).

audit-dashboard.tsx drops the now-unused syntheticRank import / rank prop and threads result into the new section.

4.7 — Empty / running views (4e0f805)

  • empty-state.tsx.empty-panel with a 6×6 pixel-grid sigil, Architype Stedelijk headline, .btn-press CTA, audit-style meta caption. Replaces the prior shadcn Button + lucide-react icon center-card.
    • Mode no-cache → "run your first audit." with [ run audit ].
    • Mode zero-sessions → "install hooks first." with [ install guide → ].
  • run-progress.tsx → terminal-style .running-panel: $ failproofai audit --since 30d ▮ header with a blinking pink cursor; stage list with / ▮▮ / markers + per-stage braille spinner; marquee progress bar with a pink shine sweep.

4.8 — Glowing-dot section headings (4bcd6eb)

.section-h-dot + pulse keyframes (green dot, sized to text via em, opacity + box-shadow dim/glow loop). Replaces the ━━ projects · agent SDK folders ● N folders indexed and ━━ policies · live evaluation ● evaluating in real time masthead rows on /projects and /policies — heading is now Projects / Policies + glowing dot.

⚠️ Heads-up for reviewers running locally: restart bun run dev once after pulling. Turbopack HMR cannot hot-swap :root / @import changes in globals.css reliably.


5 · Telemetry (PostHog)

Commits: bd623b2, 75eefec, 7fa1de9, 78c326e

PostHog instrumentation added across the new surfaces. The existing FAILPROOFAI_TELEMETRY_DISABLED=1 kill switch is honored by every new event.

5.1 — Auth + reminder routes (75eefec)

app/api/auth/{login-request, login-verify, logout, reminder}/route.ts now emit:

  • audit_otp_requested · audit_otp_verified · audit_user_logged_out
  • audit_reminder_set · audit_reminder_cleared
  • All with status + error_code on failure. The OTP-request path carries a SHA-256 email hash so distinct senders are countable without storing PII.

src/auth/cli.ts mirrors the same OTP/login/logout events plus CLI-only:

  • audit_cli_auth_login_started · _login_completed · _logout_completed · _whoami
  • With attempt counters and had_session / upstream outcomes.

Also restores literal \x1b[ escape sequences in the CLI color constants that had been collapsed to raw control bytes in the prior commit.

5.2 — Identity link + policy add/remove failures (7fa1de9)

  1. audit_user_identity_linked on CLI auth. src/auth/cli.ts now emits the event on successful OTP verify with source: "cli", carrying user_id, user_email, and local_random_id = getInstanceId(). The dashboard's /api/auth/login-verify already emitted the same event with source: "audit_set_reminder_auth_dialog"; this is the CLI sibling — without it, anyone who signed in via failproofai auth --login stayed unjoined to their pre-auth instance events in PostHog.
  2. cli_policy_{add,remove}_failure events. bin/failproofai.mjs stashes the action in lastPolicyAction at dispatch time; the top-level catch emits cli_policy_${action}_failure (CliError or unexpected) with error_type + exit_code — same shape as the existing cli_install_failure / cli_uninstall_failure events. Enables add/remove conversion-rate analysis.

5.3 — Five audit-page funnel gaps closed (78c326e)

  1. audit_dashboard_viewed — fires when the report renders, carries {score, grade, archetype, secondary, missing, transcripts_scanned, results_count, detectors_triggered}. The existing audit_page_viewed only carried state + has_cache — click-through against share / download / rerun was impossible to compute without joining server logs.
  2. audit_reminder_cta_shown / audit_reminder_cta_clicked — fired in return-section.tsx after the /api/auth/status probe resolves and on press. Closes the front of the funnel shown → clicked → AuthDialog → reminder_set.
  3. auth-dialog.tsx emits audit_auth_dialog_opened, _dismissed (with the step the user gave up on), _succeeded — all tagged with a source prop ("return_section"). Exact dropoff at email vs OTP step is now visible.
  4. audit_rerun_failed — fires from both rerun-button.tsx and return-section.tsx's handleRerun with kind (post_failed | network | timeout) from RerunError. Alertable on rerun reliability without parsing /api/audit/run server logs.
  5. api_server_unreachable — incremented from fetchWithTimeout in lib/auth/api-server-client.ts (kind + request path + method). "The api-server is down" is one PostHog count instead of a server-log scrape. No-op on the CLI side when telemetry has not been initialised.

5.4 — Product tag

Every PostHog event across all four channels (hooks/audit, server, web UI, npm install/uninstall) is now stamped with product: "failproofai-oss". The value lives in a single POSTHOG_PRODUCT constant in src/posthog-key.ts, reused by the three TypeScript channels; the standalone scripts/install-telemetry.mjs inlines the same literal because it cannot import the TS module at install time.


6 · Hardening sweep

Commit: 507f3c9

  • Atomic writesauth.json and next-audit.json now use temp-file-then-rename with 0600 enforced on both temp and final paths. A concurrent write or crash mid-write can no longer leave a half-written / truncated session or reminder file behind.
  • 10 s AbortSignal.timeout on every /v0/auth/* and /v0/reminders fetch. A wedged DNS resolver, a hung api-server, or a stalled refresh no longer pins the CLI or a dashboard request indefinitely. Surfaces as AuthApiError(code: "timeout") — same "could not reach the api-server" copy as transport failures.
  • runLogin stale-session handling — see §2.6.
  • /api/auth/reminder — empty body (defaults to 7-day offset) vs malformed JSON / non-object body (400 validation_error) are now distinguished. Previously the route silently coerced to {} and wrote a default-offset reminder.
  • /api/audit/run — rejects null, arrays, and primitive bodies with 400 before sanitize(null) throws 500. Guards both the JSON.parse path and the post-parse shape check.
  • dashboard-cache.ts — adds explicit schemaVersion field. Entries written by older code are now rejected as null instead of being rendered against the wrong shape.
  • rerun-button.tsxtriggerRun now throws RerunError on POST failure / network failure / poll-loop timeout. Button paints a pink-border rerun failed — retry state for 4 s instead of pretending the run completed. triggerRun is typed as Promise<void> that explicitly throws so EmptyState's CTA can adopt the same UX.
  • run-progress.tsx — caps visual progress at 90 % and swaps the last-stage detail to finishing up…. A real 30 s run no longer paints 4/4 + 100 % at the 16 s mark.
  • identity-section.tsx — LinkedIn-share every key policy is live copy now requires grade === "A" && missing === 0 (previously any A-grade triggered the verbatim copy even when there were unenabled prescribed policies).
  • assets/audit/archetypes.jsx Sigil() — normalizes unknown archetypeKey once at the top so the index lookup uses the same safe key as the sigil grid (previously the sigil lookup had a fallback but ARCHETYPES[archetypeKey].index crashed on unknown keys).
  • score-section.tsxuseMemo(pointsToNext) replaced with a plain pointsToNextFor(score) (cheap 5-threshold scan). Fixes react-hooks/preserve-manual-memoization CI red.

7 · Tests

Commit: 7c2b961 · +40 tests · full suite 1701 → 1741 passing

  • __tests__/audit/archetypes.test.ts — zero-signal → precision, broad-spread → goldfish, secondary ≥ 40 % promotion vs authored fallback, deterministic variant picker.
  • __tests__/audit/findings.test.ts — ranking, zero-hit drop, detector → policy remapping, alsoCoveredBy, alreadyEnabled enable-set + builtin-config heuristics, relative-time + missing lastSeen fallback.
  • __tests__/audit/strengths.test.ts — clean-rate headline, credential / retry / push-to-main absence gates, 5-item cap, fallback row when too few qualify.
  • __tests__/lib/auth-store.test.ts — round-trip, mode 0600, atomic-write leaves no .tmp siblings, shape-mismatch rejection, reminder scoping, atomic overwrite.
  • __tests__/audit/replay.test.ts — updated to cover the new snapshot/restore flow.
  • __tests__/audit/dashboard-cache.test.ts and __tests__/lib/api-server-client.test.ts added.

8 · Docs

Commits: 934080b, 1e6ccff, ba6bcab, 34b6c99, d8703b7

  • docs/cli/auth.mdx (new) — usage block; --login / --logout / --whoami sections; on-disk auth.json shape; env-var table; troubleshooting list for the common Could not reach the api-server / Rate limited / Code rejected cases. Persistent-reminder section covering ~/.failproofai/next-audit.json (file shape, per-email scoping, 7-day default, 0600 perms) and the GET / POST / DELETE /api/auth/reminder endpoint.
  • docs/cli/environment-variables.mdx — new Authentication section: FAILPROOF_API_URL (default now named: https://api.befailproof.ai) and FAILPROOFAI_AUTH_DIR (default ~/.failproofai).
  • docs/dashboard.mdx — new ### Audit page entry; audit appended to FAILPROOFAI_DISABLE_PAGES valid values.
  • CHANGELOG.md — full 0.0.11-beta.3 block under today's date.
  • i18n mirrors deliberately deferred — the existing translate-docs sync workflow picks them up.

9 · Environment variables

9.1 — Dashboard / CLI (this repo)

  • FAILPROOF_API_URL · default https://api.befailproof.ai (flipped from http://localhost:8080 in d93392e)
    Base URL of the api-server. Override to http://localhost:8080 for local dev or to a self-hosted host.

  • FAILPROOFAI_AUTH_DIR · default ~/.failproofai
    Directory where auth.json + next-audit.json live. Tests use this to redirect to a tmpdir.

  • FAILPROOFAI_DISABLE_PAGES · unset
    Comma-separated list of dashboard pages to disable. Now accepts audit in addition to the existing values.

  • FAILPROOFAI_TELEMETRY_DISABLED · unset
    Existing kill switch. Honored by every new PostHog event added in this branch.

9.2 — Api-server (feat/ses-mailing)

  • FAILPROOF_REMINDER_SWEEP_INTERVAL_SECS · default 60
    How often the background tokio task scans the in-memory map for due reminders.

  • FAILPROOF_REMINDER_DEFAULT_DAYS · default 7
    Offset for the auto-scheduled-on-verify reminder.

  • FAILPROOF_REMINDER_AUTO_SCHEDULE_ON_VERIFY · default true
    When true, registers a default 7-day reminder on every successful OTP verify so users get a baseline cadence without touching the dashboard.


10 · Deployment notes

  1. Order matters — api-server first, then this branch. The dashboard's scheduleReminder / cancelReminder helpers call /v0/reminders, which does not exist on api-server main. Until feat/ses-mailing ships, the dashboard succeeds locally (file write) and logs upstream: "failed" telemetry — no user-visible failure, but no mail goes out.
  2. DEFAULT_API_BASE flip. Published npm users now hit https://api.befailproof.ai automatically. Ensure that host is up and reachable at merge time. Self-hosted users continue to override via FAILPROOF_API_URL (no breaking change for anyone who already had the env var set).
  3. CORS is not required. Every browser-visible auth call goes through Next.js API routes (server-side); the api-server never sees a cross-origin browser request.
  4. Database / JWT / SES config all lives with the api-server deployment. The npm package never touches Postgres directly — only the HTTP surface of the api-server.
  5. Refresh-token reuse detection happens on the api-server (rotated_to chain). The client treats any 401-from-refresh as "wipe local session," so theft-revoked users get pushed back to the login dialog automatically.
  6. Pod reschedules wipe in-flight reminders by design — the scheduler is in-memory. The dashboard mirrors locally; recovery path is "next user action re-registers." Document this for ops.
  7. Local dev restart. Restart bun run dev once after pulling — Turbopack HMR cannot hot-swap :root / @import changes in globals.css reliably.

11 · Verification

  • bunx tsc --noEmit — passes
  • bun run lint — passes (only the two pre-existing warnings)
  • bun run test:run1741 / 1741 pass
  • bun --bun next build — Compiled successfully; new /api/auth/{status, login-request, login-verify, logout, reminder} and /api/audit/{run, status} routes register; /audit page builds.
  • Manual end-to-end against a Docker Postgres + the api-server on feat/ses-mailing:
    • CLI --login + --whoami + --logout
    • All five auth routes + both audit routes
    • /audit rendering with the gated reminder button
    • Shared auth.json across both surfaces (sign in via CLI → dashboard sees it; logout via CLI → dashboard reverts to anonymous on next page load)
    • Reminder fires end-to-end through the SES log sender

12 · Test plan

  • Bring up the api-server on feat/ses-mailing with FAILPROOF_EMAIL_SENDER_BACKEND=log
  • FAILPROOF_API_URL=http://127.0.0.1:8080 bun run dev
  • bun bin/failproofai.mjs auth --login → OTP visible in api-server stdout → verify → --whoami confirms
  • Open /audit → empty state → run audit → report renders with archetype + score
  • [ make poster ] downloads a PNG; [ download audit card ] downloads the score-share PNG
  • Anon path: [ set a reminder ] → AuthDialog → sign in → reminder auto-set, no second click
  • Authed path: clear reminder → [ set a reminder ] writes directly, no dialog
  • [ re-audit now ] triggers a fresh run + page reload
  • failproofai auth --logout → dashboard reverts to anon on next page load
  • Confirm PostHog events landed:
    • audit_dashboard_viewed
    • audit_reminder_cta_shown / _clicked / audit_reminder_set
    • audit_user_identity_linked from both CLI and dashboard
    • cli_policy_add_failure (force a bad slug)
  • Confirm the api-server's SES log sender prints the audit-reminder template at sweep time

Commit map (most recent → oldest)

d8703b7  docs(changelog): credit environment-variables.mdx in api-server URL entry
34b6c99  docs(cli): note new https://api.befailproof.ai default in env-vars index
d93392e  feat(auth): default api-server base URL to https://api.befailproof.ai
78c326e  feat(telemetry): close five funnel gaps in audit-page events
685937b  docs(changelog): note identity-link CLI emit + policy add/remove failure events
7fa1de9  feat(telemetry): identity-link on CLI auth + policy add/remove failure events
7c2b961  test(audit+auth): cover archetypes classifier, findings, strengths, auth-store
507f3c9  fix(audit+auth): hardening sweep across dashboard + CLI
4bcd6eb  feat(ui): glowing-dot section headings on /policies + /projects
91df2e9  docs(changelog): note auth/reminder telemetry + scheduler wiring
75eefec  feat(telemetry): instrument auth/reminder routes and wire reminder scheduler
bd623b2  feat(posthog): add posthog telemetry, update cli docs, cleanup old telemetry
1884dda  feat(cli): update auth cli, rename commands
356bd17  ui fixes
31e17cf  ui fixes
187ee90  ui fixes
1e6ccff  docs(auth): document ~/.failproofai/next-audit.json + reminder endpoint
4e0f805  feat(ui): bigger type, score+share card, persistent reminder, re-audit now
500e97a  update global css
34bf971  feat(ui): unify dashboard around audit pixel-craft system; fix nav style leak
934080b  docs(auth): add docs/cli/auth.mdx and env-vars entry for FAILPROOF_API_URL
1b38daf  feat(auth): email-OTP login for CLI + dashboard, wired to failproof-api-server
9a0b22b  feat(audit): persona variant catalog + scroll/poster/install-CTA polish
ba6bcab  docs(audit): CHANGELOG entry + /audit dashboard section in dashboard.mdx
f41089e  feat(audit): /audit dashboard with archetype classifier, scoring, and shareable poster

SiddarthAA and others added 12 commits May 28, 2026 00:37
… shareable poster

Adds an in-app audit report at /audit that turns the existing
`failproofai audit` data into a personality-driven dashboard. Every
detector / policy hit feeds a weighted classifier that lands the agent
in one of 8 archetypes; the report uses that to motivate enabling
unenabled builtin policies.

What's new

- Archetype catalog + classifier (`src/audit/archetypes.ts`): 8 archetypes
  (optimist, cowboy, explorer, goldfish, paranoid architect, precision
  builder, hammer, ghost) with pixel sigils, taglines, "common in" /
  "primary risk" copy. `SIGNAL_MAP` maps every builtin policy + every
  audit-only detector (47/47 coverage) to an archetype with a tuned
  weight. Classifier picks the dominant archetype, falls back to
  `goldfish` for broad-spread agents, and to `precision` when no signal
  fired.
- Scoring (`src/audit/scoring.ts`): starts at 100, subtracts capped
  per-source penalties (deny -1.2, instruct/warn -0.7, sanitize -0.4,
  detector -0.5). Grade thresholds S/A/B/C/D/F match the reference.
  `projectedScore` previews the post-enable uplift; `syntheticRank`
  produces a stable cohort rank from the score.
- Derivations (`src/audit/strengths.ts`, `src/audit/findings.ts`):
  Strengths surface real numbers (clean-call %, avg turns, "0 credential
  leaks" when sanitize policies didn't fire, etc.). Findings carry
  hand-curated body + cost copy per policy slug and the real captured
  evidence from `AuditCount.examples`.
- Detector → policy fix mapping (`findings.ts:DETECTOR_TO_POLICY`):
  each of the 8 audit-only detectors is paired with the closest real-time
  builtin policy, so every finding card shows a real
  `$ failproof policy add <slug>` install command — no "audit-only"
  framing in the report. Multi-policy mappings render an "also covered
  by <policy>" hint. Prescribed-policy section aggregates detector hits
  into the target policy with `(via redundant-cd-cwd, …)` attribution.

Sections

01 Identity (archetype hero with sigil + meta grid), 01b Show off CTA,
02 Strengths, 03 Score + cohort leaderboard with distribution histogram,
04 Findings (per-policy cards: what happened / cost / evidence / fix),
05 Prescribed policies (with projected score uplift callout), 06 Return
loop ("re-audit in 7 days"). Server page reads the dashboard cache only;
all derivation is client-side. Catalog size is computed server-side and
passed as a prop (BUILTIN_POLICIES and audit detectors pull in node:fs
via the workflow / require-* policies, so they can't ship to the client).

Cache + API

- Dashboard cache at ~/.failproofai/audit-dashboard.json (mode 0600,
  single slot, new runs overwrite). Helper at
  `src/audit/dashboard-cache.ts` (read/write/staleness). Schema bumped
  to `version: 2` with new fields `eventsScanned: number` (total
  tool-use events scanned, drives the "X tool calls" headline),
  `projectsScanned: string[]` (drives the project filter), and
  `enabledBuiltinNames: string[]` (lets findings answer "is this fix
  already enabled?" without iterating result rows).
- POST /api/audit/run calls runAudit() in-process, writes the dashboard
  cache, and serializes via a module-scoped singleton lock so concurrent
  clicks 409. GET /api/audit/status reports {running, startedAt,
  cachedAt} for client polling. Server action
  `app/actions/get-audit-result.ts` reads the cache without triggering
  a run, mirroring the `/policies` `getHooksConfigAction()` pattern.

Re-run UX

- Empty state CTA on first visit; in-flight re-runs render a four-stage
  faux progress UI (`run-progress.tsx`). RerunButton POSTs `/api/audit/run`
  with the current scan params, polls `/api/audit/status` at 1Hz, and
  refetches via the server action when running flips false.
- Shareable PNG export: clicking "make poster" captures the identity
  archetype-frame DOM via html2canvas at scale 2 and downloads
  `failproofai-<archetype>-<YYYY-MM-DD>.png`. New dependency:
  html2canvas@^1.4.1.

Styling

- Ported `assets/audit/styles.css` (1235 lines) verbatim into
  `app/audit/audit-styles.css`, scoped to the route via page-level
  import. JetBrains Mono + VT323 loaded from Google Fonts; Architype
  Stedelijk shipped locally under public/audit/fonts/. Reference design
  kit (audit.jsx / poster.jsx / tweaks-panel.jsx / styles.css /
  archetypes.jsx + screenshots) committed under assets/audit/ for
  future iteration. ESLint config gains an assets/ ignore so the design
  kit's vanilla React-Babel JSX isn't linted as project source.

Animation primitives in app/globals.css: `.audit-row-enter` (staggered
fade-up via `--row-delay`) and `.audit-bar-fill` (width 0 → `--bar-width`
on mount), both honoring `prefers-reduced-motion`.

Navbar / layout

- Navbar gains an "Audit" entry between Policies and Projects with a
  ClipboardCheck icon and an optional slipping-count chip (rendered
  when the layout's server-side cache read finds >0 slipping hits).
  Layout passes the count via a new `auditSlippingCount` prop.

Core changes (additive, original policies untouched)

- `src/hooks/policy-registry.ts`: added `getAllPolicies()` and
  `setAllPolicies()` exports for snapshot/restore. Existing
  `registerPolicy` / `clearPolicies` / `getPoliciesForEvent` /
  `normalizePolicyName` semantics unchanged.
- `src/audit/replay.ts`: `initReplay()` now snapshots the registry via
  `getAllPolicies()` before clearing it; new `restoreReplay()` puts the
  pre-init policies back. `runAudit()` wraps the work in try/finally so
  embedding the audit in long-running processes (the Next.js dashboard
  is one) no longer wipes pre-existing registrations.
- `src/audit/index.ts`: surfaces `eventsScanned`, `projectsScanned`,
  `enabledBuiltinNames` on the result; per-transcript scan now tracks
  events count + cwd. Schema-version bump 1 → 2.

Tests

- New `__tests__/audit/dashboard-cache.test.ts` (round-trip, 0600 mode,
  corrupt-JSON resilience, staleness threshold).
- `__tests__/audit/replay.test.ts` adds three tests covering registry
  snapshot/restore: a user-registered policy survives `initReplay()` →
  `restoreReplay()`, `restoreReplay()` is idempotent, and calling
  `restoreReplay()` before `initReplay()` is a no-op.
- Full suite green: 1701 / 1701.

Verification

- `bunx tsc --noEmit` clean
- `bun run lint` 0 errors (2 pre-existing warnings retained)
- `bun run test:run` 1701 / 1701
- `bun --bun next build` succeeds; new routes `/audit`,
  `/api/audit/run`, `/api/audit/status` all registered
- Hook handler smoke against live config (`block-failproofai-commands`
  fires deny on `failproofai policies --uninstall`, harmless commands
  pass cleanly) — runtime policy enforcement intact
Adds the missing CHANGELOG entry for the /audit dashboard work and a
new "### Audit" section under docs/dashboard.mdx's Pages list. Also
appends `audit` to the FAILPROOFAI_DISABLE_PAGES valid-values list
(the page-level disable gate added in app/audit/page.tsx already honors
it; the docs were one step behind).

Translated dashboard.mdx mirrors (14 locales) are intentionally left for
the translation-sync workflow — same pattern as the env-vars docs from
0.0.11-beta.2.
- Expand every archetype in src/audit/archetypes.ts to a multi-variant
  catalog (4–6 taglines, keyword sets, descriptions, signature blocks,
  common-in / primary-risk / closing lines per archetype). A new
  pickArchetypeVariant(key, seed) resolver picks one variant per field
  via a djb2-hashed, per-field-axis index, so the persona blurb stays
  stable for a given project seed but two projects landing on the same
  archetype see different copy. IdentitySection consumes the resolved
  variant; the seed flows from audit-dashboard.tsx as the inferred
  project name. Fix the picker's signed-modulo bug (final XOR
  re-introduced signedness → negative index → undefined keywords)
  by forcing >>> 0 on the final mix.
- Simplify return-section's CTA to '[ install policies ]' copying the
  bare `failproofai policies --install` command (no per-policy short
  names appended).
- Fix the [ share → ] header button: replace scrollIntoView with a
  manual window.scrollTo that subtracts the sticky .app-header height
  (+16px breathing room), plus a scroll-margin-top: 80px fallback on
  .showoff.
- Harden the 'make poster' PNG export so the captured archetype frame
  no longer collides with the sigil / tagline: await document.fonts.ready
  before capture, apply a .capturing class that locks every clamp()'d
  font-size and grid column to an absolute value tuned for the 1100px
  capture width, drop text-shadow / box-shadow that html2canvas crops
  unpredictably, and capture with a 12px bleed on every side so the
  frame's corner accents survive the crop.
…pi-server

Implements end-to-end email-OTP auth against the Rust failproof-api-server,
exposed through both the `failproofai auth` CLI subcommand and the in-app
dashboard. Adds a gating step on the /audit page's "set a reminder" CTA so
unidentified visitors can verify themselves inline before reminders get
queued (mail-scheduling itself is deferred).

Architecture

  CLI (failproofai auth)            Dashboard (Next.js)
            \\                              /
             \\  reads/writes              /  reads/writes
              \\                          /
               ~/.failproofai/auth.json (mode 0600)
                          |
                          | bearer JWT
                          v
              failproof-api-server (Rust) -> Postgres

CLI surface (src/auth/cli.ts, dispatched from bin/failproofai.mjs)

  failproofai auth --login    Email + OTP flow, writes auth.json
  failproofai auth --logout   Revokes server-side, wipes auth.json
  failproofai auth --whoami   Prints identity from /me (silent refresh)
  failproofai auth --help     Usage

Readline input-masking is TTY-gated so piped stdin (tests / scripts) doesn't
stall on the per-character _writeToOutput callback.

Shared HTTP + persistence layer (lib/auth/)

  api-server-client.ts  Stateless fetch client. Endpoint helpers
                        (requestLoginCode, verifyLoginCode,
                        refreshAccessToken, logoutSession, fetchMe,
                        decodeJwt). AuthApiError carries status, code,
                        retry_after_secs. Base URL from FAILPROOF_API_URL
                        (default http://localhost:8080). Tolerates both
                        the documented {code,message} error shape and the
                        live server's {success,code,detail} shape.

  auth-store.ts         File persistence at ~/.failproofai/auth.json,
                        mode 0600 (creation + chmodSync on overwrite).
                        getValidAccessToken() auto-refreshes within a 60s
                        leeway; whoAmI() does one refresh-and-retry on a
                        hard 401 then wipes the file. FAILPROOFAI_AUTH_DIR
                        env-var override exists for tests.

Dashboard API routes (app/api/auth/)

  GET  /api/auth/status         {authenticated, user?} via whoAmI()
  POST /api/auth/login-request  Proxy; surfaces retry_after_secs
  POST /api/auth/login-verify   Proxy; on 200 persists tokens locally and
                                returns ONLY {authenticated, user} -- the
                                refresh token never reaches the browser
  POST /api/auth/logout         Revokes upstream, deletes auth.json
                                regardless of upstream success

Dashboard UI

  app/audit/_components/auth-dialog.tsx
      Modal dialog matched to /audit's pixel-craft aesthetic: pink corner
      brackets, dashed-frame backdrop, terminal mono inputs, masked OTP
      entry, live 30s resend countdown, ESC / backdrop / [x] close, error
      banner with rate-limit messaging.

  app/audit/_components/return-section.tsx
      Probes /api/auth/status on mount. [set a reminder] gates on auth:
      unknown -> button disabled, anon -> opens dialog with "oops -- you
      are unknown", authed -> flashes [reminder queued for <email>] and
      shows a green "signed in as <email>" pill under the CTA.

  app/audit/audit-styles.css
      New .auth-dialog* + .auth-status-pill rules using the existing color
      palette and font stack.

Production deploy hooks

  - Set FAILPROOF_API_URL on the user's machine OR change DEFAULT_API_BASE
    in lib/auth/api-server-client.ts to the prod URL before publishing.
    The npm package never touches Postgres directly -- only the HTTP
    surface of the api-server. Database / JWT / SES config all live with
    the api-server deployment.
  - CORS work is not needed: every browser-visible auth call goes through
    the Next.js API routes (server-side), so the api-server never sees a
    cross-origin browser request.
  - Refresh-token reuse detection happens on the api-server (rotated_to
    chain); the client treats any 401-from-refresh as "wipe local
    session" so theft-revoked users get pushed back to the login dialog.

Local dev loop

  1. docker run -d --rm --name failproof-pg -e POSTGRES_PASSWORD=postgres \\
       -e POSTGRES_USER=postgres -e POSTGRES_DB=failproof -p 5544:5432 \\
       postgres:16-alpine
  2. cd platform/failproofai/api-server && \\
     FAILPROOF_DATABASE_URL=postgres://postgres:postgres@localhost:5544/failproof \\
     FAILPROOF_JWT_SIGNING_KEY=<>=32-byte-string> \\
     FAILPROOF_BIND_ADDR=127.0.0.1:8080 \\
     FAILPROOF_EMAIL_SENDER_BACKEND=log FAILPROOF_ENVIRONMENT=local \\
     cargo run --bin server
  3. FAILPROOF_API_URL=http://127.0.0.1:8080 bun run dev
  4. In a fourth terminal:
       bun bin/failproofai.mjs auth --login
     OTP appears in the api-server's stdout under the
     "login code (dev log sender)" log line.

Verified end-to-end against a Docker postgres + the api-server: CLI login
+ whoami + logout, all four dashboard routes, the audit page rendering
with the gated reminder button, and the shared auth.json across both
surfaces (sign in via CLI -> dashboard sees it; logout via CLI ->
dashboard reverts to anonymous on next page load). Existing 1701 vitest
tests, eslint, and tsc all stay green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…I_URL

Documents the new `failproofai auth --login | --logout | --whoami` subcommand
and the two env-var knobs (`FAILPROOF_API_URL`, `FAILPROOFAI_AUTH_DIR`) shipped
with the auth feature. i18n mirrors will pick this up via the existing
translate-docs workflow on the next sync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…yle leak

Promotes the /audit page's design language to the whole app so /policies and
/projects pick up the same fonts, palette, chrome, and component vocabulary.
Also fixes a latent bug where the audit page's :root + body resets persisted
on client-side navigation back to other routes, leaving them with audit's
JetBrains Mono / dark canvas but none of the matching section chrome.

Strategy: unify at the CSS-variable level. Every shadcn-style Tailwind token
(--background, --card, --foreground, --primary, --border, --radius, …) is
repointed at the audit palette (--bg, --bg-2, --ink, --accent-pink, …) in
globals.css. Existing Tailwind utility classes like `bg-card text-foreground
border-border` continue to work but now produce audit visuals — no component
rewrites needed for the 1661-line hooks-client tree.

Files changed

  app/globals.css        Rewritten. Single source of truth for fonts, tokens,
                         body atmosphere (cross-hatch + grain + pink vignette),
                         and every shared chrome class (.app-header / .h-brand
                         / .btn / .btn-press / .tabs / .tab / .section /
                         .section-mast / .section-h / .report / new .panel).

  app/audit/audit-styles.css
                         Trimmed by 150 lines. Drops :root, the html/body/#root
                         resets, the body atmosphere overlays, .app-header,
                         .btn, .tabs — all now live in globals. Keeps only the
                         /audit-only widgets (archetype-frame, sigil, score
                         grade, leaderboard, findings, return hook, auth
                         dialog). Side effect: nothing left to leak.

  app/layout.tsx         Removes the next/font/google Geist Mono import. Fonts
                         ship via the @import url(…JetBrains+Mono…) in
                         globals.css so the design system is one stylesheet.

  components/navbar.tsx  Rewritten around .app-header. Pink "▮▮" pixel mark +
                         Architype Stedelijk wordmark, optional version chip,
                         dynamic per-section eyebrow ("policies" / "audit" /
                         "projects"), .tab links with sharp pink underline on
                         the active route. Drops lucide icons from the bar.

  app/projects/page.tsx + loading.tsx
                         Wrapped in .report + .section + .panel. New
                         green-eyebrow masthead with the ━━ glyph and
                         "your agent footprint." section heading. Empty and
                         loaded states both use the dashed-frame .panel.
                         ProjectList component itself unchanged.

  app/policies/hooks-client.tsx
                         Top-level <div className="min-h-screen bg-background
                         …"> replaced with a .report + .section shell. New
                         masthead with audit-style copy ("what your agents
                         tried." / "what to stop them doing.") and an enabled-
                         count meta chip in pink. TabBar swapped from rounded
                         pill to global .tabs / .tab with sharp pink underline
                         on active. Dropped the unused ArrowLeft + back-to-
                         projects link (navbar handles cross-page nav now).
                         No inner refactor of ActivityTab / PoliciesTab.

Verification

  bunx tsc --noEmit         passes
  bun run lint              passes (only the 2 pre-existing warnings)
  bun run test:run          1701/1701 pass
  bun --bun next build      Compiled successfully in 6.2s; static + dynamic
                            routes for /, /policies, /projects, /audit, and all
                            /api/auth and /api/audit endpoints generated.

The user needs to restart `bun run dev` once after pulling this commit — the
Turbopack HMR pipeline can't hot-swap :root / @import changes reliably.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…t now

Six polish items in one pass — sizing, second-navbar fix, score-section
rewrite, empty/running restyle, and persistent reminder state across
sessions.

Sizing
  globals.css: base 13 → 14.5px, .report max-width 1180 → 1380px (40px
  side padding), .section padding restored to 64px. Default-zoom
  readability across /audit, /policies, /projects no longer forces a
  browser zoom-in.

Double navbar
  Delete app/audit/_components/app-header.tsx and all three of its
  mount sites in audit-dashboard.tsx (cached, in-flight, and
  ShellEmpty). The global navbar already supplies brand + tabs + reach;
  the in-page bar with [share →] was redundant chrome.

Score section
  Drop the synthetic cohort leaderboard. Replace ScoreSection with a
  single .panel (.score-share-card) split into score + share:
    left  — big tier-colored score, tier badge, progress bar to the
            next grade band, 3 stat boxes (missing policies, pts to
            next tier, est. days to fix), policy-status chip strip
    right — X + LinkedIn pre-written templates derived from
            score/archetype/missing; [share on X], [share on
            LinkedIn], [download audit card] (html2canvas captures
            the entire panel as failproofai-card-<grade>-<score>.png)
  audit-dashboard.tsx drops the unused syntheticRank import / rank
  prop and threads `result` into the new section.

Empty / running
  empty-state.tsx: shadcn Button + lucide icon center card → .panel
  with a 6×6 pixel-grid sigil, Architype Stedelijk headline,
  .btn-press CTA, audit-style meta caption. Mode "no-cache" → "run
  your first audit." with [ run audit ]. Mode "zero-sessions" →
  "install hooks first." with [ install guide → ].
  run-progress.tsx: terminal-style panel — "$ failproofai audit
  --since 30d ▮" header with a blinking pink cursor, stage list with
  ✓ / ▮▮ / ○ markers + per-stage braille spinner, marquee progress
  bar with a pink shine sweep.

Persistent reminder
  ~/.failproofai/next-audit.json — separate from auth.json so a token
  refresh / re-login doesn't churn the reminder. Mode 0600, same
  perms hygiene as auth.json (writeFileSync with mode + post-write
  chmodSync on overwrite).
  lib/auth/auth-store.ts: new readReminder / writeReminder /
  deleteReminder / getReminderFilePath + StoredReminder type.
  app/api/auth/reminder/route.ts: GET / POST / DELETE. POST defaults
  to a 7-day offset; reminder is scoped to the active session so a
  reminder for a@x.com is invisible when b@x.com is the live CLI
  session.
  /api/auth/status returns `reminder: { next_audit_at, user_email,
  set_at } | null` alongside the user.

Return section
  Behavior matrix in return-section.tsx:
    unknown → buttons disabled while /api/auth/status is in flight
    anon    → [set a reminder] opens AuthDialog, on success persists
              the 7-day reminder automatically (no second click)
    authed + no reminder → [set a reminder] writes the timestamp
              directly, no dialog
    authed + reminder set → status panel showing
              "next audit set for <Mon Jun 8> · in 7 days" and
              "signed in as <email>", plus [re-audit now] /
              [install policies] / "clear reminder"
  [re-audit now] button is exposed to all authed states (plus anon,
  next to install-policies). It reuses triggerRun() from
  rerun-button.tsx and reloads the page once the new run finishes.

Verification
  bunx tsc --noEmit  passes
  bun run lint       passes (only the 2 pre-existing warnings)
  bun run test:run   1701/1701 pass
  bun --bun next build  Compiled successfully — new
    /api/auth/reminder route registers alongside /api/auth/{status,
    login-request, login-verify, logout}.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The new persistent re-audit reminder ships a small companion file
alongside auth.json. Add a short section to docs/cli/auth.mdx covering
its shape, the per-email scoping rule (so swapping CLI accounts hides
the previous user's reminder), the 0600 perms, and the GET / POST /
DELETE /api/auth/reminder endpoint that backs the UI button. CHANGELOG
Docs entry matched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 1, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

Adds email OTP auth (CLI + local token store + Next.js proxy routes + reminder API) and a new /audit dashboard with caching, run/status endpoints and module lock, archetype classification, scoring/grades/cohort, findings/policies UIs, poster PNG export, global design-system CSS, standalone audit assets, tests, and docs.

Changes

Email OTP Authentication & Audit Dashboard

Layer / File(s) Summary
Auth HTTP client and persistence libraries
lib/auth/api-server-client.ts, lib/auth/auth-store.ts
HTTP client for /v0/auth/* with error parsing, request timeouts and JWT decoding; local auth and reminder persistence with atomic writes, permission hardening, refresh and whoami flows.
CLI authentication commands & top-level CLI dispatch
src/auth/cli.ts, bin/failproofai.mjs
failproofai auth CLI (login/logout/whoami) with interactive prompts, masking, retries, and top-level dispatcher updates plus policy command handling and failure telemetry.
Audit core computation
src/audit/archetypes.ts, src/audit/scoring.ts, src/audit/findings.ts, src/audit/strengths.ts
Archetype catalog and deterministic variant resolver, agent classification, scoring/grade/tier/cohort utilities, finding derivation with curated copy, and strengths derivation.
Audit run, replay, and types
src/audit/index.ts, src/audit/replay.ts, src/audit/types.ts
Per-transcript cwd/events counters, runAudit inner/finally restoreReplay, policy-registry snapshot/restore, and AuditResult schema bumped to v2 (projectsScanned, eventsScanned, enabledBuiltinNames).
Dashboard cache
src/audit/dashboard-cache.ts, __tests__/audit/dashboard-cache.test.ts
File-based cache ~/.failproofai/audit-dashboard.json with schema versioning, read/write helpers, permission tightening, staleness detection, and tests for read/write/stale/corrupt cases.
Audit API endpoints: run/status and run-state
app/api/audit/_state.ts, app/api/audit/run/route.ts, app/api/audit/status/route.ts
In-memory run state coordination (tryAcquireRun/release), POST /api/audit/run with sanitization and 409 on concurrent runs, runAudit invocation and cache persistence, and GET /api/audit/status for polling.
Auth API proxy and reminder/status routes
app/api/auth/login-request/route.ts, app/api/auth/login-verify/route.ts, app/api/auth/logout/route.ts, app/api/auth/reminder/route.ts, app/api/auth/status/route.ts
Browser-facing proxies for OTP request/verify/logout, per-user reminder GET/POST/DELETE with validation and optional api-server forwarding, and auth status derived from local cache with matched reminder scoping.
Audit server page and server action
app/audit/page.tsx, app/actions/get-audit-result.ts, app/audit/loading.tsx
Server entry for /audit that reads cache, computes catalog size, conditionally notFound(), renders client AuditDashboard in Suspense, plus a server action to return cached result payload.
Audit dashboard React components
app/audit/_components/*
Client components: AuditDashboard orchestrator, AuthDialog modal, EmptyState, RunProgress, Identity/Strengths/Score/Findings/Policies/Return sections, Sigil, ShowOffCTA (html2canvas poster capture), RerunButton (triggerRun + polling), ReportFooter, and supporting helpers.
Design system and page styles
app/globals.css, app/audit/audit-styles.css
Centralized CSS tokens, font loading, shared app chrome classes and audit-specific styles (identity, strengths, score, findings, policies, auth dialog, poster capture).
Navigation, policies/projects UI updates
components/navbar.tsx, components/reach-developers.tsx, app/policies/hooks-client.tsx, app/projects/*
Navbar restyle with /audit link and slipping-count badge, ReachDevelopers menu color customization, policies/projects pages migrated to new report/section chrome.
Standalone audit assets
assets/audit/*
Static HTML/JS/CSS audit report and poster pages (CDN React+Babel), archetype catalog, tweaks-panel, poster export styles and scripts for offline/standalone use.
Tests, docs, config, deps
__tests__/*, CHANGELOG.md, docs/*, eslint.config.mjs, package.json
Added tests for cache, replay, archetypes/findings/strengths, and auth-store; updated CHANGELOG and docs for CLI auth and env vars; ESLint ignore expanded for assets; added runtime dependency html2canvas.

Estimated code review effort:
🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly Related PRs

"A rabbit hops to guard the gate,
Tokens safe and audits great,
Scores that shine and posters bloom,
Reminders set — no more doom,
Hooray! Your dashboard finds its fate."

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/policies/hooks-client.tsx (1)

1573-1584: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep the parent summary state in sync after configure-tab changes.

This effect seeds policyCounts and installedCliLabels once, but PoliciesTab can later install/remove CLIs and toggle policies. After those updates, the section meta and activity summary below keep showing the pre-change values until a full reload. Refresh these derived values from the same reload path you already use in PoliciesTab, or lift that summary state into a shared refresh function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/policies/hooks-client.tsx` around lines 1573 - 1584, The current
useEffect seeds policyCounts and installedCliLabels only once via
getHooksConfigAction(), so later changes in PoliciesTab are not reflected;
extract the fetch-and-set logic into a shared refresh function (e.g.,
fetchHooksConfig) that calls getHooksConfigAction() and then calls
setHooksInstalled, setPolicyCounts, and setInstalledCliLabels, update the
useEffect to call that function, and have PoliciesTab call the same
fetchHooksConfig after any CLI install/remove or policy toggle so the parent
summary state stays in sync.
🟠 Major comments (27)
app/api/auth/reminder/route.ts-94-96 (1)

94-96: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope reminder deletion to the active session.

DELETE unconditionally wipes next-audit.json. That means an anonymous caller, or a different signed-in user on the same host, can clear someone else's persisted reminder even though GET/POST are user-scoped.

Require whoAmI() here and only delete when the stored reminder belongs to that session; otherwise return 401/404.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/auth/reminder/route.ts` around lines 94 - 96, The DELETE handler
currently calls deleteReminder() unconditionally; change it to require whoAmI()
and scope deletion to the active session: call whoAmI() at the start of DELETE,
return 401 if it yields no authenticated session, fetch the stored reminder (the
same store used by GET/POST), verify the reminder’s owner/session id matches the
whoAmI() result, and only then call deleteReminder(); if no reminder exists or
it belongs to someone else return 404 (or 401 for unauthenticated), otherwise
return NextResponse.json({ ok: true }) after deletion. Use the existing function
names (DELETE, whoAmI, deleteReminder) to locate and update the logic.
src/auth/cli.ts-126-133 (1)

126-133: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't block --login on a stale auth.json.

readAuth() only proves the file exists. If the stored session is expired or already revoked, this early return forces the user to discover and run failproofai auth --logout before they can log back in.

Use whoAmI() or getValidAccessToken() before short-circuiting, and fall through to the login flow when the stored session can't be recovered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/auth/cli.ts` around lines 126 - 133, The early return in runLogin() uses
readAuth() to detect an existing auth file but doesn't verify that the session
is still valid; update runLogin() to validate the stored session (call whoAmI()
or getValidAccessToken() using the data returned by readAuth()) and only
short-circuit when the token is confirmed valid; if validation fails or throws,
fall through to the interactive login flow and remove the current early-return
behavior so stale/revoked tokens do not block --login.
lib/auth/auth-store.ts-64-247 (1)

64-247: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Add regression tests for the new persistence and refresh flows.

This file adds disk validation, refresh-on-expiry, refresh-on-401, and cross-user reminder scoping, but there’s no accompanying __tests__ coverage in this review set. These paths are stateful and easy to regress.

Based on learnings/coding guidelines: "Always add unit tests for new behaviour. Place tests in tests/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/auth/auth-store.ts` around lines 64 - 247, You need to add unit tests
under __tests__ covering the new persistence and refresh behaviours: write tests
for readReminder/writeReminder/deleteReminder and readAuth/writeAuth/deleteAuth
to validate on-disk validation and permission handling (use mocks/fs temp dirs),
tests for authFromTokenResponse and readAccessExpiry (decodeJwt) to assert
token→StoredAuth conversion and exp parsing, tests for getValidAccessToken to
exercise successful no-refresh, refresh-on-expiry, and refresh failure paths
(mock refreshAccessToken and AuthApiError), and tests for whoAmI to cover normal
fetchMe, refresh-on-401 retry (mock readAuth/refreshAccessToken/fetchMe) and
unrecoverable 401 leading to deleteAuth; place these tests in __tests__ and use
time mocking (Date.now), fs mocks or temp filesystem, and mocking of
refreshAccessToken/fetchMe to simulate network and error cases.
app/api/auth/reminder/route.ts-62-67 (1)

62-67: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject malformed JSON instead of silently scheduling the default reminder.

req.json().catch(() => ({})) makes an invalid body behave like an empty one, so a bad client request still writes a 7-day reminder. This should be a 400, not a successful mutation.

Treat only an actually empty body as "use defaults"; malformed JSON should fail validation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/auth/reminder/route.ts` around lines 62 - 67, Remove the silent
.catch that converts malformed JSON to {} and instead detect and reject invalid
JSON: read the raw request body via req.text(), if the text is empty or only
whitespace then set body = {} (use SetBody) to apply defaults; otherwise attempt
JSON.parse on the text (or call JSON.parse after text) and if parsing throws
return a 400 response. Update the code around the existing body/SetBody logic in
the route handler that currently does body = (await req.json().catch(() =>
({}))) as SetBody so malformed JSON results in a 400 while truly empty bodies
still use the default 7-day offset.
lib/auth/auth-store.ts-87-97 (1)

87-97: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make the shared JSON writes atomic.

These files are intentionally shared between the CLI and the dashboard, but both write paths replace the target file in place. A concurrent write or process crash can leave truncated JSON behind, which turns into a silent logout/reminder loss on the next read.

Suggested direction
+import { renameSync } from "node:fs";
+
+function writeJsonAtomically(path: string, value: unknown): void {
+  const tmp = `${path}.tmp`;
+  writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 });
+  try {
+    if (statSync(tmp).mode & 0o077) chmodSync(tmp, 0o600);
+  } catch {
+    // best-effort
+  }
+  renameSync(tmp, path);
+}
+
 export function writeReminder(reminder: StoredReminder): void {
   const p = getReminderFilePath();
   const dir = dirname(p);
   if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
-  writeFileSync(p, JSON.stringify(reminder, null, 2), { mode: 0o600 });
-  try {
-    if (statSync(p).mode & 0o077) chmodSync(p, 0o600);
-  } catch {
-    // best-effort
-  }
+  writeJsonAtomically(p, reminder);
 }
 
 export function writeAuth(auth: StoredAuth): void {
   const p = getAuthFilePath();
   const dir = dirname(p);
   if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
-  writeFileSync(p, JSON.stringify(auth, null, 2), { mode: 0o600 });
-  try {
-    if (statSync(p).mode & 0o077) chmodSync(p, 0o600);
-  } catch {
-    // best-effort
-  }
+  writeJsonAtomically(p, auth);
 }

Also applies to: 139-152

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/auth/auth-store.ts` around lines 87 - 97, The writeReminder function
performs in-place writes which can produce truncated JSON on concurrent writes
or crashes; change it (and any other file-writers in this module) to perform
atomic replace by writing JSON to a temp file in the same directory (use a
randomized suffix), set the temp file permissions to 0o600, fsync the temp file,
rename the temp file to the real path (atomic on POSIX), then fsync the
directory; keep the existing best-effort chmod/exists logic but ensure errors
are handled and cleaned up (unlink temp on error) so readers never see a
partially written file.
lib/auth/auth-store.ts-201-209 (1)

201-209: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't collapse transient upstream failures into "logged out".

Both refresh and /me verification return null for network/upstream errors, so /api/auth/status ends up reporting authenticated: false during transient outages. That turns a temporary auth-service problem into an anonymous state change in the UI.

A separate "unavailable" path here would let callers preserve the last known auth state instead of dropping the session on transport errors.

Also applies to: 223-240

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/auth/auth-store.ts` around lines 201 - 209, The current catch blocks in
the auth-store (which check for AuthApiError and call deleteAuth()) collapse
network/upstream errors into a "logged out" null return; instead, leave null
only for unrecoverable 401 cases and return a distinct "unavailable" signal for
transient failures so callers can preserve the last known session. Concretely:
in the catch blocks that reference AuthApiError and call deleteAuth(), keep the
deleteAuth()+null behavior for err instanceof AuthApiError && err.status ===
401, but for all other errors return or throw a dedicated marker (e.g.,
AuthUnavailable / throw new AuthUnavailableError) rather than null; apply this
change to both catch sites (the refresh/token path and the /me verification
path) and update consumers (e.g., getAuthStatus or the /api/auth/status handler)
to treat that marker as "service unavailable" instead of clearing the session.
lib/auth/api-server-client.ts-99-118 (1)

99-118: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a timeout to the shared upstream fetch helpers.

Both helpers can wait on fetch() indefinitely. In the CLI that can hang failproofai auth forever, and in the dashboard routes it can pin a request until the platform times it out.

Suggested fix
+const FETCH_TIMEOUT_MS = 10_000;
+
 async function postJson<T>(path: string, body: unknown, init?: { accessToken?: string }): Promise<T> {
   const headers: Record<string, string> = { "content-type": "application/json" };
   if (init?.accessToken) headers["authorization"] = `Bearer ${init.accessToken}`;
   const res = await fetch(`${getApiBase()}${path}`, {
     method: "POST",
     headers,
     body: JSON.stringify(body),
+    signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
   });
   if (res.status === 204) return undefined as T;
   if (!res.ok) throw await parseError(res);
   return (await res.json()) as T;
 }
 
 async function getJson<T>(path: string, accessToken: string): Promise<T> {
   const res = await fetch(`${getApiBase()}${path}`, {
     method: "GET",
     headers: { authorization: `Bearer ${accessToken}` },
+    signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
   });
   if (!res.ok) throw await parseError(res);
   return (await res.json()) as T;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/auth/api-server-client.ts` around lines 99 - 118, postJson and getJson
can hang indefinitely because fetch has no timeout; fix both by using an
AbortController: create an AbortController inside postJson and getJson, pass
controller.signal to fetch, start a timeout (configurable or default, e.g., 10s)
that calls controller.abort(), and clear the timeout after fetch completes;
ensure any aborts propagate as errors (so existing parseError handling still
runs) and that you pass the signal in the fetch init for functions postJson and
getJson.
src/audit/archetypes.ts-894-939 (1)

894-939: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add direct unit coverage for the classifier branches.

This introduces three distinct outcome rules plus the 40% secondary cutoff, but the supplied __tests__ changes don't exercise any of them. Please add table-driven cases for zero-signal → precision, broad-spread → goldfish, and secondary fallback vs. promotion so these thresholds stay stable.

As per coding guidelines "Always add unit tests for new behaviour. Place tests in tests/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/audit/archetypes.ts` around lines 894 - 939, Add direct unit tests for
classifyAgent to cover its three branching rules: create table-driven tests in
__tests__ that (1) pass an AuditResult with no signals (result.results empty or
hits=0) and assert archetype === "precision" and totalSignal === 0; (2) craft
results that map via SIGNAL_MAP to at least 5 non-zero archetypes with
top3Sum/totalSignal < 0.6 and assert archetype === "goldfish" and secondary
equals the highest-weighted archetype; and (3) exercise the 40% secondary cutoff
by building two cases where the second-highest weight is just above 40% of
primary (expect secondary promoted to sorted[1][0]) and just below 40% (expect
secondary === ARCHETYPES[primary].secondary); reference classifyAgent,
SIGNAL_MAP, ARCHETYPES and AuditResult when constructing those cases.
app/audit/_components/identity-section.tsx-43-47 (1)

43-47: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't claim all key policies are live for every A-tier result.

The positive branch keys off grade === "A", but A-tier audits can still have missing > 0. That makes the shared LinkedIn copy contradict the actual findings for some users.

📝 Suggested fix
 function buildLinkedInTemplate(score: number, archetypeName: string, grade: Grade, missing: number): string {
-  const verdict = (grade === "S" || grade === "A")
+  const verdict = missing === 0
     ? `${score}/100 — ${grade} tier. every key policy is live. the audit confirmed what good looks like.`
     : `${score}/100 — ${grade} tier. ${missing} prescribed polic${missing === 1 ? "y" : "ies"} uncovered — each one is a real attack surface.`;
   return `We ran a failproofai security audit on our AI agent stack.\n\n${verdict}\n\nArchetype: ${archetypeName.toLowerCase()}. failproofai maps your agent\'s behavior pattern, identifies the exposure, and prescribes the exact policies to close it.\n\nFree. Open-source. 30 seconds to run: ${SITE_URL}`;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/audit/_components/identity-section.tsx` around lines 43 - 47, The
LinkedIn copy in buildLinkedInTemplate incorrectly assumes all A-tier results
have no missing policies; change the conditional that builds `verdict` so that
the positive branch requires both `grade === "S" || grade === "A"` AND `missing
=== 0`, e.g., check `grade === "S" || (grade === "A" && missing === 0)`, so that
when `missing > 0` the template uses the negative branch which mentions
uncovered policies; update only the condition used to select the message (leave
the wording of each branch intact) to ensure the copy matches actual findings.
src/audit/scoring.ts-97-112 (1)

97-112: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Cap projected recovery with the same buckets as deriveScore.

projectedScore adds back raw hit weights, but deriveScore only ever subtracts up to 25/15/10 per bucket. Once a bucket is already capped, extra hits keep inflating the projection even though they never lowered the current score, so this can promise a much larger jump than enabling the policies can actually produce.

🔧 Suggested fix
 export function projectedScore(result: AuditResult, currentScore: number): number {
-  let recoverable = 0;
+  let denyRecoverable = 0;
+  let instructRecoverable = 0;
+  let sanitizeRecoverable = 0;
   for (const row of result.results) {
     if (row.source !== "builtin") continue;
     if (row.enabledInConfig) continue;
-    if (row.severity === "deny") recoverable += row.hits * 1.2;
-    else if (row.severity === "instruct" || row.severity === "warn") recoverable += row.hits * 0.7;
-    else recoverable += row.hits * 0.4;
+    if (row.severity === "deny") denyRecoverable += row.hits * 1.2;
+    else if (row.severity === "instruct" || row.severity === "warn") instructRecoverable += row.hits * 0.7;
+    else sanitizeRecoverable += row.hits * 0.4;
   }
-  const proj = Math.min(92, currentScore + Math.round(recoverable));
+  const recoverable =
+    Math.min(denyRecoverable, 25) +
+    Math.min(instructRecoverable, 15) +
+    Math.min(sanitizeRecoverable, 10);
+  const proj = Math.min(92, currentScore + Math.round(recoverable));
   return Math.max(currentScore, proj);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/audit/scoring.ts` around lines 97 - 112, projectedScore currently sums
weighted recoverable hits directly, which can exceed the per-severity caps used
by deriveScore; modify projectedScore to aggregate recoverable points by
severity (for builtin && !enabledInConfig rows) and apply the same per-bucket
caps as deriveScore (e.g., deny cap 25, instruct cap 15, warn/other cap 10)
before summing them, then proceed with Math.min(92, currentScore +
Math.round(cappedRecoverable)) and Math.max(currentScore, proj); update the loop
in projectedScore to compute per-severity totals, apply the caps, and then
combine.
src/audit/findings.ts-210-298 (1)

210-298: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add unit tests for detector mapping and card derivation.

This file adds ranking, detector-to-policy remapping, enabled-state logic, relative-time formatting, and fallback copy without any targeted unit coverage. A few focused tests in __tests__/audit/ would catch regressions here quickly.

As per coding guidelines, "Always add unit tests for new behaviour. Place tests in tests/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/audit/findings.ts` around lines 210 - 298, Add unit tests under
__tests__/audit/ that exercise deriveFindings and buildCard behavior: create
AuditResult fixtures with builtin rows and detector rows to verify sorting by
hits, detector-to-policy remapping via DETECTOR_TO_POLICY, that fix slug uses
mapping.primary when present, that POLICY_META fallbacks (displayTitle/impact)
and FINDING_COPY fallbacks are used for body/cost/desc, that alreadyEnabled
logic respects enabledSet and enabledInConfig, that evidence caps at 4 and adds
a "no example commands captured." comment when empty, and that lastSeen is
formatted via relTimeAgo; assert expected FindingCard fields (num, title
lowercased, install command `failproof policy add ${slug}`, projects, count, and
alsoCoveredBy) for each case.
src/audit/strengths.ts-39-50 (1)

39-50: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Clean-rate is computed from finding hits, not dirty tool calls.

Line 42 subtracts totals.hits from eventsScanned, but totals.hits increments once per policy/detector fire. A single tool call can contribute multiple hits, so this can understate cleanliness or clamp to 0% on mixed audits. Either aggregate a distinct eventsWithHits count upstream or stop labeling this as "clean tool calls".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/audit/strengths.ts` around lines 39 - 50, The cleanRate calculation is
wrong because totals.hits counts detector hits (can be many per event) but the
UI labels it as "clean tool calls"; update the code in the block around
variables events, totalHits, detectorsTriggered, cleanRate and the out.push call
so it either uses a distinct eventsWithHits count (if available upstream) to
compute cleanRate as (events - eventsWithHits)/events, or if that upstream count
is not available, stop implying per-tool-call cleanliness: compute a hit-based
rate (totalHits / events or hits-per-event) and change the unit/headline/detail
text in the out.push to reflect "hits" (e.g., "clean hits" or "hit-based clean
rate") instead of "clean tool calls". Ensure updates target the cleanRate
variable and the corresponding unit/headline/detail strings.
src/audit/strengths.ts-37-138 (1)

37-138: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add unit coverage for the new strengths derivation.

This module adds user-facing ranking and fallback logic, but there isn't a matching test suite for cases like the clean-rate headline, zero-hit gates, and the 5-item cap. Please add coverage under __tests__/audit/.

As per coding guidelines, "Always add unit tests for new behaviour. Place tests in tests/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/audit/strengths.ts` around lines 37 - 138, Add unit tests for
deriveStrengths to cover its new ranking and fallback logic: write tests under
__tests__/audit/ that call deriveStrengths with crafted AuditResult fixtures
exercising (1) the clean-rate headline when eventsScanned > 0 and
detectorsTriggered > 0, (2) the zero-credential gate by ensuring hitsForShort
returns 0 for credentialPolicies, (3) the retry/gitrewrite/wasteful-edit
zero-hit gates (use inputs that drive retryHits, gitHits, wastefulEdits to 0),
(4) average session length branches (avgTurns <15, between 15–29, and >=30), and
(5) the cap-to-5 behavior and the fallback “audit complete” entry when
out.length < 2. Use the deriveStrengths function from strengths.ts and create
minimal AuditResult fixtures (mock totals.hits, transcripts.scanned,
eventsScanned, results array) and assert Strength array contents (metrics,
headlines, units) for each scenario; mock or stub hitsForShort behavior if
needed to target specific policy groups.
src/audit/findings.ts-290-293 (1)

290-293: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the real CLI install command in the fix CTA.

The finding cards currently emit failproof policy add ..., but the rest of the audit flow uses failproofai policies --install .... Copying this from the dashboard will send users to a different command surface than the one exposed elsewhere in this PR.

💡 Proposed fix
-      install: `failproof policy add ${fixSlug}`,
+      install: `failproofai policies --install ${fixSlug}`,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/audit/findings.ts` around lines 290 - 293, The fix object's install CTA
currently uses the wrong CLI; update the install string in the fix block (where
fix: { slug: fixSlug, desc: fixDesc, install: ... } is defined) to use the
consistent CLI command used elsewhere: `failproofai policies --install
${fixSlug}` so the generated card copies the same command surface as the rest of
the audit flow.
src/audit/dashboard-cache.ts-40-48 (1)

40-48: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Invalidate old or null cache shapes before returning them.

The current guard accepts params: null, result: null, and any historical AuditResult version. After this schema bump, an existing on-disk v1 cache will still be returned even though it lacks projectsScanned, eventsScanned, and enabledBuiltinNames, which can break the dashboard until the user re-runs the audit.

💡 Proposed fix
     const raw = readFileSync(cachePath, "utf-8");
     const entry = JSON.parse(raw) as DashboardCacheEntry;
     if (
       typeof entry?.cachedAt !== "string"
-      || typeof entry?.params !== "object"
-      || typeof entry?.result !== "object"
+      || !entry?.params
+      || typeof entry.params !== "object"
+      || !entry?.result
+      || typeof entry.result !== "object"
+      || entry.result.version !== 2
+      || !Array.isArray(entry.result.projectsScanned)
+      || typeof entry.result.eventsScanned !== "number"
+      || !Array.isArray(entry.result.enabledBuiltinNames)
     ) {
       return null;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/audit/dashboard-cache.ts` around lines 40 - 48, The guard in
dashboard-cache.ts currently accepts null or old-version shapes; update the
validation after JSON.parse (the const entry = JSON.parse(raw) as
DashboardCacheEntry line) to return null if entry.params or entry.result are
null or not plain objects, and also verify required v2 AuditResult fields exist
and have correct types (e.g., entry.result.projectsScanned is a number,
entry.result.eventsScanned is a number, and entry.result.enabledBuiltinNames is
an array). Keep the cachedAt string check, and ensure any
missing/incorrectly-typed required fields cause the function to return null so
only up-to-date DashboardCacheEntry shapes are returned.
app/api/audit/run/route.ts-50-78 (1)

50-78: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add route tests for the new run contract.

This endpoint adds important behavior with a few edge paths: malformed-but-valid JSON (null), 409 when a run is already active, and the success path that writes cache. Please cover those under __tests__/ so regressions in the rerun flow get caught early. As per coding guidelines, "Always add unit tests for new behaviour. Place tests in tests/. "

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/audit/run/route.ts` around lines 50 - 78, Add unit tests under
__tests__/ for the POST route behavior in route.ts: cover the
malformed-but-valid JSON case (body "null") returning 400, the concurrency case
when tryAcquireRun() returns false producing a 409 with { status:
"already-running" }, and the successful path where sanitize(body) is used,
runAudit(opts) resolves and writeDashboardCache(opts, result) is called and the
response is { status: "ok", result }; mock/stub tryAcquireRun, runAudit,
writeDashboardCache, and releaseRun to assert they are invoked appropriately and
that releaseRun() runs in finally.
app/audit/_components/rerun-button.tsx-46-79 (1)

46-79: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't treat failed reruns as completed reruns.

triggerRun() resolves on POST failures, network failures, and poll timeout, so callers can't distinguish success from failure. In ReturnSection that means a stale page reload still happens after a failed rerun. Return an explicit status or throw so the caller only runs its completion path when a scan actually finished.

Proposed fix
-export async function triggerRun(scanParams: ScanParams): Promise<void> {
+export async function triggerRun(scanParams: ScanParams): Promise<boolean> {
   try {
     const res = await fetch("/api/audit/run", {
       method: "POST",
       headers: { "content-type": "application/json" },
       body: JSON.stringify(paramsToBody(scanParams)),
     });
     if (!res.ok && res.status !== 409) {
       const text = await res.text().catch(() => "");
       console.error("audit run failed:", res.status, text);
-      return;
+      return false;
     }
   } catch (err) {
     console.error("audit run request failed:", err);
-    return;
+    return false;
   }

   const startedAt = Date.now();
   while (Date.now() - startedAt < MAX_POLL_MS) {
     await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
     try {
       const sres = await fetch("/api/audit/status", { cache: "no-store" });
       if (!sres.ok) continue;
       const s = await sres.json() as { running: boolean };
-      if (!s.running) return;
+      if (!s.running) return true;
     } catch {
       // Transient — keep polling.
     }
   }
+
+  return false;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/audit/_components/rerun-button.tsx` around lines 46 - 79, triggerRun
currently resolves on POST failures, network errors, and poll timeouts so
callers (e.g., ReturnSection) can't tell success from failure; change triggerRun
to throw on any POST non-OK (except allowed 409) and on network/fetch errors and
to throw if polling exceeds MAX_POLL_MS without seeing running flip to false,
and only resolve (return) when the poll observes the scan finished; include
response text or error details in thrown Error messages (reference triggerRun,
paramsToBody, /api/audit/run, /api/audit/status, MAX_POLL_MS, POLL_INTERVAL_MS)
so callers can run completion logic only on true success.
app/api/audit/run/route.ts-51-59 (1)

51-59: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject non-object JSON bodies before calling sanitize().

JSON.parse("null") succeeds, so body becomes null here and sanitize(body) throws on Line 59. That turns a bad request into a 500. Validate that the parsed payload is a plain object and return 400 otherwise.

Proposed fix
 export async function POST(request: NextRequest): Promise<NextResponse> {
   let body: RunBody = {};
   try {
     const raw = await request.text();
-    if (raw) body = JSON.parse(raw) as RunBody;
+    if (raw) {
+      const parsed: unknown = JSON.parse(raw);
+      if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+        return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+      }
+      body = parsed as RunBody;
+    }
   } catch {
     return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/audit/run/route.ts` around lines 51 - 59, The parsed JSON may be
non-object (e.g., null or an array) which causes sanitize(body) to throw; after
parsing the request text (the result of request.text() and JSON.parse), validate
that the parsed value is a plain non-null object (typeof parsed === "object" &&
parsed !== null && !Array.isArray(parsed)) before assigning to body and calling
sanitize(body); if the check fails, return NextResponse.json({ error: "Invalid
JSON body" }, { status: 400 }) so sanitize() only receives a proper object
(refer to the RunBody variable, the local body/parsing logic, and the sanitize()
call).
app/api/audit/_state.ts-21-39 (1)

21-39: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Move the run lock out of module memory.

app/api/audit/_state.ts keeps the guard in module-level in-memory variables, so it only coordinates within a single Node.js process/worker. If your deployment runs multiple replicas/processes, /api/audit/run can acquire the lock in one worker while /api/audit/status polls another (or another run is accepted), making running/concurrency incorrect. Use a shared/distributed lock+status store (e.g., Redis/DB with atomic acquire and TTL) instead of module memory.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/audit/_state.ts` around lines 21 - 39, The module-level lock
represented by state and the functions tryAcquireRun, releaseRun, and
getRunState must be replaced with a distributed lock/status stored in a shared
system (e.g., Redis or DB): change tryAcquireRun to perform an atomic acquire
(e.g., Redis SET key value NX EX ttl) and store a unique owner token and
startedAt in the shared store returning true only on success, change releaseRun
to release only if the owner token matches (to avoid deleting another process's
lock), and change getRunState to read running and startedAt from the shared
store; ensure TTL is used to avoid stuck locks and persist startedAt alongside
the lock.
app/audit/_components/run-progress.tsx-29-37 (1)

29-37: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the progress UI below “complete” until the run actually finishes.

This animation hits 4/4 and 100% after 16 seconds, but the copy says runs can take up to ~30 seconds. On slower audits the screen looks done while the request is still pending, which reads like a hang.

💡 One way to avoid the false-complete state
 export function RunProgress() {
   const [stage, setStage] = useState(0);
   const [tick, setTick] = useState(0);
+  const atLastStage = stage === STAGES.length - 1;
+  const progressPct = atLastStage ? 90 : ((stage + 1) / STAGES.length) * 100;

   useEffect(() => {
@@
         <div className="running-bar-label">
           <span>progress</span>
-          <span style={{ color: "var(--dim)" }}>{stage + 1}/{STAGES.length}</span>
+          <span style={{ color: "var(--dim)" }}>
+            {atLastStage ? "finishing up" : `${stage + 1}/${STAGES.length}`}
+          </span>
         </div>
         <div className="running-bar-track">
           <div
             className="running-bar-fill"
-            style={{ width: `${((stage + 1) / STAGES.length) * 100}%` }}
+            style={{ width: `${progressPct}%` }}
           />
         </div>

Also applies to: 88-96

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/audit/_components/run-progress.tsx` around lines 29 - 37, The progress UI
currently advances through all STAGES based purely on STAGE_DURATION_MS inside
the useEffect (timers updating setStage and setTick), causing it to reach the
final "complete" stage before the audit actually finishes; modify the effect to
cap stage progression at STAGES.length - 2 (or otherwise prevent reaching the
final index) while the run is still pending, and only allow setStage to advance
to STAGES.length - 1 when a real completion flag (e.g., an isFinished/isComplete
prop or state tied to the audit request) is true; keep the existing tick timer
behavior but ensure the stageTimer callback checks that completion flag before
calling setStage((s) => Math.min(s + 1, STAGES.length - 1)) so the UI only shows
100% when the run has truly finished.
app/audit/_components/policies-section.tsx-63-110 (1)

63-110: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add unit coverage for this policy derivation.

This mapping/aggregation logic now defines the report output, but the supplied tests only cover cache/replay behavior. Please add cases for detector mapping, enabled-policy exclusion, and multi-source aggregation under __tests__/audit/. As per coding guidelines, "Always add unit tests for new behaviour. Place tests in tests/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/audit/_components/policies-section.tsx` around lines 63 - 110, Add unit
tests for buildPolicyCards in a new test under __tests__/audit/ that exercise
detector-to-primary-policy mapping, exclusion when a policy is already enabled,
and aggregation from multiple sources: construct AuditResult objects with rows
where source === "audit-detector" that map via DETECTOR_TO_PRIMARY_POLICY (use
shortName(row.name) keys), rows with source === "builtin" and both
enabledInConfig true/false to verify enabled ones are skipped, and multiple rows
that target the same policy to confirm hits/projects/sources aggregate into one
PolicyCard; assert the returned PolicyCard array (from buildPolicyCards)
contains expected name, hits, projects, desc (from POLICY_DESC fallback), and
that the catches string includes the via list when applicable. Ensure tests
import buildPolicyCards, DETECTOR_TO_PRIMARY_POLICY, shortName and use
enabledBuiltinNames in AuditResult to cover exclusion behavior.
app/audit/_components/policies-section.tsx-152-178 (1)

152-178: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the actual CLI binary in the copied install command.

This builds failproof policy add …, but the rest of this PR surfaces the CLI as failproofai. Copying the current string will hand users a command that doesn't match the shipped binary.

🔧 Proposed fix
-  const install = `failproof policy add ${policy.name}`;
+  const install = `failproofai policy add ${policy.name}`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/audit/_components/policies-section.tsx` around lines 152 - 178, The
install string in PolicyTile is using the wrong CLI binary name; update the
install template in the PolicyTile component (where install is defined) from
"failproof policy add ${policy.name}" to "failproofai policy add ${policy.name}"
so the copied command matches the shipped binary, ensuring handleCopy still
writes the updated install variable to the clipboard.
assets/audit/poster.jsx-93-100 (1)

93-100: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the current poster params when linking back to the audit report.

Both back links drop the active a/s/g/r/c/p query string, so a poster opened directly or shared externally can't reconstruct the same audit view and falls back to defaults instead.

🩹 Suggested direction
 function Poster() {
+  const auditHref = "Audit Report.html" + window.location.search;
   const key = getParam("a", "optimist");
@@
-    window.location.href = "Audit Report.html";
+    window.location.href = auditHref;
@@
-          <a className="poster-back" href="Audit Report.html" onClick={handleBack}>
+          <a className="poster-back" href={auditHref} onClick={handleBack}>
@@
-          <a href="Audit Report.html" style={{ color: "var(--ink-2)" }}>view full audit →</a>
+          <a href={auditHref} style={{ color: "var(--ink-2)" }}>view full audit →</a>

Also applies to: 107-107, 239-239

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@assets/audit/poster.jsx` around lines 93 - 100, The back-link handler
handleBack currently navigates to "Audit Report.html" without preserving poster
query params (a/s/g/r/c/p); update handleBack (and the other back-link handlers
referenced) to capture the current window.location.search (or filter and rebuild
a search string keeping only keys a, s, g, r, c, p) and append it to "Audit
Report.html" (e.g., "Audit Report.html" + "?" + preservedSearch) before setting
window.location.href, and ensure e.preventDefault() remains when redirecting.
assets/audit/archetypes.jsx-246-266 (1)

246-266: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the Sigil fallback consistent.

Line 247 falls back to SIGILS.optimist, but Line 265 still reads ARCHETYPES[archetypeKey].index directly. Any unknown archetypeKey will still crash render even though the grid fallback succeeded.

🩹 Proposed fix
 function Sigil({ archetypeKey }) {
-  const grid = SIGILS[archetypeKey] || SIGILS.optimist;
+  const archetype = ARCHETYPES[archetypeKey] || ARCHETYPES.optimist;
+  const grid = SIGILS[archetype.key] || SIGILS.optimist;
   const cells = [];
   for (let y = 0; y < 8; y++) {
     const row = grid[y] || "........";
@@
       <div className="sigil">{cells}</div>
       <div className="sigil-label">
-        <span className="ix">№{ARCHETYPES[archetypeKey].index}</span>
+        <span className="ix">№{archetype.index}</span>
         sigil
       </div>
     </div>
   );
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@assets/audit/archetypes.jsx` around lines 246 - 266, The Sigil component uses
SIGILS[archetypeKey] with a fallback but still accesses
ARCHETYPES[archetypeKey].index directly which can crash for unknown keys; update
Sigil to compute a single safe fallback (e.g., const archetype =
ARCHETYPES[archetypeKey] || ARCHETYPES.optimist and const grid =
SIGILS[archetypeKey] || SIGILS.optimist) and then use archetype.index and any
archetype properties for the label instead of indexing ARCHETYPES again with
archetypeKey so both grid and metadata consistently use the fallback.
assets/audit/audit.jsx-18-26 (1)

18-26: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Sanitize numeric query params before storing defaults.

Lines 20-22 accept parseInt(...) results verbatim, so malformed ?s=, ?r=, or ?c= values become NaN and then flow into score math, toLocaleString(), and rank/percentile copy across the page. This is user-reachable from the URL.

🩹 Suggested direction
+function getIntParam(name, fallback, { min, max } = {}) {
+  const raw = Number.parseInt(getParam(name, String(fallback)), 10);
+  if (!Number.isFinite(raw)) return fallback;
+  const lower = min ?? raw;
+  const upper = max ?? raw;
+  return Math.min(upper, Math.max(lower, raw));
+}
+
 const REPORT_DEFAULTS = /*EDITMODE-BEGIN*/{
   "archetype": getParam("a", "optimist"),
-  "score": parseInt(getParam("s", "58"), 10),
-  "rank": parseInt(getParam("r", "1847"), 10),
-  "cohort": parseInt(getParam("c", "2316"), 10),
+  "score": getIntParam("s", 58, { min: 0, max: 100 }),
+  "rank": getIntParam("r", 1847, { min: 1 }),
+  "cohort": getIntParam("c", 2316, { min: 1 }),
   "tweetVariant": "show-off",
   "showSecondary": true,
   "project": getParam("p", "blrnow / api-coder")
 }/*EDITMODE-END*/;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@assets/audit/audit.jsx` around lines 18 - 26, REPORT_DEFAULTS currently
assigns score, rank, and cohort using parseInt(getParam(...)) directly which can
yield NaN from malformed query params; update the initialization in
REPORT_DEFAULTS so that for each numeric field (score, rank, cohort) you parse
the param (using parseInt(..., 10) as already done), then validate the result
(Number.isFinite or !Number.isNaN) and if invalid fall back to the original
hardcoded defaults (58, 1847, 2316 respectively); reference the REPORT_DEFAULTS
object and getParam usages so the fallback logic is applied inline where those
values are set.
assets/audit/tweaks-panel.jsx-192-200 (1)

192-200: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate the postMessage sender before accepting edit-mode commands.

assets/audit/tweaks-panel.jsx toggles the panel based solely on e.data.type (__activate_edit_mode / __deactivate_edit_mode) with no e.source/e.origin checks, so any frame can drive the host edit-mode UI. Gate on the expected sender at minimum (e.source === window.parent), and validate e.origin if the embed origin is known.

🩹 Proposed fix
   React.useEffect(() => {
     const onMsg = (e) => {
+      if (e.source !== window.parent) return;
       const t = e?.data?.type;
       if (t === '__activate_edit_mode') setOpen(true);
       else if (t === '__deactivate_edit_mode') setOpen(false);
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@assets/audit/tweaks-panel.jsx` around lines 192 - 200, The message handler
inside the React.useEffect (onMsg) currently toggles setOpen based only on
e.data.type; update onMsg to first verify the sender by checking e.source ===
window.parent and, if you know the embed origin, also validate e.origin against
that expected origin before acting on __activate_edit_mode /
__deactivate_edit_mode; keep the window.parent.postMessage call but if an
expected origin is available use it instead of '*' and ensure the cleanup still
removes the same onMsg listener.
assets/audit/poster.jsx-85-90 (1)

85-90: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix async clipboard copy success state and preserve poster query params when navigating back

  • assets/audit/poster.jsx (lines 85-91): navigator.clipboard.writeText(...) is Promise-based; the current try/catch won’t catch permission/insecure-context failures, yet the UI still flips to [ link copied ]. Make the handler async and only set setCopied(true) after a successful await, and handle failures.
🩹 Proposed fix
-  const handleCopyLink = () => {
+  const handleCopyLink = async () => {
     try {
-      navigator.clipboard.writeText(window.location.href);
+      await navigator.clipboard.writeText(window.location.href);
       setCopied(true);
       setTimeout(() => setCopied(false), 1600);
-    } catch (e) {}
+    } catch (e) {
+      setCopied(false);
+    }
   };
- `assets/audit/poster.jsx` (lines 93-100, link at 107, footer link at 239): navigating to `"Audit Report.html"` drops `window.location.search`, so the shared poster state (`a/s/g/r/c/p`) isn’t preserved. Append the current `window.location.search` to the destination (and reuse the same navigation logic for the footer link too).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@assets/audit/poster.jsx` around lines 85 - 90, The handleCopyLink handler
currently calls navigator.clipboard.writeText(...) without awaiting the Promise
and flips UI state prematurely; make handleCopyLink async, await
navigator.clipboard.writeText(window.location.href) inside a try/catch, call
setCopied(true) only after a successful await and handle/log failures in the
catch (and still avoid unhandled rejections), and keep the setTimeout to clear
the state; additionally, when building navigation targets to "Audit Report.html"
(the link at the in-file link and the footer link), preserve poster query params
by appending window.location.search to the destination URL (reuse the same
query-appending logic for both the inline link and the footer link so the shared
poster state a/s/g/r/c/p is retained).
🧹 Nitpick comments (1)
app/audit/_components/policies-section.tsx (1)

28-39: ⚡ Quick win

Share the detector→policy map instead of mirroring it.

Keeping a second hard-coded copy here can drift from src/audit/findings.ts, which would make the Findings and Policies sections disagree for the same detector. Export the mapping from one place or move it into shared audit metadata.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/audit/_components/policies-section.tsx` around lines 28 - 39, The local
DETECTOR_TO_PRIMARY_POLICY constant in policies-section.tsx is a duplicate of
the mapping in src/audit/findings.ts (DETECTOR_TO_POLICY); remove the hard-coded
copy and instead re-export or share the single source of truth: either export
DETECTOR_TO_PRIMARY_POLICY (or alias DETECTOR_TO_POLICY) from findings.ts or
move the mapping into a new shared audit metadata module and import it into
policies-section.tsx and findings.ts; update the import in
app/audit/_components/policies-section.tsx to use the shared export and delete
the local const to avoid drift between Findings and Policies.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 64607466-755d-46c1-b890-734e8f797f75

📥 Commits

Reviewing files that changed from the base of the PR and between 1a37c48 and 356bd17.

⛔ Files ignored due to path filters (12)
  • app/icon.png is excluded by !**/*.png
  • assets/audit/assets/fonts/architype-stedelijk.ttf is excluded by !**/*.ttf
  • assets/audit/assets/fonts/architype-stedelijk.woff2 is excluded by !**/*.woff2
  • assets/audit/screenshots/poster-optimist.png is excluded by !**/*.png
  • assets/audit/screenshots/poster-scrolled.png is excluded by !**/*.png
  • assets/logos/company/icon.svg is excluded by !**/*.svg
  • assets/logos/company/logo.svg is excluded by !**/*.svg
  • bun.lock is excluded by !**/*.lock
  • public/audit/fonts/architype-stedelijk.ttf is excluded by !**/*.ttf
  • public/audit/fonts/architype-stedelijk.woff2 is excluded by !**/*.woff2
  • public/icon.svg is excluded by !**/*.svg
  • public/logo.svg is excluded by !**/*.svg
📒 Files selected for processing (62)
  • CHANGELOG.md
  • __tests__/audit/dashboard-cache.test.ts
  • __tests__/audit/replay.test.ts
  • app/actions/get-audit-result.ts
  • app/api/audit/_state.ts
  • app/api/audit/run/route.ts
  • app/api/audit/status/route.ts
  • app/api/auth/login-request/route.ts
  • app/api/auth/login-verify/route.ts
  • app/api/auth/logout/route.ts
  • app/api/auth/reminder/route.ts
  • app/api/auth/status/route.ts
  • app/audit/_components/audit-dashboard.tsx
  • app/audit/_components/auth-dialog.tsx
  • app/audit/_components/empty-state.tsx
  • app/audit/_components/findings-section.tsx
  • app/audit/_components/identity-section.tsx
  • app/audit/_components/policies-section.tsx
  • app/audit/_components/report-footer.tsx
  • app/audit/_components/rerun-button.tsx
  • app/audit/_components/return-section.tsx
  • app/audit/_components/run-progress.tsx
  • app/audit/_components/score-section.tsx
  • app/audit/_components/show-off-cta.tsx
  • app/audit/_components/sigil.tsx
  • app/audit/_components/strengths-section.tsx
  • app/audit/audit-styles.css
  • app/audit/loading.tsx
  • app/audit/page.tsx
  • app/globals.css
  • app/layout.tsx
  • app/policies/hooks-client.tsx
  • app/projects/loading.tsx
  • app/projects/page.tsx
  • assets/audit/Audit Report.html
  • assets/audit/Show Off Your Agent.html
  • assets/audit/archetypes.jsx
  • assets/audit/audit.jsx
  • assets/audit/poster-styles.css
  • assets/audit/poster.jsx
  • assets/audit/styles.css
  • assets/audit/tweaks-panel.jsx
  • bin/failproofai.mjs
  • components/navbar.tsx
  • components/reach-developers.tsx
  • docs/cli/auth.mdx
  • docs/cli/environment-variables.mdx
  • docs/dashboard.mdx
  • eslint.config.mjs
  • lib/auth/api-server-client.ts
  • lib/auth/auth-store.ts
  • package.json
  • src/audit/archetypes.ts
  • src/audit/dashboard-cache.ts
  • src/audit/findings.ts
  • src/audit/index.ts
  • src/audit/replay.ts
  • src/audit/scoring.ts
  • src/audit/strengths.ts
  • src/audit/types.ts
  • src/auth/cli.ts
  • src/hooks/policy-registry.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/auth/cli.ts`:
- Around line 124-128: The ANSI color constants DIM, RESET, PINK, GREEN, and RED
are missing the ESC prefix so they print raw text; update each constant (DIM,
RESET, PINK, GREEN, RED in the cli.ts snippet) to include the ESC character
(e.g., "\x1b" or "\u001b") before the bracket so the sequences become "\x1b[2m",
"\x1b[0m", "\x1b[38;5;204m", etc., ensuring proper terminal formatting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5a46601e-7489-4bf9-b4ef-04bb25463922

📥 Commits

Reviewing files that changed from the base of the PR and between 356bd17 and 1884dda.

📒 Files selected for processing (4)
  • app/api/auth/status/route.ts
  • app/audit/_components/return-section.tsx
  • bin/failproofai.mjs
  • src/auth/cli.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/audit/_components/return-section.tsx

Comment thread src/auth/cli.ts Outdated
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 2, 2026

An unexpected error occurred while resolving merge conflicts:

Not Found - https://docs.github.com/rest/git/refs#get-a-reference

SiddarthAA and others added 3 commits June 4, 2026 01:13
…heduler

Add posthog tracking across login-request, login-verify, logout, and reminder
routes plus the auth CLI; forward dashboard reminder set/clear to the
api-server scheduler via new scheduleReminder/cancelReminder client helpers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…, sized to text via em, opacity+box-shadow dim/glow loop).

- app/policies/hooks-client.tsx — removed the ━━ policies · live evaluation ● evaluating in real time masthead row; heading on the activity tab is now Policies + glowing dot.
- app/projects/page.tsx and app/projects/loading.tsx — removed the ━━ projects · agent SDK folders ● N folders indexed row; heading is now Projects + glowing dot.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/audit/_components/return-section.tsx (1)

117-123: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle unauthorized reminder writes by reconciling local auth state.

persistReminder() treats all non-OK responses as a silent null and keeps the current auth UI unchanged. On 401, users can stay on the authed panel even though the session is gone, and “set a reminder” appears broken.

Suggested patch
   const persistReminder = useCallback(async (): Promise<Reminder | null> => {
     try {
       setReminderBusy(true);
       const res = await fetch("/api/auth/reminder", {
         method: "POST",
         headers: { "content-type": "application/json" },
         body: JSON.stringify({ in_days: DEFAULT_REMINDER_DAYS }),
       });
-      if (!res.ok) return null;
+      if (!res.ok) {
+        if (res.status === 401) {
+          setAuthStatus({ kind: "anon" });
+          setReminder(null);
+        }
+        capture("audit_reminder_saved", {
+          status: `http_${res.status}`,
+          source: "return_section",
+        });
+        return null;
+      }
       const body = (await res.json()) as { reminder?: Reminder };
       capture("audit_reminder_saved", {
         status: body.reminder ? "success" : "empty",
         source: "return_section",
       });
       return body.reminder ?? null;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/audit/_components/return-section.tsx` around lines 117 - 123,
persistReminder() currently treats any non-OK response from
fetch("/api/auth/reminder") as a silent null; update it to detect a 401 response
and reconcile local auth state instead of leaving the UI in an authed state.
Specifically, after performing the fetch with DEFAULT_REMINDER_DAYS, check
res.status === 401 and in that branch invoke the existing session/ sign-out
handler (e.g., call your
signOut()/handleSignOut()/setAuthed(false)/revalidateSession function used
elsewhere) to flip the UI back to unauthenticated; keep the existing behavior
for other non-OK statuses (return null) and still parse body = await res.json()
for OK responses. Ensure you reference the persistReminder() function and the
fetch("/api/auth/reminder") call when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/api/auth/login-verify/route.ts`:
- Around line 45-50: The telemetry call uses raw PII via tokens.user.email;
change trackEvent usage in the code that calls
trackEvent("audit_user_identity_linked", ...) to stop sending the raw email —
either remove the user_email field or replace it with a non-reversible
identifier (e.g., SHA-256 hash of tokens.user.email or only the email domain)
computed before calling trackEvent; keep user_id (tokens.user.id) and
local_random_id (getInstanceId()) as-is and ensure the hashed/anonymized value
is named clearly (e.g., user_email_hash) so consumers know it is not raw PII.
- Line 41: The call to initTelemetry placed outside the guarded try block can
throw and convert a valid OTP verification into a 500; wrap the telemetry
initialization so it cannot bubble up—either move await initTelemetry() inside
the existing try that surrounds the login verification logic or call
initTelemetry() inside its own try/catch and swallow/log errors via the
telemetry/logger, ensuring the OTP verification code (the route handler)
continues executing even if initTelemetry fails.

In `@bin/failproofai.mjs`:
- Line 640: The closest-match suggestion list is missing the supported top-level
command "policy"; update the primary command array (const primary) to include
"policy" alongside the existing entries (e.g., "--version", "--help", "--hook",
"policies", "auth") so typo suggestions can match "policy" correctly.

In `@lib/auth/api-server-client.ts`:
- Around line 166-170: The cancelReminder function calls fetch without a
timeout, which can hang; modify cancelReminder to use an AbortController: create
a controller, start a timer (e.g. 5s or configurable) that calls
controller.abort(), pass controller.signal into
fetch(`${getApiBase()}/v0/reminders`, ...), and clear the timer after fetch
resolves; ensure any thrown AbortError is propagated/handled as appropriate so
callers of cancelReminder receive a timely failure instead of hanging.

---

Outside diff comments:
In `@app/audit/_components/return-section.tsx`:
- Around line 117-123: persistReminder() currently treats any non-OK response
from fetch("/api/auth/reminder") as a silent null; update it to detect a 401
response and reconcile local auth state instead of leaving the UI in an authed
state. Specifically, after performing the fetch with DEFAULT_REMINDER_DAYS,
check res.status === 401 and in that branch invoke the existing session/
sign-out handler (e.g., call your
signOut()/handleSignOut()/setAuthed(false)/revalidateSession function used
elsewhere) to flip the UI back to unauthenticated; keep the existing behavior
for other non-OK statuses (return null) and still parse body = await res.json()
for OK responses. Ensure you reference the persistReminder() function and the
fetch("/api/auth/reminder") call when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4014e35f-dc78-4190-a266-b0502440d871

📥 Commits

Reviewing files that changed from the base of the PR and between 1884dda and 4bcd6eb.

📒 Files selected for processing (23)
  • CHANGELOG.md
  • app/api/auth/login-request/route.ts
  • app/api/auth/login-verify/route.ts
  • app/api/auth/logout/route.ts
  • app/api/auth/reminder/route.ts
  • app/audit/_components/audit-dashboard.tsx
  • app/audit/_components/empty-state.tsx
  • app/audit/_components/findings-section.tsx
  • app/audit/_components/identity-section.tsx
  • app/audit/_components/policies-section.tsx
  • app/audit/_components/return-section.tsx
  • app/audit/_components/show-off-cta.tsx
  • app/globals.css
  • app/policies/hooks-client.tsx
  • app/projects/loading.tsx
  • app/projects/page.tsx
  • bin/failproofai.mjs
  • components/navbar.tsx
  • docs/cli/audit.mdx
  • lib/auth/api-server-client.ts
  • src/audit/index.ts
  • src/audit/telemetry.ts
  • src/auth/cli.ts
💤 Files with no reviewable changes (2)
  • src/audit/telemetry.ts
  • src/audit/index.ts
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (10)
  • app/api/auth/login-request/route.ts
  • app/audit/_components/empty-state.tsx
  • app/api/auth/logout/route.ts
  • app/audit/_components/policies-section.tsx
  • app/policies/hooks-client.tsx
  • app/api/auth/reminder/route.ts
  • app/globals.css
  • src/auth/cli.ts
  • app/audit/_components/identity-section.tsx
  • app/audit/_components/show-off-cta.tsx

{ status: 400 },
);
}
await initTelemetry();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t let telemetry initialization break login verification.

Line 41 is outside the guarded try, so telemetry init errors can turn a valid OTP flow into a 500.

Suggested fix
-  await initTelemetry();
+  try {
+    await initTelemetry();
+  } catch {
+    // best-effort telemetry: never block auth
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await initTelemetry();
try {
await initTelemetry();
} catch {
// best-effort telemetry: never block auth
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/auth/login-verify/route.ts` at line 41, The call to initTelemetry
placed outside the guarded try block can throw and convert a valid OTP
verification into a 500; wrap the telemetry initialization so it cannot bubble
up—either move await initTelemetry() inside the existing try that surrounds the
login verification logic or call initTelemetry() inside its own try/catch and
swallow/log errors via the telemetry/logger, ensuring the OTP verification code
(the route handler) continues executing even if initTelemetry fails.

Comment on lines +45 to +50
trackEvent("audit_user_identity_linked", {
source: "audit_set_reminder_auth_dialog",
user_id: tokens.user.id,
user_email: tokens.user.email,
local_random_id: getInstanceId(),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid sending raw user email in telemetry payloads.

Line 48 emits user_email to analytics. That’s direct PII and increases privacy/compliance exposure without being required for event correlation.

Suggested fix
     trackEvent("audit_user_identity_linked", {
       source: "audit_set_reminder_auth_dialog",
       user_id: tokens.user.id,
-      user_email: tokens.user.email,
       local_random_id: getInstanceId(),
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
trackEvent("audit_user_identity_linked", {
source: "audit_set_reminder_auth_dialog",
user_id: tokens.user.id,
user_email: tokens.user.email,
local_random_id: getInstanceId(),
});
trackEvent("audit_user_identity_linked", {
source: "audit_set_reminder_auth_dialog",
user_id: tokens.user.id,
local_random_id: getInstanceId(),
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/auth/login-verify/route.ts` around lines 45 - 50, The telemetry call
uses raw PII via tokens.user.email; change trackEvent usage in the code that
calls trackEvent("audit_user_identity_linked", ...) to stop sending the raw
email — either remove the user_email field or replace it with a non-reversible
identifier (e.g., SHA-256 hash of tokens.user.email or only the email domain)
computed before calling trackEvent; keep user_id (tokens.user.id) and
local_random_id (getInstanceId()) as-is and ensure the hashed/anonymized value
is named clearly (e.g., user_email_hash) so consumers know it is not raw PII.

Comment thread bin/failproofai.mjs
}

const primary = ["--version", "--help", "--hook", "policies", "audit"];
const primary = ["--version", "--help", "--hook", "policies", "auth"];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Include policy in closest-match suggestions.

Line 640 omits a supported top-level command (policy), so typo suggestions can steer users to the wrong command.

Suggested fix
-    const primary = ["--version", "--help", "--hook", "policies", "auth"];
+    const primary = ["--version", "--help", "--hook", "policies", "policy", "auth"];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const primary = ["--version", "--help", "--hook", "policies", "auth"];
const primary = ["--version", "--help", "--hook", "policies", "policy", "auth"];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/failproofai.mjs` at line 640, The closest-match suggestion list is
missing the supported top-level command "policy"; update the primary command
array (const primary) to include "policy" alongside the existing entries (e.g.,
"--version", "--help", "--hook", "policies", "auth") so typo suggestions can
match "policy" correctly.

Comment thread lib/auth/api-server-client.ts
SiddarthAA and others added 8 commits June 5, 2026 02:31
- auth-store: atomic temp-then-rename writes for auth.json / next-audit.json
  with 0600 enforcement on both paths, so a crash mid-write can't truncate
  the session or reminder file.
- api-server-client: 10s AbortSignal.timeout on every /v0/auth/* and
  /v0/reminders fetch; surfaces as AuthApiError(code: "timeout").
- src/auth/cli.ts runLogin: treat stored auth.json as stale when its
  refresh_expires_at has lapsed locally. Wipes + restarts OTP instead of
  bouncing the user against a server-rejected session. Telemetry tagged
  `replaced_stale: true`.
- /api/auth/reminder: distinguish empty body (defaults) from malformed
  JSON / non-object body (400 validation_error) instead of silently
  coercing to {} and writing a default-offset reminder.
- /api/audit/run: reject null / array / primitive bodies with 400 before
  sanitize() throws 500.
- src/audit/dashboard-cache: add schemaVersion field; entries from older
  shapes are rejected as null instead of being rendered against the
  wrong shape.
- rerun-button: triggerRun now throws RerunError on POST failure /
  network failure / poll-loop timeout; button paints a pink-border
  "rerun failed — retry" state for 4s.
- run-progress: cap visual progress at 90% and swap last-stage detail to
  "finishing up…" so a 30s run no longer paints 4/4 + 100% at 16s.
- identity-section: tighten LinkedIn-share "every key policy is live"
  copy to grade === "A" && missing === 0.
- assets/audit/archetypes.jsx Sigil: normalize unknown archetypeKey once
  so both SIGILS and ARCHETYPES.index use the same safe key.
- score-section: replace useMemo(pointsToNext) with plain
  pointsToNextFor(score) — cheap 5-threshold scan, fixes
  react-hooks/preserve-manual-memoization CI red.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…uth-store

Add 40 tests across four previously-untested modules:
- archetypes: zero-signal → precision, broad-spread → goldfish, secondary
  ≥40% promotion vs authored fallback, deterministic variant picker.
- findings: ranking, zero-hit drop, detector→policy remapping,
  alsoCoveredBy, alreadyEnabled enable-set + builtin-config heuristics,
  relative-time + missing lastSeen fallback.
- strengths: clean-rate headline, credential / retry / push-to-main
  absence gates, 5-item cap, fallback row when too few qualify.
- auth-store: round-trip, mode 0600, atomic-write leaves no .tmp
  siblings, shape-mismatch rejection, reminder scoping, atomic overwrite.

Full suite: 1741 passing (was 1701).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…e events

Close two telemetry gaps surfaced during the audit/auth review:

1. CLI auth was emitting audit_otp_verified + audit_cli_auth_login_completed
   with user_id on success, but never the audit_user_identity_linked event
   that bridges the anonymous local instance ID to the server-issued user.
   The dashboard's /api/auth/login-verify already emits this with
   source: "audit_set_reminder_auth_dialog"; src/auth/cli.ts now emits the
   CLI sibling with source: "cli", carrying user_id, user_email, and
   local_random_id (getInstanceId()). Without it, anyone who signs in via
   `failproofai auth login` stayed unjoined to their pre-auth events in
   PostHog.

2. `failproofai policy add|remove` was emitting cli_policy_add_success /
   cli_policy_remove_success on the happy path, but the failure path fell
   through to the generic cli_parse_error / cli_unexpected_error events,
   so dashboards couldn't compute add/remove conversion rates. bin/
   failproofai.mjs now stashes the action in lastPolicyAction at dispatch
   time and the top-level catch emits cli_policy_${action}_failure
   (CliError or unexpected) — same shape as the existing
   cli_install_failure / cli_uninstall_failure events.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ure events

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- audit_dashboard_viewed with score/grade/archetype/missing
- audit_reminder_cta_shown / _clicked in return-section
- audit_auth_dialog_opened / _dismissed / _succeeded with source tag
- audit_rerun_failed with RerunError kind (rerun-button + return-section)
- api_server_unreachable from fetchWithTimeout (kind, path, method)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DEFAULT_API_BASE flipped from http://localhost:8080 so published
npm users hit the hosted api-server out of the box. CLI help text,
unreachable-error copy, dashboard auth-dialog error string, and
docs/cli/auth.mdx env-var table all updated to match. Local-dev
contributors override with FAILPROOF_API_URL=http://localhost:8080.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The dedicated docs/cli/auth.mdx page already listed the default; the
environment-variables index just said "override" without naming the
fallback. Add it for parity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ntry

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@SiddarthAA SiddarthAA changed the title feat(audit): persistent return-loop with auth, reminders, inline re-audit feat(audit+auth): /audit dashboard, email-OTP auth (CLI + dashboard), SES-backed re-audit reminders, unified pixel-craft design system Jun 5, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
src/audit/dashboard-cache.ts (1)

51-55: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject null cache payloads during structural validation.

The current guards accept params: null / result: null because typeof null is "object", so malformed cache can be treated as valid and crash later when fields are read.

Suggested fix
-    if (
-      typeof entry?.cachedAt !== "string"
-      || typeof entry?.params !== "object"
-      || typeof entry?.result !== "object"
-    ) {
+    if (
+      !entry
+      || typeof entry !== "object"
+      || typeof entry.cachedAt !== "string"
+      || !entry.params
+      || typeof entry.params !== "object"
+      || !entry.result
+      || typeof entry.result !== "object"
+    ) {
       return null;
     }

Also applies to: 61-64

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/audit/dashboard-cache.ts` around lines 51 - 55, The structural validation
in dashboard-cache.ts currently uses typeof checks that accept null (since
typeof null === "object"); update the guard in the block that checks
entry?.cachedAt, entry?.params, and entry?.result (and the similar guard at the
other occurrence) to also explicitly reject null values for params and result —
e.g. require typeof entry?.params === "object" AND entry?.params !== null, and
likewise for entry?.result — so null cache payloads are treated as invalid and
rejected during validation.
app/audit/_components/rerun-button.tsx (1)

65-69: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add per-request timeouts for run and status fetches.

MAX_POLL_MS does not protect against a single hung fetch; one stalled request can block forever and prevent completion/failure handling.

Suggested fix
+async function fetchWithTimeout(input: RequestInfo | URL, init: RequestInit = {}, timeoutMs = 15_000) {
+  const ac = new AbortController();
+  const id = setTimeout(() => ac.abort(), timeoutMs);
+  try {
+    return await fetch(input, { ...init, signal: ac.signal });
+  } finally {
+    clearTimeout(id);
+  }
+}
+
 export async function triggerRun(scanParams: ScanParams): Promise<void> {
   // Kick off the run. 409 (already running) is OK — we'll just poll.
   try {
-    const res = await fetch("/api/audit/run", {
+    const res = await fetchWithTimeout("/api/audit/run", {
       method: "POST",
       headers: { "content-type": "application/json" },
       body: JSON.stringify(paramsToBody(scanParams)),
-    });
+    });
@@
-      const sres = await fetch("/api/audit/status", { cache: "no-store" });
+      const sres = await fetchWithTimeout("/api/audit/status", { cache: "no-store" });

Also applies to: 83-90

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/audit/_components/rerun-button.tsx` around lines 65 - 69, Add a
per-request timeout using AbortController for the POST to "/api/audit/run" (the
fetch that sends JSON.stringify(paramsToBody(scanParams))) and for the
subsequent status polling fetches so a single stalled request cannot hang the
whole flow; create a REQUEST_TIMEOUT_MS constant, instantiate an AbortController
for each fetch, start a setTimeout that calls controller.abort() after
REQUEST_TIMEOUT_MS, pass controller.signal to fetch, and clear the timeout once
fetch resolves; also catch and handle the abort error (treat as a timed-out
request) in the same logic paths where the fetch results are processed.
app/audit/_components/return-section.tsx (2)

61-360: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add tests for the return-loop state transitions.

This component now owns auth probing, focus/visibility rehydration, immediate post-auth reminder persistence, and rerun failure handling, but no __tests__ cover those branches. That is a lot of new stateful behavior to leave unpinned.

As per coding guidelines, "Always add unit tests for new behaviour. Place tests in tests/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/audit/_components/return-section.tsx` around lines 61 - 360, Add unit
tests under __tests__ for the ReturnSection component that exercise the new auth
and rehydration behaviors: mock fetch("/api/auth/status") and verify
refreshStatus updates authStatus/reminder and that the focus/visibility handlers
re-trigger it; test handleSetReminder flow for anon → dialog open and for authed
→ calls persistReminder and updates reminder; test handleAuthed calls
persistReminder automatically and sets auth state; and test handleRerun failure
path by mocking triggerRun to throw a RerunError (and a network error) and
asserting capture is called with expected payloads. Target symbols:
ReturnSection, refreshStatus, persistReminder, handleSetReminder, handleAuthed,
handleRerun, and the AuthDialog interaction.

130-154: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reminder persistence can spin forever on a hung route.

persistReminder() flips reminderBusy before calling /api/auth/reminder, but there is no timeout or abort path. If that route stalls, the reminder CTA stays disabled indefinitely and the user has no recovery path in-page.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/audit/_components/return-section.tsx` around lines 130 - 154,
persistReminder currently sets reminderBusy and calls
fetch("/api/auth/reminder") with no timeout or abort, so a hung route leaves the
UI disabled; wrap the fetch in an AbortController with a reasonable timeout
(e.g. 5–10s) and clear the timer on success, and ensure the controller aborts on
timeout so the catch/finally path runs and setReminderBusy(false) is always
reached; update the persistReminder implementation (referencing persistReminder,
setReminderBusy, and the fetch to "/api/auth/reminder") to create an
AbortController, pass its signal to fetch, start a timeout to call
controller.abort(), and clear that timeout in the success/finally branches.
src/auth/cli.ts (1)

69-355: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add CLI tests for the new auth flow branches.

This lands new parsing, stale-session handling, OTP retry behavior, logout semantics, and whoami exit-code behavior without companion __tests__ coverage. A small mocked suite around parseAuthArgs(), expired-session reauth, and logout/whoami would catch regressions quickly.

As per coding guidelines, "Always add unit tests for new behaviour. Place tests in tests/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/auth/cli.ts` around lines 69 - 355, Add unit tests under __tests__ to
cover the new auth CLI behavior: write tests for parseAuthArgs to exercise
flags, legacy flags and error paths; a runLogin test that mocks readAuth to
return an expired session and asserts deleteAuth is called and the flow proceeds
to prompt/OTP behavior (mock requestLoginCode/verifyLoginCode and
trackHookEvent); a runLogout test that mocks readAuth and logoutSession to
assert deleteAuth is always called and the "upstream" tracking value changes on
revoke failure; and a runWhoami test that verifies process.exitCode is set to 1
when no session and prints the expected output. Use the functions parseAuthArgs,
runLogin, runLogout, runWhoami and runAuthCli as the entry points and mock
helpers like readAuth, deleteAuth, requestLoginCode, verifyLoginCode,
logoutSession, and trackHookEvent to isolate behavior.
app/audit/_components/auth-dialog.tsx (2)

120-159: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

These requests can wedge the modal indefinitely.

Both flows set busy before fetch(), and busy disables Escape, overlay-click, and the close button. Without a client-side timeout/abort, a hung /api/auth/login-request or /api/auth/login-verify call leaves the dialog stuck until a full page reload.

Also applies to: 163-199

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/audit/_components/auth-dialog.tsx` around lines 120 - 159, requestCode
currently sets busy before calling fetch and can leave the modal permanently
disabled if the network request hangs; wrap the fetch in an AbortController with
a client-side timeout (e.g. setTimeout to call controller.abort after N ms),
pass controller.signal to fetch, clear the timeout on success, and handle the
abort case in the catch (treat AbortError as a network-timeout and setStep({
kind: "email", error: "...timeout..." }) or similar) so busy is always cleared
in finally. Make the exact same change to the verifyCode function so both
requestCode and verifyCode use an AbortController + timeout, clear timers on
completion, and ensure setBusy(false) runs in finally even when aborted.

44-382: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add unit tests for the auth-dialog state machine.

This introduces a new email → code → done flow, resend countdown, and open/dismiss/success telemetry, but no __tests__ coverage landed with it. Those branches are stateful enough that manual verification will miss regressions.

As per coding guidelines, "Always add unit tests for new behaviour. Place tests in tests/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/audit/_components/auth-dialog.tsx` around lines 44 - 382, The PR adds an
email→code→done state machine in AuthDialog but no unit tests; add tests under
__tests__ that mount AuthDialog and exercise the flows: submit email (triggering
requestCode via onEmailSubmit), transition to "code" state and focus behavior,
verify code (triggering verifyCode via onCodeSubmit) to reach "done", the resend
countdown and onResend behavior (ensure resendIn ticks down and blocks until
zero), and telemetry events (capture calls for audit_auth_dialog_opened,
audit_auth_dialog_dismissed when closed without success, and
audit_auth_dialog_succeeded when onAuthed is called); reference the component
AuthDialog and handlers requestCode, verifyCode, onEmailSubmit, onCodeSubmit,
onResend, and the step.kind transitions in your assertions, mocking fetch and
the usePostHog capture to assert correct behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@__tests__/lib/api-server-client.test.ts`:
- Around line 15-72: Add unit tests in __tests__/lib/api-server-client.test.ts
that cover the new public behaviors: write tests for scheduleReminder() and
cancelReminder() to ensure reminders are scheduled and cancelled correctly
(including side effects/returned values), add tests that exercise parsing of the
Retry-After header (both seconds and HTTP-date forms) when fetch responses
return 429, and add tests for decodeJwt() including valid, expired, and
malformed tokens; use the existing requestLoginCode() tests as a template to
mock globalThis.fetch and assert telemetry (trackEventMock) and returned results
for these new paths so regressions in scheduleReminder, cancelReminder,
retry-after parsing, and decodeJwt are detected.

In `@__tests__/lib/auth-store.test.ts`:
- Around line 116-151: Add the same file-permission assertion for the reminder
file as exists for auth.json: after creating/writing a reminder (via
writeReminder or in the test that rounds-trips or immediately after
writeReminder), stat the file returned by getReminderFilePath() and assert its
mode is 0o600 (owner read/write only). Update the "reminder" describe block
tests (e.g., the "round-trips a written reminder" or a new test) to use
getReminderFilePath() and check the file's permissions so regressions on
next-audit.json are caught.

In `@src/auth/cli.ts`:
- Around line 273-278: Remove the PII by deleting the user_email field from the
telemetry payload sent to trackHookEvent in src/auth/cli.ts; keep the existing
keys (source, user_id, local_random_id) so the call to
trackHookEvent(getInstanceId(), "audit_user_identity_linked", { ... }) only
includes source, user_id, and local_random_id and does not reference
tokenResp.user.email.

---

Outside diff comments:
In `@app/audit/_components/auth-dialog.tsx`:
- Around line 120-159: requestCode currently sets busy before calling fetch and
can leave the modal permanently disabled if the network request hangs; wrap the
fetch in an AbortController with a client-side timeout (e.g. setTimeout to call
controller.abort after N ms), pass controller.signal to fetch, clear the timeout
on success, and handle the abort case in the catch (treat AbortError as a
network-timeout and setStep({ kind: "email", error: "...timeout..." }) or
similar) so busy is always cleared in finally. Make the exact same change to the
verifyCode function so both requestCode and verifyCode use an AbortController +
timeout, clear timers on completion, and ensure setBusy(false) runs in finally
even when aborted.
- Around line 44-382: The PR adds an email→code→done state machine in AuthDialog
but no unit tests; add tests under __tests__ that mount AuthDialog and exercise
the flows: submit email (triggering requestCode via onEmailSubmit), transition
to "code" state and focus behavior, verify code (triggering verifyCode via
onCodeSubmit) to reach "done", the resend countdown and onResend behavior
(ensure resendIn ticks down and blocks until zero), and telemetry events
(capture calls for audit_auth_dialog_opened, audit_auth_dialog_dismissed when
closed without success, and audit_auth_dialog_succeeded when onAuthed is
called); reference the component AuthDialog and handlers requestCode,
verifyCode, onEmailSubmit, onCodeSubmit, onResend, and the step.kind transitions
in your assertions, mocking fetch and the usePostHog capture to assert correct
behavior.

In `@app/audit/_components/rerun-button.tsx`:
- Around line 65-69: Add a per-request timeout using AbortController for the
POST to "/api/audit/run" (the fetch that sends
JSON.stringify(paramsToBody(scanParams))) and for the subsequent status polling
fetches so a single stalled request cannot hang the whole flow; create a
REQUEST_TIMEOUT_MS constant, instantiate an AbortController for each fetch,
start a setTimeout that calls controller.abort() after REQUEST_TIMEOUT_MS, pass
controller.signal to fetch, and clear the timeout once fetch resolves; also
catch and handle the abort error (treat as a timed-out request) in the same
logic paths where the fetch results are processed.

In `@app/audit/_components/return-section.tsx`:
- Around line 61-360: Add unit tests under __tests__ for the ReturnSection
component that exercise the new auth and rehydration behaviors: mock
fetch("/api/auth/status") and verify refreshStatus updates authStatus/reminder
and that the focus/visibility handlers re-trigger it; test handleSetReminder
flow for anon → dialog open and for authed → calls persistReminder and updates
reminder; test handleAuthed calls persistReminder automatically and sets auth
state; and test handleRerun failure path by mocking triggerRun to throw a
RerunError (and a network error) and asserting capture is called with expected
payloads. Target symbols: ReturnSection, refreshStatus, persistReminder,
handleSetReminder, handleAuthed, handleRerun, and the AuthDialog interaction.
- Around line 130-154: persistReminder currently sets reminderBusy and calls
fetch("/api/auth/reminder") with no timeout or abort, so a hung route leaves the
UI disabled; wrap the fetch in an AbortController with a reasonable timeout
(e.g. 5–10s) and clear the timer on success, and ensure the controller aborts on
timeout so the catch/finally path runs and setReminderBusy(false) is always
reached; update the persistReminder implementation (referencing persistReminder,
setReminderBusy, and the fetch to "/api/auth/reminder") to create an
AbortController, pass its signal to fetch, start a timeout to call
controller.abort(), and clear that timeout in the success/finally branches.

In `@src/audit/dashboard-cache.ts`:
- Around line 51-55: The structural validation in dashboard-cache.ts currently
uses typeof checks that accept null (since typeof null === "object"); update the
guard in the block that checks entry?.cachedAt, entry?.params, and entry?.result
(and the similar guard at the other occurrence) to also explicitly reject null
values for params and result — e.g. require typeof entry?.params === "object"
AND entry?.params !== null, and likewise for entry?.result — so null cache
payloads are treated as invalid and rejected during validation.

In `@src/auth/cli.ts`:
- Around line 69-355: Add unit tests under __tests__ to cover the new auth CLI
behavior: write tests for parseAuthArgs to exercise flags, legacy flags and
error paths; a runLogin test that mocks readAuth to return an expired session
and asserts deleteAuth is called and the flow proceeds to prompt/OTP behavior
(mock requestLoginCode/verifyLoginCode and trackHookEvent); a runLogout test
that mocks readAuth and logoutSession to assert deleteAuth is always called and
the "upstream" tracking value changes on revoke failure; and a runWhoami test
that verifies process.exitCode is set to 1 when no session and prints the
expected output. Use the functions parseAuthArgs, runLogin, runLogout, runWhoami
and runAuthCli as the entry points and mock helpers like readAuth, deleteAuth,
requestLoginCode, verifyLoginCode, logoutSession, and trackHookEvent to isolate
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cfcdbf74-475a-4904-8a98-9f39894a798e

📥 Commits

Reviewing files that changed from the base of the PR and between 4bcd6eb and d8703b7.

📒 Files selected for processing (23)
  • CHANGELOG.md
  • __tests__/audit/archetypes.test.ts
  • __tests__/audit/findings.test.ts
  • __tests__/audit/strengths.test.ts
  • __tests__/lib/api-server-client.test.ts
  • __tests__/lib/auth-store.test.ts
  • app/api/audit/run/route.ts
  • app/api/auth/reminder/route.ts
  • app/audit/_components/audit-dashboard.tsx
  • app/audit/_components/auth-dialog.tsx
  • app/audit/_components/identity-section.tsx
  • app/audit/_components/rerun-button.tsx
  • app/audit/_components/return-section.tsx
  • app/audit/_components/run-progress.tsx
  • app/audit/_components/score-section.tsx
  • assets/audit/archetypes.jsx
  • bin/failproofai.mjs
  • docs/cli/auth.mdx
  • docs/cli/environment-variables.mdx
  • lib/auth/api-server-client.ts
  • lib/auth/auth-store.ts
  • src/audit/dashboard-cache.ts
  • src/auth/cli.ts
✅ Files skipped from review due to trivial changes (3)
  • docs/cli/environment-variables.mdx
  • docs/cli/auth.mdx
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (7)
  • assets/audit/archetypes.jsx
  • app/api/audit/run/route.ts
  • app/audit/_components/run-progress.tsx
  • app/audit/_components/score-section.tsx
  • lib/auth/auth-store.ts
  • app/audit/_components/identity-section.tsx
  • app/api/auth/reminder/route.ts

Comment on lines +15 to +72
describe("api-server-client fetchWithTimeout telemetry", () => {
const originalFetch = globalThis.fetch;

beforeEach(() => {
trackEventMock.mockClear();
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

it("fires api_server_unreachable with kind=timeout on AbortError", async () => {
globalThis.fetch = vi.fn(async () => {
const err = new Error("aborted");
err.name = "AbortError";
throw err;
}) as unknown as typeof fetch;

await expect(requestLoginCode("a@b.co")).rejects.toBeInstanceOf(AuthApiError);
expect(trackEventMock).toHaveBeenCalledWith(
"api_server_unreachable",
expect.objectContaining({
kind: "timeout",
path: "/v0/auth/login/request",
method: "POST",
}),
);
});

it("fires api_server_unreachable with kind=network on connection error", async () => {
globalThis.fetch = vi.fn(async () => {
throw new TypeError("fetch failed");
}) as unknown as typeof fetch;

await expect(requestLoginCode("a@b.co")).rejects.toBeInstanceOf(TypeError);
expect(trackEventMock).toHaveBeenCalledWith(
"api_server_unreachable",
expect.objectContaining({
kind: "network",
path: "/v0/auth/login/request",
method: "POST",
}),
);
});

it("does NOT fire api_server_unreachable on a successful response", async () => {
globalThis.fetch = vi.fn(async () =>
new Response(
JSON.stringify({ status: "code_sent", expires_in: 600, resend_available_in: 30 }),
{ status: 200, headers: { "content-type": "application/json" } },
),
) as unknown as typeof fetch;

const out = await requestLoginCode("a@b.co");
expect(out.status).toBe("code_sent");
expect(trackEventMock).not.toHaveBeenCalled();
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add coverage for the new reminder and parsing paths.

This suite only exercises the telemetry side effect on requestLoginCode(). The new public behaviors in scheduleReminder(), cancelReminder(), retry-after parsing, and decodeJwt() still have no regression coverage, so contract breaks there will ship silently.

As per coding guidelines, "Always add unit tests for new behaviour. Place tests in tests/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/lib/api-server-client.test.ts` around lines 15 - 72, Add unit tests
in __tests__/lib/api-server-client.test.ts that cover the new public behaviors:
write tests for scheduleReminder() and cancelReminder() to ensure reminders are
scheduled and cancelled correctly (including side effects/returned values), add
tests that exercise parsing of the Retry-After header (both seconds and
HTTP-date forms) when fetch responses return 429, and add tests for decodeJwt()
including valid, expired, and malformed tokens; use the existing
requestLoginCode() tests as a template to mock globalThis.fetch and assert
telemetry (trackEventMock) and returned results for these new paths so
regressions in scheduleReminder, cancelReminder, retry-after parsing, and
decodeJwt are detected.

Comment on lines +116 to +151
describe("reminder", () => {
it("returns null when no reminder file exists", () => {
expect(readReminder()).toBeNull();
});

it("round-trips a written reminder", () => {
const r = fakeReminder();
writeReminder(r);
const out = readReminder();
expect(out).toEqual(r);
});

it("scopes by user_email — the consumer enforces this", () => {
writeReminder(fakeReminder({ user_email: "bob@example.com" }));
const out = readReminder();
expect(out?.user_email).toBe("bob@example.com");
});

it("rejects shape mismatches as null", () => {
writeFileSync(getReminderFilePath(), JSON.stringify({ next_audit_at: "string" }), "utf-8");
expect(readReminder()).toBeNull();
});

it("deleteReminder removes the file", () => {
writeReminder(fakeReminder());
expect(existsSync(getReminderFilePath())).toBe(true);
deleteReminder();
expect(existsSync(getReminderFilePath())).toBe(false);
});

it("overwrites the existing reminder atomically", () => {
writeReminder(fakeReminder({ next_audit_at: 1 }));
writeReminder(fakeReminder({ next_audit_at: 2 }));
expect(readReminder()?.next_audit_at).toBe(2);
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert restrictive perms on next-audit.json too.

The suite checks 0600 on auth.json, but the new reminder file also stores user email and is part of the same hardening story. Add the same mode assertion for getReminderFilePath() so a permission regression on next-audit.json is caught.

As per coding guidelines, "Always add unit tests for new behaviour. Place tests in tests/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/lib/auth-store.test.ts` around lines 116 - 151, Add the same
file-permission assertion for the reminder file as exists for auth.json: after
creating/writing a reminder (via writeReminder or in the test that rounds-trips
or immediately after writeReminder), stat the file returned by
getReminderFilePath() and assert its mode is 0o600 (owner read/write only).
Update the "reminder" describe block tests (e.g., the "round-trips a written
reminder" or a new test) to use getReminderFilePath() and check the file's
permissions so regressions on next-audit.json are caught.

Comment thread src/auth/cli.ts
Comment on lines +273 to +278
void trackHookEvent(getInstanceId(), "audit_user_identity_linked", {
source: "cli",
user_id: tokenResp.user.id,
user_email: tokenResp.user.email,
local_random_id: getInstanceId(),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Drop raw email from the identity-link telemetry event.

local_random_id and user_id are already enough to stitch anonymous and authenticated sessions here. Adding user_email sends avoidable PII into analytics with no obvious need.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/auth/cli.ts` around lines 273 - 278, Remove the PII by deleting the
user_email field from the telemetry payload sent to trackHookEvent in
src/auth/cli.ts; keep the existing keys (source, user_id, local_random_id) so
the call to trackHookEvent(getInstanceId(), "audit_user_identity_linked", { ...
}) only includes source, user_id, and local_random_id and does not reference
tokenResp.user.email.

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.

1 participant