@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,406 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query, internalMutation } from "./_generated/server";
3
+ import { internal } from "./_generated/api";
4
+ import { memoryTypeValidator, creatorValidator, relationTypeValidator, severityValidator } from "./schema";
5
+ import { requireId } from "./lib/ids";
6
+ import { withOrgScope, type OrgScope } from "./lib/auth";
7
+
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+ // Org-scope namespace enforcement (Day 108 fail-closed multi-tenant fix,
10
+ // task k176d9q9h6b33e8y1qgwnnx2x18aa40s).
11
+ //
12
+ // Master scope (no identity with legacy opt-in, or identity with no Clerk org)
13
+ // retains unrestricted access — preserves Alpha/internal behaviour unchanged.
14
+ // A Clerk-org-scoped caller may only read namespaces under its own
15
+ // "team/<orgSlug>" prefix; anything else is denied (never leaked cross-tenant).
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ function isNamespaceAllowedForScope(scope: OrgScope, namespace: string): boolean {
19
+ if (scope.isMaster) return true;
20
+ if (scope.orgSlug === null) return false;
21
+ const ownPrefix = `team/${scope.orgSlug}`;
22
+ return namespace === ownPrefix || namespace.startsWith(`${ownPrefix}/`);
23
+ }
24
+
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ // storeMemory
27
+ // Creates a memory row, schedules RAG embedding via internal action.
28
+ // If the memory has an "updates" relation, marks target(s) as isLatest=false
29
+ // and removes their RAG entries so they no longer appear in search.
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ export const storeMemory = mutation({
33
+ args: {
34
+ namespace: v.string(),
35
+ type: memoryTypeValidator,
36
+ content: v.string(),
37
+ createdBy: creatorValidator,
38
+ // Optional: defaults to [] server-side if omitted (fixes #262)
39
+ relations: v.optional(
40
+ v.array(
41
+ v.object({
42
+ targetId: v.id("memories"),
43
+ type: relationTypeValidator,
44
+ }),
45
+ ),
46
+ ),
47
+ isLatest: v.optional(v.boolean()),
48
+ ttl: v.optional(v.string()),
49
+ episode: v.optional(
50
+ v.object({
51
+ context: v.string(),
52
+ goal: v.string(),
53
+ action: v.string(),
54
+ outcome: v.string(),
55
+ insight: v.string(),
56
+ severity: severityValidator,
57
+ }),
58
+ ),
59
+ },
60
+ returns: v.id("memories"),
61
+ handler: async (ctx, args) => {
62
+ const now = Date.now();
63
+ const relations = args.relations ?? [];
64
+
65
+ // 1. Create the memory row
66
+ const memoryId = await ctx.db.insert("memories", {
67
+ namespace: args.namespace,
68
+ type: args.type,
69
+ content: args.content,
70
+ createdBy: args.createdBy,
71
+ relations,
72
+ isLatest: true,
73
+ ttl: args.ttl,
74
+ episode: args.episode,
75
+ createdAt: now,
76
+ updatedAt: now,
77
+ });
78
+
79
+ // 2. Handle "updates" relations — supersede target memories
80
+ // Re-add the RAG entry with isLatest="false" so it no longer appears in
81
+ // searches filtered to isLatest="true".
82
+ for (const relation of relations) {
83
+ if (relation.type === "updates") {
84
+ const target = await ctx.db.get(relation.targetId);
85
+ if (target !== null) {
86
+ await ctx.db.patch(relation.targetId, {
87
+ isLatest: false,
88
+ updatedAt: now,
89
+ });
90
+
91
+ // Schedule async RAG filter update for the superseded memory
92
+ await ctx.scheduler.runAfter(0, internal.ragSync.markRagEntrySuperseded, {
93
+ memoryId: relation.targetId,
94
+ content: target.content,
95
+ namespace: target.namespace,
96
+ type: target.type,
97
+ });
98
+ }
99
+ }
100
+ }
101
+
102
+ // 3. Schedule async RAG embedding + indexing for the new memory
103
+ await ctx.scheduler.runAfter(0, internal.ragSync.addRagEntry, {
104
+ memoryId,
105
+ content: args.content,
106
+ namespace: args.namespace,
107
+ type: args.type,
108
+ });
109
+
110
+ return memoryId;
111
+ },
112
+ });
113
+
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+ // getMemory — fetch single memory by ID
116
+ // ─────────────────────────────────────────────────────────────────────────────
117
+
118
+ export const getMemory = query({
119
+ args: { memoryId: v.string() },
120
+ returns: v.union(
121
+ v.object({
122
+ _id: v.id("memories"),
123
+ _creationTime: v.number(),
124
+ namespace: v.string(),
125
+ type: memoryTypeValidator,
126
+ content: v.string(),
127
+ createdBy: creatorValidator,
128
+ relations: v.array(
129
+ v.object({
130
+ targetId: v.id("memories"),
131
+ type: relationTypeValidator,
132
+ }),
133
+ ),
134
+ isLatest: v.boolean(),
135
+ ttl: v.optional(v.string()),
136
+ episode: v.optional(
137
+ v.object({
138
+ context: v.string(),
139
+ goal: v.string(),
140
+ action: v.string(),
141
+ outcome: v.string(),
142
+ insight: v.string(),
143
+ severity: severityValidator,
144
+ }),
145
+ ),
146
+ createdAt: v.number(),
147
+ updatedAt: v.number(),
148
+ }),
149
+ v.null(),
150
+ ),
151
+ handler: async (ctx, args) => {
152
+ const memoryId = requireId(
153
+ ctx,
154
+ "memories",
155
+ args.memoryId,
156
+ "memoryId",
157
+ "Use the full 32-char memoryId returned by recall or store_memory.",
158
+ );
159
+ const doc = await ctx.db.get(memoryId);
160
+ if (doc === null) return null;
161
+ const scope = await withOrgScope(ctx);
162
+ if (!isNamespaceAllowedForScope(scope, doc.namespace)) return null;
163
+ return doc;
164
+ },
165
+ });
166
+
167
+ // ─────────────────────────────────────────────────────────────────────────────
168
+ // listMemories — list active memories by namespace, with optional type filter
169
+ // ─────────────────────────────────────────────────────────────────────────────
170
+
171
+ const memoryDocValidator = v.object({
172
+ _id: v.id("memories"),
173
+ _creationTime: v.number(),
174
+ namespace: v.string(),
175
+ type: memoryTypeValidator,
176
+ content: v.string(),
177
+ createdBy: creatorValidator,
178
+ relations: v.array(
179
+ v.object({
180
+ targetId: v.id("memories"),
181
+ type: relationTypeValidator,
182
+ }),
183
+ ),
184
+ isLatest: v.boolean(),
185
+ ttl: v.optional(v.string()),
186
+ episode: v.optional(
187
+ v.object({
188
+ context: v.string(),
189
+ goal: v.string(),
190
+ action: v.string(),
191
+ outcome: v.string(),
192
+ insight: v.string(),
193
+ severity: severityValidator,
194
+ }),
195
+ ),
196
+ createdAt: v.number(),
197
+ updatedAt: v.number(),
198
+ });
199
+
200
+ // Paginated return shape — compat-first: existing callers reading `.value` work unchanged.
201
+ const listMemoriesResultValidator = v.object({
202
+ value: v.array(memoryDocValidator),
203
+ continueCursor: v.union(v.string(), v.null()),
204
+ isDone: v.boolean(),
205
+ });
206
+
207
+ export const listMemories = query({
208
+ args: {
209
+ fields: v.optional(v.union(v.literal("lite"), v.literal("full"))), // v2.4.12 accept (no-op for now) — closes ArgumentValidationError from MCP wrappers passing fields
210
+ namespace: v.string(),
211
+ type: v.optional(memoryTypeValidator),
212
+ createdBy: v.optional(creatorValidator),
213
+ includeSuperseded: v.optional(v.boolean()),
214
+ limit: v.optional(v.number()),
215
+ // Optional Convex pagination — when passed, uses .paginate() for cursor-based pagination.
216
+ // Shape: { numItems: number; cursor: string | null }
217
+ paginationOpts: v.optional(
218
+ v.object({
219
+ numItems: v.number(),
220
+ cursor: v.union(v.string(), v.null()),
221
+ }),
222
+ ),
223
+ },
224
+ returns: listMemoriesResultValidator,
225
+ handler: async (ctx, args) => {
226
+ const scope = await withOrgScope(ctx);
227
+ if (!isNamespaceAllowedForScope(scope, args.namespace)) {
228
+ return { value: [], continueCursor: null, isDone: true };
229
+ }
230
+ const isLatest = args.includeSuperseded === true ? undefined : true;
231
+ const numItems = args.paginationOpts?.numItems ?? args.limit ?? 50;
232
+ const cursor = args.paginationOpts?.cursor ?? null;
233
+ const createdByFilter = args.createdBy;
234
+ // Post-take filter helper — applied to result arrays when createdBy is set.
235
+ // Mirrors list_tasks createdBy semantics (Day 88 cross-tool consistency).
236
+ // Post-filter (not index-pushdown) is acceptable because createdBy is a
237
+ // rare diagnostic filter and the bounded numItems keeps the working set small.
238
+ const filterCreatedBy = <T extends { createdBy?: string }>(rows: T[]): T[] =>
239
+ createdByFilter === undefined
240
+ ? rows
241
+ : rows.filter((r) => r.createdBy === createdByFilter);
242
+
243
+ // ── Paginated path (paginationOpts provided) ──────────────────────────────
244
+ if (args.paginationOpts !== undefined) {
245
+ const opts = { numItems, cursor };
246
+ const type = args.type;
247
+
248
+ if (type !== undefined && isLatest !== undefined) {
249
+ const r = await ctx.db
250
+ .query("memories")
251
+ .withIndex("by_namespace_type", (q) =>
252
+ q.eq("namespace", args.namespace).eq("type", type).eq("isLatest", true),
253
+ )
254
+ .order("desc")
255
+ .paginate(opts);
256
+ return { value: filterCreatedBy(r.page), continueCursor: r.isDone ? null : r.continueCursor, isDone: r.isDone };
257
+ }
258
+
259
+ if (type !== undefined) {
260
+ const r = await ctx.db
261
+ .query("memories")
262
+ .withIndex("by_namespace_type", (q) =>
263
+ q.eq("namespace", args.namespace).eq("type", type),
264
+ )
265
+ .order("desc")
266
+ .paginate(opts);
267
+ return { value: filterCreatedBy(r.page), continueCursor: r.isDone ? null : r.continueCursor, isDone: r.isDone };
268
+ }
269
+
270
+ if (isLatest !== undefined) {
271
+ const r = await ctx.db
272
+ .query("memories")
273
+ .withIndex("by_namespace", (q) =>
274
+ q.eq("namespace", args.namespace).eq("isLatest", true),
275
+ )
276
+ .order("desc")
277
+ .paginate(opts);
278
+ return { value: filterCreatedBy(r.page), continueCursor: r.isDone ? null : r.continueCursor, isDone: r.isDone };
279
+ }
280
+
281
+ const r = await ctx.db
282
+ .query("memories")
283
+ .withIndex("by_namespace", (q) => q.eq("namespace", args.namespace))
284
+ .order("desc")
285
+ .paginate(opts);
286
+ return { value: filterCreatedBy(r.page), continueCursor: r.isDone ? null : r.continueCursor, isDone: r.isDone };
287
+ }
288
+
289
+ // ── Bounded default path (no paginationOpts) — compat with existing callers ──
290
+ // Returns value + continueCursor=null + isDone=true so new callers don't crash.
291
+ const limit = numItems;
292
+ const type = args.type;
293
+
294
+ if (type !== undefined && isLatest !== undefined) {
295
+ const page = await ctx.db
296
+ .query("memories")
297
+ .withIndex("by_namespace_type", (q) =>
298
+ q.eq("namespace", args.namespace).eq("type", type).eq("isLatest", true),
299
+ )
300
+ .order("desc")
301
+ .take(limit);
302
+ return { value: filterCreatedBy(page), continueCursor: null, isDone: true };
303
+ }
304
+
305
+ if (type !== undefined) {
306
+ const page = await ctx.db
307
+ .query("memories")
308
+ .withIndex("by_namespace_type", (q) =>
309
+ q.eq("namespace", args.namespace).eq("type", type),
310
+ )
311
+ .order("desc")
312
+ .take(limit);
313
+ return { value: filterCreatedBy(page), continueCursor: null, isDone: true };
314
+ }
315
+
316
+ if (isLatest !== undefined) {
317
+ const page = await ctx.db
318
+ .query("memories")
319
+ .withIndex("by_namespace", (q) =>
320
+ q.eq("namespace", args.namespace).eq("isLatest", true),
321
+ )
322
+ .order("desc")
323
+ .take(limit);
324
+ return { value: filterCreatedBy(page), continueCursor: null, isDone: true };
325
+ }
326
+
327
+ const page = await ctx.db
328
+ .query("memories")
329
+ .withIndex("by_namespace", (q) => q.eq("namespace", args.namespace))
330
+ .order("desc")
331
+ .take(limit);
332
+ return { value: filterCreatedBy(page), continueCursor: null, isDone: true };
333
+ },
334
+ });
335
+
336
+ // ─────────────────────────────────────────────────────────────────────────────
337
+ // softDeleteMemory — marks a memory as no longer latest (audit-preserving)
338
+ // Also removes the RAG entry so it no longer appears in search.
339
+ // ─────────────────────────────────────────────────────────────────────────────
340
+
341
+ export const softDeleteMemory = mutation({
342
+ args: { memoryId: v.string() },
343
+ returns: v.null(),
344
+ handler: async (ctx, args) => {
345
+ const memoryId = requireId(
346
+ ctx,
347
+ "memories",
348
+ args.memoryId,
349
+ "memoryId",
350
+ "Use the full 32-char memoryId returned by recall or store_memory.",
351
+ );
352
+
353
+ const memory = await ctx.db.get(memoryId);
354
+ if (memory === null) {
355
+ throw new Error(`Memory ${memoryId} not found`);
356
+ }
357
+
358
+ await ctx.db.patch(memoryId, { isLatest: false, updatedAt: Date.now() });
359
+
360
+ // Schedule async RAG filter update — marks entry as no longer latest
361
+ await ctx.scheduler.runAfter(0, internal.ragSync.markRagEntrySuperseded, {
362
+ memoryId,
363
+ content: memory.content,
364
+ namespace: memory.namespace,
365
+ type: memory.type,
366
+ });
367
+
368
+ return null;
369
+ },
370
+ });
371
+
372
+ // ─────────────────────────────────────────────────────────────────────────────
373
+ // expireMemoriesByTtl (internal — called by cron)
374
+ // ─────────────────────────────────────────────────────────────────────────────
375
+
376
+ export const expireMemoriesByTtl = internalMutation({
377
+ args: {},
378
+ returns: v.number(),
379
+ handler: async (ctx) => {
380
+ const now = new Date().toISOString();
381
+ let expired = 0;
382
+
383
+ const candidates = await ctx.db
384
+ .query("memories")
385
+ .filter((q) => q.neq(q.field("ttl"), undefined))
386
+ .take(500);
387
+
388
+ for (const memory of candidates) {
389
+ if (memory.ttl !== undefined && memory.ttl < now && memory.isLatest) {
390
+ await ctx.db.patch(memory._id, { isLatest: false, updatedAt: Date.now() });
391
+
392
+ // Schedule async RAG filter update — marks entry as no longer latest
393
+ await ctx.scheduler.runAfter(0, internal.ragSync.markRagEntrySuperseded, {
394
+ memoryId: memory._id,
395
+ content: memory.content,
396
+ namespace: memory.namespace,
397
+ type: memory.type,
398
+ });
399
+
400
+ expired++;
401
+ }
402
+ }
403
+
404
+ return expired;
405
+ },
406
+ });
@@ -0,0 +1,220 @@
1
+ /**
2
+ * memoriesScoped — team namespace enforcement for Clerk JWT callers.
3
+ *
4
+ * B4 RAG namespace tenant enforcement (VP task k17528bya5wnbxm0x3cebrf9vh8915n0).
5
+ *
6
+ * These functions are the Convex-layer counterpart to the MCP bearer middleware's
7
+ * namespaceRead/WritePrefixes enforcement. They ensure that a Clerk user whose JWT
8
+ * carries org_A cannot read or write a memory in team/<org_B>.
9
+ *
10
+ * Design:
11
+ * - A Clerk caller with organizationId = "org_A" may only access team/org_A/*.
12
+ * - No-identity callers (MCP/CLI deploy key) retain master access (isMaster=true).
13
+ * - Unknown or unregistered orgs are FAIL-CLOSED (throw AUTH_NAMESPACE_DENIED).
14
+ *
15
+ * storeMemoryScoped — enforced write: org_A cannot write to team/org_B.
16
+ * listMemoriesScoped — enforced read: org_A cannot read from team/org_B.
17
+ */
18
+
19
+ import { v } from "convex/values";
20
+ import type { DatabaseReader, MutationCtx, QueryCtx } from "./_generated/server";
21
+ import { mutation, query } from "./_generated/server";
22
+ import {
23
+ creatorValidator,
24
+ memoryTypeValidator,
25
+ relationTypeValidator,
26
+ severityValidator,
27
+ } from "./schema";
28
+
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+ // Helpers
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Resolves the Clerk identity's org_id. Returns null for no-identity callers
35
+ * (MCP/CLI deploy key → master). Throws AUTH_NAMESPACE_DENIED for Clerk callers
36
+ * whose org is unknown or inactive.
37
+ *
38
+ * Returns the caller's orgId string (for team/<orgId> prefix check) or null
39
+ * (master: all namespaces allowed).
40
+ */
41
+ async function resolveOrgId(ctx: QueryCtx | MutationCtx): Promise<string | null> {
42
+ const identity = await ctx.auth.getUserIdentity();
43
+
44
+ // No Clerk identity → master scope (MCP server / CLI / internal callers)
45
+ if (!identity) return null;
46
+
47
+ // Extract org_id from either organizationId or organizationSlug claim
48
+ const raw = identity as Record<string, unknown>;
49
+ const orgId =
50
+ (raw.organizationId as string | undefined) ??
51
+ (raw.organizationSlug as string | undefined) ??
52
+ null;
53
+
54
+ // Clerk caller without an org → also master (Laurent / internal dev)
55
+ if (!orgId) return null;
56
+
57
+ // Verify the org is registered and active in client_org_mapping
58
+ const mapping = await (ctx.db as DatabaseReader)
59
+ .query("client_org_mapping")
60
+ .withIndex("by_clerk_slug", (q) => q.eq("clerkOrgSlug", orgId))
61
+ .first();
62
+
63
+ if (!mapping?.isActive) {
64
+ throw new Error(
65
+ `AUTH_NAMESPACE_DENIED: org "${orgId}" is not registered or inactive`,
66
+ );
67
+ }
68
+
69
+ return orgId;
70
+ }
71
+
72
+ /**
73
+ * Asserts that the caller (identified by orgId) is allowed to access the
74
+ * given namespace.
75
+ *
76
+ * Rules:
77
+ * - orgId === null (master) → always allowed
78
+ * - namespace starts with team/<orgId> → allowed
79
+ * - otherwise → AUTH_NAMESPACE_DENIED
80
+ */
81
+ function assertNamespaceAllowed(orgId: string | null, namespace: string): void {
82
+ if (orgId === null) return; // master: unrestricted
83
+ const ownPrefix = `team/${orgId}`;
84
+ if (namespace === ownPrefix || namespace.startsWith(`${ownPrefix}/`)) return;
85
+ throw new Error(
86
+ `AUTH_NAMESPACE_DENIED: caller org "${orgId}" may not access namespace "${namespace}". ` +
87
+ `Allowed prefix: "${ownPrefix}".`,
88
+ );
89
+ }
90
+
91
+ // ─────────────────────────────────────────────────────────────────────────────
92
+ // storeMemoryScoped — write with team/<orgId> enforcement
93
+ // ─────────────────────────────────────────────────────────────────────────────
94
+
95
+ export const storeMemoryScoped = mutation({
96
+ args: {
97
+ namespace: v.string(),
98
+ type: memoryTypeValidator,
99
+ content: v.string(),
100
+ createdBy: creatorValidator,
101
+ relations: v.optional(
102
+ v.array(
103
+ v.object({
104
+ targetId: v.id("memories"),
105
+ type: relationTypeValidator,
106
+ }),
107
+ ),
108
+ ),
109
+ isLatest: v.optional(v.boolean()),
110
+ ttl: v.optional(v.string()),
111
+ episode: v.optional(
112
+ v.object({
113
+ context: v.string(),
114
+ goal: v.string(),
115
+ action: v.string(),
116
+ outcome: v.string(),
117
+ insight: v.string(),
118
+ severity: severityValidator,
119
+ }),
120
+ ),
121
+ },
122
+ returns: v.id("memories"),
123
+ handler: async (ctx, args) => {
124
+ // ── Auth: resolve org and enforce team namespace boundary ──
125
+ const orgId = await resolveOrgId(ctx);
126
+ assertNamespaceAllowed(orgId, args.namespace);
127
+
128
+ const now = Date.now();
129
+ const relations = args.relations ?? [];
130
+
131
+ // NOTE: RAG embedding is NOT scheduled here — callers that need RAG
132
+ // indexing should use the canonical memories:storeMemory mutation after
133
+ // this auth gate passes. storeMemoryScoped is the enforcement-only variant
134
+ // used by the Convex test suite and future Clerk-authenticated dashboard
135
+ // writes. Keeping it scheduler-free avoids convex-test incompatibility
136
+ // with _scheduled_functions writes.
137
+ const memoryId = await ctx.db.insert("memories", {
138
+ namespace: args.namespace,
139
+ type: args.type,
140
+ content: args.content,
141
+ createdBy: args.createdBy,
142
+ relations,
143
+ isLatest: true,
144
+ ttl: args.ttl,
145
+ episode: args.episode,
146
+ createdAt: now,
147
+ updatedAt: now,
148
+ });
149
+
150
+ return memoryId;
151
+ },
152
+ });
153
+
154
+ // ─────────────────────────────────────────────────────────────────────────────
155
+ // listMemoriesScoped — read with team/<orgId> enforcement
156
+ // ─────────────────────────────────────────────────────────────────────────────
157
+
158
+ const memoryRowValidator = v.object({
159
+ _id: v.id("memories"),
160
+ _creationTime: v.number(),
161
+ namespace: v.string(),
162
+ type: memoryTypeValidator,
163
+ content: v.string(),
164
+ createdBy: creatorValidator,
165
+ relations: v.array(
166
+ v.object({
167
+ targetId: v.id("memories"),
168
+ type: relationTypeValidator,
169
+ }),
170
+ ),
171
+ isLatest: v.boolean(),
172
+ ttl: v.optional(v.string()),
173
+ episode: v.optional(
174
+ v.object({
175
+ context: v.string(),
176
+ goal: v.string(),
177
+ action: v.string(),
178
+ outcome: v.string(),
179
+ insight: v.string(),
180
+ severity: severityValidator,
181
+ }),
182
+ ),
183
+ createdAt: v.number(),
184
+ updatedAt: v.number(),
185
+ });
186
+
187
+ export const listMemoriesScoped = query({
188
+ args: {
189
+ namespace: v.string(),
190
+ type: v.optional(memoryTypeValidator),
191
+ limit: v.optional(v.number()),
192
+ },
193
+ returns: v.array(memoryRowValidator),
194
+ handler: async (ctx, args) => {
195
+ // ── Auth: resolve org and enforce team namespace boundary ──
196
+ const orgId = await resolveOrgId(ctx);
197
+ assertNamespaceAllowed(orgId, args.namespace);
198
+
199
+ const limit = args.limit ?? 50;
200
+ const { namespace, type } = args;
201
+
202
+ if (type !== undefined) {
203
+ return await ctx.db
204
+ .query("memories")
205
+ .withIndex("by_namespace_type", (qi) =>
206
+ qi.eq("namespace", namespace).eq("type", type).eq("isLatest", true),
207
+ )
208
+ .order("desc")
209
+ .take(limit);
210
+ }
211
+
212
+ return await ctx.db
213
+ .query("memories")
214
+ .withIndex("by_namespace", (qi) =>
215
+ qi.eq("namespace", namespace).eq("isLatest", true),
216
+ )
217
+ .order("desc")
218
+ .take(limit);
219
+ },
220
+ });