@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.
- package/README.md +88 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +94 -0
- package/dist/contract.d.ts +18 -0
- package/dist/contract.js +29 -0
- package/dist/coverage-cli.d.ts +2 -0
- package/dist/coverage-cli.js +17 -0
- package/dist/coverage.d.ts +32 -0
- package/dist/coverage.js +77 -0
- package/dist/detectors/oracle-axis1.d.ts +86 -0
- package/dist/detectors/oracle-axis1.js +221 -0
- package/dist/detectors/oracle-axis2.d.ts +22 -0
- package/dist/detectors/oracle-axis2.js +149 -0
- package/dist/detectors/predicates.d.ts +19 -0
- package/dist/detectors/predicates.js +334 -0
- package/dist/detectors/shared.d.ts +56 -0
- package/dist/detectors/shared.js +103 -0
- package/dist/detectors/source/_scan.d.ts +43 -0
- package/dist/detectors/source/_scan.js +82 -0
- package/dist/detectors/source/backfill.d.ts +27 -0
- package/dist/detectors/source/backfill.js +211 -0
- package/dist/detectors/source/contract.d.ts +33 -0
- package/dist/detectors/source/contract.js +385 -0
- package/dist/detectors/source/identity.d.ts +47 -0
- package/dist/detectors/source/identity.js +410 -0
- package/dist/detectors/source/jobs.d.ts +10 -0
- package/dist/detectors/source/jobs.js +209 -0
- package/dist/detectors/source/lifecycle.d.ts +6 -0
- package/dist/detectors/source/lifecycle.js +147 -0
- package/dist/detectors/source/readbudget.d.ts +6 -0
- package/dist/detectors/source/readbudget.js +133 -0
- package/dist/detectors/source/refusal.d.ts +6 -0
- package/dist/detectors/source/refusal.js +125 -0
- package/dist/detectors/source/security.d.ts +37 -0
- package/dist/detectors/source/security.js +392 -0
- package/dist/detectors/source/writesafety.d.ts +14 -0
- package/dist/detectors/source/writesafety.js +345 -0
- package/dist/detectors/standard/_doc.d.ts +66 -0
- package/dist/detectors/standard/_doc.js +152 -0
- package/dist/detectors/standard/r41-frontier.d.ts +58 -0
- package/dist/detectors/standard/r41-frontier.js +176 -0
- package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
- package/dist/detectors/standard/r46-returns-validator.js +474 -0
- package/dist/detectors/standard/standard-doc.d.ts +12 -0
- package/dist/detectors/standard/standard-doc.js +271 -0
- package/dist/detectors/structural.d.ts +14 -0
- package/dist/detectors/structural.js +83 -0
- package/dist/engine-types.d.ts +10 -0
- package/dist/engine-types.js +1 -0
- package/dist/not-checkable.d.ts +22 -0
- package/dist/not-checkable.js +37 -0
- package/dist/oracle.d.ts +33 -0
- package/dist/oracle.js +133 -0
- package/dist/registry.d.ts +11 -0
- package/dist/registry.js +140 -0
- package/dist/report.d.ts +35 -0
- package/dist/report.js +138 -0
- package/dist/severity.d.ts +30 -0
- package/dist/severity.js +121 -0
- package/dist/tally.d.ts +57 -0
- package/dist/tally.js +152 -0
- package/dist/trigger.d.ts +47 -0
- package/dist/trigger.js +143 -0
- package/fixtures/backend-standard-51ec7af1.md +648 -0
- package/fixtures/backend-standard-a9d7877b.md +629 -0
- package/fixtures/mcp-app-standard-a74a042b.md +279 -0
- package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
- package/fixtures/mcp-server-standard-a74a042b.md +434 -0
- package/fixtures/mcp-standard-a74a042b.md +354 -0
- package/fixtures/vp-1200-green/.pin +1 -0
- package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
- package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
- package/fixtures/vp-1200-red/.pin +1 -0
- package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
- package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
- package/fixtures/vp-by-tool-day158.csv +190 -0
- package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
- package/fixtures/vp-convex/.pin +1 -0
- package/fixtures/vp-convex/bun.lock +783 -0
- package/fixtures/vp-convex/convex/auth.config.ts +8 -0
- package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
- package/fixtures/vp-convex/convex/convex.config.ts +14 -0
- package/fixtures/vp-convex/convex/credentials.ts +591 -0
- package/fixtures/vp-convex/convex/crons.ts +72 -0
- package/fixtures/vp-convex/convex/dashboard.ts +199 -0
- package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
- package/fixtures/vp-convex/convex/issues.ts +544 -0
- package/fixtures/vp-convex/convex/kb.ts +305 -0
- package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
- package/fixtures/vp-convex/convex/kbShared.ts +56 -0
- package/fixtures/vp-convex/convex/licenses.ts +443 -0
- package/fixtures/vp-convex/convex/memories.ts +406 -0
- package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
- package/fixtures/vp-convex/convex/messages.ts +971 -0
- package/fixtures/vp-convex/convex/migrations.ts +239 -0
- package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
- package/fixtures/vp-convex/convex/oauth.ts +1608 -0
- package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
- package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
- package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
- package/fixtures/vp-convex/convex/schema.ts +1229 -0
- package/fixtures/vp-convex/convex/tasks.ts +2782 -0
- package/fixtures/vp-r31-green/.pin +1 -0
- package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
- package/fixtures/vp-r31-red/.pin +1 -0
- package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
- package/fixtures/vr-by-tool-day159.csv +89 -0
- 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
|
+
});
|