@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,1108 @@
1
+ // R-53 clause 1, RECIPIENT side — a message recipient / target agent is
2
+ // resolved by its unique ID, never by a name the caller typed.
3
+ //
4
+ // Standard 2825122 §4 R-53 clause 1: "A typed or matched name (...) never
5
+ // selects a row and never authorises a write; a name is a display label only."
6
+ // The detector spec's red (a)–(d) read the ACTING principal and the write's
7
+ // admission; a recipient picked by name from a channel string, a roster or a
8
+ // name index is the same clause seen from the target, and it was invisible
9
+ // (operator decision 2026-10-08, task k1716f01f9g1a0scz7nj30118h8fx32c). The
10
+ // sites land under R-53 because the standard text already binds them; no new
11
+ // rule id is minted for a clause the pinned standard states.
12
+ //
13
+ // HOW IT READS. Every convex/ write door (mutation, action and their internal
14
+ // twins) seeds its args object. A value is a RECIPIENT NAME when it is a
15
+ // recipient-shaped argument field (RECIPIENT_ARGS) that is not a `v.id(...)`
16
+ // argument, followed by value — never by spelling — through renames,
17
+ // destructures, consts, reassignments, `for…of`, iteration callbacks, string
18
+ // and array transforms (split/trim/normalize/replace/toLowerCase/…), local
19
+ // normalisers (a helper whose return derives from its parameter), object
20
+ // literals handed to a helper, a helper's parameter, a local arrow, and the
21
+ // closure a helper returns. A recipient name is RED where it decides a row or
22
+ // a route:
23
+ // membership `set.has(n)`, `list.includes(n)`, `map.get(n)`, `indexOf`, over
24
+ // a collection that is not a list of literals;
25
+ // equality `x === n` / `!==` / `startsWith` / `endsWith` / `localeCompare`
26
+ // against a non-literal that is not an ID (`_id`, `…Id`);
27
+ // index key `q.eq(field, n)` inside `.withIndex` / `.filter` /
28
+ // `.withSearchIndex`;
29
+ // routing a routing field (ROUTING_FIELDS) of an object literal handed to
30
+ // `db.insert|patch|replace`, written from a name.
31
+ // lookup `byName[n]` (read, not an assignment target), `n in obj`;
32
+ // switch `switch (n) { case x: }` with a case that is not a literal/ID.
33
+ //
34
+ // READ SIDE (operator gate 2026-10-08, same task). A door that SELECTS an
35
+ // agent's data — an inbox, an unread count, a receipt to mark, a message to
36
+ // delete — does so by the reader's verified identity or by an ID, never by a
37
+ // name the caller typed. Query doors (`query`, `internalQuery`) are seeded
38
+ // beside the write doors, and READER_ARGS (`orchestratorId`, `instanceId`,
39
+ // `recipientInstanceId`, `agentName`, `agent`) join RECIPIENT_ARGS. Any OTHER
40
+ // non-ID argument, whatever its spelling, is a WEAK name: it is red only where
41
+ // it selects by a stored agent-identity field — `q.eq(f, n)` with `f` in
42
+ // SELECTOR_FIELDS (or the table's declared fields), `row.f === n` with `f` in
43
+ // SELECTOR_FIELDS, or a routing field written from it. So `callerOrchestrator`
44
+ // matched against `receipt.recipient` (markAsRead) or `message.from`
45
+ // (deleteMessage) is red, while the same argument checked against a roster is
46
+ // left to clauses (a)/(b)/(d). A write door's own sender (SENDER_ARGS) is not
47
+ // seeded weak: it is the acting principal.
48
+ // ROUTING IS DECLARED PER TABLE where a field name is ambiguous
49
+ // (ROUTING_BY_TABLE): `githubRepoMapping.orchestrator` routes webhook issues,
50
+ // `diary.orchestrator` / `monitoredDeployments.orchestrator` record an author.
51
+ //
52
+ // CONFORMANT: `ctx.db.normalizeId("agents", x)` + `ctx.db.get` (the result is a
53
+ // row, not a name), an ID read from a verified principal, a `v.id("agents")`
54
+ // argument, a name rendered only (message text, log, an error string, a label
55
+ // field outside ROUTING_FIELDS), a literal keyword test, and a NARROWING: a
56
+ // typed name compared with a VERIFIED value (read from `ctx.auth.
57
+ // getUserIdentity()` or a PACKAGE_RESOLVERS result) in an `if` that throws on
58
+ // mismatch is conform, and so is every later use of that name in the same
59
+ // block. A guard that refuses only some names (`a && n !== v`) does not narrow.
60
+ // `user:<subject>` is an ID only when read from the authenticated identity;
61
+ // typed, it is a name.
62
+ // OUT OF SCOPE, stated: a write door's SENDER (`from`, `fromInstanceId`,
63
+ // `createdBy`) — clauses (a)/(b)/(d) govern it; a narrowing performed inside a
64
+ // helper (the door is read, not the helper's guard: errs red); a table's
65
+ // author field read by a query (`diary.orchestrator`) unless it is declared;
66
+ // `mcp-server/src` is not read by this clause.
67
+ import * as ts from "typescript";
68
+ import { dotted, lineOf, registrations, unwrap, walk, } from "./_ast.js";
69
+ /** Argument fields that NAME the recipient / target agent of a write. */
70
+ export const RECIPIENT_ARGS = new Set([
71
+ "channel",
72
+ "to",
73
+ "recipient",
74
+ "recipients",
75
+ "target",
76
+ "targetAgent",
77
+ "targetOrchestrator",
78
+ "assignedTo",
79
+ "assignedToInstance",
80
+ "assignedOrchestrator",
81
+ "assignee",
82
+ "reviewer",
83
+ "fallbackReviewer",
84
+ "delegateTo",
85
+ ]);
86
+ /** Stored fields that ROUTE a row to its recipient. */
87
+ export const ROUTING_FIELDS = new Set([
88
+ "recipient",
89
+ "recipients",
90
+ "recipientInstanceId",
91
+ "to",
92
+ "target",
93
+ "assignedTo",
94
+ "assignedToInstance",
95
+ "assignedOrchestrator",
96
+ "lastAssignedTo",
97
+ "assignee",
98
+ "reviewer",
99
+ "fallbackReviewer",
100
+ ]);
101
+ /** Argument fields that NAME the reader / owner whose data a door selects. */
102
+ export const READER_ARGS = new Set([
103
+ "orchestratorId",
104
+ "instanceId",
105
+ "recipientInstanceId",
106
+ "agentName",
107
+ "agent",
108
+ ]);
109
+ /** A write door's own sender: the acting principal, never seeded as a name. */
110
+ const SENDER_ARGS = new Set([
111
+ "from",
112
+ "fromInstanceId",
113
+ "createdBy",
114
+ ]);
115
+ /** Stored agent-identity fields: keying or matching one with a typed value
116
+ * SELECTS an agent's data by name, whatever the argument is called. */
117
+ export const SELECTOR_FIELDS = new Set([
118
+ ...ROUTING_FIELDS,
119
+ "orchestratorId",
120
+ "from",
121
+ "fromInstanceId",
122
+ "createdBy",
123
+ ]);
124
+ /** Fields that route (or select an agent) only on a named table — the bare
125
+ * word is an author elsewhere (`diary.orchestrator`), so it is declared here
126
+ * and never added to the global sets. */
127
+ export const ROUTING_BY_TABLE = new Map([["githubRepoMapping", new Set(["orchestrator"])]]);
128
+ const SELECTOR_BY_TABLE = new Map([
129
+ ...ROUTING_BY_TABLE,
130
+ ["agents", new Set(["name", "normalizedName"])],
131
+ ["profiles", new Set(["name"])],
132
+ ]);
133
+ const WRITE_DOORS = new Set([
134
+ "mutation",
135
+ "action",
136
+ "internalMutation",
137
+ "internalAction",
138
+ ]);
139
+ const READ_DOORS = new Set(["query", "internalQuery"]);
140
+ /** Methods whose result, on a name receiver, is still the name(s). */
141
+ const TRANSFORMS = new Set([
142
+ "split",
143
+ "map",
144
+ "filter",
145
+ "flatMap",
146
+ "flat",
147
+ "slice",
148
+ "concat",
149
+ "sort",
150
+ "at",
151
+ "trim",
152
+ "trimStart",
153
+ "trimEnd",
154
+ "toLowerCase",
155
+ "toUpperCase",
156
+ "toLocaleLowerCase",
157
+ "toLocaleUpperCase",
158
+ "normalize",
159
+ "replace",
160
+ "replaceAll",
161
+ "substring",
162
+ "padStart",
163
+ "padEnd",
164
+ "join",
165
+ "find",
166
+ "findLast",
167
+ ]);
168
+ /** Methods whose first callback parameter receives one name of the receiver. */
169
+ const ITERATORS = new Set([
170
+ "map",
171
+ "filter",
172
+ "forEach",
173
+ "some",
174
+ "every",
175
+ "find",
176
+ "findIndex",
177
+ "findLast",
178
+ "flatMap",
179
+ ]);
180
+ const MEMBERSHIP = new Set([
181
+ "has",
182
+ "includes",
183
+ "get",
184
+ "indexOf",
185
+ "lastIndexOf",
186
+ ]);
187
+ const STRING_COMPARE = new Set([
188
+ "startsWith",
189
+ "endsWith",
190
+ "localeCompare",
191
+ ]);
192
+ const INDEX_CALLS = new Set([
193
+ "withIndex",
194
+ "withSearchIndex",
195
+ "filter",
196
+ ]);
197
+ /** An unresolvable callee spelled like a normaliser passes the name through. */
198
+ const NORMALISER = /normali[sz]e|canonical|fold|lower|upper|slug|strip|clean|sanitiz|trim|dedupe/i;
199
+ /** Labels whose name ends in `Id` but which carry a NAME, not an ID. */
200
+ const NAME_LIKE_IDS = new Set([
201
+ "orchestratorId",
202
+ "instanceId",
203
+ "fromInstanceId",
204
+ "recipientInstanceId",
205
+ ]);
206
+ const DB_WRITE = /\bdb\.(insert|patch|replace)$/;
207
+ /** Calls whose result is a VERIFIED identity: the host's authenticated
208
+ * identity and the package's principal resolvers (mirrors PACKAGE_RESOLVERS
209
+ * in identity-by-id.ts, which imports this module). */
210
+ const VERIFIED_SOURCES = new Set([
211
+ "getUserIdentity",
212
+ "resolveActingPrincipal",
213
+ "resolvePersonPrincipal",
214
+ ]);
215
+ const moduleOf = (rel) => rel.replace(/^.*?(^|\/)convex\//, "").replace(/\.tsx?$/, "");
216
+ function isIdish(e) {
217
+ const u = unwrap(e);
218
+ const last = ts.isPropertyAccessExpression(u)
219
+ ? u.name.text
220
+ : ts.isIdentifier(u)
221
+ ? u.text
222
+ : undefined;
223
+ if (last === undefined)
224
+ return false;
225
+ return last === "_id" || (/Id$/.test(last) && !NAME_LIKE_IDS.has(last));
226
+ }
227
+ function isLiteralish(e) {
228
+ const u = unwrap(e);
229
+ return (ts.isStringLiteralLike(u) ||
230
+ ts.isNumericLiteral(u) ||
231
+ u.kind === ts.SyntaxKind.NullKeyword ||
232
+ u.kind === ts.SyntaxKind.TrueKeyword ||
233
+ u.kind === ts.SyntaxKind.FalseKeyword ||
234
+ (ts.isIdentifier(u) && u.text === "undefined"));
235
+ }
236
+ /** The argument names a registration declares (`args: { … }`, inline, through
237
+ * a same-file const, or spreading one), or undefined when they cannot be read. */
238
+ function declaredArgs(handler) {
239
+ const cfg = handler.parent?.parent;
240
+ if (!cfg || !ts.isObjectLiteralExpression(cfg))
241
+ return undefined;
242
+ for (const pr of cfg.properties)
243
+ if (ts.isPropertyAssignment(pr) &&
244
+ ts.isIdentifier(pr.name) &&
245
+ pr.name.text === "args")
246
+ return objectKeys(pr.initializer, 0);
247
+ return new Set();
248
+ }
249
+ function objectKeys(e, depth) {
250
+ let o = unwrap(e);
251
+ if (ts.isIdentifier(o)) {
252
+ const d = resolveDecl(o);
253
+ const vd = d?.parent;
254
+ if (depth > 4 || !vd || !ts.isVariableDeclaration(vd) || !vd.initializer)
255
+ return undefined;
256
+ o = unwrap(vd.initializer);
257
+ }
258
+ if (!ts.isObjectLiteralExpression(o))
259
+ return undefined;
260
+ const out = new Set();
261
+ for (const m of o.properties) {
262
+ if (ts.isSpreadAssignment(m)) {
263
+ const inner = objectKeys(m.expression, depth + 1);
264
+ if (!inner)
265
+ return undefined;
266
+ for (const k of inner)
267
+ out.add(k);
268
+ }
269
+ else if (m.name &&
270
+ (ts.isIdentifier(m.name) || ts.isStringLiteral(m.name)))
271
+ out.add(m.name.text);
272
+ }
273
+ return out;
274
+ }
275
+ /** The top-level declaration a node sits in: its function or registration. */
276
+ function ownerName(n) {
277
+ let x = n;
278
+ while (x.parent && !ts.isSourceFile(x.parent))
279
+ x = x.parent;
280
+ if (ts.isFunctionDeclaration(x) && x.name)
281
+ return x.name.text;
282
+ if (ts.isVariableStatement(x)) {
283
+ const d = x.declarationList.declarations[0];
284
+ if (d && ts.isIdentifier(d.name))
285
+ return d.name.text;
286
+ }
287
+ return "<module>";
288
+ }
289
+ /** The identifiers a binding name introduces. */
290
+ function boundIds(b, out = []) {
291
+ if (ts.isIdentifier(b))
292
+ out.push(b);
293
+ else
294
+ for (const el of b.elements)
295
+ if (!ts.isOmittedExpression(el))
296
+ boundIds(el.name, out);
297
+ return out;
298
+ }
299
+ /** The declaring identifier of `id`'s binding, by lexical scope. */
300
+ function resolveDecl(id) {
301
+ const name = id.text;
302
+ const pick = (b) => boundIds(b).find((x) => x.text === name);
303
+ const inStatements = (sts) => {
304
+ for (const st of sts) {
305
+ if (ts.isVariableStatement(st))
306
+ for (const d of st.declarationList.declarations) {
307
+ const hit = pick(d.name);
308
+ if (hit)
309
+ return hit;
310
+ }
311
+ if (ts.isFunctionDeclaration(st) && st.name?.text === name)
312
+ return st.name;
313
+ }
314
+ return undefined;
315
+ };
316
+ for (let q = id.parent; q; q = q.parent) {
317
+ if (ts.isFunctionLike(q)) {
318
+ for (const p of q.parameters) {
319
+ const hit = pick(p.name);
320
+ if (hit)
321
+ return hit;
322
+ }
323
+ }
324
+ if (ts.isBlock(q) || ts.isSourceFile(q) || ts.isCaseClause(q)) {
325
+ const hit = inStatements(q.statements);
326
+ if (hit)
327
+ return hit;
328
+ }
329
+ if ((ts.isForOfStatement(q) ||
330
+ ts.isForInStatement(q) ||
331
+ ts.isForStatement(q)) &&
332
+ q.initializer &&
333
+ ts.isVariableDeclarationList(q.initializer))
334
+ for (const d of q.initializer.declarations) {
335
+ const hit = pick(d.name);
336
+ if (hit)
337
+ return hit;
338
+ }
339
+ if (ts.isCatchClause(q) && q.variableDeclaration) {
340
+ const hit = pick(q.variableDeclaration.name);
341
+ if (hit)
342
+ return hit;
343
+ }
344
+ }
345
+ return undefined;
346
+ }
347
+ /** The function a declaring identifier names, when it names one. */
348
+ function fnOfDecl(d) {
349
+ const p = d.parent;
350
+ if (ts.isFunctionDeclaration(p) && p.name === d)
351
+ return p;
352
+ if (ts.isVariableDeclaration(p) && p.name === d && p.initializer) {
353
+ const i = unwrap(p.initializer);
354
+ if (ts.isArrowFunction(i) || ts.isFunctionExpression(i))
355
+ return i;
356
+ }
357
+ return undefined;
358
+ }
359
+ /** A NON-EMPTY collection spelled only of literals (`["a","b"]`,
360
+ * `new Set(["a"])`), directly or through a `const`. An empty one is filled
361
+ * later from somewhere else, so it is never a list of literals. */
362
+ function isLiteralCollection(e, depth = 0) {
363
+ const u = unwrap(e);
364
+ if (ts.isArrayLiteralExpression(u))
365
+ return u.elements.length > 0 && u.elements.every((x) => isLiteralish(x));
366
+ if (ts.isNewExpression(u) &&
367
+ ts.isIdentifier(u.expression) &&
368
+ u.expression.text === "Set" &&
369
+ u.arguments?.[0])
370
+ return isLiteralCollection(u.arguments[0], depth + 1);
371
+ if (ts.isIdentifier(u) && depth < 4) {
372
+ const d = resolveDecl(u);
373
+ const vd = d?.parent;
374
+ if (vd &&
375
+ ts.isVariableDeclaration(vd) &&
376
+ vd.name === d &&
377
+ vd.initializer &&
378
+ ts.isVariableDeclarationList(vd.parent) &&
379
+ (vd.parent.flags & ts.NodeFlags.Const) !== 0)
380
+ return isLiteralCollection(vd.initializer, depth + 1);
381
+ }
382
+ return false;
383
+ }
384
+ /** The `.withIndex` / `.filter` call whose callback `x` sits in, if any. */
385
+ function indexCallOf(x) {
386
+ for (let q = x.parent; q; q = q.parent) {
387
+ if ((ts.isArrowFunction(q) || ts.isFunctionExpression(q)) &&
388
+ q.parent &&
389
+ ts.isCallExpression(q.parent) &&
390
+ ts.isPropertyAccessExpression(q.parent.expression) &&
391
+ INDEX_CALLS.has(q.parent.expression.name.text))
392
+ return q.parent;
393
+ }
394
+ return undefined;
395
+ }
396
+ /** The table a query chain reads: the `.query("<table>")` it starts from. */
397
+ function tableOfChain(e0) {
398
+ let e = unwrap(e0);
399
+ for (let i = 0; i < 40; i++) {
400
+ if (ts.isCallExpression(e)) {
401
+ const c = unwrap(e.expression);
402
+ const a0 = e.arguments[0];
403
+ if (ts.isPropertyAccessExpression(c) &&
404
+ c.name.text === "query" &&
405
+ a0 &&
406
+ ts.isStringLiteralLike(a0))
407
+ return a0.text;
408
+ e = c;
409
+ }
410
+ else if (ts.isPropertyAccessExpression(e))
411
+ e = unwrap(e.expression);
412
+ else
413
+ return undefined;
414
+ }
415
+ return undefined;
416
+ }
417
+ /** The table a row expression (`existing`, `existing._id`) was read from. */
418
+ function tableOfRow(e0) {
419
+ let e = unwrap(e0);
420
+ if (ts.isPropertyAccessExpression(e) && e.name.text === "_id")
421
+ e = unwrap(e.expression);
422
+ if (!ts.isIdentifier(e))
423
+ return undefined;
424
+ const vd = resolveDecl(e)?.parent;
425
+ return vd && ts.isVariableDeclaration(vd) && vd.initializer
426
+ ? tableOfChain(vd.initializer)
427
+ : undefined;
428
+ }
429
+ /** The table a `db.insert|patch|replace` call writes, when it can be read. */
430
+ function tableOfWrite(call) {
431
+ const a0 = call.arguments[0];
432
+ if (!a0)
433
+ return undefined;
434
+ if (ts.isStringLiteralLike(a0))
435
+ return a0.text;
436
+ return /insert$/.test(dotted(call.expression) ?? "")
437
+ ? undefined
438
+ : tableOfRow(a0);
439
+ }
440
+ /** The field an index/filter `q.eq(field, value)` keys: a literal or
441
+ * `q.field("<f>")`. */
442
+ function eqField(call) {
443
+ const a0 = call.arguments[0] && unwrap(call.arguments[0]);
444
+ if (!a0)
445
+ return undefined;
446
+ if (ts.isStringLiteralLike(a0))
447
+ return a0.text;
448
+ if (ts.isCallExpression(a0) &&
449
+ ts.isPropertyAccessExpression(a0.expression) &&
450
+ a0.expression.name.text === "field" &&
451
+ a0.arguments[0] &&
452
+ ts.isStringLiteralLike(a0.arguments[0]))
453
+ return a0.arguments[0].text;
454
+ return undefined;
455
+ }
456
+ /** Does `f` select an agent's data on `table` (undefined: table unknown)? */
457
+ function isSelectorField(f, table) {
458
+ return (SELECTOR_FIELDS.has(f) ||
459
+ (table !== undefined && !!SELECTOR_BY_TABLE.get(table)?.has(f)));
460
+ }
461
+ /** A read of a stored agent-identity field off a row: `receipt.recipient`. */
462
+ function isSelectorProp(e) {
463
+ const u = unwrap(e);
464
+ return ts.isPropertyAccessExpression(u) && SELECTOR_FIELDS.has(u.name.text);
465
+ }
466
+ /** Does a statement refuse (throw) on every path through it? */
467
+ function refuses(s) {
468
+ if (ts.isThrowStatement(s))
469
+ return true;
470
+ if (ts.isBlock(s)) {
471
+ const last = s.statements[s.statements.length - 1];
472
+ return !!last && ts.isThrowStatement(last);
473
+ }
474
+ return false;
475
+ }
476
+ /** The disjuncts of a condition: `a || (b || c)` -> [a, b, c]. */
477
+ function disjuncts(e0, out = []) {
478
+ const e = unwrap(e0);
479
+ if (ts.isBinaryExpression(e) &&
480
+ e.operatorToken.kind === ts.SyntaxKind.BarBarToken) {
481
+ disjuncts(e.left, out);
482
+ disjuncts(e.right, out);
483
+ }
484
+ else
485
+ out.push(e);
486
+ return out;
487
+ }
488
+ /** The db write call a routing object literal is handed to, if any. */
489
+ function dbWriteOf(obj) {
490
+ let x = obj;
491
+ while (x.parent &&
492
+ (ts.isParenthesizedExpression(x.parent) ||
493
+ ts.isConditionalExpression(x.parent) ||
494
+ ts.isSpreadAssignment(x.parent) ||
495
+ ts.isPropertyAssignment(x.parent) ||
496
+ ts.isObjectLiteralExpression(x.parent) ||
497
+ ts.isAsExpression(x.parent)))
498
+ x = x.parent;
499
+ const call = x.parent;
500
+ return call &&
501
+ ts.isCallExpression(call) &&
502
+ call.arguments.includes(x) &&
503
+ DB_WRITE.test(dotted(call.expression) ?? "")
504
+ ? call
505
+ : undefined;
506
+ }
507
+ /** The routing fields of a write to `table`: the global set plus the table's
508
+ * declared ones. */
509
+ function routingFieldsOf(table) {
510
+ const own = table === undefined ? undefined : ROUTING_BY_TABLE.get(table);
511
+ return own ? [...ROUTING_FIELDS, ...own] : [...ROUTING_FIELDS];
512
+ }
513
+ const snippet = (n) => n.getText().replace(/\s+/g, " ").slice(0, 90);
514
+ export class RecipientFlow {
515
+ parsed;
516
+ fns;
517
+ relOf = new Map();
518
+ done = new Map();
519
+ queue = [];
520
+ sites = new Map();
521
+ constructor(parsed, fns) {
522
+ this.parsed = parsed;
523
+ this.fns = fns;
524
+ for (const p of parsed)
525
+ this.relOf.set(p.sf, p.rel);
526
+ }
527
+ run() {
528
+ for (const p of this.parsed) {
529
+ if (!/(^|\/)convex\//.test(p.rel))
530
+ continue;
531
+ for (const reg of registrations(p)) {
532
+ const write = WRITE_DOORS.has(reg.builder);
533
+ if (!write && !READ_DOORS.has(reg.builder))
534
+ continue;
535
+ if (reg.handler.parameters.length < 2)
536
+ continue;
537
+ this.queue.push({
538
+ fn: reg.handler,
539
+ seeds: new Map([
540
+ [
541
+ 1,
542
+ {
543
+ kind: "obj",
544
+ origin: `${moduleOf(p.rel)}:${reg.name} args`,
545
+ idArgs: new Set(reg.argIdTable.keys()),
546
+ declared: declaredArgs(reg.handler),
547
+ write,
548
+ },
549
+ ],
550
+ ]),
551
+ });
552
+ }
553
+ }
554
+ for (let item = this.queue.shift(); item; item = this.queue.shift())
555
+ this.analyse(item.fn, item.seeds);
556
+ return [...this.sites.values()].sort((a, b) => a.rel === b.rel ? a.n - b.n : a.rel < b.rel ? -1 : 1);
557
+ }
558
+ key(fn, seeds) {
559
+ const sig = [...seeds]
560
+ .sort((a, b) => a[0] - b[0])
561
+ .map(([i, t]) => `${i}:${t.kind}${t.weak ? "~" : ""}${t.write ? "w" : ""}${t.fields
562
+ ? `{${[...t.fields]
563
+ .map(([f, v]) => `${f}${v.weak ? "~" : ""}`)
564
+ .sort()
565
+ .join(",")}}`
566
+ : ""}`)
567
+ .join(";");
568
+ return `${fn.getSourceFile().fileName}:${fn.pos}|${sig}`;
569
+ }
570
+ /** Analyse one function under seeded parameters; returns its return taint. */
571
+ analyse(fn, seeds) {
572
+ const k = this.key(fn, seeds);
573
+ const memo = this.done.get(k);
574
+ if (memo)
575
+ return memo.returns;
576
+ const slot = { returns: undefined };
577
+ this.done.set(k, slot); // in progress: a recursive call reads "no taint"
578
+ const body = fn.body;
579
+ if (!body)
580
+ return undefined;
581
+ const rel = this.relOf.get(fn.getSourceFile()) ?? "";
582
+ const env = new Map();
583
+ const closures = new Map();
584
+ const bind = (b, t) => {
585
+ if (!t)
586
+ return false;
587
+ let grew = false;
588
+ if (ts.isIdentifier(b)) {
589
+ const had = env.get(b);
590
+ if (!had || (had.weak && t.kind === "name" && !t.weak)) {
591
+ env.set(b, t);
592
+ grew = true;
593
+ }
594
+ return grew;
595
+ }
596
+ for (const el of b.elements) {
597
+ if (ts.isOmittedExpression(el))
598
+ continue;
599
+ if (t.kind === "name")
600
+ grew = bind(el.name, t) || grew;
601
+ else if (ts.isObjectBindingPattern(b) && !el.dotDotDotToken) {
602
+ const key = (el.propertyName ?? el.name).getText();
603
+ grew = bind(el.name, this.field(t, key)) || grew;
604
+ }
605
+ else
606
+ grew = bind(el.name, t) || grew;
607
+ }
608
+ return grew;
609
+ };
610
+ const bindParams = (f, args) => {
611
+ let grew = false;
612
+ f.parameters.forEach((p, i) => {
613
+ const a = args[i];
614
+ if (a)
615
+ grew = bind(p.name, taintOf(a)) || grew;
616
+ });
617
+ return grew;
618
+ };
619
+ for (const [i, t] of seeds) {
620
+ const p = fn.parameters[i];
621
+ if (p)
622
+ bind(p.name, t);
623
+ }
624
+ const lookup = (id) => {
625
+ const d = resolveDecl(id);
626
+ return d ? env.get(d) : undefined;
627
+ };
628
+ const taintOf = (e0) => {
629
+ const e = unwrap(e0);
630
+ if (ts.isIdentifier(e))
631
+ return lookup(e);
632
+ if (ts.isPropertyAccessExpression(e)) {
633
+ const base = taintOf(e.expression);
634
+ return base?.kind === "obj" ? this.field(base, e.name.text) : undefined;
635
+ }
636
+ if (ts.isElementAccessExpression(e)) {
637
+ const base = taintOf(e.expression);
638
+ return base?.kind === "name" ? base : undefined;
639
+ }
640
+ if (ts.isConditionalExpression(e))
641
+ return best(taintOf(e.whenTrue), taintOf(e.whenFalse));
642
+ if (ts.isBinaryExpression(e)) {
643
+ const op = e.operatorToken.kind;
644
+ if (op === ts.SyntaxKind.PlusToken ||
645
+ op === ts.SyntaxKind.QuestionQuestionToken ||
646
+ op === ts.SyntaxKind.BarBarToken)
647
+ return best(taintOf(e.left), taintOf(e.right));
648
+ return undefined;
649
+ }
650
+ if (ts.isTemplateExpression(e)) {
651
+ return best(...e.templateSpans.map((s) => taintOf(s.expression)));
652
+ }
653
+ if (ts.isArrayLiteralExpression(e)) {
654
+ return best(...e.elements.map((x) => taintOf(ts.isSpreadElement(x) ? x.expression : x)));
655
+ }
656
+ if (ts.isObjectLiteralExpression(e)) {
657
+ const fields = new Map();
658
+ let whole;
659
+ for (const pr of e.properties) {
660
+ if (ts.isPropertyAssignment(pr) && ts.isIdentifier(pr.name)) {
661
+ const t = taintOf(pr.initializer);
662
+ if (t?.kind === "name")
663
+ fields.set(pr.name.text, t);
664
+ }
665
+ else if (ts.isShorthandPropertyAssignment(pr)) {
666
+ const t = lookup(pr.name);
667
+ if (t?.kind === "name")
668
+ fields.set(pr.name.text, t);
669
+ }
670
+ else if (ts.isSpreadAssignment(pr)) {
671
+ const t = taintOf(pr.expression);
672
+ if (t?.kind === "obj")
673
+ whole = t;
674
+ }
675
+ }
676
+ if (whole && !fields.size)
677
+ return whole;
678
+ if (!fields.size)
679
+ return undefined;
680
+ const origin = [...fields.values()][0]?.origin ?? "";
681
+ return { kind: "obj", origin, fields };
682
+ }
683
+ if (ts.isNewExpression(e)) {
684
+ if (ts.isIdentifier(e.expression) &&
685
+ /^(Set|Array)$/.test(e.expression.text) &&
686
+ e.arguments?.[0])
687
+ return asName(taintOf(e.arguments[0]));
688
+ return undefined;
689
+ }
690
+ if (ts.isCallExpression(e)) {
691
+ const c = unwrap(e.expression);
692
+ if (ts.isPropertyAccessExpression(c)) {
693
+ const recv = taintOf(c.expression);
694
+ if (recv?.kind === "name" && TRANSFORMS.has(c.name.text))
695
+ return recv;
696
+ if (dotted(c) === "Array.from" &&
697
+ e.arguments[0] &&
698
+ taintOf(e.arguments[0])?.kind === "name")
699
+ return taintOf(e.arguments[0]);
700
+ return undefined;
701
+ }
702
+ if (ts.isIdentifier(c)) {
703
+ const tainted = e.arguments
704
+ .map((a, i) => [i, taintOf(a)])
705
+ .filter((x) => x[1] !== undefined);
706
+ if (!tainted.length)
707
+ return undefined;
708
+ const local = this.localFn(c, fn);
709
+ const targets = local
710
+ ? [local]
711
+ : this.defsFor(c, rel).map((d) => d.fn);
712
+ if (!targets.length) {
713
+ const n = tainted.find((x) => x[1].kind === "name");
714
+ return n && NORMALISER.test(c.text) ? n[1] : undefined;
715
+ }
716
+ if (local)
717
+ return undefined; // shares this env; read via returns below
718
+ // The memo is keyed by taint SHAPE, so a returned name carries the
719
+ // origin of whichever caller analysed the helper first; re-attach
720
+ // THIS caller's origin.
721
+ const mine = best(...tainted.map((x) => x[1]));
722
+ for (const f of targets) {
723
+ const r = this.analyse(f, new Map(tainted));
724
+ if (r?.kind === "name")
725
+ return mine ? { ...r, origin: mine.origin } : r;
726
+ }
727
+ }
728
+ }
729
+ return undefined;
730
+ };
731
+ // ---- verified values: the authenticated identity, the package principal
732
+ const verified = new Set();
733
+ const isVerified = (e0) => {
734
+ const e = unwrap(e0);
735
+ if (ts.isIdentifier(e)) {
736
+ const d = resolveDecl(e);
737
+ return !!d && verified.has(d);
738
+ }
739
+ if (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e))
740
+ return isVerified(e.expression);
741
+ if (ts.isCallExpression(e)) {
742
+ const c = unwrap(e.expression);
743
+ const name = ts.isPropertyAccessExpression(c)
744
+ ? c.name.text
745
+ : ts.isIdentifier(c)
746
+ ? c.text
747
+ : "";
748
+ if (VERIFIED_SOURCES.has(name))
749
+ return true;
750
+ // a normaliser of a verified value is still that value
751
+ return (ts.isPropertyAccessExpression(c) &&
752
+ TRANSFORMS.has(c.name.text) &&
753
+ isVerified(c.expression));
754
+ }
755
+ if (ts.isTemplateExpression(e))
756
+ return (e.templateSpans.length > 0 &&
757
+ e.templateSpans.every((s) => isVerified(s.expression)));
758
+ return false;
759
+ };
760
+ // ---- fixpoint over bindings --------------------------------------
761
+ for (let round = 0, grew = true; grew && round < 30; round++) {
762
+ grew = false;
763
+ walk(body, (x) => {
764
+ if (ts.isVariableDeclaration(x) && x.initializer) {
765
+ grew = bind(x.name, taintOf(x.initializer)) || grew;
766
+ if (isVerified(x.initializer))
767
+ for (const id of boundIds(x.name))
768
+ if (!verified.has(id)) {
769
+ verified.add(id);
770
+ grew = true;
771
+ }
772
+ // a helper's returned closure: `const r = helper(…)`
773
+ const i = unwrap(x.initializer);
774
+ if (ts.isIdentifier(x.name) &&
775
+ ts.isCallExpression(i) &&
776
+ ts.isIdentifier(unwrap(i.expression)) &&
777
+ !closures.has(x.name)) {
778
+ for (const d of this.defsFor(unwrap(i.expression), rel)) {
779
+ const ret = returnedFn(d.fn);
780
+ if (ret) {
781
+ closures.set(x.name, ret);
782
+ grew = true;
783
+ break;
784
+ }
785
+ }
786
+ }
787
+ }
788
+ if (ts.isBinaryExpression(x) &&
789
+ ts.isIdentifier(x.left) &&
790
+ (x.operatorToken.kind === ts.SyntaxKind.EqualsToken ||
791
+ x.operatorToken.kind ===
792
+ ts.SyntaxKind.QuestionQuestionEqualsToken ||
793
+ x.operatorToken.kind === ts.SyntaxKind.BarBarEqualsToken)) {
794
+ const d = resolveDecl(x.left);
795
+ if (d)
796
+ grew = bind(d, taintOf(x.right)) || grew;
797
+ }
798
+ if (ts.isForOfStatement(x) &&
799
+ ts.isVariableDeclarationList(x.initializer) &&
800
+ x.initializer.declarations[0]) {
801
+ const t = asName(taintOf(x.expression));
802
+ grew = bind(x.initializer.declarations[0].name, t) || grew;
803
+ }
804
+ if (ts.isCallExpression(x)) {
805
+ const c = unwrap(x.expression);
806
+ if (ts.isPropertyAccessExpression(c) && ITERATORS.has(c.name.text)) {
807
+ const recv = asName(taintOf(c.expression));
808
+ const cb = x.arguments[0] && unwrap(x.arguments[0]);
809
+ if (recv &&
810
+ cb &&
811
+ (ts.isArrowFunction(cb) || ts.isFunctionExpression(cb)) &&
812
+ cb.parameters[0])
813
+ grew = bind(cb.parameters[0].name, recv) || grew;
814
+ }
815
+ if (ts.isIdentifier(c)) {
816
+ const local = this.localFn(c, fn);
817
+ if (local)
818
+ grew = bindParams(local, x.arguments) || grew;
819
+ }
820
+ }
821
+ });
822
+ }
823
+ // ---- narrowing guards ----------------------------------------------
824
+ // `if (… || n !== <verified> || …) throw …` refuses every name but the
825
+ // caller's own: `n` is conform from the end of that `if` to the end of
826
+ // its block.
827
+ const narrowings = [];
828
+ walk(body, (x) => {
829
+ if (!ts.isIfStatement(x) || !refuses(x.thenStatement))
830
+ return;
831
+ for (const d of disjuncts(x.expression)) {
832
+ let cmp = d;
833
+ if (ts.isPrefixUnaryExpression(cmp) &&
834
+ cmp.operator === ts.SyntaxKind.ExclamationToken)
835
+ cmp = unwrap(cmp.operand);
836
+ if (!ts.isBinaryExpression(cmp))
837
+ continue;
838
+ const op = cmp.operatorToken.kind;
839
+ const negated = d !== cmp;
840
+ const mismatch = negated
841
+ ? op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
842
+ op === ts.SyntaxKind.EqualsEqualsToken
843
+ : op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
844
+ op === ts.SyntaxKind.ExclamationEqualsToken;
845
+ if (!mismatch)
846
+ continue;
847
+ const lt = asName(taintOf(cmp.left));
848
+ const rt = asName(taintOf(cmp.right));
849
+ const t = lt && isVerified(cmp.right)
850
+ ? lt
851
+ : rt && isVerified(cmp.left)
852
+ ? rt
853
+ : undefined;
854
+ if (t)
855
+ narrowings.push({
856
+ origin: t.origin,
857
+ block: x.parent,
858
+ after: x.end,
859
+ });
860
+ }
861
+ }, true);
862
+ const narrowed = (t, node) => narrowings.some((g) => g.origin === t.origin &&
863
+ node.pos >= g.after &&
864
+ node.end <= g.block.end &&
865
+ node.pos >= g.block.pos);
866
+ // ---- sinks and onward calls --------------------------------------
867
+ const site = (node, kind, t) => {
868
+ if (narrowed(t, node))
869
+ return;
870
+ const n = lineOf(node);
871
+ const key = `${rel}:${n}`;
872
+ if (this.sites.has(key))
873
+ return;
874
+ this.sites.set(key, {
875
+ rel,
876
+ n,
877
+ note: `${ownerName(node)}: (1) recipient resolved by name — ${kind} (\`${snippet(node)}\`, line ${n}) from ${t.origin}, instead of ctx.db.normalizeId + ctx.db.get on the agent's ID`,
878
+ });
879
+ };
880
+ /** `n` compared with `other`: red when `n` is a listed name and `other`
881
+ * is not a literal, an ID or a verified value — or, for any name, when
882
+ * `other` is a stored agent-identity field of a row. */
883
+ const compared = (node, n, other) => {
884
+ const t = asName(taintOf(n));
885
+ if (!t || asName(taintOf(other)))
886
+ return false;
887
+ if (isLiteralish(other) || isIdish(other) || isVerified(other))
888
+ return false;
889
+ if (t.weak && !isSelectorProp(other))
890
+ return false;
891
+ site(node, "name equality", t);
892
+ return true;
893
+ };
894
+ const routedIn = (t, table) => routingFieldsOf(table).filter((f) => this.field(t, f));
895
+ walk(body, (x) => {
896
+ if (ts.isBinaryExpression(x)) {
897
+ const op = x.operatorToken.kind;
898
+ if (op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
899
+ op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
900
+ op === ts.SyntaxKind.EqualsEqualsToken ||
901
+ op === ts.SyntaxKind.ExclamationEqualsToken) {
902
+ if (!compared(x, x.left, x.right))
903
+ compared(x, x.right, x.left);
904
+ }
905
+ // `n in byName`
906
+ if (op === ts.SyntaxKind.InKeyword) {
907
+ const t = strong(taintOf(x.left));
908
+ if (t && !taintOf(x.right) && !isLiteralCollection(x.right))
909
+ site(x, "membership in a set of names", t);
910
+ }
911
+ }
912
+ // `byName[n]` read as a lookup (an assignment target builds, not reads)
913
+ if (ts.isElementAccessExpression(x)) {
914
+ const t = strong(taintOf(x.argumentExpression));
915
+ const assigned = ts.isBinaryExpression(x.parent) &&
916
+ x.parent.left === x &&
917
+ x.parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
918
+ x.parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment;
919
+ if (t &&
920
+ !assigned &&
921
+ !taintOf(x.expression) &&
922
+ !isLiteralCollection(x.expression))
923
+ site(x, "membership in a set of names", t);
924
+ }
925
+ // `switch (n) { case x: }` — each non-literal case is an equality
926
+ if (ts.isCaseClause(x) &&
927
+ ts.isSwitchStatement(x.parent.parent) &&
928
+ !asName(taintOf(x.expression))) {
929
+ const t = strong(taintOf(x.parent.parent.expression));
930
+ if (t &&
931
+ !isLiteralish(x.expression) &&
932
+ !isIdish(x.expression) &&
933
+ !isVerified(x.expression))
934
+ site(x, "name equality", t);
935
+ }
936
+ if ((ts.isPropertyAssignment(x) || ts.isShorthandPropertyAssignment(x)) &&
937
+ ts.isIdentifier(x.name) &&
938
+ ts.isObjectLiteralExpression(x.parent)) {
939
+ const write = dbWriteOf(x.parent);
940
+ if (write &&
941
+ routingFieldsOf(tableOfWrite(write)).includes(x.name.text)) {
942
+ const t = asName(ts.isPropertyAssignment(x)
943
+ ? taintOf(x.initializer)
944
+ : lookup(x.name));
945
+ if (t)
946
+ site(x, "routing field written from a name", t);
947
+ }
948
+ }
949
+ // `{ ...args }` into a write carries every routing argument it holds
950
+ if (ts.isSpreadAssignment(x) && ts.isObjectLiteralExpression(x.parent)) {
951
+ const write = dbWriteOf(x.parent);
952
+ const t = write && taintOf(x.expression);
953
+ if (write && t?.kind === "obj") {
954
+ const routed = routedIn(t, tableOfWrite(write));
955
+ const first = routed[0] && this.field(t, routed[0]);
956
+ if (first)
957
+ site(x, `routing field written from a name (spread carries ${routed.join(", ")})`, first);
958
+ }
959
+ }
960
+ if (!ts.isCallExpression(x))
961
+ return;
962
+ const c = unwrap(x.expression);
963
+ // `db.patch(id, fields)` with `fields` the args object (or a rest of it)
964
+ const data = x.arguments[x.arguments.length - 1];
965
+ if (data &&
966
+ DB_WRITE.test(dotted(c) ?? "") &&
967
+ !ts.isObjectLiteralExpression(unwrap(data))) {
968
+ const t = taintOf(data);
969
+ if (t?.kind === "obj") {
970
+ const routed = routedIn(t, tableOfWrite(x));
971
+ const first = routed[0] && this.field(t, routed[0]);
972
+ if (first)
973
+ site(data, `routing field written from a name (the written object carries ${routed.join(", ")})`, first);
974
+ }
975
+ }
976
+ if (ts.isPropertyAccessExpression(c)) {
977
+ const m = c.name.text;
978
+ const a0 = x.arguments[0];
979
+ if (MEMBERSHIP.has(m) && a0) {
980
+ const t = strong(taintOf(a0));
981
+ if (t && !taintOf(c.expression) && !isLiteralCollection(c.expression))
982
+ site(x, "membership in a set of names", t);
983
+ }
984
+ if (STRING_COMPARE.has(m) && a0) {
985
+ if (!compared(x, c.expression, a0))
986
+ compared(x, a0, c.expression);
987
+ }
988
+ const ix = m === "eq" && x.arguments[1] ? indexCallOf(x) : undefined;
989
+ if (ix && x.arguments[1]) {
990
+ const t = asName(taintOf(x.arguments[1]));
991
+ const f = eqField(x);
992
+ const table = ts.isPropertyAccessExpression(ix.expression)
993
+ ? tableOfChain(ix.expression.expression)
994
+ : undefined;
995
+ if (t && (!t.weak || (f !== undefined && isSelectorField(f, table))))
996
+ site(x.arguments[1], "index/filter keyed by a name", t);
997
+ }
998
+ return;
999
+ }
1000
+ if (!ts.isIdentifier(c))
1001
+ return;
1002
+ const seedsOut = new Map();
1003
+ x.arguments.forEach((a, i) => {
1004
+ const t = taintOf(a);
1005
+ if (t && !(t.kind === "name" && narrowed(t, x)))
1006
+ seedsOut.set(i, t);
1007
+ });
1008
+ if (!seedsOut.size || this.localFn(c, fn))
1009
+ return;
1010
+ const d = resolveDecl(c);
1011
+ const closure = d ? closures.get(d) : undefined;
1012
+ if (closure) {
1013
+ this.queue.push({ fn: closure, seeds: seedsOut });
1014
+ return;
1015
+ }
1016
+ for (const def of this.defsFor(c, rel))
1017
+ this.queue.push({ fn: def.fn, seeds: seedsOut });
1018
+ });
1019
+ // ---- what this function returns ------------------------------------
1020
+ const rets = [];
1021
+ if (!ts.isBlock(body))
1022
+ rets.push(body);
1023
+ else
1024
+ walk(body, (y) => {
1025
+ if (ts.isReturnStatement(y) && y.expression)
1026
+ rets.push(y.expression);
1027
+ }, true);
1028
+ for (const r of rets) {
1029
+ const t = taintOf(r);
1030
+ if (t) {
1031
+ slot.returns = t;
1032
+ break;
1033
+ }
1034
+ }
1035
+ return slot.returns;
1036
+ }
1037
+ /** A field of an obj taint: a door's recipient argument, or a carried name. */
1038
+ field(t, key) {
1039
+ if (t.kind !== "obj")
1040
+ return undefined;
1041
+ if (t.fields)
1042
+ return t.fields.get(key);
1043
+ if (t.idArgs?.has(key))
1044
+ return undefined;
1045
+ if (t.declared && !t.declared.has(key))
1046
+ return undefined;
1047
+ const origin = `${t.origin}.${key}`;
1048
+ if (RECIPIENT_ARGS.has(key) || READER_ARGS.has(key))
1049
+ return { kind: "name", origin };
1050
+ // Any other declared argument is a weak name; a write door's own sender
1051
+ // is the acting principal, never a name of this clause.
1052
+ if (!t.declared?.has(key) || (t.write && SENDER_ARGS.has(key)))
1053
+ return undefined;
1054
+ return { kind: "name", origin, weak: true };
1055
+ }
1056
+ /** A function declared INSIDE `unit` (shares its environment). */
1057
+ localFn(c, unit) {
1058
+ const d = resolveDecl(c);
1059
+ const f = d && fnOfDecl(d);
1060
+ if (!f)
1061
+ return undefined;
1062
+ for (let q = f.parent; q; q = q.parent)
1063
+ if (q === unit)
1064
+ return f;
1065
+ return undefined;
1066
+ }
1067
+ /** Module-level definitions a callee names: same file first, else by name. */
1068
+ defsFor(c, rel) {
1069
+ const all = this.fns.get(c.text) ?? [];
1070
+ const same = all.filter((d) => d.rel === rel);
1071
+ return same.length ? same : all;
1072
+ }
1073
+ }
1074
+ function asName(t) {
1075
+ return t?.kind === "name" ? t : undefined;
1076
+ }
1077
+ /** The strongest name among candidates: a listed name before a weak one. */
1078
+ function best(...ts0) {
1079
+ const names = ts0.map(asName).filter((t) => t !== undefined);
1080
+ return names.find((t) => !t.weak) ?? names[0];
1081
+ }
1082
+ /** A listed (strong) name: red at every sink, not only at a selector field. */
1083
+ function strong(t) {
1084
+ return t?.kind === "name" && !t.weak ? t : undefined;
1085
+ }
1086
+ /** The function a helper returns (`return async (name) => …`), if any. */
1087
+ function returnedFn(fn) {
1088
+ const body = fn.body;
1089
+ if (!body)
1090
+ return undefined;
1091
+ if (!ts.isBlock(body)) {
1092
+ const u = unwrap(body);
1093
+ return ts.isArrowFunction(u) || ts.isFunctionExpression(u) ? u : undefined;
1094
+ }
1095
+ let found;
1096
+ walk(body, (y) => {
1097
+ if (found || !ts.isReturnStatement(y) || !y.expression)
1098
+ return;
1099
+ const u = unwrap(y.expression);
1100
+ if (ts.isArrowFunction(u) || ts.isFunctionExpression(u))
1101
+ found = u;
1102
+ }, true);
1103
+ return found;
1104
+ }
1105
+ /** Every recipient-by-name site reachable from a convex/ write door. */
1106
+ export function recipientByNameSites(parsed, fns) {
1107
+ return new RecipientFlow(parsed, fns).run();
1108
+ }