@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,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
+ >;
@@ -0,0 +1,180 @@
1
+ // CR-1 fixture — public doors gated the way vantage-peers 58ed649 gates the OKF
2
+ // export/import doors (okfBundleNode.ts:244-276, okfBundleDurable.ts:110-132,
3
+ // okfBundleNode.ts:783-793), plus the negative poles that must stay FAIL-OPEN.
4
+ // Each member is a pole for tests/cr1-refusal-on-resolved.test.ts.
5
+ import {
6
+ requireMasterDirect,
7
+ requireNamespaceSometimes,
8
+ requireNonEmpty,
9
+ requireTenantNamespace,
10
+ withOrgScope,
11
+ } from "./lib/auth";
12
+
13
+ // ─── helpers, product-shaped ─────────────────────────────────────────────────
14
+
15
+ /** ACTION side (okfBundleNode.ts:assertCanExportNamespace): binding through
16
+ * ctx.runQuery(internal.…resolveOrgScopeForAction), refusal through a
17
+ * TRANSITIVE value refuser. */
18
+ async function assertCanExportNamespace(
19
+ ctx: ActionCtx,
20
+ namespace: string,
21
+ door: string,
22
+ ): Promise<void> {
23
+ if (namespace.includes("..")) {
24
+ throw new Error(`OKF_NAMESPACE_INVALID: "${namespace}"`);
25
+ }
26
+ const scope = await ctx.runQuery(
27
+ internal.lib.auth.resolveOrgScopeForAction,
28
+ {},
29
+ );
30
+ requireTenantNamespace(scope, namespace, door);
31
+ }
32
+
33
+ /** okfBundleNode.ts:assertCanImport — reaches the gate one call deeper. */
34
+ async function assertCanImport(
35
+ ctx: ActionCtx,
36
+ namespace: string,
37
+ ): Promise<void> {
38
+ await assertCanExportNamespace(ctx, namespace, "doors:importBundle");
39
+ }
40
+
41
+ /** V8 side (okfBundleDurable.ts:assertCanExportNamespaceV8): binding through
42
+ * withOrgScope, refusal through the same TRANSITIVE value refuser. */
43
+ async function assertCanExportNamespaceV8(
44
+ ctx: MutationCtx,
45
+ namespace: string,
46
+ door: string,
47
+ ): Promise<void> {
48
+ const scope = await withOrgScope(ctx, { refuseWithoutThrow: true });
49
+ requireTenantNamespace(scope, namespace, door);
50
+ }
51
+
52
+ /** binding through withOrgScope, refusal through a DIRECT (one-level) refuser. */
53
+ async function assertMasterDirect(
54
+ ctx: MutationCtx,
55
+ door: string,
56
+ ): Promise<void> {
57
+ const scope = await withOrgScope(ctx);
58
+ requireMasterDirect(scope, door);
59
+ }
60
+
61
+ /** NEGATIVE 1 — same resolution, result IGNORED: the refuser is handed the
62
+ * namespace, never the resolved scope. */
63
+ async function assertIgnored(
64
+ ctx: ActionCtx,
65
+ namespace: string,
66
+ door: string,
67
+ ): Promise<void> {
68
+ // biome-ignore lint/correctness/noUnusedVariables: the UNUSED binding IS the pole.
69
+ const scope = await ctx.runQuery(
70
+ internal.lib.auth.resolveOrgScopeForAction,
71
+ {},
72
+ );
73
+ requireNonEmpty(namespace, door);
74
+ }
75
+
76
+ /** NEGATIVE 2 — the old okfBundleNode.ts:assertCanValidate shape: the
77
+ * absent-identity branch is a bare `return;`, whatever follows it. */
78
+ async function assertCanValidateOld(
79
+ ctx: ActionCtx,
80
+ namespace: string,
81
+ door: string,
82
+ ): Promise<void> {
83
+ const identity = await ctx.auth.getUserIdentity();
84
+ if (identity === null || identity === undefined) {
85
+ return;
86
+ }
87
+ const scope = await ctx.runQuery(
88
+ internal.lib.auth.resolveOrgScopeForAction,
89
+ {},
90
+ );
91
+ requireTenantNamespace(scope, namespace, door);
92
+ }
93
+
94
+ /** NEGATIVE 3 — the refuser proceeds silently on a caller-supplied value. */
95
+ async function assertSometimes(
96
+ ctx: MutationCtx,
97
+ namespace: string,
98
+ door: string,
99
+ ): Promise<void> {
100
+ const scope = await withOrgScope(ctx);
101
+ requireNamespaceSometimes(scope, namespace, door);
102
+ }
103
+
104
+ export const readRows = internalQuery({
105
+ args: { namespace: v.string() },
106
+ handler: async (ctx, args) => {
107
+ return await ctx.db
108
+ .query("rows")
109
+ .withIndex("by_namespace", (q) => q.eq("namespace", args.namespace))
110
+ .collect();
111
+ },
112
+ });
113
+
114
+ // ─── POSITIVE — RESOLVES ─────────────────────────────────────────────────────
115
+
116
+ export const exportBundle = action({
117
+ args: { namespace: v.string() },
118
+ handler: async (ctx, args) => {
119
+ await assertCanExportNamespace(ctx, args.namespace, "doors:exportBundle");
120
+ return await ctx.runQuery(internal.doors.readRows, {
121
+ namespace: args.namespace,
122
+ });
123
+ },
124
+ });
125
+
126
+ export const importBundle = action({
127
+ args: { namespace: v.string() },
128
+ handler: async (ctx, args) => {
129
+ await assertCanImport(ctx, args.namespace);
130
+ return await ctx.runQuery(internal.doors.readRows, {
131
+ namespace: args.namespace,
132
+ });
133
+ },
134
+ });
135
+
136
+ export const startExport = mutation({
137
+ args: { namespace: v.string() },
138
+ handler: async (ctx, args) => {
139
+ await assertCanExportNamespaceV8(ctx, args.namespace, "doors:startExport");
140
+ return await ctx.db.insert("jobs", { namespace: args.namespace });
141
+ },
142
+ });
143
+
144
+ export const directDoor = mutation({
145
+ args: { namespace: v.string() },
146
+ handler: async (ctx, args) => {
147
+ await assertMasterDirect(ctx, "doors:directDoor");
148
+ return await ctx.db.insert("jobs", { namespace: args.namespace });
149
+ },
150
+ });
151
+
152
+ // ─── NEGATIVE — FAIL-OPEN ────────────────────────────────────────────────────
153
+
154
+ export const ignoredDoor = action({
155
+ args: { namespace: v.string() },
156
+ handler: async (ctx, args) => {
157
+ await assertIgnored(ctx, args.namespace, "doors:ignoredDoor");
158
+ return await ctx.runQuery(internal.doors.readRows, {
159
+ namespace: args.namespace,
160
+ });
161
+ },
162
+ });
163
+
164
+ export const validateDoor = action({
165
+ args: { namespace: v.string() },
166
+ handler: async (ctx, args) => {
167
+ await assertCanValidateOld(ctx, args.namespace, "doors:validateDoor");
168
+ return await ctx.runQuery(internal.doors.readRows, {
169
+ namespace: args.namespace,
170
+ });
171
+ },
172
+ });
173
+
174
+ export const sometimesDoor = mutation({
175
+ args: { namespace: v.string() },
176
+ handler: async (ctx, args) => {
177
+ await assertSometimes(ctx, args.namespace, "doors:sometimesDoor");
178
+ return await ctx.db.insert("jobs", { namespace: args.namespace });
179
+ },
180
+ });
@@ -0,0 +1,104 @@
1
+ // CR-1 fixture — the RESOLVER and REFUSER helpers, shaped after vantage-peers
2
+ // 58ed649 `convex/lib/auth.ts` (withOrgScope, requireResolvedCaller,
3
+ // isNamespaceAllowedForScope, requireTenantNamespace, resolveOrgScopeForAction).
4
+ // Each helper below is a pole for tests/cr1-refusal-on-resolved.test.ts. Do not
5
+ // "tidy" it: a line that looks redundant is a discriminator.
6
+
7
+ /** RESOLVER (not a gate): an absent identity returns an anonymous scope. */
8
+ export async function withOrgScope(ctx: Ctx, opts?: Opts): Promise<Scope> {
9
+ const identity = await ctx.auth.getUserIdentity();
10
+ if (identity === null || identity === undefined) {
11
+ return { isMaster: false, orgSlug: null, anonymous: true, refused: true };
12
+ }
13
+ const row = await ctx.db
14
+ .query("client_org_mapping")
15
+ .withIndex("by_clerk_slug", (q) => q.eq("clerkSlug", identity.org_slug))
16
+ .unique();
17
+ if (row === null || opts?.refuseWithoutThrow) {
18
+ return { isMaster: false, orgSlug: null, anonymous: false, refused: true };
19
+ }
20
+ return {
21
+ isMaster: false,
22
+ orgSlug: row.orgSlug,
23
+ anonymous: false,
24
+ refused: false,
25
+ };
26
+ }
27
+
28
+ /** VALUE REFUSER, direct: refuses an anonymous scope by raising. */
29
+ export function requireResolvedCaller(
30
+ scope: Pick<Scope, "isMaster" | "orgSlug" | "anonymous" | "refused">,
31
+ registration: string,
32
+ opts?: { masterOnly?: boolean },
33
+ ): void {
34
+ if (scope.isMaster) return;
35
+ if (scope.anonymous) {
36
+ throw new ConvexError(
37
+ `RBAC_DENIED: no credential presented to "${registration}"`,
38
+ );
39
+ }
40
+ if (opts?.masterOnly) {
41
+ throw new ConvexError(
42
+ `RBAC_DENIED: "${registration}" admits the fleet master only`,
43
+ );
44
+ }
45
+ }
46
+
47
+ /** a PREDICATE on the resolved scope — returns a boolean, refuses nothing. */
48
+ export function isNamespaceAllowedForScope(
49
+ scope: Pick<Scope, "isMaster" | "orgSlug">,
50
+ namespace: string,
51
+ ): boolean {
52
+ if (scope.isMaster) return true;
53
+ if (scope.orgSlug === null) return false;
54
+ return namespace.startsWith(`team/${scope.orgSlug}`);
55
+ }
56
+
57
+ /** VALUE REFUSER, TRANSITIVE: no throw of its own — it allows on a test of the
58
+ * RESOLVED scope and otherwise hands that scope to requireResolvedCaller. */
59
+ export function requireTenantNamespace(
60
+ scope: Pick<Scope, "isMaster" | "orgSlug" | "anonymous" | "refused">,
61
+ namespace: string,
62
+ door: string,
63
+ ): void {
64
+ if (isNamespaceAllowedForScope(scope, namespace)) return;
65
+ requireResolvedCaller(scope, door, { masterOnly: true });
66
+ }
67
+
68
+ /** VALUE REFUSER, direct, one level: throws on a test of the resolved scope. */
69
+ export function requireMasterDirect(scope: Scope, door: string): void {
70
+ if (!scope.isMaster) {
71
+ throw new ConvexError(
72
+ `RBAC_DENIED: "${door}" admits the fleet master only`,
73
+ );
74
+ }
75
+ }
76
+
77
+ /** NOT a refuser of `scope`: it PROCEEDS on a test of a caller-supplied argument
78
+ * before any test of the resolved value — every principal, none included, whose
79
+ * namespace starts `public/` is waved through. */
80
+ export function requireNamespaceSometimes(
81
+ scope: Pick<Scope, "isMaster" | "orgSlug" | "anonymous" | "refused">,
82
+ namespace: string,
83
+ door: string,
84
+ ): void {
85
+ if (namespace.startsWith("public/")) return;
86
+ requireResolvedCaller(scope, door, { masterOnly: true });
87
+ }
88
+
89
+ /** a refuser of its OWN argument (a namespace), never of a principal. */
90
+ export function requireNonEmpty(namespace: string, door: string): void {
91
+ if (namespace.length === 0) {
92
+ throw new Error(`OKF_NAMESPACE_INVALID at "${door}"`);
93
+ }
94
+ }
95
+
96
+ /** the ONE way an action resolves its caller — reached as
97
+ * `internal.lib.auth.resolveOrgScopeForAction`, never by bare name. */
98
+ export const resolveOrgScopeForAction = internalQuery({
99
+ args: {},
100
+ handler: async (ctx) => {
101
+ const scope = await withOrgScope(ctx, { refuseWithoutThrow: true });
102
+ return scope;
103
+ },
104
+ });
@@ -0,0 +1,8 @@
1
+ // verified token here is forwarded verbatim to Convex as `clerkJwt` below,
2
+ // and Convex's own auth.config already requires aud === "convex", so binding
3
+ // the same value here (Critical Rule 14 element 5: aud + iss binding) closes
4
+ // the cross-tenant/cross-resource replay gap without rejecting any token a
5
+ // correctly-configured Clerk "convex" JWT template mints.
6
+ const CLERK_JWT_AUDIENCE = process.env.CLERK_JWT_AUDIENCE ?? "convex";
7
+
8
+ // Lazy singleton — createRemoteJWKSet caches JWKS in-process (10-min TTL).
@@ -0,0 +1,13 @@
1
+ * that DOES require the fallback).
2
+ */
3
+ async function tryVerifyClerkJwt(token: string): Promise<ClerkJwtResult> {
4
+ try {
5
+ const { payload } = await jwtVerify(token, clerkJwks(), {
6
+ issuer: CLERK_DOMAIN,
7
+ audience: CLERK_JWT_AUDIENCE,
8
+ });
9
+ // Verification succeeded — this IS the caller's own Clerk identity.
10
+ // `sub`/`exp` absence here would be a session-token shape violation
11
+ // (not the org concern this function types), so it is still
12
+ // "not-clerk" (try next layer) rather than "no-org".
13
+ const sub = payload.sub;