cursedbelt-server 3.0.1 โ†’ 4.1.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 (84) 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/binaryStore.d.ts +385 -0
  13. package/dist/server/storage/binaryStore.js +739 -0
  14. package/dist/server/storage/binaryStoreFake.d.ts +56 -0
  15. package/dist/server/storage/binaryStoreFake.js +63 -0
  16. package/dist/server/storage/files/catalogue.d.ts +2 -2
  17. package/dist/server/storage/files/catalogue.js +18 -208
  18. package/dist/server/storage/files/fileRoutes.d.ts +2 -20
  19. package/dist/server/storage/files/fileRoutes.js +1 -74
  20. package/dist/server/storage/files/index.d.ts +1 -1
  21. package/dist/server/storage/files/index.js +1 -1
  22. package/dist/server/storage/files/migrations.js +9 -6
  23. package/dist/server/storage/files/stack.d.ts +6 -31
  24. package/dist/server/storage/files/stack.js +0 -27
  25. package/dist/server/storage/files/types.d.ts +10 -60
  26. package/dist/server/storage/files/types.js +9 -0
  27. package/dist/server/storage/index.d.ts +2 -2
  28. package/dist/server/storage/index.js +1 -1
  29. package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
  30. package/dist/server/storage/types.d.ts +0 -9
  31. package/dist/server/storage/types.js +2 -9
  32. package/package.json +14 -2
  33. package/src/leafSubpathsImportNothing.spec.ts +30 -4
  34. package/src/server/auth/jwt.ts +2 -2
  35. package/src/server/master-lock/accountsPage.spec.ts +231 -0
  36. package/src/server/master-lock/accountsPage.ts +465 -0
  37. package/src/server/master-lock/guard.ts +44 -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/binaryStore.spec.ts +908 -0
  43. package/src/server/storage/binaryStore.ts +1049 -0
  44. package/src/server/storage/binaryStoreFake.ts +111 -0
  45. package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
  46. package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
  47. package/src/server/storage/files/adapter.photo.spec.ts +2 -2
  48. package/src/server/storage/files/adapter.spec.ts +0 -1
  49. package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
  50. package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
  51. package/src/server/storage/files/catalogue.ts +19 -240
  52. package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
  53. package/src/server/storage/files/fileRoutes.spec.ts +3 -97
  54. package/src/server/storage/files/fileRoutes.ts +2 -96
  55. package/src/server/storage/files/index.ts +1 -4
  56. package/src/server/storage/files/migrations.ts +9 -7
  57. package/src/server/storage/files/stack.spec.ts +11 -111
  58. package/src/server/storage/files/stack.ts +9 -61
  59. package/src/server/storage/files/types.ts +20 -68
  60. package/src/server/storage/index.ts +0 -4
  61. package/src/server/storage/mountMediaRoutes.ts +1 -7
  62. package/src/server/storage/types.ts +2 -11
  63. package/src/shippedFilesAreTracked.spec.ts +3 -2
  64. package/dist/server/private-media/crypto.d.ts +0 -37
  65. package/dist/server/private-media/crypto.js +0 -111
  66. package/dist/server/private-media/index-store.d.ts +0 -31
  67. package/dist/server/private-media/index-store.js +0 -36
  68. package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
  69. package/dist/server/private-media/mountPrivateRoutes.js +0 -219
  70. package/dist/server/private-media/pipeline.d.ts +0 -37
  71. package/dist/server/private-media/pipeline.js +0 -97
  72. package/dist/server/private-media/unlock.d.ts +0 -52
  73. package/dist/server/private-media/unlock.js +0 -55
  74. package/src/server/private-media/blobRoute.spec.ts +0 -165
  75. package/src/server/private-media/crypto.spec.ts +0 -81
  76. package/src/server/private-media/crypto.ts +0 -155
  77. package/src/server/private-media/gatePolicy.spec.ts +0 -98
  78. package/src/server/private-media/index-store.ts +0 -83
  79. package/src/server/private-media/mountPrivateRoutes.ts +0 -265
  80. package/src/server/private-media/pipeline.spec.ts +0 -111
  81. package/src/server/private-media/pipeline.ts +0 -170
  82. package/src/server/private-media/routes.spec.ts +0 -265
  83. package/src/server/private-media/unlock.ts +0 -112
  84. 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
@@ -0,0 +1,385 @@
1
+ import type { FileTokenClaims } from './types';
2
+ /**
3
+ * The claims binary-server verifies, re-exported from `./types` (the fleet's single
4
+ * source of truth โ€” `cursedbelt-server/storage/types`). See there for the per-field
5
+ * docs. Kept exported here so the call sites that took it from the app-side copy of
6
+ * this module read unchanged.
7
+ */
8
+ export type { FileTokenClaims };
9
+ export interface BinaryStoreConfig {
10
+ /** e.g. `http://127.0.0.1:3099` or `https://binary-server.cursedalchemy.com`. */
11
+ baseUrl: string;
12
+ /** Tenant id โ€” the first path segment and the token scope, e.g. `notes`. */
13
+ appId: string;
14
+ /** Must byte-match the issuer registered in binary-server's `apps` table. */
15
+ issuer: string;
16
+ /** PKCS8 private PEM (raw, `\n`-escaped, or base64-wrapped โ€” all accepted). */
17
+ privateKey: string;
18
+ /** Enables `remove`/`removePrefix` (sent as `x-internal-secret`). */
19
+ internalSecret?: string;
20
+ /**
21
+ * Where THIS PROCESS should reach binary-server, when that is somewhere cheaper than
22
+ * the public name โ€” `http://127.0.0.1:3099` for a deployment sharing the Mac with it.
23
+ * Defaults to {@link BinaryStoreConfig.baseUrl}. Used by `get`, `meta` and `stat`;
24
+ * never by `mediaUrl`, whose output is handed to a browser.
25
+ */
26
+ internalBaseUrl?: string;
27
+ /**
28
+ * Above this many bytes, {@link BinaryStore.put} takes the CHUNKED path by
29
+ * itself. Defaults to {@link AUTO_CHUNK_THRESHOLD_BYTES}; pass `Infinity` to
30
+ * turn the fallback off and get the pre-2026-08-08 behavior.
31
+ */
32
+ autoChunkBytes?: number;
33
+ /**
34
+ * How long a "this key is not here" is believed. Defaults to
35
+ * {@link BINARY_ABSENCE_TTL_MS}; the constant carries the owner's reasoning and
36
+ * an app should have a measured reason to differ from it.
37
+ */
38
+ absenceTtlMs?: number;
39
+ /** Injected so the memo's expiry is testable without a real clock. */
40
+ now?: () => number;
41
+ }
42
+ /**
43
+ * Which keys this process has read from the wire far too often, worst first โ€”
44
+ * empty on every healthy deployment, which is what makes it a usable alarm.
45
+ *
46
+ * Entries older than one window are dropped as they are read, so a burst that
47
+ * stopped stops being reported rather than becoming a permanent accusation.
48
+ */
49
+ export declare function repeatedBinaryKeys(now?: () => number): RepeatedBinaryKey[];
50
+ /** Test seam โ€” the register is process-wide, so a spec must be able to clear it. */
51
+ export declare function resetRepeatedBinaryKeys(): void;
52
+ /**
53
+ * Did this request ask for a FRESH answer โ€” i.e. is it a hard refresh?
54
+ *
55
+ * ๐Ÿ”ด The owner's first escape hatch, and the reason it is a shared function rather
56
+ * than an inline header read in each app: *"if the no is remembered an hour then I
57
+ * could fix it but not be able to tell for an hour."* A browser hard-refresh sends
58
+ * `Cache-Control: no-cache` (older ones send `Pragma`), so reloading the picture is
59
+ * the repair โ€” as long as every app spells the check the same way.
60
+ */
61
+ export declare function wantsFresh(req: {
62
+ headers: Headers;
63
+ } | Headers): boolean;
64
+ /**
65
+ * ๐Ÿ”ด The size at which a single-request `put` stops being safe, FLEET-WIDE.
66
+ *
67
+ * binary-server is published through a Cloudflare Tunnel, and the edge refuses a
68
+ * request body over ~100 MB with a **413 before it ever reaches the origin**. So
69
+ * the ceiling is not bs's and not the caller's โ€” it belongs to a hop neither end
70
+ * controls, and every app that stores a large blob inherits it.
71
+ *
72
+ * `apps/roms` hit it on 2026-08-08: eight genuine DS cartridges (128โ€“512 MB)
73
+ * came back `[binary-store] put rom/<digest>: 413` and it read as eight
74
+ * mysterious ingest failures rather than as one limit. roms fixed it locally,
75
+ * which left the trap in place for every other app's attachments and for any
76
+ * future video/media path โ€” so the threshold lives HERE now and `put` applies it
77
+ * itself.
78
+ *
79
+ * 64 MiB, not 100: the edge's number is approximate and includes headers and any
80
+ * transfer encoding, and the chunked path costs one extra request for a blob
81
+ * that only just crosses the line. Being early is free; being late is a 413.
82
+ */
83
+ export declare const AUTO_CHUNK_THRESHOLD_BYTES: number;
84
+ export interface PutOptions {
85
+ /** The user-facing display name ("My Dog Video") โ€” binary-server stores it in its
86
+ * db ONLY (never on disk), for its inspector/UIs. Optional everywhere. */
87
+ title?: string;
88
+ /**
89
+ * This key IS the digest of these bytes, so they can never change under it.
90
+ * binary-server then serves the object `public, max-age=31536000, immutable`
91
+ * instead of the one-day default โ€” no daily revalidation against the home
92
+ * uplink for bytes that are the same bytes by construction, and cache-busting
93
+ * needs no version segment because different bytes are a different key.
94
+ *
95
+ * Only pass it for a genuinely content-addressed key. A wrong `true` pins stale
96
+ * bytes at the edge and in every browser for a year.
97
+ */
98
+ immutable?: boolean;
99
+ }
100
+ export interface MediaUrlOptions {
101
+ filename?: string;
102
+ disposition?: 'inline' | 'attachment';
103
+ ttlSeconds?: number;
104
+ /**
105
+ * Mint a URL that is BYTE-IDENTICAL for every call inside the same window of
106
+ * this many seconds (the token's `iat`/`exp` are snapped to the window rather
107
+ * than to the clock), and valid for two windows so one minted at the very end
108
+ * of a window is still good.
109
+ *
110
+ * The problem it solves: a fresh token per call means a fresh URL per call, and
111
+ * a browser's HTTP cache keys on the URL โ€” so an `<img src>` re-rendered or a
112
+ * ROM re-launched re-downloads bytes it already has, however long the
113
+ * Cache-Control says. (The Cloudflare edge is unaffected either way; its cache
114
+ * key strips `token`.) The cost is a longer-lived bearer URL, so keep the
115
+ * window as short as the caching win allows and leave it unset for anything
116
+ * whose leak matters more than its bytes.
117
+ */
118
+ stableWindowSeconds?: number;
119
+ }
120
+ export interface BlobMeta {
121
+ size: number;
122
+ mime: string | null;
123
+ /** The display name the uploader set (`PutOptions.title`), if any. */
124
+ title: string | null;
125
+ /** sha256 binary-server computed post-write. Audit-only โ€” keys are app-chosen. */
126
+ checksum: string | null;
127
+ createdAt?: string;
128
+ updatedAt?: string;
129
+ }
130
+ export interface PutLargeOptions extends PutOptions {
131
+ /** Bytes per chunk. Default 16 MiB โ€” big enough that a 500 MB upload is ~32 requests,
132
+ * small enough that neither side ever buffers a whole video. */
133
+ chunkBytes?: number;
134
+ }
135
+ /** How a read should treat the memos โ€” see {@link BINARY_ABSENCE_TTL_MS}. */
136
+ export interface ReadOptions {
137
+ /**
138
+ * Ignore both memos: ask the store, and refresh what we believe from its answer.
139
+ *
140
+ * This is the per-request escape hatch, and `wantsFresh(request)` is what turns a
141
+ * browser's hard refresh into it โ€” so "I fixed the picture and cannot tell for an
142
+ * hour" is answered by reloading the picture.
143
+ */
144
+ fresh?: boolean;
145
+ }
146
+ /** What the memos currently hold โ€” for an owner surface, a smoke, and the tests. */
147
+ export interface BinaryCacheStats {
148
+ /** Keys known to be present. Never expires; see {@link BINARY_ABSENCE_TTL_MS}. */
149
+ present: number;
150
+ /** Keys known to be absent, and not yet lapsed. */
151
+ absent: number;
152
+ }
153
+ /** One key this process has read from the wire far too often โ€” see
154
+ * {@link BINARY_REPEAT_THRESHOLD}. */
155
+ export interface RepeatedBinaryKey {
156
+ /** binary-server tenant, i.e. which app is doing it. */
157
+ tenant: string;
158
+ key: string;
159
+ /** Wire reads inside the current window. Memo hits are NOT counted: the memo
160
+ * working is the fix, so a memoized key must go quiet. */
161
+ reads: number;
162
+ windowMs: number;
163
+ }
164
+ export interface BinaryStore {
165
+ readonly appId: string;
166
+ /**
167
+ * Store an object. Above {@link AUTO_CHUNK_THRESHOLD_BYTES} this delegates to
168
+ * {@link BinaryStore.putLarge} by itself, because the limit that makes a
169
+ * single request fail belongs to the Cloudflare Tunnel in front of
170
+ * binary-server rather than to any caller โ€” see that constant.
171
+ */
172
+ put(key: string, bytes: Uint8Array, mime?: string, opts?: PutOptions): Promise<void>;
173
+ /**
174
+ * Upload in chunks (`PUT /upload-chunk`), for objects too large to buffer whole.
175
+ *
176
+ * `put` sends one request whose body binary-server reads entirely into memory before
177
+ * writing โ€” fine for an attachment, wrong for a 500 MB video on a machine that is also
178
+ * running everything else. This sends bounded pieces and lets bs assemble them once the
179
+ * last one lands (it counts arrivals, so out-of-order delivery and retries are both safe).
180
+ */
181
+ putLarge(key: string, source: Uint8Array | Blob, mime?: string, opts?: PutLargeOptions): Promise<void>;
182
+ /** `null` when the key does not exist. */
183
+ get(key: string, opts?: ReadOptions): Promise<Uint8Array | null>;
184
+ /**
185
+ * Everything binary-server knows about a blob (`GET /meta`) โ€” `null` when unknown or
186
+ * deleted. The honest answer to "what IS this?": size, the stored mime, and the TITLE,
187
+ * none of which `stat` could report (it inferred a size from a Range probe and nothing
188
+ * else). Added to bs 2026-07-30 for exactly this.
189
+ *
190
+ * MEMOIZED since 2026-09-09 โ€” a `null` for an hour, a hit for ever. See
191
+ * {@link BINARY_ABSENCE_TTL_MS} for both halves and for the two bypasses.
192
+ */
193
+ meta(key: string, opts?: ReadOptions): Promise<BlobMeta | null>;
194
+ /** `null` when the key does not exist. Delegates to {@link BinaryStore.meta}. */
195
+ stat(key: string, opts?: ReadOptions): Promise<{
196
+ size: number;
197
+ } | null>;
198
+ /**
199
+ * Does the store hold this key? The cheap question, memoized โ€” and the one every
200
+ * app was asking the expensive way.
201
+ *
202
+ * Prefer it over `stat`/`meta` when the answer is only ever used as a boolean:
203
+ * it says what it means, and it cannot tempt a caller into trusting a memoized
204
+ * `size` for a key whose bytes are replaceable.
205
+ */
206
+ has(key: string, opts?: ReadOptions): Promise<boolean>;
207
+ /**
208
+ * What the memo ALREADY knows about a key, with no I/O and no promise.
209
+ *
210
+ * ๐Ÿ”ด The one question `has()` cannot answer, and the reason an app would otherwise keep
211
+ * a second copy of this cache. A listing route answers for hundreds of rows inside one
212
+ * request โ€” roms' `/api/roms` draws 300 games โ€” so it cannot await a round trip per row
213
+ * even when every one of them would hit the memo: `await` in a loop over 300 keys is 300
214
+ * microtask turns and a `Promise` each, on the box that also serves everything else.
215
+ * With this the render answers from what is already known and lets the per-card path
216
+ * fill in the rest.
217
+ *
218
+ * `"unknown"` is a real third answer and must not be collapsed into `"absent"`: it means
219
+ * *nobody has asked yet*, and a caller that treats it as "no" turns a cold process into
220
+ * one that permanently draws nothing.
221
+ */
222
+ known(key: string): 'present' | 'absent' | 'unknown';
223
+ /**
224
+ * Forget every "the store does not have this" โ€” all of them, or those whose key
225
+ * starts with `prefix`. Returns how many were dropped.
226
+ *
227
+ * ๐Ÿ”ด The owner's second escape hatch, and it is deliberately a REPAIR rather than
228
+ * a cache flush: the positive memos are untouched, so proving a fix costs nothing
229
+ * but the keys that were actually missing. An app exposes it behind its owner gate
230
+ * (`POST /api/media/forget-misses`) so "I put the picture back" can be verified in
231
+ * seconds instead of in an hour.
232
+ */
233
+ forgetMisses(prefix?: string): number;
234
+ /** What the memos hold right now. */
235
+ cacheStats(): BinaryCacheStats;
236
+ remove(key: string): Promise<void>;
237
+ /** Wipes a whole container tree (`?container=1`). */
238
+ removePrefix(prefix: string): Promise<void>;
239
+ /** A signed, directly-fetchable download URL (for redirects / <img src>). */
240
+ mediaUrl(key: string, opts?: MediaUrlOptions): Promise<string>;
241
+ /**
242
+ * A signed URL for `master`, authorized by `prefix` โ€” the only shape a multi-object tree
243
+ * (an HLS ladder) can be played from. See the implementation for why an exact-key token
244
+ * cannot work and why this is a separate method rather than a flag.
245
+ */
246
+ mediaPrefixUrl(prefix: string, master: string, opts?: MediaUrlOptions): Promise<string>;
247
+ /**
248
+ * The raw `Response` for an object, from the origin THIS PROCESS should use โ€” so an app
249
+ * can SERVE bytes itself, streaming, when the public edge will not.
250
+ *
251
+ * ๐Ÿ”ด The gap {@link BinaryStoreConfig.internalBaseUrl} left open. That note ends
252
+ * "redirected media โ€” videos, large downloads โ€” therefore still goes through the edge",
253
+ * and on 2026-09-08 that was the whole outage: Cloudflare spent its Workers day, every
254
+ * `/media/*` request answered 429, and apps/music could not play a note even though the
255
+ * bytes were on the same Mac as the server the browser was talking to.
256
+ *
257
+ * Two things make this a fallback rather than a second architecture:
258
+ * ยท It returns the RESPONSE, not the bytes. `get` buffers a whole object into memory,
259
+ * which is wrong for a 30 MB track and unthinkable for a video; the caller pipes
260
+ * `res.body` straight through and holds one chunk at a time.
261
+ * ยท `range` is forwarded and the origin's `206`/`content-range` come back untouched,
262
+ * because Range IS seeking. A proxy that drops it turns a seekable track into a
263
+ * download.
264
+ */
265
+ fetchMedia(key: string, init?: {
266
+ range?: string | null;
267
+ method?: 'GET' | 'HEAD';
268
+ signal?: AbortSignal;
269
+ }): Promise<Response>;
270
+ /** Escape hatch for protocols this module doesn't wrap (chunked uploads). */
271
+ signToken(claims: FileTokenClaims, ttlSeconds: number): Promise<string>;
272
+ }
273
+ /**
274
+ * ๐Ÿ”ด How long "the store does not hold this key" is believed โ€” the NEGATIVE memo.
275
+ *
276
+ * The positive one never expires and needs no constant: a key in this fleet names
277
+ * either a content digest or a timestamped upload, so the bytes under it cannot
278
+ * change and "it is there" cannot stop being true. An ABSENCE is different โ€” it is
279
+ * a fact about right now, and art, web copies and derivatives all genuinely arrive
280
+ * later โ€” so it has to lapse.
281
+ *
282
+ * An hour is the default because the owner named the cost of getting it wrong:
283
+ *
284
+ * > *"potentially we need a shorter time for remembering a 'no' in some
285
+ * > circumstances or at least a way to bypass it occasionally. For instance
286
+ * > collections showed a lot of missing thumbnails and images and if the no is
287
+ * > remembered an hour then I could fix it but not be able to tell for an hour."*
288
+ *
289
+ * So the hour is never the only answer. TWO bypasses ship with it, both cheap and
290
+ * both proven by {@link BinaryStore.forgetMisses}' tests:
291
+ *
292
+ * ยท a read with `{ fresh: true }` ignores both memos and re-asks. `wantsFresh()`
293
+ * turns a browser's hard refresh (`Cache-Control: no-cache`) into exactly that,
294
+ * so the owner's own reload IS the fix for one picture;
295
+ * ยท `forgetMisses(prefix?)` drops every negative memo, or a subtree of them, so an
296
+ * app can expose a one-click repair and prove it in seconds rather than in an
297
+ * hour.
298
+ */
299
+ export declare const BINARY_ABSENCE_TTL_MS: number;
300
+ /**
301
+ * The window and the count that make a key's re-reads an INCIDENT rather than traffic.
302
+ *
303
+ * ๐Ÿ”ด Every quota incident this fleet has had was this exact shape and nothing was
304
+ * watching for it: roms boxart at ~200 reads per key per day (20,626 failed `/media`
305
+ * fetches in one day), family portraits at 2,449 per key per day, music cover art
306
+ * doing a server-side `get` that left the Mac to read a file on the Mac. In all three
307
+ * the giveaway was one key, over and over, from a `Bun/1.3.x` user agent โ€” visible in
308
+ * Cloudflare's top-talker list and in nobody's code.
309
+ *
310
+ * 50 in five minutes is far above any legitimate server-side pattern (a page render
311
+ * asks for a key once; a warm pass asks once) and far below the rate that spends a
312
+ * day's allowance, so it fires early and it does not fire on a normal day.
313
+ */
314
+ export declare const BINARY_REPEAT_WINDOW_MS: number;
315
+ export declare const BINARY_REPEAT_THRESHOLD = 50;
316
+ export declare const DOWNLOAD_TOKEN_TTL_SECONDS = 60;
317
+ export declare const UPLOAD_TOKEN_TTL_SECONDS = 600;
318
+ /** Deletes are server-to-server and near-instant; a short TTL suffices. The token proves the
319
+ * caller holds THIS tenant's private key and is authorized for THIS path โ€” binary-server
320
+ * requires it alongside the internal secret (defense in depth, owner ruling #10). */
321
+ export declare const DELETE_TOKEN_TTL_SECONDS = 120;
322
+ /** register-app prints the key as base64(PKCS8 PEM); env files sometimes carry
323
+ * raw PEM or PEM with literal `\n` sequences. Accept all three. */
324
+ export declare function normalizePrivateKeyPem(raw: string): string;
325
+ export declare function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore;
326
+ /**
327
+ * Which binary-server TENANT this process is โ€” the app's own key, unless the environment
328
+ * names another.
329
+ *
330
+ * ๐Ÿ”ด It has to be a variable, and 2026-08-21 is the day that stopped being theoretical.
331
+ * A tenant is TWO facts that must agree: the key a request is SIGNED with, and the key the
332
+ * request PATH starts with (`authorize()` in binary-server refuses when
333
+ * `path.split("/")[0] !== token.app_id`). Apps supplied the second as a literal โ€” `const
334
+ * TENANT = "collections"` โ€” while `FILE_TOKEN_ISSUER`/`FILE_TOKEN_PRIVATE_KEY` came from
335
+ * the environment. That is fine on production, where the two happen to match, and it makes
336
+ * an ephemeral STAGE structurally unable to store a byte: `stage up` mints it a real
337
+ * `collections-stage` tenant and hands it that keypair, and every upload then signs as
338
+ * `collections-stage` while addressing `collections/โ€ฆ` and comes back
339
+ * `[binary-store] put file/<sha>: 403`. The stage looked like a working app with an empty
340
+ * gallery, which is exactly what an empty stage is supposed to look like.
341
+ *
342
+ * Reading it here means an app opts in by CALLING this instead of writing a literal, and
343
+ * `BINARY_STORE_TENANT` is already inside an app's stage-env shared-store denylist
344
+ * (`/^BINARY_STORE_(?!.*URL$)/`) โ€” so a stage cannot INHERIT production's tenant, only be
345
+ * given its own. The fallback is the app's key, so an app that never sets it is unchanged.
346
+ *
347
+ * ๐Ÿ”ด **A BLANK tenant now throws, and that is the whole point of the check
348
+ * (2026-08-23).** `undefined` is a perfectly good JavaScript string once it is
349
+ * template-interpolated, so a caller that forgot the argument signed a token for
350
+ * `undefined/<key>` and addressed `PUT /upload/undefined/<key>` โ€” at which point
351
+ * binary-server's `authorize()` refuses it correctly and answers a bare
352
+ * `{"error":"forbidden"}`. That reads as a broken CREDENTIAL, and it was filed as one
353
+ * (*"family.env's FILE_TOKEN_PRIVATE_KEY does not authorize against the family tenant"*).
354
+ * The keypair was correct the whole time and a Mac-side upload works; what was missing was
355
+ * the tenant id. bs cannot tell the two apart โ€” it sees a token for a tenant it does not
356
+ * have โ€” so the check has to live on this side of the wire, where the mistake is legible.
357
+ */
358
+ export declare function binaryStoreTenant(appId: string, env?: Record<string, string | undefined>): string;
359
+ /**
360
+ * The tenant this process's binary store resolved, or `null` when it has no store.
361
+ *
362
+ * The value an app's `mediaTenant` option wants, and the reason it exists as a named
363
+ * function rather than each app writing `readBinaryStoreEnv(x)?.appId`:
364
+ *
365
+ * ๐Ÿ”ด **`null` and a thrown error are different answers and both are correct.**
366
+ * {@link binaryStoreTenant} throws when it cannot name a tenant, which is right at an upload
367
+ * site โ€” signing for `"undefined/<key>"` earns a bare 403 that reads as a bad credential. It is
368
+ * wrong on `/healthz`, where the same throw would turn "this dev shell has no store" into a
369
+ * 500 and take the app's health probe down with it. So this asks the same question through the
370
+ * same resolution and answers `null` for the store-less case, which the health handler then
371
+ * reports as an explicit "no store" rather than as silence.
372
+ *
373
+ * Pass the app's OWN tenant key โ€” the constant its store is built from (`LIFE_BINARY_TENANT`,
374
+ * `STUDIO_BINARY_TENANT`, โ€ฆ), never the app's display name. They are the same string for most
375
+ * apps and that is precisely why the difference goes unnoticed when it is not.
376
+ */
377
+ export declare function resolvedMediaTenant(appId: string, env?: Record<string, string | undefined>): string | null;
378
+ /**
379
+ * The standard env surface every tenant's secrets file carries
380
+ * (`$FORGE_STATE/secrets/<app>.env`, written at bs registration):
381
+ * `BINARY_SERVER_URL`, `FILE_TOKEN_ISSUER`, `FILE_TOKEN_PRIVATE_KEY`, and
382
+ * optionally `BINARY_SERVER_INTERNAL_SECRET`. Returns `null` unless the three
383
+ * required vars are all present โ€” callers fall back to their local backend.
384
+ */
385
+ export declare function readBinaryStoreEnv(appId: string, env?: Record<string, string | undefined>): BinaryStoreConfig | null;