Skip to content

Our own audit. We ran /production-audit on Foundry's own public repo. Check it against the source.

All reports →

Audit report · v0.2.0

Foundry

commit 81fec42audited 2026-08-1410 dimensions · static only

Release recommendation

Safe to ship

Static review · runtime not exercised

Healthy. Ten dimensions and six adversarial verifications found one risk, a vulnerable framework pin, and it was fixed in this branch before publication; what remains is a robustness punch list on a small, deliberately fail-closed surface.

Why this verdict

Static review, runtime not exercised. Ten dimension reviews produced six risk candidates; adversarial verification confirmed one (a Next.js version in range for an unauthenticated Server Actions CPU-exhaustion advisory), which was fixed in the audited branch (next 16.3.1) and re-verified before this report was emitted. The five others were downgraded to improvements on the reachable-today rule, each with its reasoning recorded. The access-control, origin-pinning, and fail-closed auth surfaces verified clean at every layer checked. The runtime checks a static run cannot perform are listed under notAssessed.

1 verified5 downgraded
GatesMobile Not assessedInstrumentation Met

Not assessed this run: data-migration-safety

What's solid (10)

  • Authorization

    The authz matrix closed with no gaps: every gated read enforces owner-or-admin in the data-access layer itself (lib/reports.ts canView/getReportFor/getPublicReport), generateMetadata goes through the same gate, non-owner and nonexistent slugs are indistinguishable 404s, and no state-mutating endpoint exists beyond auth itself.

  • Path traversal

    All three dynamic slug routes are traversal-proof in the data layer (strict slug regex plus directory-prefix check in lib/reports.ts:92-94 and lib/skills.ts:193-195), and the Behind-the-Build fs reads are hard-whitelisted (lib/docs.ts ALLOWED set); NOTES/PROMPTS/FRICTION are unreachable through any route.

  • Auth flow integrity

    No open redirect exists (fixed-path redirects built from a server-pinned origin; SITE_ORIGIN set in fly.toml so the emailed magic-link target is never derived from a spoofable forwarded host); session trust is JWT-validated via getUser() on every access decision; a failed confirm buffers no cookies and can never half-establish or clobber a session (lib/supabase.ts applyCookies).

  • Fail-closed posture

    Missing auth env locks the gated surface closed with a one-time ops log at every consumer; a malformed report file is logged and skipped rather than 500ing the index; the VERSION file read fails open to 0.0.0, the one direction that can never falsely tell an installed skill it is behind.

  • Analytics isolation

    Client and server capture no-op without their keys, are never awaited in a request path, and never gate a response; the version endpoint validates against the canonical skill allowlist, disables person-profile processing on its synthetic actor, and serves Cache-Control: no-store.

  • Accessibility primitives

    Skip link, global focus-visible ring, blanket and per-effect reduced-motion kills, aria-live slide announcements, labeled form fields with described errors and aria-busy pending state, 44px targets throughout, and severity, verdict, and gate status always carried by text, never color alone.

  • Design-token fidelity

    globals.css matches DESIGN.md hex for hex including the AA-driven brightened faint tone; no raw hex outside documented mirrors; the display face ships in its single loaded weight; the report-excerpt color exception is honored at all four codified sites; glossary and no-em-dash rules hold across every surface swept including all eleven SKILL_COPY entries.

  • Runtime image completeness

    Every runtime fs-read target is explicitly COPYd into the standalone image (VERSION included), cross-checked against every readFileSync/readdirSync call site; build-arg keys never become runner env; security headers enforce on every route; no dev or debug endpoint exists.

  • Version-marker integrity

    The VERSION file is a single source of truth mechanically enforced: make validate fails on drift in any SKILL.md frontmatter or check URL, SECURITY.md, or a SKILL_SLUGS/skills mismatch in either direction, and CI runs it on every PR and push. The in-skill check command enforces its own opt-out, rate limit, timeout, and fail-silence.

  • Verified fix loop

    The one confirmed risk of this audit (next 16.2.9 in range for GHSA-m99w-x7hq-7vfj) was fixed in the audited branch and re-verified (next 16.3.1 resolved in the lockfile, build and typecheck green, advisory absent from the post-fix npm audit) before this report was published, alongside two skeptic-verified truth defects: the homepage verdict hue now derives from the verdict via a full-literal class map, and the CI comment now claims detection, not an enforcement no ruleset provides.

Risks to weigh (0)

Findings with a path to harm in production. These are what the verdict weighs.

No risks surfaced. Nothing here has a path to harm in production.

Improvements (39)

Safe today. Ways to make the app more robust, observable, and consistent over time. These do not affect the verdict.

SEC-01mediumPrivate-report confidentiality rests entirely on the site gate while report storage is a public git repository.security · lib/reports.ts:5
Impact
Both committed reports are public examples today, so no harm path exists now; but the first report committed with meta.public false and an owner would be world-readable in the repo and every clone, gate notwithstanding, and git history does not un-publish.
Evidence
lib/reports.ts:5 "Reads published audit reports from reports/*.json. These files are committed"; both reports/*.json carry "public": true and no owner field; the repo is public (horizon-foundry/foundry).
Recommended fix
Add a publish-time control before any private report can land: make validate fails on a committed report whose meta.public is false, and private reports live outside the repo.
Confidence: Configuration confirmedNot verified
REL-01mediumMagic-link confirmation consumes the single-use token on a bare GET, so corporate mail scanners that prefetch links can spend it before the user clicks.reliability · app/auth/confirm/route.ts:24
Impact
A report owner behind SafeLinks or Mimecast would land on error=expired on every attempt, including every resent link; no such user exists today (zero owned reports; the admin mailbox is not in that class), so the harm is to a future population and the fix belongs before the first corporate-mailbox owner is onboarded.
Evidence
app/auth/confirm/route.ts:24 "export async function GET(request: NextRequest)" and :65 verifyOtp on token_hash; Supabase's own troubleshooting doc confirms scanner prefetch "consumed instantly" behavior.
Recommended fix
Add an interstitial confirm page so the consuming call is a POST behind a human action, per Supabase's guidance; verify with a SafeLinks test mailbox.
Unverified assumption
The deployed Supabase email template uses the token_hash link shape the route expects; the template is dashboard config, not in-repo.
Confidence: Needs verificationDowngraded on verification (fresh subagent)

Mechanism confirmed real and vendor-documented, but the affected population does not exist today (both committed reports are public with no owner; ADMIN_EMAILS is a gmail mailbox), so the risk classification fails the reachable-today rule. Report as a medium improvement with a pre-onboarding mitigation.

OPS-01mediumNo alerting path with a named receiver exists anywhere: the health check only proves the static homepage renders, logs are ephemeral stdout on auto-stop machines, and nothing distinguishes analytics-down from traffic-zero.operability · fly.toml:36
Impact
A Supabase lockout, a rotated PostHog key, or a dead version-check endpoint would each be invisible until a user complains or the maintainer happens to look; the failure modes are logged locally but land nowhere durable.
Evidence
fly.toml health check GET / only; no uptime monitor, alert, or log shipping exists in the repo; lib/posthog-server.ts capture is fire-and-forget with no error handler.
Recommended fix
One external uptime check on /api/version (which exercises the dynamic path), one PostHog canary insight with a subscription, and the Operations runbook section TODOS already indexes.
Other instances (3)
fly.toml:36 · lib/posthog-server.ts:31 · instrumentation-client.ts:10
Confidence: Code tracedNot verified
OPS-02mediumThe magic-link request failure path is the one critical-flow failure with neither a log nor an event.operability · app/unlock/actions.ts:40
Impact
A Supabase outage at the request stage is invisible to the maintainer; the confirm route logs and captures its failures, the request stage silently redirects.
Evidence
app/unlock/actions.ts:40-42 "if (error) { redirect(\"/unlock?error=failed\"); }" with no console.error and no capture.
Recommended fix
Log the error with context and fire a request-stage failure event, mirroring the confirm route's posture.
Confidence: Code tracedNot verified
TEST-01mediumZero application tests exist and no runner is installed; the deterministic gates comprehensively pin the report contract and version markers while pinning nothing about the running site (one root cause; the unpinned controls are instanced).testing-confidence · package.json:8
Impact
Every authz, auth, origin-pinning, caching, and fs-boundary behavior is one uncaught refactor from silently changing. The sharpest edge: getPublicReport has a signature-identical ungated sibling in the same module, and only convention separates them; TypeScript catches the gated-route swap but not the example-route one. This converts to a medium risk the day a private report is committed.
Evidence
No test files or runner repo-wide; .github/workflows/ci.yml runs typecheck, lint, build, make validate only; lib/reports.ts exports getReport alongside getPublicReport/getReportFor.
Recommended fix
Stop exporting getReport (or rename it unsafeGetReport); add a vitest pass pinning canView's matrix, getPublicReport's non-public rejection, slug traversal rejection, and the confirm route's cookie application; wire it into CI.
Other instances (6)
lib/reports.ts:90 · app/auth/confirm/route.ts:64 · lib/supabase.ts:66 · app/api/version/route.ts:40 · middleware.ts:24 · lib/version.ts:23
Confidence: Code tracedDowngraded on verification (fresh subagent)

Argued up to risk by the finder; the skeptic rejected the classification: the control works today, zero private reports exist to expose, one of the two swaps fails typecheck in CI, and the harm requires compound future events. Kept medium as the improvement's severity for the day the preconditions change.

SHIP-01mediummain is the live npx install channel and carries no branch ruleset; CI detects but nothing blocks a direct push.release-safety · .github/workflows/ci.yml:10
Impact
One bad push ships instantly to every new install. Reachability is tempered to process-hygiene: the org has exactly one member, a standing no-direct-push law, and red-CI-on-main detection; admin-account compromise would bypass a ruleset anyway.
Evidence
GitHub API queried live: branch protection 404, rulesets empty, sole collaborator with admin; ci.yml triggers on push to main (detection).
Recommended fix
One ruleset requiring the verify check on main (a single gh api call, already on the launch-tail list awaiting go).
Confidence: Configuration confirmedDowngraded on verification (fresh subagent)

Proposed medium risk; downgraded on reachability (single-writer org verified live, three-failure chain required, detection exists) and on the suite's own SHIP-01 precedent, which classified the strictly wider gap as an improvement. The CI comment that claimed enforced merging was a separate docs-truth defect, fixed in the audited branch.

SHIP-02mediumThe validated artifact is never the shipped artifact: deploy rebuilds from the local working tree, and the image carries no revision stamp.release-safety · Dockerfile:16
Impact
An uncommitted local edit ships to production without CI seeing it, and nothing records which commit a deployed image came from.
Evidence
Dockerfile:16 "COPY . ." with .git dockerignored; deploy is manual fly deploy from a workstation; no clean-tree or on-main guard exists in any pipeline file.
Recommended fix
A deploy guard (clean tree, on main) plus a GIT_SHA build arg surfaced in a header or the version endpoint.
Confidence: Configuration confirmedNot verified
SHIP-03mediumNo named post-deploy verification exercises a user-visible outcome on either channel; the health check proves the homepage returns 200.release-safety · fly.toml:36
Impact
A deploy that breaks sign-in, report rendering, or the version endpoint reads as green; the endpoint the suite now ships is the natural named check and no doc requires it.
Evidence
fly.toml [[http_service.checks]] GET / only; the ship pipeline ends at deploy.
Recommended fix
A post-deploy step: curl /api/version equals repo VERSION, one gated-route 200 with a session, one public report renders; write it into the ship skill's project overrides.
Confidence: Configuration confirmedNot verified
SHIP-04mediumRollback is undocumented on both channels, and a skill recall cannot reach existing installs without a forward version bump plus a site deploy.release-safety · TODOS.md:117
Impact
Recalling a bad skill requires knowing an undocumented two-step (revert on main does nothing for installed copies until VERSION moves forward AND the endpoint serves it); the site rollback command is similarly unwritten.
Evidence
The check contract prompts only when the endpoint version is newer than the installed frontmatter; TODOS.md:117 indexes the rollback documentation as open.
Recommended fix
Document both recall paths in the Operations section: fly releases rollback for the site, revert-plus-forward-bump-plus-deploy for skills.
Confidence: Code tracedNot verified
PERF-01mediumThe open version endpoint amplifies each valid unauthenticated hit into an immediate outbound PostHog TLS POST with no rate limit or backpressure bound.performance-capacity · app/api/version/route.ts:40
Impact
A sustained burst degrades the single 512MB machine (outbound connection and promise growth) and junks the metric window; response delivery itself is never gated, and the identity and cardinality controls already cap the analytics blast radius, so the reachable consequence is degradation plus junk data, not loss.
Evidence
app/api/version/route.ts:40-47 capture inside the GET with lib/posthog-server.ts flushAt: 1, flushInterval: 0, never awaited.
Recommended fix
An edge or in-process token bucket on capture (not on the response), or sampled capture; size it with a 30s load test against the Fly machine.
Confidence: Code tracedNot verified
29 more · hygiene and polish
SEC-02lowThe unauthenticated email-send action has no application-layer throttle; the effective limits are Supabase's.security · app/unlock/actions.ts:35
Impact
With the recorded platform configuration (built-in SMTP, roughly two sign-in emails per hour project-wide, per-email cooldown) neither email-bombing nor quota-burn rises above nuisance; the mitigation is platform-config-dependent and silently disappears if custom SMTP raises the caps.
Evidence
app/unlock/actions.ts:35 "const { error } = await supabase.auth.signInWithOtp({"; NOTES.md:84 records the built-in email service "allows only 2 sign-in emails per hour".
Recommended fix
Land an app-layer throttle on requestMagicLink in the same change that upgrades the email provider.
Confidence: Code tracedDowngraded on verification (fresh subagent)

Filed as a medium risk without checking the platform layer the kill list requires. Supabase enforces server-side OTP rate limits and the project's recorded email cap is 2/hour, collapsing both claimed harms to nuisance level; the rubric names this exact case (a low rate-limit gap mitigated upstream) as an improvement.

SEC-03lowThe pre-auth analytics event is keyed on the raw email a visitor typed, which may be a third party's.security · app/unlock/actions.ts:46
Impact
A stranger submitting victim@example.com places unconsented PII into the analytics store as a person-keyed distinct id; it joins the real owner's identity only if they later sign in.
Evidence
app/unlock/actions.ts:46 "captureServer(email, \"magic_link_requested\");"
Recommended fix
Capture the pre-auth event with person-profile processing off, or key it on a hash and alias the hash on confirm.
Confidence: Code tracedNot verified
REL-02lowEvery confirmation failure, including a Supabase outage, is reported to the user as an expired link.reliability · app/auth/confirm/route.ts:33
Impact
During an outage users are sent into a request-a-fresh-link retry loop that also fails, and the analytics reason field records the outage as user error.
Evidence
app/auth/confirm/route.ts:33 "const failure = new URL(\"/unlock?error=expired\", origin);" used for every error branch.
Recommended fix
Distinguish an upstream failure from a consumed or expired token in both the user copy and the sign_in_failed reason.
Confidence: Code tracedNot verified
REL-03lowThe GitHub star fetch has no timeout.reliability · components/InstallBlock.tsx:26
Impact
A hung connection stalls the build or the hourly ISR revalidation up to undici defaults; per-request traffic is unaffected.
Evidence
components/InstallBlock.tsx:26 fetch with no AbortSignal.timeout.
Recommended fix
AbortSignal.timeout on the fetch.
Confidence: Code tracedNot verified
REL-04lowNo Supabase call carries an explicit timeout, and the failure envelope leans on platform defaults (one root cause; instances listed).reliability · lib/supabase.ts:129
Impact
A hung upstream pins the unlock form in its pending state until the proxy gives up; sign-out ignores its revocation result; an error thrown inside the root layout escapes to Next's unstyled default page.
Evidence
lib/supabase.ts:129 "await supabase.auth.getUser()" (no timeout); app/auth/signout/route.ts:16 "await ctx.supabase.auth.signOut();" (result ignored).
Recommended fix
AbortSignal timeouts on the auth calls, check the signOut result, add app/global-error.tsx.
Other instances (5)
lib/supabase.ts:129 · app/unlock/actions.ts:35 · app/auth/confirm/route.ts:65 · app/auth/signout/route.ts:16 · app/layout.tsx · app/layout.tsx (no global-error boundary)
Confidence: Code tracedNot verified
CON-01lowA second magic-link request likely invalidates the first link's token, and the rate-limit retry copy understates the real window.concurrency · app/unlock/actions.ts:35
Impact
A double-requesting user who clicks email one lands on expired; a quota-limited user told to "try again in a minute" retries into an hour-scale window and burns more quota.
Evidence
app/unlock/page.tsx:11 "The sign-in email could not be sent. Try again in a minute."
Recommended fix
Align the retry copy with the provider's actual window; verify the token-invalidation behavior with two sequential requests.
Confidence: Needs verificationNot verified
CON-02lowAn already-signed-in visitor to /unlock (the loser tab of a two-tab confirm, or a stale-link click) is shown the sign-in form and an expired error instead of their session.concurrency · app/unlock/page.tsx:1
Impact
Confusion plus needless re-requests against the small email quota.
Evidence
app/unlock/page.tsx renders the form unconditionally; no session check exists in the page.
Recommended fix
Check for an existing session on /unlock and redirect to /reports.
Confidence: Code tracedNot verified
A11Y-01lowFocus is dropped when the deck's Prev/Next button disables under the keyboard user at either extreme.accessibility · components/SlideDeck.tsx:186
Impact
Keyboard and switch users lose their place and the region-scoped arrow keys stop working until they re-tab from the top.
Evidence
components/SlideDeck.tsx:186 "disabled={current === 0}" and :215 "disabled={current === total - 1}".
Recommended fix
Use aria-disabled with a no-op, or move focus to the sibling control before disabling.
Confidence: Code tracedNot verified
A11Y-02lowScorecard jump controls scroll without moving focus, and zero-count cells remain focusable no-op buttons (one root cause: focus not managed on the scorecard's interactive grid).accessibility · components/ReportScorecard.tsx:35
Impact
A screen-reader user activating a count hears nothing happen; tabbing pauses on buttons that do nothing.
Evidence
components/ReportScorecard.tsx:35 "el.scrollIntoView({ behavior: \"smooth\", block: \"start\" });" with no focus move; :71 aria-disabled without disabled or tabIndex removal.
Recommended fix
Focus the jump target (tabindex -1) on activation; take zero-count cells out of the tab order.
Other instances (3)
components/ReportScorecard.tsx:35 · components/ReportScorecard.tsx:54 · components/ReportScorecard.tsx:71
Confidence: Code tracedNot verified
A11Y-03lowThe hairline border tokens sit below the 3:1 non-text contrast minimum for component boundaries.accessibility · app/globals.css:16
Impact
Low-vision users may not perceive input and control extents; softened by the focus ring, raised grounds, and high-contrast content inside each control.
Evidence
app/globals.css:16-17 "--color-line: #334455;" (1.77:1 on ink) and "--color-line-strong: #45566a;" (2.35:1).
Recommended fix
Brighten the control-boundary token toward 3:1 where a control's extent is load-bearing (form fields first).
Other instances (4)
app/unlock/page.tsx:86 · components/Terminal.tsx:196 · components/CopyButton.tsx:41 · components/InstallBlock.tsx:44
Confidence: Configuration confirmedNot verified
A11Y-04lowThe scorecard's hovered cell drops its small labels below AA contrast (4.19:1 computed).accessibility · components/ReportScorecard.tsx:74
Impact
Low-vision users on the hover state; every other computed text pair on the site clears 4.5:1.
Evidence
components/ReportScorecard.tsx:74 "hover:bg-ink-raised-2" with text-bone-faint labels.
Recommended fix
Brighten the label or lighten the hover ground; confirm with a contrast probe on the rendered state.
Confidence: Configuration confirmedNot verified
UI-01lowThe meta descriptions still enumerate five promises; the site says six.ui · app/layout.tsx:9
Impact
Search and social previews understate the suite; on-page copy is consistent everywhere.
Evidence
app/layout.tsx:9-10 and app/skills/page.tsx:8-9 both omit the design-checked-against-code promise.
Recommended fix
Add the sixth promise to both descriptions.
Confidence: Code tracedNot verified
UI-02lowThe install slug is hardcoded on two surfaces past the single source of truth that exists for it.ui · app/skills/page.tsx:75
Impact
A repo rename updates InstallBlock and leaves these two stale silently.
Evidence
app/skills/page.tsx:75 and app/behind/page.tsx:84 spell horizon-foundry/foundry literally; lib/site.ts:10 REPO_SLUG exists to own it.
Recommended fix
Interpolate REPO_SLUG on both.
Confidence: Code tracedNot verified
UI-03lowThe unlock submit is the one primary CTA rendered all-caps against the brand's sentence-case button rule.ui · components/SubmitButton.tsx:22
Impact
Inconsistency on a trust-bearing surface; every other CTA is sentence case.
Evidence
components/SubmitButton.tsx:22 "uppercase tracking-wide".
Recommended fix
Drop the uppercase transform.
Confidence: Code tracedNot verified
UI-04lowA severity hue is used on non-report chrome (the unlock form error) where DESIGN.md licenses severity color only inside report excerpts.ui · app/unlock/page.tsx:93
Impact
The color system's one strict rule leaks; the likely right fix is codifying an error-state color rather than restyling.
Evidence
app/unlock/page.tsx:93 "border border-critical/40 bg-critical/5 ... text-critical".
Recommended fix
Codify a form-error state in DESIGN.md (probably this exact treatment) or restyle within the licensed palette.
Confidence: Code tracedNot verified
INF-01lowThe runtime container runs as root; no USER directive exists.infra · Dockerfile:48
Impact
Defense-in-depth gap softened by Fly's microVM isolation and the absence of any write surface in the app.
Evidence
Dockerfile has no USER directive; CMD runs node directly.
Recommended fix
chown the standalone COPYs and add USER node.
Confidence: Configuration confirmedNot verified
INF-02lowThe dockerignore excludes .env.local and .env.*.local but not a bare .env.infra · .dockerignore:9
Impact
No .env exists today and the runner COPYs explicit paths only, so exposure needs two future mistakes; cheap to close now.
Evidence
.dockerignore:9-10 lists only the .local variants.
Recommended fix
Add .env to .dockerignore.
Confidence: Configuration confirmedNot verified
OPS-03lowFailure diagnostics conflate causes: every verify error is captured as reason expired, and the version check's zero-events state is undocumented (broken endpoint and no adopters read identically).operability · app/auth/confirm/route.ts:58
Impact
The de facto dashboard misattributes outages to user error, and the one usage metric cannot distinguish silence from failure.
Evidence
app/auth/confirm/route.ts:58-60 captures reason "expired" for any error; CLAUDE.md's event plan documents the volume spike but not the zero case.
Recommended fix
Split the failure reasons; document the zero-events ambiguity next to the spike guardrail.
Confidence: Code tracedNot verified
OPS-04lowThe ops runbook is scattered (fly.toml comments, CLAUDE.md gotchas, an error boundary whose "was logged" claim is only true of the server digest), and the consolidation is already indexed but unshipped.operability · TODOS.md:117
Impact
Recovery from the two known auth failure modes depends on remembering where the notes live.
Evidence
TODOS.md:117 indexes the Operations section with rollback command and alerting confirmation as open work.
Recommended fix
Ship the indexed Operations section.
Confidence: Code tracedNot verified
TEST-02lownpm run validate:reports runs only the schema half of the gate, inviting a contributor to believe they ran make validate.testing-confidence · package.json:12
Impact
Invariants, bundled-copy, and version-marker drift pass silently until CI.
Evidence
package.json:12 maps validate:reports to ajv only.
Recommended fix
Point the script at make validate or rename it validate:schema.
Confidence: Code tracedNot verified
SHIP-05lowThe release protocol is designed but unexecuted: zero git tags exist, the procedure is not yet in CLAUDE.md, the changelog-touch CI check is absent, and the deployed endpoint can lag main for weeks, starving the update-prompt loop (grouped; all one protocol gap the repo already indexes as workstream A).release-safety · CHANGELOG.md:5
Impact
v0.1.0 was never tagged; two 0.1.0-labeled installs weeks apart can differ materially; after the next roll-up, existing installs learn nothing until someone remembers to deploy.
Evidence
git tag -l returns nothing; the v0.1.1 plan's workstream A holds the undone protocol steps; the update prompt depends on the deployed endpoint's version.
Recommended fix
Execute workstream A at the v0.1.1 roll-up: tag, release procedure in CLAUDE.md, changelog CI check, and make fly deploy an explicit step of the roll-up.
Confidence: Configuration confirmedNot verified
PERF-02lowSession lookups are un-memoized per render: a gated report view makes up to four Supabase getUser round trips (middleware, layout, metadata, page).performance-capacity · lib/supabase.ts:119
Impact
The largest real latency term after cold start on the gated path; React.cache removes all but one per render.
Evidence
getSessionUser called independently at middleware.ts:38, app/reports/layout.tsx:20, app/reports/[slug]/page.tsx:15 and :36.
Recommended fix
Wrap getSessionUser in React.cache.
Confidence: Code tracedNot verified
PERF-03lowThe reports index re-reads and re-parses every report file roughly three times per request, forever dynamic, with report count growing one file per audit.performance-capacity · lib/reports.ts:107
Impact
Nothing at the current count; hundreds of milliseconds per request on this hardware as the set approaches a thousand.
Evidence
lib/reports.ts:107-114 double-parse in listPublicReports; app/reports/page.tsx:64 adds a per-card third parse; layout forces dynamic.
Recommended fix
A per-request or process memo over the parsed report set before the collection grows.
Confidence: Code tracedNot verified
PERF-04lowThe version check's 10s timeout holds roughly a 2s margin over the measured cold start, and a timed-out check self-suppresses for the day.performance-capacity · fly.toml:31
Impact
Systematic undercount of exactly the low-frequency users whose first request is always cold; benign failure mode by design (no retry storm, user-invisible).
Evidence
fly.toml min_machines_running = 0; the check command stamps the rate-limit file whether or not curl succeeded.
Recommended fix
min_machines_running = 1, or accept and note the undercount next to the event plan's other honesty notes.
Confidence: Needs verificationNot verified
SEC-04informationalThe CSP allows 'unsafe-inline' scripts, removing its value as an XSS backstop.security · next.config.ts:24
Impact
No XSS vector was found (React escaping throughout, raw HTML disabled in markdown), so this is defense in depth only.
Evidence
next.config.ts:24 "script-src 'self' 'unsafe-inline' https://us-assets.i.posthog.com"
Recommended fix
Migrate to a nonce-based CSP when convenient.
Confidence: Configuration confirmedNot verified
SEC-05informationalThe ?sent= query value is reflected (React-escaped) into a trust-bearing confirmation sentence.security · app/unlock/page.tsx:36
Impact
A crafted link renders a legitimate-looking "link sent" page for an address the site never emailed; a lure surface, not an injection.
Evidence
app/unlock/page.tsx:36-38 renders "We emailed a one-time sign-in link to {sent}".
Recommended fix
Render a generic confirmation, or validate the reflected value's shape.
Confidence: Code tracedNot verified
A11Y-05informationalScreen-reader announcement polish across four small surfaces (grouped; each is an announcement or semantics nit).accessibility · components/CopyButton.tsx:40
Impact
Copy confirmation may be silent to SRs; the unlock input is never marked aria-invalid; the signed-out /reports page starts at h2; the terminal's output swap is visually rich, aurally silent.
Evidence
components/CopyButton.tsx:40 aria-label swap with no status region; app/unlock/page.tsx:85 aria-describedby without aria-invalid; app/reports/page.tsx:151 h2-first when signed out; components/Terminal.tsx:145 opacity-only reveal.
Recommended fix
role=status on the copy confirmation, aria-invalid on error, an h1 in the signed-out branch, aria-live on the terminal output if it ever becomes more than demonstrative.
Confidence: Code tracedNot verified
UI-05informationalDeck chips render command tokens in body gray, the only invocation strings on the site outside the amber command color.ui · components/SlideDeck.tsx:159
Impact
Minor divergence from the command-color convention on the Getting Started slide.
Evidence
components/SlideDeck.tsx:159 chips render text-bone-dim; the chip list mixes commands and non-commands, so the fix needs a per-chip flag.
Recommended fix
Add a per-chip command flag and color those chips text-command.
Confidence: Code tracedNot verified
INF-03informationalThe image ships three docs nothing can read (NOTES, PROMPTS, FRICTION are outside the render whitelist), and the COPY comment plus the CLAUDE.md gotcha overstate the runtime-read set; the retained dev-only vulnerable ajv-cli chain also lives here as an accepted, documented exception.infra · Dockerfile:42
Impact
About 110KB of dead image weight and a comment that will mislead the next editor; the dev-chain advisories never ship.
Evidence
Dockerfile:42 COPYs six docs; lib/docs.ts:13-17 whitelists three.
Recommended fix
Trim the COPY list and fix the comment and gotcha in the same change; revisit the ajv-cli chain when a non-breaking fix exists.
Confidence: Code tracedNot verified

Prioritised remediation plan

  1. 01

    Close the report-confidentiality seam before any private report lands

    The publish-time validate control, the getReport unexport, and the first pinning tests are one small change set that converts the audit's two everything-depends-on-it improvements into enforced invariants.

    SEC-01, TEST-01

  2. 02

    Ship the Operations section and one external signal

    The runbook TODOS already indexes, an uptime check on /api/version, the request-stage failure log, and the two documented recall paths.

    OPS-01, OPS-02, OPS-03, OPS-04, SHIP-04

  3. 03

    Execute the release protocol at the v0.1.1 roll-up

    The main ruleset, the deploy guard with a revision stamp, the named post-deploy checks, tags, and the changelog CI gate are all already-designed workstream A items.

    SHIP-01, SHIP-02, SHIP-03, SHIP-05

  4. 04

    Pre-onboarding auth hardening

    The interstitial confirm page, honest failure copy, the signed-in /unlock redirect, and the pre-auth identity hygiene, all before the first external report owner.

    REL-01, REL-02, CON-01, CON-02, SEC-02, SEC-03

  5. 05

    Accessibility and polish pass

    Focus management on the deck and scorecard first; the rest is one short sweep.

    A11Y-01, A11Y-02, A11Y-03, A11Y-04, A11Y-05, UI-01, UI-02, UI-03, UI-04, UI-05

Quick wins: low regression risk

  • Add .env to .dockerignore

    Blast radius: One config line; no behavior change.

  • Wrap getSessionUser in React.cache

    Blast radius: One import and one wrapper; removes up to three network calls per gated render.

  • Sixth promise into the two meta descriptions

    Blast radius: Two strings; search previews only.

  • Interpolate REPO_SLUG on the two hardcoded install commands

    Blast radius: Two template literals.

  • AbortSignal.timeout on the GitHub star fetch

    Blast radius: One fetch option; build-time resilience only.

Requires deeper investigation

  • Supabase dashboard configuration

    The email rate limits, template link shape, and redirect allowlist all live in dashboard state the repo cannot show; SEC-02 and REL-01's mitigations and the cutover checklist depend on them.

  • Version-check endpoint under load

    PERF-01's degradation shape (connection growth vs posthog-node's internal queue absorbing the burst) is only sizeable with a live load test against the Fly machine.

Not assessed: runtime-only checks skipped

  • Device-level mobile verification matrix

    To verify: Run the mobile skill's matrix (widths, landscape, text zoom, keyboard, real devices) against the current surface.

  • Identity stitch end to end

    To verify: One magic-link sign-in with capture enabled; confirm PostHog shows one actor across the pre-auth and authed events.

  • Mail-scanner magic-link consumption

    To verify: Send a link to a Defender SafeLinks or Mimecast test mailbox; observe whether the human click lands expired.

  • Rendered contrast and screen-reader behavior

    To verify: axe plus VoiceOver/NVDA pass on the deck, scorecard, copy confirmation, and unlock error announcement.

  • Cold-start latency and the check's 10s margin

    To verify: Stop the machine, time five cold curls of /api/version, compare p95 to the timeout.

  • Secret scan with a real scanner over tracked history

    To verify: gitleaks or trufflehog across the repo history (the public history is a single orphan commit, so the surface is small).

  • Deployed-artifact identity

    To verify: After the next deploy, confirm the live /api/version equals the repo VERSION and the image was built from a clean main checkout.

Mechanical sweeps

  • dependency-auditfindingsPre-fix: 9 high, 1 moderate. The one reachable advisory (next 16.2.9, unauthenticated Server Actions CPU exhaustion, GHSA-m99w-x7hq-7vfj) was confirmed by an adversarial skeptic and fixed in-branch (next 16.3.1, non-major), which also cleared the sharp, postcss, and server-function-disclosure advisories; npm audit fix swept the dev-only brace-expansion pair. Post-fix: 2 high remain, both in the ajv-cli devDependency chain (validation tooling, never shipped); npm's proposed fix is a breaking downgrade and was rejected on the record (see INF-03).
  • secret-scancleanFallback pattern grep over source and config found no secret material; the only committed key is the PostHog browser token, public by design. A real scanner over tracked history remains worth one run; see notAssessed.
  • typecheckclean
  • lintclean