cursedbelt-server 4.18.1 → 4.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/server/activity/index.d.ts +2 -1
  2. package/dist/server/activity/index.js +2 -1
  3. package/dist/server/auth/passwordCost.d.ts +21 -0
  4. package/dist/server/auth/passwordCost.js +80 -0
  5. package/dist/server/bench/index.d.ts +1 -0
  6. package/dist/server/bench/index.js +1 -0
  7. package/dist/server/bench/tail.d.ts +110 -0
  8. package/dist/server/bench/tail.js +182 -0
  9. package/dist/server/d1/index.d.ts +1 -2
  10. package/dist/server/d1/index.js +9 -9
  11. package/dist/server/engagement/api.d.ts +71 -0
  12. package/dist/server/engagement/api.js +84 -0
  13. package/dist/server/engagement/env.d.ts +18 -0
  14. package/dist/server/engagement/env.js +52 -0
  15. package/dist/server/engagement/index.d.ts +55 -0
  16. package/dist/server/engagement/index.js +55 -0
  17. package/dist/server/engagement/places.d.ts +22 -0
  18. package/dist/server/engagement/places.js +63 -0
  19. package/dist/server/engagement/policy.d.ts +168 -0
  20. package/dist/server/engagement/policy.js +202 -0
  21. package/dist/server/engagement/store.d.ts +92 -0
  22. package/dist/server/engagement/store.js +223 -0
  23. package/dist/server/engagement/summary.d.ts +102 -0
  24. package/dist/server/engagement/summary.js +127 -0
  25. package/dist/server/engagement/types.d.ts +42 -0
  26. package/dist/server/engagement/types.js +12 -0
  27. package/dist/server/maps-budget/mapsBudget.d.ts +194 -0
  28. package/dist/server/maps-budget/mapsBudget.js +193 -0
  29. package/dist/server/satellite/config.d.ts +173 -0
  30. package/dist/server/satellite/config.js +259 -0
  31. package/dist/server/satellite/door.d.ts +112 -0
  32. package/dist/server/satellite/door.js +149 -0
  33. package/dist/server/storage/binaryStore.d.ts +18 -0
  34. package/dist/server/storage/binaryStore.js +32 -1
  35. package/dist/server/storage/derivatives.d.ts +253 -0
  36. package/dist/server/storage/derivatives.js +266 -0
  37. package/dist/server/storage/uploadSession.d.ts +75 -0
  38. package/dist/server/storage/uploadSession.js +74 -0
  39. package/docs/THE-DEV-DEPENDENCY-CYCLE.md +55 -0
  40. package/docs/activity.md +43 -0
  41. package/docs/engagement.md +47 -0
  42. package/docs/notifications.md +43 -0
  43. package/docs/retention.md +81 -0
  44. package/docs/skipped-tests.md +19 -0
  45. package/package.json +46 -9
  46. package/src/barrelsReachNoOptionalPeer.spec.ts +5 -3
  47. package/src/leafSubpathsImportNothing.spec.ts +49 -0
  48. package/src/server/activity/index.ts +2 -1
  49. package/src/server/auth/passwordCost.spec.ts +42 -0
  50. package/src/server/auth/passwordCost.ts +87 -0
  51. package/src/server/bench/index.ts +13 -0
  52. package/src/server/bench/tail.spec.ts +126 -0
  53. package/src/server/bench/tail.ts +237 -0
  54. package/src/server/d1/index.ts +9 -9
  55. package/src/server/engagement/api.ts +119 -0
  56. package/src/server/engagement/engagement.spec.ts +462 -0
  57. package/src/server/engagement/env.ts +73 -0
  58. package/src/server/engagement/index.ts +92 -0
  59. package/src/server/engagement/places.ts +76 -0
  60. package/src/server/engagement/policy.ts +250 -0
  61. package/src/server/engagement/store.ts +272 -0
  62. package/src/server/engagement/summary.ts +216 -0
  63. package/src/server/engagement/types.ts +61 -0
  64. package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
  65. package/src/server/maps-budget/mapsBudget.ts +304 -0
  66. package/src/server/satellite/config.ts +389 -0
  67. package/src/server/satellite/door.ts +169 -0
  68. package/src/server/satellite/satellite.spec.ts +161 -0
  69. package/src/server/storage/binaryStore.ts +31 -1
  70. package/src/server/storage/derivatives.spec.ts +125 -0
  71. package/src/server/storage/derivatives.ts +329 -0
  72. package/src/server/storage/uploadSession.spec.ts +132 -0
  73. package/src/server/storage/uploadSession.ts +114 -0
  74. package/dist/server/d1/kysely.d.ts +0 -56
  75. package/dist/server/d1/kysely.js +0 -138
  76. package/src/server/d1/kysely.spec.ts +0 -145
  77. package/src/server/d1/kysely.ts +0 -169
@@ -0,0 +1,194 @@
1
+ /**
2
+ * A HARD, persisted call ceiling for Google Maps Platform — monthly AND daily.
3
+ *
4
+ * ── Why this is a package and not a helper in one app ───────────────────────
5
+ * Two apps had their own copy of it — `apps/orch` (leave-by times) and
6
+ * `apps/family` (the atlas) — and two consumers is exactly the threshold the
7
+ * placement law names. The reason to promote rather than to leave them is
8
+ * sharper than tidiness: **the guarantee is that the ceiling and the network
9
+ * call cannot be separated**, and a rule living in two files that can drift is a
10
+ * guarantee with two chances to stop being one. A THIRD copy was the real risk.
11
+ *
12
+ * ── 🔴 `cursedbelt-server/maps-budget`, since 4.19.0 (task 345) ──────────────
13
+ * It was copied again in THIS generation, under two file names —
14
+ * `apps/family/src/kit/mapsBudget.ts` and `apps/station/src/kit/mapsPlatform.ts`,
15
+ * 13,408 identical bytes that no basename census could see. It lives here now,
16
+ * with its suite, as a leaf that imports nothing: an app injects its own
17
+ * {@link MapsBudgetStore}, so neither app can reach the other's ledger, and the
18
+ * generic `Api` parameter lets each name its own API set without a shared enum.
19
+ * It is in the SERVER tier rather than `cursedbelt-core` because a spend ceiling
20
+ * only means anything where the key is — never in a browser bundle.
21
+ *
22
+ * ── The owner's constraint, verbatim (2026-08-08) ───────────────────────────
23
+ * *"Note I don't want to allow spending with the apis so we have to enforce the
24
+ * limits for the free tiers if applicable."*
25
+ *
26
+ * Maps Platform is unlike the free Google APIs in three ways that together make
27
+ * a client-side ceiling the only real protection:
28
+ *
29
+ * 1. It REQUIRES a billing account. A runaway loop on Gmail or Calendar costs
30
+ * nothing; here it bills.
31
+ * 2. The free allowance is 10,000 events/month per Essentials API. Past that,
32
+ * calls cost money rather than failing.
33
+ * 3. 🔴 **A Cloud BUDGET DOES NOT CAP USAGE — it only alerts.** The only
34
+ * server-side hard stop is a per-API QUOTA in Cloud Console, and that step
35
+ * is the owner's. This is ours.
36
+ *
37
+ * ── 🔴 TWO periods, because a month is not a shape a runaway respects ───────
38
+ * A monthly cap can be burned inside a single day. A loop that starts on the
39
+ * 2nd spends the whole allowance by the 3rd and leaves the API dead for the
40
+ * other 29 — which is the outcome the ceiling exists to PREVENT, arriving by
41
+ * way of the ceiling itself. So every call is measured against both:
42
+ *
43
+ * · the **month**, which protects the free tier and therefore the bill;
44
+ * · the **day**, `dailyCeiling()` of the month's, which catches the loop in
45
+ * hours rather than in a billing period.
46
+ *
47
+ * The daily number is DERIVED from the monthly one and never written down
48
+ * twice. Two tables drift, and this module exists because two copies of this
49
+ * rule already did.
50
+ *
51
+ * Both have to pass, and a refusal by either counts nothing. Expected real
52
+ * volume across ALL APIs is a few dozen calls a day — about 1% of the free
53
+ * allowance — so a tenth of the month still leaves roughly 10× headroom on the
54
+ * busiest single API. A ceiling being hit still means something is broken.
55
+ *
56
+ * ── Two properties that are not negotiable ─────────────────────────────────
57
+ * **PERSISTED, never in memory** — BOTH counters. An in-memory counter resets
58
+ * on every restart, and a crash-loop restarting hourly would reset it hourly —
59
+ * turning the one safeguard into a formality precisely when something is wrong.
60
+ * A daily counter is the one a restart-loop would defeat most cheaply, so it is
61
+ * persisted the same way. Each key embeds its own period, so a new month or a
62
+ * new day starts at zero with no scheduled reset to fail and no "the machine was
63
+ * off on the 1st" hole.
64
+ *
65
+ * **A corrupted counter fails SAFE**, treated as exhausted rather than as zero —
66
+ * the daily one exactly as the monthly one, deliberately. The wrong direction
67
+ * here spends the owner's money, and two fail-safes with different opinions in
68
+ * one module would be worse than either choice.
69
+ *
70
+ * ── What is deliberately NOT here ──────────────────────────────────────────
71
+ * The typed API callers. `apps/family` talks to eight Maps APIs plus Street
72
+ * View; `apps/orch` talks to Routes and Weather. They share no call site, only
73
+ * this rule — and hoisting nine wrappers into a package to give two apps one
74
+ * counter would be the shared-code version of the same mistake this package
75
+ * exists to fix. Each app keeps its own ONE DOOR (`maps.ts` in family, pinned by
76
+ * a test that scans for Google hosts outside it) and both doors consume this.
77
+ */
78
+ /** Where the counter lives. A tiny KV, so an app can back it with whatever
79
+ * durable store it already has — `apps/orch` uses its own settings store,
80
+ * `apps/family` uses the `meta` table of the database it already opens. */
81
+ export interface MapsBudgetStore {
82
+ read: (key: string) => string | null;
83
+ write: (key: string, value: string) => void;
84
+ }
85
+ /** The two periods a call is measured against. They answer different questions:
86
+ * a month says whether the free tier is safe, a day says whether something is
87
+ * looping right now — and they come back at different times, which is the
88
+ * reason an app is told WHICH one refused. */
89
+ export type BudgetPeriod = "month" | "day";
90
+ export type BudgetVerdict =
91
+ /** Counted against BOTH periods. The caller may make exactly the request it
92
+ * asked for. `used`/`ceiling` are the MONTH's — the figure a health surface
93
+ * has always shown; `usage()` reports the day beside it. */
94
+ {
95
+ allowed: true;
96
+ used: number;
97
+ ceiling: number;
98
+ }
99
+ /**
100
+ * 🔴 Refused. The caller must DEGRADE LOUDLY — never skip silently, and never
101
+ * make the call anyway. `reason` is viewer-facing; `fix` is for whoever has to
102
+ * do something about it, and is empty when the app has nothing to add.
103
+ *
104
+ * `used`, `ceiling`, `scope` and `period` all describe the ONE period that
105
+ * refused, so a caller can say when the API comes back without re-deriving it.
106
+ */
107
+ | {
108
+ allowed: false;
109
+ used: number;
110
+ ceiling: number;
111
+ scope: BudgetPeriod;
112
+ period: string;
113
+ reason: string;
114
+ fix: string;
115
+ };
116
+ /** What an app is told when it must explain a refusal. */
117
+ export interface RefusalContext<Api extends string> {
118
+ api: Api;
119
+ /** Calls already spent in the period that refused, or that period's ceiling
120
+ * when the counter is unreadable — never `Infinity`, so it is always
121
+ * printable. */
122
+ used: number;
123
+ /** The ceiling of the period that refused — the MONTH's ceiling for a monthly
124
+ * refusal, a tenth of it for a daily one. */
125
+ ceiling: number;
126
+ /** Which ceiling refused. 🔴 An app that words both cases the same is telling
127
+ * a viewer the map is off for a month when it is off until midnight. */
128
+ scope: BudgetPeriod;
129
+ /** The period that refused: `YYYY-MM` for a month, `YYYY-MM-DD` for a day. */
130
+ period: string;
131
+ /** True when the counter could not be read and was failed safe. The wording
132
+ * for that case is genuinely different: nothing was necessarily spent. */
133
+ corrupted: boolean;
134
+ }
135
+ export interface MapsBudget<Api extends string> {
136
+ /**
137
+ * Check AND count in one step, immediately before the request.
138
+ *
139
+ * 🔴 The order is the whole point: counting after the call would count
140
+ * correctly and protect nothing. `calls` is for a BATCH endpoint (family's
141
+ * elevation lookup sends 100 points in one request) and a batch that would
142
+ * cross EITHER line is refused whole rather than truncated — half an answer
143
+ * drawn as a complete one is worse than a labeled absence.
144
+ */
145
+ consume(api: Api, calls?: number): BudgetVerdict;
146
+ /** Read-only, for a health surface. Both periods, because the one that will
147
+ * actually refuse the next call is usually the day, and a surface showing
148
+ * only "12 of 3000" would read as healthy while the API is off until
149
+ * midnight. */
150
+ usage(api: Api): {
151
+ api: Api;
152
+ used: number;
153
+ ceiling: number;
154
+ period: string;
155
+ day: {
156
+ used: number;
157
+ ceiling: number;
158
+ period: string;
159
+ };
160
+ };
161
+ }
162
+ /** `YYYY-MM` in local time — the period a monthly allowance is measured over. */
163
+ export declare const monthKey: (at: number) => string;
164
+ /** `YYYY-MM-DD` in local time — the period the daily sub-ceiling is measured
165
+ * over. Local, like `monthKey`, so "today" means the owner's today. */
166
+ export declare const dayKey: (at: number) => string;
167
+ /** How much of a month's allowance one day may take. A tenth: expected use is a
168
+ * few dozen calls a day against ceilings sized ~100× that, so a tenth still
169
+ * leaves roughly 10× headroom on the busiest API while stopping a runaway in
170
+ * hours instead of in a billing period. */
171
+ export declare const DAILY_SHARE_OF_MONTH = 10;
172
+ /**
173
+ * The daily sub-ceiling, DERIVED — never a second table.
174
+ *
175
+ * Rounded UP, so a ceiling of 1 stays reachable (a tenth of it rounded down
176
+ * would be zero, i.e. an API switched off by arithmetic) and every API keeps at
177
+ * least one call a day.
178
+ */
179
+ export declare const dailyCeiling: (monthlyCeiling: number) => number;
180
+ export interface MapsBudgetOptions<Api extends string> {
181
+ store: MapsBudgetStore;
182
+ /** The ceiling per API. Every API the app may call must appear, so adding one
183
+ * is a deliberate act — a new line in the owner's bill and a new row here. */
184
+ ceilings: Record<Api, number>;
185
+ /** How this app words a refusal. Supplied by the app rather than templated
186
+ * here because the audience differs: family's reaches a relative looking at a
187
+ * map, orch's reaches the owner's notification bell. */
188
+ refusal: (context: RefusalContext<Api>) => {
189
+ reason: string;
190
+ fix?: string;
191
+ };
192
+ now?: () => number;
193
+ }
194
+ export declare function createMapsBudget<Api extends string>(options: MapsBudgetOptions<Api>): MapsBudget<Api>;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * A HARD, persisted call ceiling for Google Maps Platform — monthly AND daily.
3
+ *
4
+ * ── Why this is a package and not a helper in one app ───────────────────────
5
+ * Two apps had their own copy of it — `apps/orch` (leave-by times) and
6
+ * `apps/family` (the atlas) — and two consumers is exactly the threshold the
7
+ * placement law names. The reason to promote rather than to leave them is
8
+ * sharper than tidiness: **the guarantee is that the ceiling and the network
9
+ * call cannot be separated**, and a rule living in two files that can drift is a
10
+ * guarantee with two chances to stop being one. A THIRD copy was the real risk.
11
+ *
12
+ * ── 🔴 `cursedbelt-server/maps-budget`, since 4.19.0 (task 345) ──────────────
13
+ * It was copied again in THIS generation, under two file names —
14
+ * `apps/family/src/kit/mapsBudget.ts` and `apps/station/src/kit/mapsPlatform.ts`,
15
+ * 13,408 identical bytes that no basename census could see. It lives here now,
16
+ * with its suite, as a leaf that imports nothing: an app injects its own
17
+ * {@link MapsBudgetStore}, so neither app can reach the other's ledger, and the
18
+ * generic `Api` parameter lets each name its own API set without a shared enum.
19
+ * It is in the SERVER tier rather than `cursedbelt-core` because a spend ceiling
20
+ * only means anything where the key is — never in a browser bundle.
21
+ *
22
+ * ── The owner's constraint, verbatim (2026-08-08) ───────────────────────────
23
+ * *"Note I don't want to allow spending with the apis so we have to enforce the
24
+ * limits for the free tiers if applicable."*
25
+ *
26
+ * Maps Platform is unlike the free Google APIs in three ways that together make
27
+ * a client-side ceiling the only real protection:
28
+ *
29
+ * 1. It REQUIRES a billing account. A runaway loop on Gmail or Calendar costs
30
+ * nothing; here it bills.
31
+ * 2. The free allowance is 10,000 events/month per Essentials API. Past that,
32
+ * calls cost money rather than failing.
33
+ * 3. 🔴 **A Cloud BUDGET DOES NOT CAP USAGE — it only alerts.** The only
34
+ * server-side hard stop is a per-API QUOTA in Cloud Console, and that step
35
+ * is the owner's. This is ours.
36
+ *
37
+ * ── 🔴 TWO periods, because a month is not a shape a runaway respects ───────
38
+ * A monthly cap can be burned inside a single day. A loop that starts on the
39
+ * 2nd spends the whole allowance by the 3rd and leaves the API dead for the
40
+ * other 29 — which is the outcome the ceiling exists to PREVENT, arriving by
41
+ * way of the ceiling itself. So every call is measured against both:
42
+ *
43
+ * · the **month**, which protects the free tier and therefore the bill;
44
+ * · the **day**, `dailyCeiling()` of the month's, which catches the loop in
45
+ * hours rather than in a billing period.
46
+ *
47
+ * The daily number is DERIVED from the monthly one and never written down
48
+ * twice. Two tables drift, and this module exists because two copies of this
49
+ * rule already did.
50
+ *
51
+ * Both have to pass, and a refusal by either counts nothing. Expected real
52
+ * volume across ALL APIs is a few dozen calls a day — about 1% of the free
53
+ * allowance — so a tenth of the month still leaves roughly 10× headroom on the
54
+ * busiest single API. A ceiling being hit still means something is broken.
55
+ *
56
+ * ── Two properties that are not negotiable ─────────────────────────────────
57
+ * **PERSISTED, never in memory** — BOTH counters. An in-memory counter resets
58
+ * on every restart, and a crash-loop restarting hourly would reset it hourly —
59
+ * turning the one safeguard into a formality precisely when something is wrong.
60
+ * A daily counter is the one a restart-loop would defeat most cheaply, so it is
61
+ * persisted the same way. Each key embeds its own period, so a new month or a
62
+ * new day starts at zero with no scheduled reset to fail and no "the machine was
63
+ * off on the 1st" hole.
64
+ *
65
+ * **A corrupted counter fails SAFE**, treated as exhausted rather than as zero —
66
+ * the daily one exactly as the monthly one, deliberately. The wrong direction
67
+ * here spends the owner's money, and two fail-safes with different opinions in
68
+ * one module would be worse than either choice.
69
+ *
70
+ * ── What is deliberately NOT here ──────────────────────────────────────────
71
+ * The typed API callers. `apps/family` talks to eight Maps APIs plus Street
72
+ * View; `apps/orch` talks to Routes and Weather. They share no call site, only
73
+ * this rule — and hoisting nine wrappers into a package to give two apps one
74
+ * counter would be the shared-code version of the same mistake this package
75
+ * exists to fix. Each app keeps its own ONE DOOR (`maps.ts` in family, pinned by
76
+ * a test that scans for Google hosts outside it) and both doors consume this.
77
+ */
78
+ /** `YYYY-MM` in local time — the period a monthly allowance is measured over. */
79
+ export const monthKey = (at) => {
80
+ const d = new Date(at);
81
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
82
+ };
83
+ /** `YYYY-MM-DD` in local time — the period the daily sub-ceiling is measured
84
+ * over. Local, like `monthKey`, so "today" means the owner's today. */
85
+ export const dayKey = (at) => {
86
+ const d = new Date(at);
87
+ return `${monthKey(at)}-${String(d.getDate()).padStart(2, "0")}`;
88
+ };
89
+ /** How much of a month's allowance one day may take. A tenth: expected use is a
90
+ * few dozen calls a day against ceilings sized ~100× that, so a tenth still
91
+ * leaves roughly 10× headroom on the busiest API while stopping a runaway in
92
+ * hours instead of in a billing period. */
93
+ export const DAILY_SHARE_OF_MONTH = 10;
94
+ /**
95
+ * The daily sub-ceiling, DERIVED — never a second table.
96
+ *
97
+ * Rounded UP, so a ceiling of 1 stays reachable (a tenth of it rounded down
98
+ * would be zero, i.e. an API switched off by arithmetic) and every API keeps at
99
+ * least one call a day.
100
+ */
101
+ export const dailyCeiling = (monthlyCeiling) => Math.ceil(monthlyCeiling / DAILY_SHARE_OF_MONTH);
102
+ export function createMapsBudget(options) {
103
+ const now = options.now ?? Date.now;
104
+ /**
105
+ * 🔴 The monthly key is UNCHANGED (`maps-budget.<api>.2026-09`) — live stores
106
+ * hold counts under it. The daily one is the same shape with the day on the
107
+ * end (`…2026-09-11`), so the two can never collide and no migration is
108
+ * needed: the first call of a new day simply starts a new counter at zero.
109
+ *
110
+ * The store is read/write only — deliberately, so an app can back it with any
111
+ * KV it already has — which means nothing prunes yesterday's keys. That is
112
+ * a few hundred bytes per API per year, and cheaper than an interface that
113
+ * can delete rows in the owner's database.
114
+ */
115
+ const key = (api, period) => `maps-budget.${api}.${period}`;
116
+ const readCount = (api, period) => {
117
+ const raw = Number(options.store.read(key(api, period)) ?? 0);
118
+ // 🔴 Fails SAFE — see the header. Exhausted, never zero. The daily counter
119
+ // takes the same reading for the same reason; two fail-safes with different
120
+ // opinions would be worse than either.
121
+ return Number.isFinite(raw) && raw >= 0 ? raw : Number.POSITIVE_INFINITY;
122
+ };
123
+ /** Both lines for `api` at this instant — the month, then the day. */
124
+ const linesFor = (api, at) => {
125
+ const monthly = options.ceilings[api];
126
+ return [
127
+ { scope: "month", period: monthKey(at), ceiling: monthly },
128
+ { scope: "day", period: dayKey(at), ceiling: dailyCeiling(monthly) },
129
+ ];
130
+ };
131
+ return {
132
+ consume(api, calls = 1) {
133
+ const at = now();
134
+ const [month, day] = linesFor(api, at);
135
+ const monthUsed = readCount(api, month.period);
136
+ const dayUsed = readCount(api, day.period);
137
+ /**
138
+ * 🔴 The MONTH is tested first, and the order is not cosmetic: it decides
139
+ * what a viewer is told when both are spent, and only one answer is true
140
+ * then. "Off until midnight" is a promise the budget cannot keep once the
141
+ * month is gone; "off until the month ends" is right in both cases.
142
+ */
143
+ const refused = monthUsed + calls > month.ceiling
144
+ ? { line: month, used: monthUsed }
145
+ : dayUsed + calls > day.ceiling
146
+ ? { line: day, used: dayUsed }
147
+ : null;
148
+ if (refused) {
149
+ const corrupted = !Number.isFinite(refused.used);
150
+ const used = corrupted ? refused.line.ceiling : refused.used;
151
+ const spoken = options.refusal({
152
+ api,
153
+ used,
154
+ ceiling: refused.line.ceiling,
155
+ scope: refused.line.scope,
156
+ period: refused.line.period,
157
+ corrupted,
158
+ });
159
+ return {
160
+ allowed: false,
161
+ used,
162
+ ceiling: refused.line.ceiling,
163
+ scope: refused.line.scope,
164
+ period: refused.line.period,
165
+ reason: spoken.reason,
166
+ fix: spoken.fix ?? "",
167
+ };
168
+ }
169
+ // Both fit, so both are counted. A refusal above wrote nothing at all —
170
+ // a call that did not happen must not be charged to either period.
171
+ options.store.write(key(api, month.period), String(monthUsed + calls));
172
+ options.store.write(key(api, day.period), String(dayUsed + calls));
173
+ return { allowed: true, used: monthUsed + calls, ceiling: month.ceiling };
174
+ },
175
+ usage(api) {
176
+ const [month, day] = linesFor(api, now());
177
+ // A corrupted counter reports its ceiling rather than `Infinity`, so a
178
+ // health surface prints a number and reads as exhausted — which is how
179
+ // `consume` is about to treat it.
180
+ const spent = (line) => {
181
+ const used = readCount(api, line.period);
182
+ return Number.isFinite(used) ? used : line.ceiling;
183
+ };
184
+ return {
185
+ api,
186
+ used: spent(month),
187
+ ceiling: month.ceiling,
188
+ period: month.period,
189
+ day: { used: spent(day), ceiling: day.ceiling, period: day.period },
190
+ };
191
+ },
192
+ };
193
+ }
@@ -0,0 +1,173 @@
1
+ import { type ConfigBlocker } from "cwip/env";
2
+ import { type EnvLike } from "./door.js";
3
+ export * from "./door.js";
4
+ export type { ConfigBlocker } from "cwip/env";
5
+ export { formatConfigBlockers, isConfigRefusal, sessionSecretBlocker } from "cwip/env";
6
+ /**
7
+ * The generation's state root — every app's durable data lives under it.
8
+ *
9
+ * 🔴 Throws rather than guessing: a function that invented `$HOME/.<something>` would write into
10
+ * a stranger's tree, and would be right for exactly one generation.
11
+ */
12
+ export declare function stateRoot(options?: {
13
+ env?: EnvLike;
14
+ stateRoot?: string;
15
+ }): string;
16
+ /** The scratch tree tests may write under — the one carve-out {@link assertTestSafeDbPath} allows. */
17
+ export declare function testScratchRoot(options?: {
18
+ env?: EnvLike;
19
+ stateRoot?: string;
20
+ }): string;
21
+ /** A deployed app's data directory: `<state root>/apps/<app>`. */
22
+ export declare function deployedDataDirFor(app: string, options?: {
23
+ env?: EnvLike;
24
+ stateRoot?: string;
25
+ }): string;
26
+ /** Why a test was refused a real data directory. */
27
+ export declare function realDataDirRefusal(app: string): string;
28
+ export interface ResolveAppDataDirOptions {
29
+ /** The app key — `vault`, `roms`, … Names the directory. */
30
+ app: string;
31
+ env?: EnvLike;
32
+ /**
33
+ * The dev shell's directory name, when it should differ from `app`. Defaults to `app`:
34
+ * RENAMING a dev directory makes that shell boot EMPTY, which reads exactly like data loss.
35
+ * Opt in per app with a reason (`vault` passes `vault-dev`).
36
+ */
37
+ devDirName?: string;
38
+ /** Tests of this module: the state root to resolve under. */
39
+ stateRoot?: string;
40
+ }
41
+ /**
42
+ * Where this app's durable data lives. `APP_DATA_DIR` when the deploy assigned one; under a test
43
+ * runner, a REFUSAL (never the owner's live database); otherwise `<state root>/apps/<devDirName>`.
44
+ */
45
+ export declare function resolveAppDataDir(options: ResolveAppDataDirOptions): string;
46
+ /**
47
+ * `bun:sqlite`'s `create: true` creates the file, never the directory above it. Swallowed on
48
+ * purpose: the open that follows names the real problem.
49
+ */
50
+ export declare function ensureDbDirectory(dbPath: string): void;
51
+ /** Why a test was refused a database inside the state root. */
52
+ export declare function realDbRefusal(dbPath: string): string;
53
+ /**
54
+ * Under a test runner, refuse a database path inside the state root — except under its scratch
55
+ * tree. A no-op outside tests, for `:memory:`, and where no state root can be found (there is then
56
+ * no owner data at a known place to protect).
57
+ */
58
+ export declare function assertTestSafeDbPath(dbPath: string, options?: {
59
+ env?: EnvLike;
60
+ stateRoot?: string;
61
+ }): void;
62
+ export interface SatelliteConfigSpec {
63
+ /** App key — `roms`. Prefixes errors, names the data dir and the `<PREFIX>_` vars. */
64
+ app: string;
65
+ /** `PORT`'s fallback. */
66
+ defaultPort: number;
67
+ /** Session lifetime fallback, seconds. */
68
+ sessionTtlSeconds: number;
69
+ /**
70
+ * The ONE person an owner-only app opens for, unless `<PREFIX>_OWNER_EMAIL` says otherwise.
71
+ * Required: this library is published and does not know the owner.
72
+ */
73
+ ownerEmail: string;
74
+ /** Env-var prefix. Defaults to the app key upper-cased. */
75
+ envPrefix?: string;
76
+ /** The sqlite file inside the data dir. Defaults to `<app>.sqlite`. */
77
+ dbFile?: string;
78
+ /** This app's production origin. Defaults to `https://<app>.cursedalchemy.com`. */
79
+ publicUrl?: string;
80
+ /** This app's dev origin — the vite port, which is NOT `defaultPort`. */
81
+ devPublicUrl?: string;
82
+ /**
83
+ * Compute the origin fallback yourself when the prod/dev pair is not the whole story
84
+ * (`vault`: a loopback release is `deployed` and its origin is `127.0.0.1`, not the prod
85
+ * hostname). `<PREFIX>_PUBLIC_URL` still wins.
86
+ */
87
+ publicUrlFallback?: (ctx: {
88
+ port: number;
89
+ deployed: boolean;
90
+ live: boolean;
91
+ }) => string;
92
+ /** The dev shell's data-dir name — see {@link ResolveAppDataDirOptions.devDirName}. */
93
+ devDirName?: string;
94
+ /** Take this data dir instead of resolving one — the escape hatch, not the road. */
95
+ dataDir?: string;
96
+ /** Whether the app signs in through the accounts app. Default `true`; `patterns` has its own password. */
97
+ sso?: boolean;
98
+ /**
99
+ * Whether `<PREFIX>_EXTRA_ALLOWED_EMAILS` is read at all. Default `true`. An app that must only
100
+ * ever open for its owner (`vault`) passes `false`, and the list is always `[]`.
101
+ */
102
+ extraEmails?: boolean;
103
+ env?: NodeJS.ProcessEnv;
104
+ /** Tests: the state root to resolve under. */
105
+ stateRoot?: string;
106
+ }
107
+ /** The fields every satellite's config carried, under the names they all used. */
108
+ export interface SatelliteConfig {
109
+ app: string;
110
+ port: number;
111
+ /** A release — see `isRelease`. */
112
+ deployed: boolean;
113
+ /** A release OR a production build — what origin defaults and the refusal key on. */
114
+ live: boolean;
115
+ dataDir: string;
116
+ dbPath: string;
117
+ /** Sessions get their own file, so the credential surface is not a table in the app database. */
118
+ sessionsDbPath: string;
119
+ /** A remembered per-dev-tree secret in dev; production REQUIRES a stable one (a blocker). */
120
+ sessionSecret: string;
121
+ sessionTtlSeconds: number;
122
+ /** THIS app's absolute origin — where SSO grants may be redirected. */
123
+ publicUrl: string;
124
+ /** The accounts app's origin; `""` when `sso: false`. */
125
+ authUrl: string;
126
+ /** Static SSO verification key (SPKI PEM); `""` when unset or `sso: false`. */
127
+ ssoPublicKeyPem: string;
128
+ env: NodeJS.ProcessEnv;
129
+ /** `<PREFIX>` — so an app names its extra vars without repeating it. */
130
+ envPrefix: string;
131
+ ownerEmail: string;
132
+ /** Admitted in addition to the owner — only where `extraEmailsApply` says so. */
133
+ extraAllowedEmails: string[];
134
+ }
135
+ /**
136
+ * Read the fields every satellite needs. An app spreads the result into its own config and adds
137
+ * what is genuinely its own.
138
+ */
139
+ export declare function readSatelliteConfig(spec: SatelliteConfigSpec): SatelliteConfig;
140
+ /** The file the dev session secret is remembered in, inside the app's data dir. */
141
+ export declare const DEV_SESSION_SECRET_FILE = "dev-session-secret";
142
+ /**
143
+ * The session-cookie key when no `<PREFIX>_SESSION_SECRET` is set.
144
+ *
145
+ * In DEV it is a FILE, not a per-boot UUID: the session rows are durable, so a per-boot key turns
146
+ * every restart into "sign-in just shows the button again" (owner report 2026-08-25 — seven good
147
+ * session rows in 22 seconds, not one usable). When `live` it stays a throwaway, because the
148
+ * refusal below forbids that boot and writing a key to disk would turn the refusal into a silent
149
+ * success with a credential nobody chose.
150
+ */
151
+ export declare function devSessionSecret(dataDir: string, live: boolean): string;
152
+ /** The blockers every satellite shares — today exactly one, the session secret. */
153
+ export declare function satelliteBlockers(config: Pick<SatelliteConfig, "env" | "envPrefix">): ConfigBlocker[];
154
+ /**
155
+ * The binary-server signing key — for every app whose files live there.
156
+ *
157
+ * 🔴 The failure it refuses hides better than any other: every list renders, `/healthz` is 200,
158
+ * and only the file bytes 403 — a broken gallery, not a missing variable. `family` and `roms` had
159
+ * no such refusal (task 281's census), so a release missing the key booted "healthy".
160
+ *
161
+ * @param what What the key fetches, for the sentence — "images and video", "audio", "portraits".
162
+ */
163
+ export declare function fileTokenBlocker(config: Pick<SatelliteConfig, "env">, what: string, name?: string): ConfigBlocker | null;
164
+ /** Every reason, one line each — what a `/healthz` or a `--check` prints. */
165
+ export declare function satelliteBlockerLines(config: Pick<SatelliteConfig, "env" | "envPrefix">, extra?: readonly (ConfigBlocker | null)[]): string[];
166
+ /**
167
+ * Refuse a `live` boot that cannot serve safely, naming every reason at once — `cwip/env`'s
168
+ * `ConfigRefusalError`, exit 78, so a misconfiguration never reads as a crash. A no-op in dev.
169
+ *
170
+ * @param stakes What is at risk, as one clause. It is what makes the failure legible at 3am.
171
+ * @param extra The app's own blockers; `null` entries (an optional blocker that passed) are skipped.
172
+ */
173
+ export declare function assertSatelliteUsable(config: Pick<SatelliteConfig, "app" | "env" | "envPrefix" | "live">, stakes: string, extra?: readonly (ConfigBlocker | null)[]): void;