@noy-db/as-zip 0.1.0-pre.3

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.
@@ -0,0 +1,349 @@
1
+ import { VaultDiff, Vault } from '@noy-db/hub';
2
+
3
+ /**
4
+ * Minimal zero-dependency ZIP writer — STORE method only (no compression).
5
+ *
6
+ * Produces single-disk zip archives readable by `unzip`, macOS Finder,
7
+ * Windows Explorer, and every well-behaved unzipper. Store-method is
8
+ * sufficient here because:
9
+ *
10
+ * 1. Most blobs reaching this writer are already compressed — PDFs,
11
+ * PNGs, JPEGs, `.zip`-inside-zip, encrypted `.noydb` bundles.
12
+ * Re-deflating yields near-zero savings at non-trivial CPU cost.
13
+ * 2. STORE keeps the encoder compact (~150 lines) and dependency-free.
14
+ * Deflate would need a ~5KB gzip-style implementation or an
15
+ * external dep; we're zero-deps by design.
16
+ * 3. Consumers who need further compression can deflate the whole zip
17
+ * after it's produced (`gzip archive.zip`).
18
+ *
19
+ * ## Wire format used
20
+ *
21
+ * Per PKWARE APPNOTE (the canonical ZIP spec):
22
+ *
23
+ * ```
24
+ * [Local File Header 1][File 1 data]
25
+ * [Local File Header 2][File 2 data]
26
+ * ...
27
+ * [Central Directory File Header 1]
28
+ * [Central Directory File Header 2]
29
+ * ...
30
+ * [End of Central Directory Record]
31
+ * ```
32
+ *
33
+ * All multi-byte integers are little-endian. Filenames are UTF-8
34
+ * (flag bit 11 set so unzippers interpret them correctly). The
35
+ * per-file CRC-32 uses the standard polynomial 0xEDB88320.
36
+ *
37
+ * ## What this does NOT support
38
+ *
39
+ * - Deflate / bzip2 / lzma / zstd compression methods.
40
+ * - Zip64 (files / archives > 4 GiB). 32-bit size fields apply.
41
+ * - Multi-disk / spanned archives.
42
+ * - Encryption (the ZIP-level kind — noy-db records are already
43
+ * encrypted at the envelope layer before reaching this writer).
44
+ * - Symlinks, extended attributes, NTFS/UNIX permissions.
45
+ *
46
+ * @module
47
+ */
48
+ /** Input entry to `writeZip`. */
49
+ interface ZipEntry {
50
+ /** Slash-delimited path inside the archive (e.g. `"records.json"`, `"attachments/01H.../raw.pdf"`). */
51
+ readonly path: string;
52
+ /** Raw bytes. Pass plaintext as-is; the writer doesn't interpret content. */
53
+ readonly bytes: Uint8Array;
54
+ /** Optional file mtime. Defaults to the call-time `Date`. */
55
+ readonly mtime?: Date;
56
+ }
57
+ /** Optional archive-wide settings for `writeZip`. */
58
+ interface WriteZipOptions {
59
+ /**
60
+ * When set, every entry is encrypted with WinZip-AES-256 sealed
61
+ * under this passphrase. Recipients open with stock archive
62
+ * tooling (7-Zip, Archive Utility, WinRAR, modern unzip builds)
63
+ * by typing the same passphrase.
64
+ *
65
+ * Single passphrase across all entries by design — per-entry
66
+ * passwords don't fit a noy-db export workflow and would muddy
67
+ * the API.
68
+ */
69
+ readonly password?: string;
70
+ }
71
+ /**
72
+ * Build a complete ZIP archive byte stream from `entries`. The result
73
+ * is the full archive including central directory and EOCD — ready
74
+ * to `fs.writeFile` or hand to `new Blob([bytes])`.
75
+ *
76
+ * When `options.password` is set, every entry is encrypted with
77
+ * WinZip-AES-256 (vendor version AE-2). Output remains a valid
78
+ * single-disk ZIP that archive tools recognising WinZip-AES prompt
79
+ * for the password on extract.
80
+ */
81
+ declare function writeZip(entries: readonly ZipEntry[], options?: WriteZipOptions): Promise<Uint8Array>;
82
+ declare function crc32(bytes: Uint8Array): number;
83
+
84
+ /**
85
+ * Minimal zero-dependency ZIP reader — STORE method only, with
86
+ * optional WinZip-AES-256 decryption.
87
+ *
88
+ * Mirror of `./zip.ts`'s writer. Reads the central directory, walks
89
+ * each entry, decrypts when the AES marker (method 99) is set and a
90
+ * password was supplied. Same scope limits as the writer:
91
+ *
92
+ * - **No DEFLATE / bzip2 / lzma / zstd.** STORE-only. Trying to read
93
+ * a deflated entry throws `ZipReadError`.
94
+ * - **No Zip64.** 32-bit size + offset fields apply.
95
+ * - **AES-256 only.** AES-128/192 and ZipCrypto are refused — same
96
+ * stance as the writer.
97
+ *
98
+ * @module
99
+ */
100
+ declare class ZipReadError extends Error {
101
+ readonly code = "ZIP_READ_ERROR";
102
+ constructor(message: string);
103
+ }
104
+ interface ReadZipEntry {
105
+ readonly path: string;
106
+ readonly bytes: Uint8Array;
107
+ /** True iff the entry was decrypted from a WinZip-AES region. */
108
+ readonly encrypted: boolean;
109
+ }
110
+ interface ReadZipOptions {
111
+ /** WinZip-AES-256 passphrase. Required for encrypted archives. */
112
+ readonly password?: string;
113
+ }
114
+ /**
115
+ * Parse a ZIP archive and return its entries. When the archive
116
+ * contains AES-encrypted entries (method 99 with the 0x9901 extra
117
+ * field), `options.password` MUST be supplied. Wrong password
118
+ * surfaces a `ZipCipherError` from the underlying decrypt step.
119
+ */
120
+ declare function readZip(bytes: Uint8Array, options?: ReadZipOptions): Promise<ReadZipEntry[]>;
121
+
122
+ /**
123
+ * WinZip-AES encryption for ZIP entries — write + read.
124
+ *
125
+ * Implements the AES-256 variant of the WinZip-AES extension as
126
+ * documented in
127
+ * https://www.winzip.com/win/en/aes_info.html and PKWARE Appendix E.
128
+ *
129
+ * **AES-256 only by design.** ZipCrypto and AES-128/192 are refused at
130
+ * both write and read time — the security story for the package is
131
+ * "if you need encryption, use AES-256." Adding weaker tiers would
132
+ * give consumers the impression of meaningful choice.
133
+ *
134
+ * ## Layout of an encrypted entry's data region
135
+ *
136
+ * ```
137
+ * ┌──────────┬──────────┬─────────────┬──────────┐
138
+ * │ salt(16) │ verif(2) │ ciphertext │ auth(10) │
139
+ * └──────────┴──────────┴─────────────┴──────────┘
140
+ * ```
141
+ *
142
+ * - **salt** — 16 random bytes per entry
143
+ * - **verifier** — 2 bytes, last 2 bytes of the PBKDF2 output;
144
+ * used to fail-fast on a wrong password without running CTR
145
+ * - **ciphertext** — input bytes XOR-ed with AES-CTR keystream
146
+ * - **auth** — first 10 bytes of HMAC-SHA1(ciphertext, authKey),
147
+ * where authKey is the second half of the PBKDF2 output
148
+ *
149
+ * ## Local file header changes
150
+ *
151
+ * For an encrypted entry:
152
+ *
153
+ * - `compression method` field is forced to **99** (AES marker)
154
+ * - flag **bit 0** is set (encryption)
155
+ * - extra field tag **0x9901** is appended with 7 bytes:
156
+ * - `2 bytes` vendor version → `0x0002` (AE-2)
157
+ * - `2 bytes` vendor ID → `'AE'` (`0x4541` LE)
158
+ * - `1 byte` AES strength → `0x03` (AES-256)
159
+ * - `2 bytes` real method → `0x0000` (STORE)
160
+ * - For AE-2, `crc` is forced to 0 (the spec recommends this — the
161
+ * authentication code already covers integrity)
162
+ *
163
+ * ## Counter convention (subtle!)
164
+ *
165
+ * AES-CTR per WinZip uses a **little-endian** 16-byte counter. The
166
+ * counter starts at **1** (not 0) and increments by 1 per 16-byte
167
+ * block. The lower 8 bytes hold the counter; the upper 8 bytes are 0.
168
+ *
169
+ * @module
170
+ */
171
+
172
+ declare class ZipCipherError extends Error {
173
+ readonly code = "ZIP_CIPHER_ERROR";
174
+ constructor(message: string);
175
+ }
176
+
177
+ /**
178
+ * **@noy-db/as-zip** — composite record + blob archive for noy-db.
179
+ *
180
+ * Bundles a collection's records AND every record's attached blobs
181
+ * into a single zip archive. The canonical "download this audit
182
+ * trail" / "migrate this case folder" primitive. Part of the
183
+ * `@noy-db/as-*` portable-artefact family — plaintext tier,
184
+ * document sub-family.
185
+ *
186
+ * ## Authorisation
187
+ *
188
+ * One capability check: `assertCanExport('plaintext', 'zip')`.
189
+ * A composite archive is semantically the `'zip'` format from the
190
+ * auth model's POV — requiring separate `'json'`, `'csv'`, `'blob'`
191
+ * grants for a single call would fragment the grant surface without
192
+ * adding any real isolation (the archive concatenates them anyway).
193
+ *
194
+ * The owner grants the composite capability explicitly:
195
+ *
196
+ * ```ts
197
+ * await db.grant('firm', {
198
+ * userId: 'auditor',
199
+ * role: 'viewer',
200
+ * passphrase: '…',
201
+ * exportCapability: { plaintext: ['zip'] },
202
+ * })
203
+ * ```
204
+ *
205
+ * ## Archive layout
206
+ *
207
+ * ```
208
+ * archive.zip
209
+ * ├── manifest.json # index + provenance (record count, blob count, exported-at, exported-by)
210
+ * ├── records.json # array of decrypted records
211
+ * └── attachments/
212
+ * ├── <recordId>/<slot> # raw blob bytes, MIME-native
213
+ * └── ...
214
+ * ```
215
+ *
216
+ * The folder-per-record layout makes composite entities (email +
217
+ * body + attachments, invoice + scan + receipt) navigable in
218
+ * Finder/Explorer without tooling.
219
+ *
220
+ * See [`docs/patterns/as-exports.md`](https://github.com/vLannaAi/noy-db/blob/main/docs/patterns/as-exports.md).
221
+ *
222
+ * @packageDocumentation
223
+ */
224
+
225
+ /** Record-selection options. */
226
+ interface AsZipRecordsOptions {
227
+ /** Collection to export. Must be in the caller's read ACL. */
228
+ readonly collection: string;
229
+ /**
230
+ * Optional predicate against each decrypted record. When omitted,
231
+ * every record is included. Runs after decryption, before zip
232
+ * assembly — doesn't reduce I/O, just the final set.
233
+ */
234
+ readonly filter?: (record: unknown) => boolean;
235
+ }
236
+ /** Blob-selection options. */
237
+ interface AsZipAttachmentsOptions {
238
+ /**
239
+ * Which slots to include per record. `'*'` (default) includes
240
+ * every slot on every record; a string[] selects specific slot
241
+ * names. Pass an empty array to skip blob inclusion entirely.
242
+ */
243
+ readonly slots?: readonly string[] | '*';
244
+ }
245
+ /** Top-level options for every entry point. */
246
+ interface AsZipOptions {
247
+ readonly records: AsZipRecordsOptions;
248
+ readonly attachments?: AsZipAttachmentsOptions;
249
+ /**
250
+ * Optional WinZip-AES-256 passphrase. When set, every entry
251
+ * inside the archive (records + attachments + manifest) is
252
+ * encrypted with WinZip-AES-256 and the recipient must supply the
253
+ * passphrase to extract.
254
+ *
255
+ * **Interop note:** the implementation is strictly to spec but has
256
+ * not been validated against 7-Zip / Archive Utility / WinRAR in
257
+ * this checkout. Round-trips against the package's own reader pass.
258
+ * See https://github.com/vLannaAi/noy-db/issues/304 for the
259
+ * cross-tool validation matrix.
260
+ *
261
+ * **Security framing:** this is the *interop* layer for
262
+ * cross-platform handoff to archive tools, not the *encryption*
263
+ * layer for sharing noy-db state. Use `as-noydb` for multi-recipient
264
+ * + revocable + audited egress.
265
+ */
266
+ readonly password?: string;
267
+ }
268
+ /** Options for `download()` — adds an optional filename. */
269
+ interface AsZipDownloadOptions extends AsZipOptions {
270
+ /** Filename offered to the browser. Default `'<collection>.zip'`. */
271
+ readonly filename?: string;
272
+ }
273
+ /** Options for `write()` — requires explicit risk acknowledgement. */
274
+ interface AsZipWriteOptions extends AsZipOptions {
275
+ /**
276
+ * Required for Node file-write calls — consumer acknowledgement
277
+ * that plaintext bytes will persist on disk past the current
278
+ * process lifetime (Tier 3 risk).
279
+ */
280
+ readonly acknowledgeRisks: true;
281
+ }
282
+ /** Manifest entry — one per record that landed in the archive. */
283
+ interface ManifestRecord {
284
+ readonly id: string;
285
+ readonly attachments: ReadonlyArray<{
286
+ readonly slot: string;
287
+ readonly path: string;
288
+ readonly size: number;
289
+ readonly mimeType?: string;
290
+ }>;
291
+ }
292
+ /** Archive-level manifest. Serialised as `manifest.json`. */
293
+ interface ArchiveManifest {
294
+ readonly _noydb_archive: 1;
295
+ readonly collection: string;
296
+ readonly exportedAt: string;
297
+ readonly recordCount: number;
298
+ readonly attachmentCount: number;
299
+ readonly records: readonly ManifestRecord[];
300
+ }
301
+ /**
302
+ * Assemble the archive bytes. Pure beyond the auth check + store
303
+ * reads. Records are written as `records.json`; blobs as
304
+ * `attachments/<recordId>/<slot>`. A `manifest.json` index lands
305
+ * at the archive root so extractors can walk the content without
306
+ * re-reading every file.
307
+ */
308
+ declare function toBytes(vault: Vault, options: AsZipOptions): Promise<Uint8Array>;
309
+ /**
310
+ * Browser download — wraps `toBytes()` in a `Blob` and triggers the
311
+ * browser's download prompt. Requires `URL.createObjectURL` +
312
+ * `document.createElement`. Throws in headless Node.
313
+ */
314
+ declare function download(vault: Vault, options: AsZipDownloadOptions): Promise<void>;
315
+ /**
316
+ * Node file-write — persists the archive to disk. Requires
317
+ * explicit `acknowledgeRisks: true` (Tier 3 egress — the archive
318
+ * contains plaintext records + blob bytes).
319
+ */
320
+ declare function write(vault: Vault, path: string, options: AsZipWriteOptions): Promise<void>;
321
+
322
+ type ImportPolicy = 'merge' | 'replace' | 'insert-only';
323
+ interface AsZipImportOptions {
324
+ /** Target collection for the records. */
325
+ readonly collection: string;
326
+ /** Field on each record that carries its id. Default `'id'`. */
327
+ readonly idKey?: string;
328
+ /** Reconciliation policy. Default `'merge'`. */
329
+ readonly policy?: ImportPolicy;
330
+ /** WinZip-AES-256 passphrase if the archive is encrypted. */
331
+ readonly password?: string;
332
+ }
333
+ interface AsZipImportPlan {
334
+ readonly plan: VaultDiff;
335
+ readonly policy: ImportPolicy;
336
+ apply(): Promise<void>;
337
+ }
338
+ /**
339
+ * Read a `.zip` archive (optionally WinZip-AES-256 encrypted), parse
340
+ * `records.json` from it, and return an `ImportPlan` whose `apply()`
341
+ * writes the changes through the normal collection API.
342
+ *
343
+ * Pairs with `toBytes` for round-trip workflows. The records JSON
344
+ * format matches what `toBytes` writes — array of records under
345
+ * the configured collection's id key.
346
+ */
347
+ declare function fromBytes(vault: Vault, bytes: Uint8Array, options: AsZipImportOptions): Promise<AsZipImportPlan>;
348
+
349
+ export { type ArchiveManifest, type AsZipAttachmentsOptions, type AsZipDownloadOptions, type AsZipImportOptions, type AsZipImportPlan, type AsZipOptions, type AsZipRecordsOptions, type AsZipWriteOptions, type ImportPolicy, type ManifestRecord, type ReadZipEntry, type ReadZipOptions, type WriteZipOptions, ZipCipherError, type ZipEntry, ZipReadError, crc32, download, fromBytes, readZip, toBytes, write, writeZip };