workerdeck 0.6.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.
@@ -2,10 +2,516 @@ import { createReadStream, existsSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, resolve, sep } from "node:path";
3
3
  import { createFileSessionStore, createWorkerServer } from "@workerdeck/server";
4
4
  import { dashboardDir } from "@workerdeck/web";
5
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
5
+ import { createHash, createPrivateKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
6
6
  import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
7
+ import { connect } from "node:http2";
7
8
  import { homedir } from "node:os";
8
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
9
515
  //#region src/auth-key.ts
10
516
  /** 48 hex chars — far past `createCliAuth`'s 12-char floor, and header-safe. */
11
517
  const generateKey = () => randomBytes(24).toString("hex");
@@ -469,6 +975,7 @@ function parseArgs(argv) {
469
975
  const flags = {
470
976
  profiles: [],
471
977
  cwdRoots: [],
978
+ fsRoots: [],
472
979
  allowedOrigins: [],
473
980
  allowedHosts: [],
474
981
  insecureHosts: []
@@ -525,6 +1032,13 @@ function parseArgs(argv) {
525
1032
  flags.cwdRoots.push(resolve(next(i, arg)));
526
1033
  i++;
527
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;
528
1042
  case "--allowed-origin":
529
1043
  flags.allowedOrigins.push(next(i, arg));
530
1044
  i++;
@@ -658,6 +1172,8 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
658
1172
  const stateDir = flags.parking === false || loaded.options.stateDir === null ? null : flags.stateDir ?? env.WORKERDECK_STATE_DIR ?? loaded.options.stateDir ?? defaultStateDir(loaded.path);
659
1173
  const envCwdRoots = env.WORKERDECK_CWD_ROOTS?.split(":").filter(Boolean).map((p) => resolve(cwd, p));
660
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;
661
1177
  const auth = {
662
1178
  ...loaded.options.auth,
663
1179
  secret: authKey,
@@ -669,9 +1185,14 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
669
1185
  ...[...flags.allowedHosts, ...loaded.options.allowedHosts ?? []].map((name) => name.toLowerCase()),
670
1186
  ...insecureHosts
671
1187
  ]);
672
- const { port: _p, host: _h, auth: _a, stateDir: _s, allowedHosts: _ah, insecureHosts: _ih, webRoot: _w, ...serverOptions } = loaded.options;
1188
+ const { port: _p, host: _h, auth: _a, stateDir: _s, allowedHosts: _ah, insecureHosts: _ih, webRoot: _w, apns: _ap, ...serverOptions } = loaded.options;
673
1189
  const options = { ...serverOptions };
674
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
+ };
675
1196
  if (flags.profiles.length) options.profiles = flags.profiles;
676
1197
  return {
677
1198
  port,
@@ -684,10 +1205,33 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
684
1205
  generateAuthKey,
685
1206
  allowedHosts,
686
1207
  webRoot: loaded.options.webRoot,
1208
+ apns: resolveApns(loaded),
687
1209
  open: flags.open ?? false,
688
1210
  options
689
1211
  };
690
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
+ }
691
1235
  //#endregion
692
1236
  //#region src/login-page.ts
693
1237
  const escapeHtml = (value) => value.replace(/[&<>"']/g, (c) => ({
@@ -907,8 +1451,13 @@ function createHostGuard(allowedHosts) {
907
1451
  * ungated — they are the app's own code, hold no secrets, and gating them would
908
1452
  * only mean the login page could not be styled by the app it gates. Documents
909
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.
910
1459
  */
911
- function createFallback(auth, webRoot, hostAllowed) {
1460
+ function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
912
1461
  return async (req, res) => {
913
1462
  if (!hostAllowed(req)) {
914
1463
  res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
@@ -916,6 +1465,7 @@ function createFallback(auth, webRoot, hostAllowed) {
916
1465
  return;
917
1466
  }
918
1467
  if (await auth.handleAuthRequest(req, res)) return;
1468
+ if (apnsRoute !== void 0 && await apnsRoute(req, res)) return;
919
1469
  const pathname = new URL(req.url ?? "/", "http://internal").pathname;
920
1470
  if (looksLikeAsset(pathname)) {
921
1471
  const filePath = resolveWithinRoot(webRoot, pathname);
@@ -962,7 +1512,12 @@ async function startInstance(config, options = {}) {
962
1512
  } : config.auth);
963
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");
964
1514
  const hostAllowed = createHostGuard(config.allowedHosts);
965
- const fallback = createFallback(auth, webRoot, hostAllowed);
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);
966
1521
  const parking = { ...config.options.parking };
967
1522
  if (config.stateDir && !parking.store) parking.store = createFileSessionStore({
968
1523
  dir: join(config.stateDir, "parked"),
@@ -974,6 +1529,13 @@ async function startInstance(config, options = {}) {
974
1529
  ...config.options,
975
1530
  checkCredentials: config.options.checkCredentials ?? true,
976
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
+ },
977
1539
  fallback,
978
1540
  authenticate: config.hostAuthenticates ? (req) => hostAllowed(req) ? config.options.authenticate(req) : null : (req) => hostAllowed(req) ? auth.authenticate(req) : null
979
1541
  });
@@ -999,6 +1561,10 @@ async function startInstance(config, options = {}) {
999
1561
  else if (auth.enabled) line(" auth: shared key — browsers sign in, services send a header");
1000
1562
  else line(" NO AUTH — anyone who can reach this port gets a session");
1001
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
+ }
1002
1568
  if (config.configPath) line(` config ${config.configPath}`);
1003
1569
  line("");
1004
1570
  }
@@ -1009,11 +1575,12 @@ async function startInstance(config, options = {}) {
1009
1575
  closed,
1010
1576
  close: async () => {
1011
1577
  await server.close();
1578
+ apns?.close();
1012
1579
  resolveClosed();
1013
1580
  }
1014
1581
  };
1015
1582
  }
1016
1583
  //#endregion
1017
- export { ConfigError as a, isLoopback as c, parseArgs as d, resolveInstanceConfig as f, 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 };
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 };
1018
1585
 
1019
- //# sourceMappingURL=instance-kupxU5UD.mjs.map
1586
+ //# sourceMappingURL=instance-DbIqlX-e.mjs.map