cursedops 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +57 -1
  2. package/package.json +8 -2
  3. package/src/serve.ts +153 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # cursedops
2
2
 
3
- Three build-and-ops answers that two apps independently wrote the same way.
3
+ Build-and-ops answers that the apps here wrote independently and identically.
4
4
 
5
5
  ```sh
6
6
  bun add cursedops
@@ -11,6 +11,7 @@ bun add cursedops
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
13
  | `cursedops/smoke` | the scaffolding of a deployed smoke — the ledger, the fetch, the DNS hint, the exit code |
14
+ | `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers |
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.
@@ -114,6 +115,61 @@ No checks, ever. The checks are the part `desk` and `flix` wrote differently on
114
115
  and a shared kit that starts absorbing route lists and health-payload shapes is how the
115
116
  last one reached 277 files.
116
117
 
118
+ ## `cursedops/serve`
119
+
120
+ ```ts
121
+ import { contentTypeFor, fileWithin, installCrashHandlers, isHashedAsset } from "cursedops/serve";
122
+
123
+ installCrashHandlers("myapp"); // one log line, then exit 1
124
+ const file = fileWithin(clientDir, url.pathname); // null unless it is really inside
125
+ if (!file) return c.notFound();
126
+ return new Response(Bun.file(file), {
127
+ headers: {
128
+ "content-type": contentTypeFor(file),
129
+ "cache-control": isHashedAsset(url.pathname) ? ONE_YEAR : "no-cache",
130
+ },
131
+ });
132
+ ```
133
+
134
+ 🔴 `fileWithin` is the reason this subpath exists rather than the convenience of the
135
+ other three. It is the check that stops `%2e%2e%2f` reaching the filesystem, and on
136
+ 2026-09-17 there were **eight** of it — one per app, character-identical, with nothing in
137
+ the generation counting them. Eight copies of a security guarantee are eight guarantees:
138
+ a fix in one is a fix in one.
139
+
140
+ ### The verdict for every symbol in the `serve.ts` family
141
+
142
+ Measured 2026-09-17 across `collections`, `family`, `flix`, `music`, `patterns`, `roms`,
143
+ `station` and `vault` — the eight apps that have a `src/server/serve.ts`. The files are
144
+ 247–387 lines and all eight differ, which is why no `diff` ever flagged them; the bodies
145
+ below were compared with whitespace and comments normalised away.
146
+
147
+ | symbol | identical in | verdict |
148
+ |---|---:|---|
149
+ | `fileWithin` | 8 of 8 | **moved.** The traversal guard. Entry rule 3 — a trap somebody paid for — in its purest form |
150
+ | `installCrashHandlers` | 8 of 8 | **moved.** The app's name is a parameter; it is the only app-shaped thing the four touch |
151
+ | `contentTypeFor` | 8 of 8 | **moved.** Extension → MIME, plus its private `CONTENT_TYPES` table, which was also 8 of 8 |
152
+ | `isHashedAsset` | 8 of 8 | **moved.** Vite's own output convention, which is the bundler's identity and not the app's |
153
+ | `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 |
154
+ | `createServer` / `createApp` | 0 — all eight differ | **stays.** Route tables, health payloads, gates and body limits. This is the 277-file mistake's front door |
155
+ | `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 |
156
+ | `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 |
157
+
158
+ 🔴 **The task that ordered this move recorded `contentTypeFor` and `isHashedAsset` as
159
+ 7 of 8, with `station` differing. They did not differ.** All four bodies were identical
160
+ in all eight apps when the move was made, so nothing had to converge and no app's
161
+ behaviour changed on adoption. The count came from a census of the whole `serve.ts`
162
+ family rather than of these two bodies; `bun tools/check-copies.ts` in the generation
163
+ repo is the live answer either way.
164
+
165
+ One behaviour is NOT a byte-for-byte carry, and `src/serve.ts`'s header argues it in
166
+ full: the eight copies checked containment lexically and were therefore blind to a
167
+ symlink under the root pointing out of it. `fileWithin` here resolves the real path of
168
+ **both** sides after the lexical check, which refuses the escape while leaving every
169
+ input the eight copies refused refused, at the same cost. A symlinked ancestor of the
170
+ root cancels out — which is the failure a one-sided `realpath` would have shipped to
171
+ every app on this machine, and is its own test.
172
+
117
173
  ## Verifying
118
174
 
119
175
  ```sh
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.1.0",
4
- "description": "The three build-and-ops answers two apps independently wrote the same way: finding a generation's roots without knowing a path, macOS launchd agent install/replace/remove, and the scaffolding of a deployed smoke. Mechanism only — no app knows its name from here. Bun, zero dependencies, ships source.",
3
+ "version": "0.2.0",
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 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",
@@ -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,153 @@
1
+ /**
2
+ * The four helpers every app's `src/server/serve.ts` wrote the same way — the
3
+ * path-traversal guard among them.
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
+
52
+ import { existsSync, realpathSync, statSync } from "node:fs";
53
+ import { join, normalize, resolve, sep } from "node:path";
54
+
55
+ /**
56
+ * Extension → content type, for what a built single-page client actually contains.
57
+ *
58
+ * Not a full MIME database on purpose: an app serving a type that is not here is an
59
+ * app serving something its build did not produce, and `application/octet-stream`
60
+ * is the honest answer to that rather than a guess.
61
+ */
62
+ const CONTENT_TYPES: Readonly<Record<string, string>> = {
63
+ html: "text/html; charset=utf-8",
64
+ js: "text/javascript; charset=utf-8",
65
+ mjs: "text/javascript; charset=utf-8",
66
+ css: "text/css; charset=utf-8",
67
+ json: "application/json; charset=utf-8",
68
+ svg: "image/svg+xml",
69
+ png: "image/png",
70
+ jpg: "image/jpeg",
71
+ jpeg: "image/jpeg",
72
+ webp: "image/webp",
73
+ avif: "image/avif",
74
+ ico: "image/x-icon",
75
+ woff: "font/woff",
76
+ woff2: "font/woff2",
77
+ txt: "text/plain; charset=utf-8",
78
+ map: "application/json; charset=utf-8",
79
+ };
80
+
81
+ /** The `content-type` for a path, by extension — `application/octet-stream` if unknown. */
82
+ export const contentTypeFor = (path: string): string =>
83
+ CONTENT_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "application/octet-stream";
84
+
85
+ /**
86
+ * Vite's own output convention: `name-<8+ hex/base64url chars>.ext` under
87
+ * `/assets/`. Only these get the immutable year.
88
+ */
89
+ export const isHashedAsset = (urlPath: string): boolean =>
90
+ /^\/assets\/[^/]+-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/.test(urlPath);
91
+
92
+ /**
93
+ * The file `urlPath` names inside `root`, or `null`.
94
+ *
95
+ * 🔴 The containment check is the point. `decodeURIComponent` then `normalize`
96
+ * collapses `..`, and the result must still start with the root — otherwise
97
+ * `/assets/..%2f..%2f..%2fetc%2fpasswd` reads whatever the process can. A
98
+ * directory, a missing file and a malformed escape are all `null`; a caller that
99
+ * gets a string has a regular file it may open.
100
+ *
101
+ * The final `realpath` pass is the one thing the eight app copies did not do — see
102
+ * the module header for why it cannot break a caller.
103
+ */
104
+ export function fileWithin(root: string, urlPath: string): string | null {
105
+ let decoded: string;
106
+ try {
107
+ decoded = decodeURIComponent(urlPath);
108
+ } catch {
109
+ return null; // a malformed escape is not a path
110
+ }
111
+ if (decoded.includes("\0")) return null;
112
+ const full = resolve(join(root, normalize(decoded)));
113
+ const base = resolve(root);
114
+ if (full !== base && !full.startsWith(base + sep)) return null;
115
+ if (!existsSync(full) || !statSync(full).isFile()) return null;
116
+ // Lexically inside, and the file is there. Now the same question of the DISK: a
117
+ // symlink under the root is the one way a path can pass the check above and
118
+ // still read something outside. Both sides, or a symlinked ancestor of the root
119
+ // would refuse every file it holds.
120
+ let realFull: string;
121
+ let realBase: string;
122
+ try {
123
+ realFull = realpathSync(full);
124
+ realBase = realpathSync(base);
125
+ } catch {
126
+ return null; // it existed a line ago; whatever it is now, do not serve it
127
+ }
128
+ if (realFull !== realBase && !realFull.startsWith(realBase + sep)) return null;
129
+ return full;
130
+ }
131
+
132
+ /**
133
+ * Say what killed the process, once, before it goes.
134
+ *
135
+ * The whole value is the log line. An app holding SQLite handles that Bun closes on
136
+ * exit and nothing else in flight has nothing to drain — what was missing in the
137
+ * previous generation was not cleanup, it was ever finding out why a long-running
138
+ * process had stopped.
139
+ *
140
+ * 🔴 `name` is a parameter rather than anything this library could look up. That is
141
+ * the whole of rule 2: the only app-shaped thing these four helpers touch arrives
142
+ * as an argument.
143
+ */
144
+ export function installCrashHandlers(name: string): void {
145
+ process.on("uncaughtException", (error) => {
146
+ console.error(`[${name}] uncaught exception — exiting`, error);
147
+ process.exit(1);
148
+ });
149
+ process.on("unhandledRejection", (reason) => {
150
+ console.error(`[${name}] unhandled rejection — exiting`, reason);
151
+ process.exit(1);
152
+ });
153
+ }