Our own audit. We ran /production-audit on Foundry's own public repo. Check it against the source.
All reports →Audit report · v0.1.0
Foundry
Release recommendation
Safe to ship
Static review · runtime not exercised
No risks. The core controls are verified clean; what remains are improvements to schedule, not blockers.
Why this verdict
Assessed statically, no runtime pass. No risks: the three findings that carried a path to harm, an unguarded report parse that could 500 the index, a silent auth lockout, and a magic-link origin taken from a forwarded host, were fixed in this repo and verified, so nothing here blocks a ship within the assessed scope. What remains is improvements, robustness, observability, consistency, and accessibility work that make the app better over time but do not cap the verdict. Runtime-only checks (real-device mobile, live funnel delivery, contrast) stay listed under Not assessed.
Not assessed this run: data-migration-safety
What's solid (13)
Report and doc access control
Path traversal is blocked before any filesystem read: getReport validates the slug against a strict character class and re-checks that the resolved path startsWith the reports directory (lib/reports.ts:70-76), the skills reader applies the same guard (lib/skills.ts:167-170), and the docs reader uses an exact-match ALLOWED whitelist fed only hardcoded literals (lib/docs.ts:13-21). Encoded traversal, absolute paths, and null bytes all fail.
Authentication posture
Auth is fail-closed at every layer: missing Supabase env yields a null client (lib/supabase.ts:25,70) and denial in middleware, the /reports layout, and both /reports pages independently (middleware.ts:17, app/reports/layout.tsx:20-23, app/reports/page.tsx:18, app/reports/[slug]/page.tsx:36). Identity is validated with supabase.auth.getUser() rather than trusted from the cookie (lib/supabase.ts:99-113), the admin check is an exact case-insensitive element match (lib/supabase.ts:120-129), non-owner detail slugs return 404 not 403, and the ungated /example route serves only meta.public reports (lib/reports.ts:81-84).
Output safety
No XSS sink on any author- or user-influenced content: react-markdown v10 renders with remark-gfm only and no rehype-raw, so embedded HTML is escaped (components/MarkdownDoc.tsx); all report fields render as auto-escaped React children; the single dangerouslySetInnerHTML is a static JSON-LD constant (app/layout.tsx). No innerHTML, eval, or new Function anywhere, and no open redirect (auth targets are hardcoded).
Security headers and CSP
A complete enforcing header set is dogfooded: a real CSP with default-src 'self', object-src 'none', frame-ancestors 'none', base-uri 'self', form-action 'self', and connect-src scoped to the two exact PostHog hosts, plus HSTS, X-Frame-Options DENY, nosniff, and a locked Permissions-Policy (next.config.ts:12-54). The connect-src is deliberately narrow, honoring the recorded lesson that a wide-open policy once broke analytics.
Analytics safety
Analytics can never break a flow: the client no-ops when its key is unset (instrumentation-client.ts:10, guarded at every call site), and server capture returns a null client without a key and is never awaited, with flushAt:1/flushInterval:0 so events are not lost when the Fly machine auto-stops (lib/posthog-server.ts:18-37).
Instrumentation completeness
The activation funnels are instrumented end to end in code: two funnels on one identity across client and server, with the Supabase user id as the distinct id and the pre-auth email id aliased on sign-in, defined as a table in CLAUDE.md before the code, and the code conforms to it. This is the shape the instrumentation gate looks for.
Magic-link replay handling
Replay of a used or expired link is handled cleanly: verifyOtp returns an error routed to /unlock?error=expired with no half-session established (app/auth/confirm/route.ts). The token_hash is deliberately redeemable cross-device for email opens, a documented tradeoff rather than a defect.
Design token discipline
No hardcoded colors in any rendered style; every visible color routes through a @theme token, the severity and verdict color maps use full literal class strings to survive JIT purging (components/report-ui.tsx), and no color-only signaling exists (every severity and verdict hue is paired with a text label). Consistent 44px touch targets and a spec-accurate focus-visible ring throughout.
Reduced motion and semantics
prefers-reduced-motion is honored at multiple layers (a global duration kill plus per-effect overrides and JS guards that never attach the pointer tracker or run the terminal animation under reduced motion), there is one h1 per page with MarkdownDoc shifting doc headings down a level, the magic-link form is labeled with a role=alert error, and decorative graphics are correctly aria-hidden.
Report contract enforcement
A real content-contract check exists and guards against drift: make validate schema-validates every report, runs the cross-field invariants (blocking findings must be verified, severity stats must match, no em dashes, no personal paths), and cmp-guards the bundled skill schema copy against the canonical one, protecting the contract both the installed skill and the site renderer depend on.
Guarded report rendering
The report readers skip a malformed or shape-invalid file rather than throwing (lib/reports.ts readReport), and a root error boundary degrades any unexpected error to a recoverable page (app/error.tsx), so a single bad report can no longer 500 the index for every viewer.
Observable auth failures
The fail-closed auth path logs the underlying verifyOtp/exchangeCodeForSession error, and a missing Supabase env is logged once at the client boundary, so a total lockout shows up in the logs (Fly captures stdout and stderr) rather than failing silently (app/auth/confirm/route.ts, lib/supabase.ts).
Pinned magic-link origin
The magic-link redirect target is taken from a server-only SITE_ORIGIN (fly.toml), not a client-influenceable X-Forwarded-Host, so a spoofed forwarded host cannot point the emailed link at another origin (lib/supabase.ts publicOrigin).
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 (21)
Safe today. Ways to make the app more robust, observable, and consistent over time. These do not affect the verdict.
TEST-01mediumThere is no test suite: no test runner, no test files, and no test script in package.json. The four critical controls, the ownership check (lib/reports.ts:27), the slug path-traversal guard (lib/reports.ts:70), the meta.public filter that keeps private reports off /example (lib/reports.ts:81), and the ALLOWED doc whitelist (lib/docs.ts:13), are all pure functions and none is pinned by a test.testing-confidence · package.json:8
Confirmed by a repo scan: no test script in package.json and no test files or runner present.
SHIP-01mediumNo CI gates the deploy. There is no .github/workflows or CI config of any kind, and deploy is a manual fly deploy from main. Typecheck, lint, build, and make validate exist but are human-invoked, never enforced.release-safety · fly.toml
Confirmed by a repo scan: no .github/workflows and no CI config present.
19 more · hygiene and polish›
SEC-02lowNo application-level rate limiting on the magic-link request action; it can be driven to send repeated sign-in emails to any address.security · app/unlock/actions.ts:18 · requestMagicLink
INF-01lowThe X-Powered-By: Next.js framework header is not disabled (poweredByHeader is not set to false), so every response advertises the framework.infra · next.config.ts:29
INF-02lowThe runtime container never switches to a non-root user (no USER directive), so the Node process runs as root.infra · Dockerfile:26
CON-01lowgetSessionUser is not request-memoized, so a single gated request validates the session up to three or four times (middleware, layout, generateMetadata, page). getUser triggers refresh-token rotation near expiry and the server client has no cross-request lock, so concurrent near-expiry requests (multiple tabs, or a Link prefetch alongside a real navigation) can each attempt a rotation and prematurely sign the user out. The same fan-out adds redundant auth round trips.concurrency · lib/supabase.ts:103 · getSessionUser
Calibrated the finder's medium to low: the failure is recoverable by re-authentication and requires concurrent near-expiry requests.
REL-02lowNo timeout on any Supabase auth call (signInWithOtp, verifyOtp, exchangeCodeForSession, getUser). If Supabase hangs rather than errors, the request blocks until the platform timeout with no user-visible timeout state.reliability · app/unlock/actions.ts:35
REL-03lowAn auth-unavailable condition is indistinguishable from signed-out. When getUser errors because Supabase is unreachable during an active session, only data.user is read (null), so the user is redirected to /unlock as if signed out, where the magic-link request would also fail.reliability · lib/supabase.ts:109 · getSessionUser
CON-02lowThe CopyButton success timeout (a setTimeout that resets the copied state after 1.6s) is never tracked or cleared. If the button unmounts within that window, the state setter runs after unmount.concurrency · components/CopyButton.tsx:26
OPS-02lowThin observability. The Fly health check is liveness-only (GET /), so it confirms the landing page renders but never exercises the auth gate or Supabase; there is no structured logging on the critical auth and report flows, and no rollback runbook, SLO, or named operational owner.operability · fly.toml:31
PERF-01lowFilesystem reads are not memoized. The /reports index parses the entire reports directory on every request (the subtree is force-dynamic and cookie-gated), and the detail routes parse the same file twice per render (generateMetadata plus the page body). Reports are immutable build artifacts, so caching is trivially safe.performance-capacity · lib/reports.ts:36 · listReports
PERF-02lowThe site runs on a single scale-to-zero 512MB machine (min_machines_running = 0 with auto-stop). The first request after an idle window pays a full machine boot plus Node standalone start, and a single VM has no horizontal headroom.performance-capacity · fly.toml:23
UI-01lowThe two nav bars use different active-underline treatments, and neither matches the design spec. The header nav underlines the active tab in line-strong (a dim slate) while the Behind-the-Build nav uses a signal (white) bottom border, and DESIGN.md specifies the active tab underlined in bone.ui · components/NavLink.tsx:38
UI-02lowThere is no shared Button primitive. The filled-bone primary button is hand-reimplemented in three places with divergent padding, casing, and press feedback, and the bordered ghost button appears with different text colors and padding across four sites.ui · components/SubmitButton.tsx:22
A11Y-01lowThe skip link targets #main, but no main element carries tabindex=-1 and main is not focusable by default. Where the browser does not set a sequential-focus starting point on fragment navigation, activating the skip link only scrolls and the next Tab returns to the header.accessibility · app/layout.tsx:83
SEC-03informationalThe type query param is cast as EmailOtpType without validation before verifyOtp. This is not exploitable: Supabase rejects an invalid type server-side and it falls through to the error redirect, never establishing a session. Noted because the cast masks that the value is attacker-controlled.security · app/auth/confirm/route.ts:27
INF-03informationalnpm audit reports advisories in two dependencies, both confirmed non-runtime. postcss (moderate, via Next) is a build-time CSS compiler and is absent from the standalone runtime image, and ajv-cli with fast-json-patch (high, prototype pollution) is a devDependency backing make validate that is never shipped.infra · package.json:22
INF-04informationalThe CSP script-src includes 'unsafe-inline' with no nonce or hash strategy, the standard Next-without-nonces tradeoff needed for the inline bootstrap.infra · next.config.ts:24
UI-03informationalThe sign-in form error state renders in the severity critical hue on brand chrome, while DESIGN.md's rule is that color means a severity or a verdict and nothing else. There is no dedicated error or negative token, so the error reuses the severity red.ui · app/unlock/page.tsx:93
A11Y-02informationalAfter a copy, the button label and aria-label change to Copied, but nothing is announced to assistive tech: there is no role=status or aria-live region, and a changed aria-label on a control the user has moved past is not re-read. Copy-install is the declared adoption event, so the confirmation matters.accessibility · components/CopyButton.tsx:36
A11Y-03informationalOn a form error the email input is correctly described by the error via aria-describedby and the error carries role=alert, but the input itself is never marked aria-invalid, so assistive tech does not flag which field is in an error state.accessibility · app/unlock/page.tsx:78
Prioritised remediation plan
- 01
Land the test and CI safety net before the public flip
Add the four pure-function tests that pin the gate controls (ownership, slug guard, meta.public filter, doc whitelist) and a CI workflow that runs typecheck, lint, build, and make validate on pull requests to main, required green before the visibility flip. The controls are correct today; these keep a future refactor from silently regressing them once main is the install channel.
TEST-01, SHIP-01
- 02
Deepen observability and memoize the read path
Add an alert with a named receiver on the now-logged auth failures and a health probe that touches the gate path, and wrap the report readers in React.cache plus a module-level cache so the index does not reparse the whole directory per request. Both are robustness, not blockers.
OPS-02, PERF-01
Quick wins: low regression risk
Disable the X-Powered-By header
INF-01Blast radius: None. Setting poweredByHeader: false only removes a fingerprint header; no functionality depends on it.
Run the container as the node user
INF-02Blast radius: Low. Add USER node plus a chown of the app directory; verify the standalone server still binds its port and reads the COPY'd docs and reports as the non-root user.
Add tabindex=-1 to the main landmark for the skip link
A11Y-01Blast radius: None. Making main programmatically focusable does not change tab order for other controls.
Requires deeper investigation
Request-level memoization of session validation and file reads
CON-01 and PERF-01 share a root: getSessionUser and the file readers run several times per request with no React.cache. Wrapping the leaf readers in React.cache removes the redundant auth round trips and file parses, and collapses the low-severity refresh-rotation race, in one idiomatic change.
CON-01, PERF-01
Not assessed: runtime-only checks skipped
Color contrast of text and interactive elements against their backgrounds
To verify: Run axe-core or Lighthouse against the running site on the landing, skills, reports, and example pages and review any contrast violations.
Real-device mobile behavior on the core flows
To verify: Load the sign-in and reports flows on a real iOS Safari and Android Chrome device, check zero horizontal overflow at 393px, and confirm the keyboard does not occlude the submit control.
Live activation-funnel event delivery
To verify: Walk the sign-in and report-view flows against the running site and confirm the funnel events land in PostHog on a single distinct id across client and server.
Skip-link focus landing and scroll-reveal opacity at page bottom
To verify: In a Chromium build, activate the skip link then press Tab and confirm focus is inside main, and scroll to the last section before the footer and confirm it is fully opaque.
Cold-start and gated-route latency
To verify: Measure first-byte with curl against the landing and a gated route after the machine has auto-stopped, and again warm, and report both.
Mechanical sweeps
- dependency-auditfindingsTwo moderate and two high advisories, all confirmed non-runtime: postcss (moderate) is Next's build-time CSS compiler and is absent from the standalone runtime image, and ajv-cli with fast-json-patch (high) is a devDependency backing make validate. Neither ships to production. Recorded as INF-03.
- secret-scancleanNo committed secrets in source. All credentials are read from the environment, .env.local is gitignored and excluded from the Docker build context, and only .env.example (placeholders) is tracked. No service-role key usage found.
- typecheckcleantsc --noEmit passes with no errors.
- lintcleaneslint passes with no errors.