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
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # cursedbelt-server
2
+
3
+ The server tier of the cursedbelt split: Hono on Bun, the D1 seam a Worker crosses, the
4
+ binary-server client, the guard, and the fleet standards (retention, activity, notifications,
5
+ engagement). `cursedbelt-core` is below it, the React design system `cursedbelt` above it.
6
+
7
+ ```sh
8
+ bun add cursedbelt-server
9
+ ```
10
+
11
+ ## ๐Ÿ”ด The root export is a BARREL โ€” import the leaf
12
+
13
+ `import โ€ฆ from "cursedbelt-server"` re-exports everything, so it reaches every optional peer and
14
+ `bun:sqlite` at once (the table below). An app that wants one function pays for all of them, and
15
+ on a Worker `wrangler deploy` fails to bundle it. Import the subpath that owns the symbol โ€”
16
+ `cursedbelt-server/login-throttle`, `cursedbelt-server/binary-store`, `cursedbelt-server/d1` โ€”
17
+ never the root. `package.json` `exports` is the list of subpaths.
18
+
19
+ Why this stays prose: which symbol an app WANTS is decided in the app, and a repo's gate proves
20
+ that repo โ€” so the barrel-importer grep belongs to the generation's tools, not to this package.
21
+
22
+ ## What you install
23
+
24
+ Most subpaths need nothing beyond `hono` (and `zod` where they validate). These are the only
25
+ ones that statically reach an OPTIONAL peer, or a bun builtin a Worker does not have. The table
26
+ is checked: `src/readmeInstallTable.spec.ts` fails when it disagrees with `src/subpathReach.ts`,
27
+ and `src/barrelsReachNoOptionalPeer.spec.ts` fails when that disagrees with what a bundler
28
+ actually keeps.
29
+
30
+ | subpath | optional peers it imports | bun builtins it needs |
31
+ |---|---|---|
32
+ | `.` | `kysely`, `kysely-bun-sqlite`, `otplib`, `plainjob` | `bun:sqlite` |
33
+ | `./jobs` | `plainjob` | โ€” |
34
+ | `./d1/backup-local` | โ€” | `bun:sqlite` |
35
+ | `./guard` | โ€” | `bun:sqlite` |
36
+ | `./guard/revocations` | โ€” | `bun:sqlite` |
37
+ | `./sqlite` | โ€” | `bun:sqlite` |
38
+ | `./engagement` | โ€” | `bun:sqlite` |
39
+
40
+ `sharp` and `@node-rs/argon2` are loaded lazily (`await import()`), so a missing one breaks only
41
+ the feature that asks for it, not the build.
42
+
43
+ ## ๐Ÿ”ด `Bun.serve`'s `websocket` selects an OVERLOAD
44
+
45
+ It is not an optional field. An options object whose `websocket` may be `undefined` โ€” a
46
+ conditional value or a conditional spread โ€” matches no overload, and TypeScript reports an error
47
+ about the whole options object rather than the one field. Use `serveBun(app, { websocket })`,
48
+ which takes it as a genuinely optional field and makes the two concrete calls itself.
49
+ `src/server/serveBunOverload.spec.ts` runs `tsc` on the trap and goes red when bun's types change.
50
+
51
+ ## Standards
52
+
53
+ `docs/retention.md`, `docs/activity.md`, `docs/notifications.md`, `docs/engagement.md` โ€” the
54
+ contracts every app that uses those modules is agreeing to. Cite them from an app with the
55
+ package prefix (`cursedbelt-server/docs/retention.md`).
56
+
57
+ ## Verifying
58
+
59
+ ```sh
60
+ cd "$FORGE/libs/cursedbelt-server" && bun run verify
61
+ ```
@@ -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
  *
@@ -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,21 @@
1
+ /** Production: the runtime's own argon2id defaults, with nothing pinned by hand. */
2
+ export declare const PRODUCTION_ARGON2: Bun.Password.Argon2Algorithm;
3
+ /** The floor argon2id accepts โ€” free, and still argon2id. */
4
+ export declare const TEST_ARGON2: Bun.Password.Argon2Algorithm;
5
+ /**
6
+ * Which cost this process should pay. Takes `env` explicitly so the failure path is
7
+ * drivable: a check that can only be run under the runner it is deciding about is a
8
+ * check nobody can prove.
9
+ */
10
+ export declare function argon2Cost(env?: NodeJS.ProcessEnv): Bun.Password.Argon2Algorithm;
11
+ /**
12
+ * Hash a secret at the right cost for this process. The one call every app should use;
13
+ * a bare `Bun.password.hash(x, "argon2id")` pays production's cost in every test.
14
+ */
15
+ export declare const hashSecret: (secret: string) => Promise<string>;
16
+ /**
17
+ * Verify a secret against a stored argon2id PHC string โ€” `false`, never a throw, on anything
18
+ * malformed. `Bun.password` on the Mac; on the Worker the same global is `worker/password.ts`'s
19
+ * pure-JS shim, which reproduces Bun's hashes byte for byte, so every stored verifier keeps working.
20
+ */
21
+ export declare const verifySecret: (secret: string, hash: string) => Promise<boolean>;
@@ -0,0 +1,80 @@
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
+ /** Production: the runtime's own argon2id defaults, with nothing pinned by hand. */
55
+ export const PRODUCTION_ARGON2 = { algorithm: "argon2id" };
56
+ /** The floor argon2id accepts โ€” free, and still argon2id. */
57
+ export const TEST_ARGON2 = {
58
+ algorithm: "argon2id",
59
+ memoryCost: 8,
60
+ timeCost: 1,
61
+ };
62
+ /**
63
+ * Which cost this process should pay. Takes `env` explicitly so the failure path is
64
+ * drivable: a check that can only be run under the runner it is deciding about is a
65
+ * check nobody can prove.
66
+ */
67
+ export function argon2Cost(env = process.env) {
68
+ return isTestRuntime(env) ? TEST_ARGON2 : PRODUCTION_ARGON2;
69
+ }
70
+ /**
71
+ * Hash a secret at the right cost for this process. The one call every app should use;
72
+ * a bare `Bun.password.hash(x, "argon2id")` pays production's cost in every test.
73
+ */
74
+ export const hashSecret = (secret) => Bun.password.hash(secret, argon2Cost());
75
+ /**
76
+ * Verify a secret against a stored argon2id PHC string โ€” `false`, never a throw, on anything
77
+ * malformed. `Bun.password` on the Mac; on the Worker the same global is `worker/password.ts`'s
78
+ * pure-JS shim, which reproduces Bun's hashes byte for byte, so every stored verifier keeps working.
79
+ */
80
+ export const verifySecret = (secret, hash) => Bun.password.verify(secret, hash).catch(() => false);
@@ -38,3 +38,4 @@ export { type CpuBudgetOpts, cpuBudget } from './cpuBudget.js';
38
38
  export { type CpuClock, type CpuSpan, fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock.js';
39
39
  export { type CpuBudgetReport, type CpuRecorder, type CpuRecorderOpts, createCpuRecorder, percentile, type RouteCpuStats, } from './recorder.js';
40
40
  export { BENCH_ORIGIN, type BenchCase, runCpuBench, type RunCpuBenchOpts } from './runBench.js';
41
+ export { parseTailLines, type RecordTailTracesOpts, recordTailTraces, routeMatcher, TAIL_CPU_SOURCE, type TailRecordResult, type TailTraceLike, type TailTrafficRate, tailCpuClock, tailTrafficRate, UNMATCHED_ROUTE, } from './tail.js';
@@ -38,3 +38,4 @@ export { cpuBudget } from './cpuBudget.js';
38
38
  export { fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock.js';
39
39
  export { createCpuRecorder, percentile, } from './recorder.js';
40
40
  export { BENCH_ORIGIN, runCpuBench } from './runBench.js';
41
+ export { parseTailLines, recordTailTraces, routeMatcher, TAIL_CPU_SOURCE, tailCpuClock, tailTrafficRate, UNMATCHED_ROUTE, } from './tail.js';
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The REAL reading: Worker CPU-ms off a tail worker's trace events, fed into the same recorder
3
+ * and the same assertion the local proxy uses.
4
+ *
5
+ * ## Why this shape, and not a clock
6
+ *
7
+ * {@link workerCpuClock} is a clock โ€” `start()` then read again โ€” and the isolate never hands a
8
+ * handler its own CPU time, so there is nothing for it to read inside a request. What Cloudflare
9
+ * DOES publish is one `cpuTime` per invocation on the trace event a TAIL worker receives (the
10
+ * same field `wrangler tail --format json` prints โ€” `apps/binary-server/scripts/edgeTail.ts`
11
+ * already reads it there). So the real number arrives AFTER the request, per request, and the
12
+ * honest API is to record it then: {@link recordTailTraces} turns trace events into samples on a
13
+ * recorder built with {@link tailCpuClock}, which stamps `proxy: false`.
14
+ *
15
+ * Two ways to get the events, and a consumer needs neither code nor a new Worker for the first:
16
+ *
17
+ * ยท **`wrangler tail <worker> --format json`** during a bench or a smoke against the live
18
+ * Worker โ€” one JSON trace per line, parsed with {@link parseTailLines}.
19
+ * ยท **a Tail Worker** (`tail_consumers` in the producer's wrangler config) whose `tail(events)`
20
+ * hands the array straight to {@link recordTailTraces}.
21
+ *
22
+ * ๐Ÿ”ด The degenerate guard still applies, deliberately. `cpuTime` is whole milliseconds, so a
23
+ * route that genuinely costs under 1 ms reads 0 โ€” and a report where EVERY reading is 0 is still
24
+ * refused, because that is indistinguishable from a reader that is not reading. One real route
25
+ * above a millisecond anywhere in the window clears it; `allowZeroCpu` is not the fix.
26
+ *
27
+ * ## The traffic half
28
+ *
29
+ * {@link tailTrafficRate} is requests per day over the window the events span โ€” the number
30
+ * `deriveCpuBudgetMs({ requestsPerDay })` needs to re-derive the default from THIS generation's
31
+ * Worker traffic (tasks 342/377) instead of the retired generation's corpus.
32
+ */
33
+ import type { CpuClock } from './cpuClock.js';
34
+ import type { CpuRecorder } from './recorder.js';
35
+ /** The fields of a Cloudflare `TraceItem` this reads โ€” structural, so no workers-types import. */
36
+ export interface TailTraceLike {
37
+ /** CPU-ms of the whole invocation, as billed. Absent on runtimes that do not report it. */
38
+ cpuTime?: number | null;
39
+ wallTime?: number | null;
40
+ /** Epoch ms. */
41
+ eventTimestamp?: number | null;
42
+ outcome?: string | null;
43
+ scriptName?: string | null;
44
+ /** A fetch invocation carries `request`; a cron or queue invocation does not. */
45
+ event?: {
46
+ request?: {
47
+ url?: string;
48
+ method?: string;
49
+ } | null;
50
+ } | null;
51
+ }
52
+ /** Provenance printed on every report built from tail events. */
53
+ export declare const TAIL_CPU_SOURCE = "tail-worker cpuTime";
54
+ /**
55
+ * The clock for a recorder that is FED rather than started. `proxy: false`: this is the quantity
56
+ * Cloudflare bills. Its `start()` throws โ€” a tail recorder mounted as `cpuBudget()` middleware is
57
+ * a wiring mistake, and a span that silently measured nothing is the lie this module refuses.
58
+ */
59
+ export declare function tailCpuClock(source?: string): CpuClock;
60
+ /**
61
+ * A `(method, path) โ†’ route label` matcher over declared route keys โ€” the recorder's own
62
+ * `config.routes`, by default โ€” so an app gets tail attribution with no code of its own. The
63
+ * most literal pattern wins, and a method-specific key beats an any-method one.
64
+ */
65
+ export declare function routeMatcher(keys: readonly string[]): (method: string, path: string) => string | null;
66
+ /** The label traffic that matched no declared route is recorded under โ€” so `requireDeclared` names it. */
67
+ export declare const UNMATCHED_ROUTE = "(no declared route)";
68
+ export interface RecordTailTracesOpts {
69
+ /** Map a request to its route label. Default: {@link routeMatcher} over the recorder's declared routes. */
70
+ route?: (method: string, path: string) => string | null;
71
+ /** Only this Worker's invocations โ€” a tail consumer may receive several producers. */
72
+ scriptName?: string;
73
+ }
74
+ export interface TailRecordResult {
75
+ recorded: number;
76
+ /** Invocations with no request (cron, queue) โ€” CPU, but no route to charge it to. */
77
+ notFetch: number;
78
+ /** Fetch invocations whose trace carried no finite `cpuTime`. */
79
+ noCpuTime: number;
80
+ /** Recorded under {@link UNMATCHED_ROUTE}; up to ten distinct paths, so the gap is nameable. */
81
+ unmatched: number;
82
+ unmatchedPaths: string[];
83
+ }
84
+ /**
85
+ * Charge each fetch invocation's billed `cpuTime` to its route on `recorder`.
86
+ *
87
+ * ๐Ÿ”ด A trace with no finite `cpuTime` is NOT a zero โ€” it is skipped and counted, so a runtime
88
+ * that stopped reporting the field reads as "nothing recorded" (and the assertion's `no-samples`
89
+ * guard reds it) rather than as a confident route that costs nothing.
90
+ */
91
+ export declare function recordTailTraces(recorder: CpuRecorder, traces: readonly TailTraceLike[], opts?: RecordTailTracesOpts): TailRecordResult;
92
+ /**
93
+ * `wrangler tail --format json` output โ†’ trace events. Tolerates the banner lines wrangler
94
+ * prints and blank lines; a line that is not a JSON object is skipped, never guessed at.
95
+ */
96
+ export declare function parseTailLines(text: string): TailTraceLike[];
97
+ export interface TailTrafficRate {
98
+ /** Fetch invocations in the window. */
99
+ requests: number;
100
+ /** First to last `eventTimestamp`, ms. */
101
+ spanMs: number;
102
+ /** `requests` scaled to a day โ€” `NaN` when the window is too short to scale honestly. */
103
+ requestsPerDay: number;
104
+ }
105
+ /**
106
+ * Requests per day over the window the traces span โ€” what `deriveCpuBudgetMs({ requestsPerDay })`
107
+ * takes. `NaN` below an hour of window: scaling ten minutes of traffic to a day is a guess with a
108
+ * decimal point, and a budget derived from it would be quoted as a measurement.
109
+ */
110
+ export declare function tailTrafficRate(traces: readonly TailTraceLike[], minSpanMs?: number): TailTrafficRate;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The REAL reading: Worker CPU-ms off a tail worker's trace events, fed into the same recorder
3
+ * and the same assertion the local proxy uses.
4
+ *
5
+ * ## Why this shape, and not a clock
6
+ *
7
+ * {@link workerCpuClock} is a clock โ€” `start()` then read again โ€” and the isolate never hands a
8
+ * handler its own CPU time, so there is nothing for it to read inside a request. What Cloudflare
9
+ * DOES publish is one `cpuTime` per invocation on the trace event a TAIL worker receives (the
10
+ * same field `wrangler tail --format json` prints โ€” `apps/binary-server/scripts/edgeTail.ts`
11
+ * already reads it there). So the real number arrives AFTER the request, per request, and the
12
+ * honest API is to record it then: {@link recordTailTraces} turns trace events into samples on a
13
+ * recorder built with {@link tailCpuClock}, which stamps `proxy: false`.
14
+ *
15
+ * Two ways to get the events, and a consumer needs neither code nor a new Worker for the first:
16
+ *
17
+ * ยท **`wrangler tail <worker> --format json`** during a bench or a smoke against the live
18
+ * Worker โ€” one JSON trace per line, parsed with {@link parseTailLines}.
19
+ * ยท **a Tail Worker** (`tail_consumers` in the producer's wrangler config) whose `tail(events)`
20
+ * hands the array straight to {@link recordTailTraces}.
21
+ *
22
+ * ๐Ÿ”ด The degenerate guard still applies, deliberately. `cpuTime` is whole milliseconds, so a
23
+ * route that genuinely costs under 1 ms reads 0 โ€” and a report where EVERY reading is 0 is still
24
+ * refused, because that is indistinguishable from a reader that is not reading. One real route
25
+ * above a millisecond anywhere in the window clears it; `allowZeroCpu` is not the fix.
26
+ *
27
+ * ## The traffic half
28
+ *
29
+ * {@link tailTrafficRate} is requests per day over the window the events span โ€” the number
30
+ * `deriveCpuBudgetMs({ requestsPerDay })` needs to re-derive the default from THIS generation's
31
+ * Worker traffic (tasks 342/377) instead of the retired generation's corpus.
32
+ */
33
+ /** Provenance printed on every report built from tail events. */
34
+ export const TAIL_CPU_SOURCE = 'tail-worker cpuTime';
35
+ /**
36
+ * The clock for a recorder that is FED rather than started. `proxy: false`: this is the quantity
37
+ * Cloudflare bills. Its `start()` throws โ€” a tail recorder mounted as `cpuBudget()` middleware is
38
+ * a wiring mistake, and a span that silently measured nothing is the lie this module refuses.
39
+ */
40
+ export function tailCpuClock(source = TAIL_CPU_SOURCE) {
41
+ return {
42
+ source,
43
+ proxy: false,
44
+ available: true,
45
+ start() {
46
+ throw new Error('tailCpuClock: a tail recorder is FED by recordTailTraces(), never started โ€” the isolate ' +
47
+ 'does not hand a handler its own CPU time. Mount cpuBudget() with processCpuClock() ' +
48
+ 'for the local proxy, and feed this recorder from a tail.');
49
+ },
50
+ };
51
+ }
52
+ /** A route key's pattern half: `'GET /api/items/:id'` โ†’ `/api/items/:id`, with its method. */
53
+ function splitKey(key) {
54
+ const m = /^([A-Z]+)\s+(\S+)$/.exec(key.trim());
55
+ return m ? { method: m[1] ?? null, pattern: m[2] ?? key } : { method: null, pattern: key.trim() };
56
+ }
57
+ /** Hono-style pattern โ†’ anchored regex. `:param` (with an optional `{re}`) is one segment, `*` is the rest. */
58
+ function patternRegex(pattern) {
59
+ let out = '';
60
+ for (let i = 0; i < pattern.length;) {
61
+ const rest = pattern.slice(i);
62
+ const param = /^:[A-Za-z0-9_]+(\{[^}]*\})?\??/.exec(rest);
63
+ if (param) {
64
+ out += '[^/]+';
65
+ i += param[0].length;
66
+ continue;
67
+ }
68
+ const ch = pattern[i];
69
+ out += ch === '*' ? '.*' : ch.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
70
+ i += 1;
71
+ }
72
+ return new RegExp(`^${out}$`);
73
+ }
74
+ /** How literal a pattern is โ€” the more literal characters, the earlier it is tried. */
75
+ const specificity = (pattern) => pattern.replace(/:[A-Za-z0-9_]+(\{[^}]*\})?\??/g, '').replace(/\*/g, '').length -
76
+ (pattern.includes('*') ? 1000 : 0);
77
+ /**
78
+ * A `(method, path) โ†’ route label` matcher over declared route keys โ€” the recorder's own
79
+ * `config.routes`, by default โ€” so an app gets tail attribution with no code of its own. The
80
+ * most literal pattern wins, and a method-specific key beats an any-method one.
81
+ */
82
+ export function routeMatcher(keys) {
83
+ const compiled = keys
84
+ .map((key) => ({ ...splitKey(key), key }))
85
+ .map((k) => ({ ...k, re: patternRegex(k.pattern), score: specificity(k.pattern) + (k.method ? 0.5 : 0) }))
86
+ .sort((a, b) => b.score - a.score);
87
+ return (method, path) => {
88
+ for (const k of compiled) {
89
+ if (k.method && k.method !== method.toUpperCase())
90
+ continue;
91
+ if (k.re.test(path))
92
+ return k.pattern;
93
+ }
94
+ return null;
95
+ };
96
+ }
97
+ /** The label traffic that matched no declared route is recorded under โ€” so `requireDeclared` names it. */
98
+ export const UNMATCHED_ROUTE = '(no declared route)';
99
+ /**
100
+ * Charge each fetch invocation's billed `cpuTime` to its route on `recorder`.
101
+ *
102
+ * ๐Ÿ”ด A trace with no finite `cpuTime` is NOT a zero โ€” it is skipped and counted, so a runtime
103
+ * that stopped reporting the field reads as "nothing recorded" (and the assertion's `no-samples`
104
+ * guard reds it) rather than as a confident route that costs nothing.
105
+ */
106
+ export function recordTailTraces(recorder, traces, opts = {}) {
107
+ if (recorder.clock.proxy) {
108
+ throw new Error(`recordTailTraces: the recorder's clock '${recorder.clock.source}' is a PROXY. Tail cpuTime is the ` +
109
+ 'billed quantity โ€” build this recorder with tailCpuClock(), or its report will call real numbers a proxy.');
110
+ }
111
+ const route = opts.route ?? routeMatcher(Object.keys(recorder.config.routes ?? {}));
112
+ const result = { recorded: 0, notFetch: 0, noCpuTime: 0, unmatched: 0, unmatchedPaths: [] };
113
+ for (const trace of traces) {
114
+ if (opts.scriptName && trace.scriptName && trace.scriptName !== opts.scriptName)
115
+ continue;
116
+ const request = trace.event?.request;
117
+ if (!request?.url) {
118
+ result.notFetch += 1;
119
+ continue;
120
+ }
121
+ const cpu = trace.cpuTime;
122
+ if (typeof cpu !== 'number' || !Number.isFinite(cpu)) {
123
+ result.noCpuTime += 1;
124
+ continue;
125
+ }
126
+ const method = (request.method ?? 'GET').toUpperCase();
127
+ let path;
128
+ try {
129
+ path = new URL(request.url).pathname;
130
+ }
131
+ catch {
132
+ path = request.url;
133
+ }
134
+ const label = route(method, path);
135
+ if (label === null) {
136
+ result.unmatched += 1;
137
+ if (result.unmatchedPaths.length < 10 && !result.unmatchedPaths.includes(`${method} ${path}`)) {
138
+ result.unmatchedPaths.push(`${method} ${path}`);
139
+ }
140
+ }
141
+ recorder.record(method, label ?? UNMATCHED_ROUTE, cpu);
142
+ result.recorded += 1;
143
+ }
144
+ return result;
145
+ }
146
+ /**
147
+ * `wrangler tail --format json` output โ†’ trace events. Tolerates the banner lines wrangler
148
+ * prints and blank lines; a line that is not a JSON object is skipped, never guessed at.
149
+ */
150
+ export function parseTailLines(text) {
151
+ const out = [];
152
+ for (const line of text.split('\n')) {
153
+ const trimmed = line.trim();
154
+ if (!trimmed.startsWith('{'))
155
+ continue;
156
+ try {
157
+ const parsed = JSON.parse(trimmed);
158
+ if (parsed && typeof parsed === 'object')
159
+ out.push(parsed);
160
+ }
161
+ catch {
162
+ // a partial line from an interrupted tail โ€” not a trace
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+ /**
168
+ * Requests per day over the window the traces span โ€” what `deriveCpuBudgetMs({ requestsPerDay })`
169
+ * takes. `NaN` below an hour of window: scaling ten minutes of traffic to a day is a guess with a
170
+ * decimal point, and a budget derived from it would be quoted as a measurement.
171
+ */
172
+ export function tailTrafficRate(traces, minSpanMs = 60 * 60 * 1000) {
173
+ const stamps = traces
174
+ .filter((t) => t.event?.request?.url)
175
+ .map((t) => t.eventTimestamp)
176
+ .filter((s) => typeof s === 'number' && Number.isFinite(s))
177
+ .sort((a, b) => a - b);
178
+ const requests = stamps.length;
179
+ const spanMs = requests > 1 ? stamps[requests - 1] - stamps[0] : 0;
180
+ const requestsPerDay = spanMs >= minSpanMs ? (requests / spanMs) * 86_400_000 : Number.NaN;
181
+ return { requests, spanMs, requestsPerDay };
182
+ }
@@ -7,11 +7,10 @@
7
7
  * local driver is deliberately as strict as D1 rather than as lenient as Bun.
8
8
  *
9
9
  * ```ts
10
- * import { createLocalD1, createRemoteD1, createD1Kysely } from 'cursedbelt-server/d1';
10
+ * import { createLocalD1, createRemoteD1 } from 'cursedbelt-server/d1';
11
11
  *
12
12
  * const db = env.DB ? createRemoteD1(env.DB) : createLocalD1(sqlite);
13
13
  * const row = await db.prepare('SELECT * FROM people WHERE id = ?').bind(id).first();
14
- * const ky = createD1Kysely(db);
15
14
  * ```
16
15
  */
17
16
  export { type BackupPoint, createTimeTravelBackup, type DatabaseBackup, type TimeTravelOpts, } from './backup.js';
@@ -7,11 +7,10 @@
7
7
  * local driver is deliberately as strict as D1 rather than as lenient as Bun.
8
8
  *
9
9
  * ```ts
10
- * import { createLocalD1, createRemoteD1, createD1Kysely } from 'cursedbelt-server/d1';
10
+ * import { createLocalD1, createRemoteD1 } from 'cursedbelt-server/d1';
11
11
  *
12
12
  * const db = env.DB ? createRemoteD1(env.DB) : createLocalD1(sqlite);
13
13
  * const row = await db.prepare('SELECT * FROM people WHERE id = ?').bind(id).first();
14
- * const ky = createD1Kysely(db);
15
14
  * ```
16
15
  */
17
16
  export { createTimeTravelBackup, } from './backup.js';
@@ -26,13 +25,14 @@ export { createTimeTravelBackup, } from './backup.js';
26
25
  // as the note below, one step wider: a barrel must reach neither an optional peer nor a
27
26
  // runtime the target platform lacks. `barrelsReachNoOptionalPeer.spec.ts` checks both.
28
27
  //
29
- // ๐Ÿ”ด `./kysely` is deliberately NOT re-exported here โ€” import it from
30
- // `cursedbelt-server/d1/kysely`. It statically imports `kysely` (real values: `Kysely`,
31
- // `SqliteAdapter`, `SqliteQueryCompiler`), which is an OPTIONAL peer, so re-exporting it
32
- // made `import 'cursedbelt-server/d1'` throw `Cannot find package 'kysely'` for every app
33
- // that does not use the query builder โ€” which per `../db/kysely.ts`'s own header is most
34
- // of them, since business tables stay on raw statements. Measured 2026-09-18 against the
35
- // published 4.3.0 tarball. `barrelsReachNoOptionalPeer.spec.ts` is what keeps it out.
28
+ // `./kysely` โ€” a Kysely dialect over this seam, split out as its own `d1/kysely` subpath on
29
+ // 2026-09-18 because it dragged the OPTIONAL `kysely` peer into every `import 'โ€ฆ/d1'` โ€” was
30
+ // REMOVED in 4.19.0 (task 472). Measured 2026-09-23: no file under the generation imported
31
+ // it, no app depends on `kysely` at all, and every ported Worker (patterns, collections,
32
+ // music, vault) reached D1 through raw statements on this seam. A dialect nobody wants is
33
+ // surface with a peer attached; if a ported app ever wants a query builder, it comes back
34
+ // with that app as its first consumer, and `barrelsReachNoOptionalPeer.spec.ts` still keeps
35
+ // it out of this barrel.
36
36
  export { createPendingWrites, perInvocation } from './invocation.js';
37
37
  export { createHttpD1, D1HttpError } from './http.js';
38
38
  export { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The one door engagement needs, and the in-process read of what it recorded.
3
+ *
4
+ * POST /api/engagement/view โ†’ the beacon. SESSION-gated; the user id comes
5
+ * from the session, never from the body.
6
+ *
7
+ * ๐Ÿ”ด **There is no HTTP read surface, on purpose** (task `113`/`268`, 2026-09-22).
8
+ * `GET /api/admin/engagement` used to sit here behind `SATELLITE_METRICS_TOKEN` โ€”
9
+ * ONE bearer shared across five production env files, so a leak from any app was
10
+ * a read on all of them โ€” for a fleet console that lives in the retired
11
+ * generation. Nothing in this generation read it over HTTP: station's telemetry
12
+ * page reads each app's sqlite straight off the disk, and the analytics reader
13
+ * planned after it does the same with `engagement.sqlite`. Recording carries on
14
+ * regardless; `readAppEngagement` below is the snapshot a same-machine reader
15
+ * computes from the store. If an HTTP reader is ever wanted, it gets a PER-APP
16
+ * credential from the generation's secrets store, never a fleet-wide one.
17
+ *
18
+ * โ”€โ”€ Why the user id is never in the body โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
19
+ * A beacon whose payload names its own user is an endpoint that lets anyone
20
+ * write anyone's history โ€” and on a private household app that history is the
21
+ * data. `resolveUser` is supplied by the app's own SSO consumer and is the ONLY
22
+ * source of a user key; a request that resolves to nobody is a 401 and writes
23
+ * nothing. `engagement.test.ts` pins that a body-supplied `userKey` is ignored.
24
+ *
25
+ * โ”€โ”€ Why the recorder is mounted AFTER the session gate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
26
+ * The opposite of the feedback-kit sync receiver, and for the mirrored reason:
27
+ * this door has no credential of its own and wants the gate to have run,
28
+ * because that is what puts the user where `resolveUser` can read them โ€” while
29
+ * a sync receiver carries its own bearer and must mount BEFORE the gate. The
30
+ * rule and its reason: `docs/engagement.md` ยง2 in this package.
31
+ */
32
+ import { Hono } from "hono";
33
+ import type { Context } from "hono";
34
+ import { type PlaceRef } from "./policy.js";
35
+ import type { EngagementStore } from "./store.js";
36
+ import { type AppEngagement } from "./summary.js";
37
+ export interface EngagementApiOptions {
38
+ store: EngagementStore;
39
+ /** The app's own name. */
40
+ app: string;
41
+ /**
42
+ * The allowlist of place ids โ€” the app's `routes.manifest.json` ids. Anything
43
+ * a client claims that is not in here records as `other`; see `policy.ts`
44
+ * rule 1 for why this is the whole privacy story.
45
+ */
46
+ places: readonly PlaceRef[];
47
+ /** Resolve the CURRENT request's account. Null โ†’ 401, nothing recorded. */
48
+ resolveUser: (c: Context) => string | null;
49
+ /** Injectable clock, so the tests are not timing-dependent. */
50
+ now?: () => number;
51
+ }
52
+ /** The recorder half โ€” session-gated, mounted at `/api/engagement`. */
53
+ export declare function createEngagementRecorder(options: EngagementApiOptions): Hono;
54
+ /** The whole snapshot one app publishes about itself. */
55
+ export interface AppEngagementSnapshot extends AppEngagement {
56
+ /** The manifest ids this app CAN report โ€” so a reader can show a place
57
+ * with zero views, which is the half of the question that is easy to lose. */
58
+ knownPlaces: string[];
59
+ returnCurve: Array<{
60
+ day: string;
61
+ joined: number;
62
+ returned: number;
63
+ }>;
64
+ retention: {
65
+ days: number;
66
+ places: number;
67
+ users: number;
68
+ oldestDay: string | null;
69
+ };
70
+ }
71
+ export declare function readAppEngagement(store: EngagementStore, app: string, now: number, knownPlaces?: readonly PlaceRef[]): AppEngagementSnapshot;