From d5279251e3e810a3a5d05bb65e6e9c0ea8be0be4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:27:27 +0000 Subject: [PATCH] chore(tooling): enumerate namespace-claiming wildcard mounts and require each to be classified (#4116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A handler mounted on `/*` claims an entire namespace, and Hono runs every handler matching a path in registration order with the first Response winning. So a TERMINAL wildcard — one that always answers, never yields — makes every other route under that prefix reachable only when it happens to register first, and plugin registration order is not a contract anyone declares or tests. That shape has now cost four fixes, and every one was found by hand, after the fact, by someone reading the code for an unrelated reason: - #2567 — anonymous-deny on raw `/data` held only while REST registered first, so a load-order change silently reopened anonymous data access. - #4018 — the standalone `/discovery` sat behind the dispatcher's, so a third publisher drifted unnoticed. - #4088 / #4092 — AuthPlugin's `/api/v1/auth/*` swallowed plugin-hono-server's `/auth/me/permissions`, the console's entire permission layer. - cloud#923 — the same shape in AuthProxyPlugin, which cloud had ALREADY hit in staging (its apps/*/server/index.ts record the Console 404ing on that path). Nothing in CI noticed any of them, and nothing would notice the fifth. The driven tests #4092 and cloud#923 shipped each pin ONE catch-all and cannot cover the next wildcard someone mounts — a test only drives the routes that existed when it was written. What never existed is the ENUMERATION. This adds it, following check-route-envelope.mjs (#3843) including the part that matters most: a site the scan finds but the ledger does not declare is an ERROR, not a default. Three states — `yields` (verified from the AST, so an entry cannot rot: delete the next() and it fails), `exempt` with a reason for a wildcard that is supposed to own its namespace, `ratchet` naming the issue for one that is terminal and should not be. No fourth state where nobody looked. AST rather than regex because "does this call next()?" is a question about the handler's parameter binding: a comment or string mentioning next(), or an inner closure's own next, all fool a textual match in the direction that PASSES while the defect ships, and the parameter is not always named `next`. Handlers passed by identifier are resolved too — cloud#923 mounts its handler that way, and a scan that only understood inline arrows would have called it terminal. Found 13 namespace-claiming mounts where a manual grep had found 3, and with them two real, previously untracked instances of the #4088 defect in `packages/adapters/hono` (`${prefix}/auth/*`, `${prefix}/storage/*`) — ratcheted under #4117. It also separates that file's deliberate `${prefix}/*` ownership (ADR-0076 OQ#9) from the two that merely forgot to yield; in source they look identical. Verified both directions by hand: reverting #4092's fix fails with the terminal diagnostic, and adding a new undeclared `${prefix}/reports/*` catch-all fails as NOT DECLARED. Self-test covers 15 cases, including the three textual-match failure modes and identifier handler resolution. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013VZLsKypjrGhiLpio2uWKu --- .changeset/wildcard-fallthrough-guard.md | 27 ++ .github/workflows/lint.yml | 13 + package.json | 1 + scripts/check-wildcard-fallthrough.mjs | 497 +++++++++++++++++++++++ 4 files changed, 538 insertions(+) create mode 100644 .changeset/wildcard-fallthrough-guard.md create mode 100644 scripts/check-wildcard-fallthrough.mjs diff --git a/.changeset/wildcard-fallthrough-guard.md b/.changeset/wildcard-fallthrough-guard.md new file mode 100644 index 0000000000..4a88df0111 --- /dev/null +++ b/.changeset/wildcard-fallthrough-guard.md @@ -0,0 +1,27 @@ +--- +--- + +chore(tooling): enumerate namespace-claiming wildcard mounts and require each to be classified (#4116) + +Release-nothing: adds `scripts/check-wildcard-fallthrough.mjs` plus its CI step. +No package code changes. + +A handler mounted on `/*` claims an entire namespace, and Hono runs +handlers matching a path in registration order with the first Response winning. +So a TERMINAL wildcard — one that always answers and never yields — makes every +other route under that prefix reachable only when it happens to register first. +That shape has cost four fixes (#2567, #4018, #4088/#4092, cloud#923) and every +one was found by hand, after the fact, by someone reading the code for an +unrelated reason. + +The two driven tests #4092 and cloud#923 shipped each pin one catch-all, and are +structurally unable to cover the next wildcard someone mounts. What never existed +is the enumeration. This scan is it: three states (`yields`, verified from the AST +so the entry cannot rot; `exempt` with a reason; `ratchet` naming the issue), and +a mount the scan finds but the ledger does not declare is an error, never a +default — the same shape as `check-route-envelope.mjs` (#3843). + +It found 13 namespace-claiming mounts where a manual grep had found 3, including +two real, previously untracked instances of the #4088 defect in +`packages/adapters/hono` (`${prefix}/auth/*` and `${prefix}/storage/*`), now +ratcheted under #4117. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 04ad683491..a2e8888b6e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -123,6 +123,19 @@ jobs: - name: Error-code casing guard run: pnpm check:error-code-casing + # Namespace-wildcard fall-through guard (#4116). A handler mounted on + # `/*` claims the whole namespace, and Hono's first-registered + # handler that answers wins — so a TERMINAL wildcard makes every other + # route under that prefix reachable only by registration luck. That shape + # has now cost four fixes (#2567, #4018, #4088/#4092, cloud#923) and every + # one was found by hand, never by CI. Per-plugin tests cannot cover the + # next one; what was missing is the ENUMERATION. Three states — yields + # (verified from the AST, so it cannot rot) / exempt-with-reason / ratchet + # — and an undeclared mount is an error, never a default. Runs its own + # --self-test first. + - name: Wildcard fall-through guard + run: pnpm check:wildcard-fallthrough + # Release-notes drift guard: the platform is one version-locked train, so # every released @objectstack/spec major must have a curated, navigable # release page at content/docs/releases/v.mdx. Catches the gap that diff --git a/package.json b/package.json index 69aab0c735..e48faaeaba 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "check:authz-resolver": "node scripts/check-single-authz-resolver.mjs", "check:route-envelope": "node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs", "check:error-code-casing": "node scripts/check-error-code-casing.mjs --self-test && node scripts/check-error-code-casing.mjs", + "check:wildcard-fallthrough": "node scripts/check-wildcard-fallthrough.mjs --self-test && node scripts/check-wildcard-fallthrough.mjs", "check:console-sha": "node scripts/check-console-sha.mjs", "check:release-notes": "node scripts/check-release-notes.mjs", "check:node-version": "node scripts/check-node-version.mjs" diff --git a/scripts/check-wildcard-fallthrough.mjs b/scripts/check-wildcard-fallthrough.mjs new file mode 100644 index 0000000000..0a37024dfd --- /dev/null +++ b/scripts/check-wildcard-fallthrough.mjs @@ -0,0 +1,497 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Namespace-wildcard fall-through guard (#4116). + * + * ## What it guards + * + * A handler mounted on a wildcard path — `app.all('/api/v1/auth/*', h)` — claims + * an entire namespace. Hono runs every handler matching a path in REGISTRATION + * order and the first one to produce a Response wins. So if that handler is + * TERMINAL (it always answers, never `next()`s), every other route under the + * same prefix is reachable only when it happens to register FIRST — and plugin + * registration order is not a contract anyone declares or tests. + * + * That is not a hypothetical failure mode. It has now cost four separate fixes: + * + * - #2567 — anonymous-deny on raw `/data` held only while REST registered + * first, so a load-order change silently reopened anonymous data access. + * - #4018 — the standalone `/discovery` was shadowed by the dispatcher's, so + * a third publisher drifted unnoticed behind whoever won. + * - #4088 / #4092 — `AuthPlugin`'s `/api/v1/auth/*` catch-all swallowed + * `plugin-hono-server`'s `/auth/me/permissions`, which is the console's + * entire permission layer, unless the server plugin registered first. + * - cloud#923 — the same shape in `AuthProxyPlugin`. Its sibling repo had + * already ALSO hit it in staging: `apps/{objectos,cloud}/server/index.ts` + * record the Console 404ing on `/api/v1/auth/me/permissions`. + * + * Every one of those was found by hand, after the fact, by someone reading the + * code for an unrelated reason. Nothing in CI noticed any of them, and nothing + * would notice the fifth. + * + * ## Why a whole-repo scan rather than per-plugin tests + * + * #4092 and cloud#923 each shipped a driven test that pins ONE catch-all's + * fall-through. Those are worth having, but they are structurally incapable of + * covering the next wildcard someone mounts: a test can only drive the routes + * that existed the day it was written. What has never existed is the + * ENUMERATION — the question "which handlers in this repo claim a namespace, and + * is each one terminal?" has had no answer, so a new terminal catch-all lands + * with nothing to fail. + * + * This scan answers it, and follows `check-route-envelope.mjs` (#3843) in making + * a site discovered but NOT declared an ERROR rather than a default. Silently + * passing an unknown wildcard is what let the four above accumulate. + * + * ## Three states, deliberately + * + * yields — the handler takes a `next` and calls it. The scan verifies this + * from the AST, so the entry cannot rot: delete the `next()` and + * this fails. + * exempt — a REASON string for a wildcard that is SUPPOSED to own its whole + * namespace (a transport adapter's single entry point, a dev-only + * surface). Fall-through is then not asserted. + * ratchet — currently terminal, tracked, NOT blessed: the CURRENT state plus + * the issue that will fix it. + * + * There is no fourth state where nobody looked. + * + * ## Why AST, not regex + * + * "Does this handler call `next()`?" is a question about the handler's parameter + * binding, not about the text `next(` appearing nearby — a comment mentioning + * `next()`, or a `next` belonging to an inner closure, both fool a textual + * match, in the direction that PASSES while the defect ships. The parameter name + * is also not always `next`. Resolving the handler (inline arrow, or a + * `const handler = …` in the same file) and matching calls against its own + * second parameter's symbol name makes both problems disappear. + * + * ## Usage + * + * node scripts/check-wildcard-fallthrough.mjs # audit the repo + * node scripts/check-wildcard-fallthrough.mjs --list # print every site + * node scripts/check-wildcard-fallthrough.mjs --self-test # verify the checker + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); + +/** + * Every namespace-claiming wildcard mount in the repo. Keyed by + * `: ` so two mounts in one file stay distinguishable. + * A site the scan finds that is not listed here fails — see the header. + */ +const MOUNTS = { + // ── Yields (verified from the AST) ─────────────────────────────────────── + + // Fixed by #4092. Was the terminal catch-all that swallowed + // `/auth/me/permissions`; now a 404 from better-auth means "not my path". + "packages/plugins/plugin-auth/src/auth-plugin.ts:all `${basePath}/*`": { yields: true }, + + // ADR-0069 D5 network gate — a real middleware: rejects out-of-range client + // IPs and otherwise `next()`s. Included because a wildcard that forgot to + // yield would be just as fatal here, and more silently: it fronts every auth + // route in the plugin. + "packages/plugins/plugin-auth/src/auth-plugin.ts:use `${basePath}/*`": { yields: true }, + + // Proxies the marketplace prefix upstream and yields what it does not own — + // already the shape #4092 had to retrofit onto AuthPlugin. + "packages/cloud-connection/src/marketplace-proxy-plugin.ts:all `${MARKETPLACE_PREFIX}/*`": { yields: true }, + + // Request-scoped middleware fronting every route (`serve`'s request log, the + // adapter's and plugin's own `*` middlewares). Terminal here would take down + // the entire surface, not one namespace. + 'packages/cli/src/commands/serve.ts:use *': { yields: true }, + 'packages/plugins/plugin-hono-server/src/adapter.ts:use *': { yields: true }, + 'packages/plugins/plugin-hono-server/src/hono-plugin.ts:use *': { yields: true }, + + // ── Exempt: SUPPOSED to own the namespace ─────────────────────────────── + + // The dispatcher's single entry point. DELIBERATE — ADR-0076 OQ#9 verdict + // (#3576 / #3608): `dispatch()` is a gates+registry pipeline (env → identity → + // auth gate → membership → scope strip, then a first-match handler lookup), + // and splitting it into per-prefix mounts bypasses those cross-cutting stages + // (the #2852 RLS-leak class). Owning `${prefix}/*` is the design, not an + // oversight, so fall-through is not asserted. Reopen conditions on #3608. + 'packages/adapters/hono/src/index.ts:all `${prefix}/*`': { + exempt: 'dispatcher single entry point — per-prefix mounts would bypass the gate stages (ADR-0076 OQ#9, #3576/#3608)', + }, + + // Third-party middleware factories (`hono/cors`). The handler is the return + // value of a call, so the scan cannot resolve it — and it does not need to: + // cors() yields by construction. Recorded rather than skipped so that + // swapping it for a hand-written middleware has to be reclassified. + 'packages/adapters/hono/src/index.ts:use * (cors)': { + exempt: 'hono/cors middleware factory — yields internally, handler not resolvable at the call site', + }, + 'packages/plugins/plugin-hono-server/src/hono-plugin.ts:use * (cors)': { + exempt: 'hono/cors middleware factory — yields internally, handler not resolvable at the call site', + }, + + // SPA subtrees. A single-page app owns every path under its own mount point — + // that IS the SPA fallback contract (deep links must serve index.html rather + // than 404). Neither claims a shared API namespace: one is scoped to + // `${CONSOLE_PATH}`, the other is registered inside `listen()`, i.e. AFTER + // every plugin's `kernel:ready` routes, so it cannot shadow any of them. + 'packages/cli/src/utils/console.ts:get `${CONSOLE_PATH}/*`': { + exempt: 'console SPA subtree — owning its own mount point is the SPA fallback contract', + }, + 'packages/plugins/plugin-hono-server/src/adapter.ts:get /* (serveStatic)': { + exempt: 'serveStatic SPA fallback, registered in listen() after every plugin route — cannot shadow', + }, + + // ── Ratchet: real, tracked, NOT blessed ───────────────────────────────── + // + // Both are the #4088 shape in an adapter that no in-repo package depends on, + // so nothing is broken today — but it ships, and ADR-0076's own trigger is an + // out-of-tree embedder (`../objectbase`'s gateway). An embedder mounting a + // route under either prefix hits exactly #4088. Found BY this scan; manual + // greps for the pattern had missed both. Tracked by #4117. + 'packages/adapters/hono/src/index.ts:all `${prefix}/auth/*`': { + ratchet: '#4117 — terminal, same shape as #4088; adapter has no in-repo consumer', + }, + 'packages/adapters/hono/src/index.ts:all `${prefix}/storage/*`': { + ratchet: '#4117 — terminal, same shape as #4088; adapter has no in-repo consumer', + }, +}; + +/** HTTP-verb registrars plus `use`; anything that can claim a path pattern. */ +const REGISTRARS = new Set(['all', 'use', 'get', 'post', 'put', 'delete', 'patch', 'options', 'head']); + +/** + * Receivers we treat as an app/router. Deliberately loose — the cost of a false + * positive is one ledger line, the cost of a false negative is the whole point + * of the guard. `notFound`/`onError` are not registrars and never appear here. + */ +const APP_RECEIVER = /^(raw)?[aA]pp$|^(rawApp|honoApp|router|server)$/; + +// ── Scan ───────────────────────────────────────────────────────────────────── + +/** Recursively collect candidate source files under `packages/`. */ +function discoverFiles() { + const out = []; + const skip = new Set(['node_modules', 'dist', 'build', '.turbo', '.next', 'coverage']); + const walk = (dir) => { + for (const entry of readdirSync(dir)) { + if (skip.has(entry)) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { walk(full); continue; } + if (!entry.endsWith('.ts') || entry.endsWith('.d.ts')) continue; + if (entry.includes('.test.') || entry.includes('.conformance.')) continue; + out.push(relative(ROOT, full).split(sep).join('/')); + } + }; + walk(join(ROOT, 'packages')); + return out.sort(); +} + +/** The source text of a path argument, normalised for use as a ledger key. */ +function patternText(node, src) { + if (ts.isStringLiteralLike(node)) return node.text; + if (ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return node.getText(src); + } + return undefined; +} + +/** True when a mounted pattern claims a namespace rather than one path. */ +function isWildcard(pattern) { + if (!pattern) return false; + const unquoted = pattern.replace(/^`|`$/g, ''); + return unquoted.endsWith('/*') || unquoted === '*' || unquoted === '/*'; +} + +/** + * Does `fn` take a continuation and call it? + * + * Matched against the second parameter's own name — not the text `next(` — so an + * inner closure's `next` or a comment cannot pass this. + */ +function callsContinuation(fn, src) { + if (!fn || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) return false; + const param = fn.parameters?.[1]; + if (!param || !ts.isIdentifier(param.name)) return false; + const name = param.name.text; + + const rebinds = (node) => + (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node)) && + node.parameters?.some((p) => ts.isIdentifier(p.name) && p.name.text === name); + + let found = false; + const visit = (node) => { + if (found) return; + // An inner function taking its OWN parameter of the same name shadows ours; + // a call inside it is not this handler yielding. + if (rebinds(node)) return; + // `next()` — or `return next()` / `await next()`, same shape. + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === name) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(fn, visit); + return found; +} + +/** + * Resolve the handler argument to a function node. Handles the two shapes the + * repo actually uses: an inline arrow, and a `const handler = async (c, next) …` + * declared in the same file and passed by name (cloud's AuthProxyPlugin mounts + * it that way, and a scan that only understood inline handlers would have + * reported that one as terminal). + */ +function resolveHandler(arg, src) { + if (!arg) return undefined; + if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) return arg; + if (!ts.isIdentifier(arg)) return undefined; + + const wanted = arg.text; + let found; + const visit = (node) => { + if (found) return; + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === wanted && + node.initializer && + (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) + ) { + found = node.initializer; + return; + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(src, visit); + return found; +} + +/** Every wildcard mount in one file. */ +function scanFile(file) { + const text = readFileSync(join(ROOT, file), 'utf8'); + const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + const sites = []; + + const visit = (node) => { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + REGISTRARS.has(node.expression.name.text) && + node.arguments.length >= 2 + ) { + const receiver = node.expression.expression; + const receiverName = ts.isIdentifier(receiver) + ? receiver.text + : ts.isPropertyAccessExpression(receiver) + ? receiver.name.text + : undefined; + + if (receiverName && APP_RECEIVER.test(receiverName)) { + const pattern = patternText(node.arguments[0], src); + if (isWildcard(pattern)) { + const last = node.arguments[node.arguments.length - 1]; + const handler = resolveHandler(last, src); + sites.push({ + file, + method: node.expression.name.text, + pattern, + // A middleware FACTORY (`cors(…)`, `serveStatic(…)`) names the site + // apart from an inline handler in the same file with the same + // pattern — `hono-plugin.ts` has exactly that pair, and without a + // discriminator both collapse onto one ledger key. Chosen over the + // line number on purpose: the ledger must not churn every time code + // moves down a few lines. + factory: ts.isCallExpression(last) && ts.isIdentifier(last.expression) ? last.expression.text : undefined, + line: src.getLineAndCharacterOfPosition(node.getStart(src)).line + 1, + yields: callsContinuation(handler, src), + resolvedHandler: Boolean(handler), + }); + } + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(src, visit); + return sites; +} + +function scan() { + const sites = []; + for (const file of discoverFiles()) { + // Cheap pre-filter: parsing every .ts in the repo to find ~a dozen sites is + // needless work, and the tokens below are present in any real mount. + const text = readFileSync(join(ROOT, file), 'utf8'); + if (!text.includes('/*`') && !text.includes("/*'") && !text.includes('/*"')) continue; + sites.push(...scanFile(file)); + } + return sites; +} + +/** + * Ledger key. Two sites CAN still collide (two inline `use('*')` in one file), + * and that is safe by construction: every site is checked against the shared + * declaration independently, so a colliding pair where only one yields still + * fails on the one that does not. + */ +const keyOf = (s) => `${s.file}:${s.method} ${s.pattern}${s.factory ? ` (${s.factory})` : ''}`; + +// ── Audit ──────────────────────────────────────────────────────────────────── + +function audit() { + const sites = scan(); + const problems = []; + const seen = new Set(); + let yields = 0, exempt = 0, ratcheted = 0; + + for (const site of sites) { + const key = keyOf(site); + seen.add(key); + const declared = MOUNTS[key]; + + if (!declared) { + problems.push( + `${site.file}:${site.line}\n` + + ` ${site.method}('${site.pattern}') is NOT DECLARED.\n` + + ` This handler claims the whole '${site.pattern}' namespace. Add it to MOUNTS in\n` + + ` scripts/check-wildcard-fallthrough.mjs. If it yields to other routes (takes a\n` + + ` \`next\` and calls it), declare { yields: true }. If it is SUPPOSED to own the\n` + + ` namespace, declare \`exempt\` with the reason. If it is terminal and should not\n` + + ` be, declare \`ratchet\` naming the issue — never leave one unaudited (#4116).`, + ); + continue; + } + + if (declared.exempt) { exempt++; continue; } + + if (declared.ratchet) { + ratcheted++; + if (site.yields) { + problems.push( + `${site.file}:${site.line}\n` + + ` ${site.method}('${site.pattern}') is declared as a ratchet (${declared.ratchet})\n` + + ` but now YIELDS. Fixed? Replace the ratchet with { yields: true }.`, + ); + } + continue; + } + + if (declared.yields) { + yields++; + if (!site.resolvedHandler) { + problems.push( + `${site.file}:${site.line}\n` + + ` ${site.method}('${site.pattern}') is declared { yields: true } but its handler\n` + + ` could not be resolved, so the claim is unverifiable. Inline the handler, or\n` + + ` declare \`exempt\`/\`ratchet\` with a reason.`, + ); + } else if (!site.yields) { + problems.push( + `${site.file}:${site.line}\n` + + ` ${site.method}('${site.pattern}') is declared { yields: true } but the handler\n` + + ` never calls its continuation — it is TERMINAL. Every other route under\n` + + ` '${site.pattern}' is now reachable only if it registers first (#4088). Take a\n` + + ` \`next\` and call it for paths this handler does not own.`, + ); + } + } + } + + for (const key of Object.keys(MOUNTS)) { + if (!seen.has(key)) { + problems.push( + `${key}\n` + + ` DECLARED but not found by the scan. Moved, renamed or deleted? Update MOUNTS.`, + ); + } + } + + if (problems.length) { + console.error('✗ wildcard fall-through guard (#4116)\n'); + for (const p of problems) console.error(' ' + p + '\n'); + console.error(`${problems.length} problem(s).`); + process.exit(1); + } + + console.log( + `✓ wildcard fall-through: ${yields} yielding / ${ratcheted} ratcheted / ${exempt} exempt ` + + `(${sites.length} namespace-claiming mount${sites.length === 1 ? '' : 's'})`, + ); +} + +// ── Self-test ──────────────────────────────────────────────────────────────── + +function selfTest() { + const assert = (cond, msg) => { if (!cond) { console.error('✗ self-test: ' + msg); process.exit(1); } }; + const parse = (code) => ts.createSourceFile('t.ts', code, ts.ScriptTarget.Latest, true); + + // `isWildcard` — namespace claims vs single paths. + assert(isWildcard('/api/v1/auth/*'), 'plain wildcard'); + assert(isWildcard('`${basePath}/*`'), 'template wildcard'); + assert(isWildcard('/*') && isWildcard('*'), 'bare wildcard'); + assert(!isWildcard('/api/v1/auth/me/permissions'), 'a concrete path is not a wildcard'); + assert(!isWildcard('/api/v1/data/:object'), 'a param path is not a wildcard'); + assert(!isWildcard(undefined), 'a non-literal pattern is not a wildcard'); + + // `callsContinuation` — the whole point is that this is not a text match. + const first = (code) => { + const src = parse(code); + let fn; + const visit = (n) => { if (!fn && (ts.isArrowFunction(n) || ts.isFunctionExpression(n))) fn = n; else ts.forEachChild(n, visit); }; + ts.forEachChild(src, visit); + return { fn, src }; + }; + const yieldsIn = (code) => { const { fn, src } = first(code); return callsContinuation(fn, src); }; + + assert(yieldsIn('app.all("/a/*", async (c, next) => { await next(); });'), 'await next()'); + assert(yieldsIn('app.all("/a/*", (c, next) => next());'), 'return next()'); + assert(yieldsIn('app.all("/a/*", async (c, n) => { if (x) { await n(); } return r; });'), 'renamed continuation'); + assert(!yieldsIn('app.all("/a/*", async (c) => r);'), 'no continuation parameter at all'); + assert( + !yieldsIn('app.all("/a/*", async (c, next) => { /* we could next() here */ return r; });'), + 'a comment mentioning next() must NOT count — this is the regex failure mode', + ); + assert( + !yieldsIn('app.all("/a/*", async (c, next) => { const s = "call next() later"; return r; });'), + 'a string mentioning next() must NOT count', + ); + assert( + !yieldsIn('app.all("/a/*", async (c, next) => { items.forEach((next) => next()); return r; });'), + 'an INNER binding shadowing the name must not count as yielding', + ); + + // `resolveHandler` — a handler passed by name must still be analysed, or the + // scan reports a yielding mount as terminal (cloud#923 mounts it that way). + { + const code = 'const handler = async (c, next) => { await next(); };\napp.all("/a/*", handler);'; + const src = parse(code); + let call; + const visit = (n) => { + if (!call && ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === 'all') call = n; + else ts.forEachChild(n, visit); + }; + ts.forEachChild(src, visit); + const fn = resolveHandler(call.arguments[1], src); + assert(fn, 'a handler passed by identifier resolves to its declaration'); + assert(callsContinuation(fn, src), 'and its continuation call is seen'); + } + + // `keyOf` keeps two mounts in one file distinct. + assert( + keyOf({ file: 'a.ts', method: 'all', pattern: '/x/*' }) !== keyOf({ file: 'a.ts', method: 'use', pattern: '/x/*' }), + 'method is part of the key', + ); + + console.log('✓ self-test: 15 cases'); +} + +if (process.argv.includes('--self-test')) selfTest(); +else if (process.argv.includes('--list')) { + for (const s of scan()) { + console.log(`${s.yields ? 'yields ' : 'TERMINAL'} ${s.file}:${s.line} ${s.method}('${s.pattern}')${s.resolvedHandler ? '' : ' [handler unresolved]'}`); + } +} else audit();