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,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.
|
|
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`
|
|
118
|
-
// the
|
|
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
|
|
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';
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tail reading — proven in both directions against the SAME assertion the local proxy uses,
|
|
3
|
+
* because a real reader that could not go red would be worse than the proxy it replaces.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from 'bun:test';
|
|
6
|
+
import { checkCpuBudgets } from './assert.js';
|
|
7
|
+
import { createCpuRecorder } from './recorder.js';
|
|
8
|
+
import {
|
|
9
|
+
parseTailLines,
|
|
10
|
+
recordTailTraces,
|
|
11
|
+
routeMatcher,
|
|
12
|
+
tailCpuClock,
|
|
13
|
+
tailTrafficRate,
|
|
14
|
+
type TailTraceLike,
|
|
15
|
+
UNMATCHED_ROUTE,
|
|
16
|
+
} from './tail.js';
|
|
17
|
+
import { processCpuClock } from './cpuClock.js';
|
|
18
|
+
|
|
19
|
+
const BUDGETS = {
|
|
20
|
+
routes: {
|
|
21
|
+
'GET /api/items/:id': 5,
|
|
22
|
+
'GET /api/items/recent': 2,
|
|
23
|
+
'POST /api/upload': { exempt: true as const, reason: 'hashing is the work' },
|
|
24
|
+
'/healthz': 1,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const trace = (method: string, path: string, cpuTime: number | undefined, at = 0): TailTraceLike => ({
|
|
29
|
+
cpuTime,
|
|
30
|
+
eventTimestamp: at,
|
|
31
|
+
scriptName: 'collections',
|
|
32
|
+
event: { request: { method, url: `https://collections.example${path}` } },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const many = (n: number, make: (i: number) => TailTraceLike): TailTraceLike[] => Array.from({ length: n }, (_, i) => make(i));
|
|
36
|
+
|
|
37
|
+
describe('routeMatcher', () => {
|
|
38
|
+
it('the most literal pattern wins, and the method must agree', () => {
|
|
39
|
+
const match = routeMatcher(Object.keys(BUDGETS.routes));
|
|
40
|
+
expect(match('GET', '/api/items/recent')).toBe('/api/items/recent');
|
|
41
|
+
expect(match('GET', '/api/items/abc123')).toBe('/api/items/:id');
|
|
42
|
+
expect(match('POST', '/api/items/abc123')).toBeNull();
|
|
43
|
+
expect(match('HEAD', '/healthz')).toBe('/healthz');
|
|
44
|
+
expect(match('GET', '/api/items/a/b')).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('recordTailTraces', () => {
|
|
49
|
+
it('stamps the report proxy:false with the tail as its source', () => {
|
|
50
|
+
const recorder = createCpuRecorder({ clock: tailCpuClock(), config: BUDGETS });
|
|
51
|
+
recordTailTraces(recorder, many(30, (i) => trace('GET', `/api/items/${i}`, 1 + (i % 3))));
|
|
52
|
+
const report = recorder.report();
|
|
53
|
+
expect(report.proxy).toBe(false);
|
|
54
|
+
expect(report.source).toBe('tail-worker cpuTime');
|
|
55
|
+
expect(report.routes.map((r) => r.key)).toEqual(['GET /api/items/:id']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('🔴 refuses a PROXY recorder — real numbers may not be labelled a proxy, nor the reverse', () => {
|
|
59
|
+
const recorder = createCpuRecorder({ clock: processCpuClock(), config: BUDGETS });
|
|
60
|
+
expect(() => recordTailTraces(recorder, [])).toThrow(/is a PROXY/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('🔴 a trace with no cpuTime is skipped and counted — never recorded as a zero', () => {
|
|
64
|
+
const recorder = createCpuRecorder({ clock: tailCpuClock(), config: BUDGETS });
|
|
65
|
+
const result = recordTailTraces(recorder, [
|
|
66
|
+
trace('GET', '/healthz', undefined),
|
|
67
|
+
{ cpuTime: 3, event: null },
|
|
68
|
+
trace('GET', '/nope/1', 2),
|
|
69
|
+
]);
|
|
70
|
+
expect(result).toMatchObject({ recorded: 1, noCpuTime: 1, notFetch: 1, unmatched: 1, unmatchedPaths: ['GET /nope/1'] });
|
|
71
|
+
expect(recorder.report().routes[0]?.route).toBe(UNMATCHED_ROUTE);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('a tail recorder mounted as middleware fails loudly instead of measuring nothing', () => {
|
|
75
|
+
expect(() => tailCpuClock().start()).toThrow(/FED by recordTailTraces/);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('the SAME assertion, fed by the tail', () => {
|
|
80
|
+
it('passes a window whose readings move and sit under budget', () => {
|
|
81
|
+
const recorder = createCpuRecorder({ clock: tailCpuClock(), config: BUDGETS });
|
|
82
|
+
recordTailTraces(recorder, many(40, (i) => trace('GET', `/api/items/${i}`, i % 4)));
|
|
83
|
+
expect(checkCpuBudgets(recorder.report(), { requireDeclared: true }).filter((v) => v.fails)).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('🔴 goes RED on a route over its budget, and names it', () => {
|
|
87
|
+
const recorder = createCpuRecorder({ clock: tailCpuClock(), config: BUDGETS });
|
|
88
|
+
recordTailTraces(recorder, many(40, (i) => trace('GET', '/api/items/recent', 3 + (i % 2))));
|
|
89
|
+
const failing = checkCpuBudgets(recorder.report(), { requireDeclared: true }).filter((v) => v.fails);
|
|
90
|
+
expect(failing.map((v) => v.kind)).toEqual(['over-budget']);
|
|
91
|
+
expect(failing[0]?.key).toBe('GET /api/items/recent');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('🔴 goes RED on traffic no declared route covers, under requireDeclared', () => {
|
|
95
|
+
const recorder = createCpuRecorder({ clock: tailCpuClock(), config: BUDGETS });
|
|
96
|
+
recordTailTraces(recorder, many(40, (i) => trace('GET', `/undeclared/${i}`, 1 + (i % 2))));
|
|
97
|
+
const kinds = checkCpuBudgets(recorder.report(), { requireDeclared: true }).filter((v) => v.fails).map((v) => v.kind);
|
|
98
|
+
expect(kinds).toContain('undeclared');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('🔴 a window where EVERY reading is 0 is still refused as degenerate', () => {
|
|
102
|
+
const recorder = createCpuRecorder({ clock: tailCpuClock(), config: BUDGETS });
|
|
103
|
+
recordTailTraces(recorder, many(40, (i) => trace('GET', `/api/items/${i}`, 0)));
|
|
104
|
+
expect(checkCpuBudgets(recorder.report()).map((v) => v.kind)).toEqual(['degenerate']);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('parseTailLines and tailTrafficRate', () => {
|
|
109
|
+
it('reads `wrangler tail --format json`, skipping its banner and any partial line', () => {
|
|
110
|
+
const text = [
|
|
111
|
+
'Successfully created tail, expires at …',
|
|
112
|
+
JSON.stringify(trace('GET', '/healthz', 1, 1_000)),
|
|
113
|
+
'{"cpuTime": 2, "event": {"request": {"url": "https://x/healthz", "me',
|
|
114
|
+
'',
|
|
115
|
+
JSON.stringify(trace('GET', '/healthz', 2, 2_000)),
|
|
116
|
+
].join('\n');
|
|
117
|
+
expect(parseTailLines(text).map((t) => t.cpuTime)).toEqual([1, 2]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('scales a window to requests/day — and refuses to scale one shorter than an hour', () => {
|
|
121
|
+
const hour = 3_600_000;
|
|
122
|
+
const two = [trace('GET', '/healthz', 1, 0), trace('GET', '/healthz', 1, 2 * hour), { cpuTime: 5 }];
|
|
123
|
+
expect(tailTrafficRate(two).requestsPerDay).toBe(24);
|
|
124
|
+
expect(Number.isNaN(tailTrafficRate([trace('GET', '/a', 1, 0), trace('GET', '/a', 1, 60_000)]).requestsPerDay)).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|