superbee 0.4.2-pre.3 → 0.4.2

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 CHANGED
@@ -53,11 +53,7 @@ Requires a supported Node.js 22, 24, or 26 release (22.14.0 minimum) on macOS or
53
53
  npm install -g superbee
54
54
  ```
55
55
 
56
- Stable releases publish on npm's `latest` tag and prereleases on `next`. To try the prerelease:
57
-
58
- ```sh
59
- npm install -g superbee@next
60
- ```
56
+ Stable releases publish on npm's `latest` tag and prereleases on `next`.
61
57
 
62
58
  Windows adapters and the `superbee-windows` executable live in a separate repository and are
63
59
  not included in `superbee`. Most Windows users should run Superbee in WSL2, where npm sees a
@@ -0,0 +1,119 @@
1
+ export declare const EXPORT_MANIFEST = "superbee-export.json";
2
+ export declare const EXPORT_FORMAT = "superbee-export/1";
3
+ /** The host's own bounds (20,000 objects, 64 MiB stored), plus room for headers and the manifest. */
4
+ export declare const MAX_EXPORT_ENTRIES = 20001;
5
+ export declare const MAX_EXPORT_BYTES: number;
6
+ /** Why an archive was refused: `incomplete` is an export the host stopped; the rest are contract failures. */
7
+ export type ArchiveProblem = "incomplete" | "malformed" | "unsafe_path" | "manifest_mismatch" | "wrong_bundle" | "unsupported_format";
8
+ export declare class ExportArchiveError extends Error {
9
+ readonly name = "ExportArchiveError";
10
+ readonly problem: ArchiveProblem;
11
+ constructor(problem: ArchiveProblem, message: string);
12
+ }
13
+ export type EntryKind = "document" | "reserved" | "blob";
14
+ export interface ArchiveEntry {
15
+ /** The bundle-relative path the entry is placed at. */
16
+ readonly path: string;
17
+ readonly kind: EntryKind;
18
+ readonly bytes: Uint8Array;
19
+ }
20
+ export interface ExportSource {
21
+ readonly tenantId: string;
22
+ readonly bundleId: string;
23
+ readonly revision: number;
24
+ readonly okfEdition: string | null;
25
+ }
26
+ export interface VerifiedExport {
27
+ readonly source: ExportSource;
28
+ readonly exportedAt: string;
29
+ readonly counts: {
30
+ readonly documents: number;
31
+ readonly reserved: number;
32
+ readonly blobs: number;
33
+ };
34
+ /** Every entry but the manifest, in archive order. */
35
+ readonly entries: readonly ArchiveEntry[];
36
+ /** Stored bytes, the manifest excluded. */
37
+ readonly bytes: number;
38
+ /** A paged export's page; absent on the whole archive (and on pages once joined). */
39
+ readonly page?: ExportPage;
40
+ }
41
+ /** What one page of a paged export states (`page` in its manifest). */
42
+ export interface ExportPage {
43
+ /** The inventory position of the page's first object. */
44
+ readonly from: number;
45
+ /** Objects the page carries. */
46
+ readonly count: number;
47
+ /** Objects in the whole bundle. */
48
+ readonly total: number;
49
+ /** The cursor of the next page; absent on the last. */
50
+ readonly next?: string;
51
+ /** The whole bundle's counts, as every page's manifest states them. */
52
+ readonly counts: VerifiedExport["counts"];
53
+ }
54
+ export declare function crc32(bytes: Uint8Array): number;
55
+ interface RawEntry {
56
+ readonly name: string;
57
+ readonly data: Uint8Array;
58
+ }
59
+ /**
60
+ * The entries of a store-only zip, strictly: one end record at the very end, a central directory
61
+ * that ends where it starts it, and local records laid end to end from offset zero, each stored,
62
+ * uncompressed, with no data descriptor and a matching CRC.
63
+ */
64
+ export declare function readStoredZip(archive: Uint8Array): RawEntry[];
65
+ /** What an archive path is in a bundle, or an `unsafe_path` refusal. The core guards decide. */
66
+ export declare function classifyEntryPath(entryPath: string): EntryKind;
67
+ /**
68
+ * The first two paths that one folder cannot hold apart: the same file under folding, a file where
69
+ * a folder must be, or one folder spelled two ways (a case-insensitive disk would merge them, and
70
+ * the files in it would no longer be at the paths their ids name).
71
+ */
72
+ export declare function findEntryCollision(paths: readonly string[]): readonly [string, string] | null;
73
+ /**
74
+ * Verify a whole export archive for `bundleId` and return its entries. Any failure is an
75
+ * {@link ExportArchiveError}; nothing is returned for an archive that is not complete and exact.
76
+ */
77
+ export declare function verifyExport(archive: Uint8Array, bundleId: string, options?: {
78
+ readonly paged?: boolean;
79
+ }): VerifiedExport;
80
+ /**
81
+ * The pages of one paged export, checked as each arrives and joined once the last has: every page
82
+ * must name the first page's source (one revision, one edition), state the same whole (its total,
83
+ * at most an export's entries, and its counts), start where the one before ended, and only the
84
+ * last may name no next page. So a host that repeats or skips a page is refused at that page, and
85
+ * a run of pages is at most `total` long. Joined, the pages must hold every object once, add up to
86
+ * the bundle's counts, and keep every path apart from the others in one folder.
87
+ */
88
+ export declare class ExportPageChain {
89
+ #private;
90
+ /** Adds the next page; answers its next cursor, or undefined once the export is whole. */
91
+ add(verified: VerifiedExport): string | undefined;
92
+ /** The whole bundle, as one archive would have carried it. */
93
+ joined(): VerifiedExport;
94
+ }
95
+ /** Join the verified pages of one paged export ({@link ExportPageChain}). */
96
+ export declare function joinExportPages(pages: readonly VerifiedExport[]): VerifiedExport;
97
+ /** Exact transport evidence, kept outside any materialized OKF bundle. */
98
+ export interface ExportProvenance {
99
+ readonly source: ExportSource;
100
+ readonly exportedAt: string;
101
+ /** SHA-256 and byte length of each accepted raw manifest, in page order. */
102
+ readonly manifests: readonly {
103
+ readonly sha256: string;
104
+ readonly bytes: number;
105
+ }[];
106
+ /** SHA-256 of UTF-8 JSON for path-sorted {path,kind,bytes,sha256} inventory rows. */
107
+ readonly inventorySha256: string;
108
+ }
109
+ /** Evidence for an accepted handle; refuses forged or subsequently mutated values. */
110
+ export declare function exportProvenance(verified: VerifiedExport): ExportProvenance;
111
+ /**
112
+ * Materialize a complete verified export in a fresh private child of an existing caller-owned
113
+ * parent. Returns only after every file is written. The caller owns cleanup of the returned
114
+ * directory and must keep that parent private while using the result. No target is overwritten,
115
+ * no transport metadata is written, and failure removes only this call's newly created child.
116
+ */
117
+ export declare function materializeExport(verified: VerifiedExport, parentDirectory: string): Promise<string>;
118
+ export {};
119
+ //# sourceMappingURL=portable-export.d.ts.map
@@ -0,0 +1,474 @@
1
+ // ../core/src/portable-export.ts
2
+ import { createHash } from "node:crypto";
3
+ import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ // ../core/src/errors.ts
7
+ var InvalidInputError = class extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "InvalidInputError";
11
+ }
12
+ };
13
+
14
+ // ../core/src/paths.ts
15
+ var RESERVED_FILENAMES = ["index.md", "log.md"];
16
+ function assertPortablePath(value, label) {
17
+ if (/[\u0000-\u001f\u007f]/u.test(value) || /^[a-z]:/i.test(value)) {
18
+ throw new InvalidInputError(`${label} must not contain controls or start with a drive prefix.`);
19
+ }
20
+ if (value.includes("\\") || value.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
21
+ throw new InvalidInputError(`${label} must be canonical and bundle-relative.`);
22
+ }
23
+ }
24
+ function assertSafeReservedFilename(name) {
25
+ if (!RESERVED_FILENAMES.includes(name)) {
26
+ throw new InvalidInputError("Reserved filename must be index.md or log.md.");
27
+ }
28
+ }
29
+ function toPosix(p) {
30
+ return p.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
31
+ }
32
+ function isReservedFile(relPath) {
33
+ const base = toPosix(relPath).split("/").pop() ?? "";
34
+ return RESERVED_FILENAMES.includes(base);
35
+ }
36
+ function pathFromConceptId(id) {
37
+ assertSafeConceptId(id);
38
+ return `${id}.md`;
39
+ }
40
+ function assertSafeConceptId(id) {
41
+ if (typeof id !== "string" || id.trim() === "") {
42
+ throw new InvalidInputError("Concept id must be a non-empty string.");
43
+ }
44
+ assertPortablePath(id, "Concept id");
45
+ const segments = id.split("/");
46
+ if (segments.slice(0, -1).some((seg) => seg.toLowerCase().endsWith(".md"))) {
47
+ throw new InvalidInputError(
48
+ `Concept id must not contain a non-final segment ending in '.md': '${id}' (it collides with a concept file at that path).`
49
+ );
50
+ }
51
+ }
52
+ function assertSafeBlobKey(key) {
53
+ if (typeof key !== "string" || key.trim() === "") {
54
+ throw new InvalidInputError("Blob key must be a non-empty string.");
55
+ }
56
+ assertPortablePath(key, "Blob key");
57
+ const segments = key.split("/");
58
+ if (segments.some((seg) => seg.startsWith("."))) {
59
+ throw new InvalidInputError(`Blob key must not contain dot-prefixed segments: '${key}'.`);
60
+ }
61
+ if (segments.some((seg) => seg.toLowerCase().endsWith(".md"))) {
62
+ throw new InvalidInputError(
63
+ `Blob key '${key}' has a path segment ending in '.md' (checked case-insensitively, at any depth), which collides with the concept-document namespace \u2014 write it as a doc instead.`
64
+ );
65
+ }
66
+ }
67
+ function assertSafeReservedDir(dir) {
68
+ if (typeof dir !== "string") {
69
+ throw new InvalidInputError("Reserved-file directory must be a string.");
70
+ }
71
+ if (dir !== "") assertPortablePath(dir, "Reserved-file directory");
72
+ }
73
+
74
+ // ../core/src/portable-export.ts
75
+ var EXPORT_MANIFEST = "superbee-export.json";
76
+ var EXPORT_FORMAT = "superbee-export/1";
77
+ var MAX_EXPORT_ENTRIES = 20001;
78
+ var MAX_EXPORT_BYTES = 128 * 1024 * 1024;
79
+ var LOCAL = 67324752;
80
+ var CENTRAL = 33639248;
81
+ var END = 101010256;
82
+ var UTF8_NAMES = 2048;
83
+ var ExportArchiveError = class extends Error {
84
+ name = "ExportArchiveError";
85
+ problem;
86
+ constructor(problem, message) {
87
+ super(message);
88
+ this.problem = problem;
89
+ }
90
+ };
91
+ var CRC_TABLE = (() => {
92
+ const table = new Uint32Array(256);
93
+ for (let n = 0; n < 256; n += 1) {
94
+ let c = n;
95
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
96
+ table[n] = c >>> 0;
97
+ }
98
+ return table;
99
+ })();
100
+ function crc32(bytes) {
101
+ let crc = 4294967295;
102
+ for (let i = 0; i < bytes.length; i += 1) crc = CRC_TABLE[(crc ^ bytes[i]) & 255] ^ crc >>> 8;
103
+ return (crc ^ 4294967295) >>> 0;
104
+ }
105
+ var sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
106
+ var utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
107
+ function malformed(message) {
108
+ return new ExportArchiveError("malformed", message);
109
+ }
110
+ function readStoredZip(archive) {
111
+ if (archive.byteLength > MAX_EXPORT_BYTES) throw malformed("the archive is larger than an export carries");
112
+ const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
113
+ const end = archive.byteLength - 22;
114
+ if (end < 0 || view.getUint32(end, true) !== END) {
115
+ throw new ExportArchiveError("incomplete", "the export ended before its central directory: the host stopped it part way");
116
+ }
117
+ if (view.getUint16(end + 4, true) !== 0 || view.getUint16(end + 6, true) !== 0) throw malformed("a multi-disk archive");
118
+ const count = view.getUint16(end + 8, true);
119
+ if (count !== view.getUint16(end + 10, true)) throw malformed("the end record's counts disagree");
120
+ const directorySize = view.getUint32(end + 12, true);
121
+ const directoryOffset = view.getUint32(end + 16, true);
122
+ if (view.getUint16(end + 20, true) !== 0) throw malformed("an archive comment");
123
+ if (directoryOffset + directorySize !== end) throw malformed("the central directory does not end at the end record");
124
+ if (count > MAX_EXPORT_ENTRIES) throw malformed("more entries than an export carries");
125
+ const entries = [];
126
+ let central = directoryOffset;
127
+ let expectedLocal = 0;
128
+ for (let index = 0; index < count; index += 1) {
129
+ if (central + 46 > end || view.getUint32(central, true) !== CENTRAL) throw malformed("a truncated central directory");
130
+ const flags = view.getUint16(central + 8, true);
131
+ const method = view.getUint16(central + 10, true);
132
+ const crc = view.getUint32(central + 16, true);
133
+ const compressed = view.getUint32(central + 20, true);
134
+ const size = view.getUint32(central + 24, true);
135
+ const nameLength = view.getUint16(central + 28, true);
136
+ const extraLength = view.getUint16(central + 30, true);
137
+ const commentLength = view.getUint16(central + 32, true);
138
+ const localOffset = view.getUint32(central + 42, true);
139
+ if ((flags & ~UTF8_NAMES) !== 0) throw malformed("an entry with flags a stored export never sets");
140
+ if (extraLength !== 0 || commentLength !== 0) throw malformed("an entry with extra fields or a comment");
141
+ if (method !== 0 || compressed !== size) throw malformed("a compressed entry");
142
+ const nameEnd = central + 46 + nameLength;
143
+ if (nameEnd + extraLength + commentLength > end) throw malformed("a truncated central directory");
144
+ const nameBytes = archive.subarray(central + 46, nameEnd);
145
+ central = nameEnd + extraLength + commentLength;
146
+ if (localOffset !== expectedLocal) throw malformed("entries that are not laid end to end");
147
+ if (localOffset + 30 > directoryOffset || view.getUint32(localOffset, true) !== LOCAL) throw malformed("a missing local header");
148
+ const localFlags = view.getUint16(localOffset + 6, true);
149
+ const localMethod = view.getUint16(localOffset + 8, true);
150
+ const localNameLength = view.getUint16(localOffset + 26, true);
151
+ const localExtraLength = view.getUint16(localOffset + 28, true);
152
+ if (localFlags !== flags || localMethod !== 0 || view.getUint32(localOffset + 14, true) !== crc || view.getUint32(localOffset + 18, true) !== size || view.getUint32(localOffset + 22, true) !== size || localNameLength !== nameLength || localExtraLength !== 0) {
153
+ throw malformed("a local header that disagrees with the central directory");
154
+ }
155
+ const dataStart = localOffset + 30 + localNameLength + localExtraLength;
156
+ const dataEnd = dataStart + size;
157
+ if (dataEnd > directoryOffset) throw malformed("an entry that runs into the central directory");
158
+ const localName = archive.subarray(localOffset + 30, localOffset + 30 + localNameLength);
159
+ if (Buffer.compare(Buffer.from(localName), Buffer.from(nameBytes)) !== 0) throw malformed("a local name that disagrees with the central directory");
160
+ const data = archive.subarray(dataStart, dataEnd);
161
+ if (crc32(data) !== crc) throw malformed("an entry whose CRC does not match its bytes");
162
+ let name;
163
+ try {
164
+ name = utf8.decode(nameBytes);
165
+ } catch {
166
+ throw malformed("an entry name that is not UTF-8");
167
+ }
168
+ entries.push({ name, data });
169
+ expectedLocal = dataEnd;
170
+ }
171
+ if (central !== end) throw malformed("the central directory holds more than its entries");
172
+ if (expectedLocal !== directoryOffset) throw malformed("bytes between the last entry and the central directory");
173
+ return entries;
174
+ }
175
+ var WINDOWS_DEVICE_NAMES = /* @__PURE__ */ new Set(["CON", "PRN", "AUX", "NUL", ...Array.from({ length: 9 }, (_, i) => `COM${i + 1}`), ...Array.from({ length: 9 }, (_, i) => `LPT${i + 1}`)]);
176
+ var fold = (segment) => segment.normalize("NFC").toLowerCase();
177
+ function classifyEntryPath(entryPath) {
178
+ const unsafe = (why) => new ExportArchiveError("unsafe_path", `the archive holds a path a bundle cannot keep (${why}): ${JSON.stringify(entryPath.slice(0, 200))}`);
179
+ if (entryPath === "" || entryPath.length > 4096) throw unsafe("empty or too long");
180
+ const segments = entryPath.split("/");
181
+ for (const segment of segments) {
182
+ if (segment.startsWith(".")) throw unsafe("a dot-prefixed segment");
183
+ if (segment.endsWith(".") || segment.endsWith(" ")) throw unsafe("a segment ending in a dot or a space");
184
+ if (WINDOWS_DEVICE_NAMES.has(segment.split(".")[0].toUpperCase())) throw unsafe("a reserved device name");
185
+ }
186
+ try {
187
+ if (isReservedFile(entryPath)) {
188
+ const name = segments.at(-1);
189
+ assertSafeReservedFilename(name);
190
+ assertSafeReservedDir(segments.slice(0, -1).join("/"));
191
+ return "reserved";
192
+ }
193
+ if (entryPath.endsWith(".md")) {
194
+ const id = entryPath.slice(0, -3);
195
+ assertSafeConceptId(id);
196
+ if (pathFromConceptId(id) !== entryPath) throw new Error("not canonical");
197
+ return "document";
198
+ }
199
+ assertSafeBlobKey(entryPath);
200
+ return "blob";
201
+ } catch (error) {
202
+ throw unsafe(error instanceof Error ? error.message : String(error));
203
+ }
204
+ }
205
+ function findEntryCollision(paths) {
206
+ const files = /* @__PURE__ */ new Map();
207
+ const dirs = /* @__PURE__ */ new Map();
208
+ for (const entryPath of paths) {
209
+ const segments = entryPath.split("/");
210
+ const key = segments.map(fold).join("/");
211
+ const file = files.get(key) ?? dirs.get(key)?.by;
212
+ if (file !== void 0) return [file, entryPath];
213
+ files.set(key, entryPath);
214
+ for (let depth = 1; depth < segments.length; depth += 1) {
215
+ const spelling = segments.slice(0, depth).join("/");
216
+ const dirKey = segments.slice(0, depth).map(fold).join("/");
217
+ const asFile = files.get(dirKey);
218
+ if (asFile !== void 0) return [asFile, entryPath];
219
+ const seen = dirs.get(dirKey);
220
+ if (seen === void 0) dirs.set(dirKey, { spelling, by: entryPath });
221
+ else if (seen.spelling !== spelling) return [seen.by, entryPath];
222
+ }
223
+ }
224
+ return null;
225
+ }
226
+ function isRecord(value) {
227
+ return typeof value === "object" && value !== null && !Array.isArray(value);
228
+ }
229
+ function verifyExport(archive, bundleId, options = {}) {
230
+ const raw = readStoredZip(archive);
231
+ const manifests = raw.filter((entry) => entry.name === EXPORT_MANIFEST);
232
+ if (manifests.length !== 1) throw new ExportArchiveError("manifest_mismatch", `the archive holds ${manifests.length} manifests`);
233
+ let manifest;
234
+ try {
235
+ manifest = JSON.parse(utf8.decode(manifests[0].data));
236
+ } catch {
237
+ throw new ExportArchiveError("manifest_mismatch", "the manifest is not JSON");
238
+ }
239
+ if (!isRecord(manifest)) throw new ExportArchiveError("manifest_mismatch", "the manifest is not an object");
240
+ if (manifest.format !== EXPORT_FORMAT) {
241
+ throw new ExportArchiveError("unsupported_format", `the export format is ${JSON.stringify(manifest.format)}; this reader accepts ${EXPORT_FORMAT}`);
242
+ }
243
+ const source = manifest.source;
244
+ const counts = manifest.counts;
245
+ const listed = manifest.entries;
246
+ if (!isRecord(source) || typeof source.tenantId !== "string" || typeof source.bundleId !== "string" || !Number.isSafeInteger(source.revision) || !(source.okfEdition === null || typeof source.okfEdition === "string") || typeof manifest.exportedAt !== "string" || !isRecord(counts) || ![counts.documents, counts.reserved, counts.blobs].every((n) => Number.isSafeInteger(n) && n >= 0) || !Array.isArray(listed)) {
247
+ throw new ExportArchiveError("manifest_mismatch", "the manifest is missing a field");
248
+ }
249
+ if (source.bundleId !== bundleId) throw new ExportArchiveError("wrong_bundle", `the export is of '${source.bundleId}', not '${bundleId}'`);
250
+ let page;
251
+ if (options.paged) {
252
+ const raw2 = manifest.page;
253
+ const count = (n) => Number.isSafeInteger(n) && n >= 0;
254
+ if (!isRecord(raw2) || !count(raw2.from) || !count(raw2.count) || !count(raw2.total) || raw2.next !== void 0 && (typeof raw2.next !== "string" || raw2.next === "")) {
255
+ throw new ExportArchiveError("manifest_mismatch", "the page's manifest does not state its page");
256
+ }
257
+ if (raw2.from + raw2.count > raw2.total || raw2.next === void 0 !== (raw2.from + raw2.count === raw2.total) || raw2.next !== void 0 && raw2.count === 0) {
258
+ throw new ExportArchiveError("manifest_mismatch", "the page's manifest states a page outside the bundle");
259
+ }
260
+ page = {
261
+ from: raw2.from,
262
+ count: raw2.count,
263
+ total: raw2.total,
264
+ ...raw2.next !== void 0 ? { next: raw2.next } : {},
265
+ counts: { documents: counts.documents, reserved: counts.reserved, blobs: counts.blobs }
266
+ };
267
+ } else if (manifest.page !== void 0) {
268
+ throw new ExportArchiveError("manifest_mismatch", "the archive is one page, not the whole bundle");
269
+ }
270
+ const digests = /* @__PURE__ */ new Map();
271
+ for (const row of listed) {
272
+ if (!isRecord(row) || typeof row.path !== "string" || !Number.isSafeInteger(row.bytes) || typeof row.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(row.sha256)) {
273
+ throw new ExportArchiveError("manifest_mismatch", "a manifest entry is malformed");
274
+ }
275
+ if (digests.has(row.path)) throw new ExportArchiveError("manifest_mismatch", `the manifest lists ${row.path} twice`);
276
+ digests.set(row.path, { bytes: row.bytes, sha256: row.sha256 });
277
+ }
278
+ const entries = [];
279
+ const tally = { documents: 0, reserved: 0, blobs: 0 };
280
+ let bytes = 0;
281
+ const seen = /* @__PURE__ */ new Set();
282
+ for (const entry of raw) {
283
+ if (entry.name === EXPORT_MANIFEST) continue;
284
+ if (seen.has(entry.name)) throw new ExportArchiveError("manifest_mismatch", `the archive holds ${entry.name} twice`);
285
+ seen.add(entry.name);
286
+ const kind = classifyEntryPath(entry.name);
287
+ const digest = digests.get(entry.name);
288
+ if (!digest) throw new ExportArchiveError("manifest_mismatch", `the archive holds ${entry.name}, which the manifest does not list`);
289
+ if (digest.bytes !== entry.data.byteLength || digest.sha256 !== sha256Hex(entry.data)) {
290
+ throw new ExportArchiveError("manifest_mismatch", `${entry.name} does not match its manifest digest`);
291
+ }
292
+ tally[kind === "document" ? "documents" : kind === "reserved" ? "reserved" : "blobs"] += 1;
293
+ bytes += entry.data.byteLength;
294
+ entries.push({ path: entry.name, kind, bytes: entry.data });
295
+ }
296
+ for (const listedPath of digests.keys()) {
297
+ if (!seen.has(listedPath)) throw new ExportArchiveError("manifest_mismatch", `the manifest lists ${listedPath}, which the archive does not hold`);
298
+ }
299
+ if (page) {
300
+ if (entries.length !== page.count) throw new ExportArchiveError("manifest_mismatch", "the page holds a different number of entries than it states");
301
+ } else if (tally.documents !== counts.documents || tally.reserved !== counts.reserved || tally.blobs !== counts.blobs) {
302
+ throw new ExportArchiveError("manifest_mismatch", "the manifest's counts do not match the archive");
303
+ }
304
+ const collision = findEntryCollision(entries.map((entry) => entry.path));
305
+ if (collision) {
306
+ throw new ExportArchiveError("unsafe_path", `the archive holds paths one folder cannot keep apart: ${JSON.stringify(collision[0])} and ${JSON.stringify(collision[1])}`);
307
+ }
308
+ return rememberVerified({
309
+ source: { tenantId: source.tenantId, bundleId: source.bundleId, revision: source.revision, okfEdition: source.okfEdition },
310
+ exportedAt: manifest.exportedAt,
311
+ counts: tally,
312
+ entries,
313
+ bytes,
314
+ ...page ? { page } : {}
315
+ }, [{ sha256: sha256Hex(manifests[0].data), bytes: manifests[0].data.byteLength }], archive.byteLength);
316
+ }
317
+ var ExportPageChain = class {
318
+ #pages = [];
319
+ #from = 0;
320
+ #ended = false;
321
+ #archiveBytes = 0;
322
+ /** Adds the next page; answers its next cursor, or undefined once the export is whole. */
323
+ add(verified) {
324
+ exportProvenance(verified);
325
+ const state = verifiedStates.get(verified);
326
+ const accepted = state.snapshot;
327
+ const page = accepted.page;
328
+ const first = this.#pages[0] ?? accepted;
329
+ if (!page || !first.page || this.#ended) throw new ExportArchiveError("manifest_mismatch", "a paged export answered a page outside its run");
330
+ const counts = first.page.counts;
331
+ if (accepted.source.tenantId !== first.source.tenantId || accepted.source.bundleId !== first.source.bundleId || accepted.source.revision !== first.source.revision || accepted.source.okfEdition !== first.source.okfEdition || page.counts.documents !== counts.documents || page.counts.reserved !== counts.reserved || page.counts.blobs !== counts.blobs || page.total !== first.page.total || page.total !== counts.documents + counts.reserved + counts.blobs || page.total > MAX_EXPORT_ENTRIES - 1) {
332
+ throw new ExportArchiveError("manifest_mismatch", "the pages do not describe one export of one revision");
333
+ }
334
+ if (page.from !== this.#from) throw new ExportArchiveError("manifest_mismatch", "the pages leave a gap or overlap");
335
+ if (state.archiveBytes > MAX_EXPORT_BYTES - this.#archiveBytes) throw malformed("the pages are larger than an export carries");
336
+ this.#archiveBytes += state.archiveBytes;
337
+ this.#pages.push(accepted);
338
+ this.#from += page.count;
339
+ if (page.next === void 0) this.#ended = true;
340
+ return page.next;
341
+ }
342
+ /** The whole bundle, as one archive would have carried it. */
343
+ joined() {
344
+ const first = this.#pages[0];
345
+ if (!first?.page || !this.#ended) throw new ExportArchiveError("manifest_mismatch", "the pages end before the last");
346
+ const { counts } = first.page;
347
+ const entries = [];
348
+ const tally = { documents: 0, reserved: 0, blobs: 0 };
349
+ let bytes = 0;
350
+ for (const verified of this.#pages) {
351
+ for (const entry of verified.entries) {
352
+ entries.push({ ...entry, bytes: Uint8Array.from(entry.bytes) });
353
+ tally[entry.kind === "document" ? "documents" : entry.kind === "reserved" ? "reserved" : "blobs"] += 1;
354
+ bytes += entry.bytes.byteLength;
355
+ }
356
+ }
357
+ if (this.#from !== first.page.total || entries.length !== first.page.total) throw new ExportArchiveError("manifest_mismatch", "the pages do not add up to the bundle");
358
+ if (tally.documents !== counts.documents || tally.reserved !== counts.reserved || tally.blobs !== counts.blobs) {
359
+ throw new ExportArchiveError("manifest_mismatch", "the manifest's counts do not match the pages");
360
+ }
361
+ const seen = /* @__PURE__ */ new Set();
362
+ for (const entry of entries) {
363
+ if (seen.has(entry.path)) throw new ExportArchiveError("manifest_mismatch", `the pages hold ${entry.path} twice`);
364
+ seen.add(entry.path);
365
+ }
366
+ const collision = findEntryCollision(entries.map((entry) => entry.path));
367
+ if (collision) {
368
+ throw new ExportArchiveError("unsafe_path", `the archive holds paths one folder cannot keep apart: ${JSON.stringify(collision[0])} and ${JSON.stringify(collision[1])}`);
369
+ }
370
+ return rememberVerified({ source: { ...first.source }, exportedAt: first.exportedAt, counts: tally, entries, bytes }, this.#pages.flatMap((verified) => exportProvenance(verified).manifests), this.#archiveBytes);
371
+ }
372
+ };
373
+ function joinExportPages(pages) {
374
+ const chain = new ExportPageChain();
375
+ for (const page of pages) chain.add(page);
376
+ return chain.joined();
377
+ }
378
+ var verifiedStates = /* @__PURE__ */ new WeakMap();
379
+ function inventoryOf(verified) {
380
+ return verified.entries.map((entry) => ({
381
+ path: entry.path,
382
+ kind: entry.kind,
383
+ bytes: entry.bytes.byteLength,
384
+ sha256: sha256Hex(entry.bytes)
385
+ })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
386
+ }
387
+ function fingerprintOf(verified) {
388
+ return JSON.stringify({
389
+ source: verified.source,
390
+ exportedAt: verified.exportedAt,
391
+ counts: verified.counts,
392
+ bytes: verified.bytes,
393
+ page: verified.page,
394
+ entries: verified.entries.map((entry) => ({
395
+ path: entry.path,
396
+ kind: entry.kind,
397
+ bytes: entry.bytes.byteLength,
398
+ sha256: sha256Hex(entry.bytes)
399
+ }))
400
+ });
401
+ }
402
+ function rememberVerified(verified, manifests, archiveBytes) {
403
+ const snapshot = {
404
+ source: { ...verified.source },
405
+ exportedAt: verified.exportedAt,
406
+ counts: { ...verified.counts },
407
+ entries: verified.entries.map((entry) => ({ ...entry, bytes: Uint8Array.from(entry.bytes) })),
408
+ bytes: verified.bytes,
409
+ ...verified.page ? { page: { ...verified.page, counts: { ...verified.page.counts } } } : {}
410
+ };
411
+ const state = {
412
+ snapshot,
413
+ archiveBytes,
414
+ fingerprint: fingerprintOf(verified),
415
+ provenance: {
416
+ source: { ...verified.source },
417
+ exportedAt: verified.exportedAt,
418
+ manifests: manifests.map((manifest) => ({ ...manifest })),
419
+ inventorySha256: sha256Hex(new TextEncoder().encode(JSON.stringify(inventoryOf(verified))))
420
+ }
421
+ };
422
+ verifiedStates.set(verified, state);
423
+ verifiedStates.set(snapshot, state);
424
+ return verified;
425
+ }
426
+ function exportProvenance(verified) {
427
+ const state = verifiedStates.get(verified);
428
+ if (!state || state.fingerprint !== fingerprintOf(verified)) {
429
+ throw new ExportArchiveError("manifest_mismatch", "the verified export handle is absent or changed");
430
+ }
431
+ return {
432
+ ...state.provenance,
433
+ source: { ...state.provenance.source },
434
+ manifests: state.provenance.manifests.map((manifest) => ({ ...manifest }))
435
+ };
436
+ }
437
+ async function materializeExport(verified, parentDirectory) {
438
+ exportProvenance(verified);
439
+ const accepted = verifiedStates.get(verified).snapshot;
440
+ if (accepted.page) throw new ExportArchiveError("manifest_mismatch", "one page is not a complete export");
441
+ if (![null, "0.1", "0.2"].includes(accepted.source.okfEdition)) {
442
+ throw new ExportArchiveError("unsupported_format", "the exported OKF edition is not supported");
443
+ }
444
+ const entries = accepted.entries;
445
+ const parent = await realpath(parentDirectory);
446
+ const folder = await mkdtemp(path.join(parent, ".superbee-verified-export-"));
447
+ try {
448
+ for (const entry of entries) {
449
+ const file = path.join(folder, ...entry.path.split("/"));
450
+ await mkdir(path.dirname(file), { recursive: true });
451
+ await writeFile(file, entry.bytes, { flag: "wx", mode: 384 });
452
+ }
453
+ return folder;
454
+ } catch (error) {
455
+ await rm(folder, { recursive: true, force: true });
456
+ throw error;
457
+ }
458
+ }
459
+ export {
460
+ EXPORT_FORMAT,
461
+ EXPORT_MANIFEST,
462
+ ExportArchiveError,
463
+ ExportPageChain,
464
+ MAX_EXPORT_BYTES,
465
+ MAX_EXPORT_ENTRIES,
466
+ classifyEntryPath,
467
+ crc32,
468
+ exportProvenance,
469
+ findEntryCollision,
470
+ joinExportPages,
471
+ materializeExport,
472
+ readStoredZip,
473
+ verifyExport
474
+ };
@@ -5,4 +5,6 @@ export type { PublicationErrorCodeV1, PublicationErrorOptions } from "./errors.j
5
5
  export { PUBLICATION_SNAPSHOT_SCHEMA_V1 } from "./schema.js";
6
6
  export { PUBLICATION_BRIDGE_V0, PUBLICATION_SNAPSHOT_V1, type CapturePublicationSnapshotOptionsV1, type PublicationBridgeAdmissionV1, type PublicationBridgeOutcomeV1, type PublicationBridgeV1, type PublicationJsonValue, type PublicationObjectRefV1, type PublicationSnapshotHandleV1, type PublicationSnapshotV1, } from "./types.js";
7
7
  export type { CapturePublicationRuntimeOptions } from "./capture.js";
8
+ export { assertPublicationSourceExclusions } from "./source-exclusions.js";
9
+ export type { PublicationSourceInventory, PublicationSourceExclusions } from "./source-exclusions.js";
8
10
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,29 @@
1
+ /** Source metadata sufficient for publication exposure checks; document bodies are not read. */
2
+ export interface PublicationSourceInventory {
3
+ readonly documents: readonly {
4
+ readonly id: string;
5
+ readonly frontmatter: {
6
+ readonly type?: unknown;
7
+ };
8
+ }[];
9
+ readonly reserved: readonly {
10
+ readonly dir: string;
11
+ readonly name: string;
12
+ }[];
13
+ readonly blobs: readonly {
14
+ readonly key: string;
15
+ }[];
16
+ }
17
+ export interface PublicationSourceExclusions {
18
+ /** Excluded first directory segments, never root document names. */
19
+ readonly directories?: readonly string[];
20
+ /** Excluded document types, using the source engine's string coercion and list semantics. */
21
+ readonly types?: readonly string[];
22
+ }
23
+ /**
24
+ * Refuse an entire publication source containing excluded paths or types. This is an explicit
25
+ * caller exposure policy, not authorization or redaction. Normalization accounts for equivalent
26
+ * filesystem spellings; nested type lists are checked fail-closed as well as engine coercion.
27
+ */
28
+ export declare function assertPublicationSourceExclusions(source: PublicationSourceInventory, exclusions: PublicationSourceExclusions): void;
29
+ //# sourceMappingURL=source-exclusions.d.ts.map