cursedops 0.13.1 → 0.14.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/README.md +27 -476
- package/package.json +1 -1
- package/src/workerSecrets.ts +83 -7
package/README.md
CHANGED
|
@@ -6,490 +6,41 @@ Build-and-ops answers that the apps here wrote independently and identically.
|
|
|
6
6
|
bun add cursedops
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
+
See `docs/decisions.md` for why this library exists, its entry rules, and what was
|
|
10
|
+
deliberately left in the apps instead. See `docs/reference.md` for every subpath's
|
|
11
|
+
import shape, worked example, and the incident each one is pinned to.
|
|
12
|
+
|
|
9
13
|
| subpath | what it is |
|
|
10
14
|
|---|---|
|
|
11
|
-
| `cursedops/roots` | finding a generation's roots
|
|
12
|
-
| `cursedops/paths` | the generation's whole-tree laws (`check-paths`, `check-doc-citations`, plus any `--also`) run over ONE repo, from a checkout or a worktree — and its `forge-paths` bin
|
|
13
|
-
| `cursedops/launchd` | installing, replacing and removing a macOS launchd user agent, and the port a LIVE job serves on (`livePort
|
|
14
|
-
| `cursedops/smoke` | the scaffolding of a deployed smoke
|
|
15
|
-
| `cursedops/worker-deploy` | the sequence that ships a Cloudflare Worker — clean tree, stage first, schema, `--var` stamp, secrets, smoke —
|
|
16
|
-
| `cursedops/worker-
|
|
17
|
-
| `cursedops/worker-
|
|
18
|
-
| `cursedops/
|
|
19
|
-
| `cursedops/
|
|
20
|
-
| `cursedops/d1-
|
|
21
|
-
| `cursedops/
|
|
22
|
-
| `cursedops/backups` | `BACKUP_KEEP`, the fleet's one retention for dated snapshots — six apps each exported `KEEP = 14` (0.9.0, task 2153); `BACKUP_STAMP_PATTERN`, `backupStamp` and its inverse `stampTime` — the local-time run name every backup script writes and station's audit reads (0.11.2) |
|
|
15
|
+
| `cursedops/roots` | finding a generation's roots and a checkout's package root without knowing a path, and `repoCd`/`repoCommand`, the `cd` prefix of a printed command that RUNS when pasted |
|
|
16
|
+
| `cursedops/paths` | the generation's whole-tree laws (`check-paths`, `check-doc-citations`, plus any `--also`) run over ONE repo, from a checkout or a worktree — and its `forge-paths` bin |
|
|
17
|
+
| `cursedops/launchd` | installing, replacing and removing a macOS launchd user agent, and the port a LIVE job serves on (`livePort`) |
|
|
18
|
+
| `cursedops/smoke` | the scaffolding of a deployed smoke (ledger, fetch, DNS hint, exit code) — the one check no app owns — plus its VERDICTS: the origin asked on loopback, the smoke's own environment, a network that lies about DNS, and a version that has settled |
|
|
19
|
+
| `cursedops/worker-deploy` | the sequence that ships a Cloudflare Worker — clean tree, stage first, schema, `--var` stamp, secrets, smoke — plus a billed-CPU tail wrapped around the walk and the smoke, and a ledger line per run |
|
|
20
|
+
| `cursedops/worker-secrets` | a Worker holding EXACTLY its deployment's secrets, uploaded over a pipe, read back up to five times before an inexact set is believed |
|
|
21
|
+
| `cursedops/worker-rollback` | a hostname back on its Mac origin — origin first, route second, the route found rather than typed |
|
|
22
|
+
| `cursedops/edge-fetch` | a request to a deployed Worker the way a script must make it — curl pinned past the Mac's negative DNS cache |
|
|
23
|
+
| `cursedops/d1-columns` | the schema step's first half — any column `db/schema.sql` has that a live D1 table lacks, added by `ALTER TABLE … ADD COLUMN` |
|
|
24
|
+
| `cursedops/d1-import` | a cutover's data proof — row-for-row, never a count — and `d1SchemaText`/its `forge-d1-schema` bin, the generated schema no app hand-writes |
|
|
25
|
+
| `cursedops/backups` | `BACKUP_KEEP`, the fleet's one retention for dated snapshots; `BACKUP_STAMP_PATTERN`, `backupStamp` and `stampTime` |
|
|
23
26
|
| `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers |
|
|
24
|
-
| `cursedops/
|
|
25
|
-
| `cursedops/
|
|
26
|
-
| `cursedops/
|
|
27
|
-
| `cursedops/
|
|
28
|
-
| `cursedops/
|
|
29
|
-
| `cursedops/
|
|
30
|
-
| `cursedops/
|
|
31
|
-
| `cursedops/relay
|
|
32
|
-
| `cursedops/relay-
|
|
33
|
-
| `cursedops/
|
|
34
|
-
| `cursedops/public-
|
|
27
|
+
| `cursedops/worker-safety` | the two static checks every Worker app's suite runs over its own source — the import-graph walk an app's own test does from `worker/index.ts` (a value import of `bun:sqlite` or a Bun-only subpath is a deploy that goes green and a Worker that never starts), and the await port's silent shapes (`rows.filter(async …)` keeps every row) |
|
|
28
|
+
| `cursedops/api-floor` | the rule that an unmatched `/api/...` is a phrase and never the app shell — the namespace predicates, the trailing-slash normaliser, the default 404 body |
|
|
29
|
+
| `cursedops/build-info` | which commit a checkout-served process is running and whether its tree was dirty, read once at load. `node:child_process`, so never in a Worker |
|
|
30
|
+
| `cursedops/bound-lists` | the static check that no SQL builds an `IN (…)` list one `?` per value — the shape D1 500s on past 100 ids |
|
|
31
|
+
| `cursedops/staged-client` | the IMMUTABLE client a checkout-served app serves — a build staged into `<APP_DATA_DIR>/client/<commit>/` behind `CURRENT` |
|
|
32
|
+
| `cursedops/deploy-tree` | the two refusals a checkout-served deploy needs — never deploy a dirty tree, never `git revert` in one |
|
|
33
|
+
| `cursedops/ship` | the owner's FAST SHIPPING switch as every deploy reads it — `readShipSetting`, `shipDeferral`, `FORGE_SHIP_NOW=1` to ship now whatever it says |
|
|
34
|
+
| `cursedops/relay` | the WORKER half of a relay — a Worker in front of a Mac-bound app over ONE outbound WebSocket, the frame wire, the key-digest door, and the Durable Object an app subclasses |
|
|
35
|
+
| `cursedops/relay-link` | the MAC half — `dialLink`, `readLinkSettings`, `rotateLinkKey` |
|
|
36
|
+
| `cursedops/relay-stage` | the signed-in STAGE WALK's skeleton both relay apps shared |
|
|
37
|
+
| `cursedops/public-client` | does a relay app's PUBLIC hostname serve the client this release built? — `comparePublicClient`, `recordPublicClient`, `shipClientIfBehind` |
|
|
38
|
+
| `cursedops/public-surface` | the ratchet on a LIBRARY's public surface — a symbol count per export subpath against a committed baseline that may only fall |
|
|
35
39
|
|
|
36
40
|
Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
|
|
37
41
|
name, a hostname, a port or a route. `typescript` is an **optional** peer: only
|
|
38
42
|
`cursedops/public-surface` imports it, and nothing else here ever loads it.
|
|
39
43
|
|
|
40
|
-
## 🔴 The ceiling, and why this library has one
|
|
41
|
-
|
|
42
|
-
This exists because `apps/desk` and `apps/flix` answered the same six build-and-ops
|
|
43
|
-
questions independently, and **three of the six came out the same**. It does not exist
|
|
44
|
-
because sharing is good.
|
|
45
|
-
|
|
46
|
-
The library it replaces reached **277 files** in a previous generation, on the reasoning
|
|
47
|
-
that a module used by 8 of 8 apps is a module worth sharing. What that produced was an
|
|
48
|
-
app whose entire `vite.config.ts` was one line it could not read, a build system no app
|
|
49
|
-
could opt out of, and a monorepo that could not be relocated. Every one of those 277
|
|
50
|
-
files was individually defensible.
|
|
51
|
-
|
|
52
|
-
So the entry rule here is arithmetic, not judgement:
|
|
53
|
-
|
|
54
|
-
1. **Two apps wrote it independently.** Not one app and a plan for a second — two
|
|
55
|
-
working implementations, in the tree, that a `diff` says are the same.
|
|
56
|
-
2. **It is mechanism, not identity.** If a function would need to know an app's name,
|
|
57
|
-
port, hostname, routes or health-payload shape, it belongs in the app. Every
|
|
58
|
-
function here takes those as arguments or does not see them at all.
|
|
59
|
-
3. **It is a trap somebody paid for.** Each thing in here is a measured incident turned
|
|
60
|
-
into a check — the file headers carry the dates and the costs. A convenience that
|
|
61
|
-
has never prevented anything is not a reason to add a dependency to eight apps.
|
|
62
|
-
|
|
63
|
-
A fourth rule follows from the first three: **an addition that only one app will use
|
|
64
|
-
does not go in.** That is a library that exists to be refactored later.
|
|
65
|
-
|
|
66
|
-
🔴 **`sameDeployedShell` is the one entry that rule 1 did not let in, and it is recorded
|
|
67
|
-
here rather than quietly excepted.** It was written once, by `patterns`, not twice —
|
|
68
|
-
what admits it is rule 3 at a size rule 1 cannot measure: the fault it catches was
|
|
69
|
-
invisible for six days on the app that had it, and the other five apps behind or moving
|
|
70
|
-
behind an edge Worker still cannot tell a working deploy from a working cache. A check
|
|
71
|
-
told ONE NAME is a check about that name; this one is about a property every deployment
|
|
72
|
-
with two addresses has. It buys the exception by being pinned to a failure it has
|
|
73
|
-
actually caught — `src/smoke.test.ts` runs it against the genuine 2026-09-12 shell.
|
|
74
|
-
|
|
75
|
-
🔴 **`public-surface` is the second recorded exception, and the first whose callers are
|
|
76
|
-
not apps.** Rule 1 is met — more than met — but by three LIBRARIES: `cwip`, `cursedbelt`
|
|
77
|
-
and `cursedbelt-server` each carried `scripts/publicSurface.ts` (430, 455 and 476 lines),
|
|
78
|
-
and by 0.2.6 the bodies had already forked, which is what admits it. Measured 2026-09-22:
|
|
79
|
-
`cwip`'s threw on a bare-specifier `export *` where the other two resolved and counted
|
|
80
|
-
it, so the three numbers were not commensurable; and when the ESM-extension fix (task
|
|
81
|
-
269) made `./x.js` the spelling of an internal specifier, `cursedbelt-server`'s and
|
|
82
|
-
`cursedbelt`'s copies each had to be taught `./x.js` → `./x.ts` by hand, separately,
|
|
83
|
-
while `cwip`'s never was. Each library is published on its own and its gate must pass
|
|
84
|
-
from a lone checkout, so neither a `file:` import nor a reach into `$FORGE/tools` could
|
|
85
|
-
share it; a published version could, and this package is already that for the fleet.
|
|
86
|
-
It is a devDependency of each library, so nothing their consumers install changed. On
|
|
87
|
-
adoption all three measured **identical per-subpath counts** to their own copies
|
|
88
|
-
(`cwip` 60 subpaths/2,089 symbols, `cursedbelt` 51/1,837, `cursedbelt-server` 34/992) —
|
|
89
|
-
a consolidation that moves a count has changed the measurement, not the plumbing.
|
|
90
|
-
|
|
91
|
-
🔴 **`bound-lists` is the third recorded exception, admitted by rule 3 at three consumers.**
|
|
92
|
-
It was written once, by `collections` (`src/server/boundLists.test.ts`, 2026-09-22), after
|
|
93
|
-
the shape it refuses drew an empty gallery for every folder over 100 files on D1. The same
|
|
94
|
-
day `music` was found carrying 24 sites and `vault` four — the identical trap in two more D1
|
|
95
|
-
Workers — so the choice was two more copies of the scanner or one here. Copies are what
|
|
96
|
-
`check-copies` exists to refuse, and a forked scanner is a check that quietly stops matching.
|
|
97
|
-
It is mechanism only: each app passes its own root and directories and keeps its own
|
|
98
|
-
"not vacuous" assertion about which files the scan must reach.
|
|
99
|
-
|
|
100
|
-
🔴 **`ship` is the fourth recorded exception: no app wrote it, because it is the switch the
|
|
101
|
-
deploys themselves obey.** The owner's ruling (2026-09-28) was that agents run the same commands
|
|
102
|
-
whatever the switch says and the commands deal with it — so it has to live in the one seam every
|
|
103
|
-
production deploy passes through, which is `runWorkerDeploy` here and `deploy-tree`'s callers.
|
|
104
|
-
It reads a file the runner owns and knows no app: rule 2 holds, and the header of `src/ship.ts`
|
|
105
|
-
says why every failure reads as `fast`.
|
|
106
|
-
|
|
107
|
-
### What was deliberately left in the apps
|
|
108
|
-
|
|
109
|
-
The comparison that produced this library found three areas that are genuinely
|
|
110
|
-
different and must stay that way. `tasks/Procedures/graduate-an-app.md` in the
|
|
111
|
-
generation repo carries the full verdict table, because the next app to graduate is who
|
|
112
|
-
needs to read it:
|
|
113
|
-
|
|
114
|
-
- **the build** — `desk` compiles a single binary with `bun build --compile`; `flix` is
|
|
115
|
-
a Vite SPA with an asset budget. Not the same problem.
|
|
116
|
-
- **the supervisor** — `desk` runs a watcher that rebuilds on a commit; `flix`
|
|
117
|
-
deliberately has none, and its deploy reinstalls the agent instead. That difference
|
|
118
|
-
changes what a rollback *is* in each app.
|
|
119
|
-
- **the app config** — how an app decides it is deployed, and what that turns on, is the
|
|
120
|
-
app.
|
|
121
|
-
|
|
122
|
-
`deploy.ts` is a fourth: the two scripts share a shape and roughly thirty lines of
|
|
123
|
-
helpers, but they sequence genuinely different steps and a shared deploy driver is
|
|
124
|
-
exactly the 277-file mistake starting again. What they share instead is the **exit-code
|
|
125
|
-
contract** in `cursedops/smoke` — `0` keep, `1` roll back, `2` not the app (the edge, the
|
|
126
|
-
network, or the smoke's own environment), do not roll back — which is the only part both
|
|
127
|
-
deploy scripts actually read.
|
|
128
|
-
|
|
129
|
-
🔴 **The WORKER deploy is the other way round, and `cursedops/worker-deploy` is not this
|
|
130
|
-
mistake made again.** The Mac `deploy.ts` scripts sequence genuinely different steps; the
|
|
131
|
-
four Worker `worker-deploy.ts` scripts sequence the SAME nine, and a `diff` says so — what
|
|
132
|
-
differed between them was which of the nine each copy had forgotten. So the sequence is
|
|
133
|
-
shared and every step's command is data the app passes; see "The Worker toolkit" below.
|
|
134
|
-
|
|
135
|
-
## `cursedops/roots`
|
|
136
|
-
|
|
137
|
-
```ts
|
|
138
|
-
import { forgeRootFrom, packageRootFrom, requireForgeState } from "cursedops/roots";
|
|
139
|
-
|
|
140
|
-
const generation = forgeRootFrom(import.meta.url); // string | null
|
|
141
|
-
const checkout = packageRootFrom(import.meta.url); // string | null
|
|
142
|
-
const state = requireForgeState(import.meta.dir); // string, or a thrown message
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
A generation is defined by a `forge.env` above the checkout — the same rule its
|
|
146
|
-
`check-paths` and its runner use, so the three cannot disagree. Everything returns
|
|
147
|
-
`null` rather than guessing, because a function that invented a root would write files
|
|
148
|
-
into a stranger's tree.
|
|
149
|
-
|
|
150
|
-
🔴 **A git worktree counts** (0.2.5). Worktrees live outside every generation by
|
|
151
|
-
construction, so the walk up alone answered `null` there and a repo whose gate asks for
|
|
152
|
-
the root was red in every worktree — the runner's subset gate included. When the walk
|
|
153
|
-
fails, `git rev-parse --git-common-dir` leads back to the primary checkout and the walk
|
|
154
|
-
runs from there. A clone is its own primary and still gets `null`.
|
|
155
|
-
|
|
156
|
-
🔴 The state root is **read out of `forge.env`**, never spelled here. A published
|
|
157
|
-
library that hardcoded `$HOME/.<name>` would be correct for exactly one generation and
|
|
158
|
-
silently wrong for its successor — which is the defect this replaced.
|
|
159
|
-
|
|
160
|
-
### `repoCd` — a printed command that RUNS (0.5.0, task 092)
|
|
161
|
-
|
|
162
|
-
```ts
|
|
163
|
-
import { otherRepoCommand, repoCommand } from "cursedops/roots";
|
|
164
|
-
|
|
165
|
-
const REPO_IN_FORGE = "apps/auth"; // the ONE thing the app keeps
|
|
166
|
-
repoCommand(REPO_IN_FORGE, "bun run geo:build", import.meta.dir);
|
|
167
|
-
// → source "…/forge.env" && cd "$FORGE/apps/auth" && bun run geo:build
|
|
168
|
-
otherRepoCommand("apps/station", REPO_IN_FORGE, "bun run geo", import.meta.dir);
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
Rule 1: four repos wrote it — `apps/binary-server/src/repoCommand.ts` (the original,
|
|
172
|
-
2026-09-18), `apps/auth/src/kit/repoCommand.ts`, and `scripts/forgeRoot.ts` in `family` and
|
|
173
|
-
`roms`. Rule 3: `check-paths`' `deadCd` rule measured twelve printed commands in five repos
|
|
174
|
-
`cd`-ing into directories two generations gone, each read at a moment something was already
|
|
175
|
-
wrong. The segment is always the CALLER's (the library may not know a repo's name) and is
|
|
176
|
-
refused if it is not a plain relative path; `from` is REQUIRED, because a default would be this
|
|
177
|
-
library's own directory inside `node_modules`. A `$FORGE` with no `forge.env` in it is stale and
|
|
178
|
-
is not honoured (`verifiedForgeRoot`; `forgeCodeRoot` keeps its older contract). Every fallback is
|
|
179
|
-
a directory that EXISTS, and nothing composed here ever spells `cd ~/`. `repoCommand.test.ts`
|
|
180
|
-
asserts on the COMPOSED string, which is the only thing `check-paths` cannot see.
|
|
181
|
-
|
|
182
|
-
### The state root (task 160)
|
|
183
|
-
|
|
184
|
-
Nothing new was needed: `forgeState(from, env)` / `requireForgeState(from, env)` already read
|
|
185
|
-
`FORGE_STATE` out of the `forge.env` above the calling module, which is what a launchd job with
|
|
186
|
-
no environment and a worktree both need. An app's hand-spelled `join(HOME, ".code", "<name>")`
|
|
187
|
-
becomes `requireForgeState(import.meta.dir, env)`. Text generated for ANOTHER machine (a systemd
|
|
188
|
-
unit, a shell installer) interpolates the VALUE — `relative(homedir(), requireForgeState(…))`
|
|
189
|
-
after `%h/` or `$HOME/` — never the generation's name.
|
|
190
|
-
|
|
191
|
-
## `cursedops/paths` (0.5.0, task 2089)
|
|
192
|
-
|
|
193
|
-
```jsonc
|
|
194
|
-
"paths": "forge-paths" // check-paths + check-doc-citations
|
|
195
|
-
"paths": "forge-paths --also check-gate-graph.ts" // cursedbelt's extra law, as an OPTION
|
|
196
|
-
"gate": "forge-paths --only gate.ts" // 0.5.3: the shared gate runner, and ONLY it
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
`--only <law>` (0.5.3, task 474) replaces the default laws instead of adding to them, so a repo's
|
|
200
|
-
`gate` reaches `$FORGE/tools/gate.ts` through the same walk without running `check-paths` a
|
|
201
|
-
second time or handing it `--force`. A law it names that is missing from `tools/` exits 1.
|
|
202
|
-
|
|
203
|
-
Rule 1, eighteen times: `scripts/paths.ts` was in every app, `autopilot` and every library,
|
|
204
|
-
sixteen byte-identical and three already forked (`cursedbelt` +`check-gate-graph.ts`, `station`
|
|
205
|
-
+`check-no-clock.ts`, `autopilot` split into its own `generation.ts`). `check-copies` prices
|
|
206
|
-
exported definitions and this file exports nothing, so eighteen copies read as zero. Rule 3: the
|
|
207
|
-
wrapper exists because `bun ../../tools/check-paths.ts .` died on its first step in every
|
|
208
|
-
worktree (2026-09-17). The walk and the worktree follow-back are the copies' own; **no
|
|
209
|
-
generation is still no pass** — a lone clone exits 1 saying which walk came back empty. Every
|
|
210
|
-
argument that is not `--also`/`--only` passes through to the laws (`bun run paths --prune`).
|
|
211
|
-
|
|
212
|
-
🔴 **Until the roster rule can read the bin, the wrapper is two lines that NAME the laws.**
|
|
213
|
-
`check-doc-citations`' `pathsRunsThis` proves a repo runs it by reading the `paths` command or
|
|
214
|
-
the one `scripts/` file it delegates to, so a bare `forge-paths` reads as unwired. The form that
|
|
215
|
-
passes today — and the one this repo uses — is:
|
|
216
|
-
|
|
217
|
-
```ts
|
|
218
|
-
// scripts/paths.ts, with "paths": "bun run scripts/paths.ts"
|
|
219
|
-
import { runGenerationLaws } from "cursedops/paths";
|
|
220
|
-
process.exit(runGenerationLaws(import.meta.dir, { laws: ["check-paths.ts", "check-doc-citations.ts"], argv: process.argv.slice(2) }));
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
## `cursedops/launchd`
|
|
224
|
-
|
|
225
|
-
```ts
|
|
226
|
-
import { renderPlist, replaceAgent, answering, declaredBy } from "cursedops/launchd";
|
|
227
|
-
|
|
228
|
-
const conflict = await declaredBy(LABEL, "managed-by=olddeployer");
|
|
229
|
-
if (conflict) throw new Error(`a retired deployer still declares ${LABEL}: ${conflict}`);
|
|
230
|
-
|
|
231
|
-
const { boot } = await replaceAgent(LABEL, renderPlist(spec), { also: LEGACY_LABELS });
|
|
232
|
-
if (boot.code !== 0) throw new Error(boot.out);
|
|
233
|
-
if (!(await answering(`http://127.0.0.1:${port}/healthz`))) throw new Error("a pid is not a service");
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
`livePort(label)` (0.5.0) reads `PORT` out of the LIVE job's own `environment` block — the
|
|
237
|
-
declaration the tunnel routes to — never `package.json`, which advertised the dev pair in eight
|
|
238
|
-
of eight apps (`autopilot doctor`'s `ports` section, 2026-09-15, lifted with its fixture).
|
|
239
|
-
|
|
240
|
-
`RunAtLoad` and `KeepAlive` are **not defaulted** — a server wants both, a nightly
|
|
241
|
-
snapshot wants neither, and a kit that decides gets one of them wrong.
|
|
242
|
-
|
|
243
|
-
## `cursedops/smoke`
|
|
244
|
-
|
|
245
|
-
```ts
|
|
246
|
-
import { createSmoke, guarded } from "cursedops/smoke";
|
|
247
|
-
|
|
248
|
-
const smoke = createSmoke({ base: process.env.MYAPP_SMOKE_URL ?? "https://…" });
|
|
249
|
-
await guarded(smoke, "gate", async () => {
|
|
250
|
-
const response = await smoke.anonymous("/api/v1/private");
|
|
251
|
-
smoke.record("gate", response.status === 401, `anonymous → ${response.status}`);
|
|
252
|
-
});
|
|
253
|
-
smoke.finish(); // exits 0, 1 or 2
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
One check, and it is about the ADDRESSES of a deployment rather than about any app:
|
|
257
|
-
|
|
258
|
-
```ts
|
|
259
|
-
import { sameDeployedShell } from "cursedops/smoke";
|
|
260
|
-
|
|
261
|
-
// every address of one deployment, not "edge vs origin" — an app may have no
|
|
262
|
-
// origin left. A Worker's second address is its uncached *.workers.dev preview.
|
|
263
|
-
await sameDeployedShell(smoke, [smoke.base, `http://127.0.0.1:${port}`]);
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
🔴 It refuses when the addresses name different built assets, and it records that as an
|
|
267
|
-
**edge fault (exit 2), never a rollback** — the origin is serving what this checkout
|
|
268
|
-
built, and reverting it cannot reach the Worker, cache rule or Assets binding in front.
|
|
269
|
-
Measured 2026-09-18 on `patterns`: the edge served a shell built 2026-09-12 out of a
|
|
270
|
-
Worker nobody knew about, and every automated surface stayed green for **six days**.
|
|
271
|
-
Only same-origin `.js`/`.css` references are compared, because the zone injects a Web
|
|
272
|
-
Analytics beacon in front of the edge and not on loopback — a check that reds on a
|
|
273
|
-
healthy deploy is one somebody turns off.
|
|
274
|
-
|
|
275
|
-
### The verdicts (0.5.0, task 069)
|
|
276
|
-
|
|
277
|
-
```ts
|
|
278
|
-
import { livePort } from "cursedops/launchd";
|
|
279
|
-
import { createSmoke, probeOrigin } from "cursedops/smoke";
|
|
280
|
-
|
|
281
|
-
const smoke = createSmoke({ base: PUBLIC_URL });
|
|
282
|
-
if (!smoke.requireEnvironment({ CF_ACCESS_CLIENT_ID: env.CF_ACCESS_CLIENT_ID }, "source $FORGE_STATE/secrets/cloudflare-access.env")) {
|
|
283
|
-
process.exit(smoke.report()); // 2 — the smoke could not judge; not the app
|
|
284
|
-
}
|
|
285
|
-
await probeOrigin(smoke, livePort(LABEL), { label: LABEL }); // a dead origin is 1, always
|
|
286
|
-
// … the app's own checks against the public hostname …
|
|
287
|
-
process.exit(await smoke.settle()); // asks the NETWORK before charging the app
|
|
288
|
-
```
|
|
289
|
-
|
|
290
|
-
A deployed smoke lied three ways, and each is now a verdict with a test of its failure path
|
|
291
|
-
(`src/smokeVerdict.test.ts`):
|
|
292
|
-
|
|
293
|
-
| lie | measured | verdict | exit |
|
|
294
|
-
|---|---|---|---:|
|
|
295
|
-
| PASSED about a dead origin | `patterns` and `collections` served a cached 200 over a 503 origin, every check green; four smokes made no loopback request at all | `origin` — `probeOrigin` asks `127.0.0.1:<livePort>`; no live port is no origin | **1**, whatever else the run saw |
|
|
296
|
-
| FAILED about a healthy app, for want of the smoke's own credential | `desk`: `✗ FAILED — 5 of 6` because only the deploy script loaded the Access token | `environment` — `requireEnvironment` / `refuseEnvironment` | 2 |
|
|
297
|
-
| FAILED about a healthy app, because the network substituted DNS | 2026-09-19, airline wifi answered `1.1.1.1` for a proxied host → `403 · Error 1034` | `network` — `settle()` runs `diagnoseNetwork`, `tools/check-public-dns.ts`' range invariant lifted | 2 |
|
|
298
|
-
|
|
299
|
-
🔴 **No new exit code**, on purpose: every deploy script reads `0` keep, `2` stop without
|
|
300
|
-
reverting, anything else REVERT (`apps/collections/scripts/deploy.ts:413-424` and four
|
|
301
|
-
siblings). A `3` for "credential missing" would be reverted by all five. The distinction is in
|
|
302
|
-
`verdict()` and in the sentence printed. And the network diagnosis **never retries and never
|
|
303
|
-
pins an address** — the deliverable is the distinction, not a way around the network.
|
|
304
|
-
|
|
305
|
-
`steadyHealth(ask, ready)` is the Worker smoke's wait: `ready` must hold for THREE reads in a
|
|
306
|
-
row, because on `music`'s first deploy (2026-09-23, `a54ef71`) one good `/healthz` was followed
|
|
307
|
-
by thirteen routes answering the pre-secrets version. It returns what answered and never judges.
|
|
308
|
-
A read that THROWS (0.11.1) is a bad read asked again, bounded by `deadlineMs` (240 s), and only
|
|
309
|
-
a LAST read that threw is thrown to the caller: the 2026-09-29 ship reported family and music red
|
|
310
|
-
on a first `/healthz` that timed out twice while the edge was between versions (task 2291).
|
|
311
|
-
`runWorkerSmoke` asks each of those reads with a 10 s curl `--max-time`
|
|
312
|
-
(`HEALTH_READ_MAX_TIME_SEC`, passed as `EdgeRequestInit.maxTimeSec`), so the window costs 20 s a
|
|
313
|
-
read instead of 120.
|
|
314
|
-
|
|
315
|
-
`awaitConverged(probes)` (0.10.19) is the same wait over SEVERAL paths: every probe must agree
|
|
316
|
-
three rounds in a row, bounded (120 s), before any leg is judged — a Worker's code and its static
|
|
317
|
-
assets reach the edge separately (roms `f9273ea`, task 2196: `/healthz` at HEAD while the old
|
|
318
|
-
script answered `/host/install.sh` and the new chunk 404'd). A wait that runs out says
|
|
319
|
-
`never converged in Ns — <what lagged>`, and the caller records it as a red row.
|
|
320
|
-
|
|
321
|
-
Nothing else. The checks are the part `desk` and `flix` wrote differently on purpose,
|
|
322
|
-
and a shared kit that starts absorbing route lists and health-payload shapes is how the
|
|
323
|
-
last one reached 277 files.
|
|
324
|
-
|
|
325
|
-
## `cursedops/api-floor`
|
|
326
|
-
|
|
327
|
-
```ts
|
|
328
|
-
import { API_PREFIX, apiNotFoundBody, canonicalApiPath, canonicalApiRequest } from "cursedops/api-floor";
|
|
329
|
-
|
|
330
|
-
// AFTER the app's routes, BEFORE the static catch-all, and `all` not `get`.
|
|
331
|
-
const floor = (c) => {
|
|
332
|
-
const urlPath = new URL(c.req.url).pathname;
|
|
333
|
-
// A trailing slash is never meaningful under /api/ — re-dispatch, once.
|
|
334
|
-
if (canonicalApiPath(urlPath) !== null) return app.fetch(canonicalApiRequest(c.req.raw));
|
|
335
|
-
return Response.json(apiNotFoundBody(c.req.method, urlPath, "myapp", commit), { status: 404 });
|
|
336
|
-
};
|
|
337
|
-
app.all(API_PREFIX, floor);
|
|
338
|
-
app.all(`${API_PREFIX}/*`, floor);
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
🔴 Measured 2026-09-17 on `station`: a page that had never loaded on any commit, because
|
|
342
|
-
its client asked for `/api/openclaw/`, Hono mounts a sub-app's `get("/")` at the mount
|
|
343
|
-
point without the trailing slash and matches strictly, and the miss fell into
|
|
344
|
-
`app.get("*")` and came back as 1.7 KB of `index.html` **with a 200**. The client read
|
|
345
|
-
that HTML honestly — *"an API older than this page"* — and sent the owner to deploy a
|
|
346
|
-
commit that could not have helped.
|
|
347
|
-
|
|
348
|
-
🔴 Wire it **inside the router**, never as a wrapper around the host's `fetch`. A router
|
|
349
|
-
has more callers than `Bun.serve` — a Worker's `export default`, and every harness that
|
|
350
|
-
drives `app.fetch` — and a wrap installed at one of them is absent from the rest. That is
|
|
351
|
-
the exact fault the previous generation's artifact shipped for six days with a green
|
|
352
|
-
harness.
|
|
353
|
-
|
|
354
|
-
Both patterns are registered although `"/api/*"` alone already matches a bare `/api` on
|
|
355
|
-
hono@4.13.7 (measured 2026-09-18) — that is an undocumented property of one matcher
|
|
356
|
-
version, and one extra route registration is cheaper than a floor with a hole in it the
|
|
357
|
-
day it tightens.
|
|
358
|
-
|
|
359
|
-
No `node:` import, no `Bun.` global, no `process` — asserted by `apiFloor.test.ts`, not
|
|
360
|
-
by this paragraph — so it mounts unchanged inside a Cloudflare Worker, which is where
|
|
361
|
-
`patterns` mounts it. `apiNotFoundBody` is the DEFAULT body and not the only one: an app
|
|
362
|
-
whose API namespace is not behind a gate keeps a terser phrase of its own and takes the
|
|
363
|
-
predicates.
|
|
364
|
-
|
|
365
|
-
## `cursedops/serve`
|
|
366
|
-
|
|
367
|
-
```ts
|
|
368
|
-
import { contentTypeFor, fileWithin, installCrashHandlers, isHashedAsset } from "cursedops/serve";
|
|
369
|
-
|
|
370
|
-
installCrashHandlers("myapp"); // one log line, then exit 1
|
|
371
|
-
const file = fileWithin(clientDir, url.pathname); // null unless it is really inside
|
|
372
|
-
if (!file) return c.notFound();
|
|
373
|
-
return new Response(Bun.file(file), {
|
|
374
|
-
headers: {
|
|
375
|
-
"content-type": contentTypeFor(file),
|
|
376
|
-
"cache-control": isHashedAsset(url.pathname) ? ONE_YEAR : "no-cache",
|
|
377
|
-
},
|
|
378
|
-
});
|
|
379
|
-
```
|
|
380
|
-
|
|
381
|
-
🔴 `fileWithin` is the reason this subpath exists rather than the convenience of the
|
|
382
|
-
other three. It is the check that stops `%2e%2e%2f` reaching the filesystem, and on
|
|
383
|
-
2026-09-17 there were **eight** of it — one per app, character-identical, with nothing in
|
|
384
|
-
the generation counting them. Eight copies of a security guarantee are eight guarantees:
|
|
385
|
-
a fix in one is a fix in one.
|
|
386
|
-
|
|
387
|
-
### The verdict for every symbol in the `serve.ts` family
|
|
388
|
-
|
|
389
|
-
Measured 2026-09-17 across `collections`, `family`, `flix`, `music`, `patterns`, `roms`,
|
|
390
|
-
`station` and `vault` — the eight apps that have a `src/server/serve.ts`. The files are
|
|
391
|
-
247–387 lines and all eight differ, which is why no `diff` ever flagged them; the bodies
|
|
392
|
-
below were compared with whitespace and comments normalised away.
|
|
393
|
-
|
|
394
|
-
| symbol | identical in | verdict |
|
|
395
|
-
|---|---:|---|
|
|
396
|
-
| `fileWithin` | 8 of 8 | **moved.** The traversal guard. Entry rule 3 — a trap somebody paid for — in its purest form |
|
|
397
|
-
| `installCrashHandlers` | 8 of 8 | **moved.** The app's name is a parameter; it is the only app-shaped thing the four touch |
|
|
398
|
-
| `contentTypeFor` | 8 of 8 | **moved.** Extension → MIME, plus its private `CONTENT_TYPES` table, which was also 8 of 8 |
|
|
399
|
-
| `isHashedAsset` | 8 of 8 | **moved.** Vite's own output convention, which is the bundler's identity and not the app's |
|
|
400
|
-
| `clientDirOf` | 6 of 6 that have it | **stays.** One line, and six apps pin it with their own `clientDir.test.ts` against their own `vite.config.ts`. WHERE an app's build writes is app identity — entry rule 2 — and it is the line that once served `public/` and took a live app down |
|
|
401
|
-
| `createServer` / `createApp` | 0 — all eight differ | **stays.** Route tables, health payloads, gates and body limits. This is the 277-file mistake's front door |
|
|
402
|
-
| `parseRange` / `fileResponse` | `flix` and `station` only | **stays for now.** Two apps, so rule 1 is satisfiable, but rule 3 is not: nothing has been paid for yet, and a `Range` implementation shared by two apps that stream different things is a guess. The trigger to revisit is a THIRD app that needs byte ranges |
|
|
403
|
-
| `API_PREFIX`, `isApiPath`, `canonicalApiPath`, `canonicalApiRequest`, `apiNotFoundBody` | `station` only | **stays.** One app, and rule 4 says an addition one app will use does not go in. `apiNotFoundBody` also writes a sentence the owner reads on a page — identity, not mechanism |
|
|
404
|
-
|
|
405
|
-
🔴 **The task that ordered this move recorded `contentTypeFor` and `isHashedAsset` as
|
|
406
|
-
7 of 8, with `station` differing. They did not differ.** All four bodies were identical
|
|
407
|
-
in all eight apps when the move was made, so nothing had to converge and no app's
|
|
408
|
-
behaviour changed on adoption. The count came from a census of the whole `serve.ts`
|
|
409
|
-
family rather than of these two bodies; `bun tools/check-copies.ts` in the generation
|
|
410
|
-
repo is the live answer either way.
|
|
411
|
-
|
|
412
|
-
One behaviour is NOT a byte-for-byte carry, and `src/serve.ts`'s header argues it in
|
|
413
|
-
full: the eight copies checked containment lexically and were therefore blind to a
|
|
414
|
-
symlink under the root pointing out of it. `fileWithin` here resolves the real path of
|
|
415
|
-
**both** sides after the lexical check, which refuses the escape while leaving every
|
|
416
|
-
input the eight copies refused refused, at the same cost. A symlinked ancestor of the
|
|
417
|
-
root cancels out — which is the failure a one-sided `realpath` would have shipped to
|
|
418
|
-
every app on this machine, and is its own test.
|
|
419
|
-
|
|
420
|
-
## The Worker toolkit (0.5.0, task 2097)
|
|
421
|
-
|
|
422
|
-
`cursedops/worker-deploy`, `/worker-secrets`, `/worker-rollback`, `/edge-fetch`, `/d1-import`.
|
|
423
|
-
Rule 1, four times: `collections`, `vault`, `patterns` and `music` (copied from `vault` on
|
|
424
|
-
2026-09-23) each carried the toolkit in `scripts/`, which `check-copies` did not scan. Rule 3:
|
|
425
|
-
the copies had drifted in ways that cost something — `patterns` never refused a dirty tree nor
|
|
426
|
-
stamped the commit, `vault` and `collections` judged a deploy after one `/healthz` read,
|
|
427
|
-
`collections`' secrets upload never deleted an extra, `patterns`' rollback typed its route id,
|
|
428
|
-
and only `collections`' import normalised a REAL. Each module header names its incident.
|
|
429
|
-
Everything that shells out (wrangler, git, curl, dig) is injectable; the suite never touches a
|
|
430
|
-
network.
|
|
431
|
-
|
|
432
|
-
🔴 **`edge-fetch` is the fourth recorded exception.** Rule 1 asks for two INDEPENDENT
|
|
433
|
-
implementations and this has one: `vault` wrote it and `music` copied it byte for byte. What
|
|
434
|
-
admits it is that a verbatim copy is precisely what `check-copies` exists to refuse, the task
|
|
435
|
-
that asked for this move named it, and every other part of the toolkit it serves is here — a
|
|
436
|
-
Worker smoke that had to keep its transport in the app would keep a copy of the toolkit's
|
|
437
|
-
most fragile part (a hand-rolled HTTP parser) in two places.
|
|
438
|
-
|
|
439
|
-
| part | verdict |
|
|
440
|
-
|---|---|
|
|
441
|
-
| the deploy SEQUENCE (HEAD, clean tree, app refusals, stage first, build, schema, `--var` stamp, secrets, smoke) | **moved** — `runWorkerDeploy(spec, deps)`; the commands are data |
|
|
442
|
-
| `parseWorkerEnv`, `wranglerEnvArgs`, `readWranglerJsonc`, `readEnvFile`, `cloudflareCredential` | **moved** — identical or converged in all four |
|
|
443
|
-
| exact-set secrets over a pipe, read back | **moved** — `planSecrets` + `syncSecrets` + `wranglerSecretPut`; `envFileLines` carries `collections`' pre-quoted-seed fix; `--mint` carries the keys `mintCarries` names (0.10.13; default `FILE_TOKEN_PRIVATE_KEY`, flix's is `PLEX_ACCOUNT_TOKEN`) |
|
|
444
|
-
| origin-first rollback, route found by pattern | **moved** — `rollbackToOrigin`, `findWorkerRoute`, `deleteWorkerRoute`; the whole `worker:rollback` main is `runWorkerRollback(spec, argv)` (0.10.13 — flix and music were one copied file) |
|
|
445
|
-
| `edgeFetch` (curl `--resolve` past the negative DNS cache) | **moved** — `createEdgeFetch`; the Access header now THROWS on an incomplete token instead of sending empty headers |
|
|
446
|
-
| the import's literal and row-for-row proof | **moved** — `sqlLiteral`, `rowDigest`, `sameRows`, `rowDifferences`, `wranglerRows` |
|
|
447
|
-
| the steady `/healthz` wait | **moved**, into `cursedops/smoke` as `steadyHealth` |
|
|
448
|
-
| the Worker CPU tail and its judgement (`worker-cpu.ts` + `workerCpu.ts`) | **moved** (0.7.0, task 2134) — `runWorkerCpuTail`, `judgeTailCapture`, `workerBudgets`; `runWorkerDeploy({ stageWalk, cpuTail })` runs the walk and the smoke under it |
|
|
449
|
-
| the Worker BUDGET TABLE (routes, numbers, the unlock's ceiling) | **stays** — what a route may cost is the app |
|
|
450
|
-
| the deployment table (`workerEnvs.ts`: names, database, URLs, secret map, which file) | **stays** — identity, rule 2 |
|
|
451
|
-
| the smoke's CHECKS (gated-route census, health identity fields, shell/CSP) | **stays** — the same argument as the rest of `smoke` |
|
|
452
|
-
| the walk (`workerWalk.ts`) and the stage walk's grant through `apps/auth-stage` | **stays** — what a signed-in owner does is the app |
|
|
453
|
-
| the import's table PLAN (what is skipped, parents first, what is rebuilt: FTS, `art_lookups`) | **stays** — the app's schema |
|
|
454
|
-
| minting a stage's secret FILE (which keys are copied from production, which are throwaway) | **stays** — `throwawaySecret` and `writeSecretsFile` are the mechanism it uses |
|
|
455
|
-
|
|
456
|
-
### The Worker CPU check, in `cursedops/worker-deploy` (0.7.0, task 2134)
|
|
457
|
-
|
|
458
|
-
`collections` wrote it on 2026-09-23 after averaging 489 billed CPU-ms per request with a 15 s
|
|
459
|
-
p99 and every check green; `vault` was measured in the same state the same day (argon2id at
|
|
460
|
-
4,089 CPU-ms per create, 4,458 per unlock, on its stage). The billed number exists only on the
|
|
461
|
-
trace a tail receives, so this tails the Worker around the walk and the smoke, charges each
|
|
462
|
-
request's `cpuTime` to its declared route with `cursedbelt-server/bench`, and fails on an
|
|
463
|
-
undeclared route, one over its budget, or an EXEMPT route past its `noticeAboveMs` (the library
|
|
464
|
-
only notices; a Worker is killed at 30 s of CPU, and a killed unlock reads as a wrong password).
|
|
465
|
-
An exempt route with no ceiling is red on its own. `bench` is passed in (`import * as bench`),
|
|
466
|
-
because `cursedbelt-server` depends on this package. It lives in `worker-deploy` rather than a subpath of its own because a shipped file may not
|
|
467
|
-
import a sibling (`publishShape.test.ts`). An app adopts it with a budget table, a
|
|
468
|
-
ten-line `scripts/worker-cpu.ts`, and `stageWalk` + `cpuTail` on its `runWorkerDeploy` spec.
|
|
469
|
-
|
|
470
|
-
## `cursedops/public-surface`
|
|
471
|
-
|
|
472
|
-
```sh
|
|
473
|
-
# in the library's package.json — `bun run surface` / `bun run surface --prune`
|
|
474
|
-
"surface": "public-surface" # cwip adds --peers
|
|
475
|
-
```
|
|
476
|
-
|
|
477
|
-
```ts
|
|
478
|
-
import { run } from "cursedops/public-surface";
|
|
479
|
-
|
|
480
|
-
const { fresh, grown, stale, peers } = await run({ root: packageDir }); // all four [] when clean
|
|
481
|
-
```
|
|
482
|
-
|
|
483
|
-
`publicSurface.baseline` at the package root is one `<count> <subpath>` line per
|
|
484
|
-
export subpath. It fails on a subpath the baseline does not list, on one that GREW, and
|
|
485
|
-
on an entry that shrank or matches nothing — so a deleted module cannot leave an
|
|
486
|
-
allowance behind. `--prune` only ever lowers; new surface is a line added by hand in a
|
|
487
|
-
commit that says why. What a count includes and excludes, and the three measurements
|
|
488
|
-
that each produced a copy of this, are in the header of `src/publicSurface.ts`.
|
|
489
|
-
|
|
490
|
-
It is a check a library runs on ITSELF. It never asks who imports the library — that
|
|
491
|
-
is cross-repo, and a repo's gate proves that repo.
|
|
492
|
-
|
|
493
44
|
## Verifying
|
|
494
45
|
|
|
495
46
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots — and printing a command that runs when pasted — without knowing a path, the generation's whole-tree laws run over one repo from a checkout or a worktree, macOS launchd agent install/replace/remove and the live port a job serves, the scaffolding and verdicts of a deployed smoke (origin probe, the smoke's own environment, a network that lies about DNS, a settled version), the static-serving helpers eight apps copied — the path-traversal guard among them — the API floor that keeps an unmatched /api/... from ever being answered with the app shell, the commit and dirty flag a checkout-served process reports, the Cloudflare Worker deploy toolkit four apps copied (the deploy sequence, exact-set secrets over a pipe, origin-first rollback, the curl edge fetch, the row-for-row D1 import proof, the billed-CPU tail check around a deploy's walk and smoke, and each app's worker:secrets and worker:smoke main as one function of its data), the relay a Worker fronts a Mac-bound app with (the Durable Object, the frames, the Mac's dialer and key rotation — lifted from station for roms — and the signed-in stage walk's skeleton and the relay app's whole worker:deploy), the Worker import-graph and await-port checks every Worker app's suite runs over its own source, and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
package/src/workerSecrets.ts
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
*/
|
|
37
37
|
import { spawnSync } from "node:child_process";
|
|
38
38
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
39
|
+
import { dirname, join } from "node:path";
|
|
39
40
|
// The package's own subpath, never `./workerDeploy.ts`: shipped source may not import a sibling
|
|
40
41
|
// by relative path (`publishShape.test.ts` says why), and a self-reference resolves through the
|
|
41
42
|
// same `exports` map every consumer's does.
|
|
@@ -168,14 +169,40 @@ export interface ApiResult {
|
|
|
168
169
|
|
|
169
170
|
export type CloudflareApi = (path: string, init?: RequestInit) => Promise<ApiResult>;
|
|
170
171
|
|
|
171
|
-
/**
|
|
172
|
-
export
|
|
172
|
+
/** Tries per idempotent (GET/DELETE) call whose fetch THROWS — see {@link cloudflareApi}. */
|
|
173
|
+
export const API_IDEMPOTENT_ATTEMPTS = 3;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A Cloudflare v4 API caller for `token`. `fetch` and the backoff `pause` injectable for tests.
|
|
177
|
+
*
|
|
178
|
+
* 🔴 **A GET or DELETE whose fetch throws is tried again, up to {@link API_IDEMPOTENT_ATTEMPTS}
|
|
179
|
+
* times.** On 2026-10-04 (ship run 16-39-21) desk-stage and collections-stage both died in the
|
|
180
|
+
* same second on the first secrets list — `The socket connection was closed unexpectedly` — and
|
|
181
|
+
* were left uploaded with no secrets, serving nothing. Asking again changes nothing a GET or DELETE
|
|
182
|
+
* did not already change. An HTTP status is an ANSWER and is returned as-is; a thrown POST/PUT/PATCH
|
|
183
|
+
* is never retried, because it may have landed.
|
|
184
|
+
*/
|
|
185
|
+
export function cloudflareApi(
|
|
186
|
+
token: string,
|
|
187
|
+
send: typeof fetch = fetch,
|
|
188
|
+
pause: (ms: number) => Promise<void> = (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
|
189
|
+
): CloudflareApi {
|
|
173
190
|
return async (path, init = {}) => {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
191
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
192
|
+
const attempts = method === "GET" || method === "DELETE" ? API_IDEMPOTENT_ATTEMPTS : 1;
|
|
193
|
+
let response: Response | undefined;
|
|
194
|
+
for (let tries = 1; !response; tries++) {
|
|
195
|
+
try {
|
|
196
|
+
response = await send(`https://api.cloudflare.com/client/v4${path}`, {
|
|
197
|
+
...init,
|
|
198
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
199
|
+
signal: init.signal ?? AbortSignal.timeout(30_000),
|
|
200
|
+
});
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (tries >= attempts || init.signal?.aborted) throw error;
|
|
203
|
+
await pause(1_000 * tries);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
179
206
|
return (await response.json().catch(() => ({ success: false, errors: [`HTTP ${response.status}, not JSON`] }))) as ApiResult;
|
|
180
207
|
};
|
|
181
208
|
}
|
|
@@ -248,6 +275,43 @@ export const THROWAWAY_SECRETS = {
|
|
|
248
275
|
FILE_TOKEN_PRIVATE_KEY: "FILE_TOKEN_PRIVATE_KEY",
|
|
249
276
|
} as const;
|
|
250
277
|
|
|
278
|
+
/**
|
|
279
|
+
* binary-server's delete barrier for STAGE tenants (forge tasks 2795, 2797). `DELETE /media` takes
|
|
280
|
+
* binary-server's `STAGE_DELETE_SECRET` in place of the fleet's shared secret only when the token
|
|
281
|
+
* resolves to an `<app>-stage` tenant — so a stage holding it deletes its own bytes and none of
|
|
282
|
+
* production's. Without it every delete on a stage failed: `remove requires internalSecret`.
|
|
283
|
+
*
|
|
284
|
+
* · `worker` — the name the app's binary store reads it under, the same as production's.
|
|
285
|
+
* · `file` — the key it is kept under in `<app>-worker-stage.env`, so nobody mistakes it there for
|
|
286
|
+
* the fleet secret.
|
|
287
|
+
* · `source` — where a mint copies it from: `STAGE_DELETE_SECRET` in `binary-server.env`, the sibling
|
|
288
|
+
* of the app's own `<app>.env`. A copy, never a mint: binary-server must hold the same value.
|
|
289
|
+
*/
|
|
290
|
+
export const STAGE_DELETE_SECRET = {
|
|
291
|
+
worker: "BINARY_SERVER_INTERNAL_SECRET",
|
|
292
|
+
file: "BINARY_SERVER_STAGE_DELETE_SECRET",
|
|
293
|
+
source: { file: "binary-server.env", key: "STAGE_DELETE_SECRET" },
|
|
294
|
+
} as const;
|
|
295
|
+
|
|
296
|
+
/** Every stage that deletes media on binary-server: the throwaway set, plus {@link STAGE_DELETE_SECRET}. */
|
|
297
|
+
export const STAGE_SECRETS = {
|
|
298
|
+
...THROWAWAY_SECRETS,
|
|
299
|
+
[STAGE_DELETE_SECRET.worker]: STAGE_DELETE_SECRET.file,
|
|
300
|
+
} as const;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* binary-server's stage delete secret, read from `binary-server.env` in `secretsDir`
|
|
304
|
+
* (`$FORGE_STATE/secrets`), or null when that file or the key is absent.
|
|
305
|
+
*/
|
|
306
|
+
export function readStageDeleteSecret(secretsDir: string, read: (path: string) => string | null = readIfThere): string | null {
|
|
307
|
+
const value = readEnvFile(read(join(secretsDir, STAGE_DELETE_SECRET.source.file)) ?? "")[STAGE_DELETE_SECRET.source.key]?.trim();
|
|
308
|
+
return value || null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function readIfThere(path: string): string | null {
|
|
312
|
+
return existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
251
315
|
/** What {@link runWorkerSecrets} needs to know about ONE app's ONE deployment — data, no logic. */
|
|
252
316
|
export interface WorkerSecretsSpec {
|
|
253
317
|
/** `family`, `music` — prefixes every message and names the Mac's `<app>.env`. */
|
|
@@ -311,6 +375,18 @@ export async function runWorkerSecrets(
|
|
|
311
375
|
}
|
|
312
376
|
carried[key] = value;
|
|
313
377
|
}
|
|
378
|
+
// A set that names the stage delete secret gets binary-server's own value, so a re-mint keeps
|
|
379
|
+
// the stage able to delete instead of dropping the key the upload then refuses without.
|
|
380
|
+
if (Object.values(spec.secrets).includes(STAGE_DELETE_SECRET.file)) {
|
|
381
|
+
const value = readStageDeleteSecret(dirname(spec.macSecretsFile), read);
|
|
382
|
+
if (!value) {
|
|
383
|
+
console.error(
|
|
384
|
+
`${tag} ${STAGE_DELETE_SECRET.source.file} carries no ${STAGE_DELETE_SECRET.source.key} — without it this stage cannot delete its own media.`,
|
|
385
|
+
);
|
|
386
|
+
return 2;
|
|
387
|
+
}
|
|
388
|
+
carried[STAGE_DELETE_SECRET.file] = value;
|
|
389
|
+
}
|
|
314
390
|
const who = spec.env === "stage" ? "the STAGE" : "the PREVIEW";
|
|
315
391
|
(deps.write ?? writeSecretsFile)(
|
|
316
392
|
spec.secretsFile,
|