cursedbelt-server 3.0.1 → 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 (77) hide show
  1. package/dist/server/auth/jwt.d.ts +1 -1
  2. package/dist/server/auth/jwt.js +2 -2
  3. package/dist/server/master-lock/accountsPage.d.ts +39 -0
  4. package/dist/server/master-lock/accountsPage.js +455 -0
  5. package/dist/server/master-lock/guard.js +39 -1
  6. package/dist/server/master-lock/index.d.ts +10 -2
  7. package/dist/server/master-lock/index.js +10 -2
  8. package/dist/server/master-lock/lockPage.d.ts +9 -0
  9. package/dist/server/master-lock/lockPage.js +44 -36
  10. package/dist/server/media-bun/streaming.d.ts +0 -1
  11. package/dist/server/media-bun/streaming.js +1 -1
  12. package/dist/server/storage/files/catalogue.d.ts +2 -2
  13. package/dist/server/storage/files/catalogue.js +18 -208
  14. package/dist/server/storage/files/fileRoutes.d.ts +2 -20
  15. package/dist/server/storage/files/fileRoutes.js +1 -74
  16. package/dist/server/storage/files/index.d.ts +1 -1
  17. package/dist/server/storage/files/index.js +1 -1
  18. package/dist/server/storage/files/migrations.js +9 -6
  19. package/dist/server/storage/files/stack.d.ts +6 -31
  20. package/dist/server/storage/files/stack.js +0 -27
  21. package/dist/server/storage/files/types.d.ts +10 -60
  22. package/dist/server/storage/files/types.js +9 -0
  23. package/dist/server/storage/index.d.ts +2 -2
  24. package/dist/server/storage/index.js +1 -1
  25. package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
  26. package/dist/server/storage/types.d.ts +0 -9
  27. package/dist/server/storage/types.js +2 -9
  28. package/package.json +2 -2
  29. package/src/leafSubpathsImportNothing.spec.ts +6 -4
  30. package/src/server/auth/jwt.ts +2 -2
  31. package/src/server/master-lock/accountsPage.spec.ts +231 -0
  32. package/src/server/master-lock/accountsPage.ts +465 -0
  33. package/src/server/master-lock/guard.ts +44 -1
  34. package/src/server/master-lock/index.ts +22 -2
  35. package/src/server/master-lock/lockPage.spec.ts +20 -16
  36. package/src/server/master-lock/lockPage.ts +45 -36
  37. package/src/server/media-bun/streaming.ts +0 -6
  38. package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
  39. package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
  40. package/src/server/storage/files/adapter.photo.spec.ts +2 -2
  41. package/src/server/storage/files/adapter.spec.ts +0 -1
  42. package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
  43. package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
  44. package/src/server/storage/files/catalogue.ts +19 -240
  45. package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
  46. package/src/server/storage/files/fileRoutes.spec.ts +3 -97
  47. package/src/server/storage/files/fileRoutes.ts +2 -96
  48. package/src/server/storage/files/index.ts +1 -4
  49. package/src/server/storage/files/migrations.ts +9 -7
  50. package/src/server/storage/files/stack.spec.ts +11 -111
  51. package/src/server/storage/files/stack.ts +9 -61
  52. package/src/server/storage/files/types.ts +20 -68
  53. package/src/server/storage/index.ts +0 -4
  54. package/src/server/storage/mountMediaRoutes.ts +1 -7
  55. package/src/server/storage/types.ts +2 -11
  56. package/src/shippedFilesAreTracked.spec.ts +3 -2
  57. package/dist/server/private-media/crypto.d.ts +0 -37
  58. package/dist/server/private-media/crypto.js +0 -111
  59. package/dist/server/private-media/index-store.d.ts +0 -31
  60. package/dist/server/private-media/index-store.js +0 -36
  61. package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
  62. package/dist/server/private-media/mountPrivateRoutes.js +0 -219
  63. package/dist/server/private-media/pipeline.d.ts +0 -37
  64. package/dist/server/private-media/pipeline.js +0 -97
  65. package/dist/server/private-media/unlock.d.ts +0 -52
  66. package/dist/server/private-media/unlock.js +0 -55
  67. package/src/server/private-media/blobRoute.spec.ts +0 -165
  68. package/src/server/private-media/crypto.spec.ts +0 -81
  69. package/src/server/private-media/crypto.ts +0 -155
  70. package/src/server/private-media/gatePolicy.spec.ts +0 -98
  71. package/src/server/private-media/index-store.ts +0 -83
  72. package/src/server/private-media/mountPrivateRoutes.ts +0 -265
  73. package/src/server/private-media/pipeline.spec.ts +0 -111
  74. package/src/server/private-media/pipeline.ts +0 -170
  75. package/src/server/private-media/routes.spec.ts +0 -265
  76. package/src/server/private-media/unlock.ts +0 -112
  77. package/src/server/storage/files/catalogue.private.spec.ts +0 -106
@@ -22,6 +22,15 @@ export interface LockPageOptions {
22
22
  export declare function lockPageHtml(options: LockPageOptions): string;
23
23
  /** The stylesheet. Follows the viewer's theme and commits to nothing else. */
24
24
  export declare const LOCK_STYLE = ":root{color-scheme:light dark;\n --ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;\n --ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;\n --ml-shadow:0 18px 48px rgba(16,18,22,.14)}\n@media (prefers-color-scheme:dark){:root{\n --ml-bg:#0d0f13;--ml-card:#171a20;--ml-text:#e7eaef;--ml-muted:#8b93a1;--ml-border:#2b3039;\n --ml-accent:#4f8cf7;--ml-accent-text:#0b0d11;--ml-danger:#f97066;\n --ml-shadow:0 18px 48px rgba(0,0,0,.55)}}\n*{box-sizing:border-box}\nbody{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;\n background:var(--ml-bg);color:var(--ml-text);\n font:15px/1.55 ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.card{width:100%;max-width:23rem;background:var(--ml-card);border:1px solid var(--ml-border);\n border-radius:16px;padding:28px 26px 24px;box-shadow:var(--ml-shadow);text-align:center}\n.glyph{font-size:30px;line-height:1;margin-bottom:10px}\nh1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}\n.note{margin:0 0 20px;font-size:13px;color:var(--ml-muted)}\n.field{display:block;text-align:left;margin-bottom:14px}\n.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}\ninput{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;\n background:var(--ml-bg);color:inherit;font:inherit}\ninput:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}\nbutton{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);\n color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}\nbutton[disabled]{opacity:.6;cursor:progress}\n.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}\n.status[data-tone=\"error\"]{color:var(--ml-danger)}\n.field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}\nbutton.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;\n font-size:12.5px;text-decoration:underline;text-underline-offset:3px}\nbutton.quiet:hover{color:var(--ml-text)}\nbutton.quiet[disabled]{opacity:.5}\n.hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);\n border-radius:10px;background:var(--ml-bg);font-size:12.5px}\n.hints dt{font-weight:650;color:var(--ml-text)}\n.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}\n.hints dd:last-child{margin-bottom:0}\n";
25
+ /**
26
+ * 🔴 The ONE browser copy of `deriveMasterLockVerifier`, shared by every page this feature
27
+ * serves. Pinned by `lockPage.spec.ts`, which evaluates it and compares against the
28
+ * TypeScript one. Do not "tidy" it apart, and do not write a second copy — interpolate this.
29
+ *
30
+ * It ends by publishing the function on `globalThis`, which is the seam the spec pulls it
31
+ * out through and the seam the accounts page's script calls it back through.
32
+ */
33
+ export declare const MASTER_LOCK_DERIVE_SOURCE = "function unb64u(text) {\n const padded = text.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const binary = atob(padded + \"=\".repeat((4 - (padded.length % 4)) % 4));\n const out = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);\n return out;\n}\nfunction b64u(bytes) {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\nasync function pbkdf2(material, salt, iterations) {\n const key = await crypto.subtle.importKey(\"raw\", material, \"PBKDF2\", false, [\"deriveBits\"]);\n const bits = await crypto.subtle.deriveBits(\n { name: \"PBKDF2\", salt: salt, iterations: iterations, hash: \"SHA-256\" }, key, 256);\n return new Uint8Array(bits);\n}\nasync function deriveVerifier(password, kdf) {\n const salt = unb64u(kdf.salt);\n const bytes = new TextEncoder().encode(password);\n const kek = await pbkdf2(bytes, salt, kdf.iter);\n const joined = new Uint8Array(kek.length + bytes.length);\n joined.set(kek, 0);\n joined.set(bytes, kek.length);\n return b64u(await pbkdf2(joined, salt, 1));\n}\nglobalThis.__masterLockDerive = deriveVerifier;";
25
34
  /**
26
35
  * The page's script.
27
36
  *
@@ -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
  const escapeHtml = (value) => value.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch] ?? ch);
@@ -125,35 +130,14 @@ button.quiet[disabled]{opacity:.5}
125
130
  .hints dd:last-child{margin-bottom:0}
126
131
  `;
127
132
  /**
128
- * The page's script.
133
+ * 🔴 The ONE browser copy of `deriveMasterLockVerifier`, shared by every page this feature
134
+ * serves. Pinned by `lockPage.spec.ts`, which evaluates it and compares against the
135
+ * TypeScript one. Do not "tidy" it apart, and do not write a second copy — interpolate this.
129
136
  *
130
- * Derives in the browser and sends only the verifier — the password itself never crosses
131
- * the network, which is what makes "saved nowhere" true rather than aspirational.
132
- *
133
- * 600,000 PBKDF2 rounds take a beat on a phone, so the button says so; a page that looks
134
- * frozen gets clicked again, and every extra click is another 600k rounds.
137
+ * It ends by publishing the function on `globalThis`, which is the seam the spec pulls it
138
+ * out through and the seam the accounts page's script calls it back through.
135
139
  */
136
- export const LOCK_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
137
-
138
- const form = document.getElementById("ml-form");
139
- const input = document.getElementById("ml-input");
140
- const confirmInput = document.getElementById("ml-confirm");
141
- const hintInput = document.getElementById("ml-hint");
142
- const submit = document.getElementById("ml-submit");
143
- const status = document.getElementById("ml-status");
144
- const forgot = document.getElementById("ml-forgot");
145
- const hints = document.getElementById("ml-hints");
146
-
147
- function say(message, tone) {
148
- status.textContent = message;
149
- if (tone) status.setAttribute("data-tone", tone);
150
- else status.removeAttribute("data-tone");
151
- }
152
-
153
- // ── derivation ───────────────────────────────────────────────────────────────
154
- // 🔴 Byte-identical to deriveMasterLockVerifier in cursedbelt-core/master-lock. Pinned by
155
- // lockPage.spec.ts, which evaluates THIS function and compares. Do not "tidy" it apart.
156
- function unb64u(text) {
140
+ export const MASTER_LOCK_DERIVE_SOURCE = `function unb64u(text) {
157
141
  const padded = text.replace(/-/g, "+").replace(/_/g, "/");
158
142
  const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
159
143
  const out = new Uint8Array(binary.length);
@@ -180,13 +164,37 @@ async function deriveVerifier(password, kdf) {
180
164
  joined.set(bytes, kek.length);
181
165
  return b64u(await pbkdf2(joined, salt, 1));
182
166
  }
183
- globalThis.__masterLockDerive = deriveVerifier;
167
+ globalThis.__masterLockDerive = deriveVerifier;`;
168
+ /**
169
+ * The page's script.
170
+ *
171
+ * Derives in the browser and sends only the verifier — the password itself never crosses
172
+ * the network, which is what makes "saved nowhere" true rather than aspirational.
173
+ *
174
+ * 600,000 PBKDF2 rounds take a beat on a phone, so the button says so; a page that looks
175
+ * frozen gets clicked again, and every extra click is another 600k rounds.
176
+ */
177
+ export const LOCK_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
178
+
179
+ // ── derivation ───────────────────────────────────────────────────────────────
180
+ ${MASTER_LOCK_DERIVE_SOURCE}
181
+
182
+ const form = document.getElementById("ml-form");
183
+ const input = document.getElementById("ml-input");
184
+ const confirmInput = document.getElementById("ml-confirm");
185
+ const hintInput = document.getElementById("ml-hint");
186
+ const submit = document.getElementById("ml-submit");
187
+ const status = document.getElementById("ml-status");
188
+ const forgot = document.getElementById("ml-forgot");
189
+ const hints = document.getElementById("ml-hints");
190
+
191
+ function say(message, tone) {
192
+ status.textContent = message;
193
+ if (tone) status.setAttribute("data-tone", tone);
194
+ else status.removeAttribute("data-tone");
195
+ }
184
196
 
185
197
  // ── the page ─────────────────────────────────────────────────────────────────
186
- // 🔴 Everything below this line touches \`document\` beyond \`getElementById\`, and
187
- // lockPage.spec.ts cuts the source at the marker above to evaluate the derivation with a
188
- // stub. Keep page state here, not in the prelude, or that test throws instead of running.
189
- //
190
198
  // Rendered by the server from THIS principal's own record. The server re-decides it on
191
199
  // every POST, so this only chooses which form to submit — it cannot grant anything.
192
200
  const enrolling = document.body.dataset.mode === "enroll";
@@ -53,7 +53,6 @@ export declare const hlsLadderArgs: (inPath: string, outDir: string, opts: {
53
53
  heights: number[];
54
54
  crf: number;
55
55
  hasAudio: boolean;
56
- keyInfoFile?: string;
57
56
  }) => string[];
58
57
  /** Poster: a frame `atSeconds` in (default ~1s, to skip black intros), scaled to the library
59
58
  * width, as JPEG. Pass a smaller seek (or 0) for very short clips — seeking past EOF yields no
@@ -142,7 +142,7 @@ export const hlsLadderArgs = (inPath, outDir, opts) => {
142
142
  const varMap = hs
143
143
  .map((h, i) => (opts.hasAudio ? `v:${i},a:${i},name:${h}` : `v:${i},name:${h}`))
144
144
  .join(' ');
145
- args.push('-f', 'hls', '-hls_time', '4', '-hls_playlist_type', 'vod', '-hls_segment_type', 'mpegts', ...(opts.keyInfoFile ? ['-hls_key_info_file', opts.keyInfoFile] : []), '-master_pl_name', 'master.m3u8', '-hls_segment_filename', path.join(outDir, '%v', 'seg_%05d.ts'), '-var_stream_map', varMap, path.join(outDir, '%v', 'playlist.m3u8'));
145
+ args.push('-f', 'hls', '-hls_time', '4', '-hls_playlist_type', 'vod', '-hls_segment_type', 'mpegts', '-master_pl_name', 'master.m3u8', '-hls_segment_filename', path.join(outDir, '%v', 'seg_%05d.ts'), '-var_stream_map', varMap, path.join(outDir, '%v', 'playlist.m3u8'));
146
146
  return args;
147
147
  };
148
148
  /** Poster: a frame `atSeconds` in (default ~1s, to skip black intros), scaled to the library
@@ -1,7 +1,7 @@
1
1
  import type { Database } from 'bun:sqlite';
2
2
  import { type MediaCtx, type MediaStore } from '../types';
3
3
  import { type ProcessOptions, type ProcessResult } from '../videoProcessor';
4
- import { type FileCatalogue, type PrivateFileCatalogue } from './types';
4
+ import { type FileCatalogue } from './types';
5
5
  /** Swappable video ingest→derivatives implementation. The default runs ffmpeg
6
6
  * locally ({@link processVideo}); a remote backend (binary-server) delegates
7
7
  * the heavy work to the storage host instead — see binaryServerProcessing. */
@@ -69,4 +69,4 @@ export type SqliteFileCatalogueOptions = {
69
69
  * with ffmpeg fallback). Inject a remote one when the byte store is remote. */
70
70
  imageDerivatives?: ImageDerivatives;
71
71
  };
72
- export declare function createSqliteFileCatalogue(opts: SqliteFileCatalogueOptions): FileCatalogue & PrivateFileCatalogue;
72
+ export declare function createSqliteFileCatalogue(opts: SqliteFileCatalogueOptions): FileCatalogue;
@@ -1,34 +1,11 @@
1
- import { mkdir, readdir, rm, stat as fsStat } from 'node:fs/promises';
1
+ import { readdir, stat as fsStat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { renderImage } from '../../media-bun/image';
4
- import { sealMeta } from '../../private-media/crypto';
5
- import { processPrivateBlob, processPrivateVideo } from '../../private-media/pipeline';
6
4
  import { ffmpegImageThumbnail, isBrowserRenderableImage } from '../imageThumbnail';
7
- import { fsDisplayKey, fsHlsPrefix, fsMp4Key, fsPosterKey, fsSourceKey, fsSpriteKey, fsVttKey, privateFsKey, } from '../types';
5
+ import { fsDisplayKey, fsHlsPrefix, fsMp4Key, fsPosterKey, fsSourceKey, fsSpriteKey, fsVttKey, } from '../types';
8
6
  import { processVideo } from '../videoProcessor';
9
7
  import { mimeByExt } from './mimeByExt';
10
8
  import { classifyFileType, } from './types';
11
- // Stream an upload body to a local plaintext file (staged for the private pipeline; wiped after).
12
- // Bun.write(dest, new Response(stream)) hangs in Bun 1.3.x — pump via a FileSink instead.
13
- async function streamToFile(body, dest) {
14
- await mkdir(path.dirname(dest), { recursive: true });
15
- const sink = Bun.file(dest).writer();
16
- const reader = body.getReader();
17
- let size = 0;
18
- try {
19
- while (true) {
20
- const { done, value } = await reader.read();
21
- if (done)
22
- break;
23
- size += value.byteLength;
24
- sink.write(value);
25
- }
26
- }
27
- finally {
28
- await sink.end();
29
- }
30
- return size;
31
- }
32
9
  // The general primary binary for a NON-video file (images/docs/…) lives here; video files store a
33
10
  // raw source (fsSourceKey) + derived mp4/HLS/poster/sprite via the shared video keyspace.
34
11
  export const fileBinaryKey = (owner, id) => `fs/${owner}/${id}`;
@@ -120,9 +97,9 @@ export function createSqliteFileCatalogue(opts) {
120
97
  const rand31 = () => Math.floor(Math.random() * RANDOM_MOD);
121
98
  const randKey = opts.randKey ?? rand31;
122
99
  const randomSeed = opts.randomSeed ?? rand31;
123
- const insertFile = db.query(`INSERT INTO files (id, owner, name, mime, size, file_type, is_private, processing_status, processing_progress, storage_backend, rand_key, created_at, updated_at)
124
- VALUES ($id, $owner, $name, $mime, $size, $type, 0, $status, $progress, 'disk', $rand, $now, $now)`);
125
- const insertTag = db.query(`INSERT OR IGNORE INTO file_tags (file_id, tag, owner, is_private) VALUES ($file, $tag, $owner, 0)`);
100
+ const insertFile = db.query(`INSERT INTO files (id, owner, name, mime, size, file_type, processing_status, processing_progress, storage_backend, rand_key, created_at, updated_at)
101
+ VALUES ($id, $owner, $name, $mime, $size, $type, $status, $progress, 'disk', $rand, $now, $now)`);
102
+ const insertTag = db.query(`INSERT OR IGNORE INTO file_tags (file_id, tag, owner) VALUES ($file, $tag, $owner)`);
126
103
  const deleteTags = db.query(`DELETE FROM file_tags WHERE file_id = $file`);
127
104
  const getRow = db.query(`SELECT * FROM files WHERE id = $id AND owner = $owner`);
128
105
  const deleteFile = db.query(`DELETE FROM files WHERE id = $id AND owner = $owner`);
@@ -140,20 +117,7 @@ export function createSqliteFileCatalogue(opts) {
140
117
  const replaceMeta = db.query(`UPDATE files SET name = $name, mime = $mime, size = $size, file_type = $type,
141
118
  processing_status = $status, processing_progress = $progress,
142
119
  has_hls = 0, has_thumbnail = 0, has_sprites = 0, duration = NULL, updated_at = $now
143
- WHERE id = $id AND owner = $owner AND is_private = 0`);
144
- // Private (encrypted) rows: name/tags are sealed in enc_name (sealedMeta); the wrapped AES-128
145
- // content key is in enc_dek; enc_key_type records the wrap mode. is_private = 1 keeps them out of
146
- // the public feed. These rows are surfaced to `mountPrivateRoutes` via `asPrivateIndex()`.
147
- const insertPrivate = db.query(`INSERT INTO files (id, owner, name, mime, size, file_type, is_private, has_hls, duration,
148
- processing_status, processing_progress, storage_backend, enc_name, enc_dek, enc_key_type, rand_key, created_at, updated_at)
149
- VALUES ($id, $owner, '', '', $size, $type, 1, 0, NULL, $status, $progress, 'disk', $sealed, $wrapped, $mode, $rand, $now, $now)`);
150
- const finishPrivate = db.query(`UPDATE files SET processing_status = $status, processing_progress = $progress, has_hls = $hls,
151
- duration = $duration, size = $size, enc_name = $sealed, enc_dek = $wrapped, updated_at = $now WHERE id = $id`);
152
- const getPrivateRow = db.query(`SELECT * FROM files WHERE id = $id AND owner = $owner AND is_private = 1`);
153
- const listPrivateFullRows = db.query(`SELECT * FROM files WHERE owner = $owner AND is_private = 1 ORDER BY created_at DESC, id DESC`);
154
- const updatePrivateRow = db.query(`UPDATE files SET size = $size, processing_status = $status, enc_name = $sealed, enc_dek = $wrapped,
155
- enc_key_type = $mode, updated_at = $now WHERE id = $id AND owner = $owner AND is_private = 1`);
156
- const deletePrivateRow = db.query(`DELETE FROM files WHERE id = $id AND owner = $owner AND is_private = 1`);
120
+ WHERE id = $id AND owner = $owner`);
157
121
  // ── Collection prepared statements ───────────────────────────────────────────────
158
122
  const insertCollection = db.query(`INSERT INTO file_collections (id, owner, name, kind, visibility, created_at, updated_at)
159
123
  VALUES ($id, $owner, $name, $kind, $visibility, $now, $now)
@@ -180,16 +144,6 @@ export function createSqliteFileCatalogue(opts) {
180
144
  SELECT f.id, f.owner, $now, 1 FROM files f WHERE f.id = $id AND f.owner = $owner
181
145
  ON CONFLICT(file_id) DO UPDATE SET viewed_at = excluded.viewed_at, view_count = view_count + 1`);
182
146
  const deleteFileViews = db.query(`DELETE FROM file_views WHERE file_id = $file`);
183
- const rowToPrivateItem = (r, owner) => ({
184
- id: r.id,
185
- owner,
186
- sizeBytes: r.size,
187
- createdAt: r.created_at,
188
- status: r.processing_status,
189
- keyMode: (r.enc_key_type || 'deniable'),
190
- wrappedContentKey: r.enc_dek,
191
- sealedMeta: r.enc_name,
192
- });
193
147
  const tagsFor = (ids) => {
194
148
  const map = new Map();
195
149
  if (ids.length === 0)
@@ -235,7 +189,6 @@ export function createSqliteFileCatalogue(opts) {
235
189
  size: row.size,
236
190
  fileType: row.file_type,
237
191
  tags,
238
- isPrivate: row.is_private === 1,
239
192
  hasThumbnail: row.has_thumbnail === 1,
240
193
  hasHls: row.has_hls === 1,
241
194
  hasSprites: row.has_sprites === 1,
@@ -416,8 +369,7 @@ export function createSqliteFileCatalogue(opts) {
416
369
  return rows[0];
417
370
  };
418
371
  const statsByType = db.query(`SELECT file_type, COUNT(*) AS n, SUM(size) AS bytes FROM files
419
- WHERE owner = $owner AND is_private = 0 GROUP BY file_type`);
420
- const statsPrivate = db.query(`SELECT COUNT(*) AS n, SUM(size) AS bytes FROM files WHERE owner = $owner AND is_private = 1`);
372
+ WHERE owner = $owner GROUP BY file_type`);
421
373
  const statsStatus = db.query(`SELECT processing_status, COUNT(*) AS n FROM files
422
374
  WHERE owner = $owner GROUP BY processing_status`);
423
375
  return {
@@ -431,7 +383,6 @@ export function createSqliteFileCatalogue(opts) {
431
383
  files += row.n;
432
384
  bytes += row.bytes ?? 0;
433
385
  }
434
- const priv = statsPrivate.get({ $owner: owner });
435
386
  let processing = 0;
436
387
  let failed = 0;
437
388
  for (const row of statsStatus.all({ $owner: owner })) {
@@ -444,8 +395,6 @@ export function createSqliteFileCatalogue(opts) {
444
395
  files,
445
396
  bytes,
446
397
  byType,
447
- privateItems: priv?.n ?? 0,
448
- privateBytes: priv?.bytes ?? 0,
449
398
  processing,
450
399
  failed,
451
400
  };
@@ -456,7 +405,6 @@ export function createSqliteFileCatalogue(opts) {
456
405
  const params = { $owner: owner, $limit: limit + 1 };
457
406
  const where = [
458
407
  'f.owner = $owner',
459
- 'f.is_private = 0',
460
408
  options.trashedOnly ? 'f.trashed_at IS NOT NULL' : 'f.trashed_at IS NULL',
461
409
  ];
462
410
  let from = 'files f';
@@ -547,7 +495,7 @@ export function createSqliteFileCatalogue(opts) {
547
495
  },
548
496
  async tagFacets(ctx) {
549
497
  const rows = db
550
- .query(`SELECT tag, COUNT(*) as count FROM file_tags WHERE owner = $owner AND is_private = 0
498
+ .query(`SELECT tag, COUNT(*) as count FROM file_tags WHERE owner = $owner
551
499
  GROUP BY tag ORDER BY count DESC, tag ASC`)
552
500
  .all({ $owner: ownerOf(ctx) });
553
501
  return rows;
@@ -591,9 +539,6 @@ export function createSqliteFileCatalogue(opts) {
591
539
  const row = getRow.get({ $id: id, $owner: owner });
592
540
  if (!row)
593
541
  return null;
594
- if (row.is_private === 1) {
595
- throw new Error('replaceContent: private files cannot be replaced in place');
596
- }
597
542
  const isVideo = upload.mime.startsWith('video/');
598
543
  // Store the new bytes FIRST — a failed write leaves the old row + bytes fully intact.
599
544
  const key = isVideo ? fsSourceKey(owner, id) : fileBinaryKey(owner, id);
@@ -728,24 +673,21 @@ export function createSqliteFileCatalogue(opts) {
728
673
  },
729
674
  recoverProcessing() {
730
675
  // A restart mid-transcode leaves a row stuck in `processing`/`pending` — the in-process
731
- // job is gone. For PUBLIC videos the source still sits at fsSourceKey, so re-kick the pipeline.
732
- // PRIVATE rows can't be re-kicked at boot (the set key isn't available) — video or blob alike,
733
- // mark them failed; the user re-unlocks + re-uploads. Public non-video rows are `ready` at
734
- // insert and never appear here. (Ported from ca's recoverOnBoot; our jobs are in-process,
735
- // so recovery is app-invoked at startup rather than a persistent queue.)
676
+ // job is gone. The source still sits at fsSourceKey, so re-kick the pipeline. Non-video rows
677
+ // are `ready` at insert and never appear here. (Ported from ca's recoverOnBoot; our jobs are
678
+ // in-process, so recovery is app-invoked at startup rather than a persistent queue.)
736
679
  // Re-kicks are CAPPED at maxProcessingAttempts total kicks: if the transcode itself is what
737
680
  // keeps killing the process (2026-07-04 prod outage: ffmpeg OOM on a 1GB host → systemd
738
681
  // restart → unconditional re-kick → OOM again, forever), the row must land on `failed`
739
682
  // instead of holding the whole service down.
740
683
  const stuck = db
741
- .query(`SELECT id, owner, is_private, processing_attempts FROM files
742
- WHERE (file_type = 'video' OR is_private = 1)
743
- AND processing_status IN ('pending', 'processing')`)
684
+ .query(`SELECT id, owner, processing_attempts FROM files
685
+ WHERE file_type = 'video' AND processing_status IN ('pending', 'processing')`)
744
686
  .all();
745
687
  let requeued = 0;
746
688
  let failed = 0;
747
689
  for (const r of stuck) {
748
- if (r.is_private === 1 || r.processing_attempts >= maxProcessingAttempts) {
690
+ if (r.processing_attempts >= maxProcessingAttempts) {
749
691
  setProcessing.run({ $id: r.id, $status: 'failed', $progress: 0, $now: now() });
750
692
  failed++;
751
693
  }
@@ -756,14 +698,14 @@ export function createSqliteFileCatalogue(opts) {
756
698
  }
757
699
  return { requeued, failed };
758
700
  },
759
- // Backfill grid thumbnails for PUBLIC video rows that finished (ready/failed) without a poster —
701
+ // Backfill grid thumbnails for video rows that finished (ready/failed) without a poster —
760
702
  // e.g. rows that landed while transcodeVideos was off entirely, or that failed a full transcode
761
703
  // before the poster step. Re-kicks the (now poster-capable) pipeline; the attempts counter is
762
704
  // reset first so the maxProcessingAttempts cap doesn't pre-empt a fresh, cheaper poster-only run.
763
705
  backfillVideoPosters() {
764
706
  const missing = db
765
707
  .query(`SELECT id, owner FROM files
766
- WHERE file_type = 'video' AND is_private = 0 AND has_thumbnail = 0
708
+ WHERE file_type = 'video' AND has_thumbnail = 0
767
709
  AND processing_status IN ('ready', 'failed')`)
768
710
  .all();
769
711
  // Serialize: on the memory-constrained hosts this exists for, running every poster extract
@@ -775,7 +717,7 @@ export function createSqliteFileCatalogue(opts) {
775
717
  }, Promise.resolve());
776
718
  return { requeued: missing.length };
777
719
  },
778
- // Backfill grid thumbnails for PUBLIC image rows with no poster — e.g. HEIC/HEVC iPhone photos
720
+ // Backfill grid thumbnails for image rows with no poster — e.g. HEIC/HEVC iPhone photos
779
721
  // whose sharp thumbnail silently failed before the ffmpeg fallback existed. Re-kicks the (now
780
722
  // HEIC-capable) image pipeline, serialized so at most one ffmpeg decode runs at a time.
781
723
  backfillImageThumbnails() {
@@ -785,7 +727,7 @@ export function createSqliteFileCatalogue(opts) {
785
727
  // present, so a warm boot re-runs nothing.
786
728
  const candidates = db
787
729
  .query(`SELECT id, owner, mime, has_thumbnail FROM files
788
- WHERE file_type = 'image' AND is_private = 0
730
+ WHERE file_type = 'image'
789
731
  AND (has_thumbnail = 0 OR mime IN ('image/heic', 'image/heif'))`)
790
732
  .all();
791
733
  void candidates.reduce(async (prev, r) => {
@@ -897,137 +839,5 @@ export function createSqliteFileCatalogue(opts) {
897
839
  // Upsert-gated to the caller's own files; unknown/other-owner ids record nothing.
898
840
  recordViewStmt.run({ $id: fileId, $owner: ownerOf(ctx), $now: now() });
899
841
  },
900
- async ingestPrivate(ctx, upload, cfg) {
901
- const owner = ownerOf(ctx);
902
- const id = crypto.randomUUID();
903
- const ts = now();
904
- // Seal the descriptor NOW — sealing is independent of the transcode (only the wrapped content
905
- // KEY needs the pipeline). Storing enc_name at insert means a try-a-password reveal matches the
906
- // item immediately, before its HLS finishes encrypting (no seal race).
907
- const descriptor = {
908
- name: upload.name,
909
- mime: upload.mime,
910
- tags: upload.tags ?? [],
911
- };
912
- const sealed = sealMeta(cfg.setKey, descriptor);
913
- const isVideo = upload.mime.startsWith('video/');
914
- insertPrivate.run({
915
- $id: id,
916
- $owner: owner,
917
- $size: 0,
918
- $type: classifyFileType(upload.mime),
919
- $mode: cfg.keyMode,
920
- $status: 'processing',
921
- $progress: 0,
922
- $sealed: sealed,
923
- $wrapped: '',
924
- $rand: randKey(),
925
- $now: ts,
926
- });
927
- // Stage plaintext to a scratch file, then encrypt: video → AES-128 HLS ladder; anything
928
- // else (photo/doc/audio) → one AES-128-CTR blob at privateSourceKey. Plaintext is never
929
- // persisted either way. We MUST await: the wrapped key is produced by the pipeline and
930
- // there is nothing to serve without it.
931
- const srcPath = path.join(workDir, 'ingest', `${owner}-${id}.src`);
932
- let size = 0;
933
- try {
934
- size = await streamToFile(upload.body, srcPath);
935
- const res = isVideo
936
- ? await processPrivateVideo({
937
- store,
938
- workDir,
939
- owner,
940
- id,
941
- sourcePath: srcPath,
942
- setKey: cfg.setKey,
943
- ladderHeights: opts.ladderHeights,
944
- onProgress: (status, pct) => setProcessing.run({ $id: id, $status: status, $progress: pct, $now: now() }),
945
- })
946
- : {
947
- ...(await processPrivateBlob({
948
- store,
949
- owner,
950
- id,
951
- sourcePath: srcPath,
952
- setKey: cfg.setKey,
953
- })),
954
- durationSeconds: 0,
955
- hasHls: false,
956
- };
957
- finishPrivate.run({
958
- $id: id,
959
- $status: res.status,
960
- $progress: res.status === 'ready' ? 100 : 0,
961
- $hls: res.hasHls ? 1 : 0,
962
- $duration: res.durationSeconds || null,
963
- $size: size,
964
- $sealed: sealed,
965
- $wrapped: res.wrappedContentKey ?? '',
966
- $now: now(),
967
- });
968
- }
969
- finally {
970
- await rm(srcPath, { force: true }).catch(() => { });
971
- }
972
- const row = getRow.get({ $id: id, $owner: owner });
973
- return {
974
- id,
975
- size,
976
- createdAt: ts,
977
- processingStatus: row.processing_status,
978
- processingProgress: row.processing_progress,
979
- hasHls: row.has_hls === 1,
980
- };
981
- },
982
- asPrivateIndex() {
983
- // A PrivateIndex view over the catalogue's private rows — the seam mountPrivateRoutes reads.
984
- // `owner` here is the private owner axis the routes pass (a userId), matching how ingestPrivate
985
- // stored the row (both go through the files.owner column).
986
- return {
987
- async add(item) {
988
- insertPrivate.run({
989
- $id: item.id,
990
- $owner: item.owner,
991
- $size: item.sizeBytes,
992
- // External index writers predate blob items and only ever added videos.
993
- $type: 'video',
994
- $mode: item.keyMode,
995
- $status: item.status,
996
- $progress: item.status === 'ready' ? 100 : 0,
997
- $sealed: item.sealedMeta,
998
- $wrapped: item.wrappedContentKey,
999
- $rand: randKey(),
1000
- $now: now(),
1001
- });
1002
- },
1003
- async get(owner, id) {
1004
- const row = getPrivateRow.get({ $id: id, $owner: owner });
1005
- return row ? rowToPrivateItem(row, owner) : null;
1006
- },
1007
- async list(owner) {
1008
- return listPrivateFullRows.all({ $owner: owner }).map((r) => rowToPrivateItem(r, owner));
1009
- },
1010
- async update(owner, id, patch) {
1011
- const cur = getPrivateRow.get({ $id: id, $owner: owner });
1012
- if (!cur)
1013
- return;
1014
- const merged = { ...rowToPrivateItem(cur, owner), ...patch };
1015
- updatePrivateRow.run({
1016
- $id: id,
1017
- $owner: owner,
1018
- $size: merged.sizeBytes,
1019
- $status: merged.status,
1020
- $sealed: merged.sealedMeta,
1021
- $wrapped: merged.wrappedContentKey,
1022
- $mode: merged.keyMode,
1023
- $now: now(),
1024
- });
1025
- },
1026
- async remove(owner, id) {
1027
- await store.removePrefix(privateFsKey(owner, id));
1028
- deletePrivateRow.run({ $id: id, $owner: owner });
1029
- },
1030
- };
1031
- },
1032
842
  };
1033
843
  }
@@ -1,9 +1,8 @@
1
1
  import type { Context, Hono } from 'hono';
2
- import type { UnlockPasswordPolicy, UnlockService } from '../../private-media/unlock';
3
2
  import type { StreamTokenService } from '../streamToken';
4
3
  import type { MediaCtx, MediaStore } from '../types';
5
4
  import type { ChunkedUploadService } from './chunkedUpload';
6
- import { type FileCatalogue, type PrivateFileCatalogue } from './types';
5
+ import { type FileCatalogue } from './types';
7
6
  /** The 413 message a single-PUT over the cap returns — the client treats it as a hard error. */
8
7
  export declare const SINGLE_UPLOAD_TOO_LARGE = "file too large for a single request \u2014 use chunked upload";
9
8
  export interface FileUrls {
@@ -26,24 +25,7 @@ export type MountFileRoutesOptions = {
26
25
  /** Session auth for the upload PUTs (a fetch CAN send the Bearer). Return a ctx or null. */
27
26
  authorizeUpload?: (c: Context) => Promise<MediaCtx | null> | MediaCtx | null;
28
27
  /** The catalogue uploads land in. Required for the upload routes to be mounted. */
29
- catalogue?: FileCatalogue & PrivateFileCatalogue;
30
- /** Wire private uploads: derive a per-user set key from the request (password header ⇒ deniable,
31
- * absent ⇒ standard server key). Omit ⇒ the private-upload route is disabled. */
32
- privateUpload?: {
33
- unlock: UnlockService;
34
- authorize: (c: Context) => Promise<{
35
- userId: string;
36
- userSalt: string;
37
- } | null>;
38
- /** Header carrying the plaintext password for a deniable set (default 'x-private-password'). */
39
- passwordHeader?: string;
40
- /** Header carrying an UNLOCK TOKEN (an already-unlocked session) — takes precedence over the
41
- * password header; setKey + keyMode come from the verified grant (default 'x-unlock-token'). */
42
- tokenHeader?: string;
43
- /** What a supplied password means (default derive — deniable set). `gate` verifies it
44
- * against the app's accounts and wraps under the recoverable server key instead. */
45
- passwordPolicy?: UnlockPasswordPolicy;
46
- };
28
+ catalogue?: FileCatalogue;
47
29
  /** Wire large-file chunked uploads (direct-to-backend). Omit ⇒ init/complete routes are absent
48
30
  * and clients use the streamed `PUT /upload`. Uses the same `authorizeUpload` guard. */
49
31
  chunked?: ChunkedUploadService;
@@ -1,7 +1,7 @@
1
1
  import { MAX_SINGLE_UPLOAD_BYTES } from 'cursedbelt-core/media/upload-limits';
2
2
  import { serveRange } from '../streamRange';
3
3
  import { fileBinaryKey } from './catalogue';
4
- import { FILE_SORTS, } from './types';
4
+ import { FILE_SORTS } from './types';
5
5
  // ── File binary + upload routes ─────────────────────────────────────────────────
6
6
  // The FileCatalogue's byte edge for GENERAL files (images/docs/audio/any) — the piece
7
7
  // mountMediaRoutes (video-only: mp4/hls/poster/sprite) doesn't cover. A file's primary binary
@@ -144,79 +144,6 @@ export function mountFileRoutes(app, opts) {
144
144
  }
145
145
  });
146
146
  }
147
- // PUT a PRIVATE upload → catalogue.ingestPrivate under a set key derived from the request:
148
- // either an UNLOCK TOKEN (an already-unlocked session — same mechanism the content-key route
149
- // uses) or a password header (deniable) / neither (standard server key).
150
- const pv = opts.privateUpload;
151
- if (pv && catalogue) {
152
- const passwordHeader = pv.passwordHeader ?? 'x-private-password';
153
- const tokenHeader = pv.tokenHeader ?? 'x-unlock-token';
154
- app.put(`${base}/upload-private`, async (c) => {
155
- const who = await pv.authorize(c);
156
- if (!who)
157
- return c.json({ error: 'unauthorized' }, 401);
158
- const raw = c.req.raw.body;
159
- if (!raw)
160
- return c.json({ error: 'empty body' }, 400);
161
- const body = guardSingleUpload(c, raw);
162
- if (body instanceof Response)
163
- return body;
164
- let setKey;
165
- let keyMode;
166
- const unlockToken = c.req.header(tokenHeader) || undefined;
167
- if (unlockToken) {
168
- // Token path: recover the sealed set key + mode from the grant. The token must belong
169
- // to the session caller.
170
- const grant = await pv.unlock.verify(unlockToken);
171
- if (!grant || grant.userId !== who.userId) {
172
- return c.json({ error: 'invalid unlock token' }, 401);
173
- }
174
- setKey = grant.setKey;
175
- keyMode = grant.mode;
176
- }
177
- else {
178
- // Password path: derive fresh from the header (deniable) or the server key. Under the
179
- // gate policy the password is an access check only — verify, then wrap standard.
180
- const password = c.req.header(passwordHeader) || undefined;
181
- const policy = pv.passwordPolicy ?? { mode: 'derive' };
182
- let derivePassword = password;
183
- if (policy.mode === 'gate' && password) {
184
- if (!(await policy.verifyPassword(who, password))) {
185
- return c.json({ error: 'invalid password' }, 401);
186
- }
187
- derivePassword = undefined;
188
- }
189
- setKey = pv.unlock.deriveSetKey({
190
- userId: who.userId,
191
- userSalt: who.userSalt,
192
- password: derivePassword,
193
- });
194
- keyMode = derivePassword ? 'deniable' : 'standard';
195
- }
196
- // The private owner axis is the userId (deniability is personal), matching mountPrivateRoutes.
197
- const ctx = { userId: who.userId, tenantId: who.userId };
198
- const tags = c.req.header('x-tags');
199
- try {
200
- const item = await catalogue.ingestPrivate(ctx, {
201
- name: c.req.header('x-filename') ?? 'upload',
202
- mime: c.req.header('content-type') ?? 'application/octet-stream',
203
- body,
204
- tags: tags
205
- ? tags
206
- .split(',')
207
- .map((t) => t.trim())
208
- .filter(Boolean)
209
- : undefined,
210
- }, { setKey, keyMode });
211
- return c.json({ item });
212
- }
213
- catch (e) {
214
- if (isUploadTooLarge(e))
215
- return c.json({ error: SINGLE_UPLOAD_TOO_LARGE }, 413);
216
- throw e;
217
- }
218
- });
219
- }
220
147
  // Chunked upload (large files, direct-to-backend). init → the client PUTs each chunk straight to
221
148
  // the backend (URLs the app minted) → complete registers the row over the assembled bytes. The
222
149
  // bytes never traverse this origin.