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.
- 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/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/engagement/api.ts +119 -0
- package/src/server/engagement/engagement.spec.ts +462 -0
- package/src/server/engagement/env.ts +73 -0
- package/src/server/engagement/index.ts +92 -0
- package/src/server/engagement/places.ts +76 -0
- package/src/server/engagement/policy.ts +250 -0
- package/src/server/engagement/store.ts +272 -0
- package/src/server/engagement/summary.ts +216 -0
- package/src/server/engagement/types.ts +61 -0
- package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
- package/src/server/maps-budget/mapsBudget.ts +304 -0
- package/src/server/satellite/config.ts +389 -0
- package/src/server/satellite/door.ts +169 -0
- package/src/server/satellite/satellite.spec.ts +161 -0
- package/src/server/storage/binaryStore.ts +31 -1
- package/src/server/storage/derivatives.spec.ts +125 -0
- package/src/server/storage/derivatives.ts +329 -0
- package/src/server/storage/uploadSession.spec.ts +132 -0
- package/src/server/storage/uploadSession.ts +114 -0
- package/dist/server/d1/kysely.d.ts +0 -56
- package/dist/server/d1/kysely.js +0 -138
- package/src/server/d1/kysely.spec.ts +0 -145
- package/src/server/d1/kysely.ts +0 -169
|
@@ -0,0 +1,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;
|