@vantageos/backend-doctor 0.1.14 → 0.3.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 (93) hide show
  1. package/README.md +66 -5
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +72 -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 +1198 -0
  9. package/dist/cli.js +249 -17
  10. package/dist/contract.d.ts +25 -7
  11. package/dist/contract.js +52 -7
  12. package/dist/coverage-cli.js +8 -0
  13. package/dist/coverage.d.ts +1 -1
  14. package/dist/coverage.js +2 -2
  15. package/dist/detectors/oracle-axis1.d.ts +5 -3
  16. package/dist/detectors/oracle-axis1.js +28 -18
  17. package/dist/detectors/oracle-axis2.d.ts +6 -0
  18. package/dist/detectors/oracle-axis2.js +37 -22
  19. package/dist/detectors/predicates.d.ts +3 -0
  20. package/dist/detectors/predicates.js +93 -19
  21. package/dist/detectors/shared.js +8 -7
  22. package/dist/detectors/source/_ast.d.ts +92 -0
  23. package/dist/detectors/source/_ast.js +353 -0
  24. package/dist/detectors/source/_scan.js +4 -3
  25. package/dist/detectors/source/backfill.js +71 -18
  26. package/dist/detectors/source/contract.js +21 -13
  27. package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
  28. package/dist/detectors/source/cross-tenant-write.js +1408 -0
  29. package/dist/detectors/source/guard-helper-flow.d.ts +134 -0
  30. package/dist/detectors/source/guard-helper-flow.js +1216 -0
  31. package/dist/detectors/source/identity-by-id.d.ts +80 -0
  32. package/dist/detectors/source/identity-by-id.js +1783 -0
  33. package/dist/detectors/source/identity.d.ts +58 -2
  34. package/dist/detectors/source/identity.js +1158 -28
  35. package/dist/detectors/source/isolation.d.ts +11 -1
  36. package/dist/detectors/source/isolation.js +106 -25
  37. package/dist/detectors/source/jobs.js +178 -4
  38. package/dist/detectors/source/lifecycle.js +14 -2
  39. package/dist/detectors/source/local-identity.d.ts +26 -0
  40. package/dist/detectors/source/local-identity.js +661 -0
  41. package/dist/detectors/source/package-principal.d.ts +9 -0
  42. package/dist/detectors/source/package-principal.js +177 -0
  43. package/dist/detectors/source/r6-shapes.d.ts +20 -0
  44. package/dist/detectors/source/r6-shapes.js +285 -0
  45. package/dist/detectors/source/readbudget.d.ts +1 -0
  46. package/dist/detectors/source/readbudget.js +358 -4
  47. package/dist/detectors/source/recipient-by-id.d.ts +40 -0
  48. package/dist/detectors/source/recipient-by-id.js +1108 -0
  49. package/dist/detectors/source/refusal.d.ts +14 -0
  50. package/dist/detectors/source/refusal.js +75 -2
  51. package/dist/detectors/source/security.d.ts +16 -0
  52. package/dist/detectors/source/security.js +680 -33
  53. package/dist/detectors/source/write-isolation.d.ts +8 -1
  54. package/dist/detectors/source/write-isolation.js +64 -30
  55. package/dist/detectors/source/writesafety.js +27 -14
  56. package/dist/detectors/standard/_doc.d.ts +1 -1
  57. package/dist/detectors/standard/_doc.js +5 -4
  58. package/dist/detectors/standard/r41-frontier.js +3 -2
  59. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  60. package/dist/detectors/standard/r46-returns-validator.js +48 -16
  61. package/dist/detectors/structural.d.ts +6 -7
  62. package/dist/detectors/structural.js +84 -48
  63. package/dist/not-checkable.d.ts +1 -1
  64. package/dist/not-checkable.js +11 -0
  65. package/dist/oracle.d.ts +92 -3
  66. package/dist/oracle.js +229 -12
  67. package/dist/registry.d.ts +8 -1
  68. package/dist/registry.js +74 -8
  69. package/dist/report.d.ts +11 -1
  70. package/dist/report.js +15 -2
  71. package/dist/rule-class.d.ts +85 -0
  72. package/dist/rule-class.js +218 -0
  73. package/dist/state-rank.d.ts +5 -0
  74. package/dist/state-rank.js +9 -0
  75. package/dist/tally.d.ts +13 -5
  76. package/dist/tally.js +15 -13
  77. package/dist/target.d.ts +32 -0
  78. package/dist/target.js +173 -0
  79. package/dist/violation-count.d.ts +48 -0
  80. package/dist/violation-count.js +94 -0
  81. package/fixtures/backend-standard-2825122.md +722 -0
  82. package/fixtures/backend-standard-4dcd3e0.md +722 -0
  83. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  84. package/fixtures/cr1-classes/surface.ts +206 -0
  85. package/fixtures/cr1-empty/nothing.ts +1 -0
  86. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  87. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  88. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  89. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  90. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  91. package/fixtures/rule-classes-2825122.json +228 -0
  92. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  93. package/package.json +2 -2
@@ -0,0 +1,177 @@
1
+ // R-6 — the principal @vantageos/cloud-identity resolved, once the door has
2
+ // refused the unresolved branch, is a VERIFIED-IDENTITY value.
3
+ //
4
+ // `resolveActingPrincipal(credential, lookups, door)` and
5
+ // `resolvePersonPrincipal(…)` return `{ ok: true, principal } | { ok: false,
6
+ // refusal }`: the package resolves the person by ID in the organisation its
7
+ // credential is bound to (standard 2825122 R-53). Their definitions live in the
8
+ // package, never in the scanned tree, so R-6's in-tree resolver judgement
9
+ // (judgeResolverFunction) cannot read them, and a door writing
10
+ // `orgId: who.principal.orgId` read as a laundered caller argument —
11
+ // vantage-peers `startBinding` at sigma/start-binding-by-id ae58c95.
12
+ //
13
+ // What is credited, read with the parser on the handler's DIRECT statements
14
+ // before its first write (a statement that ends at or after the write line is
15
+ // not read):
16
+ // - `const R = await F(…)` where F is the file's own named (or namespace)
17
+ // import of `resolveActingPrincipal` / `resolvePersonPrincipal` from
18
+ // @vantageos/cloud-identity (aliases resolved). `let`/`var`, a call that is
19
+ // not awaited, a local function, or an import from any other module: not
20
+ // credited.
21
+ // - `if (!R.ok) <exit>` (or `R.ok === false`, `R.ok !== true`), no `else`,
22
+ // whose branch ALWAYS leaves the handler: a `throw`/`return` statement, or
23
+ // a block whose last statement is one. A branch that logs and continues, an
24
+ // empty block, or a branch that exits on only one of its paths: not
25
+ // credited.
26
+ // - the principal is then `R.principal` and any member read of it, and an
27
+ // ALIAS bound by `const P = R.principal` or `const { principal[: P] } = R`
28
+ // (no default value), and any member read of the alias.
29
+ // The principal of an R that is not checked, or of a different R than the one
30
+ // checked, is never credited. Nothing here credits `R` itself, `R.refusal…`, or
31
+ // an expression that mixes the principal with anything else
32
+ // (`R.principal.orgId ?? args.orgId`).
33
+ //
34
+ // Declared limits: a refusal inside a nested block of the handler (a branch, a
35
+ // try, a loop) is not read — the door is then judged as before this change; a
36
+ // nested-scope shadow of R or P before the write is not modelled (the text taint
37
+ // read of R-6 still sees the shadowing assignment and keeps it accused).
38
+ import * as ts from "typescript";
39
+ import { dotted, lineOf, unwrap } from "./_ast.js";
40
+ import { handlerBodyOf } from "./guard-helper-flow.js";
41
+ import { importsOf, PACKAGE_RESOLVERS, pkgExportOf, refusesOnNotOk, } from "./identity-by-id.js";
42
+ const NONE = { principalOf: () => undefined };
43
+ /** Does `e` read the handler's `args`, directly or through a `const` it names?
44
+ * A local whose declaration is not among `locals` (a parameter, a closure
45
+ * variable) is not followed; `args` itself is the caller's input. */
46
+ function readsArgs(e, locals, seen) {
47
+ let hit = false;
48
+ const visit = (n) => {
49
+ if (hit)
50
+ return;
51
+ if (ts.isIdentifier(n)) {
52
+ const parent = n.parent;
53
+ const nameOnly = parent &&
54
+ ((ts.isPropertyAccessExpression(parent) && parent.name === n) ||
55
+ (ts.isPropertyAssignment(parent) && parent.name === n));
56
+ if (!nameOnly) {
57
+ if (n.text === "args")
58
+ hit = true;
59
+ else if (!seen.has(n.text) && locals.has(n.text)) {
60
+ seen.add(n.text);
61
+ if (readsArgs(locals.get(n.text), locals, seen))
62
+ hit = true;
63
+ }
64
+ }
65
+ }
66
+ n.forEachChild(visit);
67
+ };
68
+ visit(e);
69
+ return hit;
70
+ }
71
+ /** A statement that leaves the handler on every path. */
72
+ function alwaysExits(st) {
73
+ if (ts.isThrowStatement(st) || ts.isReturnStatement(st))
74
+ return true;
75
+ if (!ts.isBlock(st) || st.statements.length === 0)
76
+ return false;
77
+ const last = st.statements[st.statements.length - 1];
78
+ return ts.isThrowStatement(last) || ts.isReturnStatement(last);
79
+ }
80
+ const endLineOf = (n) => n.getSourceFile().getLineAndCharacterOfPosition(n.getEnd()).line + 1;
81
+ /** Read handler `handlerName` of `p` before `beforeLine` (the first write). */
82
+ export function packagePrincipals(p, handlerName, beforeLine) {
83
+ const { imports } = importsOf(p);
84
+ if (imports.named.size === 0 && imports.namespaces.size === 0)
85
+ return NONE;
86
+ const body = handlerBodyOf(p.sf, handlerName);
87
+ if (!body)
88
+ return NONE;
89
+ const results = new Map();
90
+ const locals = new Map(); // const name -> initializer
91
+ const aliases = new Map(); // alias -> result local
92
+ for (const st of body.statements) {
93
+ if (endLineOf(st) >= beforeLine)
94
+ break;
95
+ if (ts.isVariableStatement(st)) {
96
+ if ((st.declarationList.flags & ts.NodeFlags.Const) === 0)
97
+ continue;
98
+ for (const d of st.declarationList.declarations) {
99
+ if (!d.initializer)
100
+ continue;
101
+ const init = d.initializer;
102
+ if (ts.isIdentifier(d.name)) {
103
+ const awaited = ts.isAwaitExpression(init)
104
+ ? unwrap(init.expression)
105
+ : undefined;
106
+ const exp = awaited && ts.isCallExpression(awaited)
107
+ ? pkgExportOf(awaited.expression, imports)
108
+ : undefined;
109
+ if (exp && PACKAGE_RESOLVERS.has(exp)) {
110
+ // The credential is the identity claim: one the caller's
111
+ // arguments reach is a forged claim, never a verified one.
112
+ const cred = awaited && ts.isCallExpression(awaited) ? awaited.arguments[0] : undefined;
113
+ if (!cred || readsArgs(cred, locals, new Set()))
114
+ continue;
115
+ results.set(d.name.text, { resolver: exp, line: lineOf(st) });
116
+ continue;
117
+ }
118
+ locals.set(d.name.text, init);
119
+ const path = dotted(init);
120
+ const m = path ? /^([A-Za-z_$][\w$]*)\.principal$/.exec(path) : null;
121
+ if (m && results.has(m[1]))
122
+ aliases.set(d.name.text, m[1]);
123
+ continue;
124
+ }
125
+ if (ts.isObjectBindingPattern(d.name)) {
126
+ const from = unwrap(init);
127
+ if (!ts.isIdentifier(from) || !results.has(from.text))
128
+ continue;
129
+ for (const e of d.name.elements) {
130
+ if (e.dotDotDotToken || e.initializer)
131
+ continue;
132
+ if (!ts.isIdentifier(e.name))
133
+ continue;
134
+ const key = e.propertyName ?? e.name;
135
+ if (ts.isIdentifier(key) && key.text === "principal")
136
+ aliases.set(e.name.text, from.text);
137
+ }
138
+ }
139
+ }
140
+ continue;
141
+ }
142
+ if (!ts.isIfStatement(st) || st.elseStatement)
143
+ continue;
144
+ if (!alwaysExits(st.thenStatement))
145
+ continue;
146
+ for (const [local, r] of results)
147
+ if (r.checkLine === undefined && refusesOnNotOk(st.expression, local))
148
+ r.checkLine = lineOf(st);
149
+ }
150
+ const why = (local) => {
151
+ const r = results.get(local);
152
+ if (!r || r.checkLine === undefined)
153
+ return undefined;
154
+ return `the principal @vantageos/cloud-identity ${r.resolver}() resolved into "${local}" (line ${r.line}), refused on !${local}.ok at line ${r.checkLine} before the write`;
155
+ };
156
+ if (![...results.values()].some((r) => r.checkLine !== undefined))
157
+ return NONE;
158
+ return {
159
+ principalOf(expr) {
160
+ const e = expr
161
+ .trim()
162
+ .replace(/;+\s*$/, "")
163
+ .replace(/\s+/g, "")
164
+ .replace(/\?\./g, ".");
165
+ const m = /^([A-Za-z_$][\w$]*)((?:\.[A-Za-z_$][\w$]*)*)$/.exec(e);
166
+ if (!m)
167
+ return undefined;
168
+ const [, head, rest] = m;
169
+ const aliased = aliases.get(head);
170
+ if (aliased !== undefined)
171
+ return why(aliased);
172
+ if (rest === ".principal" || rest.startsWith(".principal."))
173
+ return why(head);
174
+ return undefined;
175
+ },
176
+ };
177
+ }
@@ -0,0 +1,20 @@
1
+ import * as ts from "typescript";
2
+ import { type Registration, type SrcFile } from "./_ast.js";
3
+ /** Judge a function as a master-only wrapper (shape 1). */
4
+ export declare function isMasterOnlyWrapper(fn: ts.FunctionLikeDeclaration): boolean;
5
+ /** Shape 1 at a handler: an awaited top-level call, before the first write, to a
6
+ * FILE-LOCAL master-only wrapper. Returns the credit text or undefined. */
7
+ export declare function masterOnlyWrapperCredit(f: SrcFile, handlerName: string): string | undefined;
8
+ /** Shape 2 at a handler. Returns the credit text or undefined. */
9
+ export declare function possessionGatedCredit(f: SrcFile, handlerName: string): string | undefined;
10
+ export interface PossessionGate {
11
+ rowVar: string;
12
+ table: string;
13
+ field: string;
14
+ hashLine: number;
15
+ rowLine: number;
16
+ writes: number;
17
+ }
18
+ /** The possession gate of a registration, or undefined when the shape is not
19
+ * held in full. */
20
+ export declare function possessionGate(reg: Registration): PossessionGate | undefined;
@@ -0,0 +1,285 @@
1
+ // Two write shapes R-6 used to defer (could-not-judge) although each is readable
2
+ // in full from the handler and the file it sits in. Both are judged here on the
3
+ // AST; neither is credited from a comment marker.
4
+ //
5
+ // 1. MASTER-ONLY FILE-LOCAL WRAPPER. The handler's first statements call a
6
+ // function defined in the SAME file whose body is exactly:
7
+ // const scope = await withOrgScope(ctx); // fail-closed resolver
8
+ // if (!scope.isMaster) throw …; // refuses every non-master
9
+ // [return scope;]
10
+ // Every path through it refuses unless the verified caller is the fleet
11
+ // master, so the write that follows is master-only. A master-only write on a
12
+ // table with no member boundary has no boundary an argument could steer: R-6
13
+ // conforms. ANY other statement in the wrapper (an early return, a second
14
+ // branch, a conjunction in the master test, an `allowNoIdentityMaster: true`
15
+ // option) leaves the shape unrecognised and the site stays where it was.
16
+ //
17
+ // 2. POSSESSION-GATED WRITE. The handler hashes an ARGUMENT
18
+ // (`const h = await sha256Hex(args.key)`), selects ONE row through a unique
19
+ // index on a hash field (`.withIndex(…, q => q.eq("keyHash", h)).unique()`),
20
+ // and every write it performs is a patch/replace/delete of THAT row's `_id` —
21
+ // no insert, no scheduled or run effect, no other row. The caller can only
22
+ // reach a row whose secret it already holds; the write carries no boundary
23
+ // the caller could choose. A handler that hash-matches and then writes ANY
24
+ // other row is not this shape. `@credential` / `allow-no-caller-resolution:`
25
+ // markers above the registration are reported as corroboration only.
26
+ //
27
+ // Origin: VP main 11a7057 — seven requireMasterScope sites (errorMonitor.ts
28
+ // 537/565, githubRepoMapping.ts 303/337, issues.ts 183/210/270) and
29
+ // licenses.ts:140 activate, proven safe by vantage-peers sigma/r6-abstentions
30
+ // @08aa83e convex/__tests__/r6AbstentionsResolved.test.ts (10/10).
31
+ import * as ts from "typescript";
32
+ import { dotted, lineOf, parseFile, registrations, unwrap, walk, } from "./_ast.js";
33
+ const HASH_CALLEE = /sha256|sha512|hash|digest/i;
34
+ /** A resolver whose `isMaster: true` comes only from a verified identity unless
35
+ * the call opts into `allowNoIdentityMaster` (vantage-peers convex/lib/auth.ts
36
+ * `withOrgScope`: the no-identity branch returns a non-master scope). */
37
+ const FAIL_CLOSED_SCOPE = new Set(["withOrgScope"]);
38
+ // Keyed on the file OBJECT: one parse per file per run, and two different files
39
+ // that share a path (a fixture rewritten between runs) never share a parse.
40
+ const cache = new WeakMap();
41
+ function parsedOf(f) {
42
+ let p = cache.get(f);
43
+ if (!p) {
44
+ p = parseFile(f);
45
+ cache.set(f, p);
46
+ }
47
+ return p;
48
+ }
49
+ function refusesNonMasterOn(c0, scopeVar) {
50
+ const c = unwrap(c0);
51
+ if (ts.isPrefixUnaryExpression(c) &&
52
+ c.operator === ts.SyntaxKind.ExclamationToken) {
53
+ const o = unwrap(c.operand);
54
+ return (ts.isPropertyAccessExpression(o) &&
55
+ o.name.text === "isMaster" &&
56
+ dotted(o.expression) === scopeVar);
57
+ }
58
+ return false;
59
+ }
60
+ function throwsOnly(s) {
61
+ if (ts.isThrowStatement(s))
62
+ return true;
63
+ return (ts.isBlock(s) &&
64
+ s.statements.length > 0 &&
65
+ ts.isThrowStatement(s.statements[s.statements.length - 1]) &&
66
+ s.statements.every((x) => ts.isThrowStatement(x) || ts.isVariableStatement(x)));
67
+ }
68
+ /** Judge a function as a master-only wrapper (shape 1). */
69
+ export function isMasterOnlyWrapper(fn) {
70
+ const body = fn.body;
71
+ if (!body || !ts.isBlock(body))
72
+ return false;
73
+ const st = body.statements;
74
+ if (st.length < 2 || st.length > 3)
75
+ return false;
76
+ const s0 = st[0];
77
+ if (!ts.isVariableStatement(s0))
78
+ return false;
79
+ const decls = s0.declarationList.declarations;
80
+ if (decls.length !== 1 || !ts.isIdentifier(decls[0].name))
81
+ return false;
82
+ const scopeVar = decls[0].name.text;
83
+ const init = decls[0].initializer && unwrap(decls[0].initializer);
84
+ if (!init || !ts.isCallExpression(init))
85
+ return false;
86
+ const callee = unwrap(init.expression);
87
+ if (!ts.isIdentifier(callee) || !FAIL_CLOSED_SCOPE.has(callee.text))
88
+ return false;
89
+ if (init.arguments
90
+ .slice(1)
91
+ .some((a) => /allowNoIdentityMaster\s*:\s*(?!false\b)/.test(a.getText())))
92
+ return false;
93
+ const s1 = st[1];
94
+ if (!ts.isIfStatement(s1) ||
95
+ s1.elseStatement !== undefined ||
96
+ !refusesNonMasterOn(s1.expression, scopeVar) ||
97
+ !throwsOnly(s1.thenStatement))
98
+ return false;
99
+ if (st.length === 3) {
100
+ const s2 = st[2];
101
+ if (!ts.isReturnStatement(s2))
102
+ return false;
103
+ if (s2.expression && dotted(s2.expression) !== scopeVar)
104
+ return false;
105
+ }
106
+ return true;
107
+ }
108
+ function registrationNamed(p, name) {
109
+ return registrations(p).find((r) => r.name === name);
110
+ }
111
+ function localFunctions(p) {
112
+ const out = new Map();
113
+ for (const st of p.sf.statements) {
114
+ if (ts.isFunctionDeclaration(st) && st.name && st.body)
115
+ out.set(st.name.text, st);
116
+ else if (ts.isVariableStatement(st))
117
+ for (const d of st.declarationList.declarations) {
118
+ const i = d.initializer && unwrap(d.initializer);
119
+ if (ts.isIdentifier(d.name) &&
120
+ i &&
121
+ (ts.isArrowFunction(i) || ts.isFunctionExpression(i)))
122
+ out.set(d.name.text, i);
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+ const WRITE = /\bdb\.(patch|replace|delete|insert)$/;
128
+ const EFFECT = /\b(scheduler\.(runAfter|runAt)|runMutation|runAction)$/;
129
+ /** Shape 1 at a handler: an awaited top-level call, before the first write, to a
130
+ * FILE-LOCAL master-only wrapper. Returns the credit text or undefined. */
131
+ export function masterOnlyWrapperCredit(f, handlerName) {
132
+ const p = parsedOf(f);
133
+ const reg = registrationNamed(p, handlerName);
134
+ const body = reg?.handler.body;
135
+ if (!reg || !body || !ts.isBlock(body))
136
+ return undefined;
137
+ const locals = localFunctions(p);
138
+ for (const st of body.statements) {
139
+ let firstWrite = false;
140
+ walk(st, (x) => {
141
+ if (ts.isCallExpression(x)) {
142
+ const c = dotted(x.expression) ?? "";
143
+ if (WRITE.test(c) || EFFECT.test(c))
144
+ firstWrite = true;
145
+ }
146
+ });
147
+ if (firstWrite)
148
+ return undefined;
149
+ const e = ts.isExpressionStatement(st)
150
+ ? unwrap(st.expression)
151
+ : ts.isVariableStatement(st) &&
152
+ st.declarationList.declarations.length === 1 &&
153
+ st.declarationList.declarations[0].initializer
154
+ ? unwrap(st.declarationList.declarations[0].initializer)
155
+ : undefined;
156
+ if (!e || !ts.isCallExpression(e))
157
+ continue;
158
+ const callee = unwrap(e.expression);
159
+ if (!ts.isIdentifier(callee))
160
+ continue;
161
+ const fn = locals.get(callee.text);
162
+ if (fn && isMasterOnlyWrapper(fn))
163
+ return `master-only: ${callee.text}() (file-local, ${f.rel}:${lineOf(fn)}) resolves the caller through a fail-closed withOrgScope and refuses every non-master on every path, called at line ${lineOf(st)} before the first write — a master-only write has no member boundary an argument could steer`;
164
+ }
165
+ return undefined;
166
+ }
167
+ /** Shape 2 at a handler. Returns the credit text or undefined. */
168
+ export function possessionGatedCredit(f, handlerName) {
169
+ const p = parsedOf(f);
170
+ const reg = registrationNamed(p, handlerName);
171
+ const body = reg?.handler.body;
172
+ if (!reg || !body)
173
+ return undefined;
174
+ const g = possessionGate(reg);
175
+ if (!g)
176
+ return undefined;
177
+ const markers = [];
178
+ const start = reg.line;
179
+ const lines = p.sf.text.split("\n");
180
+ for (let i = Math.max(0, start - 15); i < start - 1; i++) {
181
+ if (/@credential\b/.test(lines[i]))
182
+ markers.push("@credential");
183
+ if (/allow-no-caller-resolution:/.test(lines[i]))
184
+ markers.push("allow-no-caller-resolution");
185
+ }
186
+ return `possession-gated: the argument is hashed (line ${g.hashLine}) and matched through a unique index on ${g.table}.${g.field} (line ${g.rowLine}); every write (${g.writes}) is that one row's _id — no other row, no insert, no effect${markers.length ? ` (corroborated by marker(s) ${[...new Set(markers)].join(", ")}, never the reason)` : ""}`;
187
+ }
188
+ /** The possession gate of a registration, or undefined when the shape is not
189
+ * held in full. */
190
+ export function possessionGate(reg) {
191
+ const body = reg.handler.body;
192
+ if (!body)
193
+ return undefined;
194
+ const hashVars = new Map();
195
+ const rows = new Map();
196
+ walk(body, (x) => {
197
+ if (!ts.isVariableDeclaration(x) ||
198
+ !ts.isIdentifier(x.name) ||
199
+ !x.initializer)
200
+ return;
201
+ const init = unwrap(x.initializer);
202
+ if (ts.isCallExpression(init)) {
203
+ const c = dotted(init.expression) ?? "";
204
+ if (HASH_CALLEE.test(c.split(".").pop() ?? "") &&
205
+ init.arguments.some((a) => /\bargs\.\w+/.test(a.getText()))) {
206
+ hashVars.set(x.name.text, lineOf(x));
207
+ return;
208
+ }
209
+ // ctx.db.query("T").withIndex(_, q => q.eq("f", h)).unique()
210
+ const outer = unwrap(init.expression);
211
+ if (ts.isPropertyAccessExpression(outer) &&
212
+ outer.name.text === "unique" &&
213
+ init.arguments.length === 0) {
214
+ let table;
215
+ let eq;
216
+ let eqCount = 0;
217
+ walk(init, (y) => {
218
+ if (!ts.isCallExpression(y))
219
+ return;
220
+ const yc = unwrap(y.expression);
221
+ if (!ts.isPropertyAccessExpression(yc))
222
+ return;
223
+ if (yc.name.text === "query" &&
224
+ y.arguments[0] &&
225
+ ts.isStringLiteralLike(y.arguments[0]))
226
+ table = y.arguments[0].text;
227
+ if (yc.name.text === "eq" &&
228
+ y.arguments.length === 2 &&
229
+ ts.isStringLiteralLike(y.arguments[0])) {
230
+ eqCount++;
231
+ eq = {
232
+ field: y.arguments[0].text,
233
+ value: y.arguments[1].getText(),
234
+ };
235
+ }
236
+ });
237
+ if (table && eq && eqCount === 1 && /hash/i.test(eq.field))
238
+ rows.set(x.name.text, {
239
+ table,
240
+ field: `${eq.field}=${eq.value}`,
241
+ line: lineOf(x),
242
+ });
243
+ }
244
+ }
245
+ });
246
+ let gate;
247
+ for (const [rowVar, r] of rows) {
248
+ const [field, value] = r.field.split("=");
249
+ const hashLine = hashVars.get(value);
250
+ if (hashLine === undefined)
251
+ continue;
252
+ gate = {
253
+ rowVar,
254
+ table: r.table,
255
+ field,
256
+ hashLine,
257
+ rowLine: r.line,
258
+ writes: 0,
259
+ };
260
+ }
261
+ if (!gate)
262
+ return undefined;
263
+ let ok = true;
264
+ let writes = 0;
265
+ walk(body, (x) => {
266
+ if (!ts.isCallExpression(x))
267
+ return;
268
+ const c = dotted(x.expression) ?? "";
269
+ if (EFFECT.test(c))
270
+ ok = false;
271
+ const m = WRITE.exec(c);
272
+ if (!m)
273
+ return;
274
+ writes++;
275
+ if (m[1] === "insert")
276
+ ok = false;
277
+ else if (!x.arguments[0] ||
278
+ dotted(x.arguments[0]) !== `${gate?.rowVar}._id`)
279
+ ok = false;
280
+ });
281
+ if (!ok || writes === 0)
282
+ return undefined;
283
+ gate.writes = writes;
284
+ return gate;
285
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Detector } from "../../engine-types.js";
2
2
  import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
3
+ export declare const READ_BOUND_TOKEN: RegExp;
3
4
  export declare function scanR31(files: SrcFile[]): Scan;
4
5
  export declare const R31: Detector;
5
6
  /** In the form the orchestrator spreads into REGISTRY. */