mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-13 15:48:31 +08:00
chore(audits): check raw-route parseRequest contracts and enforce surface-neutral application imports (#8883)
* chore(audits): check raw-route parseRequest contracts and enforce surface-neutral application imports
check:route-verbs now resolves the contract each raw withRouteHandler route
passes to parseRequest and checks the exported verb and path against it
(322 sites across 258 files, previously unchecked). check:boundaries now bans
next/server and app/api imports, and runtime route-contract, presenter and
Copilot-handler imports, from application code. listSearchSources takes its
cursor route from the adapter instead of importing the route contract.
* fix(tests): pass cursorRoute in the org source-summary integration case
* fix(audits): resolve aliased contract imports and export-list verbs in check:route-verbs
Read a contract by the name its module exports, not the local alias, and
read handler verbs from export lists (export { GET }, export { h as GET })
as well as inline exports. Both builder and raw parseRequest sites share
the fix.
* fix(audits): follow aliased parseRequest imports and relative application imports
check:route-verbs matched raw parseRequest calls only by the literal name, so
`import { parseRequest as parse }` left the contract unchecked; it now matches
every local name bound to parseRequest from @/lib/api/server(/validation).
check:boundaries applied the application rules only to @/ specifiers, so a
relative import of a contract object, presenter, Copilot handler or app/api
module passed; relative specifiers are now normalized to their @/ form first.
* fix(audits): accept HEAD on a GET contract and treat empty import/export clauses as runtime edges
* fix(audits): keep the HEAD-on-GET allowance off defineScimRoute and catch extension-suffixed presenter imports
* fix(audits): follow exported non-verb helpers when resolving raw-route contracts
This commit is contained in:
@@ -226,7 +226,7 @@ Do not call shared authorization, principal audit attribution, or `recordAudit`
|
||||
|
||||
Inspect legacy orchestration before reusing it. If it already authorizes, audits, notifies, or captures analytics, call a lower-level primitive or remove duplicate responsibility for migrated callers.
|
||||
|
||||
Application code must remain surface-neutral. It must not import `app/api/**`, `next/server`, internal/v1/v2 contracts or presenters, or Copilot tool handlers. Return domain values and let each surface presenter project its own wire result.
|
||||
Application code stays surface-neutral (`check:boundaries`): never `next/server` or `app/api/**`, and never a runtime import of a route contract object, presenter, or Copilot handler (contract types, schemas, and constants are fine); a surface fact, such as a cursor's route, comes in as input. Return domain values and let each surface presenter project its own wire result.
|
||||
|
||||
## Adapt internal APIs
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ packages/
|
||||
- Every protected read, write, canonical resource lookup, or authorization-sensitive reference resolution enters through an authorized application use case.
|
||||
- Define one stable semantic operation with its minimum role, workspace-key policy, allowed principal kinds, and delegated services. Internal APIs, v2 APIs, Copilot, and trusted tools call the same use case when the domain behavior is the same.
|
||||
- Surface adapters authenticate and construct a `Principal`, apply request-rate policy, parse contracts, map input, and present their own result. They never query protected data, decide resource authorization, implement business transactions, or record semantic audit.
|
||||
- Application use cases load canonical context, compare asserted scope, authorize current access, execute managers/repositories, project semantic audit, and trigger shared domain effects. Managers accept canonical IDs and scope, never credentials or principals. Application code stays surface-neutral: it never imports `app/api/**`, `next/server`, route contracts/presenters, or Copilot handlers.
|
||||
- Application use cases load canonical context, compare asserted scope, authorize current access, execute managers/repositories, project semantic audit, and trigger shared domain effects. Managers accept canonical IDs and scope, never credentials or principals. Application code stays surface-neutral (`check:boundaries`): never `next/server` or `app/api/**`, and never a runtime import of a route contract object, presenter, or Copilot handler (contract types, schemas, and constants are fine); a surface fact, such as a cursor's route, comes in as input.
|
||||
- Copilot is a surface adapter. Use `createCopilotApplicationAdapter` and the domain's registered operation object; never a Copilot-only authorization or business implementation.
|
||||
- Protected compound mutations belong in one top-level semantic application operation, never a sequence of independently committing mutations in a route or tool adapter.
|
||||
- Never substitute a billing owner, uploader, creator, or API-key owner for the acting principal. Fail fast when the identity model or operation policy cannot express the caller.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { listSearchSourcesContract } from '@/lib/api/contracts/knowledge'
|
||||
import { cursorRoute } from '@/lib/api/cursor-binding'
|
||||
import {
|
||||
defineInternalJsonRoute,
|
||||
internalRateLimits,
|
||||
@@ -16,7 +17,7 @@ export const GET = defineInternalJsonRoute({
|
||||
reason: 'Workspace source summaries for the Search page and indexing status polling',
|
||||
}),
|
||||
errorPolicy: internalKnowledgeErrorPolicies.connectors,
|
||||
mapInput: ({ query }) => query,
|
||||
mapInput: ({ query }) => ({ ...query, cursorRoute: cursorRoute(listSearchSourcesContract) }),
|
||||
useCase: listSearchSources,
|
||||
present: (page) => ({ success: true as const, data: page }),
|
||||
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
|
||||
|
||||
@@ -23,7 +23,8 @@ const sourceIds = Array.from({ length: 105 }, () => generateId())
|
||||
.sort()
|
||||
.reverse()
|
||||
const olderSourceId = generateId()
|
||||
const input = { workspaceId: ids.workspaceId }
|
||||
const cursorRoute = { method: 'GET', path: '/api/knowledge/sim-search/sources' }
|
||||
const input = { workspaceId: ids.workspaceId, cursorRoute }
|
||||
|
||||
beforeAll(async () => {
|
||||
await seedKnowledgeAclFixture(ids)
|
||||
@@ -157,8 +158,10 @@ describe('bounded live Search source configuration pagination', () => {
|
||||
connectorType: 'google_drive',
|
||||
approved: false,
|
||||
})
|
||||
const owner = { organizationId: ids.organizationId }
|
||||
const summary = await listSearchSources.execute({ principal: alice, input: owner })
|
||||
const summary = await listSearchSources.execute({
|
||||
principal: alice,
|
||||
input: { organizationId: ids.organizationId, cursorRoute },
|
||||
})
|
||||
expect(summary.sources[0]).toMatchObject({
|
||||
connectorId: approvalSourceId,
|
||||
approved: false,
|
||||
|
||||
@@ -47,7 +47,8 @@ workspaceAuthzMockFns.mockPermissionSatisfies.mockImplementation(
|
||||
)
|
||||
|
||||
const principal = createSessionPrincipal({ userId: 'reader', sessionId: 'session' })
|
||||
const input = { workspaceId: 'workspace' }
|
||||
const cursorRoute = { method: 'GET', path: '/api/knowledge/sim-search/sources' }
|
||||
const input = { workspaceId: 'workspace', cursorRoute }
|
||||
const LAST_SYNC = new Date('2026-09-05T12:00:00.000Z')
|
||||
|
||||
function source(id: string, connectorType = 'google_drive', accessMode = 'admin') {
|
||||
@@ -156,7 +157,7 @@ describe('organization Search source summaries', () => {
|
||||
seed([source('drive')])
|
||||
const result = await listSearchSources.execute({
|
||||
principal,
|
||||
input: { organizationId: 'org-1' },
|
||||
input: { organizationId: 'org-1', cursorRoute },
|
||||
})
|
||||
expect(result.sources[0]).toMatchObject({
|
||||
connectorId: 'drive',
|
||||
@@ -174,7 +175,7 @@ describe('organization Search source summaries', () => {
|
||||
})
|
||||
queueTableRows(member, [])
|
||||
await expect(
|
||||
listSearchSources.execute({ principal, input: { organizationId: 'org-1' } })
|
||||
listSearchSources.execute({ principal, input: { organizationId: 'org-1', cursorRoute } })
|
||||
).rejects.toThrow('Organization not found')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,11 +3,8 @@ import { db } from '@sim/db'
|
||||
import { knowledgeBase, knowledgeConnector } from '@sim/db/schema'
|
||||
import { toRecord } from '@sim/utils/object'
|
||||
import { and, desc, eq, inArray, isNull, lt, ne, or, sql } from 'drizzle-orm'
|
||||
import {
|
||||
listSearchSourcesContract,
|
||||
searchSourceCursorSchema,
|
||||
} from '@/lib/api/contracts/knowledge/connectors'
|
||||
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
|
||||
import { searchSourceCursorSchema } from '@/lib/api/contracts/knowledge/connectors'
|
||||
import { type CursorScopeRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { type ResourceOwner, resourceScopeFromOwner } from '@/lib/core/resource-scope'
|
||||
import { resourceScopeCondition } from '@/lib/core/resource-scope.server'
|
||||
@@ -24,6 +21,8 @@ import { describeSearchSource } from '@/lib/sim-search/source-identity'
|
||||
import { getConnectorMeta } from '@/connectors/registry'
|
||||
|
||||
export interface ListSearchSourcesInput extends ResourceOwner {
|
||||
/** The list endpoint the adapter's cursors belong to; binds every cursor to it. */
|
||||
cursorRoute: CursorScopeRoute
|
||||
cursor?: string
|
||||
connectorId?: string
|
||||
connectorType?: string
|
||||
@@ -41,7 +40,7 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({
|
||||
const search = input.search?.trim().toLowerCase() ?? ''
|
||||
const connectorType = input.connectorType?.trim()
|
||||
const excludeConnectorType = input.excludeConnectorType?.trim()
|
||||
const cursorScope = cursorScopeKey(cursorRoute(listSearchSourcesContract), {
|
||||
const cursorScope = cursorScopeKey(input.cursorRoute, {
|
||||
workspaceId: context.workspaceId,
|
||||
organizationId: context.organizationId,
|
||||
userId: userId,
|
||||
|
||||
@@ -68,7 +68,15 @@ describe('Search source direct tool', () => {
|
||||
audience: 'sim:knowledge',
|
||||
resourceScope: { chatId: 'actual-chat' },
|
||||
}),
|
||||
input: { organizationId: 'actual-org', cursor: 'previous' },
|
||||
input: {
|
||||
organizationId: 'actual-org',
|
||||
cursor: 'previous',
|
||||
cursorRoute: {
|
||||
method: 'GET',
|
||||
path: '/api/knowledge/sim-search/sources',
|
||||
params: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(mocks.chat).toHaveBeenCalledBefore(mocks.list)
|
||||
})
|
||||
@@ -77,7 +85,17 @@ describe('Search source direct tool', () => {
|
||||
tool.execute({ action: 'get', connectorId: 'foreign' }, context)
|
||||
).rejects.toMatchObject({ code: 'not_found' })
|
||||
expect(mocks.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ input: { organizationId: 'actual-org', connectorId: 'foreign' } })
|
||||
expect.objectContaining({
|
||||
input: {
|
||||
organizationId: 'actual-org',
|
||||
connectorId: 'foreign',
|
||||
cursorRoute: {
|
||||
method: 'GET',
|
||||
path: '/api/knowledge/sim-search/sources',
|
||||
params: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
it('returns existing setup UI without creating an index, connecting or changing approval', async () => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { listSearchSourcesContract } from '@/lib/api/contracts/knowledge/connectors'
|
||||
import {
|
||||
type OrganizationSearchSourcesInput,
|
||||
type OrganizationSearchSourcesOutput,
|
||||
organizationSearchSourcesInputSchema,
|
||||
organizationSearchSourcesOutputSchema,
|
||||
} from '@/lib/api/contracts/mothership-search-sources'
|
||||
import { cursorRoute } from '@/lib/api/cursor-binding'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization'
|
||||
import {
|
||||
@@ -47,18 +49,23 @@ export const organizationSearchSourcesServerTool: BaseServerTool<
|
||||
context?.userStopSignal?.throwIfAborted()
|
||||
const input = organizationSearchSourcesInputSchema.parse(args)
|
||||
const owner = { organizationId: trusted.organizationId }
|
||||
// Shares the Search page's cursor namespace, so a page cursor stays valid on either surface.
|
||||
const sourcesCursorRoute = cursorRoute(listSearchSourcesContract)
|
||||
switch (input.action) {
|
||||
case 'list': {
|
||||
const { action, ...filters } = input
|
||||
return {
|
||||
action,
|
||||
...(await listSearchSources.execute({ principal, input: { ...owner, ...filters } })),
|
||||
...(await listSearchSources.execute({
|
||||
principal,
|
||||
input: { ...owner, ...filters, cursorRoute: sourcesCursorRoute },
|
||||
})),
|
||||
}
|
||||
}
|
||||
case 'get': {
|
||||
const page = await listSearchSources.execute({
|
||||
principal,
|
||||
input: { ...owner, connectorId: input.connectorId },
|
||||
input: { ...owner, connectorId: input.connectorId, cursorRoute: sourcesCursorRoute },
|
||||
})
|
||||
const source = page.sources[0]
|
||||
if (!source) throw new OrchestrationError('not_found', 'Search source not found')
|
||||
|
||||
@@ -66,4 +66,68 @@ describe('package boundary command', () => {
|
||||
rmSync(fixture, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('keeps application code surface-neutral while allowing shared contract vocabulary', () => {
|
||||
const fixture = mkdtempSync(path.join(ROOT, 'node_modules/.boundary-audit-'))
|
||||
try {
|
||||
mkdirSync(path.join(fixture, 'scripts'))
|
||||
mkdirSync(path.join(fixture, 'packages'))
|
||||
mkdirSync(path.join(fixture, 'apps/sim/lib/widgets/application'), { recursive: true })
|
||||
copyFileSync(
|
||||
path.join(ROOT, 'scripts/check-monorepo-boundaries.ts'),
|
||||
path.join(fixture, 'scripts/check-monorepo-boundaries.ts')
|
||||
)
|
||||
const useCase = 'apps/sim/lib/widgets/application/use-case.ts'
|
||||
const cases = [
|
||||
["import { NextResponse } from 'next/server'", useCase, false],
|
||||
["import type { NextRequest } from 'next/server'", useCase, false],
|
||||
["import { GET } from '@/app/api/widgets/route'", useCase, false],
|
||||
["import { listWidgetsContract } from '@/lib/api/contracts/widgets'", useCase, false],
|
||||
["export { listWidgetsContract } from '@/lib/api/contracts/widgets'", useCase, false],
|
||||
["import * as contracts from '@/lib/api/contracts/widgets'", useCase, false],
|
||||
["import { presentWidget } from '@/lib/api/server/widget-presenters'", useCase, false],
|
||||
["import { presentWidget } from '@/lib/api/server/widget-presenters.ts'", useCase, false],
|
||||
["import { run } from '@/lib/mothership/tools/handlers/run-code'", useCase, false],
|
||||
["import {} from '@/lib/mothership/tools/handlers/run-code'", useCase, false],
|
||||
["export {} from '@/lib/api/server/widget-presenters'", useCase, false],
|
||||
["const tool = import('@/lib/mothership/tools/server/widgets')", useCase, false],
|
||||
["import { listWidgetsContract } from '../../api/contracts/widgets'", useCase, false],
|
||||
["import { GET } from '../../../app/api/widgets/route'", useCase, false],
|
||||
["import type { ListWidgetsContract } from '../../api/contracts/widgets'", useCase, true],
|
||||
["import type { ListWidgetsContract } from '@/lib/api/contracts/widgets'", useCase, true],
|
||||
["import { type listWidgetsContract } from '@/lib/api/contracts/widgets'", useCase, true],
|
||||
[
|
||||
"import { widgetBodySchema, MAX_WIDGETS } from '@/lib/api/contracts/widgets'",
|
||||
useCase,
|
||||
true,
|
||||
],
|
||||
[
|
||||
"import type { ServerToolContext } from '@/lib/mothership/tools/server/base-tool'",
|
||||
useCase,
|
||||
true,
|
||||
],
|
||||
[
|
||||
"import { NextResponse } from 'next/server'",
|
||||
'apps/sim/lib/widgets/application/use-case.test.ts',
|
||||
true,
|
||||
],
|
||||
["import { NextResponse } from 'next/server'", 'apps/sim/lib/widgets/routes.ts', true],
|
||||
] as const
|
||||
|
||||
for (const [source, file, allowed] of cases) {
|
||||
rmSync(path.join(fixture, 'apps/sim/lib/widgets'), { recursive: true, force: true })
|
||||
mkdirSync(path.join(fixture, 'apps/sim/lib/widgets/application'), { recursive: true })
|
||||
writeFileSync(path.join(fixture, file), `${source}\n`)
|
||||
const result = spawnSync(
|
||||
'bun',
|
||||
[path.join(fixture, 'scripts/check-monorepo-boundaries.ts')],
|
||||
{ cwd: fixture, encoding: 'utf8' }
|
||||
)
|
||||
expect.soft(result.status, `${file}: ${source}\n${result.stderr}`).toBe(allowed ? 0 : 1)
|
||||
if (!allowed) expect.soft(result.stderr).toContain(`${file}:1`)
|
||||
}
|
||||
} finally {
|
||||
rmSync(fixture, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Fails when any file under `packages/*` imports from `apps/*`.
|
||||
* Import boundaries no type-check can see.
|
||||
*
|
||||
* Dependencies point toward shared code: apps consume packages, never the reverse. A package
|
||||
* that reaches into an app drags that app's module graph (Next.js, the block and tool
|
||||
* registries, the executor) into every other consumer, including `apps/realtime`, and couples
|
||||
* the package to an app's internal layout.
|
||||
* 1. Packages never import from `apps/*`. Dependencies point toward shared code: a package that
|
||||
* reaches into an app drags that app's module graph (Next.js, the block and tool registries,
|
||||
* the executor) into every other consumer, including `apps/realtime`. Flagged, including
|
||||
* type-only imports, re-exports, `import()` and `require()`: an `@/…` or `apps/…` specifier,
|
||||
* an app's package name (or a subpath of it), and a relative path that resolves into
|
||||
* `apps/`. Fix by moving the shared code into a package both sides import, or by keeping the
|
||||
* code that needs it in the app.
|
||||
* 2. Application code (every non-test file under an `application/` folder in `apps/sim`) stays
|
||||
* surface-neutral, so internal routes, v2, Copilot and jobs can all call the same use case:
|
||||
* - never `next/server` or `@/app/api/**`, not even for a type;
|
||||
* - never, at runtime, a route contract object (a `*Contract` binding from
|
||||
* `@/lib/api/contracts`), a presenter module (`*-presenter(s)`) or a Copilot handler
|
||||
* (`@/lib/mothership/tools/{handlers,server}/**`). Contract types, schemas and constants
|
||||
* are shared domain vocabulary and stay allowed, as does `import type` of a Copilot
|
||||
* context type.
|
||||
* Fix by taking the surface fact (a cursor's route identity, a wire shape) as input from
|
||||
* the adapter.
|
||||
*
|
||||
* Flagged, including type-only imports, re-exports, `import()` and `require()`: an `@/…` or
|
||||
* `apps/…` specifier, an app's package name (or a subpath of it), and a relative path that
|
||||
* resolves into `apps/`.
|
||||
*
|
||||
* Fix a finding by moving the shared code into a package that both sides import, or by keeping
|
||||
* the code that needs it in the app. There is no allowlist.
|
||||
*
|
||||
* Run: `bun run check:boundaries`
|
||||
* There is no allowlist. Run: `bun run check:boundaries`
|
||||
*/
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
@@ -22,6 +28,150 @@ import ts from '@typescript/typescript6'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..')
|
||||
const PACKAGES_DIR = path.join(ROOT, 'packages')
|
||||
const SIM_DIR = path.join(ROOT, 'apps/sim')
|
||||
|
||||
const TEST_FILE = /\.(test|spec|integration)\.tsx?$/
|
||||
|
||||
function matchesModule(specifier: string, module: string): boolean {
|
||||
return specifier === module || specifier.startsWith(`${module}/`)
|
||||
}
|
||||
|
||||
/**
|
||||
* A relative specifier rewritten into its `@/…` form against the importing file
|
||||
* (the `apps/sim` root is `@/`), so the application rules see one spelling.
|
||||
* Bare and already-aliased specifiers, and relative ones leaving `apps/sim`,
|
||||
* come back unchanged.
|
||||
*/
|
||||
function aliasedSpecifier(file: string, specifier: string): string {
|
||||
if (!specifier.startsWith('.')) return specifier
|
||||
const target = path.relative(SIM_DIR, path.resolve(path.dirname(file), specifier))
|
||||
if (target.startsWith('..') || path.isAbsolute(target)) return specifier
|
||||
return target ? `@/${target.split(path.sep).join('/')}` : '@'
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an application file may not take this import edge, or `null` when it may.
|
||||
* An empty `bindings` list is an edge the compiler erases.
|
||||
*/
|
||||
function applicationViolation(
|
||||
specifier: string,
|
||||
bindings: readonly string[] | 'all'
|
||||
): string | null {
|
||||
if (matchesModule(specifier, 'next/server') || matchesModule(specifier, '@/app/api')) {
|
||||
return `application code imports a surface module: ${specifier}`
|
||||
}
|
||||
if (bindings !== 'all' && bindings.length === 0) return null
|
||||
if (
|
||||
matchesModule(specifier, '@/lib/mothership/tools/handlers') ||
|
||||
matchesModule(specifier, '@/lib/mothership/tools/server')
|
||||
) {
|
||||
return `application code imports a Copilot handler: ${specifier}`
|
||||
}
|
||||
if (specifier.startsWith('@/') && /(^|[/-])presenters?(\.[cm]?[jt]sx?)?$/.test(specifier)) {
|
||||
return `application code imports a presenter: ${specifier}`
|
||||
}
|
||||
if (matchesModule(specifier, '@/lib/api/contracts')) {
|
||||
const contracts =
|
||||
bindings === 'all' ? ['*'] : bindings.filter((name) => name.endsWith('Contract'))
|
||||
if (contracts.length > 0) {
|
||||
return `application code imports route contract ${contracts.join(', ')} from ${specifier}; take the surface fact as input from the adapter`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime (non-type) names an import/export declaration binds: `'all'` for a
|
||||
* namespace, default, star, empty-clause or side-effect edge, `[]` for one the compiler erases.
|
||||
*/
|
||||
function runtimeBindings(node: ts.ImportDeclaration | ts.ExportDeclaration): string[] | 'all' {
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
const clause = node.importClause
|
||||
if (!clause) return 'all'
|
||||
if (clause.isTypeOnly) return []
|
||||
if (clause.name) return 'all'
|
||||
const named = clause.namedBindings
|
||||
if (!named) return []
|
||||
if (ts.isNamespaceImport(named) || named.elements.length === 0) return 'all'
|
||||
return named.elements
|
||||
.filter((element) => !element.isTypeOnly)
|
||||
.map((element) => (element.propertyName ?? element.name).text)
|
||||
}
|
||||
if (node.isTypeOnly) return []
|
||||
const clause = node.exportClause
|
||||
if (!clause || ts.isNamespaceExport(clause) || clause.elements.length === 0) return 'all'
|
||||
return clause.elements
|
||||
.filter((element) => !element.isTypeOnly)
|
||||
.map((element) => (element.propertyName ?? element.name).text)
|
||||
}
|
||||
|
||||
interface ImportEdge {
|
||||
module: ts.StringLiteralLike
|
||||
/** See {@link runtimeBindings}; dynamic `import()` and `require` bind `'all'`. */
|
||||
bindings: string[] | 'all'
|
||||
}
|
||||
|
||||
/** Every static, type-level and dynamic module reference in a file. */
|
||||
function importEdges(source: ts.SourceFile): ImportEdge[] {
|
||||
const edges: ImportEdge[] = []
|
||||
function visit(node: ts.Node): void {
|
||||
let module: ts.Node | undefined
|
||||
let bindings: string[] | 'all' = 'all'
|
||||
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
||||
module = node.moduleSpecifier
|
||||
bindings = runtimeBindings(node)
|
||||
} else if (
|
||||
ts.isImportEqualsDeclaration(node) &&
|
||||
ts.isExternalModuleReference(node.moduleReference)
|
||||
) {
|
||||
module = node.moduleReference.expression
|
||||
if (node.isTypeOnly) bindings = []
|
||||
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
||||
module = node.argument.literal
|
||||
bindings = []
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
||||
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
|
||||
) {
|
||||
module = node.arguments[0]
|
||||
}
|
||||
if (module && ts.isStringLiteralLike(module)) edges.push({ module, bindings })
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
return edges
|
||||
}
|
||||
|
||||
interface Offender {
|
||||
file: string
|
||||
line: number
|
||||
description: string
|
||||
snippet: string
|
||||
}
|
||||
|
||||
/** Parses `file` and reports each import edge `violation` describes. */
|
||||
async function scanImports(
|
||||
file: string,
|
||||
violation: (edge: ImportEdge) => string | null
|
||||
): Promise<Offender[]> {
|
||||
const content = await readFile(file, 'utf8')
|
||||
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true)
|
||||
const lines = content.split('\n')
|
||||
const offenders: Offender[] = []
|
||||
for (const edge of importEdges(source)) {
|
||||
const description = violation(edge)
|
||||
if (!description) continue
|
||||
const { line } = source.getLineAndCharacterOfPosition(edge.module.getStart(source))
|
||||
offenders.push({
|
||||
file: path.relative(ROOT, file),
|
||||
line: line + 1,
|
||||
description,
|
||||
snippet: lines[line].trim(),
|
||||
})
|
||||
}
|
||||
return offenders
|
||||
}
|
||||
|
||||
/** Package dependencies point toward shared code, including type-only imports. */
|
||||
function forbiddenImport(file: string, specifier: string, appNames: ReadonlySet<string>): boolean {
|
||||
@@ -71,53 +221,35 @@ async function main() {
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(PACKAGES_DIR, entry.name))
|
||||
|
||||
const offenders: Array<{ file: string; line: number; description: string; snippet: string }> = []
|
||||
const offenders: Offender[] = []
|
||||
|
||||
for (const dir of packageDirs) {
|
||||
const files = await walk(dir)
|
||||
for (const file of files) {
|
||||
const content = await readFile(file, 'utf8')
|
||||
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true)
|
||||
const lines = content.split('\n')
|
||||
function visit(node: ts.Node): void {
|
||||
let module: ts.Node | undefined
|
||||
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
||||
module = node.moduleSpecifier
|
||||
} else if (
|
||||
ts.isImportEqualsDeclaration(node) &&
|
||||
ts.isExternalModuleReference(node.moduleReference)
|
||||
) {
|
||||
module = node.moduleReference.expression
|
||||
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
||||
module = node.argument.literal
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
||||
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
|
||||
) {
|
||||
module = node.arguments[0]
|
||||
}
|
||||
if (
|
||||
module &&
|
||||
ts.isStringLiteralLike(module) &&
|
||||
forbiddenImport(file, module.text, appNames)
|
||||
) {
|
||||
const { line } = source.getLineAndCharacterOfPosition(module.getStart(source))
|
||||
offenders.push({
|
||||
file: path.relative(ROOT, file),
|
||||
line: line + 1,
|
||||
description: `import into apps/: ${module.text}`,
|
||||
snippet: lines[line].trim(),
|
||||
})
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
for (const file of await walk(dir)) {
|
||||
offenders.push(
|
||||
...(await scanImports(file, ({ module }) =>
|
||||
forbiddenImport(file, module.text, appNames) ? `import into apps/: ${module.text}` : null
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of await walk(SIM_DIR).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return []
|
||||
throw error
|
||||
})) {
|
||||
if (!path.relative(ROOT, file).split(path.sep).includes('application') || TEST_FILE.test(file))
|
||||
continue
|
||||
offenders.push(
|
||||
...(await scanImports(file, ({ module, bindings }) =>
|
||||
applicationViolation(aliasedSpecifier(file, module.text), bindings)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
if (offenders.length === 0) {
|
||||
console.log('✅ Monorepo boundaries OK: no package imports from apps/*')
|
||||
console.log(
|
||||
'✅ Monorepo boundaries OK: no package imports from apps/*, and application code is surface-neutral'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { wrappedRouteSites } from './check-route-verbs'
|
||||
import {
|
||||
checkSite,
|
||||
importedNames,
|
||||
rawRouteContractSites,
|
||||
wrappedRouteSites,
|
||||
} from './check-route-verbs'
|
||||
|
||||
describe('traced route declarations', () => {
|
||||
const handler = `const handler = defineInternalJsonRoute({
|
||||
@@ -10,7 +15,7 @@ describe('traced route declarations', () => {
|
||||
export const POST = (request: NextRequest, context?: Parameters<typeof handler>[1]) =>
|
||||
withIncomingGoSpan(request.headers, span, undefined, () => handler(request, context))`
|
||||
expect(wrappedRouteSites(source)).toEqual([
|
||||
{ verb: 'POST', optionsStart: source.indexOf('{') + 1 },
|
||||
{ verb: 'POST', builder: 'defineInternalJsonRoute', optionsStart: source.indexOf('{') + 1 },
|
||||
])
|
||||
})
|
||||
it('preserves a wrong verb so the contract comparison rejects it', () => {
|
||||
@@ -25,8 +30,126 @@ const fallback = defineInternalJsonRoute({
|
||||
})
|
||||
export const POST = enabled ? handler : fallback`
|
||||
expect(wrappedRouteSites(source)).toEqual([
|
||||
{ verb: 'POST', optionsStart: source.indexOf('{') + 1 },
|
||||
{ verb: 'POST', optionsStart: source.indexOf('{', source.indexOf('const fallback')) + 1 },
|
||||
{ verb: 'POST', builder: 'defineInternalJsonRoute', optionsStart: source.indexOf('{') + 1 },
|
||||
{
|
||||
verb: 'POST',
|
||||
builder: 'defineInternalJsonRoute',
|
||||
optionsStart: source.indexOf('{', source.indexOf('const fallback')) + 1,
|
||||
},
|
||||
])
|
||||
})
|
||||
it('checks a builder handler exported through an export list', () => {
|
||||
const source = `const GET = defineInternalJsonRoute({
|
||||
contract: abortContract,
|
||||
})
|
||||
export { GET }`
|
||||
expect(wrappedRouteSites(source)).toEqual([
|
||||
{ verb: 'GET', builder: 'defineInternalJsonRoute', optionsStart: source.indexOf('{') + 1 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('raw route parseRequest sites', () => {
|
||||
it('attributes a contract parsed in a same-file helper to the verb that calls it', () => {
|
||||
const source = `async function handle(request) {
|
||||
return parseRequest(updateContract, request, {})
|
||||
}
|
||||
export const PATCH = withRouteHandler((request) => handle(request))`
|
||||
expect(rawRouteContractSites(source)).toEqual([{ verb: 'PATCH', identifier: 'updateContract' }])
|
||||
})
|
||||
it('checks a verb alias only under the verb it forwards to', () => {
|
||||
const source = `export const PATCH = withRouteHandler((request) => parseRequest(updateContract, request, {}))
|
||||
export const PUT = withRouteHandler((request, context) => PATCH(request, context))`
|
||||
expect(rawRouteContractSites(source)).toEqual([{ verb: 'PATCH', identifier: 'updateContract' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('export-list verbs on raw routes', () => {
|
||||
it('follows an exported non-verb helper that parses the contract', () => {
|
||||
const source = `import { updateContract } from '@/lib/api/contracts/things'
|
||||
export async function parseUpdate(request) { return parseRequest(updateContract, request) }
|
||||
export const PUT = withRouteHandler((request) => parseUpdate(request))`
|
||||
expect(rawRouteContractSites(source)).toEqual([{ verb: 'PUT', identifier: 'updateContract' }])
|
||||
})
|
||||
it('checks a handler exported as \`export { GET }\`', () => {
|
||||
const source = `const GET = withRouteHandler((request) => parseRequest(listContract, request, {}))
|
||||
export { GET }`
|
||||
expect(rawRouteContractSites(source)).toEqual([{ verb: 'GET', identifier: 'listContract' }])
|
||||
})
|
||||
it('attributes a renamed export to its exported verb, so a wrong verb is compared', () => {
|
||||
const source = `const handler = withRouteHandler((request) => parseRequest(updateContract, request, {}))
|
||||
export { handler as PUT }`
|
||||
expect(rawRouteContractSites(source)).toEqual([{ verb: 'PUT', identifier: 'updateContract' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('aliased parseRequest imports', () => {
|
||||
it('finds the contract parsed through an aliased parseRequest, so a wrong verb is compared', async () => {
|
||||
const source = `import { parseRequest as parse } from '@/lib/api/server'
|
||||
import { updateContract } from '@/lib/api/contracts/things'
|
||||
export const PUT = withRouteHandler((request) => parse(updateContract, request, {}))`
|
||||
const route = {
|
||||
relative: 'route.ts',
|
||||
bindings: importedNames(source),
|
||||
expectedPath: '/api/things',
|
||||
}
|
||||
const loadModule = () =>
|
||||
Promise.resolve({ updateContract: { method: 'PATCH', path: '/api/things' } })
|
||||
const failures: string[] = []
|
||||
for (const { verb, identifier } of rawRouteContractSites(source)) {
|
||||
await checkSite(route, 'raw', verb, identifier, failures, false, loadModule)
|
||||
}
|
||||
expect(failures).toHaveLength(1)
|
||||
expect(failures[0]).toContain('which declares PATCH /api/things')
|
||||
})
|
||||
})
|
||||
|
||||
describe('aliased contract imports', () => {
|
||||
const source = `import { updateContract as contract } from '@/lib/api/contracts/things'`
|
||||
const route = {
|
||||
relative: 'route.ts',
|
||||
bindings: importedNames(source),
|
||||
expectedPath: '/api/things',
|
||||
}
|
||||
const loadModule = () =>
|
||||
Promise.resolve({ updateContract: { method: 'PATCH', path: '/api/things' } })
|
||||
|
||||
it('reads the contract by its exported name and passes the matching verb', async () => {
|
||||
const failures: string[] = []
|
||||
expect(await checkSite(route, 'raw', 'PATCH', 'contract', failures, false, loadModule)).toBe(
|
||||
true
|
||||
)
|
||||
expect(failures).toEqual([])
|
||||
})
|
||||
it('reports a verb that disagrees with the aliased contract', async () => {
|
||||
const failures: string[] = []
|
||||
await checkSite(route, 'builder', 'PUT', 'contract', failures, false, loadModule)
|
||||
expect(failures).toHaveLength(1)
|
||||
expect(failures[0]).toContain('which declares PATCH /api/things')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HEAD handlers', () => {
|
||||
const route = {
|
||||
relative: 'route.ts',
|
||||
bindings: importedNames(`import { readContract } from '@/lib/api/contracts/things'`),
|
||||
expectedPath: '/api/things',
|
||||
}
|
||||
const loadModule = () => Promise.resolve({ readContract: { method: 'GET', path: '/api/things' } })
|
||||
|
||||
it('accepts a HEAD handler on a GET contract, as methodMatchesContract does', async () => {
|
||||
const failures: string[] = []
|
||||
await checkSite(route, 'raw', 'HEAD', 'readContract', failures, false, loadModule)
|
||||
expect(failures).toEqual([])
|
||||
})
|
||||
it('rejects HEAD on a GET contract for defineScimRoute, which requires an exact method', async () => {
|
||||
const failures: string[] = []
|
||||
await checkSite(route, 'scim', 'HEAD', 'readContract', failures, false, loadModule)
|
||||
expect(failures).toHaveLength(1)
|
||||
})
|
||||
it('still rejects any other verb on a GET contract', async () => {
|
||||
const failures: string[] = []
|
||||
await checkSite(route, 'raw', 'POST', 'readContract', failures, false, loadModule)
|
||||
expect(failures).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
+333
-102
@@ -31,15 +31,22 @@
|
||||
* 3. Compare the exported verb symbol to `contract.method`, and the derived
|
||||
* URL to `contract.path`.
|
||||
*
|
||||
* Known, intentional limitation: routes written as raw `withRouteHandler(...)`
|
||||
* — the documented protocol/lifecycle exceptions for streaming, multipart
|
||||
* control, large-body admission, OAuth and public execution — have no
|
||||
* `contract:` key and are deliberately out of scope. They are excluded by
|
||||
* requiring a builder call, not by an allowlist.
|
||||
* Raw `withRouteHandler(...)` routes — the documented protocol/lifecycle
|
||||
* exceptions for streaming, multipart control, large-body admission, OAuth and
|
||||
* public execution — have no `contract:` key, but most still validate with
|
||||
* `parseRequest(<contract>, …)`. Every `parseRequest` call reachable from an
|
||||
* exported verb (directly, or through a same-file function or const it names)
|
||||
* is checked the same way. The mismatch is just as silent there: `parseRequest`
|
||||
* never reads a body for a `GET` contract, and `requestJson` sends the
|
||||
* contract's verb, so clients get a 405.
|
||||
*
|
||||
* Nothing is skipped silently. A builder call site whose contract cannot be
|
||||
* located, resolved, imported or read fails the build exactly like a mismatch:
|
||||
* a guard that quietly ignores what it cannot parse guards nothing.
|
||||
* a guard that quietly ignores what it cannot parse guards nothing. The same
|
||||
* holds for a raw route's `parseRequest` argument imported from
|
||||
* `@/lib/api/contracts`. A raw route whose argument is a local value or comes
|
||||
* from any other module is out of scope: resolving it would mean importing
|
||||
* server code.
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/check-route-verbs.ts
|
||||
@@ -88,10 +95,63 @@ const CONTRACT_KEY_RE = /\n\s{2}contract:\s*([A-Za-z0-9_$]+)\s*,/
|
||||
/** How far past the builder's `({` to look for the `contract:` key. */
|
||||
const OPTIONS_SCAN_CHARS = 4000
|
||||
|
||||
type Statements = ReturnType<typeof parse>['program']['body']
|
||||
|
||||
function isVerb(name: string): boolean {
|
||||
return VERBS.some((verb) => verb === name)
|
||||
}
|
||||
|
||||
/**
|
||||
* A module's top-level functions and variables (name -> body or initialiser),
|
||||
* and every local export as `{ exported, local }` — both an inline
|
||||
* `export const GET = …` and an export list such as `export { handler as GET }`.
|
||||
* A re-export from another module (`export { GET } from '…'`) binds no local
|
||||
* and is left out.
|
||||
*/
|
||||
function topLevelBindings(statements: Statements): {
|
||||
locals: Map<string, unknown>
|
||||
exports: Array<{ exported: string; local: string }>
|
||||
} {
|
||||
const locals = new Map<string, unknown>()
|
||||
const exports: Array<{ exported: string; local: string }> = []
|
||||
for (const statement of statements) {
|
||||
const isExport = statement.type === 'ExportNamedDeclaration'
|
||||
if (isExport && !statement.declaration && !statement.source) {
|
||||
for (const specifier of statement.specifiers) {
|
||||
if (specifier.type !== 'ExportSpecifier') continue
|
||||
const exported =
|
||||
specifier.exported.type === 'Identifier'
|
||||
? specifier.exported.name
|
||||
: specifier.exported.value
|
||||
exports.push({ exported, local: specifier.local.name })
|
||||
}
|
||||
continue
|
||||
}
|
||||
const declaration = isExport ? statement.declaration : statement
|
||||
const named: Array<[string, unknown]> = []
|
||||
if (declaration?.type === 'FunctionDeclaration' && declaration.id) {
|
||||
named.push([declaration.id.name, declaration.body])
|
||||
} else if (declaration?.type === 'VariableDeclaration') {
|
||||
for (const declarator of declaration.declarations) {
|
||||
if (declarator.id.type === 'Identifier' && declarator.init) {
|
||||
named.push([declarator.id.name, declarator.init])
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [name, root] of named) {
|
||||
locals.set(name, root)
|
||||
if (isExport) exports.push({ exported: name, local: name })
|
||||
}
|
||||
}
|
||||
return { locals, exports }
|
||||
}
|
||||
|
||||
/** Resolves exported handler references and tracing callbacks without importing server code. */
|
||||
export function wrappedRouteSites(source: string): Array<{ verb: string; optionsStart: number }> {
|
||||
export function wrappedRouteSites(
|
||||
source: string
|
||||
): Array<{ verb: string; builder: string; optionsStart: number }> {
|
||||
const statements = parse(source, { sourceType: 'module', plugins: ['typescript'] }).program.body
|
||||
const handlers = new Map<string, number>()
|
||||
const handlers = new Map<string, { builder: string; optionsStart: number }>()
|
||||
for (const statement of statements) {
|
||||
if (statement.type !== 'VariableDeclaration' || statement.kind !== 'const') continue
|
||||
for (const declaration of statement.declarations) {
|
||||
@@ -101,41 +161,121 @@ export function wrappedRouteSites(source: string): Array<{ verb: string; options
|
||||
continue
|
||||
const options = call.arguments[0]
|
||||
if (options?.type === 'ObjectExpression' && typeof options.start === 'number') {
|
||||
handlers.set(declaration.id.name, options.start + 1)
|
||||
handlers.set(declaration.id.name, {
|
||||
builder: call.callee.name,
|
||||
optionsStart: options.start + 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const sites: Array<{ verb: string; optionsStart: number }> = []
|
||||
for (const statement of statements) {
|
||||
if (
|
||||
statement.type !== 'ExportNamedDeclaration' ||
|
||||
statement.declaration?.type !== 'VariableDeclaration'
|
||||
)
|
||||
continue
|
||||
for (const declaration of statement.declaration.declarations) {
|
||||
if (
|
||||
declaration.id.type !== 'Identifier' ||
|
||||
!VERBS.some((verb) => verb === declaration.id.name)
|
||||
)
|
||||
continue
|
||||
const verb = declaration.id.name
|
||||
const found = new Set<number>()
|
||||
const visit = (value: unknown): void => {
|
||||
if (!value || typeof value !== 'object') return
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit)
|
||||
return
|
||||
}
|
||||
const node = value as Record<string, unknown>
|
||||
if (node.type === 'Identifier' && typeof node.name === 'string') {
|
||||
const start = handlers.get(node.name)
|
||||
if (start !== undefined) found.add(start)
|
||||
}
|
||||
Object.values(node).forEach(visit)
|
||||
const { locals, exports } = topLevelBindings(statements)
|
||||
const sites: Array<{ verb: string; builder: string; optionsStart: number }> = []
|
||||
for (const { exported: verb, local } of exports) {
|
||||
if (!isVerb(verb)) continue
|
||||
const found = new Set<{ builder: string; optionsStart: number }>()
|
||||
const direct = handlers.get(local)
|
||||
if (direct !== undefined) found.add(direct)
|
||||
const visit = (value: unknown): void => {
|
||||
if (!value || typeof value !== 'object') return
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit)
|
||||
return
|
||||
}
|
||||
visit(declaration.init)
|
||||
for (const optionsStart of found) sites.push({ verb, optionsStart })
|
||||
const node = value as Record<string, unknown>
|
||||
if (node.type === 'Identifier' && typeof node.name === 'string') {
|
||||
const handler = handlers.get(node.name)
|
||||
if (handler !== undefined) found.add(handler)
|
||||
}
|
||||
Object.values(node).forEach(visit)
|
||||
}
|
||||
if (direct === undefined) visit(locals.get(local))
|
||||
for (const handler of found) sites.push({ verb, ...handler })
|
||||
}
|
||||
return sites
|
||||
}
|
||||
|
||||
/** Modules that export the server-side `parseRequest`. */
|
||||
const PARSE_REQUEST_MODULES = new Set(['@/lib/api/server', '@/lib/api/server/validation'])
|
||||
|
||||
/**
|
||||
* Local names a module binds to `parseRequest`: `parseRequest` itself, plus
|
||||
* each alias of an `import { parseRequest as … }` from its real module.
|
||||
*/
|
||||
function parseRequestNames(statements: Statements): Set<string> {
|
||||
const names = new Set(['parseRequest'])
|
||||
for (const statement of statements) {
|
||||
if (statement.type !== 'ImportDeclaration') continue
|
||||
if (!PARSE_REQUEST_MODULES.has(statement.source.value)) continue
|
||||
for (const specifier of statement.specifiers) {
|
||||
if (specifier.type !== 'ImportSpecifier') continue
|
||||
const imported =
|
||||
specifier.imported.type === 'Identifier'
|
||||
? specifier.imported.name
|
||||
: specifier.imported.value
|
||||
if (imported === 'parseRequest') names.add(specifier.local.name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/**
|
||||
* The `parseRequest(<identifier>, …)` contract arguments reachable from each
|
||||
* exported verb of a raw route, following same-file functions and consts the
|
||||
* verb's handler names. Another exported verb is not followed: an alias such as
|
||||
* `PUT` forwarding to `PATCH` is checked as `PATCH`. A non-identifier argument
|
||||
* is not a resolvable contract and is left out.
|
||||
*/
|
||||
export function rawRouteContractSites(source: string): Array<{ verb: string; identifier: string }> {
|
||||
const statements = parse(source, { sourceType: 'module', plugins: ['typescript'] }).program.body
|
||||
const { locals, exports } = topLevelBindings(statements)
|
||||
const parsers = parseRequestNames(statements)
|
||||
const verbLocals = new Set(
|
||||
exports.filter(({ exported }) => isVerb(exported)).map(({ local }) => local)
|
||||
)
|
||||
const sites: Array<{ verb: string; identifier: string }> = []
|
||||
for (const { exported: verb, local } of exports) {
|
||||
if (!isVerb(verb)) continue
|
||||
const identifiers = new Set<string>()
|
||||
const followed = new Set(verbLocals)
|
||||
const visit = (value: unknown): void => {
|
||||
if (!value || typeof value !== 'object') return
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit)
|
||||
return
|
||||
}
|
||||
const node = value as Record<string, unknown>
|
||||
if (node.type === 'CallExpression') {
|
||||
const callee = node.callee as { type?: string; name?: string }
|
||||
const [first] = node.arguments as Array<{ type?: string; name?: string }>
|
||||
if (
|
||||
callee.type === 'Identifier' &&
|
||||
callee.name !== undefined &&
|
||||
parsers.has(callee.name) &&
|
||||
first?.type === 'Identifier' &&
|
||||
first.name
|
||||
) {
|
||||
identifiers.add(first.name)
|
||||
}
|
||||
}
|
||||
if (node.type === 'Identifier' && typeof node.name === 'string') {
|
||||
const local = locals.get(node.name)
|
||||
if (local !== undefined && !followed.has(node.name)) {
|
||||
followed.add(node.name)
|
||||
visit(local)
|
||||
}
|
||||
}
|
||||
if (node.type === 'MemberExpression' && !node.computed) {
|
||||
visit(node.object)
|
||||
return
|
||||
}
|
||||
if (node.type === 'ObjectProperty' && !node.computed) {
|
||||
visit(node.value)
|
||||
return
|
||||
}
|
||||
Object.values(node).forEach(visit)
|
||||
}
|
||||
visit(locals.get(local))
|
||||
for (const identifier of identifiers) sites.push({ verb, identifier })
|
||||
}
|
||||
return sites
|
||||
}
|
||||
@@ -154,9 +294,15 @@ function listRouteFiles(dir: string, found: string[] = []): string[] {
|
||||
return found
|
||||
}
|
||||
|
||||
/** Local binding name -> module specifier, from the file's import statements. */
|
||||
function importedNames(source: string): Map<string, string> {
|
||||
const bindings = new Map<string, string>()
|
||||
/** Where an imported local binding comes from: its module and the name that module exports it as. */
|
||||
interface ImportedBinding {
|
||||
specifier: string
|
||||
exported: string
|
||||
}
|
||||
|
||||
/** Local binding name -> its source module and exported name, from the file's import statements. */
|
||||
export function importedNames(source: string): Map<string, ImportedBinding> {
|
||||
const bindings = new Map<string, ImportedBinding>()
|
||||
for (const match of source.matchAll(
|
||||
/import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+['"]([^'"]+)['"]/g
|
||||
)) {
|
||||
@@ -164,7 +310,7 @@ function importedNames(source: string): Map<string, string> {
|
||||
const clause = raw.trim().replace(/^type\s+/, '')
|
||||
if (!clause) continue
|
||||
const [original, alias] = clause.split(/\s+as\s+/).map((part) => part.trim())
|
||||
bindings.set(alias ?? original, match[2])
|
||||
bindings.set(alias ?? original, { specifier: match[2], exported: original })
|
||||
}
|
||||
}
|
||||
return bindings
|
||||
@@ -191,6 +337,14 @@ function loadContractModule(file: string): Promise<Record<string, unknown>> {
|
||||
return loaded
|
||||
}
|
||||
|
||||
/** Resolves and imports a contract module; null when the specifier resolves to no file. */
|
||||
type ContractModuleLoader = (specifier: string) => Promise<Record<string, unknown>> | null
|
||||
|
||||
const loadContractSpecifier: ContractModuleLoader = (specifier) => {
|
||||
const modulePath = resolveContractModule(specifier)
|
||||
return modulePath ? loadContractModule(modulePath) : null
|
||||
}
|
||||
|
||||
/** The URL Next.js actually serves this file at: its directory, minus route groups. */
|
||||
function derivedPath(file: string): string {
|
||||
const segments = path
|
||||
@@ -200,6 +354,113 @@ function derivedPath(file: string): string {
|
||||
return ['/api', ...segments].join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a contract identifier is bound in a route: a builder's `contract:` key or a raw
|
||||
* `parseRequest`. `scim` is `defineScimRoute`, the one builder that rejects a `HEAD` request
|
||||
* against a `GET` contract instead of serving it through `methodMatchesContract`.
|
||||
*/
|
||||
type SiteKind = 'builder' | 'scim' | 'raw'
|
||||
|
||||
/** One route file, as every site in it is checked against. */
|
||||
interface RouteFile {
|
||||
relative: string
|
||||
/** Local binding name -> its source module and exported name. */
|
||||
bindings: Map<string, ImportedBinding>
|
||||
/** The URL the file serves. */
|
||||
expectedPath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a route serves `contractPath`. A raw catch-all route (`[...slug]`,
|
||||
* `[[...slug]]`) dispatches sub-paths itself, so any contract under its prefix
|
||||
* is one it serves.
|
||||
*/
|
||||
function servesPath(kind: SiteKind, expectedPath: string, contractPath: string): boolean {
|
||||
if (contractPath === expectedPath) return true
|
||||
const catchAll = expectedPath.search(/\/\[{1,2}\.\.\./)
|
||||
return (
|
||||
kind === 'raw' &&
|
||||
catchAll !== -1 &&
|
||||
contractPath.startsWith(expectedPath.slice(0, catchAll + 1))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `identifier` through the route's imports and compares the contract to
|
||||
* the exported verb and the path the file serves, pushing every disagreement to
|
||||
* `failures`. Returns whether a contract was compared: false for a site that
|
||||
* could not be resolved (a failure) or a raw route's non-contract argument (out
|
||||
* of scope).
|
||||
*/
|
||||
export async function checkSite(
|
||||
route: RouteFile,
|
||||
kind: SiteKind,
|
||||
verb: string,
|
||||
identifier: string,
|
||||
failures: string[],
|
||||
verbose: boolean,
|
||||
loadModule: ContractModuleLoader = loadContractSpecifier
|
||||
): Promise<boolean> {
|
||||
const { relative, bindings, expectedPath } = route
|
||||
const binding = bindings.get(identifier)
|
||||
if (kind === 'raw' && !binding?.specifier.startsWith('@/lib/api/contracts')) return false
|
||||
if (!binding) {
|
||||
failures.push(`${relative}: export const ${verb} uses \`${identifier}\`, which is not imported`)
|
||||
return false
|
||||
}
|
||||
const { specifier, exported } = binding
|
||||
|
||||
const loading = loadModule(specifier)
|
||||
if (!loading) {
|
||||
failures.push(
|
||||
`${relative}: export const ${verb} imports \`${identifier}\` from '${specifier}', which does not resolve to a contract module`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
let module: Record<string, unknown>
|
||||
try {
|
||||
module = await loading
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`${relative}: export const ${verb} — importing '${specifier}' failed: ${(error as Error).message}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const contract = module[exported] as RouteContract | undefined
|
||||
if (typeof contract?.method !== 'string' || typeof contract?.path !== 'string') {
|
||||
failures.push(
|
||||
`${relative}: export const ${verb} — \`${identifier}\` from '${specifier}' is not a route contract (no string \`method\`/\`path\`)`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const subject =
|
||||
kind !== 'raw'
|
||||
? `export const ${verb} is built from \`${identifier}\``
|
||||
: `export const ${verb} parses with \`${identifier}\``
|
||||
const method = contract.method.toUpperCase()
|
||||
if (method !== verb && !(verb === 'HEAD' && method === 'GET' && kind !== 'scim')) {
|
||||
failures.push(
|
||||
kind !== 'raw'
|
||||
? `${relative}: ${subject}, which declares ${contract.method} ${contract.path}. Next routes by the exported symbol, so ${verb} requests 500 and ${contract.method} requests 404.`
|
||||
: `${relative}: ${subject}, which declares ${contract.method} ${contract.path}. Clients calling through the contract send ${contract.method} and get a 405.`
|
||||
)
|
||||
}
|
||||
if (!servesPath(kind, expectedPath, contract.path)) {
|
||||
failures.push(
|
||||
`${relative}: ${subject}, whose path is ${contract.path}, but this file serves ${expectedPath}.`
|
||||
)
|
||||
}
|
||||
if (verbose) {
|
||||
console.log(
|
||||
`✓ ${relative} ${verb} ← ${identifier} (${kind}, ${contract.method} ${contract.path})`
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const verbose = process.argv.includes('--verbose')
|
||||
|
||||
@@ -211,28 +472,46 @@ async function main() {
|
||||
const failures: string[] = []
|
||||
let checked = 0
|
||||
let files = 0
|
||||
let rawChecked = 0
|
||||
let rawFiles = 0
|
||||
|
||||
for (const file of listRouteFiles(API_DIR).sort()) {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
const relative = path.relative(ROOT, file)
|
||||
const route: RouteFile = {
|
||||
relative,
|
||||
bindings: importedNames(source),
|
||||
expectedPath: derivedPath(file),
|
||||
}
|
||||
|
||||
if (source.includes('parseRequest')) {
|
||||
let rawSitesInFile = 0
|
||||
for (const { verb, identifier } of rawRouteContractSites(source)) {
|
||||
if (await checkSite(route, 'raw', verb, identifier, failures, verbose)) rawSitesInFile += 1
|
||||
}
|
||||
if (rawSitesInFile > 0) {
|
||||
rawChecked += rawSitesInFile
|
||||
rawFiles += 1
|
||||
}
|
||||
}
|
||||
|
||||
let builderCalls = 0
|
||||
for (const _ of source.matchAll(BUILDER_CALL_RE)) builderCalls += 1
|
||||
if (builderCalls === 0) continue
|
||||
|
||||
files += 1
|
||||
const relative = path.relative(ROOT, file)
|
||||
const bindings = importedNames(source)
|
||||
const expectedPath = derivedPath(file)
|
||||
let sitesInFile = 0
|
||||
|
||||
const directSites = [...source.matchAll(EXPORT_RE)].map((match) => ({
|
||||
verb: match[1],
|
||||
builder: match[2],
|
||||
optionsStart: (match.index ?? 0) + match[0].length,
|
||||
}))
|
||||
const sites =
|
||||
directSites.length === builderCalls
|
||||
? directSites
|
||||
: [...directSites, ...wrappedRouteSites(source)]
|
||||
for (const { verb, optionsStart } of sites) {
|
||||
for (const { verb, builder, optionsStart } of sites) {
|
||||
sitesInFile += 1
|
||||
const options = source.slice(optionsStart, optionsStart + OPTIONS_SCAN_CHARS)
|
||||
|
||||
@@ -242,56 +521,8 @@ async function main() {
|
||||
continue
|
||||
}
|
||||
const identifier = contractKey[1]
|
||||
|
||||
const specifier = bindings.get(identifier)
|
||||
if (!specifier) {
|
||||
failures.push(
|
||||
`${relative}: export const ${verb} uses \`${identifier}\`, which is not imported`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const modulePath = resolveContractModule(specifier)
|
||||
if (!modulePath) {
|
||||
failures.push(
|
||||
`${relative}: export const ${verb} imports \`${identifier}\` from '${specifier}', which does not resolve to a contract module`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
let module: Record<string, unknown>
|
||||
try {
|
||||
module = await loadContractModule(modulePath)
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`${relative}: export const ${verb} — importing '${specifier}' failed: ${(error as Error).message}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const contract = module[identifier] as RouteContract | undefined
|
||||
if (typeof contract?.method !== 'string' || typeof contract?.path !== 'string') {
|
||||
failures.push(
|
||||
`${relative}: export const ${verb} — \`${identifier}\` from '${specifier}' is not a route contract (no string \`method\`/\`path\`)`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
checked += 1
|
||||
|
||||
if (contract.method.toUpperCase() !== verb) {
|
||||
failures.push(
|
||||
`${relative}: export const ${verb} is built from \`${identifier}\`, which declares ${contract.method} ${contract.path}. Next routes by the exported symbol, so ${verb} requests 500 and ${contract.method} requests 404.`
|
||||
)
|
||||
}
|
||||
if (contract.path !== expectedPath) {
|
||||
failures.push(
|
||||
`${relative}: export const ${verb} is built from \`${identifier}\`, whose path is ${contract.path}, but this file serves ${expectedPath}.`
|
||||
)
|
||||
}
|
||||
if (verbose) {
|
||||
console.log(`✓ ${relative} ${verb} ← ${identifier} (${contract.method} ${contract.path})`)
|
||||
}
|
||||
const kind = builder === 'defineScimRoute' ? 'scim' : 'builder'
|
||||
if (await checkSite(route, kind, verb, identifier, failures, verbose)) checked += 1
|
||||
}
|
||||
|
||||
if (sitesInFile < builderCalls) {
|
||||
@@ -301,9 +532,9 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
if (checked === 0) {
|
||||
if (checked === 0 || rawChecked === 0) {
|
||||
console.error(
|
||||
'❌ No builder-backed route handlers found. Refusing to pass vacuously — the scan patterns are stale.'
|
||||
'❌ No builder-backed or no raw parseRequest route handlers found. Refusing to pass vacuously — the scan patterns are stale.'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -312,15 +543,15 @@ async function main() {
|
||||
console.error(`\n❌ ${failures.length} route/contract disagreement(s):\n`)
|
||||
for (const failure of failures) console.error(` ${failure}`)
|
||||
console.error(
|
||||
'\nThe builders only compare request.method to contract.method at runtime, so these'
|
||||
'\nThe builders only compare request.method to contract.method at runtime, and raw'
|
||||
)
|
||||
console.error('fail as 500s in production rather than at build time. Fix the export symbol or')
|
||||
console.error('point the route at the right contract.')
|
||||
console.error('routes never do, so these fail in production rather than at build time. Fix the')
|
||||
console.error('export symbol or point the route at the right contract.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✓ ${checked} builder-backed route handler(s) across ${files} file(s) match their contract's method and path`
|
||||
`✓ ${checked} builder-backed route handler(s) across ${files} file(s) and ${rawChecked} raw parseRequest site(s) across ${rawFiles} file(s) match their contract's method and path`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user