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.1.0

Foundry

commit 7d85850audited 2026-07-164,700 loc10 dimensions · static only

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.

2 verified1 downgraded
GatesMobile Not assessedInstrumentation Met

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
Impact
A refactor that inverts the ownership check, loosens the slug regex, or drops the meta.public filter would compile, typecheck, lint, and deploy green while silently opening the gate. This is the highest-leverage untested surface, and it matters more as main becomes the public install channel.
Evidence
No test script in package.json; no *.test.* / *.spec.* / __tests__ in the repo; no vitest, jest, or playwright dependency.
Recommended fix
Land four pure-function unit tests for the controls above plus a test script, and make them a required CI gate before the public flip.
Confidence: Configuration confirmedVerified · survived refutation (repro script)

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
Impact
A bad version, a broken SKILL.md, a schema fork between the canonical and bundled schema copies, or a compile break reaches production the moment someone runs fly deploy. Per the publish plan, main is about to become the live npx skills add install channel, so an unguarded break would land directly in installers.
Evidence
No .github directory; deploy documented as manual fly deploy; typecheck, lint, build, and validate are npm and make targets with no workflow invoking them.
Recommended fix
Add a CI workflow running npm ci, typecheck, lint, build, and make validate on pull requests to main, and require it green before the visibility flip.
Confidence: Configuration confirmedVerified · survived refutation (repro script)

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
Impact
Email-bomb or cost, bounded only by Supabase's own per-email and per-IP OTP limits, which are the actual control and live in Supabase config rather than the repo.
Evidence
requestMagicLink calls signInWithOtp with no local throttle (app/unlock/actions.ts:18-38).
Recommended fix
Rely on Supabase OTP rate limits (confirm they are enabled) and add a lightweight per-IP throttle at the edge if abuse appears.
Confidence: Code tracedNot verified
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
Impact
Framework fingerprint disclosure. Not exploitable, but the one gap in an otherwise curated header set.
Evidence
The Next config sets output and headers but never poweredByHeader: false (next.config.ts:29-55).
Recommended fix
Add poweredByHeader: false to the Next config.
Confidence: Configuration confirmedNot verified
INF-02lowThe runtime container never switches to a non-root user (no USER directive), so the Node process runs as root.infra · Dockerfile:26
Impact
A standard container-hardening gap. Blast radius is contained by Fly's per-app Firecracker microVM, so it is not directly exploitable here, but running as root is unnecessary.
Evidence
The Dockerfile runner stage has no USER node; the node base image ships a node user.
Recommended fix
Add USER node (and chown of the app directory) in the runner stage.
Confidence: Configuration confirmedNot verified
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
Impact
A user with a near-expiry token and concurrent requests can be bounced to /unlock mid-session. Recoverable by signing in again and dependent on specific timing, hence low; downgraded from the finder's medium on that recoverability.
Evidence
getUser used in middleware.ts:38, lib/supabase.ts:111 (layout), and app/reports/[slug]/page.tsx:16 and :35 with no React.cache; the server createServerClient has no navigator.locks serialization.
Recommended fix
Wrap getSessionUser in React.cache so the layout, generateMetadata, and page share one validated result per request; treat middleware as the single refresher; enable the Supabase refresh-token reuse interval.
Confidence: Code tracedDowngraded on verification (fresh subagent)

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
Impact
Degraded experience on a Supabase slowdown, with no distinct timeout signal to the user.
Evidence
Default supabase-js fetch with no AbortController at the auth call sites (app/unlock/actions.ts:35, app/auth/confirm/route.ts:60,69).
Recommended fix
Wrap auth calls in an AbortController timeout mapped to the existing error=failed and error=unavailable states.
Confidence: Code tracedNot verified
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
Impact
A transient auth outage looks to the user like an expired session, with no temporarily-down-retry signal.
Evidence
getSessionUser reads only data.user and discards the error (lib/supabase.ts:109-113).
Recommended fix
Inspect the getUser error and route a transient or unavailable case to a distinct message.
Confidence: Code tracedNot verified
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
Impact
A latent timer leak and a post-unmount state update. Harmless under React 19 (no warning or crash), but the one component that does not follow the clean-timer pattern the Terminal uses.
Evidence
window.setTimeout with no ref or cleanup (components/CopyButton.tsx:26); the Terminal clears all timers on unmount (components/Terminal.tsx:133).
Recommended fix
Store the timer id in a ref and clear it in a cleanup effect.
Confidence: Code tracedNot verified
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
Impact
The gate can be fully broken (locking out users) while the health check stays green, and recovery depends on operator memory. A fast platform rollback exists (fly releases plus a redeploy), which caps this at low.
Evidence
A single GET / check (fly.toml:31-36); no console logging or logger in app, lib, or middleware; no runbook or SLO in the docs.
Recommended fix
Add a deeper health probe that touches the gate path, minimal structured logging at the auth-failure and report-read-failure sites, and an Operations section with the rollback command, the known failure modes, and a named owner.
Confidence: Code tracedNot verified
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
Impact
Negligible at current scale (one report). It bites around the low hundreds of reports, where each index request does hundreds of synchronous readFileSync plus JSON.parse calls on the request path, and it doubles the parse cost on every detail render.
Evidence
listReports reads and parses all files with no cache (lib/reports.ts:36-55); getReport and getPublicReport re-read per call in generateMetadata and the page body (app/reports/[slug]/page.tsx:26,40).
Recommended fix
Wrap the leaf readers in React.cache and hold a module-level cache keyed on file mtime; reports are immutable so invalidate only on cold start.
Confidence: Code tracedNot verified
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
Impact
Cold-start tail latency, most visible on the gated /reports path that also fans out to Supabase on that same cold request. A reasonable posture for a low-traffic site, stated on the record.
Evidence
auto_stop_machines with min_machines_running = 0 and a single shared-cpu-1x 512MB VM (fly.toml:23-37).
Recommended fix
None required at current traffic. Set min_machines_running = 1 if cold-start p95 proves painful; measure cold versus warm first-byte to quantify.
Confidence: Needs verificationNot verified
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
Impact
Two tab systems disagree with each other and with the stated spec.
Evidence
NavLink active uses decoration-line-strong (components/NavLink.tsx:38); BehindNav uses border-signal (components/BehindNav.tsx:33); DESIGN.md:85 specifies bone.
Recommended fix
Pick one active-underline token for both bars and align it with DESIGN.md, ideally sharing one active-state helper.
Confidence: Code tracedNot verified
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
Impact
The same visual roles drift across implementations, and a style change must be made in several places.
Evidence
Primary reimplemented at components/SubmitButton.tsx:22, app/page.tsx:135, and app/page.tsx:396; ghost variants at app/page.tsx:141, components/SiteHeader.tsx:42 and :51, and app/not-found.tsx:18.
Recommended fix
Extract one Button/ButtonLink with primary and secondary variants so padding, casing, and the press micro-interaction are defined once.
Confidence: Code tracedNot verified
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
Impact
The skip-to-content control (WCAG 2.4.1) may not move focus into main on some engines.
Evidence
Skip link at app/layout.tsx:83-88; no tabIndex on any main id="main" (for example app/page.tsx:110).
Recommended fix
Add tabIndex={-1} to each main id="main" so focus reliably lands in the main region.
Confidence: Needs verificationNot verified
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
Impact
None today; a future refactor that trusts the cast elsewhere could regress.
Evidence
type is read and cast without a whitelist (app/auth/confirm/route.ts:27,59-63).
Recommended fix
Optional. Whitelist type against the expected OTP types before the call.
Confidence: Code tracedNot verified
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
Impact
No production attack surface; local audit noise only.
Evidence
postcss resolves under next and is not present in .next/standalone; ajv-cli is under devDependencies and not in the runtime image.
Recommended fix
No runtime action. Track the Next release that bumps postcss; optionally move report validation off ajv-cli to clear the local audit output. Do not run npm audit fix --force, which would downgrade Next.
Confidence: Configuration confirmedNot verified
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
Impact
Lowers XSS defense-in-depth, but the app renders no user-controlled HTML into a script context (report data is typed JSON through React, escaped by default), and the rest of the CSP is tight.
Evidence
script-src 'self' 'unsafe-inline' with the PostHog assets host (next.config.ts:24).
Recommended fix
Optional. Adopt a nonce-based CSP via middleware if inline-script defense-in-depth becomes warranted.
Confidence: Configuration confirmedNot verified
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
Impact
A defensible but real divergence from the stated color-meaning rule.
Evidence
The error message uses border-critical, bg-critical, and text-critical (app/unlock/page.tsx:93); DESIGN.md reserves severity hues for report excerpts.
Recommended fix
Add a documented error or negative semantic token distinct from severity critical, or record an explicit DESIGN.md exception for form errors.
Confidence: Code tracedNot verified
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
Impact
Screen-reader users get no confirmation of a successful copy.
Evidence
The label and aria-label swap on a timeout with no live region (components/CopyButton.tsx:36-45).
Recommended fix
Add a visually-hidden role=status (aria-live=polite) element that renders Copied when the state flips.
Confidence: Code tracedNot verified
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
Impact
Minor; the error is currently form-level, so this is a refinement rather than a break.
Evidence
The input has aria-describedby but no aria-invalid (app/unlock/page.tsx:78-97).
Recommended fix
Set aria-invalid on the input when an error is present.
Confidence: Code tracedNot verified

Prioritised remediation plan

  1. 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

  2. 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-01

    Blast radius: None. Setting poweredByHeader: false only removes a fingerprint header; no functionality depends on it.

  • Run the container as the node user

    INF-02

    Blast 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-01

    Blast 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.