@vantageos/backend-doctor 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +19 -2
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +54 -0
  4. package/dist/build-stamp.d.ts +13 -0
  5. package/dist/build-stamp.js +76 -0
  6. package/dist/build-stamp.json +4 -0
  7. package/dist/caller-resolution.d.ts +222 -0
  8. package/dist/caller-resolution.js +1195 -0
  9. package/dist/cli.js +217 -10
  10. package/dist/coverage-cli.js +8 -0
  11. package/dist/crud-coverage.d.ts +81 -0
  12. package/dist/crud-coverage.js +375 -0
  13. package/dist/detectors/oracle-axis1.d.ts +5 -3
  14. package/dist/detectors/oracle-axis1.js +24 -15
  15. package/dist/detectors/oracle-axis2.js +5 -6
  16. package/dist/detectors/predicates.d.ts +3 -0
  17. package/dist/detectors/predicates.js +81 -16
  18. package/dist/detectors/shared.js +5 -5
  19. package/dist/detectors/source/_scan.js +6 -1
  20. package/dist/detectors/source/backfill.js +9 -1
  21. package/dist/detectors/source/guard-helper-flow.d.ts +112 -0
  22. package/dist/detectors/source/guard-helper-flow.js +1173 -0
  23. package/dist/detectors/source/identity.d.ts +51 -2
  24. package/dist/detectors/source/identity.js +815 -14
  25. package/dist/detectors/source/isolation.d.ts +11 -1
  26. package/dist/detectors/source/isolation.js +106 -25
  27. package/dist/detectors/source/jobs.js +165 -2
  28. package/dist/detectors/source/readbudget.d.ts +1 -0
  29. package/dist/detectors/source/readbudget.js +67 -4
  30. package/dist/detectors/source/security.d.ts +16 -0
  31. package/dist/detectors/source/security.js +225 -7
  32. package/dist/detectors/source/write-isolation.d.ts +8 -1
  33. package/dist/detectors/source/write-isolation.js +64 -30
  34. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  35. package/dist/detectors/standard/r46-returns-validator.js +45 -14
  36. package/dist/detectors/structural.js +14 -10
  37. package/dist/oracle.d.ts +92 -3
  38. package/dist/oracle.js +229 -12
  39. package/dist/registry.d.ts +7 -0
  40. package/dist/registry.js +48 -6
  41. package/dist/report.d.ts +11 -1
  42. package/dist/report.js +15 -2
  43. package/dist/state-rank.d.ts +5 -0
  44. package/dist/state-rank.js +9 -0
  45. package/dist/tally.d.ts +35 -0
  46. package/dist/tally.js +76 -8
  47. package/dist/target.d.ts +32 -0
  48. package/dist/target.js +173 -0
  49. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  50. package/fixtures/cr1-classes/surface.ts +206 -0
  51. package/fixtures/cr1-empty/nothing.ts +1 -0
  52. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  53. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  54. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  55. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  56. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  57. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  58. package/package.json +2 -2
package/dist/tally.js CHANGED
@@ -12,14 +12,13 @@
12
12
  import { computeExitCode } from "@vantageos/mcp-doctor";
13
13
  import { deriveRuleIds } from "./coverage.js";
14
14
  import { deriveSeverityMap } from "./severity.js";
15
- /** The four states, in worst-first order — the reduction picks the worst. */
16
- const STATE_RANK = {
17
- "non-conforming": 3,
18
- "could-not-judge": 2,
19
- absent: 1,
20
- present: 0,
21
- };
22
- const ALL_STATES = ["present", "non-conforming", "could-not-judge", "absent"];
15
+ import { STATE_RANK } from "./state-rank.js";
16
+ const ALL_STATES = [
17
+ "present",
18
+ "non-conforming",
19
+ "could-not-judge",
20
+ "absent",
21
+ ];
23
22
  /** Extract the ruleId (`R-N`) a result belongs to, from its leading id token. */
24
23
  export function ruleIdOf(result) {
25
24
  const m = /^(R-\d+)\b/.exec(result.id);
@@ -143,6 +142,75 @@ export function verdict(t, results, severity = deriveSeverityMap()) {
143
142
  summary,
144
143
  };
145
144
  }
145
+ /**
146
+ * The HANDLER-SURFACE rules — the source/*-isolation detectors that judge the
147
+ * public query/mutation surface (R-50 read-isolation, R-51 write-isolation).
148
+ * Reported APART from the document/oracle/process rules (R-32, R-37, R-40,
149
+ * R-48, …) so the handler-surface could-not-judge count is visible on its own
150
+ * and can be driven to 0 without conflating it with honest human-gate CNJ.
151
+ * Laurent doctrine: an instrument that cannot go red protects nothing — the
152
+ * handler-surface CNJ must reach 0 except genuine parse-fail / marker-borne /
153
+ * ambiguous-domination sites.
154
+ */
155
+ export const HANDLER_SURFACE_RULES = ["R-50", "R-51"];
156
+ // The per-rule breakdown token every source detect() emits: `could-not-judge=N`.
157
+ // The site figure is the sum of N across the handler-surface rules' results — the
158
+ // same N that names the sites under decide()'s CNJ label, never a re-derivation.
159
+ const CNJ_SITES_IN_VERDICT = /could-not-judge=(\d+)/;
160
+ /**
161
+ * The handler-surface slice of the tally, computed over HANDLER_SURFACE_RULES
162
+ * only. Reduces each rule to its worst result state (same reduction as `tally`)
163
+ * so a rule with any non-conforming result reads non-conforming, and surfaces a
164
+ * distinct handler-surface could-not-judge count the CLI prints separately.
165
+ */
166
+ export function handlerSurfaceStats(results, handlerRules = HANDLER_SURFACE_RULES) {
167
+ const set = new Set(handlerRules);
168
+ const ruleState = new Map();
169
+ // Sites abstaining is summed over the RAW results (before the rule reduction),
170
+ // so a non-conforming rule's abstaining sites are still counted — one entry per
171
+ // rule's single reduced result here, but robust to multi-result rules.
172
+ const cnjSitesByRule = new Map();
173
+ for (const r of results) {
174
+ const id = ruleIdOf(r);
175
+ if (!set.has(id))
176
+ continue;
177
+ const prev = ruleState.get(id);
178
+ if (prev === undefined || STATE_RANK[r.state] > STATE_RANK[prev]) {
179
+ ruleState.set(id, r.state);
180
+ }
181
+ const m = CNJ_SITES_IN_VERDICT.exec(r.verdict);
182
+ if (m)
183
+ cnjSitesByRule.set(id, (cnjSitesByRule.get(id) ?? 0) + Number(m[1]));
184
+ }
185
+ const byState = {
186
+ present: 0,
187
+ "non-conforming": 0,
188
+ "could-not-judge": 0,
189
+ absent: 0,
190
+ };
191
+ for (const s of ruleState.values())
192
+ byState[s] += 1;
193
+ let sitesAbstaining = 0;
194
+ for (const n of cnjSitesByRule.values())
195
+ sitesAbstaining += n;
196
+ return {
197
+ byState,
198
+ ruleState,
199
+ couldNotJudge: byState["could-not-judge"],
200
+ sitesAbstaining,
201
+ };
202
+ }
203
+ /** The one-line handler-surface tally string the CLI prints, apart from the
204
+ * document/oracle/process rules. */
205
+ export function handlerSurfaceLine(s) {
206
+ const ids = [...s.ruleState.entries()]
207
+ .map(([id, st]) => `${id}=${st}`)
208
+ .join(", ");
209
+ return (`HANDLER-SURFACE TALLY (${HANDLER_SURFACE_RULES.join("+")}, reported apart from doc/oracle/process rules): ` +
210
+ `present=${s.byState.present} non-conforming=${s.byState["non-conforming"]} ` +
211
+ `could-not-judge=${s.byState["could-not-judge"]} absent=${s.byState.absent}` +
212
+ `${ids ? ` [${ids}]` : ""} | rules abstaining=${s.couldNotJudge} | sites abstaining=${s.sitesAbstaining}`);
213
+ }
146
214
  /** The one-line machine tally string the CLI prints. */
147
215
  export function tallyLine(t) {
148
216
  return (`TALLY (by rule, ${t.ruleTotal} rules from ${t.resultTotal} results): ` +
@@ -0,0 +1,32 @@
1
+ import type { Detector, Tree } from "./engine-types.js";
2
+ export interface TargetScope {
3
+ /** the argument as given, resolved to an absolute path. */
4
+ input: string;
5
+ /** the repository root every `rel` is computed against. */
6
+ repoRoot: string;
7
+ /** which marker made `repoRoot` the root. */
8
+ repoRootBy: "package.json" | ".git" | "no marker (argument used as-is)";
9
+ /** the convex functions dir, or undefined when the repository has none. */
10
+ convexDir: string | undefined;
11
+ /** the directories actually walked, absolute, in order. */
12
+ scanRoots: string[];
13
+ }
14
+ /** Resolve a CLI argument to the backend it names. Pure over the filesystem. */
15
+ export declare function resolveTarget(arg: string): TargetScope;
16
+ /** The ONE tree every detector judges: the scan roots' files, `rel` relative to
17
+ * the repository root, `root` = the repository root. */
18
+ export declare function loadScopedTree(scope: TargetScope): Tree;
19
+ /** Files scanned per top-level directory (two segments under the repo root), so a
20
+ * reader sees WHAT was judged — an empty or surprising surface is visible. */
21
+ export declare function coverageInventory(tree: Tree): Map<string, number>;
22
+ export declare function describeScope(scope: TargetScope, tree: Tree): string;
23
+ /** Wrap detectors so each judges the scoped tree instead of the engine's
24
+ * per-argument tree. runDoctor still owns the loud-fail contract. */
25
+ export declare function onScopedTree(tree: Tree, detectors: Detector[]): Detector[];
26
+ /** Absolute convex dir for a scoped tree (tree.root = repo root) or for a tree
27
+ * rooted AT a convex dir (direct detector tests). `prefix` is what the tree's
28
+ * rels carry in front of a convex-relative path. */
29
+ export declare function convexRootOf(tree: Tree): {
30
+ dir: string;
31
+ prefix: string;
32
+ };
package/dist/target.js ADDED
@@ -0,0 +1,173 @@
1
+ // TARGET RESOLUTION — the run judges the same backend whichever path names it.
2
+ //
3
+ // The defect this closes: the engine's loadTree computes every file's `rel`
4
+ // relative to the path the CLI was given, so the SAME repository produced two
5
+ // different trees under the two documented invocations:
6
+ // - `backend-doctor <repo>/convex` → rels `messages.ts` (no `convex/` segment)
7
+ // - `backend-doctor <repo>` → rels `convex/messages.ts`, PLUS every other
8
+ // `.ts` file in the repository, including archived copies such as
9
+ // `_archive-v0.2/<shell>/convex/*.ts` that a `(^|/)convex/` predicate then
10
+ // judged as live backend source.
11
+ // Detectors keyed on the `convex/` prefix skipped every file in the first form and
12
+ // over-counted in the second; R-36 looked for the lockfile beside `convex/`; CR-1
13
+ // looked for `_generated/api.d.ts` beside the repository root. Measured on vCRM
14
+ // d47a6d4: R-16 2 vs 50 violations, R-6 could-not-judge vs 19 violations.
15
+ //
16
+ // The fix is ONE normalisation at the entry, never a per-detector patch:
17
+ // repoRoot nearest ancestor-or-self holding package.json or .git (a `convex`
18
+ // dir itself is never the repo root, even when it holds a package.json)
19
+ // convexDir the `convex` segment the argument names, else <repoRoot>/convex
20
+ // scanRoots convexDir and <repoRoot>/mcp-server/src (the two source surfaces the
21
+ // detectors' own predicates name), each loaded by the ENGINE's loader
22
+ // rel relative to repoRoot, so every convex file carries `convex/`
23
+ // tree.root repoRoot, so repository co-artifacts (lockfile, workflows) are found
24
+ // Both invocations therefore reduce to the same (repoRoot, convexDir, files) triple.
25
+ import { existsSync, statSync } from "node:fs";
26
+ import { basename, dirname, join, relative, resolve } from "node:path";
27
+ import { runDoctor } from "@vantageos/mcp-doctor";
28
+ function isDir(p) {
29
+ try {
30
+ return statSync(p).isDirectory();
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ function hasRepoMarker(dir) {
37
+ return existsSync(join(dir, "package.json")) || existsSync(join(dir, ".git"));
38
+ }
39
+ /** The `convex` dir the argument is (or sits inside), found walking UP from it and
40
+ * stopping at the first repository marker — so a repository that merely lives
41
+ * under some `/…/convex/…` host path is never mistaken for a convex dir. */
42
+ function namedConvexDir(p) {
43
+ for (let dir = p;; dir = dirname(dir)) {
44
+ if (basename(dir) === "convex")
45
+ return dir;
46
+ if (hasRepoMarker(dir) || dirname(dir) === dir)
47
+ return undefined;
48
+ }
49
+ }
50
+ /** Resolve a CLI argument to the backend it names. Pure over the filesystem. */
51
+ export function resolveTarget(arg) {
52
+ const input = resolve(arg);
53
+ const named = namedConvexDir(input);
54
+ const start = named ? dirname(named) : input;
55
+ let repoRoot = start;
56
+ let repoRootBy = "no marker (argument used as-is)";
57
+ for (let dir = start;; dir = dirname(dir)) {
58
+ if (existsSync(join(dir, "package.json"))) {
59
+ repoRoot = dir;
60
+ repoRootBy = "package.json";
61
+ break;
62
+ }
63
+ if (existsSync(join(dir, ".git"))) {
64
+ repoRoot = dir;
65
+ repoRootBy = ".git";
66
+ break;
67
+ }
68
+ if (dirname(dir) === dir)
69
+ break;
70
+ }
71
+ const convexDir = named && isDir(named)
72
+ ? named
73
+ : isDir(join(repoRoot, "convex"))
74
+ ? join(repoRoot, "convex")
75
+ : undefined;
76
+ const scanRoots = [];
77
+ if (convexDir) {
78
+ scanRoots.push(convexDir);
79
+ const mcpSrc = join(repoRoot, "mcp-server", "src");
80
+ if (isDir(mcpSrc))
81
+ scanRoots.push(mcpSrc);
82
+ }
83
+ else {
84
+ // No convex dir anywhere: judge exactly what was named, never a guess.
85
+ scanRoots.push(input);
86
+ }
87
+ return { input, repoRoot, repoRootBy, convexDir, scanRoots };
88
+ }
89
+ /** Load one directory through the ENGINE's own loader (never a second traversal
90
+ * with its own skip list): a capture detector receives the tree runDoctor built. */
91
+ function engineTree(root) {
92
+ let captured;
93
+ runDoctor(root, [
94
+ {
95
+ id: "target-capture",
96
+ run: (tree) => {
97
+ captured = tree;
98
+ return {
99
+ id: "target-capture",
100
+ anchor: root,
101
+ state: "present",
102
+ verdict: "captured",
103
+ };
104
+ },
105
+ },
106
+ ]);
107
+ if (captured === undefined) {
108
+ throw new Error(`backend-doctor: the engine loaded no tree for ${root} — refusing to judge a surface that was never read`);
109
+ }
110
+ return captured;
111
+ }
112
+ /** The ONE tree every detector judges: the scan roots' files, `rel` relative to
113
+ * the repository root, `root` = the repository root. */
114
+ export function loadScopedTree(scope) {
115
+ const files = [];
116
+ let packageJson = null;
117
+ for (const root of scope.scanRoots) {
118
+ const t = engineTree(root);
119
+ packageJson ??= t.packageJson;
120
+ for (const f of t.files) {
121
+ const rel = relative(scope.repoRoot, f.path);
122
+ files.push({ ...f, rel: rel === "" ? basename(f.path) : rel });
123
+ }
124
+ }
125
+ return { root: scope.repoRoot, files, packageJson };
126
+ }
127
+ /** Files scanned per top-level directory (two segments under the repo root), so a
128
+ * reader sees WHAT was judged — an empty or surprising surface is visible. */
129
+ export function coverageInventory(tree) {
130
+ const inv = new Map();
131
+ for (const f of tree.files) {
132
+ const parts = f.rel.split("/");
133
+ const key = parts.length > 2
134
+ ? `${parts[0]}/${parts[1]}`
135
+ : parts.length === 2
136
+ ? parts[0]
137
+ : ".";
138
+ inv.set(key, (inv.get(key) ?? 0) + 1);
139
+ }
140
+ return new Map([...inv.entries()].sort((a, b) => a[0].localeCompare(b[0])));
141
+ }
142
+ export function describeScope(scope, tree) {
143
+ const lines = [
144
+ `TARGET: ${scope.input}`,
145
+ ` repo root: ${scope.repoRoot} (by ${scope.repoRootBy})`,
146
+ ` convex dir: ${scope.convexDir ?? "NONE — no convex/ dir under the repo root; judging the argument as given"}`,
147
+ `COVERAGE INVENTORY (files scanned per dir, rel to repo root; ${tree.files.length} total):`,
148
+ ];
149
+ const inv = coverageInventory(tree);
150
+ if (inv.size === 0)
151
+ lines.push(" scanned NOTHING — every source rule below abstains on an empty scan");
152
+ for (const [dir, n] of inv)
153
+ lines.push(` scanned ${dir}: ${n}`);
154
+ return lines.join("\n");
155
+ }
156
+ /** Wrap detectors so each judges the scoped tree instead of the engine's
157
+ * per-argument tree. runDoctor still owns the loud-fail contract. */
158
+ export function onScopedTree(tree, detectors) {
159
+ return detectors.map((d) => ({ ...d, run: () => d.run(tree) }));
160
+ }
161
+ /** Absolute convex dir for a scoped tree (tree.root = repo root) or for a tree
162
+ * rooted AT a convex dir (direct detector tests). `prefix` is what the tree's
163
+ * rels carry in front of a convex-relative path. */
164
+ export function convexRootOf(tree) {
165
+ if (existsSync(join(tree.root, "_generated")) ||
166
+ basename(tree.root) === "convex") {
167
+ return { dir: tree.root, prefix: "" };
168
+ }
169
+ const dir = join(tree.root, "convex");
170
+ if (isDir(dir))
171
+ return { dir, prefix: "convex/" };
172
+ return { dir: tree.root, prefix: "" };
173
+ }
@@ -0,0 +1,24 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * Generated `api` utility — a MINIMAL, hand-kept replica of the shape
4
+ * `npx convex dev` emits, so CR-1's population parser is exercised on the real
5
+ * artefact shape (the `import type * as` map plus the `fullApi` member list) without
6
+ * this repository depending on the Convex CLI. See fixtures/cr1-classes/surface.ts.
7
+ * @module
8
+ */
9
+
10
+ import type * as surface from "../surface.js";
11
+
12
+ declare const fullApi: ApiFromModules<{
13
+ surface: typeof surface;
14
+ }>;
15
+
16
+ export declare const api: FilterApi<
17
+ typeof fullApi,
18
+ FunctionReference<any, "public">
19
+ >;
20
+
21
+ export declare const internal: FilterApi<
22
+ typeof fullApi,
23
+ FunctionReference<any, "internal">
24
+ >;
@@ -0,0 +1,206 @@
1
+ // CR-1's BIPOLAR PER-CLASS fixture. One member of each of the four classes, plus one
2
+ // `internalQuery` whose exclusion CR-1 must prove from the builder token at its own
3
+ // site. Every member is deliberately minimal: exactly one discriminator distinguishes
4
+ // it from its neighbouring class, so tests/caller-resolution.test.ts can delete that
5
+ // one discriminator and watch the member MIGRATE. Do not "tidy" this file — each line
6
+ // that looks redundant is a pole.
7
+
8
+ // ─── the tree's own helpers, which CR-1 derives (never a name list) ───────────
9
+
10
+ /** A GATE: consults the verified principal and REFUSES on its absent path. */
11
+ async function requireCaller(ctx: Ctx): Promise<void> {
12
+ const identity = await ctx.auth.getUserIdentity();
13
+ if (!identity) {
14
+ throw new Error("RBAC_DENIED: no verified principal.");
15
+ }
16
+ }
17
+
18
+ /** A RESOLVER: consults the principal and RETURNS a scope; the absent path is a
19
+ * typed empty scope, so the handler that calls it must use what it returns. */
20
+ async function resolveScope(ctx: Ctx): Promise<{ orgSlug: string | null }> {
21
+ const identity = await ctx.auth.getUserIdentity();
22
+ if (!identity) {
23
+ return { orgSlug: null };
24
+ }
25
+ return { orgSlug: String(identity.organizationSlug) };
26
+ }
27
+
28
+ // ─── RESOLVES — a gate refuses on the absent-identity path before any read ────
29
+
30
+ export const resolvesMember = query({
31
+ args: {},
32
+ handler: async (ctx) => {
33
+ await requireCaller(ctx);
34
+ return await ctx.db.query("rows").collect();
35
+ },
36
+ });
37
+
38
+ // ─── FAIL-OPEN — the resolver's result is bound and never read again ──────────
39
+
40
+ export const failOpenMember = query({
41
+ args: {},
42
+ handler: async (ctx) => {
43
+ // biome-ignore lint/correctness/noUnusedVariables: the UNUSED binding IS the pole —
44
+ // this fixture member exists to be a resolved principal that is then discarded.
45
+ // Renaming it `_scope` would delete the discriminator the FAIL-OPEN class is about.
46
+ const scope = await resolveScope(ctx);
47
+ return await ctx.db.query("rows").collect();
48
+ },
49
+ });
50
+
51
+ // ─── NO-IDENTITY — serves data, consults no principal, no justification ──────
52
+
53
+ export const noIdentityMember = query({
54
+ args: {},
55
+ handler: async (ctx) => {
56
+ return await ctx.db.query("rows").collect();
57
+ },
58
+ });
59
+
60
+ // ─── NO-DATA — touches no data, and says why, in this source ──────────────────
61
+
62
+ // allow-no-caller-resolution: returns the deployment's build constant only; it
63
+ // reads no table, no storage and no other function, so there is no caller-scoped
64
+ // datum for a principal to scope.
65
+ export const noDataMember = query({
66
+ args: {},
67
+ handler: async () => {
68
+ return { build: "fixture" };
69
+ },
70
+ });
71
+
72
+ // ─── RESOLVES through a DECLARE-THEN-ASSIGN gate, the shape a reviewer's ──────
73
+ // ─── probe caught the matcher failing to read ─────────────────────────────────
74
+ //
75
+ // ORIGIN, cited rather than invented: this is the shape of
76
+ // `requireServiceAccount` in vantageos-agency/vantage-peers
77
+ // convex/oauth.ts @ 40c71cb55721d99d3bed17058fdc10281103b158 — the local is
78
+ // DECLARED on its own line precisely so the resolution can sit inside a `try`
79
+ // and re-raise naming its own door, then the authority test follows.
80
+ //
81
+ // The matcher originally required the `=` to follow the declarator on one
82
+ // statement, so every gate written this way read as ABSENT and SEVEN correctly
83
+ // service-account-gated oauth handlers were classed FAIL-OPEN. Measured on that
84
+ // same tree: FAIL-OPEN 11 -> 4 and RESOLVES 139 -> 146 once the shape is read,
85
+ // with the classified total unchanged at 179.
86
+ //
87
+ // This site asserts RESOLVES. It is a fixture rather than a unit assertion
88
+ // because the defect was in the POPULATION-wide read, not in one predicate.
89
+
90
+ export const resolvesViaDeclareThenAssignGate = query({
91
+ args: {},
92
+ handler: async (ctx) => {
93
+ await requireServiceAccountShim(ctx, "fixture:resolvesViaDeclareThenAssignGate");
94
+ // It must TOUCH DATA, or CR-1 classes it NO-DATA and the gate question is
95
+ // moot — which is what happened on the first draft of this fixture, and
96
+ // is itself the instrument behaving correctly.
97
+ return await ctx.db.query("rows").collect();
98
+ },
99
+ });
100
+
101
+ async function requireServiceAccountShim(
102
+ ctx: Ctx,
103
+ registration: string,
104
+ ): Promise<void> {
105
+ let scope: { isMaster: boolean } | undefined;
106
+ try {
107
+ scope = await resolveScopeShim(ctx);
108
+ } catch (err: unknown) {
109
+ throw new Error(`RBAC_DENIED: "${registration}" unresolved-caller`);
110
+ }
111
+ if (!scope?.isMaster) {
112
+ throw new Error(`RBAC_DENIED: "${registration}" not-the-service-account`);
113
+ }
114
+ }
115
+
116
+ async function resolveScopeShim(ctx: Ctx): Promise<{ isMaster: boolean }> {
117
+ const identity = await ctx.auth.getUserIdentity();
118
+ return { isMaster: identity !== null };
119
+ }
120
+
121
+ // ─── FAIL-OPEN in the SAME declare-then-assign shape — the WIDENING pole ──────
122
+ //
123
+ // WHY THIS EXISTS, and it is the pole that decides whether the shape above is a
124
+ // fix or a hole. `resolvesViaDeclareThenAssignGate` proves the matcher READS
125
+ // declare-then-assign. It does NOT prove the matcher still REFUSES when such a
126
+ // binding is never tested. A reviewer's mutant made every declare-then-assign
127
+ // binding count as a gate regardless of any refusal, and it SURVIVED the whole
128
+ // suite — 465/465, and the real-tree FAIL-OPEN count unmoved at 4. So
129
+ // let s; s = await resolver(ctx); return ctx.db…
130
+ // with no test of `s` would have read as RESOLVES.
131
+ //
132
+ // The widening direction of a security detector is the direction that hides a
133
+ // leak, and it was the untested one. This site is identical to its sibling in
134
+ // SHAPE and differs only in the thing that matters: the resolved value decides
135
+ // NOTHING before the data is served. It must class FAIL-OPEN and appear in
136
+ // violations().
137
+
138
+ export const failsOpenViaDeclareThenAssignNoTest = query({
139
+ args: {},
140
+ handler: async (ctx) => {
141
+ // Declared on its own line, assigned inside a try — the shape the matcher
142
+ // now reads — and then the binding is simply never consulted.
143
+ let scope: { isMaster: boolean } | undefined;
144
+ try {
145
+ scope = await resolveScopeShim(ctx);
146
+ } catch {
147
+ // swallowed: not a refusal. The caller is resolved and discarded.
148
+ }
149
+ void scope;
150
+ return await ctx.db.query("rows").collect();
151
+ },
152
+ });
153
+
154
+ // ─── the pole that actually kills the widening mutant ─────────────────────────
155
+ //
156
+ // WHY THE PREVIOUS POLE WAS NOT ENOUGH, measured rather than guessed. The
157
+ // reviewer's mutant W makes any declare-then-assign binding count as a REFUSAL,
158
+ // which promotes the enclosing function to a GATE. `failsOpenViaDeclareThenAssign
159
+ // NoTest` above is classed FAIL-OPEN on its own body and stays FAIL-OPEN under W,
160
+ // so it does not bite: W's damage lands on whoever TRUSTS a wrongly-promoted
161
+ // gate, not on the promoted function itself.
162
+ //
163
+ // So the pole has to be the site that DELEGATES its authority to such a helper.
164
+ // `pretendGateDeclareThenAssignNoTest` below is in the declare-then-assign shape
165
+ // and never tests what it resolved — it is not a gate. A registration whose only
166
+ // authority is that helper is therefore FAIL-OPEN. Under W the helper is promoted
167
+ // and the registration reads RESOLVES: a leak classified as safe, which is the
168
+ // single outcome a security detector must never produce.
169
+
170
+ export const failsOpenBehindAPretendGate = query({
171
+ args: {},
172
+ handler: async (ctx) => {
173
+ await pretendGateDeclareThenAssignNoTest(ctx);
174
+ return await ctx.db.query("rows").collect();
175
+ },
176
+ });
177
+
178
+ async function pretendGateDeclareThenAssignNoTest(ctx: Ctx): Promise<void> {
179
+ // Declared on its own line, assigned inside a try — the shape the matcher now
180
+ // reads — and then NEVER consulted. Resolving is not refusing.
181
+ let scope: { isMaster: boolean } | undefined;
182
+ try {
183
+ scope = await resolveScopeShim(ctx);
184
+ } catch {
185
+ // swallowed on purpose: there is no refusal anywhere in this function.
186
+ }
187
+ void scope;
188
+ }
189
+
190
+ // ─── excluded — and the exclusion is proven by the builder token on THIS line ──
191
+
192
+ export const internalMember = internalQuery({
193
+ args: {},
194
+ handler: async (ctx) => {
195
+ return await ctx.db.query("rows").collect();
196
+ },
197
+ });
198
+
199
+ // ─── minimal ambient shims so this fixture is readable TypeScript on its own ──
200
+
201
+ interface Ctx {
202
+ auth: { getUserIdentity: () => Promise<Record<string, unknown> | null> };
203
+ db: { query: (t: string) => { collect: () => Promise<unknown[]> } };
204
+ }
205
+ declare function query<T>(config: T): T;
206
+ declare function internalQuery<T>(config: T): T;
@@ -0,0 +1 @@
1
+ export const nothing = 1;
@@ -0,0 +1,24 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * Generated `api` utility — a MINIMAL, hand-kept replica of the shape
4
+ * `npx convex dev` emits (see fixtures/cr1-classes/_generated/api.d.ts). Only
5
+ * `doors` is listed: `lib/auth.ts` registers no public function, exactly as the
6
+ * product tree's `lib/auth` exposes none to CR-1's population.
7
+ * @module
8
+ */
9
+
10
+ import type * as doors from "../doors.js";
11
+
12
+ declare const fullApi: ApiFromModules<{
13
+ doors: typeof doors;
14
+ }>;
15
+
16
+ export declare const api: FilterApi<
17
+ typeof fullApi,
18
+ FunctionReference<any, "public">
19
+ >;
20
+
21
+ export declare const internal: FilterApi<
22
+ typeof fullApi,
23
+ FunctionReference<any, "internal">
24
+ >;