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,266 @@
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
+ import { hlsMasterKeyFor, hlsPrefixFor, spriteVttKeyFor, webPlayableKeyFor, } from "./videoDerivativeKeys.js";
22
+ // ── STILL images ───────────────────────────────────────────────────────────────────────────
23
+ /**
24
+ * ── 🔴 The STILL-image derivatives binary-server writes, and how an app READS them ───
25
+ *
26
+ * The sibling of `videoDerivatives.ts`, and it exists for a different reason worth being
27
+ * explicit about. A video's remux is about SPEED — the original plays, eventually. A still's
28
+ * `.web.jpg` is, for a large and growing share of the fleet's photographs, about the picture
29
+ * appearing AT ALL.
30
+ *
31
+ * ── The report that produced this file (owner, 2026-08-14) ───────────────────────────
32
+ * *"determine why a full album shows up with no thumbnails and videos and images not
33
+ * working"* — `family.cursedalchemy.com` → Keepsakes, one album of 85 items drawn as a wall
34
+ * of file names with a torn-page glyph on every tile, and the album's own cover blank.
35
+ *
36
+ * Nothing was missing and nothing had failed. Measured that day against real prod: every one
37
+ * of those rows answered `/api/sources/:id/file` with **HTTP 200 and
38
+ * `Content-Type: image/heic`**. `apps/family` served the bytes the family uploaded, exactly
39
+ * as designed — and **no browser except Safari can decode HEIC**, so a correct 200 rendered
40
+ * as a broken image in Chrome and Firefox. Every iPhone in the household shoots HEIC by
41
+ * default; 86 of the tenant's 182 stored stills were HEIC.
42
+ *
43
+ * binary-server had already done its half. It classes `heic`/`heif`/`tiff`/`x-adobe-dng` as
44
+ * UNVIEWABLE and exempts them from its "too small to bother" floor precisely because for
45
+ * those *"the derivative is not a saving — it is the only copy anything can display"*. All
46
+ * 86 had both a `.web.jpg` and a `.thumb.jpg` sitting beside them. The app simply never asked
47
+ * for either.
48
+ *
49
+ * ── 🔴 The two rules a consumer needs ────────────────────────────────────────────────
50
+ *
51
+ * 1. **A picture on a SCREEN is `webImageKeyFor(key)` when that object exists**, and the
52
+ * original only when it does not. This is not merely an optimization: for an unviewable
53
+ * original it is the difference between a photograph and a broken image.
54
+ * 2. **A picture on a DOWNLOAD is always the original.** A person asking for their own file
55
+ * must get the file they gave us — full resolution, original container, original
56
+ * metadata. A re-encode handed over as "your photograph" is a quiet, permanent loss on
57
+ * the one road where the family expects the archive to be an archive.
58
+ *
59
+ * The tile picture is `posterKeyFor` (below, with the video rules) — `<key>.thumb.jpg`, deliberately
60
+ * the SAME name a video's still gets, so a grid has one rule for everything it draws. It is
61
+ * not duplicated.
62
+ *
63
+ * ── 🔴 Absence is "not ready yet", and it CANNOT be probed per row ───────────────────
64
+ * As everywhere in this contract, a derivative is found by computing its name — so the object
65
+ * being absent IS the not-ready state, with no webhook, no job id and no column. That is
66
+ * cheap for the ONE item a person opened and ruinous for a grid: probing per tile is a
67
+ * network round trip per row, over a Cloudflare Tunnel, from a `MemoryMax=300M` box. So a
68
+ * consumer must never probe to build a list. It offers the derived tile URL for every row and
69
+ * lets the `<img>` fall back to the full picture on error — `cursedbelt/react/media-gallery`'s
70
+ * `ThumbImg` does exactly this, for exactly this reason.
71
+ */
72
+ /**
73
+ * The web-optimized still: bounded dimensions, re-encoded for size, and for HEIC/TIFF/RAW the
74
+ * only copy a browser can draw.
75
+ *
76
+ * 🔴 `.jpg`, and it is binary-server's key that this must match byte for byte
77
+ * (`src/media/derivativeKeys.ts` → `webImageKeyFor`). The extension is baked into the name and
78
+ * the name is what a consumer computes without asking, so changing the container on either
79
+ * side means an app confidently addressing bytes that are not there — which fails as a 404 on
80
+ * a picture, i.e. silently and only in a browser.
81
+ */
82
+ export const webImageKeyFor = (key) => `${key}.web.jpg`;
83
+ /**
84
+ * True when NO browser can draw the original, so `webImageKeyFor` is not an optimization but
85
+ * the only copy that can appear on a screen.
86
+ *
87
+ * 🔴 This mirrors binary-server's own `isUnviewableOriginal`
88
+ * (`src/media/imageKinds.ts` → `/^image\/(x-adobe-dng|tiff|heic|heif)$/`), and the two must
89
+ * agree: bs uses it to decide which stills are EXEMPT from its "too small to bother" size
90
+ * floor — *"the derivative is not a saving, it is the only copy anything can display"* — so a
91
+ * consumer that classes a mime differently either offers a derivative bs never built (a
92
+ * broken picture) or serves an original nothing decodes (the same broken picture, with the
93
+ * fix sitting unused in storage). binary-server may import it from here; until it does, the regex is the seam, and it is
94
+ * three words long.
95
+ *
96
+ * 🔴 The NAME is checked too, and that is not belt-and-braces. `File.type` is empty for a
97
+ * `.DNG` in every browser tested and often for an iPhone `.MOV`/`.HEIC`, so an app's catalog
98
+ * frequently holds `application/octet-stream` for exactly the files this predicate exists to
99
+ * catch — the same reason binary-server sniffs the first 4 KB rather than trusting the mime
100
+ * it was handed.
101
+ */
102
+ export const isUnviewableStill = (mime, fileName) => {
103
+ const bare = (mime ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
104
+ if (/^image\/(x-adobe-dng|tiff|heic|heif)(-sequence)?$/.test(bare))
105
+ return true;
106
+ const extension = (fileName ?? "").toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? "";
107
+ return ["dng", "tif", "tiff", "heic", "heif"].includes(extension);
108
+ };
109
+ // ── VIDEO ──────────────────────────────────────────────────────────────────────────────────
110
+ /**
111
+ * ── 🔴 The video derivatives binary-server writes, and how an app READS them ─────────
112
+ *
113
+ * binary-server remuxes every uploaded video into a browser-playable sibling and samples a
114
+ * scrub-preview sprite sheet + track, naming each result after the source object. **That
115
+ * naming is the whole contract**: a consumer finds a derivative by computing its key and
116
+ * asking `/meta` whether it exists yet, so there is no webhook, no job id, no schema column
117
+ * and nothing to reconcile. The absence of the object is the "not ready yet" state, and it
118
+ * answers itself.
119
+ *
120
+ * Why it matters at all, measured on the owner's own 90 MB upload (2026-08-13): a phone
121
+ * MP4/MOV stores its `moov` index at the END of the file — that clip's began at byte
122
+ * 90,305,514 of 90,339,606 — and a browser cannot decode frame one until it has that index,
123
+ * so it downloads the whole file first. That is the "videos stay on a spinner for minutes"
124
+ * report. The derivative is a `-movflags +faststart` remux with the index at byte 32, and for
125
+ * h264+aac sources (which is what phones produce) it is a STREAM COPY: same pictures, no
126
+ * re-encode, nothing lost. It also drops the junk tracks iPhones attach — that clip's original
127
+ * carried an unknown audio stream and five data streams, which is a second, independent reason
128
+ * a browser refuses a `.mov`.
129
+ *
130
+ * 🔴 These are DERIVED, never stored on the row. A `web_key` column would let an app's catalog
131
+ * disagree with what binary-server actually holds — and the catalog lives on prod-ec2 while
132
+ * the bytes live on the Mac, so the two would drift the first time either side was restored
133
+ * from a backup.
134
+ *
135
+ * ── Why this was a SHARED module and not one app's ───────────────────────────────────
136
+ * The record, because it explains the shape this file still has. It was
137
+ * `@satellites/satellite-kit/video-derivatives`; `collections` is the only app in this
138
+ * generation that has it, and it is copied here rather than re-inlined into `storage.ts`.
139
+ *
140
+ * It was `apps/collections/src/server/storage.ts` first, and it worked, so `apps/family`
141
+ * shipped video with none of it: no faststart stream, no scrub previews, no processing state
142
+ * — the owner's 2026-08-13 note ("the ffmpeg processing, awesome scrubbing … should all be
143
+ * possible" in family, with collections named as the example to copy). A key contract shared
144
+ * with ANOTHER REPO is exactly the thing that must not be re-typed per app: a second copy of
145
+ * `${key}.web.mp4` is a silent no-preview the day binary-server renames anything.
146
+ *
147
+ * 🔴 What is shared here is the STRATEGY, never the media. Nothing in this module reaches
148
+ * across tenants — every function takes the caller's own store, and each app is its own
149
+ * binary-server tenant with its own signing key. family must never be able to resolve a
150
+ * collections object and vice versa; keeping this module store-agnostic is what keeps that
151
+ * true while the two apps share one pipeline.
152
+ */
153
+ /**
154
+ * The single still a gallery draws on a tile — binary-server's `.thumb.jpg`.
155
+ *
156
+ * ── 🔴 Why this is here, from the owner's report (2026-08-14) ────────────────────────
157
+ * *"videos are not showing with thumbnails in the family app keepsakes"*, with a screenshot
158
+ * of one album: eleven photographs with tiles and three videos — 129, 282 and 633 MB — drawn
159
+ * as a grey play triangle.
160
+ *
161
+ * Nothing was broken. binary-server's `runPoster` had always existed and was reachable only
162
+ * from its `library` strategy, while `family` and `collections` both submit `faststart`,
163
+ * which minted a remux and sprites and no still. With no picture to show, the gallery fell
164
+ * back to asking the BROWSER to decode the clip's own first frame — which works on a small
165
+ * file and does not happen at all on a 633 MB one over a home connection, because the browser
166
+ * must fetch and demux the front of the container first and quietly gives up.
167
+ *
168
+ * 🔴 **The sprite sheet is not a substitute and was rejected as one.** It is a GRID of frames,
169
+ * so drawing it on a tile shows a contact sheet, and using one cell means every consumer
170
+ * carries the VTT's geometry in order to crop it — a second, subtler copy of exactly the
171
+ * cross-repo duplication this module exists to prevent.
172
+ *
173
+ * 🔴 **The same name a still photograph's thumbnail gets, deliberately.** A consumer then has
174
+ * ONE rule — *the tile for anything is `<key>.thumb.jpg`, and its absence means not ready* —
175
+ * rather than one rule per media kind, and `.thumb.jpg` was already in binary-server's
176
+ * `DERIVATIVE_SUFFIXES`, so nothing about the key contract, the sweep's backlog arithmetic or
177
+ * the duplicate scan changes. A video and a photograph are different things to a person and
178
+ * the same thing to a grid.
179
+ */
180
+ /* The definition moved to cursedbelt with the rest of the contract (re-exported above);
181
+ * the reasoning above is kept here because it is the report that produced the name. */
182
+ /**
183
+ * The relative sprite name binary-server writes into every VTT cue, which a consumer rewrites
184
+ * into a signed URL when it serves the track.
185
+ *
186
+ * 🔴 A literal shared with another repo and enforced by nothing but this comment. It is
187
+ * `buildSpriteVtt`'s `spriteName` default in binary-server's `ffmpegJobs.ts`. If that default
188
+ * ever changes, scrub previews go blank silently — a cue pointing at a name nothing serves
189
+ * produces no error, no console warning and no visible failure except an empty thumbnail
190
+ * strip. Each consumer's scrub-route test asserts a real binary-server-shaped VTT is
191
+ * rewritten, so the pin is behavioral rather than a hope.
192
+ */
193
+ export const SPRITE_VTT_PLACEHOLDER = "sprites.jpg";
194
+ /**
195
+ * The largest scrub track an app will proxy. A VTT is one text cue per sampled frame and
196
+ * binary-server caps a sheet at a few hundred tiles, so an honest one is under 20 KB; this is
197
+ * a tripwire against proxying something that is not a VTT at all, on a box with
198
+ * `MemoryMax=300M`.
199
+ */
200
+ export const MAX_SCRUB_VTT_BYTES = 256 * 1024;
201
+ /**
202
+ * Rewrite a binary-server scrub track so its cues point at a signed sprite URL.
203
+ *
204
+ * 🔴 The reference is replaced WHOLESALE rather than prefixed: a signed URL carries a query
205
+ * string, and a cue's `#xywh=` fragment must stay after it. Prefixing produces
206
+ * `…/sprite.jpg?token=…#xywh=0,0,160,90` only by accident of ordering, and gets it wrong the
207
+ * moment the signer appends a parameter.
208
+ */
209
+ export const rewriteScrubVtt = (vtt, spriteUrl) => vtt.replaceAll(SPRITE_VTT_PLACEHOLDER, spriteUrl);
210
+ /**
211
+ * Resolve what a video can offer RIGHT NOW: the faststart stream if it is ready, and whether
212
+ * a scrub track exists.
213
+ *
214
+ * 🔴 Best-effort throughout, deliberately. binary-server being slow, down, or out of disk must
215
+ * degrade this to "here are the original bytes", never break opening a file — a probe that
216
+ * throws would turn a working (if slow) video into a broken one, which is strictly worse than
217
+ * the problem the derivative exists to solve. An absent derivative is a NORMAL state.
218
+ *
219
+ * The signer is injected rather than taken as a store + options bag: the two consumers mint
220
+ * URLs under different privacy rules (collections signs a private item with a short,
221
+ * non-stable window; family signs by kind), and that decision must stay at the call site
222
+ * where the row's privacy is known.
223
+ */
224
+ export async function resolveVideoPlayback(store, sourceKey, signStream,
225
+ /**
226
+ * Signs a PREFIX-scoped token for an HLS tree, when the caller can serve one.
227
+ *
228
+ * Separate from `signStream` because it mints a different claim (`p`, not `k`): one token
229
+ * has to authorize the master playlist AND every rung index AND every segment under it,
230
+ * and re-signing per segment would mean a round trip to the app for each one. Optional so
231
+ * a consumer that has not wired the HLS route yet keeps working unchanged — it simply
232
+ * never advertises a ladder.
233
+ */
234
+ signHlsTree) {
235
+ const webKey = webPlayableKeyFor(sourceKey);
236
+ const webMeta = await store.meta(webKey).catch(() => null);
237
+ const scrubMeta = await store.meta(spriteVttKeyFor(sourceKey)).catch(() => null);
238
+ const scrubReady = scrubMeta !== null && scrubMeta !== undefined;
239
+ if (!webMeta)
240
+ return { processingStatus: "processing", scrubReady };
241
+ const streamUrl = await signStream(webKey).catch(() => null);
242
+ // A signable derivative that would not sign is the credentials being wrong, which is the
243
+ // same answer as "not ready": play the original.
244
+ if (!streamUrl)
245
+ return { processingStatus: "processing", scrubReady };
246
+ /*
247
+ * 🔴 The ladder is probed by its MASTER, and the master is written LAST.
248
+ *
249
+ * binary-server stores every rung and every segment before `master.m3u8`, so the master's
250
+ * presence is the one honest "the whole ladder is there" signal. Probing the prefix, or a
251
+ * rung, would advertise a half-written ladder — and that fails far worse than no ladder at
252
+ * all: a player handed a master whose segments 404 RETRIES them rather than falling back,
253
+ * so the video stalls instead of playing the progressive copy it could have used.
254
+ *
255
+ * Everything here stays best-effort. No ladder is the normal case (it is a re-encode, so
256
+ * most videos will never have one) and a failed probe or an unsignable prefix simply means
257
+ * `streamUrl` is what the player gets — the state it has always been in.
258
+ */
259
+ const hlsUrl = signHlsTree
260
+ ? await store
261
+ .meta(hlsMasterKeyFor(sourceKey))
262
+ .then((master) => (master ? signHlsTree(hlsPrefixFor(sourceKey)) : null))
263
+ .catch(() => null)
264
+ : null;
265
+ return hlsUrl ? { streamUrl, hlsUrl, scrubReady } : { streamUrl, scrubReady };
266
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Server-side half of the direct-to-binary-server upload: mint a session an app can hand to
3
+ * a browser.
4
+ *
5
+ * 🔴 SERVER ONLY — this reaches the tenant's private key through {@link BinaryStore}. The
6
+ * browser half (`uploadDirect`, in the React design system's tier) imports nothing from here.
7
+ *
8
+ * ── `cursedbelt-server/binary-store/upload-session`, since 4.19.0 (task 343) ──────────────
9
+ * It was `src/kit/directUploadSession.ts` in `collections` and `family`, and the two copies
10
+ * had already forked the way copies of a wire format do: `collections`' mint declared the
11
+ * source length as `sz` and `family`'s did not, so a wrong `tc` in `family` could still store a
12
+ * short object indexed `active` — the 2026-08-13 loss `collections` had already paid for. This
13
+ * is the `collections` body. It is a leaf of its own rather than part of `./binary-store`
14
+ * because that entry's promise is ONE file that costs `node:crypto` and nothing else, and the
15
+ * fixture proving it copies only that file; this one's only import is a TYPE, so it keeps the
16
+ * same promise at zero runtime cost.
17
+ *
18
+ * The token this produces is a capability: it authorizes writing ONE key for a bounded time,
19
+ * and nothing else. It cannot read, cannot delete, cannot touch another key, and cannot touch
20
+ * another tenant (binary-server resolves the app from the token's issuer and requires the
21
+ * request path to match the claim). So handing it to a page is safe in the way handing over a
22
+ * signed download URL is safe — which the fleet already does for media reads.
23
+ */
24
+ import type { BinaryStore } from "./binaryStore.js";
25
+ /** Bytes per part. 16 MiB matches what `putLarge` has used in production since 2026-08-08:
26
+ * comfortably under the Cloudflare Tunnel's ~100 MB request wall with room for retries, and
27
+ * large enough that a 10 GB upload is ~640 requests rather than tens of thousands. */
28
+ export declare const DIRECT_UPLOAD_CHUNK_BYTES: number;
29
+ /**
30
+ * How long a session token stays valid.
31
+ *
32
+ * 🔴 Deliberately hours, not the 10 minutes `UPLOAD_TOKEN_TTL_SECONDS` gives a server-to-server
33
+ * upload. A 10 GB file on a home upstream is a multi-hour transfer, and a token that expires
34
+ * mid-way turns it into a failed one. Six hours covers the realistic worst case; beyond that
35
+ * the browser re-mints for the same `sid` (see `refreshToken` in the browser half), so a paused
36
+ * overnight upload still resumes without widening this window.
37
+ *
38
+ * The exposure a longer TTL buys is small and bounded: write access to one key the app just
39
+ * allocated for this upload, which the app has not yet shown to anyone.
40
+ */
41
+ export declare const DIRECT_UPLOAD_TOKEN_TTL_SECONDS: number;
42
+ /** The descriptor handed to the browser. Mirrors `UploadSession` in `directUpload.ts` —
43
+ * they are the two ends of one wire format, so change them together. */
44
+ export interface UploadSessionDescriptor {
45
+ baseUrl: string;
46
+ path: string;
47
+ token: string;
48
+ sid: string;
49
+ totalChunks: number;
50
+ chunkBytes: number;
51
+ }
52
+ export interface CreateUploadSessionOptions {
53
+ /** App-relative destination key (NOT app-prefixed — the store adds that). */
54
+ key: string;
55
+ /** Exact byte length of the file being uploaded. */
56
+ sizeBytes: number;
57
+ /** binary-server's public base URL, as the browser must reach it. */
58
+ baseUrl: string;
59
+ /** Owner recorded on the stored object (`uid` claim). */
60
+ ownerId?: string;
61
+ /** Reuse an existing session id to RESUME — the same `sid` addresses the same staged
62
+ * parts, so re-minting a token never orphans partial work. */
63
+ sid?: string;
64
+ chunkBytes?: number;
65
+ ttlSeconds?: number;
66
+ }
67
+ /**
68
+ * Mint an upload session for one file.
69
+ *
70
+ * `totalChunks` is derived from `sizeBytes` here, on the server, and baked into the token as
71
+ * `tc` — that is what makes it authoritative. binary-server assembles when `tc` parts have
72
+ * arrived, so a client cannot claim a file is complete early by lying about its length; the
73
+ * worst it can do is describe its own file wrongly and fail its own upload.
74
+ */
75
+ export declare function createUploadSession(store: BinaryStore, opts: CreateUploadSessionOptions): Promise<UploadSessionDescriptor>;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Server-side half of the direct-to-binary-server upload: mint a session an app can hand to
3
+ * a browser.
4
+ *
5
+ * 🔴 SERVER ONLY — this reaches the tenant's private key through {@link BinaryStore}. The
6
+ * browser half (`uploadDirect`, in the React design system's tier) imports nothing from here.
7
+ *
8
+ * ── `cursedbelt-server/binary-store/upload-session`, since 4.19.0 (task 343) ──────────────
9
+ * It was `src/kit/directUploadSession.ts` in `collections` and `family`, and the two copies
10
+ * had already forked the way copies of a wire format do: `collections`' mint declared the
11
+ * source length as `sz` and `family`'s did not, so a wrong `tc` in `family` could still store a
12
+ * short object indexed `active` — the 2026-08-13 loss `collections` had already paid for. This
13
+ * is the `collections` body. It is a leaf of its own rather than part of `./binary-store`
14
+ * because that entry's promise is ONE file that costs `node:crypto` and nothing else, and the
15
+ * fixture proving it copies only that file; this one's only import is a TYPE, so it keeps the
16
+ * same promise at zero runtime cost.
17
+ *
18
+ * The token this produces is a capability: it authorizes writing ONE key for a bounded time,
19
+ * and nothing else. It cannot read, cannot delete, cannot touch another key, and cannot touch
20
+ * another tenant (binary-server resolves the app from the token's issuer and requires the
21
+ * request path to match the claim). So handing it to a page is safe in the way handing over a
22
+ * signed download URL is safe — which the fleet already does for media reads.
23
+ */
24
+ /** Bytes per part. 16 MiB matches what `putLarge` has used in production since 2026-08-08:
25
+ * comfortably under the Cloudflare Tunnel's ~100 MB request wall with room for retries, and
26
+ * large enough that a 10 GB upload is ~640 requests rather than tens of thousands. */
27
+ export const DIRECT_UPLOAD_CHUNK_BYTES = 16 * 1024 * 1024;
28
+ /**
29
+ * How long a session token stays valid.
30
+ *
31
+ * 🔴 Deliberately hours, not the 10 minutes `UPLOAD_TOKEN_TTL_SECONDS` gives a server-to-server
32
+ * upload. A 10 GB file on a home upstream is a multi-hour transfer, and a token that expires
33
+ * mid-way turns it into a failed one. Six hours covers the realistic worst case; beyond that
34
+ * the browser re-mints for the same `sid` (see `refreshToken` in the browser half), so a paused
35
+ * overnight upload still resumes without widening this window.
36
+ *
37
+ * The exposure a longer TTL buys is small and bounded: write access to one key the app just
38
+ * allocated for this upload, which the app has not yet shown to anyone.
39
+ */
40
+ export const DIRECT_UPLOAD_TOKEN_TTL_SECONDS = 6 * 60 * 60;
41
+ /**
42
+ * Mint an upload session for one file.
43
+ *
44
+ * `totalChunks` is derived from `sizeBytes` here, on the server, and baked into the token as
45
+ * `tc` — that is what makes it authoritative. binary-server assembles when `tc` parts have
46
+ * arrived, so a client cannot claim a file is complete early by lying about its length; the
47
+ * worst it can do is describe its own file wrongly and fail its own upload.
48
+ */
49
+ export async function createUploadSession(store, opts) {
50
+ const chunkBytes = opts.chunkBytes ?? DIRECT_UPLOAD_CHUNK_BYTES;
51
+ if (!Number.isFinite(opts.sizeBytes) || opts.sizeBytes < 0) {
52
+ throw new Error(`createUploadSession: bad sizeBytes ${opts.sizeBytes}`);
53
+ }
54
+ const totalChunks = Math.max(1, Math.ceil(opts.sizeBytes / chunkBytes));
55
+ const sid = opts.sid ?? crypto.randomUUID();
56
+ const path = `${store.appId}/${opts.key}`;
57
+ // 🔴 The source length rides along with the count, because a count is NOT a length: `tc` is
58
+ // arithmetic over a chunk size the client chose, so the server can derive neither from the
59
+ // other. With `sz` on the token bs compares staged and assembled bytes against it and answers
60
+ // 422, so a wrong `tc` can only cost a refused upload rather than a short object indexed
61
+ // `active`. It is the same number `tc` was derived from two lines up, so declaring it costs
62
+ // nothing; the caller states a real length (`POST /api/uploads` refuses `sizeBytes <= 0`
63
+ // outright), and a stated length is the only thing this can be — never a placeholder, because
64
+ // a `sz` that does not match the bytes refuses every assemble.
65
+ const token = await store.signToken({ k: path, sid, tc: totalChunks, sz: opts.sizeBytes, uid: opts.ownerId }, opts.ttlSeconds ?? DIRECT_UPLOAD_TOKEN_TTL_SECONDS);
66
+ return {
67
+ baseUrl: opts.baseUrl.replace(/\/+$/, ""),
68
+ path,
69
+ token,
70
+ sid,
71
+ totalChunks,
72
+ chunkBytes,
73
+ };
74
+ }
@@ -0,0 +1,55 @@
1
+ # 🔴 `cursedbelt` is a devDependency of this package, and `cursedbelt` devDepends on this one
2
+
3
+ A cycle, deliberately, and only in `devDependencies`. Written down because the next person
4
+ to see it will assume it is a mistake, and because it has one consequence that bites.
5
+
6
+ ## Why it exists
7
+
8
+ Two integration specs prove the chunked-upload protocol end to end:
9
+
10
+ - `src/server/storage/fileUploadClient.integration.spec.ts`
11
+ - `src/server/storage/fileUploadClient-chunked.integration.spec.ts`
12
+
13
+ Their subject is **this** package's server internals — `diskStore`, `streamToken`,
14
+ `files/catalogue`, `files/fileRoutes` — none of which are exported, and none of which
15
+ should be: widening the public surface so a test can reach it is how a surface rots.
16
+ So the specs must live here.
17
+
18
+ But the thing that DRIVES that protocol is the React-side upload client, which lives in
19
+ `cursedbelt` (`cursedbelt/react/media/upload-client`). Hence the dev-only edge.
20
+
21
+ The alternative was to move the specs into `cursedbelt` and export four server internals
22
+ to make them reachable. That trades a dev-only cycle for a permanent public promise, which
23
+ is the worse trade.
24
+
25
+ ## What is NOT circular
26
+
27
+ **Runtime dependencies are acyclic and must stay that way.**
28
+
29
+ cwip → cursedbelt-core → cursedbelt-server
30
+ ↘ cursedbelt (react)
31
+ ↘ cursedbelt-cc
32
+
33
+ `cursedbelt-server` does not depend on `cursedbelt` at runtime, and `cursedbelt` does not
34
+ depend on `cursedbelt-server` at runtime. If either ever does, this file is no longer
35
+ describing a devDependency cycle — it is describing a bug.
36
+
37
+ ## 🔴 The consequence: publish order
38
+
39
+ `prepublishOnly` runs the full `verify`, and `verify` runs these specs, which resolve
40
+ `cursedbelt` from the registry. So **a version of this package whose specs need a not-yet-
41
+ published `cursedbelt` cannot publish until that `cursedbelt` is live.** The two packages
42
+ cannot be bumped in lockstep in one pass.
43
+
44
+ The order that works, and the one the 2026-09-15 split used:
45
+
46
+ 1. `cursedbelt-core` (depends on neither)
47
+ 2. `cursedbelt-server` against the CURRENT published `cursedbelt`
48
+ 3. `cursedbelt` against the just-published `cursedbelt-server`
49
+ 4. `cursedbelt-cc` last — it depends on all three
50
+
51
+ If you need a `cursedbelt-server` change and a `cursedbelt` change that depend on each
52
+ other, ship them as two rounds of that order, not one. And remember `tools/publish-libs.sh`
53
+ polls the registry because npm answers a publish with 202 and serves it minutes later — a
54
+ timeout there is not a failure, and `curl -sI https://registry.npmjs.org/<pkg>/<version>`
55
+ is the one command that settles it.
@@ -0,0 +1,43 @@
1
+ # Activity — the ONE audit stream
2
+
3
+ `cursedbelt-server/activity` (storage + query) over `cursedbelt-core/activity` (the pure
4
+ vocabulary) is the code; this page is the contract. Written 2026-09-23 (task 324) because
5
+ `apps/roms/src/server/routes.ts` asserted a rule "`cursedbelt-server/activity` is explicit
6
+ about" that no document stated.
7
+
8
+ ## 1. One stream per app, append-only, never deleted by this package
9
+
10
+ Who did what, to which record, when, and how it went — one table
11
+ (`ACTIVITY_EVENTS_TABLE`), written through `createActivityLog`. This package never deletes an
12
+ event. A high-volume stream opts into [retention](retention.md) as `event-log`; a low-volume
13
+ audit of destructive actions is right to declare nothing (retention §4).
14
+
15
+ ## 2. The three outcomes, and why a refusal is `denied` — never `error`
16
+
17
+ `ACTIVITY_OUTCOMES` is `ok`, `denied`, `error`, and the line between the last two is the whole
18
+ value of the column:
19
+
20
+ | outcome | means | who acts on it |
21
+ |---|---|---|
22
+ | `ok` | it happened | nobody |
23
+ | `denied` | the app **correctly refused** — a policy, a permission, a quota, a validation said no | the person who was refused, or the owner deciding the policy |
24
+ | `error` | the app **failed** to do what it meant to do | whoever maintains the app |
25
+
26
+ A refusal recorded as `error` makes the app look broken exactly when it worked, and buries its
27
+ real failures under correct behaviour; a failure recorded as `denied` hides a bug as a policy.
28
+ `roms` records BOTH halves of an upload for this reason: an accepted one puts a file on the
29
+ owner's storage under his name, and a refused one — `denied` — is the row that answers "I tried
30
+ to add that game weeks ago".
31
+
32
+ ## 3. A resource is `(app, kind, id)`
33
+
34
+ Identical to `cursedbelt-core/sharing`'s `ShareResource`, so a resource passes from one
35
+ primitive to the other with no translation (`activityFromShareEvent`). `app` is on every row so
36
+ a merge across apps stays labelled.
37
+
38
+ ## 4. Describing an event is the library's job
39
+
40
+ `describeActivityEvent` / `describeActivityResource` produce the sentence a reader sees. An app
41
+ that writes its own sentence for the same event creates a second wording that drifts.
42
+
43
+ Cite this page from an app as `cursedbelt-server/docs/activity.md`.
@@ -0,0 +1,47 @@
1
+ # Engagement — product analytics, and which side of the gate a receiver mounts on
2
+
3
+ `cursedbelt-server/engagement` is the server half (recorder, store, policy, summary); the beacon
4
+ is the browser half in the design system's tier. Four apps copied `src/kit/engagement/` and cited
5
+ a feedback-kit standard for its mount-order rule; neither existed here until 2026-09-23 (tasks
6
+ 280 and 324). This page is that rule and the privacy promise it serves.
7
+
8
+ ## 1. The privacy promise is three structural rules (`src/server/engagement/policy.ts`)
9
+
10
+ 1. **A place is an allowlisted id, never a path.** The allowlist is the app's own
11
+ `routes.manifest.json`; anything a client claims that is not on it records as `other`.
12
+ 2. **Some apps are excluded by name, in the library** (`ENGAGEMENT_EXCLUDED_APPS` — the vault).
13
+ No environment variable can turn an excluded app back on.
14
+ 3. **Counts and clocks only.** One row per (user, place, UTC day); there is no event log, so a
15
+ timeline of a household's private apps cannot be reconstructed from the store.
16
+
17
+ The user key comes ONLY from the app's `resolveUser(c)` — never from the request body — so the
18
+ beacon cannot be used to write somebody else's history.
19
+
20
+ ## 2. Mount order — the decision four apps had to agree on
21
+
22
+ | receiver | its credential | mounts |
23
+ |---|---|---|
24
+ | the engagement recorder (`POST <mount>/view`) | none of its own — it needs the SESSION's user | **AFTER** the session gate |
25
+ | a sync receiver (a feed another machine pushes into) | its own bearer | **BEFORE** the session gate |
26
+
27
+ The two are mirror images for one reason: **a door mounts on the side of the gate that supplies
28
+ the identity it trusts.** The recorder trusts the session, so the gate must have run and put the
29
+ user where `resolveUser` reads it; mounted before, every view is a 401 and nothing records. A
30
+ sync receiver trusts its bearer and has no session at all; mounted after, the gate refuses the
31
+ machine before its own check can run, and the feed goes silently dark.
32
+
33
+ ## 3. What the app keeps
34
+
35
+ Its `routes.manifest.json` and the test that proves it parses. `engagementPlacesFor` returns `[]`
36
+ for anything it cannot read — deliberately silent, so telemetry never takes a boot down — which
37
+ degrades the allowlist to nothing while the board keeps rendering. Only the app can prove its own
38
+ manifest parses (`apps/roms/routes.manifest.test.ts` is the pattern).
39
+
40
+ ## 4. Where the data lives
41
+
42
+ `engagement.sqlite` beside the app's own database (`readEngagementEnv` resolves it with
43
+ `cursedbelt-server/satellite-config`'s `resolveAppDataDir`), or nowhere: a test runner, an
44
+ excluded app, `SATELLITE_ENGAGEMENT=0` or an unresolvable data directory each turn recording OFF
45
+ and say why, and the app still boots.
46
+
47
+ Cite this page from an app as `cursedbelt-server/docs/engagement.md`.
@@ -0,0 +1,43 @@
1
+ # Notifications — the bell and inbox contract
2
+
3
+ `cursedbelt-server/notifications` (routes, SSE hub, migration) over the engine in
4
+ `cwip/notifications` is the code, and `cursedbelt/react/notifications` is the client. This page
5
+ is the contract a producer in any app is agreeing to. Written 2026-09-23 (task 324); `family`
6
+ cited it four times before it existed.
7
+
8
+ ## 1. A notification is a row before it is an event
9
+
10
+ Delivery (SSE) is a cache of who is listening; the poll transport is the backstop and is not
11
+ optional. Nothing is lost to a closed laptop.
12
+
13
+ ## 2. Coalesce what RECURS — never coalesce what is individually ACTIONABLE
14
+
15
+ A `groupKey` folds repeats into one row with a count, and it is enforced by the database (a
16
+ partial unique index on `(user_id, group_key) WHERE group_key IS NOT NULL AND done = 0`), not
17
+ by a debounce, so it holds under concurrent producers.
18
+
19
+ The rule for choosing one:
20
+
21
+ - **Give it a `groupKey`** when the twentieth instance tells the reader nothing the first did:
22
+ "a sync failed", "new comments on this thread", "a backup is overdue". One row, a count, one
23
+ thing to go and look at.
24
+ - **Give it NO `groupKey`** when each instance is its own thing to go and look at: two
25
+ relatives shared with the same person are two people to open (`apps/family/src/server/shares.ts`,
26
+ `shareNotificationDraft`), two uploads that need review are two reviews. Coalescing those
27
+ hides work behind a number.
28
+
29
+ When in doubt, do not coalesce: an extra row costs a glance, a swallowed one costs the thing
30
+ the notification was for.
31
+
32
+ ## 3. The routes address the session's user and nothing else
33
+
34
+ There is no route that notifies somebody else. Producing is server-side code calling
35
+ `center.notify*`; the mounted routes list, read and mark the CURRENT user's rows.
36
+
37
+ ## 4. A notification is written once and read later — so it names nothing the reader may not see
38
+
39
+ It is not filtered by the reader's scope at read time and cannot be. Put what is safe for the
40
+ recipient in the title and body, and let the LINK carry them to the thing, where every normal
41
+ read filter applies (`family`'s share notification does not name the relative for this reason).
42
+
43
+ Cite this page from an app as `cursedbelt-server/docs/notifications.md`.