@majikah/majik-file 0.0.1

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,413 @@
1
+ /**
2
+ * utils.ts
3
+ *
4
+ * Utility functions for MajikFile:
5
+ * - Base64 encode / decode
6
+ * - SHA-256 hashing (synchronous via @stablelib/sha256)
7
+ * - UUID generation
8
+ * - .mjkb binary encode / decode ← updated format supporting single & group
9
+ * - R2 key construction
10
+ * - MIME type helpers
11
+ * - Human-readable file size formatting
12
+ * - Expiry helpers
13
+ */
14
+ import { hash } from "@stablelib/sha256";
15
+ import { MajikFileError } from "./error";
16
+ import { MJKB_MAGIC, MJKB_VERSION, R2_PREFIX, INLINE_VIEWABLE_MIME_TYPES, EXTENSION_TO_MIME, MAX_RECIPIENTS, INCOMPRESSIBLE_MIME_TYPES, WEBP_CONVERTIBLE_IMAGE_TYPES, } from "./crypto/constants";
17
+ // ─── Base64 ───────────────────────────────────────────────────────────────────
18
+ export function arrayToBase64(data) {
19
+ let binary = "";
20
+ const chunkSize = 0x8000;
21
+ for (let i = 0; i < data.length; i += chunkSize) {
22
+ binary += String.fromCharCode(...data.subarray(i, i + chunkSize));
23
+ }
24
+ return btoa(binary);
25
+ }
26
+ export function arrayBufferToBase64(buffer) {
27
+ return arrayToBase64(new Uint8Array(buffer));
28
+ }
29
+ export function base64ToUint8Array(base64) {
30
+ const binary = atob(base64);
31
+ const bytes = new Uint8Array(binary.length);
32
+ for (let i = 0; i < binary.length; i++) {
33
+ bytes[i] = binary.charCodeAt(i);
34
+ }
35
+ return bytes;
36
+ }
37
+ export function base64ToArrayBuffer(base64) {
38
+ return base64ToUint8Array(base64).buffer;
39
+ }
40
+ // ─── SHA-256 ──────────────────────────────────────────────────────────────────
41
+ /**
42
+ * Synchronous SHA-256 digest → lowercase hex string (64 chars).
43
+ * Always computed over the ORIGINAL raw bytes (before compression/encryption)
44
+ * so it can be used reliably for duplicate detection.
45
+ */
46
+ export function sha256Hex(data) {
47
+ const digest = hash(data);
48
+ return Array.from(digest)
49
+ .map((b) => b.toString(16).padStart(2, "0"))
50
+ .join("");
51
+ }
52
+ /**
53
+ * Synchronous SHA-256 digest → base64 string.
54
+ * Used for ML-KEM public key fingerprints.
55
+ */
56
+ export function sha256Base64(data) {
57
+ return arrayToBase64(hash(data));
58
+ }
59
+ // ─── UUID ─────────────────────────────────────────────────────────────────────
60
+ export function generateUUID() {
61
+ return crypto.randomUUID();
62
+ }
63
+ // ─── Human-readable Size ──────────────────────────────────────────────────────
64
+ export function formatBytes(bytes) {
65
+ if (bytes < 0)
66
+ return "0 B";
67
+ if (bytes < 1024)
68
+ return `${bytes} B`;
69
+ if (bytes < 1024 ** 2)
70
+ return `${(bytes / 1024).toFixed(1)} KB`;
71
+ if (bytes < 1024 ** 3)
72
+ return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
73
+ return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
74
+ }
75
+ // ─── R2 Key Construction ──────────────────────────────────────────────────────
76
+ /**
77
+ * Build an R2 object key for a permanent (user-owned) file.
78
+ * files/user/<userId>/<fileHash>.mjkb
79
+ */
80
+ export function buildPermanentR2Key(userId, fileHash) {
81
+ return `${R2_PREFIX.PERMANENT}/${userId}/${fileHash}.mjkb`;
82
+ }
83
+ /**
84
+ * Build an R2 object key for a temporary / public file.
85
+ * Objects under this prefix are auto-deleted by the bucket lifecycle policy.
86
+ * files/public/<userId>_<fileHash>.mjkb
87
+ */
88
+ export function buildTemporaryR2Key(userId, fileHash) {
89
+ return `${R2_PREFIX.TEMPORARY}/${userId}_${fileHash}.mjkb`;
90
+ }
91
+ /**
92
+ * Build an R2 object key for an encrypted WebP chat image.
93
+ *
94
+ * Scoped per conversation so all images belonging to a conversation can be
95
+ * listed or batch-deleted via a single R2 prefix scan.
96
+ *
97
+ * images/chats/<conversationId>/<userId>_<fileHash>.mjkb
98
+ *
99
+ * @param conversationId The chat conversation / channel ID (UUID or slug).
100
+ * @param userId The uploading user's auth sub (UUID).
101
+ * @param fileHash SHA-256 hex digest of the ORIGINAL image bytes
102
+ * (pre-WebP-conversion, pre-compression) — same value
103
+ * stored in majik_files.file_hash for dedup queries.
104
+ */
105
+ export function buildChatImageR2Key(conversationId, userId, fileHash) {
106
+ return `${R2_PREFIX.CHAT_IMAGE}/${conversationId}/${userId}_${fileHash}.mjkb`;
107
+ }
108
+ // ─── MIME Type Helpers ────────────────────────────────────────────────────────
109
+ /**
110
+ * Returns true if the MIME type can be rendered inline in a browser.
111
+ */
112
+ export function isMimeTypeInlineViewable(mimeType) {
113
+ if (!mimeType)
114
+ return false;
115
+ return INLINE_VIEWABLE_MIME_TYPES.has(mimeType.toLowerCase().split(";")[0].trim());
116
+ }
117
+ /**
118
+ * Infer a MIME type from a filename extension.
119
+ * Returns null if the extension is unknown.
120
+ *
121
+ * @example
122
+ * inferMimeTypeFromFilename("photo.jpg") // "image/jpeg"
123
+ * inferMimeTypeFromFilename("archive.rar") // "application/x-rar-compressed"
124
+ * inferMimeTypeFromFilename("unknown.xyz") // null
125
+ */
126
+ export function inferMimeTypeFromFilename(filename) {
127
+ if (!filename)
128
+ return null;
129
+ const dot = filename.lastIndexOf(".");
130
+ if (dot === -1)
131
+ return null;
132
+ const ext = filename.slice(dot + 1).toLowerCase();
133
+ return EXTENSION_TO_MIME[ext] ?? null;
134
+ }
135
+ /**
136
+ * Derive a safe download filename from the file hash + original extension.
137
+ * Falls back to "<hash>.mjkb" if originalName is null or has no extension.
138
+ */
139
+ export function deriveFilename(fileHash, originalName) {
140
+ if (!originalName)
141
+ return `${fileHash}.mjkb`;
142
+ const dot = originalName.lastIndexOf(".");
143
+ const ext = dot !== -1 ? originalName.slice(dot).toLowerCase() : "";
144
+ const safeExt = /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : "";
145
+ return `${fileHash}${safeExt || ".mjkb"}`;
146
+ }
147
+ // ─── Normalise Input to Uint8Array ────────────────────────────────────────────
148
+ export function normaliseToUint8Array(data) {
149
+ if (data instanceof Uint8Array)
150
+ return data;
151
+ return new Uint8Array(data);
152
+ }
153
+ export async function normaliseToUint8ArrayAsync(data) {
154
+ if (data instanceof Blob)
155
+ return new Uint8Array(await data.arrayBuffer());
156
+ return normaliseToUint8Array(data);
157
+ }
158
+ // ─── .mjkb Binary Codec ───────────────────────────────────────────────────────
159
+ //
160
+ // The format supports both single-recipient and group-recipient files by
161
+ // embedding a variable-length JSON payload section (instead of a fixed
162
+ // ML-KEM ciphertext field) after the IV.
163
+ //
164
+ // ┌───────────────────────────────────────────────────────────────────────────┐
165
+ // │ 4 bytes │ Magic: ASCII "MJKB" (0x4D 0x4A 0x4B 0x42) │
166
+ // │ 1 byte │ Version (currently 0x01) │
167
+ // │ 12 bytes │ AES-GCM IV │
168
+ // │ 4 bytes │ Payload JSON length (big-endian uint32) │
169
+ // │ N bytes │ Payload JSON (UTF-8; MjkbSinglePayload | MjkbGroupPayload) │
170
+ // │ M bytes │ AES-GCM ciphertext (Zstd-compressed plaintext + 16-byte tag)│
171
+ // └───────────────────────────────────────────────────────────────────────────┘
172
+ //
173
+ // Single payload JSON:
174
+ // { "mlKemCipherText": "<base64 1088 bytes>" }
175
+ //
176
+ // Group payload JSON:
177
+ // { "keys": [{ "fingerprint": "...", "mlKemCipherText": "...", "encryptedAesKey": "..." }, ...] }
178
+ //
179
+ // Fixed header size (before payload JSON): 4 + 1 + 12 + 4 = 21 bytes
180
+ const MJKB_FIXED_HEADER = 4 + 1 + 12 + 4; // 21 bytes
181
+ /**
182
+ * Encode a .mjkb binary from its constituent parts.
183
+ *
184
+ * @param iv 12-byte AES-GCM IV.
185
+ * @param payload Serialised MjkbSinglePayload or MjkbGroupPayload.
186
+ * @param ciphertext AES-GCM ciphertext (Zstd-compressed plaintext + 16-byte auth tag).
187
+ */
188
+ export function encodeMjkb(iv, payload, ciphertext) {
189
+ const payloadBytes = new TextEncoder().encode(JSON.stringify(payload));
190
+ const payloadLen = payloadBytes.length;
191
+ const total = MJKB_FIXED_HEADER + payloadLen + ciphertext.length;
192
+ const buf = new Uint8Array(total);
193
+ let offset = 0;
194
+ // Magic
195
+ buf.set(MJKB_MAGIC, offset);
196
+ offset += 4;
197
+ // Version
198
+ buf[offset++] = MJKB_VERSION;
199
+ // IV (12 bytes)
200
+ buf.set(iv, offset);
201
+ offset += 12;
202
+ // Payload JSON length (big-endian uint32)
203
+ buf[offset++] = (payloadLen >>> 24) & 0xff;
204
+ buf[offset++] = (payloadLen >>> 16) & 0xff;
205
+ buf[offset++] = (payloadLen >>> 8) & 0xff;
206
+ buf[offset++] = payloadLen & 0xff;
207
+ // Payload JSON
208
+ buf.set(payloadBytes, offset);
209
+ offset += payloadLen;
210
+ // Ciphertext
211
+ buf.set(ciphertext, offset);
212
+ return buf;
213
+ }
214
+ /**
215
+ * Decode a raw .mjkb buffer into its constituent parts.
216
+ *
217
+ * @throws MajikFileError on magic mismatch, unsupported version, truncation,
218
+ * or malformed payload JSON.
219
+ */
220
+ export function decodeMjkb(raw) {
221
+ // Minimum: fixed header + 1 byte payload JSON + 1 byte ciphertext
222
+ if (raw.length < MJKB_FIXED_HEADER + 2) {
223
+ throw MajikFileError.formatError(`.mjkb binary is too short (${raw.length} bytes) — minimum is ${MJKB_FIXED_HEADER + 2} bytes`);
224
+ }
225
+ // Magic check
226
+ for (let i = 0; i < 4; i++) {
227
+ if (raw[i] !== MJKB_MAGIC[i]) {
228
+ throw MajikFileError.formatError("Invalid .mjkb magic bytes — this is not a MajikFile binary");
229
+ }
230
+ }
231
+ let offset = 4;
232
+ const version = raw[offset++];
233
+ if (version !== MJKB_VERSION) {
234
+ throw MajikFileError.unsupportedVersion(version, MJKB_VERSION);
235
+ }
236
+ // IV (12 bytes)
237
+ const iv = raw.slice(offset, offset + 12);
238
+ offset += 12;
239
+ // Payload JSON length (big-endian uint32)
240
+ const payloadLen = (raw[offset] << 24) |
241
+ (raw[offset + 1] << 16) |
242
+ (raw[offset + 2] << 8) |
243
+ raw[offset + 3];
244
+ offset += 4;
245
+ if (payloadLen <= 0 || raw.length < offset + payloadLen + 1) {
246
+ throw MajikFileError.formatError(`.mjkb binary is truncated — payload JSON declares ${payloadLen} bytes but insufficient data remains`);
247
+ }
248
+ // Payload JSON
249
+ let payload;
250
+ try {
251
+ payload = JSON.parse(new TextDecoder().decode(raw.slice(offset, offset + payloadLen)));
252
+ }
253
+ catch {
254
+ throw MajikFileError.formatError(".mjkb payload JSON is malformed and could not be parsed");
255
+ }
256
+ offset += payloadLen;
257
+ // Ciphertext (remainder)
258
+ const ciphertext = raw.slice(offset);
259
+ if (ciphertext.length === 0) {
260
+ throw MajikFileError.formatError(".mjkb ciphertext section is empty");
261
+ }
262
+ return { version, iv, payload, ciphertext };
263
+ }
264
+ // ─── Expiry Helpers ───────────────────────────────────────────────────────────
265
+ /** Returns true if the given ISO-8601 timestamp is in the past. */
266
+ export function isExpired(expiresAt) {
267
+ if (!expiresAt)
268
+ return false;
269
+ return new Date(expiresAt).getTime() < Date.now();
270
+ }
271
+ /**
272
+ * Build a default expiry ISO string for temporary files.
273
+ * @param days Days from now. Defaults to 15 (matching the R2 lifecycle policy).
274
+ */
275
+ export function buildExpiryDate(days = 15) {
276
+ const d = new Date();
277
+ d.setDate(d.getDate() + days);
278
+ return d.toISOString();
279
+ }
280
+ // ─── Module-level helpers ─────────────────────────────────────────────────────
281
+ /**
282
+ * Deduplicate a recipient list and strip the owner's own key.
283
+ *
284
+ * Rules:
285
+ * - The owner's fingerprint is never allowed in `recipients` — it is
286
+ * always prepended automatically in the group path. If present, it is
287
+ * silently removed rather than throwing.
288
+ * - Any fingerprint that appears more than once is deduplicated; the first
289
+ * occurrence wins.
290
+ * - Deduplication is by fingerprint string, not by raw public key bytes.
291
+ *
292
+ * Returns the cleaned list. If the result is empty the caller should use
293
+ * the single-recipient path.
294
+ */
295
+ export function deduplicateRecipients(recipients, ownerFingerprint) {
296
+ const seen = new Set([ownerFingerprint]);
297
+ const result = [];
298
+ for (const r of recipients) {
299
+ if (seen.has(r.fingerprint))
300
+ continue; // owner duplicate or repeated entry
301
+ seen.add(r.fingerprint);
302
+ result.push(r);
303
+ }
304
+ return result;
305
+ }
306
+ /**
307
+ * Assert that the recipient count (after deduplication) does not exceed
308
+ * MAX_RECIPIENTS (100). Throws a MajikFileError if the limit is exceeded.
309
+ *
310
+ * Does NOT include the owner in the count — `recipients` here is the
311
+ * already-deduplicated list of *additional* recipients beyond the owner.
312
+ *
313
+ * @throws MajikFileError("INVALID_INPUT") if count > MAX_RECIPIENTS.
314
+ */
315
+ export function assertRecipientLimit(recipients) {
316
+ if (recipients.length > MAX_RECIPIENTS) {
317
+ throw MajikFileError.invalidInput(`Too many recipients: ${recipients.length} (maximum is ${MAX_RECIPIENTS} excluding the owner). ` +
318
+ `Consider splitting into multiple files or threads.`);
319
+ }
320
+ }
321
+ /**
322
+ * Decide whether raw bytes should be Zstd-compressed before encryption.
323
+ *
324
+ * Returns false for MIME types that are already compressed at the codec level
325
+ * (JPEG, WebP, AVIF, all video, lossy audio, archives, zipped Office formats).
326
+ * Returns true for everything else — text, code, raw images (PNG, BMP),
327
+ * lossless audio (WAV, FLAC, AIFF), PDFs, JSON, XML, etc.
328
+ *
329
+ * @param mimeType The resolved MIME type of the file, or null if unknown.
330
+ * When null, compression is applied (safer default).
331
+ */
332
+ export function shouldCompress(mimeType) {
333
+ if (!mimeType)
334
+ return true;
335
+ const normalised = mimeType.toLowerCase().split(";")[0].trim();
336
+ return !INCOMPRESSIBLE_MIME_TYPES.has(normalised);
337
+ }
338
+ /**
339
+ * Convert an image Uint8Array to WebP format using the browser's Canvas API.
340
+ *
341
+ * Used exclusively in the `chat_attachment` context to normalise all
342
+ * non-WebP images (PNG, JPEG, GIF, BMP, etc.) to WebP before encryption.
343
+ * This reduces payload size for PNG and BMP in particular, and ensures a
344
+ * consistent delivery format to chat clients.
345
+ *
346
+ * SVG files are returned unchanged — they are vector and cannot be meaningfully
347
+ * rasterised without knowing the intended display dimensions.
348
+ *
349
+ * HEIC/HEIF/JXL files are returned unchanged — browsers do not support
350
+ * encoding these via Canvas.
351
+ *
352
+ * @param imageBytes Raw bytes of the source image.
353
+ * @param mimeType Resolved MIME type of the source image.
354
+ * @param quality WebP encoding quality 0–1. Defaults to 0.88 (a good
355
+ * balance between visual quality and file size).
356
+ * @returns WebP bytes, or the original bytes if conversion is not
357
+ * applicable for this MIME type.
358
+ */
359
+ export async function convertImageToWebP(imageBytes, mimeType, quality = 0.88) {
360
+ const normalised = mimeType.toLowerCase().split(";")[0].trim();
361
+ // Already WebP, or not a type we can convert via Canvas
362
+ if (!WEBP_CONVERTIBLE_IMAGE_TYPES.has(normalised)) {
363
+ return { bytes: imageBytes, mimeType };
364
+ }
365
+ // Canvas API is only available in browser environments
366
+ if (typeof document === "undefined" ||
367
+ typeof HTMLCanvasElement === "undefined") {
368
+ // Server-side / non-browser environment — return as-is
369
+ return { bytes: imageBytes, mimeType };
370
+ }
371
+ return new Promise((resolve) => {
372
+ const blob = new Blob([imageBytes], { type: mimeType });
373
+ const url = URL.createObjectURL(blob);
374
+ const img = new Image();
375
+ img.onload = () => {
376
+ URL.revokeObjectURL(url);
377
+ try {
378
+ const canvas = document.createElement("canvas");
379
+ canvas.width = img.naturalWidth;
380
+ canvas.height = img.naturalHeight;
381
+ const ctx = canvas.getContext("2d");
382
+ if (!ctx) {
383
+ // Canvas context unavailable — fall back to original
384
+ resolve({ bytes: imageBytes, mimeType });
385
+ return;
386
+ }
387
+ ctx.drawImage(img, 0, 0);
388
+ canvas.toBlob((webpBlob) => {
389
+ if (!webpBlob) {
390
+ // Browser declined to encode WebP — fall back to original
391
+ resolve({ bytes: imageBytes, mimeType });
392
+ return;
393
+ }
394
+ webpBlob
395
+ .arrayBuffer()
396
+ .then((buf) => {
397
+ resolve({ bytes: new Uint8Array(buf), mimeType: "image/webp" });
398
+ })
399
+ .catch(() => resolve({ bytes: imageBytes, mimeType }));
400
+ }, "image/webp", quality);
401
+ }
402
+ catch {
403
+ resolve({ bytes: imageBytes, mimeType });
404
+ }
405
+ };
406
+ img.onerror = () => {
407
+ URL.revokeObjectURL(url);
408
+ // Not a renderable image — keep original
409
+ resolve({ bytes: imageBytes, mimeType });
410
+ };
411
+ img.src = url;
412
+ });
413
+ }
@@ -0,0 +1,280 @@
1
+ import type { MajikFileJSON, CreateOptions, MajikFileIdentity, MajikFileStats, FileContext, StorageType } from "./core/types";
2
+ /**
3
+ * MajikFile
4
+ * ----------------
5
+ * Post-quantum binary file encryption for Majik Message.
6
+ *
7
+ * Mirrors MajikEnvelope's single/group encryption model, but operates on raw
8
+ * binary blobs rather than plaintext strings.
9
+ *
10
+ * Single-recipient ────────────────────────────────────────────────────────
11
+ * ----------------
12
+ * ML-KEM encapsulate → 32-byte sharedSecret used directly as AES-256-GCM key.
13
+ * Payload JSON: { mlKemCipherText }
14
+ *
15
+ * Group (2+ recipients) ───────────────────────────────────────────────────
16
+ * ----------------
17
+ * Generate random 32-byte AES key → encrypt file once.
18
+ * Per recipient: ML-KEM encapsulate → encryptedAesKey = aesKey XOR sharedSecret.
19
+ * Payload JSON: { keys: [{ fingerprint, mlKemCipherText, encryptedAesKey }] }
20
+ *
21
+ * The owner is always included as the first recipient automatically.
22
+ * Duplicate recipients are silently removed; if the deduplicated list is empty
23
+ * after stripping the owner's own key, single-recipient mode is used.
24
+ *
25
+ * ─── Immutability ────────────────────────────────────────────────────────────
26
+ * MajikFile binaries are write-once. A file cannot be patched or replaced
27
+ * in place — callers must delete the existing record + R2 object and call
28
+ * create() again. This is enforced by the absence of any update/patch method
29
+ * on the encrypted fields.
30
+ *
31
+ * ─── Encrypt pipeline ────────────────────────────────────────────────────────
32
+ * raw bytes
33
+ * → SHA-256 hash (for dedup, computed pre-compression)
34
+ * → image/webp convert (chat_image always; chat_attachment for images only)
35
+ * → Zstd compress (compressible formats only; skipped for already-
36
+ * compressed images JPEG/WebP/AVIF and video/audio/archives)
37
+ * → [single] ML-KEM encapsulate → sharedSecret = AES key
38
+ * [group] random AES key; per recipient: ML-KEM encapsulate → XOR wrap
39
+ * → AES-256-GCM encrypt
40
+ * → .mjkb binary (stored in R2)
41
+ *
42
+ * ─── .mjkb binary format ─────────────────────────────────────────────────────
43
+ * [4 magic "MJKB"]
44
+ * [1 version]
45
+ * [12 AES-GCM IV]
46
+ * [4 payload JSON length (big-endian uint32)]
47
+ * [N payload JSON — MjkbSinglePayload | MjkbGroupPayload]
48
+ * [M AES-GCM ciphertext (Zstd-compressed file + 16-byte auth tag)]
49
+ */
50
+ export declare class MajikFile {
51
+ private readonly _id;
52
+ private readonly _userId;
53
+ private readonly _r2Key;
54
+ private readonly _originalName;
55
+ private readonly _mimeType;
56
+ private readonly _sizeOriginal;
57
+ private readonly _sizeStored;
58
+ private readonly _fileHash;
59
+ private readonly _encryptionIv;
60
+ private readonly _storageType;
61
+ private _isShared;
62
+ private _shareToken;
63
+ private readonly _context;
64
+ private readonly _chatMessageId;
65
+ private readonly _threadMessageId;
66
+ private readonly _conversationId;
67
+ private readonly _expiresAt;
68
+ private readonly _timestamp;
69
+ private _lastUpdate;
70
+ private readonly _isGroup;
71
+ /**
72
+ * Encrypted .mjkb binary.
73
+ * NOT serialised to JSON / Supabase — lives in R2 storage only.
74
+ */
75
+ private _binary;
76
+ private constructor();
77
+ get id(): string;
78
+ get userId(): string;
79
+ get r2Key(): string;
80
+ get originalName(): string | null;
81
+ get mimeType(): string | null;
82
+ get sizeOriginal(): number;
83
+ get sizeStored(): number;
84
+ get fileHash(): string;
85
+ /** Original file size in kilobytes (3 decimal places). */
86
+ get sizeKB(): number;
87
+ /** Original file size in megabytes (3 decimal places). */
88
+ get sizeMB(): number;
89
+ /** Original file size in gigabytes (3 decimal places). */
90
+ get sizeGB(): number;
91
+ /** Original file size in terabytes (3 decimal places). */
92
+ get sizeTB(): number;
93
+ get encryptionIv(): string;
94
+ get storageType(): StorageType;
95
+ get isShared(): boolean;
96
+ get shareToken(): string | null;
97
+ get context(): FileContext | null;
98
+ get chatMessageId(): string | null;
99
+ get threadMessageId(): string | null;
100
+ /** Conversation ID — only populated for chat_image context files. */
101
+ get conversationId(): string | null;
102
+ get expiresAt(): string | null;
103
+ get timestamp(): string | null;
104
+ get lastUpdate(): string | null;
105
+ /** True if the encrypted .mjkb binary is loaded in memory. */
106
+ get hasBinary(): boolean;
107
+ /** True if this file was encrypted for multiple recipients. */
108
+ get isGroup(): boolean;
109
+ /** True if this file was encrypted for a single recipient (the owner). */
110
+ get isSingle(): boolean;
111
+ /**
112
+ * Encrypt a raw binary file and produce a MajikFile instance.
113
+ *
114
+ * Single-recipient (no `recipients` supplied or empty array):
115
+ * ML-KEM encapsulate → sharedSecret → AES-256-GCM key.
116
+ *
117
+ * Group (one or more entries in `recipients`):
118
+ * Random 32-byte AES key encrypts the file once.
119
+ * The owner + every recipient each get their own ML-KEM key entry.
120
+ * encryptedAesKey = aesKey XOR sharedSecret (safe one-time-pad).
121
+ *
122
+ * Steps:
123
+ * 1. Validate inputs and enforce size limit
124
+ * 2. Infer MIME type from filename if not provided
125
+ * 3. Compute SHA-256 file_hash (original bytes, pre-compression)
126
+ * 4. Zstd compress at level 22
127
+ * 5. Encrypt (single or group path)
128
+ * 6. Encode to .mjkb binary → store in _binary
129
+ * 7. Build metadata + validate
130
+ *
131
+ * @throws MajikFileError on validation or crypto failure
132
+ */
133
+ static create(options: CreateOptions): Promise<MajikFile>;
134
+ /**
135
+ * Decrypt a .mjkb Blob, Uint8Array, or ArrayBuffer.
136
+ *
137
+ * Single:
138
+ * Decapsulate → sharedSecret → AES-256-GCM key → decompress → raw bytes.
139
+ *
140
+ * Group:
141
+ * Find key entry by `identity.fingerprint` → decapsulate → XOR to recover
142
+ * group AES key → AES-256-GCM decrypt → decompress → raw bytes.
143
+ *
144
+ * Note: ML-KEM decapsulation NEVER throws on a wrong key — it returns a garbage
145
+ * shared secret. AES-GCM authentication catches this silently (returns null).
146
+ *
147
+ * @throws MajikFileError on wrong key, missing key entry, corrupt data, or format errors.
148
+ */
149
+ static decrypt(source: Blob | Uint8Array | ArrayBuffer, identity: Pick<MajikFileIdentity, "fingerprint" | "mlKemSecretKey">): Promise<Uint8Array>;
150
+ /**
151
+ * Decrypt the .mjkb binary already loaded on this instance.
152
+ * Convenience wrapper around MajikFile.decrypt() — avoids re-fetching from R2.
153
+ *
154
+ * @throws MajikFileError if _binary is not loaded or decryption fails.
155
+ */
156
+ decryptBinary(identity: Pick<MajikFileIdentity, "fingerprint" | "mlKemSecretKey">): Promise<Uint8Array>;
157
+ /**
158
+ * Serialise metadata to a plain object matching the `majik_files` Supabase table.
159
+ * The encrypted binary (_binary) is intentionally excluded.
160
+ */
161
+ toJSON(): MajikFileJSON;
162
+ /**
163
+ * Restore a MajikFile from its serialised JSON representation.
164
+ *
165
+ * The R2 prefix check is intentionally NOT performed here — rows restored
166
+ * from Supabase may have been written by earlier code or migrations and
167
+ * should not be rejected at read time.
168
+ *
169
+ * @param json MajikFileJSON — typically a Supabase row.
170
+ * @param binary Optional encrypted .mjkb bytes. When provided the instance is
171
+ * immediately ready for toMJKB() / decryptBinary().
172
+ */
173
+ static fromJSON(json: MajikFileJSON, binary?: Uint8Array | ArrayBuffer | null): MajikFile;
174
+ /**
175
+ * Async variant of fromJSON that accepts a Blob for the binary parameter.
176
+ */
177
+ static fromJSONWithBlob(json: MajikFileJSON, binary: Blob): Promise<MajikFile>;
178
+ /**
179
+ * Export the encrypted binary as a .mjkb Blob for upload to R2.
180
+ * @throws MajikFileError if _binary is not loaded.
181
+ */
182
+ toMJKB(): Blob;
183
+ /**
184
+ * Export the encrypted binary as a raw Uint8Array.
185
+ * @throws MajikFileError if _binary is not loaded.
186
+ */
187
+ toBinaryBytes(): Uint8Array;
188
+ /**
189
+ * Validate all required properties against business invariants.
190
+ * Collects ALL errors before throwing so the full list is visible at once.
191
+ *
192
+ * NOTE: R2 prefix structure is only checked during create(), not here.
193
+ * This keeps fromJSON() tolerant of rows written by other services.
194
+ *
195
+ * @throws MajikFileError
196
+ */
197
+ validate(): void;
198
+ /**
199
+ * Stricter validation used only during create() — includes R2 prefix checks.
200
+ */
201
+ private _validateCreate;
202
+ /** Returns true if the given userId matches the file's owner. */
203
+ userIsOwner(userId: string): boolean;
204
+ /**
205
+ * Attach (or replace) the encrypted .mjkb binary on this instance.
206
+ * Also updates the isGroup flag by peeking at the payload type.
207
+ */
208
+ attachBinary(binary: Uint8Array | ArrayBuffer): void;
209
+ /**
210
+ * Clear the in-memory binary to free memory after an upload completes.
211
+ */
212
+ clearBinary(): void;
213
+ /** Returns true if this file has an active share token. */
214
+ get hasShareToken(): boolean;
215
+ /**
216
+ * Toggle the shareable state of this file.
217
+ *
218
+ * - If currently NOT shared → sets isShared = true, assigns token (auto-generated if omitted).
219
+ * - If currently shared → sets isShared = false, clears token.
220
+ *
221
+ * Updates last_update automatically. Call toJSON() to persist the change.
222
+ *
223
+ * @param token Optional explicit token. Ignored when toggling OFF.
224
+ * @returns The active share token, or null if sharing was disabled.
225
+ */
226
+ toggleSharing(token?: string): string | null;
227
+ /** Returns true if this file has passed its expiry date. */
228
+ get isExpired(): boolean;
229
+ /** Returns true if this file uses temporary storage. */
230
+ get isTemporary(): boolean;
231
+ /** Returns true if the MIME type can be rendered inline in a browser. */
232
+ get isInlineViewable(): boolean;
233
+ /** Safe download filename derived from the hash + original extension. */
234
+ get safeFilename(): string;
235
+ /**
236
+ * Returns true if the original file size exceeds the given limit.
237
+ * @param limitMB Limit in megabytes (must be positive and finite).
238
+ * @throws MajikFileError on invalid input.
239
+ */
240
+ exceedsSize(limitMB: number): boolean;
241
+ /**
242
+ * Lightweight fingerprint check — returns true if the given public key
243
+ * hashes (SHA-256 base64) to the supplied ownerFingerprint.
244
+ *
245
+ * This does NOT attempt decryption. For cryptographic proof use decrypt().
246
+ *
247
+ * @param publicKey ML-KEM-768 public key (1184 bytes).
248
+ * @param ownerFingerprint Base64 SHA-256 fingerprint of the authorised key.
249
+ */
250
+ static hasPublicKeyAccess(publicKey: Uint8Array, ownerFingerprint: string): boolean;
251
+ /** Return a human-readable stats snapshot for display in a file manager UI. */
252
+ getStats(): MajikFileStats;
253
+ /**
254
+ * Returns true if this file has the same plaintext content as another
255
+ * MajikFile (comparison by SHA-256 file_hash of original bytes).
256
+ */
257
+ isDuplicateOf(other: MajikFile): boolean;
258
+ /**
259
+ * Synchronous check — returns true if raw bytes would produce a duplicate.
260
+ * Use this to short-circuit the encrypt + upload flow.
261
+ */
262
+ static wouldBeDuplicate(rawBytes: Uint8Array, existingHash: string): boolean;
263
+ /**
264
+ * Quick magic-byte check. Does NOT fully parse — use before attempting decryption.
265
+ */
266
+ static isMjkbCandidate(data: Uint8Array | ArrayBuffer): boolean;
267
+ /**
268
+ * Build a default ISO-8601 expiry date for temporary files.
269
+ * @param days Days from now. Defaults to 15 (R2 lifecycle policy).
270
+ */
271
+ static buildExpiryDate(days?: number): string;
272
+ /** Format bytes as a human-readable string (e.g. "4.2 MB"). */
273
+ static formatBytes(bytes: number): string;
274
+ /**
275
+ * Infer a MIME type from a filename extension.
276
+ * Exposed here for convenience — delegates to core/utils.
277
+ */
278
+ static inferMimeType(filename: string): string | null;
279
+ toString(): string;
280
+ }