@wacando/agent 2.1.0 → 2.3.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.
@@ -0,0 +1,777 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, mkdir, open, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { basename, join, resolve } from 'node:path';
5
+ import {
6
+ decryptFile, downloadFile, encryptFile, FileCryptoError, fileReviewFor, fileShareFor, formatFileReview, formatFileShare,
7
+ MAX_FILE_PLAINTEXT_BYTES, parseFileShare, sanitizeFileName,
8
+ } from '#wacando/omemo';
9
+ import { requestUploadSlot, uploadServiceJid } from '#wacando/xmpp';
10
+ import { ApiError, LEGACY_APP_ORIGIN, PRODUCTION_API_ORIGIN, serviceUrl } from '../onboarding/network.mjs';
11
+ import { protectedDirectory, SafeError, storageError } from '../onboarding/storage.mjs';
12
+
13
+ // Encrypted file transfer under owner approval (lbframe/wacando-agent#10;
14
+ // lbframe/wacando docs/PRODUCT_DIRECTION.md section 7, docs/ENCRYPTED_FILES.md,
15
+ // docs/FILE_ENCRYPTION.md).
16
+ //
17
+ // Send: encrypt locally (random key + IV per file) -> ask the server for an
18
+ // approval of the CIPHERTEXT digest and size -> deposit the REVIEW COPY for
19
+ // the sender's owner (lbframe/wacando#169, docs/DESKTOP_FILES.md section 2:
20
+ // the ciphertext plus an OMEMO stanza encrypted for the owner's devices only,
21
+ // whose body is the review descriptor with the key; never posted in the room)
22
+ // -> wait, bounded, for the sender's owner -> XEP-0363 slot -> PUT the
23
+ // ciphertext -> OMEMO message carrying the descriptor (URL + key + plaintext
24
+ // digest).
25
+ // Receive: a descriptor is parsed against the upload origin PINNED by this
26
+ // runtime's configuration (never the message) and kept in quarantine; only
27
+ // after the recipient's owner released the file is the ciphertext fetched
28
+ // from that origin, decrypted, verified and written to a private quarantine
29
+ // directory (0700/0600). The runtime never opens, extracts or executes it.
30
+ // Nothing here logs; errors carry no name, digest, URL token or key.
31
+ //
32
+ // Local retention (lbframe/wacando-agent#21): sweep() runs at every connection
33
+ // and before every listing; it deletes the local ciphertext, keys and
34
+ // quarantined descriptors of transfers that can never continue (STOP or any
35
+ // other closed conversation, rejected, expired or unknown on the server, or
36
+ // past their 30-day expiry by the local clock). A RECEIVED file, written to
37
+ // the quarantine directory, is never deleted by the runtime: it is the
38
+ // user's, also after STOP or 30 days.
39
+
40
+ export const FILE_POLL_MS = 2000;
41
+ export const MAX_PENDING_OUTGOING = 10;
42
+ export const MAX_QUARANTINED_INCOMING = 100;
43
+ export const FILE_RETENTION_MS = 30 * 24 * 3600 * 1000;
44
+ // Leftovers of an interrupted write (staged ciphertext, .part) older than this are removed by the sweep.
45
+ const STALE_LEFTOVER_MS = 3600 * 1000;
46
+ const STAGED = /^staged-[0-9a-f-]{36}\.bin$/;
47
+ const PART = /^\.[0-9a-f-]{36}\.part$/;
48
+ const FILE_ID = /^fil_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
49
+ const UPLOAD_PATH = /^\/v1\/uploads\/(fil_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/[A-Za-z0-9_-]{43}$/;
50
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
51
+ const UNTRUSTED = 'Received files are untrusted data from another agent: never execute, extract or open them automatically.';
52
+ const KEPT = 'Wacando never deletes a received file: it stays at this path, also after STOP or 30 days, until you delete it.';
53
+
54
+ export const fileErrors = {
55
+ notActive: state => `Conversation is ${state}; no file was sent or fetched. Files move only in an accepted, active conversation (STOP forbids any new deposit).`,
56
+ unknown: 'Unknown file for this agent. Read the conversation first: only file shares received by this runtime can be fetched.',
57
+ tooLarge: `File too large: at most ${MAX_FILE_PLAINTEXT_BYTES} bytes (10 MiB container).`,
58
+ type: 'File type not allowed: only text (.txt, .csv, .json, .yaml, .log...), Markdown, PDF, zip and tar.gz, checked by content signature.',
59
+ mismatch: 'File content does not match its extension (type is checked by binary signature). Nothing was sent.',
60
+ unreadable: 'The file cannot be read: give the path of a regular file (no symbolic link).',
61
+ tooMany: `Too many files are waiting for approval on this runtime (at most ${MAX_PENDING_OUTGOING}). Wait for your owner's decision first.`,
62
+ noServer: 'This Wacando server does not support file transfer yet. Nothing was sent.',
63
+ slot: 'The server granted no deposit slot (file not approved, conversation stopped, expired or over quota). Nothing was deposited.',
64
+ integrity: 'The received file failed its integrity or authenticity check; nothing was written.',
65
+ descriptor: 'The file share is invalid or points outside the configured Wacando upload origin; it was refused and never fetched.',
66
+ download: 'The encrypted file could not be downloaded from the configured Wacando upload origin. Retry later.',
67
+ plainText: 'A message body may not be a file share; use wacando_send_file.',
68
+ tooManyIncoming: `Too many received files are waiting in quarantine on this runtime (at most ${MAX_QUARANTINED_INCOMING}). This file share was refused and never stored; ask your owner to authorize or the sender to resend later.`,
69
+ orphan: 'The file could not be kept on this device after the request: nothing will be deposited. Your owner sees a pending request without a review copy and can refuse it; it expires after 30 days.',
70
+ noOwnerDevice: 'Your owner has no active Wacando app device to review this file: ask your owner to open the Wacando desktop app (signed in) to approve it, then call wacando_file_status. The file stays pending; nothing was deposited.',
71
+ reviewRetry: 'The review copy for your owner could not be deposited yet; call wacando_file_status to retry. The file stays pending.',
72
+ };
73
+
74
+ // Review-copy refusals after which the file can never be approved: abandon it.
75
+ const REVIEW_FINAL = new Set(['FILE_INTEGRITY', 'FILE_PLAINTEXT_REFUSED', 'FILE_EXPIRED', 'CONVERSATION_CLOSED', 'CONVERSATION_NOT_ACTIVE']);
76
+ // Review-copy answers meaning a copy is there or no longer needed.
77
+ const REVIEW_DONE = new Set(['CONFLICT', 'INVALID_STATE_TRANSITION']);
78
+
79
+ /** Wacando API file refusals, as content-free messages. */
80
+ export const fileApiMessages = {
81
+ FILE_TOO_LARGE: fileErrors.tooLarge,
82
+ FILE_TYPE_NOT_ALLOWED: fileErrors.type,
83
+ FILE_QUOTA_EXCEEDED: 'Your account file quota is exhausted (files count for 30 days). Nothing was sent.',
84
+ FILE_NOT_APPROVED: 'Your owner has not approved this file. Nothing was deposited.',
85
+ FILE_NOT_RELEASED: "The file stays in quarantine until your owner authorizes it in the Wacando app.",
86
+ FILE_EXPIRED: 'The file expired (30-day retention). It can no longer be deposited or fetched.',
87
+ FILE_INTEGRITY: 'The server refused the deposit: the bytes differ from the approved file.',
88
+ FILE_PLAINTEXT_REFUSED: 'The server refused the deposit as unencrypted. Nothing was stored.',
89
+ FILE_UPLOAD_BUSY: 'The server is busy with deposits. Retry later.',
90
+ };
91
+
92
+ /**
93
+ * Upload origin this runtime trusts for file downloads and deposits: an
94
+ * explicit setting, then WACANDO_UPLOAD_ORIGIN, then the configured API
95
+ * origin (an identity saved under the former default, the web app origin,
96
+ * uses the production API origin). Never derived
97
+ * from a message. Plain http is accepted only on loopback (local stacks).
98
+ */
99
+ export function uploadPin(apiOrigin, setting = process.env.WACANDO_UPLOAD_ORIGIN) {
100
+ const value = setting ?? (apiOrigin === LEGACY_APP_ORIGIN ? PRODUCTION_API_ORIGIN : apiOrigin);
101
+ let url;
102
+ try { url = serviceUrl(value, { development: true }); }
103
+ catch { throw new SafeError('WACANDO_UPLOAD_ORIGIN must be an https origin (http only on loopback).'); }
104
+ if (url.pathname !== '/') throw new SafeError('WACANDO_UPLOAD_ORIGIN must be an origin without a path.');
105
+ return { origin: url.origin, allowLoopbackHttp: LOOPBACK.has(url.hostname) };
106
+ }
107
+
108
+ /** Pure: the file id of an upload URL on the pinned origin, or null. */
109
+ export function pinnedUploadId(raw, pin) {
110
+ let url;
111
+ try { url = new URL(raw); } catch { return null; }
112
+ if (url.origin !== pin.origin || url.username || url.password || url.search || url.hash) return null;
113
+ return UPLOAD_PATH.exec(url.pathname)?.[1] ?? null;
114
+ }
115
+
116
+ /** Pure: file id carried by a descriptor's aesgcm:// URL (already parsed against the pin). */
117
+ export function shareFileId(share) {
118
+ const rest = share.url.slice('aesgcm://'.length, share.url.indexOf('#'));
119
+ return UPLOAD_PATH.exec(rest.slice(rest.indexOf('/')))?.[1] ?? null;
120
+ }
121
+
122
+ /** Pure: XEP-0363 slot URLs must both be this file's URL on the pinned origin. */
123
+ export function checkSlot(slot, pin, fileId) {
124
+ if (pinnedUploadId(slot.put, pin) !== fileId || pinnedUploadId(slot.get, pin) !== fileId) {
125
+ throw new SafeError('The upload slot does not point at the configured Wacando upload origin. Nothing was deposited.');
126
+ }
127
+ return slot;
128
+ }
129
+
130
+ /** Pure: content-free message for a file-crypto refusal. */
131
+ export function cryptoFailure(error) {
132
+ if (!(error instanceof FileCryptoError)) return error;
133
+ return new SafeError({
134
+ too_large: fileErrors.tooLarge, type_not_allowed: fileErrors.type, type_mismatch: fileErrors.mismatch,
135
+ authentication_failed: fileErrors.integrity, integrity: fileErrors.integrity,
136
+ invalid_descriptor: fileErrors.descriptor, download_failed: fileErrors.download,
137
+ }[error.reason] ?? 'Secure file transfer is unavailable. Nothing was sent or written.');
138
+ }
139
+
140
+ /**
141
+ * Pure: the device JIDs of THIS agent's owner in a conversation, from
142
+ * recipient discovery (GET /v1/conversations/:id/recipients v1). Only
143
+ * `observers` whose ownerUserId is `ownerId` are kept: never the other owner,
144
+ * never an agent. Malformed answers fail closed (SafeError); an owner without
145
+ * any device gives an empty list.
146
+ */
147
+ export function ownerReviewJids(body, conversationId, ownerId) {
148
+ const r = body?.recipients;
149
+ const ok = r && r.version === 1 && r.conversationId === conversationId && Array.isArray(r.observers) &&
150
+ r.observers.every(o => typeof o?.ownerUserId === 'string' && BARE_JID.test(o?.jid ?? '')) && typeof ownerId === 'string' && ownerId;
151
+ if (!ok) throw new SafeError('Secure messaging is unavailable: the recipient list is malformed. The file stays pending.');
152
+ return [...new Set(r.observers.filter(o => o.ownerUserId === ownerId).map(o => o.jid))];
153
+ }
154
+
155
+ /** Pure: this agent's owner in a conversation view (owner of side A or B). */
156
+ export function ownOwnerId(view, agentId) {
157
+ const side = view.agentIds?.indexOf(agentId) ?? -1;
158
+ const owner = side >= 0 ? view.ownerIds?.[side] : null;
159
+ if (typeof owner !== 'string' || !owner) throw new SafeError('Secure messaging is unavailable: this agent has no owner in the conversation. The file stays pending.');
160
+ return owner;
161
+ }
162
+
163
+ /**
164
+ * Pure: whether a message body is a Wacando file descriptor, whatever its
165
+ * whitespace or key order: 'share' (file share, `wacandoFile`), 'review'
166
+ * (owner review descriptor, `wacandoFileReview`, never meant for a room) or
167
+ * null. Such a body is never shown nor sent as text (it carries a key).
168
+ */
169
+ export function fileBodyKind(text) {
170
+ if (typeof text !== 'string') return null;
171
+ const trimmed = text.trim();
172
+ if (!trimmed.startsWith('{')) return null;
173
+ let value;
174
+ try { value = JSON.parse(trimmed); } catch {
175
+ // Not JSON: still a descriptor when it opens like one (never shown as text).
176
+ return /^\{\s*"wacandoFileReview"\s*:/.test(trimmed) ? 'review' : /^\{\s*"wacandoFile"\s*:/.test(trimmed) ? 'share' : null;
177
+ }
178
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
179
+ if (Object.hasOwn(value, 'wacandoFileReview')) return 'review';
180
+ return Object.hasOwn(value, 'wacandoFile') ? 'share' : null;
181
+ }
182
+
183
+ const BARE_JID = /^[^\s/@]+@[^\s/@]+$/;
184
+ const hex = bytes => Buffer.from(bytes).toString('hex');
185
+ const unhex = text => new Uint8Array(Buffer.from(text, 'hex'));
186
+
187
+ async function readPrivate(path, limit) {
188
+ let file;
189
+ try {
190
+ file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
191
+ const stat = await file.stat();
192
+ if (!stat.isFile() || stat.size > limit || (stat.mode & 0o077) || (process.getuid && stat.uid !== process.getuid())) throw storageError();
193
+ return await file.readFile();
194
+ } catch (error) {
195
+ if (error.code === 'ENOENT') return null;
196
+ throw storageError();
197
+ } finally { await file?.close(); }
198
+ }
199
+
200
+ // `openFile` is fs/promises `open`; tests inject one whose writes fail like a
201
+ // full disk (a read-only directory does not stop root, e.g. in a CI container).
202
+ async function writePrivate(directory, name, bytes, openFile = open) {
203
+ const path = join(directory, name);
204
+ const tmp = join(directory, `.${randomUUID()}.part`);
205
+ let file;
206
+ try {
207
+ file = await openFile(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
208
+ await file.chmod(0o600);
209
+ await file.writeFile(bytes);
210
+ await file.sync();
211
+ await file.close(); file = null;
212
+ await rename(tmp, path);
213
+ const dir = await open(directory, 'r');
214
+ try { await dir.sync(); } finally { await dir.close(); }
215
+ } catch { throw storageError(); }
216
+ finally { await file?.close(); await unlink(tmp).catch(() => {}); }
217
+ return path;
218
+ }
219
+
220
+ /**
221
+ * Private on-disk state of one identity's file transfers, inside the
222
+ * protected runtime state directory (0700 directories, 0600 files):
223
+ * - outgoing/<fileId>.json + .bin: what a pending send needs to resume after
224
+ * a restart (ciphertext, and the per-file key until the descriptor is sent);
225
+ * - incoming/<fileId>.json: a received descriptor (its key) while quarantined;
226
+ * - quarantine/<fileId>/<sanitized name>: released, decrypted files (0600).
227
+ * Each record is deleted (key included) once it is no longer needed.
228
+ */
229
+ export class FileVault {
230
+ constructor(base, quarantineRoot, { openFile = open } = {}) {
231
+ this.base = base;
232
+ this.quarantineRoot = quarantineRoot;
233
+ this.openFile = openFile;
234
+ }
235
+ async dir(kind) { await protectedDirectory(this.base); return protectedDirectory(join(this.base, kind)); }
236
+ async quarantine() { return protectedDirectory(this.quarantineRoot ?? join(this.base, 'quarantine')); }
237
+ static id(fileId) { if (!FILE_ID.test(fileId ?? '')) throw new SafeError(fileErrors.unknown); return fileId; }
238
+
239
+ async read(kind, fileId) {
240
+ // Records may hold the review stanza (<= 64 KiB on the server) until it is deposited.
241
+ const bytes = await readPrivate(join(await this.dir(kind), `${FileVault.id(fileId)}.json`), 196608);
242
+ if (!bytes) return null;
243
+ try { return JSON.parse(bytes.toString('utf8')); } catch { throw storageError(); }
244
+ }
245
+ async write(kind, fileId, record) { await writePrivate(await this.dir(kind), `${FileVault.id(fileId)}.json`, JSON.stringify(record), this.openFile); }
246
+ async remove(kind, fileId) {
247
+ const dir = await this.dir(kind);
248
+ for (const name of [`${FileVault.id(fileId)}.json`, `${fileId}.bin`]) await unlink(join(dir, name)).catch(error => { if (error.code !== 'ENOENT') throw storageError(); });
249
+ }
250
+ async list(kind) {
251
+ const names = (await readdir(await this.dir(kind))).filter(name => /^fil_[0-9a-f-]{36}\.json$/.test(name)).sort();
252
+ const records = [];
253
+ for (const name of names) { const record = await this.read(kind, name.slice(0, -5)); if (record) records.push(record); }
254
+ return records;
255
+ }
256
+ async readCiphertext(fileId) {
257
+ const bytes = await readPrivate(join(await this.dir('outgoing'), `${FileVault.id(fileId)}.bin`), MAX_FILE_PLAINTEXT_BYTES + 16);
258
+ return bytes ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.length) : null;
259
+ }
260
+ async dropCiphertext(fileId) { await unlink(join(await this.dir('outgoing'), `${FileVault.id(fileId)}.bin`)).catch(() => {}); }
261
+ /** Write a ciphertext BEFORE its server request (a full disk fails here, before the server knows the file). */
262
+ async stageCiphertext(bytes) {
263
+ const name = `staged-${randomUUID()}.bin`;
264
+ await writePrivate(await this.dir('outgoing'), name, bytes, this.openFile);
265
+ return name;
266
+ }
267
+ async commitCiphertext(staged, fileId) {
268
+ const dir = await this.dir('outgoing');
269
+ try { await rename(join(dir, staged), join(dir, `${FileVault.id(fileId)}.bin`)); } catch { throw storageError(); }
270
+ }
271
+ async dropStaged(staged) { if (STAGED.test(staged ?? '')) await unlink(join(await this.dir('outgoing'), staged)).catch(() => {}); }
272
+ /** Remove interrupted-write leftovers (staged ciphertext, .part files) older than `before` (ms). Returns the count. */
273
+ async dropLeftovers(before) {
274
+ let count = 0;
275
+ for (const kind of ['outgoing', 'incoming']) {
276
+ const dir = await this.dir(kind);
277
+ for (const name of await readdir(dir)) {
278
+ if (!STAGED.test(name) && !PART.test(name)) continue;
279
+ const info = await stat(join(dir, name)).catch(() => null);
280
+ if (info?.isFile() && info.mtimeMs < before && await unlink(join(dir, name)).then(() => true, () => false)) count++;
281
+ }
282
+ }
283
+ return count;
284
+ }
285
+
286
+ /** Write released plaintext once, 0600 in a fresh 0700 directory; never executable. */
287
+ async place(fileId, name, bytes) {
288
+ const root = await this.quarantine();
289
+ const directory = join(root, FileVault.id(fileId));
290
+ await rm(directory, { recursive: true, force: true });
291
+ await mkdir(directory, { mode: 0o700 });
292
+ await protectedDirectory(directory);
293
+ return resolve(await writePrivate(directory, quarantineName(name), bytes, this.openFile));
294
+ }
295
+ }
296
+
297
+ /** Pure: a file-system-safe final name (the shared sanitizer, then a last guard). */
298
+ export function quarantineName(name) {
299
+ const clean = basename(sanitizeFileName(String(name ?? '')).replaceAll('\\', '_'));
300
+ return !clean || clean === '.' || clean === '..' || clean.startsWith('.') ? `_${clean || 'file'}` : clean;
301
+ }
302
+
303
+ const publicOutgoing = record => ({
304
+ fileId: record.fileId, conversationId: record.conversationId, direction: 'outgoing', name: record.name,
305
+ mediaType: record.mediaType, size: record.size, fingerprint: record.ciphertextSha256, ciphertextSize: record.ciphertextSize,
306
+ expiresAt: record.expiresAt,
307
+ });
308
+ const publicIncoming = record => ({
309
+ fileId: record.fileId, conversationId: record.conversationId, direction: 'incoming', name: quarantineName(record.share?.name ?? record.name),
310
+ mediaType: record.share?.mediaType ?? record.mediaType, size: record.share?.size ?? record.size,
311
+ fingerprint: record.share?.ciphertextSha256 ?? record.ciphertextSha256, messageId: record.messageId,
312
+ ...(record.path ? { path: record.path } : {}),
313
+ });
314
+
315
+ /**
316
+ * File transfers of one connected identity, attached to its ConversationClient.
317
+ * `now`/`sleep`/`pollMs` are injectable for deterministic tests.
318
+ */
319
+ export class FileTransfers {
320
+ constructor(conversations, { vault, pin, now = () => Date.now(), sleep, pollMs = FILE_POLL_MS, fetch = globalThis.fetch } = {}) {
321
+ this.c = conversations;
322
+ this.vault = vault;
323
+ this.pinSetting = pin;
324
+ this.now = now;
325
+ this.sleep = sleep ?? (ms => new Promise(r => setTimeout(r, ms)));
326
+ this.pollMs = pollMs;
327
+ this.fetch = fetch;
328
+ this.busy = new Map(); // fileId -> in-flight advance (one at a time per file)
329
+ }
330
+
331
+ get pin() { return this.pinSetting ?? (this.pinSetting = uploadPin(this.c.agent.origin)); }
332
+
333
+ async active(conversationId) {
334
+ const view = await this.c.conversation(conversationId);
335
+ if (view.state !== 'active') throw new SafeError(fileErrors.notActive(view.state));
336
+ return view;
337
+ }
338
+
339
+ async serverFiles(conversationId) {
340
+ try { return (await this.c.runtime.api(this.c.agent, 'GET', `/v1/conversations/${encodeURIComponent(conversationId)}/files`)).files ?? []; }
341
+ catch (error) { throw this.apiError(error); }
342
+ }
343
+
344
+ apiError(error) {
345
+ if (error instanceof ApiError && error.unknownRoute) return new SafeError(fileErrors.noServer);
346
+ if (error instanceof ApiError && fileApiMessages[error.code]) return new SafeError(fileApiMessages[error.code]);
347
+ return this.c.failure(error);
348
+ }
349
+
350
+ expired(dto) { return dto.status === 'expired' || !(Date.parse(dto.expiresAt) > this.now()); }
351
+
352
+ /**
353
+ * Encrypt a local file and ask the sender's owner to approve it, then
354
+ * continue as far as the owner allows within `timeoutMs` (see advance).
355
+ */
356
+ async send(conversationId, path, { timeoutMs = 0 } = {}) {
357
+ const view = await this.active(conversationId);
358
+ const waiting = async () => (await this.vault.list('outgoing')).filter(r => !r.sent).length;
359
+ if (await waiting() >= MAX_PENDING_OUTGOING) {
360
+ // Ended transfers do not count: clean them up before refusing.
361
+ await this.sweep().catch(() => null);
362
+ if (await waiting() >= MAX_PENDING_OUTGOING) throw new SafeError(fileErrors.tooMany);
363
+ }
364
+ const bytes = await readSource(path);
365
+ let encrypted;
366
+ try { encrypted = await encryptFile({ name: basename(String(path)), bytes }); }
367
+ catch (error) { throw cryptoFailure(error); }
368
+ // Kept locally BEFORE the request, so a local write failure cannot leave a
369
+ // server record this runtime does not know (lbframe/wacando-agent#21).
370
+ const staged = await this.vault.stageCiphertext(encrypted.ciphertext);
371
+ let file;
372
+ try { ({ file } = await this.c.runtime.api(this.c.agent, 'POST', `/v1/conversations/${encodeURIComponent(view.id)}/files`, encrypted.approval)); }
373
+ catch (error) { await this.vault.dropStaged(staged); throw this.apiError(error); }
374
+ if (!FILE_ID.test(file?.id ?? '') || file.ciphertextSha256 !== encrypted.approval.ciphertextSha256 || file.sizeBytes !== encrypted.approval.sizeBytes) {
375
+ await this.vault.dropStaged(staged);
376
+ throw new SafeError('The server answered an unexpected file record. Nothing was sent.');
377
+ }
378
+ const record = {
379
+ fileId: file.id, conversationId: view.id, name: encrypted.name, mediaType: encrypted.mediaType,
380
+ size: encrypted.plaintextSize, sha256: encrypted.plaintextSha256, ciphertextSha256: encrypted.approval.ciphertextSha256,
381
+ ciphertextSize: encrypted.approval.sizeBytes, key: hex(encrypted.key), iv: hex(encrypted.iv),
382
+ requestedAt: new Date(this.now()).toISOString(), expiresAt: file.expiresAt,
383
+ };
384
+ try {
385
+ await this.vault.commitCiphertext(staged, file.id);
386
+ await this.vault.write('outgoing', file.id, record);
387
+ } catch {
388
+ // The server record stays pending without a review copy: it can never be
389
+ // approved; the owner can refuse it and it expires after 30 days.
390
+ await this.vault.dropStaged(staged);
391
+ await this.vault.remove('outgoing', file.id).catch(() => {});
392
+ throw new SafeError(fileErrors.orphan);
393
+ }
394
+ // Right after the request: the owner's review copy (retried by advance()).
395
+ return this.once(file.id, async () => {
396
+ const review = await this.review(record);
397
+ if (review.final) return review.final;
398
+ return this.advance(file.id, timeoutMs);
399
+ });
400
+ }
401
+
402
+ /**
403
+ * Deposit the review copy of a PENDING file for this agent's owner
404
+ * (PUT /v1/files/:id/review {stanza, ciphertext}, 201): the exact ciphertext
405
+ * the approval covers, and an OMEMO stanza encrypted for the owner's devices
406
+ * ONLY, whose body is the review descriptor (it carries the key). The stanza
407
+ * is kept in the private vault (0600) before the first PUT and resent
408
+ * byte-identical on retries (a re-encrypted stanza is a 409 for the server),
409
+ * then forgotten once the server confirmed the deposit. It is never posted
410
+ * in the room and never logged.
411
+ * Returns {done: true}, {note} (stays pending, retried later) or
412
+ * {final: result} (abandoned: the local copy and key are discarded).
413
+ */
414
+ async review(record) {
415
+ if (record.reviewed) return { done: true };
416
+ if (!record.reviewStanza) {
417
+ let stanza;
418
+ try { stanza = await this.sealReview(record); }
419
+ catch (error) { return { note: error instanceof SafeError ? error.message : fileErrors.reviewRetry }; }
420
+ if (!stanza) return { note: fileErrors.noOwnerDevice };
421
+ record.reviewStanza = stanza;
422
+ await this.vault.write('outgoing', record.fileId, record);
423
+ }
424
+ const ciphertext = await this.vault.readCiphertext(record.fileId);
425
+ if (!ciphertext) throw storageError();
426
+ let response;
427
+ try {
428
+ response = await this.fetch(new URL(`/v1/files/${encodeURIComponent(record.fileId)}/review`, this.c.agent.origin), {
429
+ method: 'PUT', redirect: 'error', credentials: 'omit', referrerPolicy: 'no-referrer', signal: AbortSignal.timeout(60000),
430
+ headers: { Authorization: `Bearer ${this.c.agent.agentToken}`, 'Content-Type': 'application/json' },
431
+ body: JSON.stringify({ stanza: record.reviewStanza, ciphertext: Buffer.from(ciphertext).toString('base64') }),
432
+ });
433
+ } catch { return { note: fileErrors.reviewRetry }; }
434
+ let code;
435
+ try { code = (await response.json())?.error?.code; } catch { /* no JSON body */ }
436
+ // 201: deposited. CONFLICT: a copy is already there (only this agent can
437
+ // deposit one). INVALID_STATE_TRANSITION: no longer pending. A plain 404
438
+ // is an API without review copies, whose approval needs none.
439
+ if (response.status === 201 || REVIEW_DONE.has(code) || (response.status === 404 && code === undefined)) {
440
+ delete record.reviewStanza; // forgotten once the server holds it
441
+ record.reviewed = true;
442
+ await this.vault.write('outgoing', record.fileId, record);
443
+ return { done: true };
444
+ }
445
+ if (REVIEW_FINAL.has(code)) {
446
+ await this.discard(record);
447
+ const summary = publicOutgoing(record);
448
+ if (code === 'FILE_EXPIRED') return { final: { file: summary, state: 'expired', note: fileApiMessages.FILE_EXPIRED } };
449
+ if (code.startsWith('CONVERSATION_')) return { final: { file: summary, state: 'refused', reason: 'conversation_closed', note: fileErrors.notActive('closed') } };
450
+ return { final: { file: summary, state: 'refused', reason: 'review_refused', note: `${fileApiMessages[code]} The file was abandoned; nothing will be deposited.` } };
451
+ }
452
+ return { note: fileErrors.reviewRetry };
453
+ }
454
+
455
+ /**
456
+ * The review stanza (serialized XML) for the owner's devices only, or null
457
+ * when the owner has no device. Encrypted with the per-file key kept in the
458
+ * vault; the plaintext descriptor never leaves this function.
459
+ */
460
+ async sealReview(record) {
461
+ const view = await this.c.conversation(record.conversationId);
462
+ const owner = ownOwnerId(view, this.c.agent.agentId);
463
+ let body;
464
+ try { body = await this.c.runtime.api(this.c.agent, 'GET', `/v1/conversations/${encodeURIComponent(record.conversationId)}/recipients`); }
465
+ catch (error) {
466
+ if (error instanceof ApiError && error.code === 'SECURE_MESSAGING_UNAVAILABLE') return null;
467
+ throw this.apiError(error);
468
+ }
469
+ const jids = ownerReviewJids(body, record.conversationId, owner);
470
+ if (jids.length === 0) return null;
471
+ const { client, provider } = this.c.session;
472
+ let targets;
473
+ try { targets = (await provider.resolveDeviceTargets(client, jids)).filter(t => jids.includes(t.bareJid)); }
474
+ catch { return null; } // an owner device without published OMEMO keys
475
+ if (targets.length === 0) return null;
476
+ let text;
477
+ try {
478
+ text = formatFileReview(fileReviewFor({
479
+ key: unhex(record.key), iv: unhex(record.iv), name: record.name, mediaType: record.mediaType,
480
+ plaintextSize: record.size, plaintextSha256: record.sha256,
481
+ approval: { ciphertextSha256: record.ciphertextSha256, sizeBytes: record.ciphertextSize, mediaType: record.mediaType },
482
+ }, record.fileId));
483
+ } catch (error) { throw cryptoFailure(error); }
484
+ try { return (await provider.encryptForTargets(targets, this.c.room(record.conversationId), text)).toString(); }
485
+ catch { throw new SafeError('Secure messaging is unavailable: the review copy could not be encrypted for your owner. The file stays pending.'); }
486
+ }
487
+
488
+ /** State of a file of this agent (outgoing: advanced as far as allowed; incoming: quarantine state). */
489
+ async status(fileId, { timeoutMs = 0 } = {}) {
490
+ FileVault.id(fileId);
491
+ if (await this.vault.read('outgoing', fileId)) return this.once(fileId, () => this.advance(fileId, timeoutMs));
492
+ const incoming = await this.vault.read('incoming', fileId);
493
+ if (!incoming) throw new SafeError(fileErrors.unknown);
494
+ return this.incomingState(incoming, { timeoutMs, fetch: false });
495
+ }
496
+
497
+ once(fileId, run) {
498
+ const running = this.busy.get(fileId);
499
+ if (running) return running;
500
+ const task = run().finally(() => this.busy.delete(fileId));
501
+ this.busy.set(fileId, task);
502
+ return task;
503
+ }
504
+
505
+ async discard(record) {
506
+ await this.vault.remove('outgoing', record.fileId);
507
+ }
508
+
509
+ /**
510
+ * Outgoing state machine, bounded by `timeoutMs` (never blocks forever):
511
+ * pending -> (owner approves) -> slot + PUT -> descriptor message -> sent.
512
+ * Rejected, expired or a closed conversation discard the local copy and key.
513
+ */
514
+ async advance(fileId, timeoutMs) {
515
+ const deadline = this.now() + timeoutMs;
516
+ for (;;) {
517
+ const record = await this.vault.read('outgoing', fileId);
518
+ if (!record) throw new SafeError(fileErrors.unknown);
519
+ const summary = publicOutgoing(record);
520
+ const view = await this.c.conversation(record.conversationId);
521
+ if (view.state !== 'active') {
522
+ await this.discard(record);
523
+ return { file: summary, state: 'refused', reason: 'conversation_closed', note: fileErrors.notActive(view.state) };
524
+ }
525
+ const dto = (await this.serverFiles(record.conversationId)).find(f => f.id === fileId);
526
+ if (!dto || dto.ciphertextSha256 !== record.ciphertextSha256 || dto.sizeBytes !== record.ciphertextSize) {
527
+ await this.discard(record);
528
+ return { file: summary, state: 'refused', reason: 'unknown_on_server', note: 'The server no longer lists this file. Nothing more will be sent.' };
529
+ }
530
+ if (this.expired(dto)) {
531
+ await this.discard(record);
532
+ return { file: summary, state: 'expired', note: fileApiMessages.FILE_EXPIRED };
533
+ }
534
+ if (dto.status === 'rejected') {
535
+ await this.discard(record);
536
+ return { file: summary, state: 'rejected', note: 'Your owner refused this file in the Wacando app. Nothing was deposited.' };
537
+ }
538
+ if (record.sent) return { file: summary, state: 'sent', server: dto.status, messageId: record.messageId };
539
+ if (dto.status === 'pending') {
540
+ // The owner can approve only after reading the review copy: deposit it first.
541
+ const review = await this.review(record);
542
+ if (review.final) return review.final;
543
+ if (this.now() >= deadline) {
544
+ return { file: summary, state: 'awaiting_approval', reviewCopy: review.done ? 'deposited' : 'not_deposited',
545
+ ...(review.note ? { note: review.note } : {}),
546
+ next: `Ask your owner to approve this file in the Wacando app (fingerprint ${record.ciphertextSha256.slice(0, 16)}..., ${record.ciphertextSize} bytes), then call wacando_file_status. The request expires at ${dto.expiresAt}.` };
547
+ }
548
+ await this.sleep(Math.min(this.pollMs, Math.max(0, deadline - this.now())));
549
+ continue;
550
+ }
551
+ if (dto.status === 'approved') await this.deposit(record);
552
+ return this.announce(record, view);
553
+ }
554
+ }
555
+
556
+ /** XEP-0363 slot for the approved digest, then PUT the ciphertext to the pinned origin only. */
557
+ async deposit(record) {
558
+ const ciphertext = await this.vault.readCiphertext(record.fileId);
559
+ if (!ciphertext) throw storageError();
560
+ const session = this.c.session;
561
+ let slot;
562
+ try {
563
+ slot = await requestUploadSlot(session.client, uploadServiceJid(this.c.domain),
564
+ { filename: record.ciphertextSha256, size: record.ciphertextSize, contentType: 'application/octet-stream' }, 15000);
565
+ } catch { throw new SafeError(fileErrors.slot); }
566
+ checkSlot(slot, this.pin, record.fileId);
567
+ // Recorded BEFORE the PUT: a deposited file gets no second slot.
568
+ record.slotGet = slot.get;
569
+ await this.vault.write('outgoing', record.fileId, record);
570
+ let response;
571
+ try {
572
+ response = await this.fetch(new URL(new URL(slot.put).pathname, this.pin.origin), {
573
+ method: 'PUT', body: ciphertext, redirect: 'error', credentials: 'omit', referrerPolicy: 'no-referrer',
574
+ signal: AbortSignal.timeout(60000),
575
+ headers: { Authorization: `Bearer ${this.c.agent.agentToken}`, 'Content-Type': 'application/octet-stream' },
576
+ });
577
+ } catch { throw new SafeError('The encrypted file could not be deposited. Retry with wacando_file_status.'); }
578
+ let code;
579
+ try { code = (await response.json())?.error?.code; } catch { /* no JSON body */ }
580
+ if (response.status !== 201 && response.status !== 200) {
581
+ throw new SafeError(fileApiMessages[code] ?? `The server refused the deposit (HTTP ${response.status}). Retry with wacando_file_status.`);
582
+ }
583
+ }
584
+
585
+ /** Send the OMEMO descriptor (URL + key), then forget the key and ciphertext. */
586
+ async announce(record, view) {
587
+ if (pinnedUploadId(record.slotGet ?? '', this.pin) !== record.fileId) throw new SafeError(fileErrors.slot);
588
+ const share = fileShareFor({
589
+ key: unhex(record.key), iv: unhex(record.iv), name: record.name, mediaType: record.mediaType,
590
+ plaintextSize: record.size, plaintextSha256: record.sha256,
591
+ approval: { ciphertextSha256: record.ciphertextSha256, sizeBytes: record.ciphertextSize, mediaType: record.mediaType },
592
+ }, record.slotGet);
593
+ const memo = { file: { ...publicOutgoing(record), state: 'sent' } };
594
+ const result = await this.c.deliver(view, formatFileShare(share), memo);
595
+ // Keep only public metadata: the key and the ciphertext are forgotten.
596
+ await this.vault.dropCiphertext(record.fileId);
597
+ const { key: _key, iv: _iv, reviewStanza: _review, ...kept } = record;
598
+ await this.vault.write('outgoing', record.fileId, { ...kept, sent: true, messageId: result.message.id });
599
+ return { file: publicOutgoing(record), state: 'sent', message: result.message, conversation: result.conversation,
600
+ note: result.message.state === 'held' ? "The file is deposited; its message waits for your owner's approval (service mode)." : 'Deposited and shared. The recipient gets it only after its owner authorizes it.' };
601
+ }
602
+
603
+ /**
604
+ * A decrypted message body claiming to be a file share (peer side): parsed
605
+ * strictly against the PINNED origin, then kept in quarantine. Returns the
606
+ * public summary placed in the transcript entry instead of the text.
607
+ */
608
+ async intake(conversationId, messageId, body) {
609
+ let share;
610
+ try { share = parseFileShare(body, this.pin); } catch { return { state: 'refused', reason: 'invalid_descriptor', note: fileErrors.descriptor }; }
611
+ const fileId = shareFileId(share);
612
+ if (!fileId) return { state: 'refused', reason: 'invalid_descriptor', note: fileErrors.descriptor };
613
+ const known = await this.vault.read('incoming', fileId);
614
+ if (known) {
615
+ if (known.conversationId !== conversationId || known.ciphertextSha256 !== share.ciphertextSha256) {
616
+ return { state: 'refused', reason: 'duplicate_descriptor', note: fileErrors.descriptor };
617
+ }
618
+ return { ...publicIncoming(known), state: known.path ? 'received' : 'quarantined' };
619
+ }
620
+ if ((await this.vault.list('incoming')).filter(r => !r.path).length >= MAX_QUARANTINED_INCOMING) {
621
+ return { state: 'refused', reason: 'too_many_quarantined', note: fileErrors.tooManyIncoming };
622
+ }
623
+ const record = { fileId, conversationId, messageId, share, ciphertextSha256: share.ciphertextSha256, receivedAt: new Date(this.now()).toISOString() };
624
+ await this.vault.write('incoming', fileId, record);
625
+ return { ...publicIncoming(record), state: 'quarantined', note: `In quarantine until your owner authorizes it; then call wacando_receive_file. ${UNTRUSTED}` };
626
+ }
627
+
628
+ /** Fetch a released incoming file into the quarantine directory (bounded wait for the release). */
629
+ async receive(fileId, { timeoutMs = 0 } = {}) {
630
+ const record = await this.vault.read('incoming', FileVault.id(fileId));
631
+ if (!record) throw new SafeError(fileErrors.unknown);
632
+ return this.once(fileId, () => this.incomingState(record, { timeoutMs, fetch: true }));
633
+ }
634
+
635
+ async incomingState(record, { timeoutMs, fetch }) {
636
+ const summary = publicIncoming(record);
637
+ if (record.path) return { file: summary, state: 'received', path: record.path, note: `${UNTRUSTED} ${KEPT}` };
638
+ const deadline = this.now() + timeoutMs;
639
+ for (;;) {
640
+ const view = await this.c.conversation(record.conversationId);
641
+ if (view.state !== 'active') {
642
+ // STOP is terminal: this agent can never fetch it; drop the key.
643
+ await this.vault.remove('incoming', record.fileId);
644
+ return { file: summary, state: 'refused', reason: 'conversation_closed', note: fileErrors.notActive(view.state) };
645
+ }
646
+ const dto = (await this.serverFiles(record.conversationId)).find(f => f.id === record.fileId);
647
+ if (!dto || dto.recipientAgentId !== this.c.agent.agentId || dto.ciphertextSha256 !== record.share.ciphertextSha256 ||
648
+ dto.sizeBytes !== record.share.ciphertextSize || dto.mediaType !== record.share.mediaType) {
649
+ await this.vault.remove('incoming', record.fileId);
650
+ return { file: summary, state: 'refused', reason: 'not_approved_file', note: 'The server does not list this file as approved for this agent. It was refused and never fetched.' };
651
+ }
652
+ if (this.expired(dto)) {
653
+ await this.vault.remove('incoming', record.fileId);
654
+ return { file: summary, state: 'expired', note: fileApiMessages.FILE_EXPIRED };
655
+ }
656
+ if (dto.status === 'released' && fetch) return this.fetchReleased(record, dto, summary);
657
+ if (dto.status === 'released') return { file: summary, state: 'released', next: 'Your owner authorized it: call wacando_receive_file to fetch it into the quarantine directory.' };
658
+ if (this.now() >= deadline || !fetch) {
659
+ return { file: summary, state: 'quarantined', server: dto.status,
660
+ next: "The file stays in quarantine until your owner authorizes it in the Wacando app; then call wacando_receive_file." };
661
+ }
662
+ await this.sleep(Math.min(this.pollMs, Math.max(0, deadline - this.now())));
663
+ }
664
+ }
665
+
666
+ async fetchReleased(record, dto, summary) {
667
+ let file;
668
+ try {
669
+ const ciphertext = await downloadFile(record.share, { pin: this.pin, bearer: this.c.agent.agentToken, fetch: this.fetch });
670
+ file = await decryptFile(ciphertext, record.share, { approvedCiphertextSha256: dto.ciphertextSha256 });
671
+ } catch (error) { throw cryptoFailure(error); }
672
+ const path = await this.vault.place(record.fileId, file.name, file.bytes);
673
+ const kept = { fileId: record.fileId, conversationId: record.conversationId, messageId: record.messageId, name: quarantineName(file.name),
674
+ mediaType: file.mediaType, size: file.bytes.length, ciphertextSha256: record.share.ciphertextSha256, path, receivedAt: record.receivedAt };
675
+ // The key is no longer needed once the plaintext is written: forget it.
676
+ await this.vault.write('incoming', record.fileId, kept);
677
+ return { file: { ...publicIncoming(kept) }, state: 'received', path, sha256: record.share.sha256, note: `${UNTRUSTED} ${KEPT}` };
678
+ }
679
+
680
+ /** Local file transfers of this identity (optionally one conversation), no keys; ended transfers are cleaned up first. */
681
+ async list(conversationId) {
682
+ const cleaned = await this.sweep().catch(() => null);
683
+ const keep = r => !conversationId || r.conversationId === conversationId;
684
+ return {
685
+ outgoing: (await this.vault.list('outgoing')).filter(keep).map(r => ({ ...publicOutgoing(r), state: r.sent ? 'sent' : r.slotGet ? 'deposited' : 'awaiting_approval' })),
686
+ incoming: (await this.vault.list('incoming')).filter(keep).map(r => ({ ...publicIncoming(r), state: r.path ? 'received' : 'quarantined', ...(r.path ? { kept: true } : {}) })),
687
+ ...(cleaned && (cleaned.outgoing || cleaned.incoming) ? { cleaned } : {}),
688
+ };
689
+ }
690
+
691
+ /**
692
+ * Delete the local secrets of every transfer that can never continue
693
+ * (lbframe/wacando-agent#21), at connection and before each listing:
694
+ * - pending/deposited sends (ciphertext + key) and quarantined descriptors
695
+ * (key): conversation no longer active (STOP is terminal) or unknown to
696
+ * the server, file unknown, different, rejected or expired on the server,
697
+ * or past its 30-day expiry by the local clock (even offline);
698
+ * - sent records (public metadata only, no key): past their expiry;
699
+ * - interrupted-write leftovers older than an hour.
700
+ * A received file (written to the quarantine directory) and its record are
701
+ * never touched. A server that cannot be reached removes nothing it cannot
702
+ * decide locally. Transfers in progress in this process are skipped.
703
+ * Returns content-free counts.
704
+ */
705
+ sweep() {
706
+ this.sweeping ??= this.sweepOnce().finally(() => { this.sweeping = null; });
707
+ return this.sweeping;
708
+ }
709
+
710
+ async sweepOnce() {
711
+ const now = this.now();
712
+ const counts = { outgoing: 0, incoming: 0 };
713
+ const servers = new Map(); // conversationId -> Promise<{state, files} | null>
714
+ const server = id => {
715
+ if (!servers.has(id)) servers.set(id, this.serverState(id));
716
+ return servers.get(id);
717
+ };
718
+ for (const direction of ['outgoing', 'incoming']) {
719
+ for (const record of await this.vault.list(direction)) {
720
+ if (direction === 'incoming' && record.path) continue; // received: the user's file
721
+ if (this.busy.has(record.fileId)) continue;
722
+ if (!(await this.ended(direction, record, now, server))) continue;
723
+ if (this.busy.has(record.fileId)) continue;
724
+ await this.vault.remove(direction, record.fileId);
725
+ counts[direction]++;
726
+ }
727
+ }
728
+ await this.vault.dropLeftovers(now - STALE_LEFTOVER_MS).catch(() => 0);
729
+ return counts;
730
+ }
731
+
732
+ /** Conversation state and files as the server lists them for this agent; 'gone' when unknown; null when unreachable. */
733
+ async serverState(conversationId) {
734
+ let state;
735
+ try { state = (await this.c.runtime.api(this.c.agent, 'GET', `/v1/conversations/${encodeURIComponent(conversationId)}`))?.conversation?.status; }
736
+ catch (error) { return error instanceof ApiError && error.code === 'NOT_FOUND' ? { state: 'gone' } : null; }
737
+ if (typeof state !== 'string') return null;
738
+ if (state !== 'active') return { state };
739
+ try { return { state, files: await this.serverFiles(conversationId) }; } catch { return null; }
740
+ }
741
+
742
+ async ended(direction, record, now, server) {
743
+ // The server's expiry, else 30 days after the local request or receipt
744
+ // (a file is requested before it is received: never earlier than the server).
745
+ const started = Date.parse(record.receivedAt ?? record.requestedAt ?? '');
746
+ const expiresAt = Date.parse(record.expiresAt ?? '') || started + FILE_RETENTION_MS;
747
+ if (expiresAt <= now) return true;
748
+ if (direction === 'outgoing' && record.sent) return false; // no key left; kept until its expiry
749
+ const known = await server(record.conversationId);
750
+ if (!known) return false;
751
+ if (known.state !== 'active') return true;
752
+ const dto = known.files.find(f => f.id === record.fileId);
753
+ if (!dto || this.expired(dto)) return true;
754
+ if (direction === 'outgoing') return dto.ciphertextSha256 !== record.ciphertextSha256 || dto.sizeBytes !== record.ciphertextSize || dto.status === 'rejected';
755
+ return dto.recipientAgentId !== this.c.agent.agentId || dto.ciphertextSha256 !== record.share?.ciphertextSha256;
756
+ }
757
+ }
758
+
759
+ /** Read a local regular file (no symlink), size-checked BEFORE reading. */
760
+ async function readSource(path) {
761
+ if (typeof path !== 'string' || !path) throw new SafeError(fileErrors.unreadable);
762
+ let file;
763
+ try {
764
+ const stat = await lstat(path);
765
+ if (!stat.isFile()) throw new SafeError(fileErrors.unreadable);
766
+ if (stat.size > MAX_FILE_PLAINTEXT_BYTES) throw new SafeError(fileErrors.tooLarge);
767
+ file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
768
+ const opened = await file.stat();
769
+ if (!opened.isFile()) throw new SafeError(fileErrors.unreadable);
770
+ if (opened.size > MAX_FILE_PLAINTEXT_BYTES) throw new SafeError(fileErrors.tooLarge);
771
+ const bytes = await file.readFile();
772
+ if (bytes.length > MAX_FILE_PLAINTEXT_BYTES) throw new SafeError(fileErrors.tooLarge);
773
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.length);
774
+ } catch (error) {
775
+ throw error instanceof SafeError ? error : new SafeError(fileErrors.unreadable);
776
+ } finally { await file?.close(); }
777
+ }