cursedbelt-server 4.18.1 → 4.19.1

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 (83) hide show
  1. package/README.md +61 -0
  2. package/dist/server/activity/index.d.ts +2 -1
  3. package/dist/server/activity/index.js +2 -1
  4. package/dist/server/auth/passwordCost.d.ts +21 -0
  5. package/dist/server/auth/passwordCost.js +80 -0
  6. package/dist/server/bench/index.d.ts +1 -0
  7. package/dist/server/bench/index.js +1 -0
  8. package/dist/server/bench/tail.d.ts +110 -0
  9. package/dist/server/bench/tail.js +182 -0
  10. package/dist/server/d1/index.d.ts +1 -2
  11. package/dist/server/d1/index.js +9 -9
  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/dist/subpathReach.d.ts +32 -0
  41. package/dist/subpathReach.js +58 -0
  42. package/docs/THE-DEV-DEPENDENCY-CYCLE.md +55 -0
  43. package/docs/activity.md +43 -0
  44. package/docs/engagement.md +47 -0
  45. package/docs/notifications.md +43 -0
  46. package/docs/retention.md +81 -0
  47. package/docs/skipped-tests.md +19 -0
  48. package/package.json +48 -9
  49. package/src/barrelsReachNoOptionalPeer.spec.ts +1 -47
  50. package/src/leafSubpathsImportNothing.spec.ts +49 -0
  51. package/src/readmeInstallTable.spec.ts +48 -0
  52. package/src/server/activity/index.ts +2 -1
  53. package/src/server/auth/passwordCost.spec.ts +42 -0
  54. package/src/server/auth/passwordCost.ts +87 -0
  55. package/src/server/bench/index.ts +13 -0
  56. package/src/server/bench/tail.spec.ts +126 -0
  57. package/src/server/bench/tail.ts +237 -0
  58. package/src/server/d1/index.ts +9 -9
  59. package/src/server/engagement/api.ts +119 -0
  60. package/src/server/engagement/engagement.spec.ts +462 -0
  61. package/src/server/engagement/env.ts +73 -0
  62. package/src/server/engagement/index.ts +92 -0
  63. package/src/server/engagement/places.ts +76 -0
  64. package/src/server/engagement/policy.ts +250 -0
  65. package/src/server/engagement/store.ts +272 -0
  66. package/src/server/engagement/summary.ts +216 -0
  67. package/src/server/engagement/types.ts +61 -0
  68. package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
  69. package/src/server/maps-budget/mapsBudget.ts +304 -0
  70. package/src/server/satellite/config.ts +389 -0
  71. package/src/server/satellite/door.ts +169 -0
  72. package/src/server/satellite/satellite.spec.ts +161 -0
  73. package/src/server/serveBunOverload.spec.ts +52 -0
  74. package/src/server/storage/binaryStore.ts +31 -1
  75. package/src/server/storage/derivatives.spec.ts +125 -0
  76. package/src/server/storage/derivatives.ts +329 -0
  77. package/src/server/storage/uploadSession.spec.ts +132 -0
  78. package/src/server/storage/uploadSession.ts +114 -0
  79. package/src/subpathReach.ts +61 -0
  80. package/dist/server/d1/kysely.d.ts +0 -56
  81. package/dist/server/d1/kysely.js +0 -138
  82. package/src/server/d1/kysely.spec.ts +0 -145
  83. package/src/server/d1/kysely.ts +0 -169
@@ -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.1",
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,10 @@
28
28
  },
29
29
  "files": [
30
30
  "dist",
31
- "src"
31
+ "src",
32
+ "docs",
33
+ "README.md",
34
+ "!src/server/typetests"
32
35
  ],
33
36
  "exports": {
34
37
  ".": {
@@ -67,6 +70,12 @@
67
70
  "source": "./src/server/storage/binaryStore.ts",
68
71
  "import": "./dist/server/storage/binaryStore.js"
69
72
  },
73
+ "./binary-store/upload-session": {
74
+ "types": "./dist/server/storage/uploadSession.d.ts",
75
+ "bun": "./src/server/storage/uploadSession.ts",
76
+ "source": "./src/server/storage/uploadSession.ts",
77
+ "import": "./dist/server/storage/uploadSession.js"
78
+ },
70
79
  "./binary-store/testing": {
71
80
  "types": "./dist/server/storage/binaryStoreFake.d.ts",
72
81
  "bun": "./src/server/storage/binaryStoreFake.ts",
@@ -91,12 +100,6 @@
91
100
  "source": "./src/server/d1/localBackup.ts",
92
101
  "import": "./dist/server/_bunOnly.js"
93
102
  },
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
103
  "./d1/testing": {
101
104
  "types": "./dist/server/d1/fakeD1.d.ts",
102
105
  "bun": "./src/server/d1/fakeD1.ts",
@@ -109,6 +112,12 @@
109
112
  "source": "./src/server/dropzone/index.ts",
110
113
  "import": "./dist/server/dropzone/index.js"
111
114
  },
115
+ "./engagement": {
116
+ "types": "./dist/server/engagement/index.d.ts",
117
+ "bun": "./src/server/engagement/index.ts",
118
+ "source": "./src/server/engagement/index.ts",
119
+ "import": "./dist/server/_bunOnly.js"
120
+ },
112
121
  "./errors": {
113
122
  "types": "./dist/server/errors.d.ts",
114
123
  "bun": "./src/server/errors.ts",
@@ -157,6 +166,12 @@
157
166
  "source": "./src/server/auth/loginThrottle.ts",
158
167
  "import": "./dist/server/auth/loginThrottle.js"
159
168
  },
169
+ "./maps-budget": {
170
+ "types": "./dist/server/maps-budget/mapsBudget.d.ts",
171
+ "bun": "./src/server/maps-budget/mapsBudget.ts",
172
+ "source": "./src/server/maps-budget/mapsBudget.ts",
173
+ "import": "./dist/server/maps-budget/mapsBudget.js"
174
+ },
160
175
  "./master-lock": {
161
176
  "types": "./dist/server/master-lock/index.d.ts",
162
177
  "bun": "./src/server/master-lock/index.ts",
@@ -187,6 +202,12 @@
187
202
  "source": "./src/server/net/outboundGuard.ts",
188
203
  "import": "./dist/server/net/outboundGuard.js"
189
204
  },
205
+ "./password": {
206
+ "types": "./dist/server/auth/passwordCost.d.ts",
207
+ "bun": "./src/server/auth/passwordCost.ts",
208
+ "source": "./src/server/auth/passwordCost.ts",
209
+ "import": "./dist/server/_bunOnly.js"
210
+ },
190
211
  "./notifications": {
191
212
  "types": "./dist/server/notifications/index.d.ts",
192
213
  "bun": "./src/server/notifications/index.ts",
@@ -205,6 +226,18 @@
205
226
  "source": "./src/server/retention/index.ts",
206
227
  "import": "./dist/server/retention/index.js"
207
228
  },
229
+ "./satellite-config": {
230
+ "types": "./dist/server/satellite/config.d.ts",
231
+ "bun": "./src/server/satellite/config.ts",
232
+ "source": "./src/server/satellite/config.ts",
233
+ "import": "./dist/server/_bunOnly.js"
234
+ },
235
+ "./satellite-door": {
236
+ "types": "./dist/server/satellite/door.d.ts",
237
+ "bun": "./src/server/satellite/door.ts",
238
+ "source": "./src/server/satellite/door.ts",
239
+ "import": "./dist/server/satellite/door.js"
240
+ },
208
241
  "./search": {
209
242
  "types": "./dist/server/search/index.d.ts",
210
243
  "bun": "./src/server/search/index.ts",
@@ -229,6 +262,12 @@
229
262
  "source": "./src/server/storage/index.ts",
230
263
  "import": "./dist/server/storage/index.js"
231
264
  },
265
+ "./storage/derivatives": {
266
+ "types": "./dist/server/storage/derivatives.d.ts",
267
+ "bun": "./src/server/storage/derivatives.ts",
268
+ "source": "./src/server/storage/derivatives.ts",
269
+ "import": "./dist/server/storage/derivatives.js"
270
+ },
232
271
  "./storage/types": {
233
272
  "types": "./dist/server/storage/types.d.ts",
234
273
  "bun": "./src/server/storage/types.ts",
@@ -250,6 +289,7 @@
250
289
  },
251
290
  "dependencies": {
252
291
  "cursedbelt-core": "^2.1.1",
292
+ "cursedops": "^0.5.2",
253
293
  "cwip": "^4.6.0",
254
294
  "jose": "^6.2.3"
255
295
  },
@@ -290,7 +330,6 @@
290
330
  "@types/bun": "^1.3.14",
291
331
  "@types/node": "^24",
292
332
  "cursedbelt": "^4.5.0",
293
- "cursedops": "^0.2.7",
294
333
  "hono": "4.12.28",
295
334
  "kysely": "^0.28.17",
296
335
  "kysely-bun-sqlite": "^0.4.0",
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import pkg from '../package.json';
5
+ import { MAY_DRAG, MAY_NEED_BUN } from './subpathReach.js';
5
6
 
6
7
  /**
7
8
  * A public subpath must not STATICALLY drag anything its consumers cannot have — an
@@ -99,53 +100,6 @@ import pkg from '../package.json';
99
100
 
100
101
  const REPO = fileURLToPath(new URL('..', import.meta.url));
101
102
 
102
- /**
103
- * The subpaths whose whole PURPOSE is the optional peer they pull, mapped to what they are
104
- * allowed to pull and why. An entry here is a promise that the peer is the point of the
105
- * subpath — not a place to park a new drag.
106
- *
107
- * 🔴 Measured 2026-09-18: these three are the ONLY subpaths in the map that reach an
108
- * optional peer. Everything else is clean, which is what makes this list an allowlist
109
- * rather than a baseline — it can only shrink.
110
- */
111
- const MAY_DRAG: Record<string, readonly string[]> = {
112
- // The whole-package barrel. It exists to re-export everything, so it necessarily reaches
113
- // what the specific subpaths reach. An app importing `cursedbelt-server` whole is asking
114
- // for that; an app importing `cursedbelt-server/d1` is not, and that is the distinction
115
- // this spec protects.
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'],
120
- // The job queue is plainjob. Nothing else here is.
121
- './jobs': ['plainjob'],
122
- };
123
-
124
- /**
125
- * The subpaths whose whole PURPOSE is a bun-specific runtime, mapped to the builtins they
126
- * are allowed to reach. Everything NOT listed here must be importable on `workerd`, which
127
- * is the property the D1 seam exists to deliver.
128
- *
129
- * 🔴 Measured 2026-09-18 after the `./d1/backup-local` split: these five are the ONLY
130
- * subpaths in the map that reach a bun builtin at all. Like {@link MAY_DRAG} it is an
131
- * allowlist, not a baseline — it can only shrink, and an entry that stops being true is a
132
- * failure rather than a tidy-up.
133
- */
134
- const MAY_NEED_BUN: Record<string, readonly string[]> = {
135
- // The whole-package barrel — it re-exports everything, including the Mac-hosted halves.
136
- // An app importing `cursedbelt-server` whole is a Bun server by construction; an app
137
- // importing `cursedbelt-server/d1` is not, and that is the distinction this protects.
138
- '.': ['bun:sqlite'],
139
- // The local backup IS `VACUUM INTO` against a live `bun:sqlite` handle. Split out of
140
- // `./d1` on 2026-09-18 precisely so the seam itself stops paying for it — see the header.
141
- './d1/backup-local': ['bun:sqlite'],
142
- // The guard's revocation store is a `bun:sqlite` table, and `./guard` mounts it.
143
- './guard': ['bun:sqlite'],
144
- './guard/revocations': ['bun:sqlite'],
145
- // `./sqlite` is the bun:sqlite tier. Naming it is the point of the subpath.
146
- './sqlite': ['bun:sqlite'],
147
- };
148
-
149
103
  const OPTIONAL_PEERS = new Set(
150
104
  Object.entries(
151
105
  (pkg as { peerDependenciesMeta?: Record<string, { optional?: boolean }> }).peerDependenciesMeta ?? {},
@@ -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}`));
@@ -0,0 +1,48 @@
1
+ /**
2
+ * README.md's "What you install" table is what an importing app reads (task 104) — so it is held
3
+ * to the same data the bundler check proves (`subpathReach.ts`), row for row, in both directions.
4
+ */
5
+ import { describe, expect, it } from 'bun:test';
6
+ import { readFileSync } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { MAY_DRAG, MAY_NEED_BUN } from './subpathReach.js';
9
+
10
+ const README = readFileSync(fileURLToPath(new URL('../README.md', import.meta.url)), 'utf8');
11
+
12
+ /** `| \`./x\` | \`a\`, \`b\` | — |` → { './x': { peers: [a, b], bun: [] } } */
13
+ function tableRows(): Map<string, { peers: string[]; bun: string[] }> {
14
+ const section = README.split('## What you install')[1]?.split('\n## ')[0] ?? '';
15
+ const rows = new Map<string, { peers: string[]; bun: string[] }>();
16
+ for (const line of section.split('\n')) {
17
+ const cells = line.split('|').map((c) => c.trim());
18
+ const subpath = /^`(\.[^`]*)`$/.exec(cells[1] ?? '')?.[1];
19
+ if (!subpath) continue;
20
+ const named = (cell: string | undefined) => [...(cell ?? '').matchAll(/`([^`]+)`/g)].map((m) => m[1] as string).sort();
21
+ rows.set(subpath, { peers: named(cells[2]), bun: named(cells[3]) });
22
+ }
23
+ return rows;
24
+ }
25
+
26
+ describe('README install table', () => {
27
+ const rows = tableRows();
28
+ const expected = new Set([...Object.keys(MAY_DRAG), ...Object.keys(MAY_NEED_BUN)]);
29
+
30
+ it('has a row for every subpath that reaches an optional peer or a bun builtin — and no other', () => {
31
+ expect(rows.size).toBeGreaterThan(0);
32
+ expect([...rows.keys()].sort()).toEqual([...expected].sort());
33
+ });
34
+
35
+ it('names exactly what each subpath reaches', () => {
36
+ for (const subpath of expected) {
37
+ expect({ subpath, ...rows.get(subpath) }).toEqual({
38
+ subpath,
39
+ peers: [...(MAY_DRAG[subpath] ?? [])].sort(),
40
+ bun: [...(MAY_NEED_BUN[subpath] ?? [])].sort(),
41
+ });
42
+ }
43
+ });
44
+
45
+ it('warns that the root export is a barrel', () => {
46
+ expect(README).toContain('The root export is a BARREL');
47
+ });
48
+ });
@@ -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
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * HOW EXPENSIVE ARGON2ID SHOULD BE IN THIS PROCESS — production's cost, unless a test
3
+ * runner is in charge.
4
+ *
5
+ * ## The measurement (2026-09-09)
6
+ *
7
+ * Argon2id is slow ON PURPOSE: one hash costs ~66 ms on this Mac, and that slowness IS
8
+ * the security property. It is also the wrong price to pay six hundred times in a unit
9
+ * test whose assertion is *"a wrong password is refused"* — that asserts the comparison,
10
+ * never the cost of the KDF.
11
+ *
12
+ * `apps/auth`'s own suite, measured both ways with nothing else changed:
13
+ *
14
+ * production cost 52.85 s
15
+ * test cost 7.32 s
16
+ *
17
+ * Eighty-six per cent of what was, that morning, the largest single workspace in
18
+ * `verify:scoped` — paid by every agent on every branch touching auth or any shared
19
+ * package, and again on every union gate.
20
+ *
21
+ * ## Why this is safe to key on the test runtime, stated plainly
22
+ *
23
+ * A weakened KDF is the most dangerous thing this module could do, so the argument has
24
+ * to be better than "the flag is only set in tests".
25
+ *
26
+ * `TEST_RUNTIME_ENV_KEYS` is the fleet's existing answer to *"is a test runner in
27
+ * charge"*, and `app-data` **already refuses to open the owner's real database** when it
28
+ * is true. A process carrying one of these markers therefore has no production data to
29
+ * write a weak hash into — it is the same claim, load-bearing in the same direction,
30
+ * decided in one place. If that guarantee ever weakens, it weakens for the database
31
+ * first, which is the louder failure.
32
+ *
33
+ * The ALGORITHM never changes. Only `memoryCost`/`timeCost` move, argon2 encodes its own
34
+ * parameters into the hash, and verification is therefore identical — so a stored hash
35
+ * made under either cost verifies under either cost.
36
+ *
37
+ * ## `cursedbelt-server/password`, since 4.19.0 (task 321)
38
+
39
+ It was `src/kit/passwordCost.ts` in `auth`, `collections`, `patterns` and `vault` — four copies of
40
+ a security-relevant cost decision, one of them (`vault`) already carrying a `verifySecret` the
41
+ others lacked. The test-runtime rule it keys on is `./satellite-door`'s `isTestRuntime`, the same
42
+ predicate that refuses a test the owner's real database, so the two can never disagree about
43
+ whether a runner is in charge. It is not in `cursedauth` because that package has no
44
+ dependencies and this decision must share its predicate, not copy it.
45
+
46
+ ## Why it lives in one place and not in each app
47
+ *
48
+ * Five call sites hashed at production cost when this was written — `apps/auth`,
49
+ * `apps/collections` (×2), `apps/patterns`, `apps/vault` — and a per-app copy of a
50
+ * security-relevant cost decision is how four of them end up agreeing and the fifth does
51
+ * not. One module, one test, one place to read.
52
+ */
53
+ import { isTestRuntime } from "../satellite/door.js";
54
+
55
+ /** Production: the runtime's own argon2id defaults, with nothing pinned by hand. */
56
+ export const PRODUCTION_ARGON2: Bun.Password.Argon2Algorithm = { algorithm: "argon2id" };
57
+
58
+ /** The floor argon2id accepts — free, and still argon2id. */
59
+ export const TEST_ARGON2: Bun.Password.Argon2Algorithm = {
60
+ algorithm: "argon2id",
61
+ memoryCost: 8,
62
+ timeCost: 1,
63
+ };
64
+
65
+ /**
66
+ * Which cost this process should pay. Takes `env` explicitly so the failure path is
67
+ * drivable: a check that can only be run under the runner it is deciding about is a
68
+ * check nobody can prove.
69
+ */
70
+ export function argon2Cost(env: NodeJS.ProcessEnv = process.env): Bun.Password.Argon2Algorithm {
71
+ return isTestRuntime(env) ? TEST_ARGON2 : PRODUCTION_ARGON2;
72
+ }
73
+
74
+ /**
75
+ * Hash a secret at the right cost for this process. The one call every app should use;
76
+ * a bare `Bun.password.hash(x, "argon2id")` pays production's cost in every test.
77
+ */
78
+ export const hashSecret = (secret: string): Promise<string> =>
79
+ Bun.password.hash(secret, argon2Cost());
80
+
81
+ /**
82
+ * Verify a secret against a stored argon2id PHC string — `false`, never a throw, on anything
83
+ * malformed. `Bun.password` on the Mac; on the Worker the same global is `worker/password.ts`'s
84
+ * pure-JS shim, which reproduces Bun's hashes byte for byte, so every stored verifier keeps working.
85
+ */
86
+ export const verifySecret = (secret: string, hash: string): Promise<boolean> =>
87
+ Bun.password.verify(secret, hash).catch(() => false);
@@ -80,3 +80,16 @@ export {
80
80
  type RouteCpuStats,
81
81
  } from './recorder.js';
82
82
  export { BENCH_ORIGIN, type BenchCase, runCpuBench, type RunCpuBenchOpts } from './runBench.js';
83
+ export {
84
+ parseTailLines,
85
+ type RecordTailTracesOpts,
86
+ recordTailTraces,
87
+ routeMatcher,
88
+ TAIL_CPU_SOURCE,
89
+ type TailRecordResult,
90
+ type TailTraceLike,
91
+ type TailTrafficRate,
92
+ tailCpuClock,
93
+ tailTrafficRate,
94
+ UNMATCHED_ROUTE,
95
+ } from './tail.js';