cursedbelt-server 4.18.1 → 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 (77) 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/engagement/api.d.ts +71 -0
  12. package/dist/server/engagement/api.js +84 -0
  13. package/dist/server/engagement/env.d.ts +18 -0
  14. package/dist/server/engagement/env.js +52 -0
  15. package/dist/server/engagement/index.d.ts +55 -0
  16. package/dist/server/engagement/index.js +55 -0
  17. package/dist/server/engagement/places.d.ts +22 -0
  18. package/dist/server/engagement/places.js +63 -0
  19. package/dist/server/engagement/policy.d.ts +168 -0
  20. package/dist/server/engagement/policy.js +202 -0
  21. package/dist/server/engagement/store.d.ts +92 -0
  22. package/dist/server/engagement/store.js +223 -0
  23. package/dist/server/engagement/summary.d.ts +102 -0
  24. package/dist/server/engagement/summary.js +127 -0
  25. package/dist/server/engagement/types.d.ts +42 -0
  26. package/dist/server/engagement/types.js +12 -0
  27. package/dist/server/maps-budget/mapsBudget.d.ts +194 -0
  28. package/dist/server/maps-budget/mapsBudget.js +193 -0
  29. package/dist/server/satellite/config.d.ts +173 -0
  30. package/dist/server/satellite/config.js +259 -0
  31. package/dist/server/satellite/door.d.ts +112 -0
  32. package/dist/server/satellite/door.js +149 -0
  33. package/dist/server/storage/binaryStore.d.ts +18 -0
  34. package/dist/server/storage/binaryStore.js +32 -1
  35. package/dist/server/storage/derivatives.d.ts +253 -0
  36. package/dist/server/storage/derivatives.js +266 -0
  37. package/dist/server/storage/uploadSession.d.ts +75 -0
  38. package/dist/server/storage/uploadSession.js +74 -0
  39. package/docs/THE-DEV-DEPENDENCY-CYCLE.md +55 -0
  40. package/docs/activity.md +43 -0
  41. package/docs/engagement.md +47 -0
  42. package/docs/notifications.md +43 -0
  43. package/docs/retention.md +81 -0
  44. package/docs/skipped-tests.md +19 -0
  45. package/package.json +46 -9
  46. package/src/barrelsReachNoOptionalPeer.spec.ts +5 -3
  47. package/src/leafSubpathsImportNothing.spec.ts +49 -0
  48. package/src/server/activity/index.ts +2 -1
  49. package/src/server/auth/passwordCost.spec.ts +42 -0
  50. package/src/server/auth/passwordCost.ts +87 -0
  51. package/src/server/bench/index.ts +13 -0
  52. package/src/server/bench/tail.spec.ts +126 -0
  53. package/src/server/bench/tail.ts +237 -0
  54. package/src/server/d1/index.ts +9 -9
  55. package/src/server/engagement/api.ts +119 -0
  56. package/src/server/engagement/engagement.spec.ts +462 -0
  57. package/src/server/engagement/env.ts +73 -0
  58. package/src/server/engagement/index.ts +92 -0
  59. package/src/server/engagement/places.ts +76 -0
  60. package/src/server/engagement/policy.ts +250 -0
  61. package/src/server/engagement/store.ts +272 -0
  62. package/src/server/engagement/summary.ts +216 -0
  63. package/src/server/engagement/types.ts +61 -0
  64. package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
  65. package/src/server/maps-budget/mapsBudget.ts +304 -0
  66. package/src/server/satellite/config.ts +389 -0
  67. package/src/server/satellite/door.ts +169 -0
  68. package/src/server/satellite/satellite.spec.ts +161 -0
  69. package/src/server/storage/binaryStore.ts +31 -1
  70. package/src/server/storage/derivatives.spec.ts +125 -0
  71. package/src/server/storage/derivatives.ts +329 -0
  72. package/src/server/storage/uploadSession.spec.ts +132 -0
  73. package/src/server/storage/uploadSession.ts +114 -0
  74. package/dist/server/d1/kysely.d.ts +0 -56
  75. package/dist/server/d1/kysely.js +0 -138
  76. package/src/server/d1/kysely.spec.ts +0 -145
  77. package/src/server/d1/kysely.ts +0 -169
@@ -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`.
@@ -0,0 +1,81 @@
1
+ # Retention — the fleet standard
2
+
3
+ `cursedbelt-server/retention` is the code; this page is the contract every app that deletes
4
+ old rows is agreeing to. Four apps cited a `docs/retention.md` for two generations and it did
5
+ not exist here until 2026-09-23 (task 324). Where this page and the code disagree, the code
6
+ wins and this page is the bug.
7
+
8
+ ## 1. Retention is OPT-IN, and by CATEGORY — never by table
9
+
10
+ A resource can be swept only if it declares a `RetentionCategory`
11
+ (`src/server/retention/policy.ts`, `RETENTION_CATEGORIES`). Every category describes one kind
12
+ of data: **a record that exists so a human can find out what happened, and whose loss costs
13
+ nothing but hindsight** — `audit-log`, `event-log`, `run-log`, `transcript`, `metric`, `cache`,
14
+ `replica-mirror`, `queue-result`, `scratch-artifact`, and the one exception, `archived-history`.
15
+
16
+ Why by category rather than by table:
17
+
18
+ - **Long-term data is protected by ABSENCE.** There is no category that can describe a note, a
19
+ setting, a password, a save state, a playlist or a vault entry. "Should this table have a
20
+ policy?" is answered by whether an honest category exists, not by somebody's judgement on
21
+ the day — and a new table is safe by default, because it is not swept until it is declared.
22
+ - **The number is the category's, not the app's.** Every deleting category defaults to the
23
+ owner's two-week standard or shorter. A deleting category that wanted a longer default would
24
+ be describing data that belongs to the user — the case this vocabulary refuses to express.
25
+ - **The owner, not the app, moves the number.** An app publishes what it owns
26
+ (`publishRetention`); overrides are read from one file the station edits
27
+ (`writeRetentionOverride`); both the station's view and every sweep resolve the policy with
28
+ the same function (`resolveRetentionPolicy`), so the screen can never promise a date the
29
+ sweeper does not keep.
30
+
31
+ ## 2. `archived-history` is the only category allowed a longer window
32
+
33
+ It keeps a bounded window on the instance and writes everything older to a verified,
34
+ append-only archive BEFORE a row is deleted. `archivesBeforeDelete` is a property of the
35
+ category: the plain-delete targets in `src/server/retention/target.ts` refuse a declaration
36
+ that carries it, and the archiving target refuses to delete a row it cannot prove it archived.
37
+
38
+ ## 3. Two floors that hold even when a policy is wrong
39
+
40
+ - `floorItems` — no policy, however mis-set, can empty a resource.
41
+ - `RETENTION_MIN_DAYS` / `RETENTION_MAX_DAYS` bound every override.
42
+
43
+ ## 4. Declaring nothing is a legitimate answer
44
+
45
+ An audit of destructive actions that writes a handful of rows a month is right to declare no
46
+ category and say so in a comment — a 14-day cutoff must not reach an audit trail of production
47
+ deletions. Opting in is a decision about the data, not a checkbox every table ticks.
48
+
49
+ ## 5. Anything that grows without bound and is NOT sweepable is a design bug
50
+
51
+ If a resource grows for ever and no category fits it, the answer is not a longer retention —
52
+ it is that the thing should not be accumulating.
53
+
54
+ ## 6. A threshold is tuned against the CORPUS, never against the story
55
+
56
+ `apps/music/scripts/backup.ts:172-190` records the failure: a refusal keyed on "a catalogue
57
+ with tracks and no organizing work must be a stage database" was written from the story, and
58
+ the live corpus at cutover (5,391 tracks, 0 playlists, 0 tags) would have tripped it nightly on
59
+ a healthy library. Before a retention or refusal number ships, measure it against what the app
60
+ actually holds, and write the measurement beside the number.
61
+
62
+ ## Adopting it
63
+
64
+ ```ts
65
+ import { createRetentionSweeper, createSqliteTableTarget } from "cursedbelt-server/retention";
66
+
67
+ createRetentionSweeper({
68
+ app: "myapp",
69
+ targets: [
70
+ createSqliteTableTarget({
71
+ db, table: "events", timeColumn: "at",
72
+ declaration: { app: "myapp", id: "events", label: "Event log", category: "event-log",
73
+ unit: "rows", location: "myapp.sqlite · events",
74
+ consequence: "Who did what, for hindsight. Losing it costs no data." },
75
+ }),
76
+ ],
77
+ }).start();
78
+ ```
79
+
80
+ Cite this page from an app as `cursedbelt-server/docs/retention.md` — with the package prefix,
81
+ because a bare `docs/retention.md` is a promise about the citing app's own `docs/`.
@@ -0,0 +1,19 @@
1
+ # Skipped tests — what each one is waiting for
2
+
3
+ `src/skippedTestsAreAnswered.spec.ts` keeps this table honest in both directions:
4
+ every file with a skip site must have a row here, and a row whose file has stopped
5
+ carrying one goes red until it is removed. Inherited from `cursedbelt` at the
6
+ 2026-09-15 split (task 148) — rows below are the ones whose files moved here.
7
+
8
+ | file | waiting for |
9
+ | --- | --- |
10
+ | `src/server/search/pgSearchProvider.pg.test.ts` | 24 | 22 | 2026-09-13 |
11
+ | `src/server/storage/transcode.integration.spec.ts` | 7 | 5 | 2026-09-13 |
12
+ | `src/server/storage/binaryServerProcessing.integration.spec.ts` | 4 | 2 | 2026-09-13 |
13
+ | `src/server/storage/chunked.integration.spec.ts` | 3 | 1 | 2026-09-13 |
14
+ | `src/server/media-bun/image.spec.ts` | the optional `sharp` peer | no |
15
+ | `src/server/media-bun/video.spec.ts` | `ffmpeg` + `ffprobe` on `PATH` | no |
16
+ | `src/server/storage/imageThumbnail.spec.ts` | `ffmpeg` on `PATH` | no |
17
+ | `src/server/storage/videoProcessor.posterOnly.spec.ts` | `ffmpeg` on `PATH` | no |
18
+ | `src/server/storage/files/adapter.photo.display.spec.ts` | `ffmpeg` on `PATH` | no |
19
+ | `src/server/storage/fileUploadClient-chunked.integration.spec.ts` | a real `binary-server` checkout — set `BINARY_SERVER_DIR`. No default by design: a published package must not go looking in one machine's directory layout, and `binary-server` has not been re-homed into this generation, so it skips on every host today. Moved here from `cursedbelt` at the 2026-09-15 split, with the storage internals it drives. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.18.1",
3
+ "version": "4.19.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -28,7 +28,8 @@
28
28
  },
29
29
  "files": [
30
30
  "dist",
31
- "src"
31
+ "src",
32
+ "docs"
32
33
  ],
33
34
  "exports": {
34
35
  ".": {
@@ -67,6 +68,12 @@
67
68
  "source": "./src/server/storage/binaryStore.ts",
68
69
  "import": "./dist/server/storage/binaryStore.js"
69
70
  },
71
+ "./binary-store/upload-session": {
72
+ "types": "./dist/server/storage/uploadSession.d.ts",
73
+ "bun": "./src/server/storage/uploadSession.ts",
74
+ "source": "./src/server/storage/uploadSession.ts",
75
+ "import": "./dist/server/storage/uploadSession.js"
76
+ },
70
77
  "./binary-store/testing": {
71
78
  "types": "./dist/server/storage/binaryStoreFake.d.ts",
72
79
  "bun": "./src/server/storage/binaryStoreFake.ts",
@@ -91,12 +98,6 @@
91
98
  "source": "./src/server/d1/localBackup.ts",
92
99
  "import": "./dist/server/_bunOnly.js"
93
100
  },
94
- "./d1/kysely": {
95
- "types": "./dist/server/d1/kysely.d.ts",
96
- "bun": "./src/server/d1/kysely.ts",
97
- "source": "./src/server/d1/kysely.ts",
98
- "import": "./dist/server/d1/kysely.js"
99
- },
100
101
  "./d1/testing": {
101
102
  "types": "./dist/server/d1/fakeD1.d.ts",
102
103
  "bun": "./src/server/d1/fakeD1.ts",
@@ -109,6 +110,12 @@
109
110
  "source": "./src/server/dropzone/index.ts",
110
111
  "import": "./dist/server/dropzone/index.js"
111
112
  },
113
+ "./engagement": {
114
+ "types": "./dist/server/engagement/index.d.ts",
115
+ "bun": "./src/server/engagement/index.ts",
116
+ "source": "./src/server/engagement/index.ts",
117
+ "import": "./dist/server/_bunOnly.js"
118
+ },
112
119
  "./errors": {
113
120
  "types": "./dist/server/errors.d.ts",
114
121
  "bun": "./src/server/errors.ts",
@@ -157,6 +164,12 @@
157
164
  "source": "./src/server/auth/loginThrottle.ts",
158
165
  "import": "./dist/server/auth/loginThrottle.js"
159
166
  },
167
+ "./maps-budget": {
168
+ "types": "./dist/server/maps-budget/mapsBudget.d.ts",
169
+ "bun": "./src/server/maps-budget/mapsBudget.ts",
170
+ "source": "./src/server/maps-budget/mapsBudget.ts",
171
+ "import": "./dist/server/maps-budget/mapsBudget.js"
172
+ },
160
173
  "./master-lock": {
161
174
  "types": "./dist/server/master-lock/index.d.ts",
162
175
  "bun": "./src/server/master-lock/index.ts",
@@ -187,6 +200,12 @@
187
200
  "source": "./src/server/net/outboundGuard.ts",
188
201
  "import": "./dist/server/net/outboundGuard.js"
189
202
  },
203
+ "./password": {
204
+ "types": "./dist/server/auth/passwordCost.d.ts",
205
+ "bun": "./src/server/auth/passwordCost.ts",
206
+ "source": "./src/server/auth/passwordCost.ts",
207
+ "import": "./dist/server/_bunOnly.js"
208
+ },
190
209
  "./notifications": {
191
210
  "types": "./dist/server/notifications/index.d.ts",
192
211
  "bun": "./src/server/notifications/index.ts",
@@ -205,6 +224,18 @@
205
224
  "source": "./src/server/retention/index.ts",
206
225
  "import": "./dist/server/retention/index.js"
207
226
  },
227
+ "./satellite-config": {
228
+ "types": "./dist/server/satellite/config.d.ts",
229
+ "bun": "./src/server/satellite/config.ts",
230
+ "source": "./src/server/satellite/config.ts",
231
+ "import": "./dist/server/_bunOnly.js"
232
+ },
233
+ "./satellite-door": {
234
+ "types": "./dist/server/satellite/door.d.ts",
235
+ "bun": "./src/server/satellite/door.ts",
236
+ "source": "./src/server/satellite/door.ts",
237
+ "import": "./dist/server/satellite/door.js"
238
+ },
208
239
  "./search": {
209
240
  "types": "./dist/server/search/index.d.ts",
210
241
  "bun": "./src/server/search/index.ts",
@@ -229,6 +260,12 @@
229
260
  "source": "./src/server/storage/index.ts",
230
261
  "import": "./dist/server/storage/index.js"
231
262
  },
263
+ "./storage/derivatives": {
264
+ "types": "./dist/server/storage/derivatives.d.ts",
265
+ "bun": "./src/server/storage/derivatives.ts",
266
+ "source": "./src/server/storage/derivatives.ts",
267
+ "import": "./dist/server/storage/derivatives.js"
268
+ },
232
269
  "./storage/types": {
233
270
  "types": "./dist/server/storage/types.d.ts",
234
271
  "bun": "./src/server/storage/types.ts",
@@ -250,6 +287,7 @@
250
287
  },
251
288
  "dependencies": {
252
289
  "cursedbelt-core": "^2.1.1",
290
+ "cursedops": "^0.4.0",
253
291
  "cwip": "^4.6.0",
254
292
  "jose": "^6.2.3"
255
293
  },
@@ -290,7 +328,6 @@
290
328
  "@types/bun": "^1.3.14",
291
329
  "@types/node": "^24",
292
330
  "cursedbelt": "^4.5.0",
293
- "cursedops": "^0.2.7",
294
331
  "hono": "4.12.28",
295
332
  "kysely": "^0.28.17",
296
333
  "kysely-bun-sqlite": "^0.4.0",
@@ -114,9 +114,8 @@ const MAY_DRAG: Record<string, readonly string[]> = {
114
114
  // for that; an app importing `cursedbelt-server/d1` is not, and that is the distinction
115
115
  // this spec protects.
116
116
  '.': ['kysely', 'kysely-bun-sqlite', 'otplib', 'plainjob'],
117
- // `createD1Kysely` IS the kysely adapter. Split out of `./d1` on 2026-09-18 precisely so
118
- // the seam itself stops paying for it — see the header.
119
- './d1/kysely': ['kysely'],
117
+ // `./d1/kysely` (`createD1Kysely`) was here until 4.19.0, when it was removed: no file in
118
+ // the generation had ever imported it (task 472's census), so the allowance went with it.
120
119
  // The job queue is plainjob. Nothing else here is.
121
120
  './jobs': ['plainjob'],
122
121
  };
@@ -144,6 +143,9 @@ const MAY_NEED_BUN: Record<string, readonly string[]> = {
144
143
  './guard/revocations': ['bun:sqlite'],
145
144
  // `./sqlite` is the bun:sqlite tier. Naming it is the point of the subpath.
146
145
  './sqlite': ['bun:sqlite'],
146
+ // The engagement store IS `engagement.sqlite` on the Mac (4.19.0, task 280) — the recorder
147
+ // runs where the app's own database is. A Worker app records nothing until it has a D1 store.
148
+ './engagement': ['bun:sqlite'],
147
149
  };
148
150
 
149
151
  const OPTIONAL_PEERS = new Set(
@@ -161,6 +161,50 @@ const LEAVES = [
161
161
  */
162
162
  evaluates: 'createGoogleTokenMinter',
163
163
  },
164
+ {
165
+ subpath: './maps-budget',
166
+ /**
167
+ * The Google Maps spend ceiling (4.19.0, task 345) — copied into `family` and `station`
168
+ * under two different file names. The store is injected, so the module needs nothing.
169
+ */
170
+ evaluates: 'createMapsBudget',
171
+ },
172
+ {
173
+ subpath: './binary-store/upload-session',
174
+ /**
175
+ * The direct-upload mint (4.19.0, task 343). Its one import is `import type` of the
176
+ * `BinaryStore` interface, which is erased — so it keeps `./binary-store`'s promise.
177
+ */
178
+ evaluates: 'createUploadSession',
179
+ },
180
+ {
181
+ subpath: './storage/derivatives',
182
+ /**
183
+ * How an app READS binary-server's derivatives (4.19.0, task 344). Collections serves it
184
+ * from a Worker, so it may reach nothing at all — except the key contract it re-exports,
185
+ * which is itself import-free and is copied beside it rather than excused.
186
+ */
187
+ evaluates: 'resolveVideoPlayback',
188
+ siblings: ['src/server/storage/videoDerivativeKeys.ts'],
189
+ },
190
+ {
191
+ subpath: './satellite-door',
192
+ /**
193
+ * The pure half of the satellite config contract (4.19.0, task 281) — what a Worker's door
194
+ * imports INSTEAD of the Node half. `music`'s Worker dragged `node:fs` and the whole data-dir
195
+ * chain into its bundle to reach two of these functions; this leaf is why it no longer must.
196
+ */
197
+ evaluates: 'allowedEmailsFor',
198
+ },
199
+ {
200
+ subpath: './password',
201
+ /**
202
+ * The argon2id cost decision (4.19.0, task 321). It shares its test-runtime predicate with
203
+ * `./satellite-door` rather than copying it, and that sibling is itself import-free.
204
+ */
205
+ evaluates: 'argon2Cost',
206
+ siblings: ['src/server/satellite/door.ts'],
207
+ },
164
208
  ] as const;
165
209
 
166
210
  /**
@@ -269,6 +313,11 @@ const run = async (): Promise<Record<string, Outcome>> => {
269
313
  // copy made through it can land in a mock the spawned child cannot see. Same trap as
270
314
  // publishShape.spec.ts.
271
315
  await Bun.write(`${pkgDir}/${source}`, Bun.file(`${REPO}/${source}`));
316
+ // A leaf that re-exports an import-free sibling ships with exactly that sibling and no
317
+ // other — so the sibling is held to the same promise rather than excused from it.
318
+ for (const sibling of 'siblings' in leaf ? leaf.siblings : []) {
319
+ await Bun.write(`${pkgDir}/${sibling}`, Bun.file(`${REPO}/${sibling}`));
320
+ }
272
321
  targets.push({ name: leaf.subpath, specifier: `${pkg.name}/${leaf.subpath.slice(2)}` });
273
322
  }
274
323
  await Bun.write(`${pkgDir}/${CONTROL}`, Bun.file(`${REPO}/${CONTROL}`));
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * ── Retention ───────────────────────────────────────────────────────────────
12
12
  * This package never deletes an event. An app whose stream is high-volume opts
13
- * into the fleet standard (`cursedbelt-server/retention`, in this package) by
13
+ * into the fleet standard (`cursedbelt-server/retention`; the contract is
14
+ * `docs/retention.md` in this package, and this stream's own is `docs/activity.md`) by
14
15
  * declaring the table, which puts the number under the owner's control in the
15
16
  * station rather than in a constant here:
16
17
  *
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The KDF cost decision — and the one direction it may never move by accident.
3
+ *
4
+ * 🔴 The failure worth a test is a PRODUCTION process paying test cost: a weakened argon2id on
5
+ * the owner's real password hash, silently. So the production path is asserted against an env
6
+ * with every marker absent, not merely "whatever this runner has".
7
+ */
8
+ import { describe, expect, it } from 'bun:test';
9
+ import { TEST_RUNTIME_ENV_KEYS } from '../satellite/door.js';
10
+ import { argon2Cost, hashSecret, PRODUCTION_ARGON2, TEST_ARGON2, verifySecret } from './passwordCost.js';
11
+
12
+ describe('argon2Cost', () => {
13
+ it('🔴 a process with NO test marker pays production cost', () => {
14
+ expect(argon2Cost({ NODE_ENV: 'production' })).toBe(PRODUCTION_ARGON2);
15
+ expect(argon2Cost({})).toBe(PRODUCTION_ARGON2);
16
+ });
17
+
18
+ it('each marker the data-dir guard trusts lowers the cost — and nothing else does', () => {
19
+ expect(argon2Cost({ NODE_ENV: 'test' })).toBe(TEST_ARGON2);
20
+ expect(argon2Cost({ BUN_TEST: '1' })).toBe(TEST_ARGON2);
21
+ expect(argon2Cost({ SATELLITE_TEST_RUNTIME: '1' })).toBe(TEST_ARGON2);
22
+ expect(argon2Cost({ NODE_ENV: 'development' })).toBe(PRODUCTION_ARGON2);
23
+ expect(TEST_RUNTIME_ENV_KEYS).toEqual(['NODE_ENV', 'BUN_TEST', 'SATELLITE_TEST_RUNTIME']);
24
+ });
25
+
26
+ it('never changes the ALGORITHM, so a hash made under either cost verifies under either', async () => {
27
+ expect(TEST_ARGON2.algorithm).toBe('argon2id');
28
+ expect(PRODUCTION_ARGON2.algorithm).toBe('argon2id');
29
+ const cheap = await Bun.password.hash('correct horse', TEST_ARGON2);
30
+ expect(await verifySecret('correct horse', cheap)).toBe(true);
31
+ expect(await verifySecret('wrong horse', cheap)).toBe(false);
32
+ });
33
+ });
34
+
35
+ describe('hashSecret / verifySecret', () => {
36
+ it('round-trips, and a malformed stored hash is `false` — never a throw', async () => {
37
+ const hash = await hashSecret('s3cret-enough');
38
+ expect(hash.startsWith('$argon2id$')).toBe(true);
39
+ expect(await verifySecret('s3cret-enough', hash)).toBe(true);
40
+ expect(await verifySecret('s3cret-enough', 'not-a-phc-string')).toBe(false);
41
+ });
42
+ });