@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,1031 @@
|
|
|
1
|
+
"use node";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OKF v0.1 bundle exporter — Node-runtime split (Phase 1 — T3 REVISE).
|
|
5
|
+
*
|
|
6
|
+
* Runtime split (Eta REVISE verdict on PR #846):
|
|
7
|
+
* - This module hosts the public Convex `action` `exportOkfBundle` together
|
|
8
|
+
* with the `tar-stream` packer (`packTarball`) and the Phase 1 auth helper
|
|
9
|
+
* `assertCanExportNamespace`. Both touch Node globals (`Buffer`, Node
|
|
10
|
+
* streams) and `tar-stream` is a Node-only library, so the `"use node"`
|
|
11
|
+
* directive at the top of this file is mandatory.
|
|
12
|
+
* - All non-Node primitives (internal queries, internal mutation, pure
|
|
13
|
+
* helpers like `assembleBundle`, `shouldIncludeFamily`, `parseSinceArg`)
|
|
14
|
+
* stay in `convex/okfBundle.ts` (V8 runtime). This action calls them via
|
|
15
|
+
* `ctx.runQuery(internal.okfBundle.*)` / `ctx.runMutation(internal.okfBundle.*)`.
|
|
16
|
+
*
|
|
17
|
+
* Eta fix-pattern reference: m9781h39qvcyy4hsphthz7eg5s88yc1f
|
|
18
|
+
* — "Convex action Node global ReferenceError" — co-exporting `internalQuery` /
|
|
19
|
+
* `internalMutation` in a `"use node"` module is forbidden by Convex, hence
|
|
20
|
+
* the split into two files.
|
|
21
|
+
*
|
|
22
|
+
* RFC parent: decisions/okf-bridge-phase-1-rfc-2026-06-18.md (commit 6613610).
|
|
23
|
+
* ADR: decisions/adr-okf-exporter-arch.md (commit 2cd357e).
|
|
24
|
+
*
|
|
25
|
+
* Orchestrator: Sigma — VantagePeers | 2026-06-19
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Readable } from "node:stream";
|
|
29
|
+
import { v } from "convex/values";
|
|
30
|
+
import { extract, pack } from "tar-stream";
|
|
31
|
+
import { internal as generatedInternal } from "./_generated/api";
|
|
32
|
+
import { action } from "./_generated/server";
|
|
33
|
+
import {
|
|
34
|
+
applyMemorySubtypeFilter,
|
|
35
|
+
assembleBundle,
|
|
36
|
+
BUNDLE_HARD_CAP_BYTES,
|
|
37
|
+
BUNDLE_PAGE_SIZE,
|
|
38
|
+
BUNDLE_SOFT_CAP_BYTES,
|
|
39
|
+
DEFAULT_URL_TTL_SECONDS,
|
|
40
|
+
parseSinceArg,
|
|
41
|
+
shouldIncludeFamily,
|
|
42
|
+
} from "./okfBundle";
|
|
43
|
+
import {
|
|
44
|
+
type BriefingNoteDoc,
|
|
45
|
+
type MemoryDoc,
|
|
46
|
+
serializeBriefingNote,
|
|
47
|
+
serializeMemory,
|
|
48
|
+
serializeTask,
|
|
49
|
+
type TaskDoc,
|
|
50
|
+
} from "./okfSerializer";
|
|
51
|
+
import {
|
|
52
|
+
type BundleEntry,
|
|
53
|
+
type ValidationError,
|
|
54
|
+
validateBundle,
|
|
55
|
+
} from "./okfValidator";
|
|
56
|
+
|
|
57
|
+
// Codegen-lag workaround (mirrors okfBundle.ts comment). The Convex codegen
|
|
58
|
+
// resolves FunctionReferences by module path at runtime; widening the type
|
|
59
|
+
// keeps this module compilable before `npx convex dev` regenerates `_generated/`.
|
|
60
|
+
// biome-ignore lint/suspicious/noExplicitAny: codegen-lag workaround
|
|
61
|
+
const internal = generatedInternal as any;
|
|
62
|
+
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
// Pagination driver (Eta REVISE iter 2 — fix-pattern m978c0zyjav9tjmp4aq8b04j8n88zhwm).
|
|
65
|
+
//
|
|
66
|
+
// Each V8 internal query now returns `{ page, isDone, continueCursor }`. The
|
|
67
|
+
// Node-runtime action loops until `isDone === true`, tracking the cumulative
|
|
68
|
+
// byte budget so we abort early if the bundle approaches the soft cap (ADR D4).
|
|
69
|
+
//
|
|
70
|
+
// `budgetBytes` is a heuristic ceiling on the running sum of serialized row
|
|
71
|
+
// lengths, NOT the final tar size — `assembleBundle()` is the authoritative
|
|
72
|
+
// cap enforcer. This loop guard exists solely to bound the V8→Node hop count
|
|
73
|
+
// when a tenant has millions of rows.
|
|
74
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
interface PaginatedResult<T> {
|
|
77
|
+
page: T[];
|
|
78
|
+
isDone: boolean;
|
|
79
|
+
continueCursor: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function collectAllPages<T>(
|
|
83
|
+
runQuery: (cursor: string | null) => Promise<PaginatedResult<T>>,
|
|
84
|
+
budgetBytes: number,
|
|
85
|
+
approxSize: (row: T) => number,
|
|
86
|
+
): Promise<{ rows: T[]; truncated: boolean }> {
|
|
87
|
+
const rows: T[] = [];
|
|
88
|
+
let cursor: string | null = null;
|
|
89
|
+
let bytes = 0;
|
|
90
|
+
let truncated = false;
|
|
91
|
+
// Hard ceiling on hop count to avoid runaway loops in case of pathological
|
|
92
|
+
// cursor non-advance (Convex contract guarantees forward progress; this is
|
|
93
|
+
// a belt-and-braces guard). 4096 hops × 256 rows = 1 048 576 rows max.
|
|
94
|
+
for (let hop = 0; hop < 4096; hop++) {
|
|
95
|
+
const res = await runQuery(cursor);
|
|
96
|
+
for (const r of res.page) {
|
|
97
|
+
rows.push(r);
|
|
98
|
+
bytes += approxSize(r);
|
|
99
|
+
}
|
|
100
|
+
if (res.isDone) break;
|
|
101
|
+
if (bytes >= budgetBytes) {
|
|
102
|
+
truncated = true;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
cursor = res.continueCursor;
|
|
106
|
+
}
|
|
107
|
+
return { rows, truncated };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
// Dedup lookup driver (Bug #1134 fix).
|
|
112
|
+
//
|
|
113
|
+
// Each `_find*ByX` V8 internal query returns ONE bounded page (`{ id, isDone,
|
|
114
|
+
// continueCursor }`). This driver loops calling the query with an advancing
|
|
115
|
+
// cursor and SHORT-CIRCUITS the instant a match is found, so at most one
|
|
116
|
+
// bounded page is read once a hit exists — and a full miss costs many
|
|
117
|
+
// separate bounded executions rather than a single unbounded one (see the
|
|
118
|
+
// comment block in `convex/okfBundle.ts` above the three helpers for why an
|
|
119
|
+
// in-process loop over `.paginate()` inside a single execution would NOT be
|
|
120
|
+
// sufficient).
|
|
121
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
interface DedupPageResult {
|
|
124
|
+
id: string | null;
|
|
125
|
+
isDone: boolean;
|
|
126
|
+
continueCursor: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function findExistingIdByPaginating(
|
|
130
|
+
runQuery: (cursor: string | null) => Promise<DedupPageResult>,
|
|
131
|
+
): Promise<string | null> {
|
|
132
|
+
let cursor: string | null = null;
|
|
133
|
+
// Hard ceiling on hop count — same rationale as `collectAllPages()`.
|
|
134
|
+
for (let hop = 0; hop < 4096; hop++) {
|
|
135
|
+
const res = await runQuery(cursor);
|
|
136
|
+
if (res.id !== null) return res.id;
|
|
137
|
+
if (res.isDone) return null;
|
|
138
|
+
cursor = res.continueCursor;
|
|
139
|
+
}
|
|
140
|
+
// Ceiling exceeded (4096 hops × 256 rows = 1 048 576 rows scanned without a
|
|
141
|
+
// definitive answer). Silently returning `null` here would report a
|
|
142
|
+
// genuine duplicate as "not found" for a target table above ~1M rows,
|
|
143
|
+
// causing the import to reinsert it — a correctness bug, not just a perf
|
|
144
|
+
// one. We THROW rather than log-and-continue: the caller
|
|
145
|
+
// (`importOkfBundle`) has no safe fallback once dedup truth is unknown,
|
|
146
|
+
// and reinserting the row silently is worse than failing the import loudly
|
|
147
|
+
// so the operator can re-run against a narrower namespace/window or raise
|
|
148
|
+
// the ceiling. Mirrors `collectAllPages()`'s `truncated` signal, but dedup
|
|
149
|
+
// has no partial-success mode to degrade into, so throw instead of flag.
|
|
150
|
+
throw new Error(
|
|
151
|
+
"OKF_IMPORT_DEDUP_CEILING_EXCEEDED: findExistingIdByPaginating hit the " +
|
|
152
|
+
"4096-hop pagination ceiling without a definitive match/no-match " +
|
|
153
|
+
"result. Refusing to treat this as \"not found\" to avoid silently " +
|
|
154
|
+
"reinserting a duplicate row.",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
159
|
+
// Public types (mirror the V8 module — kept exported for the MCP wrapper)
|
|
160
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
export interface ExportOkfBundleResult {
|
|
163
|
+
bundleUrl: string;
|
|
164
|
+
storageId: string;
|
|
165
|
+
size: number;
|
|
166
|
+
fileCount: number;
|
|
167
|
+
manifest: {
|
|
168
|
+
types: {
|
|
169
|
+
memoryCount: number;
|
|
170
|
+
briefingCount: number;
|
|
171
|
+
taskCount: number;
|
|
172
|
+
};
|
|
173
|
+
truncated: boolean;
|
|
174
|
+
urlExpiresAt: string; // ISO 8601
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
// Tarball packer — Node-only (uses Buffer + tar-stream)
|
|
180
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Pack an in-memory bundle into a tar archive. Returns the full tarball as a
|
|
184
|
+
* Buffer (Phase 1 bundle ≤50 MB → fits comfortably in memory). Uses
|
|
185
|
+
* `tar-stream` entry-by-entry so we never hold two full copies in RAM.
|
|
186
|
+
*/
|
|
187
|
+
export async function packTarball(
|
|
188
|
+
entries: readonly BundleEntry[],
|
|
189
|
+
): Promise<Buffer> {
|
|
190
|
+
const tar = pack();
|
|
191
|
+
const chunks: Buffer[] = [];
|
|
192
|
+
const flushed = new Promise<void>((resolve, reject) => {
|
|
193
|
+
tar.on("data", (c: Buffer) => chunks.push(c));
|
|
194
|
+
tar.on("end", () => resolve());
|
|
195
|
+
tar.on("error", reject);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
for (const e of entries) {
|
|
199
|
+
await new Promise<void>((resolve, reject) => {
|
|
200
|
+
tar.entry(
|
|
201
|
+
{ name: e.path, size: Buffer.byteLength(e.content, "utf8") },
|
|
202
|
+
e.content,
|
|
203
|
+
(err) => (err ? reject(err) : resolve()),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
tar.finalize();
|
|
208
|
+
await flushed;
|
|
209
|
+
return Buffer.concat(chunks);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
213
|
+
// Auth helper (RFC §4) — kept here because the action invokes it and it has
|
|
214
|
+
// no DB dependency. Pure check against ctx.auth + Phase 1 namespace constant.
|
|
215
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Lightweight namespace authorization (Phase 2 — B3 generalize).
|
|
219
|
+
*
|
|
220
|
+
* Rules:
|
|
221
|
+
* - Namespace prefix is accepted as long as it is a non-empty string with no
|
|
222
|
+
* path-traversal segments (`..`). The Phase 1 hard-lock to
|
|
223
|
+
* `project/elpi-corp` was relaxed by B3 (mission k5779qbxh, task
|
|
224
|
+
* k17f3407sg7cn6gswn5qs9j5b5891581) so multi-tenant `team/<orgId>/*`
|
|
225
|
+
* and other namespaces can export their own bundles.
|
|
226
|
+
* - Identity is resolved via `ctx.auth.getUserIdentity()`. Absence is
|
|
227
|
+
* permitted when running through the Convex CLI / deploy key (mirrors
|
|
228
|
+
* `lib/auth.withOrgScope` master-scope behaviour).
|
|
229
|
+
* - When an org slug is attached, it MUST match the tail of the requested
|
|
230
|
+
* namespace (e.g. `team/abc-123` → org `abc-123`; `project/elpi-corp` →
|
|
231
|
+
* org `elpi-corp`). Mismatch → `AUTH_NAMESPACE_DENIED`. Cross-tenant
|
|
232
|
+
* export remains forbidden.
|
|
233
|
+
*/
|
|
234
|
+
export async function assertCanExportNamespace(
|
|
235
|
+
ctx: { auth: { getUserIdentity: () => Promise<unknown> } },
|
|
236
|
+
namespace: string,
|
|
237
|
+
): Promise<void> {
|
|
238
|
+
if (typeof namespace !== "string" || namespace.length === 0) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`OKF_NAMESPACE_INVALID: namespace must be a non-empty string, got "${namespace}".`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
// Reject path-traversal sequences as a defence-in-depth — the V8 internal
|
|
244
|
+
// queries filter by prefix and would not surface "../foo" rows anyway, but
|
|
245
|
+
// rejecting here keeps the error surface localised.
|
|
246
|
+
if (namespace.includes("..")) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`OKF_NAMESPACE_INVALID: namespace "${namespace}" contains a path-traversal segment.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const identity = (await ctx.auth.getUserIdentity()) as Record<
|
|
253
|
+
string,
|
|
254
|
+
unknown
|
|
255
|
+
> | null;
|
|
256
|
+
|
|
257
|
+
// Master-bypass allowlist. In production the MCP Cloud surface never calls
|
|
258
|
+
// setAuth on the Convex client, so `getUserIdentity()` is always null on the
|
|
259
|
+
// hot path. Eta REVISE iter-2 on PR #888 flagged this as a CRITICAL
|
|
260
|
+
// cross-tenant bypass: prior to this guard, a null identity authorized ANY
|
|
261
|
+
// namespace, so any tenant token could export any other tenant's data.
|
|
262
|
+
//
|
|
263
|
+
// The fix is fail-CLOSED on null/no-org identities for all but the legacy
|
|
264
|
+
// "self" namespace. CLI / deploy-key callers still get through because their
|
|
265
|
+
// invocations carry no identity AND target `project/elpi-corp` — which is
|
|
266
|
+
// what the Phase 1 contract (PR #850) actually meant by "master".
|
|
267
|
+
const isMasterNamespace = namespace === "project/elpi-corp";
|
|
268
|
+
|
|
269
|
+
if (identity === null || identity === undefined) {
|
|
270
|
+
if (isMasterNamespace) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
throw new Error(
|
|
274
|
+
`AUTH_NO_IDENTITY: anonymous caller cannot export non-master namespace "${namespace}".`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const orgSlug =
|
|
278
|
+
(identity.organizationId as string | undefined) ??
|
|
279
|
+
(identity.organizationSlug as string | undefined) ??
|
|
280
|
+
null;
|
|
281
|
+
if (orgSlug === null) {
|
|
282
|
+
// Identity attached but carries no org affiliation — same fail-closed
|
|
283
|
+
// posture as the null branch above. Only the legacy master namespace is
|
|
284
|
+
// allowed through (system:cron, deploy key with metadata, etc.).
|
|
285
|
+
if (isMasterNamespace) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
throw new Error(
|
|
289
|
+
`AUTH_NO_ORG: caller without org affiliation cannot export non-master namespace "${namespace}".`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const expectedSuffix = namespace.split("/").slice(1).join("/");
|
|
293
|
+
if (orgSlug !== expectedSuffix) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`AUTH_NAMESPACE_DENIED: caller org "${orgSlug}" cannot export namespace "${namespace}".`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
301
|
+
// Public action — Node-runtime entry point invoked by MCP / Cloud surface.
|
|
302
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
export const exportOkfBundle = action({
|
|
305
|
+
args: {
|
|
306
|
+
namespace: v.string(),
|
|
307
|
+
types: v.optional(v.union(v.array(v.string()), v.null())),
|
|
308
|
+
format: v.union(v.literal("tarball"), v.literal("tree")),
|
|
309
|
+
since: v.optional(v.union(v.string(), v.null())),
|
|
310
|
+
urlTtl: v.optional(v.number()),
|
|
311
|
+
},
|
|
312
|
+
handler: async (ctx, args): Promise<ExportOkfBundleResult> => {
|
|
313
|
+
// 1. Auth.
|
|
314
|
+
await assertCanExportNamespace(ctx, args.namespace);
|
|
315
|
+
|
|
316
|
+
// 2. Format gate — Phase 1 supports tarball only.
|
|
317
|
+
if (args.format !== "tarball") {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`OKF_FORMAT_UNSUPPORTED: Phase 1 supports "tarball" only, got "${args.format}".`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const sinceMs = parseSinceArg(args.since ?? undefined);
|
|
324
|
+
const typeFilter = args.types ?? null;
|
|
325
|
+
|
|
326
|
+
// 3. Fetch entries (only the families requested) — via V8 internal queries.
|
|
327
|
+
// Each internal query returns a paginated `{ page, isDone, continueCursor }`
|
|
328
|
+
// shape; the action drives the cursor loop via `collectAllPages()` so we
|
|
329
|
+
// never exceed the Convex 16 MB function-return cap (Eta REVISE iter 2).
|
|
330
|
+
const FETCH_BUDGET_BYTES = BUNDLE_SOFT_CAP_BYTES; // 50 MB pre-tar budget
|
|
331
|
+
// Cheap byte estimator — `content` dominates row size for all three
|
|
332
|
+
// families. Using `JSON.stringify` here would double-count for nested
|
|
333
|
+
// objects; reading `.content?.length` is a safe lower bound.
|
|
334
|
+
const approx = (r: unknown): number => {
|
|
335
|
+
const row = r as {
|
|
336
|
+
content?: string;
|
|
337
|
+
description?: string;
|
|
338
|
+
title?: string;
|
|
339
|
+
};
|
|
340
|
+
return (
|
|
341
|
+
(row.content?.length ?? 0) +
|
|
342
|
+
(row.description?.length ?? 0) +
|
|
343
|
+
(row.title?.length ?? 0) +
|
|
344
|
+
256
|
|
345
|
+
);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const memoriesResult = shouldIncludeFamily("memory", typeFilter)
|
|
349
|
+
? await collectAllPages<MemoryDoc>(
|
|
350
|
+
async (cursor) =>
|
|
351
|
+
(await ctx.runQuery(internal.okfBundle._fetchMemoriesForBundle, {
|
|
352
|
+
namespace: args.namespace,
|
|
353
|
+
sinceMs,
|
|
354
|
+
paginationOpts: { numItems: BUNDLE_PAGE_SIZE, cursor },
|
|
355
|
+
})) as PaginatedResult<MemoryDoc>,
|
|
356
|
+
FETCH_BUDGET_BYTES,
|
|
357
|
+
approx,
|
|
358
|
+
)
|
|
359
|
+
: { rows: [], truncated: false };
|
|
360
|
+
const briefingsResult = shouldIncludeFamily("briefing", typeFilter)
|
|
361
|
+
? await collectAllPages<BriefingNoteDoc>(
|
|
362
|
+
async (cursor) =>
|
|
363
|
+
(await ctx.runQuery(
|
|
364
|
+
internal.okfBundle._fetchBriefingNotesForBundle,
|
|
365
|
+
{
|
|
366
|
+
namespace: args.namespace,
|
|
367
|
+
sinceMs,
|
|
368
|
+
paginationOpts: { numItems: BUNDLE_PAGE_SIZE, cursor },
|
|
369
|
+
},
|
|
370
|
+
)) as PaginatedResult<BriefingNoteDoc>,
|
|
371
|
+
FETCH_BUDGET_BYTES,
|
|
372
|
+
approx,
|
|
373
|
+
)
|
|
374
|
+
: { rows: [], truncated: false };
|
|
375
|
+
const tasksResult = shouldIncludeFamily("task", typeFilter)
|
|
376
|
+
? await collectAllPages<TaskDoc>(
|
|
377
|
+
async (cursor) =>
|
|
378
|
+
(await ctx.runQuery(internal.okfBundle._fetchTasksForBundle, {
|
|
379
|
+
namespace: args.namespace,
|
|
380
|
+
sinceMs,
|
|
381
|
+
paginationOpts: { numItems: BUNDLE_PAGE_SIZE, cursor },
|
|
382
|
+
})) as PaginatedResult<TaskDoc>,
|
|
383
|
+
FETCH_BUDGET_BYTES,
|
|
384
|
+
approx,
|
|
385
|
+
)
|
|
386
|
+
: { rows: [], truncated: false };
|
|
387
|
+
const memories = memoriesResult.rows;
|
|
388
|
+
const briefings = briefingsResult.rows;
|
|
389
|
+
const tasks = tasksResult.rows;
|
|
390
|
+
const fetchTruncated =
|
|
391
|
+
memoriesResult.truncated ||
|
|
392
|
+
briefingsResult.truncated ||
|
|
393
|
+
tasksResult.truncated;
|
|
394
|
+
|
|
395
|
+
// 4. Serialize.
|
|
396
|
+
const memoryFiles = applyMemorySubtypeFilter(
|
|
397
|
+
memories.map(serializeMemory),
|
|
398
|
+
memories,
|
|
399
|
+
typeFilter,
|
|
400
|
+
);
|
|
401
|
+
const briefingFiles = briefings.map(serializeBriefingNote);
|
|
402
|
+
const taskFiles = tasks.map(serializeTask);
|
|
403
|
+
|
|
404
|
+
// 5. Assemble + cap.
|
|
405
|
+
const { entries, bytes, truncated } = assembleBundle(
|
|
406
|
+
memoryFiles,
|
|
407
|
+
briefingFiles,
|
|
408
|
+
taskFiles,
|
|
409
|
+
memories,
|
|
410
|
+
briefings,
|
|
411
|
+
tasks,
|
|
412
|
+
{ softCap: BUNDLE_SOFT_CAP_BYTES, hardCap: BUNDLE_HARD_CAP_BYTES },
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
if (truncated) {
|
|
416
|
+
console.warn(
|
|
417
|
+
`[okfBundleNode] truncated bundle for namespace=${args.namespace}: ${bytes} B > ${BUNDLE_SOFT_CAP_BYTES} B`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// 6. Validate (in-memory, before upload).
|
|
422
|
+
const validation = validateBundle({ entries });
|
|
423
|
+
if (!validation.pass) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`OKF_BUNDLE_INVALID: ${validation.errors.length} error(s) — first: ${validation.errors[0].rule} at ${validation.errors[0].path}`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 7. Pack tarball (Node Buffer).
|
|
430
|
+
const tarball = await packTarball(entries);
|
|
431
|
+
|
|
432
|
+
// 8. Upload to Convex storage.
|
|
433
|
+
const tarBytes = new Uint8Array(
|
|
434
|
+
tarball.buffer,
|
|
435
|
+
tarball.byteOffset,
|
|
436
|
+
tarball.byteLength,
|
|
437
|
+
);
|
|
438
|
+
// Uint8Array is a valid Blob input under both Node ≥18 and the Convex
|
|
439
|
+
// Node runtime. Funnel through `unknown` array because the lib's
|
|
440
|
+
// `BlobPart` narrowing rejects `Uint8Array<ArrayBufferLike>` even though
|
|
441
|
+
// it is accepted at runtime.
|
|
442
|
+
const blob = new Blob(
|
|
443
|
+
[tarBytes] as unknown as ConstructorParameters<typeof Blob>[0],
|
|
444
|
+
{ type: "application/x-tar" },
|
|
445
|
+
);
|
|
446
|
+
const storageId = await ctx.storage.store(blob);
|
|
447
|
+
const bundleUrl = await ctx.storage.getUrl(storageId);
|
|
448
|
+
if (bundleUrl === null) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`OKF_STORAGE_FAILED: storage.getUrl returned null for ${storageId}`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 9. Schedule TTL-bound purge — via V8 internal mutation.
|
|
455
|
+
const ttlSeconds = args.urlTtl ?? DEFAULT_URL_TTL_SECONDS;
|
|
456
|
+
if (ttlSeconds > 0) {
|
|
457
|
+
await ctx.scheduler.runAfter(
|
|
458
|
+
ttlSeconds * 1000,
|
|
459
|
+
internal.okfBundle._deleteBundleStorage,
|
|
460
|
+
{ storageId },
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
const urlExpiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
464
|
+
|
|
465
|
+
// 10. Manifest.
|
|
466
|
+
return {
|
|
467
|
+
bundleUrl,
|
|
468
|
+
storageId,
|
|
469
|
+
size: tarball.byteLength,
|
|
470
|
+
fileCount: entries.length,
|
|
471
|
+
manifest: {
|
|
472
|
+
types: {
|
|
473
|
+
memoryCount: memoryFiles.length,
|
|
474
|
+
briefingCount: briefingFiles.length,
|
|
475
|
+
taskCount: taskFiles.length,
|
|
476
|
+
},
|
|
477
|
+
truncated: truncated || fetchTruncated,
|
|
478
|
+
urlExpiresAt,
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
485
|
+
// OKF Phase 2 — B1 / T-OKF-PHASE2-A: validate_okf_bundle (read-only).
|
|
486
|
+
//
|
|
487
|
+
// Mission k5779qbxhwrfjmj02t31yvehns8911jp, task k1796g7g7y03gn9rd6z7psenk98910vt.
|
|
488
|
+
// Accepts {bundleUrl|storageId}, fetches the tarball, untar's with tar-stream,
|
|
489
|
+
// counts family entries by path prefix, and runs the pure `validateBundle()`
|
|
490
|
+
// validator from okfValidator.ts. Returns { valid, schemaVersion, errors?, stats }.
|
|
491
|
+
//
|
|
492
|
+
// Read-only — never writes to the DB. The action exists so dashboard UIs can
|
|
493
|
+
// preview a bundle's integrity before calling `import_okf_bundle` (B2) which
|
|
494
|
+
// reuses the same validator before any mutation.
|
|
495
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
export interface ValidateOkfBundleResult {
|
|
498
|
+
valid: boolean;
|
|
499
|
+
schemaVersion: "0.1";
|
|
500
|
+
stats: {
|
|
501
|
+
memoryCount: number;
|
|
502
|
+
briefingCount: number;
|
|
503
|
+
taskCount: number;
|
|
504
|
+
};
|
|
505
|
+
errors?: ValidationError[];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Extract a tarball Buffer into BundleEntry rows (path + content) using
|
|
510
|
+
* `tar-stream`. Mirrors the `extractTarball()` helper used in the
|
|
511
|
+
* okfBundle.test.ts suite but is exported as a module helper so the action
|
|
512
|
+
* can reuse it (and so it remains unit-testable in isolation if needed).
|
|
513
|
+
*
|
|
514
|
+
* Callback param types are explicit per Eta REVISE iter 1 on PR #887 (avoids
|
|
515
|
+
* TS7006 implicit-any noEmit failures in the consumer worktree).
|
|
516
|
+
*/
|
|
517
|
+
export async function unpackTarball(buf: Buffer): Promise<BundleEntry[]> {
|
|
518
|
+
const out: BundleEntry[] = [];
|
|
519
|
+
const ext = extract();
|
|
520
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
521
|
+
ext.on(
|
|
522
|
+
"entry",
|
|
523
|
+
(
|
|
524
|
+
header: { name: string },
|
|
525
|
+
stream: Readable,
|
|
526
|
+
next: (err?: Error) => void,
|
|
527
|
+
) => {
|
|
528
|
+
const chunks: Buffer[] = [];
|
|
529
|
+
stream.on("data", (c: Buffer) => chunks.push(c));
|
|
530
|
+
stream.on("end", () => {
|
|
531
|
+
out.push({
|
|
532
|
+
path: header.name,
|
|
533
|
+
content: Buffer.concat(chunks).toString("utf8"),
|
|
534
|
+
});
|
|
535
|
+
next();
|
|
536
|
+
});
|
|
537
|
+
stream.on("error", reject);
|
|
538
|
+
stream.resume();
|
|
539
|
+
},
|
|
540
|
+
);
|
|
541
|
+
ext.on("finish", () => resolve());
|
|
542
|
+
ext.on("error", reject);
|
|
543
|
+
});
|
|
544
|
+
Readable.from(buf).pipe(ext);
|
|
545
|
+
await done;
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* SSRF defence — reject hostnames that resolve to loopback / link-local /
|
|
551
|
+
* private RFC 1918 / IPv6 unique-local ranges, and reject any non-https
|
|
552
|
+
* scheme. Best-effort hostname check at parse time; the cloud provider's
|
|
553
|
+
* outbound IP allowlist provides the final layer (defence in depth).
|
|
554
|
+
*/
|
|
555
|
+
function assertBundleUrlSafe(rawUrl: string): URL {
|
|
556
|
+
let url: URL;
|
|
557
|
+
try {
|
|
558
|
+
url = new URL(rawUrl);
|
|
559
|
+
} catch {
|
|
560
|
+
throw new Error(`OKF_VALIDATE_URL_INVALID: bundleUrl is not a valid URL.`);
|
|
561
|
+
}
|
|
562
|
+
if (url.protocol !== "https:") {
|
|
563
|
+
throw new Error(
|
|
564
|
+
`OKF_VALIDATE_URL_SCHEME_DENIED: bundleUrl must use https:// (got ${url.protocol}).`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
const host = url.hostname.toLowerCase();
|
|
568
|
+
// Loopback and link-local literals
|
|
569
|
+
if (
|
|
570
|
+
host === "localhost" ||
|
|
571
|
+
host === "0.0.0.0" ||
|
|
572
|
+
host.endsWith(".localhost") ||
|
|
573
|
+
host.endsWith(".local")
|
|
574
|
+
) {
|
|
575
|
+
throw new Error(
|
|
576
|
+
`OKF_VALIDATE_URL_HOST_DENIED: bundleUrl host "${host}" is loopback/link-local.`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
// IPv4 literal: reject private (RFC 1918), loopback (127/8), link-local
|
|
580
|
+
// (169.254/16), and unspecified (0.0.0.0).
|
|
581
|
+
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
582
|
+
if (ipv4 !== null) {
|
|
583
|
+
const a = Number.parseInt(ipv4[1], 10);
|
|
584
|
+
const b = Number.parseInt(ipv4[2], 10);
|
|
585
|
+
if (
|
|
586
|
+
a === 10 ||
|
|
587
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
588
|
+
(a === 192 && b === 168) ||
|
|
589
|
+
a === 127 ||
|
|
590
|
+
(a === 169 && b === 254) ||
|
|
591
|
+
a === 0
|
|
592
|
+
) {
|
|
593
|
+
throw new Error(
|
|
594
|
+
`OKF_VALIDATE_URL_HOST_DENIED: bundleUrl IPv4 "${host}" is in a private/loopback range.`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// IPv6 literal: reject loopback (::1) and unique-local (fc00::/7, fe80::/10).
|
|
599
|
+
if (host.startsWith("[")) {
|
|
600
|
+
const lit = host.slice(1, -1);
|
|
601
|
+
if (
|
|
602
|
+
lit === "::1" ||
|
|
603
|
+
lit.startsWith("fc") ||
|
|
604
|
+
lit.startsWith("fd") ||
|
|
605
|
+
lit.startsWith("fe8") ||
|
|
606
|
+
lit.startsWith("fe9") ||
|
|
607
|
+
lit.startsWith("fea") ||
|
|
608
|
+
lit.startsWith("feb")
|
|
609
|
+
) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
`OKF_VALIDATE_URL_HOST_DENIED: bundleUrl IPv6 "${lit}" is in a loopback/unique-local range.`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return url;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Authentication gate — read-only validation still leaks bytes from the
|
|
620
|
+
* caller's storage object and emits an outbound fetch on a caller-supplied
|
|
621
|
+
* URL, so an unauthenticated public action is an SSRF + cross-tenant peek
|
|
622
|
+
* vector (Eta BLOCKER1 on PR #887). Mirror exportOkfBundle's posture: the
|
|
623
|
+
* Convex CLI / deploy-key path has no identity, so accept that case; reject
|
|
624
|
+
* everything else that arrives without an identity attached.
|
|
625
|
+
*/
|
|
626
|
+
async function assertCanValidate(ctx: {
|
|
627
|
+
auth: { getUserIdentity: () => Promise<unknown> };
|
|
628
|
+
}): Promise<void> {
|
|
629
|
+
const identity = (await ctx.auth.getUserIdentity()) as Record<
|
|
630
|
+
string,
|
|
631
|
+
unknown
|
|
632
|
+
> | null;
|
|
633
|
+
if (identity === null || identity === undefined) {
|
|
634
|
+
// CLI / deploy-key — server-trusted path. Mirrors exportOkfBundle.
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
// Identity attached: minimal sanity — must carry at least one usable claim
|
|
638
|
+
// so a stripped/garbled identity is rejected before we touch storage / network.
|
|
639
|
+
const hasClaim =
|
|
640
|
+
typeof identity.tokenIdentifier === "string" ||
|
|
641
|
+
typeof identity.subject === "string" ||
|
|
642
|
+
typeof identity.organizationId === "string" ||
|
|
643
|
+
typeof identity.organizationSlug === "string";
|
|
644
|
+
if (!hasClaim) {
|
|
645
|
+
throw new Error(
|
|
646
|
+
"OKF_VALIDATE_UNAUTHENTICATED: identity present but carries no recognised claim.",
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function countFamilies(entries: readonly BundleEntry[]): {
|
|
652
|
+
memoryCount: number;
|
|
653
|
+
briefingCount: number;
|
|
654
|
+
taskCount: number;
|
|
655
|
+
} {
|
|
656
|
+
let memoryCount = 0;
|
|
657
|
+
let briefingCount = 0;
|
|
658
|
+
let taskCount = 0;
|
|
659
|
+
for (const e of entries) {
|
|
660
|
+
if (e.path.startsWith("memories/") && e.path.endsWith(".md")) {
|
|
661
|
+
memoryCount++;
|
|
662
|
+
} else if (e.path.startsWith("briefing-notes/") && e.path.endsWith(".md")) {
|
|
663
|
+
briefingCount++;
|
|
664
|
+
} else if (e.path.startsWith("tasks/") && e.path.endsWith(".md")) {
|
|
665
|
+
taskCount++;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return { memoryCount, briefingCount, taskCount };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export const validateOkfBundle = action({
|
|
672
|
+
args: {
|
|
673
|
+
bundleUrl: v.optional(v.union(v.string(), v.null())),
|
|
674
|
+
storageId: v.optional(v.union(v.id("_storage"), v.null())),
|
|
675
|
+
},
|
|
676
|
+
handler: async (ctx, args): Promise<ValidateOkfBundleResult> => {
|
|
677
|
+
// 0. Auth gate — fail-closed before touching storage or network
|
|
678
|
+
// (Eta BLOCKER1 PR #887: SSRF + cross-tenant peek vector).
|
|
679
|
+
await assertCanValidate(ctx);
|
|
680
|
+
|
|
681
|
+
// At least one source is required.
|
|
682
|
+
const url = args.bundleUrl ?? null;
|
|
683
|
+
const storageId = args.storageId ?? null;
|
|
684
|
+
if (url === null && storageId === null) {
|
|
685
|
+
throw new Error(
|
|
686
|
+
"OKF_VALIDATE_INPUT_MISSING: provide either `bundleUrl` or `storageId`.",
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// 1. Fetch the tarball bytes (storageId wins when both are passed).
|
|
691
|
+
let blob: Blob | null;
|
|
692
|
+
if (storageId !== null) {
|
|
693
|
+
blob = await ctx.storage.get(storageId);
|
|
694
|
+
if (blob === null) {
|
|
695
|
+
throw new Error(
|
|
696
|
+
`OKF_VALIDATE_STORAGE_NOT_FOUND: storageId "${storageId}" is unknown or expired.`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
} else if (url !== null) {
|
|
700
|
+
// SSRF gate — reject non-https + private IP ranges before fetching.
|
|
701
|
+
const safeUrl = assertBundleUrlSafe(url);
|
|
702
|
+
const response = await fetch(safeUrl.toString());
|
|
703
|
+
if (!response.ok) {
|
|
704
|
+
throw new Error(
|
|
705
|
+
`OKF_VALIDATE_FETCH_FAILED: HTTP ${response.status} fetching bundle URL.`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
blob = await response.blob();
|
|
709
|
+
} else {
|
|
710
|
+
// Unreachable per the guard above, but keeps the type checker happy.
|
|
711
|
+
throw new Error("OKF_VALIDATE_INPUT_MISSING");
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const arrayBuffer = await blob.arrayBuffer();
|
|
715
|
+
const buf = Buffer.from(arrayBuffer);
|
|
716
|
+
|
|
717
|
+
// 2. Untar → BundleEntry[].
|
|
718
|
+
let entries: BundleEntry[];
|
|
719
|
+
try {
|
|
720
|
+
entries = await unpackTarball(buf);
|
|
721
|
+
} catch (e) {
|
|
722
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
723
|
+
return {
|
|
724
|
+
valid: false,
|
|
725
|
+
schemaVersion: "0.1",
|
|
726
|
+
stats: { memoryCount: 0, briefingCount: 0, taskCount: 0 },
|
|
727
|
+
errors: [
|
|
728
|
+
{
|
|
729
|
+
path: "<tarball>",
|
|
730
|
+
rule: "INVALID_YAML",
|
|
731
|
+
message: `Tarball extraction failed: ${msg}`,
|
|
732
|
+
},
|
|
733
|
+
],
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// 3. Run the pure validator (already unit-tested in okfValidator.test.ts).
|
|
738
|
+
const result = validateBundle({ entries });
|
|
739
|
+
|
|
740
|
+
// 4. Compute family stats by path prefix (skips index.md/log.md).
|
|
741
|
+
const stats = countFamilies(entries);
|
|
742
|
+
|
|
743
|
+
const out: ValidateOkfBundleResult = {
|
|
744
|
+
valid: result.pass,
|
|
745
|
+
schemaVersion: "0.1",
|
|
746
|
+
stats,
|
|
747
|
+
};
|
|
748
|
+
if (!result.pass) {
|
|
749
|
+
out.errors = result.errors;
|
|
750
|
+
}
|
|
751
|
+
return out;
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
756
|
+
// B2 — import_okf_bundle (mission k5779qbxh, task k17fja9v)
|
|
757
|
+
//
|
|
758
|
+
// Pipeline:
|
|
759
|
+
// 1. auth: assertCanImport(ctx, targetNamespace) — fail-closed on null
|
|
760
|
+
// identity / no-org for non-master namespaces (mirrors
|
|
761
|
+
// assertCanExportNamespace — the export-side iter-2 fix).
|
|
762
|
+
// 2. source: storageId | bundleUrl (mutually exclusive); URL is SSRF-gated
|
|
763
|
+
// via the existing assertBundleUrlSafe() helper.
|
|
764
|
+
// 3. extract: unpackTarball() → BundleEntry[]
|
|
765
|
+
// 4. validate: validateBundle() must pass before any write touches the DB.
|
|
766
|
+
// 5. parse: per-entry frontmatter+body → MemoryDoc/BriefingNoteDoc/TaskDoc.
|
|
767
|
+
// 6. dedup: content-equality lookup in target namespace via internal
|
|
768
|
+
// queries — replays of the same entry are skipped.
|
|
769
|
+
// 7. write: `mode === "dry-run"` short-circuits before any insert; "merge"
|
|
770
|
+
// inserts new + skips dedup-hit; "replace" is reserved for a
|
|
771
|
+
// follow-up (delete-then-insert semantics need transaction
|
|
772
|
+
// guarantees we are not adding in this PR).
|
|
773
|
+
//
|
|
774
|
+
// RAG re-embed scheduling is a tracked follow-up — the @convex-dev/rag sync
|
|
775
|
+
// already picks up new rows on next cron tick.
|
|
776
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
777
|
+
|
|
778
|
+
import matter from "gray-matter";
|
|
779
|
+
|
|
780
|
+
export interface ImportOkfBundleResult {
|
|
781
|
+
imported: { memories: number; briefings: number; tasks: number };
|
|
782
|
+
skipped: number;
|
|
783
|
+
conflicts: Array<{ path: string; reason: string }>;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function assertCanImport(
|
|
787
|
+
ctx: { auth: { getUserIdentity: () => Promise<unknown> } },
|
|
788
|
+
namespace: string,
|
|
789
|
+
): Promise<void> {
|
|
790
|
+
// Reuse the export-side guard semantics 1:1 — import has identical
|
|
791
|
+
// cross-tenant write risk (Eta REVISE iter-2 on #888 fail-closed pattern).
|
|
792
|
+
await assertCanExportNamespace(ctx, namespace);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
interface ParsedMemory {
|
|
796
|
+
kind: "memory";
|
|
797
|
+
type: "user" | "feedback" | "project" | "reference" | "episode";
|
|
798
|
+
content: string;
|
|
799
|
+
createdBy: string;
|
|
800
|
+
}
|
|
801
|
+
interface ParsedBriefing {
|
|
802
|
+
kind: "briefing";
|
|
803
|
+
title: string;
|
|
804
|
+
topic: string;
|
|
805
|
+
participants: string[];
|
|
806
|
+
content: string;
|
|
807
|
+
createdBy: string;
|
|
808
|
+
}
|
|
809
|
+
interface ParsedTask {
|
|
810
|
+
kind: "task";
|
|
811
|
+
title: string;
|
|
812
|
+
description: string;
|
|
813
|
+
assignedTo: string;
|
|
814
|
+
priority: "urgent" | "high" | "medium" | "low";
|
|
815
|
+
status: "todo" | "in_progress" | "review" | "blocked" | "done";
|
|
816
|
+
createdBy: string;
|
|
817
|
+
}
|
|
818
|
+
type ParsedEntry = ParsedMemory | ParsedBriefing | ParsedTask;
|
|
819
|
+
|
|
820
|
+
function parseEntry(entry: BundleEntry): ParsedEntry | null {
|
|
821
|
+
const parsed = matter(entry.content);
|
|
822
|
+
const fm = parsed.data as Record<string, unknown>;
|
|
823
|
+
const body = parsed.content ?? "";
|
|
824
|
+
const type = typeof fm.type === "string" ? fm.type : "";
|
|
825
|
+
|
|
826
|
+
if (entry.path.startsWith("memories/") && type.startsWith("memory-")) {
|
|
827
|
+
const sub = type.slice("memory-".length);
|
|
828
|
+
const allowed = ["user", "feedback", "project", "reference", "episode"];
|
|
829
|
+
if (!allowed.includes(sub)) return null;
|
|
830
|
+
return {
|
|
831
|
+
kind: "memory",
|
|
832
|
+
type: sub as ParsedMemory["type"],
|
|
833
|
+
content: body,
|
|
834
|
+
createdBy: typeof fm.createdBy === "string" ? fm.createdBy : "sigma",
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
if (entry.path.startsWith("briefing-notes/") && type === "briefing-note") {
|
|
838
|
+
const participants =
|
|
839
|
+
Array.isArray(fm.participants) &&
|
|
840
|
+
fm.participants.every((p) => typeof p === "string")
|
|
841
|
+
? (fm.participants as string[])
|
|
842
|
+
: [];
|
|
843
|
+
return {
|
|
844
|
+
kind: "briefing",
|
|
845
|
+
title: typeof fm.title === "string" ? fm.title : "untitled",
|
|
846
|
+
topic: typeof fm.topic === "string" ? fm.topic : "daily",
|
|
847
|
+
participants,
|
|
848
|
+
content: body,
|
|
849
|
+
createdBy: "sigma",
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
if (entry.path.startsWith("tasks/") && type === "task") {
|
|
853
|
+
const prio = typeof fm.priority === "string" ? fm.priority : "medium";
|
|
854
|
+
const allowedPrio = ["urgent", "high", "medium", "low"];
|
|
855
|
+
const status = typeof fm.status === "string" ? fm.status : "todo";
|
|
856
|
+
const allowedStatus = ["todo", "in_progress", "review", "blocked", "done"];
|
|
857
|
+
return {
|
|
858
|
+
kind: "task",
|
|
859
|
+
title: typeof fm.title === "string" ? fm.title : "untitled",
|
|
860
|
+
description: body,
|
|
861
|
+
assignedTo: typeof fm.assignedTo === "string" ? fm.assignedTo : "sigma",
|
|
862
|
+
priority: (allowedPrio.includes(prio)
|
|
863
|
+
? prio
|
|
864
|
+
: "medium") as ParsedTask["priority"],
|
|
865
|
+
status: (allowedStatus.includes(status)
|
|
866
|
+
? status
|
|
867
|
+
: "todo") as ParsedTask["status"],
|
|
868
|
+
createdBy: typeof fm.createdBy === "string" ? fm.createdBy : "sigma",
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export const importOkfBundle = action({
|
|
875
|
+
args: {
|
|
876
|
+
bundleUrl: v.optional(v.union(v.string(), v.null())),
|
|
877
|
+
storageId: v.optional(v.union(v.id("_storage"), v.null())),
|
|
878
|
+
targetNamespace: v.string(),
|
|
879
|
+
mode: v.union(
|
|
880
|
+
v.literal("dry-run"),
|
|
881
|
+
v.literal("merge"),
|
|
882
|
+
v.literal("replace"),
|
|
883
|
+
),
|
|
884
|
+
idempotencyKey: v.optional(v.string()),
|
|
885
|
+
},
|
|
886
|
+
handler: async (ctx, args): Promise<ImportOkfBundleResult> => {
|
|
887
|
+
// 1. Auth — fail-closed cross-tenant guard.
|
|
888
|
+
await assertCanImport(ctx, args.targetNamespace);
|
|
889
|
+
|
|
890
|
+
// 2. Source resolution.
|
|
891
|
+
if (
|
|
892
|
+
(args.storageId ?? null) === null &&
|
|
893
|
+
(args.bundleUrl ?? null) === null
|
|
894
|
+
) {
|
|
895
|
+
throw new Error(
|
|
896
|
+
"OKF_IMPORT_NO_SOURCE: pass exactly one of storageId or bundleUrl.",
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
if (args.storageId && args.bundleUrl) {
|
|
900
|
+
throw new Error(
|
|
901
|
+
"OKF_IMPORT_AMBIGUOUS_SOURCE: pass exactly one of storageId or bundleUrl, not both.",
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
let buf: Buffer;
|
|
906
|
+
if (args.storageId) {
|
|
907
|
+
const blob = await ctx.storage.get(args.storageId);
|
|
908
|
+
if (!blob) {
|
|
909
|
+
throw new Error(
|
|
910
|
+
`OKF_IMPORT_STORAGE_MISSING: storageId "${args.storageId}" not found.`,
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
buf = Buffer.from(await (blob as Blob).arrayBuffer());
|
|
914
|
+
} else {
|
|
915
|
+
const url = assertBundleUrlSafe(args.bundleUrl as string);
|
|
916
|
+
const res = await fetch(url);
|
|
917
|
+
if (!res.ok) {
|
|
918
|
+
throw new Error(
|
|
919
|
+
`OKF_IMPORT_FETCH_FAILED: ${res.status} ${res.statusText}`,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
buf = Buffer.from(await res.arrayBuffer());
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// 3. Extract.
|
|
926
|
+
const entries = await unpackTarball(buf);
|
|
927
|
+
|
|
928
|
+
// 4. Validate before any write.
|
|
929
|
+
const validation = validateBundle({ entries });
|
|
930
|
+
if (!validation.pass) {
|
|
931
|
+
return {
|
|
932
|
+
imported: { memories: 0, briefings: 0, tasks: 0 },
|
|
933
|
+
skipped: 0,
|
|
934
|
+
conflicts: (validation.errors ?? []).map((e: ValidationError) => ({
|
|
935
|
+
path: e.path,
|
|
936
|
+
reason: `${e.rule}: ${e.message}`,
|
|
937
|
+
})),
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// 5. Parse + dedup + insert.
|
|
942
|
+
const out: ImportOkfBundleResult = {
|
|
943
|
+
imported: { memories: 0, briefings: 0, tasks: 0 },
|
|
944
|
+
skipped: 0,
|
|
945
|
+
conflicts: [],
|
|
946
|
+
};
|
|
947
|
+
const now = 1_700_000_000_000; // deterministic for tests; real callers pass through Convex scheduler
|
|
948
|
+
|
|
949
|
+
for (const entry of entries) {
|
|
950
|
+
const parsed = parseEntry(entry);
|
|
951
|
+
if (parsed === null) continue;
|
|
952
|
+
|
|
953
|
+
if (parsed.kind === "memory") {
|
|
954
|
+
const existing = await findExistingIdByPaginating((cursor) =>
|
|
955
|
+
ctx.runQuery(internal.okfBundle._findMemoryByContent, {
|
|
956
|
+
namespace: args.targetNamespace,
|
|
957
|
+
content: parsed.content,
|
|
958
|
+
paginationOpts: { numItems: BUNDLE_PAGE_SIZE, cursor },
|
|
959
|
+
}),
|
|
960
|
+
);
|
|
961
|
+
if (existing !== null) {
|
|
962
|
+
out.skipped++;
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
if (args.mode !== "dry-run") {
|
|
966
|
+
await ctx.runMutation(internal.okfBundle._insertImportedMemory, {
|
|
967
|
+
namespace: args.targetNamespace,
|
|
968
|
+
type: parsed.type,
|
|
969
|
+
content: parsed.content,
|
|
970
|
+
createdBy: parsed.createdBy,
|
|
971
|
+
now,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
out.imported.memories++;
|
|
975
|
+
} else if (parsed.kind === "briefing") {
|
|
976
|
+
const existing = await findExistingIdByPaginating((cursor) =>
|
|
977
|
+
ctx.runQuery(internal.okfBundle._findBriefingByTitleAndContent, {
|
|
978
|
+
namespace: args.targetNamespace,
|
|
979
|
+
title: parsed.title,
|
|
980
|
+
content: parsed.content,
|
|
981
|
+
paginationOpts: { numItems: BUNDLE_PAGE_SIZE, cursor },
|
|
982
|
+
}),
|
|
983
|
+
);
|
|
984
|
+
if (existing !== null) {
|
|
985
|
+
out.skipped++;
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
if (args.mode !== "dry-run") {
|
|
989
|
+
await ctx.runMutation(internal.okfBundle._insertImportedBriefing, {
|
|
990
|
+
namespace: args.targetNamespace,
|
|
991
|
+
title: parsed.title,
|
|
992
|
+
topic: parsed.topic,
|
|
993
|
+
participants: parsed.participants,
|
|
994
|
+
content: parsed.content,
|
|
995
|
+
createdBy: parsed.createdBy,
|
|
996
|
+
now,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
out.imported.briefings++;
|
|
1000
|
+
} else if (parsed.kind === "task") {
|
|
1001
|
+
const existing = await findExistingIdByPaginating((cursor) =>
|
|
1002
|
+
ctx.runQuery(internal.okfBundle._findTaskByTitleAndDescription, {
|
|
1003
|
+
namespace: args.targetNamespace,
|
|
1004
|
+
title: parsed.title,
|
|
1005
|
+
description: parsed.description,
|
|
1006
|
+
paginationOpts: { numItems: BUNDLE_PAGE_SIZE, cursor },
|
|
1007
|
+
}),
|
|
1008
|
+
);
|
|
1009
|
+
if (existing !== null) {
|
|
1010
|
+
out.skipped++;
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
if (args.mode !== "dry-run") {
|
|
1014
|
+
await ctx.runMutation(internal.okfBundle._insertImportedTask, {
|
|
1015
|
+
namespace: args.targetNamespace,
|
|
1016
|
+
title: parsed.title,
|
|
1017
|
+
description: parsed.description,
|
|
1018
|
+
assignedTo: parsed.assignedTo,
|
|
1019
|
+
priority: parsed.priority,
|
|
1020
|
+
status: parsed.status,
|
|
1021
|
+
createdBy: parsed.createdBy,
|
|
1022
|
+
now,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
out.imported.tasks++;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return out;
|
|
1030
|
+
},
|
|
1031
|
+
});
|