@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,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
|
+
});
|