cursedbelt-server 3.0.0 → 4.0.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.
Files changed (81) hide show
  1. package/dist/server/activity/index.d.ts +1 -1
  2. package/dist/server/activity/index.js +1 -1
  3. package/dist/server/auth/jwt.d.ts +1 -1
  4. package/dist/server/auth/jwt.js +2 -2
  5. package/dist/server/master-lock/accountsPage.d.ts +39 -0
  6. package/dist/server/master-lock/accountsPage.js +455 -0
  7. package/dist/server/master-lock/guard.js +57 -1
  8. package/dist/server/master-lock/index.d.ts +10 -2
  9. package/dist/server/master-lock/index.js +10 -2
  10. package/dist/server/master-lock/lockPage.d.ts +9 -0
  11. package/dist/server/master-lock/lockPage.js +44 -36
  12. package/dist/server/media-bun/streaming.d.ts +0 -1
  13. package/dist/server/media-bun/streaming.js +1 -1
  14. package/dist/server/storage/files/catalogue.d.ts +2 -2
  15. package/dist/server/storage/files/catalogue.js +18 -208
  16. package/dist/server/storage/files/fileRoutes.d.ts +2 -20
  17. package/dist/server/storage/files/fileRoutes.js +1 -74
  18. package/dist/server/storage/files/index.d.ts +1 -1
  19. package/dist/server/storage/files/index.js +1 -1
  20. package/dist/server/storage/files/migrations.js +9 -6
  21. package/dist/server/storage/files/stack.d.ts +6 -31
  22. package/dist/server/storage/files/stack.js +0 -27
  23. package/dist/server/storage/files/types.d.ts +10 -60
  24. package/dist/server/storage/files/types.js +9 -0
  25. package/dist/server/storage/index.d.ts +2 -2
  26. package/dist/server/storage/index.js +1 -1
  27. package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
  28. package/dist/server/storage/types.d.ts +0 -9
  29. package/dist/server/storage/types.js +2 -9
  30. package/package.json +2 -2
  31. package/src/leafSubpathsImportNothing.spec.ts +6 -4
  32. package/src/server/activity/index.ts +1 -1
  33. package/src/server/auth/jwt.ts +2 -2
  34. package/src/server/master-lock/accountsPage.spec.ts +231 -0
  35. package/src/server/master-lock/accountsPage.ts +465 -0
  36. package/src/server/master-lock/guard.spec.ts +25 -0
  37. package/src/server/master-lock/guard.ts +65 -1
  38. package/src/server/master-lock/index.ts +22 -2
  39. package/src/server/master-lock/lockPage.spec.ts +20 -16
  40. package/src/server/master-lock/lockPage.ts +45 -36
  41. package/src/server/media-bun/streaming.ts +0 -6
  42. package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
  43. package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
  44. package/src/server/storage/files/adapter.photo.spec.ts +2 -2
  45. package/src/server/storage/files/adapter.spec.ts +0 -1
  46. package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
  47. package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
  48. package/src/server/storage/files/catalogue.ts +19 -240
  49. package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
  50. package/src/server/storage/files/fileRoutes.spec.ts +3 -97
  51. package/src/server/storage/files/fileRoutes.ts +2 -96
  52. package/src/server/storage/files/index.ts +1 -4
  53. package/src/server/storage/files/migrations.ts +9 -7
  54. package/src/server/storage/files/stack.spec.ts +11 -111
  55. package/src/server/storage/files/stack.ts +9 -61
  56. package/src/server/storage/files/types.ts +20 -68
  57. package/src/server/storage/index.ts +0 -4
  58. package/src/server/storage/mountMediaRoutes.ts +1 -7
  59. package/src/server/storage/types.ts +2 -11
  60. package/src/shippedFilesAreTracked.spec.ts +3 -2
  61. package/dist/server/private-media/crypto.d.ts +0 -37
  62. package/dist/server/private-media/crypto.js +0 -111
  63. package/dist/server/private-media/index-store.d.ts +0 -31
  64. package/dist/server/private-media/index-store.js +0 -36
  65. package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
  66. package/dist/server/private-media/mountPrivateRoutes.js +0 -219
  67. package/dist/server/private-media/pipeline.d.ts +0 -37
  68. package/dist/server/private-media/pipeline.js +0 -97
  69. package/dist/server/private-media/unlock.d.ts +0 -52
  70. package/dist/server/private-media/unlock.js +0 -55
  71. package/src/server/private-media/blobRoute.spec.ts +0 -165
  72. package/src/server/private-media/crypto.spec.ts +0 -81
  73. package/src/server/private-media/crypto.ts +0 -155
  74. package/src/server/private-media/gatePolicy.spec.ts +0 -98
  75. package/src/server/private-media/index-store.ts +0 -83
  76. package/src/server/private-media/mountPrivateRoutes.ts +0 -265
  77. package/src/server/private-media/pipeline.spec.ts +0 -111
  78. package/src/server/private-media/pipeline.ts +0 -170
  79. package/src/server/private-media/routes.spec.ts +0 -265
  80. package/src/server/private-media/unlock.ts +0 -112
  81. package/src/server/storage/files/catalogue.private.spec.ts +0 -106
@@ -13,7 +13,8 @@
13
13
  */
14
14
  import { describe, expect, test } from "bun:test";
15
15
  import { MASTER_LOCK_PATHS, type MasterLockKdfParams, deriveMasterLockVerifier } from "cursedbelt-core/master-lock";
16
- import { LOCK_SCRIPT, LOCK_STYLE, lockPageHtml } from "./lockPage";
16
+ import { MASTER_LOCK_ACCOUNTS_SCRIPT } from "./accountsPage";
17
+ import { LOCK_SCRIPT, LOCK_STYLE, MASTER_LOCK_DERIVE_SOURCE, lockPageHtml } from "./lockPage";
17
18
 
18
19
  const KDF: MasterLockKdfParams = {
19
20
  v: 1,
@@ -24,25 +25,17 @@ const KDF: MasterLockKdfParams = {
24
25
  const PASSWORD = "correct horse battery staple";
25
26
 
26
27
  /**
27
- * Pull `deriveVerifier` out of the served script.
28
+ * Evaluate `deriveVerifier` out of the source that is actually served.
28
29
  *
29
- * The script assigns it to `globalThis.__masterLockDerive` for exactly this — a seam that
30
- * costs one line in the browser and is the difference between a pinned copy and a hopeful
31
- * one. Everything after that assignment touches `document`, so the source is cut there.
30
+ * It assigns itself to `globalThis.__masterLockDerive` for exactly this — a seam that costs
31
+ * one line in the browser and is the difference between a pinned copy and a hopeful one. The
32
+ * constant touches nothing but `crypto`, so it evaluates as-is with no DOM stub.
32
33
  */
33
34
  function extractDerive(): (password: string, kdf: MasterLockKdfParams) => Promise<string> {
34
- const marker = "globalThis.__masterLockDerive = deriveVerifier;";
35
- const cut = LOCK_SCRIPT.indexOf(marker);
36
- expect(cut).toBeGreaterThan(0);
37
- const source = LOCK_SCRIPT.slice(0, cut + marker.length);
38
- // The prelude reads `document`, which does not exist under `bun test`; the slice above
39
- // stops before it, but the leading `const` declarations still run — so evaluate in a
40
- // function scope with those globals stubbed.
41
35
  const factory = new Function(
42
- "document",
43
- `${source}\nreturn globalThis.__masterLockDerive;`,
44
- ) as (doc: unknown) => (password: string, kdf: MasterLockKdfParams) => Promise<string>;
45
- return factory({ getElementById: () => ({ addEventListener() {}, setAttribute() {}, removeAttribute() {} }) });
36
+ `${MASTER_LOCK_DERIVE_SOURCE}\nreturn globalThis.__masterLockDerive;`,
37
+ ) as () => (password: string, kdf: MasterLockKdfParams) => Promise<string>;
38
+ return factory();
46
39
  }
47
40
 
48
41
  describe("the lock page's hand-copied derivation", () => {
@@ -62,6 +55,17 @@ describe("the lock page's hand-copied derivation", () => {
62
55
  await deriveMasterLockVerifier("another password", kdf),
63
56
  );
64
57
  });
58
+
59
+ test("🔴 EVERY served script uses that one copy, so none can drift on its own", () => {
60
+ // The accounts page derives too — a rotation is two verifiers. If it ever grew its own
61
+ // transcription, the two tests above would keep passing while the owner's correct
62
+ // password was refused on exactly one of the two pages.
63
+ expect(LOCK_SCRIPT).toContain(MASTER_LOCK_DERIVE_SOURCE);
64
+ expect(MASTER_LOCK_ACCOUNTS_SCRIPT).toContain(MASTER_LOCK_DERIVE_SOURCE);
65
+ // And nobody has quietly pasted a second `deriveVerifier` alongside the shared one.
66
+ expect(LOCK_SCRIPT.split("async function deriveVerifier").length - 1).toBe(1);
67
+ expect(MASTER_LOCK_ACCOUNTS_SCRIPT.split("async function deriveVerifier").length - 1).toBe(1);
68
+ });
65
69
  });
66
70
 
67
71
  describe("the page itself", () => {
@@ -12,13 +12,18 @@
12
12
  * keep a wall that strict. Two same-origin files cost one round trip each and let the page
13
13
  * ship under `script-src 'self'` with no nonce plumbing.
14
14
  *
15
- * ── 🔴 The derivation in `LOCK_SCRIPT` is a HAND COPY of `deriveMasterLockVerifier` ──────
15
+ * ── 🔴 The derivation in `MASTER_LOCK_DERIVE_SOURCE` is a HAND COPY of
16
+ * `deriveMasterLockVerifier` ────────────────────────────────────────────
16
17
  * It cannot import it: this file is served to a browser as plain text, outside every build.
17
18
  * A copy that drifts derives a verifier that can never match, and the symptom would be "my
18
19
  * correct password is refused" — the same shape of bug that cost `apps/collections` a day.
19
- * So `lockPage.spec.ts` EVALUATES the script's function out of this source and asserts it
20
- * agrees with the TypeScript one on a fixed vector. Change one, the spec fails until you
21
- * change the other.
20
+ * So `lockPage.spec.ts` EVALUATES the function out of this source and asserts it agrees with
21
+ * the TypeScript one on a fixed vector. Change one, the spec fails until you change the other.
22
+ *
23
+ * 🔴 It is its OWN exported constant, and there is exactly ONE of it. `accountsPage.ts` serves
24
+ * a second browser script that has to derive the same way — a second hand copy would be a
25
+ * second thing to keep in step, and the pinning spec would only be watching one of them. Both
26
+ * scripts interpolate this string, and the spec asserts that they do.
22
27
  */
23
28
  import { MASTER_LOCK_PATHS } from "cursedbelt-core/master-lock";
24
29
 
@@ -159,35 +164,14 @@ button.quiet[disabled]{opacity:.5}
159
164
  `;
160
165
 
161
166
  /**
162
- * The page's script.
167
+ * 🔴 The ONE browser copy of `deriveMasterLockVerifier`, shared by every page this feature
168
+ * serves. Pinned by `lockPage.spec.ts`, which evaluates it and compares against the
169
+ * TypeScript one. Do not "tidy" it apart, and do not write a second copy — interpolate this.
163
170
  *
164
- * Derives in the browser and sends only the verifier — the password itself never crosses
165
- * the network, which is what makes "saved nowhere" true rather than aspirational.
166
- *
167
- * 600,000 PBKDF2 rounds take a beat on a phone, so the button says so; a page that looks
168
- * frozen gets clicked again, and every extra click is another 600k rounds.
171
+ * It ends by publishing the function on `globalThis`, which is the seam the spec pulls it
172
+ * out through and the seam the accounts page's script calls it back through.
169
173
  */
170
- export const LOCK_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
171
-
172
- const form = document.getElementById("ml-form");
173
- const input = document.getElementById("ml-input");
174
- const confirmInput = document.getElementById("ml-confirm");
175
- const hintInput = document.getElementById("ml-hint");
176
- const submit = document.getElementById("ml-submit");
177
- const status = document.getElementById("ml-status");
178
- const forgot = document.getElementById("ml-forgot");
179
- const hints = document.getElementById("ml-hints");
180
-
181
- function say(message, tone) {
182
- status.textContent = message;
183
- if (tone) status.setAttribute("data-tone", tone);
184
- else status.removeAttribute("data-tone");
185
- }
186
-
187
- // ── derivation ───────────────────────────────────────────────────────────────
188
- // 🔴 Byte-identical to deriveMasterLockVerifier in cursedbelt-core/master-lock. Pinned by
189
- // lockPage.spec.ts, which evaluates THIS function and compares. Do not "tidy" it apart.
190
- function unb64u(text) {
174
+ export const MASTER_LOCK_DERIVE_SOURCE = `function unb64u(text) {
191
175
  const padded = text.replace(/-/g, "+").replace(/_/g, "/");
192
176
  const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
193
177
  const out = new Uint8Array(binary.length);
@@ -214,13 +198,38 @@ async function deriveVerifier(password, kdf) {
214
198
  joined.set(bytes, kek.length);
215
199
  return b64u(await pbkdf2(joined, salt, 1));
216
200
  }
217
- globalThis.__masterLockDerive = deriveVerifier;
201
+ globalThis.__masterLockDerive = deriveVerifier;`;
202
+
203
+ /**
204
+ * The page's script.
205
+ *
206
+ * Derives in the browser and sends only the verifier — the password itself never crosses
207
+ * the network, which is what makes "saved nowhere" true rather than aspirational.
208
+ *
209
+ * 600,000 PBKDF2 rounds take a beat on a phone, so the button says so; a page that looks
210
+ * frozen gets clicked again, and every extra click is another 600k rounds.
211
+ */
212
+ export const LOCK_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
213
+
214
+ // ── derivation ───────────────────────────────────────────────────────────────
215
+ ${MASTER_LOCK_DERIVE_SOURCE}
216
+
217
+ const form = document.getElementById("ml-form");
218
+ const input = document.getElementById("ml-input");
219
+ const confirmInput = document.getElementById("ml-confirm");
220
+ const hintInput = document.getElementById("ml-hint");
221
+ const submit = document.getElementById("ml-submit");
222
+ const status = document.getElementById("ml-status");
223
+ const forgot = document.getElementById("ml-forgot");
224
+ const hints = document.getElementById("ml-hints");
225
+
226
+ function say(message, tone) {
227
+ status.textContent = message;
228
+ if (tone) status.setAttribute("data-tone", tone);
229
+ else status.removeAttribute("data-tone");
230
+ }
218
231
 
219
232
  // ── the page ─────────────────────────────────────────────────────────────────
220
- // 🔴 Everything below this line touches \`document\` beyond \`getElementById\`, and
221
- // lockPage.spec.ts cuts the source at the marker above to evaluate the derivation with a
222
- // stub. Keep page state here, not in the prelude, or that test throws instead of running.
223
- //
224
233
  // Rendered by the server from THIS principal's own record. The server re-decides it on
225
234
  // every POST, so this only chooses which form to submit — it cannot grant anything.
226
235
  const enrolling = document.body.dataset.mode === "enroll";
@@ -166,11 +166,6 @@ export const hlsLadderArgs = (
166
166
  heights: number[];
167
167
  crf: number;
168
168
  hasAudio: boolean;
169
- // When set, ffmpeg AES-128-encrypts every segment using this key-info file (line 1 = the
170
- // key URI written into the playlist's #EXT-X-KEY, line 2 = the raw 16-byte key file, line 3
171
- // = IV hex). Yields encrypted-at-rest, edge-cacheable segments the player decrypts. Used by
172
- // the private-media pipeline; omit for public (plaintext) media.
173
- keyInfoFile?: string;
174
169
  },
175
170
  ): string[] => {
176
171
  const hs = opts.heights.length ? opts.heights : [720];
@@ -218,7 +213,6 @@ export const hlsLadderArgs = (
218
213
  'vod',
219
214
  '-hls_segment_type',
220
215
  'mpegts',
221
- ...(opts.keyInfoFile ? ['-hls_key_info_file', opts.keyInfoFile] : []),
222
216
  '-master_pl_name',
223
217
  'master.m3u8',
224
218
  '-hls_segment_filename',
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * Moved from cursedbelt's src/react/media at the 2026-09-15 split: this spec
3
3
  * drives the published react-side upload client against this package's storage
4
- * INTERNALS (diskStore, streamToken, private-media), which are deliberately not
4
+ * INTERNALS (diskStore, streamToken), which are deliberately not
5
5
  * exported — so the spec lives beside them and takes the client half from the
6
6
  * published `cursedbelt` instead. Runtime deps stay acyclic; this is dev-only.
7
7
  */
@@ -13,8 +13,6 @@ import path from 'node:path';
13
13
  import { Hono } from 'hono';
14
14
  import { MAX_SINGLE_UPLOAD_BYTES } from 'cursedbelt-core/media/upload-limits';
15
15
  import { runMigrations } from '../migration/runner';
16
- import { generateUserSalt } from '../private-media/crypto';
17
- import { createUnlockService } from '../private-media/unlock';
18
16
  import { createDiskStore } from './diskStore';
19
17
  import { createSqliteFileCatalogue } from './files/catalogue';
20
18
  import { mountFileRoutes, SINGLE_UPLOAD_TOO_LARGE } from './files/fileRoutes';
@@ -24,11 +22,9 @@ import type { MediaCtx } from './types';
24
22
  import { createFileUploadClient } from 'cursedbelt/react/media/upload-client';
25
23
 
26
24
  // Drives the REAL mountFileRoutes edge — the client and the routes share one header
27
- // protocol (X-Filename/X-Tags/X-Unlock-Token), so the round-trip is the contract.
25
+ // protocol (X-Filename/X-Tags), so the round-trip is the contract.
28
26
 
29
27
  const CTX: MediaCtx = { userId: 'u1', tenantId: 't1' };
30
- const unlock = createUnlockService({ serverSecret: 'srv', unlockSecret: 'unl' });
31
- const salt = generateUserSalt();
32
28
 
33
29
  describe('createFileUploadClient ⇄ mountFileRoutes', () => {
34
30
  let db: Database;
@@ -55,10 +51,6 @@ describe('createFileUploadClient ⇄ mountFileRoutes', () => {
55
51
  token: createStreamTokenService({ secret: 's' }),
56
52
  catalogue,
57
53
  authorizeUpload: () => CTX,
58
- privateUpload: {
59
- unlock,
60
- authorize: async () => ({ userId: CTX.userId, userSalt: salt }),
61
- },
62
54
  });
63
55
  });
64
56
  afterAll(async () => {
@@ -90,17 +82,6 @@ describe('createFileUploadClient ⇄ mountFileRoutes', () => {
90
82
  expect(res.file?.size).toBe('replaced'.length);
91
83
  });
92
84
 
93
- test('uploadPrivate with an unlock token lands an encrypted item', async () => {
94
- const { token } = await unlock.unlock({ userId: CTX.userId, userSalt: salt, password: 'pw' });
95
- const res = await client.uploadPrivate(
96
- new File(['secret-bytes'], 'secret.jpg', { type: 'image/jpeg' }),
97
- { unlockToken: token, tags: ['hidden'] },
98
- );
99
- expect(res.ok).toBe(true);
100
- if (!res.ok) return;
101
- expect(res.file?.processingStatus).toBe('ready'); // image blob path — no ffmpeg
102
- });
103
-
104
85
  test('uploadAuto falls back to the streamed PUT when chunked is unwired (404 init)', async () => {
105
86
  const big = new File([new Uint8Array(9 * 1024 * 1024)], 'big.bin', {
106
87
  type: 'application/octet-stream',
@@ -12,7 +12,7 @@ import { createCataloguePhotoSource } from './adapter';
12
12
  import { createSqliteFileCatalogue } from './catalogue';
13
13
  import type { FileUrls } from './fileRoutes';
14
14
  import { FILE_CATALOGUE_MIGRATIONS } from './migrations';
15
- import type { FileCatalogue, PrivateFileCatalogue } from './types';
15
+ import type { FileCatalogue } from './types';
16
16
  import { bytesStream } from 'cursedbelt-core/testing';
17
17
 
18
18
  // End-to-end: an HEIC photo must gain a browser-safe full-size display derivative + advertise it as
@@ -79,7 +79,7 @@ async function heicBytes(): Promise<Uint8Array | null> {
79
79
  describe.skipIf(!haveFfmpeg)('photo display derivative (HEIC)', () => {
80
80
  let db: Database;
81
81
  let root: string;
82
- let cat: FileCatalogue & PrivateFileCatalogue;
82
+ let cat: FileCatalogue;
83
83
 
84
84
  beforeAll(async () => {
85
85
  db = new Database(':memory:');
@@ -11,7 +11,7 @@ import { createCataloguePhotoSource } from './adapter';
11
11
  import { createSqliteFileCatalogue } from './catalogue';
12
12
  import type { FileUrls } from './fileRoutes';
13
13
  import { FILE_CATALOGUE_MIGRATIONS } from './migrations';
14
- import type { FileCatalogue, PrivateFileCatalogue } from './types';
14
+ import type { FileCatalogue } from './types';
15
15
 
16
16
  // The photo source is an image-filtered VIEW over the catalogue: primary bytes via the file
17
17
  // route URL, grid thumb via the type-agnostic poster URL (only when the thumbnailer produced one).
@@ -42,7 +42,7 @@ const fakeMediaUrls: MediaUrls = {
42
42
  describe('createCataloguePhotoSource', () => {
43
43
  let db: Database;
44
44
  let root: string;
45
- let cat: FileCatalogue & PrivateFileCatalogue;
45
+ let cat: FileCatalogue;
46
46
  let photoId: string;
47
47
  let videoId: string;
48
48
 
@@ -39,7 +39,6 @@ const videoRecord = (over: Partial<FileRecord> = {}): FileRecord => ({
39
39
  size: 10,
40
40
  fileType: 'video',
41
41
  tags: ['fun'],
42
- isPrivate: false,
43
42
  favorite: false,
44
43
  collectionIds: [],
45
44
  hasThumbnail: true,
@@ -4,22 +4,15 @@ import { mkdtemp, rm } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { runMigrations } from '../../migration/runner';
7
- import {
8
- cryptBlobStream,
9
- derivePasswordSetKey,
10
- generateUserSalt,
11
- openMeta,
12
- unwrapContentKey,
13
- } from '../../private-media/crypto';
14
7
  import { createDiskStore } from '../diskStore';
15
- import { fsPosterKey, type MediaCtx, type MediaStore, privateSourceKey } from '../types';
8
+ import { fsPosterKey, type MediaCtx, type MediaStore } from '../types';
16
9
  import { createSqliteFileCatalogue, fileBinaryKey } from './catalogue';
17
10
  import { FILE_CATALOGUE_MIGRATIONS } from './migrations';
18
- import type { FileCatalogue, PrivateFileCatalogue } from './types';
11
+ import type { FileCatalogue } from './types';
19
12
  import { bytesStream } from 'cursedbelt-core/testing';
20
13
 
21
- // New media-host capabilities: private NON-video blobs (photos), in-place byte replacement
22
- // (the editor's "overwrite original"), and ingest-time image thumbnails.
14
+ // Media-host capabilities: in-place byte replacement (the editor's "overwrite original") and
15
+ // ingest-time image thumbnails.
23
16
 
24
17
  const CTX: MediaCtx = { userId: 'u1', tenantId: 't1' };
25
18
  const OWNER = CTX.userId;
@@ -30,7 +23,7 @@ const drain = async (s: ReadableStream<Uint8Array>): Promise<Uint8Array> =>
30
23
  let db: Database;
31
24
  let root: string;
32
25
  let store: MediaStore;
33
- let cat: FileCatalogue & PrivateFileCatalogue;
26
+ let cat: FileCatalogue;
34
27
 
35
28
  beforeAll(async () => {
36
29
  db = new Database(':memory:');
@@ -51,55 +44,6 @@ afterAll(async () => {
51
44
  await rm(root, { recursive: true, force: true });
52
45
  });
53
46
 
54
- describe('private blob ingest (non-video)', () => {
55
- const salt = generateUserSalt();
56
- const setKey = derivePasswordSetKey('photo-pass', salt);
57
- const plaintext = new TextEncoder().encode('these are secret photo bytes — not for the feed');
58
- let id: string;
59
-
60
- test('ingests an image as a ready, encrypted blob (no ffmpeg involved)', async () => {
61
- const item = await cat.ingestPrivate(
62
- CTX,
63
- { name: 'secret.jpg', mime: 'image/jpeg', body: stream(plaintext), tags: ['hidden'] },
64
- { setKey, keyMode: 'deniable' },
65
- );
66
- id = item.id;
67
- expect(item.processingStatus).toBe('ready');
68
- expect(item.size).toBe(plaintext.byteLength);
69
- expect(item.hasHls).toBe(false);
70
- });
71
-
72
- test('stored bytes are ciphertext; the wrapped content key decrypts them', async () => {
73
- const stored = await store.get(privateSourceKey(OWNER, id));
74
- expect(stored).not.toBeNull();
75
- expect(stored?.byteLength).toBe(plaintext.byteLength); // CTR is length-preserving
76
- expect(Buffer.from(stored as Uint8Array).equals(Buffer.from(plaintext))).toBe(false);
77
-
78
- const item = await cat.asPrivateIndex().get(OWNER, id);
79
- const content = unwrapContentKey(setKey, item?.wrappedContentKey ?? '');
80
- expect(content).not.toBeNull();
81
- const decrypted = await drain(
82
- cryptBlobStream(content as NonNullable<typeof content>, stream(stored as Uint8Array)),
83
- );
84
- expect(Buffer.from(decrypted).equals(Buffer.from(plaintext))).toBe(true);
85
- });
86
-
87
- test('the sealed descriptor reveals name/mime/tags to its set key only', async () => {
88
- const item = await cat.asPrivateIndex().get(OWNER, id);
89
- const meta = openMeta<{ name: string; mime: string; tags: string[] }>(
90
- setKey,
91
- item?.sealedMeta ?? '',
92
- );
93
- expect(meta).toEqual({ name: 'secret.jpg', mime: 'image/jpeg', tags: ['hidden'] });
94
- expect(openMeta(derivePasswordSetKey('wrong', salt), item?.sealedMeta ?? '')).toBeNull();
95
- });
96
-
97
- test('index.remove wipes the ciphertext blob too', async () => {
98
- await cat.asPrivateIndex().remove(OWNER, id);
99
- expect(await store.get(privateSourceKey(OWNER, id))).toBeNull();
100
- });
101
- });
102
-
103
47
  describe('replaceContent — overwrite bytes, keep identity', () => {
104
48
  let id: string;
105
49
  let collectionId: string;
@@ -137,19 +81,10 @@ describe('replaceContent — overwrite bytes, keep identity', () => {
137
81
  expect(new TextDecoder().decode(bytes as Uint8Array)).toBe('v2 much longer replacement bytes');
138
82
  });
139
83
 
140
- test('unknown id → null; private rows refuse replacement', async () => {
84
+ test('unknown id → null', async () => {
141
85
  expect(await cat.replaceContent(CTX, 'nope', { mime: 'text/plain', body: stream('x') })).toBe(
142
86
  null,
143
87
  );
144
- const salt = generateUserSalt();
145
- const priv = await cat.ingestPrivate(
146
- CTX,
147
- { name: 'p.jpg', mime: 'image/jpeg', body: stream('p') },
148
- { setKey: derivePasswordSetKey('pw', salt), keyMode: 'deniable' },
149
- );
150
- expect(
151
- cat.replaceContent(CTX, priv.id, { mime: 'image/jpeg', body: stream('q') }),
152
- ).rejects.toThrow(/private/);
153
88
  });
154
89
  });
155
90
 
@@ -10,7 +10,8 @@ import { FILE_CATALOGUE_MIGRATIONS } from './migrations';
10
10
  import type { FileCatalogue } from './types';
11
11
 
12
12
  // recoverProcessing (ported from ca's recoverOnBoot): a restart mid-transcode leaves video rows
13
- // stuck in processing/pending; public rows get re-kicked (source persists), private rows are failed.
13
+ // stuck in processing/pending; they get re-kicked (the source persists) unless the row has already
14
+ // burned its kick budget, in which case it lands on `failed` — see the attempt-cap describe below.
14
15
  describe('FileCatalogue.recoverProcessing', () => {
15
16
  let db: Database;
16
17
  let root: string;
@@ -25,13 +26,11 @@ describe('FileCatalogue.recoverProcessing', () => {
25
26
  store: createDiskStore(root),
26
27
  workDir: path.join(root, 'w'),
27
28
  });
28
- // Simulate two rows a crash left mid-transcode: one public, one private.
29
- const insert = db.query(
30
- `INSERT INTO files (id, owner, name, mime, size, file_type, is_private, processing_status, processing_progress, storage_backend, created_at, updated_at)
31
- VALUES ($id, 'u1', $name, 'video/mp4', 10, 'video', $priv, 'processing', 40, 'disk', 1, 1)`,
32
- );
33
- insert.run({ $id: 'pub1', $name: 'a.mp4', $priv: 0 });
34
- insert.run({ $id: 'prv1', $name: '', $priv: 1 });
29
+ // Simulate a row a crash left mid-transcode.
30
+ db.query(
31
+ `INSERT INTO files (id, owner, name, mime, size, file_type, processing_status, processing_progress, storage_backend, created_at, updated_at)
32
+ VALUES ($id, 'u1', $name, 'video/mp4', 10, 'video', 'processing', 40, 'disk', 1, 1)`,
33
+ ).run({ $id: 'pub1', $name: 'a.mp4' });
35
34
  });
36
35
  afterAll(async () => {
37
36
  await Bun.sleep(150); // let the re-kicked public job settle before closing the db
@@ -39,18 +38,10 @@ describe('FileCatalogue.recoverProcessing', () => {
39
38
  await rm(root, { recursive: true, force: true });
40
39
  });
41
40
 
42
- test('re-queues stuck public videos and fails stuck private ones', () => {
41
+ test('re-queues stuck videos', () => {
43
42
  const res = cat.recoverProcessing();
44
43
  expect(res.requeued).toBe(1); // pub1 re-kicked
45
- expect(res.failed).toBe(1); // prv1 can't be re-kicked at boot → failed
46
-
47
- const status = (id: string) =>
48
- db
49
- .query<{ processing_status: string }, { $id: string }>(
50
- 'SELECT processing_status FROM files WHERE id = $id',
51
- )
52
- .get({ $id: id })?.processing_status;
53
- expect(status('prv1')).toBe('failed');
44
+ expect(res.failed).toBe(0);
54
45
  // pub1 was re-kicked (its final status is settled by the async pipeline; here we just assert it
55
46
  // left the recover call counted as requeued, not failed).
56
47
  });
@@ -68,7 +59,7 @@ describe('FileCatalogue.recoverProcessing', () => {
68
59
  // The boot-loop guard: when the transcode itself keeps killing the process (ffmpeg OOM →
69
60
  // restart → re-kick), the attempt cap must land the row on `failed` instead of re-kicking forever.
70
61
  describe('FileCatalogue.recoverProcessing attempt cap', () => {
71
- test('marks a public video failed once processing_attempts reaches the cap', async () => {
62
+ test('marks a video failed once processing_attempts reaches the cap', async () => {
72
63
  const db = new Database(':memory:');
73
64
  runMigrations(db, FILE_CATALOGUE_MIGRATIONS);
74
65
  const root = await mkdtemp(path.join(tmpdir(), 'fc-recover-cap-'));
@@ -79,8 +70,8 @@ describe('FileCatalogue.recoverProcessing attempt cap', () => {
79
70
  maxProcessingAttempts: 2,
80
71
  });
81
72
  db.query(
82
- `INSERT INTO files (id, owner, name, mime, size, file_type, is_private, processing_status, processing_progress, storage_backend, processing_attempts, created_at, updated_at)
83
- VALUES ('loop1', 'u1', 'l.mp4', 'video/mp4', 10, 'video', 0, 'processing', 40, 'disk', 2, 1, 1)`,
73
+ `INSERT INTO files (id, owner, name, mime, size, file_type, processing_status, processing_progress, storage_backend, processing_attempts, created_at, updated_at)
74
+ VALUES ('loop1', 'u1', 'l.mp4', 'video/mp4', 10, 'video', 'processing', 40, 'disk', 2, 1, 1)`,
84
75
  ).run();
85
76
 
86
77
  const res = cat.recoverProcessing();
@@ -111,8 +102,8 @@ describe('FileCatalogue transcodeVideos: false', () => {
111
102
  transcodeVideos: false,
112
103
  });
113
104
  db.query(
114
- `INSERT INTO files (id, owner, name, mime, size, file_type, is_private, processing_status, processing_progress, storage_backend, created_at, updated_at)
115
- VALUES ('raw1', 'u1', 'r.mp4', 'video/mp4', 10, 'video', 0, 'processing', 40, 'disk', 1, 1)`,
105
+ `INSERT INTO files (id, owner, name, mime, size, file_type, processing_status, processing_progress, storage_backend, created_at, updated_at)
106
+ VALUES ('raw1', 'u1', 'r.mp4', 'video/mp4', 10, 'video', 'processing', 40, 'disk', 1, 1)`,
116
107
  ).run();
117
108
 
118
109
  const res = cat.recoverProcessing();