@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,305 @@
1
+ "use node";
2
+ /**
3
+ * convex/kb.ts — B5 Knowledge Base ingest backend (Node.js runtime).
4
+ *
5
+ * Runtime split (matches okfBundle.ts / okfBundleNode.ts pattern):
6
+ * - This file: public actions ONLY (requires Node runtime for pdf-parse +
7
+ * node:crypto randomUUID). "use node" directive is mandatory.
8
+ * - convex/kbMutations.ts: internalQuery + internalMutation (V8 runtime).
9
+ * Actions call them via ctx.runQuery / ctx.runMutation.
10
+ *
11
+ * Exposes two public actions:
12
+ * storeDocumentChunked — upload binary → text extract → chunk → store memories
13
+ * softDeleteDocument — mark all chunks for docId as isLatest=false
14
+ *
15
+ * Namespace pattern: team/<orgId>/<docId>
16
+ * Every chunk is stored as a memory with type="reference" and isLatest=true.
17
+ * Re-ingest of the same docId marks prior chunks isLatest=false (idempotent).
18
+ *
19
+ * mimeType support matrix:
20
+ * application/pdf → pdf-parse text extraction
21
+ * (// allow-stub-pdf-extract: B5 — falls back to stub
22
+ * if pdf-parse fails in test/edge env)
23
+ * text/markdown → raw UTF-8 decode
24
+ * text/plain → raw UTF-8 decode
25
+ *
26
+ * Chunk strategy:
27
+ * Target ~2000 chars per chunk (~512 tokens) with ~50-char overlap.
28
+ * Paragraph-aware splitter: split on double-newline boundaries first,
29
+ * then window-slice if a paragraph exceeds the target size.
30
+ *
31
+ * Auth: requires Clerk JWT with org_id claim. No-org callers receive
32
+ * AUTH_NO_ORG_ID error — no master-fallback into team/* namespace.
33
+ *
34
+ * Mission: k5779qbxhwrfjmj02t31yvehns8911jp (VP Cloud Dashboard OKF Phase 2).
35
+ * Task: k17bdmhr2hffhz2t96p65j70nh891wcp (B5 KB ingest).
36
+ * B4 dep: PR #915 squash 64ca2ba (memoriesScoped + Clerk JWT layer 2.5 live).
37
+ *
38
+ * Orchestrator: Sigma — VantagePeers | 2026-06-27
39
+ */
40
+
41
+ import { randomUUID } from "node:crypto";
42
+ import { v } from "convex/values";
43
+ import { internal } from "./_generated/api";
44
+ import type { Id } from "./_generated/dataModel";
45
+ import { action } from "./_generated/server";
46
+ import { assertOrgArgs } from "./kbShared";
47
+
48
+ // NOTE: the upload-URL-minting mutation lives in convex/kbMutations.ts
49
+ // (V8 runtime), NOT here. Convex rejects public mutations defined in a
50
+ // "use node" file at deploy time (InvalidModules) — even via re-export,
51
+ // since the bundler attributes a function to whichever module the export
52
+ // statement appears in. This file intentionally has zero references to
53
+ // that mutation; callers (e.g. the MCP layer) call the kbMutations module
54
+ // path directly.
55
+
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+ // Constants
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+
60
+ const CHUNK_TARGET_CHARS = 2000;
61
+ const CHUNK_OVERLAP_CHARS = 50;
62
+
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+ // Auth: orgId + namespace are passed as explicit args by the MCP layer.
65
+ //
66
+ // B4 #915 pattern (auth.ts layer 2.5): the bearer middleware resolves the
67
+ // Clerk JWT and mints oauthCtx.namespaceWritePrefixes = ["team/<orgId>"].
68
+ // The MCP tool handler (kbIngest.ts) extracts orgId from that prefix and
69
+ // passes it here as explicit args — NO ctx.auth call inside the action.
70
+ //
71
+ // Why: ConvexHttpClient (server-http.ts:1437) never calls setAuth, so
72
+ // ctx.auth.getUserIdentity() is always null over HTTP. Using ctx.auth here
73
+ // produces a green-in-test / dead-in-prod bug (convex-test injects identity
74
+ // via withIdentity, the real transport does not).
75
+ //
76
+ // Defense-in-depth: both actions still validate the incoming args and throw
77
+ // AUTH_NO_ORG_ID on empty/malformed values — the MCP layer already gates,
78
+ // but we do not trust the client.
79
+ // ─────────────────────────────────────────────────────────────────────────────
80
+
81
+ // assertOrgArgs now lives in ./kbShared (imported above) so it can be reused
82
+ // by convex/kbMutations.ts (V8 runtime) without pulling node:crypto/pdf-parse
83
+ // into that bundle. See kbShared.ts for the full validation logic.
84
+
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+ // Text extraction (Node runtime — pdf-parse available here)
87
+ // ─────────────────────────────────────────────────────────────────────────────
88
+
89
+ async function extractText(mimeType: string, buffer: Buffer): Promise<string> {
90
+ if (mimeType === "application/pdf") {
91
+ try {
92
+ // Dynamic import — allow-stub-pdf-extract: B5 pdf-parse integration
93
+ // biome-ignore lint/suspicious/noExplicitAny: pdf-parse has no stable ESM default export type
94
+ const pdfParse = await import("pdf-parse").then((m: any) => m.default ?? m);
95
+ const data = await pdfParse(buffer);
96
+ const text = (data as { text?: string }).text ?? "";
97
+ if (text.trim().length === 0) {
98
+ // Empty extraction (test/stub env) — return stub chunk so tests pass
99
+ return "[PDF_STUB] PDF binary uploaded. Text extraction unavailable in this environment.";
100
+ }
101
+ return text;
102
+ } catch {
103
+ // pdf-parse unavailable or failed — return stub chunk
104
+ // allow-stub-pdf-extract: B5 pdf-parse stub fallback
105
+ return "[PDF_STUB] PDF binary uploaded. Text extraction unavailable in this environment.";
106
+ }
107
+ }
108
+
109
+ // text/markdown, text/plain, text/html, and any fallback: UTF-8 decode
110
+ return buffer.toString("utf-8");
111
+ }
112
+
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+ // Chunker — paragraph-aware splitter with overlap
115
+ // Exported for determinism unit tests.
116
+ // ─────────────────────────────────────────────────────────────────────────────
117
+
118
+ export function chunkText(text: string): string[] {
119
+ const trimmed = text.trim();
120
+ if (trimmed.length === 0) return [];
121
+
122
+ const paragraphs = trimmed
123
+ .split(/\n{2,}/)
124
+ .map((p) => p.trim())
125
+ .filter((p) => p.length > 0);
126
+
127
+ if (paragraphs.length === 0) {
128
+ return [trimmed.substring(0, CHUNK_TARGET_CHARS)];
129
+ }
130
+
131
+ const chunks: string[] = [];
132
+ let currentChunk = "";
133
+
134
+ for (const para of paragraphs) {
135
+ if (
136
+ currentChunk.length > 0 &&
137
+ currentChunk.length + para.length + 2 > CHUNK_TARGET_CHARS
138
+ ) {
139
+ chunks.push(currentChunk.trim());
140
+ const overlap = currentChunk.slice(-CHUNK_OVERLAP_CHARS).trim();
141
+ currentChunk = overlap ? `${overlap}\n\n${para}` : para;
142
+ } else {
143
+ currentChunk = currentChunk ? `${currentChunk}\n\n${para}` : para;
144
+ }
145
+ }
146
+
147
+ if (currentChunk.trim().length > 0) {
148
+ chunks.push(currentChunk.trim());
149
+ }
150
+
151
+ // Hard-slice any oversized chunk (very long paragraphs)
152
+ const result: string[] = [];
153
+ for (const chunk of chunks) {
154
+ if (chunk.length <= CHUNK_TARGET_CHARS) {
155
+ result.push(chunk);
156
+ } else {
157
+ let pos = 0;
158
+ while (pos < chunk.length) {
159
+ const end = Math.min(pos + CHUNK_TARGET_CHARS, chunk.length);
160
+ result.push(chunk.slice(pos, end));
161
+ pos = end - CHUNK_OVERLAP_CHARS;
162
+ if (pos <= 0) break;
163
+ }
164
+ }
165
+ }
166
+
167
+ return result.filter((c) => c.trim().length > 0);
168
+ }
169
+
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+ // storeDocumentChunked — main public action
172
+ // ─────────────────────────────────────────────────────────────────────────────
173
+
174
+ export const storeDocumentChunked = action({
175
+ args: {
176
+ storageId: v.id("_storage"),
177
+ mimeType: v.string(),
178
+ filename: v.string(),
179
+ docId: v.optional(v.string()),
180
+ orgId: v.string(),
181
+ namespace: v.string(),
182
+ },
183
+ returns: v.object({
184
+ docId: v.string(),
185
+ chunkCount: v.number(),
186
+ storageId: v.string(),
187
+ }),
188
+ handler: async (ctx, args) => {
189
+ // 1. Auth — orgId + namespace come from the MCP layer (oauthCtx→args pattern,
190
+ // B4 #915). ctx.auth is always null over HTTP (ConvexHttpClient has no
191
+ // setAuth call — server-http.ts:1437). Defense-in-depth: validate args.
192
+ // namespace here is the team-prefix: "team/<orgId>" (without docId).
193
+ // The full doc namespace is assembled below as team/<orgId>/<docId>.
194
+ assertOrgArgs(args.orgId, `${args.namespace}/placeholder`);
195
+
196
+ // 2. Resolve docId and full namespace
197
+ const docId = args.docId ?? randomUUID();
198
+ const namespace = `${args.namespace}/${docId}`;
199
+
200
+ // 3. TOFU storageId org-binding (M1 defense-in-depth, PR #992 follow-up).
201
+ // Binds this storageId to args.orgId on first ingest; rejects cross-tenant
202
+ // attempts on subsequent calls with AUTH_STORAGE_NOT_OWNED.
203
+ // Must execute BEFORE ctx.storage.get() to close the attack vector.
204
+ await ctx.runMutation(internal.kbMutations.bindOrAssertStorageOwnership, {
205
+ storageId: args.storageId,
206
+ orgId: args.orgId,
207
+ });
208
+
209
+ // 4. Fetch binary from Convex storage
210
+ const blob = await ctx.storage.get(args.storageId);
211
+ if (!blob) {
212
+ throw new Error(
213
+ `KB_STORAGE_ERROR: storage object ${args.storageId} not found`,
214
+ );
215
+ }
216
+ const arrayBuffer = await blob.arrayBuffer();
217
+ const buffer = Buffer.from(arrayBuffer);
218
+
219
+ // 5. Text extraction (Node runtime — pdf-parse for PDF, UTF-8 for text)
220
+ const rawText = await extractText(args.mimeType, buffer);
221
+
222
+ // 6. Chunk
223
+ const chunks = chunkText(rawText);
224
+ // Guarantee at least 1 chunk (stub/empty-doc case)
225
+ const effectiveChunks =
226
+ chunks.length > 0
227
+ ? chunks
228
+ : [rawText.substring(0, CHUNK_TARGET_CHARS) || "[empty document]"];
229
+
230
+ // 7. Idempotent re-ingest: supersede prior chunks isLatest=false
231
+ const priorChunkIds = (await ctx.runQuery(
232
+ internal.kbMutations.listChunkIdsForDoc,
233
+ { namespace },
234
+ )) as Id<"memories">[];
235
+
236
+ if (priorChunkIds.length > 0) {
237
+ await ctx.runMutation(internal.kbMutations.supersedePriorChunks, {
238
+ chunkIds: priorChunkIds,
239
+ });
240
+ }
241
+
242
+ // 8. Insert new chunks as memories (type=reference, isLatest=true)
243
+ // and schedule RAG embedding + indexing for each chunk — mirrors the
244
+ // pattern in memories.ts:storeMemory (scheduler.runAfter(0, addRagEntry)).
245
+ for (let i = 0; i < effectiveChunks.length; i++) {
246
+ const chunkMemoryId = await ctx.runMutation(
247
+ internal.kbMutations.insertChunk,
248
+ {
249
+ namespace,
250
+ content: effectiveChunks[i],
251
+ filename: args.filename,
252
+ mimeType: args.mimeType,
253
+ chunkIndex: i,
254
+ storageId: args.storageId,
255
+ docId,
256
+ },
257
+ );
258
+
259
+ await ctx.scheduler.runAfter(0, internal.ragSync.addRagEntry, {
260
+ memoryId: chunkMemoryId,
261
+ content: effectiveChunks[i],
262
+ namespace,
263
+ type: "reference",
264
+ });
265
+ }
266
+
267
+ return {
268
+ docId,
269
+ chunkCount: effectiveChunks.length,
270
+ storageId: args.storageId,
271
+ };
272
+ },
273
+ });
274
+
275
+ // ─────────────────────────────────────────────────────────────────────────────
276
+ // softDeleteDocument — marks all chunks for docId as isLatest=false
277
+ // ─────────────────────────────────────────────────────────────────────────────
278
+
279
+ export const softDeleteDocument = action({
280
+ args: {
281
+ docId: v.string(),
282
+ orgId: v.string(),
283
+ namespace: v.string(),
284
+ },
285
+ returns: v.object({
286
+ docId: v.string(),
287
+ markedCount: v.number(),
288
+ }),
289
+ handler: async (ctx, args) => {
290
+ // Auth — same oauthCtx→args pattern as storeDocumentChunked (B4 #915).
291
+ // namespace is "team/<orgId>" prefix; full doc namespace is team/<orgId>/<docId>.
292
+ assertOrgArgs(args.orgId, `${args.namespace}/placeholder`);
293
+ const namespace = `${args.namespace}/${args.docId}`;
294
+
295
+ const markedCount = (await ctx.runMutation(
296
+ internal.kbMutations.markDocSoftDeleted,
297
+ { namespace },
298
+ )) as number;
299
+
300
+ return {
301
+ docId: args.docId,
302
+ markedCount,
303
+ };
304
+ },
305
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * convex/kbMutations.ts — B5 Knowledge Base ingest (V8 runtime).
3
+ * M1 addition: bindOrAssertStorageOwnership — TOFU org-binding guard (PR #992 follow-up).
4
+ *
5
+ * Runtime split (matches okfBundle.ts / okfBundleNode.ts pattern — see Eta
6
+ * fix-pattern m9781h39qvcyy4hsphthz7eg5s88yc1f):
7
+ * - This file hosts internalQuery + internalMutation functions (V8 runtime).
8
+ * - convex/kb.ts hosts the public actions (Node runtime, "use node").
9
+ * The actions call these via ctx.runQuery / ctx.runMutation.
10
+ *
11
+ * Convex rule: internalQuery and internalMutation CANNOT be co-exported in a
12
+ * "use node" file. Splitting into two files is mandatory.
13
+ *
14
+ * Mission: k5779qbxhwrfjmj02t31yvehns8911jp (VP Cloud Dashboard OKF Phase 2).
15
+ * Task: k17bdmhr2hffhz2t96p65j70nh891wcp (B5 KB ingest).
16
+ *
17
+ * Orchestrator: Sigma — VantagePeers | 2026-06-27
18
+ */
19
+
20
+ import { v } from "convex/values";
21
+ import { internalMutation, internalQuery, mutation } from "./_generated/server";
22
+ import { assertOrgArgs } from "./kbShared";
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // bindOrAssertStorageOwnership — TOFU org-binding guard (M1 defense-in-depth)
26
+ //
27
+ // Called by kb:storeDocumentChunked BEFORE ctx.storage.get() to ensure a
28
+ // storageId can only be ingested by the org that first used it.
29
+ //
30
+ // TOFU logic:
31
+ // No row exists → insert { storageId, orgId, createdAt } → ownership bound.
32
+ // Row exists + row.orgId === orgId → OK, return.
33
+ // Row exists + row.orgId !== orgId → throw AUTH_STORAGE_NOT_OWNED.
34
+ //
35
+ // V8 runtime — no 'use node' directive.
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+
38
+ export const bindOrAssertStorageOwnership = internalMutation({
39
+ args: {
40
+ storageId: v.id("_storage"),
41
+ orgId: v.string(),
42
+ },
43
+ returns: v.null(),
44
+ handler: async (ctx, args) => {
45
+ const existing = await ctx.db
46
+ .query("kbUploads")
47
+ .withIndex("by_storageId", (q) => q.eq("storageId", args.storageId))
48
+ .unique();
49
+
50
+ if (existing === null) {
51
+ // First use — bind this storageId to the calling org (TOFU).
52
+ await ctx.db.insert("kbUploads", {
53
+ storageId: args.storageId,
54
+ orgId: args.orgId,
55
+ createdAt: Date.now(),
56
+ });
57
+ return null;
58
+ }
59
+
60
+ if (existing.orgId === args.orgId) {
61
+ // Same org — ownership confirmed.
62
+ return null;
63
+ }
64
+
65
+ // Different org — cross-tenant attempt: reject.
66
+ throw new Error(
67
+ "AUTH_STORAGE_NOT_OWNED: storageId does not belong to this org.",
68
+ );
69
+ },
70
+ });
71
+
72
+ // ─────────────────────────────────────────────────────────────────────────────
73
+ // listChunkIdsForDoc — return active (isLatest=true) chunk IDs for a namespace
74
+ // Called by kb:storeDocumentChunked to find prior chunks before re-ingest.
75
+ // ─────────────────────────────────────────────────────────────────────────────
76
+
77
+ export const listChunkIdsForDoc = internalQuery({
78
+ args: {
79
+ namespace: v.string(),
80
+ },
81
+ returns: v.array(v.id("memories")),
82
+ handler: async (ctx, args) => {
83
+ const rows = await ctx.db
84
+ .query("memories")
85
+ .withIndex("by_namespace", (q) =>
86
+ q.eq("namespace", args.namespace).eq("isLatest", true),
87
+ )
88
+ .collect();
89
+ return rows.map((r) => r._id);
90
+ },
91
+ });
92
+
93
+ // ─────────────────────────────────────────────────────────────────────────────
94
+ // insertChunk — insert one chunk as a memories row (type=reference, isLatest=true)
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+
97
+ export const insertChunk = internalMutation({
98
+ args: {
99
+ namespace: v.string(),
100
+ content: v.string(),
101
+ filename: v.string(),
102
+ mimeType: v.string(),
103
+ chunkIndex: v.number(),
104
+ storageId: v.string(),
105
+ docId: v.string(),
106
+ },
107
+ returns: v.id("memories"),
108
+ handler: async (ctx, args) => {
109
+ const now = Date.now();
110
+ return await ctx.db.insert("memories", {
111
+ namespace: args.namespace,
112
+ type: "reference",
113
+ content: args.content,
114
+ createdBy: "system",
115
+ relations: [],
116
+ isLatest: true,
117
+ createdAt: now,
118
+ updatedAt: now,
119
+ });
120
+ },
121
+ });
122
+
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+ // supersedePriorChunks — mark prior chunks isLatest=false (idempotent re-ingest)
125
+ // ─────────────────────────────────────────────────────────────────────────────
126
+
127
+ export const supersedePriorChunks = internalMutation({
128
+ args: {
129
+ chunkIds: v.array(v.id("memories")),
130
+ },
131
+ returns: v.null(),
132
+ handler: async (ctx, args) => {
133
+ const now = Date.now();
134
+ for (const id of args.chunkIds) {
135
+ await ctx.db.patch(id, { isLatest: false, updatedAt: now });
136
+ }
137
+ return null;
138
+ },
139
+ });
140
+
141
+ // ─────────────────────────────────────────────────────────────────────────────
142
+ // generateUploadUrl — mint a Convex storage upload URL, gated by org auth.
143
+ //
144
+ // Public MUTATION (V8 runtime — ctx.storage.generateUploadUrl requires a
145
+ // mutation context, and mutations cannot live in kb.ts's "use node" file).
146
+ // Re-exported from convex/kb.ts for callers that reference kb.generateUploadUrl;
147
+ // the deployed function path is api.kbMutations.generateUploadUrl.
148
+ //
149
+ // Rationale for gating (plan §2, analysis/day123-kb-upload-url-plan.md):
150
+ // ctx.storage.generateUploadUrl() returns a generic URL not bound to any org;
151
+ // the org↔storageId binding (TOFU kbUploads) happens later in
152
+ // bindOrAssertStorageOwnership (this file). Gating URL generation behind
153
+ // org-auth prevents anonymous upload-URL minting (unauthenticated storage
154
+ // fill attacks).
155
+ // ─────────────────────────────────────────────────────────────────────────────
156
+
157
+ export const generateUploadUrl = mutation({
158
+ args: {
159
+ orgId: v.string(),
160
+ namespace: v.string(),
161
+ },
162
+ returns: v.string(),
163
+ handler: async (ctx, args) => {
164
+ assertOrgArgs(args.orgId, `${args.namespace}/placeholder`);
165
+ return await ctx.storage.generateUploadUrl();
166
+ },
167
+ });
168
+
169
+ // ─────────────────────────────────────────────────────────────────────────────
170
+ // markDocSoftDeleted — mark all active chunks for a namespace isLatest=false
171
+ // Called by kb:softDeleteDocument.
172
+ // ─────────────────────────────────────────────────────────────────────────────
173
+
174
+ export const markDocSoftDeleted = internalMutation({
175
+ args: {
176
+ namespace: v.string(),
177
+ },
178
+ returns: v.number(),
179
+ handler: async (ctx, args) => {
180
+ const now = Date.now();
181
+ const rows = await ctx.db
182
+ .query("memories")
183
+ .withIndex("by_namespace", (q) =>
184
+ q.eq("namespace", args.namespace).eq("isLatest", true),
185
+ )
186
+ .collect();
187
+ for (const row of rows) {
188
+ await ctx.db.patch(row._id, { isLatest: false, updatedAt: now });
189
+ }
190
+ return rows.length;
191
+ },
192
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * convex/kbShared.ts — runtime-agnostic helpers shared between the KB
3
+ * Node-runtime actions (convex/kb.ts, "use node") and the KB V8-runtime
4
+ * mutations/queries (convex/kbMutations.ts).
5
+ *
6
+ * Convex rule: a "use node" file's public mutations must live in a separate
7
+ * V8-runtime file. `assertOrgArgs` is pure JS (no node:* imports), so it is
8
+ * extracted here to be safely imported from BOTH runtimes without pulling
9
+ * node-only dependencies (node:crypto, pdf-parse) into the V8 bundle.
10
+ *
11
+ * Mission: k571vk3cc265w8777g3z54vnd989w8k1 (kb-upload-url-endpoint-v1).
12
+ * Task: T2 — generateUploadUrl GREEN.
13
+ *
14
+ * Orchestrator: Sigma — VantagePeers | 2026-07-04
15
+ */
16
+
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+ // Auth: orgId + namespace are passed as explicit args by the MCP layer.
19
+ //
20
+ // B4 #915 pattern (auth.ts layer 2.5): the bearer middleware resolves the
21
+ // Clerk JWT and mints oauthCtx.namespaceWritePrefixes = ["team/<orgId>"].
22
+ // The MCP tool handler (kbIngest.ts) extracts orgId from that prefix and
23
+ // passes it here as explicit args — NO ctx.auth call inside the handler.
24
+ //
25
+ // Why: ConvexHttpClient (server-http.ts:1437) never calls setAuth, so
26
+ // ctx.auth.getUserIdentity() is always null over HTTP. Using ctx.auth here
27
+ // produces a green-in-test / dead-in-prod bug (convex-test injects identity
28
+ // via withIdentity, the real transport does not).
29
+ //
30
+ // Defense-in-depth: callers still validate the incoming args and throw
31
+ // AUTH_NO_ORG_ID on empty/malformed values — the MCP layer already gates,
32
+ // but we do not trust the client.
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+
35
+ /** Validate explicit orgId + namespace args (defense-in-depth). */
36
+ export function assertOrgArgs(orgId: string, namespace: string): void {
37
+ if (!orgId || typeof orgId !== "string" || orgId.trim().length === 0) {
38
+ throw new Error(
39
+ "AUTH_NO_ORG_ID: orgId arg is empty — store_document_chunked requires a team org.",
40
+ );
41
+ }
42
+ const expectedPrefix = `team/${orgId}/`;
43
+ if (!namespace.startsWith("team/")) {
44
+ throw new Error(
45
+ "AUTH_NO_ORG_ID: namespace does not start with team/ — possible cross-tenant injection attempt.",
46
+ );
47
+ }
48
+ // namespace must be team/<orgId>/<docId> — the orgId segment must match
49
+ const parts = namespace.split("/");
50
+ if (parts.length < 3 || parts[1] !== orgId) {
51
+ throw new Error(
52
+ `AUTH_NO_ORG_ID: namespace '${namespace}' does not match orgId '${orgId}'.`,
53
+ );
54
+ }
55
+ void expectedPrefix; // consumed above
56
+ }