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
@@ -1,17 +1,11 @@
1
1
  import type { Context, Hono } from 'hono';
2
2
  import { MAX_SINGLE_UPLOAD_BYTES } from 'cursedbelt-core/media/upload-limits';
3
- import type { UnlockPasswordPolicy, UnlockService } from '../../private-media/unlock';
4
3
  import { serveRange } from '../streamRange';
5
4
  import type { StreamTokenService } from '../streamToken';
6
5
  import type { MediaCtx, MediaStore } from '../types';
7
6
  import { fileBinaryKey } from './catalogue';
8
7
  import type { ChunkedUploadService } from './chunkedUpload';
9
- import {
10
- FILE_SORTS,
11
- type FileCatalogue,
12
- type ListFilesOptions,
13
- type PrivateFileCatalogue,
14
- } from './types';
8
+ import { FILE_SORTS, type FileCatalogue, type ListFilesOptions } from './types';
15
9
 
16
10
  // ── File binary + upload routes ─────────────────────────────────────────────────
17
11
  // The FileCatalogue's byte edge for GENERAL files (images/docs/audio/any) — the piece
@@ -106,21 +100,7 @@ export type MountFileRoutesOptions = {
106
100
  /** Session auth for the upload PUTs (a fetch CAN send the Bearer). Return a ctx or null. */
107
101
  authorizeUpload?: (c: Context) => Promise<MediaCtx | null> | MediaCtx | null;
108
102
  /** The catalogue uploads land in. Required for the upload routes to be mounted. */
109
- catalogue?: FileCatalogue & PrivateFileCatalogue;
110
- /** Wire private uploads: derive a per-user set key from the request (password header ⇒ deniable,
111
- * absent ⇒ standard server key). Omit ⇒ the private-upload route is disabled. */
112
- privateUpload?: {
113
- unlock: UnlockService;
114
- authorize: (c: Context) => Promise<{ userId: string; userSalt: string } | null>;
115
- /** Header carrying the plaintext password for a deniable set (default 'x-private-password'). */
116
- passwordHeader?: string;
117
- /** Header carrying an UNLOCK TOKEN (an already-unlocked session) — takes precedence over the
118
- * password header; setKey + keyMode come from the verified grant (default 'x-unlock-token'). */
119
- tokenHeader?: string;
120
- /** What a supplied password means (default derive — deniable set). `gate` verifies it
121
- * against the app's accounts and wraps under the recoverable server key instead. */
122
- passwordPolicy?: UnlockPasswordPolicy;
123
- };
103
+ catalogue?: FileCatalogue;
124
104
  /** Wire large-file chunked uploads (direct-to-backend). Omit ⇒ init/complete routes are absent
125
105
  * and clients use the streamed `PUT /upload`. Uses the same `authorizeUpload` guard. */
126
106
  chunked?: ChunkedUploadService;
@@ -197,80 +177,6 @@ export function mountFileRoutes(app: Hono, opts: MountFileRoutesOptions): void {
197
177
  });
198
178
  }
199
179
 
200
- // PUT a PRIVATE upload → catalogue.ingestPrivate under a set key derived from the request:
201
- // either an UNLOCK TOKEN (an already-unlocked session — same mechanism the content-key route
202
- // uses) or a password header (deniable) / neither (standard server key).
203
- const pv = opts.privateUpload;
204
- if (pv && catalogue) {
205
- const passwordHeader = pv.passwordHeader ?? 'x-private-password';
206
- const tokenHeader = pv.tokenHeader ?? 'x-unlock-token';
207
- app.put(`${base}/upload-private`, async (c) => {
208
- const who = await pv.authorize(c);
209
- if (!who) return c.json({ error: 'unauthorized' }, 401);
210
- const raw = c.req.raw.body;
211
- if (!raw) return c.json({ error: 'empty body' }, 400);
212
- const body = guardSingleUpload(c, raw);
213
- if (body instanceof Response) return body;
214
-
215
- let setKey: Buffer;
216
- let keyMode: 'standard' | 'deniable';
217
- const unlockToken = c.req.header(tokenHeader) || undefined;
218
- if (unlockToken) {
219
- // Token path: recover the sealed set key + mode from the grant. The token must belong
220
- // to the session caller.
221
- const grant = await pv.unlock.verify(unlockToken);
222
- if (!grant || grant.userId !== who.userId) {
223
- return c.json({ error: 'invalid unlock token' }, 401);
224
- }
225
- setKey = grant.setKey;
226
- keyMode = grant.mode;
227
- } else {
228
- // Password path: derive fresh from the header (deniable) or the server key. Under the
229
- // gate policy the password is an access check only — verify, then wrap standard.
230
- const password = c.req.header(passwordHeader) || undefined;
231
- const policy = pv.passwordPolicy ?? { mode: 'derive' as const };
232
- let derivePassword = password;
233
- if (policy.mode === 'gate' && password) {
234
- if (!(await policy.verifyPassword(who, password))) {
235
- return c.json({ error: 'invalid password' }, 401);
236
- }
237
- derivePassword = undefined;
238
- }
239
- setKey = pv.unlock.deriveSetKey({
240
- userId: who.userId,
241
- userSalt: who.userSalt,
242
- password: derivePassword,
243
- });
244
- keyMode = derivePassword ? 'deniable' : 'standard';
245
- }
246
-
247
- // The private owner axis is the userId (deniability is personal), matching mountPrivateRoutes.
248
- const ctx: MediaCtx = { userId: who.userId, tenantId: who.userId };
249
- const tags = c.req.header('x-tags');
250
- try {
251
- const item = await catalogue.ingestPrivate(
252
- ctx,
253
- {
254
- name: c.req.header('x-filename') ?? 'upload',
255
- mime: c.req.header('content-type') ?? 'application/octet-stream',
256
- body,
257
- tags: tags
258
- ? tags
259
- .split(',')
260
- .map((t) => t.trim())
261
- .filter(Boolean)
262
- : undefined,
263
- },
264
- { setKey, keyMode },
265
- );
266
- return c.json({ item });
267
- } catch (e) {
268
- if (isUploadTooLarge(e)) return c.json({ error: SINGLE_UPLOAD_TOO_LARGE }, 413);
269
- throw e;
270
- }
271
- });
272
- }
273
-
274
180
  // Chunked upload (large files, direct-to-backend). init → the client PUTs each chunk straight to
275
181
  // the backend (URLs the app minted) → complete registers the row over the assembled bytes. The
276
182
  // bytes never traverse this origin.
@@ -1,5 +1,5 @@
1
1
  // cursedbelt-server/storage FileCatalogue — a SQLite-backed tagged flat file system over the
2
- // MediaStore + media pipeline + private-media. One source of truth for a user's binaries; the
2
+ // MediaStore + media pipeline. One source of truth for a user's binaries; the
3
3
  // video gallery is a video-filtered view over it.
4
4
  export {
5
5
  createCatalogueMediaSource,
@@ -59,8 +59,5 @@ export {
59
59
  type FileToPlayable,
60
60
  type FileType,
61
61
  type ListFilesOptions,
62
- type PrivateFileCatalogue,
63
- type PrivateFileItem,
64
- type PrivateKeyMode,
65
62
  type TagFacet,
66
63
  } from './types';
@@ -6,6 +6,15 @@ import type { Migration } from '../../migration/types';
6
6
  // (not re-derived per list), tags live in an indexed junction (fast facets, not a JSON scan),
7
7
  // processing state is typed columns (not an opaque blob), and a storage-operations ledger backs
8
8
  // durable cross-boundary (binary-server/S3) ops. Flat — no parent_id tree.
9
+ //
10
+ // 🔴 `0001` is edited IN PLACE rather than followed by a drop-column migration (2026-09-17,
11
+ // removing private media). It is `CREATE TABLE IF NOT EXISTS` and is recorded in `_migrations`,
12
+ // so an existing database never re-runs it and simply keeps three vestigial columns
13
+ // (`is_private`, `enc_name`, `enc_dek`, `enc_key_type`, `file_tags.is_private`) that nothing
14
+ // reads; every one of them had `NOT NULL DEFAULT`, so the leaner INSERTs below still satisfy
15
+ // them. A fresh database gets the lean shape. That is safe here only because no app in the
16
+ // fleet mounts `createFilesStack` — if one ever does, removing a column becomes a real
17
+ // migration, not an edit.
9
18
 
10
19
  export const FILE_CATALOGUE_MIGRATIONS: readonly Migration[] = [
11
20
  {
@@ -19,7 +28,6 @@ export const FILE_CATALOGUE_MIGRATIONS: readonly Migration[] = [
19
28
  mime TEXT NOT NULL DEFAULT '',
20
29
  size INTEGER NOT NULL DEFAULT 0,
21
30
  file_type TEXT NOT NULL DEFAULT 'other',
22
- is_private INTEGER NOT NULL DEFAULT 0,
23
31
  has_thumbnail INTEGER NOT NULL DEFAULT 0,
24
32
  has_hls INTEGER NOT NULL DEFAULT 0,
25
33
  has_sprites INTEGER NOT NULL DEFAULT 0,
@@ -27,9 +35,6 @@ export const FILE_CATALOGUE_MIGRATIONS: readonly Migration[] = [
27
35
  processing_status TEXT NOT NULL DEFAULT 'ready',
28
36
  processing_progress INTEGER NOT NULL DEFAULT 100,
29
37
  storage_backend TEXT NOT NULL DEFAULT 'disk',
30
- enc_name TEXT NOT NULL DEFAULT '',
31
- enc_dek TEXT NOT NULL DEFAULT '',
32
- enc_key_type TEXT NOT NULL DEFAULT '',
33
38
  created_at INTEGER NOT NULL,
34
39
  updated_at INTEGER NOT NULL
35
40
  )
@@ -41,8 +46,6 @@ export const FILE_CATALOGUE_MIGRATIONS: readonly Migration[] = [
41
46
  db.run(
42
47
  `CREATE INDEX IF NOT EXISTS idx_files_owner_type_created ON files(owner, file_type, created_at DESC, id DESC)`,
43
48
  );
44
- db.run(`CREATE INDEX IF NOT EXISTS idx_files_owner_private ON files(owner, is_private)`);
45
-
46
49
  // Tags as a junction (indexed) — single-tag filter + facet counts are index lookups, not a
47
50
  // full-table JSON parse (ca's weakness). `owner` denormalized so facet queries stay covering.
48
51
  db.run(`
@@ -50,7 +53,6 @@ export const FILE_CATALOGUE_MIGRATIONS: readonly Migration[] = [
50
53
  file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
51
54
  tag TEXT NOT NULL,
52
55
  owner TEXT NOT NULL,
53
- is_private INTEGER NOT NULL DEFAULT 0,
54
56
  PRIMARY KEY (file_id, tag)
55
57
  )
56
58
  `);
@@ -5,23 +5,23 @@ import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { Hono } from 'hono';
7
7
  import { runMigrations } from '../../migration/runner';
8
- import { generateUserSalt } from '../../private-media/crypto';
9
8
  import { createDiskStore } from '../diskStore';
10
9
  import type { MediaCtx } from '../types';
11
10
  import { FILE_CATALOGUE_MIGRATIONS } from './migrations';
12
11
  import { createFilesStack, type FilesStack } from './stack';
13
12
 
14
13
  const CTX: MediaCtx = { userId: 'u1', tenantId: 't1' };
15
- const salt = generateUserSalt();
16
14
 
17
- // Seed a row recoverProcessing acts on WITHOUT ffmpeg: a private video stuck in `processing`
18
- // is marked failed synchronously (its set key isn't available at boot).
19
- function seedStuckPrivateRow(db: Database, id: string): void {
15
+ // Seed a row recoverProcessing acts on WITHOUT ffmpeg: a video stuck in `processing` that has
16
+ // already burned its kick budget is marked failed synchronously, no re-kick. (Before 2026-09-17
17
+ // this seeded a stuck PRIVATE row, which took the same branch for a different reason; the
18
+ // attempts cap is the branch that still exists, and the one the 2026-07-04 OOM outage bought.)
19
+ function seedExhaustedVideoRow(db: Database, id: string): void {
20
20
  runMigrations(db, FILE_CATALOGUE_MIGRATIONS); // idempotent — the stack re-runs it harmlessly
21
21
  db.run(
22
- `INSERT INTO files (id, owner, name, mime, size, file_type, is_private, processing_status,
23
- processing_progress, storage_backend, enc_name, enc_dek, enc_key_type, created_at, updated_at)
24
- VALUES (?, 'u1', '', '', 1, 'video', 1, 'processing', 40, 'disk', 'x', 'x', 'deniable', 1, 1)`,
22
+ `INSERT INTO files (id, owner, name, mime, size, file_type, processing_status,
23
+ processing_progress, storage_backend, processing_attempts, created_at, updated_at)
24
+ VALUES (?, 'u1', 'v.mp4', 'video/mp4', 1, 'video', 'processing', 40, 'disk', 3, 1, 1)`,
25
25
  [id],
26
26
  );
27
27
  }
@@ -60,10 +60,6 @@ describe('createFilesStack — public stack', () => {
60
60
  expect(page.items).toEqual([]);
61
61
  });
62
62
 
63
- test('no private layer configured ⇒ unlock is null', () => {
64
- expect(stack.unlock).toBeNull();
65
- });
66
-
67
63
  test('URL builders are wired to the default bases', async () => {
68
64
  const f = await stack.fileUrls.build(CTX, 'fid', 'image/png');
69
65
  expect(f.url).toStartWith('/_files/s/');
@@ -144,9 +140,9 @@ describe('createFilesStack — recoverProcessing', () => {
144
140
  recoverProcessing,
145
141
  });
146
142
 
147
- test('runs by default — a stuck private row is marked failed at build', () => {
143
+ test('runs by default — a stuck row past its kick budget is marked failed at build', () => {
148
144
  const db = new Database(':memory:');
149
- seedStuckPrivateRow(db, 'stuck1');
145
+ seedExhaustedVideoRow(db, 'stuck1');
150
146
  build(db);
151
147
  expect(statusOf(db, 'stuck1')).toBe('failed');
152
148
  db.close();
@@ -154,105 +150,9 @@ describe('createFilesStack — recoverProcessing', () => {
154
150
 
155
151
  test('recoverProcessing: false skips it', () => {
156
152
  const db = new Database(':memory:');
157
- seedStuckPrivateRow(db, 'stuck2');
153
+ seedExhaustedVideoRow(db, 'stuck2');
158
154
  build(db, false);
159
155
  expect(statusOf(db, 'stuck2')).toBe('processing');
160
156
  db.close();
161
157
  });
162
158
  });
163
-
164
- describe('createFilesStack — private stack', () => {
165
- let db: Database;
166
- let root: string;
167
- let stack: FilesStack;
168
- const app = new Hono();
169
-
170
- beforeAll(async () => {
171
- db = new Database(':memory:');
172
- root = await mkdtemp(path.join(tmpdir(), 'stack-priv-'));
173
- stack = createFilesStack({
174
- db,
175
- store: createDiskStore(root),
176
- workDir: path.join(root, 'work'),
177
- streamSecret: 'stream-secret',
178
- privateMedia: { serverSecret: 'srv', unlockSecret: 'unl' },
179
- });
180
- stack.mount(app, {
181
- authorizeUpload: () => CTX,
182
- authorizePrivate: async () => ({ userId: CTX.userId, userSalt: salt }),
183
- });
184
- });
185
- afterAll(async () => {
186
- db.close();
187
- await rm(root, { recursive: true, force: true });
188
- });
189
-
190
- test('unlock service exists and its tokens verify', async () => {
191
- expect(stack.unlock).not.toBeNull();
192
- const { token, mode } = await stack.unlock!.unlock({
193
- userId: CTX.userId,
194
- userSalt: salt,
195
- password: 'pw',
196
- });
197
- expect(mode).toBe('deniable');
198
- expect((await stack.unlock!.verify(token))?.userId).toBe(CTX.userId);
199
- });
200
-
201
- test('POST /_private/unlock with a password mints a deniable token', async () => {
202
- const res = await app.request('/_private/unlock', {
203
- method: 'POST',
204
- headers: { 'content-type': 'application/json' },
205
- body: JSON.stringify({ password: 'hunter2' }),
206
- });
207
- expect(res.status).toBe(200);
208
- const body = (await res.json()) as { token: string; mode: string };
209
- expect(body.mode).toBe('deniable');
210
- expect((await stack.unlock!.verify(body.token))?.mode).toBe('deniable');
211
- });
212
-
213
- test('reveal route is mounted (password reveal over an empty set)', async () => {
214
- const res = await app.request('/_private/reveal', {
215
- method: 'POST',
216
- headers: { 'content-type': 'application/json' },
217
- body: JSON.stringify({ password: 'hunter2' }),
218
- });
219
- expect(res.status).toBe(200);
220
- expect((await res.json()) as { items: unknown[] }).toEqual({ items: [] });
221
- });
222
-
223
- test('private upload route is mounted and session-guarded', async () => {
224
- const denied = new Hono();
225
- stack.mount(denied, {
226
- authorizeUpload: () => CTX,
227
- authorizePrivate: async () => null,
228
- });
229
- const res = await denied.request('/_files/upload-private', { method: 'PUT', body: 'x' });
230
- expect(res.status).toBe(401); // mounted (not 404) but rejected by the host auth
231
- });
232
-
233
- test('mount() throws without authorizePrivate when privateMedia is configured', () => {
234
- expect(() => stack.mount(new Hono(), { authorizeUpload: () => CTX })).toThrow(
235
- /authorizePrivate/,
236
- );
237
- });
238
-
239
- test('privateBasePath flows through to the private mount', async () => {
240
- const db2 = new Database(':memory:');
241
- const s = createFilesStack({
242
- db: db2,
243
- store: createDiskStore(root),
244
- workDir: path.join(root, 'work2'),
245
- streamSecret: 's',
246
- privateBasePath: '/vault',
247
- privateMedia: { serverSecret: 'srv', unlockSecret: 'unl' },
248
- });
249
- const a = new Hono();
250
- s.mount(a, {
251
- authorizeUpload: () => CTX,
252
- authorizePrivate: async () => ({ userId: CTX.userId, userSalt: salt }),
253
- });
254
- const res = await a.request('/vault/unlock', { method: 'POST', body: '{}' });
255
- expect(res.status).toBe(200);
256
- db2.close();
257
- });
258
- });
@@ -1,12 +1,6 @@
1
1
  import type { Database } from 'bun:sqlite';
2
2
  import type { Context, Hono } from 'hono';
3
3
  import { runMigrations } from '../../migration/runner';
4
- import { mountPrivateRoutes } from '../../private-media/mountPrivateRoutes';
5
- import {
6
- createUnlockService,
7
- type UnlockPasswordPolicy,
8
- type UnlockService,
9
- } from '../../private-media/unlock';
10
4
  import { mountMediaRoutes } from '../mountMediaRoutes';
11
5
  import { createMediaUrls, type MediaUrls, SourceRegistry } from '../sources';
12
6
  import { createStreamTokenService, type StreamTokenService } from '../streamToken';
@@ -25,14 +19,14 @@ import {
25
19
  import { createFileUrls, type FileUrls, mountFileRoutes } from './fileRoutes';
26
20
  import { FILE_CATALOGUE_MIGRATIONS } from './migrations';
27
21
  import { createStorageOpsLedger } from './storageOps';
28
- import type { FileCatalogue, PrivateFileCatalogue } from './types';
22
+ import type { FileCatalogue } from './types';
29
23
 
30
24
  // ── Files stack — the host recipe ───────────────────────────────────────────────
31
25
  // One call that assembles the whole files/media server stack a host would otherwise hand-wire:
32
26
  // migrations + SQLite catalogue over a MediaStore, the stream-token service, the media/file URL
33
- // builders, the catalogue-as-video-source registered in a SourceRegistry, the optional private
34
- // (encrypted) layer, and a `mount()` that wires every byte/upload route onto a Hono app. The auth
35
- // seams (session → MediaCtx / {userId, userSalt}) stay host-injected — the stack owns no auth.
27
+ // builders, the catalogue-as-video-source registered in a SourceRegistry, and a `mount()` that
28
+ // wires every byte/upload route onto a Hono app. The auth seam (session → MediaCtx) stays
29
+ // host-injected — the stack owns no auth.
36
30
 
37
31
  export interface FilesStackOptions {
38
32
  /** The catalogue database. The stack runs FILE_CATALOGUE_MIGRATIONS on it itself. */
@@ -43,9 +37,9 @@ export interface FilesStackOptions {
43
37
  workDir: string;
44
38
  /** Secret for the stream-token service (path-embedded byte-route auth). */
45
39
  streamSecret: string;
46
- /** Owner keyspace segment. Defaults to the userId (files + private are personal) — note the
47
- * bare primitives default to tenantId; the stack picks the personal axis so every URL/route
48
- * agrees with the private layer's per-user owner. */
40
+ /** Owner keyspace segment. Defaults to the userId (files are personal) — note the bare
41
+ * primitives default to tenantId; the stack picks the personal axis so every URL and route
42
+ * agrees on one owner. */
49
43
  owner?: (ctx: MediaCtx) => string;
50
44
  /** Registered video-source id (also the media-token sourceId). Default 'files'. */
51
45
  sourceId?: string;
@@ -66,8 +60,6 @@ export interface FilesStackOptions {
66
60
  mediaPublicBase?: string;
67
61
  /** PUBLIC base for browser file URLs (split-origin counterpart of `filesBasePath`). */
68
62
  filesPublicBase?: string;
69
- /** Private (encrypted) route mount. Default '/_private'. */
70
- privateBasePath?: string;
71
63
  /** HLS ladder heights for video ingest (createSqliteFileCatalogue passthrough). */
72
64
  ladderHeights?: SqliteFileCatalogueOptions['ladderHeights'];
73
65
  /** Cap on transcode kicks per file before it's marked failed (createSqliteFileCatalogue
@@ -82,11 +74,6 @@ export interface FilesStackOptions {
82
74
  /** Image thumbnail/display derivative implementation (createSqliteFileCatalogue passthrough).
83
75
  * Default: local sharp with ffmpeg fallback. */
84
76
  imageDerivatives?: SqliteFileCatalogueOptions['imageDerivatives'];
85
- /** Enables the private (encrypted) stack: unlock service + /_private routes + private uploads.
86
- * Omit ⇒ `unlock` is null and mount() wires only the public routes. `passwordPolicy` sets what
87
- * a supplied password MEANS (derive = deniable sets, the default; gate = verify against the
88
- * app's accounts + recoverable server-key sets — see UnlockPasswordPolicy). */
89
- privateMedia?: { serverSecret: string; unlockSecret: string; passwordPolicy?: UnlockPasswordPolicy };
90
77
  /** Re-kick transcodes a restart left stuck (catalogue.recoverProcessing) at build time.
91
78
  * Default true. */
92
79
  recoverProcessing?: boolean;
@@ -113,15 +100,8 @@ export interface FilesStackMountAuth {
113
100
  /** Session auth for uploads + listings (same guard mountMediaRoutes/mountFileRoutes take).
114
101
  * Return a MediaCtx to allow, null to reject. */
115
102
  authorizeUpload: (c: Context) => Promise<MediaCtx | null> | MediaCtx | null;
116
- /** Session auth for the private layer → {userId, userSalt}. REQUIRED when `privateMedia` is
117
- * configured (mount() throws otherwise); ignored when it isn't. */
118
- authorizePrivate?: (c: Context) => Promise<{ userId: string; userSalt: string } | null>;
119
103
  /** Wire large-file chunked uploads (mountFileRoutes' `chunked` passthrough). */
120
104
  mintChunkUpload?: ChunkedUploadService;
121
- /** Private-upload password header (default 'x-private-password'). */
122
- passwordHeader?: string;
123
- /** Private-upload unlock-token header (default 'x-unlock-token'). */
124
- tokenHeader?: string;
125
105
  }
126
106
 
127
107
  /**
@@ -131,7 +111,7 @@ export interface FilesStackMountAuth {
131
111
  * not, layering) import cursedshell.
132
112
  */
133
113
  export interface FilesStack {
134
- catalogue: FileCatalogue & PrivateFileCatalogue;
114
+ catalogue: FileCatalogue;
135
115
  token: StreamTokenService;
136
116
  mediaUrls: MediaUrls;
137
117
  fileUrls: FileUrls;
@@ -141,15 +121,12 @@ export interface FilesStack {
141
121
  /** The MIXED view — photos + videos in one feed (media gallery data source). */
142
122
  mediaSource: MediaGallerySource;
143
123
  registry: SourceRegistry;
144
- /** Null unless `privateMedia` was configured. */
145
- unlock: UnlockService | null;
146
124
  /** The large-file chunked-upload service, or null unless `chunkedUpload` was configured. mount()
147
125
  * auto-wires it into the file routes; exposed for observability (pending()) + tests. */
148
126
  chunked: ChunkedUploadService | null;
149
127
  /**
150
128
  * Mount every byte/upload route the stack owns onto a Hono app: mountMediaRoutes
151
- * (mediaBasePath) + mountFileRoutes (filesBasePath, incl. privateUpload when unlock exists) +
152
- * mountPrivateRoutes (privateBasePath, when unlock exists). Auth stays host-injected.
129
+ * (mediaBasePath) + mountFileRoutes (filesBasePath). Auth stays host-injected.
153
130
  */
154
131
  mount(app: Hono, auth: FilesStackMountAuth): void;
155
132
  }
@@ -212,8 +189,6 @@ export function createFilesStack(opts: FilesStackOptions): FilesStack {
212
189
  });
213
190
  const registry = new SourceRegistry().register(videoSource);
214
191
 
215
- const unlock = opts.privateMedia ? createUnlockService(opts.privateMedia) : null;
216
-
217
192
  const chunked = opts.chunkedUpload
218
193
  ? createChunkedUploadService({
219
194
  catalogue,
@@ -234,15 +209,8 @@ export function createFilesStack(opts: FilesStackOptions): FilesStack {
234
209
  photoSource,
235
210
  mediaSource,
236
211
  registry,
237
- unlock,
238
212
  chunked,
239
213
  mount(app, auth) {
240
- if (unlock && !auth.authorizePrivate) {
241
- throw new Error(
242
- 'createFilesStack: privateMedia is configured but mount() got no authorizePrivate — ' +
243
- 'the private routes need a session→{userId, userSalt} resolver.',
244
- );
245
- }
246
214
  mountMediaRoutes(app, {
247
215
  store,
248
216
  token,
@@ -258,27 +226,7 @@ export function createFilesStack(opts: FilesStackOptions): FilesStack {
258
226
  authorizeUpload: auth.authorizeUpload,
259
227
  // Prefer a mount-time override, else the service the stack built from `chunkedUpload` opts.
260
228
  chunked: auth.mintChunkUpload ?? chunked ?? undefined,
261
- privateUpload:
262
- unlock && auth.authorizePrivate
263
- ? {
264
- unlock,
265
- authorize: auth.authorizePrivate,
266
- passwordHeader: auth.passwordHeader,
267
- tokenHeader: auth.tokenHeader,
268
- passwordPolicy: opts.privateMedia?.passwordPolicy,
269
- }
270
- : undefined,
271
229
  });
272
- if (unlock && auth.authorizePrivate) {
273
- mountPrivateRoutes(app, {
274
- store,
275
- index: catalogue.asPrivateIndex(),
276
- unlock,
277
- basePath: opts.privateBasePath,
278
- authorize: auth.authorizePrivate,
279
- passwordPolicy: opts.privateMedia?.passwordPolicy,
280
- });
281
- }
282
230
  },
283
231
  };
284
232
  }
@@ -1,5 +1,4 @@
1
1
  import type { PlayableVideo, ProcessingStatus } from 'cursedbelt-core/media';
2
- import type { PrivateIndex } from '../../private-media/index-store';
3
2
  import type { MediaCtx } from '../types';
4
3
 
5
4
  // ── FileCatalogue: a tagged flat file system over the storage layer ──────────────
@@ -8,7 +7,7 @@ import type { MediaCtx } from '../types';
8
7
  // becomes a video-filtered view over it. Improves on ca's fs_nodes: FLAT (no folder tree), the
9
8
  // smart-view `fileType` is STORED at write (not re-derived per request), tags are a JOIN table
10
9
  // (indexed facets, not a JSON scan), listing is CURSOR-paginated, and processing state is typed
11
- // columns — not an opaque JSON blob. Reuses MediaStore, the media pipeline, and private-media.
10
+ // columns — not an opaque JSON blob. Reuses MediaStore and the media pipeline.
12
11
 
13
12
  export type FileType = 'image' | 'video' | 'audio' | 'doc' | 'code' | 'other';
14
13
 
@@ -39,7 +38,6 @@ export type FileRecord = {
39
38
  size: number;
40
39
  fileType: FileType;
41
40
  tags: string[];
42
- isPrivate: boolean;
43
41
  hasThumbnail: boolean;
44
42
  hasHls: boolean;
45
43
  hasSprites: boolean;
@@ -79,11 +77,8 @@ export type ListFilesOptions = {
79
77
  type?: FileType;
80
78
  /** Single-tag filter (indexed via the join table). */
81
79
  tag?: string;
82
- /** Name substring (public files only — private names are sealed). */
80
+ /** Name substring. */
83
81
  query?: string;
84
- /** Include the caller's private files (requires an unlocked set — the caller resolves which
85
- * ones actually open). Omitted ⇒ public only, matching ca's default listing. */
86
- includePrivate?: boolean;
87
82
  /** Filter to members of a specific collection. An unlock-gated collection yields empty unless
88
83
  * `includeUnlockable` is also true. */
89
84
  collectionId?: string;
@@ -116,14 +111,11 @@ export function classifyFileType(mime: string, isText = false): FileType {
116
111
  /** The catalogue contract. A SQLite implementation is the production default; the interface lets
117
112
  * an app swap it. All calls are owner-scoped via {@link MediaCtx} (one tenant never sees another). */
118
113
  /** Cheap owner-scoped aggregates (COUNT/SUM over the files table) — the admin
119
- * surface's "site stats" source. Private items report count + bytes only
120
- * (names/kinds stay sealed). */
114
+ * surface's "site stats" source. */
121
115
  export type CatalogueStats = {
122
116
  files: number;
123
117
  bytes: number;
124
118
  byType: Partial<Record<FileType, number>>;
125
- privateItems: number;
126
- privateBytes: number;
127
119
  processing: number;
128
120
  failed: number;
129
121
  };
@@ -133,7 +125,7 @@ export interface FileCatalogue {
133
125
  get(ctx: MediaCtx, id: string): Promise<FileRecord | null>;
134
126
  /** Owner-scoped aggregates for admin/stats surfaces. */
135
127
  stats(ctx: MediaCtx): Promise<CatalogueStats>;
136
- tagFacets(ctx: MediaCtx, opts?: { includePrivate?: boolean }): Promise<TagFacet[]>;
128
+ tagFacets(ctx: MediaCtx): Promise<TagFacet[]>;
137
129
  /** Ingest a public upload: persist bytes, classify, kick off processing (thumbnail/HLS). */
138
130
  ingest(
139
131
  ctx: MediaCtx,
@@ -160,9 +152,8 @@ export interface FileCatalogue {
160
152
  ): Promise<FileRecord | null>;
161
153
  /** Overwrite a file's BYTES in place — the identity (id, tags, collections, favorites, view
162
154
  * history) survives while mime/size/fileType re-derive, stale derivatives are wiped, and
163
- * processing re-kicks (video transcode / image thumbnail). Public files only — replacing an
164
- * encrypted private item throws (delete + re-upload instead). Returns the refreshed record,
165
- * or null if the id is unknown/other-owner. */
155
+ * processing re-kicks (video transcode / image thumbnail). Returns the refreshed record, or
156
+ * null if the id is unknown/other-owner. */
166
157
  replaceContent(
167
158
  ctx: MediaCtx,
168
159
  id: string,
@@ -189,15 +180,15 @@ export interface FileCatalogue {
189
180
  absPath: string,
190
181
  opts?: { tags?: string[] },
191
182
  ): Promise<{ imported: FileRecord[]; skipped: { path: string; reason: string }[] }>;
192
- /** Recover from a restart that lost in-flight transcodes: re-kick public video rows still marked
193
- * pending/processing (their source persists), and mark stuck private rows failed. Call once at
194
- * app startup. Returns how many were re-queued vs. failed. */
183
+ /** Recover from a restart that lost in-flight transcodes: re-kick video rows still marked
184
+ * pending/processing (their source persists), and mark rows that have exhausted their kick
185
+ * budget failed. Call once at app startup. Returns how many were re-queued vs. failed. */
195
186
  recoverProcessing(): { requeued: number; failed: number };
196
- /** Backfill grid thumbnails for finished public video rows that have no poster (landed while
187
+ /** Backfill grid thumbnails for finished video rows that have no poster (landed while
197
188
  * transcoding was off, or failed before the poster step). Re-kicks the poster pipeline for each.
198
189
  * Returns how many were re-queued. */
199
190
  backfillVideoPosters(): { requeued: number };
200
- /** Backfill grid thumbnails for public image rows with no poster (e.g. HEIC photos whose sharp
191
+ /** Backfill grid thumbnails for image rows with no poster (e.g. HEIC photos whose sharp
201
192
  * thumbnail failed before the ffmpeg fallback). Re-kicks the image pipeline. */
202
193
  backfillImageThumbnails(): { requeued: number };
203
194
 
@@ -235,51 +226,12 @@ export interface FileCatalogue {
235
226
  /** Resolve a video FileRecord to a PlayableVideo (URLs baked server-side). Non-video ⇒ null. */
236
227
  export type FileToPlayable = (ctx: MediaCtx, file: FileRecord) => Promise<PlayableVideo | null>;
237
228
 
238
- // ── Private (encrypted) files ─────────────────────────────────────────────────────
239
- // Private items live in the SAME catalogue (one source of truth), flagged is_private and holding
240
- // a SEALED descriptor + WRAPPED content key instead of plaintext name/tags. The crypto plumbing
241
- // (deriving the set key from a password/unlock token vs the server secret) stays at the route
242
- // layer — the catalogue takes an already-derived `setKey: Buffer`. See private-media/crypto.
243
-
244
- /** How a private item's content key is wrapped: `standard` (server-recoverable) vs `deniable`
245
- * (password-derived; zero-knowledge). Mirrors private-media `KeyMode`. */
246
- export type PrivateKeyMode = 'standard' | 'deniable';
247
-
248
- /** The OPAQUE locked-index row — only non-sensitive metadata (so a user can prune media whose
249
- * password is lost). NEVER name/tags/thumbnail: those are sealed. No set identifier is exposed. */
250
- export type PrivateFileItem = {
251
- id: string;
252
- size: number;
253
- createdAt: number;
254
- processingStatus: ProcessingStatus;
255
- processingProgress: number;
256
- hasHls: boolean;
257
- };
258
-
259
- /** Private-media capability of a catalogue. `createSqliteFileCatalogue` implements this alongside
260
- * {@link FileCatalogue}, storing private items as sealed rows in the SAME table (one source of
261
- * truth). Membership is by decrypt-success — no stored password/set hash.
262
- *
263
- * Deliberately small: the catalogue owns only INGEST (the new private-upload path). Everything
264
- * else — the locked index, unlock, try-a-password reveal, the gated content-key route, and the
265
- * encrypted-HLS byte serving — is the existing `mountPrivateRoutes`, which operates through a
266
- * {@link PrivateIndex}. `asPrivateIndex()` is the view that lets those routes read/write the
267
- * catalogue's private rows, so there is exactly one store and one set of routes. */
268
- export interface PrivateFileCatalogue {
269
- /** Ingest an encrypted video: stage plaintext (never persisted), transcode to AES-128 HLS, seal
270
- * the descriptor + wrap the content key under `setKey`, and insert a private row. */
271
- ingestPrivate(
272
- ctx: MediaCtx,
273
- upload: {
274
- name: string;
275
- mime: string;
276
- body: ReadableStream<Uint8Array>;
277
- size?: number;
278
- tags?: string[];
279
- },
280
- opts: { setKey: Buffer; keyMode: PrivateKeyMode },
281
- ): Promise<PrivateFileItem>;
282
- /** A {@link PrivateIndex} view over this catalogue's private rows — pass to `mountPrivateRoutes`
283
- * so unlock/index/reveal/key/HLS all operate on the catalogue. `remove` also drops the bytes. */
284
- asPrivateIndex(): PrivateIndex;
285
- }
229
+ // ── There is no private half, deliberately (removed 2026-09-17) ───────────────────
230
+ // A `PrivateFileCatalogue` used to sit here: sealed descriptors, wrapped content keys, a
231
+ // `KeyMode: 'standard' | 'deniable'` and membership "by decrypt-success — no stored password".
232
+ // It was 1,505 lines that no app ever mounted, over a byte store that encrypts nothing
233
+ // (binary-server's `exportFiles.ts`: *"Objects are stored unencrypted"*), so there was never a
234
+ // ciphertext behind it — and the owner chose to delete the whole module rather than carry a
235
+ // second, unexercised confidentiality story alongside the master lock, which is the one that
236
+ // actually guards these apps. This paragraph is the record; it is not an invitation to
237
+ // reintroduce it. The wall that IS real is `../../master-lock`.