@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,1783 @@
1
+ // R-53 — an actor and a target are identified by ID, resolved through the shared
2
+ // identity package; a name never selects or authorises a row.
3
+ //
4
+ // Standard 2825122 §4 (R-53) and its Detector spec, read literally:
5
+ // reads every public mutation/action handler and every function it calls;
6
+ // every gate in mcp-server/src; the import graph to
7
+ // `@vantageos/cloud-identity`; per write, the predicate that admitted
8
+ // the caller to the target.
9
+ // red (a) a principal ID or organisation taken from anything other than the
10
+ // package's resolver output;
11
+ // (b) a write whose admission compares a name field (assignedTo,
12
+ // createdBy, callerOrchestrator, name, …) with a typed or
13
+ // credential-carried name, or has no predicate on the target at all;
14
+ // (c) a branch that continues when no principal / no organisation is
15
+ // resolved — as master, as the service account, or on a name match;
16
+ // (d) a function outside the package that decides identity: compares
17
+ // names to authenticate, or re-implements the resolution.
18
+ // green principal from the package; every write admitted by comparing stored
19
+ // IDs with the resolved IDs; the unresolved branch refuses.
20
+ // cnj only an undelimitable predicate or an unresolvable package import.
21
+ // No justification-token exit. Coverage fails on itself.
22
+ //
23
+ // SITES. One site per public write door (convex), naming every clause it fails;
24
+ // one per function that decides identity locally (d); one per branch that
25
+ // continues on an unresolved principal or organisation (c), in convex/ and in
26
+ // mcp-server/src (where the gate that forwards an org only when it has one lets
27
+ // an org-less bearer through — the PR #1464 `bulk_complete_tasks` path).
28
+ import * as ts from "typescript";
29
+ import { closeOverCalls, dotted, indexFunctions, lineOf, parseFile, propReadResolved, registrations, unwrap, walk, } from "./_ast.js";
30
+ import { decide, emptyScan, isConvexOrMcp, isJudgeable, SOURCE_STANDARD_PIN, } from "./_scan.js";
31
+ import { recipientByNameSites } from "./recipient-by-id.js";
32
+ export const IDENTITY_PACKAGE = "@vantageos/cloud-identity";
33
+ export const R53_ID = "R-53 an actor and a target are identified by ID through @vantageos/cloud-identity; a name never selects or authorises a row";
34
+ const R53_CHECK = "the principal is the package resolver's output, every write admits by comparing stored IDs with the resolved IDs, the unresolved branch refuses, and no local function decides identity";
35
+ /** Name fields: a display label that must never be an admission operand. */
36
+ export const NAME_TOKENS = new Set([
37
+ "assignedTo",
38
+ "createdBy",
39
+ "lastAssignedTo",
40
+ "callerOrchestrator",
41
+ "orchestrator",
42
+ "orchestratorId",
43
+ "assertedName",
44
+ "agentName",
45
+ "name",
46
+ ]);
47
+ /** Stored-ID fields a target is admitted by. */
48
+ const ID_FIELDS = new Set([
49
+ "orgId",
50
+ "ownerId",
51
+ "userId",
52
+ "tenantId",
53
+ "principalId",
54
+ "agentId",
55
+ ]);
56
+ const WRITE = /\bdb\.(patch|replace|delete|insert)$/;
57
+ const TARGET_WRITE = /\bdb\.(patch|replace|delete)$/;
58
+ /** The org mapping read by the product itself: resolution, whatever feeds it. */
59
+ const ORG_MAPPING_READ = /\.query\(\s*["'`]client_org_mapping["'`]\s*\)/;
60
+ const HOST_READ = /\bgetUserIdentity$/;
61
+ /** The package's principal resolvers: the ONLY sinks a host credential read
62
+ * may feed (first argument). The package verifies nothing about the credential
63
+ * (cloud-identity 0.11.0 `principal-by-id.d.ts:37`, `person-principal.d.ts:28`),
64
+ * so the host MUST read `ctx.auth` and hand the claims over; the read is a local
65
+ * identity decision only when its value goes anywhere else. */
66
+ export const PACKAGE_RESOLVERS = new Set([
67
+ "resolveActingPrincipal",
68
+ "resolvePersonPrincipal",
69
+ ]);
70
+ function isLiteral(e) {
71
+ const u = unwrap(e);
72
+ return (ts.isStringLiteralLike(u) ||
73
+ ts.isNumericLiteral(u) ||
74
+ u.kind === ts.SyntaxKind.NullKeyword ||
75
+ u.kind === ts.SyntaxKind.TrueKeyword ||
76
+ u.kind === ts.SyntaxKind.FalseKeyword ||
77
+ (ts.isIdentifier(u) && u.text === "undefined"));
78
+ }
79
+ /** A slug is a label, never an ID: `slug`, `orgSlug`, `clerkOrgSlug`,
80
+ * `org_slug`: a trailing "slug" at the start of the name or after a lowercase
81
+ * letter or an underscore (camelCase, snake_case and the bare word alike). */
82
+ function isSlugName(s) {
83
+ return /(^|[a-z_])slug$/i.test(s);
84
+ }
85
+ /** Does an operand carry a name (a name field read, a name identifier)? A field
86
+ * is read as `x.f`, as `x["f"]`, or through ONE const alias of either
87
+ * (`const owner = x.f`), so spelling the read differently dodges nothing. */
88
+ function mentionsName(e) {
89
+ let hit = false;
90
+ walk(e, (x) => {
91
+ const r = ts.isExpression(x) ? propReadResolved(x) : undefined;
92
+ if (r &&
93
+ (NAME_TOKENS.has(r.name) || isSlugName(r.name)) &&
94
+ !isPropertyNamePart(x))
95
+ hit = true;
96
+ else if (ts.isIdentifier(x) &&
97
+ (NAME_TOKENS.has(x.text) || isSlugName(x.text)) &&
98
+ !(ts.isPropertyAccessExpression(x.parent) && x.parent.name === x))
99
+ hit = true;
100
+ });
101
+ return hit;
102
+ }
103
+ /** The `name` of `a.name`: not itself a read of a binding. */
104
+ function isPropertyNamePart(x) {
105
+ return (ts.isIdentifier(x) &&
106
+ ts.isPropertyAccessExpression(x.parent) &&
107
+ x.parent.name === x);
108
+ }
109
+ function mentionsIdField(e) {
110
+ let hit = false;
111
+ walk(e, (x) => {
112
+ const r = ts.isExpression(x) ? propReadResolved(x) : undefined;
113
+ if (r && ID_FIELDS.has(r.name) && !isPropertyNamePart(x))
114
+ hit = true;
115
+ });
116
+ return hit;
117
+ }
118
+ const EQ = new Set([
119
+ ts.SyntaxKind.EqualsEqualsEqualsToken,
120
+ ts.SyntaxKind.ExclamationEqualsEqualsToken,
121
+ ts.SyntaxKind.EqualsEqualsToken,
122
+ ts.SyntaxKind.ExclamationEqualsToken,
123
+ ]);
124
+ /** Name comparisons in a node's OWN body: `a === b` with a name on one side
125
+ * and no literal on either, or `names.includes(x)` over a name list. */
126
+ export function nameComparisons(n) {
127
+ const out = [];
128
+ walk(n, (x) => {
129
+ if (ts.isBinaryExpression(x) && EQ.has(x.operatorToken.kind)) {
130
+ if (isLiteral(x.left) || isLiteral(x.right))
131
+ return;
132
+ // exempt only when BOTH operands are ID fields: a name (or a slug)
133
+ // on either side is a name comparison, whatever the other side is.
134
+ if (mentionsIdField(x.left) && mentionsIdField(x.right))
135
+ return;
136
+ if (mentionsName(x.left) || mentionsName(x.right))
137
+ out.push(x);
138
+ }
139
+ // `switch (a) { case b: }` is the equality `a === b`.
140
+ if (ts.isCaseClause(x) && ts.isSwitchStatement(x.parent.parent)) {
141
+ const subject = x.parent.parent.expression;
142
+ if (isLiteral(subject) || isLiteral(x.expression))
143
+ return;
144
+ if (mentionsIdField(subject) && mentionsIdField(x.expression))
145
+ return;
146
+ if (mentionsName(subject) || mentionsName(x.expression))
147
+ out.push(x);
148
+ }
149
+ if (ts.isCallExpression(x) &&
150
+ ts.isPropertyAccessExpression(x.expression) &&
151
+ x.expression.name.text === "includes" &&
152
+ /allowedOrchestrators|\broster\b|allowList\b|fromAllowList/i.test(x.expression.expression.getText()) &&
153
+ x.arguments[0] &&
154
+ !isLiteral(x.arguments[0]))
155
+ out.push(x);
156
+ });
157
+ return out;
158
+ }
159
+ /** ID comparisons: a stored ID field compared with something not a literal. */
160
+ function idComparisons(n) {
161
+ const out = [];
162
+ walk(n, (x) => {
163
+ if (ts.isBinaryExpression(x) &&
164
+ EQ.has(x.operatorToken.kind) &&
165
+ !isLiteral(x.left) &&
166
+ !isLiteral(x.right) &&
167
+ mentionsIdField(x.left) &&
168
+ mentionsIdField(x.right))
169
+ out.push(x);
170
+ });
171
+ return out;
172
+ }
173
+ /** Does `node` sit in a condition that refuses (throw / return) on its branch? */
174
+ function admits(cmp) {
175
+ for (let x = cmp; x; x = x.parent) {
176
+ if (ts.isIfStatement(x))
177
+ return true;
178
+ if (ts.isReturnStatement(x))
179
+ return true;
180
+ if (ts.isConditionalExpression(x))
181
+ return true;
182
+ if (ts.isCaseClause(x))
183
+ return true;
184
+ // a callback handed to a call (`list.some((e) => e === name)`) is part
185
+ // of the enclosing predicate; any other function boundary stops.
186
+ if (ts.isFunctionLike(x) && !(x.parent && ts.isCallExpression(x.parent)))
187
+ return false;
188
+ }
189
+ return false;
190
+ }
191
+ /** The package's own target check (cloud-identity `principal-by-id`):
192
+ * `assertTargetBelongsTo(principal, { orgId, ownerId? }, opts?)` at a6e5780,
193
+ * `await assertTargetBelongsTo(principal, target, lookups, opts?)` at PR #21. */
194
+ export const TARGET_CHECK = "assertTargetBelongsTo";
195
+ export function importsOf(p) {
196
+ const pkg = new Set();
197
+ const imports = { named: new Map(), namespaces: new Set() };
198
+ let unresolved = false;
199
+ for (const st of p.sf.statements) {
200
+ if (!ts.isImportDeclaration(st))
201
+ continue;
202
+ const from = st.moduleSpecifier.text;
203
+ if (from !== IDENTITY_PACKAGE)
204
+ continue;
205
+ const b = st.importClause?.namedBindings;
206
+ if (b && ts.isNamedImports(b))
207
+ for (const e of b.elements) {
208
+ pkg.add(e.name.text);
209
+ imports.named.set(e.name.text, (e.propertyName ?? e.name).text);
210
+ }
211
+ else if (b && ts.isNamespaceImport(b)) {
212
+ pkg.add(b.name.text);
213
+ imports.namespaces.add(b.name.text);
214
+ }
215
+ else if (st.importClause?.name)
216
+ pkg.add(st.importClause.name.text);
217
+ else
218
+ unresolved = true;
219
+ }
220
+ return { pkg, imports, unresolved };
221
+ }
222
+ /** The package export a callee names, through the file's own import of it
223
+ * (`assertTargetBelongsTo`, `x as y`, `identity.assertTargetBelongsTo`). */
224
+ export function pkgExportOf(callee, imp) {
225
+ const e = unwrap(callee);
226
+ if (ts.isIdentifier(e))
227
+ return imp.named.get(e.text);
228
+ if (ts.isPropertyAccessExpression(e)) {
229
+ const head = unwrap(e.expression);
230
+ if (ts.isIdentifier(head) && imp.namespaces.has(head.text))
231
+ return e.name.text;
232
+ }
233
+ return undefined;
234
+ }
235
+ /** `!v.ok`, `v.ok === false`, `v.ok !== true` (either operand order). */
236
+ export function refusesOnNotOk(c0, v) {
237
+ const c = unwrap(c0);
238
+ if (ts.isPrefixUnaryExpression(c) &&
239
+ c.operator === ts.SyntaxKind.ExclamationToken)
240
+ return dotted(c.operand) === `${v}.ok`;
241
+ if (!ts.isBinaryExpression(c))
242
+ return false;
243
+ const k = c.operatorToken.kind;
244
+ const pair = (a, b) => {
245
+ if (dotted(a) !== `${v}.ok`)
246
+ return false;
247
+ const lit = unwrap(b).kind;
248
+ return (((k === ts.SyntaxKind.EqualsEqualsEqualsToken ||
249
+ k === ts.SyntaxKind.EqualsEqualsToken) &&
250
+ lit === ts.SyntaxKind.FalseKeyword) ||
251
+ ((k === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
252
+ k === ts.SyntaxKind.ExclamationEqualsToken) &&
253
+ lit === ts.SyntaxKind.TrueKeyword));
254
+ };
255
+ return pair(c.left, c.right) || pair(c.right, c.left);
256
+ }
257
+ /** The branch leaves the handler: a throw or a return of its own. */
258
+ function exits(n) {
259
+ let hit = false;
260
+ walk(n, (y) => {
261
+ if (ts.isThrowStatement(y) || ts.isReturnStatement(y))
262
+ hit = true;
263
+ }, true);
264
+ return hit;
265
+ }
266
+ /** Does `stmt` precede `node` in a block enclosing `node`, up to `stop`? */
267
+ function precedesInEnclosingBlock(stmt, node, stop) {
268
+ let child = node;
269
+ for (let q = node.parent; q && q !== stop.parent; child = q, q = q.parent) {
270
+ if (ts.isBlock(q) || ts.isSourceFile(q)) {
271
+ const i = q.statements.indexOf(stmt);
272
+ const j = q.statements.indexOf(child);
273
+ if (i >= 0 && j >= 0)
274
+ return i < j;
275
+ }
276
+ if (ts.isFunctionLike(q) && q !== stop)
277
+ return false;
278
+ }
279
+ return false;
280
+ }
281
+ /** The writes of `body` that a package target check admits (standard 2825122
282
+ * R-53 green: "the target row is checked to belong to the resolved principal or
283
+ * its organisation by comparing stored IDs"): a local bound to
284
+ * `[await] assertTargetBelongsTo(<principal>, { orgId: R.orgId[, ownerId: R.ownerId] }, …)`
285
+ * resolved through the file's import of the package; R a row read by
286
+ * `db.get(K)`; an `if (!may.ok) throw|return` preceding the write in a block
287
+ * that encloses it; the write addressing `K` or `R._id`. A call whose result is
288
+ * ignored, or whose refusal does not dominate, admits nothing. */
289
+ function packageAdmittedWrites(body, imp, principalRoots) {
290
+ const out = new Set();
291
+ const locals = new Map();
292
+ walk(body, (x) => {
293
+ if (ts.isVariableDeclaration(x) && ts.isIdentifier(x.name))
294
+ locals.set(x.name.text, x);
295
+ }, true);
296
+ const writes = [];
297
+ walk(body, (x) => {
298
+ if (ts.isCallExpression(x) &&
299
+ TARGET_WRITE.test(dotted(x.expression) ?? ""))
300
+ writes.push(x);
301
+ }, true);
302
+ for (const [v, decl] of locals) {
303
+ const init = decl.initializer && unwrap(decl.initializer);
304
+ if (!init || !ts.isCallExpression(init))
305
+ continue;
306
+ if (pkgExportOf(init.expression, imp) !== TARGET_CHECK)
307
+ continue;
308
+ const [a0, a1] = init.arguments;
309
+ if (!a0 || !a1)
310
+ continue;
311
+ const pRoot = (dotted(a0) ?? "").split(".")[0];
312
+ if (!principalRoots.has(pRoot))
313
+ continue;
314
+ const t = unwrap(a1);
315
+ if (!ts.isObjectLiteralExpression(t))
316
+ continue;
317
+ const fields = new Map();
318
+ for (const pr of t.properties)
319
+ if (ts.isPropertyAssignment(pr) && ts.isIdentifier(pr.name))
320
+ fields.set(pr.name.text, pr.initializer);
321
+ else if (ts.isShorthandPropertyAssignment(pr))
322
+ fields.set(pr.name.text, pr.name);
323
+ const rowOf = (field) => {
324
+ const e = fields.get(field);
325
+ if (!e)
326
+ return undefined;
327
+ const r = propReadResolved(e);
328
+ if (!r || r.name !== field || !ts.isIdentifier(unwrap(r.obj)))
329
+ return undefined;
330
+ return unwrap(r.obj).text;
331
+ };
332
+ const row = rowOf("orgId");
333
+ if (!row || principalRoots.has(row))
334
+ continue;
335
+ if (fields.has("ownerId") && rowOf("ownerId") !== row)
336
+ continue;
337
+ // the row and the key that selected it
338
+ const keys = new Set([`${row}._id`]);
339
+ const rowInit = locals.get(row)?.initializer;
340
+ const ri = rowInit && unwrap(rowInit);
341
+ if (ri &&
342
+ ts.isCallExpression(ri) &&
343
+ /\bdb\.get$/.test(dotted(ri.expression) ?? "") &&
344
+ ri.arguments[0]) {
345
+ const k = dotted(ri.arguments[ri.arguments.length - 1]);
346
+ if (k)
347
+ keys.add(k);
348
+ }
349
+ // the refusal on !ok, throwing or returning
350
+ const refusals = [];
351
+ walk(body, (x) => {
352
+ if (ts.isIfStatement(x) &&
353
+ refusesOnNotOk(x.expression, v) &&
354
+ exits(x.thenStatement))
355
+ refusals.push(x);
356
+ }, true);
357
+ for (const w of writes) {
358
+ const sel = w.arguments[0] && dotted(w.arguments[0]);
359
+ if (!sel || !keys.has(sel))
360
+ continue;
361
+ if (refusals.some((r) => precedesInEnclosingBlock(r, w, body)))
362
+ out.add(w);
363
+ }
364
+ }
365
+ return out;
366
+ }
367
+ function callsAny(n, names) {
368
+ let hit = false;
369
+ walk(n, (x) => {
370
+ if (!ts.isCallExpression(x))
371
+ return;
372
+ const head = (dotted(x.expression) ?? "").split(".")[0];
373
+ if (names.has(head))
374
+ hit = true;
375
+ });
376
+ return hit;
377
+ }
378
+ // ---- the host credential read, followed by value -------------------------
379
+ //
380
+ // A `getUserIdentity()` read is CONFORMANT when every path its value takes ends
381
+ // as the first argument of a package resolver (PACKAGE_RESOLVERS, resolved
382
+ // through the calling file's own import of the package). The value is followed
383
+ // as R-6 follows a boundary — through locals, renames, destructuring, a field
384
+ // read, an object/array literal that carries it, a helper's parameter, and a
385
+ // helper's return to EVERY one of its call sites — never by name. The only
386
+ // condition on the raw value admitted is an ABSENCE test inside a helper whose
387
+ // branch is `return null|undefined` (absence forwarded to the package, which
388
+ // refuses it); any other use — a comparison, any other branch, an index key, a
389
+ // non-resolver call, a door returning it — is a local identity decision.
390
+ /** `!x`, `x === null|undefined` (either order), with `x` exactly `ref`. */
391
+ function absenceTestOf(ref) {
392
+ let x = ref;
393
+ while (x.parent &&
394
+ (ts.isParenthesizedExpression(x.parent) || ts.isNonNullExpression(x.parent)))
395
+ x = x.parent;
396
+ const par = x.parent;
397
+ if (par &&
398
+ ts.isPrefixUnaryExpression(par) &&
399
+ par.operator === ts.SyntaxKind.ExclamationToken)
400
+ return par;
401
+ if (par &&
402
+ ts.isBinaryExpression(par) &&
403
+ (par.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
404
+ par.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken)) {
405
+ const other = par.left === x ? par.right : par.left;
406
+ if (isLiteral(other) && /^(null|undefined)$/.test(unwrap(other).getText()))
407
+ return par;
408
+ }
409
+ return undefined;
410
+ }
411
+ /** `return null`, `return undefined`, `return;` — alone, or alone in a block. */
412
+ function forwardsAbsence(s) {
413
+ const one = ts.isBlock(s) && s.statements.length === 1 ? s.statements[0] : s;
414
+ if (!ts.isReturnStatement(one))
415
+ return false;
416
+ if (!one.expression)
417
+ return true;
418
+ const e = unwrap(one.expression);
419
+ return (e.kind === ts.SyntaxKind.NullKeyword ||
420
+ (ts.isIdentifier(e) && e.text === "undefined"));
421
+ }
422
+ /** A Convex registration's own `handler` (a door, never a helper). */
423
+ function isHandler(fn) {
424
+ return (((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
425
+ !!fn.parent &&
426
+ ts.isPropertyAssignment(fn.parent) &&
427
+ ts.isIdentifier(fn.parent.name) &&
428
+ fn.parent.name.text === "handler") ||
429
+ (ts.isMethodDeclaration(fn) &&
430
+ ts.isIdentifier(fn.name) &&
431
+ fn.name.text === "handler"));
432
+ }
433
+ function enclosingFn(n) {
434
+ for (let q = n.parent; q; q = q.parent)
435
+ if (ts.isFunctionLike(q))
436
+ return q;
437
+ return undefined;
438
+ }
439
+ /** An identifier in a position that is not a value read of a binding. */
440
+ function isNameOnly(id) {
441
+ const p = id.parent;
442
+ return ((ts.isPropertyAccessExpression(p) && p.name === id) ||
443
+ (ts.isPropertyAssignment(p) && p.name === id) ||
444
+ (ts.isBindingElement(p) && p.propertyName === id) ||
445
+ ((ts.isVariableDeclaration(p) ||
446
+ ts.isFunctionDeclaration(p) ||
447
+ ts.isParameter(p) ||
448
+ ts.isBindingElement(p)) &&
449
+ p.name === id) ||
450
+ ts.isImportSpecifier(p) ||
451
+ ts.isExportSpecifier(p) ||
452
+ ts.isImportClause(p) ||
453
+ ts.isNamespaceImport(p) ||
454
+ (ts.isMethodDeclaration(p) && p.name === id) ||
455
+ ts.isTypeReferenceNode(p) ||
456
+ ts.isQualifiedName(p));
457
+ }
458
+ /** The binding `name` is declared from, within `scope`: the initializer of a
459
+ * plain declaration, or the source of a destructure that binds it. */
460
+ function declaredFrom(name, scope) {
461
+ let found;
462
+ const binds = (b) => ts.isIdentifier(b)
463
+ ? b.text === name
464
+ : b.elements.some((el) => !ts.isOmittedExpression(el) && binds(el.name));
465
+ walk(scope, (n) => {
466
+ if (found === undefined &&
467
+ ts.isVariableDeclaration(n) &&
468
+ n.initializer !== undefined &&
469
+ binds(n.name))
470
+ found = n.initializer;
471
+ });
472
+ return found;
473
+ }
474
+ /** The value a member is read from: the expression under property and
475
+ * element accesses and casts, followed back through local bindings and
476
+ * destructures (`const { subject: sub } = id` -> `id`; `const SVC = "x"` ->
477
+ * `"x"`). Parameters and anything without a local declaration stay as is. */
478
+ function readRoot(e, scope, depth = 0) {
479
+ let x = unwrap(e);
480
+ while (ts.isPropertyAccessExpression(x) || ts.isElementAccessExpression(x))
481
+ x = unwrap(x.expression);
482
+ if (ts.isIdentifier(x) && depth < 8) {
483
+ const from = declaredFrom(x.text, scope);
484
+ if (from !== undefined)
485
+ return readRoot(from, scope, depth + 1);
486
+ }
487
+ return x.getText();
488
+ }
489
+ function isKindMember(m) {
490
+ return (m.name !== undefined &&
491
+ (ts.isIdentifier(m.name) || ts.isStringLiteral(m.name)) &&
492
+ m.name.text === "kind");
493
+ }
494
+ /** The value one member carries, or undefined for a method or accessor. */
495
+ function memberValue(m) {
496
+ if (ts.isPropertyAssignment(m))
497
+ return m.initializer;
498
+ if (ts.isShorthandPropertyAssignment(m))
499
+ return m.name;
500
+ if (ts.isSpreadAssignment(m))
501
+ return m.expression;
502
+ return undefined;
503
+ }
504
+ /** A credential object carrying the read must carry NOTHING ELSE: every
505
+ * member other than the `kind` discriminator is the read, a property of it,
506
+ * or a spread of it. A literal, a binding to a literal, a boolean authority,
507
+ * or any value from another source is a credential the package never saw
508
+ * verified. Inverted from a literal test, which a `const` binding defeats. */
509
+ function carriesOnlyTheRead(obj, carrier) {
510
+ const own = memberValue(carrier);
511
+ if (own === undefined)
512
+ return false;
513
+ const scope = enclosingFn(obj) ?? obj.getSourceFile();
514
+ const root = readRoot(own, scope);
515
+ return obj.properties.every((m) => {
516
+ if (m === carrier || isKindMember(m))
517
+ return true;
518
+ const v = memberValue(m);
519
+ return v !== undefined && readRoot(v, scope) === root;
520
+ });
521
+ }
522
+ export class HostReadFlow {
523
+ parsed;
524
+ fns;
525
+ pkgImports;
526
+ defOf = new Map();
527
+ memo = new Map();
528
+ constructor(parsed, fns, pkgImports) {
529
+ this.parsed = parsed;
530
+ this.fns = fns;
531
+ this.pkgImports = pkgImports;
532
+ for (const ds of fns.values())
533
+ for (const d of ds)
534
+ this.defOf.set(d.fn, d);
535
+ }
536
+ /** Every `getUserIdentity()` call in `n` whose value does NOT end only in a
537
+ * package resolver. */
538
+ unsanctioned(n) {
539
+ const out = [];
540
+ walk(n, (x) => {
541
+ if (ts.isCallExpression(x) &&
542
+ HOST_READ.test(dotted(x.expression) ?? "") &&
543
+ !this.sanctioned(x))
544
+ out.push(x);
545
+ });
546
+ return out;
547
+ }
548
+ sanctioned(read) {
549
+ const m = this.memo.get(read);
550
+ if (m !== undefined)
551
+ return m;
552
+ const v = this.flows(read, new Set());
553
+ this.memo.set(read, v);
554
+ return v;
555
+ }
556
+ /** Does the value of expression `e` reach ONLY package resolvers? */
557
+ flows(e, seen) {
558
+ let x = e;
559
+ for (;;) {
560
+ const par = x.parent;
561
+ if (!par)
562
+ return false;
563
+ if (ts.isAwaitExpression(par) ||
564
+ ts.isParenthesizedExpression(par) ||
565
+ ts.isNonNullExpression(par) ||
566
+ ts.isAsExpression(par) ||
567
+ ts.isSatisfiesExpression(par) ||
568
+ ts.isTypeAssertionExpression(par) ||
569
+ (ts.isPropertyAccessExpression(par) && par.expression === x) ||
570
+ ts.isArrayLiteralExpression(par) ||
571
+ (ts.isConditionalExpression(par) && par.condition !== x) ||
572
+ (ts.isBinaryExpression(par) &&
573
+ par.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken &&
574
+ par.left === x &&
575
+ // only absence may stand in for an absent credential: `?? null`.
576
+ // `?? "svc_x"` substitutes a typed identity the package never verified.
577
+ isLiteral(par.right) &&
578
+ /^(null|undefined)$/.test(unwrap(par.right).getText()))) {
579
+ x = par;
580
+ continue;
581
+ }
582
+ if ((ts.isPropertyAssignment(par) && par.initializer === x) ||
583
+ ts.isShorthandPropertyAssignment(par) ||
584
+ ts.isSpreadAssignment(par) ||
585
+ ts.isSpreadElement(par)) {
586
+ // A credential object that carries the read must carry nothing
587
+ // else (see carriesOnlyTheRead).
588
+ const obj = par.parent;
589
+ if (ts.isObjectLiteralExpression(obj) &&
590
+ !ts.isSpreadElement(par) &&
591
+ !carriesOnlyTheRead(obj, par))
592
+ return false;
593
+ x = obj;
594
+ continue;
595
+ }
596
+ break;
597
+ }
598
+ const par = x.parent;
599
+ if (!par)
600
+ return false;
601
+ if (ts.isVariableDeclaration(par) && par.initializer === x) {
602
+ const scope = enclosingFn(par) ?? par.getSourceFile();
603
+ return this.bindingFlows(par.name, scope, seen);
604
+ }
605
+ if (ts.isReturnStatement(par)) {
606
+ const fn = enclosingFn(par);
607
+ return fn !== undefined && this.returnFlows(fn, seen);
608
+ }
609
+ if (ts.isArrowFunction(par) && par.body === x)
610
+ return this.returnFlows(par, seen);
611
+ if (ts.isCallExpression(par) && par.expression !== x) {
612
+ const i = par.arguments.indexOf(x);
613
+ if (i < 0)
614
+ return false;
615
+ const rel = par.getSourceFile().fileName;
616
+ const imp = this.pkgImports.get(rel);
617
+ const exp = imp && pkgExportOf(par.expression, imp);
618
+ if (exp !== undefined)
619
+ return i === 0 && PACKAGE_RESOLVERS.has(exp);
620
+ const callee = unwrap(par.expression);
621
+ if (!ts.isIdentifier(callee))
622
+ return false;
623
+ const defs = this.fns.get(callee.text);
624
+ if (!defs?.length)
625
+ return false;
626
+ return defs.every((d) => {
627
+ const prm = d.fn.parameters[i];
628
+ if (!prm || prm.dotDotDotToken)
629
+ return false;
630
+ const key = `param:${d.rel}:${d.line}:${i}`;
631
+ if (seen.has(key))
632
+ return true;
633
+ return this.bindingFlows(prm.name, d.fn, new Set([...seen, key]));
634
+ });
635
+ }
636
+ return false;
637
+ }
638
+ bindingFlows(name, scope, seen) {
639
+ if (ts.isObjectBindingPattern(name))
640
+ return name.elements.every((el) => this.bindingFlows(el.name, scope, seen));
641
+ if (!ts.isIdentifier(name))
642
+ return false;
643
+ const refs = [];
644
+ walk(scope, (y) => {
645
+ if (ts.isIdentifier(y) && y !== name && y.text === name.text)
646
+ if (!isNameOnly(y) || ts.isShorthandPropertyAssignment(y.parent))
647
+ refs.push(y);
648
+ });
649
+ // The value must actually REACH something: a binding read only by absence
650
+ // tests forwards nothing to the package, and whatever credential the helper
651
+ // returns instead (a literal account) is a local identity decision.
652
+ if (!refs.some((r) => absenceTestOf(r) === undefined))
653
+ return false;
654
+ return refs.every((r) => this.refFlows(r, seen));
655
+ }
656
+ /** One read of a bound value: an absence test forwarded to the package, or
657
+ * a value that flows on. */
658
+ refFlows(ref, seen) {
659
+ const test = absenceTestOf(ref);
660
+ if (test) {
661
+ let c = test;
662
+ while (c.parent && ts.isParenthesizedExpression(c.parent))
663
+ c = c.parent;
664
+ const host = c.parent;
665
+ if (host &&
666
+ ts.isIfStatement(host) &&
667
+ host.expression === c &&
668
+ !host.elseStatement &&
669
+ forwardsAbsence(host.thenStatement)) {
670
+ const fn = enclosingFn(host);
671
+ return fn !== undefined && !isHandler(fn);
672
+ }
673
+ // `id === null ? null : { …id… }` — absence forwarded inside the value
674
+ if (host &&
675
+ ts.isConditionalExpression(host) &&
676
+ host.condition === c &&
677
+ [host.whenTrue, host.whenFalse].some((b) => unwrap(b).kind === ts.SyntaxKind.NullKeyword))
678
+ return this.flows(host, seen);
679
+ return false;
680
+ }
681
+ return this.flows(ref, seen);
682
+ }
683
+ /** A function's return reaches only package resolvers, at EVERY call site. */
684
+ returnFlows(fn, seen) {
685
+ if (isHandler(fn))
686
+ return false;
687
+ const d = this.defOf.get(fn);
688
+ if (!d)
689
+ return false;
690
+ const key = `ret:${d.rel}:${d.line}`;
691
+ if (seen.has(key))
692
+ return true;
693
+ const next = new Set([...seen, key]);
694
+ const sites = [];
695
+ for (const p of this.parsed)
696
+ walk(p.sf, (y) => {
697
+ if (ts.isIdentifier(y) && y.text === d.name && !isNameOnly(y))
698
+ sites.push(y);
699
+ });
700
+ if (sites.length === 0)
701
+ return false;
702
+ return sites.every((s) => {
703
+ let c = s;
704
+ while (c.parent && ts.isParenthesizedExpression(c.parent))
705
+ c = c.parent;
706
+ return (!!c.parent &&
707
+ ts.isCallExpression(c.parent) &&
708
+ c.parent.expression === c &&
709
+ this.flows(c.parent, next));
710
+ });
711
+ }
712
+ }
713
+ // ---- the org-kind read, by Clerk org ID ------------------------------------
714
+ //
715
+ // Ruling (task k1742q0vm05x2f5pqawnz1eah18fp01p): "a client_org_mapping read is
716
+ // conformant ONLY when it is keyed by the Clerk org ID (org_…) and only feeds
717
+ // orgKindOf / assertTargetBelongsTo; a read keyed by slug or name stays a
718
+ // violation." cloud-identity 0.11.0 asks the host for `lookups.orgKindOf(orgId)`
719
+ // and the host answers it from its own org table, so ONE shape of that read is
720
+ // not a local identity decision. A `.query("client_org_mapping")` call is
721
+ // conformant only when ALL of these hold; every other reach of the table keeps
722
+ // the pre-ruling verdict, byte for byte:
723
+ //
724
+ // (1) KEYED BY ID. The chain is exactly one key constraint —
725
+ // `.withIndex("by_clerk_org_id", (q) => q.eq("clerkOrgId", K))` or
726
+ // `.filter((q) => q.eq(q.field("clerkOrgId"), K))` — then `.unique()` or
727
+ // `.first()`. Any other index (by_clerk_slug), any other field, a second
728
+ // filter, or any other method is not this read.
729
+ // (2) THE KEY IS THE ID THE PACKAGE HANDED OVER. How the detector decides it:
730
+ // K is, after unwrapping, an identifier naming a PARAMETER of the function
731
+ // holding the read, never assigned in that function; and that parameter is
732
+ // (a) the adapter's own first parameter — the org ID the package passes to
733
+ // `orgKindOf(orgId)`, i.e. the resolved principal's org — or (b) a helper's
734
+ // parameter that EVERY call site of the helper fills with exactly that
735
+ // adapter parameter. A literal, a field read (`identity.orgSlug`,
736
+ // `row.name`), a computed value (`orgId.toLowerCase()`), a door's argument,
737
+ // or a reassigned parameter is not the package's ID: red. The detector does
738
+ // not read the value's type or its `org_` prefix — it reads WHERE the value
739
+ // came from, which is the only thing a static reader can establish.
740
+ // (3) THE RESULT FEEDS ONLY THE ADAPTER. The function holding the read is
741
+ // either an `orgKindOf` member of an object literal that reaches a
742
+ // package call (`resolveActingPrincipal`, `assertTargetBelongsTo`, through
743
+ // the file's own import) as a NON-first argument — directly, through a
744
+ // local, or as the return of a helper every call site of which does the
745
+ // same — or a named helper (never `withOrgScope` / `lookupOrgMapping`)
746
+ // every reference of which is the whole body of such an adapter. And that
747
+ // function decides nothing else: its only call is the read chain, and its
748
+ // body is the read bound to a local, `if (…) return <literal>` branches,
749
+ // and a call-free return (no throw, no write, no other call).
750
+ //
751
+ // The same read answers `organisationById` (task: vantage-peers #1465 / #1466).
752
+ // cloud-identity 0.11.0 `resolveActingPrincipal` ALWAYS calls
753
+ // `lookups.organisationById(orgId)` (principal-by-id.d.ts, `PrincipalLookups`,
754
+ // `OrganisationRow = { id, active }`) and refuses an inactive organisation
755
+ // from its answer, so the host must read its org table there too. Conditions
756
+ // (1)–(3) apply unchanged, with `organisationById` in place of `orgKindOf`,
757
+ // plus:
758
+ // (4) THE ANSWER IS THE ROW'S. Every non-absent return of an
759
+ // `organisationById` adapter (or of the helper that is its whole body) is
760
+ // an object literal `{ id, active }` — directly or as a branch of a
761
+ // call-free conditional — whose values read only the row binding or the
762
+ // adapter's key, `active` reading the row; an `if` branch and an absent
763
+ // branch return only null / undefined. A fabricated `active: true`, or a
764
+ // row answered on absence, is a local decision that the org is active.
765
+ // (5) A BUILDER ONLY ASSEMBLES. When the lookups object reaches the package
766
+ // as the return of a named builder (`principalLookups(ctx)`), that
767
+ // builder's own body — nested adapters excluded — is variable statements
768
+ // and one final return: no `if`, conditional, switch, loop, try, throw,
769
+ // `&&` / `||` / `??`, and no expression statement. Applies to both
770
+ // adapters.
771
+ export const ORG_KIND_ADAPTER = "orgKindOf";
772
+ export const ORG_BY_ID_ADAPTER = "organisationById";
773
+ /** The host adapters that may hold the ID-keyed mapping read. */
774
+ export const ORG_ID_ADAPTERS = new Set([
775
+ ORG_KIND_ADAPTER,
776
+ ORG_BY_ID_ADAPTER,
777
+ ]);
778
+ export const ORG_ID_INDEX = "by_clerk_org_id";
779
+ export const ORG_ID_FIELD = "clerkOrgId";
780
+ /** The sinks a lookups object may reach (as a non-first argument). */
781
+ const LOOKUPS_SINKS = new Set([
782
+ "resolveActingPrincipal",
783
+ TARGET_CHECK,
784
+ ]);
785
+ /** Never an adapter, whatever they read: the product's own org resolvers. */
786
+ const LOCAL_RESOLVER_NAMES = new Set([
787
+ "withOrgScope",
788
+ "lookupOrgMapping",
789
+ ]);
790
+ /** `(q) => q.eq(<field-expr>, K)` — the field expression and K, else undefined. */
791
+ function eqCallback(cb) {
792
+ if (!cb)
793
+ return undefined;
794
+ const f = unwrap(cb);
795
+ if (!ts.isArrowFunction(f) || f.parameters.length !== 1)
796
+ return undefined;
797
+ const q = f.parameters[0].name;
798
+ if (!ts.isIdentifier(q))
799
+ return undefined;
800
+ let body = f.body;
801
+ if (ts.isBlock(body)) {
802
+ const one = body.statements.length === 1 ? body.statements[0] : undefined;
803
+ if (!one || !ts.isReturnStatement(one) || !one.expression)
804
+ return undefined;
805
+ body = one.expression;
806
+ }
807
+ const c = unwrap(body);
808
+ if (!ts.isCallExpression(c) ||
809
+ dotted(c.expression) !== `${q.text}.eq` ||
810
+ c.arguments.length !== 2)
811
+ return undefined;
812
+ return { field: c.arguments[0], key: c.arguments[1] };
813
+ }
814
+ /** (1): the chain from `.query("client_org_mapping")` — its key K and its top
815
+ * call (`….unique()`), or undefined when it is not the ID-keyed read. */
816
+ function idKeyedChain(read) {
817
+ let key;
818
+ let x = read;
819
+ for (;;) {
820
+ const pa = x.parent;
821
+ if (!pa || !ts.isPropertyAccessExpression(pa) || pa.expression !== x)
822
+ return undefined;
823
+ const call = pa.parent;
824
+ if (!call || !ts.isCallExpression(call) || call.expression !== pa)
825
+ return undefined;
826
+ const m = pa.name.text;
827
+ if (m === "unique" || m === "first") {
828
+ if (!key || call.arguments.length)
829
+ return undefined;
830
+ return { key, top: call };
831
+ }
832
+ if (key)
833
+ return undefined; // exactly one key constraint
834
+ if (m === "withIndex") {
835
+ const [ix, cb] = call.arguments;
836
+ if (!ix || !ts.isStringLiteralLike(ix) || ix.text !== ORG_ID_INDEX)
837
+ return undefined;
838
+ const eq = eqCallback(cb);
839
+ const fld = eq && unwrap(eq.field);
840
+ if (!eq ||
841
+ !fld ||
842
+ !ts.isStringLiteralLike(fld) ||
843
+ fld.text !== ORG_ID_FIELD)
844
+ return undefined;
845
+ key = eq.key;
846
+ }
847
+ else if (m === "filter") {
848
+ const eq = eqCallback(call.arguments[0]);
849
+ const fld = eq && unwrap(eq.field);
850
+ if (!eq ||
851
+ !fld ||
852
+ !ts.isCallExpression(fld) ||
853
+ !/\.field$/.test(dotted(fld.expression) ?? "") ||
854
+ fld.arguments.length !== 1 ||
855
+ !ts.isStringLiteralLike(fld.arguments[0]) ||
856
+ fld.arguments[0].text !== ORG_ID_FIELD)
857
+ return undefined;
858
+ key = eq.key;
859
+ }
860
+ else
861
+ return undefined;
862
+ x = call;
863
+ }
864
+ }
865
+ /** Is `name` assigned anywhere in `fn` (`=`, compound, `++`/`--`)? */
866
+ function assignedIn(fn, name) {
867
+ let hit = false;
868
+ walk(fn, (y) => {
869
+ if (ts.isBinaryExpression(y) &&
870
+ y.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
871
+ y.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
872
+ ts.isIdentifier(unwrap(y.left)) &&
873
+ unwrap(y.left).text === name)
874
+ hit = true;
875
+ if ((ts.isPrefixUnaryExpression(y) || ts.isPostfixUnaryExpression(y)) &&
876
+ (y.operator === ts.SyntaxKind.PlusPlusToken ||
877
+ y.operator === ts.SyntaxKind.MinusMinusToken) &&
878
+ ts.isIdentifier(unwrap(y.operand)) &&
879
+ unwrap(y.operand).text === name)
880
+ hit = true;
881
+ });
882
+ return hit;
883
+ }
884
+ /** The literal-ish return a branch of the adapter may take. */
885
+ function returnsLiteral(s) {
886
+ const one = ts.isBlock(s) && s.statements.length === 1 ? s.statements[0] : s;
887
+ return (ts.isReturnStatement(one) && (!one.expression || isLiteral(one.expression)));
888
+ }
889
+ function hasCall(n) {
890
+ let hit = false;
891
+ walk(n, (y) => {
892
+ if (ts.isCallExpression(y))
893
+ hit = true;
894
+ });
895
+ return hit;
896
+ }
897
+ /** (3), the body half: the function's only call is the read chain, and it
898
+ * decides nothing but its own return. */
899
+ function decidesNothingElse(fn, top) {
900
+ const body = fn.body;
901
+ if (!body)
902
+ return false;
903
+ let stray = false;
904
+ walk(body, (y) => {
905
+ if (ts.isCallExpression(y) && !(y.pos >= top.pos && y.end <= top.end))
906
+ stray = true;
907
+ if (ts.isThrowStatement(y))
908
+ stray = true;
909
+ });
910
+ if (stray)
911
+ return false;
912
+ if (!ts.isBlock(body))
913
+ return true;
914
+ return body.statements.every((st) => {
915
+ if (ts.isVariableStatement(st)) {
916
+ const ds = st.declarationList.declarations;
917
+ return (ds.length === 1 &&
918
+ ts.isIdentifier(ds[0].name) &&
919
+ !!ds[0].initializer &&
920
+ unwrap(ds[0].initializer) === top);
921
+ }
922
+ if (ts.isIfStatement(st))
923
+ return (!st.elseStatement &&
924
+ !hasCall(st.expression) &&
925
+ returnsLiteral(st.thenStatement));
926
+ if (ts.isReturnStatement(st))
927
+ return (!st.expression ||
928
+ unwrap(st.expression) === top ||
929
+ !hasCall(st.expression));
930
+ return false;
931
+ });
932
+ }
933
+ /** The adapter member (`orgKindOf` / `organisationById`) a function is: the
934
+ * object literal holding it and the adapter's name. */
935
+ function adapterObjectOf(fn) {
936
+ const named = (n) => n &&
937
+ (ts.isIdentifier(n) || ts.isStringLiteralLike(n)) &&
938
+ ORG_ID_ADAPTERS.has(n.text)
939
+ ? n.text
940
+ : undefined;
941
+ if (ts.isMethodDeclaration(fn)) {
942
+ const adapter = named(fn.name);
943
+ return adapter && ts.isObjectLiteralExpression(fn.parent)
944
+ ? { obj: fn.parent, adapter }
945
+ : undefined;
946
+ }
947
+ let x = fn;
948
+ while (x.parent && ts.isParenthesizedExpression(x.parent))
949
+ x = x.parent;
950
+ const pa = x.parent;
951
+ if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
952
+ pa &&
953
+ ts.isPropertyAssignment(pa) &&
954
+ pa.initializer === x &&
955
+ ts.isObjectLiteralExpression(pa.parent)) {
956
+ const adapter = named(pa.name);
957
+ if (adapter)
958
+ return { obj: pa.parent, adapter };
959
+ }
960
+ return undefined;
961
+ }
962
+ /** (4): every answer of an `organisationById` body is absence, or the row's own
963
+ * `{ id, active }` — values reading only the row binding or the key, `active`
964
+ * reading the row. `decidesNothingElse` has already held the body to the read,
965
+ * `if` branches and call-free returns. */
966
+ function answersOnlyTheRow(fn, top, key) {
967
+ const body = fn.body;
968
+ if (!body || !ts.isBlock(body))
969
+ return false; // the raw row is not { id, active }
970
+ let row;
971
+ for (const st of body.statements)
972
+ if (ts.isVariableStatement(st)) {
973
+ const d = st.declarationList.declarations[0];
974
+ if (d &&
975
+ ts.isIdentifier(d.name) &&
976
+ d.initializer &&
977
+ unwrap(d.initializer) === top)
978
+ row = d.name.text;
979
+ }
980
+ if (!row)
981
+ return false;
982
+ const rowName = row;
983
+ const absent = (e) => {
984
+ if (!e)
985
+ return true;
986
+ const u = unwrap(e);
987
+ return (u.kind === ts.SyntaxKind.NullKeyword ||
988
+ (ts.isIdentifier(u) && u.text === "undefined"));
989
+ };
990
+ const reads = (v, allowed) => {
991
+ let ok = true;
992
+ walk(v, (y) => {
993
+ if (ts.isIdentifier(y) && !isNameOnly(y) && !allowed.has(y.text))
994
+ ok = false;
995
+ });
996
+ return ok;
997
+ };
998
+ const allowed = new Set([rowName, key]);
999
+ // `active` is the row's own field: `row.f`, or `row.f` compared (=== / ==)
1000
+ // with a literal. `!!row`, `row !== null`, `row.f || true`, `row.f ?? true`
1001
+ // and a conditional all answer "active" for any row that exists.
1002
+ const rowField = (e) => {
1003
+ const u = unwrap(e);
1004
+ return (ts.isPropertyAccessExpression(u) &&
1005
+ ts.isIdentifier(u.expression) &&
1006
+ u.expression.text === rowName);
1007
+ };
1008
+ const literal = (e) => {
1009
+ const u = unwrap(e);
1010
+ return (u.kind === ts.SyntaxKind.TrueKeyword ||
1011
+ u.kind === ts.SyntaxKind.FalseKeyword ||
1012
+ ts.isStringLiteralLike(u) ||
1013
+ ts.isNumericLiteral(u));
1014
+ };
1015
+ const readsRowField = (e) => {
1016
+ const u = unwrap(e);
1017
+ if (rowField(u))
1018
+ return true;
1019
+ if (ts.isBinaryExpression(u) &&
1020
+ (u.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
1021
+ u.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken))
1022
+ return ((rowField(u.left) && literal(u.right)) ||
1023
+ (literal(u.left) && rowField(u.right)));
1024
+ return false;
1025
+ };
1026
+ const answer = (e) => {
1027
+ const u = unwrap(e);
1028
+ if (absent(u))
1029
+ return true;
1030
+ if (ts.isConditionalExpression(u))
1031
+ return answer(u.whenTrue) && answer(u.whenFalse);
1032
+ if (!ts.isObjectLiteralExpression(u))
1033
+ return false;
1034
+ const seen = new Set();
1035
+ for (const m of u.properties) {
1036
+ if (!ts.isPropertyAssignment(m) ||
1037
+ !(ts.isIdentifier(m.name) || ts.isStringLiteralLike(m.name)))
1038
+ return false;
1039
+ const k = m.name.text;
1040
+ if ((k !== "id" && k !== "active") || seen.has(k))
1041
+ return false;
1042
+ seen.add(k);
1043
+ if (!reads(m.initializer, allowed))
1044
+ return false;
1045
+ if (k === "active" && !readsRowField(m.initializer))
1046
+ return false;
1047
+ }
1048
+ return seen.has("active");
1049
+ };
1050
+ return body.statements.every((st) => {
1051
+ if (ts.isVariableStatement(st))
1052
+ return true;
1053
+ if (ts.isIfStatement(st)) {
1054
+ const one = ts.isBlock(st.thenStatement) && st.thenStatement.statements.length === 1
1055
+ ? st.thenStatement.statements[0]
1056
+ : st.thenStatement;
1057
+ return ts.isReturnStatement(one) && absent(one.expression);
1058
+ }
1059
+ if (ts.isReturnStatement(st))
1060
+ return st.expression === undefined || answer(st.expression);
1061
+ return false;
1062
+ });
1063
+ }
1064
+ /** (5): a builder whose return carries the lookups object only assembles it —
1065
+ * its own body, nested functions excluded, decides nothing. */
1066
+ function assemblesOnly(fn) {
1067
+ const body = fn.body;
1068
+ if (!body)
1069
+ return false;
1070
+ let decides = false;
1071
+ const visit = (y) => {
1072
+ if (y !== body && ts.isFunctionLike(y))
1073
+ return;
1074
+ if (ts.isIfStatement(y) ||
1075
+ ts.isConditionalExpression(y) ||
1076
+ ts.isSwitchStatement(y) ||
1077
+ ts.isThrowStatement(y) ||
1078
+ ts.isTryStatement(y) ||
1079
+ ts.isIterationStatement(y, false) ||
1080
+ (ts.isBinaryExpression(y) &&
1081
+ (y.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
1082
+ y.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
1083
+ y.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)))
1084
+ decides = true;
1085
+ y.forEachChild(visit);
1086
+ };
1087
+ visit(body);
1088
+ if (decides)
1089
+ return false;
1090
+ if (!ts.isBlock(body))
1091
+ return true;
1092
+ const sts = body.statements;
1093
+ return (sts.length > 0 &&
1094
+ ts.isReturnStatement(sts[sts.length - 1]) &&
1095
+ sts.slice(0, -1).every((st) => ts.isVariableStatement(st)));
1096
+ }
1097
+ class OrgKindReads {
1098
+ parsed;
1099
+ pkgImports;
1100
+ defOf = new Map();
1101
+ conformant = new Set();
1102
+ constructor(parsed, fns, pkgImports) {
1103
+ this.parsed = parsed;
1104
+ this.pkgImports = pkgImports;
1105
+ for (const ds of fns.values())
1106
+ for (const d of ds)
1107
+ this.defOf.set(d.fn, d);
1108
+ for (const p of parsed)
1109
+ walk(p.sf, (x) => {
1110
+ if (this.isMappingQuery(x) && this.judge(x))
1111
+ this.conformant.add(x);
1112
+ });
1113
+ }
1114
+ isMappingQuery(x) {
1115
+ if (!ts.isCallExpression(x))
1116
+ return false;
1117
+ const a0 = x.arguments[0];
1118
+ return (/\.query$/.test(dotted(x.expression) ?? "") &&
1119
+ x.arguments.length === 1 &&
1120
+ !!a0 &&
1121
+ ts.isStringLiteralLike(a0) &&
1122
+ a0.text === "client_org_mapping");
1123
+ }
1124
+ judge(read) {
1125
+ const chain = idKeyedChain(read);
1126
+ if (!chain)
1127
+ return false;
1128
+ const k = unwrap(chain.key);
1129
+ if (!ts.isIdentifier(k))
1130
+ return false;
1131
+ const fn = enclosingFn(read);
1132
+ if (!fn || isHandler(fn))
1133
+ return false;
1134
+ const pi = fn.parameters.findIndex((p) => ts.isIdentifier(p.name) && p.name.text === k.text);
1135
+ if (pi < 0 || assignedIn(fn, k.text))
1136
+ return false;
1137
+ if (!decidesNothingElse(fn, chain.top))
1138
+ return false;
1139
+ // (a) the read sits in the adapter itself, keyed by its first parameter.
1140
+ const own = adapterObjectOf(fn);
1141
+ if (own)
1142
+ return (pi === 0 &&
1143
+ (own.adapter !== ORG_BY_ID_ADAPTER ||
1144
+ answersOnlyTheRow(fn, chain.top, k.text)) &&
1145
+ this.feedsPackage(own.obj, new Set()));
1146
+ // (b) a helper used ONLY as the whole body of such an adapter, the key
1147
+ // filled with the adapter's own first parameter.
1148
+ const d = this.defOf.get(fn);
1149
+ if (!d || LOCAL_RESOLVER_NAMES.has(d.name))
1150
+ return false;
1151
+ const sites = [];
1152
+ for (const p of this.parsed)
1153
+ walk(p.sf, (y) => {
1154
+ if (ts.isIdentifier(y) && y.text === d.name && !isNameOnly(y))
1155
+ sites.push(y);
1156
+ });
1157
+ const adapters = sites.map((s) => this.adapterForwards(s, pi));
1158
+ return (sites.length > 0 &&
1159
+ adapters.every((a) => a !== undefined) &&
1160
+ (!adapters.includes(ORG_BY_ID_ADAPTER) ||
1161
+ answersOnlyTheRow(fn, chain.top, k.text)));
1162
+ }
1163
+ /** `orgKindOf: (orgId) => H(…, orgId, …)` (or `{ return [await] H(…) }`,
1164
+ * or the same under `organisationById`), the key slot filled with exactly
1165
+ * the adapter's first parameter. The adapter's name, or undefined. */
1166
+ adapterForwards(site, keyAt) {
1167
+ let c = site;
1168
+ while (c.parent && ts.isParenthesizedExpression(c.parent))
1169
+ c = c.parent;
1170
+ const call = c.parent;
1171
+ if (!call || !ts.isCallExpression(call) || call.expression !== c)
1172
+ return undefined;
1173
+ let x = call;
1174
+ while (x.parent &&
1175
+ (ts.isAwaitExpression(x.parent) || ts.isParenthesizedExpression(x.parent)))
1176
+ x = x.parent;
1177
+ let fn = x.parent;
1178
+ if (fn && ts.isReturnStatement(fn)) {
1179
+ const blk = fn.parent;
1180
+ if (!ts.isBlock(blk) || blk.statements.length !== 1)
1181
+ return undefined;
1182
+ fn = blk.parent;
1183
+ }
1184
+ else if (!(fn && ts.isArrowFunction(fn) && fn.body === x))
1185
+ return undefined;
1186
+ if (!fn || !ts.isFunctionLike(fn))
1187
+ return undefined;
1188
+ const adapter = fn;
1189
+ const own = adapterObjectOf(adapter);
1190
+ const p0 = adapter.parameters[0]?.name;
1191
+ if (!own || !p0 || !ts.isIdentifier(p0))
1192
+ return undefined;
1193
+ if (assignedIn(adapter, p0.text))
1194
+ return undefined;
1195
+ const arg = call.arguments[keyAt];
1196
+ const a = arg && unwrap(arg);
1197
+ return a &&
1198
+ ts.isIdentifier(a) &&
1199
+ a.text === p0.text &&
1200
+ this.feedsPackage(own.obj, new Set())
1201
+ ? own.adapter
1202
+ : undefined;
1203
+ }
1204
+ /** Does the value at `e` reach ONLY a package call, as a non-first argument
1205
+ * (the lookups slot of resolveActingPrincipal / assertTargetBelongsTo)? */
1206
+ feedsPackage(e, seen) {
1207
+ let x = e;
1208
+ while (x.parent &&
1209
+ (ts.isAwaitExpression(x.parent) ||
1210
+ ts.isParenthesizedExpression(x.parent) ||
1211
+ ts.isNonNullExpression(x.parent) ||
1212
+ ts.isAsExpression(x.parent) ||
1213
+ ts.isSatisfiesExpression(x.parent)))
1214
+ x = x.parent;
1215
+ const par = x.parent;
1216
+ if (!par)
1217
+ return false;
1218
+ if (ts.isCallExpression(par) && par.expression !== x) {
1219
+ const i = par.arguments.indexOf(x);
1220
+ const imp = this.pkgImports.get(par.getSourceFile().fileName);
1221
+ const exp = imp && pkgExportOf(par.expression, imp);
1222
+ return i >= 1 && exp !== undefined && LOOKUPS_SINKS.has(exp);
1223
+ }
1224
+ if (ts.isVariableDeclaration(par) && par.initializer === x) {
1225
+ if (!ts.isIdentifier(par.name))
1226
+ return false;
1227
+ const scope = enclosingFn(par) ?? par.getSourceFile();
1228
+ const refs = [];
1229
+ walk(scope, (y) => {
1230
+ if (ts.isIdentifier(y) &&
1231
+ y !== par.name &&
1232
+ y.text === par.name.text &&
1233
+ !isNameOnly(y))
1234
+ refs.push(y);
1235
+ });
1236
+ return refs.length > 0 && refs.every((r) => this.feedsPackage(r, seen));
1237
+ }
1238
+ const fn = ts.isReturnStatement(par)
1239
+ ? enclosingFn(par)
1240
+ : ts.isArrowFunction(par) && par.body === x
1241
+ ? par
1242
+ : undefined;
1243
+ if (!fn || isHandler(fn))
1244
+ return false;
1245
+ // (5) the builder returning the lookups object only assembles it.
1246
+ if (!assemblesOnly(fn))
1247
+ return false;
1248
+ const d = this.defOf.get(fn);
1249
+ if (!d)
1250
+ return false;
1251
+ const key = `ret:${d.rel}:${d.line}`;
1252
+ if (seen.has(key))
1253
+ return true;
1254
+ const next = new Set([...seen, key]);
1255
+ const sites = [];
1256
+ for (const p of this.parsed)
1257
+ walk(p.sf, (y) => {
1258
+ if (ts.isIdentifier(y) && y.text === d.name && !isNameOnly(y))
1259
+ sites.push(y);
1260
+ });
1261
+ return (sites.length > 0 &&
1262
+ sites.every((s) => {
1263
+ let c = s;
1264
+ while (c.parent && ts.isParenthesizedExpression(c.parent))
1265
+ c = c.parent;
1266
+ return (!!c.parent &&
1267
+ ts.isCallExpression(c.parent) &&
1268
+ c.parent.expression === c &&
1269
+ this.feedsPackage(c.parent, next));
1270
+ }));
1271
+ }
1272
+ }
1273
+ /**
1274
+ * The `client_org_mapping` reads the org-kind ruling admits (see OrgKindReads),
1275
+ * computed over `parsed` so a caller holding the same AST nodes can test
1276
+ * membership. Exported for CR-2 (local-identity.ts), which must not re-derive
1277
+ * R-53's ruling.
1278
+ */
1279
+ export function conformantMappingReads(parsed) {
1280
+ const pkgImports = new Map();
1281
+ for (const p of parsed)
1282
+ pkgImports.set(p.rel, importsOf(p).imports);
1283
+ return new OrgKindReads(parsed, indexFunctions(parsed), pkgImports)
1284
+ .conformant;
1285
+ }
1286
+ /** `n`'s text with every conformant org-kind read cut out, so the pre-ruling
1287
+ * text test still sees every other reach of the table exactly as before. */
1288
+ function textWithout(n, ok) {
1289
+ const text = n.getText();
1290
+ const base = n.getStart();
1291
+ const cuts = [];
1292
+ walk(n, (y) => {
1293
+ if (ts.isCallExpression(y) && ok.has(y))
1294
+ cuts.push([y.getStart() - base, y.getEnd() - base]);
1295
+ });
1296
+ if (!cuts.length)
1297
+ return text;
1298
+ let out = "";
1299
+ let at = 0;
1300
+ for (const [a, b] of cuts.sort((p, q) => p[0] - q[0])) {
1301
+ out += text.slice(at, a);
1302
+ at = b;
1303
+ }
1304
+ return out + text.slice(at);
1305
+ }
1306
+ /** The product resolves identity itself in `n`: an org-mapping read (other than
1307
+ * the conformant org-kind read), or a host credential read whose value goes
1308
+ * anywhere but a package resolver. */
1309
+ function resolvesLocallyIn(n, flow, okReads) {
1310
+ return (ORG_MAPPING_READ.test(textWithout(n, okReads)) ||
1311
+ flow.unsanctioned(n).length > 0);
1312
+ }
1313
+ function facts(files) {
1314
+ const parsed = files
1315
+ .filter((f) => isConvexOrMcp(f) && isJudgeable(f) && /\.tsx?$/.test(f.rel))
1316
+ .map(parseFile);
1317
+ const fns = indexFunctions(parsed);
1318
+ const pkgNames = new Map();
1319
+ const pkgImports = new Map();
1320
+ const pkgUnresolved = [];
1321
+ for (const p of parsed) {
1322
+ const i = importsOf(p);
1323
+ pkgNames.set(p.rel, i.pkg);
1324
+ pkgImports.set(p.rel, i.imports);
1325
+ if (i.unresolved)
1326
+ pkgUnresolved.push(p.rel);
1327
+ }
1328
+ const usesPackage = closeOverCalls(fns, (d) => callsAny(d.fn, pkgNames.get(d.rel) ?? new Set()));
1329
+ const flow = new HostReadFlow(parsed, fns, pkgImports);
1330
+ const okReads = new OrgKindReads(parsed, fns, pkgImports).conformant;
1331
+ const resolvesLocally = closeOverCalls(fns, (d) => resolvesLocallyIn(d.fn, flow, okReads));
1332
+ const nameAdmitting = closeOverCalls(fns, (d) => nameComparisons(d.fn).some(admits));
1333
+ return {
1334
+ parsed,
1335
+ fns,
1336
+ pkgNames,
1337
+ pkgImports,
1338
+ usesPackage,
1339
+ resolvesLocally,
1340
+ nameAdmitting,
1341
+ pkgUnresolved,
1342
+ flow,
1343
+ okReads,
1344
+ };
1345
+ }
1346
+ function calledFns(n) {
1347
+ const out = [];
1348
+ walk(n, (x) => {
1349
+ if (ts.isCallExpression(x)) {
1350
+ const e = unwrap(x.expression);
1351
+ if (ts.isIdentifier(e) && !out.includes(e.text))
1352
+ out.push(e.text);
1353
+ }
1354
+ });
1355
+ return out;
1356
+ }
1357
+ const firstDef = (f, n) => f.fns.get(n)?.[0];
1358
+ const where = (d) => (d ? ` (${d.rel}:${d.line})` : "");
1359
+ // ---- (c) unresolved pass-through -----------------------------------------
1360
+ const ABSENT_SUBJECT = /identity|principal|org|actor|scope|proof/i;
1361
+ /** The identifier an absence test is about: `!x`, `x === null|undefined`. */
1362
+ function absenceSubject(c0) {
1363
+ const c = unwrap(c0);
1364
+ if (ts.isPrefixUnaryExpression(c) &&
1365
+ c.operator === ts.SyntaxKind.ExclamationToken)
1366
+ return dotted(c.operand);
1367
+ if (ts.isBinaryExpression(c) &&
1368
+ (c.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
1369
+ c.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken)) {
1370
+ const [a, b] = [c.left, c.right];
1371
+ if (isLiteral(b) && /^(null|undefined)$/.test(unwrap(b).getText()))
1372
+ return dotted(a);
1373
+ if (isLiteral(a) && /^(null|undefined)$/.test(unwrap(a).getText()))
1374
+ return dotted(b);
1375
+ }
1376
+ return undefined;
1377
+ }
1378
+ /** convex-side: `if (<no identity/org>) … isMaster: true` — the unresolved
1379
+ * caller continues as master. */
1380
+ function masterOnAbsence(p) {
1381
+ const out = [];
1382
+ walk(p.sf, (x) => {
1383
+ if (!ts.isIfStatement(x))
1384
+ return;
1385
+ const subj = absenceSubject(x.expression);
1386
+ if (!subj || !ABSENT_SUBJECT.test(subj))
1387
+ return;
1388
+ let master;
1389
+ walk(x.thenStatement, (y) => {
1390
+ if (!master &&
1391
+ ts.isPropertyAssignment(y) &&
1392
+ ts.isIdentifier(y.name) &&
1393
+ y.name.text === "isMaster" &&
1394
+ unwrap(y.initializer).kind === ts.SyntaxKind.TrueKeyword)
1395
+ master = y;
1396
+ });
1397
+ if (!master)
1398
+ return;
1399
+ let fn = "<module>";
1400
+ for (let q = x; q; q = q.parent) {
1401
+ if (ts.isFunctionDeclaration(q) && q.name) {
1402
+ fn = q.name.text;
1403
+ break;
1404
+ }
1405
+ if (ts.isVariableDeclaration(q) && ts.isIdentifier(q.name)) {
1406
+ fn = q.name.text;
1407
+ break;
1408
+ }
1409
+ }
1410
+ out.push({ n: lineOf(master), fn, subject: subj });
1411
+ });
1412
+ return out;
1413
+ }
1414
+ /** mcp-side: a forward to Convex that carries the org only when it was
1415
+ * resolved — `...(org !== undefined ? { …org… } : {})` — with no refusal on
1416
+ * the absent org before it. */
1417
+ function orgOptionalForward(p) {
1418
+ const out = [];
1419
+ walk(p.sf, (x) => {
1420
+ if (!ts.isSpreadAssignment(x))
1421
+ return;
1422
+ const c = unwrap(x.expression);
1423
+ if (!ts.isConditionalExpression(c))
1424
+ return;
1425
+ const f = unwrap(c.whenFalse);
1426
+ if (!ts.isObjectLiteralExpression(f) || f.properties.length > 0)
1427
+ return;
1428
+ const cond = unwrap(c.condition);
1429
+ let org;
1430
+ if (ts.isIdentifier(cond))
1431
+ org = cond.text;
1432
+ else if (ts.isBinaryExpression(cond) &&
1433
+ (cond.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
1434
+ cond.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken) &&
1435
+ isLiteral(cond.right))
1436
+ org = dotted(cond.left);
1437
+ if (!org || !/org/i.test(org))
1438
+ return;
1439
+ // the object is an argument of a Convex call (mutation/action/query)
1440
+ let call;
1441
+ for (let q = x.parent; q; q = q.parent) {
1442
+ if (ts.isCallExpression(q)) {
1443
+ call = q;
1444
+ break;
1445
+ }
1446
+ if (ts.isFunctionLike(q))
1447
+ break;
1448
+ }
1449
+ if (!call || !/\.(mutation|action)$/.test(dotted(call.expression) ?? ""))
1450
+ return;
1451
+ // a refusal on the absent org before the forward voids it
1452
+ let refused = false;
1453
+ let child = call;
1454
+ for (let q = call.parent; q && !ts.isSourceFile(q); child = q, q = q.parent) {
1455
+ if (ts.isBlock(q))
1456
+ for (const st of q.statements) {
1457
+ if (st === child)
1458
+ break;
1459
+ if (ts.isIfStatement(st) &&
1460
+ absenceSubject(st.expression) === org &&
1461
+ (() => {
1462
+ let ex = false;
1463
+ walk(st.thenStatement, (y) => {
1464
+ if (ts.isReturnStatement(y) || ts.isThrowStatement(y))
1465
+ ex = true;
1466
+ });
1467
+ return ex;
1468
+ })())
1469
+ refused = true;
1470
+ }
1471
+ if (ts.isFunctionLike(q))
1472
+ break;
1473
+ }
1474
+ if (refused)
1475
+ return;
1476
+ const a0 = call.arguments[0] && unwrap(call.arguments[0]);
1477
+ const target = a0 && ts.isStringLiteralLike(a0) ? a0.text : (a0?.getText() ?? "?");
1478
+ const owner = ownerOf(x, p.rel);
1479
+ out.push({
1480
+ n: lineOf(x),
1481
+ org,
1482
+ call: target,
1483
+ owner: owner?.name ?? "<module>",
1484
+ });
1485
+ });
1486
+ return out;
1487
+ }
1488
+ /** The innermost NAMED function a node sits in — a helper or an MCP tool
1489
+ * handler. A Convex registration's own `handler` is a DOOR (judged under (b)),
1490
+ * not a local identity layer: undefined. */
1491
+ function ownerOf(n, rel) {
1492
+ for (let x = n.parent; x; x = x.parent) {
1493
+ if ((ts.isArrowFunction(x) ||
1494
+ ts.isFunctionExpression(x) ||
1495
+ ts.isMethodDeclaration(x)) &&
1496
+ x.parent &&
1497
+ ((ts.isPropertyAssignment(x.parent) &&
1498
+ ts.isIdentifier(x.parent.name) &&
1499
+ x.parent.name.text === "handler") ||
1500
+ (ts.isMethodDeclaration(x) &&
1501
+ ts.isIdentifier(x.name) &&
1502
+ x.name.text === "handler")))
1503
+ return undefined;
1504
+ if ((ts.isArrowFunction(x) || ts.isFunctionExpression(x)) &&
1505
+ x.parent &&
1506
+ ts.isCallExpression(x.parent) &&
1507
+ /^(defineTool|tool|registerTool)$/.test((dotted(x.parent.expression) ?? "").split(".").pop() ?? "")) {
1508
+ const label = x.parent.arguments.find((a) => ts.isStringLiteralLike(a) ||
1509
+ (ts.isIdentifier(a) && /_TOOL_NAME$/.test(a.text)));
1510
+ return {
1511
+ rel,
1512
+ n: lineOf(x.parent),
1513
+ name: `tool ${label ? label.getText() : "<anonymous>"}`,
1514
+ };
1515
+ }
1516
+ if (ts.isFunctionDeclaration(x) && x.name)
1517
+ return { rel, n: lineOf(x), name: x.name.text };
1518
+ if (ts.isMethodDeclaration(x) && ts.isIdentifier(x.name))
1519
+ return { rel, n: lineOf(x), name: x.name.text };
1520
+ if ((ts.isArrowFunction(x) || ts.isFunctionExpression(x)) &&
1521
+ x.parent &&
1522
+ (ts.isVariableDeclaration(x.parent) ||
1523
+ ts.isPropertyAssignment(x.parent)) &&
1524
+ ts.isIdentifier(x.parent.name))
1525
+ return { rel, n: lineOf(x.parent), name: x.parent.name.text };
1526
+ }
1527
+ return undefined;
1528
+ }
1529
+ export function scanR53(files) {
1530
+ return scanR53Detailed(files).scan;
1531
+ }
1532
+ export function scanR53Detailed(files) {
1533
+ const scan = emptyScan();
1534
+ const f = facts(files);
1535
+ for (const rel of f.pkgUnresolved)
1536
+ scan.cnj.push({
1537
+ rel,
1538
+ n: 0,
1539
+ note: `import: an import of ${IDENTITY_PACKAGE} whose bindings cannot be read`,
1540
+ });
1541
+ let doors = 0;
1542
+ let gates = 0;
1543
+ // (d) local identity layers — the INNERMOST named function that compares
1544
+ // names to admit, or that reads the credential / the org mapping itself.
1545
+ const layers = new Map();
1546
+ const note = (owner, why) => {
1547
+ const key = `${owner.rel}:${owner.n}:${owner.name}`;
1548
+ const e = layers.get(key) ?? { ...owner, why: [] };
1549
+ if (!e.why.some((w) => w.slice(0, 12) === why.slice(0, 12)))
1550
+ e.why.push(why);
1551
+ layers.set(key, e);
1552
+ };
1553
+ for (const p of f.parsed) {
1554
+ for (const cmp of nameComparisons(p.sf).filter(admits)) {
1555
+ const o = ownerOf(cmp, p.rel);
1556
+ if (o)
1557
+ note(o, `compares names to admit (\`${cmp.getText().replace(/\s+/g, " ").slice(0, 90)}\`, line ${lineOf(cmp)})`);
1558
+ }
1559
+ walk(p.sf, (x) => {
1560
+ if (!ts.isCallExpression(x))
1561
+ return;
1562
+ const callee = dotted(x.expression) ?? "";
1563
+ const a0 = x.arguments[0];
1564
+ // the credential read, or the org lookup keyed on a credential claim
1565
+ // (`client_org_mapping` through its clerk index) — resolution itself.
1566
+ const keyedOnClaim = () => {
1567
+ for (let q = x; q.parent; q = q.parent) {
1568
+ const par = q.parent;
1569
+ if (ts.isPropertyAccessExpression(par) &&
1570
+ par.name.text === "withIndex" &&
1571
+ ts.isCallExpression(par.parent) &&
1572
+ /clerk/i.test(par.parent.arguments[0]?.getText() ?? ""))
1573
+ return true;
1574
+ if (!ts.isPropertyAccessExpression(par) && !ts.isCallExpression(par))
1575
+ return false;
1576
+ }
1577
+ return false;
1578
+ };
1579
+ const what = HOST_READ.test(callee) && !f.flow.sanctioned(x)
1580
+ ? "getUserIdentity()"
1581
+ : /\.query$/.test(callee) &&
1582
+ a0 &&
1583
+ ts.isStringLiteralLike(a0) &&
1584
+ a0.text === "client_org_mapping" &&
1585
+ !f.okReads.has(x) &&
1586
+ keyedOnClaim()
1587
+ ? 'query("client_org_mapping").withIndex(<clerk claim>)'
1588
+ : undefined;
1589
+ if (!what)
1590
+ return;
1591
+ const o = ownerOf(x, p.rel);
1592
+ if (o)
1593
+ note(o, `resolves identity itself (\`${what}\`, line ${lineOf(x)}) instead of delegating to ${IDENTITY_PACKAGE}`);
1594
+ });
1595
+ }
1596
+ for (const e of layers.values()) {
1597
+ gates++;
1598
+ scan.violate.push({
1599
+ rel: e.rel,
1600
+ n: e.n,
1601
+ note: `${e.name}: (d) local identity layer — ${e.why.join("; ")}`,
1602
+ });
1603
+ }
1604
+ // (c) unresolved pass-through.
1605
+ for (const p of f.parsed) {
1606
+ for (const m of masterOnAbsence(p))
1607
+ scan.violate.push({
1608
+ rel: p.rel,
1609
+ n: m.n,
1610
+ note: `${m.fn}: (c) continues as master when \`${m.subject}\` is unresolved (\`isMaster: true\` on the absence branch) instead of refusing`,
1611
+ });
1612
+ if (/(^|\/)mcp-server\/src\//.test(p.rel))
1613
+ for (const o of orgOptionalForward(p))
1614
+ scan.violate.push({
1615
+ rel: p.rel,
1616
+ n: o.n,
1617
+ note: `${o.owner}: (c) forwards the organisation to ${o.call} only when \`${o.org}\` resolved — an org-less bearer still reaches ${o.call}, with no organisation, never refused`,
1618
+ });
1619
+ }
1620
+ // doors — every public convex mutation/action that writes.
1621
+ for (const p of f.parsed) {
1622
+ if (!/(^|\/)convex\//.test(p.rel))
1623
+ continue;
1624
+ const pkg = f.pkgNames.get(p.rel) ?? new Set();
1625
+ for (const reg of registrations(p)) {
1626
+ if (reg.builder !== "mutation" && reg.builder !== "action")
1627
+ continue;
1628
+ const body = reg.handler.body;
1629
+ if (!body)
1630
+ continue;
1631
+ let writes = 0;
1632
+ let targetWrites = 0;
1633
+ walk(body, (x) => {
1634
+ if (!ts.isCallExpression(x))
1635
+ return;
1636
+ const c = dotted(x.expression) ?? "";
1637
+ if (WRITE.test(c) || /\brun(Mutation|Action)$/.test(c))
1638
+ writes++;
1639
+ if (TARGET_WRITE.test(c))
1640
+ targetWrites++;
1641
+ });
1642
+ if (writes === 0)
1643
+ continue;
1644
+ doors++;
1645
+ const called = calledFns(body);
1646
+ const reasons = [];
1647
+ // (a) principal source
1648
+ const viaPkg = callsAny(body, pkg) || called.some((n) => f.usesPackage.has(n));
1649
+ const local = called.find((n) => f.resolvesLocally.has(n));
1650
+ const ownLocal = resolvesLocallyIn(body, f.flow, f.okReads);
1651
+ if (local || ownLocal)
1652
+ reasons.push(`(a) principal resolved by ${local ? `local ${local}()${where(firstDef(f, local))}` : "the handler itself"}, not by ${IDENTITY_PACKAGE}`);
1653
+ else if (!viaPkg)
1654
+ reasons.push("(a) no principal resolved at all before the write");
1655
+ // (b) admission predicate
1656
+ const ownNames = nameComparisons(body).filter(admits);
1657
+ const helperNames = called.filter((n) => f.nameAdmitting.has(n));
1658
+ if (ownNames.length)
1659
+ reasons.push(`(b) admission compares a name (\`${ownNames[0].getText().replace(/\s+/g, " ").slice(0, 80)}\`, line ${lineOf(ownNames[0])})`);
1660
+ else if (helperNames.length)
1661
+ reasons.push(`(b) admission compares names in ${helperNames
1662
+ .slice(0, 3)
1663
+ .map((n) => `${n}()${where(firstDef(f, n))}`)
1664
+ .join(", ")}`);
1665
+ // the locals bound to a package call (never to its target check), or
1666
+ // to a local helper that delegates to the package: the principal.
1667
+ const imp = f.pkgImports.get(p.rel) ?? {
1668
+ named: new Map(),
1669
+ namespaces: new Set(),
1670
+ };
1671
+ // `principals`: bound to the package itself (clause c reads these);
1672
+ // `principalRoots`: also bound to a local helper that delegates to it
1673
+ // (an admissible first argument of the target check).
1674
+ const principals = new Set();
1675
+ const principalRoots = new Set();
1676
+ walk(body, (x) => {
1677
+ if (ts.isVariableDeclaration(x) &&
1678
+ ts.isIdentifier(x.name) &&
1679
+ x.initializer) {
1680
+ const i = unwrap(x.initializer);
1681
+ if (!ts.isCallExpression(i))
1682
+ return;
1683
+ if (pkgExportOf(i.expression, imp) === TARGET_CHECK)
1684
+ return;
1685
+ const head = (dotted(i.expression) ?? "").split(".")[0];
1686
+ if (pkg.has(head))
1687
+ principals.add(x.name.text);
1688
+ if (pkg.has(head) || f.usesPackage.has(head))
1689
+ principalRoots.add(x.name.text);
1690
+ }
1691
+ });
1692
+ // the resolver's principal under another local: `const p = who.principal`,
1693
+ // `const p = who`, `const { principal } = who` (to a fixed point).
1694
+ for (let grew = true; grew;) {
1695
+ grew = false;
1696
+ walk(body, (x) => {
1697
+ if (!ts.isVariableDeclaration(x) || !x.initializer)
1698
+ return;
1699
+ const src = dotted(x.initializer);
1700
+ if (!src)
1701
+ return;
1702
+ const [root, ...rest] = src.split(".");
1703
+ if (!principalRoots.has(root))
1704
+ return;
1705
+ if (rest.length > 1 || (rest.length === 1 && rest[0] !== "principal"))
1706
+ return;
1707
+ const bound = [];
1708
+ if (ts.isIdentifier(x.name))
1709
+ bound.push(x.name.text);
1710
+ else if (ts.isObjectBindingPattern(x.name) && rest.length === 0)
1711
+ for (const el of x.name.elements) {
1712
+ const key = (el.propertyName ?? el.name).getText();
1713
+ if (key === "principal" && ts.isIdentifier(el.name))
1714
+ bound.push(el.name.text);
1715
+ }
1716
+ for (const b of bound)
1717
+ if (!principalRoots.has(b)) {
1718
+ principalRoots.add(b);
1719
+ grew = true;
1720
+ }
1721
+ });
1722
+ }
1723
+ if (targetWrites > 0 && !ownNames.length && !helperNames.length) {
1724
+ const ids = idComparisons(body);
1725
+ const admitted = packageAdmittedWrites(body, imp, principalRoots);
1726
+ if (ids.length === 0 &&
1727
+ !called.some((n) => f.usesPackage.has(n)) &&
1728
+ admitted.size < targetWrites)
1729
+ reasons.push("(b) no predicate on the target at all");
1730
+ }
1731
+ // (c) at the door: a package principal must be refused when absent.
1732
+ if (viaPkg && !local && !ownLocal) {
1733
+ let refused = false;
1734
+ walk(body, (x) => {
1735
+ const subj = ts.isIfStatement(x)
1736
+ ? absenceSubject(x.expression)
1737
+ : undefined;
1738
+ if (ts.isIfStatement(x) &&
1739
+ subj &&
1740
+ principals.has(subj.split(".")[0])) {
1741
+ walk(x.thenStatement, (y) => {
1742
+ if (ts.isThrowStatement(y) || ts.isReturnStatement(y))
1743
+ refused = true;
1744
+ });
1745
+ }
1746
+ });
1747
+ // a throwing `require…`/`assert…` export refuses by itself — never
1748
+ // the target check, which returns a result the door must read.
1749
+ const requiring = [...pkg].some((n) => /^require|^assert/.test(n) &&
1750
+ imp.named.get(n) !== TARGET_CHECK &&
1751
+ callsAny(body, new Set([n])));
1752
+ if (!refused && !requiring)
1753
+ reasons.push("(c) the package principal is used with no refusal on its absence");
1754
+ }
1755
+ const site = { rel: p.rel, n: reg.line };
1756
+ if (reasons.length)
1757
+ scan.violate.push({
1758
+ ...site,
1759
+ note: `${reg.name}: ${reasons.join(" | ")}`,
1760
+ });
1761
+ else
1762
+ scan.conform.push({
1763
+ ...site,
1764
+ note: `${reg.name}: principal from ${IDENTITY_PACKAGE}, target admitted by stored-ID comparison, unresolved refused`,
1765
+ });
1766
+ }
1767
+ }
1768
+ // clause 1, recipient side: a recipient / target agent selected by a name
1769
+ // the caller typed (see recipient-by-id.ts). One site per deciding line.
1770
+ for (const s of recipientByNameSites(f.parsed, f.fns))
1771
+ scan.violate.push(s);
1772
+ return { scan, doors, gates };
1773
+ }
1774
+ export const R53 = {
1775
+ id: R53_ID,
1776
+ run: (tree) => {
1777
+ const d = scanR53Detailed(tree.files);
1778
+ return decide("R-53", R53_ID, R53_CHECK, d.scan, "R-53: no applicable site — no public write door in convex/ and no identity gate in convex/ or mcp-server/src was read. NOT a clean bill.");
1779
+ },
1780
+ };
1781
+ export const SOURCE_IDENTITY_BY_ID_DETECTORS = [
1782
+ { detector: R53, ruleId: "R-53", standardPin: SOURCE_STANDARD_PIN },
1783
+ ];