cursedbelt-server 4.18.0 → 4.19.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 (80) hide show
  1. package/dist/server/activity/index.d.ts +2 -1
  2. package/dist/server/activity/index.js +2 -1
  3. package/dist/server/auth/passwordCost.d.ts +21 -0
  4. package/dist/server/auth/passwordCost.js +80 -0
  5. package/dist/server/bench/index.d.ts +1 -0
  6. package/dist/server/bench/index.js +1 -0
  7. package/dist/server/bench/tail.d.ts +110 -0
  8. package/dist/server/bench/tail.js +182 -0
  9. package/dist/server/d1/index.d.ts +1 -2
  10. package/dist/server/d1/index.js +9 -9
  11. package/dist/server/d1/pullD1.js +16 -3
  12. package/dist/server/engagement/api.d.ts +71 -0
  13. package/dist/server/engagement/api.js +84 -0
  14. package/dist/server/engagement/env.d.ts +18 -0
  15. package/dist/server/engagement/env.js +52 -0
  16. package/dist/server/engagement/index.d.ts +55 -0
  17. package/dist/server/engagement/index.js +55 -0
  18. package/dist/server/engagement/places.d.ts +22 -0
  19. package/dist/server/engagement/places.js +63 -0
  20. package/dist/server/engagement/policy.d.ts +168 -0
  21. package/dist/server/engagement/policy.js +202 -0
  22. package/dist/server/engagement/store.d.ts +92 -0
  23. package/dist/server/engagement/store.js +223 -0
  24. package/dist/server/engagement/summary.d.ts +102 -0
  25. package/dist/server/engagement/summary.js +127 -0
  26. package/dist/server/engagement/types.d.ts +42 -0
  27. package/dist/server/engagement/types.js +12 -0
  28. package/dist/server/maps-budget/mapsBudget.d.ts +194 -0
  29. package/dist/server/maps-budget/mapsBudget.js +193 -0
  30. package/dist/server/satellite/config.d.ts +173 -0
  31. package/dist/server/satellite/config.js +259 -0
  32. package/dist/server/satellite/door.d.ts +112 -0
  33. package/dist/server/satellite/door.js +149 -0
  34. package/dist/server/storage/binaryStore.d.ts +18 -0
  35. package/dist/server/storage/binaryStore.js +32 -1
  36. package/dist/server/storage/derivatives.d.ts +253 -0
  37. package/dist/server/storage/derivatives.js +266 -0
  38. package/dist/server/storage/uploadSession.d.ts +75 -0
  39. package/dist/server/storage/uploadSession.js +74 -0
  40. package/docs/THE-DEV-DEPENDENCY-CYCLE.md +55 -0
  41. package/docs/activity.md +43 -0
  42. package/docs/engagement.md +47 -0
  43. package/docs/notifications.md +43 -0
  44. package/docs/retention.md +81 -0
  45. package/docs/skipped-tests.md +19 -0
  46. package/package.json +46 -9
  47. package/src/barrelsReachNoOptionalPeer.spec.ts +5 -3
  48. package/src/leafSubpathsImportNothing.spec.ts +49 -0
  49. package/src/server/activity/index.ts +2 -1
  50. package/src/server/auth/passwordCost.spec.ts +42 -0
  51. package/src/server/auth/passwordCost.ts +87 -0
  52. package/src/server/bench/index.ts +13 -0
  53. package/src/server/bench/tail.spec.ts +126 -0
  54. package/src/server/bench/tail.ts +237 -0
  55. package/src/server/d1/index.ts +9 -9
  56. package/src/server/d1/pullD1.spec.ts +20 -0
  57. package/src/server/d1/pullD1.ts +18 -2
  58. package/src/server/engagement/api.ts +119 -0
  59. package/src/server/engagement/engagement.spec.ts +462 -0
  60. package/src/server/engagement/env.ts +73 -0
  61. package/src/server/engagement/index.ts +92 -0
  62. package/src/server/engagement/places.ts +76 -0
  63. package/src/server/engagement/policy.ts +250 -0
  64. package/src/server/engagement/store.ts +272 -0
  65. package/src/server/engagement/summary.ts +216 -0
  66. package/src/server/engagement/types.ts +61 -0
  67. package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
  68. package/src/server/maps-budget/mapsBudget.ts +304 -0
  69. package/src/server/satellite/config.ts +389 -0
  70. package/src/server/satellite/door.ts +169 -0
  71. package/src/server/satellite/satellite.spec.ts +161 -0
  72. package/src/server/storage/binaryStore.ts +31 -1
  73. package/src/server/storage/derivatives.spec.ts +125 -0
  74. package/src/server/storage/derivatives.ts +329 -0
  75. package/src/server/storage/uploadSession.spec.ts +132 -0
  76. package/src/server/storage/uploadSession.ts +114 -0
  77. package/dist/server/d1/kysely.d.ts +0 -56
  78. package/dist/server/d1/kysely.js +0 -138
  79. package/src/server/d1/kysely.spec.ts +0 -145
  80. package/src/server/d1/kysely.ts +0 -169
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `cursedbelt-server/satellite-door` — the PURE half of the satellite configuration contract:
3
+ * who an instance is (a release, a stage, a test runner, a production build) and who it opens
4
+ * for. No `node:*` import, no `process` read unless one exists, no package — so a Cloudflare
5
+ * Worker's door imports the SAME rules its Mac twin does.
6
+ *
7
+ * ## Why this is here, since 4.19.0 (task 281)
8
+ *
9
+ * Nine apps held an `appConfig.ts` in five versions (measured 2026-09-23: `roms`, `family`,
10
+ * `music`, `collections` and `auth` on one 445-line lineage; `vault` a trimmed fork of it;
11
+ * `flix`, `station` and `patterns` a second lineage). The divergence was not taste — it was
12
+ * protection lost in one copy and kept in another: `vault`, `flix` and `station` lost the stage
13
+ * identity override, so a stage of any of them would have signed in against PRODUCTION auth;
14
+ * `station` admitted its extra e-mails on its deployed release; lineage B could not tell an
15
+ * `APP_RELEASE=0` scratch boot from a release. The rules below are the union, decided once.
16
+ *
17
+ * And the Workers needed them without the Node half: `music`'s Worker imported the whole
18
+ * `appConfig.ts` to reach two pure functions, dragging `node:fs` and the data-dir chain into
19
+ * its bundle, while `collections` split a `doorConstants.ts` out by hand to avoid the same.
20
+ * This leaf is that split, once. `./satellite-config` is the Bun/Node half and re-exports all
21
+ * of this, so a Mac-side caller needs one import.
22
+ *
23
+ * 🔴 **This file imports NOTHING.** `src/leafSubpathsImportNothing.spec.ts` proves it in a
24
+ * fixture with no packages resolvable; an import that survives transpilation turns that red.
25
+ *
26
+ * 🔴 It knows the fleet's ZONE (`cursedalchemy.com`) and its accounts app's ports, as the
27
+ * binary-store client already does — mechanism of this fleet, not of any app. It does NOT
28
+ * know the owner: `ownerEmail` is always passed in.
29
+ */
30
+ /** The accounts app (`apps/auth`) in production. */
31
+ export const PROD_AUTH_URL = "https://auth.cursedalchemy.com";
32
+ /** The accounts app's dev shell. */
33
+ export const DEV_AUTH_URL = "http://localhost:3309";
34
+ /**
35
+ * The LOCAL accounts door a stage signs in against — loopback, never the public hostname, so a
36
+ * stage's test sign-ins never write a row into the owner's real accounts registry.
37
+ */
38
+ export const LOCAL_AUTH_URL = "http://127.0.0.1:3309";
39
+ /** `APP_STAGE_IDENTITY=local` — the marker a stage boots with. */
40
+ export const STAGE_IDENTITY_ENV = "APP_STAGE_IDENTITY";
41
+ /** The one directory under the state root that is fixtures by construction. */
42
+ export const TEST_SCRATCH_DIR_NAME = "test-scratch.noindex";
43
+ /**
44
+ * Every marker that means "a test runner is in charge". A spec that spawns a REAL server strips
45
+ * exactly these ({@link withoutTestRuntimeMarkers}), so a fourth marker added here is stripped by
46
+ * everyone for free.
47
+ */
48
+ export const TEST_RUNTIME_ENV_KEYS = ["NODE_ENV", "BUN_TEST", "SATELLITE_TEST_RUNTIME"];
49
+ /** `process.env` where there is one; `{}` in a Worker, which has none. */
50
+ const ambientEnv = () => globalThis.process?.env ?? {};
51
+ /** Is this instance a stage whose identity must stay LOCAL? */
52
+ export function stageIdentityIsLocal(env = ambientEnv()) {
53
+ return env[STAGE_IDENTITY_ENV]?.trim().toLowerCase() === "local";
54
+ }
55
+ /**
56
+ * Is this origin an ephemeral STAGE — `<app>-stage.cursedalchemy.com`?
57
+ *
58
+ * Derived from the hostname, never configured: a `-stage` name in the fleet's own zone cannot be
59
+ * typed into existence (only `stage up` creates the record, behind one Access application), so a
60
+ * production release cannot acquire one by a typo in a secrets file.
61
+ */
62
+ export function isStageOrigin(publicUrl) {
63
+ try {
64
+ return new URL(publicUrl).hostname.endsWith("-stage.cursedalchemy.com");
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ /**
71
+ * Is a test runner in charge? The three markers, and `SATELLITE_TEST_RUNTIME` because the React
72
+ * preloads force `NODE_ENV=development` and erase Bun's own `test`.
73
+ */
74
+ export function isTestRuntime(env = ambientEnv()) {
75
+ return env.NODE_ENV === "test" || Boolean(env.BUN_TEST) || env.SATELLITE_TEST_RUNTIME === "1";
76
+ }
77
+ /** A copy of `env` with every test-runtime marker removed — what a spec hands a REAL server it spawns. */
78
+ export function withoutTestRuntimeMarkers(env) {
79
+ const out = {};
80
+ for (const [key, value] of Object.entries(env)) {
81
+ if (value === undefined)
82
+ continue;
83
+ if (TEST_RUNTIME_ENV_KEYS.includes(key))
84
+ continue;
85
+ out[key] = value;
86
+ }
87
+ return out;
88
+ }
89
+ /**
90
+ * IS THIS A RELEASE? One question, one variable.
91
+ *
92
+ * `APP_RELEASE` is stamped by the deploy and by nothing else; `0`/`false` is an explicit no (a
93
+ * stage plan and a floor test both rely on that — lineage B ignored it and could not). Without
94
+ * the stamp it falls back to `APP_DATA_DIR`, minus the test-scratch tree, because a release
95
+ * shipped before the stamp existed must still answer yes: flipping it to no would open the smoke
96
+ * extras on the open internet. The fallback retires itself as each app redeploys.
97
+ */
98
+ export function isRelease(env = ambientEnv()) {
99
+ const explicit = env.APP_RELEASE?.trim();
100
+ if (explicit)
101
+ return explicit !== "0" && explicit.toLowerCase() !== "false";
102
+ const appDataDir = env.APP_DATA_DIR?.trim() ?? "";
103
+ return Boolean(appDataDir) && !appDataDir.includes(TEST_SCRATCH_DIR_NAME);
104
+ }
105
+ /**
106
+ * Is this a DEV runtime? Vite's `import.meta.env.DEV` when a bundle carries it, else
107
+ * `NODE_ENV !== "production"` of the env given (or the ambient one).
108
+ */
109
+ export function isDevRuntime(env) {
110
+ if (env === undefined) {
111
+ const viteDev = import.meta.env?.DEV;
112
+ if (typeof viteDev === "boolean")
113
+ return viteDev;
114
+ }
115
+ return (env ?? ambientEnv()).NODE_ENV !== "production";
116
+ }
117
+ /** The inverse of {@link isDevRuntime} — what `assertSatelliteUsable`'s refusal keys on with `deployed`. */
118
+ export function isProductionRuntime(env) {
119
+ return !isDevRuntime(env);
120
+ }
121
+ /** `a@x, ,b@x` → `["a@x", "b@x"]`. The one parse for every `*_EXTRA_ALLOWED_EMAILS`. */
122
+ export function parseEmailList(raw) {
123
+ return (raw ?? "")
124
+ .split(",")
125
+ .map((email) => email.trim())
126
+ .filter((email) => email.length > 0);
127
+ }
128
+ /**
129
+ * May extra accounts widen the door on THIS instance? Only off a release, or on a stage.
130
+ *
131
+ * 🔴 Keyed on `deployed`, not on the origin and not on loopback: a gate's artifact smoke runs the
132
+ * PRODUCTION build on `127.0.0.1`, so `live` is true there and the origin falls back to the prod
133
+ * hostname — keying on either would leave the extras inert in the one place they exist to work.
134
+ * Derived rather than configured: a boolean meaning "let someone else in" is one typo from true
135
+ * in production.
136
+ */
137
+ export function extraEmailsApply(config) {
138
+ if (!config.deployed)
139
+ return true;
140
+ return isStageOrigin(config.publicUrl ?? "");
141
+ }
142
+ /**
143
+ * The addresses this instance opens for, owner first — what an owner-only app hands its SSO
144
+ * consumer's `allowedEmails`. One function, so the server, `/healthz` and the tests cannot
145
+ * disagree about the answer.
146
+ */
147
+ export function allowedEmailsFor(config) {
148
+ return extraEmailsApply(config) ? [config.ownerEmail, ...config.extraAllowedEmails] : [config.ownerEmail];
149
+ }
@@ -322,6 +322,24 @@ export declare const DELETE_TOKEN_TTL_SECONDS = 120;
322
322
  /** register-app prints the key as base64(PKCS8 PEM); env files sometimes carry
323
323
  * raw PEM or PEM with literal `\n` sequences. Accept all three. */
324
324
  export declare function normalizePrivateKeyPem(raw: string): string;
325
+ /**
326
+ * A short, stable, PUBLIC identifier for a tenant's signing key — the first 16 hex of sha256
327
+ * over the public half's SPKI DER. `""` for an unset key, `"unreadable"` for one that does not
328
+ * parse; never throws.
329
+ *
330
+ * 🔴 Why it exists. A wrong or stale `FILE_TOKEN_PRIVATE_KEY` is the failure that hides best:
331
+ * every list renders, `/healthz` is 200, and only the file bytes 403 — and the assertion that
332
+ * would catch it ("a file URL serves bytes") needs a signed-in session a deploy smoke does not
333
+ * have. So an instance publishes this digest on `/healthz` and the smoke derives the same digest
334
+ * from the key the deploying machine holds; the two are compared with no session anywhere.
335
+ *
336
+ * Safe to publish: a truncated hash of the PUBLIC half, which binary-server's tenant registry
337
+ * already holds. It was `fileTokenKeyId` in four apps' `src/server/config.ts` (music first, then
338
+ * family, roms and collections), byte-identical, until 4.19.0 (task 321). Lives here because it
339
+ * is `node:crypto` over the same key {@link createBinaryStore} signs with — and so it runs in a
340
+ * Worker under `nodejs_compat` exactly as the store does.
341
+ */
342
+ export declare function fileTokenKeyId(privateKeyPem: string): string;
325
343
  export declare function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore;
326
344
  /**
327
345
  * Which binary-server TENANT this process is — the app's own key, unless the environment
@@ -40,7 +40,7 @@
40
40
  * import specifier (`cursedbelt/server/storage`, the pre-split package name) — no
41
41
  * behavioural difference in any of the three, so nothing was dropped to land the union.
42
42
  */
43
- import { createPrivateKey, sign as cryptoSign } from 'node:crypto';
43
+ import { createHash, createPrivateKey, createPublicKey, sign as cryptoSign } from 'node:crypto';
44
44
  /*
45
45
  * ── The process-wide repeat register (§ the repeat-key counter) ───────────────
46
46
  *
@@ -196,6 +196,37 @@ export function normalizePrivateKeyPem(raw) {
196
196
  }
197
197
  return decoded;
198
198
  }
199
+ /**
200
+ * A short, stable, PUBLIC identifier for a tenant's signing key — the first 16 hex of sha256
201
+ * over the public half's SPKI DER. `""` for an unset key, `"unreadable"` for one that does not
202
+ * parse; never throws.
203
+ *
204
+ * 🔴 Why it exists. A wrong or stale `FILE_TOKEN_PRIVATE_KEY` is the failure that hides best:
205
+ * every list renders, `/healthz` is 200, and only the file bytes 403 — and the assertion that
206
+ * would catch it ("a file URL serves bytes") needs a signed-in session a deploy smoke does not
207
+ * have. So an instance publishes this digest on `/healthz` and the smoke derives the same digest
208
+ * from the key the deploying machine holds; the two are compared with no session anywhere.
209
+ *
210
+ * Safe to publish: a truncated hash of the PUBLIC half, which binary-server's tenant registry
211
+ * already holds. It was `fileTokenKeyId` in four apps' `src/server/config.ts` (music first, then
212
+ * family, roms and collections), byte-identical, until 4.19.0 (task 321). Lives here because it
213
+ * is `node:crypto` over the same key {@link createBinaryStore} signs with — and so it runs in a
214
+ * Worker under `nodejs_compat` exactly as the store does.
215
+ */
216
+ export function fileTokenKeyId(privateKeyPem) {
217
+ if (!privateKeyPem.trim())
218
+ return '';
219
+ try {
220
+ const spki = createPublicKey(createPrivateKey(normalizePrivateKeyPem(privateKeyPem))).export({
221
+ type: 'spki',
222
+ format: 'der',
223
+ });
224
+ return createHash('sha256').update(spki).digest('hex').slice(0, 16);
225
+ }
226
+ catch {
227
+ return 'unreadable';
228
+ }
229
+ }
199
230
  const base64url = (bytes) => Buffer.from(bytes).toString('base64url');
200
231
  /** Keys may contain `/` as a real separator; each SEGMENT is percent-encoded
201
232
  * because the server decodes the whole path before matching it to the token. */
@@ -0,0 +1,253 @@
1
+ /**
2
+ * ── `cursedbelt-server/storage/derivatives` — how an app READS what binary-server derives ──
3
+ *
4
+ * Since 4.19.0 (task 344). The key NAMES already lived here (`./videoDerivativeKeys.ts`, on the
5
+ * owner's 2026-08-14 ruling — *"Make it in cb because many apps may move to cloudflare and share
6
+ * the logic"*); what was still copied into `collections` and `family` as `src/kit/imageDerivatives.ts`
7
+ * and `src/kit/videoDerivatives.ts` was the READING strategy built on them — the part with the
8
+ * judgement in it. The video copy had already forked (three import lines and a doc comment, benign
9
+ * by accident), and the HEIC rule below decides whether a photograph appears AT ALL, which may not
10
+ * exist in two places.
11
+ *
12
+ * 🔴 Store-agnostic throughout: every function takes the caller's own store or signer, so nothing
13
+ * here can reach across tenants — each app is its own binary-server tenant and must stay unable to
14
+ * resolve another's objects.
15
+ *
16
+ * The key names are RE-EXPORTED from here, so a consumer changes one import specifier and not
17
+ * every call site. This file imports only `./videoDerivativeKeys.ts`, which imports nothing, so it
18
+ * mounts unchanged inside a Worker (`src/leafSubpathsImportNothing.spec.ts` proves it).
19
+ */
20
+ export { hlsMasterKeyFor, hlsPrefixFor, posterKeyFor, spriteKeyFor, spriteVttKeyFor, webPlayableKeyFor, } from "./videoDerivativeKeys.js";
21
+ /**
22
+ * ── 🔴 The STILL-image derivatives binary-server writes, and how an app READS them ───
23
+ *
24
+ * The sibling of `videoDerivatives.ts`, and it exists for a different reason worth being
25
+ * explicit about. A video's remux is about SPEED — the original plays, eventually. A still's
26
+ * `.web.jpg` is, for a large and growing share of the fleet's photographs, about the picture
27
+ * appearing AT ALL.
28
+ *
29
+ * ── The report that produced this file (owner, 2026-08-14) ───────────────────────────
30
+ * *"determine why a full album shows up with no thumbnails and videos and images not
31
+ * working"* — `family.cursedalchemy.com` → Keepsakes, one album of 85 items drawn as a wall
32
+ * of file names with a torn-page glyph on every tile, and the album's own cover blank.
33
+ *
34
+ * Nothing was missing and nothing had failed. Measured that day against real prod: every one
35
+ * of those rows answered `/api/sources/:id/file` with **HTTP 200 and
36
+ * `Content-Type: image/heic`**. `apps/family` served the bytes the family uploaded, exactly
37
+ * as designed — and **no browser except Safari can decode HEIC**, so a correct 200 rendered
38
+ * as a broken image in Chrome and Firefox. Every iPhone in the household shoots HEIC by
39
+ * default; 86 of the tenant's 182 stored stills were HEIC.
40
+ *
41
+ * binary-server had already done its half. It classes `heic`/`heif`/`tiff`/`x-adobe-dng` as
42
+ * UNVIEWABLE and exempts them from its "too small to bother" floor precisely because for
43
+ * those *"the derivative is not a saving — it is the only copy anything can display"*. All
44
+ * 86 had both a `.web.jpg` and a `.thumb.jpg` sitting beside them. The app simply never asked
45
+ * for either.
46
+ *
47
+ * ── 🔴 The two rules a consumer needs ────────────────────────────────────────────────
48
+ *
49
+ * 1. **A picture on a SCREEN is `webImageKeyFor(key)` when that object exists**, and the
50
+ * original only when it does not. This is not merely an optimization: for an unviewable
51
+ * original it is the difference between a photograph and a broken image.
52
+ * 2. **A picture on a DOWNLOAD is always the original.** A person asking for their own file
53
+ * must get the file they gave us — full resolution, original container, original
54
+ * metadata. A re-encode handed over as "your photograph" is a quiet, permanent loss on
55
+ * the one road where the family expects the archive to be an archive.
56
+ *
57
+ * The tile picture is `posterKeyFor` (below, with the video rules) — `<key>.thumb.jpg`, deliberately
58
+ * the SAME name a video's still gets, so a grid has one rule for everything it draws. It is
59
+ * not duplicated.
60
+ *
61
+ * ── 🔴 Absence is "not ready yet", and it CANNOT be probed per row ───────────────────
62
+ * As everywhere in this contract, a derivative is found by computing its name — so the object
63
+ * being absent IS the not-ready state, with no webhook, no job id and no column. That is
64
+ * cheap for the ONE item a person opened and ruinous for a grid: probing per tile is a
65
+ * network round trip per row, over a Cloudflare Tunnel, from a `MemoryMax=300M` box. So a
66
+ * consumer must never probe to build a list. It offers the derived tile URL for every row and
67
+ * lets the `<img>` fall back to the full picture on error — `cursedbelt/react/media-gallery`'s
68
+ * `ThumbImg` does exactly this, for exactly this reason.
69
+ */
70
+ /**
71
+ * The web-optimized still: bounded dimensions, re-encoded for size, and for HEIC/TIFF/RAW the
72
+ * only copy a browser can draw.
73
+ *
74
+ * 🔴 `.jpg`, and it is binary-server's key that this must match byte for byte
75
+ * (`src/media/derivativeKeys.ts` → `webImageKeyFor`). The extension is baked into the name and
76
+ * the name is what a consumer computes without asking, so changing the container on either
77
+ * side means an app confidently addressing bytes that are not there — which fails as a 404 on
78
+ * a picture, i.e. silently and only in a browser.
79
+ */
80
+ export declare const webImageKeyFor: (key: string) => string;
81
+ /**
82
+ * True when NO browser can draw the original, so `webImageKeyFor` is not an optimization but
83
+ * the only copy that can appear on a screen.
84
+ *
85
+ * 🔴 This mirrors binary-server's own `isUnviewableOriginal`
86
+ * (`src/media/imageKinds.ts` → `/^image\/(x-adobe-dng|tiff|heic|heif)$/`), and the two must
87
+ * agree: bs uses it to decide which stills are EXEMPT from its "too small to bother" size
88
+ * floor — *"the derivative is not a saving, it is the only copy anything can display"* — so a
89
+ * consumer that classes a mime differently either offers a derivative bs never built (a
90
+ * broken picture) or serves an original nothing decodes (the same broken picture, with the
91
+ * fix sitting unused in storage). binary-server may import it from here; until it does, the regex is the seam, and it is
92
+ * three words long.
93
+ *
94
+ * 🔴 The NAME is checked too, and that is not belt-and-braces. `File.type` is empty for a
95
+ * `.DNG` in every browser tested and often for an iPhone `.MOV`/`.HEIC`, so an app's catalog
96
+ * frequently holds `application/octet-stream` for exactly the files this predicate exists to
97
+ * catch — the same reason binary-server sniffs the first 4 KB rather than trusting the mime
98
+ * it was handed.
99
+ */
100
+ export declare const isUnviewableStill: (mime: string | null | undefined, fileName?: string | null) => boolean;
101
+ /**
102
+ * ── 🔴 The video derivatives binary-server writes, and how an app READS them ─────────
103
+ *
104
+ * binary-server remuxes every uploaded video into a browser-playable sibling and samples a
105
+ * scrub-preview sprite sheet + track, naming each result after the source object. **That
106
+ * naming is the whole contract**: a consumer finds a derivative by computing its key and
107
+ * asking `/meta` whether it exists yet, so there is no webhook, no job id, no schema column
108
+ * and nothing to reconcile. The absence of the object is the "not ready yet" state, and it
109
+ * answers itself.
110
+ *
111
+ * Why it matters at all, measured on the owner's own 90 MB upload (2026-08-13): a phone
112
+ * MP4/MOV stores its `moov` index at the END of the file — that clip's began at byte
113
+ * 90,305,514 of 90,339,606 — and a browser cannot decode frame one until it has that index,
114
+ * so it downloads the whole file first. That is the "videos stay on a spinner for minutes"
115
+ * report. The derivative is a `-movflags +faststart` remux with the index at byte 32, and for
116
+ * h264+aac sources (which is what phones produce) it is a STREAM COPY: same pictures, no
117
+ * re-encode, nothing lost. It also drops the junk tracks iPhones attach — that clip's original
118
+ * carried an unknown audio stream and five data streams, which is a second, independent reason
119
+ * a browser refuses a `.mov`.
120
+ *
121
+ * 🔴 These are DERIVED, never stored on the row. A `web_key` column would let an app's catalog
122
+ * disagree with what binary-server actually holds — and the catalog lives on prod-ec2 while
123
+ * the bytes live on the Mac, so the two would drift the first time either side was restored
124
+ * from a backup.
125
+ *
126
+ * ── Why this was a SHARED module and not one app's ───────────────────────────────────
127
+ * The record, because it explains the shape this file still has. It was
128
+ * `@satellites/satellite-kit/video-derivatives`; `collections` is the only app in this
129
+ * generation that has it, and it is copied here rather than re-inlined into `storage.ts`.
130
+ *
131
+ * It was `apps/collections/src/server/storage.ts` first, and it worked, so `apps/family`
132
+ * shipped video with none of it: no faststart stream, no scrub previews, no processing state
133
+ * — the owner's 2026-08-13 note ("the ffmpeg processing, awesome scrubbing … should all be
134
+ * possible" in family, with collections named as the example to copy). A key contract shared
135
+ * with ANOTHER REPO is exactly the thing that must not be re-typed per app: a second copy of
136
+ * `${key}.web.mp4` is a silent no-preview the day binary-server renames anything.
137
+ *
138
+ * 🔴 What is shared here is the STRATEGY, never the media. Nothing in this module reaches
139
+ * across tenants — every function takes the caller's own store, and each app is its own
140
+ * binary-server tenant with its own signing key. family must never be able to resolve a
141
+ * collections object and vice versa; keeping this module store-agnostic is what keeps that
142
+ * true while the two apps share one pipeline.
143
+ */
144
+ /**
145
+ * The single still a gallery draws on a tile — binary-server's `.thumb.jpg`.
146
+ *
147
+ * ── 🔴 Why this is here, from the owner's report (2026-08-14) ────────────────────────
148
+ * *"videos are not showing with thumbnails in the family app keepsakes"*, with a screenshot
149
+ * of one album: eleven photographs with tiles and three videos — 129, 282 and 633 MB — drawn
150
+ * as a grey play triangle.
151
+ *
152
+ * Nothing was broken. binary-server's `runPoster` had always existed and was reachable only
153
+ * from its `library` strategy, while `family` and `collections` both submit `faststart`,
154
+ * which minted a remux and sprites and no still. With no picture to show, the gallery fell
155
+ * back to asking the BROWSER to decode the clip's own first frame — which works on a small
156
+ * file and does not happen at all on a 633 MB one over a home connection, because the browser
157
+ * must fetch and demux the front of the container first and quietly gives up.
158
+ *
159
+ * 🔴 **The sprite sheet is not a substitute and was rejected as one.** It is a GRID of frames,
160
+ * so drawing it on a tile shows a contact sheet, and using one cell means every consumer
161
+ * carries the VTT's geometry in order to crop it — a second, subtler copy of exactly the
162
+ * cross-repo duplication this module exists to prevent.
163
+ *
164
+ * 🔴 **The same name a still photograph's thumbnail gets, deliberately.** A consumer then has
165
+ * ONE rule — *the tile for anything is `<key>.thumb.jpg`, and its absence means not ready* —
166
+ * rather than one rule per media kind, and `.thumb.jpg` was already in binary-server's
167
+ * `DERIVATIVE_SUFFIXES`, so nothing about the key contract, the sweep's backlog arithmetic or
168
+ * the duplicate scan changes. A video and a photograph are different things to a person and
169
+ * the same thing to a grid.
170
+ */
171
+ /**
172
+ * The relative sprite name binary-server writes into every VTT cue, which a consumer rewrites
173
+ * into a signed URL when it serves the track.
174
+ *
175
+ * 🔴 A literal shared with another repo and enforced by nothing but this comment. It is
176
+ * `buildSpriteVtt`'s `spriteName` default in binary-server's `ffmpegJobs.ts`. If that default
177
+ * ever changes, scrub previews go blank silently — a cue pointing at a name nothing serves
178
+ * produces no error, no console warning and no visible failure except an empty thumbnail
179
+ * strip. Each consumer's scrub-route test asserts a real binary-server-shaped VTT is
180
+ * rewritten, so the pin is behavioral rather than a hope.
181
+ */
182
+ export declare const SPRITE_VTT_PLACEHOLDER = "sprites.jpg";
183
+ /**
184
+ * The largest scrub track an app will proxy. A VTT is one text cue per sampled frame and
185
+ * binary-server caps a sheet at a few hundred tiles, so an honest one is under 20 KB; this is
186
+ * a tripwire against proxying something that is not a VTT at all, on a box with
187
+ * `MemoryMax=300M`.
188
+ */
189
+ export declare const MAX_SCRUB_VTT_BYTES: number;
190
+ /**
191
+ * Rewrite a binary-server scrub track so its cues point at a signed sprite URL.
192
+ *
193
+ * 🔴 The reference is replaced WHOLESALE rather than prefixed: a signed URL carries a query
194
+ * string, and a cue's `#xywh=` fragment must stay after it. Prefixing produces
195
+ * `…/sprite.jpg?token=…#xywh=0,0,160,90` only by accident of ordering, and gets it wrong the
196
+ * moment the signer appends a parameter.
197
+ */
198
+ export declare const rewriteScrubVtt: (vtt: string, spriteUrl: string) => string;
199
+ /** Just enough of `BinaryStore` to resolve a derivative — so this module needs no store type. */
200
+ export interface DerivativeProbe {
201
+ meta(key: string): Promise<unknown | null>;
202
+ }
203
+ export interface VideoPlayback {
204
+ /**
205
+ * The faststart remux, when binary-server has finished writing it. Absent means play the
206
+ * original and say why — never fail.
207
+ */
208
+ streamUrl?: string;
209
+ /** `"processing"` exactly when the remux is not there yet. */
210
+ processingStatus?: "processing";
211
+ /** True when a scrub track exists, so the caller can advertise its own proxy route. */
212
+ scrubReady: boolean;
213
+ /**
214
+ * The HLS master playlist, when a ladder exists — cursedbelt's `VideoPlayer` PREFERS this
215
+ * over {@link streamUrl}.
216
+ *
217
+ * 🔴 Absent is the NORMAL state and must stay cheap. Only some videos will ever have a
218
+ * ladder (it is a re-encode, unlike the faststart stream copy), so a consumer treats this
219
+ * exactly like every other derivative here: present means use it, absent means fall back,
220
+ * never an error.
221
+ *
222
+ * What it buys, given Range already works: a progressive MP4 cannot ADAPT. A weak
223
+ * connection stalls on a 23 Mbps master instead of dropping a rung, and each segment is a
224
+ * small object that caches whole at the edge rather than needing the big-object segment
225
+ * machinery.
226
+ */
227
+ hlsUrl?: string;
228
+ }
229
+ /**
230
+ * Resolve what a video can offer RIGHT NOW: the faststart stream if it is ready, and whether
231
+ * a scrub track exists.
232
+ *
233
+ * 🔴 Best-effort throughout, deliberately. binary-server being slow, down, or out of disk must
234
+ * degrade this to "here are the original bytes", never break opening a file — a probe that
235
+ * throws would turn a working (if slow) video into a broken one, which is strictly worse than
236
+ * the problem the derivative exists to solve. An absent derivative is a NORMAL state.
237
+ *
238
+ * The signer is injected rather than taken as a store + options bag: the two consumers mint
239
+ * URLs under different privacy rules (collections signs a private item with a short,
240
+ * non-stable window; family signs by kind), and that decision must stay at the call site
241
+ * where the row's privacy is known.
242
+ */
243
+ export declare function resolveVideoPlayback(store: DerivativeProbe, sourceKey: string, signStream: (key: string) => Promise<string>,
244
+ /**
245
+ * Signs a PREFIX-scoped token for an HLS tree, when the caller can serve one.
246
+ *
247
+ * Separate from `signStream` because it mints a different claim (`p`, not `k`): one token
248
+ * has to authorize the master playlist AND every rung index AND every segment under it,
249
+ * and re-signing per segment would mean a round trip to the app for each one. Optional so
250
+ * a consumer that has not wired the HLS route yet keeps working unchanged — it simply
251
+ * never advertises a ladder.
252
+ */
253
+ signHlsTree?: (prefix: string) => Promise<string>): Promise<VideoPlayback>;