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.
- package/dist/server/activity/index.d.ts +2 -1
- package/dist/server/activity/index.js +2 -1
- package/dist/server/auth/passwordCost.d.ts +21 -0
- package/dist/server/auth/passwordCost.js +80 -0
- package/dist/server/bench/index.d.ts +1 -0
- package/dist/server/bench/index.js +1 -0
- package/dist/server/bench/tail.d.ts +110 -0
- package/dist/server/bench/tail.js +182 -0
- package/dist/server/d1/index.d.ts +1 -2
- package/dist/server/d1/index.js +9 -9
- package/dist/server/d1/pullD1.js +16 -3
- package/dist/server/engagement/api.d.ts +71 -0
- package/dist/server/engagement/api.js +84 -0
- package/dist/server/engagement/env.d.ts +18 -0
- package/dist/server/engagement/env.js +52 -0
- package/dist/server/engagement/index.d.ts +55 -0
- package/dist/server/engagement/index.js +55 -0
- package/dist/server/engagement/places.d.ts +22 -0
- package/dist/server/engagement/places.js +63 -0
- package/dist/server/engagement/policy.d.ts +168 -0
- package/dist/server/engagement/policy.js +202 -0
- package/dist/server/engagement/store.d.ts +92 -0
- package/dist/server/engagement/store.js +223 -0
- package/dist/server/engagement/summary.d.ts +102 -0
- package/dist/server/engagement/summary.js +127 -0
- package/dist/server/engagement/types.d.ts +42 -0
- package/dist/server/engagement/types.js +12 -0
- package/dist/server/maps-budget/mapsBudget.d.ts +194 -0
- package/dist/server/maps-budget/mapsBudget.js +193 -0
- package/dist/server/satellite/config.d.ts +173 -0
- package/dist/server/satellite/config.js +259 -0
- package/dist/server/satellite/door.d.ts +112 -0
- package/dist/server/satellite/door.js +149 -0
- package/dist/server/storage/binaryStore.d.ts +18 -0
- package/dist/server/storage/binaryStore.js +32 -1
- package/dist/server/storage/derivatives.d.ts +253 -0
- package/dist/server/storage/derivatives.js +266 -0
- package/dist/server/storage/uploadSession.d.ts +75 -0
- package/dist/server/storage/uploadSession.js +74 -0
- package/docs/THE-DEV-DEPENDENCY-CYCLE.md +55 -0
- package/docs/activity.md +43 -0
- package/docs/engagement.md +47 -0
- package/docs/notifications.md +43 -0
- package/docs/retention.md +81 -0
- package/docs/skipped-tests.md +19 -0
- package/package.json +46 -9
- package/src/barrelsReachNoOptionalPeer.spec.ts +5 -3
- package/src/leafSubpathsImportNothing.spec.ts +49 -0
- package/src/server/activity/index.ts +2 -1
- package/src/server/auth/passwordCost.spec.ts +42 -0
- package/src/server/auth/passwordCost.ts +87 -0
- package/src/server/bench/index.ts +13 -0
- package/src/server/bench/tail.spec.ts +126 -0
- package/src/server/bench/tail.ts +237 -0
- package/src/server/d1/index.ts +9 -9
- package/src/server/d1/pullD1.spec.ts +20 -0
- package/src/server/d1/pullD1.ts +18 -2
- package/src/server/engagement/api.ts +119 -0
- package/src/server/engagement/engagement.spec.ts +462 -0
- package/src/server/engagement/env.ts +73 -0
- package/src/server/engagement/index.ts +92 -0
- package/src/server/engagement/places.ts +76 -0
- package/src/server/engagement/policy.ts +250 -0
- package/src/server/engagement/store.ts +272 -0
- package/src/server/engagement/summary.ts +216 -0
- package/src/server/engagement/types.ts +61 -0
- package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
- package/src/server/maps-budget/mapsBudget.ts +304 -0
- package/src/server/satellite/config.ts +389 -0
- package/src/server/satellite/door.ts +169 -0
- package/src/server/satellite/satellite.spec.ts +161 -0
- package/src/server/storage/binaryStore.ts +31 -1
- package/src/server/storage/derivatives.spec.ts +125 -0
- package/src/server/storage/derivatives.ts +329 -0
- package/src/server/storage/uploadSession.spec.ts +132 -0
- package/src/server/storage/uploadSession.ts +114 -0
- package/dist/server/d1/kysely.d.ts +0 -56
- package/dist/server/d1/kysely.js +0 -138
- package/src/server/d1/kysely.spec.ts +0 -145
- 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.
|
package/docs/activity.md
ADDED
|
@@ -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`.
|