workerdeck 0.0.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +100 -9
- package/build/cli.d.mts +1 -0
- package/build/cli.mjs +131 -0
- package/build/cli.mjs.map +1 -0
- package/build/guard-D3VN855w.mjs +241 -0
- package/build/guard-D3VN855w.mjs.map +1 -0
- package/build/index.d.mts +453 -0
- package/build/index.mjs +3 -0
- package/build/instance-DbIqlX-e.mjs +1586 -0
- package/build/instance-DbIqlX-e.mjs.map +1 -0
- package/package.json +55 -12
|
@@ -0,0 +1,1586 @@
|
|
|
1
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
+
import { createFileSessionStore, createWorkerServer } from "@workerdeck/server";
|
|
4
|
+
import { dashboardDir } from "@workerdeck/web";
|
|
5
|
+
import { createHash, createPrivateKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { connect } from "node:http2";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
//#region src/apns/client.ts
|
|
11
|
+
/**
|
|
12
|
+
* The environment is per *device token*, not per deployment, and that is the
|
|
13
|
+
* single most expensive thing to get wrong here: a build run from Xcode gets a
|
|
14
|
+
* sandbox token, a TestFlight build gets a production one, and the two
|
|
15
|
+
* namespaces do not overlap. Same key, same phone, different token — cross them
|
|
16
|
+
* and Apple answers `BadDeviceToken`. So the app declares which environment it
|
|
17
|
+
* registered in and the forwarder routes each token to its own host.
|
|
18
|
+
*/
|
|
19
|
+
const HOSTS = {
|
|
20
|
+
development: "https://api.sandbox.push.apple.com",
|
|
21
|
+
production: "https://api.push.apple.com"
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Apple rejects a provider token older than an hour, and rate-limits refreshing
|
|
25
|
+
* one (`TooManyProviderTokenUpdates`) if you re-sign much more often than every
|
|
26
|
+
* twenty minutes. Forty sits in the middle of that window with room for clock
|
|
27
|
+
* skew at both ends — and re-signing per push, the obvious-looking thing, is
|
|
28
|
+
* exactly what the rate limit exists to punish.
|
|
29
|
+
*/
|
|
30
|
+
const TOKEN_TTL_MS = 2400 * 1e3;
|
|
31
|
+
const REQUEST_TIMEOUT_MS = 1e4;
|
|
32
|
+
const base64url = (input) => Buffer.from(input).toString("base64url");
|
|
33
|
+
/**
|
|
34
|
+
* Load and sanity-check the auth key. Done once at startup rather than at the
|
|
35
|
+
* first push, so a mistyped path is a launch error with a clear message instead
|
|
36
|
+
* of a notification that silently never arrives.
|
|
37
|
+
*/
|
|
38
|
+
async function loadApnsKey(keyFile) {
|
|
39
|
+
let pem;
|
|
40
|
+
try {
|
|
41
|
+
pem = await readFile(keyFile, "utf8");
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(`apns: cannot read the auth key at ${keyFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
44
|
+
}
|
|
45
|
+
let key;
|
|
46
|
+
try {
|
|
47
|
+
key = createPrivateKey(pem);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new Error(`apns: ${keyFile} is not a private key (${error instanceof Error ? error.message : String(error)})`);
|
|
50
|
+
}
|
|
51
|
+
if (key.asymmetricKeyType !== "ec") throw new Error(`apns: ${keyFile} is a ${key.asymmetricKeyType ?? "unknown"} key, not EC — an APNs auth key is the .p8 downloaded from Keys in the developer portal, not a certificate`);
|
|
52
|
+
return key;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A cached provider JWT. The signature is over `{alg:ES256,kid}` + `{iss,iat}`,
|
|
56
|
+
* and JWS wants the raw `r||s` pair — `sign()` produces a DER SEQUENCE unless
|
|
57
|
+
* told otherwise, which Apple rejects with a bare 403 and no explanation.
|
|
58
|
+
*/
|
|
59
|
+
function createProviderToken(key, keyId, teamId) {
|
|
60
|
+
let cached = null;
|
|
61
|
+
return {
|
|
62
|
+
get(now = Date.now()) {
|
|
63
|
+
if (cached !== null && now - cached.issuedAt < TOKEN_TTL_MS) return cached.token;
|
|
64
|
+
const signingInput = `${base64url(JSON.stringify({
|
|
65
|
+
alg: "ES256",
|
|
66
|
+
kid: keyId
|
|
67
|
+
}))}.${base64url(JSON.stringify({
|
|
68
|
+
iss: teamId,
|
|
69
|
+
iat: Math.floor(now / 1e3)
|
|
70
|
+
}))}`;
|
|
71
|
+
const token = `${signingInput}.${base64url(sign("sha256", Buffer.from(signingInput), {
|
|
72
|
+
key,
|
|
73
|
+
dsaEncoding: "ieee-p1363"
|
|
74
|
+
}))}`;
|
|
75
|
+
cached = {
|
|
76
|
+
token,
|
|
77
|
+
issuedAt: now
|
|
78
|
+
};
|
|
79
|
+
return token;
|
|
80
|
+
},
|
|
81
|
+
invalidate() {
|
|
82
|
+
cached = null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* One long-lived HTTP/2 session per environment, reconnected on demand. APNs
|
|
88
|
+
* sends GOAWAY routinely (it rebalances connections), so a dead session is a
|
|
89
|
+
* normal event and not an error worth surfacing — the next push simply dials
|
|
90
|
+
* again.
|
|
91
|
+
*/
|
|
92
|
+
function createSessionPool(hosts) {
|
|
93
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
94
|
+
const failures = /* @__PURE__ */ new Map();
|
|
95
|
+
const drop = (environment, session) => {
|
|
96
|
+
if (sessions.get(environment) === session) sessions.delete(environment);
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
get(environment) {
|
|
100
|
+
const existing = sessions.get(environment);
|
|
101
|
+
if (existing !== void 0 && !existing.closed && !existing.destroyed) return existing;
|
|
102
|
+
const session = connect(hosts[environment]);
|
|
103
|
+
sessions.set(environment, session);
|
|
104
|
+
failures.delete(environment);
|
|
105
|
+
session.on("error", (error) => {
|
|
106
|
+
failures.set(environment, `${error.code ?? "error"}: ${error.message}`);
|
|
107
|
+
drop(environment, session);
|
|
108
|
+
});
|
|
109
|
+
session.on("goaway", (code, _lastStreamId, data) => {
|
|
110
|
+
const detail = data !== void 0 && data.length > 0 ? `: ${data.toString("utf8").slice(0, 200)}` : "";
|
|
111
|
+
failures.set(environment, `GOAWAY ${code}${detail}`);
|
|
112
|
+
drop(environment, session);
|
|
113
|
+
});
|
|
114
|
+
session.on("close", () => drop(environment, session));
|
|
115
|
+
session.setTimeout(5 * 6e4, () => session.close());
|
|
116
|
+
return session;
|
|
117
|
+
},
|
|
118
|
+
lastFailure: (environment) => failures.get(environment),
|
|
119
|
+
close() {
|
|
120
|
+
for (const session of sessions.values()) session.close();
|
|
121
|
+
sessions.clear();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function createApnsClient(config, key, options = {}) {
|
|
126
|
+
const providerToken = createProviderToken(key, config.keyId, config.teamId);
|
|
127
|
+
const pool = createSessionPool(options.hosts ?? HOSTS);
|
|
128
|
+
const post = (request, jwt) => new Promise((resolve) => {
|
|
129
|
+
const body = Buffer.from(JSON.stringify(request.payload));
|
|
130
|
+
let stream;
|
|
131
|
+
try {
|
|
132
|
+
stream = pool.get(request.environment).request({
|
|
133
|
+
":method": "POST",
|
|
134
|
+
":path": `/3/device/${request.deviceToken}`,
|
|
135
|
+
authorization: `bearer ${jwt}`,
|
|
136
|
+
"apns-topic": config.topic,
|
|
137
|
+
"apns-push-type": "alert",
|
|
138
|
+
"apns-priority": String(request.priority ?? 10),
|
|
139
|
+
"apns-expiration": String(request.expiration ?? 0),
|
|
140
|
+
"content-length": String(body.byteLength),
|
|
141
|
+
...request.collapseId === void 0 ? {} : { "apns-collapse-id": request.collapseId }
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
resolve({
|
|
145
|
+
ok: false,
|
|
146
|
+
status: 0,
|
|
147
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
148
|
+
unregistered: false
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let status = 0;
|
|
153
|
+
let apnsId;
|
|
154
|
+
const chunks = [];
|
|
155
|
+
let settled = false;
|
|
156
|
+
const settle = (result) => {
|
|
157
|
+
if (settled) return;
|
|
158
|
+
settled = true;
|
|
159
|
+
resolve(result);
|
|
160
|
+
};
|
|
161
|
+
stream.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
|
162
|
+
stream.close();
|
|
163
|
+
settle({
|
|
164
|
+
ok: false,
|
|
165
|
+
status: 0,
|
|
166
|
+
reason: "Timeout",
|
|
167
|
+
unregistered: false
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
stream.on("response", (headers) => {
|
|
171
|
+
status = Number(headers[":status"] ?? 0);
|
|
172
|
+
const id = headers["apns-id"];
|
|
173
|
+
apnsId = typeof id === "string" ? id : void 0;
|
|
174
|
+
});
|
|
175
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
176
|
+
stream.on("error", (error) => {
|
|
177
|
+
const cause = pool.lastFailure(request.environment);
|
|
178
|
+
settle({
|
|
179
|
+
ok: false,
|
|
180
|
+
status,
|
|
181
|
+
reason: cause !== void 0 ? `${cause} (stream: ${error.message})` : error.message,
|
|
182
|
+
unregistered: false
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
stream.on("end", () => {
|
|
186
|
+
if (status === 200) {
|
|
187
|
+
settle({
|
|
188
|
+
ok: true,
|
|
189
|
+
apnsId
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
let reason = `HTTP ${status}`;
|
|
194
|
+
try {
|
|
195
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
196
|
+
if (typeof parsed.reason === "string") reason = parsed.reason;
|
|
197
|
+
} catch {}
|
|
198
|
+
settle({
|
|
199
|
+
ok: false,
|
|
200
|
+
status,
|
|
201
|
+
reason,
|
|
202
|
+
unregistered: reason === "Unregistered" || reason === "BadDeviceToken"
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
stream.end(body);
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
async send(request) {
|
|
209
|
+
const first = await post(request, providerToken.get());
|
|
210
|
+
if (!first.ok && first.reason === "ExpiredProviderToken") {
|
|
211
|
+
providerToken.invalidate();
|
|
212
|
+
return post(request, providerToken.get());
|
|
213
|
+
}
|
|
214
|
+
return first;
|
|
215
|
+
},
|
|
216
|
+
close: pool.close
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/apns/devices.ts
|
|
221
|
+
const FILENAME = "apns-devices.json";
|
|
222
|
+
/** Apple's tokens are 64 hex chars today. The upper bound is generous because
|
|
223
|
+
* Apple has reserved the right to grow them; the lower one just rejects junk. */
|
|
224
|
+
const TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/;
|
|
225
|
+
const MAX_BODY_BYTES = 4096;
|
|
226
|
+
const isEnvironment = (value) => value === "development" || value === "production";
|
|
227
|
+
/**
|
|
228
|
+
* `dir` null keeps the registry in memory: a restart then forgets every token,
|
|
229
|
+
* which is survivable because the app re-registers on launch, but it does mean
|
|
230
|
+
* an instance with no state dir goes quiet until each phone is next opened.
|
|
231
|
+
*/
|
|
232
|
+
async function createDeviceRegistry(options) {
|
|
233
|
+
const path = options.dir === null ? null : join(options.dir, FILENAME);
|
|
234
|
+
const devices = /* @__PURE__ */ new Map();
|
|
235
|
+
if (path !== null) try {
|
|
236
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
237
|
+
for (const record of parsed.devices ?? []) if (typeof record?.token === "string" && isEnvironment(record.environment)) devices.set(record.token, record);
|
|
238
|
+
} catch {}
|
|
239
|
+
const persist = async () => {
|
|
240
|
+
if (path === null) return;
|
|
241
|
+
try {
|
|
242
|
+
await mkdir(options.dir, {
|
|
243
|
+
recursive: true,
|
|
244
|
+
mode: 448
|
|
245
|
+
});
|
|
246
|
+
await writeFile(path, `${JSON.stringify({ devices: [...devices.values()] }, null, 2)}\n`, { mode: 384 });
|
|
247
|
+
await chmod(path, 384);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
options.onError?.(error, {
|
|
250
|
+
op: "write",
|
|
251
|
+
path
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
return {
|
|
256
|
+
list: () => [...devices.values()],
|
|
257
|
+
async register(record) {
|
|
258
|
+
const existing = devices.get(record.token);
|
|
259
|
+
devices.set(record.token, {
|
|
260
|
+
...record,
|
|
261
|
+
updatedAt: Date.now()
|
|
262
|
+
});
|
|
263
|
+
if (existing !== void 0 && existing.environment === record.environment && existing.hostId === record.hostId) return;
|
|
264
|
+
await persist();
|
|
265
|
+
},
|
|
266
|
+
async remove(token) {
|
|
267
|
+
if (!devices.delete(token)) return;
|
|
268
|
+
await persist();
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const respond = (res, status, body) => {
|
|
273
|
+
res.writeHead(status, {
|
|
274
|
+
"content-type": "application/json",
|
|
275
|
+
"cache-control": "no-store"
|
|
276
|
+
}).end(JSON.stringify(body));
|
|
277
|
+
};
|
|
278
|
+
const readBody = (req) => new Promise((resolve) => {
|
|
279
|
+
const chunks = [];
|
|
280
|
+
let size = 0;
|
|
281
|
+
let settled = false;
|
|
282
|
+
const finish = (value) => {
|
|
283
|
+
if (!settled) {
|
|
284
|
+
settled = true;
|
|
285
|
+
resolve(value);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
req.on("data", (chunk) => {
|
|
289
|
+
size += chunk.length;
|
|
290
|
+
if (size > MAX_BODY_BYTES) {
|
|
291
|
+
finish(null);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
chunks.push(chunk);
|
|
295
|
+
});
|
|
296
|
+
req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
|
297
|
+
req.on("error", () => finish(null));
|
|
298
|
+
});
|
|
299
|
+
/**
|
|
300
|
+
* `POST /apns/devices` to register a token, `DELETE /apns/devices` to drop one.
|
|
301
|
+
* Returns true when it consumed the request.
|
|
302
|
+
*
|
|
303
|
+
* Deliberately outside `/v1`: this is the forwarder's own surface, not part of
|
|
304
|
+
* the protocol `packages/protocol` defines, and a client that finds a 404 here
|
|
305
|
+
* has simply reached a gateway running without push configured.
|
|
306
|
+
*
|
|
307
|
+
* DELETE exists so removing a gateway from the app can stop its pushes. Without
|
|
308
|
+
* it a forgotten server keeps buzzing a phone that no longer has any way to act
|
|
309
|
+
* on what it says.
|
|
310
|
+
*/
|
|
311
|
+
function createDeviceRoute(registry, authenticate) {
|
|
312
|
+
return async (req, res) => {
|
|
313
|
+
let pathname;
|
|
314
|
+
try {
|
|
315
|
+
pathname = new URL(req.url ?? "/", "http://internal").pathname;
|
|
316
|
+
} catch {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
if (pathname !== "/apns/devices") return false;
|
|
320
|
+
if (req.method !== "POST" && req.method !== "DELETE") {
|
|
321
|
+
res.writeHead(405, { allow: "POST, DELETE" }).end();
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
if (authenticate(req) === null) {
|
|
325
|
+
respond(res, 401, { error: "unauthorized" });
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
const raw = await readBody(req);
|
|
329
|
+
if (raw === null) {
|
|
330
|
+
respond(res, 413, { error: "body too large" });
|
|
331
|
+
res.once("finish", () => req.destroy());
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
let body;
|
|
335
|
+
try {
|
|
336
|
+
body = JSON.parse(raw);
|
|
337
|
+
} catch {
|
|
338
|
+
respond(res, 400, { error: "invalid JSON body" });
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
const token = body.token;
|
|
342
|
+
if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
|
|
343
|
+
respond(res, 400, { error: "token must be a hex APNs device token" });
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
if (req.method === "DELETE") {
|
|
347
|
+
await registry.remove(token);
|
|
348
|
+
res.writeHead(204).end();
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
if (!isEnvironment(body.environment)) {
|
|
352
|
+
respond(res, 400, { error: "environment must be 'development' or 'production'" });
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
const optionalString = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
|
|
356
|
+
await registry.register({
|
|
357
|
+
token,
|
|
358
|
+
environment: body.environment,
|
|
359
|
+
hostId: optionalString(body.hostId),
|
|
360
|
+
bundleId: optionalString(body.bundleId),
|
|
361
|
+
platform: optionalString(body.platform)
|
|
362
|
+
});
|
|
363
|
+
respond(res, 200, {
|
|
364
|
+
registered: true,
|
|
365
|
+
environment: body.environment
|
|
366
|
+
});
|
|
367
|
+
return true;
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/apns/forwarder.ts
|
|
372
|
+
/**
|
|
373
|
+
* Turns the server's session notifications into APNs pushes.
|
|
374
|
+
*
|
|
375
|
+
* The architectural point, restated because it is easy to erode: session
|
|
376
|
+
* webhooks are the primitive and this is one consumer of them. It hooks
|
|
377
|
+
* `notifications.onNotification` in-process — same process as the gateway, so
|
|
378
|
+
* there is no HTTP hop — but nothing about the server knows that, and the same
|
|
379
|
+
* events can just as well drive Slack or a custom relay. Push credentials live
|
|
380
|
+
* here and nowhere in `packages/server`.
|
|
381
|
+
*/
|
|
382
|
+
/** APNs caps a payload at 4 KB. Staying well under leaves room for the alert
|
|
383
|
+
* dictionary to grow without anyone rediscovering the limit the hard way. */
|
|
384
|
+
const MAX_PAYLOAD_BYTES = 3800;
|
|
385
|
+
const BODY_LIMIT = 300;
|
|
386
|
+
/** Category identifiers are wire contract with the app: it registers the
|
|
387
|
+
* Approve/Deny actions under this exact string, and a mismatch means a
|
|
388
|
+
* notification that arrives with no buttons on it. */
|
|
389
|
+
const CATEGORY = {
|
|
390
|
+
permission: "PERMISSION_REQUEST",
|
|
391
|
+
event: "SESSION_EVENT"
|
|
392
|
+
};
|
|
393
|
+
const label = (session) => {
|
|
394
|
+
const title = session.title?.trim();
|
|
395
|
+
if (title !== void 0 && title !== "") return title;
|
|
396
|
+
const leaf = session.cwd.split("/").filter(Boolean).at(-1);
|
|
397
|
+
return leaf !== void 0 && leaf !== "" ? leaf : session.id;
|
|
398
|
+
};
|
|
399
|
+
const oneLine = (text, limit = BODY_LIMIT) => {
|
|
400
|
+
const flat = (text ?? "").replace(/\s+/g, " ").trim();
|
|
401
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
402
|
+
};
|
|
403
|
+
/** A collapse id is capped at 64 bytes and a session id has no such bound, so
|
|
404
|
+
* hash rather than truncate — two sessions sharing a prefix must not collapse
|
|
405
|
+
* into each other. */
|
|
406
|
+
const collapseKey = (sessionId) => createHash("sha256").update(sessionId).digest("base64url").slice(0, 32);
|
|
407
|
+
const titleFor = (notification, name) => {
|
|
408
|
+
switch (notification.type) {
|
|
409
|
+
case "permission_requested": return `Approval needed — ${name}`;
|
|
410
|
+
case "turn_completed": return notification.result?.isError === true ? `Turn failed — ${name}` : name;
|
|
411
|
+
case "session_error": return `Session error — ${name}`;
|
|
412
|
+
case "session_closed": return `Session ended — ${name}`;
|
|
413
|
+
default: return name;
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
const bodyFor = (notification) => {
|
|
417
|
+
const preview = oneLine(notification.preview);
|
|
418
|
+
if (preview !== "") return preview;
|
|
419
|
+
switch (notification.type) {
|
|
420
|
+
case "permission_requested": return "The agent is waiting for your approval.";
|
|
421
|
+
case "turn_completed": return "The turn finished.";
|
|
422
|
+
case "session_closed": return `Closed by the ${notification.reason ?? "server"}.`;
|
|
423
|
+
default: return "Something needs your attention.";
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
/**
|
|
427
|
+
* Build the push for one notification.
|
|
428
|
+
*
|
|
429
|
+
* The payload carries routing and nothing else — `sessionId` to deep-link,
|
|
430
|
+
* `requestId` because a lock-screen Approve has nothing to POST to without it,
|
|
431
|
+
* and `hostId` so a client with two gateways knows which one this came from.
|
|
432
|
+
* Everything else the app fetches over REST the moment it opens; a transcript
|
|
433
|
+
* has no business in a 4 KB envelope.
|
|
434
|
+
*/
|
|
435
|
+
function buildPush(notification, hostId) {
|
|
436
|
+
const permission = notification.type === "permission_requested";
|
|
437
|
+
const name = label(notification.session);
|
|
438
|
+
let body = bodyFor(notification);
|
|
439
|
+
const build = (text) => ({
|
|
440
|
+
aps: {
|
|
441
|
+
alert: {
|
|
442
|
+
title: titleFor(notification, name),
|
|
443
|
+
body: text
|
|
444
|
+
},
|
|
445
|
+
sound: "default",
|
|
446
|
+
category: permission ? CATEGORY.permission : CATEGORY.event,
|
|
447
|
+
"thread-id": notification.sessionId
|
|
448
|
+
},
|
|
449
|
+
type: notification.type,
|
|
450
|
+
sessionId: notification.sessionId,
|
|
451
|
+
seq: notification.seq,
|
|
452
|
+
...hostId === void 0 ? {} : { hostId },
|
|
453
|
+
...notification.request === void 0 ? {} : { requestId: notification.request.id }
|
|
454
|
+
});
|
|
455
|
+
let payload = build(body);
|
|
456
|
+
while (Buffer.byteLength(JSON.stringify(payload)) > MAX_PAYLOAD_BYTES && body.length > 16) {
|
|
457
|
+
body = oneLine(body, Math.floor(body.length / 2));
|
|
458
|
+
payload = build(body);
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
payload,
|
|
462
|
+
priority: permission || notification.type === "session_error" ? 10 : 5,
|
|
463
|
+
expiration: permission && notification.request?.expiresAt !== void 0 ? Math.floor(notification.request.expiresAt / 1e3) : Math.floor(Date.now() / 1e3) + 3600,
|
|
464
|
+
...notification.type === "turn_completed" ? { collapseId: `t:${collapseKey(notification.sessionId)}` } : {}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
async function createApnsForwarder(options) {
|
|
468
|
+
const warn = options.warn ?? ((message) => process.stderr.write(`[workerdeck] apns: ${message}\n`));
|
|
469
|
+
const key = await loadApnsKey(options.config.keyFile);
|
|
470
|
+
const client = createApnsClient(options.config, key);
|
|
471
|
+
const registry = await createDeviceRegistry({
|
|
472
|
+
dir: options.stateDir,
|
|
473
|
+
onError: (error, context) => warn(`device registry ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
474
|
+
});
|
|
475
|
+
const handleRequest = createDeviceRoute(registry, options.authenticate);
|
|
476
|
+
const fallbackEnvironment = options.config.production === false ? "development" : "production";
|
|
477
|
+
/** Per-session delivery chain, so a session's pushes arrive in the order the
|
|
478
|
+
* events happened — which matters precisely because `turn_completed` collapses
|
|
479
|
+
* and an out-of-order pair would leave the older text on screen. */
|
|
480
|
+
const chains = /* @__PURE__ */ new Map();
|
|
481
|
+
const deliver = async (notification) => {
|
|
482
|
+
const devices = registry.list();
|
|
483
|
+
if (devices.length === 0) return;
|
|
484
|
+
await Promise.all(devices.map(async (device) => {
|
|
485
|
+
const push = buildPush(notification, device.hostId);
|
|
486
|
+
const result = await client.send({
|
|
487
|
+
...push,
|
|
488
|
+
deviceToken: device.token,
|
|
489
|
+
environment: device.environment ?? fallbackEnvironment
|
|
490
|
+
});
|
|
491
|
+
if (result.ok) return;
|
|
492
|
+
if (result.unregistered) {
|
|
493
|
+
await registry.remove(device.token);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
warn(`push to ${device.token.slice(0, 8)}… failed: ${result.reason} (${result.status})`);
|
|
497
|
+
}));
|
|
498
|
+
};
|
|
499
|
+
return {
|
|
500
|
+
onNotification(notification) {
|
|
501
|
+
const next = (chains.get(notification.sessionId) ?? Promise.resolve()).then(() => deliver(notification).catch((error) => {
|
|
502
|
+
warn(error instanceof Error ? error.message : String(error));
|
|
503
|
+
}));
|
|
504
|
+
chains.set(notification.sessionId, next);
|
|
505
|
+
next.then(() => {
|
|
506
|
+
if (chains.get(notification.sessionId) === next) chains.delete(notification.sessionId);
|
|
507
|
+
});
|
|
508
|
+
},
|
|
509
|
+
handleRequest,
|
|
510
|
+
deviceCount: () => registry.list().length,
|
|
511
|
+
close: client.close
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
//#endregion
|
|
515
|
+
//#region src/auth-key.ts
|
|
516
|
+
/** 48 hex chars — far past `createCliAuth`'s 12-char floor, and header-safe. */
|
|
517
|
+
const generateKey = () => randomBytes(24).toString("hex");
|
|
518
|
+
/** A stored key must be one printable-ASCII line long enough to be a secret:
|
|
519
|
+
* both transports (HTTP header, login form) choke on anything else, and a
|
|
520
|
+
* truncated or garbage file should regenerate, not crash or half-work. */
|
|
521
|
+
const usableStoredKey = (raw) => {
|
|
522
|
+
const line = raw.split("\n", 1)[0]?.trim() ?? "";
|
|
523
|
+
return line.length >= 12 && /^[\x21-\x7e]+$/.test(line) ? line : null;
|
|
524
|
+
};
|
|
525
|
+
async function materializeAuthKey(stateDir, options = {}) {
|
|
526
|
+
const warn = options.warn ?? ((message) => process.stderr.write(`[workerdeck] ${message}\n`));
|
|
527
|
+
if (stateDir === null) return {
|
|
528
|
+
key: generateKey(),
|
|
529
|
+
source: "ephemeral",
|
|
530
|
+
path: null
|
|
531
|
+
};
|
|
532
|
+
const path = join(stateDir, "auth-key");
|
|
533
|
+
let raw = null;
|
|
534
|
+
try {
|
|
535
|
+
raw = await readFile(path, "utf8");
|
|
536
|
+
} catch {
|
|
537
|
+
raw = null;
|
|
538
|
+
}
|
|
539
|
+
if (raw !== null) {
|
|
540
|
+
const key = usableStoredKey(raw);
|
|
541
|
+
if (key !== null) {
|
|
542
|
+
try {
|
|
543
|
+
const { mode } = await stat(path);
|
|
544
|
+
if ((mode & 63) !== 0) warn(`auth key file ${path} is readable by other users (mode ${(mode & 511).toString(8)}) — run: chmod 600 ${path}`);
|
|
545
|
+
} catch {}
|
|
546
|
+
return {
|
|
547
|
+
key,
|
|
548
|
+
source: "stored",
|
|
549
|
+
path
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const key = generateKey();
|
|
554
|
+
await mkdir(stateDir, {
|
|
555
|
+
recursive: true,
|
|
556
|
+
mode: 448
|
|
557
|
+
});
|
|
558
|
+
await writeFile(path, `${key}\n`, { mode: 384 });
|
|
559
|
+
await chmod(path, 384);
|
|
560
|
+
return {
|
|
561
|
+
key,
|
|
562
|
+
source: "created",
|
|
563
|
+
path
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
//#endregion
|
|
567
|
+
//#region src/auth.ts
|
|
568
|
+
const MIN_SECRET_LENGTH = 12;
|
|
569
|
+
const DEFAULT_COOKIE_NAME = "workerdeck_session";
|
|
570
|
+
const DEFAULT_TTL_MS = 10080 * 60 * 1e3;
|
|
571
|
+
const DEFAULT_THROTTLE_WINDOW_MS = 900 * 1e3;
|
|
572
|
+
const DEFAULT_MAX_FAILURES_PER_IP = 10;
|
|
573
|
+
const DEFAULT_MAX_FAILURES_GLOBAL = 100;
|
|
574
|
+
/** Only successful logins insert, so this cap only fences the secret-holder's
|
|
575
|
+
* own memory use (every login within the ttl is a live entry). Oldest goes. */
|
|
576
|
+
const MAX_SESSIONS = 100;
|
|
577
|
+
const MAX_LOGIN_BODY_BYTES = 4096;
|
|
578
|
+
const SAFE_METHODS = new Set([
|
|
579
|
+
"GET",
|
|
580
|
+
"HEAD",
|
|
581
|
+
"OPTIONS"
|
|
582
|
+
]);
|
|
583
|
+
const sha256 = (value) => createHash("sha256").update(value).digest();
|
|
584
|
+
function createCliAuth(options = {}) {
|
|
585
|
+
const { secret } = options;
|
|
586
|
+
const enabled = secret !== void 0;
|
|
587
|
+
if (secret !== void 0 && secret.length < MIN_SECRET_LENGTH) throw new Error(`createCliAuth: secret must be at least ${MIN_SECRET_LENGTH} characters — use a long random value, or leave it unset to run without auth on loopback`);
|
|
588
|
+
const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
589
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
590
|
+
if (!(ttlMs > 0)) throw new Error("createCliAuth: ttlMs must be positive");
|
|
591
|
+
const trustProxy = options.trustProxy === true;
|
|
592
|
+
const windowMs = options.throttle?.windowMs ?? DEFAULT_THROTTLE_WINDOW_MS;
|
|
593
|
+
const maxFailuresPerIp = options.throttle?.maxFailuresPerIp ?? DEFAULT_MAX_FAILURES_PER_IP;
|
|
594
|
+
const maxFailuresGlobal = options.throttle?.maxFailuresGlobal ?? DEFAULT_MAX_FAILURES_GLOBAL;
|
|
595
|
+
const allowedOrigins = new Set((options.allowedOrigins ?? []).map((entry) => {
|
|
596
|
+
try {
|
|
597
|
+
return new URL(entry).origin;
|
|
598
|
+
} catch {
|
|
599
|
+
throw new Error(`createCliAuth: allowedOrigins entry is not a valid origin: ${JSON.stringify(entry)}`);
|
|
600
|
+
}
|
|
601
|
+
}));
|
|
602
|
+
/** Both the raw secret and session tokens are compared as fixed-length SHA-256
|
|
603
|
+
* digests via timingSafeEqual / digest-keyed lookup, so no code path compares
|
|
604
|
+
* secret material byte-by-byte with early exit — and unequal input lengths
|
|
605
|
+
* leak nothing either. */
|
|
606
|
+
const secretDigest = secret === void 0 ? void 0 : sha256(secret);
|
|
607
|
+
const secretMatches = (candidate) => secretDigest !== void 0 && timingSafeEqual(sha256(candidate), secretDigest);
|
|
608
|
+
/**
|
|
609
|
+
* Browser sessions are a server-side table, not signed tokens: logout must
|
|
610
|
+
* actually invalidate, and a stateless HMAC token stays valid until expiry no
|
|
611
|
+
* matter what the server thinks. This is one long-lived process (multi-node
|
|
612
|
+
* is a non-goal), so "table" means one Map — the cost is that a restart signs
|
|
613
|
+
* every browser out, which for an operator dashboard one secret away from
|
|
614
|
+
* re-login is the right trade. Keys are token digests: recovering an entry's
|
|
615
|
+
* token from lookup timing would need a SHA-256 preimage.
|
|
616
|
+
*/
|
|
617
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
618
|
+
const tokenKey = (token) => sha256(token).toString("hex");
|
|
619
|
+
const createSession = () => {
|
|
620
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
621
|
+
const oldest = sessions.keys().next().value;
|
|
622
|
+
if (oldest !== void 0) sessions.delete(oldest);
|
|
623
|
+
}
|
|
624
|
+
const token = randomBytes(32).toString("base64url");
|
|
625
|
+
sessions.set(tokenKey(token), { expiresAt: Date.now() + ttlMs });
|
|
626
|
+
return token;
|
|
627
|
+
};
|
|
628
|
+
const cookieToken = (req) => {
|
|
629
|
+
const header = req.headers.cookie;
|
|
630
|
+
if (typeof header !== "string") return void 0;
|
|
631
|
+
for (const part of header.split(";")) {
|
|
632
|
+
const eq = part.indexOf("=");
|
|
633
|
+
if (eq === -1) continue;
|
|
634
|
+
if (part.slice(0, eq).trim() === cookieName) return part.slice(eq + 1).trim();
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
const hasSession = (req) => {
|
|
638
|
+
const token = cookieToken(req);
|
|
639
|
+
if (token === void 0 || token === "") return false;
|
|
640
|
+
const key = tokenKey(token);
|
|
641
|
+
const entry = sessions.get(key);
|
|
642
|
+
if (entry === void 0) return false;
|
|
643
|
+
if (entry.expiresAt <= Date.now()) {
|
|
644
|
+
sessions.delete(key);
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
return true;
|
|
648
|
+
};
|
|
649
|
+
/** Last value of a possibly comma-joined forwarded header — the one appended
|
|
650
|
+
* (or set) by the trusted proxy; every earlier position is client-writable. */
|
|
651
|
+
const forwardedLast = (value) => {
|
|
652
|
+
if (value === void 0) return void 0;
|
|
653
|
+
const last = (Array.isArray(value) ? value.join(",") : value).split(",").at(-1)?.trim();
|
|
654
|
+
return last === "" ? void 0 : last;
|
|
655
|
+
};
|
|
656
|
+
const isSecure = (req) => {
|
|
657
|
+
if (req.socket.encrypted === true) return true;
|
|
658
|
+
return trustProxy && forwardedLast(req.headers["x-forwarded-proto"])?.toLowerCase() === "https";
|
|
659
|
+
};
|
|
660
|
+
/** The origin this server believes it is being served as, from the request's
|
|
661
|
+
* own Host (or the proxy's forwarded host) — the only self-knowledge we have. */
|
|
662
|
+
const expectedOrigin = (req) => {
|
|
663
|
+
const host = (trustProxy ? forwardedLast(req.headers["x-forwarded-host"]) : void 0) ?? req.headers.host;
|
|
664
|
+
if (host === void 0 || host === "") return null;
|
|
665
|
+
try {
|
|
666
|
+
return new URL(`${isSecure(req) ? "https" : "http"}://${host}`).origin;
|
|
667
|
+
} catch {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
/**
|
|
672
|
+
* The CSRF core. `SameSite=Lax` alone is not enough for two reasons: same
|
|
673
|
+
* *site* is not same *origin* (another port on localhost — any other local
|
|
674
|
+
* web app — is same-site, cookies attach), and the WS handshake is exempt
|
|
675
|
+
* from CORS, so a foreign page that gets the cookie attached can read the
|
|
676
|
+
* stream. So the Origin header is checked explicitly, against the request's
|
|
677
|
+
* own origin (full scheme + authority: an http:// page on the same host must
|
|
678
|
+
* not drive the https:// dashboard) or the operator's allowlist. `Origin:
|
|
679
|
+
* null` and unparseable values are foreign. Verdicts are tri-state because
|
|
680
|
+
* absence means different things per call site: every current browser sends
|
|
681
|
+
* Origin on cross-site POSTs and every WS handshake, so absence means a
|
|
682
|
+
* non-browser client — which carries no ambient cookie and gets to decide
|
|
683
|
+
* per-endpoint below.
|
|
684
|
+
*/
|
|
685
|
+
const originVerdict = (req) => {
|
|
686
|
+
const raw = req.headers.origin;
|
|
687
|
+
if (raw === void 0) return "absent";
|
|
688
|
+
let origin;
|
|
689
|
+
try {
|
|
690
|
+
origin = new URL(raw).origin;
|
|
691
|
+
} catch {
|
|
692
|
+
return "foreign";
|
|
693
|
+
}
|
|
694
|
+
if (allowedOrigins.has(origin)) return "ok";
|
|
695
|
+
const expected = expectedOrigin(req);
|
|
696
|
+
return expected !== null && origin === expected ? "ok" : "foreign";
|
|
697
|
+
};
|
|
698
|
+
const headerSecret = (req) => {
|
|
699
|
+
const key = req.headers["x-workerdeck-key"];
|
|
700
|
+
if (typeof key === "string" && key !== "") return key;
|
|
701
|
+
const authorization = req.headers.authorization;
|
|
702
|
+
if (typeof authorization === "string") {
|
|
703
|
+
const match = /^Bearer\s+(.+)$/i.exec(authorization);
|
|
704
|
+
if (match !== null) return match[1];
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
const openPrincipal = {
|
|
708
|
+
via: "open",
|
|
709
|
+
canManageProfiles: true
|
|
710
|
+
};
|
|
711
|
+
const authenticate = (req) => {
|
|
712
|
+
if (!enabled) return openPrincipal;
|
|
713
|
+
const provided = headerSecret(req);
|
|
714
|
+
if (provided !== void 0) return secretMatches(provided) ? {
|
|
715
|
+
via: "header",
|
|
716
|
+
canManageProfiles: true
|
|
717
|
+
} : null;
|
|
718
|
+
if (!hasSession(req)) return null;
|
|
719
|
+
const verdict = originVerdict(req);
|
|
720
|
+
if (verdict === "foreign") return null;
|
|
721
|
+
const upgrade = req.headers.upgrade;
|
|
722
|
+
const isUpgrade = typeof upgrade === "string" && upgrade.toLowerCase().includes("websocket");
|
|
723
|
+
const unsafe = !SAFE_METHODS.has((req.method ?? "GET").toUpperCase());
|
|
724
|
+
if ((isUpgrade || unsafe) && verdict !== "ok") return null;
|
|
725
|
+
return {
|
|
726
|
+
via: "cookie",
|
|
727
|
+
canManageProfiles: true
|
|
728
|
+
};
|
|
729
|
+
};
|
|
730
|
+
/**
|
|
731
|
+
* Login throttle: the secret is the only factor and the endpoint is reachable
|
|
732
|
+
* by anyone who can reach the port, so guessing must be rate-limited. Failed
|
|
733
|
+
* attempts count per client IP inside a fixed window, with a global cap
|
|
734
|
+
* behind it so rotating IPs (trivial over IPv6) buys an attacker nothing.
|
|
735
|
+
* Only wrong secrets count — malformed requests and foreign-Origin posts are
|
|
736
|
+
* refused earlier precisely so a hostile page cannot burn a victim IP's
|
|
737
|
+
* budget cross-site. The global cap also bounds this map's size: expired
|
|
738
|
+
* entries are swept once it grows past a nominal size.
|
|
739
|
+
*/
|
|
740
|
+
const failures = /* @__PURE__ */ new Map();
|
|
741
|
+
const globalFailures = {
|
|
742
|
+
count: 0,
|
|
743
|
+
windowStart: 0
|
|
744
|
+
};
|
|
745
|
+
const clientIp = (req) => (trustProxy ? forwardedLast(req.headers["x-forwarded-for"]) : void 0) ?? req.socket.remoteAddress ?? "unknown";
|
|
746
|
+
const blockedMs = (entry, max, now) => entry !== void 0 && entry.count >= max && now - entry.windowStart < windowMs ? entry.windowStart + windowMs - now : 0;
|
|
747
|
+
const loginBlockedMs = (ip, now) => Math.max(blockedMs(failures.get(ip), maxFailuresPerIp, now), blockedMs(globalFailures, maxFailuresGlobal, now));
|
|
748
|
+
const recordFailure = (ip, now) => {
|
|
749
|
+
if (failures.size > 256) {
|
|
750
|
+
for (const [key, entry] of failures) if (now - entry.windowStart >= windowMs) failures.delete(key);
|
|
751
|
+
}
|
|
752
|
+
const entry = failures.get(ip);
|
|
753
|
+
if (entry === void 0 || now - entry.windowStart >= windowMs) failures.set(ip, {
|
|
754
|
+
count: 1,
|
|
755
|
+
windowStart: now
|
|
756
|
+
});
|
|
757
|
+
else entry.count += 1;
|
|
758
|
+
if (now - globalFailures.windowStart >= windowMs) {
|
|
759
|
+
globalFailures.count = 1;
|
|
760
|
+
globalFailures.windowStart = now;
|
|
761
|
+
} else globalFailures.count += 1;
|
|
762
|
+
};
|
|
763
|
+
const cookieAttributes = (req) => {
|
|
764
|
+
const attrs = [
|
|
765
|
+
"Path=/",
|
|
766
|
+
"HttpOnly",
|
|
767
|
+
"SameSite=Lax"
|
|
768
|
+
];
|
|
769
|
+
if (isSecure(req)) attrs.push("Secure");
|
|
770
|
+
return attrs;
|
|
771
|
+
};
|
|
772
|
+
const setCookieValue = (token, req) => [
|
|
773
|
+
`${cookieName}=${token}`,
|
|
774
|
+
`Max-Age=${Math.ceil(ttlMs / 1e3)}`,
|
|
775
|
+
...cookieAttributes(req)
|
|
776
|
+
].join("; ");
|
|
777
|
+
const clearCookieValue = (req) => [
|
|
778
|
+
`${cookieName}=`,
|
|
779
|
+
"Max-Age=0",
|
|
780
|
+
...cookieAttributes(req)
|
|
781
|
+
].join("; ");
|
|
782
|
+
const respondJson = (res, status, body, headers) => {
|
|
783
|
+
res.writeHead(status, {
|
|
784
|
+
"content-type": "application/json",
|
|
785
|
+
"cache-control": "no-store",
|
|
786
|
+
...headers
|
|
787
|
+
}).end(JSON.stringify(body));
|
|
788
|
+
};
|
|
789
|
+
const respondRedirect = (res, location, headers) => {
|
|
790
|
+
res.writeHead(303, {
|
|
791
|
+
location,
|
|
792
|
+
"cache-control": "no-store",
|
|
793
|
+
...headers
|
|
794
|
+
}).end();
|
|
795
|
+
};
|
|
796
|
+
/** JSON responses when the client asks for them, 303 redirects otherwise —
|
|
797
|
+
* so a dependency-free `<form method="post">` login page works without JS,
|
|
798
|
+
* and a fetch()-based one gets real status codes. */
|
|
799
|
+
const wantsJson = (req) => (req.headers.accept ?? "").includes("application/json");
|
|
800
|
+
const readBody = (req, maxBytes) => new Promise((resolve) => {
|
|
801
|
+
const chunks = [];
|
|
802
|
+
let size = 0;
|
|
803
|
+
let settled = false;
|
|
804
|
+
const finish = (value) => {
|
|
805
|
+
if (!settled) {
|
|
806
|
+
settled = true;
|
|
807
|
+
resolve(value);
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
req.on("data", (chunk) => {
|
|
811
|
+
size += chunk.length;
|
|
812
|
+
if (size > maxBytes) {
|
|
813
|
+
finish(null);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
chunks.push(chunk);
|
|
817
|
+
});
|
|
818
|
+
req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
|
819
|
+
req.on("error", () => finish(null));
|
|
820
|
+
});
|
|
821
|
+
const handleLogin = async (req, res) => {
|
|
822
|
+
const json = wantsJson(req);
|
|
823
|
+
if (!enabled) {
|
|
824
|
+
respondJson(res, 409, { error: "auth is disabled: no secret is configured" });
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (originVerdict(req) === "foreign") {
|
|
828
|
+
respondJson(res, 403, { error: "origin not allowed" });
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const ip = clientIp(req);
|
|
832
|
+
const blocked = loginBlockedMs(ip, Date.now());
|
|
833
|
+
if (blocked > 0) {
|
|
834
|
+
const retryAfter = String(Math.ceil(blocked / 1e3));
|
|
835
|
+
if (json) respondJson(res, 429, { error: "too many failed attempts" }, { "retry-after": retryAfter });
|
|
836
|
+
else respondRedirect(res, "/?auth=throttled", { "retry-after": retryAfter });
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const body = await readBody(req, MAX_LOGIN_BODY_BYTES);
|
|
840
|
+
if (body === null) {
|
|
841
|
+
respondJson(res, 413, { error: "body too large" });
|
|
842
|
+
res.once("finish", () => req.destroy());
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const contentType = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
|
|
846
|
+
let candidate;
|
|
847
|
+
if (contentType === "application/x-www-form-urlencoded") candidate = new URLSearchParams(body).get("secret");
|
|
848
|
+
else if (contentType === "application/json") try {
|
|
849
|
+
candidate = JSON.parse(body).secret;
|
|
850
|
+
} catch {
|
|
851
|
+
respondJson(res, 400, { error: "invalid body" });
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
respondJson(res, 415, { error: "expected application/x-www-form-urlencoded or application/json" });
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (typeof candidate !== "string" || candidate === "") {
|
|
859
|
+
respondJson(res, 400, { error: "missing secret" });
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (!secretMatches(candidate)) {
|
|
863
|
+
recordFailure(ip, Date.now());
|
|
864
|
+
if (json) respondJson(res, 401, { error: "invalid secret" });
|
|
865
|
+
else respondRedirect(res, "/?auth=failed");
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
failures.delete(ip);
|
|
869
|
+
const cookie = setCookieValue(createSession(), req);
|
|
870
|
+
if (json) res.writeHead(204, {
|
|
871
|
+
"set-cookie": cookie,
|
|
872
|
+
"cache-control": "no-store"
|
|
873
|
+
}).end();
|
|
874
|
+
else respondRedirect(res, "/", { "set-cookie": cookie });
|
|
875
|
+
};
|
|
876
|
+
const handleLogout = (req, res) => {
|
|
877
|
+
const json = wantsJson(req);
|
|
878
|
+
if (enabled) {
|
|
879
|
+
if (originVerdict(req) === "foreign") {
|
|
880
|
+
respondJson(res, 403, { error: "origin not allowed" });
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const token = cookieToken(req);
|
|
884
|
+
if (token !== void 0 && token !== "") sessions.delete(tokenKey(token));
|
|
885
|
+
}
|
|
886
|
+
const cookie = clearCookieValue(req);
|
|
887
|
+
if (json) res.writeHead(204, {
|
|
888
|
+
"set-cookie": cookie,
|
|
889
|
+
"cache-control": "no-store"
|
|
890
|
+
}).end();
|
|
891
|
+
else respondRedirect(res, "/", { "set-cookie": cookie });
|
|
892
|
+
};
|
|
893
|
+
const handleAuthRoute = async (pathname, req, res) => {
|
|
894
|
+
if (pathname === "/auth/status") {
|
|
895
|
+
if (req.method !== "GET") respondJson(res, 405, { error: "method not allowed" }, { allow: "GET" });
|
|
896
|
+
else respondJson(res, 200, {
|
|
897
|
+
enabled,
|
|
898
|
+
authenticated: enabled ? hasSession(req) : true
|
|
899
|
+
});
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if (pathname === "/auth/login") {
|
|
903
|
+
if (req.method !== "POST") respondJson(res, 405, { error: "method not allowed" }, { allow: "POST" });
|
|
904
|
+
else await handleLogin(req, res);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (pathname === "/auth/logout") {
|
|
908
|
+
if (req.method !== "POST") respondJson(res, 405, { error: "method not allowed" }, { allow: "POST" });
|
|
909
|
+
else handleLogout(req, res);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
respondJson(res, 404, { error: "not found" });
|
|
913
|
+
};
|
|
914
|
+
const handleAuthRequest = (req, res) => {
|
|
915
|
+
let pathname;
|
|
916
|
+
try {
|
|
917
|
+
pathname = new URL(req.url ?? "/", "http://internal").pathname;
|
|
918
|
+
} catch {
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
if (pathname !== "/auth" && !pathname.startsWith("/auth/")) return false;
|
|
922
|
+
return handleAuthRoute(pathname, req, res).then(() => true);
|
|
923
|
+
};
|
|
924
|
+
const loginPage = (req) => {
|
|
925
|
+
let reason = null;
|
|
926
|
+
try {
|
|
927
|
+
reason = new URL(req.url ?? "/", "http://internal").searchParams.get("auth");
|
|
928
|
+
} catch {
|
|
929
|
+
reason = null;
|
|
930
|
+
}
|
|
931
|
+
return {
|
|
932
|
+
action: "/auth/login",
|
|
933
|
+
field: "secret",
|
|
934
|
+
error: reason === "failed" ? "Invalid access key. Try again." : reason === "throttled" ? "Too many failed attempts. Wait a few minutes, then try again." : void 0
|
|
935
|
+
};
|
|
936
|
+
};
|
|
937
|
+
return {
|
|
938
|
+
enabled,
|
|
939
|
+
authenticate,
|
|
940
|
+
handleAuthRequest,
|
|
941
|
+
hasValidSession: (req) => enabled ? hasSession(req) : true,
|
|
942
|
+
loginPage
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
//#endregion
|
|
946
|
+
//#region src/config.ts
|
|
947
|
+
/**
|
|
948
|
+
* The config surface has to be JavaScript, not JSON: the two options a real
|
|
949
|
+
* deployment always needs — `authenticate` and `buildRunnerConfig` — are
|
|
950
|
+
* functions. So the file default-exports `WorkerServerOptions` (optionally as a
|
|
951
|
+
* function, sync or async, for config that has to await something), and flags
|
|
952
|
+
* and env cover the cases that fit on a command line.
|
|
953
|
+
*
|
|
954
|
+
* Precedence, narrowest wins: flags > env > config file > defaults. A config
|
|
955
|
+
* file that sets `authenticate` itself opts out of the built-in shared-secret
|
|
956
|
+
* auth entirely — see `resolveInstanceConfig`.
|
|
957
|
+
*/
|
|
958
|
+
const CONFIG_BASENAMES = [
|
|
959
|
+
"workerdeck.config.mjs",
|
|
960
|
+
"workerdeck.config.js",
|
|
961
|
+
"workerdeck.config.cjs"
|
|
962
|
+
];
|
|
963
|
+
var ConfigError = class extends Error {};
|
|
964
|
+
function parsePort(raw, source) {
|
|
965
|
+
const port = Number(raw);
|
|
966
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new ConfigError(`${source}: not a valid port: ${raw}`);
|
|
967
|
+
return port;
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Hand-rolled rather than a dependency: the CLI's whole value is that `npx
|
|
971
|
+
* workerdeck` pulls down a small tree, and an arg parser is a hundred lines
|
|
972
|
+
* of it.
|
|
973
|
+
*/
|
|
974
|
+
function parseArgs(argv) {
|
|
975
|
+
const flags = {
|
|
976
|
+
profiles: [],
|
|
977
|
+
cwdRoots: [],
|
|
978
|
+
fsRoots: [],
|
|
979
|
+
allowedOrigins: [],
|
|
980
|
+
allowedHosts: [],
|
|
981
|
+
insecureHosts: []
|
|
982
|
+
};
|
|
983
|
+
const next = (i, name) => {
|
|
984
|
+
const value = argv[i + 1];
|
|
985
|
+
if (value === void 0 || value.startsWith("-")) throw new ConfigError(`${name} requires a value`);
|
|
986
|
+
return value;
|
|
987
|
+
};
|
|
988
|
+
for (let i = 0; i < argv.length; i++) {
|
|
989
|
+
const arg = argv[i];
|
|
990
|
+
switch (arg) {
|
|
991
|
+
case "-h":
|
|
992
|
+
case "--help":
|
|
993
|
+
flags.help = true;
|
|
994
|
+
break;
|
|
995
|
+
case "-v":
|
|
996
|
+
case "--version":
|
|
997
|
+
flags.version = true;
|
|
998
|
+
break;
|
|
999
|
+
case "-c":
|
|
1000
|
+
case "--config":
|
|
1001
|
+
flags.config = next(i, arg);
|
|
1002
|
+
i++;
|
|
1003
|
+
break;
|
|
1004
|
+
case "-p":
|
|
1005
|
+
case "--port":
|
|
1006
|
+
flags.port = parsePort(next(i, arg), arg);
|
|
1007
|
+
i++;
|
|
1008
|
+
break;
|
|
1009
|
+
case "--host":
|
|
1010
|
+
flags.host = next(i, arg);
|
|
1011
|
+
i++;
|
|
1012
|
+
break;
|
|
1013
|
+
case "--auth-key":
|
|
1014
|
+
flags.authKey = next(i, arg);
|
|
1015
|
+
i++;
|
|
1016
|
+
break;
|
|
1017
|
+
case "--profile": {
|
|
1018
|
+
const raw = next(i, arg);
|
|
1019
|
+
i++;
|
|
1020
|
+
const eq = raw.indexOf("=");
|
|
1021
|
+
if (eq <= 0) throw new ConfigError(`--profile expects name=dir, got: ${raw}`);
|
|
1022
|
+
const name = raw.slice(0, eq);
|
|
1023
|
+
const dir = raw.slice(eq + 1);
|
|
1024
|
+
if (!dir) throw new ConfigError(`--profile ${name}= is missing a directory`);
|
|
1025
|
+
flags.profiles.push({
|
|
1026
|
+
name,
|
|
1027
|
+
configDir: resolve(dir)
|
|
1028
|
+
});
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
case "--cwd-root":
|
|
1032
|
+
flags.cwdRoots.push(resolve(next(i, arg)));
|
|
1033
|
+
i++;
|
|
1034
|
+
break;
|
|
1035
|
+
case "--fs-root":
|
|
1036
|
+
flags.fsRoots.push(resolve(next(i, arg)));
|
|
1037
|
+
i++;
|
|
1038
|
+
break;
|
|
1039
|
+
case "--fs-write":
|
|
1040
|
+
flags.fsWrite = true;
|
|
1041
|
+
break;
|
|
1042
|
+
case "--allowed-origin":
|
|
1043
|
+
flags.allowedOrigins.push(next(i, arg));
|
|
1044
|
+
i++;
|
|
1045
|
+
break;
|
|
1046
|
+
case "--allowed-host":
|
|
1047
|
+
flags.allowedHosts.push(next(i, arg));
|
|
1048
|
+
i++;
|
|
1049
|
+
break;
|
|
1050
|
+
case "--insecure-host":
|
|
1051
|
+
flags.insecureHosts.push(next(i, arg));
|
|
1052
|
+
i++;
|
|
1053
|
+
break;
|
|
1054
|
+
case "--trust-proxy":
|
|
1055
|
+
flags.trustProxy = true;
|
|
1056
|
+
break;
|
|
1057
|
+
case "--state-dir":
|
|
1058
|
+
flags.stateDir = resolve(next(i, arg));
|
|
1059
|
+
i++;
|
|
1060
|
+
break;
|
|
1061
|
+
case "--no-parking-store":
|
|
1062
|
+
flags.parking = false;
|
|
1063
|
+
break;
|
|
1064
|
+
case "--insecure":
|
|
1065
|
+
flags.insecure = true;
|
|
1066
|
+
break;
|
|
1067
|
+
case "--open":
|
|
1068
|
+
flags.open = true;
|
|
1069
|
+
break;
|
|
1070
|
+
default: throw new ConfigError(`unknown option: ${arg}`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
return flags;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Explicit `--config` must exist — a typo that silently starts a default
|
|
1077
|
+
* instance is worse than a failure. An implicit one is looked up in cwd only:
|
|
1078
|
+
* walking parent directories would make what a given command does depend on
|
|
1079
|
+
* where it was run from.
|
|
1080
|
+
*/
|
|
1081
|
+
async function loadConfigFile(explicit, cwd = process.cwd()) {
|
|
1082
|
+
let path = null;
|
|
1083
|
+
if (explicit) {
|
|
1084
|
+
path = isAbsolute(explicit) ? explicit : resolve(cwd, explicit);
|
|
1085
|
+
if (!existsSync(path)) throw new ConfigError(`no config file at ${path}`);
|
|
1086
|
+
} else path = CONFIG_BASENAMES.map((name) => join(cwd, name)).find((p) => existsSync(p)) ?? null;
|
|
1087
|
+
if (!path) return {
|
|
1088
|
+
path: null,
|
|
1089
|
+
options: {}
|
|
1090
|
+
};
|
|
1091
|
+
let mod;
|
|
1092
|
+
try {
|
|
1093
|
+
mod = await import(pathToFileURL(path).href);
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
throw new ConfigError(`failed to load ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1096
|
+
}
|
|
1097
|
+
const exported = mod.default;
|
|
1098
|
+
if (exported === void 0) throw new ConfigError(`${path} has no default export (expected WorkerServerOptions)`);
|
|
1099
|
+
const options = typeof exported === "function" ? await exported() : exported;
|
|
1100
|
+
if (typeof options !== "object" || options === null) throw new ConfigError(`${path} default export is not an options object`);
|
|
1101
|
+
return {
|
|
1102
|
+
path,
|
|
1103
|
+
options
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
const LOOPBACK = new Set([
|
|
1107
|
+
"127.0.0.1",
|
|
1108
|
+
"::1",
|
|
1109
|
+
"localhost",
|
|
1110
|
+
"::ffff:127.0.0.1"
|
|
1111
|
+
]);
|
|
1112
|
+
function isLoopback(host) {
|
|
1113
|
+
return LOOPBACK.has(host);
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Durable parking is on by default because this is a long-lived instance: a
|
|
1117
|
+
* turnkey tool that silently drops parked work on every restart is the wrong
|
|
1118
|
+
* default. The store writes whole transcripts in plaintext, so it goes beside
|
|
1119
|
+
* the config file (or under the home directory) rather than anywhere temporary,
|
|
1120
|
+
* and one directory serves exactly one instance — the store is single-process
|
|
1121
|
+
* by design, which the single-port model already implies.
|
|
1122
|
+
*/
|
|
1123
|
+
function defaultStateDir(configPath) {
|
|
1124
|
+
return configPath ? join(dirname(configPath), ".workerdeck") : join(homedir(), ".workerdeck");
|
|
1125
|
+
}
|
|
1126
|
+
/** Hostname out of a Host header, minus the port and any IPv6 brackets. */
|
|
1127
|
+
function hostnameOf(hostHeader) {
|
|
1128
|
+
try {
|
|
1129
|
+
return new URL(`http://${hostHeader}`).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
1130
|
+
} catch {
|
|
1131
|
+
return "";
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
/** 127.0.0.0/8, ::1, and the names that mean them. */
|
|
1135
|
+
function isLoopbackHostname(hostname) {
|
|
1136
|
+
if (LOOPBACK.has(hostname)) return true;
|
|
1137
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* An `insecureHosts` entry names a host, never an endpoint: it is compared
|
|
1141
|
+
* against the bind host and against Host headers, and both are portless by the
|
|
1142
|
+
* time they are compared. An entry carrying a port would therefore never match
|
|
1143
|
+
* anything — a gate that looks armed and is not — so it is rejected loudly, as
|
|
1144
|
+
* is anything that does not parse as a host name or address. Bare IPv6 is
|
|
1145
|
+
* bracketed before parsing (WHATWG URL requires that), and the result is
|
|
1146
|
+
* lowercased to match `hostnameOf`'s normal form.
|
|
1147
|
+
*/
|
|
1148
|
+
function normalizeInsecureHost(raw) {
|
|
1149
|
+
const entry = raw.trim();
|
|
1150
|
+
const looksLikeNamePort = /^[^:]+:\d+$/.test(entry);
|
|
1151
|
+
const candidate = entry.includes(":") && !entry.startsWith("[") && !looksLikeNamePort ? `[${entry}]` : entry;
|
|
1152
|
+
let url;
|
|
1153
|
+
try {
|
|
1154
|
+
url = new URL(`http://${candidate}`);
|
|
1155
|
+
} catch {
|
|
1156
|
+
throw new ConfigError(`not a host name or address in insecureHosts: ${JSON.stringify(raw)}`);
|
|
1157
|
+
}
|
|
1158
|
+
if (url.port !== "") throw new ConfigError(`insecureHosts entry ${JSON.stringify(raw)} carries a port — name the host alone; the bind host and the Host header are both compared portless`);
|
|
1159
|
+
return url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
1160
|
+
}
|
|
1161
|
+
function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.cwd()) {
|
|
1162
|
+
const envPort = env.WORKERDECK_PORT ? parsePort(env.WORKERDECK_PORT, "WORKERDECK_PORT") : void 0;
|
|
1163
|
+
const port = flags.port ?? envPort ?? loaded.options.port ?? 8787;
|
|
1164
|
+
const host = flags.host ?? env.WORKERDECK_HOST ?? loaded.options.host ?? "127.0.0.1";
|
|
1165
|
+
const authKey = flags.authKey || env.WORKERDECK_AUTH_KEY || loaded.options.auth?.secret || void 0;
|
|
1166
|
+
const hostAuthenticates = typeof loaded.options.authenticate === "function";
|
|
1167
|
+
const insecureHosts = new Set([...flags.insecureHosts, ...loaded.options.insecureHosts ?? []].map(normalizeInsecureHost));
|
|
1168
|
+
/** The bind host in the same normal form the entries were put in. It never
|
|
1169
|
+
* carries a port — that is a separate flag — so only brackets and case vary. */
|
|
1170
|
+
const bindHost = host.trim().replace(/^\[|\]$/g, "").toLowerCase();
|
|
1171
|
+
const generateAuthKey = !authKey && !hostAuthenticates && !loaded.options.allowUnauthenticated && !isLoopback(host) && !flags.insecure && !insecureHosts.has(bindHost);
|
|
1172
|
+
const stateDir = flags.parking === false || loaded.options.stateDir === null ? null : flags.stateDir ?? env.WORKERDECK_STATE_DIR ?? loaded.options.stateDir ?? defaultStateDir(loaded.path);
|
|
1173
|
+
const envCwdRoots = env.WORKERDECK_CWD_ROOTS?.split(":").filter(Boolean).map((p) => resolve(cwd, p));
|
|
1174
|
+
const cwdRoots = flags.cwdRoots.length ? flags.cwdRoots : envCwdRoots;
|
|
1175
|
+
const envFsRoots = env.WORKERDECK_FS_ROOTS?.split(":").filter(Boolean).map((p) => resolve(cwd, p));
|
|
1176
|
+
const fsRoots = flags.fsRoots.length ? flags.fsRoots : envFsRoots;
|
|
1177
|
+
const auth = {
|
|
1178
|
+
...loaded.options.auth,
|
|
1179
|
+
secret: authKey,
|
|
1180
|
+
trustProxy: flags.trustProxy ?? loaded.options.auth?.trustProxy,
|
|
1181
|
+
allowedOrigins: [...loaded.options.auth?.allowedOrigins ?? [], ...flags.allowedOrigins]
|
|
1182
|
+
};
|
|
1183
|
+
const allowedHosts = Boolean(authKey) || hostAuthenticates || generateAuthKey ? null : new Set([
|
|
1184
|
+
...LOOPBACK,
|
|
1185
|
+
...[...flags.allowedHosts, ...loaded.options.allowedHosts ?? []].map((name) => name.toLowerCase()),
|
|
1186
|
+
...insecureHosts
|
|
1187
|
+
]);
|
|
1188
|
+
const { port: _p, host: _h, auth: _a, stateDir: _s, allowedHosts: _ah, insecureHosts: _ih, webRoot: _w, apns: _ap, ...serverOptions } = loaded.options;
|
|
1189
|
+
const options = { ...serverOptions };
|
|
1190
|
+
if (cwdRoots?.length) options.allowedCwdRoots = cwdRoots;
|
|
1191
|
+
if (fsRoots?.length || flags.fsWrite) options.hostFiles = {
|
|
1192
|
+
...loaded.options.hostFiles,
|
|
1193
|
+
...fsRoots?.length ? { roots: fsRoots } : {},
|
|
1194
|
+
...flags.fsWrite ? { write: true } : {}
|
|
1195
|
+
};
|
|
1196
|
+
if (flags.profiles.length) options.profiles = flags.profiles;
|
|
1197
|
+
return {
|
|
1198
|
+
port,
|
|
1199
|
+
host,
|
|
1200
|
+
authKey,
|
|
1201
|
+
auth,
|
|
1202
|
+
stateDir,
|
|
1203
|
+
configPath: loaded.path,
|
|
1204
|
+
hostAuthenticates,
|
|
1205
|
+
generateAuthKey,
|
|
1206
|
+
allowedHosts,
|
|
1207
|
+
webRoot: loaded.options.webRoot,
|
|
1208
|
+
apns: resolveApns(loaded),
|
|
1209
|
+
open: flags.open ?? false,
|
|
1210
|
+
options
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* A relative `keyFile` resolves against the config file's own directory, not the
|
|
1215
|
+
* cwd — the same convention `defaultStateDir` uses, and the one that makes a
|
|
1216
|
+
* deployment directory self-contained. Missing required fields throw here rather
|
|
1217
|
+
* than at the first push, since a half-configured forwarder is a notification
|
|
1218
|
+
* that silently never arrives.
|
|
1219
|
+
*/
|
|
1220
|
+
function resolveApns(loaded) {
|
|
1221
|
+
const apns = loaded.options.apns;
|
|
1222
|
+
if (!apns) return void 0;
|
|
1223
|
+
for (const field of [
|
|
1224
|
+
"keyFile",
|
|
1225
|
+
"keyId",
|
|
1226
|
+
"teamId",
|
|
1227
|
+
"topic"
|
|
1228
|
+
]) if (typeof apns[field] !== "string" || apns[field] === "") throw new ConfigError(`apns.${field} is required when apns is configured`);
|
|
1229
|
+
const base = loaded.path ? dirname(loaded.path) : process.cwd();
|
|
1230
|
+
return {
|
|
1231
|
+
...apns,
|
|
1232
|
+
keyFile: resolve(base, apns.keyFile)
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
//#endregion
|
|
1236
|
+
//#region src/login-page.ts
|
|
1237
|
+
const escapeHtml = (value) => value.replace(/[&<>"']/g, (c) => ({
|
|
1238
|
+
"&": "&",
|
|
1239
|
+
"<": "<",
|
|
1240
|
+
">": ">",
|
|
1241
|
+
"\"": """,
|
|
1242
|
+
"'": "'"
|
|
1243
|
+
})[c] ?? c);
|
|
1244
|
+
function renderLoginPage(options) {
|
|
1245
|
+
const { action, field, error, redirectTo, redirectField } = options;
|
|
1246
|
+
const hidden = redirectField && redirectTo ? `<input type="hidden" name="${escapeHtml(redirectField)}" value="${escapeHtml(redirectTo)}">` : "";
|
|
1247
|
+
const alert = error ? `<p class="error" role="alert">${escapeHtml(error)}</p>` : "";
|
|
1248
|
+
return `<!doctype html>
|
|
1249
|
+
<html lang="en">
|
|
1250
|
+
<head>
|
|
1251
|
+
<meta charset="utf-8">
|
|
1252
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1253
|
+
<meta name="robots" content="noindex">
|
|
1254
|
+
<title>workerdeck</title>
|
|
1255
|
+
<style>
|
|
1256
|
+
:root {
|
|
1257
|
+
color-scheme: light dark;
|
|
1258
|
+
--bg: #fbfaf9; --fg: #1c1917; --muted: #78716c;
|
|
1259
|
+
--card: #ffffff; --border: #e7e5e4; --accent: #c2410c; --error: #b91c1c;
|
|
1260
|
+
}
|
|
1261
|
+
@media (prefers-color-scheme: dark) {
|
|
1262
|
+
:root {
|
|
1263
|
+
--bg: #1c1917; --fg: #fafaf9; --muted: #a8a29e;
|
|
1264
|
+
--card: #292524; --border: #44403c; --accent: #fb923c; --error: #fca5a5;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
* { box-sizing: border-box; }
|
|
1268
|
+
body {
|
|
1269
|
+
margin: 0; min-height: 100dvh; display: grid; place-items: center; padding: 1.5rem;
|
|
1270
|
+
background: var(--bg); color: var(--fg);
|
|
1271
|
+
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
1272
|
+
}
|
|
1273
|
+
form {
|
|
1274
|
+
width: 100%; max-width: 22rem; background: var(--card);
|
|
1275
|
+
border: 1px solid var(--border); border-radius: 12px; padding: 1.75rem;
|
|
1276
|
+
}
|
|
1277
|
+
h1 { margin: 0 0 .25rem; font-size: 1.05rem; letter-spacing: -0.01em; }
|
|
1278
|
+
p.sub { margin: 0 0 1.25rem; color: var(--muted); font-size: .875rem; }
|
|
1279
|
+
label { display: block; font-size: .8125rem; font-weight: 500; margin-bottom: .375rem; }
|
|
1280
|
+
input[type=password] {
|
|
1281
|
+
width: 100%; padding: .5rem .625rem; font: inherit; color: inherit;
|
|
1282
|
+
background: var(--bg); border: 1px solid var(--border); border-radius: 7px;
|
|
1283
|
+
}
|
|
1284
|
+
input[type=password]:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
1285
|
+
button {
|
|
1286
|
+
width: 100%; margin-top: 1rem; padding: .5rem .75rem; font: inherit; font-weight: 500;
|
|
1287
|
+
color: var(--bg); background: var(--fg); border: 0; border-radius: 7px; cursor: pointer;
|
|
1288
|
+
}
|
|
1289
|
+
button:hover { opacity: .9; }
|
|
1290
|
+
.error { margin: 0 0 1rem; color: var(--error); font-size: .8125rem; }
|
|
1291
|
+
</style>
|
|
1292
|
+
</head>
|
|
1293
|
+
<body>
|
|
1294
|
+
<form method="post" action="${escapeHtml(action)}">
|
|
1295
|
+
<h1>workerdeck</h1>
|
|
1296
|
+
<p class="sub">This instance is protected. Enter its access key to continue.</p>
|
|
1297
|
+
${alert}
|
|
1298
|
+
<label for="key">Access key</label>
|
|
1299
|
+
<input id="key" name="${escapeHtml(field)}" type="password" autocomplete="current-password"
|
|
1300
|
+
autofocus required spellcheck="false">
|
|
1301
|
+
${hidden}
|
|
1302
|
+
<button type="submit">Sign in</button>
|
|
1303
|
+
</form>
|
|
1304
|
+
</body>
|
|
1305
|
+
</html>
|
|
1306
|
+
`;
|
|
1307
|
+
}
|
|
1308
|
+
//#endregion
|
|
1309
|
+
//#region src/static.ts
|
|
1310
|
+
/**
|
|
1311
|
+
* Static file serving for the bundled dashboard. Deliberately policy-free: what
|
|
1312
|
+
* counts as a document, and whether an unauthenticated visitor gets the app or a
|
|
1313
|
+
* login page, is decided by the caller (see `instance.ts`). This module only
|
|
1314
|
+
* answers "is there such a file, and what headers does it want".
|
|
1315
|
+
*/
|
|
1316
|
+
const CONTENT_TYPES = {
|
|
1317
|
+
".html": "text/html; charset=utf-8",
|
|
1318
|
+
".js": "text/javascript; charset=utf-8",
|
|
1319
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
1320
|
+
".css": "text/css; charset=utf-8",
|
|
1321
|
+
".json": "application/json; charset=utf-8",
|
|
1322
|
+
".map": "application/json; charset=utf-8",
|
|
1323
|
+
".svg": "image/svg+xml",
|
|
1324
|
+
".png": "image/png",
|
|
1325
|
+
".jpg": "image/jpeg",
|
|
1326
|
+
".jpeg": "image/jpeg",
|
|
1327
|
+
".webp": "image/webp",
|
|
1328
|
+
".avif": "image/avif",
|
|
1329
|
+
".ico": "image/x-icon",
|
|
1330
|
+
".woff": "font/woff",
|
|
1331
|
+
".woff2": "font/woff2",
|
|
1332
|
+
".ttf": "font/ttf",
|
|
1333
|
+
".wasm": "application/wasm",
|
|
1334
|
+
".txt": "text/plain; charset=utf-8"
|
|
1335
|
+
};
|
|
1336
|
+
function contentTypeFor(pathname) {
|
|
1337
|
+
const dot = pathname.lastIndexOf(".");
|
|
1338
|
+
if (dot < 0) return "application/octet-stream";
|
|
1339
|
+
return CONTENT_TYPES[pathname.slice(dot).toLowerCase()] ?? "application/octet-stream";
|
|
1340
|
+
}
|
|
1341
|
+
/** A request for a file rather than an app route: anything with a known extension. */
|
|
1342
|
+
function looksLikeAsset(pathname) {
|
|
1343
|
+
const dot = pathname.lastIndexOf(".");
|
|
1344
|
+
if (dot < 0) return false;
|
|
1345
|
+
return pathname.slice(dot).toLowerCase() in CONTENT_TYPES;
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Resolve `pathname` inside `root`, or null if it escapes. Vite emits every
|
|
1349
|
+
* asset under a content-hashed name, so the only paths that ever reach here are
|
|
1350
|
+
* ones the app itself generated — but this server is reachable by anything that
|
|
1351
|
+
* can open a socket, and `..` in a URL is the oldest trick there is.
|
|
1352
|
+
*/
|
|
1353
|
+
function resolveWithinRoot(root, pathname) {
|
|
1354
|
+
let decoded;
|
|
1355
|
+
try {
|
|
1356
|
+
decoded = decodeURIComponent(pathname);
|
|
1357
|
+
} catch {
|
|
1358
|
+
return null;
|
|
1359
|
+
}
|
|
1360
|
+
if (decoded.includes("\0")) return null;
|
|
1361
|
+
const candidate = resolve(join(root, decoded));
|
|
1362
|
+
const base = resolve(root);
|
|
1363
|
+
if (candidate !== base && !candidate.startsWith(base + sep)) return null;
|
|
1364
|
+
return candidate;
|
|
1365
|
+
}
|
|
1366
|
+
function sendHtml(req, res, status, html, cache) {
|
|
1367
|
+
const body = Buffer.from(html, "utf8");
|
|
1368
|
+
res.writeHead(status, {
|
|
1369
|
+
"content-type": "text/html; charset=utf-8",
|
|
1370
|
+
"content-length": body.byteLength,
|
|
1371
|
+
"cache-control": cache,
|
|
1372
|
+
"x-frame-options": "DENY",
|
|
1373
|
+
"x-content-type-options": "nosniff",
|
|
1374
|
+
"referrer-policy": "same-origin"
|
|
1375
|
+
});
|
|
1376
|
+
res.end(req.method === "HEAD" ? void 0 : body);
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Stream a file out of `root`. `immutable` is the caller's call, because it is a
|
|
1380
|
+
* promise about the URL, not the file: Vite's hashed assets can be cached
|
|
1381
|
+
* forever, but index.html must be revalidated every time or a deployed update
|
|
1382
|
+
* never reaches a browser that already has the old one.
|
|
1383
|
+
*/
|
|
1384
|
+
async function serveFile(req, res, filePath, options = {}) {
|
|
1385
|
+
if (req.method !== "GET" && req.method !== "HEAD") return "method-not-allowed";
|
|
1386
|
+
let size;
|
|
1387
|
+
try {
|
|
1388
|
+
const info = await stat(filePath);
|
|
1389
|
+
if (!info.isFile()) return "not-found";
|
|
1390
|
+
size = info.size;
|
|
1391
|
+
} catch {
|
|
1392
|
+
return "not-found";
|
|
1393
|
+
}
|
|
1394
|
+
res.writeHead(200, {
|
|
1395
|
+
"content-type": contentTypeFor(filePath),
|
|
1396
|
+
"content-length": size,
|
|
1397
|
+
"cache-control": options.immutable ? "public, max-age=31536000, immutable" : "no-cache, must-revalidate",
|
|
1398
|
+
"x-content-type-options": "nosniff"
|
|
1399
|
+
});
|
|
1400
|
+
if (req.method === "HEAD") {
|
|
1401
|
+
res.end();
|
|
1402
|
+
return "served";
|
|
1403
|
+
}
|
|
1404
|
+
await new Promise((resolvePromise) => {
|
|
1405
|
+
const stream = createReadStream(filePath);
|
|
1406
|
+
stream.on("error", () => {
|
|
1407
|
+
res.destroy();
|
|
1408
|
+
resolvePromise();
|
|
1409
|
+
});
|
|
1410
|
+
stream.on("end", () => resolvePromise());
|
|
1411
|
+
stream.pipe(res);
|
|
1412
|
+
});
|
|
1413
|
+
return "served";
|
|
1414
|
+
}
|
|
1415
|
+
//#endregion
|
|
1416
|
+
//#region src/instance.ts
|
|
1417
|
+
/**
|
|
1418
|
+
* The dashboard comes from `@workerdeck/web`, which ships it prebuilt and
|
|
1419
|
+
* exports the path to it. Depending on the package rather than vendoring a copy
|
|
1420
|
+
* means one dashboard, versioned in lockstep with everything else.
|
|
1421
|
+
*
|
|
1422
|
+
* In a checkout that directory only exists once the app has been built — dev
|
|
1423
|
+
* never builds — so the miss is worth a real message rather than a stack trace
|
|
1424
|
+
* from the static host.
|
|
1425
|
+
*/
|
|
1426
|
+
function resolveWebRoot() {
|
|
1427
|
+
if (existsSync(join(dashboardDir, "index.html"))) return dashboardDir;
|
|
1428
|
+
throw new Error(`no dashboard build at ${dashboardDir}\n in a checkout: pnpm --filter @workerdeck/web run build`);
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* The Host-header gate for an unauthenticated instance. `allowedHosts` is null
|
|
1432
|
+
* whenever auth is on, and then this is the identity function — with a
|
|
1433
|
+
* credential in play a rebound origin holds no cookie and fails `authenticate`
|
|
1434
|
+
* anyway. Loopback *names* are what's checked, not the socket: the attacker in
|
|
1435
|
+
* this scenario controls DNS, so the connection genuinely arrives on 127.0.0.1;
|
|
1436
|
+
* what they cannot control is the name the victim's browser writes into Host.
|
|
1437
|
+
*/
|
|
1438
|
+
function createHostGuard(allowedHosts) {
|
|
1439
|
+
if (allowedHosts === null) return () => true;
|
|
1440
|
+
return (req) => {
|
|
1441
|
+
const header = req.headers.host;
|
|
1442
|
+
if (header === void 0) return true;
|
|
1443
|
+
const hostname = hostnameOf(header);
|
|
1444
|
+
if (hostname === "") return false;
|
|
1445
|
+
return isLoopbackHostname(hostname) || allowedHosts.has(hostname);
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Everything outside `/v1`. Order matters: the auth endpoints first (they are
|
|
1450
|
+
* how a browser gets a session in the first place), then assets, which stay
|
|
1451
|
+
* ungated — they are the app's own code, hold no secrets, and gating them would
|
|
1452
|
+
* only mean the login page could not be styled by the app it gates. Documents
|
|
1453
|
+
* come last, and that is the single place the auth decision is made.
|
|
1454
|
+
*
|
|
1455
|
+
* The APNs device route sits between auth and assets: it does its own
|
|
1456
|
+
* authentication (header only — it is never called by a browser), and it must
|
|
1457
|
+
* not fall through to the SPA's catch-all, which would answer a failed
|
|
1458
|
+
* registration with a 200 and an HTML document.
|
|
1459
|
+
*/
|
|
1460
|
+
function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
|
|
1461
|
+
return async (req, res) => {
|
|
1462
|
+
if (!hostAllowed(req)) {
|
|
1463
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
1464
|
+
res.end("unrecognised Host header.\n\nThis instance runs without auth, so it only answers to loopback host names and the ones it was told to expect. Declare this name with --insecure-host <name> (which also lets it be bound without a key) or --allowed-host <name>, or set --auth-key.\n");
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
if (await auth.handleAuthRequest(req, res)) return;
|
|
1468
|
+
if (apnsRoute !== void 0 && await apnsRoute(req, res)) return;
|
|
1469
|
+
const pathname = new URL(req.url ?? "/", "http://internal").pathname;
|
|
1470
|
+
if (looksLikeAsset(pathname)) {
|
|
1471
|
+
const filePath = resolveWithinRoot(webRoot, pathname);
|
|
1472
|
+
if (!filePath) {
|
|
1473
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
1474
|
+
res.end("bad request");
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
const result = await serveFile(req, res, filePath, { immutable: pathname.startsWith("/assets/") });
|
|
1478
|
+
if (result === "served") return;
|
|
1479
|
+
if (result === "method-not-allowed") {
|
|
1480
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
1481
|
+
res.end();
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
1485
|
+
res.end("not found");
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1489
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
1490
|
+
res.end();
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
if (auth.enabled && !auth.hasValidSession(req)) {
|
|
1494
|
+
sendHtml(req, res, 401, renderLoginPage(auth.loginPage(req)), "no-store");
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
if (await serveFile(req, res, join(webRoot, "index.html"), { immutable: false }) !== "served") {
|
|
1498
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
1499
|
+
res.end("dashboard build is missing its entry document");
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
async function startInstance(config, options = {}) {
|
|
1504
|
+
const webRoot = config.webRoot ?? resolveWebRoot();
|
|
1505
|
+
const generated = config.generateAuthKey && !config.hostAuthenticates ? await materializeAuthKey(config.stateDir) : null;
|
|
1506
|
+
const auth = createCliAuth(config.hostAuthenticates ? {
|
|
1507
|
+
...config.auth,
|
|
1508
|
+
secret: void 0
|
|
1509
|
+
} : generated ? {
|
|
1510
|
+
...config.auth,
|
|
1511
|
+
secret: generated.key
|
|
1512
|
+
} : config.auth);
|
|
1513
|
+
if (config.allowedHosts === null && !config.hostAuthenticates && !auth.enabled) throw new Error("refusing to serve: the resolved config expects auth but no shared secret was materialized — this instance would be open while believing itself authenticated");
|
|
1514
|
+
const hostAllowed = createHostGuard(config.allowedHosts);
|
|
1515
|
+
const apns = config.apns === void 0 ? void 0 : await createApnsForwarder({
|
|
1516
|
+
config: config.apns,
|
|
1517
|
+
stateDir: config.stateDir,
|
|
1518
|
+
authenticate: (req) => hostAllowed(req) ? auth.authenticate(req) : null
|
|
1519
|
+
});
|
|
1520
|
+
const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest);
|
|
1521
|
+
const parking = { ...config.options.parking };
|
|
1522
|
+
if (config.stateDir && !parking.store) parking.store = createFileSessionStore({
|
|
1523
|
+
dir: join(config.stateDir, "parked"),
|
|
1524
|
+
onError: (error, context) => {
|
|
1525
|
+
process.stderr.write(`[workerdeck] parked-session store ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
const server = createWorkerServer({
|
|
1529
|
+
...config.options,
|
|
1530
|
+
checkCredentials: config.options.checkCredentials ?? true,
|
|
1531
|
+
parking,
|
|
1532
|
+
notifications: apns === void 0 ? config.options.notifications : {
|
|
1533
|
+
...config.options.notifications,
|
|
1534
|
+
onNotification: (notification) => {
|
|
1535
|
+
config.options.notifications?.onNotification?.(notification);
|
|
1536
|
+
apns.onNotification(notification);
|
|
1537
|
+
}
|
|
1538
|
+
},
|
|
1539
|
+
fallback,
|
|
1540
|
+
authenticate: config.hostAuthenticates ? (req) => hostAllowed(req) ? config.options.authenticate(req) : null : (req) => hostAllowed(req) ? auth.authenticate(req) : null
|
|
1541
|
+
});
|
|
1542
|
+
const { port } = await server.listen(config.port, config.host);
|
|
1543
|
+
const displayHost = config.host === "0.0.0.0" || config.host === "::" ? "localhost" : config.host;
|
|
1544
|
+
const url = `http://${displayHost.includes(":") ? `[${displayHost}]` : displayHost}:${port}`;
|
|
1545
|
+
let resolveClosed = () => {};
|
|
1546
|
+
const closed = new Promise((r) => {
|
|
1547
|
+
resolveClosed = r;
|
|
1548
|
+
});
|
|
1549
|
+
if (!options.quiet) {
|
|
1550
|
+
const line = (text) => void process.stdout.write(`${text}\n`);
|
|
1551
|
+
line("");
|
|
1552
|
+
line(` workerdeck ${url}`);
|
|
1553
|
+
if (config.hostAuthenticates) line(" auth: the config file supplies its own `authenticate`");
|
|
1554
|
+
else if (generated?.source === "created") {
|
|
1555
|
+
line(` auth: generated key ${generated.key}`);
|
|
1556
|
+
line(` stored in ${generated.path} — later starts reuse it without printing it`);
|
|
1557
|
+
} else if (generated?.source === "ephemeral") {
|
|
1558
|
+
line(` auth: generated key ${generated.key}`);
|
|
1559
|
+
line(" ephemeral — no state dir to keep it, so the next start mints a new one");
|
|
1560
|
+
} else if (generated?.source === "stored") line(` auth: shared key from ${generated.path}`);
|
|
1561
|
+
else if (auth.enabled) line(" auth: shared key — browsers sign in, services send a header");
|
|
1562
|
+
else line(" NO AUTH — anyone who can reach this port gets a session");
|
|
1563
|
+
line(config.stateDir ? ` parked sessions persist in ${join(config.stateDir, "parked")}` : " parked sessions are in memory only — a restart drops them");
|
|
1564
|
+
if (apns) {
|
|
1565
|
+
const count = apns.deviceCount();
|
|
1566
|
+
line(` push: APNs forwarder on ${config.apns?.topic} — ${count === 0 ? "no devices registered yet" : `${count} device(s)`}`);
|
|
1567
|
+
}
|
|
1568
|
+
if (config.configPath) line(` config ${config.configPath}`);
|
|
1569
|
+
line("");
|
|
1570
|
+
}
|
|
1571
|
+
return {
|
|
1572
|
+
server,
|
|
1573
|
+
url,
|
|
1574
|
+
port,
|
|
1575
|
+
closed,
|
|
1576
|
+
close: async () => {
|
|
1577
|
+
await server.close();
|
|
1578
|
+
apns?.close();
|
|
1579
|
+
resolveClosed();
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
//#endregion
|
|
1584
|
+
export { createDeviceRegistry as _, ConfigError as a, loadApnsKey as b, isLoopback as c, parseArgs as d, resolveInstanceConfig as f, createApnsForwarder as g, buildPush as h, renderLoginPage as i, isLoopbackHostname as l, materializeAuthKey as m, resolveWebRoot as n, defaultStateDir as o, createCliAuth as p, startInstance as r, hostnameOf as s, createHostGuard as t, loadConfigFile as u, createDeviceRoute as v, createApnsClient as y };
|
|
1585
|
+
|
|
1586
|
+
//# sourceMappingURL=instance-DbIqlX-e.mjs.map
|