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