@vantageos/backend-doctor 0.4.0 → 0.4.3

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.
@@ -71,7 +71,7 @@
71
71
  // per-module split, and gives a non-zero exit.
72
72
  import * as ts from "typescript";
73
73
  import { siteCount, withCount } from "../../violation-count.js";
74
- import { calleeName, dotted, lineOf, parseFile, propRead, propReadResolved, sourceOf, unwrap, walk, } from "./_ast.js";
74
+ import { calleeName, dotted, indexFunctions, lineOf, parseFile, propRead, propReadResolved, sourceOf, unwrap, walk, } from "./_ast.js";
75
75
  import { isConvexOrMcp, isJudgeable, } from "./_scan.js";
76
76
  import { conformantMappingReads, IDENTITY_PACKAGE, scanR53Detailed, } from "./identity-by-id.js";
77
77
  export const CR2_ID = "CR-2 no application defines its own identity primitive (tenant resolution, membership, scope filtering, credential validation, identification by name); identity lives only in @vantageos/cloud-identity";
@@ -301,8 +301,435 @@ const isNormaliserCall = (e) => {
301
301
  const u = unwrap(e);
302
302
  return ts.isCallExpression(u) && NAME_NORMALISER.test(calleeName(u) ?? "");
303
303
  };
304
+ // ---- forwarding a verified claim ------------------------------------------
305
+ //
306
+ // A function that copies a verified-claim field (`oauthCtx.fromAllowList`) into
307
+ // the arguments of a backend call decides nothing from it: the door it calls
308
+ // does the deciding. That is the CONFORMING shape — the claim travels to the
309
+ // one place allowed to judge it. vantage-peers 9e224b2 `rosterClaimArgs`
310
+ // (mcp-server/src/tools.ts:1849) needed an `allow-local-identity:` marker only
311
+ // because the read was taken for a filter.
312
+ //
313
+ // Credited ONLY when, mechanically:
314
+ // - the call it reaches is a BACKEND DOOR (`doorCheckIn`:
315
+ // `convex|client|<x>Client .query/.mutation/.action`, `<ctx>.runQuery/
316
+ // runMutation/runAction`, on a receiver whose BINDING is a parameter, a
317
+ // package import, a constructed client or a judged client wrapper — never
318
+ // its name alone, Eta probe V5). A claim handed to a function defined in the tree,
319
+ // to an unknown helper, or to a storage write is NOT forwarded: that callee
320
+ // is where the decision would move (`etaAdmits(rosterClaimArgs(ctx), who)`),
321
+ // and nothing judges it here (Eta on #68, probe V2);
322
+ // - the read's value reaches a call ARGUMENT (or an object literal that is a
323
+ // call argument) through nothing but parentheses, casts, non-null, and a
324
+ // `?? <literal | [] | {}>` / `|| <…>` fallback — any other use (a `.filter`,
325
+ // `.includes`, a comparison, a local binding) is not a forward;
326
+ // - or that object literal is RETURNED by a named function (a declaration, or
327
+ // a `const` bound to an arrow / function expression) and EVERY reference to
328
+ // that name in the tree is a call whose result is itself handed to a
329
+ // backend door's argument — directly, spread into the arguments, or spread into an object
330
+ // literal that is a call argument. A function nobody calls, a callback, a
331
+ // property-assigned arrow, or one caller that keeps the result (to compare
332
+ // it, to bind it, to return it) credits nothing: a tool does not clear a
333
+ // site whose consumer nobody judged;
334
+ // - the receiver is not the caller's own `args` (a client-supplied list is not
335
+ // a verified claim);
336
+ // - the function holding it makes NO identity decision: no equality between
337
+ // two non-literal values, no equality reading a name or slug field, no
338
+ // membership test (`includes`/`has`/`indexOf`/`some`/`every`) on a
339
+ // non-literal, and no refusing `if` (then-arm returns or throws) whose
340
+ // condition reads a field of the object the claim is read off. Any one of
341
+ // these keeps the read red.
342
+ const FALLBACK_OPS = new Set([
343
+ ts.SyntaxKind.QuestionQuestionToken,
344
+ ts.SyntaxKind.BarBarToken,
345
+ ]);
346
+ const isInertFallback = (e) => {
347
+ const u = unwrap(e);
348
+ return (isLiteral(u) ||
349
+ (ts.isArrayLiteralExpression(u) && u.elements.length === 0) ||
350
+ (ts.isObjectLiteralExpression(u) && u.properties.length === 0));
351
+ };
352
+ const rootIdentifier = (e) => {
353
+ let x = unwrap(e);
354
+ for (;;) {
355
+ const r = propRead(x);
356
+ if (r)
357
+ x = unwrap(r.obj);
358
+ else if (ts.isCallExpression(x))
359
+ return undefined;
360
+ else
361
+ return ts.isIdentifier(x) ? x : undefined;
362
+ }
363
+ };
364
+ /** The calls allowed to judge a forwarded claim: a Convex door reached
365
+ * through a client (`convex.query/mutation/action`, `client.…`,
366
+ * `<x>Client.…`) or from a function context (`<ctx>.runQuery/runMutation/
367
+ * runAction`). Never a function defined in the tree, never a storage write
368
+ * (`ctx.db.patch` / `insert`), never an unknown helper: none of those judges
369
+ * the claim, so handing it to one is not forwarding it.
370
+ *
371
+ * The door is recognised by its receiver's BINDING, never by its name alone
372
+ * (Eta on #68, probe V5: a local `const etaClient = { query: <decides> }`
373
+ * named like a client). The receiver must be a plain identifier that resolves,
374
+ * in its own file, to one of:
375
+ * - a parameter of an enclosing function (the caller hands the client in);
376
+ * - an import from a PACKAGE (a non-relative specifier);
377
+ * - a `const` built by a known client constructor (`new ConvexHttpClient(…)`,
378
+ * `new ConvexClient(…)`, `new ConvexReactClient(…)`);
379
+ * - a `const` built by a CLIENT WRAPPER `W(<receiver>, …)` whose first
380
+ * argument itself qualifies, where every definition of `W` in the tree takes
381
+ * the client as its first parameter and returns only that parameter or
382
+ * `new Proxy(<that parameter>, …)` (vantage-peers `withVerifiedActor`).
383
+ * A `W` with no definition in the tree credits nothing.
384
+ * Anything else is refused: an object literal, an arrow or function, a class,
385
+ * a `let`, a loop variable, a relative (tree) import, an unresolved name, or a
386
+ * property-access receiver (`ctx.db.query`, `deps.client.query`).
387
+ * NOT JUDGED (declared): the handler of a `new Proxy(<client>, handler)`
388
+ * wrapper; such a handler could decide, and is credited as a pass-through. */
389
+ const CLIENT_DOOR_METHODS = new Set([
390
+ "query",
391
+ "mutation",
392
+ "action",
393
+ ]);
394
+ const CTX_DOOR_METHODS = new Set([
395
+ "runQuery",
396
+ "runMutation",
397
+ "runAction",
398
+ ]);
399
+ const CLIENT_RECEIVER = /^(convex|client|[A-Za-z_$][\w$]*Client)$/;
400
+ const CLIENT_CONSTRUCTORS = new Set([
401
+ "ConvexHttpClient",
402
+ "ConvexClient",
403
+ "ConvexReactClient",
404
+ ]);
405
+ /** Does a binding name (identifier or destructuring pattern) bind `name`? */
406
+ function bindsName(b, name) {
407
+ if (ts.isIdentifier(b))
408
+ return b.text === name;
409
+ const els = b.elements;
410
+ return els.some((e) => !ts.isOmittedExpression(e) && bindsName(e.name, name));
411
+ }
412
+ /** The declaration `id` resolves to, by lexical scope, in its own file. */
413
+ function bindingOf(id) {
414
+ const name = id.text;
415
+ for (let n = id.parent; n; n = n.parent) {
416
+ if (ts.isFunctionLike(n)) {
417
+ if (n.parameters.some((prm) => bindsName(prm.name, name)))
418
+ return { kind: "param" };
419
+ if (ts.isFunctionExpression(n) && n.name?.text === name)
420
+ return { kind: "other" };
421
+ }
422
+ if ((ts.isForStatement(n) || ts.isForOfStatement(n) || ts.isForInStatement(n)) &&
423
+ n.initializer &&
424
+ ts.isVariableDeclarationList(n.initializer) &&
425
+ n.initializer.declarations.some((d) => bindsName(d.name, name)))
426
+ return { kind: "other" };
427
+ if (ts.isCatchClause(n) &&
428
+ n.variableDeclaration &&
429
+ bindsName(n.variableDeclaration.name, name))
430
+ return { kind: "other" };
431
+ const stmts = ts.isBlock(n) ||
432
+ ts.isSourceFile(n) ||
433
+ ts.isModuleBlock(n) ||
434
+ ts.isCaseClause(n) ||
435
+ ts.isDefaultClause(n)
436
+ ? n.statements
437
+ : undefined;
438
+ if (!stmts)
439
+ continue;
440
+ for (const st of stmts) {
441
+ if (ts.isVariableStatement(st)) {
442
+ const isConst = (st.declarationList.flags & ts.NodeFlags.Const) !== 0;
443
+ for (const d of st.declarationList.declarations) {
444
+ if (!bindsName(d.name, name))
445
+ continue;
446
+ return isConst && ts.isIdentifier(d.name)
447
+ ? { kind: "const", init: d.initializer }
448
+ : { kind: "other" };
449
+ }
450
+ }
451
+ if ((ts.isFunctionDeclaration(st) ||
452
+ ts.isClassDeclaration(st) ||
453
+ ts.isEnumDeclaration(st) ||
454
+ ts.isModuleDeclaration(st)) &&
455
+ st.name &&
456
+ ts.isIdentifier(st.name) &&
457
+ st.name.text === name)
458
+ return { kind: "other" };
459
+ if (ts.isImportDeclaration(st) &&
460
+ ts.isStringLiteral(st.moduleSpecifier)) {
461
+ const c = st.importClause;
462
+ const b = c?.namedBindings;
463
+ const hit = c?.name?.text === name ||
464
+ (b !== undefined &&
465
+ (ts.isNamespaceImport(b)
466
+ ? b.name.text === name
467
+ : b.elements.some((e) => e.name.text === name)));
468
+ if (hit)
469
+ return { kind: "import", spec: st.moduleSpecifier.text };
470
+ }
471
+ }
472
+ }
473
+ return undefined;
474
+ }
475
+ /** Own return expressions of `fn` (nested functions excluded); a concise
476
+ * arrow body counts as its one return. */
477
+ function ownReturns(fn) {
478
+ if (!fn.body)
479
+ return [];
480
+ if (!ts.isBlock(fn.body))
481
+ return [fn.body];
482
+ const out = [];
483
+ const visit = (x) => {
484
+ if (ts.isFunctionLike(x) || ts.isClassLike(x))
485
+ return;
486
+ if (ts.isReturnStatement(x))
487
+ out.push(x.expression);
488
+ ts.forEachChild(x, visit);
489
+ };
490
+ ts.forEachChild(fn.body, visit);
491
+ return out;
492
+ }
493
+ /** `fn` takes the client first and hands back only that client, bare or behind
494
+ * `new Proxy(<client>, …)`. */
495
+ function isClientWrapper(fn) {
496
+ const p0 = fn.parameters[0]?.name;
497
+ if (!p0 || !ts.isIdentifier(p0))
498
+ return false;
499
+ const isP0 = (e) => {
500
+ if (!e)
501
+ return false;
502
+ const u = unwrap(e);
503
+ return ts.isIdentifier(u) && u.text === p0.text;
504
+ };
505
+ const rets = ownReturns(fn);
506
+ return (rets.length > 0 &&
507
+ rets.every((r) => {
508
+ if (isP0(r))
509
+ return true;
510
+ const u = r && unwrap(r);
511
+ return (!!u &&
512
+ ts.isNewExpression(u) &&
513
+ ts.isIdentifier(u.expression) &&
514
+ u.expression.text === "Proxy" &&
515
+ isP0(u.arguments?.[0]));
516
+ }));
517
+ }
518
+ /** The door check over a parsed tree (client wrappers resolve by name). */
519
+ function doorCheckIn(parsed) {
520
+ const idx = indexFunctions(parsed);
521
+ const wrapperMemo = new Map();
522
+ const wrapperOk = (name) => {
523
+ let v = wrapperMemo.get(name);
524
+ if (v === undefined) {
525
+ const defs = idx.get(name) ?? [];
526
+ v = defs.length > 0 && defs.every((d) => isClientWrapper(d.fn));
527
+ wrapperMemo.set(name, v);
528
+ }
529
+ return v;
530
+ };
531
+ const clientBinding = (id, depth) => {
532
+ if (depth > 4)
533
+ return false;
534
+ const b = bindingOf(id);
535
+ if (!b)
536
+ return false;
537
+ if (b.kind === "param")
538
+ return true;
539
+ if (b.kind === "import")
540
+ return !b.spec.startsWith(".") && !b.spec.startsWith("/");
541
+ if (b.kind !== "const" || !b.init)
542
+ return false;
543
+ const init = unwrap(b.init);
544
+ if (ts.isNewExpression(init) &&
545
+ ts.isIdentifier(init.expression) &&
546
+ CLIENT_CONSTRUCTORS.has(init.expression.text))
547
+ return true;
548
+ if (ts.isCallExpression(init) && ts.isIdentifier(init.expression)) {
549
+ const a0 = init.arguments[0] && unwrap(init.arguments[0]);
550
+ return (!!a0 &&
551
+ ts.isIdentifier(a0) &&
552
+ clientBinding(a0, depth + 1) &&
553
+ wrapperOk(init.expression.text));
554
+ }
555
+ return false;
556
+ };
557
+ return (call) => {
558
+ const c = unwrap(call.expression);
559
+ if (!ts.isPropertyAccessExpression(c))
560
+ return false;
561
+ const m = c.name.text;
562
+ const recv = unwrap(c.expression);
563
+ if (!ts.isIdentifier(recv))
564
+ return false;
565
+ if (CTX_DOOR_METHODS.has(m))
566
+ return clientBinding(recv, 0);
567
+ if (!CLIENT_DOOR_METHODS.has(m))
568
+ return false;
569
+ return CLIENT_RECEIVER.test(recv.text) && clientBinding(recv, 0);
570
+ };
571
+ }
572
+ /** Climb from `n` through wrappers and object-literal nesting; true when it
573
+ * lands in a BACKEND DOOR call's arguments (`isBackendDoor`). `inObject` reports whether an object literal was
574
+ * climbed (the caller of a returned literal needs one). */
575
+ function climbToArgument(n, isBackendDoor, onReturn) {
576
+ let cur = n;
577
+ let inObject = false;
578
+ for (let p = cur.parent; p; cur = p, p = p.parent) {
579
+ if (ts.isParenthesizedExpression(p) ||
580
+ ts.isNonNullExpression(p) ||
581
+ ts.isAsExpression(p) ||
582
+ ts.isSatisfiesExpression(p) ||
583
+ ts.isAwaitExpression(p))
584
+ continue;
585
+ if (ts.isBinaryExpression(p) &&
586
+ FALLBACK_OPS.has(p.operatorToken.kind) &&
587
+ p.left === cur &&
588
+ isInertFallback(p.right))
589
+ continue;
590
+ if ((ts.isPropertyAssignment(p) && p.initializer === cur) ||
591
+ (ts.isSpreadAssignment(p) && p.expression === cur)) {
592
+ p = p.parent; // the object literal
593
+ inObject = true;
594
+ continue;
595
+ }
596
+ if (ts.isSpreadElement(p) && p.expression === cur) {
597
+ if (p.parent && ts.isCallExpression(p.parent))
598
+ return (isBackendDoor(p.parent) && p.parent.arguments.some((a) => a === p));
599
+ return false;
600
+ }
601
+ if (ts.isCallExpression(p))
602
+ return isBackendDoor(p) && p.arguments.some((a) => a === cur);
603
+ if (!onReturn)
604
+ return false;
605
+ if (ts.isReturnStatement(p) && p.expression === cur) {
606
+ let fn = p.parent;
607
+ while (fn && !ts.isFunctionLike(fn))
608
+ fn = fn.parent;
609
+ return !!fn && onReturn(fn, inObject);
610
+ }
611
+ if (ts.isArrowFunction(p) && p.body === cur)
612
+ return onReturn(p, inObject);
613
+ return false;
614
+ }
615
+ return false;
616
+ }
617
+ /** The name a function is called by: a declaration's, or the `const` an arrow /
618
+ * function expression initialises. */
619
+ function calledName(fn) {
620
+ if (ts.isFunctionDeclaration(fn))
621
+ return fn.name?.text;
622
+ if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
623
+ fn.parent &&
624
+ ts.isVariableDeclaration(fn.parent) &&
625
+ fn.parent.initializer === fn &&
626
+ ts.isIdentifier(fn.parent.name))
627
+ return fn.parent.name.text;
628
+ return undefined;
629
+ }
630
+ function callersForwardIn(parsed, isBackendDoor) {
631
+ const memo = new Map();
632
+ return (name) => {
633
+ const hit = memo.get(name);
634
+ if (hit !== undefined)
635
+ return hit;
636
+ let calls = 0;
637
+ let ok = true;
638
+ for (const p of parsed) {
639
+ if (!ok)
640
+ break;
641
+ if (!p.sf.text.includes(name))
642
+ continue;
643
+ walk(p.sf, (x) => {
644
+ if (!ok || !ts.isIdentifier(x) || x.text !== name)
645
+ return;
646
+ const par = x.parent;
647
+ if ((ts.isFunctionDeclaration(par) && par.name === x) ||
648
+ (ts.isVariableDeclaration(par) && par.name === x) ||
649
+ ts.isImportSpecifier(par) ||
650
+ ts.isExportSpecifier(par) ||
651
+ ts.isImportClause(par) ||
652
+ (ts.isPropertyAccessExpression(par) && par.name === x) ||
653
+ (ts.isPropertyAssignment(par) && par.name === x))
654
+ return;
655
+ if (ts.isCallExpression(par) &&
656
+ par.expression === x &&
657
+ climbToArgument(par, isBackendDoor)) {
658
+ calls++;
659
+ return;
660
+ }
661
+ ok = false;
662
+ });
663
+ }
664
+ const v = ok && calls > 0;
665
+ memo.set(name, v);
666
+ return v;
667
+ };
668
+ }
669
+ /** Does the read `n` (receiver `obj`) only travel into a call's arguments? */
670
+ function forwardsOnly(n, obj, callersForward, isBackendDoor) {
671
+ if (rootIdentifier(obj)?.text === "args")
672
+ return false;
673
+ return climbToArgument(n, isBackendDoor, (fn, inObject) => {
674
+ if (!inObject)
675
+ return false;
676
+ const name = calledName(fn);
677
+ return name !== undefined && callersForward(name);
678
+ });
679
+ }
680
+ const NAME_OR_SLUG = new Set([
681
+ ...NAME_FIELDS,
682
+ "slug",
683
+ "orgSlug",
684
+ "clerkOrgSlug",
685
+ ]);
686
+ /** The first node of `owner` that decides on identity, or undefined. */
687
+ function decidesOnIdentity(owner,
688
+ /** the root identifier the forwarded claim is read off (`mapping`) */
689
+ root) {
690
+ let hit;
691
+ walk(owner, (x) => {
692
+ if (hit)
693
+ return;
694
+ // a refusing gate that judges a field of the very object the claim is
695
+ // forwarded from (`if (!mapping || !mapping.isActive) return …`): the
696
+ // function admits on that object, it does not merely forward it
697
+ if (root !== undefined && ts.isIfStatement(x) && exits(x.thenStatement)) {
698
+ let reads = false;
699
+ walk(x.expression, (y) => {
700
+ const r = propRead(y);
701
+ if (r && rootIdentifier(r.obj)?.text === root)
702
+ reads = true;
703
+ });
704
+ if (reads) {
705
+ hit = x.expression;
706
+ return;
707
+ }
708
+ }
709
+ if (ts.isBinaryExpression(x) && EQ.has(x.operatorToken.kind)) {
710
+ const named = [x.left, x.right].some((s) => {
711
+ const r = propReadResolved(s);
712
+ return ((r !== undefined && NAME_OR_SLUG.has(r.name)) ||
713
+ (ts.isIdentifier(unwrap(s)) &&
714
+ NAME_OR_SLUG.has(unwrap(s).text)));
715
+ });
716
+ if (named || (!isLiteral(x.left) && !isLiteral(x.right)))
717
+ hit = x;
718
+ return;
719
+ }
720
+ if (ts.isCallExpression(x)) {
721
+ const c = unwrap(x.expression);
722
+ if (ts.isPropertyAccessExpression(c) &&
723
+ MEMBER_TEST.has(c.name.text) &&
724
+ x.arguments[0] &&
725
+ !isLiteral(x.arguments[0]))
726
+ hit = x;
727
+ }
728
+ });
729
+ return hit;
730
+ }
304
731
  /** The primitives this module adds beside R-53's, one hit per deciding node. */
305
- function ownHits(p, okReads) {
732
+ function ownHits(p, okReads, callersForward, isBackendDoor) {
306
733
  const out = [];
307
734
  const jwt = jwtBindings(p);
308
735
  walk(p.sf, (x) => {
@@ -389,10 +816,23 @@ function ownHits(p, okReads) {
389
816
  });
390
817
  }
391
818
  }
392
- // scope: a read of an allow-list field
819
+ // scope: a read of an allow-list field — unless it only FORWARDS the
820
+ // verified claim into call arguments from a function that decides nothing
393
821
  const rd = propRead(x);
394
- if (rd && SCOPE_PROPS.has(rd.name))
395
- out.push({ node: x, kind: "scope", why: `reads \`${snippet(x)}\`` });
822
+ if (rd && SCOPE_PROPS.has(rd.name)) {
823
+ const fwd = forwardsOnly(x, rd.obj, callersForward, isBackendDoor);
824
+ const decision = fwd
825
+ ? decidesOnIdentity(ownerOf(x, p.rel).node, rootIdentifier(rd.obj)?.text)
826
+ : undefined;
827
+ if (!fwd)
828
+ out.push({ node: x, kind: "scope", why: `reads \`${snippet(x)}\`` });
829
+ else if (decision)
830
+ out.push({
831
+ node: x,
832
+ kind: "scope",
833
+ why: `reads \`${snippet(x)}\` (forwarded, but the same function also decides: \`${snippet(decision)}\`)`,
834
+ });
835
+ }
396
836
  // membership: a gate on a role / master field
397
837
  if (ts.isIfStatement(x) && exits(x.thenStatement)) {
398
838
  const r = readsProp(x.expression, ROLE_PROPS);
@@ -545,8 +985,10 @@ export function scanLocalIdentity(files) {
545
985
  const o = r.atOwner ? ownerAtDecl(node, p.rel, r.n) : ownerOf(node, p.rel);
546
986
  note(o, r.kind, r.why);
547
987
  }
988
+ const isBackendDoor = doorCheckIn(parsed);
989
+ const callersForward = callersForwardIn(parsed, isBackendDoor);
548
990
  for (const p of parsed)
549
- for (const h of ownHits(p, okReads))
991
+ for (const h of ownHits(p, okReads, callersForward, isBackendDoor))
550
992
  note(ownerOf(h.node, p.rel), h.kind, `${h.why}, line ${lineOf(h.node)}`);
551
993
  for (const { owner, why } of [...sites.values()].sort((a, b) => a.owner.rel.localeCompare(b.owner.rel) || a.owner.n - b.owner.n)) {
552
994
  const site = { rel: owner.rel, n: owner.n };
package/dist/trigger.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // D6 — THE PATH-TRIGGER. Decides, given a list of changed paths, whether
2
2
  // backend-doctor MUST run. The backend-path shapes are DERIVED from what a
3
- // Convex backend actually IS (grounded against the real /root/coding/vantage-memory
3
+ // Convex backend actually IS (grounded against the real vantage-peers repository
4
4
  // layout, read-only):
5
5
  //
6
6
  // SHAPE (a) SCHEMA — convex/schema.ts (the defineSchema file).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vantageos/backend-doctor",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "A conformance instrument that measures the VantagePeers backend against the pinned backend standard. The THIRD doctor built ON the @vantageos/mcp-doctor engine — never a third engine. It consumes runDoctor/renderTable and adds only detectors.",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",