workerdeck 0.23.0 → 1.0.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/build/cli.mjs +18 -5
- package/build/cli.mjs.map +1 -1
- package/build/{guard-DkovUj7o.mjs → guard-BJNUnO3H.mjs} +1 -26
- package/build/guard-BJNUnO3H.mjs.map +1 -0
- package/build/index.d.mts +36 -327
- package/build/index.mjs +2 -2
- package/build/{instance-iBl_fOW2.mjs → instance-5sdv2Rrw.mjs} +91 -392
- package/build/instance-5sdv2Rrw.mjs.map +1 -0
- package/package.json +4 -4
- package/build/guard-DkovUj7o.mjs.map +0 -1
- package/build/instance-iBl_fOW2.mjs.map +0 -1
|
@@ -8,60 +8,32 @@ import { connect, constants } from "node:http2";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { pathToFileURL } from "node:url";
|
|
10
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
11
|
const HOSTS = {
|
|
20
12
|
development: "https://api.sandbox.push.apple.com",
|
|
21
13
|
production: "https://api.push.apple.com"
|
|
22
14
|
};
|
|
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
15
|
const TOKEN_TTL_MS = 2400 * 1e3;
|
|
31
16
|
const REQUEST_TIMEOUT_MS = 1e4;
|
|
32
|
-
/** Per-address budget for a dial. Node defaults this to 250ms, which is under
|
|
33
|
-
* Apple's observed handshake on at least one real path; see the comment at the
|
|
34
|
-
* `connect()` call. Generous rather than tuned — the cost of being slow to give
|
|
35
|
-
* up on an address is a slower dial, and the cost of being quick is a lost
|
|
36
|
-
* notification. */
|
|
37
17
|
const DIAL_ATTEMPT_TIMEOUT_MS = 2e3;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
* first push, so a mistyped path is a launch error with a clear message instead
|
|
42
|
-
* of a notification that silently never arrives.
|
|
43
|
-
*/
|
|
18
|
+
function base64url(input) {
|
|
19
|
+
return Buffer.from(input).toString("base64url");
|
|
20
|
+
}
|
|
44
21
|
async function loadApnsKey(keyFile) {
|
|
45
22
|
let pem;
|
|
46
23
|
try {
|
|
47
24
|
pem = await readFile(keyFile, "utf8");
|
|
48
25
|
} catch (error) {
|
|
49
|
-
throw new Error(`apns: cannot read the auth key at ${keyFile}: ${error instanceof Error ? error.message : String(error)}
|
|
26
|
+
throw new Error(`apns: cannot read the auth key at ${keyFile}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
50
27
|
}
|
|
51
28
|
let key;
|
|
52
29
|
try {
|
|
53
30
|
key = createPrivateKey(pem);
|
|
54
31
|
} catch (error) {
|
|
55
|
-
throw new Error(`apns: ${keyFile} is not a private key (${error instanceof Error ? error.message : String(error)})
|
|
32
|
+
throw new Error(`apns: ${keyFile} is not a private key (${error instanceof Error ? error.message : String(error)})`, { cause: error });
|
|
56
33
|
}
|
|
57
34
|
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`);
|
|
58
35
|
return key;
|
|
59
36
|
}
|
|
60
|
-
/**
|
|
61
|
-
* A cached provider JWT. The signature is over `{alg:ES256,kid}` + `{iss,iat}`,
|
|
62
|
-
* and JWS wants the raw `r||s` pair — `sign()` produces a DER SEQUENCE unless
|
|
63
|
-
* told otherwise, which Apple rejects with a bare 403 and no explanation.
|
|
64
|
-
*/
|
|
65
37
|
function createProviderToken(key, keyId, teamId) {
|
|
66
38
|
let cached = null;
|
|
67
39
|
return {
|
|
@@ -89,12 +61,6 @@ function createProviderToken(key, keyId, teamId) {
|
|
|
89
61
|
}
|
|
90
62
|
};
|
|
91
63
|
}
|
|
92
|
-
/**
|
|
93
|
-
* One long-lived HTTP/2 session per environment, reconnected on demand. APNs
|
|
94
|
-
* sends GOAWAY routinely (it rebalances connections), so a dead session is a
|
|
95
|
-
* normal event and not an error worth surfacing — the next push simply dials
|
|
96
|
-
* again.
|
|
97
|
-
*/
|
|
98
64
|
function createSessionPool(hosts) {
|
|
99
65
|
const sessions = /* @__PURE__ */ new Map();
|
|
100
66
|
const failures = /* @__PURE__ */ new Map();
|
|
@@ -132,21 +98,13 @@ function createSessionPool(hosts) {
|
|
|
132
98
|
}
|
|
133
99
|
};
|
|
134
100
|
}
|
|
135
|
-
|
|
136
|
-
* A stream that never left this machine is destroyed with
|
|
137
|
-
* ERR_HTTP2_STREAM_CANCEL, which buries the real failure in `cause` — and when
|
|
138
|
-
* every connect attempt fails (Happy Eyeballs walks both address families of
|
|
139
|
-
* api.push.apple.com), that cause is an AggregateError whose own message is
|
|
140
|
-
* EMPTY, so the text ends in "(caused by: )" and names nothing. Mine the
|
|
141
|
-
* aggregate so the log says ECONNREFUSED/EHOSTUNREACH instead of nothing.
|
|
142
|
-
*/
|
|
143
|
-
const describeStreamError = (error) => {
|
|
101
|
+
function describeStreamError(error) {
|
|
144
102
|
const cause = error.cause;
|
|
145
103
|
if (!(cause instanceof AggregateError)) return error.message;
|
|
146
104
|
const parts = cause.errors.map((inner) => inner instanceof Error ? inner.message : String(inner)).filter((message) => message !== "");
|
|
147
105
|
if (parts.length === 0) return error.message;
|
|
148
106
|
return `${error.message.replace(" (caused by: )", "")} (caused by: ${parts.join("; ")})`;
|
|
149
|
-
}
|
|
107
|
+
}
|
|
150
108
|
function createApnsClient(config, key, options = {}) {
|
|
151
109
|
const providerToken = createProviderToken(key, config.keyId, config.teamId);
|
|
152
110
|
const pool = createSessionPool(options.hosts ?? HOSTS);
|
|
@@ -191,18 +149,11 @@ function createApnsClient(config, key, options = {}) {
|
|
|
191
149
|
retry
|
|
192
150
|
});
|
|
193
151
|
};
|
|
194
|
-
/** Classify a transport-level death. `pending` is true only while the
|
|
195
|
-
* stream has no id — its HEADERS frame was never handed to nghttp2, so a
|
|
196
|
-
* retry cannot duplicate anything. REFUSED_STREAM is Apple's explicit
|
|
197
|
-
* "received but not processed", defined by the RFC as safe to retry. */
|
|
198
152
|
const transportRetry = () => {
|
|
199
153
|
if (stream.pending) return "redial";
|
|
200
154
|
if (stream.rstCode === constants.NGHTTP2_REFUSED_STREAM) return "now";
|
|
201
155
|
return "never";
|
|
202
156
|
};
|
|
203
|
-
/** A stream still pending when its attempt dies marks a connect that is
|
|
204
|
-
* failing or hanging; without this, every later push (and the retry)
|
|
205
|
-
* would queue behind the same doomed dial until the OS gave up on it. */
|
|
206
157
|
const dropDoomedDial = () => {
|
|
207
158
|
if (stream.pending && session.connecting) pool.discard(request.environment, session);
|
|
208
159
|
};
|
|
@@ -285,18 +236,51 @@ function createApnsClient(config, key, options = {}) {
|
|
|
285
236
|
};
|
|
286
237
|
}
|
|
287
238
|
//#endregion
|
|
239
|
+
//#region src/lib/http.ts
|
|
240
|
+
/**
|
|
241
|
+
* Read a request body, bounded. Resolves `null` if the body exceeds `maxBytes` or the socket
|
|
242
|
+
* errors — the caller answers 413 either way; it never resolves twice, and it never buffers
|
|
243
|
+
* past the cap, which is the whole point of not using a body parser here.
|
|
244
|
+
*/
|
|
245
|
+
function readBody(req, maxBytes) {
|
|
246
|
+
return new Promise((resolve) => {
|
|
247
|
+
const chunks = [];
|
|
248
|
+
let size = 0;
|
|
249
|
+
let settled = false;
|
|
250
|
+
const finish = (value) => {
|
|
251
|
+
if (!settled) {
|
|
252
|
+
settled = true;
|
|
253
|
+
resolve(value);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
req.on("data", (chunk) => {
|
|
257
|
+
size += chunk.length;
|
|
258
|
+
if (size > maxBytes) {
|
|
259
|
+
finish(null);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
chunks.push(chunk);
|
|
263
|
+
});
|
|
264
|
+
req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
|
265
|
+
req.on("error", () => finish(null));
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/** A JSON answer that is never cached — these routes all carry auth state. */
|
|
269
|
+
function respondJson(res, status, body, headers) {
|
|
270
|
+
res.writeHead(status, {
|
|
271
|
+
"content-type": "application/json",
|
|
272
|
+
"cache-control": "no-store",
|
|
273
|
+
...headers
|
|
274
|
+
}).end(JSON.stringify(body));
|
|
275
|
+
}
|
|
276
|
+
//#endregion
|
|
288
277
|
//#region src/apns/devices.ts
|
|
289
278
|
const FILENAME = "apns-devices.json";
|
|
290
|
-
/** Apple's tokens are 64 hex chars today. The upper bound is generous because
|
|
291
|
-
* Apple has reserved the right to grow them; the lower one just rejects junk. */
|
|
292
279
|
const TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/;
|
|
293
280
|
const MAX_BODY_BYTES = 4096;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
* which is survivable because the app re-registers on launch, but it does mean
|
|
298
|
-
* an instance with no state dir goes quiet until each phone is next opened.
|
|
299
|
-
*/
|
|
281
|
+
function isEnvironment(value) {
|
|
282
|
+
return value === "development" || value === "production";
|
|
283
|
+
}
|
|
300
284
|
async function createDeviceRegistry(options) {
|
|
301
285
|
const path = options.dir === null ? null : join(options.dir, FILENAME);
|
|
302
286
|
const devices = /* @__PURE__ */ new Map();
|
|
@@ -337,45 +321,6 @@ async function createDeviceRegistry(options) {
|
|
|
337
321
|
}
|
|
338
322
|
};
|
|
339
323
|
}
|
|
340
|
-
const respond = (res, status, body) => {
|
|
341
|
-
res.writeHead(status, {
|
|
342
|
-
"content-type": "application/json",
|
|
343
|
-
"cache-control": "no-store"
|
|
344
|
-
}).end(JSON.stringify(body));
|
|
345
|
-
};
|
|
346
|
-
const readBody = (req) => new Promise((resolve) => {
|
|
347
|
-
const chunks = [];
|
|
348
|
-
let size = 0;
|
|
349
|
-
let settled = false;
|
|
350
|
-
const finish = (value) => {
|
|
351
|
-
if (!settled) {
|
|
352
|
-
settled = true;
|
|
353
|
-
resolve(value);
|
|
354
|
-
}
|
|
355
|
-
};
|
|
356
|
-
req.on("data", (chunk) => {
|
|
357
|
-
size += chunk.length;
|
|
358
|
-
if (size > MAX_BODY_BYTES) {
|
|
359
|
-
finish(null);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
chunks.push(chunk);
|
|
363
|
-
});
|
|
364
|
-
req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
|
365
|
-
req.on("error", () => finish(null));
|
|
366
|
-
});
|
|
367
|
-
/**
|
|
368
|
-
* `POST /apns/devices` to register a token, `DELETE /apns/devices` to drop one.
|
|
369
|
-
* Returns true when it consumed the request.
|
|
370
|
-
*
|
|
371
|
-
* Deliberately outside `/v1`: this is the forwarder's own surface, not part of
|
|
372
|
-
* the protocol `packages/protocol` defines, and a client that finds a 404 here
|
|
373
|
-
* has simply reached a gateway running without push configured.
|
|
374
|
-
*
|
|
375
|
-
* DELETE exists so removing a gateway from the app can stop its pushes. Without
|
|
376
|
-
* it a forgotten server keeps buzzing a phone that no longer has any way to act
|
|
377
|
-
* on what it says.
|
|
378
|
-
*/
|
|
379
324
|
function createDeviceRoute(registry, authenticate) {
|
|
380
325
|
return async (req, res) => {
|
|
381
326
|
let pathname;
|
|
@@ -390,12 +335,12 @@ function createDeviceRoute(registry, authenticate) {
|
|
|
390
335
|
return true;
|
|
391
336
|
}
|
|
392
337
|
if (authenticate(req) === null) {
|
|
393
|
-
|
|
338
|
+
respondJson(res, 401, { error: "unauthorized" });
|
|
394
339
|
return true;
|
|
395
340
|
}
|
|
396
|
-
const raw = await readBody(req);
|
|
341
|
+
const raw = await readBody(req, MAX_BODY_BYTES);
|
|
397
342
|
if (raw === null) {
|
|
398
|
-
|
|
343
|
+
respondJson(res, 413, { error: "body too large" });
|
|
399
344
|
res.once("finish", () => req.destroy());
|
|
400
345
|
return true;
|
|
401
346
|
}
|
|
@@ -403,12 +348,12 @@ function createDeviceRoute(registry, authenticate) {
|
|
|
403
348
|
try {
|
|
404
349
|
body = JSON.parse(raw);
|
|
405
350
|
} catch {
|
|
406
|
-
|
|
351
|
+
respondJson(res, 400, { error: "invalid JSON body" });
|
|
407
352
|
return true;
|
|
408
353
|
}
|
|
409
354
|
const token = body.token;
|
|
410
355
|
if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
|
|
411
|
-
|
|
356
|
+
respondJson(res, 400, { error: "token must be a hex APNs device token" });
|
|
412
357
|
return true;
|
|
413
358
|
}
|
|
414
359
|
if (req.method === "DELETE") {
|
|
@@ -417,7 +362,7 @@ function createDeviceRoute(registry, authenticate) {
|
|
|
417
362
|
return true;
|
|
418
363
|
}
|
|
419
364
|
if (!isEnvironment(body.environment)) {
|
|
420
|
-
|
|
365
|
+
respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
|
|
421
366
|
return true;
|
|
422
367
|
}
|
|
423
368
|
const optionalString = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
|
|
@@ -428,7 +373,7 @@ function createDeviceRoute(registry, authenticate) {
|
|
|
428
373
|
bundleId: optionalString(body.bundleId),
|
|
429
374
|
platform: optionalString(body.platform)
|
|
430
375
|
});
|
|
431
|
-
|
|
376
|
+
respondJson(res, 200, {
|
|
432
377
|
registered: true,
|
|
433
378
|
environment: body.environment
|
|
434
379
|
});
|
|
@@ -437,42 +382,26 @@ function createDeviceRoute(registry, authenticate) {
|
|
|
437
382
|
}
|
|
438
383
|
//#endregion
|
|
439
384
|
//#region src/apns/forwarder.ts
|
|
440
|
-
/**
|
|
441
|
-
* Turns the server's session notifications into APNs pushes.
|
|
442
|
-
*
|
|
443
|
-
* The architectural point, restated because it is easy to erode: session
|
|
444
|
-
* webhooks are the primitive and this is one consumer of them. It hooks
|
|
445
|
-
* `notifications.onNotification` in-process — same process as the gateway, so
|
|
446
|
-
* there is no HTTP hop — but nothing about the server knows that, and the same
|
|
447
|
-
* events can just as well drive Slack or a custom relay. Push credentials live
|
|
448
|
-
* here and nowhere in `packages/server`.
|
|
449
|
-
*/
|
|
450
|
-
/** APNs caps a payload at 4 KB. Staying well under leaves room for the alert
|
|
451
|
-
* dictionary to grow without anyone rediscovering the limit the hard way. */
|
|
452
385
|
const MAX_PAYLOAD_BYTES = 3800;
|
|
453
386
|
const BODY_LIMIT = 300;
|
|
454
|
-
/** Category identifiers are wire contract with the app: it registers the
|
|
455
|
-
* Approve/Deny actions under this exact string, and a mismatch means a
|
|
456
|
-
* notification that arrives with no buttons on it. */
|
|
457
387
|
const CATEGORY = {
|
|
458
388
|
permission: "PERMISSION_REQUEST",
|
|
459
389
|
event: "SESSION_EVENT"
|
|
460
390
|
};
|
|
461
|
-
|
|
391
|
+
function label(session) {
|
|
462
392
|
const title = session.title?.trim();
|
|
463
393
|
if (title !== void 0 && title !== "") return title;
|
|
464
394
|
const leaf = session.cwd.split("/").filter(Boolean).at(-1);
|
|
465
395
|
return leaf !== void 0 && leaf !== "" ? leaf : session.id;
|
|
466
|
-
}
|
|
467
|
-
|
|
396
|
+
}
|
|
397
|
+
function oneLine(text, limit = BODY_LIMIT) {
|
|
468
398
|
const flat = (text ?? "").replace(/\s+/g, " ").trim();
|
|
469
399
|
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const titleFor = (notification, name) => {
|
|
400
|
+
}
|
|
401
|
+
function collapseKey(sessionId) {
|
|
402
|
+
return createHash("sha256").update(sessionId).digest("base64url").slice(0, 32);
|
|
403
|
+
}
|
|
404
|
+
function titleFor(notification, name) {
|
|
476
405
|
switch (notification.type) {
|
|
477
406
|
case "permission_requested": return `Approval needed — ${name}`;
|
|
478
407
|
case "turn_completed": return notification.result?.isError === true ? `Turn failed — ${name}` : name;
|
|
@@ -480,8 +409,8 @@ const titleFor = (notification, name) => {
|
|
|
480
409
|
case "session_closed": return `Session ended — ${name}`;
|
|
481
410
|
default: return name;
|
|
482
411
|
}
|
|
483
|
-
}
|
|
484
|
-
|
|
412
|
+
}
|
|
413
|
+
function bodyFor(notification) {
|
|
485
414
|
const preview = oneLine(notification.preview);
|
|
486
415
|
if (preview !== "") return preview;
|
|
487
416
|
switch (notification.type) {
|
|
@@ -490,16 +419,7 @@ const bodyFor = (notification) => {
|
|
|
490
419
|
case "session_closed": return `Closed by the ${notification.reason ?? "server"}.`;
|
|
491
420
|
default: return "Something needs your attention.";
|
|
492
421
|
}
|
|
493
|
-
}
|
|
494
|
-
/**
|
|
495
|
-
* Build the push for one notification.
|
|
496
|
-
*
|
|
497
|
-
* The payload carries routing and nothing else — `sessionId` to deep-link,
|
|
498
|
-
* `requestId` because a lock-screen Approve has nothing to POST to without it,
|
|
499
|
-
* and `hostId` so a client with two gateways knows which one this came from.
|
|
500
|
-
* Everything else the app fetches over REST the moment it opens; a transcript
|
|
501
|
-
* has no business in a 4 KB envelope.
|
|
502
|
-
*/
|
|
422
|
+
}
|
|
503
423
|
function buildPush(notification, hostId) {
|
|
504
424
|
const permission = notification.type === "permission_requested";
|
|
505
425
|
const name = label(notification.session);
|
|
@@ -542,9 +462,6 @@ async function createApnsForwarder(options) {
|
|
|
542
462
|
});
|
|
543
463
|
const handleRequest = createDeviceRoute(registry, options.authenticate);
|
|
544
464
|
const fallbackEnvironment = options.config.production === false ? "development" : "production";
|
|
545
|
-
/** Per-session delivery chain, so a session's pushes arrive in the order the
|
|
546
|
-
* events happened — which matters precisely because `turn_completed` collapses
|
|
547
|
-
* and an out-of-order pair would leave the older text on screen. */
|
|
548
465
|
const chains = /* @__PURE__ */ new Map();
|
|
549
466
|
const deliver = async (notification) => {
|
|
550
467
|
const devices = registry.list();
|
|
@@ -581,15 +498,13 @@ async function createApnsForwarder(options) {
|
|
|
581
498
|
}
|
|
582
499
|
//#endregion
|
|
583
500
|
//#region src/auth/auth-key.ts
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
* truncated or garbage file should regenerate, not crash or half-work. */
|
|
589
|
-
const usableStoredKey = (raw) => {
|
|
501
|
+
function generateKey() {
|
|
502
|
+
return randomBytes(24).toString("hex");
|
|
503
|
+
}
|
|
504
|
+
function usableStoredKey(raw) {
|
|
590
505
|
const line = raw.split("\n", 1)[0]?.trim() ?? "";
|
|
591
506
|
return line.length >= 12 && /^[\x21-\x7e]+$/.test(line) ? line : null;
|
|
592
|
-
}
|
|
507
|
+
}
|
|
593
508
|
async function materializeAuthKey(stateDir, options = {}) {
|
|
594
509
|
const warn = options.warn ?? ((message) => process.stderr.write(`[workerdeck] ${message}\n`));
|
|
595
510
|
if (stateDir === null) return {
|
|
@@ -633,29 +548,9 @@ async function materializeAuthKey(stateDir, options = {}) {
|
|
|
633
548
|
}
|
|
634
549
|
//#endregion
|
|
635
550
|
//#region src/auth/auth-sessions.ts
|
|
636
|
-
/**
|
|
637
|
-
* Durable browser-login sessions for the turnkey CLI — the other half of
|
|
638
|
-
* `materializeAuthKey`, and here for the same reason: the key survives a
|
|
639
|
-
* restart so clients stay paired, and the login cookie should too. Without
|
|
640
|
-
* this the cookie outlives the table it points into, so a restart signs every
|
|
641
|
-
* browser out while the browser still holds a perfectly valid-looking cookie.
|
|
642
|
-
*
|
|
643
|
-
* What lands on disk is **not credential material**: `createCliAuth` keys its
|
|
644
|
-
* table by `HMAC-SHA256(secret, token)`, so a stolen file yields neither the
|
|
645
|
-
* operator secret nor any cookie value (inverting either needs a preimage of a
|
|
646
|
-
* 256-bit-entropy input). That keying is also what makes key rotation
|
|
647
|
-
* invalidate every outstanding cookie for free — entries written under the old
|
|
648
|
-
* secret simply never match a lookup again, and age out on their own expiry.
|
|
649
|
-
*
|
|
650
|
-
* Writes are whole-file and serialized behind one promise chain (the table is
|
|
651
|
-
* capped at `MAX_SESSIONS`, so "whole file" is a few kilobytes), and go through
|
|
652
|
-
* a temp file + rename so a crash mid-write cannot leave a truncated table.
|
|
653
|
-
* Every failure is a warning, never a throw: losing durability signs the
|
|
654
|
-
* operator out, losing the gateway does much worse.
|
|
655
|
-
*/
|
|
656
551
|
const FORMAT_VERSION = 1;
|
|
657
552
|
const FILE_NAME = "auth-sessions.json";
|
|
658
|
-
|
|
553
|
+
function parseSessions(raw, now) {
|
|
659
554
|
let parsed;
|
|
660
555
|
try {
|
|
661
556
|
parsed = JSON.parse(raw);
|
|
@@ -673,7 +568,7 @@ const parseSessions = (raw, now) => {
|
|
|
673
568
|
entries.push([key, { expiresAt }]);
|
|
674
569
|
}
|
|
675
570
|
return entries;
|
|
676
|
-
}
|
|
571
|
+
}
|
|
677
572
|
async function createAuthSessionStore(options) {
|
|
678
573
|
const now = options.now ?? Date.now;
|
|
679
574
|
const warn = options.warn ?? ((message) => process.stderr.write(`[workerdeck] ${message}\n`));
|
|
@@ -728,8 +623,6 @@ const DEFAULT_TTL_MS = 10080 * 60 * 1e3;
|
|
|
728
623
|
const DEFAULT_THROTTLE_WINDOW_MS = 900 * 1e3;
|
|
729
624
|
const DEFAULT_MAX_FAILURES_PER_IP = 10;
|
|
730
625
|
const DEFAULT_MAX_FAILURES_GLOBAL = 100;
|
|
731
|
-
/** Only successful logins insert, so this cap only fences the secret-holder's
|
|
732
|
-
* own memory use (every login within the ttl is a live entry). Oldest goes. */
|
|
733
626
|
const MAX_SESSIONS = 100;
|
|
734
627
|
const MAX_LOGIN_BODY_BYTES = 4096;
|
|
735
628
|
const SAFE_METHODS = new Set([
|
|
@@ -737,7 +630,9 @@ const SAFE_METHODS = new Set([
|
|
|
737
630
|
"HEAD",
|
|
738
631
|
"OPTIONS"
|
|
739
632
|
]);
|
|
740
|
-
|
|
633
|
+
function sha256(value) {
|
|
634
|
+
return createHash("sha256").update(value).digest();
|
|
635
|
+
}
|
|
741
636
|
function createCliAuth(options = {}) {
|
|
742
637
|
const { secret } = options;
|
|
743
638
|
const enabled = secret !== void 0;
|
|
@@ -756,27 +651,8 @@ function createCliAuth(options = {}) {
|
|
|
756
651
|
throw new Error(`createCliAuth: allowedOrigins entry is not a valid origin: ${JSON.stringify(entry)}`);
|
|
757
652
|
}
|
|
758
653
|
}));
|
|
759
|
-
/** Both the raw secret and session tokens are compared as fixed-length SHA-256
|
|
760
|
-
* digests via timingSafeEqual / digest-keyed lookup, so no code path compares
|
|
761
|
-
* secret material byte-by-byte with early exit — and unequal input lengths
|
|
762
|
-
* leak nothing either. */
|
|
763
654
|
const secretDigest = secret === void 0 ? void 0 : sha256(secret);
|
|
764
655
|
const secretMatches = (candidate) => secretDigest !== void 0 && timingSafeEqual(sha256(candidate), secretDigest);
|
|
765
|
-
/**
|
|
766
|
-
* Browser sessions are a server-side table, not signed tokens: logout must
|
|
767
|
-
* actually invalidate, and a stateless HMAC token stays valid until expiry no
|
|
768
|
-
* matter what the server thinks. This is one long-lived process (multi-node
|
|
769
|
-
* is a non-goal), so "table" means one Map — optionally mirrored to a
|
|
770
|
-
* `CliSessionStore` so a restart does not sign every browser out while the
|
|
771
|
-
* browser still holds a cookie the ttl says is good for a week.
|
|
772
|
-
*
|
|
773
|
-
* Keys are `HMAC-SHA256(secret, token)`, which buys three things at once:
|
|
774
|
-
* recovering a token from a key (or from lookup timing) needs a preimage;
|
|
775
|
-
* what a store writes to disk is not credential material; and rotating the
|
|
776
|
-
* operator secret invalidates every outstanding cookie **for free**, since
|
|
777
|
-
* rows written under the old secret can no longer be looked up and age out on
|
|
778
|
-
* their own expiry.
|
|
779
|
-
*/
|
|
780
656
|
const sessions = /* @__PURE__ */ new Map();
|
|
781
657
|
const store = options.sessions;
|
|
782
658
|
const tokenKey = (token) => secret === void 0 ? sha256(token).toString("hex") : createHmac("sha256", secret).update(token).digest("hex");
|
|
@@ -797,7 +673,7 @@ function createCliAuth(options = {}) {
|
|
|
797
673
|
};
|
|
798
674
|
const cookieToken = (req) => {
|
|
799
675
|
const header = req.headers.cookie;
|
|
800
|
-
if (typeof header !== "string") return
|
|
676
|
+
if (typeof header !== "string") return;
|
|
801
677
|
for (const part of header.split(";")) {
|
|
802
678
|
const eq = part.indexOf("=");
|
|
803
679
|
if (eq === -1) continue;
|
|
@@ -817,10 +693,8 @@ function createCliAuth(options = {}) {
|
|
|
817
693
|
}
|
|
818
694
|
return true;
|
|
819
695
|
};
|
|
820
|
-
/** Last value of a possibly comma-joined forwarded header — the one appended
|
|
821
|
-
* (or set) by the trusted proxy; every earlier position is client-writable. */
|
|
822
696
|
const forwardedLast = (value) => {
|
|
823
|
-
if (value === void 0) return
|
|
697
|
+
if (value === void 0) return;
|
|
824
698
|
const last = (Array.isArray(value) ? value.join(",") : value).split(",").at(-1)?.trim();
|
|
825
699
|
return last === "" ? void 0 : last;
|
|
826
700
|
};
|
|
@@ -828,8 +702,6 @@ function createCliAuth(options = {}) {
|
|
|
828
702
|
if (req.socket.encrypted === true) return true;
|
|
829
703
|
return trustProxy && forwardedLast(req.headers["x-forwarded-proto"])?.toLowerCase() === "https";
|
|
830
704
|
};
|
|
831
|
-
/** The origin this server believes it is being served as, from the request's
|
|
832
|
-
* own Host (or the proxy's forwarded host) — the only self-knowledge we have. */
|
|
833
705
|
const expectedOrigin = (req) => {
|
|
834
706
|
const host = (trustProxy ? forwardedLast(req.headers["x-forwarded-host"]) : void 0) ?? req.headers.host;
|
|
835
707
|
if (host === void 0 || host === "") return null;
|
|
@@ -839,20 +711,6 @@ function createCliAuth(options = {}) {
|
|
|
839
711
|
return null;
|
|
840
712
|
}
|
|
841
713
|
};
|
|
842
|
-
/**
|
|
843
|
-
* The CSRF core. `SameSite=Lax` alone is not enough for two reasons: same
|
|
844
|
-
* *site* is not same *origin* (another port on localhost — any other local
|
|
845
|
-
* web app — is same-site, cookies attach), and the WS handshake is exempt
|
|
846
|
-
* from CORS, so a foreign page that gets the cookie attached can read the
|
|
847
|
-
* stream. So the Origin header is checked explicitly, against the request's
|
|
848
|
-
* own origin (full scheme + authority: an http:// page on the same host must
|
|
849
|
-
* not drive the https:// dashboard) or the operator's allowlist. `Origin:
|
|
850
|
-
* null` and unparseable values are foreign. Verdicts are tri-state because
|
|
851
|
-
* absence means different things per call site: every current browser sends
|
|
852
|
-
* Origin on cross-site POSTs and every WS handshake, so absence means a
|
|
853
|
-
* non-browser client — which carries no ambient cookie and gets to decide
|
|
854
|
-
* per-endpoint below.
|
|
855
|
-
*/
|
|
856
714
|
const originVerdict = (req) => {
|
|
857
715
|
const raw = req.headers.origin;
|
|
858
716
|
if (raw === void 0) return "absent";
|
|
@@ -883,20 +741,8 @@ function createCliAuth(options = {}) {
|
|
|
883
741
|
const upgrade = req.headers.upgrade;
|
|
884
742
|
return typeof upgrade === "string" && upgrade.toLowerCase().includes("websocket");
|
|
885
743
|
};
|
|
886
|
-
/**
|
|
887
|
-
* The secret from `?key=` — **accepted on WebSocket upgrades only**.
|
|
888
|
-
*
|
|
889
|
-
* A browser cannot put a header on a WS handshake, so a tab attaching to a
|
|
890
|
-
* gateway that is not its own origin (where the cookie would ride) has no
|
|
891
|
-
* other way to present the key. That is the whole reason this exists.
|
|
892
|
-
*
|
|
893
|
-
* Restricting it to upgrades is what keeps the blast radius at "one attach":
|
|
894
|
-
* a key in a query string is not a transport we want anywhere else, because
|
|
895
|
-
* URLs land in proxy access logs and browser history in a way headers do not.
|
|
896
|
-
* A REST call with `?key=` is therefore *not* authenticated by it.
|
|
897
|
-
*/
|
|
898
744
|
const querySecret = (req) => {
|
|
899
|
-
if (!isUpgradeRequest(req)) return
|
|
745
|
+
if (!isUpgradeRequest(req)) return;
|
|
900
746
|
const key = new URL(req.url ?? "/", "http://internal").searchParams.get("key");
|
|
901
747
|
return key !== null && key !== "" ? key : void 0;
|
|
902
748
|
};
|
|
@@ -918,16 +764,6 @@ function createCliAuth(options = {}) {
|
|
|
918
764
|
canManageProfiles: true
|
|
919
765
|
};
|
|
920
766
|
};
|
|
921
|
-
/**
|
|
922
|
-
* Login throttle: the secret is the only factor and the endpoint is reachable
|
|
923
|
-
* by anyone who can reach the port, so guessing must be rate-limited. Failed
|
|
924
|
-
* attempts count per client IP inside a fixed window, with a global cap
|
|
925
|
-
* behind it so rotating IPs (trivial over IPv6) buys an attacker nothing.
|
|
926
|
-
* Only wrong secrets count — malformed requests and foreign-Origin posts are
|
|
927
|
-
* refused earlier precisely so a hostile page cannot burn a victim IP's
|
|
928
|
-
* budget cross-site. The global cap also bounds this map's size: expired
|
|
929
|
-
* entries are swept once it grows past a nominal size.
|
|
930
|
-
*/
|
|
931
767
|
const failures = /* @__PURE__ */ new Map();
|
|
932
768
|
const globalFailures = {
|
|
933
769
|
count: 0,
|
|
@@ -970,13 +806,6 @@ function createCliAuth(options = {}) {
|
|
|
970
806
|
"Max-Age=0",
|
|
971
807
|
...cookieAttributes(req)
|
|
972
808
|
].join("; ");
|
|
973
|
-
const respondJson = (res, status, body, headers) => {
|
|
974
|
-
res.writeHead(status, {
|
|
975
|
-
"content-type": "application/json",
|
|
976
|
-
"cache-control": "no-store",
|
|
977
|
-
...headers
|
|
978
|
-
}).end(JSON.stringify(body));
|
|
979
|
-
};
|
|
980
809
|
const respondRedirect = (res, location, headers) => {
|
|
981
810
|
res.writeHead(303, {
|
|
982
811
|
location,
|
|
@@ -984,31 +813,7 @@ function createCliAuth(options = {}) {
|
|
|
984
813
|
...headers
|
|
985
814
|
}).end();
|
|
986
815
|
};
|
|
987
|
-
/** JSON responses when the client asks for them, 303 redirects otherwise —
|
|
988
|
-
* so a dependency-free `<form method="post">` login page works without JS,
|
|
989
|
-
* and a fetch()-based one gets real status codes. */
|
|
990
816
|
const wantsJson = (req) => (req.headers.accept ?? "").includes("application/json");
|
|
991
|
-
const readBody = (req, maxBytes) => new Promise((resolve) => {
|
|
992
|
-
const chunks = [];
|
|
993
|
-
let size = 0;
|
|
994
|
-
let settled = false;
|
|
995
|
-
const finish = (value) => {
|
|
996
|
-
if (!settled) {
|
|
997
|
-
settled = true;
|
|
998
|
-
resolve(value);
|
|
999
|
-
}
|
|
1000
|
-
};
|
|
1001
|
-
req.on("data", (chunk) => {
|
|
1002
|
-
size += chunk.length;
|
|
1003
|
-
if (size > maxBytes) {
|
|
1004
|
-
finish(null);
|
|
1005
|
-
return;
|
|
1006
|
-
}
|
|
1007
|
-
chunks.push(chunk);
|
|
1008
|
-
});
|
|
1009
|
-
req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
|
1010
|
-
req.on("error", () => finish(null));
|
|
1011
|
-
});
|
|
1012
817
|
const handleLogin = async (req, res) => {
|
|
1013
818
|
const json = wantsJson(req);
|
|
1014
819
|
if (!enabled) {
|
|
@@ -1135,17 +940,6 @@ function createCliAuth(options = {}) {
|
|
|
1135
940
|
}
|
|
1136
941
|
//#endregion
|
|
1137
942
|
//#region src/config.ts
|
|
1138
|
-
/**
|
|
1139
|
-
* The config surface has to be JavaScript, not JSON: the two options a real
|
|
1140
|
-
* deployment always needs — `authenticate` and `buildRunnerConfig` — are
|
|
1141
|
-
* functions. So the file default-exports `WorkerServerOptions` (optionally as a
|
|
1142
|
-
* function, sync or async, for config that has to await something), and flags
|
|
1143
|
-
* and env cover the cases that fit on a command line.
|
|
1144
|
-
*
|
|
1145
|
-
* Precedence, narrowest wins: flags > env > config file > defaults. A config
|
|
1146
|
-
* file that sets `authenticate` itself opts out of the built-in shared-secret
|
|
1147
|
-
* auth entirely — see `resolveInstanceConfig`.
|
|
1148
|
-
*/
|
|
1149
943
|
const CONFIG_BASENAMES = [
|
|
1150
944
|
"workerdeck.config.mjs",
|
|
1151
945
|
"workerdeck.config.js",
|
|
@@ -1157,11 +951,6 @@ function parsePort(raw, source) {
|
|
|
1157
951
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new ConfigError(`${source}: not a valid port: ${raw}`);
|
|
1158
952
|
return port;
|
|
1159
953
|
}
|
|
1160
|
-
/**
|
|
1161
|
-
* Hand-rolled rather than a dependency: the CLI's whole value is that `npx
|
|
1162
|
-
* workerdeck` pulls down a small tree, and an arg parser is a hundred lines
|
|
1163
|
-
* of it.
|
|
1164
|
-
*/
|
|
1165
954
|
function parseArgs(argv) {
|
|
1166
955
|
const flags = {
|
|
1167
956
|
profiles: [],
|
|
@@ -1271,12 +1060,6 @@ function parseArgs(argv) {
|
|
|
1271
1060
|
}
|
|
1272
1061
|
return flags;
|
|
1273
1062
|
}
|
|
1274
|
-
/**
|
|
1275
|
-
* Explicit `--config` must exist — a typo that silently starts a default
|
|
1276
|
-
* instance is worse than a failure. An implicit one is looked up in cwd only:
|
|
1277
|
-
* walking parent directories would make what a given command does depend on
|
|
1278
|
-
* where it was run from.
|
|
1279
|
-
*/
|
|
1280
1063
|
async function loadConfigFile(explicit, cwd = process.cwd()) {
|
|
1281
1064
|
let path = null;
|
|
1282
1065
|
if (explicit) {
|
|
@@ -1311,18 +1094,9 @@ const LOOPBACK = new Set([
|
|
|
1311
1094
|
function isLoopback(host) {
|
|
1312
1095
|
return LOOPBACK.has(host);
|
|
1313
1096
|
}
|
|
1314
|
-
/**
|
|
1315
|
-
* Durable parking is on by default because this is a long-lived instance: a
|
|
1316
|
-
* turnkey tool that silently drops parked work on every restart is the wrong
|
|
1317
|
-
* default. The store writes whole transcripts in plaintext, so it goes beside
|
|
1318
|
-
* the config file (or under the home directory) rather than anywhere temporary,
|
|
1319
|
-
* and one directory serves exactly one instance — the store is single-process
|
|
1320
|
-
* by design, which the single-port model already implies.
|
|
1321
|
-
*/
|
|
1322
1097
|
function defaultStateDir(configPath) {
|
|
1323
1098
|
return configPath ? join(dirname(configPath), ".workerdeck") : join(homedir(), ".workerdeck");
|
|
1324
1099
|
}
|
|
1325
|
-
/** Hostname out of a Host header, minus the port and any IPv6 brackets. */
|
|
1326
1100
|
function hostnameOf(hostHeader) {
|
|
1327
1101
|
try {
|
|
1328
1102
|
return new URL(`http://${hostHeader}`).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
@@ -1330,20 +1104,10 @@ function hostnameOf(hostHeader) {
|
|
|
1330
1104
|
return "";
|
|
1331
1105
|
}
|
|
1332
1106
|
}
|
|
1333
|
-
/** 127.0.0.0/8, ::1, and the names that mean them. */
|
|
1334
1107
|
function isLoopbackHostname(hostname) {
|
|
1335
1108
|
if (LOOPBACK.has(hostname)) return true;
|
|
1336
1109
|
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
|
|
1337
1110
|
}
|
|
1338
|
-
/**
|
|
1339
|
-
* An `insecureHosts` entry names a host, never an endpoint: it is compared
|
|
1340
|
-
* against the bind host and against Host headers, and both are portless by the
|
|
1341
|
-
* time they are compared. An entry carrying a port would therefore never match
|
|
1342
|
-
* anything — a gate that looks armed and is not — so it is rejected loudly, as
|
|
1343
|
-
* is anything that does not parse as a host name or address. Bare IPv6 is
|
|
1344
|
-
* bracketed before parsing (WHATWG URL requires that), and the result is
|
|
1345
|
-
* lowercased to match `hostnameOf`'s normal form.
|
|
1346
|
-
*/
|
|
1347
1111
|
function normalizeInsecureHost(raw) {
|
|
1348
1112
|
const entry = raw.trim();
|
|
1349
1113
|
const looksLikeNamePort = /^[^:]+:\d+$/.test(entry);
|
|
@@ -1364,8 +1128,6 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
|
|
|
1364
1128
|
const authKey = flags.authKey || env.WORKERDECK_AUTH_KEY || loaded.options.auth?.secret || void 0;
|
|
1365
1129
|
const hostAuthenticates = typeof loaded.options.authenticate === "function";
|
|
1366
1130
|
const insecureHosts = new Set([...flags.insecureHosts, ...loaded.options.insecureHosts ?? []].map(normalizeInsecureHost));
|
|
1367
|
-
/** The bind host in the same normal form the entries were put in. It never
|
|
1368
|
-
* carries a port — that is a separate flag — so only brackets and case vary. */
|
|
1369
1131
|
const bindHost = host.trim().replace(/^\[|\]$/g, "").toLowerCase();
|
|
1370
1132
|
const generateAuthKey = !authKey && !hostAuthenticates && !loaded.options.allowUnauthenticated && !isLoopback(host) && !flags.insecure && !insecureHosts.has(bindHost);
|
|
1371
1133
|
const stateDir = flags.parking === false || loaded.options.stateDir === null ? null : flags.stateDir ?? env.WORKERDECK_STATE_DIR ?? loaded.options.stateDir ?? defaultStateDir(loaded.path);
|
|
@@ -1424,16 +1186,9 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
|
|
|
1424
1186
|
options
|
|
1425
1187
|
};
|
|
1426
1188
|
}
|
|
1427
|
-
/**
|
|
1428
|
-
* A relative `keyFile` resolves against the config file's own directory, not the
|
|
1429
|
-
* cwd — the same convention `defaultStateDir` uses, and the one that makes a
|
|
1430
|
-
* deployment directory self-contained. Missing required fields throw here rather
|
|
1431
|
-
* than at the first push, since a half-configured forwarder is a notification
|
|
1432
|
-
* that silently never arrives.
|
|
1433
|
-
*/
|
|
1434
1189
|
function resolveApns(loaded) {
|
|
1435
1190
|
const apns = loaded.options.apns;
|
|
1436
|
-
if (!apns) return
|
|
1191
|
+
if (!apns) return;
|
|
1437
1192
|
for (const field of [
|
|
1438
1193
|
"keyFile",
|
|
1439
1194
|
"keyId",
|
|
@@ -1448,13 +1203,15 @@ function resolveApns(loaded) {
|
|
|
1448
1203
|
}
|
|
1449
1204
|
//#endregion
|
|
1450
1205
|
//#region src/auth/login-page.ts
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1206
|
+
function escapeHtml(value) {
|
|
1207
|
+
return value.replace(/[&<>"']/g, (c) => ({
|
|
1208
|
+
"&": "&",
|
|
1209
|
+
"<": "<",
|
|
1210
|
+
">": ">",
|
|
1211
|
+
"\"": """,
|
|
1212
|
+
"'": "'"
|
|
1213
|
+
})[c] ?? c);
|
|
1214
|
+
}
|
|
1458
1215
|
function renderLoginPage(options) {
|
|
1459
1216
|
const { action, field, error, redirectTo, redirectField } = options;
|
|
1460
1217
|
const hidden = redirectField && redirectTo ? `<input type="hidden" name="${escapeHtml(redirectField)}" value="${escapeHtml(redirectTo)}">` : "";
|
|
@@ -1521,12 +1278,6 @@ function renderLoginPage(options) {
|
|
|
1521
1278
|
}
|
|
1522
1279
|
//#endregion
|
|
1523
1280
|
//#region src/lib/static.ts
|
|
1524
|
-
/**
|
|
1525
|
-
* Static file serving for the bundled dashboard. Deliberately policy-free: what
|
|
1526
|
-
* counts as a document, and whether an unauthenticated visitor gets the app or a
|
|
1527
|
-
* login page, is decided by the caller (see `instance.ts`). This module only
|
|
1528
|
-
* answers "is there such a file, and what headers does it want".
|
|
1529
|
-
*/
|
|
1530
1281
|
const CONTENT_TYPES = {
|
|
1531
1282
|
".html": "text/html; charset=utf-8",
|
|
1532
1283
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -1552,18 +1303,11 @@ function contentTypeFor(pathname) {
|
|
|
1552
1303
|
if (dot < 0) return "application/octet-stream";
|
|
1553
1304
|
return CONTENT_TYPES[pathname.slice(dot).toLowerCase()] ?? "application/octet-stream";
|
|
1554
1305
|
}
|
|
1555
|
-
/** A request for a file rather than an app route: anything with a known extension. */
|
|
1556
1306
|
function looksLikeAsset(pathname) {
|
|
1557
1307
|
const dot = pathname.lastIndexOf(".");
|
|
1558
1308
|
if (dot < 0) return false;
|
|
1559
1309
|
return pathname.slice(dot).toLowerCase() in CONTENT_TYPES;
|
|
1560
1310
|
}
|
|
1561
|
-
/**
|
|
1562
|
-
* Resolve `pathname` inside `root`, or null if it escapes. Vite emits every
|
|
1563
|
-
* asset under a content-hashed name, so the only paths that ever reach here are
|
|
1564
|
-
* ones the app itself generated — but this server is reachable by anything that
|
|
1565
|
-
* can open a socket, and `..` in a URL is the oldest trick there is.
|
|
1566
|
-
*/
|
|
1567
1311
|
function resolveWithinRoot(root, pathname) {
|
|
1568
1312
|
let decoded;
|
|
1569
1313
|
try {
|
|
@@ -1589,12 +1333,6 @@ function sendHtml(req, res, status, html, cache) {
|
|
|
1589
1333
|
});
|
|
1590
1334
|
res.end(req.method === "HEAD" ? void 0 : body);
|
|
1591
1335
|
}
|
|
1592
|
-
/**
|
|
1593
|
-
* Stream a file out of `root`. `immutable` is the caller's call, because it is a
|
|
1594
|
-
* promise about the URL, not the file: Vite's hashed assets can be cached
|
|
1595
|
-
* forever, but index.html must be revalidated every time or a deployed update
|
|
1596
|
-
* never reaches a browser that already has the old one.
|
|
1597
|
-
*/
|
|
1598
1336
|
async function serveFile(req, res, filePath, options = {}) {
|
|
1599
1337
|
if (req.method !== "GET" && req.method !== "HEAD") return "method-not-allowed";
|
|
1600
1338
|
let size;
|
|
@@ -1628,27 +1366,10 @@ async function serveFile(req, res, filePath, options = {}) {
|
|
|
1628
1366
|
}
|
|
1629
1367
|
//#endregion
|
|
1630
1368
|
//#region src/lib/instance.ts
|
|
1631
|
-
/**
|
|
1632
|
-
* The dashboard comes from `@workerdeck/web`, which ships it prebuilt and
|
|
1633
|
-
* exports the path to it. Depending on the package rather than vendoring a copy
|
|
1634
|
-
* means one dashboard, versioned in lockstep with everything else.
|
|
1635
|
-
*
|
|
1636
|
-
* In a checkout that directory only exists once the app has been built — dev
|
|
1637
|
-
* never builds — so the miss is worth a real message rather than a stack trace
|
|
1638
|
-
* from the static host.
|
|
1639
|
-
*/
|
|
1640
1369
|
function resolveWebRoot() {
|
|
1641
1370
|
if (existsSync(join(dashboardDir, "index.html"))) return dashboardDir;
|
|
1642
1371
|
throw new Error(`no dashboard build at ${dashboardDir}\n in a checkout: pnpm --filter @workerdeck/web run build`);
|
|
1643
1372
|
}
|
|
1644
|
-
/**
|
|
1645
|
-
* The Host-header gate for an unauthenticated instance. `allowedHosts` is null
|
|
1646
|
-
* whenever auth is on, and then this is the identity function — with a
|
|
1647
|
-
* credential in play a rebound origin holds no cookie and fails `authenticate`
|
|
1648
|
-
* anyway. Loopback *names* are what's checked, not the socket: the attacker in
|
|
1649
|
-
* this scenario controls DNS, so the connection genuinely arrives on 127.0.0.1;
|
|
1650
|
-
* what they cannot control is the name the victim's browser writes into Host.
|
|
1651
|
-
*/
|
|
1652
1373
|
function createHostGuard(allowedHosts) {
|
|
1653
1374
|
if (allowedHosts === null) return () => true;
|
|
1654
1375
|
return (req) => {
|
|
@@ -1659,8 +1380,6 @@ function createHostGuard(allowedHosts) {
|
|
|
1659
1380
|
return isLoopbackHostname(hostname) || allowedHosts.has(hostname);
|
|
1660
1381
|
};
|
|
1661
1382
|
}
|
|
1662
|
-
/** The request path, or null when the target is malformed enough that `URL`
|
|
1663
|
-
* refuses it — a caller matching a fixed route wants a miss, not a throw. */
|
|
1664
1383
|
function pathnameOf(req) {
|
|
1665
1384
|
try {
|
|
1666
1385
|
return new URL(req.url ?? "/", "http://internal").pathname;
|
|
@@ -1668,27 +1387,6 @@ function pathnameOf(req) {
|
|
|
1668
1387
|
return null;
|
|
1669
1388
|
}
|
|
1670
1389
|
}
|
|
1671
|
-
/**
|
|
1672
|
-
* Everything outside `/v1`. Order matters: the auth endpoints first (they are
|
|
1673
|
-
* how a browser gets a session in the first place), then assets, which stay
|
|
1674
|
-
* ungated — they are the app's own code, hold no secrets, and gating them would
|
|
1675
|
-
* only mean the login page could not be styled by the app it gates. Documents
|
|
1676
|
-
* come last, and that is the single place the auth decision is made.
|
|
1677
|
-
*
|
|
1678
|
-
* The APNs device route sits between auth and assets: it does its own
|
|
1679
|
-
* authentication (header only — it is never called by a browser), and it must
|
|
1680
|
-
* not fall through to the SPA's catch-all, which would answer a failed
|
|
1681
|
-
* registration with a 200 and an HTML document.
|
|
1682
|
-
*
|
|
1683
|
-
* That last rule holds whether or not a forwarder is configured, which is why
|
|
1684
|
-
* the path is claimed unconditionally below. With no `apns` config the route
|
|
1685
|
-
* does not exist, and an unclaimed `/apns/devices` reaches the catch-all, where
|
|
1686
|
-
* a registration POST draws a 405 (`GET, HEAD`) rather than the documented 404.
|
|
1687
|
-
* A client cannot tell that from a broken gateway: the iOS app reads only 404
|
|
1688
|
-
* as `unsupported`, so it throws instead, never marks the host synced, and
|
|
1689
|
-
* retries on every foreground with a visible error — for what is the normal
|
|
1690
|
-
* state of every gateway that never wanted push.
|
|
1691
|
-
*/
|
|
1692
1390
|
function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
|
|
1693
1391
|
return async (req, res) => {
|
|
1694
1392
|
if (!hostAllowed(req)) {
|
|
@@ -1824,6 +1522,7 @@ async function startInstance(config, options = {}) {
|
|
|
1824
1522
|
url,
|
|
1825
1523
|
port,
|
|
1826
1524
|
closed,
|
|
1525
|
+
drain: (drainOptions) => server.drain(drainOptions),
|
|
1827
1526
|
close: async () => {
|
|
1828
1527
|
await server.close();
|
|
1829
1528
|
await sessions?.flush?.();
|
|
@@ -1835,4 +1534,4 @@ async function startInstance(config, options = {}) {
|
|
|
1835
1534
|
//#endregion
|
|
1836
1535
|
export { createApnsForwarder as _, ConfigError as a, createApnsClient as b, isLoopback as c, parseArgs as d, resolveInstanceConfig as f, buildPush as g, materializeAuthKey as h, renderLoginPage as i, isLoopbackHostname as l, createAuthSessionStore as m, resolveWebRoot as n, defaultStateDir as o, createCliAuth as p, startInstance as r, hostnameOf as s, createHostGuard as t, loadConfigFile as u, createDeviceRegistry as v, loadApnsKey as x, createDeviceRoute as y };
|
|
1837
1536
|
|
|
1838
|
-
//# sourceMappingURL=instance-
|
|
1537
|
+
//# sourceMappingURL=instance-5sdv2Rrw.mjs.map
|