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
|
@@ -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
|
*
|
|
@@ -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,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
|
|
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';
|
package/dist/server/d1/index.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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';
|
package/dist/server/d1/pullD1.js
CHANGED
|
@@ -82,6 +82,8 @@ export async function servedRuntime(url, { attempts = 3, sleep = (ms) => new Pro
|
|
|
82
82
|
}
|
|
83
83
|
return null;
|
|
84
84
|
}
|
|
85
|
+
/** How many times one table's export is asked for when wrangler says success and writes nothing. */
|
|
86
|
+
const EXPORT_ATTEMPTS = 3;
|
|
85
87
|
/**
|
|
86
88
|
* The tables a snapshot carries, from D1's own `sqlite_master` rows — every real table, and
|
|
87
89
|
* none of what cannot or must not be re-inserted: a virtual table and its shadows (`_config`,
|
|
@@ -123,12 +125,23 @@ export async function pullD1ToSqlite(options) {
|
|
|
123
125
|
let sql = '';
|
|
124
126
|
for (const table of tables) {
|
|
125
127
|
const dump = `${destination}.${table}.sql`;
|
|
126
|
-
|
|
128
|
+
// 🔴 `wrangler d1 export` has been measured answering SUCCESS and writing no file —
|
|
129
|
+
// collections' nightly of 2026-09-23T15:35Z refused on `derivative_retries`, and the same
|
|
130
|
+
// command wrote the file (32 bytes, an empty table) an hour later. So a missing file is
|
|
131
|
+
// asked for again, twice, before it is a refusal; a failure that persists still refuses,
|
|
132
|
+
// because a snapshot missing a table is not a backup.
|
|
133
|
+
let exported = { code: 1, stdout: '', stderr: 'not attempted' };
|
|
134
|
+
for (let attempt = 1; attempt <= EXPORT_ATTEMPTS; attempt++) {
|
|
135
|
+
exported = wrangler(['d1', 'export', database, '--remote', '--env=', '--table', table, '--no-schema', '--output', dump, '-y']);
|
|
136
|
+
if (exported.code !== 0 || existsSync(dump))
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
127
139
|
if (exported.code !== 0) {
|
|
128
140
|
throw new Error(`wrangler d1 export --table ${table} failed: ${exported.stderr.trim() || exported.stdout.trim()}`);
|
|
129
141
|
}
|
|
130
|
-
if (!existsSync(dump))
|
|
131
|
-
throw new Error(`wrangler d1 export --table ${table} reported success and wrote no file at ${dump}`);
|
|
142
|
+
if (!existsSync(dump)) {
|
|
143
|
+
throw new Error(`wrangler d1 export --table ${table} reported success ${EXPORT_ATTEMPTS} times and wrote no file at ${dump}`);
|
|
144
|
+
}
|
|
132
145
|
sql += `${readFileSync(dump, 'utf8')}\n`;
|
|
133
146
|
}
|
|
134
147
|
if (!/INSERT INTO/i.test(sql)) {
|
|
@@ -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;
|