cursedops 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -3
- package/package.json +9 -3
- package/src/serve.ts +265 -0
- package/src/smoke.ts +197 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# cursedops
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build-and-ops answers that the apps here wrote independently and identically.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
bun add cursedops
|
|
@@ -10,7 +10,8 @@ bun add cursedops
|
|
|
10
10
|
|---|---|
|
|
11
11
|
| `cursedops/roots` | finding a generation's roots, and a checkout's package root, without knowing a path |
|
|
12
12
|
| `cursedops/launchd` | installing, replacing and removing a macOS launchd user agent |
|
|
13
|
-
| `cursedops/smoke` | the scaffolding of a deployed smoke — the ledger, the fetch, the DNS hint, the exit code |
|
|
13
|
+
| `cursedops/smoke` | the scaffolding of a deployed smoke — the ledger, the fetch, the DNS hint, the exit code — and the one check no app owns: every address of a deployment serving the same built client |
|
|
14
|
+
| `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers — and the API floor: the predicates, the trailing-slash normaliser and the default 404 body that keep an unmatched `/api/...` from ever being answered with the app shell |
|
|
14
15
|
|
|
15
16
|
Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
|
|
16
17
|
name, a hostname, a port or a route.
|
|
@@ -41,6 +42,15 @@ So the entry rule here is arithmetic, not judgement:
|
|
|
41
42
|
A fourth rule follows from the first three: **an addition that only one app will use
|
|
42
43
|
does not go in.** That is a library that exists to be refactored later.
|
|
43
44
|
|
|
45
|
+
🔴 **`sameDeployedShell` is the one entry that rule 1 did not let in, and it is recorded
|
|
46
|
+
here rather than quietly excepted.** It was written once, by `patterns`, not twice —
|
|
47
|
+
what admits it is rule 3 at a size rule 1 cannot measure: the fault it catches was
|
|
48
|
+
invisible for six days on the app that had it, and the other five apps behind or moving
|
|
49
|
+
behind an edge Worker still cannot tell a working deploy from a working cache. A check
|
|
50
|
+
told ONE NAME is a check about that name; this one is about a property every deployment
|
|
51
|
+
with two addresses has. It buys the exception by being pinned to a failure it has
|
|
52
|
+
actually caught — `src/smoke.test.ts` runs it against the genuine 2026-09-12 shell.
|
|
53
|
+
|
|
44
54
|
### What was deliberately left in the apps
|
|
45
55
|
|
|
46
56
|
The comparison that produced this library found three areas that are genuinely
|
|
@@ -110,10 +120,84 @@ await guarded(smoke, "gate", async () => {
|
|
|
110
120
|
smoke.finish(); // exits 0, 1 or 2
|
|
111
121
|
```
|
|
112
122
|
|
|
113
|
-
|
|
123
|
+
One check, and it is about the ADDRESSES of a deployment rather than about any app:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { sameDeployedShell } from "cursedops/smoke";
|
|
127
|
+
|
|
128
|
+
// every address of one deployment, not "edge vs origin" — an app may have no
|
|
129
|
+
// origin left. A Worker's second address is its uncached *.workers.dev preview.
|
|
130
|
+
await sameDeployedShell(smoke, [smoke.base, `http://127.0.0.1:${port}`]);
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
🔴 It refuses when the addresses name different built assets, and it records that as an
|
|
134
|
+
**edge fault (exit 2), never a rollback** — the origin is serving what this checkout
|
|
135
|
+
built, and reverting it cannot reach the Worker, cache rule or Assets binding in front.
|
|
136
|
+
Measured 2026-09-18 on `patterns`: the edge served a shell built 2026-09-12 out of a
|
|
137
|
+
Worker nobody knew about, and every automated surface stayed green for **six days**.
|
|
138
|
+
Only same-origin `.js`/`.css` references are compared, because the zone injects a Web
|
|
139
|
+
Analytics beacon in front of the edge and not on loopback — a check that reds on a
|
|
140
|
+
healthy deploy is one somebody turns off.
|
|
141
|
+
|
|
142
|
+
Nothing else. The checks are the part `desk` and `flix` wrote differently on purpose,
|
|
114
143
|
and a shared kit that starts absorbing route lists and health-payload shapes is how the
|
|
115
144
|
last one reached 277 files.
|
|
116
145
|
|
|
146
|
+
## `cursedops/serve`
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { contentTypeFor, fileWithin, installCrashHandlers, isHashedAsset } from "cursedops/serve";
|
|
150
|
+
|
|
151
|
+
installCrashHandlers("myapp"); // one log line, then exit 1
|
|
152
|
+
const file = fileWithin(clientDir, url.pathname); // null unless it is really inside
|
|
153
|
+
if (!file) return c.notFound();
|
|
154
|
+
return new Response(Bun.file(file), {
|
|
155
|
+
headers: {
|
|
156
|
+
"content-type": contentTypeFor(file),
|
|
157
|
+
"cache-control": isHashedAsset(url.pathname) ? ONE_YEAR : "no-cache",
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
🔴 `fileWithin` is the reason this subpath exists rather than the convenience of the
|
|
163
|
+
other three. It is the check that stops `%2e%2e%2f` reaching the filesystem, and on
|
|
164
|
+
2026-09-17 there were **eight** of it — one per app, character-identical, with nothing in
|
|
165
|
+
the generation counting them. Eight copies of a security guarantee are eight guarantees:
|
|
166
|
+
a fix in one is a fix in one.
|
|
167
|
+
|
|
168
|
+
### The verdict for every symbol in the `serve.ts` family
|
|
169
|
+
|
|
170
|
+
Measured 2026-09-17 across `collections`, `family`, `flix`, `music`, `patterns`, `roms`,
|
|
171
|
+
`station` and `vault` — the eight apps that have a `src/server/serve.ts`. The files are
|
|
172
|
+
247–387 lines and all eight differ, which is why no `diff` ever flagged them; the bodies
|
|
173
|
+
below were compared with whitespace and comments normalised away.
|
|
174
|
+
|
|
175
|
+
| symbol | identical in | verdict |
|
|
176
|
+
|---|---:|---|
|
|
177
|
+
| `fileWithin` | 8 of 8 | **moved.** The traversal guard. Entry rule 3 — a trap somebody paid for — in its purest form |
|
|
178
|
+
| `installCrashHandlers` | 8 of 8 | **moved.** The app's name is a parameter; it is the only app-shaped thing the four touch |
|
|
179
|
+
| `contentTypeFor` | 8 of 8 | **moved.** Extension → MIME, plus its private `CONTENT_TYPES` table, which was also 8 of 8 |
|
|
180
|
+
| `isHashedAsset` | 8 of 8 | **moved.** Vite's own output convention, which is the bundler's identity and not the app's |
|
|
181
|
+
| `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 |
|
|
182
|
+
| `createServer` / `createApp` | 0 — all eight differ | **stays.** Route tables, health payloads, gates and body limits. This is the 277-file mistake's front door |
|
|
183
|
+
| `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 |
|
|
184
|
+
| `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 |
|
|
185
|
+
|
|
186
|
+
🔴 **The task that ordered this move recorded `contentTypeFor` and `isHashedAsset` as
|
|
187
|
+
7 of 8, with `station` differing. They did not differ.** All four bodies were identical
|
|
188
|
+
in all eight apps when the move was made, so nothing had to converge and no app's
|
|
189
|
+
behaviour changed on adoption. The count came from a census of the whole `serve.ts`
|
|
190
|
+
family rather than of these two bodies; `bun tools/check-copies.ts` in the generation
|
|
191
|
+
repo is the live answer either way.
|
|
192
|
+
|
|
193
|
+
One behaviour is NOT a byte-for-byte carry, and `src/serve.ts`'s header argues it in
|
|
194
|
+
full: the eight copies checked containment lexically and were therefore blind to a
|
|
195
|
+
symlink under the root pointing out of it. `fileWithin` here resolves the real path of
|
|
196
|
+
**both** sides after the lexical check, which refuses the escape while leaving every
|
|
197
|
+
input the eight copies refused refused, at the same cost. A symlinked ancestor of the
|
|
198
|
+
root cancels out — which is the failure a one-sided `realpath` would have shipped to
|
|
199
|
+
every app on this machine, and is its own test.
|
|
200
|
+
|
|
117
201
|
## Verifying
|
|
118
202
|
|
|
119
203
|
```sh
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "The
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots without knowing a path, macOS launchd agent install/replace/remove, the scaffolding of a deployed smoke, and the static-serving helpers eight apps copied — the path-traversal guard and the API floor among them. Mechanism only — no app knows its name from here. Bun, zero dependencies, ships source.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
8
8
|
"lint": "biome check .",
|
|
9
9
|
"test": "bun test src",
|
|
10
|
-
"paths": "bun
|
|
10
|
+
"paths": "bun run scripts/paths.ts",
|
|
11
11
|
"verify": "bun run paths && bun run typecheck && bun run lint && bun run test",
|
|
12
12
|
"prepublishOnly": "bun run verify"
|
|
13
13
|
},
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
"source": "./src/launchd.ts",
|
|
25
25
|
"import": "./src/launchd.ts"
|
|
26
26
|
},
|
|
27
|
+
"./serve": {
|
|
28
|
+
"types": "./src/serve.ts",
|
|
29
|
+
"bun": "./src/serve.ts",
|
|
30
|
+
"source": "./src/serve.ts",
|
|
31
|
+
"import": "./src/serve.ts"
|
|
32
|
+
},
|
|
27
33
|
"./smoke": {
|
|
28
34
|
"types": "./src/smoke.ts",
|
|
29
35
|
"bun": "./src/smoke.ts",
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The helpers every app's `src/server/serve.ts` wrote the same way — the
|
|
3
|
+
* path-traversal guard among them, and the API floor.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is a library and not eight copies
|
|
6
|
+
*
|
|
7
|
+
* Measured 2026-09-17 by the apps maintenance sweep, and again by this move before
|
|
8
|
+
* it started: `collections`, `family`, `flix`, `music`, `patterns`, `roms`,
|
|
9
|
+
* `station` and `vault` each have a `src/server/serve.ts`. The FILES differ — 247
|
|
10
|
+
* to 387 lines, eight distinct versions, which is why no `diff` ever flagged them —
|
|
11
|
+
* but these four bodies are character-identical in **eight of eight**, with the
|
|
12
|
+
* whitespace and comments normalised away.
|
|
13
|
+
*
|
|
14
|
+
* 🔴 **`fileWithin` is why this was urgent rather than tidy.** It is the check that
|
|
15
|
+
* stops `%2e%2e%2f` reaching the filesystem. Eight copies of a security guarantee
|
|
16
|
+
* are eight guarantees: a fix in one is a fix in one, and the other seven apps keep
|
|
17
|
+
* the hole. That is the arithmetic the entry rule in `../README.md` asks for —
|
|
18
|
+
* rule 1 (two apps wrote it independently) answered eight times over, rule 2
|
|
19
|
+
* (mechanism, not identity — nothing here knows an app's name, port, hostname or
|
|
20
|
+
* routes; `installCrashHandlers` is HANDED the name), and rule 3 (a trap somebody
|
|
21
|
+
* paid for — a traversal guard is the definition of one).
|
|
22
|
+
*
|
|
23
|
+
* `clientDirOf` deliberately did NOT come with them. It is one line, six apps pin
|
|
24
|
+
* it with their own `clientDir.test.ts` against their own `vite.config.ts`, and
|
|
25
|
+
* where an app's build writes is app identity.
|
|
26
|
+
*
|
|
27
|
+
* `tools/check-copies.baseline` in the generation repo carried these four at
|
|
28
|
+
* `8 <symbol> src/server/serve.ts` and is the census that will notice if a copy
|
|
29
|
+
* comes back.
|
|
30
|
+
*
|
|
31
|
+
* ## 🔴 The one behaviour that is NOT a byte-for-byte carry
|
|
32
|
+
*
|
|
33
|
+
* The eight copies resolved containment LEXICALLY — `resolve(join(root, …))` and a
|
|
34
|
+
* `startsWith` — which is correct for `..` and for `%2e%2e%2f`, and blind to a
|
|
35
|
+
* symlink inside the root that points out of it. {@link fileWithin} now resolves
|
|
36
|
+
* the real path of both sides before comparing, so a symlink cannot walk out
|
|
37
|
+
* either. It is a strict tightening, not a change of contract:
|
|
38
|
+
*
|
|
39
|
+
* - the lexical check runs FIRST and unchanged, so every input the eight copies
|
|
40
|
+
* refused is still refused, at the same cost;
|
|
41
|
+
* - a symlinked ANCESTOR of the root cancels out, because the root is resolved
|
|
42
|
+
* the same way — the failure mode a naive `realpath` on one side only would
|
|
43
|
+
* have introduced, and the reason this is not one line;
|
|
44
|
+
* - every caller today passes a Vite `dist/`, and all eight were checked for
|
|
45
|
+
* symlinks on 2026-09-17: there are none. So no app's behaviour moves, and the
|
|
46
|
+
* day one of them serves a directory it did not build, the guard already holds.
|
|
47
|
+
*
|
|
48
|
+
* `serve.test.ts` drives both halves, including the symlink escape, because a guard
|
|
49
|
+
* that has only ever been seen passing is a guard nobody has proved can fail.
|
|
50
|
+
*
|
|
51
|
+
* ## 🔴 The API floor, added 2026-09-18 — and why it is here rather than in an app
|
|
52
|
+
*
|
|
53
|
+
* The second family in this file, and it arrives by the same arithmetic. `station` and
|
|
54
|
+
* `patterns` each wrote a floor — a rule that an unmatched `/api/...` is answered with a
|
|
55
|
+
* phrase and never with the single-page shell — independently, in different shapes, for
|
|
56
|
+
* the same incident. That is entry rule 1 twice over. Rule 2 holds because nothing below
|
|
57
|
+
* looks anything up: the app's name, its commit and the body it chooses to send all
|
|
58
|
+
* arrive as arguments, and there is no Hono type in this file, so the three lines of
|
|
59
|
+
* `app.all(...)` wiring stay in the app that owns the router. Rule 3 is the measurement:
|
|
60
|
+
*
|
|
61
|
+
* > 2026-09-17. `station`'s Openclaw page had never loaded on any commit. The client
|
|
62
|
+
* > asked for `/api/openclaw/`; Hono mounts that sub-app's `get("/")` at
|
|
63
|
+
* > `/api/openclaw` — `mergePath("/api/openclaw", "/")` drops the trailing slash — and
|
|
64
|
+
* > then matches strictly, so the request matched nothing, fell into `app.get("*")`,
|
|
65
|
+
* > and came back as 1.7 KB of `index.html` with a **200**. The client's honest reading
|
|
66
|
+
* > of an HTML body was *"that is what an API older than this page looks like — deploy
|
|
67
|
+
* > this commit and reload"*, which sent the owner at a deploy that could not have
|
|
68
|
+
* > helped.
|
|
69
|
+
*
|
|
70
|
+
* Both halves of that are here, because a floor without the normaliser fixes the symptom
|
|
71
|
+
* the owner did not have: {@link canonicalApiPath} makes the trailing slash a non-event,
|
|
72
|
+
* and {@link isApiPath} + {@link apiNotFoundBody} make every remaining miss a JSON 404
|
|
73
|
+
* that names the route, the app and the commit answering. A wrong path says "wrong path";
|
|
74
|
+
* a stale process says which commit it is.
|
|
75
|
+
*
|
|
76
|
+
* 🔴 **{@link apiNotFoundBody} is the DEFAULT body, not the only one.** `patterns` sends
|
|
77
|
+
* `{status, code, error}` through its own `reply.refuse` and deliberately says less —
|
|
78
|
+
* its floor faces the public internet, where the app's name and commit are one bit more
|
|
79
|
+
* than a scanner should get. An app that knows something this library cannot keeps its
|
|
80
|
+
* own body and takes the two predicates; that is the `cwip/asset-budget` shape, and it is
|
|
81
|
+
* what stops this growing into the kit it replaced.
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
85
|
+
import { join, normalize, resolve, sep } from "node:path";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Extension → content type, for what a built single-page client actually contains.
|
|
89
|
+
*
|
|
90
|
+
* Not a full MIME database on purpose: an app serving a type that is not here is an
|
|
91
|
+
* app serving something its build did not produce, and `application/octet-stream`
|
|
92
|
+
* is the honest answer to that rather than a guess.
|
|
93
|
+
*/
|
|
94
|
+
const CONTENT_TYPES: Readonly<Record<string, string>> = {
|
|
95
|
+
html: "text/html; charset=utf-8",
|
|
96
|
+
js: "text/javascript; charset=utf-8",
|
|
97
|
+
mjs: "text/javascript; charset=utf-8",
|
|
98
|
+
css: "text/css; charset=utf-8",
|
|
99
|
+
json: "application/json; charset=utf-8",
|
|
100
|
+
svg: "image/svg+xml",
|
|
101
|
+
png: "image/png",
|
|
102
|
+
jpg: "image/jpeg",
|
|
103
|
+
jpeg: "image/jpeg",
|
|
104
|
+
webp: "image/webp",
|
|
105
|
+
avif: "image/avif",
|
|
106
|
+
ico: "image/x-icon",
|
|
107
|
+
woff: "font/woff",
|
|
108
|
+
woff2: "font/woff2",
|
|
109
|
+
txt: "text/plain; charset=utf-8",
|
|
110
|
+
map: "application/json; charset=utf-8",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** The `content-type` for a path, by extension — `application/octet-stream` if unknown. */
|
|
114
|
+
export const contentTypeFor = (path: string): string =>
|
|
115
|
+
CONTENT_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "application/octet-stream";
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Vite's own output convention: `name-<8+ hex/base64url chars>.ext` under
|
|
119
|
+
* `/assets/`. Only these get the immutable year.
|
|
120
|
+
*/
|
|
121
|
+
export const isHashedAsset = (urlPath: string): boolean =>
|
|
122
|
+
/^\/assets\/[^/]+-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/.test(urlPath);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The file `urlPath` names inside `root`, or `null`.
|
|
126
|
+
*
|
|
127
|
+
* 🔴 The containment check is the point. `decodeURIComponent` then `normalize`
|
|
128
|
+
* collapses `..`, and the result must still start with the root — otherwise
|
|
129
|
+
* `/assets/..%2f..%2f..%2fetc%2fpasswd` reads whatever the process can. A
|
|
130
|
+
* directory, a missing file and a malformed escape are all `null`; a caller that
|
|
131
|
+
* gets a string has a regular file it may open.
|
|
132
|
+
*
|
|
133
|
+
* The final `realpath` pass is the one thing the eight app copies did not do — see
|
|
134
|
+
* the module header for why it cannot break a caller.
|
|
135
|
+
*/
|
|
136
|
+
export function fileWithin(root: string, urlPath: string): string | null {
|
|
137
|
+
let decoded: string;
|
|
138
|
+
try {
|
|
139
|
+
decoded = decodeURIComponent(urlPath);
|
|
140
|
+
} catch {
|
|
141
|
+
return null; // a malformed escape is not a path
|
|
142
|
+
}
|
|
143
|
+
if (decoded.includes("\0")) return null;
|
|
144
|
+
const full = resolve(join(root, normalize(decoded)));
|
|
145
|
+
const base = resolve(root);
|
|
146
|
+
if (full !== base && !full.startsWith(base + sep)) return null;
|
|
147
|
+
if (!existsSync(full) || !statSync(full).isFile()) return null;
|
|
148
|
+
// Lexically inside, and the file is there. Now the same question of the DISK: a
|
|
149
|
+
// symlink under the root is the one way a path can pass the check above and
|
|
150
|
+
// still read something outside. Both sides, or a symlinked ancestor of the root
|
|
151
|
+
// would refuse every file it holds.
|
|
152
|
+
let realFull: string;
|
|
153
|
+
let realBase: string;
|
|
154
|
+
try {
|
|
155
|
+
realFull = realpathSync(full);
|
|
156
|
+
realBase = realpathSync(base);
|
|
157
|
+
} catch {
|
|
158
|
+
return null; // it existed a line ago; whatever it is now, do not serve it
|
|
159
|
+
}
|
|
160
|
+
if (realFull !== realBase && !realFull.startsWith(realBase + sep)) return null;
|
|
161
|
+
return full;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Say what killed the process, once, before it goes.
|
|
166
|
+
*
|
|
167
|
+
* The whole value is the log line. An app holding SQLite handles that Bun closes on
|
|
168
|
+
* exit and nothing else in flight has nothing to drain — what was missing in the
|
|
169
|
+
* previous generation was not cleanup, it was ever finding out why a long-running
|
|
170
|
+
* process had stopped.
|
|
171
|
+
*
|
|
172
|
+
* 🔴 `name` is a parameter rather than anything this library could look up. That is
|
|
173
|
+
* the whole of rule 2: the only app-shaped thing these four helpers touch arrives
|
|
174
|
+
* as an argument.
|
|
175
|
+
*/
|
|
176
|
+
/** The API namespace. See the module header: nothing under it may answer with the shell. */
|
|
177
|
+
export const API_PREFIX = "/api";
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Is this path inside the API namespace? `/api` itself counts; `/apiary` does not.
|
|
181
|
+
*
|
|
182
|
+
* 🔴 The namespace ITSELF is the case every hand-rolled floor missed. A Hono
|
|
183
|
+
* `app.all("/api/*")` does not match a bare `/api` — the pattern requires the slash — so
|
|
184
|
+
* the one path most likely to be typed by hand fell through to the shell in the two apps
|
|
185
|
+
* that already believed they had a floor.
|
|
186
|
+
*/
|
|
187
|
+
export const isApiPath = (urlPath: string): boolean =>
|
|
188
|
+
urlPath === API_PREFIX || urlPath.startsWith(`${API_PREFIX}/`);
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The path an API request SHOULD have asked for, or `null` when it already did.
|
|
192
|
+
*
|
|
193
|
+
* 🔴 One rule, and it is the one that broke the Openclaw page: a trailing slash on an API
|
|
194
|
+
* path is never meaningful. Hono mounts a sub-app's `get("/")` at the mount point WITHOUT
|
|
195
|
+
* a trailing slash and then matches strictly, so `/api/openclaw/` is a different path
|
|
196
|
+
* from `/api/openclaw` and matched nothing at all. Normalising once, in front of the
|
|
197
|
+
* router, means no caller anywhere can spell it the losing way again — which a fix in the
|
|
198
|
+
* one client module that happened to do it would not have bought.
|
|
199
|
+
*
|
|
200
|
+
* The query string is untouched: only the path is rewritten.
|
|
201
|
+
*/
|
|
202
|
+
export function canonicalApiPath(urlPath: string): string | null {
|
|
203
|
+
if (!isApiPath(urlPath) || !urlPath.endsWith("/")) return null;
|
|
204
|
+
const trimmed = urlPath.replace(/\/+$/, "");
|
|
205
|
+
return trimmed === urlPath ? null : trimmed;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The same normalisation, applied to a whole request. Returns the request unchanged when
|
|
210
|
+
* there is nothing to normalise, so the hot path allocates nothing.
|
|
211
|
+
*
|
|
212
|
+
* 🔴 Wrap it around the ONE function every response leaves through — the `fetch` a host
|
|
213
|
+
* hands `Bun.serve` or exports from a Worker — and never as a Hono middleware. Hono runs
|
|
214
|
+
* middleware in registration order against handlers registered AFTER it, and an app's
|
|
215
|
+
* routes are mounted before the host is built; a middleware added at that point runs for
|
|
216
|
+
* none of them. It also cannot change which route matches, which is the entire job here.
|
|
217
|
+
*/
|
|
218
|
+
export function canonicalApiRequest(request: Request): Request {
|
|
219
|
+
const url = new URL(request.url);
|
|
220
|
+
const canonical = canonicalApiPath(url.pathname);
|
|
221
|
+
if (canonical === null) return request;
|
|
222
|
+
url.pathname = canonical;
|
|
223
|
+
return new Request(url, request);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* What an unmatched API path answers — a JSON 404 that names itself.
|
|
228
|
+
*
|
|
229
|
+
* 🔴 The commit is in the body on purpose. The two reasons a route is missing are "this
|
|
230
|
+
* path never existed" and "this process is older than the page that asked", and they need
|
|
231
|
+
* different hands; without the commit a client can only guess, and the guess one printed
|
|
232
|
+
* for the owner sent him to deploy a commit that would not have fixed anything.
|
|
233
|
+
*
|
|
234
|
+
* 🔴 Safe to publish only where the floor sits BEHIND the app's gate, which is where every
|
|
235
|
+
* caller puts it — an unauthenticated request is answered 401 by the gate, matched or not.
|
|
236
|
+
* An app whose API namespace is open sends its own, terser body instead; see the module
|
|
237
|
+
* header.
|
|
238
|
+
*/
|
|
239
|
+
export function apiNotFoundBody(
|
|
240
|
+
method: string,
|
|
241
|
+
urlPath: string,
|
|
242
|
+
app: string,
|
|
243
|
+
commit: string | null | undefined,
|
|
244
|
+
): { error: string; route: string; app: string; commit: string | null } {
|
|
245
|
+
return {
|
|
246
|
+
error:
|
|
247
|
+
`no such route: ${method} ${urlPath}. ${app} is serving ${commit ?? "an unreadable commit"} ` +
|
|
248
|
+
"and has no such API route — either the path is wrong, or this process is older than the page " +
|
|
249
|
+
"that asked for it. It is NOT the app shell: nothing under /api/ is ever answered with HTML.",
|
|
250
|
+
route: `${method} ${urlPath}`,
|
|
251
|
+
app,
|
|
252
|
+
commit: commit ?? null,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function installCrashHandlers(name: string): void {
|
|
257
|
+
process.on("uncaughtException", (error) => {
|
|
258
|
+
console.error(`[${name}] uncaught exception — exiting`, error);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
});
|
|
261
|
+
process.on("unhandledRejection", (reason) => {
|
|
262
|
+
console.error(`[${name}] unhandled rejection — exiting`, reason);
|
|
263
|
+
process.exit(1);
|
|
264
|
+
});
|
|
265
|
+
}
|
package/src/smoke.ts
CHANGED
|
@@ -39,6 +39,22 @@
|
|
|
39
39
|
* exactly where it is. {@link Smoke.finish} is the single place that code is
|
|
40
40
|
* decided, so the two apps reading it cannot disagree about what a `2` meant.
|
|
41
41
|
*
|
|
42
|
+
* ## The ONE check that lives here, and why it is not an app's to write
|
|
43
|
+
*
|
|
44
|
+
* {@link sameDeployedShell} is the exception to "no checks", and the paragraph
|
|
45
|
+
* above is its argument rather than an argument against it: every incident in that
|
|
46
|
+
* list is *the deploy said yes and the visitor got something else*, and asking one
|
|
47
|
+
* address can never see that. It is a check about the ADDRESSES of a deployment,
|
|
48
|
+
* which every app has and no app owns — not about any app's routes, payloads or
|
|
49
|
+
* titles, which stay out.
|
|
50
|
+
*
|
|
51
|
+
* Measured 2026-09-18 on `patterns`: the origin served the shell this checkout
|
|
52
|
+
* built, `patterns.cursedalchemy.com` served one built on 2026-09-12 out of a
|
|
53
|
+
* Worker nobody knew about, and **every automated surface was green for six days.**
|
|
54
|
+
* The app-local implementation that found it — `apps/patterns/scripts/worker-smoke.ts`,
|
|
55
|
+
* `CROSS_CHECKS` — is what this generalises, because five more apps are moving
|
|
56
|
+
* behind an edge Worker and a check told ONE NAME is a check about that name.
|
|
57
|
+
*
|
|
42
58
|
* ## What this module will never grow
|
|
43
59
|
*
|
|
44
60
|
* No route lists, no health-payload shapes, no upload sizes, no titles. Those are
|
|
@@ -224,3 +240,184 @@ export async function guarded(smoke: Smoke, check: string, body: () => Promise<v
|
|
|
224
240
|
smoke.record(check, false, `could not complete against ${smoke.base}: ${failed.message}${smoke.dnsHint(failed)}`);
|
|
225
241
|
}
|
|
226
242
|
}
|
|
243
|
+
|
|
244
|
+
/** What one address said when it was asked for the shell. */
|
|
245
|
+
export interface AddressShell {
|
|
246
|
+
/** The address as it was passed in. */
|
|
247
|
+
address: string;
|
|
248
|
+
/** Same-origin built files the shell references, de-duplicated and sorted. */
|
|
249
|
+
assets: string[];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface ShellAgreement {
|
|
253
|
+
/** True only when every address named the same non-empty set of built files. */
|
|
254
|
+
agreed: boolean;
|
|
255
|
+
/** One line naming which address served what — the whole point on a failure. */
|
|
256
|
+
detail: string;
|
|
257
|
+
shells: AddressShell[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface ShellOptions {
|
|
261
|
+
/** The document to compare. The shell, so `/` unless an app's entry is elsewhere. */
|
|
262
|
+
path?: string;
|
|
263
|
+
/** Per-request ceiling, matching {@link SmokeOptions.timeoutMs}'s default. */
|
|
264
|
+
timeoutMs?: number;
|
|
265
|
+
/** The cache-busting query value. Injected so a test can be deterministic. */
|
|
266
|
+
cacheBust?: () => string;
|
|
267
|
+
/** Injected for tests. Defaults to the global `fetch`. */
|
|
268
|
+
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* `src="…"` and `href="…"`, either quote. Attribute scan, not a parser — see
|
|
273
|
+
* {@link builtAssetsIn}. The leading whitespace is what keeps `data-src` and
|
|
274
|
+
* `xlink:href` out of the comparison.
|
|
275
|
+
*/
|
|
276
|
+
const REFERENCE = /\s(?:src|href)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
|
|
277
|
+
/** What a bundler emits and a shell names. Extensions, because hashes are not universal. */
|
|
278
|
+
const BUILT_FILE = /\.(?:js|mjs|cjs|css)$/i;
|
|
279
|
+
|
|
280
|
+
function hostOf(address: string): string | null {
|
|
281
|
+
try {
|
|
282
|
+
return new URL(address).host;
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The built files a shell references, as paths — the stable part of an HTML body.
|
|
290
|
+
*
|
|
291
|
+
* 🔴 **Same-origin only, and that is load-bearing rather than tidy.** The two
|
|
292
|
+
* addresses of one deployment legitimately differ in what a third party injected:
|
|
293
|
+
* on the app this check was built for, the zone's Web Analytics Automatic Setup
|
|
294
|
+
* appends `static.cloudflareinsights.com/beacon.min.js` at the edge and not on
|
|
295
|
+
* loopback. Comparing bodies, or comparing every `.js` a shell names, fails on that
|
|
296
|
+
* every time — a check that reds on a healthy deploy is one somebody turns off.
|
|
297
|
+
*
|
|
298
|
+
* Nothing here parses HTML: a bundler-emitted shell is machine-written, and the
|
|
299
|
+
* comparison only has to be STABLE between two copies of the same one.
|
|
300
|
+
*/
|
|
301
|
+
export function builtAssetsIn(html: string, address: string): string[] {
|
|
302
|
+
const host = hostOf(address);
|
|
303
|
+
const found = new Set<string>();
|
|
304
|
+
for (const match of html.matchAll(REFERENCE)) {
|
|
305
|
+
const raw = (match[1] ?? match[2] ?? "").trim();
|
|
306
|
+
if (!raw) continue;
|
|
307
|
+
let url: URL;
|
|
308
|
+
try {
|
|
309
|
+
url = new URL(raw, address);
|
|
310
|
+
} catch {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (url.host !== host) continue;
|
|
314
|
+
if (!BUILT_FILE.test(url.pathname)) continue;
|
|
315
|
+
found.add(url.pathname);
|
|
316
|
+
}
|
|
317
|
+
return [...found].sort();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Ask every address of ONE deployment for the shell, and say whether they agree.
|
|
322
|
+
*
|
|
323
|
+
* 🔴 The assertion is **"every address agrees"**, not "the edge matches the
|
|
324
|
+
* origin" — which is the shape that survives an app having no origin left to
|
|
325
|
+
* compare against, and the reason this takes a list rather than a pair. Two is the
|
|
326
|
+
* usual list (the public hostname and `http://127.0.0.1:<port>`); a Worker's is the
|
|
327
|
+
* custom domain and its `*.workers.dev` preview, because a preview URL is not
|
|
328
|
+
* behind the cache.
|
|
329
|
+
*
|
|
330
|
+
* Every request is cache-busted, because the question is what that address HAS,
|
|
331
|
+
* not what it hands a repeat visitor.
|
|
332
|
+
*/
|
|
333
|
+
export async function compareDeployedShells(addresses: string[], options: ShellOptions = {}): Promise<ShellAgreement> {
|
|
334
|
+
const path = options.path ?? "/";
|
|
335
|
+
const timeoutMs = options.timeoutMs ?? 20_000;
|
|
336
|
+
const cacheBust = options.cacheBust ?? (() => Math.random().toString(36).slice(2));
|
|
337
|
+
const send = options.fetch ?? ((url: string, init: RequestInit) => fetch(url, init));
|
|
338
|
+
|
|
339
|
+
// 🔴 Loud, not silent. One address cannot disagree with itself, so a run that
|
|
340
|
+
// quietly passed here would be a skip wearing a pass — the exact reading that
|
|
341
|
+
// let a six-day-old shell sit behind six green surfaces.
|
|
342
|
+
if (addresses.length < 2) {
|
|
343
|
+
return {
|
|
344
|
+
agreed: false,
|
|
345
|
+
detail:
|
|
346
|
+
`needs at least two addresses of the same deployment and got ${addresses.length}` +
|
|
347
|
+
` (${addresses.join(", ") || "none"}) — one address cannot disagree with itself,` +
|
|
348
|
+
" so there is nothing here that could have failed.",
|
|
349
|
+
shells: [],
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const shells: AddressShell[] = [];
|
|
354
|
+
const problems: string[] = [];
|
|
355
|
+
for (const address of addresses) {
|
|
356
|
+
const url = new URL(path, address);
|
|
357
|
+
url.searchParams.set("cb", cacheBust());
|
|
358
|
+
// 🔴 Re-thrown WITH the address and with the original message intact: which
|
|
359
|
+
// of the addresses could not be reached is the whole diagnosis, and
|
|
360
|
+
// {@link Smoke.dnsHint} still has to recognise an ENOTFOUND inside it.
|
|
361
|
+
const response = await send(url.toString(), {
|
|
362
|
+
redirect: "manual",
|
|
363
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
364
|
+
headers: { accept: "text/html" },
|
|
365
|
+
}).catch((thrown: unknown) => {
|
|
366
|
+
const failed = thrown as Error;
|
|
367
|
+
throw new Error(`${address} could not be reached — ${failed.message}`);
|
|
368
|
+
});
|
|
369
|
+
if (response.status !== 200) {
|
|
370
|
+
problems.push(`${address} answered ${response.status} for ${path}`);
|
|
371
|
+
shells.push({ address, assets: [] });
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const assets = builtAssetsIn(await response.text(), address);
|
|
375
|
+
if (assets.length === 0) problems.push(`${address} served a shell naming no built assets`);
|
|
376
|
+
shells.push({ address, assets });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const named = (shell: AddressShell) => `${shell.address} → ${shell.assets.join(", ") || "(none)"}`;
|
|
380
|
+
if (problems.length > 0) {
|
|
381
|
+
return { agreed: false, detail: `${problems.join("; ")} — ${shells.map(named).join(" | ")}`, shells };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const distinct = new Set(shells.map((shell) => shell.assets.join(",")));
|
|
385
|
+
if (distinct.size > 1) {
|
|
386
|
+
return {
|
|
387
|
+
agreed: false,
|
|
388
|
+
detail:
|
|
389
|
+
`the addresses of one deployment serve DIFFERENT built assets — ${shells.map(named).join(" | ")}.` +
|
|
390
|
+
" Something in front of this app is answering with bytes this checkout did not build.",
|
|
391
|
+
shells,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return { agreed: true, detail: `${shells.length} addresses agree — ${shells[0]?.assets.join(", ")}`, shells };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The check's name in the ledger. Exported so a caller can talk about its own result. */
|
|
399
|
+
export const SAME_SHELL_CHECK = "every address serves the same built client";
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* {@link compareDeployedShells}, recorded — and an EDGE fault when it disagrees.
|
|
403
|
+
*
|
|
404
|
+
* 🔴 **Code 2, deliberately: a disagreement is never a reason to roll back.** The
|
|
405
|
+
* origin in that comparison is serving what this checkout built; what is wrong sits
|
|
406
|
+
* in FRONT of it — a shadowing Worker, a cache rule, a stale Assets binding — and
|
|
407
|
+
* reverting the application code cannot reach any of them. It would discard the
|
|
408
|
+
* good deploy and leave the visitor on the same old bytes, which is the failure the
|
|
409
|
+
* exit-code contract at the top of this file exists to prevent.
|
|
410
|
+
*
|
|
411
|
+
* ```ts
|
|
412
|
+
* await sameDeployedShell(smoke, [smoke.base, `http://127.0.0.1:${port}`]);
|
|
413
|
+
* ```
|
|
414
|
+
*/
|
|
415
|
+
export async function sameDeployedShell(smoke: Smoke, addresses: string[], options: ShellOptions = {}): Promise<boolean> {
|
|
416
|
+
let agreed = false;
|
|
417
|
+
await guarded(smoke, SAME_SHELL_CHECK, async () => {
|
|
418
|
+
const agreement = await compareDeployedShells(addresses, options);
|
|
419
|
+
agreed = smoke.record(SAME_SHELL_CHECK, agreement.agreed, agreement.detail);
|
|
420
|
+
if (!agreement.agreed) smoke.markEdgeFault();
|
|
421
|
+
});
|
|
422
|
+
return agreed;
|
|
423
|
+
}
|