@vantageos/backend-doctor 0.1.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +19 -2
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +54 -0
  4. package/dist/build-stamp.d.ts +13 -0
  5. package/dist/build-stamp.js +76 -0
  6. package/dist/build-stamp.json +4 -0
  7. package/dist/caller-resolution.d.ts +222 -0
  8. package/dist/caller-resolution.js +1195 -0
  9. package/dist/cli.js +91 -8
  10. package/dist/coverage-cli.js +8 -0
  11. package/dist/detectors/oracle-axis1.d.ts +5 -3
  12. package/dist/detectors/oracle-axis1.js +24 -15
  13. package/dist/detectors/oracle-axis2.js +5 -6
  14. package/dist/detectors/predicates.d.ts +3 -0
  15. package/dist/detectors/predicates.js +81 -16
  16. package/dist/detectors/shared.js +5 -5
  17. package/dist/detectors/source/backfill.js +9 -1
  18. package/dist/detectors/source/guard-helper-flow.d.ts +112 -0
  19. package/dist/detectors/source/guard-helper-flow.js +1173 -0
  20. package/dist/detectors/source/identity.d.ts +51 -2
  21. package/dist/detectors/source/identity.js +815 -14
  22. package/dist/detectors/source/isolation.d.ts +11 -1
  23. package/dist/detectors/source/isolation.js +106 -25
  24. package/dist/detectors/source/jobs.js +165 -2
  25. package/dist/detectors/source/readbudget.d.ts +1 -0
  26. package/dist/detectors/source/readbudget.js +67 -4
  27. package/dist/detectors/source/security.d.ts +16 -0
  28. package/dist/detectors/source/security.js +225 -7
  29. package/dist/detectors/source/write-isolation.d.ts +8 -1
  30. package/dist/detectors/source/write-isolation.js +64 -30
  31. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  32. package/dist/detectors/standard/r46-returns-validator.js +45 -14
  33. package/dist/detectors/structural.js +14 -10
  34. package/dist/oracle.d.ts +92 -3
  35. package/dist/oracle.js +229 -12
  36. package/dist/registry.d.ts +7 -0
  37. package/dist/registry.js +48 -6
  38. package/dist/report.d.ts +11 -1
  39. package/dist/report.js +15 -2
  40. package/dist/state-rank.d.ts +5 -0
  41. package/dist/state-rank.js +9 -0
  42. package/dist/tally.js +1 -7
  43. package/dist/target.d.ts +32 -0
  44. package/dist/target.js +173 -0
  45. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  46. package/fixtures/cr1-classes/surface.ts +206 -0
  47. package/fixtures/cr1-empty/nothing.ts +1 -0
  48. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  49. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  50. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  51. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  52. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  53. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  54. package/package.json +2 -2
@@ -0,0 +1,1195 @@
1
+ // CR-1 — does this public registration RESOLVE ITS CALLER before serving or
2
+ // writing data?
3
+ //
4
+ // WHY THIS CONTROL EXISTS, measured not supposed. At vantage-memory
5
+ // bd8c60e94b4cd9ffd579c9e4f84e670280ddebb4, fourteen public queries returned real
6
+ // rows to an ANONYMOUS POST against the live production deployment — no
7
+ // Authorization header, no bearer, no JWT — including `mandates:list` (spending
8
+ // authority) and `tasks:listUnlinkedBlocked` (159 rows). The instrument scored
9
+ // that exact tree `revise-mandatory` and its rendered verdict table NAMED 0 of the
10
+ // 14 (measured: 0/14 at 14f35d9). R-50 did classify 13 of them as violations, but
11
+ // its renderer prints three examples, so no reader of the report could see them;
12
+ // and the fourteenth — `messages:listByChannel` — R-50 called CONFORM, because
13
+ // R-50 asks whether a REFUSAL IS SHAPED CORRECTLY (a typed value versus a throw),
14
+ // and `listByChannel` does call the fail-closed resolver, then allows any caller
15
+ // through a hardcoded `if (ch === "broadcast") return true`. A fifteenth shape,
16
+ // `convex/search.ts::searchFixPatterns`, is an `action` with a hardcoded namespace
17
+ // and no identity at all — outside R-50's population entirely (R-50 scans queries).
18
+ //
19
+ // SO CR-1 ASKS A DIFFERENT QUESTION, and it is deliberately NOT R-50's:
20
+ // R-50 / R-51 : when this handler refuses, is the refusal SHAPED so a subscribing
21
+ // client can render it? (a rule about the shape of a refusal)
22
+ // CR-1 : is there a caller RESOLVED at all, and does the resolution decide
23
+ // what is served? (a rule about the presence of authority)
24
+ // A handler with no guard has nothing to throw, so it can never fail a rule about
25
+ // throwing. That is why CR-1 exists, and it is why a "does it refuse properly"
26
+ // check must never creep in here as the test — that would be R-50's blind spot
27
+ // under a new name.
28
+ //
29
+ // FOUR PROPERTIES THIS MODULE HOLDS ITSELF TO, each a defect it was built against:
30
+ //
31
+ // 1. POPULATION IS THE EXPOSED SURFACE ITSELF. It is derived from the TARGET tree's
32
+ // own `_generated/api.d.ts` (`declare const fullApi: ApiFromModules<{…}>` plus
33
+ // the `import type * as <key> from "…"` map that resolves each key to a file) —
34
+ // never a hand-written list, and never a pattern over ARGUMENT NAMES. Three
35
+ // side-scripts in the target repository were blind to all fourteen precisely
36
+ // because each defined its population by argument shape: not one of the fourteen
37
+ // takes a tenant-shaped argument and most take no arguments at all.
38
+ // 2. THE EXCLUSION OF `internal*` IS PROVEN PER SITE. Every registration is read
39
+ // with the BUILDER TOKEN observed at its own `export const NAME = <builder>(`,
40
+ // and an excluded site is REPORTED with that token. Nothing is excluded because
41
+ // of how it is named.
42
+ // 3. THE COUNT CLASSIFIED EQUALS THE COUNT EXPOSED. A discrepancy is CR-1's OWN
43
+ // BUG and is reported as could-not-judge naming the drift — never rounded away.
44
+ // 4. NO EXEMPTION LIST, NO BASELINE, NO TOLERATED COUNT. The only exit from a
45
+ // NO-DATA or NO-IDENTITY finding is a justification token written IN THE TARGET
46
+ // SOURCE at the registration, which this module reads from there. A list of
47
+ // names living beside the instrument that can hold a name and still pass IS the
48
+ // defect, so there is none. FAIL-OPEN has no token exit at all.
49
+ //
50
+ // CR-1 IS CRITICAL AND IT READS THE TREE. FAIL-OPEN and unjustified NO-IDENTITY are
51
+ // MECHANICAL violations, so the deploy gate refuses on them.
52
+ //
53
+ // STANDING GAP, NAMED: CR-1 is DOCTOR-OWNED. The pinned backend standard
54
+ // (fixtures/backend-standard-4bcd605.md) numbers R-1..R-51 and does not yet carry
55
+ // this obligation, so CR-1 is deliberately NOT in `registry.ts` — adding an id the
56
+ // vendored standard does not define would make `coverage.ts` report a phantom rule
57
+ // and would forge a pin. It is rendered in its own section, tallied on its own
58
+ // line, and it drives the exit code. Numbering it upstream (proposed R-52) is a
59
+ // separate change to the standard document in its own repository.
60
+ import { readFileSync } from "node:fs";
61
+ import { join } from "node:path";
62
+ import { convexRootOf } from "./target.js";
63
+ // ---------------------------------------------------------------------------
64
+ // population — the exposed surface, from the TARGET tree's own api.d.ts
65
+ // ---------------------------------------------------------------------------
66
+ const API_IMPORT = /^import\s+type\s+\*\s+as\s+([A-Za-z0-9_$]+)\s+from\s+"(\.\.\/[^"]+)"/;
67
+ const FULL_API_OPEN = /declare\s+const\s+fullApi\s*:\s*ApiFromModules\s*<\s*\{/;
68
+ const FULL_API_KEY = /^\s*([A-Za-z0-9_$]+)\s*:\s*typeof\s+([A-Za-z0-9_$]+)\s*;?\s*$/;
69
+ /**
70
+ * Derive the exposed module surface from `<root>/_generated/api.d.ts`. The engine's
71
+ * loadTree excludes `_generated/`, so this reads the file from the target root —
72
+ * the same target the CLI was pointed at, never the tool's own install directory
73
+ * (the defect that let five oracle-scored rules present a frozen CSV count as a
74
+ * verdict on a tree they never opened).
75
+ */
76
+ export function deriveApiSurface(root, read = (p) => readFileSync(p, "utf8")) {
77
+ const path = join(root, "_generated", "api.d.ts");
78
+ let blob;
79
+ try {
80
+ blob = read(path);
81
+ }
82
+ catch (e) {
83
+ return {
84
+ modules: new Map(),
85
+ unreadable: `${path} could not be read (${e.message})`,
86
+ };
87
+ }
88
+ const lines = blob.split("\n");
89
+ const imports = new Map();
90
+ for (const raw of lines) {
91
+ const m = API_IMPORT.exec(raw);
92
+ if (!m)
93
+ continue;
94
+ // "../lib/auth.js" -> "lib/auth.ts" (rel to the convex root the tree uses)
95
+ imports.set(m[1], m[2].replace(/^\.\.\//, "").replace(/\.js$/, ".ts"));
96
+ }
97
+ let inFullApi = false;
98
+ const modules = new Map();
99
+ const unmapped = [];
100
+ for (const raw of lines) {
101
+ if (!inFullApi) {
102
+ if (FULL_API_OPEN.test(raw))
103
+ inFullApi = true;
104
+ continue;
105
+ }
106
+ if (/^\}>/.test(raw))
107
+ break;
108
+ const k = FULL_API_KEY.exec(raw);
109
+ if (!k)
110
+ continue;
111
+ const rel = imports.get(k[2]);
112
+ if (rel === undefined) {
113
+ unmapped.push(k[1]);
114
+ continue;
115
+ }
116
+ modules.set(k[1], rel);
117
+ }
118
+ if (modules.size === 0) {
119
+ return {
120
+ modules,
121
+ unreadable: `${path} carries no resolvable \`declare const fullApi: ApiFromModules<{…}>\` member — the exposed surface cannot be derived from it`,
122
+ };
123
+ }
124
+ if (unmapped.length > 0) {
125
+ return {
126
+ modules,
127
+ unreadable: `${path}: fullApi member(s) ${unmapped.join(", ")} resolve to no \`import type * as\` line — CR-1's api.d.ts parse is incomplete (CR-1's own bug, not a tree finding)`,
128
+ };
129
+ }
130
+ return { modules };
131
+ }
132
+ // ---------------------------------------------------------------------------
133
+ // registrations — read with the builder token observed AT each site
134
+ // ---------------------------------------------------------------------------
135
+ /** `export const NAME = <builder>(` — the only registration shape Convex exposes. */
136
+ const REGISTRATION = /^\s*export\s+const\s+([A-Za-z0-9_$]+)\s*=\s*(?:await\s+)?([A-Za-z0-9_$]+)\s*\(/;
137
+ /** `export const NAME = customQuery(` and friends — a wrapper DEFINITION. */
138
+ const CUSTOM_BUILDER_DEF = /^\s*(?:export\s+)?const\s+([A-Za-z0-9_$]+)\s*=\s*(custom(?:Query|Mutation|Action))\s*\(/;
139
+ const FIRST_IDENT = /([A-Za-z_$][A-Za-z0-9_$]*)/;
140
+ const PUBLIC_BASES = new Set(["query", "mutation", "action"]);
141
+ const NON_PUBLIC_BASES = new Set([
142
+ "internalQuery",
143
+ "internalMutation",
144
+ "internalAction",
145
+ "httpAction",
146
+ ]);
147
+ function base(rel) {
148
+ return (rel.split("/").pop() ?? rel).toLowerCase();
149
+ }
150
+ /** a file CR-1 judges — never a test, never generated. */
151
+ export function isJudgeable(f) {
152
+ const b = base(f.rel);
153
+ return (!b.endsWith(".test.ts") &&
154
+ !b.endsWith(".d.ts") &&
155
+ !f.rel.includes("__tests__") &&
156
+ !f.rel.includes("_generated/"));
157
+ }
158
+ function isCode(l) {
159
+ return !l.comment;
160
+ }
161
+ /**
162
+ * Discover every registration BUILDER the tree declares, so a `customQuery(query,…)`
163
+ * wrapper is recognised as public and a `customQuery(internalQuery,…)` wrapper as
164
+ * not. A wrapper whose base resolves to neither leaves the exposed surface
165
+ * unknowable → CR-1 refuses (never a silent green).
166
+ */
167
+ export function discoverBuilders(files) {
168
+ const map = {
169
+ publicBuilders: new Map([...PUBLIC_BASES].map((b) => [b, b])),
170
+ nonPublicBuilders: new Map([...NON_PUBLIC_BASES].map((b) => [b, b])),
171
+ unresolvable: [],
172
+ };
173
+ for (const f of files) {
174
+ if (!isJudgeable(f))
175
+ continue;
176
+ const code = f.lines.filter(isCode);
177
+ for (let i = 0; i < code.length; i++) {
178
+ const m = CUSTOM_BUILDER_DEF.exec(code[i].raw);
179
+ if (!m)
180
+ continue;
181
+ const [, name, ,] = m;
182
+ const after = code[i].raw.slice((m.index ?? 0) + m[0].length);
183
+ let b = FIRST_IDENT.exec(after)?.[1];
184
+ for (let j = i + 1; !b && j < code.length && j <= i + 4; j++) {
185
+ b = FIRST_IDENT.exec(code[j].raw)?.[1];
186
+ }
187
+ if (b !== undefined && PUBLIC_BASES.has(b)) {
188
+ map.publicBuilders.set(name, b);
189
+ }
190
+ else if (b !== undefined && NON_PUBLIC_BASES.has(b)) {
191
+ map.nonPublicBuilders.set(name, b);
192
+ }
193
+ else {
194
+ map.unresolvable.push({
195
+ rel: f.rel,
196
+ n: code[i].n,
197
+ name,
198
+ base: b ?? "<none>",
199
+ });
200
+ }
201
+ }
202
+ }
203
+ return map;
204
+ }
205
+ /** delimit a body from its opener at index i by matching braces/parens. */
206
+ function blockAt(lines, i) {
207
+ let depth = 0;
208
+ let seenOpen = false;
209
+ const body = [];
210
+ for (let j = i; j < lines.length; j++) {
211
+ body.push(lines[j]);
212
+ for (const ch of lines[j].raw) {
213
+ if (ch === "(" || ch === "{") {
214
+ depth += 1;
215
+ seenOpen = true;
216
+ }
217
+ else if (ch === ")" || ch === "}") {
218
+ depth -= 1;
219
+ }
220
+ }
221
+ if (seenOpen && depth <= 0)
222
+ return { lines: body, unterminated: false };
223
+ }
224
+ return { lines: body, unterminated: seenOpen };
225
+ }
226
+ // ---------------------------------------------------------------------------
227
+ // identity helpers — DERIVED from the tree, transitively; never a name list
228
+ // ---------------------------------------------------------------------------
229
+ const GET_IDENTITY = /ctx\.auth\.getUserIdentity\s*\(/;
230
+ const FN_DECL = /^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z0-9_$]+)\s*[(<]/;
231
+ const FN_CONST = /^\s*(?:export\s+)?const\s+([A-Za-z0-9_$]+)\s*(?::[^=]*)?=\s*(?:async\s+)?(?:\(|function\b)/;
232
+ /** a privileged shape — what "an absent identity is treated as privileged" LOOKS
233
+ * like in a returned object: a master flag, or a wildcard authority sentinel. */
234
+ const PRIVILEGED_MARKER = /isMaster\s*:\s*true|"\*"|'\*'|isAdmin\s*:\s*true/;
235
+ /** `opts?.flag` / `options.flag` / `opts.flag` — an opt-in read inside a helper. */
236
+ const OPT_READ = /\b(?:opts|options|o)\s*\??\.\s*([A-Za-z0-9_$]+)/g;
237
+ /** the `name` half of a `rel::name` key. */
238
+ export function nameOf(key) {
239
+ return key.slice(key.indexOf("::") + 2);
240
+ }
241
+ /** the `rel` half of a `rel::name` key. */
242
+ export function relOf(key) {
243
+ return key.slice(0, key.indexOf("::"));
244
+ }
245
+ /**
246
+ * The members of `set` that a handler in `rel` can actually reach: a plain function
247
+ * declared in `rel` or imported by `rel`, or a registration (reachable from anywhere,
248
+ * but only through its qualified `internal.…`/`api.…` name).
249
+ */
250
+ export function reachable(model, set, rel) {
251
+ const out = new Map();
252
+ const scope = model.scopes.get(rel) ?? new Set();
253
+ for (const key of set) {
254
+ const name = nameOf(key);
255
+ const qualified = model.regNames.has(key);
256
+ if (qualified) {
257
+ out.set(`q:${name}`, { name, qualified: true });
258
+ continue;
259
+ }
260
+ if (relOf(key) === rel || scope.has(name)) {
261
+ out.set(name, { name, qualified: false });
262
+ }
263
+ }
264
+ return [...out.values()];
265
+ }
266
+ /** the refs in `refs` that `text` actually references. */
267
+ export function referenced(text, refs) {
268
+ return refs.filter((r) => references(text, r.name, r.qualified));
269
+ }
270
+ /** `import { a, b as c } from "…"` — the local names a module brings into scope. The
271
+ * brace list is very often WRAPPED over several lines by the formatter, so a
272
+ * single-line regex misses it entirely: that is not a cosmetic miss, it empties the
273
+ * importing module's scope and files every handler that calls an imported resolver
274
+ * under NO-IDENTITY (measured on run 8: 83 NO-IDENTITY, `messages:listByChannel`
275
+ * among them, because `withOrgScope` is imported across four lines). */
276
+ const IMPORT_OPEN = /^\s*import\s+(?:type\s+)?\{(.*)$/;
277
+ /** collect (`rel::name` -> body) for every function-like declaration in the tree,
278
+ * plus, per file, the set of local names it declares or imports. */
279
+ function functionBodies(files) {
280
+ const bodies = new Map();
281
+ const scopes = new Map();
282
+ for (const f of files) {
283
+ if (!isJudgeable(f))
284
+ continue;
285
+ const scope = new Set();
286
+ scopes.set(f.rel, scope);
287
+ const lines = f.lines;
288
+ for (let i = 0; i < lines.length; i++) {
289
+ if (!isCode(lines[i]))
290
+ continue;
291
+ const imp = IMPORT_OPEN.exec(lines[i].raw);
292
+ if (imp) {
293
+ // accumulate the brace list, however many lines the formatter wrapped it over.
294
+ let spec = imp[1];
295
+ let j = i;
296
+ while (!spec.includes("}") && j + 1 < lines.length) {
297
+ j += 1;
298
+ spec += `\n${lines[j].raw}`;
299
+ }
300
+ for (const part of spec.slice(0, spec.indexOf("}")).split(",")) {
301
+ const local = part
302
+ .trim()
303
+ .split(/\s+as\s+/)
304
+ .pop()
305
+ ?.trim();
306
+ if (local && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(local))
307
+ scope.add(local);
308
+ }
309
+ i = j;
310
+ continue;
311
+ }
312
+ // REGISTRATION bodies are indexed too: an ACTION resolves its principal by
313
+ // `ctx.runQuery(internal.lib.auth.resolveOrgScopeForAction, {})`, so the
314
+ // internalQuery it names must be modelled as an identity resolver or every
315
+ // action-side authority gate reads as "never consults the principal".
316
+ const fn = FN_DECL.exec(lines[i].raw) ?? FN_CONST.exec(lines[i].raw);
317
+ const reg = fn ? null : REGISTRATION.exec(lines[i].raw);
318
+ const m = fn ?? reg;
319
+ if (!m)
320
+ continue;
321
+ const blk = blockAt(lines, i);
322
+ scope.add(m[1]);
323
+ bodies.set(`${f.rel}::${m[1]}`, {
324
+ text: blk.lines
325
+ .filter(isCode)
326
+ .map((l) => l.raw)
327
+ .join("\n"),
328
+ at: `${f.rel}:${lines[i].n}`,
329
+ isRegistration: reg !== null,
330
+ });
331
+ }
332
+ }
333
+ return { bodies, scopes };
334
+ }
335
+ export function deriveIdentityModel(files) {
336
+ const { bodies, scopes } = functionBodies(files);
337
+ const regNames = new Set([...bodies.entries()].filter(([, b]) => b.isRegistration).map(([k]) => k));
338
+ const base = {
339
+ helpers: new Set(),
340
+ gates: new Set(),
341
+ dataFns: new Set(),
342
+ permissiveOnAbsence: new Map(),
343
+ regNames,
344
+ scopes,
345
+ privilegeOnAbsenceFlags: new Map(),
346
+ refuserParams: new Map(),
347
+ };
348
+ /** grow `set` to every body that REACHES a member of `set` from its own scope. */
349
+ const close = (set, rounds) => {
350
+ for (let round = 0; round < rounds; round++) {
351
+ let grew = false;
352
+ for (const [key, b] of bodies) {
353
+ if (set.has(key))
354
+ continue;
355
+ if (referenced(b.text, reachable(base, set, relOf(key))).length > 0) {
356
+ set.add(key);
357
+ grew = true;
358
+ }
359
+ }
360
+ if (!grew)
361
+ break;
362
+ }
363
+ };
364
+ for (const [key, b] of bodies) {
365
+ if (GET_IDENTITY.test(b.text))
366
+ base.helpers.add(key);
367
+ }
368
+ close(base.helpers, 4);
369
+ deriveValueRefusers(bodies, base);
370
+ // GATES — an identity helper that REFUSES on the absent-identity path, read from
371
+ // its own body; then transitively, a helper that reaches a gate refuses through it.
372
+ for (const [key, b] of bodies) {
373
+ if (!base.helpers.has(key))
374
+ continue;
375
+ if (absentIdentityBranchThrows(b.text) ||
376
+ refusesOnResolvedValue(b.text, base, relOf(key))) {
377
+ base.gates.add(key);
378
+ }
379
+ }
380
+ for (let round = 0; round < 4; round++) {
381
+ let grew = false;
382
+ for (const [key, b] of bodies) {
383
+ if (!base.helpers.has(key) || base.gates.has(key))
384
+ continue;
385
+ if (referenced(b.text, reachable(base, base.gates, relOf(key))).length > 0) {
386
+ base.gates.add(key);
387
+ grew = true;
388
+ }
389
+ }
390
+ if (!grew)
391
+ break;
392
+ }
393
+ // DATA-TOUCHING functions, closed the same way.
394
+ for (const [key, b] of bodies) {
395
+ if (DATA_ACCESS.test(b.text))
396
+ base.dataFns.add(key);
397
+ }
398
+ close(base.dataFns, 5);
399
+ // PERMISSIVE-ON-ABSENCE: the absent-identity branch returns with no value, i.e.
400
+ // "proceed" — the opposite of a refusal, read from the helper's own body.
401
+ for (const [key, b] of bodies) {
402
+ if (!GET_IDENTITY.test(b.text))
403
+ continue;
404
+ const bind0 = /(?:const|let)\s+([A-Za-z0-9_$]+)\s*(?::[^=\n]*)?=\s*\(?\s*await\s+ctx\.auth\.getUserIdentity/.exec(b.text);
405
+ if (!bind0)
406
+ continue;
407
+ const g0 = absentGuard(b.text, bind0[1]);
408
+ if (!g0)
409
+ continue;
410
+ // ALLOW-ON-ABSENCE, conditional or not. A helper is an assertion that the
411
+ // caller may proceed; a bare `return;` (or `return true`) ANYWHERE in its
412
+ // absent-identity branch is a path on which an anonymous caller proceeds.
413
+ // The pre-fix test required the branch to contain NO throw, so the shape
414
+ // if (identity === null) { if (isMasterNamespace) return; throw … }
415
+ // (vantage-peers 9e1413a okfBundleDurable.ts:124-128, okfBundleNode.ts:280-287)
416
+ // read as a refusing GATE — the throw was seen and the `return;` before it
417
+ // was not — and every export registration calling it was classed RESOLVES.
418
+ // A throw AFTER a conditional allow does not refuse the callers the allow
419
+ // already let through; the condition is on a caller-supplied value
420
+ // (`namespace`), never on a resolved principal.
421
+ const allow = PROCEED_RETURN.exec(g0.branch);
422
+ if (allow) {
423
+ const conditional = THROWS.test(g0.branch);
424
+ base.permissiveOnAbsence.set(nameOf(key), conditional
425
+ ? `${b.at} (${nameOf(key)}: its absent-identity branch returns \`${allow[0].replace(/\s+/g, " ").trim()}\` on a condition BEFORE it throws — an anonymous caller meeting that condition is waved through; the throw refuses only the others)`
426
+ : `${b.at} (${nameOf(key)}: its absent-identity branch is a bare \`return;\` — an anonymous caller is waved through)`);
427
+ }
428
+ }
429
+ for (const [key, b] of bodies) {
430
+ if (!GET_IDENTITY.test(b.text))
431
+ continue;
432
+ const bind = /(?:const|let)\s+([A-Za-z0-9_$]+)\s*(?::[^=\n]*)?=\s*\(?\s*await\s+ctx\.auth\.getUserIdentity/.exec(b.text);
433
+ if (!bind)
434
+ continue;
435
+ const guard = absentGuard(b.text, bind[1]);
436
+ if (!guard)
437
+ continue;
438
+ const region = guard.branch;
439
+ OPT_READ.lastIndex = 0;
440
+ let m;
441
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
442
+ while ((m = OPT_READ.exec(region)) !== null) {
443
+ const after = region.slice(m.index, m.index + 900);
444
+ if (PRIVILEGED_MARKER.test(after)) {
445
+ base.privilegeOnAbsenceFlags.set(m[1], `${b.at} (${nameOf(key)}: an absent identity returns a privileged shape when \`${m[1]}\` is opted into)`);
446
+ }
447
+ }
448
+ }
449
+ return base;
450
+ }
451
+ /**
452
+ * `name` is REFERENCED in `text` — called (`name(`) or passed as a value
453
+ * (`internal.lib.auth.resolveOrgScopeForAction,`). The second form is how an action
454
+ * reaches a resolver, so a call-only test is blind to the whole action surface.
455
+ */
456
+ export function references(text, name, qualified = false) {
457
+ if (qualified) {
458
+ // a registration is reached ONLY through the generated api/internal object —
459
+ // `internal.lib.auth.resolveOrgScopeForAction`. Nothing else may match it.
460
+ return new RegExp(`\\b(?:internal|api)(?:\\.[A-Za-z0-9_$]+)*\\.${name}\\b`).test(text);
461
+ }
462
+ return new RegExp(`(?:^|[^A-Za-z0-9_$.])${name}\\s*(?:\\(|,|\\)|;|$)`, "m").test(text);
463
+ }
464
+ /** Every `if (...)` in `text`, with its brace-balanced condition source. */
465
+ export function ifConditions(text) {
466
+ const out = [];
467
+ const open = /\bif\s*\(/g;
468
+ let m;
469
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
470
+ while ((m = open.exec(text)) !== null) {
471
+ let depth = 1;
472
+ let j = m.index + m[0].length;
473
+ for (; j < text.length && depth > 0; j++) {
474
+ if (text[j] === "(")
475
+ depth += 1;
476
+ else if (text[j] === ")")
477
+ depth -= 1;
478
+ }
479
+ out.push({
480
+ index: m.index,
481
+ cond: text.slice(m.index + m[0].length, j - 1),
482
+ end: j,
483
+ });
484
+ }
485
+ return out;
486
+ }
487
+ /**
488
+ * ABSENT-IDENTITY GUARD — an `if` whose condition tests `v` ITSELF for absence:
489
+ * `!v`, `v === null`, `v == null`, `v === undefined`, including as one disjunct of a
490
+ * widened test (`if (identity === null || identity === undefined)`, the real
491
+ * `assertCanExportNamespace` shape). It must NOT match a test on a PROPERTY of `v`
492
+ * (`if (!scope.isMaster)` is an authority test on a RESOLVED principal, not a test
493
+ * for its absence) — reading those as absence accused every correctly-scoped
494
+ * dashboard query on the first run of this rule.
495
+ */
496
+ export function absentGuard(text, v) {
497
+ const bare = new RegExp(`(?:^|[^.\\w$])!\\s*${v}\\s*(?![.\\w$[(])`);
498
+ const nullish = new RegExp(`\\b${v}\\s*===?\\s*(?:null|undefined)\\b`);
499
+ for (const c of ifConditions(text)) {
500
+ if (bare.test(c.cond) || nullish.test(c.cond)) {
501
+ return { index: c.index, branch: branchAfter(text, c.end) };
502
+ }
503
+ }
504
+ return null;
505
+ }
506
+ /**
507
+ * The GUARDED BRANCH itself, from just after an `if (…)`'s closing paren: a braced
508
+ * block to its matching brace, else the single statement up to its `;`. Reading a
509
+ * fixed-width window instead runs past the branch into the rest of the handler, and
510
+ * that is not a width error — it INVERTS the verdict: `if (ns === null) return [];`
511
+ * followed by the normal data path read as "the absent-identity branch serves data",
512
+ * which accused all three `search:*` actions that in fact refuse first (measured).
513
+ */
514
+ export function branchAfter(text, from) {
515
+ let i = from;
516
+ while (i < text.length && /\s/.test(text[i]))
517
+ i++;
518
+ if (text[i] === "{") {
519
+ let depth = 0;
520
+ for (let j = i; j < text.length; j++) {
521
+ if (text[j] === "{")
522
+ depth += 1;
523
+ else if (text[j] === "}") {
524
+ depth -= 1;
525
+ if (depth === 0)
526
+ return text.slice(i, j + 1);
527
+ }
528
+ }
529
+ return text.slice(i);
530
+ }
531
+ const semi = text.indexOf(";", i);
532
+ return text.slice(i, semi === -1 ? text.length : semi + 1);
533
+ }
534
+ /**
535
+ * A helper that resolves the principal through ANOTHER resolver and then REFUSES on
536
+ * a test of the resolved value is a GATE too — the real `requireFleetMaster` shape:
537
+ * const scope = await withOrgScope(ctx);
538
+ * if (!scope.isMaster) throw new ConvexError("RBAC_DENIED: …");
539
+ * `withOrgScope` itself does not throw on an absent identity (it returns an
540
+ * anonymous, empty scope), so the refusal lives one level up. Missing this level
541
+ * made the rule accuse all four `mandates:*` writers, which are master-gated.
542
+ */
543
+ function refusesOnResolvedValue(text, model, rel) {
544
+ const refs = referenced(text, reachable(model, model.helpers, rel)).filter((r) => !r.qualified);
545
+ const alts = refs.map((r) => r.name).join("|");
546
+ const ALT = alts || "\\0NEVER";
547
+ // SHAPE 1 — declaration and resolution in ONE statement:
548
+ // const scope = await withOrgScope(ctx);
549
+ const bind = new RegExp(`(?:const|let)\\s+([A-Za-z0-9_$]+)\\s*(?::[^=\\n]*)?=\\s*\\(?\\s*(?:await\\s+)?(?:${ALT})\\s*\\(`, "g");
550
+ // SHAPE 2 — DECLARE THEN ASSIGN, which shape 1 cannot see:
551
+ // let scope: Awaited<ReturnType<typeof withOrgScope>>;
552
+ // try { scope = await withOrgScope(ctx); } catch { throw … }
553
+ // if (!scope.isMaster) throw …
554
+ // This is the real `requireServiceAccount` of the audited tree (vantage-peers
555
+ // convex/oauth.ts @ 40c71cb), which declares the local on its own line
556
+ // PRECISELY so the resolution can sit inside a try and re-raise naming its
557
+ // own door. Shape 1 requires the `=` to follow the declarator, so every gate
558
+ // written this way read as ABSENT and SEVEN correctly service-account-gated
559
+ // oauth handlers were classed FAIL-OPEN. Measured on that tree: FAIL-OPEN
560
+ // 11 -> 4, RESOLVES 139 -> 146, classified total unchanged at 179.
561
+ //
562
+ // Why this is a detector defect rather than a style complaint: a gate that
563
+ // can only pass by being rewritten into the matcher's single accepted shape
564
+ // is a gate-satisfiability failure. The instrument reads the code the
565
+ // codebase writes; it does not demand the code the matcher finds convenient.
566
+ //
567
+ // A bare assignment counts only when the SAME identifier is also DECLARED
568
+ // with let/var in this body, so an assignment to a parameter, an imported
569
+ // binding or an object property is not mistaken for a local resolution.
570
+ const assign = new RegExp(`(?:^|[;{}\\n])\\s*([A-Za-z0-9_$]+)\\s*=\\s*\\(?\\s*(?:await\\s+)?(?:${ALT})\\s*\\(`, "g");
571
+ // SHAPE 3 — an ACTION resolves through a REGISTRATION it cannot call directly:
572
+ // const scope = await ctx.runQuery(internal.lib.auth.resolveOrgScopeForAction, {});
573
+ // (vantage-peers 58ed649 okfBundleNode.ts:271, `assertCanExportNamespace`). The
574
+ // registration must be a tree-derived identity helper reached by its QUALIFIED
575
+ // name — never a name list, and never any `ctx.runQuery` whatever it targets:
576
+ // a runQuery to a data read is not a resolution. Shapes 1-2 drop qualified refs
577
+ // (a registration is never called by bare name), so every action-side gate of
578
+ // this form read as "binds nothing" and three gated OKF doors were FAIL-OPEN.
579
+ const qualifiedAlts = referenced(text, reachable(model, model.helpers, rel))
580
+ .filter((r) => r.qualified)
581
+ .map((r) => r.name)
582
+ .join("|");
583
+ const runQueryBind = new RegExp(`(?:const|let)\\s+([A-Za-z0-9_$]+)\\s*(?::[^=\\n]*)?=\\s*\\(?\\s*(?:await\\s+)?ctx\\.runQuery\\s*\\(\\s*(?:internal|api)(?:\\.[A-Za-z0-9_$]+)*\\.(?:${qualifiedAlts || "\\0NEVER"})\\b`, "g");
584
+ const bound = new Set();
585
+ for (const re of [bind, assign, runQueryBind]) {
586
+ re.lastIndex = 0;
587
+ let m;
588
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
589
+ while ((m = re.exec(text)) !== null) {
590
+ const v = m[1];
591
+ if (re === assign && !new RegExp(`(?:let|var)\\s+${v}\\b`).test(text)) {
592
+ continue; // assigned but never declared here — not a local resolution.
593
+ }
594
+ bound.add(v);
595
+ }
596
+ }
597
+ for (const v of bound) {
598
+ for (const c of ifConditions(text)) {
599
+ if (!new RegExp(`\\b${v}\\b`).test(c.cond))
600
+ continue;
601
+ if (THROWS.test(branchAfter(text, c.end)))
602
+ return true;
603
+ }
604
+ // The refusal may live in a function the RESOLVED value is handed to:
605
+ // requireTenantNamespace(scope, namespace, door);
606
+ // It counts only when `v` itself is the argument at a refused position of a
607
+ // reachable value refuser — `requireNonEmpty(namespace)` beside an unread
608
+ // `scope` refuses nothing about the principal.
609
+ if (passesToRefuser(text, v, model, rel))
610
+ return true;
611
+ }
612
+ return false;
613
+ }
614
+ // ---------------------------------------------------------------------------
615
+ // value refusers — a refusal expressed as `fn(resolvedValue, …)`
616
+ // ---------------------------------------------------------------------------
617
+ /** split a parenthesised list's inner text on its TOP-LEVEL commas. */
618
+ function splitTopLevel(inner) {
619
+ const out = [];
620
+ let depth = 0;
621
+ let cur = "";
622
+ for (let i = 0; i < inner.length; i++) {
623
+ const ch = inner[i];
624
+ if (ch === "(" || ch === "[" || ch === "{" || ch === "<")
625
+ depth += 1;
626
+ else if (ch === ")" || ch === "]" || ch === "}")
627
+ depth -= 1;
628
+ // `>` closes a generic, but `=>` is an arrow and closes nothing.
629
+ else if (ch === ">" && inner[i - 1] !== "=")
630
+ depth -= 1;
631
+ if (ch === "," && depth === 0) {
632
+ out.push(cur);
633
+ cur = "";
634
+ continue;
635
+ }
636
+ cur += ch;
637
+ }
638
+ if (cur.trim() !== "")
639
+ out.push(cur);
640
+ return out;
641
+ }
642
+ /** the balanced `(…)` starting at `open` (an index of `(`): inner text and end. */
643
+ function parenAt(text, open) {
644
+ let depth = 0;
645
+ for (let j = open; j < text.length; j++) {
646
+ if (text[j] === "(")
647
+ depth += 1;
648
+ else if (text[j] === ")") {
649
+ depth -= 1;
650
+ if (depth === 0)
651
+ return { inner: text.slice(open + 1, j), end: j + 1 };
652
+ }
653
+ }
654
+ return null;
655
+ }
656
+ /**
657
+ * `v` is passed, as itself, at a refused position of a value refuser REACHABLE from
658
+ * `rel` — declared in `rel`, or imported by it (the same scope rule `reachable`
659
+ * applies; a same-named function in a module `rel` does not import is not seen).
660
+ */
661
+ function passesToRefuser(text, v, model, rel) {
662
+ const scope = model.scopes.get(rel) ?? new Set();
663
+ for (const [key, positions] of model.refuserParams) {
664
+ const name = nameOf(key);
665
+ if (relOf(key) !== rel && !scope.has(name))
666
+ continue;
667
+ const call = new RegExp(`(?:^|[^A-Za-z0-9_$.])${name}\\s*\\(`, "g");
668
+ let m;
669
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
670
+ while ((m = call.exec(text)) !== null) {
671
+ const p = parenAt(text, m.index + m[0].length - 1);
672
+ if (p === null)
673
+ continue;
674
+ const args = splitTopLevel(p.inner).map((a) => a.trim());
675
+ for (const i of positions) {
676
+ if (args[i] === v)
677
+ return true;
678
+ }
679
+ }
680
+ }
681
+ return false;
682
+ }
683
+ /** parameter names of a function body's FIRST parenthesised list, by position
684
+ * (`null` for a destructured or unreadable parameter), and where the list ends. */
685
+ function paramsOf(text) {
686
+ const open = text.indexOf("(");
687
+ if (open < 0)
688
+ return null;
689
+ const p = parenAt(text, open);
690
+ if (p === null)
691
+ return null;
692
+ const names = splitTopLevel(p.inner).map((raw) => {
693
+ const m = /^\s*(?:\.\.\.)?([A-Za-z_$][A-Za-z0-9_$]*)\s*[?:=,]?/.exec(raw);
694
+ return m ? m[1] : null;
695
+ });
696
+ return { names, end: p.end };
697
+ }
698
+ /**
699
+ * Derive the VALUE REFUSERS from the tree, transitively. A plain (non-registration)
700
+ * function refuses on its parameter `p` when BOTH hold:
701
+ *
702
+ * REFUSES — an `if` whose condition reads `p` has a branch that THROWS
703
+ * (`if (scope.anonymous) throw …`), OR it hands `p` itself to another
704
+ * value refuser at a refused position (`requireTenantNamespace` ->
705
+ * `requireResolvedCaller(scope, …)`).
706
+ * NEVER PROCEEDS SILENTLY ON ANYTHING ELSE — every proceed (`return;` /
707
+ * `return true`) in the body sits in the branch of an `if` whose
708
+ * condition reads `p`. An allow decided on the RESOLVED value
709
+ * (`if (isNamespaceAllowedForScope(scope, ns)) return;`) is the
710
+ * authority deciding; an allow decided on a caller-supplied argument
711
+ * (`if (ns.startsWith("public/")) return;`) lets any principal —
712
+ * including none — through, so that function is NOT a refuser.
713
+ *
714
+ * What "refuses" means here is the bar CR-1 already applies to an inline gate
715
+ * (`refusesOnResolvedValue`: an `if` on the resolved value that throws), carried one
716
+ * call deeper. It is not a proof that every non-authorised input throws — that is
717
+ * not decidable from source; it is the absent-principal path CR-1 asks about.
718
+ */
719
+ function deriveValueRefusers(bodies, model) {
720
+ const parsed = new Map();
721
+ for (const [key, b] of bodies) {
722
+ if (b.isRegistration)
723
+ continue;
724
+ const ps = paramsOf(b.text);
725
+ if (ps === null)
726
+ continue;
727
+ parsed.set(key, { names: ps.names, body: b.text.slice(ps.end) });
728
+ }
729
+ const silentlyProceeds = (body, p) => {
730
+ const reads = new RegExp(`\\b${p}\\b`);
731
+ const guarded = [];
732
+ for (const c of ifConditions(body)) {
733
+ if (!reads.test(c.cond))
734
+ continue;
735
+ let s = c.end;
736
+ while (s < body.length && /\s/.test(body[s]))
737
+ s++;
738
+ guarded.push([s, s + branchAfter(body, c.end).length]);
739
+ }
740
+ const proceed = new RegExp(PROCEED_RETURN.source, "g");
741
+ let m;
742
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
743
+ while ((m = proceed.exec(body)) !== null) {
744
+ const at = m.index;
745
+ if (!guarded.some(([s, e]) => at >= s && at < e))
746
+ return true;
747
+ }
748
+ return false;
749
+ };
750
+ for (let round = 0; round < 5; round++) {
751
+ let grew = false;
752
+ for (const [key, { names, body }] of parsed) {
753
+ names.forEach((p, i) => {
754
+ if (p === null || model.refuserParams.get(key)?.has(i))
755
+ return;
756
+ if (silentlyProceeds(body, p))
757
+ return;
758
+ const reads = new RegExp(`\\b${p}\\b`);
759
+ const direct = ifConditions(body).some((c) => reads.test(c.cond) && THROWS.test(branchAfter(body, c.end)));
760
+ if (!direct && !passesToRefuser(body, p, model, relOf(key)))
761
+ return;
762
+ const set = model.refuserParams.get(key) ?? new Set();
763
+ set.add(i);
764
+ model.refuserParams.set(key, set);
765
+ grew = true;
766
+ });
767
+ }
768
+ if (!grew)
769
+ break;
770
+ }
771
+ }
772
+ /** true when a helper body resolves the identity and THROWS on its absent path. */
773
+ function absentIdentityBranchThrows(text) {
774
+ const bind = /(?:const|let)\s+([A-Za-z0-9_$]+)\s*(?::[^=\n]*)?=\s*\(?\s*await\s+ctx\.auth\.getUserIdentity/.exec(text);
775
+ if (bind) {
776
+ const g = absentGuard(text, bind[1]);
777
+ if (g && THROWS.test(g.branch))
778
+ return true;
779
+ }
780
+ // `const identity = await ctx.auth.getUserIdentity(); if (!identity) throw` is the
781
+ // common form; a helper with no binding that throws on a falsy identity expression
782
+ // (`if (!(await ctx.auth.getUserIdentity())) throw`) refuses just as decisively.
783
+ const inline = /if\s*\(\s*!\s*\(?\s*await\s+ctx\.auth\.getUserIdentity/.exec(text);
784
+ return (inline !== null && THROWS.test(text.slice(inline.index, inline.index + 600)));
785
+ }
786
+ // ---------------------------------------------------------------------------
787
+ // the four classes
788
+ // ---------------------------------------------------------------------------
789
+ /** the handler TOUCHES DATA — reads, writes, schedules, or delegates to a function
790
+ * that does. A registration that touches none of these is the NO-DATA class. */
791
+ const DATA_ACCESS = /ctx\.db\b|ctx\.storage\b|ctx\.runQuery\b|ctx\.runMutation\b|ctx\.runAction\b|ctx\.scheduler\b|ctx\.vectorSearch\b|\bcomponents\./;
792
+ /** a refusal — an explicit throw. */
793
+ const THROWS = /\bthrow\b/;
794
+ /** a PROCEED inside an assertion helper: a bare `return;` / `return }` or a
795
+ * `return true` — the caller is let through. A `return null` / `return []` /
796
+ * `return {…}` is a value (a typed refusal shape in a resolver), not a proceed. */
797
+ const PROCEED_RETURN = /\breturn\s*(?:;|\}|true\b)/;
798
+ /**
799
+ * A CONSTANT in place of an authority: `if (x === "literal") return true` — an
800
+ * allow decision taken on a hardcoded string and nothing else. This is the shape
801
+ * that defeated every predecessor at `messages:listByChannel`
802
+ * (`if (ch === "broadcast") return true;`) and at `search.ts::searchFixPatterns`
803
+ * (a hardcoded namespace). A constant is not an authorisation.
804
+ */
805
+ const CONSTANT_ALLOWANCE = /if\s*\(\s*[^)]*===\s*(['"`])[^'"`\n]*\1\s*\)\s*(?:\{\s*)?return\s+true\b/;
806
+ /** the justification token, read FROM THE TARGET SOURCE at the registration. */
807
+ const JUSTIFICATION = /allow-no-caller-resolution:\s*(\S[^\n]{7,})/;
808
+ /**
809
+ * Classify ONE registration from its own block text. `pre` is the comment context
810
+ * immediately above the opener (a justification token may sit there or inside).
811
+ *
812
+ * ORDER MATTERS and is the rule's logic:
813
+ * no data touched -> NO-DATA (token required)
814
+ * data touched, principal never consulted-> NO-IDENTITY (token required)
815
+ * principal consulted but not decisive -> FAIL-OPEN (NO token exit)
816
+ * otherwise -> RESOLVES
817
+ */
818
+ export function classify(codeText, fullText, pre, model, rel) {
819
+ const token = JUSTIFICATION.exec(`${pre}\n${fullText}`);
820
+ const dataFnCalls = referenced(codeText, reachable(model, model.dataFns, rel));
821
+ const touchesData = DATA_ACCESS.test(codeText) || dataFnCalls.length > 0;
822
+ if (!touchesData) {
823
+ return token
824
+ ? {
825
+ cls: "NO-DATA",
826
+ why: `touches no data — neither directly (ctx.db / ctx.storage / ctx.run* / ctx.scheduler / components.*) nor through any tree-derived data-touching helper — and carries a written justification at the registration: "${token[1].trim().slice(0, 120)}"`,
827
+ justified: true,
828
+ }
829
+ : {
830
+ cls: "NO-DATA",
831
+ why: "touches no data — neither directly (ctx.db / ctx.storage / ctx.run* / ctx.scheduler / components.*) nor through any tree-derived data-touching helper — and carries NO `allow-no-caller-resolution: <reason>` token at the registration. A public registration that resolves no caller is accepted only against a reason written in this source",
832
+ };
833
+ }
834
+ const helperCalls = referenced(codeText, reachable(model, model.helpers, rel));
835
+ const gateRefs = reachable(model, model.gates, rel);
836
+ const consults = GET_IDENTITY.test(codeText) || helperCalls.length > 0;
837
+ if (!consults) {
838
+ return token
839
+ ? {
840
+ cls: "NO-IDENTITY",
841
+ why: `serves or writes data and never consults the verified principal, but carries a written justification at the registration: "${token[1].trim().slice(0, 120)}"`,
842
+ justified: true,
843
+ }
844
+ : {
845
+ cls: "NO-IDENTITY",
846
+ why: "serves or writes data and NEVER consults the verified principal — no `ctx.auth.getUserIdentity()` and no call to any tree-derived identity helper. An anonymous caller is served whatever this handler reads",
847
+ };
848
+ }
849
+ // --- FAIL-OPEN: the principal is resolved, then does not decide. ---
850
+ const reasons = [];
851
+ for (const [flag, where] of model.privilegeOnAbsenceFlags) {
852
+ if (new RegExp(`\\b${flag}\\s*:\\s*true\\b`).test(codeText)) {
853
+ reasons.push(`treats an ABSENT identity as privileged: it opts into \`${flag}: true\`, and that flag's own helper — ${where} — returns a privileged shape on a null identity`);
854
+ }
855
+ }
856
+ // A GATE call refuses on the absent-identity path by throwing, so it decides even
857
+ // as a bare statement — `await requireOrgAdmin(ctx);` IS the fail-closed idiom and
858
+ // must never be read as a discarded principal. Only a RESOLVER's return value can
859
+ // be computed and discarded.
860
+ const isGate = (r) => gateRefs.some((g) => g.name === r.name && g.qualified === r.qualified);
861
+ const refusesInline = GET_IDENTITY.test(codeText) && absentIdentityBranchThrows(codeText);
862
+ for (const r of helperCalls) {
863
+ const where = model.permissiveOnAbsence.get(r.name);
864
+ if (where === undefined)
865
+ continue;
866
+ // The permissive helper only matters if an anonymous caller can REACH it. A
867
+ // handler that resolves the identity itself and throws on its absence BEFORE
868
+ // the helper call (`cancelOkfBundleExportDurable` at vantage-peers 9e1413a:
869
+ // `if (identity === null) throw …` then `assertCanExportNamespaceV8(…)`) has
870
+ // already refused every anonymous caller; the helper's allow-on-absence path
871
+ // is unreachable from here. The refusal must PRECEDE the first helper call —
872
+ // an inline refusal after the helper has run refuses nothing it let through.
873
+ const helperAt = codeText.search(new RegExp(`(?:^|[^A-Za-z0-9_$.])${r.name}\\s*\\(`));
874
+ if (refusesInline &&
875
+ helperAt >= 0 &&
876
+ absentIdentityBranchThrows(codeText.slice(0, helperAt))) {
877
+ continue;
878
+ }
879
+ reasons.push(`its only authority is \`${r.name}()\`, which GRANTS on an absent identity — ${where}. An anonymous caller passes the gate and reaches the data`);
880
+ }
881
+ const gateCalls = helperCalls.filter(isGate).map((r) => r.name);
882
+ const resolverCalls = helperCalls
883
+ .filter((r) => !isGate(r))
884
+ .map((r) => r.name);
885
+ // the principal is COMPUTED AND DISCARDED: every binding of an identity /
886
+ // resolver result occurs exactly once (its own declaration), or the call is a
887
+ // bare statement with no binding at all — and nothing in the handler refuses.
888
+ const bindPattern = new RegExp(`(?:const|let)\\s+(?:\\{[^}]*\\}|([A-Za-z0-9_$]+))\\s*(?::[^=\\n]*)?=\\s*\\(?\\s*(?:await\\s+)?(?:ctx\\.auth\\.getUserIdentity|${resolverCalls.length ? resolverCalls.join("|") : "\\0NEVER"})\\s*\\(`, "g");
889
+ const binds = [];
890
+ let destructured = false;
891
+ let bm;
892
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
893
+ while ((bm = bindPattern.exec(codeText)) !== null) {
894
+ if (bm[1] === undefined)
895
+ destructured = true;
896
+ else
897
+ binds.push(bm[1]);
898
+ }
899
+ if (gateCalls.length === 0 && !refusesInline && !destructured) {
900
+ if (binds.length === 0) {
901
+ reasons.push("resolves the principal and BINDS NOTHING — the identity/resolver call is a bare statement that does not refuse, so the verified principal is computed and discarded and cannot decide what is served");
902
+ }
903
+ else {
904
+ const unused = binds.filter((b) => (codeText.match(new RegExp(`\\b${b}\\b`, "g")) ?? []).length <= 1);
905
+ if (unused.length === binds.length) {
906
+ reasons.push(`resolves the principal into ${unused.map((u) => `\`${u}\``).join(", ")} and never reads ${unused.length > 1 ? "any of them" : "it"} again — the verified principal is computed and discarded`);
907
+ }
908
+ }
909
+ }
910
+ if (CONSTANT_ALLOWANCE.test(codeText)) {
911
+ reasons.push('grants access on a HARDCODED CONSTANT — an `if (… === "<literal>") return true` allow decision that no resolved principal takes part in. A constant is not an authorisation');
912
+ }
913
+ // an absent-identity branch that SERVES DATA instead of refusing. The guard must
914
+ // test the binding ITSELF (`if (!scope)`), never a property of it
915
+ // (`if (!scope.isMaster)` is an authority test on a RESOLVED principal).
916
+ for (const b of binds) {
917
+ const g = absentGuard(codeText, b);
918
+ if (!g)
919
+ continue;
920
+ const region = g.branch;
921
+ const regionServes = DATA_ACCESS.test(region) || referenced(region, dataFnCalls).length > 0;
922
+ // a `return` in the branch IS a refusal shape (a typed empty / unauthorized
923
+ // result), exactly as a `throw` is — R-50 governs WHICH of the two a client can
924
+ // render; CR-1 only asks that the absent-identity path does not reach the data.
925
+ if (regionServes && !THROWS.test(region) && !/\breturn\b/.test(region)) {
926
+ reasons.push(`its absent-identity branch (\`if (!${b})\`) SERVES DATA and does not refuse — an unauthenticated caller reaches the data path`);
927
+ }
928
+ }
929
+ if (reasons.length > 0) {
930
+ return {
931
+ cls: "FAIL-OPEN",
932
+ why: `${reasons.join("; ")}. NO justification token clears FAIL-OPEN — a resolved principal that does not decide is the confirmed leak class, not a documented exception`,
933
+ };
934
+ }
935
+ return {
936
+ cls: "RESOLVES",
937
+ why: `consults the verified principal (${[
938
+ GET_IDENTITY.test(codeText) ? "ctx.auth.getUserIdentity()" : "",
939
+ ...gateCalls.map((h) => `${h}() [refusing gate]`),
940
+ ...resolverCalls.map((h) => `${h}() [resolver]`),
941
+ ]
942
+ .filter(Boolean)
943
+ .join(", ")}) and ${gateCalls.length > 0 || refusesInline ? "refuses on the absent-identity path" : "the resolved value decides what is served"}`,
944
+ };
945
+ }
946
+ // ---------------------------------------------------------------------------
947
+ // the scan
948
+ // ---------------------------------------------------------------------------
949
+ /** up to 8 comment lines immediately above `i`, as text. */
950
+ function commentContext(lines, i) {
951
+ const out = [];
952
+ for (let j = i - 1; j >= 0 && i - j <= 8; j--) {
953
+ if (!lines[j].comment && lines[j].raw.trim() !== "")
954
+ break;
955
+ out.unshift(lines[j].raw);
956
+ }
957
+ return out.join("\n");
958
+ }
959
+ /** The exposed surface for a tree, with module rels in the TREE's rel space: a
960
+ * tree rooted at the repository (src/target.ts) carries `convex/` on every convex
961
+ * rel, while api.d.ts names modules relative to the convex dir. */
962
+ export function surfaceForTree(tree) {
963
+ const cr = convexRootOf(tree);
964
+ const s = deriveApiSurface(cr.dir);
965
+ if (cr.prefix === "")
966
+ return s;
967
+ return {
968
+ ...s,
969
+ modules: new Map([...s.modules].map(([m, rel]) => [m, `${cr.prefix}${rel}`])),
970
+ };
971
+ }
972
+ export function scanCallerResolution(tree, surface = surfaceForTree(tree)) {
973
+ // CR-1's population is the CONVEX surface only: a repo-rooted tree also carries
974
+ // mcp-server/src, whose files api.d.ts never exposes and must not be read as drift.
975
+ const { dir: convexDir, prefix } = convexRootOf(tree);
976
+ const inConvex = (f) => f.rel.startsWith(prefix);
977
+ const files = tree.files.filter((f) => isJudgeable(f) && inConvex(f));
978
+ const byRel = new Map(files.map((f) => [f.rel, f]));
979
+ const builders = discoverBuilders(files);
980
+ const model = deriveIdentityModel(files);
981
+ const scan = {
982
+ exposedModules: surface.modules,
983
+ populationSource: surface.unreadable
984
+ ? `DEGRADED — ${surface.unreadable}`
985
+ : `${convexDir}/_generated/api.d.ts (\`declare const fullApi: ApiFromModules<{…}>\`, ${surface.modules.size} exposed module(s))`,
986
+ findings: [],
987
+ excluded: [],
988
+ selfDefects: [],
989
+ exposedCount: 0,
990
+ classifiedCount: 0,
991
+ };
992
+ // An UNREADABLE api.d.ts is only a defect when there is a surface to miss. On a
993
+ // tree with ZERO public registrations the absent generated file is CONSISTENT with
994
+ // "nothing is exposed", and the honest report is NO APPLICABLE SITE — the
995
+ // empty-tree control (a tree whose only file declares `export const nothing = 1;`)
996
+ // must yield that, never a count and never a self-defect. On a tree that DOES
997
+ // register public functions, the same absence means the exposed surface cannot be
998
+ // derived, which is a refusal — never a silent pass.
999
+ const publicRegistrationsAnywhere = files.some((f) => f.lines.some((l) => {
1000
+ if (!isCode(l))
1001
+ return false;
1002
+ const m = REGISTRATION.exec(l.raw);
1003
+ return m !== null && builders.publicBuilders.has(m[2]);
1004
+ }));
1005
+ if (surface.unreadable && publicRegistrationsAnywhere) {
1006
+ scan.selfDefects.push(surface.unreadable);
1007
+ }
1008
+ if (surface.unreadable && !publicRegistrationsAnywhere) {
1009
+ scan.populationSource = `${convexDir}/_generated/api.d.ts is absent AND the tree registers no public query/mutation/action — nothing is exposed, so there is no applicable site (consistent, not a defect)`;
1010
+ }
1011
+ for (const u of builders.unresolvable) {
1012
+ scan.selfDefects.push(`${u.rel}:${u.n} — builder \`${u.name}\` = ${u.base === "<none>" ? "custom*(…)" : `custom*(${u.base}, …)`}: its base resolves to neither a public nor an internal Convex base, so whether registrations built from it are EXPOSED cannot be established. CR-1 refuses rather than certify a surface it cannot see`);
1013
+ }
1014
+ // The population is the exposed surface itself: only modules api.d.ts lists.
1015
+ for (const [mod, rel] of surface.modules) {
1016
+ const f = byRel.get(rel);
1017
+ if (f === undefined) {
1018
+ scan.selfDefects.push(`api.d.ts exposes module \`${mod}\` -> ${rel}, which is not in the loaded tree — the exposed surface and the scanned tree DISAGREE (CR-1's own population bug, never a rounding)`);
1019
+ continue;
1020
+ }
1021
+ const lines = f.lines;
1022
+ for (let i = 0; i < lines.length; i++) {
1023
+ if (!isCode(lines[i]))
1024
+ continue;
1025
+ const m = REGISTRATION.exec(lines[i].raw);
1026
+ if (!m)
1027
+ continue;
1028
+ const [, name, builder] = m;
1029
+ const isPublic = builders.publicBuilders.has(builder);
1030
+ const isNonPublic = builders.nonPublicBuilders.has(builder);
1031
+ if (!isPublic && !isNonPublic)
1032
+ continue; // not a registration at all
1033
+ const reg = {
1034
+ module: mod,
1035
+ name,
1036
+ api: `${mod}:${name}`,
1037
+ rel: f.rel,
1038
+ n: lines[i].n,
1039
+ builder,
1040
+ };
1041
+ const blk = blockAt(lines, i);
1042
+ i += blk.lines.length - 1;
1043
+ if (isNonPublic) {
1044
+ // The exclusion is PROVEN PER SITE: the builder token observed at this
1045
+ // very line is what excludes it, never the shape of its name.
1046
+ scan.excluded.push(reg);
1047
+ continue;
1048
+ }
1049
+ scan.exposedCount += 1;
1050
+ if (blk.unterminated) {
1051
+ scan.findings.push({
1052
+ ...reg,
1053
+ cls: "COULD-NOT-JUDGE",
1054
+ why: "its block could not be delimited (unbalanced braces/parens) — whether it resolves its caller is not readable; never a silent green",
1055
+ });
1056
+ continue;
1057
+ }
1058
+ const codeText = blk.lines
1059
+ .filter(isCode)
1060
+ .map((l) => l.raw)
1061
+ .join("\n");
1062
+ const fullText = blk.lines.map((l) => l.raw).join("\n");
1063
+ const c = classify(codeText, fullText, commentContext(lines, blk.lines[0].n - 1), model, f.rel);
1064
+ scan.findings.push({ ...reg, ...c });
1065
+ }
1066
+ }
1067
+ // POPULATION DRIFT, both directions: a public registration in a judgeable file
1068
+ // that api.d.ts does NOT expose is either a stale api.d.ts or a CR-1 parse bug.
1069
+ // Either way it is named, never dropped.
1070
+ const exposedRels = new Set(surface.modules.values());
1071
+ for (const f of files) {
1072
+ if (exposedRels.has(f.rel))
1073
+ continue;
1074
+ for (const l of f.lines) {
1075
+ if (!isCode(l))
1076
+ continue;
1077
+ const m = REGISTRATION.exec(l.raw);
1078
+ if (!m)
1079
+ continue;
1080
+ if (!builders.publicBuilders.has(m[2]))
1081
+ continue;
1082
+ scan.selfDefects.push(`${f.rel}:${l.n} — public registration \`${m[1]}\` (builder \`${m[2]}\`) lives in a file the target's own api.d.ts does not expose. The exposed surface and the tree DISAGREE: either api.d.ts is stale or CR-1's parse is incomplete. Named, never rounded`);
1083
+ }
1084
+ }
1085
+ scan.classifiedCount = scan.findings.length;
1086
+ if (scan.classifiedCount !== scan.exposedCount) {
1087
+ scan.selfDefects.push(`CR-1 OWN BUG: classified ${scan.classifiedCount} != exposed ${scan.exposedCount}. The count classified must EQUAL the count exposed`);
1088
+ }
1089
+ return scan;
1090
+ }
1091
+ // ---------------------------------------------------------------------------
1092
+ // verdict + rendering — EVERY member of EVERY class is named
1093
+ // ---------------------------------------------------------------------------
1094
+ export const CR1_ID = "CR-1 a public registration resolves its caller before serving or writing data";
1095
+ export function byClass(scan, cls) {
1096
+ return scan.findings.filter((f) => f.cls === cls);
1097
+ }
1098
+ /** the findings that are VIOLATIONS: fail-open, plus unjustified no-identity/no-data. */
1099
+ export function violations(scan) {
1100
+ return scan.findings.filter((f) => f.cls === "FAIL-OPEN" ||
1101
+ ((f.cls === "NO-IDENTITY" || f.cls === "NO-DATA") && !f.justified));
1102
+ }
1103
+ export function abstentions(scan) {
1104
+ return scan.findings.filter((f) => f.cls === "COULD-NOT-JUDGE");
1105
+ }
1106
+ export function callerResolutionResult(scan) {
1107
+ const v = violations(scan);
1108
+ const a = abstentions(scan);
1109
+ const counts = `[exposed=${scan.exposedCount} classified=${scan.classifiedCount} RESOLVES=${byClass(scan, "RESOLVES").length} FAIL-OPEN=${byClass(scan, "FAIL-OPEN").length} NO-IDENTITY=${byClass(scan, "NO-IDENTITY").length} NO-DATA=${byClass(scan, "NO-DATA").length} could-not-judge=${a.length} internal-excluded=${scan.excluded.length}]`;
1110
+ if (scan.exposedCount === 0 && scan.selfDefects.length === 0) {
1111
+ // The EMPTY-TREE CONTROL. A tree that exposes nothing yields NO APPLICABLE
1112
+ // SITE — never a count. This is the control that exposed the frozen-CSV
1113
+ // defect in the oracle-scored rules, and CR-1 must pass it.
1114
+ const reason = "CR-1: no applicable site — the target exposes no public registration (no `declare const fullApi` member resolving to a file that registers a public query/mutation/action). NOT a clean bill: nothing was certified because there was nothing to certify.";
1115
+ return {
1116
+ id: CR1_ID,
1117
+ anchor: "caller-resolution:no-applicable-site",
1118
+ state: "could-not-judge",
1119
+ verdict: reason,
1120
+ reason,
1121
+ };
1122
+ }
1123
+ if (v.length > 0) {
1124
+ const named = v
1125
+ .map((f) => `${f.api} @ ${f.rel}:${f.n} [${f.cls}] — ${f.why}`)
1126
+ .join("\n ");
1127
+ return {
1128
+ id: CR1_ID,
1129
+ anchor: `${v[0].rel}:${v[0].n}`,
1130
+ state: "non-conforming",
1131
+ verdict: `CR-1: ${v.length} public registration(s) do NOT resolve their caller before serving or writing data. ${counts}\n ${named}${scan.selfDefects.length
1132
+ ? `\n CR-1 SELF-DEFECTS (the instrument's own, never a tree finding): ${scan.selfDefects.join(" | ")}`
1133
+ : ""}`,
1134
+ };
1135
+ }
1136
+ if (a.length > 0 || scan.selfDefects.length > 0) {
1137
+ const reason = `CR-1: 0 violations, but ${a.length} registration(s) could not be judged and ${scan.selfDefects.length} instrument self-defect(s) stand. ${counts} ${[
1138
+ ...a.map((f) => `${f.api} @ ${f.rel}:${f.n} — ${f.why}`),
1139
+ ...scan.selfDefects,
1140
+ ].join(" | ")}. This is NOT a clean bill.`;
1141
+ return {
1142
+ id: CR1_ID,
1143
+ anchor: a.length
1144
+ ? `${a[0].rel}:${a[0].n}`
1145
+ : "caller-resolution:self-defect",
1146
+ state: "could-not-judge",
1147
+ verdict: reason,
1148
+ reason,
1149
+ };
1150
+ }
1151
+ return {
1152
+ id: CR1_ID,
1153
+ anchor: `${scan.findings[0].rel}:${scan.findings[0].n}`,
1154
+ state: "present",
1155
+ verdict: `CR-1: all ${scan.exposedCount} exposed public registration(s) resolve their caller (or carry a written justification at the registration); 0 violations. ${counts}`,
1156
+ };
1157
+ }
1158
+ /** The rendered section. EVERY member of EVERY class is named — the truncation that
1159
+ * let R-50 classify thirteen of the fourteen and show a reader none of them is the
1160
+ * defect this renderer refuses to repeat. */
1161
+ export function callerResolutionSection(scan) {
1162
+ const out = [];
1163
+ out.push("CALLER RESOLUTION (CR-1) — DOCTOR-OWNED CRITICAL control, NOT one of the numbered rules in the pinned standard (see src/caller-resolution.ts: numbering it upstream is a separate change to the standard document). It drives the exit code.");
1164
+ out.push(` population source: ${scan.populationSource}`);
1165
+ out.push(` population cross-check: exposed=${scan.exposedCount} classified=${scan.classifiedCount} ${scan.exposedCount === scan.classifiedCount
1166
+ ? "EQUAL (ok)"
1167
+ : "DISCREPANT — CR-1's own bug"}; internal/httpAction excluded=${scan.excluded.length}, each proven by the builder token read at its own site`);
1168
+ const order = [
1169
+ "FAIL-OPEN",
1170
+ "NO-IDENTITY",
1171
+ "NO-DATA",
1172
+ "RESOLVES",
1173
+ "COULD-NOT-JUDGE",
1174
+ ];
1175
+ for (const cls of order) {
1176
+ const members = byClass(scan, cls);
1177
+ out.push(` ${cls} (${members.length}):`);
1178
+ for (const f of members) {
1179
+ out.push(` ${f.justified ? "JUSTIFIED " : ""}${f.api} @ ${f.rel}:${f.n} (${f.builder}) — ${f.why}`);
1180
+ }
1181
+ }
1182
+ if (scan.selfDefects.length > 0) {
1183
+ out.push(` CR-1 SELF-DEFECTS (${scan.selfDefects.length}):`);
1184
+ for (const d of scan.selfDefects)
1185
+ out.push(` ${d}`);
1186
+ }
1187
+ const v = violations(scan);
1188
+ out.push(` CR-1 VERDICT: ${v.length} violation(s) — ${v.length > 0 ? "CRITICAL, the gate refuses" : "none"}; ${abstentions(scan).length} could-not-judge; ${scan.selfDefects.length} self-defect(s).`);
1189
+ return out.join("\n");
1190
+ }
1191
+ /** CR-1 as an engine Detector, for tests and for a future registry slot. */
1192
+ export const CR1 = {
1193
+ id: CR1_ID,
1194
+ run: (tree) => callerResolutionResult(scanCallerResolution(tree)),
1195
+ };