Files
sim/scripts/check-api-validation-contracts.ts
T
Vikhyath MondretiandClaude Opus 5 521348b529 feat(secrets): record which secrets each run resolves, and surface it per secret (#6823)
* feat(secrets): record which secrets each run resolves and surface it per secret

Redaction stops a value at a boundary but cannot stop code that never emits it —
a Function block can print a key one character at a time and nothing ever matches
the secret. That is undecidable in general, so this adds the other half of the
posture: attribution.

Every run now records which configured secrets it actually resolved, under whose
identity, through which surface (workflow, Sim agent, MCP). The data already
existed in ResolvedSecretTraceRegistry.addActiveEntry and was persisted only for
paused runs; this persists it for every terminal path.

Execution logs cannot answer this. They store the whole available encrypted
environment rather than what a run referenced, they evidence a secret only where
value-matching redaction happened to fire, and they expire under
logRetentionHours — while "who has touched this key" outlives any single run.

- secret_usage: per-UTC-day rollup keyed by workspace, secret, scope, owner,
  source, workflow, actor. A one-minute schedule touching three secrets would
  otherwise write thousands of rows a day, which is also why this is not
  audit_log. workflow_id/actor_user_id use '' sentinels rather than null so the
  unique key works on Postgres 14 without NULLS NOT DISTINCT, and are not FKs:
  they are historical facts, and an onDelete would rewrite a key column.
- secret_owner_user_id is part of the key. Two people can hold a personal secret
  under one name and a shared personal secret resolves for a caller who does not
  own it, so name and scope alone do not identify a secret. It is NOT the actor:
  a scheduled run resolves the workflow owner's personal slice under the
  workspace's execution actor.
- Direct environment reads are now detected in JS (TypeScript AST), Python
  (tokenizer-checked) and shell (quote/heredoc-scanned), so a secret read as
  environmentVariables['K'] or $K enters the run's provenance instead of going
  unredacted. Each detector prescans for names that are actually configured
  secrets before paying for a lex or quote-frame pass.
- Copilot integration tool calls are covered: resolveCopilotEnvReferences
  substitutes {{SECRET}} into user-only params, which is a real use.
- See usage lives behind a credential-admin gate, using the same predicate that
  reveals the value; members get a disabled chip explaining why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(audit): register the secret-usage route in the validation baseline

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): keep rollup metadata with its run, and stop shadowed bindings faking usage

Review round 1.

- record.ts: last_execution_id/last_trigger were assigned unconditionally while
  last_used_at was chosen by greatest(), so two runs completing out of order split
  one row between them — the newer run's timestamp beside the older run's execution
  id, making "View log" open a run the row does not describe. Both are now guarded
  on the timestamp actually advancing, so the row's metadata always belongs to the
  run that owns its timestamp.
- javascript.ts: a local binding named environmentVariables (declaration, parameter,
  destructured binding, or bare reassignment) made reads off the user's own object
  look like mounted-secret reads. Any such binding now disables detection for the
  file; the AST already had parent pointers, so this is a kind check during the
  existing walk.
- python.ts: same class of bug with no parser available, so the rule is an allowlist
  — every mention of the binding must be a literal subscript or .get(), otherwise
  detection is off for the file. This also subsumes the cross-line attribute case
  (other.\n environmentVariables['K']), which the previous space-and-tab look-behind
  missed.

Under-reporting is the safe direction here: a trail that claims a use that never
happened is worse than one that misses a use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(db): format the generated migration snapshot

CI runs lint:check across every workspace; the drizzle-kit output in packages/db
had never been through biome, so the branch was green locally (where lint had
only been run inside apps/sim) and red on CI. Whitespace only — both files are
byte-for-byte identical once parsed, and drizzle-kit still reports no pending
schema diff against the reformatted snapshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): detect every rebinding of the environment identifier, not just declarations

Review round 2. A bare `for (environmentVariables of rows)` has no declaration to
key off, so the previous check missed it and reads of the loop value were still
recorded as secret usage.

Rather than extend the hand-rolled node-kind list, this reuses the pair the same
file already applies to reject a placeholder in a write position:
isDeclarationIdentifier covers declarations, parameters, destructured bindings and
imports, and isWriteIdentifier covers every assignment operator, ++/--,
destructuring targets, and for-in / for-of initializers.

That also closes four forms neither the review nor the original check named:
logical (||=) and nullish (??=) assignment, and object and array destructuring
assignment. Six of the eight added cases fail against the previous check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): apply the rebinding rule to shell, and say when a run's log is gone

Review round 3, plus the docs that were left claiming the old behavior.

- shell.ts: a script that writes a configured name (API_KEY=local, export/local/
  readonly, read, for, unset) expands its own value from that point on, not the
  mounted secret, so recording it claimed a use that never happened. Every mention
  of the name must now be a `$NAME` / `${NAME}` expansion, matching the allowlist
  shape the Python detector already uses. Applied per name rather than per file:
  JavaScript and Python shadow one object holding every secret, whereas rebinding
  one shell variable says nothing about the rest.

- The usage trail deliberately outlives execution logs, so a row routinely names a
  run whose log has been pruned. The read now left-joins workflow_execution_logs on
  its unique execution_id and reports availability, and the panel renders the chip
  disabled with the platform tooltip instead of linking into an empty Logs view.
  Three states: no run to link, a run whose log is gone, and a live link.

- Docs said a direct environmentVariables/$KEY read does not activate masking,
  which this branch changes. Corrected in credentials.mdx, function.mdx and the
  logging FAQ, and the recognition limits are now written down: runtime-built
  names, reassigned bindings, and reads that cannot be told apart from text.
  Added a "See usage" section covering who can see it and why an empty trail
  means "nothing recognized" rather than "never used".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): writing a name is not reading it, and a bare mention is not a rebinding

Review round 4.

- javascript.ts / python.ts: `environmentVariables.API_KEY = 'x'` and
  `delete environmentVariables.API_KEY` touch the name without ever reading the
  mounted value, but the detectors matched the member access and recorded a use
  that never happened. JavaScript now asks the same isWriteIdentifier the
  placeholder rewriter uses (its parameter is widened to ts.Node — the body
  already walked generic nodes, so this is a type change, not a behaviour one)
  plus a delete check; Python excludes a subscript followed by `=` and a `del`
  target.

- shell.ts: requiring every mention of a name to be an expansion also fired on
  text that binds nothing — a comment naming the key, or `echo "API_KEY=$API_KEY"`
  where the literal is an argument rather than an assignment — and dropping those
  cost masking on a genuine read. It now looks for actual writes: an assignment at
  command-word position, a binding builtin, `printf -v`, or a `for` target.

  The two directions are not symmetric, which is why this errs toward detecting
  the read: missing a write records a use of a secret the script only had in its
  environment, a misleading audit row and nothing more, since masking still
  searches for the real value and will not find it. Over-detecting a write
  suppresses masking on a value that does reach the log.

  This also makes the code match what the docs already described — skipping after
  a rebinding, not after any mention.

13 tests added; 11 fail against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): an update reads before it stores, and a del target may be parenthesized

Review round 5. The first of these is a regression from round 4.

- javascript.ts: reusing isWriteIdentifier to answer "is this a read" was wrong.
  That predicate answers the rewriter's question — is this a target the
  substitution must refuse — so it treats every assignment operator alike, which
  is correct there and wrong here: `+=`, `||=`, `??=`, `++` and `--` all load the
  current value before storing, so they are genuine reads and were silently
  losing their masking. Only a plain `=` stores without reading. Replaced with a
  purpose-named predicate, and isWriteIdentifier's parameter is narrowed back to
  ts.Identifier now that nothing else needs it widened.

  A test committed last round asserted the wrong behaviour for `+=`; it has been
  corrected rather than left to pin the bug.

- python.ts: `del (environmentVariables['K'])` slipped past a check that looked
  only at the characters immediately before the match. It now isolates the
  enclosing logical line and tests whether that is a del statement, which also
  covers `del((x))`, `del(x)`, `del a, x`, and a del after a semicolon.

12 tests added or corrected; 10 fail against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): stop excluding Python writes, which kept leaking in the unsafe direction

Review round 6. Greptile found that `del environmentVariables[environmentVariables['K']]`
had its inner access — which computes a key, so it is a genuine read — skipped
along with the delete, leaving that value unmasked.

The narrow fix was another textual rule. Instead this removes the write and delete
exclusions from the Python detector entirely, because they were optimizing the
wrong direction.

`resolvedSecretNames` feeds `outputSecretMatcher`, an exact-value matcher over the
output. Naming a secret the code never read costs nothing there: the matcher scans
for a value that does not appear. Failing to name one that was read leaves it
unmasked. The two error directions are therefore not comparable, and the
exclusions bought only audit-trail tidiness while every heuristic they needed has
so far leaked into the dangerous side — first a parenthesized target, now a nested
read. A `del` or an assignment is reported like any other access.

JavaScript keeps its exclusion: a real AST answers the question per node, with no
text to misread, and it has produced no such hole.

Net 30 lines removed from python.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): report recognized reads instead of proving they are not reads

Review round 7. Greptile flagged both directions at once — false usage from
reporting a write target, and unmasked secrets from the file-wide shadow flag —
so I traced what the signal actually drives before choosing.

The chain: the compiler's names feed outputSecretPlaintextsByName and the
exact-value matcher, NOT context.resolvedSecretNames, which starts empty. After
execution activateOutputSecretProvenance scans the output and adds only names
whose plaintext actually appeared; those become __resolvedSecretNames, which
tools/index.ts turns into recordResolved calls, which is what the usage trail
reads.

So a compile-time false positive produces no usage row on the ordinary path — it
only hands the matcher a value the code never emits. It does produce one on the
!projection.safe fallback, where the system already over-approximates by design.
A false negative, by contrast, keeps the value out of the matcher entirely, so a
genuinely read secret is never masked on any path.

That asymmetry decides it, so every "prove this is not a read" mechanism is gone:

- javascript.ts: the file-wide shadow flag. A helper declaring its own
  environmentVariables discarded genuine reads of the mounted binding everywhere
  else in the file — Greptile's security finding, and real.
- python.ts: the allowlist requiring every mention to be a subscript or .get().
  Same hole: passing the dict to a function suppressed unrelated reads.
- shell.ts: the rebinding check. It had the same hole in a form nobody flagged —
  `echo "$API_KEY"; API_KEY=local` dropped the first read, which is of the real
  secret.

What stays is the question of whether the text is code at all — strings, comments,
single quotes, quoted heredocs — plus the receiver check that `other.environment
Variables['K']` is a different object, and JavaScript's node-precise write/delete
exclusion, which cannot suppress a read elsewhere.

Net 215 lines removed across the three detectors and their tests. Docs updated:
the rule is now stated as reporting rather than proving, and that See usage may
occasionally list a secret the code had available but did not read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(secrets): drop the last write-vs-read special case

`environmentVariables` is a plain object deserialized from the run payload
(route.ts:206), not a handle on the stored secret. Assigning to it changes
nothing outside the sandbox and is discarded when the run ends, so separating a
write from a read bought almost nothing while leaving JavaScript as the one
language still trying to prove a read is not a read.

Every language now follows the same rule: report a recognized read of a
configured secret name. The only exclusions left are facts rather than
inferences — the text is not executable (string, comment, single quote, quoted
heredoc), the receiver is a different object, or the name is not statically
knowable.

Docs note that assigning to the binding does not edit the secret.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(secrets): ship only the fields the trail actually shows

Five fields crossed the API and reached no reader: usageDate, firstUsedAt,
actorEmail, workflowId and actorUserId. The panel renders the timestamp, the
trigger, what used the secret, the actor's name, the run count and the run link;
everything else was projected, serialized and discarded.

first_used_at is dropped from the table as well. Nothing read it, and inside a
per-day bucket "first used that day" says nothing next to "last used that day" —
so it was a column written on every run for no question anyone asks. The upsert
loses its least() with it. Migration regenerated; the identifier columns behind
the joins stay, they simply are not returned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): report referenced code secrets, not only ones that surface in output

The Function route activated a secret's provenance — and therefore its usage row
and downstream masking — only when the exact value appeared in the result,
stdout, or error. That gate made the trail miss silent use entirely: a key that
authenticates an API call and is never echoed reported nothing, and so did the
founding scenario of this feature, a key exfiltrated character by character. The
innocent run that echoed a key got a row; the run worth catching did not.

Activation now follows the referenced set the compiler already computes: resolved
{{KEY}} bindings plus recognized direct reads, filtered to configured values —
the same set the unsafe-projection fallback already activated. An extra name only
hands the output matcher a value that never appears; configured-but-unreferenced
values are still never included. The output-scan activation path and its surface
helper are deleted rather than kept alongside.

One old test pinned the gate ("does not activate a referenced secret that does
not cross the Function result"); it now asserts the reverse, with the reasoning
attached. Two new tests pin the char-split exfiltration and the silent API-call
case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): shell escaping is backslash parity, not adjacency

Review round 8. `\\$API_KEY` is an escaped backslash followed by a LIVE expansion
— bash prints `\` plus the value — while `\$API_KEY` is an escaped dollar and
stays literal. Checking only the character adjacent to `$` read every even run as
escaped, dropping a real read from usage and masking alike; verified against
bash before fixing.

The scanner now counts the run of backslashes before the `$` and skips only odd
runs, the same parity rule logicalLineEndAfterContinuations in this file already
applies to line continuations. Six-case parity table added; the three even-run
cases fail against the previous check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): recognize destructured environment reads

Review round 9. `const { API_KEY } = environmentVariables` delivers the value by
name with no property- or element-access node in the AST, so the member-access
walk missed it entirely — and a missed read leaves an emitted value unmasked,
the dangerous direction.

The AST walk now also recognizes the declaration form (shorthand, renames,
defaults, string-literal keys), the assignment form ({ KEY } = env), and a
...rest element — which names no key but takes every value, so it reports every
configured name; the alternative left `const { ...all } = env; return all`
entirely unmasked. A computed key stays unrecognized, the same runtime-name
boundary as a computed subscript, and a receiver that is not the bare identifier
is not attributed.

Nine cases added; the six positive ones fail against the previous walk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): one receiver rule for destructured reads, parentheses included

Review round 10. Two accurate findings, folded into a generalization instead of
two more special cases:

- A parameter default (function f({ API_KEY } = environmentVariables)) and a
  binding-element default are the same by-name delivery as a variable
  declaration. The detector now keys on the ObjectBindingPattern itself and
  checks its parent's initializer, so every declaration position follows one
  rule instead of per-kind arms.
- Parentheses group without changing the receiver, so (environmentVariables) is
  unwrapped before the identifier check — in the destructuring arm AND the
  member-access arm, which had the same hole unreported.

Declined the for-of-over-array-literal finding: the receiver there is a
container, not the environment object, and following data flow through
containers has no fixed point — the same documented boundary as aliasing and
computed keys. A test pins the boundary so it reads as chosen, not missed.

Eight cases added; the seven receiver-rule cases fail against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): a dot in prose is not a qualifier, and a literal computed key is a subscript

Review round 11. Both findings were implementation-narrower-than-rule, fixed by
consulting authorities the detectors already had rather than adding new ones:

- python.ts: the receiver walk crosses whitespace so a parenthesized `other.` on
  a previous line is seen — but it landed on a comment's final period
  (`# Load the value.`) and discarded the genuine read on the next line. The
  landing position is now checked against the same lexer ranges that filter the
  candidates, which is also why the receiver check moves after lexing.
- javascript.ts: `const { ['API_KEY']: key } = environmentVariables` is the
  element-access rule in pattern position, so a computed key holding a string
  literal resolves like a literal subscript; any other computed key keeps the
  runtime-name boundary a computed subscript already has.

Eight cases added; the comment-period case and all three literal-computed-key
cases fail against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:32:20 -07:00

1403 lines
51 KiB
TypeScript

#!/usr/bin/env bun
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dir, '..')
const API_DIR = path.join(ROOT, 'apps/sim/app/api')
const CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts')
const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
totalRoutes: 1123,
zodRoutes: 1123,
nonZodRoutes: 0,
} as const
const BOUNDARY_POLICY_BASELINE = {
routeZodImports: 0,
routeLocalSchemaRoutes: 0,
routeLocalSchemaConstructors: 0,
routeZodErrorReferences: 0,
clientHookZodImports: 0,
clientHookLocalSchemaFiles: 0,
clientHookLocalSchemaConstructors: 0,
clientHookRawFetches: 0,
clientSameOriginApiFetches: 0,
doubleCasts: 6,
rawJsonReads: 5,
untypedResponses: 0,
annotationsMissingReason: 0,
} as const
const INDIRECT_ZOD_ROUTES = new Set([
// Catch-all JSON 404 for unknown /api/v2 paths. It has no contract by
// construction: it exists precisely for requests that match no operation, so
// there is no input to validate and its only response is the fixed v2 error
// envelope.
'apps/sim/app/api/v2/[[...segments]]/route.ts',
'apps/sim/app/api/demo-requests/route.ts',
// Input-less session-bound GET: nothing to validate; response is
// contract-typed via `satisfies InvitationDetails` in the route.
// Public updater feed: input-less GET, session-less, returns YAML (not JSON),
// so it can't be JSON-contract-bound. Wrapped in withRouteHandler.
'apps/sim/app/api/desktop/update/latest-mac.yml/route.ts',
// Public updater download redirect: input-less GET, session-less, whose only
// response is a 302 to a GitHub release asset. Wrapped in withRouteHandler.
'apps/sim/app/api/desktop/update/download/route.ts',
'apps/sim/app/api/invitations/route.ts',
'apps/sim/app/api/logs/export/route.ts',
'apps/sim/app/api/tools/docusign/route.ts',
// Better Auth handles its own validation for the catch-all route below.
'apps/sim/app/api/auth/[...all]/route.ts',
// Better Auth handles validation for the Stripe webhook handler.
'apps/sim/app/api/auth/webhook/stripe/route.ts',
// Routes with no client-supplied input that previously had no-op
// `z.object({}).strict().parse({})` guards. The boundary contract for
// these routes is "no input", and they consume validated data only via
// session/headers handled by `getSession()` / Better Auth.
'apps/sim/app/api/auth/oauth/connections/route.ts',
'apps/sim/app/api/auth/providers/route.ts',
'apps/sim/app/api/auth/socket-token/route.ts',
'apps/sim/app/api/desktop/auth/handoff/route.ts',
'apps/sim/app/api/workspaces/invitations/route.ts',
// Internal cron entry point that authenticates via `Authorization: Bearer
// CRON_SECRET` and ignores query/body. The boundary contract is "no
// client-supplied input"; query params from external callers are not
// consumed.
'apps/sim/app/api/schedules/execute/route.ts',
// Routes with no client-supplied input. Auth is handled via session/cron/internal
// tokens and there are no params, query, or body to validate. Previously had
// no-op `validateSchema(noInputSchema, {})` guards.
'apps/sim/app/api/health/route.ts',
'apps/sim/app/api/settings/allowed-providers/route.ts',
'apps/sim/app/api/settings/allowed-integrations/route.ts',
'apps/sim/app/api/settings/allowed-mcp-domains/route.ts',
'apps/sim/app/api/cron/cleanup-tasks/route.ts',
'apps/sim/app/api/cron/cleanup-soft-deletes/route.ts',
'apps/sim/app/api/cron/cleanup-stale-executions/route.ts',
'apps/sim/app/api/cron/cleanup-sandbox-images/route.ts',
'apps/sim/app/api/cron/renew-subscriptions/route.ts',
'apps/sim/app/api/cron/reconcile-billing-seats/route.ts',
'apps/sim/app/api/cron/reconcile-inbox-entitlement/route.ts',
'apps/sim/app/api/cron/run-data-drains/route.ts',
'apps/sim/app/api/logs/cleanup/route.ts',
'apps/sim/app/api/knowledge/connectors/sync/route.ts',
'apps/sim/app/api/webhooks/outbox/process/route.ts',
'apps/sim/app/api/webhooks/cleanup/idempotency/route.ts',
// Shared Slack app event ingest. The body is an opaque, HMAC-verified Slack
// event envelope (varies per event type) read via parseWebhookBody; there is
// no client contract to bind — authenticity is enforced by signature.
'apps/sim/app/api/webhooks/slack/route.ts',
'apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts',
'apps/sim/app/api/resume/poll/route.ts',
// MCP routes that take only auth context (no client-supplied params/query/body).
'apps/sim/app/api/mcp/discover/route.ts',
'apps/sim/app/api/mcp/tools/stored/route.ts',
// MCP OAuth callback is the provider redirect target — the response is HTML
// that closes the popup, so the JSON-mode contract framework doesn't fit.
// Validation is enforced via state lookup + session-vs-row userId match.
'apps/sim/app/api/mcp/oauth/callback/route.ts',
// Deprecated Copilot MCP surface: these routes are gated to always return
// 410 Gone and consume no client-supplied input.
'apps/sim/app/api/mcp/copilot/route.ts',
'apps/sim/app/api/mcp/copilot/.well-known/oauth-authorization-server/route.ts',
'apps/sim/app/api/mcp/copilot/.well-known/oauth-protected-resource/route.ts',
// Deprecated v1 headless copilot chat API: gated to always return 410 Gone
// and consumes no client-supplied input.
'apps/sim/app/api/v1/copilot/chat/route.ts',
])
/**
* Routes baseline-allowed to use `await request.json()` / `await req.json()`
* directly (without an inline `// boundary-raw-json:` annotation).
*
* These are legitimately partial: tolerant body parses (`.catch(() => ({}))`),
* JSON-RPC envelopes that need their own dispatch, multi-stage MCP routes that
* read pre-parsed bodies, and routes whose Zod-backed migration is queued
* behind a separate contract / schema authoring step. New routes must NOT
* introduce raw `await request.json()` reads — annotate the call with
* `// boundary-raw-json: <reason>` instead.
*/
const RAW_JSON_BASELINE_ROUTES = new Set([
'apps/sim/app/api/billing/portal/route.ts',
'apps/sim/app/api/copilot/api-keys/generate/route.ts',
'apps/sim/app/api/copilot/api-keys/validate/route.ts',
'apps/sim/app/api/copilot/chat/abort/route.ts',
'apps/sim/app/api/folders/[id]/restore/route.ts',
'apps/sim/app/api/invitations/[id]/accept/route.ts',
'apps/sim/app/api/invitations/[id]/reject/route.ts',
'apps/sim/app/api/invitations/[id]/route.ts',
'apps/sim/app/api/knowledge/[id]/documents/route.ts',
'apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts',
'apps/sim/app/api/mcp/serve/[serverId]/route.ts',
'apps/sim/app/api/mcp/servers/route.ts',
'apps/sim/app/api/mcp/servers/[id]/route.ts',
'apps/sim/app/api/mcp/servers/test-connection/route.ts',
'apps/sim/app/api/mcp/tools/discover/route.ts',
'apps/sim/app/api/mcp/tools/execute/route.ts',
'apps/sim/app/api/mcp/workflow-servers/route.ts',
'apps/sim/app/api/mcp/workflow-servers/[id]/route.ts',
'apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts',
'apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts',
'apps/sim/app/api/organizations/route.ts',
'apps/sim/app/api/organizations/[id]/invitations/route.ts',
'apps/sim/app/api/organizations/[id]/members/route.ts',
'apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts',
'apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts',
'apps/sim/app/api/speech/token/route.ts',
'apps/sim/app/api/table/[tableId]/rows/route.ts',
'apps/sim/app/api/tools/file/manage/route.ts',
'apps/sim/app/api/workspaces/invitations/batch/route.ts',
'apps/sim/app/api/workspaces/[id]/route.ts',
'apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts',
])
const CONTRACT_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/contracts(?:\/[^'"]*)?['"]/
const DECLARATIVE_ROUTE_BUILDER_IMPORT_PATTERN =
/\bimport\s*\{[^}]*(?:\bdefineInternalJsonRoute\b|\bdefineV2JsonRoute\b|\bdefineInternalBinaryRoute\b|\bdefineV2BinaryRoute\b)[^}]*\}\s*from\s*['"]@\/lib\/api\/server\/routes['"]/
const DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN =
/\b(?:defineInternalJsonRoute|defineV2JsonRoute|defineInternalBinaryRoute|defineV2BinaryRoute)\s*\(/
const SERVER_VALIDATION_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/server(?:\/validation)?['"]/
const SCHEMA_PARSE_PATTERN = /\b\w+Schema\.(?:safeParse|parse)\(/
const CONTRACT_SERVER_HELPER_PATTERN = /\bparseToolRequest\(/
const CANONICAL_HELPER_USAGE_PATTERN =
/\b(?:isZodError|validationErrorResponse|validationErrorResponseFromError|getValidationErrorMessage)\s*\(/
const CONTRACT_MAP_PARSE_PATTERN =
/\b\w+ContractsByPath[\s\S]{0,600}\.(?:body|query|params)!?\.(?:safeParse|parse)\(/
/**
* Matches `from 'zod'` and any zod subpath import like `from 'zod/v4'` or
* `from 'zod/mini'`. The capturing-group-free alternation keeps this safe to
* use with `.test(...)` and `.replace(...)` callers.
*/
const ZOD_IMPORT_PATTERN = /\bfrom\s+['"]zod(?:\/[^'"]+)?['"]/
const ZOD_REQUIRE_PATTERN = /\brequire\(['"]zod(?:\/[^'"]+)?['"]\)/
const ZOD_SCHEMA_CONSTRUCTOR_PATTERN =
/\bz\.(?:object|string|number|boolean|array|enum|nativeEnum|union|discriminatedUnion|record|literal|tuple|preprocess|coerce|date|unknown|any|instanceof|custom|lazy)\s*\(/g
const ZOD_ERROR_PATTERN = /\bZodError\b|\bz\.ZodError\b/
const SKIP_DIRS = new Set(['node_modules', '.next', '.turbo', 'coverage'])
const WIRE_TYPE_DECLARATION_PATTERN =
/(?:^|\n)\s*(?:export\s+)?(interface|type)\s+([A-Z]\w*(?:Response|Result))\b(?=\s*(?:=|extends|\{))/g
const CONTRACT_DERIVED_WIRE_TYPE_PATTERN =
/\b(?:ContractJsonResponse|ContractJsonErrorResponse|z\.(?:input|output|infer))\b/
const RAW_FETCH_PATTERN = /\bfetch\(/g
const RAW_FETCH_HELPER_GUARD_PATTERN = /(?:requestJson|requestRaw|prefetchJson|preFetchJson)$/
/**
* Matches `fetch(` (with optional whitespace, including newlines) followed by
* a string literal — single quote, double quote, or template literal —
* whose first character is `/api/`. This catches same-origin internal API
* fetches in any non-test source file under `apps/sim/**` that aren't an
* `app/api/**\/route.ts` server handler. Template literals with leading
* interpolations (e.g. `${base}/api/foo`) are intentionally NOT matched
* because they're rare and could trigger false positives on non-`/api/` URLs.
*/
const SAME_ORIGIN_API_FETCH_PATTERN = /\bfetch\(\s*[`'"]\/api\//g
const DOUBLE_CAST_PATTERN = /\bas unknown as\b/g
/**
* Matches `await request.json()` / `await req.json()` and the multi-line
* `await request.clone().json()` clone-then-read variant. Both forms read
* the request body without going through `parseRequest(...)` / a contract
* and count toward the `rawJsonReads` ratchet.
*
* `\s` is multi-line (handles common Prettier/Biome formatting where the
* `.clone()` and `.json()` calls land on separate lines).
*/
const RAW_JSON_READ_PATTERN =
/\bawait\s+(?:request|req)\s*(?:\.\s*clone\s*\(\s*\))?\s*\.\s*json\s*\(\s*\)/g
/**
* Matches `schema:` followed directly by a "validates nothing" zod construct.
* Three forms are treated equivalently:
* 1. `schema: z.unknown()` — no validation at all.
* 2. `schema: z.object({}).passthrough()` — validates only that the value
* is an object; allows any keys/values.
* 3. `schema: z.record(z.string(), z.unknown())` — validates only that the
* value is a string-keyed object; values are arbitrary.
*
* Anchored on the literal `schema:` token so that nested `z.unknown()` /
* `z.object({}).passthrough()` uses inside an otherwise-typed object schema
* are NOT flagged — only the top-level response declaration in
* `defineRouteContract({ ..., response: { mode: 'json', schema: ... } })`.
*/
const UNTYPED_RESPONSE_PATTERN =
/\bschema\s*:\s*(?:z\.unknown\s*\(\s*\)|z\.object\s*\(\s*\{\s*\}\s*\)\s*\.passthrough\s*\(\s*\)|z\.record\s*\(\s*z\.string\s*\(\s*\)\s*,\s*z\.unknown\s*\(\s*\)\s*\))/g
const RAW_FETCH_ANNOTATION_PREFIX = '// boundary-raw-fetch:'
const DOUBLE_CAST_ANNOTATION_PREFIX = '// double-cast-allowed:'
const RAW_JSON_ANNOTATION_PREFIX = '// boundary-raw-json:'
const UNTYPED_RESPONSE_ANNOTATION_PREFIX = '// untyped-response:'
const SOURCE_FILE_EXTENSIONS = /\.(?:ts|tsx)$/
const TEST_FILE_PATTERN = /(?:\.test|\.spec)\.(?:ts|tsx)$/
const TEST_HELPER_FILE_PATTERN = /(?:^|\/)test-[^/]+\.ts$/
const TEST_DIR_SEGMENT_PATTERN = /(?:^|\/)(?:__tests__|testing)(?:\/|$)/
/**
* Skips user-uploaded content stored under `apps/sim/uploads/...` (workspace
* file uploads, etc.). Does NOT match `apps/sim/lib/uploads/...`, which is
* source code for the uploads subsystem.
*/
const USER_UPLOADS_DIR_PATTERN = /(?:^|\/)apps\/sim\/uploads(?:\/|$)/
const SOURCE_SKIP_DIRS = new Set([
'node_modules',
'.next',
'.turbo',
'coverage',
'dist',
'__tests__',
'testing',
])
type AnnotationKind = 'raw-fetch' | 'double-cast' | 'raw-json' | 'untyped-response'
interface AnnotationResult {
allowed: boolean
missingReason: boolean
}
interface RawFetchFinding {
path: string
line: number
preview: string
}
interface SameOriginApiFetchFinding {
path: string
line: number
preview: string
}
interface DoubleCastFinding {
path: string
line: number
preview: string
}
interface RawJsonFinding {
path: string
line: number
preview: string
}
interface UntypedResponseFinding {
path: string
line: number
preview: string
}
interface AnnotationMissingReasonFinding {
path: string
line: number
kind: AnnotationKind
}
interface RouteAudit {
path: string
usesZod: boolean
hasZodImport: boolean
schemaConstructorCount: number
hasZodErrorReference: boolean
hasBodyRead: boolean
hasQueryRead: boolean
hasFormDataRead: boolean
hasParamsContext: boolean
}
interface WireTypeFinding {
path: string
name: string
line: number
}
interface QueryHookAudit {
path: string
hasZodImport: boolean
schemaConstructorCount: number
adHocWireTypes: WireTypeFinding[]
}
interface FamilyStats {
total: number
zod: number
nonZod: number
}
type BoundaryPolicyKey = keyof typeof BOUNDARY_POLICY_BASELINE
interface BoundaryPolicyMetric {
key: BoundaryPolicyKey
label: string
current: number
}
interface PrintOnlyBoundaryPolicyMetric {
label: string
current: number
}
async function walk(
dir: string,
shouldIncludeFile: (fileName: string) => boolean,
results: string[] = []
): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await walk(fullPath, shouldIncludeFile, results)
} else if (shouldIncludeFile(entry.name)) {
results.push(fullPath)
}
}
return results
}
function lineNumberForIndex(content: string, index: number): number {
let line = 1
for (let i = 0; i < index; i++) {
if (content.charCodeAt(i) === 10) line += 1
}
return line
}
/**
* Inspects up to three consecutive non-empty preceding lines for an
* opt-out annotation matching the given kind. The annotation is allowed
* when the matching prefix is followed by a non-empty reason. When the
* prefix is present but the reason is empty, `missingReason` is set so
* the audit can flag and fail on dangling annotations.
*/
function extractAnnotation(
content: string,
lineIndex: number,
kind: AnnotationKind
): AnnotationResult {
const prefix =
kind === 'raw-fetch'
? RAW_FETCH_ANNOTATION_PREFIX
: kind === 'double-cast'
? DOUBLE_CAST_ANNOTATION_PREFIX
: kind === 'raw-json'
? RAW_JSON_ANNOTATION_PREFIX
: UNTYPED_RESPONSE_ANNOTATION_PREFIX
const lines = content.split('\n')
let inspected = 0
for (let i = lineIndex - 1; i >= 0 && inspected < 3; i -= 1) {
const trimmed = lines[i]?.trim() ?? ''
if (trimmed.length === 0) continue
inspected += 1
if (!trimmed.startsWith('//')) {
return { allowed: false, missingReason: false }
}
const prefixIndex = trimmed.indexOf(prefix)
if (prefixIndex === -1) continue
const reason = trimmed.slice(prefixIndex + prefix.length).trim()
if (reason.length === 0) {
return { allowed: false, missingReason: true }
}
return { allowed: true, missingReason: false }
}
return { allowed: false, missingReason: false }
}
/**
* Walks `apps/sim/**` and optionally `packages/**` for `.ts` / `.tsx`
* source files, excluding tests, build artifacts, and coverage output.
* Kept separate from `walk(API_DIR)` because the source-wide audit has
* different exclusion rules than the route audit.
*/
async function walkAllSourceFiles(root: string, includePackages: boolean): Promise<string[]> {
const roots = includePackages
? [path.join(root, 'apps/sim'), path.join(root, 'packages')]
: [path.join(root, 'apps/sim')]
const results: string[] = []
for (const start of roots) {
await walkSourceTree(start, results)
}
return results
}
async function walkSourceTree(dir: string, results: string[]): Promise<void> {
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (SOURCE_SKIP_DIRS.has(entry.name)) continue
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await walkSourceTree(fullPath, results)
continue
}
if (!SOURCE_FILE_EXTENSIONS.test(entry.name)) continue
if (TEST_FILE_PATTERN.test(entry.name)) continue
if (TEST_HELPER_FILE_PATTERN.test(entry.name)) continue
if (TEST_DIR_SEGMENT_PATTERN.test(fullPath)) continue
if (USER_UPLOADS_DIR_PATTERN.test(fullPath)) continue
results.push(fullPath)
}
}
function isContractFetchHelperCall(line: string, matchIndex: number): boolean {
const before = line.slice(0, matchIndex)
return RAW_FETCH_HELPER_GUARD_PATTERN.test(before)
}
function buildPreview(line: string): string {
return line.trim().slice(0, 160)
}
function findRawFetchFindings(
filePath: string,
content: string
): {
findings: RawFetchFinding[]
exemptions: number
missingReasons: AnnotationMissingReasonFinding[]
} {
const relativePath = path.relative(ROOT, filePath)
const lines = content.split('\n')
const findings: RawFetchFinding[] = []
const missingReasons: AnnotationMissingReasonFinding[] = []
let exemptions = 0
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? ''
RAW_FETCH_PATTERN.lastIndex = 0
let match: RegExpExecArray | null
while ((match = RAW_FETCH_PATTERN.exec(line)) !== null) {
if (isContractFetchHelperCall(line, match.index)) continue
const annotation = extractAnnotation(content, i, 'raw-fetch')
if (annotation.missingReason) {
missingReasons.push({ path: relativePath, line: i + 1, kind: 'raw-fetch' })
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
continue
}
if (annotation.allowed) {
exemptions += 1
continue
}
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
}
}
return { findings, exemptions, missingReasons }
}
function findDoubleCastFindings(
filePath: string,
content: string
): {
findings: DoubleCastFinding[]
exemptions: number
missingReasons: AnnotationMissingReasonFinding[]
} {
const relativePath = path.relative(ROOT, filePath)
const lines = content.split('\n')
const findings: DoubleCastFinding[] = []
const missingReasons: AnnotationMissingReasonFinding[] = []
let exemptions = 0
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? ''
DOUBLE_CAST_PATTERN.lastIndex = 0
while (DOUBLE_CAST_PATTERN.exec(line) !== null) {
const annotation = extractAnnotation(content, i, 'double-cast')
if (annotation.missingReason) {
missingReasons.push({ path: relativePath, line: i + 1, kind: 'double-cast' })
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
continue
}
if (annotation.allowed) {
exemptions += 1
continue
}
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
}
}
return { findings, exemptions, missingReasons }
}
/**
* Inspect a route file for `await request.json()` / `await req.json()` reads.
*
* Returns one finding per unannotated read. Routes in
* `RAW_JSON_BASELINE_ROUTES` are baseline-allowed: their reads still appear
* in `findings` so the `rawJsonReads` ratcheted metric counts them, but they
* are NOT required to carry per-line `// boundary-raw-json: <reason>`
* annotations. The ratchet's enforcement is by file count
* (see `buildBoundaryPolicyMetrics`): adding a raw read in a route outside
* the baseline pushes the unique-file count above `BOUNDARY_POLICY_BASELINE.rawJsonReads`.
*
* Annotated reads (`// boundary-raw-json: <reason>` on one of the three
* preceding non-empty lines) are treated as exemptions and excluded from
* `findings`. An annotation with the prefix but an empty reason is flagged
* via `missingReasons` and still counts as a finding.
*/
function findRawJsonFindings(
filePath: string,
content: string
): {
findings: RawJsonFinding[]
exemptions: number
missingReasons: AnnotationMissingReasonFinding[]
} {
const relativePath = path.relative(ROOT, filePath)
const lines = content.split('\n')
const findings: RawJsonFinding[] = []
const missingReasons: AnnotationMissingReasonFinding[] = []
let exemptions = 0
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? ''
RAW_JSON_READ_PATTERN.lastIndex = 0
while (RAW_JSON_READ_PATTERN.exec(line) !== null) {
const annotation = extractAnnotation(content, i, 'raw-json')
if (annotation.missingReason) {
missingReasons.push({ path: relativePath, line: i + 1, kind: 'raw-json' })
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
continue
}
if (annotation.allowed) {
exemptions += 1
continue
}
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
}
}
return { findings, exemptions, missingReasons }
}
/**
* Inspect a contracts file for "validates nothing" response schema
* declarations. Three forms are treated equivalently and all count toward
* the `untypedResponses` ratchet:
* - `schema: z.unknown()`
* - `schema: z.object({}).passthrough()`
* - `schema: z.record(z.string(), z.unknown())`
*
* Anchored on `schema:` so nested uses inside an otherwise-typed object
* (e.g. `output: z.unknown()` inside `z.object({ ... })`) are NOT flagged.
* Each callsite must carry a `// untyped-response: <reason>` annotation on
* one of the three preceding non-empty lines. Annotated callsites become
* exemptions; un-annotated callsites become findings that count toward the
* `untypedResponses` ratchet. An annotation with the prefix but an empty
* reason is flagged via `missingReasons` and still counts as a finding.
*/
function findUntypedResponseFindings(
filePath: string,
content: string
): {
findings: UntypedResponseFinding[]
exemptions: number
missingReasons: AnnotationMissingReasonFinding[]
} {
const relativePath = path.relative(ROOT, filePath)
const lines = content.split('\n')
const findings: UntypedResponseFinding[] = []
const missingReasons: AnnotationMissingReasonFinding[] = []
let exemptions = 0
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? ''
UNTYPED_RESPONSE_PATTERN.lastIndex = 0
while (UNTYPED_RESPONSE_PATTERN.exec(line) !== null) {
const annotation = extractAnnotation(content, i, 'untyped-response')
if (annotation.missingReason) {
missingReasons.push({ path: relativePath, line: i + 1, kind: 'untyped-response' })
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
continue
}
if (annotation.allowed) {
exemptions += 1
continue
}
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
}
}
return { findings, exemptions, missingReasons }
}
function isClientHookFile(filePath: string): boolean {
const normalized = filePath.replace(/\\/g, '/')
return (
normalized.startsWith(`${QUERY_HOOKS_DIR}/`) || normalized.startsWith(`${SELECTOR_HOOKS_DIR}/`)
)
}
/**
* Identifies `apps/sim/app/api/**\/route.ts` API route handlers. Same-origin
* `/api/` fetch scanning skips these — server-side fetches from inside a
* route handler are a different concern and are not what this ratchet is
* trying to catch.
*/
function isApiRouteHandler(filePath: string): boolean {
const normalized = filePath.replace(/\\/g, '/')
if (!normalized.startsWith(`${API_DIR}/`)) return false
return normalized.endsWith('/route.ts')
}
/**
* Inspect a non-API-route source file under `apps/sim/**` for raw
* `fetch('/api/...')`, `fetch("/api/...")`, or ``fetch(`/api/...`)`` calls.
*
* Each callsite is either an exemption (annotated with
* `// boundary-raw-fetch: <reason>` on one of the three preceding non-empty
* lines) or a finding. The ratchet's enforcement is by unique-file count:
* introducing a same-origin `/api/` fetch without an annotation pushes the
* unique-file count above `BOUNDARY_POLICY_BASELINE.clientSameOriginApiFetches`
* and fails the audit.
*
* Scanning runs over the entire file content (not line-by-line) so that
* multi-line constructs like `fetch(\n \`/api/...\`)` are still caught.
* An annotation with the prefix but an empty reason is flagged via
* `missingReasons` and still counts as a finding.
*/
function findSameOriginApiFetchFindings(
filePath: string,
content: string
): {
findings: SameOriginApiFetchFinding[]
exemptions: number
missingReasons: AnnotationMissingReasonFinding[]
} {
const relativePath = path.relative(ROOT, filePath)
const lines = content.split('\n')
const findings: SameOriginApiFetchFinding[] = []
const missingReasons: AnnotationMissingReasonFinding[] = []
let exemptions = 0
SAME_ORIGIN_API_FETCH_PATTERN.lastIndex = 0
let match: RegExpExecArray | null
while ((match = SAME_ORIGIN_API_FETCH_PATTERN.exec(content)) !== null) {
const lineNumber = lineNumberForIndex(content, match.index)
const lineIndex = lineNumber - 1
const line = lines[lineIndex] ?? ''
const annotation = extractAnnotation(content, lineIndex, 'raw-fetch')
if (annotation.missingReason) {
missingReasons.push({ path: relativePath, line: lineNumber, kind: 'raw-fetch' })
findings.push({ path: relativePath, line: lineNumber, preview: buildPreview(line) })
continue
}
if (annotation.allowed) {
exemptions += 1
continue
}
findings.push({ path: relativePath, line: lineNumber, preview: buildPreview(line) })
}
return { findings, exemptions, missingReasons }
}
function routeFamily(routePath: string): string {
const relative = routePath.replace(/^apps\/sim\/app\/api\//, '')
const [first, second] = relative.split('/')
if (first === 'tools') return `tools/${second ?? 'unknown'}`
if (first === 'v1') return second ? `v1/${second}` : 'v1'
return first ?? 'unknown'
}
function hasZodUsage(relativePath: string, content: string): boolean {
if (ZOD_IMPORT_PATTERN.test(content) || ZOD_REQUIRE_PATTERN.test(content)) {
return true
}
if (
/\bparseRequest\(/.test(content) &&
/\bfrom\s+['"]@\/lib\/api\/server['"]/.test(content) &&
CONTRACT_IMPORT_PATTERN.test(content)
) {
return true
}
if (
CONTRACT_IMPORT_PATTERN.test(content) &&
DECLARATIVE_ROUTE_BUILDER_IMPORT_PATTERN.test(content) &&
DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN.test(content)
) {
return true
}
if (
CONTRACT_IMPORT_PATTERN.test(content) &&
(SCHEMA_PARSE_PATTERN.test(content) || CONTRACT_MAP_PARSE_PATTERN.test(content))
) {
return true
}
if (CONTRACT_IMPORT_PATTERN.test(content) && CONTRACT_SERVER_HELPER_PATTERN.test(content)) {
return true
}
if (
CONTRACT_IMPORT_PATTERN.test(content) &&
SERVER_VALIDATION_IMPORT_PATTERN.test(content) &&
CANONICAL_HELPER_USAGE_PATTERN.test(content)
) {
return true
}
if (
SERVER_VALIDATION_IMPORT_PATTERN.test(content) &&
/\b(?:isZodError|validationErrorResponseFromError)\b/.test(content) &&
SCHEMA_PARSE_PATTERN.test(content)
) {
return true
}
return INDIRECT_ZOD_ROUTES.has(relativePath)
}
function auditRoute(filePath: string, content: string): RouteAudit {
const relativePath = path.relative(ROOT, filePath)
const schemaConstructorCount = [...content.matchAll(ZOD_SCHEMA_CONSTRUCTOR_PATTERN)].length
return {
path: relativePath,
usesZod: hasZodUsage(relativePath, content),
hasZodImport: ZOD_IMPORT_PATTERN.test(content) || ZOD_REQUIRE_PATTERN.test(content),
schemaConstructorCount,
hasZodErrorReference: ZOD_ERROR_PATTERN.test(content),
hasBodyRead: /\brequest\.json\(\)|\breq\.json\(\)/.test(content),
hasQueryRead: /\.searchParams\b|new URL\([^)]*\)\.searchParams/.test(content),
hasFormDataRead: /\.formData\(\)/.test(content),
hasParamsContext: /\bparams\b/.test(content) && /\bPromise<\{|\bRouteContext\b/.test(content),
}
}
function findAdHocWireTypes(filePath: string, content: string): WireTypeFinding[] {
const findings: WireTypeFinding[] = []
const relativePath = path.relative(ROOT, filePath)
for (const match of content.matchAll(WIRE_TYPE_DECLARATION_PATTERN)) {
const kind = match[1]
const name = match[2]
const declarationStart = match.index ?? 0
const declarationPreview = content.slice(declarationStart, declarationStart + 800)
if (name.endsWith('QueryResult')) continue
if (CONTRACT_DERIVED_WIRE_TYPE_PATTERN.test(declarationPreview)) continue
if (kind === 'type') {
const equalsIndex = declarationPreview.indexOf('=')
const typeBody = declarationPreview.slice(equalsIndex + 1).trimStart()
if (!typeBody.startsWith('{') && !/^[A-Z]\w*\s*&\s*\{/.test(typeBody)) {
continue
}
}
findings.push({
path: relativePath,
name,
line: lineNumberForIndex(content, declarationStart),
})
}
return findings
}
function auditQueryHook(filePath: string, content: string): QueryHookAudit {
const relativePath = path.relative(ROOT, filePath)
const schemaConstructorCount = [...content.matchAll(ZOD_SCHEMA_CONSTRUCTOR_PATTERN)].length
return {
path: relativePath,
hasZodImport: ZOD_IMPORT_PATTERN.test(content) || ZOD_REQUIRE_PATTERN.test(content),
schemaConstructorCount,
adHocWireTypes: findAdHocWireTypes(filePath, content),
}
}
function printFamilyStats(audits: RouteAudit[]) {
const families = new Map<string, FamilyStats>()
for (const audit of audits) {
const family = routeFamily(audit.path)
const stats = families.get(family) ?? { total: 0, zod: 0, nonZod: 0 }
stats.total += 1
if (audit.usesZod) {
stats.zod += 1
} else {
stats.nonZod += 1
}
families.set(family, stats)
}
console.log('\nFamily breakdown:')
for (const [family, stats] of [...families.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
console.log(
` ${family.padEnd(32)} total=${String(stats.total).padStart(3)} zod=${String(stats.zod).padStart(3)} nonZod=${String(stats.nonZod).padStart(3)}`
)
}
}
function printRiskyNonZodRoutes(audits: RouteAudit[]) {
const risky = audits.filter(
(audit) =>
!audit.usesZod &&
(audit.hasBodyRead || audit.hasQueryRead || audit.hasFormDataRead || audit.hasParamsContext)
)
console.log(`\nNon-Zod routes with parsed inputs: ${risky.length}`)
for (const audit of risky.slice(0, 50)) {
const markers = [
audit.hasBodyRead ? 'json' : null,
audit.hasFormDataRead ? 'formData' : null,
audit.hasQueryRead ? 'query' : null,
audit.hasParamsContext ? 'params' : null,
].filter(Boolean)
console.log(` ${audit.path} (${markers.join(', ')})`)
}
if (risky.length > 50) {
console.log(` ... ${risky.length - 50} more`)
}
}
function printAllNonZodRoutes(audits: RouteAudit[]) {
if (!process.argv.includes('--list-non-zod')) return
const nonZod = audits.filter((audit) => !audit.usesZod)
console.log(`\nAll non-Zod routes: ${nonZod.length}`)
for (const audit of nonZod) {
const markers = [
audit.hasBodyRead ? 'json' : null,
audit.hasFormDataRead ? 'formData' : null,
audit.hasQueryRead ? 'query' : null,
audit.hasParamsContext ? 'params' : null,
].filter(Boolean)
console.log(` ${audit.path}${markers.length > 0 ? ` (${markers.join(', ')})` : ''}`)
}
}
function buildBoundaryPolicyMetrics(
routeAudits: RouteAudit[],
queryHookAudits: QueryHookAudit[],
rawFetchSummary: { findings: RawFetchFinding[]; exemptions: number },
sameOriginApiFetchSummary: {
findings: SameOriginApiFetchFinding[]
exemptions: number
},
doubleCastSummary: { findings: DoubleCastFinding[]; exemptions: number },
rawJsonSummary: { findings: RawJsonFinding[]; exemptions: number },
untypedResponseSummary: { findings: UntypedResponseFinding[]; exemptions: number },
annotationsMissingReason: AnnotationMissingReasonFinding[]
): {
ratchetedMetrics: BoundaryPolicyMetric[]
printOnlyMetrics: PrintOnlyBoundaryPolicyMetric[]
} {
const routeZodImports = routeAudits.filter((audit) => audit.hasZodImport)
const routeLocalSchemaRoutes = routeAudits.filter((audit) => audit.schemaConstructorCount > 0)
const routeLocalSchemaConstructors = routeLocalSchemaRoutes.reduce(
(total, audit) => total + audit.schemaConstructorCount,
0
)
const routeZodErrorReferences = routeAudits.filter((audit) => audit.hasZodErrorReference)
const queryHookZodImports = queryHookAudits.filter((audit) => audit.hasZodImport)
const queryHookLocalSchemaFiles = queryHookAudits.filter(
(audit) => audit.schemaConstructorCount > 0
)
const queryHookLocalSchemaConstructors = queryHookLocalSchemaFiles.reduce(
(total, audit) => total + audit.schemaConstructorCount,
0
)
const queryHookAdHocWireTypes = queryHookAudits.flatMap((audit) => audit.adHocWireTypes)
return {
ratchetedMetrics: [
{
key: 'routeZodImports',
label: 'route files importing zod',
current: routeZodImports.length,
},
{
key: 'routeLocalSchemaRoutes',
label: 'route files with local schema constructors',
current: routeLocalSchemaRoutes.length,
},
{
key: 'routeLocalSchemaConstructors',
label: 'route local schema constructor calls',
current: routeLocalSchemaConstructors,
},
{
key: 'routeZodErrorReferences',
label: 'route files referencing ZodError',
current: routeZodErrorReferences.length,
},
{
key: 'clientHookZodImports',
label: 'client hook files importing zod',
current: queryHookZodImports.length,
},
{
key: 'clientHookLocalSchemaFiles',
label: 'client hook files with local schema constructors',
current: queryHookLocalSchemaFiles.length,
},
{
key: 'clientHookLocalSchemaConstructors',
label: 'client hook local schema constructor calls',
current: queryHookLocalSchemaConstructors,
},
{
key: 'clientHookRawFetches',
label: 'client hook raw fetch() calls',
current: rawFetchSummary.findings.length,
},
{
key: 'clientSameOriginApiFetches',
label: 'apps/sim files with raw same-origin /api/ fetch() calls',
current: new Set(sameOriginApiFetchSummary.findings.map((finding) => finding.path)).size,
},
{
key: 'doubleCasts',
label: 'as unknown as double-casts (non-test)',
current: doubleCastSummary.findings.length,
},
{
key: 'rawJsonReads',
label: 'route files with raw await request.json() reads',
current: new Set(rawJsonSummary.findings.map((finding) => finding.path)).size,
},
{
key: 'untypedResponses',
label:
'contract untyped response schemas (z.unknown / z.object({}).passthrough / z.record)',
current: untypedResponseSummary.findings.length,
},
{
key: 'annotationsMissingReason',
label: 'audit annotations missing reason',
current: annotationsMissingReason.length,
},
],
printOnlyMetrics: [
{
label: 'client hook ad-hoc wire Response/Result types',
current: queryHookAdHocWireTypes.length,
},
{
label: 'client hook raw fetch() exemptions (annotated)',
current: rawFetchSummary.exemptions,
},
{
label: 'apps/sim raw same-origin /api/ fetch() callsites',
current: sameOriginApiFetchSummary.findings.length,
},
{
label: 'apps/sim raw same-origin /api/ fetch() exemptions (annotated)',
current: sameOriginApiFetchSummary.exemptions,
},
{
label: 'as unknown as double-cast exemptions (annotated)',
current: doubleCastSummary.exemptions,
},
{
label: 'route raw await request.json() annotated exemptions',
current: rawJsonSummary.exemptions,
},
{
label: 'contract untyped response annotated exemptions',
current: untypedResponseSummary.exemptions,
},
],
}
}
function printBoundaryPolicyMetric(metric: BoundaryPolicyMetric) {
const baseline = BOUNDARY_POLICY_BASELINE[metric.key]
const delta = metric.current - baseline
const deltaText = delta === 0 ? 'at baseline' : `${delta > 0 ? '+' : ''}${delta} vs baseline`
console.log(` ${metric.label}: ${metric.current} (${deltaText})`)
}
function printRawFetchAndDoubleCastMetrics(
rawFetchFindings: RawFetchFinding[],
sameOriginApiFetchFindings: SameOriginApiFetchFinding[],
doubleCastFindings: DoubleCastFinding[],
rawJsonFindings: RawJsonFinding[],
untypedResponseFindings: UntypedResponseFinding[],
annotationsMissingReason: AnnotationMissingReasonFinding[],
rawFetchExemptions: number,
sameOriginApiFetchExemptions: number,
doubleCastExemptions: number,
rawJsonExemptions: number,
untypedResponseExemptions: number
) {
console.log('\nRaw fetch and double-cast metrics:')
console.log(` client hook raw fetch() calls: ${rawFetchFindings.length}`)
console.log(` client hook raw fetch() exemptions (annotated): ${rawFetchExemptions}`)
const sameOriginFiles = new Set(sameOriginApiFetchFindings.map((finding) => finding.path)).size
console.log(
` apps/sim files with raw same-origin /api/ fetch() calls: ${sameOriginFiles} (baseline ${BOUNDARY_POLICY_BASELINE.clientSameOriginApiFetches})`
)
console.log(
` apps/sim raw same-origin /api/ fetch() callsites: ${sameOriginApiFetchFindings.length}`
)
console.log(
` apps/sim raw same-origin /api/ fetch() exemptions (annotated): ${sameOriginApiFetchExemptions}`
)
console.log(` as unknown as double-casts (non-test): ${doubleCastFindings.length}`)
console.log(` as unknown as double-cast exemptions (annotated): ${doubleCastExemptions}`)
const rawJsonFiles = new Set(rawJsonFindings.map((finding) => finding.path)).size
console.log(
` route files with raw await request.json() reads: ${rawJsonFiles} (baseline ${BOUNDARY_POLICY_BASELINE.rawJsonReads})`
)
console.log(` route raw await request.json() reads (callsites): ${rawJsonFindings.length}`)
console.log(` route raw await request.json() annotated exemptions: ${rawJsonExemptions}`)
console.log(
` contract untyped response schemas (z.unknown / z.object({}).passthrough / z.record): ${untypedResponseFindings.length}`
)
console.log(` contract untyped response annotated exemptions: ${untypedResponseExemptions}`)
console.log(` audit annotations missing reason: ${annotationsMissingReason.length}`)
console.log(' raw fetch examples:')
for (const finding of rawFetchFindings.slice(0, 25)) {
console.log(` ${finding.path}:${finding.line} ${finding.preview}`)
}
if (rawFetchFindings.length > 25) {
console.log(` ... ${rawFetchFindings.length - 25} more`)
}
console.log(' same-origin /api/ fetch examples:')
for (const finding of sameOriginApiFetchFindings.slice(0, 25)) {
console.log(` ${finding.path}:${finding.line} ${finding.preview}`)
}
if (sameOriginApiFetchFindings.length > 25) {
console.log(` ... ${sameOriginApiFetchFindings.length - 25} more`)
}
console.log(' double-cast examples:')
for (const finding of doubleCastFindings.slice(0, 25)) {
console.log(` ${finding.path}:${finding.line} ${finding.preview}`)
}
if (doubleCastFindings.length > 25) {
console.log(` ... ${doubleCastFindings.length - 25} more`)
}
console.log(' raw await request.json() examples:')
for (const finding of rawJsonFindings.slice(0, 25)) {
console.log(` ${finding.path}:${finding.line} ${finding.preview}`)
}
if (rawJsonFindings.length > 25) {
console.log(` ... ${rawJsonFindings.length - 25} more`)
}
console.log(' untyped response schema examples:')
for (const finding of untypedResponseFindings.slice(0, 25)) {
console.log(` ${finding.path}:${finding.line} ${finding.preview}`)
}
if (untypedResponseFindings.length > 25) {
console.log(` ... ${untypedResponseFindings.length - 25} more`)
}
console.log(' annotations missing reason (must be 0 for --enforce-boundary-baseline):')
for (const finding of annotationsMissingReason.slice(0, 25)) {
console.log(` ${finding.path}:${finding.line} (${finding.kind})`)
}
if (annotationsMissingReason.length > 25) {
console.log(` ... ${annotationsMissingReason.length - 25} more`)
}
console.log(
' annotation forms: `// boundary-raw-fetch: <reason>` (raw fetch in client hook OR same-origin /api/ fetch outside an API route handler), `// double-cast-allowed: <reason>` (double-cast), `// boundary-raw-json: <reason>` (raw request.json read), `// untyped-response: <reason>` (z.unknown() / z.object({}).passthrough() / z.record(z.string(), z.unknown()) response schema)'
)
}
function printBoundaryContractDrift(
routeAudits: RouteAudit[],
queryHookAudits: QueryHookAudit[],
sameOriginApiFetchFindings: SameOriginApiFetchFinding[],
untypedResponseFindings: UntypedResponseFinding[],
ratchetedMetrics: BoundaryPolicyMetric[],
printOnlyMetrics: PrintOnlyBoundaryPolicyMetric[]
) {
const zodImportRoutes = routeAudits.filter((audit) => audit.hasZodImport)
const localSchemaRoutes = routeAudits.filter((audit) => audit.schemaConstructorCount > 0)
const zodErrorRoutes = routeAudits.filter((audit) => audit.hasZodErrorReference)
const zodImportQueryHooks = queryHookAudits.filter((audit) => audit.hasZodImport)
const localSchemaQueryHooks = queryHookAudits.filter((audit) => audit.schemaConstructorCount > 0)
const adHocWireTypes = queryHookAudits.flatMap((audit) => audit.adHocWireTypes)
const sameOriginApiFetchFiles = [
...new Set(sameOriginApiFetchFindings.map((finding) => finding.path)),
].sort()
console.log('\nBoundary policy drift:')
console.log(' ratcheted metrics:')
for (const metric of ratchetedMetrics) {
printBoundaryPolicyMetric(metric)
}
console.log(' print-only heuristics:')
for (const metric of printOnlyMetrics) {
console.log(` ${metric.label}: ${metric.current}`)
}
console.log(
' ratchet enforcement: pass --enforce-boundary-baseline to fail on ratcheted metric increases'
)
console.log(' ratchet update: lower BOUNDARY_POLICY_BASELINE after reducing a ratcheted count')
console.log('\nBoundary policy examples:')
console.log(' route zod import examples:')
for (const audit of zodImportRoutes.slice(0, 25)) {
console.log(` ${audit.path}`)
}
if (zodImportRoutes.length > 25) {
console.log(` ... ${zodImportRoutes.length - 25} more`)
}
console.log(' route local schema constructor examples:')
for (const audit of localSchemaRoutes.slice(0, 25)) {
console.log(` ${audit.path} (${audit.schemaConstructorCount})`)
}
if (localSchemaRoutes.length > 25) {
console.log(` ... ${localSchemaRoutes.length - 25} more`)
}
console.log(' route ZodError reference examples:')
for (const audit of zodErrorRoutes.slice(0, 25)) {
console.log(` ${audit.path}`)
}
if (zodErrorRoutes.length > 25) {
console.log(` ... ${zodErrorRoutes.length - 25} more`)
}
console.log(' client hook zod import examples:')
for (const audit of zodImportQueryHooks.slice(0, 25)) {
console.log(` ${audit.path}`)
}
if (zodImportQueryHooks.length > 25) {
console.log(` ... ${zodImportQueryHooks.length - 25} more`)
}
console.log(' query-hook local schema constructor examples:')
for (const audit of localSchemaQueryHooks.slice(0, 25)) {
console.log(` ${audit.path} (${audit.schemaConstructorCount})`)
}
if (localSchemaQueryHooks.length > 25) {
console.log(` ... ${localSchemaQueryHooks.length - 25} more`)
}
console.log(' query-hook ad-hoc wire type examples:')
for (const finding of adHocWireTypes.slice(0, 25)) {
console.log(` ${finding.path}:${finding.line} ${finding.name}`)
}
if (adHocWireTypes.length > 25) {
console.log(` ... ${adHocWireTypes.length - 25} more`)
}
console.log(' apps/sim same-origin /api/ fetch file examples:')
for (const filePath of sameOriginApiFetchFiles.slice(0, 25)) {
console.log(` ${filePath}`)
}
if (sameOriginApiFetchFiles.length > 25) {
console.log(` ... ${sameOriginApiFetchFiles.length - 25} more`)
}
console.log(' contract untyped response schema examples:')
for (const finding of untypedResponseFindings.slice(0, 25)) {
console.log(` ${finding.path}:${finding.line} ${finding.preview}`)
}
if (untypedResponseFindings.length > 25) {
console.log(` ... ${untypedResponseFindings.length - 25} more`)
}
}
function boundaryPolicyFailures(metrics: BoundaryPolicyMetric[]): string[] {
return metrics
.filter((metric) => metric.current > BOUNDARY_POLICY_BASELINE[metric.key])
.map(
(metric) =>
`${metric.label} increased from ${BOUNDARY_POLICY_BASELINE[metric.key]} to ${metric.current}`
)
}
async function auditQueryHooks(): Promise<QueryHookAudit[]> {
const queryHookFiles = await walk(
QUERY_HOOKS_DIR,
(fileName) => /\.(ts|tsx)$/.test(fileName) && !/\.test\.(ts|tsx)$/.test(fileName)
)
const audits: QueryHookAudit[] = []
for (const filePath of queryHookFiles) {
const content = await readFile(filePath, 'utf8')
audits.push(auditQueryHook(filePath, content))
}
return audits
}
async function main() {
const checkOnly = process.argv.includes('--check')
const enforceBoundaryBaseline = process.argv.includes('--enforce-boundary-baseline')
const routeFiles = await walk(API_DIR, (fileName) => fileName === 'route.ts')
const audits: RouteAudit[] = []
const rawJsonFindings: RawJsonFinding[] = []
const annotationsMissingReason: AnnotationMissingReasonFinding[] = []
let rawJsonExemptions = 0
for (const filePath of routeFiles) {
const content = await readFile(filePath, 'utf8')
audits.push(auditRoute(filePath, content))
const rawJson = findRawJsonFindings(filePath, content)
rawJsonFindings.push(...rawJson.findings)
rawJsonExemptions += rawJson.exemptions
annotationsMissingReason.push(...rawJson.missingReasons)
}
const queryHookAudits = await auditQueryHooks()
const sourceFiles = await walkAllSourceFiles(ROOT, true)
const rawFetchFindings: RawFetchFinding[] = []
const sameOriginApiFetchFindings: SameOriginApiFetchFinding[] = []
const doubleCastFindings: DoubleCastFinding[] = []
let rawFetchExemptions = 0
let sameOriginApiFetchExemptions = 0
let doubleCastExemptions = 0
const appsSimRoot = path.join(ROOT, 'apps/sim')
for (const filePath of sourceFiles) {
const content = await readFile(filePath, 'utf8')
const normalized = filePath.replace(/\\/g, '/')
if (isClientHookFile(filePath)) {
const rawFetch = findRawFetchFindings(filePath, content)
rawFetchFindings.push(...rawFetch.findings)
rawFetchExemptions += rawFetch.exemptions
annotationsMissingReason.push(...rawFetch.missingReasons)
}
if (
normalized.startsWith(`${appsSimRoot}/`) &&
!isApiRouteHandler(filePath) &&
filePath !== path.join(ROOT, 'scripts', 'check-api-validation-contracts.ts')
) {
const sameOrigin = findSameOriginApiFetchFindings(filePath, content)
sameOriginApiFetchFindings.push(...sameOrigin.findings)
sameOriginApiFetchExemptions += sameOrigin.exemptions
annotationsMissingReason.push(...sameOrigin.missingReasons)
}
const doubleCast = findDoubleCastFindings(filePath, content)
doubleCastFindings.push(...doubleCast.findings)
doubleCastExemptions += doubleCast.exemptions
annotationsMissingReason.push(...doubleCast.missingReasons)
}
const contractFiles = await walk(CONTRACTS_DIR, (fileName) => /\.ts$/.test(fileName))
const untypedResponseFindings: UntypedResponseFinding[] = []
let untypedResponseExemptions = 0
for (const filePath of contractFiles) {
const content = await readFile(filePath, 'utf8')
const untyped = findUntypedResponseFindings(filePath, content)
untypedResponseFindings.push(...untyped.findings)
untypedResponseExemptions += untyped.exemptions
annotationsMissingReason.push(...untyped.missingReasons)
}
const { ratchetedMetrics, printOnlyMetrics } = buildBoundaryPolicyMetrics(
audits,
queryHookAudits,
{ findings: rawFetchFindings, exemptions: rawFetchExemptions },
{
findings: sameOriginApiFetchFindings,
exemptions: sameOriginApiFetchExemptions,
},
{ findings: doubleCastFindings, exemptions: doubleCastExemptions },
{ findings: rawJsonFindings, exemptions: rawJsonExemptions },
{ findings: untypedResponseFindings, exemptions: untypedResponseExemptions },
annotationsMissingReason
)
const totalRoutes = audits.length
const zodRoutes = audits.filter((audit) => audit.usesZod).length
const nonZodRoutes = totalRoutes - zodRoutes
console.log('API validation route audit')
console.log(` total routes: ${totalRoutes}`)
console.log(` Zod-backed routes: ${zodRoutes}`)
console.log(` non-Zod routes: ${nonZodRoutes}`)
console.log(
` baseline: total=${BASELINE.totalRoutes} zod=${BASELINE.zodRoutes} nonZod=${BASELINE.nonZodRoutes}`
)
printFamilyStats(audits)
printRiskyNonZodRoutes(audits)
printAllNonZodRoutes(audits)
printBoundaryContractDrift(
audits,
queryHookAudits,
sameOriginApiFetchFindings,
untypedResponseFindings,
ratchetedMetrics,
printOnlyMetrics
)
printRawFetchAndDoubleCastMetrics(
rawFetchFindings,
sameOriginApiFetchFindings,
doubleCastFindings,
rawJsonFindings,
untypedResponseFindings,
annotationsMissingReason,
rawFetchExemptions,
sameOriginApiFetchExemptions,
doubleCastExemptions,
rawJsonExemptions,
untypedResponseExemptions
)
if (!checkOnly) return
const failures: string[] = []
if (totalRoutes > BASELINE.totalRoutes) {
failures.push(`route count increased from ${BASELINE.totalRoutes} to ${totalRoutes}`)
}
if (nonZodRoutes > BASELINE.nonZodRoutes) {
failures.push(
`non-Zod routes increased from ${BASELINE.nonZodRoutes} to ${nonZodRoutes} (${zodRoutes} Zod-backed routes)`
)
}
if (enforceBoundaryBaseline) {
failures.push(...boundaryPolicyFailures(ratchetedMetrics))
}
if (failures.length > 0) {
console.error('\nAPI validation audit failed:')
for (const failure of failures) {
console.error(` - ${failure}`)
}
process.exit(1)
}
console.log('\nAPI validation audit passed.')
}
void main().catch((error) => {
console.error('API validation audit failed:', error)
process.exit(1)
})