@vantageos/backend-doctor 0.1.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 (108) hide show
  1. package/README.md +88 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +94 -0
  4. package/dist/contract.d.ts +18 -0
  5. package/dist/contract.js +29 -0
  6. package/dist/coverage-cli.d.ts +2 -0
  7. package/dist/coverage-cli.js +17 -0
  8. package/dist/coverage.d.ts +32 -0
  9. package/dist/coverage.js +77 -0
  10. package/dist/detectors/oracle-axis1.d.ts +86 -0
  11. package/dist/detectors/oracle-axis1.js +221 -0
  12. package/dist/detectors/oracle-axis2.d.ts +22 -0
  13. package/dist/detectors/oracle-axis2.js +149 -0
  14. package/dist/detectors/predicates.d.ts +19 -0
  15. package/dist/detectors/predicates.js +334 -0
  16. package/dist/detectors/shared.d.ts +56 -0
  17. package/dist/detectors/shared.js +103 -0
  18. package/dist/detectors/source/_scan.d.ts +43 -0
  19. package/dist/detectors/source/_scan.js +82 -0
  20. package/dist/detectors/source/backfill.d.ts +27 -0
  21. package/dist/detectors/source/backfill.js +211 -0
  22. package/dist/detectors/source/contract.d.ts +33 -0
  23. package/dist/detectors/source/contract.js +385 -0
  24. package/dist/detectors/source/identity.d.ts +47 -0
  25. package/dist/detectors/source/identity.js +410 -0
  26. package/dist/detectors/source/jobs.d.ts +10 -0
  27. package/dist/detectors/source/jobs.js +209 -0
  28. package/dist/detectors/source/lifecycle.d.ts +6 -0
  29. package/dist/detectors/source/lifecycle.js +147 -0
  30. package/dist/detectors/source/readbudget.d.ts +6 -0
  31. package/dist/detectors/source/readbudget.js +133 -0
  32. package/dist/detectors/source/refusal.d.ts +6 -0
  33. package/dist/detectors/source/refusal.js +125 -0
  34. package/dist/detectors/source/security.d.ts +37 -0
  35. package/dist/detectors/source/security.js +392 -0
  36. package/dist/detectors/source/writesafety.d.ts +14 -0
  37. package/dist/detectors/source/writesafety.js +345 -0
  38. package/dist/detectors/standard/_doc.d.ts +66 -0
  39. package/dist/detectors/standard/_doc.js +152 -0
  40. package/dist/detectors/standard/r41-frontier.d.ts +58 -0
  41. package/dist/detectors/standard/r41-frontier.js +176 -0
  42. package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
  43. package/dist/detectors/standard/r46-returns-validator.js +474 -0
  44. package/dist/detectors/standard/standard-doc.d.ts +12 -0
  45. package/dist/detectors/standard/standard-doc.js +271 -0
  46. package/dist/detectors/structural.d.ts +14 -0
  47. package/dist/detectors/structural.js +83 -0
  48. package/dist/engine-types.d.ts +10 -0
  49. package/dist/engine-types.js +1 -0
  50. package/dist/not-checkable.d.ts +22 -0
  51. package/dist/not-checkable.js +37 -0
  52. package/dist/oracle.d.ts +33 -0
  53. package/dist/oracle.js +133 -0
  54. package/dist/registry.d.ts +11 -0
  55. package/dist/registry.js +140 -0
  56. package/dist/report.d.ts +35 -0
  57. package/dist/report.js +138 -0
  58. package/dist/severity.d.ts +30 -0
  59. package/dist/severity.js +121 -0
  60. package/dist/tally.d.ts +57 -0
  61. package/dist/tally.js +152 -0
  62. package/dist/trigger.d.ts +47 -0
  63. package/dist/trigger.js +143 -0
  64. package/fixtures/backend-standard-51ec7af1.md +648 -0
  65. package/fixtures/backend-standard-a9d7877b.md +629 -0
  66. package/fixtures/mcp-app-standard-a74a042b.md +279 -0
  67. package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
  68. package/fixtures/mcp-server-standard-a74a042b.md +434 -0
  69. package/fixtures/mcp-standard-a74a042b.md +354 -0
  70. package/fixtures/vp-1200-green/.pin +1 -0
  71. package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
  72. package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
  73. package/fixtures/vp-1200-red/.pin +1 -0
  74. package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
  75. package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
  76. package/fixtures/vp-by-tool-day158.csv +190 -0
  77. package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
  78. package/fixtures/vp-convex/.pin +1 -0
  79. package/fixtures/vp-convex/bun.lock +783 -0
  80. package/fixtures/vp-convex/convex/auth.config.ts +8 -0
  81. package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
  82. package/fixtures/vp-convex/convex/convex.config.ts +14 -0
  83. package/fixtures/vp-convex/convex/credentials.ts +591 -0
  84. package/fixtures/vp-convex/convex/crons.ts +72 -0
  85. package/fixtures/vp-convex/convex/dashboard.ts +199 -0
  86. package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
  87. package/fixtures/vp-convex/convex/issues.ts +544 -0
  88. package/fixtures/vp-convex/convex/kb.ts +305 -0
  89. package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
  90. package/fixtures/vp-convex/convex/kbShared.ts +56 -0
  91. package/fixtures/vp-convex/convex/licenses.ts +443 -0
  92. package/fixtures/vp-convex/convex/memories.ts +406 -0
  93. package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
  94. package/fixtures/vp-convex/convex/messages.ts +971 -0
  95. package/fixtures/vp-convex/convex/migrations.ts +239 -0
  96. package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
  97. package/fixtures/vp-convex/convex/oauth.ts +1608 -0
  98. package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
  99. package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
  100. package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
  101. package/fixtures/vp-convex/convex/schema.ts +1229 -0
  102. package/fixtures/vp-convex/convex/tasks.ts +2782 -0
  103. package/fixtures/vp-r31-green/.pin +1 -0
  104. package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
  105. package/fixtures/vp-r31-red/.pin +1 -0
  106. package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
  107. package/fixtures/vr-by-tool-day159.csv +89 -0
  108. package/package.json +39 -0
@@ -0,0 +1,1608 @@
1
+ /**
2
+ * OAuth 2.0 server-side storage + scope enforcement helpers.
3
+ *
4
+ * Shipped Day 47 (mission k578zezmnqgpb6hhfvz8kmvbfs856hz6) to replace the
5
+ * in-memory OAuth state that lived in mcp-server/server-http.ts (Day 45 MVP).
6
+ *
7
+ * All tokens and client secrets are stored as SHA-256 hex hashes — raw values
8
+ * NEVER hit Convex. The raw secret is returned exactly once by the admin
9
+ * provisioning endpoint and must be transmitted to the client out-of-band.
10
+ *
11
+ * Admin-only mutations (createClient, deleteClient, listClients, seed*)
12
+ * require the caller to present the master bearer token, validated against
13
+ * process.env.BEARER_SECRET_MASTER via constant-time comparison.
14
+ */
15
+
16
+ import { v } from "convex/values";
17
+ import { mutation, query } from "./_generated/server";
18
+
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // Shared auth helper — master-token gate for admin mutations
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ async function timingSafeEqual(a: string, b: string): Promise<boolean> {
24
+ const encoder = new TextEncoder();
25
+ const aBytes = encoder.encode(a);
26
+ const bBytes = encoder.encode(b);
27
+ if (aBytes.length !== bBytes.length) {
28
+ // Still do a comparison on equal-length buffers to avoid branch-timing leak.
29
+ const dummy = new Uint8Array(aBytes.length);
30
+ const aKey = await crypto.subtle.importKey(
31
+ "raw",
32
+ aBytes,
33
+ { name: "HMAC", hash: "SHA-256" },
34
+ false,
35
+ ["sign"],
36
+ );
37
+ await crypto.subtle.sign("HMAC", aKey, dummy);
38
+ return false;
39
+ }
40
+ let diff = 0;
41
+ for (let i = 0; i < aBytes.length; i++) {
42
+ diff |= aBytes[i] ^ bBytes[i];
43
+ }
44
+ return diff === 0;
45
+ }
46
+
47
+ async function requireMasterAuth(callerToken: string): Promise<void> {
48
+ const masterToken = process.env.BEARER_SECRET_MASTER;
49
+ if (!masterToken) {
50
+ throw new Error("BEARER_SECRET_MASTER env var is not configured");
51
+ }
52
+ const valid = await timingSafeEqual(callerToken, masterToken);
53
+ if (!valid) {
54
+ throw new Error("Unauthorized: invalid master token");
55
+ }
56
+ }
57
+
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+ // Scope profile shape
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+
62
+ const scopeProfileShape = v.object({
63
+ profileId: v.string(),
64
+ description: v.string(),
65
+ fromAllowList: v.array(v.string()),
66
+ namespaceReadPrefixes: v.array(v.string()),
67
+ namespaceWritePrefixes: v.array(v.string()),
68
+ });
69
+
70
+ // ─────────────────────────────────────────────────────────────────────────────
71
+ // seedDefaultProfiles — admin only, idempotent, UPSERT semantics
72
+ // S3.4 B4 (catalog-SSOT doctrine): when a persisted row drifts from the
73
+ // catalog seed (description / fromAllowList / namespaceReadPrefixes /
74
+ // namespaceWritePrefixes), patch the differing fields in-place and write an
75
+ // `oauth_audit_log` entry with eventType=`seed_upsert` capturing before/after.
76
+ // When the persisted row already matches the catalog, the row is a true no-op
77
+ // (no DB write, no audit log). When no row exists, the seed is inserted.
78
+ //
79
+ // Rows present in the DB but NOT in the catalog (operator-created profiles,
80
+ // post-D9 rename survivors) are PRESERVED — this seed mutation is never
81
+ // destructive. This obsoletes the bespoke catalog-drift migration pattern
82
+ // shown in `convex/migrations/patch_marie_iris_rh_scope.ts`.
83
+ //
84
+ // Return shape: `{ inserted, updated, skipped }` arrays of profileId strings.
85
+ // Master preserves full-access semantics of the BEARER_SECRET_MASTER path.
86
+ // ─────────────────────────────────────────────────────────────────────────────
87
+
88
+ export const seedDefaultProfiles = mutation({
89
+ args: { callerToken: v.string() },
90
+ returns: v.object({
91
+ inserted: v.array(v.string()),
92
+ updated: v.array(v.string()),
93
+ skipped: v.array(v.string()),
94
+ }),
95
+ handler: async (ctx, args) => {
96
+ await requireMasterAuth(args.callerToken);
97
+
98
+ // SCOPE NOTICE (PR #1120): `profileId` / `fromAllowList` /
99
+ // `namespaceReadPrefixes` / `namespaceWritePrefixes` below carry the
100
+ // client slug in CLEARTEXT in this PUBLIC repo, on purpose -- they
101
+ // are the blocking authorization control, not decoration.
102
+ // `scripts/source_prose_identity_guard.py` is GREEN on this file's
103
+ // PROSE (comments/description) but never scans these arrays (class 2
104
+ // of its declared scope, by construction) -- a green guard here does
105
+ // NOT mean this file is clean of client identity; these arrays still
106
+ // spell it in cleartext. Open, tracked to close by moving these
107
+ // profiles from CODE to DATA: k170xwqveg15kzrqwvfq5ynqd58b263s.
108
+ const defaults = [
109
+ {
110
+ profileId: "master",
111
+ description: "Full admin access — reserved for Pi and internal ops.",
112
+ fromAllowList: ["*"],
113
+ namespaceReadPrefixes: ["*"],
114
+ namespaceWritePrefixes: ["*"],
115
+ },
116
+ {
117
+ profileId: "marie-iris-rh",
118
+ description:
119
+ "Marie (the onboarding client) — send_message as 'marie' only; read/write in her own orchestrator namespace + project namespace + global. Day 88 fix: added orchestrator/marie which was missing — every orchestrator owns their orchestrator/<name> namespace by convention.",
120
+ fromAllowList: ["marie"],
121
+ namespaceReadPrefixes: [
122
+ "orchestrator/marie",
123
+ "orchestrator/victor",
124
+ "project/marie",
125
+ "global",
126
+ ],
127
+ namespaceWritePrefixes: [
128
+ "orchestrator/marie",
129
+ "orchestrator/victor",
130
+ "project/marie",
131
+ "global",
132
+ ],
133
+ },
134
+ // <redacted-client> trio (Clio + Hélios + Victor) — 3 dual-host
135
+ // orchestrator personas sharing a project workspace; each profile
136
+ // lists the other two's case variants in
137
+ // `fromAllowList` + their orchestrator namespaces in both prefix
138
+ // arrays so each persona can switch hosts and continue the
139
+ // conversation without re-paste of credentials. The
140
+ // `recipient ∈ fromAllowList` doctrine (commit 24b39c5) gives
141
+ // each persona symmetric read access to the others' inbox.
142
+ {
143
+ profileId: "clio-iris-rh",
144
+ description:
145
+ "Clio (the onboarding client's ChatGPT orchestrator persona) — send/check as Clio + cross-persona read of Hélios + Victor inboxes; read/write the shared project workspace + the other two personas' orchestrator namespaces.",
146
+ fromAllowList: [
147
+ "Clio",
148
+ "clio",
149
+ "Hélios",
150
+ "Helios",
151
+ "helios",
152
+ "hélios",
153
+ "Victor",
154
+ "victor",
155
+ ],
156
+ namespaceReadPrefixes: [
157
+ "orchestrator/Clio",
158
+ "orchestrator/clio",
159
+ "orchestrator/Hélios",
160
+ "orchestrator/Helios",
161
+ "orchestrator/helios",
162
+ "orchestrator/hélios",
163
+ "orchestrator/Victor",
164
+ "orchestrator/victor",
165
+ "project/iris-rh",
166
+ ],
167
+ namespaceWritePrefixes: [
168
+ "orchestrator/Clio",
169
+ "orchestrator/clio",
170
+ "orchestrator/Hélios",
171
+ "orchestrator/Helios",
172
+ "orchestrator/helios",
173
+ "orchestrator/hélios",
174
+ "orchestrator/Victor",
175
+ "orchestrator/victor",
176
+ "project/iris-rh",
177
+ ],
178
+ },
179
+ {
180
+ profileId: "helios-iris-rh",
181
+ description:
182
+ "Hélios (the onboarding client's Claude.ai orchestrator persona) — send/check as Hélios + cross-persona read of Clio + Victor inboxes; read/write the shared project workspace + the other two personas' orchestrator namespaces.",
183
+ fromAllowList: [
184
+ "Hélios",
185
+ "Helios",
186
+ "helios",
187
+ "hélios",
188
+ "Clio",
189
+ "clio",
190
+ "Victor",
191
+ "victor",
192
+ ],
193
+ namespaceReadPrefixes: [
194
+ "orchestrator/Hélios",
195
+ "orchestrator/Helios",
196
+ "orchestrator/helios",
197
+ "orchestrator/hélios",
198
+ "orchestrator/Clio",
199
+ "orchestrator/clio",
200
+ "orchestrator/Victor",
201
+ "orchestrator/victor",
202
+ "project/iris-rh",
203
+ ],
204
+ namespaceWritePrefixes: [
205
+ "orchestrator/Hélios",
206
+ "orchestrator/Helios",
207
+ "orchestrator/helios",
208
+ "orchestrator/hélios",
209
+ "orchestrator/Clio",
210
+ "orchestrator/clio",
211
+ "orchestrator/Victor",
212
+ "orchestrator/victor",
213
+ "project/iris-rh",
214
+ ],
215
+ },
216
+ {
217
+ profileId: "client-generic",
218
+ description:
219
+ "Deny-by-default template for new clients. MUST be overridden before issuing tokens.",
220
+ fromAllowList: [],
221
+ namespaceReadPrefixes: [],
222
+ namespaceWritePrefixes: [],
223
+ },
224
+ {
225
+ // Day 88: minimum-read scope profile for self-registered (DCR) clients
226
+ // that need to read PUBLIC global state without any tenant or
227
+ // orchestrator-owned data. Used by Claude.ai "Add custom integration"
228
+ // auto-discovery path so anonymous clients cannot reach cross-tenant
229
+ // namespaces. Write is empty (deny). fromAllowList="external" tags
230
+ // the impersonation source for audit trails.
231
+ profileId: "public-readonly",
232
+ description:
233
+ "Minimum-read scope for anonymous DCR clients — read global/* only, no write, no tenant access.",
234
+ fromAllowList: ["external"],
235
+ // "global" matches both the exact namespace and any nested global/X
236
+ // per the checkNamespacePrefix slash-boundary rule in mcp-server/auth.ts.
237
+ // This is the "global/*" intent expressed in prefix form (no glob support).
238
+ namespaceReadPrefixes: ["global"],
239
+ namespaceWritePrefixes: [],
240
+ },
241
+ ];
242
+
243
+ const inserted: string[] = [];
244
+ const updated: string[] = [];
245
+ const skipped: string[] = [];
246
+
247
+ const arraysEqual = (a: string[], b: string[]): boolean => {
248
+ if (a.length !== b.length) return false;
249
+ for (let i = 0; i < a.length; i++) {
250
+ if (a[i] !== b[i]) return false;
251
+ }
252
+ return true;
253
+ };
254
+
255
+ // actorTokenHash is computed lazily (only when we know we'll write an
256
+ // audit row) so the no-op idempotent path stays a pure read.
257
+ let actorTokenHashCache: string | null = null;
258
+ const getActorTokenHash = async (): Promise<string> => {
259
+ if (actorTokenHashCache === null) {
260
+ actorTokenHashCache = await sha256Hex(args.callerToken);
261
+ }
262
+ return actorTokenHashCache;
263
+ };
264
+
265
+ for (const p of defaults) {
266
+ const existing = await ctx.db
267
+ .query("oauth_scope_profiles")
268
+ .withIndex("by_profileId", (q) => q.eq("profileId", p.profileId))
269
+ .unique();
270
+
271
+ const now = Date.now();
272
+
273
+ if (!existing) {
274
+ await ctx.db.insert("oauth_scope_profiles", {
275
+ ...p,
276
+ createdAt: now,
277
+ updatedAt: now,
278
+ });
279
+ inserted.push(p.profileId);
280
+ continue;
281
+ }
282
+
283
+ // Build the patch: only fields whose persisted value diverges from
284
+ // the catalog. _creationTime + createdAt are preserved.
285
+ const patch: Record<string, unknown> = {};
286
+ if (existing.description !== p.description) {
287
+ patch.description = p.description;
288
+ }
289
+ if (!arraysEqual(existing.fromAllowList, p.fromAllowList)) {
290
+ patch.fromAllowList = p.fromAllowList;
291
+ }
292
+ if (
293
+ !arraysEqual(existing.namespaceReadPrefixes, p.namespaceReadPrefixes)
294
+ ) {
295
+ patch.namespaceReadPrefixes = p.namespaceReadPrefixes;
296
+ }
297
+ if (
298
+ !arraysEqual(existing.namespaceWritePrefixes, p.namespaceWritePrefixes)
299
+ ) {
300
+ patch.namespaceWritePrefixes = p.namespaceWritePrefixes;
301
+ }
302
+
303
+ if (Object.keys(patch).length === 0) {
304
+ skipped.push(p.profileId);
305
+ continue;
306
+ }
307
+
308
+ patch.updatedAt = now;
309
+ await ctx.db.patch(existing._id, patch);
310
+
311
+ // Audit log: capture the constrained {profileId, fromAllowList,
312
+ // namespaceReadPrefixes, namespaceWritePrefixes} snapshots required
313
+ // by the oauth_audit_log schema. Description drift, while patched,
314
+ // is not part of the forensic schema and is intentionally omitted
315
+ // from the audit row.
316
+ const previousState = {
317
+ profileId: existing.profileId,
318
+ fromAllowList: existing.fromAllowList,
319
+ namespaceReadPrefixes: existing.namespaceReadPrefixes,
320
+ namespaceWritePrefixes: existing.namespaceWritePrefixes,
321
+ };
322
+ const newState = {
323
+ profileId: p.profileId,
324
+ fromAllowList: p.fromAllowList,
325
+ namespaceReadPrefixes: p.namespaceReadPrefixes,
326
+ namespaceWritePrefixes: p.namespaceWritePrefixes,
327
+ };
328
+ await ctx.db.insert("oauth_audit_log", {
329
+ eventType: "seed_upsert",
330
+ actorTokenHash: await getActorTokenHash(),
331
+ targetProfileId: p.profileId,
332
+ previousState,
333
+ newState,
334
+ reason: "seedDefaultProfiles upsert — catalog drift patched (S3.4 B4)",
335
+ cascadeRevokedCount: 0,
336
+ clientsRetargeted: 0,
337
+ createdAt: now,
338
+ });
339
+ updated.push(p.profileId);
340
+ }
341
+
342
+ return { inserted, updated, skipped };
343
+ },
344
+ });
345
+
346
+ // ─────────────────────────────────────────────────────────────────────────────
347
+ // upsertScopeProfile — admin only, generic, idempotent, keyed UPSERT
348
+ //
349
+ // Replaces the risky `convex import --append` provisioning path for
350
+ // oauth_scope_profiles: `--append` can silently REPLACE the whole table if
351
+ // misused, wiping every sibling profile in one shot. A keyed upsert (lookup
352
+ // by `by_profileId`, patch-or-insert) cannot wipe siblings — it only ever
353
+ // touches the single targeted row.
354
+ //
355
+ // Contract:
356
+ // - Master-gated: requireMasterAuth runs FIRST, before any DB access.
357
+ // - Present → ctx.db.patch(existing._id, { ...profile, updatedAt: now }),
358
+ // preserving createdAt + _creationTime. Returns "updated".
359
+ // - Absent → ctx.db.insert(..., { ...profile, createdAt: now, updatedAt: now }).
360
+ // Returns "inserted".
361
+ // - Writes one oauth_audit_log row per call (eventType="scope_profile_upsert")
362
+ // capturing before/after state, mirroring seedDefaultProfiles' discipline.
363
+ // ─────────────────────────────────────────────────────────────────────────────
364
+
365
+ export const upsertScopeProfile = mutation({
366
+ args: { callerToken: v.string(), profile: scopeProfileShape },
367
+ returns: v.union(v.literal("inserted"), v.literal("updated")),
368
+ handler: async (ctx, args) => {
369
+ await requireMasterAuth(args.callerToken);
370
+
371
+ const { profile } = args;
372
+ const now = Date.now();
373
+ const actorTokenHash = await sha256Hex(args.callerToken);
374
+
375
+ const existing = await ctx.db
376
+ .query("oauth_scope_profiles")
377
+ .withIndex("by_profileId", (q) => q.eq("profileId", profile.profileId))
378
+ .unique();
379
+
380
+ const emptyState = {
381
+ profileId: "",
382
+ fromAllowList: [] as string[],
383
+ namespaceReadPrefixes: [] as string[],
384
+ namespaceWritePrefixes: [] as string[],
385
+ };
386
+ const newState = {
387
+ profileId: profile.profileId,
388
+ fromAllowList: profile.fromAllowList,
389
+ namespaceReadPrefixes: profile.namespaceReadPrefixes,
390
+ namespaceWritePrefixes: profile.namespaceWritePrefixes,
391
+ };
392
+
393
+ if (!existing) {
394
+ await ctx.db.insert("oauth_scope_profiles", {
395
+ ...profile,
396
+ createdAt: now,
397
+ updatedAt: now,
398
+ });
399
+
400
+ await ctx.db.insert("oauth_audit_log", {
401
+ eventType: "scope_profile_upsert",
402
+ actorTokenHash,
403
+ targetProfileId: profile.profileId,
404
+ previousState: emptyState,
405
+ newState,
406
+ reason: `upsertScopeProfile insert — profile "${profile.profileId}" did not exist`,
407
+ cascadeRevokedCount: 0,
408
+ clientsRetargeted: 0,
409
+ createdAt: now,
410
+ });
411
+
412
+ return "inserted" as const;
413
+ }
414
+
415
+ const previousState = {
416
+ profileId: existing.profileId,
417
+ fromAllowList: existing.fromAllowList,
418
+ namespaceReadPrefixes: existing.namespaceReadPrefixes,
419
+ namespaceWritePrefixes: existing.namespaceWritePrefixes,
420
+ };
421
+
422
+ await ctx.db.patch(existing._id, {
423
+ ...profile,
424
+ updatedAt: now,
425
+ });
426
+
427
+ await ctx.db.insert("oauth_audit_log", {
428
+ eventType: "scope_profile_upsert",
429
+ actorTokenHash,
430
+ targetProfileId: profile.profileId,
431
+ previousState,
432
+ newState,
433
+ reason: `upsertScopeProfile update — profile "${profile.profileId}" patched`,
434
+ cascadeRevokedCount: 0,
435
+ clientsRetargeted: 0,
436
+ createdAt: now,
437
+ });
438
+
439
+ return "updated" as const;
440
+ },
441
+ });
442
+
443
+ // ─────────────────────────────────────────────────────────────────────────────
444
+ // getScopeProfile — internal use by token-issuance path
445
+ // ─────────────────────────────────────────────────────────────────────────────
446
+
447
+ export const getScopeProfile = query({
448
+ args: { profileId: v.string() },
449
+ returns: v.union(scopeProfileShape, v.null()),
450
+ handler: async (ctx, args) => {
451
+ const row = await ctx.db
452
+ .query("oauth_scope_profiles")
453
+ .withIndex("by_profileId", (q) => q.eq("profileId", args.profileId))
454
+ .unique();
455
+ if (!row) return null;
456
+ return {
457
+ profileId: row.profileId,
458
+ description: row.description,
459
+ fromAllowList: row.fromAllowList,
460
+ namespaceReadPrefixes: row.namespaceReadPrefixes,
461
+ namespaceWritePrefixes: row.namespaceWritePrefixes,
462
+ };
463
+ },
464
+ });
465
+
466
+ // ─────────────────────────────────────────────────────────────────────────────
467
+ // CLIENTS
468
+ // ─────────────────────────────────────────────────────────────────────────────
469
+
470
+ const clientPublicShape = v.object({
471
+ _id: v.id("oauth_clients"),
472
+ clientId: v.string(),
473
+ name: v.string(),
474
+ scopeProfile: v.string(),
475
+ redirectUris: v.array(v.string()),
476
+ createdAt: v.number(),
477
+ revokedAt: v.optional(v.number()),
478
+ });
479
+
480
+ export const createClient = mutation({
481
+ args: {
482
+ callerToken: v.string(),
483
+ clientId: v.string(),
484
+ clientSecretHash: v.string(),
485
+ name: v.string(),
486
+ redirectUris: v.array(v.string()),
487
+ scopeProfile: v.string(),
488
+ tokenEndpointAuthMethod: v.optional(v.string()),
489
+ },
490
+ returns: v.id("oauth_clients"),
491
+ handler: async (ctx, args) => {
492
+ await requireMasterAuth(args.callerToken);
493
+
494
+ // Profile must exist
495
+ const profile = await ctx.db
496
+ .query("oauth_scope_profiles")
497
+ .withIndex("by_profileId", (q) => q.eq("profileId", args.scopeProfile))
498
+ .unique();
499
+ if (!profile) {
500
+ throw new Error(`Unknown scope_profile: ${args.scopeProfile}`);
501
+ }
502
+
503
+ // clientId must be unique
504
+ const existing = await ctx.db
505
+ .query("oauth_clients")
506
+ .withIndex("by_clientId", (q) => q.eq("clientId", args.clientId))
507
+ .unique();
508
+ if (existing) {
509
+ throw new Error(`clientId collision: ${args.clientId}`);
510
+ }
511
+
512
+ return await ctx.db.insert("oauth_clients", {
513
+ clientId: args.clientId,
514
+ clientSecretHash: args.clientSecretHash,
515
+ name: args.name,
516
+ redirectUris: args.redirectUris,
517
+ scopeProfile: args.scopeProfile,
518
+ createdAt: Date.now(),
519
+ // RFC 7591 §2: default to confidential client_secret_basic when absent.
520
+ tokenEndpointAuthMethod:
521
+ args.tokenEndpointAuthMethod ?? "client_secret_basic",
522
+ });
523
+ },
524
+ });
525
+
526
+ // ─────────────────────────────────────────────────────────────────────────────
527
+ // SECURITY: scope profiles that must NEVER be granted via public DCR self-reg.
528
+ // Master scope is admin-only; it requires explicit Pi authorization via the
529
+ // POST /admin/oauth/clients endpoint (masterOnlyMiddleware gated).
530
+ // ─────────────────────────────────────────────────────────────────────────────
531
+
532
+ const BLOCKED_PUBLIC_DCR_PROFILES: ReadonlySet<string> = new Set(["master"]);
533
+
534
+ // Public DCR path — anonymous clients (Claude.ai connector) register themselves
535
+ // with the default profile. The returned clientSecret is the caller's
536
+ // responsibility to capture; we store only the hash.
537
+ //
538
+ // SECURITY: This function enforces that self-registration NEVER yields master
539
+ // scope. Any attempt to pass scopeProfile="master" is rejected with an explicit
540
+ // ScopeViolation error. Profiles are further constrained to only the safe
541
+ // deny-by-default "client-generic" value; all other non-blocked profiles still
542
+ // require admin elevation post-registration before tokens carry real scopes.
543
+ export const registerPublicClient = mutation({
544
+ args: {
545
+ clientId: v.string(),
546
+ clientSecretHash: v.string(),
547
+ name: v.string(),
548
+ redirectUris: v.array(v.string()),
549
+ scopeProfile: v.string(),
550
+ tokenEndpointAuthMethod: v.optional(v.string()),
551
+ },
552
+ returns: v.id("oauth_clients"),
553
+ handler: async (ctx, args) => {
554
+ // SECURITY: Defense-in-depth — reject empty redirectUris at the Convex
555
+ // layer so that non-HTTP callers (admin scripts, direct Convex calls) also
556
+ // cannot create zombie clients. The HTTP layer (server-http.ts POST /register)
557
+ // already blocks this, but Convex is the last line of defense.
558
+ if (args.redirectUris.length === 0) {
559
+ throw new Error(
560
+ "InvalidRedirectUris: redirectUris must be a non-empty array. " +
561
+ "Zombie clients with empty redirectUris fail every /authorize call.",
562
+ );
563
+ }
564
+
565
+ // SECURITY: Refuse master scope (and any future admin-only profiles) at the
566
+ // Convex layer. This is defense-in-depth: server-http.ts already hardcodes
567
+ // DEFAULT_PUBLIC_DCR_PROFILE, but a direct Convex call must also be safe.
568
+ if (BLOCKED_PUBLIC_DCR_PROFILES.has(args.scopeProfile)) {
569
+ throw new Error(
570
+ `ScopeViolation: scopeProfile="${args.scopeProfile}" cannot be requested via self-registration. ` +
571
+ "Master scope requires admin authorization via POST /admin/oauth/clients.",
572
+ );
573
+ }
574
+
575
+ // Enforce a strict default profile for anonymous DCR — no admin required,
576
+ // but the profile MUST exist and be safe (deny-by-default or marie flow).
577
+ const profile = await ctx.db
578
+ .query("oauth_scope_profiles")
579
+ .withIndex("by_profileId", (q) => q.eq("profileId", args.scopeProfile))
580
+ .unique();
581
+ if (!profile) {
582
+ throw new Error(`Unknown scope_profile: ${args.scopeProfile}`);
583
+ }
584
+
585
+ const existing = await ctx.db
586
+ .query("oauth_clients")
587
+ .withIndex("by_clientId", (q) => q.eq("clientId", args.clientId))
588
+ .unique();
589
+ if (existing) {
590
+ throw new Error(`clientId collision: ${args.clientId}`);
591
+ }
592
+
593
+ return await ctx.db.insert("oauth_clients", {
594
+ clientId: args.clientId,
595
+ clientSecretHash: args.clientSecretHash,
596
+ name: args.name,
597
+ redirectUris: args.redirectUris,
598
+ scopeProfile: args.scopeProfile,
599
+ createdAt: Date.now(),
600
+ // RFC 7591 §2: default confidential. Public clients ("none") may be
601
+ // promoted later via admin/oauth/clients PATCH (out of scope here).
602
+ tokenEndpointAuthMethod:
603
+ args.tokenEndpointAuthMethod ?? "client_secret_basic",
604
+ });
605
+ },
606
+ });
607
+
608
+ export const getClientByClientId = query({
609
+ args: { clientId: v.string() },
610
+ returns: v.union(
611
+ v.object({
612
+ clientId: v.string(),
613
+ clientSecretHash: v.string(),
614
+ name: v.string(),
615
+ redirectUris: v.array(v.string()),
616
+ scopeProfile: v.string(),
617
+ revokedAt: v.optional(v.number()),
618
+ tokenEndpointAuthMethod: v.optional(v.string()),
619
+ }),
620
+ v.null(),
621
+ ),
622
+ handler: async (ctx, args) => {
623
+ const row = await ctx.db
624
+ .query("oauth_clients")
625
+ .withIndex("by_clientId", (q) => q.eq("clientId", args.clientId))
626
+ .unique();
627
+ if (!row) return null;
628
+ return {
629
+ clientId: row.clientId,
630
+ clientSecretHash: row.clientSecretHash,
631
+ name: row.name,
632
+ redirectUris: row.redirectUris,
633
+ scopeProfile: row.scopeProfile,
634
+ revokedAt: row.revokedAt,
635
+ tokenEndpointAuthMethod: row.tokenEndpointAuthMethod,
636
+ };
637
+ },
638
+ });
639
+
640
+ export const listClients = query({
641
+ args: { callerToken: v.string() },
642
+ returns: v.array(clientPublicShape),
643
+ handler: async (ctx, args) => {
644
+ await requireMasterAuth(args.callerToken);
645
+ const rows = await ctx.db.query("oauth_clients").order("desc").collect();
646
+ return rows.map((r) => ({
647
+ _id: r._id,
648
+ clientId: r.clientId,
649
+ name: r.name,
650
+ scopeProfile: r.scopeProfile,
651
+ redirectUris: r.redirectUris,
652
+ createdAt: r.createdAt,
653
+ revokedAt: r.revokedAt,
654
+ }));
655
+ },
656
+ });
657
+
658
+ export const deleteClient = mutation({
659
+ args: { callerToken: v.string(), clientId: v.string() },
660
+ returns: v.object({
661
+ revokedClient: v.boolean(),
662
+ revokedTokens: v.number(),
663
+ revokedRefresh: v.number(),
664
+ }),
665
+ handler: async (ctx, args) => {
666
+ await requireMasterAuth(args.callerToken);
667
+ const client = await ctx.db
668
+ .query("oauth_clients")
669
+ .withIndex("by_clientId", (q) => q.eq("clientId", args.clientId))
670
+ .unique();
671
+ if (!client) {
672
+ return { revokedClient: false, revokedTokens: 0, revokedRefresh: 0 };
673
+ }
674
+
675
+ const now = Date.now();
676
+ await ctx.db.patch(client._id, { revokedAt: now });
677
+
678
+ // Revoke all access tokens
679
+ const accessTokens = await ctx.db
680
+ .query("oauth_access_tokens")
681
+ .withIndex("by_clientId", (q) => q.eq("clientId", args.clientId))
682
+ .collect();
683
+ for (const t of accessTokens) {
684
+ if (t.revokedAt === undefined) {
685
+ await ctx.db.patch(t._id, { revokedAt: now });
686
+ }
687
+ }
688
+
689
+ // Revoke all refresh tokens
690
+ const refreshTokens = await ctx.db
691
+ .query("oauth_refresh_tokens")
692
+ .withIndex("by_clientId", (q) => q.eq("clientId", args.clientId))
693
+ .collect();
694
+ for (const t of refreshTokens) {
695
+ if (t.revokedAt === undefined) {
696
+ await ctx.db.patch(t._id, { revokedAt: now });
697
+ }
698
+ }
699
+
700
+ return {
701
+ revokedClient: true,
702
+ revokedTokens: accessTokens.length,
703
+ revokedRefresh: refreshTokens.length,
704
+ };
705
+ },
706
+ });
707
+
708
+ // ─────────────────────────────────────────────────────────────────────────────
709
+ // patchClientScopeAndRefreshTokens — Day 92 LIVE
710
+ //
711
+ // Re-target a client to a new scope_profile WITHOUT revoking its tokens, so
712
+ // the bearer the user already pasted into their MCP host keeps working but
713
+ // inherits the new profile's `fromAllowList` + namespace prefixes. The
714
+ // access token row caches those three fields at mint time (see
715
+ // createAccessToken); without this mutation the only way to apply a
716
+ // profile change to an in-use token was to revoke + re-mint, which forces
717
+ // the user to re-paste credentials.
718
+ //
719
+ // What this does (single transaction):
720
+ // 1. Validate the target scope_profile exists in `oauth_scope_profiles`.
721
+ // 2. Patch `oauth_clients[clientId].scopeProfile` to the new value.
722
+ // 3. Walk every non-revoked row in `oauth_access_tokens` for that
723
+ // clientId and patch its `scopeProfile` + `fromAllowList` +
724
+ // `namespaceReadPrefixes` + `namespaceWritePrefixes` to the values
725
+ // from the new profile.
726
+ // 4. Refresh tokens are intentionally NOT touched — they only carry
727
+ // clientId + userId + tokenHash, no cached scope, so the next
728
+ // refresh-flow will naturally observe the new client.scopeProfile.
729
+ // 5. Append an `oauth_audit_log` row capturing the rename for forensic
730
+ // traceability (eventType="patch_client_scope").
731
+ //
732
+ // Master-gated. Idempotent on identical profile.
733
+ // ─────────────────────────────────────────────────────────────────────────────
734
+ export const patchClientScopeAndRefreshTokens = mutation({
735
+ args: {
736
+ callerToken: v.string(),
737
+ clientId: v.string(),
738
+ newScopeProfile: v.string(),
739
+ reason: v.string(),
740
+ },
741
+ returns: v.object({
742
+ clientPatched: v.boolean(),
743
+ previousScopeProfile: v.string(),
744
+ newScopeProfile: v.string(),
745
+ accessTokensRefreshed: v.number(),
746
+ refreshTokensRetargeted: v.number(),
747
+ auditLogId: v.id("oauth_audit_log"),
748
+ }),
749
+ handler: async (ctx, args) => {
750
+ await requireMasterAuth(args.callerToken);
751
+
752
+ if (args.reason.length < 20) {
753
+ throw new Error(
754
+ "reason must be at least 20 characters (operator audit trail)",
755
+ );
756
+ }
757
+
758
+ const client = await ctx.db
759
+ .query("oauth_clients")
760
+ .withIndex("by_clientId", (q) => q.eq("clientId", args.clientId))
761
+ .unique();
762
+ if (!client) {
763
+ throw new Error(`client not found: ${args.clientId}`);
764
+ }
765
+ if (client.revokedAt !== undefined) {
766
+ throw new Error(`client is revoked: ${args.clientId}`);
767
+ }
768
+
769
+ const newProfile = await ctx.db
770
+ .query("oauth_scope_profiles")
771
+ .withIndex("by_profileId", (q) => q.eq("profileId", args.newScopeProfile))
772
+ .unique();
773
+ if (!newProfile) {
774
+ throw new Error(`scope_profile not found: ${args.newScopeProfile}`);
775
+ }
776
+
777
+ const previousScopeProfile = client.scopeProfile;
778
+ const now = Date.now();
779
+
780
+ // Patch the client itself.
781
+ await ctx.db.patch(client._id, { scopeProfile: args.newScopeProfile });
782
+
783
+ // Walk live access tokens and refresh the cached scope + prefixes.
784
+ const tokens = await ctx.db
785
+ .query("oauth_access_tokens")
786
+ .withIndex("by_clientId", (q) => q.eq("clientId", args.clientId))
787
+ .collect();
788
+ let refreshed = 0;
789
+ for (const t of tokens) {
790
+ if (t.revokedAt !== undefined) continue;
791
+ if (t.expiresAt < now) continue;
792
+ await ctx.db.patch(t._id, {
793
+ scopeProfile: args.newScopeProfile,
794
+ fromAllowList: newProfile.fromAllowList,
795
+ namespaceReadPrefixes: newProfile.namespaceReadPrefixes,
796
+ namespaceWritePrefixes: newProfile.namespaceWritePrefixes,
797
+ });
798
+ refreshed++;
799
+ }
800
+
801
+ // CRITICAL — refresh tokens cache `scopeProfile` (used by /token
802
+ // refresh handler L789 via loadScopeProfile(record.scopeProfile)).
803
+ // If we leave the refresh token row stale, the next refresh-flow
804
+ // MINTS a NEW access token with the OLD profile's fromAllowList,
805
+ // silently re-introducing the stale identity. Patch all live
806
+ // refresh tokens for this client so the next mint reads the new
807
+ // profile.
808
+ const refreshTokens = await ctx.db
809
+ .query("oauth_refresh_tokens")
810
+ .withIndex("by_clientId", (q) => q.eq("clientId", args.clientId))
811
+ .collect();
812
+ let refreshTokensRetargeted = 0;
813
+ for (const r of refreshTokens) {
814
+ if (r.revokedAt !== undefined) continue;
815
+ if (r.expiresAt < now) continue;
816
+ await ctx.db.patch(r._id, { scopeProfile: args.newScopeProfile });
817
+ refreshTokensRetargeted++;
818
+ }
819
+
820
+ const auditLogId = await ctx.db.insert("oauth_audit_log", {
821
+ eventType: "patch_client_scope",
822
+ actorTokenHash: await sha256Hex(args.callerToken),
823
+ targetProfileId: args.newScopeProfile,
824
+ previousState: {
825
+ profileId: previousScopeProfile,
826
+ fromAllowList: [],
827
+ namespaceReadPrefixes: [],
828
+ namespaceWritePrefixes: [],
829
+ },
830
+ newState: {
831
+ profileId: args.newScopeProfile,
832
+ fromAllowList: newProfile.fromAllowList,
833
+ namespaceReadPrefixes: newProfile.namespaceReadPrefixes,
834
+ namespaceWritePrefixes: newProfile.namespaceWritePrefixes,
835
+ },
836
+ reason: args.reason,
837
+ cascadeRevokedCount: 0,
838
+ clientsRetargeted: 1,
839
+ createdAt: now,
840
+ });
841
+
842
+ return {
843
+ clientPatched: true,
844
+ previousScopeProfile,
845
+ newScopeProfile: args.newScopeProfile,
846
+ accessTokensRefreshed: refreshed,
847
+ refreshTokensRetargeted,
848
+ auditLogId,
849
+ };
850
+ },
851
+ });
852
+
853
+ // ─────────────────────────────────────────────────────────────────────────────
854
+ // revokeAccessTokensOnly — Day 92 LIVE
855
+ //
856
+ // Revoke every live access token for a client WITHOUT touching its
857
+ // refresh tokens. The next API call from the connector hits 401, which
858
+ // triggers an OAuth refresh-flow that re-mints a fresh access token
859
+ // reading the CURRENT `oauth_clients.scopeProfile` + the current scope
860
+ // profile catalog (via server-http.ts L789 loadScopeProfile). Combined
861
+ // with `patchClientScopeAndRefreshTokens` (which already retargeted
862
+ // refresh_tokens.scopeProfile in commit 40413bd) this guarantees the
863
+ // next mint observes the new profile.
864
+ //
865
+ // Use case: a profile change shipped while a long-lived bearer is in
866
+ // use. The operator does NOT want to wait for the natural access-token
867
+ // expiry (24h) but ALSO does NOT want to force the customer to re-paste
868
+ // credentials (the refresh token stays alive). This mutation is the
869
+ // minimum-friction force-rotate.
870
+ //
871
+ // Master-gated. Returns the number of access tokens revoked.
872
+ // ─────────────────────────────────────────────────────────────────────────────
873
+ export const revokeAccessTokensOnly = mutation({
874
+ args: {
875
+ callerToken: v.string(),
876
+ clientId: v.string(),
877
+ reason: v.string(),
878
+ },
879
+ returns: v.object({
880
+ clientId: v.string(),
881
+ accessTokensRevoked: v.number(),
882
+ refreshTokensPreserved: v.number(),
883
+ }),
884
+ handler: async (ctx, args) => {
885
+ await requireMasterAuth(args.callerToken);
886
+
887
+ if (args.reason.length < 20) {
888
+ throw new Error(
889
+ "reason must be at least 20 characters (operator audit trail)",
890
+ );
891
+ }
892
+
893
+ const client = await ctx.db
894
+ .query("oauth_clients")
895
+ .withIndex("by_clientId", (q) => q.eq("clientId", args.clientId))
896
+ .unique();
897
+ if (!client) {
898
+ throw new Error(`client not found: ${args.clientId}`);
899
+ }
900
+
901
+ const now = Date.now();
902
+ let revoked = 0;
903
+ const accessTokens = await ctx.db
904
+ .query("oauth_access_tokens")
905
+ .withIndex("by_clientId", (q) => q.eq("clientId", args.clientId))
906
+ .collect();
907
+ for (const t of accessTokens) {
908
+ if (t.revokedAt !== undefined) continue;
909
+ await ctx.db.patch(t._id, { revokedAt: now });
910
+ revoked++;
911
+ }
912
+
913
+ // Count surviving refresh tokens (no patch). Useful in the response
914
+ // so the operator can confirm the refresh-flow can proceed.
915
+ const refreshTokens = await ctx.db
916
+ .query("oauth_refresh_tokens")
917
+ .withIndex("by_clientId", (q) => q.eq("clientId", args.clientId))
918
+ .collect();
919
+ const preserved = refreshTokens.filter(
920
+ (r) => r.revokedAt === undefined && r.expiresAt > now,
921
+ ).length;
922
+
923
+ return {
924
+ clientId: args.clientId,
925
+ accessTokensRevoked: revoked,
926
+ refreshTokensPreserved: preserved,
927
+ };
928
+ },
929
+ });
930
+
931
+ // ─────────────────────────────────────────────────────────────────────────────
932
+ // AUTHORIZATION CODES
933
+ // ─────────────────────────────────────────────────────────────────────────────
934
+
935
+ // Gated by master token — only the HTTP server (which knows BEARER_SECRET_MASTER)
936
+ // may mint authorization codes. Closes the pre-Day-47 hole where any caller with
937
+ // Convex HTTP access could forge a code row and chain it into a scoped token.
938
+ export const createAuthorizationCode = mutation({
939
+ args: {
940
+ callerToken: v.string(),
941
+ code: v.string(),
942
+ clientId: v.string(),
943
+ redirectUri: v.string(),
944
+ codeChallenge: v.string(),
945
+ scope: v.string(),
946
+ userId: v.string(),
947
+ expiresAt: v.number(),
948
+ },
949
+ returns: v.id("oauth_authorization_codes"),
950
+ handler: async (ctx, args) => {
951
+ await requireMasterAuth(args.callerToken);
952
+ return await ctx.db.insert("oauth_authorization_codes", {
953
+ code: args.code,
954
+ clientId: args.clientId,
955
+ redirectUri: args.redirectUri,
956
+ codeChallenge: args.codeChallenge,
957
+ scope: args.scope,
958
+ userId: args.userId,
959
+ expiresAt: args.expiresAt,
960
+ });
961
+ },
962
+ });
963
+
964
+ export const consumeAuthorizationCode = mutation({
965
+ args: { code: v.string() },
966
+ returns: v.union(
967
+ v.object({
968
+ clientId: v.string(),
969
+ redirectUri: v.string(),
970
+ codeChallenge: v.string(),
971
+ scope: v.string(),
972
+ userId: v.string(),
973
+ expiresAt: v.number(),
974
+ }),
975
+ v.null(),
976
+ ),
977
+ handler: async (ctx, args) => {
978
+ const row = await ctx.db
979
+ .query("oauth_authorization_codes")
980
+ .withIndex("by_code", (q) => q.eq("code", args.code))
981
+ .unique();
982
+ if (!row) return null;
983
+ // Single-use: delete before returning
984
+ await ctx.db.delete(row._id);
985
+ return {
986
+ clientId: row.clientId,
987
+ redirectUri: row.redirectUri,
988
+ codeChallenge: row.codeChallenge,
989
+ scope: row.scope,
990
+ userId: row.userId,
991
+ expiresAt: row.expiresAt,
992
+ };
993
+ },
994
+ });
995
+
996
+ // ─────────────────────────────────────────────────────────────────────────────
997
+ // ACCESS TOKENS
998
+ // ─────────────────────────────────────────────────────────────────────────────
999
+
1000
+ // Gated by master token — only the HTTP server may issue access tokens. Without
1001
+ // this gate an attacker with Convex HTTP access could insert a row granting
1002
+ // master-scope access and present the raw bearer to the MCP server.
1003
+ export const createAccessToken = mutation({
1004
+ args: {
1005
+ callerToken: v.string(),
1006
+ tokenHash: v.string(),
1007
+ clientId: v.string(),
1008
+ userId: v.string(),
1009
+ scopes: v.array(v.string()),
1010
+ scopeProfile: v.string(),
1011
+ fromAllowList: v.array(v.string()),
1012
+ namespaceReadPrefixes: v.array(v.string()),
1013
+ namespaceWritePrefixes: v.array(v.string()),
1014
+ expiresAt: v.number(),
1015
+ refreshTokenHash: v.optional(v.string()),
1016
+ },
1017
+ returns: v.id("oauth_access_tokens"),
1018
+ handler: async (ctx, args) => {
1019
+ await requireMasterAuth(args.callerToken);
1020
+ return await ctx.db.insert("oauth_access_tokens", {
1021
+ tokenHash: args.tokenHash,
1022
+ clientId: args.clientId,
1023
+ userId: args.userId,
1024
+ scopes: args.scopes,
1025
+ scopeProfile: args.scopeProfile,
1026
+ fromAllowList: args.fromAllowList,
1027
+ namespaceReadPrefixes: args.namespaceReadPrefixes,
1028
+ namespaceWritePrefixes: args.namespaceWritePrefixes,
1029
+ expiresAt: args.expiresAt,
1030
+ refreshTokenHash: args.refreshTokenHash,
1031
+ createdAt: Date.now(),
1032
+ });
1033
+ },
1034
+ });
1035
+
1036
+ // Returned to bearer auth middleware — only the fields needed for enforcement.
1037
+ const oauthContextShape = v.object({
1038
+ clientId: v.string(),
1039
+ userId: v.string(),
1040
+ scopes: v.array(v.string()),
1041
+ scopeProfile: v.string(),
1042
+ fromAllowList: v.array(v.string()),
1043
+ namespaceReadPrefixes: v.array(v.string()),
1044
+ namespaceWritePrefixes: v.array(v.string()),
1045
+ expiresAt: v.number(),
1046
+ });
1047
+
1048
+ export const getAccessTokenByHash = query({
1049
+ args: { tokenHash: v.string() },
1050
+ returns: v.union(oauthContextShape, v.null()),
1051
+ handler: async (ctx, args) => {
1052
+ const row = await ctx.db
1053
+ .query("oauth_access_tokens")
1054
+ .withIndex("by_tokenHash", (q) => q.eq("tokenHash", args.tokenHash))
1055
+ .unique();
1056
+ if (!row) return null;
1057
+ if (row.revokedAt !== undefined) return null;
1058
+ if (row.expiresAt < Date.now()) return null;
1059
+ return {
1060
+ clientId: row.clientId,
1061
+ userId: row.userId,
1062
+ scopes: row.scopes,
1063
+ scopeProfile: row.scopeProfile,
1064
+ fromAllowList: row.fromAllowList,
1065
+ namespaceReadPrefixes: row.namespaceReadPrefixes,
1066
+ namespaceWritePrefixes: row.namespaceWritePrefixes,
1067
+ expiresAt: row.expiresAt,
1068
+ };
1069
+ },
1070
+ });
1071
+
1072
+ // ─────────────────────────────────────────────────────────────────────────────
1073
+ // REFRESH TOKENS
1074
+ // ─────────────────────────────────────────────────────────────────────────────
1075
+
1076
+ // Gated by master token — only the HTTP server may issue refresh tokens.
1077
+ export const createRefreshToken = mutation({
1078
+ args: {
1079
+ callerToken: v.string(),
1080
+ tokenHash: v.string(),
1081
+ clientId: v.string(),
1082
+ userId: v.string(),
1083
+ scopeProfile: v.string(),
1084
+ expiresAt: v.number(),
1085
+ },
1086
+ returns: v.id("oauth_refresh_tokens"),
1087
+ handler: async (ctx, args) => {
1088
+ await requireMasterAuth(args.callerToken);
1089
+ return await ctx.db.insert("oauth_refresh_tokens", {
1090
+ tokenHash: args.tokenHash,
1091
+ clientId: args.clientId,
1092
+ userId: args.userId,
1093
+ scopeProfile: args.scopeProfile,
1094
+ expiresAt: args.expiresAt,
1095
+ createdAt: Date.now(),
1096
+ });
1097
+ },
1098
+ });
1099
+
1100
+ export const getRefreshTokenByHash = query({
1101
+ args: { tokenHash: v.string() },
1102
+ returns: v.union(
1103
+ v.object({
1104
+ clientId: v.string(),
1105
+ userId: v.string(),
1106
+ scopeProfile: v.string(),
1107
+ expiresAt: v.number(),
1108
+ }),
1109
+ v.null(),
1110
+ ),
1111
+ handler: async (ctx, args) => {
1112
+ const row = await ctx.db
1113
+ .query("oauth_refresh_tokens")
1114
+ .withIndex("by_tokenHash", (q) => q.eq("tokenHash", args.tokenHash))
1115
+ .unique();
1116
+ if (!row) return null;
1117
+ if (row.revokedAt !== undefined) return null;
1118
+ if (row.expiresAt < Date.now()) return null;
1119
+ return {
1120
+ clientId: row.clientId,
1121
+ userId: row.userId,
1122
+ scopeProfile: row.scopeProfile,
1123
+ expiresAt: row.expiresAt,
1124
+ };
1125
+ },
1126
+ });
1127
+
1128
+ // ─────────────────────────────────────────────────────────────────────────────
1129
+ // SHA-256 hex helper (local, mirrors credentials.ts — avoids cross-file import)
1130
+ // ─────────────────────────────────────────────────────────────────────────────
1131
+
1132
+ async function sha256Hex(input: string): Promise<string> {
1133
+ const encoded = new TextEncoder().encode(input);
1134
+ const hashBuffer = await crypto.subtle.digest("SHA-256", encoded);
1135
+ return Array.from(new Uint8Array(hashBuffer))
1136
+ .map((b) => b.toString(16).padStart(2, "0"))
1137
+ .join("");
1138
+ }
1139
+
1140
+ // ─────────────────────────────────────────────────────────────────────────────
1141
+ // patchScopeProfileEmergency — S1.2-mutation
1142
+ //
1143
+ // Emergency mutation for administrative scope profile remediation.
1144
+ // Security properties:
1145
+ // - Master token guard (constant-time, same timingSafeEqual as createClient)
1146
+ // - reason ≥ 40 chars required (audit trail hygiene)
1147
+ // - D4 enforcement: `global` and `*` forbidden in read/write prefixes unless
1148
+ // the target profileId is "master" (after optional rename applied)
1149
+ // - Cascade revoke: deletes all oauth_access_tokens + oauth_refresh_tokens
1150
+ // where scopeProfile = oldName OR newName
1151
+ // - Append-only audit log: previousState + newState + actorTokenHash + reason
1152
+ //
1153
+ // Day 90 use-case: drops `global` from the onboarding-client scope profile,
1154
+ // renamed per D9 workspace-level naming (see patch_marie_iris_rh_scope.ts).
1155
+ // ─────────────────────────────────────────────────────────────────────────────
1156
+
1157
+ export const patchScopeProfileEmergency = mutation({
1158
+ args: {
1159
+ callerToken: v.string(),
1160
+ profileId: v.string(),
1161
+ rename: v.optional(v.string()),
1162
+ fromAllowList: v.optional(v.array(v.string())),
1163
+ namespaceReadPrefixes: v.optional(v.array(v.string())),
1164
+ namespaceWritePrefixes: v.optional(v.array(v.string())),
1165
+ cascadeRevokeTokens: v.boolean(),
1166
+ reason: v.string(),
1167
+ },
1168
+ returns: v.object({
1169
+ patchedProfileId: v.string(),
1170
+ cascadeRevokedCount: v.number(),
1171
+ clientsRetargeted: v.number(),
1172
+ auditLogId: v.id("oauth_audit_log"),
1173
+ }),
1174
+ handler: async (ctx, args) => {
1175
+ // ── Master token guard (constant-time) ────────────────────────────────
1176
+ await requireMasterAuth(args.callerToken);
1177
+
1178
+ // ── Reason length guard ───────────────────────────────────────────────
1179
+ if (args.reason.length < 40) {
1180
+ throw new Error(
1181
+ "reason must be at least 40 characters for audit trail hygiene",
1182
+ );
1183
+ }
1184
+
1185
+ // ── Lookup existing profile ───────────────────────────────────────────
1186
+ const existing = await ctx.db
1187
+ .query("oauth_scope_profiles")
1188
+ .withIndex("by_profileId", (q) => q.eq("profileId", args.profileId))
1189
+ .unique();
1190
+ if (!existing) {
1191
+ throw new Error(`profile not found: ${args.profileId}`);
1192
+ }
1193
+
1194
+ // ── Determine final profileId after optional rename ───────────────────
1195
+ const newProfileId = args.rename ?? args.profileId;
1196
+
1197
+ // ── D4 enforcement ────────────────────────────────────────────────────
1198
+ // If target profile name is NOT "master", forbid "global" and "*" in
1199
+ // any submitted prefix list.
1200
+ if (newProfileId !== "master") {
1201
+ const allPrefixes = [
1202
+ ...(args.namespaceReadPrefixes ?? []),
1203
+ ...(args.namespaceWritePrefixes ?? []),
1204
+ ];
1205
+ for (const p of allPrefixes) {
1206
+ if (p === "global" || p === "*") {
1207
+ throw new Error(
1208
+ `D4 violation: profile "${newProfileId}" cannot include "${p}" in namespace prefixes`,
1209
+ );
1210
+ }
1211
+ }
1212
+ }
1213
+
1214
+ // ── Capture previous state for audit log ──────────────────────────────
1215
+ const previousState = {
1216
+ profileId: existing.profileId,
1217
+ fromAllowList: existing.fromAllowList,
1218
+ namespaceReadPrefixes: existing.namespaceReadPrefixes,
1219
+ namespaceWritePrefixes: existing.namespaceWritePrefixes,
1220
+ };
1221
+
1222
+ // ── Apply selective patch ─────────────────────────────────────────────
1223
+ const patchPayload: {
1224
+ profileId?: string;
1225
+ fromAllowList?: string[];
1226
+ namespaceReadPrefixes?: string[];
1227
+ namespaceWritePrefixes?: string[];
1228
+ updatedAt: number;
1229
+ } = { updatedAt: Date.now() };
1230
+
1231
+ if (args.rename !== undefined) {
1232
+ patchPayload.profileId = args.rename;
1233
+ }
1234
+ if (args.fromAllowList !== undefined) {
1235
+ patchPayload.fromAllowList = args.fromAllowList;
1236
+ }
1237
+ if (args.namespaceReadPrefixes !== undefined) {
1238
+ patchPayload.namespaceReadPrefixes = args.namespaceReadPrefixes;
1239
+ }
1240
+ if (args.namespaceWritePrefixes !== undefined) {
1241
+ patchPayload.namespaceWritePrefixes = args.namespaceWritePrefixes;
1242
+ }
1243
+
1244
+ await ctx.db.patch(existing._id, patchPayload);
1245
+
1246
+ // ── Cascade-update oauth_clients (S2.1-D9) ───────────────────────────
1247
+ // When renamed, retarget every oauth_clients row that pointed at the old
1248
+ // profile name so no client orphans after rename.
1249
+ // This step runs BEFORE cascade revoke so revoke logic covers both names.
1250
+ let clientsRetargeted = 0;
1251
+
1252
+ if (args.rename !== undefined && args.rename !== args.profileId) {
1253
+ const clientsToRetarget = await ctx.db
1254
+ .query("oauth_clients")
1255
+ .withIndex("by_scopeProfile", (q) =>
1256
+ q.eq("scopeProfile", args.profileId),
1257
+ )
1258
+ .collect();
1259
+ for (const client of clientsToRetarget) {
1260
+ await ctx.db.patch(client._id, { scopeProfile: args.rename });
1261
+ clientsRetargeted++;
1262
+ }
1263
+ }
1264
+
1265
+ // ── Cascade revoke tokens ─────────────────────────────────────────────
1266
+ let cascadeRevokedCount = 0;
1267
+
1268
+ if (args.cascadeRevokeTokens) {
1269
+ const oldName = args.profileId;
1270
+
1271
+ // Collect profile names to revoke (old name + new name if renamed)
1272
+ const profileNamesToRevoke = new Set<string>([oldName]);
1273
+ if (newProfileId !== oldName) {
1274
+ profileNamesToRevoke.add(newProfileId);
1275
+ }
1276
+
1277
+ for (const profileName of profileNamesToRevoke) {
1278
+ // Delete access tokens citing this profile
1279
+ const accessTokens = await ctx.db
1280
+ .query("oauth_access_tokens")
1281
+ .filter((q) => q.eq(q.field("scopeProfile"), profileName))
1282
+ .collect();
1283
+ for (const t of accessTokens) {
1284
+ await ctx.db.delete(t._id);
1285
+ cascadeRevokedCount++;
1286
+ }
1287
+
1288
+ // Delete refresh tokens citing this profile
1289
+ const refreshTokens = await ctx.db
1290
+ .query("oauth_refresh_tokens")
1291
+ .filter((q) => q.eq(q.field("scopeProfile"), profileName))
1292
+ .collect();
1293
+ for (const t of refreshTokens) {
1294
+ await ctx.db.delete(t._id);
1295
+ cascadeRevokedCount++;
1296
+ }
1297
+ }
1298
+ }
1299
+
1300
+ // ── Re-read new state ─────────────────────────────────────────────────
1301
+ const updated = await ctx.db.get(existing._id);
1302
+ const newState = {
1303
+ profileId: updated?.profileId ?? newProfileId,
1304
+ fromAllowList: updated?.fromAllowList ?? existing.fromAllowList,
1305
+ namespaceReadPrefixes:
1306
+ updated?.namespaceReadPrefixes ?? existing.namespaceReadPrefixes,
1307
+ namespaceWritePrefixes:
1308
+ updated?.namespaceWritePrefixes ?? existing.namespaceWritePrefixes,
1309
+ };
1310
+
1311
+ // ── Append audit log ──────────────────────────────────────────────────
1312
+ const actorTokenHash = await sha256Hex(args.callerToken);
1313
+ const auditLogId = await ctx.db.insert("oauth_audit_log", {
1314
+ eventType: "scope_profile_emergency_patch",
1315
+ actorTokenHash,
1316
+ targetProfileId: args.profileId, // original profileId for stable index key
1317
+ previousState,
1318
+ newState,
1319
+ reason: args.reason,
1320
+ cascadeRevokedCount,
1321
+ clientsRetargeted,
1322
+ createdAt: Date.now(),
1323
+ });
1324
+
1325
+ return {
1326
+ patchedProfileId: newProfileId,
1327
+ cascadeRevokedCount,
1328
+ clientsRetargeted,
1329
+ auditLogId,
1330
+ };
1331
+ },
1332
+ });
1333
+
1334
+ // ─────────────────────────────────────────────────────────────────────────────
1335
+ // seedTestTenantTrio — Day 92 A0 persistent test infrastructure
1336
+ //
1337
+ // Creates 3 scope_profiles for the alpha/beta/gamma test orchestrator trio.
1338
+ // IDEMPOTENT: skips any profile that already exists by profileId.
1339
+ // Master-gated. Each profile grants symmetric read access to all 3 orchestrator
1340
+ // namespaces + project/mcp-test, and write access scoped to its own namespace.
1341
+ //
1342
+ // Profiles:
1343
+ // alpha-test-trio — write: orchestrator/Alpha + orchestrator/alpha + project/mcp-test
1344
+ // beta-test-trio — write: orchestrator/Beta + orchestrator/beta + project/mcp-test
1345
+ // gamma-test-trio — write: orchestrator/Gamma + orchestrator/gamma + project/mcp-test
1346
+ // All three share the same read prefixes (full trio + project/mcp-test).
1347
+ // fromAllowList includes all case variants of Alpha, Beta, Gamma for robustness.
1348
+ // ─────────────────────────────────────────────────────────────────────────────
1349
+
1350
+ export const seedTestTenantTrio = mutation({
1351
+ args: { callerToken: v.string() },
1352
+ returns: v.object({
1353
+ inserted: v.array(v.string()),
1354
+ skipped: v.array(v.string()),
1355
+ }),
1356
+ handler: async (ctx, args) => {
1357
+ await requireMasterAuth(args.callerToken);
1358
+
1359
+ const trioReadPrefixes = [
1360
+ "orchestrator/Alpha",
1361
+ "orchestrator/alpha",
1362
+ "orchestrator/Beta",
1363
+ "orchestrator/beta",
1364
+ "orchestrator/Gamma",
1365
+ "orchestrator/gamma",
1366
+ "project/mcp-test",
1367
+ ];
1368
+
1369
+ const trioFromAllowList = [
1370
+ "Alpha",
1371
+ "alpha",
1372
+ "Beta",
1373
+ "beta",
1374
+ "Gamma",
1375
+ "gamma",
1376
+ "ALPHA",
1377
+ "BETA",
1378
+ "GAMMA",
1379
+ ];
1380
+
1381
+ const profiles = [
1382
+ {
1383
+ profileId: "alpha-test-trio",
1384
+ description:
1385
+ "Alpha test orchestrator — day92 persistent test trio. Write: Alpha namespace. Read: full trio + project/mcp-test.",
1386
+ fromAllowList: trioFromAllowList,
1387
+ namespaceReadPrefixes: trioReadPrefixes,
1388
+ namespaceWritePrefixes: [
1389
+ "orchestrator/Alpha",
1390
+ "orchestrator/alpha",
1391
+ "project/mcp-test",
1392
+ ],
1393
+ },
1394
+ {
1395
+ profileId: "beta-test-trio",
1396
+ description:
1397
+ "Beta test orchestrator — day92 persistent test trio. Write: Beta namespace. Read: full trio + project/mcp-test.",
1398
+ fromAllowList: trioFromAllowList,
1399
+ namespaceReadPrefixes: trioReadPrefixes,
1400
+ namespaceWritePrefixes: [
1401
+ "orchestrator/Beta",
1402
+ "orchestrator/beta",
1403
+ "project/mcp-test",
1404
+ ],
1405
+ },
1406
+ {
1407
+ profileId: "gamma-test-trio",
1408
+ description:
1409
+ "Gamma test orchestrator — day92 persistent test trio. Write: Gamma namespace. Read: full trio + project/mcp-test.",
1410
+ fromAllowList: trioFromAllowList,
1411
+ namespaceReadPrefixes: trioReadPrefixes,
1412
+ namespaceWritePrefixes: [
1413
+ "orchestrator/Gamma",
1414
+ "orchestrator/gamma",
1415
+ "project/mcp-test",
1416
+ ],
1417
+ },
1418
+ ];
1419
+
1420
+ const inserted: string[] = [];
1421
+ const skipped: string[] = [];
1422
+ const now = Date.now();
1423
+ const actorTokenHash = await sha256Hex(args.callerToken);
1424
+
1425
+ for (const p of profiles) {
1426
+ const existing = await ctx.db
1427
+ .query("oauth_scope_profiles")
1428
+ .withIndex("by_profileId", (q) => q.eq("profileId", p.profileId))
1429
+ .unique();
1430
+
1431
+ if (existing) {
1432
+ skipped.push(p.profileId);
1433
+ continue;
1434
+ }
1435
+
1436
+ await ctx.db.insert("oauth_scope_profiles", {
1437
+ ...p,
1438
+ createdAt: now,
1439
+ updatedAt: now,
1440
+ });
1441
+
1442
+ await ctx.db.insert("oauth_audit_log", {
1443
+ eventType: "create_test_tenant_trio",
1444
+ actorTokenHash,
1445
+ targetProfileId: p.profileId,
1446
+ previousState: {
1447
+ profileId: "",
1448
+ fromAllowList: [],
1449
+ namespaceReadPrefixes: [],
1450
+ namespaceWritePrefixes: [],
1451
+ },
1452
+ newState: {
1453
+ profileId: p.profileId,
1454
+ fromAllowList: p.fromAllowList,
1455
+ namespaceReadPrefixes: p.namespaceReadPrefixes,
1456
+ namespaceWritePrefixes: p.namespaceWritePrefixes,
1457
+ },
1458
+ reason:
1459
+ "day92-mcp-quality-overhaul-mission-k57a36y8 — test tenant + 3 OAuth clients minted for persistent fleet-wide testing per Laurent doctrine 2026-06-05",
1460
+ cascadeRevokedCount: 0,
1461
+ clientsRetargeted: 0,
1462
+ createdAt: now,
1463
+ });
1464
+
1465
+ inserted.push(p.profileId);
1466
+ }
1467
+
1468
+ return { inserted, skipped };
1469
+ },
1470
+ });
1471
+
1472
+ // ─────────────────────────────────────────────────────────────────────────────
1473
+ // createTestTenantTrioClients — Day 92 A0 persistent test infrastructure
1474
+ //
1475
+ // Creates 3 confidential OAuth clients, one per test-trio scope profile.
1476
+ // IDEMPOTENT: if a client with the given clientId already exists, skips it
1477
+ // and returns clientSecret=null for that entry (raw secret is a one-time value
1478
+ // returned at creation time — if you lost it, delete the client and recreate).
1479
+ //
1480
+ // Returns: array of { name, clientId, clientSecret | null } — clientSecret is
1481
+ // the raw secret for newly created clients, null for already-existing ones.
1482
+ // The caller MUST persist clientSecret before this call returns.
1483
+ // Master-gated.
1484
+ // ─────────────────────────────────────────────────────────────────────────────
1485
+
1486
+ export const createTestTenantTrioClients = mutation({
1487
+ args: { callerToken: v.string() },
1488
+ returns: v.array(
1489
+ v.object({
1490
+ name: v.string(),
1491
+ clientId: v.string(),
1492
+ clientSecret: v.union(v.string(), v.null()),
1493
+ scopeProfile: v.string(),
1494
+ existed: v.boolean(),
1495
+ }),
1496
+ ),
1497
+ handler: async (ctx, args) => {
1498
+ await requireMasterAuth(args.callerToken);
1499
+
1500
+ const clientDefs = [
1501
+ {
1502
+ name: "alpha-test-client",
1503
+ clientId: "alpha-test-client",
1504
+ scopeProfile: "alpha-test-trio",
1505
+ },
1506
+ {
1507
+ name: "beta-test-client",
1508
+ clientId: "beta-test-client",
1509
+ scopeProfile: "beta-test-trio",
1510
+ },
1511
+ {
1512
+ name: "gamma-test-client",
1513
+ clientId: "gamma-test-client",
1514
+ scopeProfile: "gamma-test-trio",
1515
+ },
1516
+ ];
1517
+
1518
+ const results: Array<{
1519
+ name: string;
1520
+ clientId: string;
1521
+ clientSecret: string | null;
1522
+ scopeProfile: string;
1523
+ existed: boolean;
1524
+ }> = [];
1525
+
1526
+ for (const def of clientDefs) {
1527
+ // Verify scope profile exists
1528
+ const profile = await ctx.db
1529
+ .query("oauth_scope_profiles")
1530
+ .withIndex("by_profileId", (q) => q.eq("profileId", def.scopeProfile))
1531
+ .unique();
1532
+ if (!profile) {
1533
+ throw new Error(
1534
+ `scope_profile not found: ${def.scopeProfile} — run seedTestTenantTrio first`,
1535
+ );
1536
+ }
1537
+
1538
+ // IDEMPOTENT: skip if already exists
1539
+ const existing = await ctx.db
1540
+ .query("oauth_clients")
1541
+ .withIndex("by_clientId", (q) => q.eq("clientId", def.clientId))
1542
+ .unique();
1543
+
1544
+ if (existing) {
1545
+ results.push({
1546
+ name: def.name,
1547
+ clientId: def.clientId,
1548
+ clientSecret: null,
1549
+ scopeProfile: def.scopeProfile,
1550
+ existed: true,
1551
+ });
1552
+ continue;
1553
+ }
1554
+
1555
+ // Generate a 32-byte random secret (64 hex chars)
1556
+ const secretBytes = new Uint8Array(32);
1557
+ crypto.getRandomValues(secretBytes);
1558
+ const rawSecret = Array.from(secretBytes)
1559
+ .map((b) => b.toString(16).padStart(2, "0"))
1560
+ .join("");
1561
+
1562
+ const secretHash = await sha256Hex(rawSecret);
1563
+
1564
+ await ctx.db.insert("oauth_clients", {
1565
+ clientId: def.clientId,
1566
+ clientSecretHash: secretHash,
1567
+ name: def.name,
1568
+ redirectUris: ["http://localhost:8000/oauth/callback"],
1569
+ scopeProfile: def.scopeProfile,
1570
+ createdAt: Date.now(),
1571
+ tokenEndpointAuthMethod: "client_secret_basic",
1572
+ });
1573
+
1574
+ results.push({
1575
+ name: def.name,
1576
+ clientId: def.clientId,
1577
+ clientSecret: rawSecret,
1578
+ scopeProfile: def.scopeProfile,
1579
+ existed: false,
1580
+ });
1581
+ }
1582
+
1583
+ return results;
1584
+ },
1585
+ });
1586
+
1587
+ // ─────────────────────────────────────────────────────────────────────────────
1588
+ // listScopeProfiles — admin query (master-gated) to enumerate all profiles
1589
+ // ─────────────────────────────────────────────────────────────────────────────
1590
+
1591
+ export const listScopeProfiles = query({
1592
+ args: { callerToken: v.string() },
1593
+ returns: v.array(scopeProfileShape),
1594
+ handler: async (ctx, args) => {
1595
+ await requireMasterAuth(args.callerToken);
1596
+ const rows = await ctx.db
1597
+ .query("oauth_scope_profiles")
1598
+ .order("asc")
1599
+ .collect();
1600
+ return rows.map((r) => ({
1601
+ profileId: r.profileId,
1602
+ description: r.description,
1603
+ fromAllowList: r.fromAllowList,
1604
+ namespaceReadPrefixes: r.namespaceReadPrefixes,
1605
+ namespaceWritePrefixes: r.namespaceWritePrefixes,
1606
+ }));
1607
+ },
1608
+ });