cursedops 0.9.2 → 0.10.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 +2 -0
- package/package.json +14 -2
- package/src/relay.ts +494 -0
- package/src/relayLink.ts +210 -0
package/README.md
CHANGED
|
@@ -25,6 +25,8 @@ bun add cursedops
|
|
|
25
25
|
| `cursedops/bound-lists` | the static check that no SQL builds an `IN (…)` list one `?` per value — the shape D1 500s on past 100 ids. Test-time only; the recorded third exception, see below |
|
|
26
26
|
| `cursedops/staged-client` | the IMMUTABLE client a checkout-served app serves — a build staged into `<APP_DATA_DIR>/client/<commit>/` behind `CURRENT`, so another agent's `vite build` in the checkout never changes live bytes — and its `forge-client stamp` / `forge-client stage` bin (0.6.0, lifted from family, flix, roms, station) |
|
|
27
27
|
| `cursedops/deploy-tree` | the two refusals a checkout-served deploy needs — never deploy a dirty tree, never `git revert` in one (`readTreeStatus`, `refuseToDeploy`, `refuseToRevert`; 0.6.0, lifted from nine apps) |
|
|
28
|
+
| `cursedops/relay` | the WORKER half of a relay — a Worker in front of a Mac-bound app over ONE outbound WebSocket: the frames (station's wire, byte for byte), the key-digest door (`acceptLink`), and `RelayLink`, the Durable Object an app subclasses with its name, body ceiling, timeout and (optionally) station's per-cookie offline cache. No `node:` import (0.10.0, lifted from station for roms, task 065) |
|
|
29
|
+
| `cursedops/relay-link` | the MAC half — `dialLink` (answer each frame with the app's own `fetch`, redial 1 s → 60 s on close, no timer otherwise), `readLinkSettings` for `<APP>_LINK_URL`/`<APP>_LINK_KEY`, and `rotateLinkKey` (digest to the Worker FIRST, the key to the 0600 file only if it took) (0.10.0) |
|
|
28
30
|
| `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 — and its `public-surface` bin. Not an app's: the one entry here admitted for three published libraries, see below |
|
|
29
31
|
|
|
30
32
|
Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.
|
|
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 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.",
|
|
3
|
+
"version": "0.10.1",
|
|
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), 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": {
|
|
7
7
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
@@ -121,6 +121,18 @@
|
|
|
121
121
|
"bun": "./src/deployTree.ts",
|
|
122
122
|
"source": "./src/deployTree.ts",
|
|
123
123
|
"import": "./src/deployTree.ts"
|
|
124
|
+
},
|
|
125
|
+
"./relay": {
|
|
126
|
+
"types": "./src/relay.ts",
|
|
127
|
+
"bun": "./src/relay.ts",
|
|
128
|
+
"source": "./src/relay.ts",
|
|
129
|
+
"import": "./src/relay.ts"
|
|
130
|
+
},
|
|
131
|
+
"./relay-link": {
|
|
132
|
+
"types": "./src/relayLink.ts",
|
|
133
|
+
"bun": "./src/relayLink.ts",
|
|
134
|
+
"source": "./src/relayLink.ts",
|
|
135
|
+
"import": "./src/relayLink.ts"
|
|
124
136
|
}
|
|
125
137
|
},
|
|
126
138
|
"bin": {
|
package/src/relay.ts
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedops/relay` — the WORKER half of a relay: a Cloudflare Worker in front, the app unchanged
|
|
3
|
+
* on the Mac behind ONE outbound WebSocket, `cursed.app.runtime: "relay"`. The Mac half (the dialer
|
|
4
|
+
* and the key rotation) is `cursedops/relay-link`.
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* browser ──HTTPS──▶ Worker ──(Durable Object RelayLink)──▶ the Mac's socket ──▶ app.fetch
|
|
8
|
+
* ◀── { t: "res", id, … } ◀──
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* ## Why it is here, and who wrote it twice
|
|
12
|
+
*
|
|
13
|
+
* `apps/station` built it first (task 047, 2026-09-24 — its Worker, its frames and
|
|
14
|
+
* `apps/station/scripts/link.ts`) — station is the console of THIS Mac and cannot
|
|
15
|
+
* live in an isolate. `apps/roms` needed the identical shape the same day (task 065): its play-host
|
|
16
|
+
* agent drives play on this Mac, so a full D1 port would have rewritten 40 `bun:sqlite` files to
|
|
17
|
+
* move an app whose other half can never move. Lifted here ONCE rather than copied — the README's
|
|
18
|
+
* rule 1 is met by two apps needing the same wire, and a relay whose two copies drifted would put
|
|
19
|
+
* two frame formats on the fleet.
|
|
20
|
+
*
|
|
21
|
+
* 🔴 **The wire is station's, byte for byte**: JSON text frames, bodies base64, `set-cookie` lines
|
|
22
|
+
* kept separate. A Worker on this module and a Mac on station's old `link.ts` (or the reverse)
|
|
23
|
+
* still talk, which is what let station move onto this without a flag day.
|
|
24
|
+
*
|
|
25
|
+
* ## What it holds, and what it does not
|
|
26
|
+
*
|
|
27
|
+
* Mechanism only. The app's Worker decides WHICH paths relay (its routes are identity), holds the
|
|
28
|
+
* key digest as a secret, and subclasses {@link RelayLink} with its name and knobs. The gate stays
|
|
29
|
+
* the Mac's: this Worker holds no session and no D1, so it cannot drift from the app it fronts.
|
|
30
|
+
*
|
|
31
|
+
* Pure: no `node:` import and no Worker globals at module scope, because both ends import the
|
|
32
|
+
* frames — workerd for the object, Bun for the dialer.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** The largest request or response body a link carries unless the app says otherwise. */
|
|
36
|
+
export const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
/** How long the Worker waits for the Mac to answer one request before it says so. */
|
|
39
|
+
export const DEFAULT_RELAY_TIMEOUT_MS = 30_000;
|
|
40
|
+
|
|
41
|
+
/** A request travelling DOWN the link. `path` includes the query string. */
|
|
42
|
+
export interface RequestFrame {
|
|
43
|
+
t: "req";
|
|
44
|
+
id: string;
|
|
45
|
+
method: string;
|
|
46
|
+
path: string;
|
|
47
|
+
headers: [string, string][];
|
|
48
|
+
body: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The Mac's answer, travelling UP. Multiple `set-cookie` lines stay separate pairs. */
|
|
52
|
+
export interface ResponseFrame {
|
|
53
|
+
t: "res";
|
|
54
|
+
id: string;
|
|
55
|
+
status: number;
|
|
56
|
+
headers: [string, string][];
|
|
57
|
+
body: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type LinkFrame = RequestFrame | ResponseFrame;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sent UP by the Mac once, right after it connects (station, task 089): the GETs every page
|
|
64
|
+
* performs, so a {@link RelayLink} with an offline cache can refresh it while the Mac is awake.
|
|
65
|
+
* Only `/api/` GET paths survive the parse; at most 64.
|
|
66
|
+
*/
|
|
67
|
+
export interface WarmFrame {
|
|
68
|
+
t: "warm";
|
|
69
|
+
paths: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Headers that must NOT cross: hop-by-hop ones, and the edge's own, which the Mac's request log
|
|
74
|
+
* must not mistake for its tunnel's. `cf-connecting-ip` and `cf-ipcountry` DO cross, so the Mac
|
|
75
|
+
* still places a request the way it always has (station, task 2156).
|
|
76
|
+
*/
|
|
77
|
+
const DROP = new Set(["connection", "keep-alive", "transfer-encoding", "upgrade", "host", "content-length", "cf-ray", "cf-visitor", "cf-worker"]);
|
|
78
|
+
|
|
79
|
+
function portableHeaders(headers: Headers): [string, string][] {
|
|
80
|
+
const out: [string, string][] = [];
|
|
81
|
+
headers.forEach((value, name) => {
|
|
82
|
+
if (name === "set-cookie" || DROP.has(name)) return;
|
|
83
|
+
out.push([name, value]);
|
|
84
|
+
});
|
|
85
|
+
for (const cookie of headers.getSetCookie?.() ?? []) out.push(["set-cookie", cookie]);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function toBase64(bytes: Uint8Array): string {
|
|
90
|
+
let binary = "";
|
|
91
|
+
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
92
|
+
return btoa(binary);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function fromBase64(text: string): Uint8Array<ArrayBuffer> {
|
|
96
|
+
const binary = atob(text);
|
|
97
|
+
const out = new Uint8Array(new ArrayBuffer(binary.length));
|
|
98
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A frame off the wire, or null for anything that is not one — never a throw into a handler. */
|
|
103
|
+
export function parseFrame(text: string): LinkFrame | null {
|
|
104
|
+
let value: unknown;
|
|
105
|
+
try {
|
|
106
|
+
value = JSON.parse(text);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (typeof value !== "object" || value === null) return null;
|
|
111
|
+
const f = value as Record<string, unknown>;
|
|
112
|
+
if (typeof f.id !== "string" || !Array.isArray(f.headers)) return null;
|
|
113
|
+
if (f.body !== null && typeof f.body !== "string") return null;
|
|
114
|
+
if (f.t === "req" && typeof f.method === "string" && typeof f.path === "string" && f.path.startsWith("/")) return f as unknown as RequestFrame;
|
|
115
|
+
if (f.t === "res" && typeof f.status === "number") return f as unknown as ResponseFrame;
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseWarmFrame(text: string): WarmFrame | null {
|
|
120
|
+
let value: unknown;
|
|
121
|
+
try {
|
|
122
|
+
value = JSON.parse(text);
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const f = value as { t?: unknown; paths?: unknown } | null;
|
|
127
|
+
if (f?.t !== "warm" || !Array.isArray(f.paths)) return null;
|
|
128
|
+
return { t: "warm", paths: f.paths.filter((p): p is string => typeof p === "string" && p.startsWith("/api/")).slice(0, 64) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function requestFrame(id: string, request: Request): Promise<RequestFrame> {
|
|
132
|
+
const url = new URL(request.url);
|
|
133
|
+
const bytes = request.method === "GET" || request.method === "HEAD" ? null : new Uint8Array(await request.arrayBuffer());
|
|
134
|
+
return {
|
|
135
|
+
t: "req",
|
|
136
|
+
id,
|
|
137
|
+
method: request.method,
|
|
138
|
+
path: `${url.pathname}${url.search}`,
|
|
139
|
+
headers: portableHeaders(request.headers),
|
|
140
|
+
body: bytes && bytes.length > 0 ? toBase64(bytes) : null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The Request the Mac's app answers — on the Worker's own origin, so every URL it builds is right. */
|
|
145
|
+
export function frameToRequest(frame: RequestFrame, origin: string): Request {
|
|
146
|
+
const headers = new Headers();
|
|
147
|
+
for (const [name, value] of frame.headers) headers.append(name, value);
|
|
148
|
+
return new Request(`${origin.replace(/\/+$/, "")}${frame.path}`, {
|
|
149
|
+
method: frame.method,
|
|
150
|
+
headers,
|
|
151
|
+
body: frame.body === null ? null : fromBase64(frame.body),
|
|
152
|
+
redirect: "manual",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function responseFrame(id: string, response: Response): Promise<ResponseFrame> {
|
|
157
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
158
|
+
return { t: "res", id, status: response.status, headers: portableHeaders(response.headers), body: bytes.length > 0 ? toBase64(bytes) : null };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function frameToResponse(frame: ResponseFrame): Response {
|
|
162
|
+
const headers = new Headers();
|
|
163
|
+
for (const [name, value] of frame.headers) headers.append(name, value);
|
|
164
|
+
// A 1xx/204/304 may carry no body, and `new Response` throws if one is given.
|
|
165
|
+
const bodyless = frame.status === 204 || frame.status === 304 || frame.status < 200;
|
|
166
|
+
return new Response(bodyless || frame.body === null ? null : fromBase64(frame.body), { status: frame.status, headers });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function sha256Hex(text: string): Promise<string> {
|
|
170
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
171
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Constant-time compare of two SHA-256 hex digests. Its own, not `cwip/constant-time`, because this
|
|
176
|
+
* package has ZERO runtime dependencies and a Worker bundle imports it; the length difference is
|
|
177
|
+
* folded into the accumulator, never an early return (the rule `check-own-compare` enforces).
|
|
178
|
+
*/
|
|
179
|
+
function digestsEqual(a: string, b: string): boolean { // check-own-compare:ignore zero-dependency library; compares two fixed-length SHA-256 digests, length folded in
|
|
180
|
+
let diff = a.length ^ b.length;
|
|
181
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
|
|
182
|
+
return diff === 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Is `header` (`Bearer <key>`) the key whose SHA-256 the Worker holds? The Worker keeps only the
|
|
187
|
+
* digest as a secret, so the key itself exists on the Mac alone and a leaked secret opens nothing.
|
|
188
|
+
*/
|
|
189
|
+
export async function linkKeyMatches(header: string | null, expectedSha256: string | undefined): Promise<boolean> {
|
|
190
|
+
if (!expectedSha256 || !header?.startsWith("Bearer ")) return false;
|
|
191
|
+
const presented = header.slice("Bearer ".length).trim();
|
|
192
|
+
if (presented.length < 32) return false;
|
|
193
|
+
return digestsEqual(await sha256Hex(presented), expectedSha256.trim().toLowerCase());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** One cookie's value off a `cookie` header, or null — exact name match, `=` in the value kept. */
|
|
197
|
+
export function cookieValue(header: string | null, name: string): string | null {
|
|
198
|
+
for (const part of (header ?? "").split(";")) {
|
|
199
|
+
const [key, ...rest] = part.trim().split("=");
|
|
200
|
+
if (key === name && rest.length > 0) return rest.join("=") || null;
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A server-side socket as the hibernation API hands it back. Structural — no Worker globals. */
|
|
206
|
+
export interface LinkSocket {
|
|
207
|
+
send(data: string): void;
|
|
208
|
+
close(code?: number, reason?: string): void;
|
|
209
|
+
serializeAttachment(value: unknown): void;
|
|
210
|
+
deserializeAttachment(): unknown;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The slice of `DurableObjectStorage` the offline answers use. */
|
|
214
|
+
export interface LinkStorage {
|
|
215
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
216
|
+
put(key: string, value: unknown): Promise<void>;
|
|
217
|
+
delete(keys: string[]): Promise<number>;
|
|
218
|
+
list<T>(options: { prefix: string }): Promise<Map<string, T>>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The slice of `DurableObjectState` this object uses. */
|
|
222
|
+
export interface LinkState {
|
|
223
|
+
acceptWebSocket(socket: LinkSocket, tags?: string[]): void;
|
|
224
|
+
getWebSockets(tag?: string): LinkSocket[];
|
|
225
|
+
storage: LinkStorage;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** What `/healthz` and a smoke may know about the link. No content — the body is public. */
|
|
229
|
+
export interface LinkStatus {
|
|
230
|
+
connected: boolean;
|
|
231
|
+
/** When the socket that is up now connected, ISO. */
|
|
232
|
+
since: string | null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** `new WebSocketPair()` on workerd; a test passes its own. */
|
|
236
|
+
export type PairMaker = () => { client: unknown; server: LinkSocket };
|
|
237
|
+
|
|
238
|
+
const workerdPair: PairMaker = () => {
|
|
239
|
+
const pair = new (globalThis as unknown as { WebSocketPair: new () => Record<string, unknown> }).WebSocketPair();
|
|
240
|
+
const [client, server] = Object.values(pair);
|
|
241
|
+
return { client, server: server as LinkSocket };
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The Mac asleep — the last good answer, as of when (station, task 089). Off unless an app asks.
|
|
246
|
+
*
|
|
247
|
+
* 🔴 **The Worker cannot read a session** — the gate is the Mac's. So an answer is keyed by the
|
|
248
|
+
* SHA-256 of the request's own session cookie: a request gets back only what that exact cookie
|
|
249
|
+
* was already shown, for no longer than {@link OfflineCache.ttlMs} (make it the Mac's session
|
|
250
|
+
* TTL). A sign-out, or any `401` from the Mac under a cookie, drops everything kept for it. A
|
|
251
|
+
* request with no cookie gets the offline `503`.
|
|
252
|
+
*/
|
|
253
|
+
export interface OfflineCache {
|
|
254
|
+
/** The app's session cookie name, e.g. `station_session`. */
|
|
255
|
+
cookie: string;
|
|
256
|
+
/** Every path that ends a session — kept answers for the cookie are dropped on it. */
|
|
257
|
+
signOut: readonly string[];
|
|
258
|
+
/** How long an answer may be served with the Mac away. */
|
|
259
|
+
ttlMs: number;
|
|
260
|
+
/** The header an offline answer carries: when the Mac gave it, ISO. */
|
|
261
|
+
asOfHeader: string;
|
|
262
|
+
/** Bigger answers are not kept. Default 512 KB. */
|
|
263
|
+
maxKeptBody?: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface RelayLinkOptions {
|
|
267
|
+
/** For the messages: `"station's Mac …"`. */
|
|
268
|
+
app: string;
|
|
269
|
+
/** The JSON 503's `error` while no Mac is linked. Say what dials out and when. */
|
|
270
|
+
offlineMessage: string;
|
|
271
|
+
/** Default {@link DEFAULT_MAX_BODY_BYTES}. Set it to at least the Mac's own body ceiling. */
|
|
272
|
+
maxBodyBytes?: number;
|
|
273
|
+
/** Default {@link DEFAULT_RELAY_TIMEOUT_MS}. Must exceed the Mac's longest held request. */
|
|
274
|
+
timeoutMs?: number;
|
|
275
|
+
/** Absent: nothing is kept and a Mac away is always the 503. */
|
|
276
|
+
offline?: OfflineCache;
|
|
277
|
+
makePair?: PairMaker;
|
|
278
|
+
now?: () => number;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
interface Kept {
|
|
282
|
+
body: string;
|
|
283
|
+
contentType: string;
|
|
284
|
+
at: number;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const json = (status: number, body: unknown) =>
|
|
288
|
+
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store" } });
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The Durable Object that holds the Mac's end of the link. Subclass it in the app's Worker with
|
|
292
|
+
* the runtime's `(state, env)` constructor and this one's options:
|
|
293
|
+
*
|
|
294
|
+
* ```ts
|
|
295
|
+
* export class MacLink extends RelayLink {
|
|
296
|
+
* constructor(state: LinkState) { super(state, { app: "roms", offlineMessage: "…" }); }
|
|
297
|
+
* }
|
|
298
|
+
* ```
|
|
299
|
+
*
|
|
300
|
+
* The Worker hands it three paths: `/__link` (a verified upgrade — {@link linkKeyMatches} first),
|
|
301
|
+
* `/__status`, and anything else, which is relayed.
|
|
302
|
+
*
|
|
303
|
+
* ## Why an object and not the isolate
|
|
304
|
+
*
|
|
305
|
+
* The Mac's socket and a browser's request must MEET, and they arrive in different isolates. An
|
|
306
|
+
* object addressed by one name is the one place every isolate reaches.
|
|
307
|
+
*
|
|
308
|
+
* ## Hibernation
|
|
309
|
+
*
|
|
310
|
+
* Accepted with `state.acceptWebSocket`, so a quiet day is not billed as duration. The pending
|
|
311
|
+
* map is memory, and that is correct: an object with a request in flight is not evicted.
|
|
312
|
+
*
|
|
313
|
+
* ## One Mac
|
|
314
|
+
*
|
|
315
|
+
* A second link REPLACES the first (close code 4000): a Mac that woke from sleep reconnects before
|
|
316
|
+
* the edge has noticed its old socket died, and a relay that sent to the dead one would time every
|
|
317
|
+
* request out for as long as that took.
|
|
318
|
+
*
|
|
319
|
+
* 🔴 A plain class with a `fetch` method, NOT `extends DurableObject`: importing
|
|
320
|
+
* `cloudflare:workers` leaks Worker globals over a Bun host's type graph. The hibernation handlers
|
|
321
|
+
* are found by name.
|
|
322
|
+
*/
|
|
323
|
+
export class RelayLink {
|
|
324
|
+
private readonly pending = new Map<string, { socket: LinkSocket; resolve: (r: Response) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
325
|
+
private readonly makePair: PairMaker;
|
|
326
|
+
protected readonly timeoutMs: number;
|
|
327
|
+
protected readonly maxBodyBytes: number;
|
|
328
|
+
private readonly now: () => number;
|
|
329
|
+
|
|
330
|
+
constructor(
|
|
331
|
+
private readonly state: LinkState,
|
|
332
|
+
private readonly options: RelayLinkOptions,
|
|
333
|
+
) {
|
|
334
|
+
this.makePair = options.makePair ?? workerdPair;
|
|
335
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_RELAY_TIMEOUT_MS;
|
|
336
|
+
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
337
|
+
this.now = options.now ?? Date.now;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async fetch(request: Request): Promise<Response> {
|
|
341
|
+
const path = new URL(request.url).pathname;
|
|
342
|
+
if (path === "/__link") return this.accept();
|
|
343
|
+
if (path === "/__status") return json(200, this.status());
|
|
344
|
+
return this.relay(request);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
status(): LinkStatus {
|
|
348
|
+
const socket = this.state.getWebSockets()[0];
|
|
349
|
+
const attachment = socket?.deserializeAttachment() as { since?: string } | null | undefined;
|
|
350
|
+
return { connected: socket !== undefined, since: attachment?.since ?? null };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private accept(): Response {
|
|
354
|
+
for (const old of this.state.getWebSockets()) old.close(4000, "replaced by a newer link");
|
|
355
|
+
const { client, server } = this.makePair();
|
|
356
|
+
this.state.acceptWebSocket(server);
|
|
357
|
+
server.serializeAttachment({ since: new Date().toISOString() });
|
|
358
|
+
return new Response(null, { status: 101, webSocket: client } as ResponseInit);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private offline(): Response {
|
|
362
|
+
return json(503, { error: this.options.offlineMessage, link: "down" });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Relay, and keep or drop what the answer says about the cookie that asked — see {@link OfflineCache}. */
|
|
366
|
+
private async relay(request: Request): Promise<Response> {
|
|
367
|
+
const cache = this.options.offline;
|
|
368
|
+
if (!cache) return this.state.getWebSockets()[0] ? this.send(request) : this.offline();
|
|
369
|
+
const url = new URL(request.url);
|
|
370
|
+
const cookie = cookieValue(request.headers.get("cookie"), cache.cookie);
|
|
371
|
+
const owner = cookie ? await sha256Hex(cookie) : null;
|
|
372
|
+
const keyed = `${url.pathname}${url.search}`;
|
|
373
|
+
const cacheable = owner !== null && request.method === "GET" && url.pathname.startsWith("/api/");
|
|
374
|
+
if (owner && cache.signOut.includes(url.pathname)) await this.forget(owner);
|
|
375
|
+
if (!this.state.getWebSockets()[0]) {
|
|
376
|
+
if (cacheable) {
|
|
377
|
+
const kept = await this.state.storage.get<Kept>(`c:${owner}:${keyed}`);
|
|
378
|
+
if (kept && this.now() - kept.at < cache.ttlMs) {
|
|
379
|
+
return new Response(kept.body, {
|
|
380
|
+
status: 200,
|
|
381
|
+
headers: { "content-type": kept.contentType, "cache-control": "no-store", [cache.asOfHeader]: new Date(kept.at).toISOString() },
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return this.offline();
|
|
386
|
+
}
|
|
387
|
+
const response = await this.send(request);
|
|
388
|
+
if (owner && response.status === 401) await this.forget(owner);
|
|
389
|
+
else if (cacheable && response.status === 200 && (response.headers.get("content-type") ?? "").includes("json")) {
|
|
390
|
+
const body = await response.clone().text();
|
|
391
|
+
if (body.length <= (cache.maxKeptBody ?? 512 * 1024)) {
|
|
392
|
+
const at = this.now();
|
|
393
|
+
await this.state.storage.put(`c:${owner}:${keyed}`, { body, contentType: response.headers.get("content-type") ?? "application/json", at } satisfies Kept);
|
|
394
|
+
// The newest cookie the Mac honoured is the one a `warm` frame refreshes under.
|
|
395
|
+
await this.state.storage.put("warm", { cookie, at });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return response;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Drop every answer kept for one cookie, and the warm cookie if it is that one. */
|
|
402
|
+
private async forget(owner: string): Promise<void> {
|
|
403
|
+
const keys = [...(await this.state.storage.list({ prefix: `c:${owner}:` })).keys()];
|
|
404
|
+
const warm = await this.state.storage.get<{ cookie: string }>("warm");
|
|
405
|
+
if (warm && (await sha256Hex(warm.cookie)) === owner) keys.push("warm");
|
|
406
|
+
if (keys.length > 0) await this.state.storage.delete(keys);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Refresh every read the Mac named, under the newest cookie it honoured — if that is still fresh. */
|
|
410
|
+
private async warm(paths: readonly string[]): Promise<void> {
|
|
411
|
+
const cache = this.options.offline;
|
|
412
|
+
if (!cache) return;
|
|
413
|
+
const warm = await this.state.storage.get<{ cookie: string; at: number }>("warm");
|
|
414
|
+
if (!warm || this.now() - warm.at >= cache.ttlMs) return;
|
|
415
|
+
for (const path of paths) {
|
|
416
|
+
if (!this.state.getWebSockets()[0]) return;
|
|
417
|
+
await this.relay(new Request(`https://relay.link${path}`, { headers: { cookie: `${cache.cookie}=${warm.cookie}` } }));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private async send(request: Request): Promise<Response> {
|
|
422
|
+
const socket = this.state.getWebSockets()[0];
|
|
423
|
+
if (!socket) return this.offline();
|
|
424
|
+
const tooBig = () => json(413, { error: `request body over the link's ${this.maxBodyBytes} bytes` });
|
|
425
|
+
const declared = Number(request.headers.get("content-length") ?? 0);
|
|
426
|
+
if (declared > this.maxBodyBytes) return tooBig();
|
|
427
|
+
const id = crypto.randomUUID();
|
|
428
|
+
const frame = await requestFrame(id, request);
|
|
429
|
+
// Exactly, off the base64: a chunked upload carries no content-length, and a slack bound let
|
|
430
|
+
// the ceiling + 2 bytes through (roms' suite caught it at MAX_BODY_BYTES + 1).
|
|
431
|
+
const pad = frame.body?.endsWith("==") ? 2 : frame.body?.endsWith("=") ? 1 : 0;
|
|
432
|
+
if (frame.body !== null && (frame.body.length / 4) * 3 - pad > this.maxBodyBytes) return tooBig();
|
|
433
|
+
return await new Promise<Response>((resolve) => {
|
|
434
|
+
const timer = setTimeout(() => {
|
|
435
|
+
this.pending.delete(id);
|
|
436
|
+
resolve(json(504, { error: `${this.options.app}'s Mac did not answer ${frame.method} ${frame.path} within ${this.timeoutMs / 1000}s` }));
|
|
437
|
+
}, this.timeoutMs);
|
|
438
|
+
this.pending.set(id, { socket, resolve, timer });
|
|
439
|
+
try {
|
|
440
|
+
socket.send(JSON.stringify(frame));
|
|
441
|
+
} catch (error) {
|
|
442
|
+
clearTimeout(timer);
|
|
443
|
+
this.pending.delete(id);
|
|
444
|
+
resolve(json(502, { error: `the link refused the request: ${(error as Error).message}` }));
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
webSocketMessage(_socket: LinkSocket, message: string | ArrayBuffer): void | Promise<void> {
|
|
450
|
+
if (typeof message !== "string") return;
|
|
451
|
+
const warm = parseWarmFrame(message);
|
|
452
|
+
if (warm) return this.warm(warm.paths);
|
|
453
|
+
const frame = parseFrame(message);
|
|
454
|
+
if (frame?.t !== "res") return;
|
|
455
|
+
const waiting = this.pending.get(frame.id);
|
|
456
|
+
if (!waiting) return;
|
|
457
|
+
clearTimeout(waiting.timer);
|
|
458
|
+
this.pending.delete(frame.id);
|
|
459
|
+
waiting.resolve(frameToResponse(frame));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
webSocketClose(socket: LinkSocket, code: number, reason: string): void {
|
|
463
|
+
for (const [id, waiting] of this.pending) {
|
|
464
|
+
if (waiting.socket !== socket) continue;
|
|
465
|
+
clearTimeout(waiting.timer);
|
|
466
|
+
this.pending.delete(id);
|
|
467
|
+
waiting.resolve(json(502, { error: `${this.options.app}'s link closed mid-request (${code}${reason ? ` ${reason}` : ""})` }));
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
socket.close(code, reason);
|
|
471
|
+
} catch {}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
webSocketError(socket: LinkSocket): void {
|
|
475
|
+
this.webSocketClose(socket, 1011, "socket error");
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The Worker's `/link` door, in one call: WebSocket only, a deployment with no digest refuses every
|
|
481
|
+
* link rather than accepting any, a wrong key is a 401 before the object is reached, and a right one
|
|
482
|
+
* is handed to the object as `/__link`.
|
|
483
|
+
*/
|
|
484
|
+
export async function acceptLink(
|
|
485
|
+
request: Request,
|
|
486
|
+
digest: string | undefined,
|
|
487
|
+
link: { fetch(request: Request): Promise<Response> },
|
|
488
|
+
names: { app: string; mintCommand: string },
|
|
489
|
+
): Promise<Response> {
|
|
490
|
+
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return json(426, { error: "the link is a WebSocket" });
|
|
491
|
+
if (!digest) return json(503, { error: `this deployment holds no link key — \`${names.mintCommand}\` mints one` });
|
|
492
|
+
if (!(await linkKeyMatches(request.headers.get("authorization"), digest))) return json(401, { error: `not ${names.app}'s Mac` });
|
|
493
|
+
return link.fetch(new Request(`${new URL(request.url).origin}/__link`, request));
|
|
494
|
+
}
|
package/src/relayLink.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedops/relay-link` — the MAC half of a relay (`cursedops/relay` is the Worker half): dial the
|
|
3
|
+
* app's Worker at `/link` over ONE outbound WebSocket, answer every request frame that comes down
|
|
4
|
+
* it with the app's own `fetch`, reconnect when it closes, and mint (= rotate) the key.
|
|
5
|
+
*
|
|
6
|
+
* Lifted from `apps/station/scripts/link.ts` and `apps/station/scripts/link-key.ts` (task 047) when `apps/roms`
|
|
7
|
+
* needed the identical dialer (task 065). What stays in each app: which process dials (station's
|
|
8
|
+
* `com.station.link`, roms' own `com.roms.host`), what else that process arms, and its names.
|
|
9
|
+
*
|
|
10
|
+
* 🔴 **The Mac listens on nothing for the relay.** What reaches the app arrives down a socket this
|
|
11
|
+
* process opened to a hostname it verified over TLS, holding a key only the Worker's digest matches.
|
|
12
|
+
*
|
|
13
|
+
* 🔴 **No timer asks anything** (owner, 2026-09-15: *"There should be no polling"*). The one
|
|
14
|
+
* `setTimeout` is the reconnect after the socket CLOSES — a sleeping Mac's socket dies, and the link
|
|
15
|
+
* is back within seconds of waking, backing off to a minute while the Worker is unreachable.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { frameToRequest, parseFrame, type ResponseFrame, responseFrame, sha256Hex, toBase64 } from "cursedops/relay";
|
|
20
|
+
import { readEnvFile } from "cursedops/worker-deploy";
|
|
21
|
+
|
|
22
|
+
export interface LinkSettings {
|
|
23
|
+
url: string;
|
|
24
|
+
key: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The link's URL and key out of its 0600 env file (`<APP>_LINK_URL`, `<APP>_LINK_KEY`), or a sentence
|
|
29
|
+
* saying what is wrong. `wss://<host>/link`, or `ws://127.0.0.1:<port>/link` for a local workerd.
|
|
30
|
+
*/
|
|
31
|
+
export function readLinkSettings(text: string, prefix: string, mintCommand = "bun run link:key"): LinkSettings | string {
|
|
32
|
+
const found = readEnvFile(text);
|
|
33
|
+
const url = found[`${prefix}_LINK_URL`]?.trim() ?? "";
|
|
34
|
+
const key = found[`${prefix}_LINK_KEY`]?.trim() ?? "";
|
|
35
|
+
if (!/^wss:\/\/[^/]+\/link$/.test(url) && !/^ws:\/\/(127\.0\.0\.1|localhost)(:\d+)?\/link$/.test(url)) {
|
|
36
|
+
return `${prefix}_LINK_URL must be wss://<host>/link (or ws://127.0.0.1:<port>/link for a local workerd), got "${url}"`;
|
|
37
|
+
}
|
|
38
|
+
if (key.length < 32) return `${prefix}_LINK_KEY is missing or short — \`${mintCommand}\` mints one`;
|
|
39
|
+
return { url, key };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The public origin a link's URL belongs to — the origin the Mac's app builds its URLs on. */
|
|
43
|
+
export const publicOriginOf = (linkUrl: string): string => {
|
|
44
|
+
const u = new URL(linkUrl);
|
|
45
|
+
return `${u.protocol === "wss:" ? "https:" : "http:"}//${u.host}`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The Access service token, when the link dials a host behind Access (a `*-stage` host). Measured on
|
|
50
|
+
* station's first stage link: without these the edge refused every handshake (1006).
|
|
51
|
+
*/
|
|
52
|
+
export function linkAccessHeaders(env: Record<string, string | undefined>): Record<string, string> {
|
|
53
|
+
const id = env.CF_ACCESS_CLIENT_ID?.trim();
|
|
54
|
+
const secret = env.CF_ACCESS_CLIENT_SECRET?.trim();
|
|
55
|
+
return id && secret ? { "CF-Access-Client-Id": id, "CF-Access-Client-Secret": secret } : {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Answer one frame with `fetch`, never throwing: a crash in a route is a 500 on the wire. */
|
|
59
|
+
export async function answerFrame(
|
|
60
|
+
fetch: (request: Request) => Response | Promise<Response>,
|
|
61
|
+
text: string,
|
|
62
|
+
origin: string,
|
|
63
|
+
app = "the app",
|
|
64
|
+
): Promise<ResponseFrame | null> {
|
|
65
|
+
const frame = parseFrame(text);
|
|
66
|
+
if (frame?.t !== "req") return null;
|
|
67
|
+
try {
|
|
68
|
+
return await responseFrame(frame.id, await fetch(frameToRequest(frame, origin)));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const body = new TextEncoder().encode(JSON.stringify({ error: `${app}'s Mac failed ${frame.method} ${frame.path}: ${(error as Error).message}` }));
|
|
71
|
+
return { t: "res", id: frame.id, status: 500, headers: [["content-type", "application/json"]], body: toBase64(body) };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The one socket shape the dialer uses — the global `WebSocket` in Bun, a fake in a test. */
|
|
76
|
+
export interface DialSocket {
|
|
77
|
+
send(data: string): void;
|
|
78
|
+
close(code?: number, reason?: string): void;
|
|
79
|
+
addEventListener(type: "open" | "message" | "close" | "error", listener: (event: { data?: unknown; code?: number; reason?: string }) => void): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface DialOptions {
|
|
83
|
+
settings: LinkSettings;
|
|
84
|
+
/** The app's own `fetch` — every request frame is answered with it. */
|
|
85
|
+
fetch: (request: Request) => Response | Promise<Response>;
|
|
86
|
+
/** The origin each relayed Request is built on. Default: the link URL's ({@link publicOriginOf}). */
|
|
87
|
+
origin?: string;
|
|
88
|
+
/** For the log lines: `[roms-link] …`. */
|
|
89
|
+
app: string;
|
|
90
|
+
/** Extra handshake headers — {@link linkAccessHeaders} for a host behind Access. */
|
|
91
|
+
headers?: Record<string, string>;
|
|
92
|
+
/** Sent right after each connect — station's `warm` frame. */
|
|
93
|
+
onOpen?: (socket: DialSocket) => void;
|
|
94
|
+
log?: (line: string) => void;
|
|
95
|
+
/** Tests only. */
|
|
96
|
+
openSocket?: (url: string, headers: Record<string, string>) => DialSocket;
|
|
97
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface DialedLink {
|
|
101
|
+
/** Stop reconnecting and close the socket. */
|
|
102
|
+
close(): void;
|
|
103
|
+
/** Is a socket open right now? */
|
|
104
|
+
connected(): boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const bunSocket = (url: string, headers: Record<string, string>): DialSocket =>
|
|
108
|
+
// Bun's WebSocket takes `{ headers }` as its second argument; the DOM typing says protocols.
|
|
109
|
+
new WebSocket(url, { headers } as unknown as string[]) as unknown as DialSocket;
|
|
110
|
+
|
|
111
|
+
/** Dial, answer, and redial on close with a 1 s → 60 s backoff. See the header. */
|
|
112
|
+
export function dialLink(options: DialOptions): DialedLink {
|
|
113
|
+
const origin = options.origin ?? publicOriginOf(options.settings.url);
|
|
114
|
+
const log = options.log ?? ((line: string) => console.log(line));
|
|
115
|
+
const open = options.openSocket ?? bunSocket;
|
|
116
|
+
const later = options.setTimer ?? ((fn: () => void, ms: number) => setTimeout(fn, ms));
|
|
117
|
+
let backoff = 1_000;
|
|
118
|
+
let stopped = false;
|
|
119
|
+
let current: DialSocket | null = null;
|
|
120
|
+
let isOpen = false;
|
|
121
|
+
const connect = () => {
|
|
122
|
+
if (stopped) return;
|
|
123
|
+
const socket = open(options.settings.url, { ...(options.headers ?? {}), authorization: `Bearer ${options.settings.key}` });
|
|
124
|
+
current = socket;
|
|
125
|
+
socket.addEventListener("open", () => {
|
|
126
|
+
backoff = 1_000;
|
|
127
|
+
isOpen = true;
|
|
128
|
+
log(`[${options.app}-link] ✓ linked to ${origin} — answering its requests`);
|
|
129
|
+
options.onOpen?.(socket);
|
|
130
|
+
});
|
|
131
|
+
socket.addEventListener("message", async (event) => {
|
|
132
|
+
const reply = await answerFrame(options.fetch, String(event.data), origin, options.app);
|
|
133
|
+
if (reply) socket.send(JSON.stringify(reply));
|
|
134
|
+
});
|
|
135
|
+
socket.addEventListener("close", (event) => {
|
|
136
|
+
isOpen = false;
|
|
137
|
+
if (stopped) return;
|
|
138
|
+
log(`[${options.app}-link] link closed (${event.code}${event.reason ? ` ${event.reason}` : ""}); reconnecting in ${backoff / 1000}s`);
|
|
139
|
+
later(connect, backoff);
|
|
140
|
+
backoff = Math.min(backoff * 2, 60_000);
|
|
141
|
+
});
|
|
142
|
+
socket.addEventListener("error", () => {
|
|
143
|
+
/* the close that follows reconnects */
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
connect();
|
|
147
|
+
return {
|
|
148
|
+
close() {
|
|
149
|
+
stopped = true;
|
|
150
|
+
current?.close(1000, "closed by the Mac");
|
|
151
|
+
},
|
|
152
|
+
connected: () => isOpen,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 32 random bytes, hex — a link key. */
|
|
157
|
+
export function mintLinkKey(): string {
|
|
158
|
+
return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The link's env file, single-quoted so a shell `source` cannot mangle it. */
|
|
162
|
+
export function linkEnvText(prefix: string, url: string, key: string, mintCommand = "bun run link:key"): string {
|
|
163
|
+
return `# ${prefix.toLowerCase()}'s link — written by \`${mintCommand}\`. The key never leaves this Mac.\n${prefix}_LINK_URL='${url}'\n${prefix}_LINK_KEY='${key}'\n`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface RotateSpec {
|
|
167
|
+
/** The app checkout — where `wrangler secret put` runs. */
|
|
168
|
+
appDir: string;
|
|
169
|
+
/** The 0600 env file the Mac reads. */
|
|
170
|
+
file: string;
|
|
171
|
+
/** `ROMS` → `ROMS_LINK_URL` / `ROMS_LINK_KEY` / `ROMS_LINK_KEY_SHA256`. */
|
|
172
|
+
prefix: string;
|
|
173
|
+
/** A new URL, or undefined to keep the file's. */
|
|
174
|
+
url?: string;
|
|
175
|
+
/** `cloudflareCredential(...)` — the env overlay wrangler runs with. */
|
|
176
|
+
credential: Record<string, string>;
|
|
177
|
+
/** `wrangler secret put … --env <env>` when set. */
|
|
178
|
+
env?: string;
|
|
179
|
+
/** Tests only. */
|
|
180
|
+
putSecret?: (name: string, value: string) => { ok: boolean; detail: string };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Mint (= ROTATE) the link key: the digest goes to the Worker FIRST, the key to the 0600 file only
|
|
185
|
+
* if the Worker took it — so a refused upload changes nothing on the Mac. The old key stops matching
|
|
186
|
+
* the moment the digest is replaced; the running link is refused on its next connect.
|
|
187
|
+
*/
|
|
188
|
+
export async function rotateLinkKey(spec: RotateSpec): Promise<{ ok: true; file: string } | { ok: false; why: string }> {
|
|
189
|
+
const previous = existsSync(spec.file) ? readEnvFile(readFileSync(spec.file, "utf8")) : {};
|
|
190
|
+
const url = spec.url ?? previous[`${spec.prefix}_LINK_URL`];
|
|
191
|
+
if (!url) return { ok: false, why: `no link URL yet — pass \`--url wss://<worker host>/link\` the first time` };
|
|
192
|
+
const key = mintLinkKey();
|
|
193
|
+
const secret = `${spec.prefix}_LINK_KEY_SHA256`;
|
|
194
|
+
const put =
|
|
195
|
+
spec.putSecret ??
|
|
196
|
+
((name: string, value: string) => {
|
|
197
|
+
const r = spawnSync("bunx", ["wrangler@4", "secret", "put", name, ...(spec.env ? ["--env", spec.env] : [])], {
|
|
198
|
+
cwd: spec.appDir,
|
|
199
|
+
input: value,
|
|
200
|
+
env: { ...process.env, ...spec.credential },
|
|
201
|
+
encoding: "utf8",
|
|
202
|
+
});
|
|
203
|
+
return { ok: r.status === 0, detail: `${r.stderr ?? ""}` };
|
|
204
|
+
});
|
|
205
|
+
const done = put(secret, await sha256Hex(key));
|
|
206
|
+
if (!done.ok) return { ok: false, why: `the Worker refused the digest — nothing was changed on this Mac:\n${done.detail}` };
|
|
207
|
+
writeFileSync(spec.file, linkEnvText(spec.prefix, url, key), { mode: 0o600 });
|
|
208
|
+
chmodSync(spec.file, 0o600);
|
|
209
|
+
return { ok: true, file: spec.file };
|
|
210
|
+
}
|