workerdeck 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  import { createReadStream, existsSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, resolve, sep } from "node:path";
3
- import { createFileProfileStore, createFileSessionStore, createFileSpendStore, createWorkerServer } from "@workerdeck/server";
3
+ import { createFileProfileStore, createFileSessionStore, createFileSpendStore, createWorkerServer, readJsonOr, writeJsonAtomic } from "@workerdeck/server";
4
4
  import { dashboardDir } from "@workerdeck/web";
5
- import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
6
5
  import { createHash, createHmac, createPrivateKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
6
+ import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
7
7
  import { connect, constants } from "node:http2";
8
8
  import { CHECKLIST_TEXT_MAX, isAgentRecord, parseUserQuestions, subagentLabel } from "@workerdeck/protocol";
9
9
  import { homedir } from "node:os";
@@ -40,14 +40,90 @@ function respondJson(res, status, body, headers) {
40
40
  ...headers
41
41
  }).end(JSON.stringify(body));
42
42
  }
43
+ async function isAuthenticated(authenticate, req) {
44
+ const principal = await authenticate(req);
45
+ return principal !== null && principal !== void 0 && principal !== false;
46
+ }
47
+ function isJsonRequest(req) {
48
+ return (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase() === "application/json";
49
+ }
43
50
  //#endregion
44
- //#region src/apns/activities.ts
45
- const FILENAME$1 = "apns-activities.json";
46
- const TOKEN_PATTERN$1 = /^[0-9a-fA-F]{32,200}$/;
47
- const MAX_BODY_BYTES$1 = 4096;
48
- function isEnvironment$1(value) {
51
+ //#region src/apns/json-registry.ts
52
+ const TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/;
53
+ const MAX_BODY_BYTES = 4096;
54
+ function isEnvironment(value) {
49
55
  return value === "development" || value === "production";
50
56
  }
57
+ function jsonRecordFile(options, filename, field) {
58
+ const path = options.dir === null ? null : join(options.dir, filename);
59
+ return {
60
+ load: async () => {
61
+ if (path === null) return [];
62
+ const records = (await readJsonOr(path, null))?.[field];
63
+ return Array.isArray(records) ? records : [];
64
+ },
65
+ save: async (records) => {
66
+ if (path === null) return;
67
+ try {
68
+ await writeJsonAtomic(path, { [field]: records }, {
69
+ indent: 2,
70
+ trailingNewline: true
71
+ });
72
+ } catch (error) {
73
+ options.onError?.(error, {
74
+ op: "write",
75
+ path
76
+ });
77
+ }
78
+ }
79
+ };
80
+ }
81
+ function jsonPushRoute(route, registry, authenticate, handle) {
82
+ return async (req, res) => {
83
+ let pathname;
84
+ try {
85
+ pathname = new URL(req.url ?? "/", "http://internal").pathname;
86
+ } catch {
87
+ return false;
88
+ }
89
+ if (pathname !== route) return false;
90
+ if (registry === null) {
91
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
92
+ res.end("this gateway runs without push\n");
93
+ return true;
94
+ }
95
+ if (req.method !== "POST" && req.method !== "DELETE") {
96
+ res.writeHead(405, { allow: "POST, DELETE" }).end();
97
+ return true;
98
+ }
99
+ if (!await isAuthenticated(authenticate, req)) {
100
+ respondJson(res, 401, { error: "unauthorized" });
101
+ return true;
102
+ }
103
+ if (!isJsonRequest(req)) {
104
+ respondJson(res, 415, { error: "expected content-type application/json" });
105
+ return true;
106
+ }
107
+ const raw = await readBody(req, MAX_BODY_BYTES);
108
+ if (raw === null) {
109
+ respondJson(res, 413, { error: "body too large" });
110
+ res.once("finish", () => req.destroy());
111
+ return true;
112
+ }
113
+ let body;
114
+ try {
115
+ body = JSON.parse(raw);
116
+ } catch {
117
+ respondJson(res, 400, { error: "invalid JSON body" });
118
+ return true;
119
+ }
120
+ await handle(registry, req, res, body);
121
+ return true;
122
+ };
123
+ }
124
+ //#endregion
125
+ //#region src/apns/activities.ts
126
+ const FILENAME$1 = "apns-activities.json";
51
127
  function keyOf(deviceToken, sessionId) {
52
128
  return `${deviceToken}:${sessionId}`;
53
129
  }
@@ -64,28 +140,10 @@ function persistable(record) {
64
140
  };
65
141
  }
66
142
  async function createActivityRegistry(options) {
67
- const path = options.dir === null ? null : join(options.dir, FILENAME$1);
143
+ const file = jsonRecordFile(options, FILENAME$1, "activities");
68
144
  const records = /* @__PURE__ */ new Map();
69
- if (path !== null) try {
70
- const parsed = JSON.parse(await readFile(path, "utf8"));
71
- for (const record of parsed.activities ?? []) if (typeof record?.deviceToken === "string" && typeof record.sessionId === "string" && isEnvironment$1(record.environment)) records.set(keyOf(record.deviceToken, record.sessionId), record);
72
- } catch {}
73
- const persist = async () => {
74
- if (path === null) return;
75
- try {
76
- await mkdir(options.dir, {
77
- recursive: true,
78
- mode: 448
79
- });
80
- await writeFile(path, `${JSON.stringify({ activities: [...records.values()].map(persistable) }, null, 2)}\n`, { mode: 384 });
81
- await chmod(path, 384);
82
- } catch (error) {
83
- options.onError?.(error, {
84
- op: "write",
85
- path
86
- });
87
- }
88
- };
145
+ for (const record of await file.load()) if (typeof record?.deviceToken === "string" && typeof record.sessionId === "string" && isEnvironment(record.environment)) records.set(keyOf(record.deviceToken, record.sessionId), record);
146
+ const persist = () => file.save([...records.values()].map(persistable));
89
147
  return {
90
148
  list: () => [...records.values()],
91
149
  get: (deviceToken, sessionId) => records.get(keyOf(deviceToken, sessionId)),
@@ -134,74 +192,40 @@ async function createActivityRegistry(options) {
134
192
  };
135
193
  }
136
194
  function createActivityRoute(registry, authenticate) {
137
- return async (req, res) => {
138
- let pathname;
139
- try {
140
- pathname = new URL(req.url ?? "/", "http://internal").pathname;
141
- } catch {
142
- return false;
143
- }
144
- if (pathname !== "/apns/activities") return false;
145
- if (registry === null) {
146
- res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
147
- res.end("this gateway runs without push\n");
148
- return true;
149
- }
150
- if (req.method !== "POST" && req.method !== "DELETE") {
151
- res.writeHead(405, { allow: "POST, DELETE" }).end();
152
- return true;
153
- }
154
- if (authenticate(req) === null) {
155
- respondJson(res, 401, { error: "unauthorized" });
156
- return true;
157
- }
158
- const raw = await readBody(req, MAX_BODY_BYTES$1);
159
- if (raw === null) {
160
- respondJson(res, 413, { error: "body too large" });
161
- res.once("finish", () => req.destroy());
162
- return true;
163
- }
164
- let body;
165
- try {
166
- body = JSON.parse(raw);
167
- } catch {
168
- respondJson(res, 400, { error: "invalid JSON body" });
169
- return true;
170
- }
195
+ return jsonPushRoute("/apns/activities", registry, authenticate, async (activities, req, res, body) => {
171
196
  const token = body.token;
172
- if (typeof token !== "string" || !TOKEN_PATTERN$1.test(token)) {
197
+ if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
173
198
  respondJson(res, 400, { error: "token must be a hex Live Activity update token" });
174
- return true;
199
+ return;
175
200
  }
176
201
  if (req.method === "DELETE") {
177
- await registry.removeByUpdateToken(token);
202
+ await activities.removeByUpdateToken(token);
178
203
  res.writeHead(204).end();
179
- return true;
204
+ return;
180
205
  }
181
206
  const sessionId = body.sessionId;
182
207
  if (typeof sessionId !== "string" || sessionId === "") {
183
208
  respondJson(res, 400, { error: "sessionId is required" });
184
- return true;
209
+ return;
185
210
  }
186
- if (!isEnvironment$1(body.environment)) {
211
+ if (!isEnvironment(body.environment)) {
187
212
  respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
188
- return true;
213
+ return;
189
214
  }
190
- const deviceToken = typeof body.deviceToken === "string" && TOKEN_PATTERN$1.test(body.deviceToken) ? body.deviceToken : void 0;
191
- if (await registry.attach({
215
+ const deviceToken = typeof body.deviceToken === "string" && TOKEN_PATTERN.test(body.deviceToken) ? body.deviceToken : void 0;
216
+ if (await activities.attach({
192
217
  sessionId,
193
218
  deviceToken,
194
219
  environment: body.environment
195
220
  }, token) === void 0) {
196
221
  respondJson(res, 404, { error: "no activity awaiting a token for this session" });
197
- return true;
222
+ return;
198
223
  }
199
224
  respondJson(res, 200, {
200
225
  attached: true,
201
226
  sessionId
202
227
  });
203
- return true;
204
- };
228
+ });
205
229
  }
206
230
  //#endregion
207
231
  //#region src/apns/devices.ts
@@ -217,11 +241,6 @@ const DEFAULT_NOTIFY = [
217
241
  "session_error"
218
242
  ];
219
243
  const FILENAME = "apns-devices.json";
220
- const TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/;
221
- const MAX_BODY_BYTES = 4096;
222
- function isEnvironment(value) {
223
- return value === "development" || value === "production";
224
- }
225
244
  function isNotifyList(value) {
226
245
  return Array.isArray(value) && value.every((entry) => NOTIFY_EVENTS.includes(entry));
227
246
  }
@@ -229,32 +248,17 @@ function sameNotify(a, b) {
229
248
  if (a === void 0 || b === void 0) return a === b;
230
249
  return a.length === b.length && a.every((entry, index) => entry === b[index]);
231
250
  }
251
+ function optionalString(value) {
252
+ return typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
253
+ }
232
254
  function wantsNotification(device, type) {
233
255
  return (device.notify ?? DEFAULT_NOTIFY).includes(type);
234
256
  }
235
257
  async function createDeviceRegistry(options) {
236
- const path = options.dir === null ? null : join(options.dir, FILENAME);
258
+ const file = jsonRecordFile(options, FILENAME, "devices");
237
259
  const devices = /* @__PURE__ */ new Map();
238
- if (path !== null) try {
239
- const parsed = JSON.parse(await readFile(path, "utf8"));
240
- for (const record of parsed.devices ?? []) if (typeof record?.token === "string" && isEnvironment(record.environment)) devices.set(record.token, record);
241
- } catch {}
242
- const persist = async () => {
243
- if (path === null) return;
244
- try {
245
- await mkdir(options.dir, {
246
- recursive: true,
247
- mode: 448
248
- });
249
- await writeFile(path, `${JSON.stringify({ devices: [...devices.values()] }, null, 2)}\n`, { mode: 384 });
250
- await chmod(path, 384);
251
- } catch (error) {
252
- options.onError?.(error, {
253
- op: "write",
254
- path
255
- });
256
- }
257
- };
260
+ for (const record of await file.load()) if (typeof record?.token === "string" && isEnvironment(record.environment)) devices.set(record.token, record);
261
+ const persist = () => file.save([...devices.values()]);
258
262
  return {
259
263
  list: () => [...devices.values()],
260
264
  async register(record) {
@@ -279,70 +283,40 @@ async function createDeviceRegistry(options) {
279
283
  };
280
284
  }
281
285
  function createDeviceRoute(registry, authenticate) {
282
- return async (req, res) => {
283
- let pathname;
284
- try {
285
- pathname = new URL(req.url ?? "/", "http://internal").pathname;
286
- } catch {
287
- return false;
288
- }
289
- if (pathname !== "/apns/devices") return false;
290
- if (registry === null) {
291
- res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
292
- res.end("this gateway runs without push\n");
293
- return true;
294
- }
295
- if (req.method !== "POST" && req.method !== "DELETE") {
296
- res.writeHead(405, { allow: "POST, DELETE" }).end();
297
- return true;
298
- }
299
- if (authenticate(req) === null) {
300
- respondJson(res, 401, { error: "unauthorized" });
301
- return true;
302
- }
303
- const raw = await readBody(req, MAX_BODY_BYTES);
304
- if (raw === null) {
305
- respondJson(res, 413, { error: "body too large" });
306
- res.once("finish", () => req.destroy());
307
- return true;
308
- }
309
- let body;
310
- try {
311
- body = JSON.parse(raw);
312
- } catch {
313
- respondJson(res, 400, { error: "invalid JSON body" });
314
- return true;
315
- }
286
+ return jsonPushRoute("/apns/devices", registry, authenticate, async (devices, req, res, body) => {
316
287
  const token = body.token;
317
288
  if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
318
289
  respondJson(res, 400, { error: "token must be a hex APNs device token" });
319
- return true;
290
+ return;
320
291
  }
321
292
  if (req.method === "DELETE") {
322
- await registry.remove(token);
293
+ await devices.remove(token);
323
294
  res.writeHead(204).end();
324
- return true;
295
+ return;
325
296
  }
326
297
  if (!isEnvironment(body.environment)) {
327
298
  respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
328
- return true;
299
+ return;
329
300
  }
330
- const optionalString = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
331
301
  const startTokenGiven = Object.hasOwn(body, "liveActivityStartToken");
332
302
  const startToken = body.liveActivityStartToken;
333
303
  if (startTokenGiven && startToken !== null && (typeof startToken !== "string" || !TOKEN_PATTERN.test(startToken))) {
334
304
  respondJson(res, 400, { error: "liveActivityStartToken must be a hex token or null" });
335
- return true;
305
+ return;
336
306
  }
337
307
  const notifyGiven = Object.hasOwn(body, "notify");
338
308
  if (notifyGiven && !isNotifyList(body.notify)) {
339
309
  respondJson(res, 400, { error: `notify must be an array of ${NOTIFY_EVENTS.join(", ")}` });
340
- return true;
310
+ return;
311
+ }
312
+ const previous = devices.list().find((record) => record.token === token);
313
+ if (previous === void 0 && devices.list().length >= 64) {
314
+ respondJson(res, 409, { error: `device limit reached (64); unregister one first` });
315
+ return;
341
316
  }
342
- const previous = registry.list().find((record) => record.token === token);
343
317
  const liveActivityStartToken = startTokenGiven ? startToken ?? void 0 : previous?.liveActivityStartToken;
344
318
  const notify = notifyGiven ? body.notify : previous?.notify;
345
- await registry.register({
319
+ await devices.register({
346
320
  token,
347
321
  environment: body.environment,
348
322
  hostId: optionalString(body.hostId),
@@ -355,8 +329,7 @@ function createDeviceRoute(registry, authenticate) {
355
329
  registered: true,
356
330
  environment: body.environment
357
331
  });
358
- return true;
359
- };
332
+ });
360
333
  }
361
334
  //#endregion
362
335
  //#region src/apns/routes.ts
@@ -834,16 +807,6 @@ const CATEGORY = {
834
807
  permission: "PERMISSION_REQUEST",
835
808
  event: "SESSION_EVENT"
836
809
  };
837
- function label(session) {
838
- const title = session.title?.trim();
839
- if (title !== void 0 && title !== "") return title;
840
- const leaf = session.cwd.split("/").filter(Boolean).at(-1);
841
- return leaf !== void 0 && leaf !== "" ? leaf : session.id;
842
- }
843
- function oneLine(text, limit = BODY_LIMIT) {
844
- const flat = (text ?? "").replace(/\s+/g, " ").trim();
845
- return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
846
- }
847
810
  function collapseKey(sessionId) {
848
811
  return createHash("sha256").update(sessionId).digest("base64url").slice(0, 32);
849
812
  }
@@ -863,7 +826,7 @@ function titleFor(notification, name) {
863
826
  }
864
827
  }
865
828
  function bodyFor(notification) {
866
- const preview = oneLine(notification.preview);
829
+ const preview = clamp(notification.preview ?? "", BODY_LIMIT);
867
830
  if (preview !== "") return preview;
868
831
  switch (notification.type) {
869
832
  case "permission_requested": return "The agent is waiting for your approval.";
@@ -874,7 +837,7 @@ function bodyFor(notification) {
874
837
  }
875
838
  function buildPush(notification, hostId) {
876
839
  const permission = notification.type === "permission_requested";
877
- const name = label(notification.session);
840
+ const name = sessionLabel(notification.session);
878
841
  let body = bodyFor(notification);
879
842
  const build = (text) => ({
880
843
  aps: {
@@ -895,7 +858,7 @@ function buildPush(notification, hostId) {
895
858
  });
896
859
  let payload = build(body);
897
860
  while (Buffer.byteLength(JSON.stringify(payload)) > MAX_PAYLOAD_BYTES && body.length > 16) {
898
- body = oneLine(body, Math.floor(body.length / 2));
861
+ body = clamp(body, Math.floor(body.length / 2));
899
862
  payload = build(body);
900
863
  }
901
864
  return {
@@ -1328,15 +1291,8 @@ async function createAuthSessionStore(options) {
1328
1291
  version: FORMAT_VERSION,
1329
1292
  sessions: entries.map(([key, s]) => [key, s.expiresAt])
1330
1293
  };
1331
- const tmp = `${path}.${process.pid}.tmp`;
1332
1294
  try {
1333
- await mkdir(dirname(path), {
1334
- recursive: true,
1335
- mode: 448
1336
- });
1337
- await writeFile(tmp, `${JSON.stringify(body)}\n`, { mode: 384 });
1338
- await chmod(tmp, 384);
1339
- await rename(tmp, path);
1295
+ await writeJsonAtomic(path, body, { trailingNewline: true });
1340
1296
  warned = false;
1341
1297
  } catch (error) {
1342
1298
  if (!warned) {
@@ -1354,328 +1310,423 @@ async function createAuthSessionStore(options) {
1354
1310
  };
1355
1311
  }
1356
1312
  //#endregion
1357
- //#region src/auth/auth.ts
1358
- const MIN_SECRET_LENGTH = 12;
1359
- const DEFAULT_COOKIE_NAME = "workerdeck_session";
1360
- const DEFAULT_TTL_MS = 6048e5;
1361
- const DEFAULT_THROTTLE_WINDOW_MS = 9e5;
1362
- const DEFAULT_MAX_FAILURES_PER_IP = 10;
1363
- const DEFAULT_MAX_FAILURES_GLOBAL = 100;
1364
- const MAX_SESSIONS = 100;
1365
- const MAX_LOGIN_BODY_BYTES = 4096;
1313
+ //#region src/auth/origin.ts
1366
1314
  const SAFE_METHODS = /* @__PURE__ */ new Set([
1367
1315
  "GET",
1368
1316
  "HEAD",
1369
1317
  "OPTIONS"
1370
1318
  ]);
1371
- function sha256(value) {
1372
- return createHash("sha256").update(value).digest();
1373
- }
1374
- function createCliAuth(options = {}) {
1375
- const { secret } = options;
1376
- const enabled = secret !== void 0;
1377
- 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`);
1378
- const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
1379
- const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
1380
- if (!(ttlMs > 0)) throw new Error("createCliAuth: ttlMs must be positive");
1381
- const trustProxy = options.trustProxy === true;
1382
- const windowMs = options.throttle?.windowMs ?? DEFAULT_THROTTLE_WINDOW_MS;
1383
- const maxFailuresPerIp = options.throttle?.maxFailuresPerIp ?? DEFAULT_MAX_FAILURES_PER_IP;
1384
- const maxFailuresGlobal = options.throttle?.maxFailuresGlobal ?? DEFAULT_MAX_FAILURES_GLOBAL;
1385
- const allowedOrigins = new Set((options.allowedOrigins ?? []).map((entry) => {
1319
+ function parseAllowedOrigins(entries) {
1320
+ return new Set(entries.map((entry) => {
1386
1321
  try {
1387
1322
  return new URL(entry).origin;
1388
1323
  } catch {
1389
1324
  throw new Error(`createCliAuth: allowedOrigins entry is not a valid origin: ${JSON.stringify(entry)}`);
1390
1325
  }
1391
1326
  }));
1392
- const secretDigest = secret === void 0 ? void 0 : sha256(secret);
1393
- const secretMatches = (candidate) => secretDigest !== void 0 && timingSafeEqual(sha256(candidate), secretDigest);
1394
- const sessions = /* @__PURE__ */ new Map();
1395
- const store = options.sessions;
1396
- const tokenKey = (token) => secret === void 0 ? sha256(token).toString("hex") : createHmac("sha256", secret).update(token).digest("hex");
1397
- if (store?.initial !== void 0) for (const [key, entry] of store.initial) {
1398
- if (sessions.size >= MAX_SESSIONS) break;
1399
- sessions.set(key, { expiresAt: entry.expiresAt });
1327
+ }
1328
+ function isLoopbackAddress(ip) {
1329
+ return ip === "::1" || ip.startsWith("127.") || ip.startsWith("::ffff:127.");
1330
+ }
1331
+ function forwardedLast(value) {
1332
+ if (value === void 0) return;
1333
+ const last = (Array.isArray(value) ? value.join(",") : value).split(",").at(-1)?.trim();
1334
+ return last === "" ? void 0 : last;
1335
+ }
1336
+ function isSecure(req, trustProxy) {
1337
+ if (req.socket.encrypted === true) return true;
1338
+ return trustProxy && forwardedLast(req.headers["x-forwarded-proto"])?.toLowerCase() === "https";
1339
+ }
1340
+ function clientIp(req, trustProxy) {
1341
+ return (trustProxy ? forwardedLast(req.headers["x-forwarded-for"]) : void 0) ?? req.socket.remoteAddress ?? "unknown";
1342
+ }
1343
+ function isUpgradeRequest(req) {
1344
+ const upgrade = req.headers.upgrade;
1345
+ return typeof upgrade === "string" && upgrade.toLowerCase().includes("websocket");
1346
+ }
1347
+ function isUnsafeMethod(req) {
1348
+ return !SAFE_METHODS.has((req.method ?? "GET").toUpperCase());
1349
+ }
1350
+ function originVerdict(req, policy) {
1351
+ const raw = req.headers.origin;
1352
+ if (raw === void 0) return "absent";
1353
+ let origin;
1354
+ try {
1355
+ origin = new URL(raw).origin;
1356
+ } catch {
1357
+ return "foreign";
1400
1358
  }
1401
- const persist = () => store?.save([...sessions]);
1402
- const createSession = () => {
1403
- if (sessions.size >= MAX_SESSIONS) {
1404
- const oldest = sessions.keys().next().value;
1405
- if (oldest !== void 0) sessions.delete(oldest);
1406
- }
1407
- const token = randomBytes(32).toString("base64url");
1408
- sessions.set(tokenKey(token), { expiresAt: Date.now() + ttlMs });
1409
- persist();
1410
- return token;
1411
- };
1412
- const cookieToken = (req) => {
1413
- const header = req.headers.cookie;
1414
- if (typeof header !== "string") return;
1415
- for (const part of header.split(";")) {
1416
- const eq = part.indexOf("=");
1417
- if (eq === -1) continue;
1418
- if (part.slice(0, eq).trim() === cookieName) return part.slice(eq + 1).trim();
1419
- }
1420
- };
1421
- const hasSession = (req) => {
1422
- const token = cookieToken(req);
1423
- if (token === void 0 || token === "") return false;
1424
- const key = tokenKey(token);
1425
- const entry = sessions.get(key);
1426
- if (entry === void 0) return false;
1427
- if (entry.expiresAt <= Date.now()) {
1428
- sessions.delete(key);
1429
- persist();
1430
- return false;
1431
- }
1432
- return true;
1433
- };
1434
- const forwardedLast = (value) => {
1435
- if (value === void 0) return;
1436
- const last = (Array.isArray(value) ? value.join(",") : value).split(",").at(-1)?.trim();
1437
- return last === "" ? void 0 : last;
1438
- };
1439
- const isSecure = (req) => {
1440
- if (req.socket.encrypted === true) return true;
1441
- return trustProxy && forwardedLast(req.headers["x-forwarded-proto"])?.toLowerCase() === "https";
1442
- };
1443
- const expectedOrigin = (req) => {
1444
- const host = (trustProxy ? forwardedLast(req.headers["x-forwarded-host"]) : void 0) ?? req.headers.host;
1445
- if (host === void 0 || host === "") return null;
1446
- try {
1447
- return new URL(`${isSecure(req) ? "https" : "http"}://${host}`).origin;
1448
- } catch {
1449
- return null;
1450
- }
1451
- };
1452
- const originVerdict = (req) => {
1453
- const raw = req.headers.origin;
1454
- if (raw === void 0) return "absent";
1455
- let origin;
1456
- try {
1457
- origin = new URL(raw).origin;
1458
- } catch {
1459
- return "foreign";
1460
- }
1461
- if (allowedOrigins.has(origin)) return "ok";
1462
- const expected = expectedOrigin(req);
1463
- return expected !== null && origin === expected ? "ok" : "foreign";
1464
- };
1465
- const headerSecret = (req) => {
1466
- const key = req.headers["x-workerdeck-key"];
1467
- if (typeof key === "string" && key !== "") return key;
1468
- const authorization = req.headers.authorization;
1469
- if (typeof authorization === "string") {
1470
- const match = /^Bearer\s+(.+)$/i.exec(authorization);
1471
- if (match !== null) return match[1];
1472
- }
1473
- };
1474
- const openPrincipal = {
1475
- via: "open",
1476
- canManageProfiles: true
1477
- };
1478
- const isUpgradeRequest = (req) => {
1479
- const upgrade = req.headers.upgrade;
1480
- return typeof upgrade === "string" && upgrade.toLowerCase().includes("websocket");
1481
- };
1482
- const querySecret = (req) => {
1483
- if (!isUpgradeRequest(req)) return;
1484
- const key = new URL(req.url ?? "/", "http://internal").searchParams.get("key");
1485
- return key !== null && key !== "" ? key : void 0;
1486
- };
1487
- const authenticate = (req) => {
1488
- if (!enabled) return openPrincipal;
1489
- const provided = headerSecret(req) ?? querySecret(req);
1490
- if (provided !== void 0) return secretMatches(provided) ? {
1491
- via: "header",
1492
- canManageProfiles: true
1493
- } : null;
1494
- if (!hasSession(req)) return null;
1495
- const verdict = originVerdict(req);
1496
- if (verdict === "foreign") return null;
1497
- const isUpgrade = isUpgradeRequest(req);
1498
- const unsafe = !SAFE_METHODS.has((req.method ?? "GET").toUpperCase());
1499
- if ((isUpgrade || unsafe) && verdict !== "ok") return null;
1500
- return {
1501
- via: "cookie",
1502
- canManageProfiles: true
1503
- };
1504
- };
1505
- const failures = /* @__PURE__ */ new Map();
1506
- const globalFailures = {
1507
- count: 0,
1508
- windowStart: 0
1509
- };
1510
- const clientIp = (req) => (trustProxy ? forwardedLast(req.headers["x-forwarded-for"]) : void 0) ?? req.socket.remoteAddress ?? "unknown";
1511
- const blockedMs = (entry, max, now) => entry !== void 0 && entry.count >= max && now - entry.windowStart < windowMs ? entry.windowStart + windowMs - now : 0;
1512
- const loginBlockedMs = (ip, now) => Math.max(blockedMs(failures.get(ip), maxFailuresPerIp, now), blockedMs(globalFailures, maxFailuresGlobal, now));
1513
- const recordFailure = (ip, now) => {
1514
- if (failures.size > 256) {
1515
- for (const [key, entry] of failures) if (now - entry.windowStart >= windowMs) failures.delete(key);
1516
- }
1517
- const entry = failures.get(ip);
1518
- if (entry === void 0 || now - entry.windowStart >= windowMs) failures.set(ip, {
1519
- count: 1,
1520
- windowStart: now
1521
- });
1522
- else entry.count += 1;
1523
- if (now - globalFailures.windowStart >= windowMs) {
1524
- globalFailures.count = 1;
1525
- globalFailures.windowStart = now;
1526
- } else globalFailures.count += 1;
1527
- };
1528
- const cookieAttributes = (req) => {
1529
- const attrs = [
1530
- "Path=/",
1531
- "HttpOnly",
1532
- "SameSite=Lax"
1533
- ];
1534
- if (isSecure(req)) attrs.push("Secure");
1535
- return attrs;
1359
+ if (policy.allowedOrigins.has(origin)) return "ok";
1360
+ const expected = expectedOrigin(req, policy.trustProxy);
1361
+ return expected !== null && origin === expected ? "ok" : "foreign";
1362
+ }
1363
+ function isCrossSiteBrowserRequest(req, policy) {
1364
+ if (originVerdict(req, policy) === "foreign") return true;
1365
+ return (isUnsafeMethod(req) || isUpgradeRequest(req)) && req.headers["sec-fetch-site"] === "cross-site";
1366
+ }
1367
+ function cookieOriginAccepted(req, policy) {
1368
+ const verdict = originVerdict(req, policy);
1369
+ if (verdict === "foreign") return false;
1370
+ return !((isUpgradeRequest(req) || isUnsafeMethod(req)) && verdict !== "ok");
1371
+ }
1372
+ function expectedOrigin(req, trustProxy) {
1373
+ const host = (trustProxy ? forwardedLast(req.headers["x-forwarded-host"]) : void 0) ?? req.headers.host;
1374
+ if (host === void 0 || host === "") return null;
1375
+ try {
1376
+ return new URL(`${isSecure(req, trustProxy) ? "https" : "http"}://${host}`).origin;
1377
+ } catch {
1378
+ return null;
1379
+ }
1380
+ }
1381
+ //#endregion
1382
+ //#region src/auth/secret.ts
1383
+ function sha256(value) {
1384
+ return createHash("sha256").update(value).digest();
1385
+ }
1386
+ function secretMatcher(secret) {
1387
+ const digest = secret === void 0 ? void 0 : sha256(secret);
1388
+ return (candidate) => digest !== void 0 && timingSafeEqual(sha256(candidate), digest);
1389
+ }
1390
+ function headerSecret(req) {
1391
+ const key = req.headers["x-workerdeck-key"];
1392
+ if (typeof key === "string" && key !== "") return key;
1393
+ const authorization = req.headers.authorization;
1394
+ if (typeof authorization === "string") {
1395
+ const match = /^Bearer\s+(.+)$/i.exec(authorization);
1396
+ if (match !== null) return match[1];
1397
+ }
1398
+ }
1399
+ function querySecret(req) {
1400
+ if (!isUpgradeRequest(req)) return;
1401
+ const key = new URL(req.url ?? "/", "http://internal").searchParams.get("key");
1402
+ return key !== null && key !== "" ? key : void 0;
1403
+ }
1404
+ //#endregion
1405
+ //#region src/auth/cookie-sessions.ts
1406
+ const MAX_SESSIONS = 100;
1407
+ function createCookieSessions(options) {
1408
+ const entries = /* @__PURE__ */ new Map();
1409
+ for (const [key, entry] of options.store?.initial ?? []) {
1410
+ if (entries.size >= MAX_SESSIONS) break;
1411
+ entries.set(key, { expiresAt: entry.expiresAt });
1412
+ }
1413
+ return {
1414
+ ...options,
1415
+ entries
1536
1416
  };
1537
- const setCookieValue = (token, req) => [
1538
- `${cookieName}=${token}`,
1539
- `Max-Age=${Math.ceil(ttlMs / 1e3)}`,
1540
- ...cookieAttributes(req)
1417
+ }
1418
+ function mintSession(sessions) {
1419
+ if (sessions.entries.size >= MAX_SESSIONS) {
1420
+ const oldest = sessions.entries.keys().next().value;
1421
+ if (oldest !== void 0) sessions.entries.delete(oldest);
1422
+ }
1423
+ const token = randomBytes(32).toString("base64url");
1424
+ sessions.entries.set(tokenKey(sessions, token), { expiresAt: Date.now() + sessions.ttlMs });
1425
+ persist(sessions);
1426
+ return token;
1427
+ }
1428
+ function hasSession(sessions, req) {
1429
+ const token = cookieToken(sessions, req);
1430
+ if (token === void 0 || token === "") return false;
1431
+ const key = tokenKey(sessions, token);
1432
+ const entry = sessions.entries.get(key);
1433
+ if (entry === void 0) return false;
1434
+ if (entry.expiresAt <= Date.now()) {
1435
+ sessions.entries.delete(key);
1436
+ persist(sessions);
1437
+ return false;
1438
+ }
1439
+ return true;
1440
+ }
1441
+ function revokeSession(sessions, req) {
1442
+ const token = cookieToken(sessions, req);
1443
+ if (token !== void 0 && token !== "" && sessions.entries.delete(tokenKey(sessions, token))) persist(sessions);
1444
+ }
1445
+ function setCookieValue(sessions, token, req) {
1446
+ return [
1447
+ `${sessions.cookieName}=${token}`,
1448
+ `Max-Age=${Math.ceil(sessions.ttlMs / 1e3)}`,
1449
+ ...cookieAttributes(sessions, req)
1541
1450
  ].join("; ");
1542
- const clearCookieValue = (req) => [
1543
- `${cookieName}=`,
1451
+ }
1452
+ function clearCookieValue(sessions, req) {
1453
+ return [
1454
+ `${sessions.cookieName}=`,
1544
1455
  "Max-Age=0",
1545
- ...cookieAttributes(req)
1456
+ ...cookieAttributes(sessions, req)
1546
1457
  ].join("; ");
1547
- const respondRedirect = (res, location, headers) => {
1548
- res.writeHead(303, {
1549
- location,
1550
- "cache-control": "no-store",
1551
- ...headers
1552
- }).end();
1553
- };
1554
- const wantsJson = (req) => (req.headers.accept ?? "").includes("application/json");
1555
- const handleLogin = async (req, res) => {
1556
- const json = wantsJson(req);
1557
- if (!enabled) {
1558
- respondJson(res, 409, { error: "auth is disabled: no secret is configured" });
1559
- return;
1560
- }
1561
- if (originVerdict(req) === "foreign") {
1562
- respondJson(res, 403, { error: "origin not allowed" });
1563
- return;
1564
- }
1565
- const ip = clientIp(req);
1566
- const blocked = loginBlockedMs(ip, Date.now());
1567
- if (blocked > 0) {
1568
- const retryAfter = String(Math.ceil(blocked / 1e3));
1569
- if (json) respondJson(res, 429, { error: "too many failed attempts" }, { "retry-after": retryAfter });
1570
- else respondRedirect(res, "/?auth=throttled", { "retry-after": retryAfter });
1571
- return;
1572
- }
1573
- const body = await readBody(req, MAX_LOGIN_BODY_BYTES);
1574
- if (body === null) {
1575
- respondJson(res, 413, { error: "body too large" });
1576
- res.once("finish", () => req.destroy());
1577
- return;
1578
- }
1579
- const contentType = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
1580
- let candidate;
1581
- if (contentType === "application/x-www-form-urlencoded") candidate = new URLSearchParams(body).get("secret");
1582
- else if (contentType === "application/json") try {
1583
- candidate = JSON.parse(body).secret;
1584
- } catch {
1585
- respondJson(res, 400, { error: "invalid body" });
1586
- return;
1587
- }
1588
- else {
1589
- respondJson(res, 415, { error: "expected application/x-www-form-urlencoded or application/json" });
1590
- return;
1591
- }
1592
- if (typeof candidate !== "string" || candidate === "") {
1593
- respondJson(res, 400, { error: "missing secret" });
1594
- return;
1595
- }
1596
- if (!secretMatches(candidate)) {
1597
- recordFailure(ip, Date.now());
1598
- if (json) respondJson(res, 401, { error: "invalid secret" });
1599
- else respondRedirect(res, "/?auth=failed");
1600
- return;
1458
+ }
1459
+ function persist(sessions) {
1460
+ sessions.store?.save([...sessions.entries]);
1461
+ }
1462
+ function tokenKey(sessions, token) {
1463
+ return sessions.secret === void 0 ? sha256(token).toString("hex") : createHmac("sha256", sessions.secret).update(token).digest("hex");
1464
+ }
1465
+ function cookieToken(sessions, req) {
1466
+ const header = req.headers.cookie;
1467
+ if (typeof header !== "string") return;
1468
+ for (const part of header.split(";")) {
1469
+ const eq = part.indexOf("=");
1470
+ if (eq === -1) continue;
1471
+ if (part.slice(0, eq).trim() === sessions.cookieName) return part.slice(eq + 1).trim();
1472
+ }
1473
+ }
1474
+ function cookieAttributes(sessions, req) {
1475
+ const attrs = [
1476
+ "Path=/",
1477
+ "HttpOnly",
1478
+ "SameSite=Lax"
1479
+ ];
1480
+ if (isSecure(req, sessions.trustProxy)) attrs.push("Secure");
1481
+ return attrs;
1482
+ }
1483
+ //#endregion
1484
+ //#region src/auth/throttle.ts
1485
+ const PRUNE_ABOVE = 256;
1486
+ function createLoginThrottle(options) {
1487
+ return {
1488
+ ...options,
1489
+ failures: /* @__PURE__ */ new Map(),
1490
+ global: {
1491
+ count: 0,
1492
+ windowStart: 0
1601
1493
  }
1602
- failures.delete(ip);
1603
- const cookie = setCookieValue(createSession(), req);
1604
- if (json) res.writeHead(204, {
1605
- "set-cookie": cookie,
1606
- "cache-control": "no-store"
1607
- }).end();
1608
- else respondRedirect(res, "/", { "set-cookie": cookie });
1609
1494
  };
1610
- const handleLogout = (req, res) => {
1611
- const json = wantsJson(req);
1612
- if (enabled) {
1613
- if (originVerdict(req) === "foreign") {
1614
- respondJson(res, 403, { error: "origin not allowed" });
1615
- return;
1495
+ }
1496
+ function ipBlockedMs(throttle, ip, now) {
1497
+ return blockedMs(throttle.failures.get(ip), throttle.maxFailuresPerIp, throttle.windowMs, now);
1498
+ }
1499
+ function loginBlockedMs(throttle, ip, now) {
1500
+ return Math.max(ipBlockedMs(throttle, ip, now), blockedMs(throttle.global, throttle.maxFailuresGlobal, throttle.windowMs, now));
1501
+ }
1502
+ function recordFailure(throttle, ip, now, countGlobally = true) {
1503
+ const { failures, windowMs } = throttle;
1504
+ if (failures.size > PRUNE_ABOVE) {
1505
+ for (const [key, entry] of failures) if (now - entry.windowStart >= windowMs) failures.delete(key);
1506
+ }
1507
+ const entry = failures.get(ip);
1508
+ if (entry === void 0 || now - entry.windowStart >= windowMs) failures.set(ip, {
1509
+ count: 1,
1510
+ windowStart: now
1511
+ });
1512
+ else entry.count += 1;
1513
+ if (!countGlobally) return;
1514
+ if (now - throttle.global.windowStart >= windowMs) {
1515
+ throttle.global.count = 1;
1516
+ throttle.global.windowStart = now;
1517
+ } else throttle.global.count += 1;
1518
+ }
1519
+ function forgiveIp(throttle, ip) {
1520
+ throttle.failures.delete(ip);
1521
+ }
1522
+ function blockedMs(entry, max, windowMs, now) {
1523
+ return entry !== void 0 && entry.count >= max && now - entry.windowStart < windowMs ? entry.windowStart + windowMs - now : 0;
1524
+ }
1525
+ //#endregion
1526
+ //#region src/auth/auth.ts
1527
+ const MIN_SECRET_LENGTH = 12;
1528
+ const DEFAULT_COOKIE_NAME = "workerdeck_session";
1529
+ const DEFAULT_TTL_MS = 6048e5;
1530
+ const DEFAULT_THROTTLE_WINDOW_MS = 9e5;
1531
+ const DEFAULT_MAX_FAILURES_PER_IP = 10;
1532
+ const DEFAULT_MAX_FAILURES_GLOBAL = 100;
1533
+ const MAX_LOGIN_BODY_BYTES = 4096;
1534
+ const OPEN_PRINCIPAL = {
1535
+ via: "open",
1536
+ canManageProfiles: true
1537
+ };
1538
+ function createCliAuth(options = {}) {
1539
+ const state = authState(options);
1540
+ const { enabled } = state;
1541
+ return {
1542
+ enabled,
1543
+ authenticate: (req) => authenticateRequest(state, req),
1544
+ handleAuthRequest: (req, res) => {
1545
+ let pathname;
1546
+ try {
1547
+ pathname = new URL(req.url ?? "/", "http://internal").pathname;
1548
+ } catch {
1549
+ return false;
1616
1550
  }
1617
- const token = cookieToken(req);
1618
- if (token !== void 0 && token !== "" && sessions.delete(tokenKey(token))) persist();
1619
- }
1620
- const cookie = clearCookieValue(req);
1621
- if (json) res.writeHead(204, {
1622
- "set-cookie": cookie,
1623
- "cache-control": "no-store"
1624
- }).end();
1625
- else respondRedirect(res, "/", { "set-cookie": cookie });
1626
- };
1627
- const handleAuthRoute = async (pathname, req, res) => {
1628
- if (pathname === "/auth/status") {
1629
- if (req.method !== "GET") respondJson(res, 405, { error: "method not allowed" }, { allow: "GET" });
1630
- else respondJson(res, 200, {
1631
- enabled,
1632
- authenticated: enabled ? hasSession(req) : true
1633
- });
1634
- return;
1635
- }
1636
- if (pathname === "/auth/login") {
1637
- if (req.method !== "POST") respondJson(res, 405, { error: "method not allowed" }, { allow: "POST" });
1638
- else await handleLogin(req, res);
1639
- return;
1640
- }
1641
- if (pathname === "/auth/logout") {
1642
- if (req.method !== "POST") respondJson(res, 405, { error: "method not allowed" }, { allow: "POST" });
1643
- else handleLogout(req, res);
1644
- return;
1645
- }
1646
- respondJson(res, 404, { error: "not found" });
1551
+ if (pathname !== "/auth" && !pathname.startsWith("/auth/")) return false;
1552
+ return handleAuthRoute(state, pathname, req, res).then(() => true);
1553
+ },
1554
+ hasValidSession: (req) => enabled ? hasSession(state.sessions, req) : true,
1555
+ loginPage
1647
1556
  };
1648
- const handleAuthRequest = (req, res) => {
1649
- let pathname;
1650
- try {
1651
- pathname = new URL(req.url ?? "/", "http://internal").pathname;
1652
- } catch {
1653
- return false;
1654
- }
1655
- if (pathname !== "/auth" && !pathname.startsWith("/auth/")) return false;
1656
- return handleAuthRoute(pathname, req, res).then(() => true);
1557
+ }
1558
+ function authState(options) {
1559
+ const { secret } = options;
1560
+ 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`);
1561
+ const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
1562
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
1563
+ if (!(ttlMs > 0)) throw new Error("createCliAuth: ttlMs must be positive");
1564
+ const trustProxy = options.trustProxy === true;
1565
+ const throttle = createLoginThrottle({
1566
+ windowMs: options.throttle?.windowMs ?? DEFAULT_THROTTLE_WINDOW_MS,
1567
+ maxFailuresPerIp: options.throttle?.maxFailuresPerIp ?? DEFAULT_MAX_FAILURES_PER_IP,
1568
+ maxFailuresGlobal: options.throttle?.maxFailuresGlobal ?? DEFAULT_MAX_FAILURES_GLOBAL
1569
+ });
1570
+ const policy = {
1571
+ trustProxy,
1572
+ allowedOrigins: parseAllowedOrigins(options.allowedOrigins ?? [])
1657
1573
  };
1658
- const loginPage = (req) => {
1659
- let reason = null;
1660
- try {
1661
- reason = new URL(req.url ?? "/", "http://internal").searchParams.get("auth");
1662
- } catch {
1663
- reason = null;
1574
+ return {
1575
+ enabled: secret !== void 0,
1576
+ policy,
1577
+ sessions: createCookieSessions({
1578
+ secret,
1579
+ cookieName,
1580
+ ttlMs,
1581
+ trustProxy,
1582
+ store: options.sessions
1583
+ }),
1584
+ throttle,
1585
+ secretMatches: secretMatcher(secret)
1586
+ };
1587
+ }
1588
+ function authenticateRequest(state, req) {
1589
+ if (!state.enabled) return isCrossSiteBrowserRequest(req, state.policy) ? null : OPEN_PRINCIPAL;
1590
+ const provided = headerSecret(req) ?? querySecret(req);
1591
+ if (provided !== void 0) return headerAttempt(state, req, provided) ? {
1592
+ via: "header",
1593
+ canManageProfiles: true
1594
+ } : null;
1595
+ if (!hasSession(state.sessions, req)) return null;
1596
+ return cookieOriginAccepted(req, state.policy) ? {
1597
+ via: "cookie",
1598
+ canManageProfiles: true
1599
+ } : null;
1600
+ }
1601
+ function headerAttempt(state, req, provided) {
1602
+ const ip = clientIp(req, state.policy.trustProxy);
1603
+ const throttled = !isLoopbackAddress(ip);
1604
+ const now = Date.now();
1605
+ if (throttled && ipBlockedMs(state.throttle, ip, now) > 0) return false;
1606
+ if (state.secretMatches(provided)) return true;
1607
+ if (throttled) recordFailure(state.throttle, ip, now, false);
1608
+ return false;
1609
+ }
1610
+ async function handleAuthRoute(state, pathname, req, res) {
1611
+ if (pathname === "/auth/status") {
1612
+ if (req.method !== "GET") respondJson(res, 405, { error: "method not allowed" }, { allow: "GET" });
1613
+ else respondJson(res, 200, {
1614
+ enabled: state.enabled,
1615
+ authenticated: state.enabled ? hasSession(state.sessions, req) : true
1616
+ });
1617
+ return;
1618
+ }
1619
+ if (pathname === "/auth/login") {
1620
+ if (req.method !== "POST") respondJson(res, 405, { error: "method not allowed" }, { allow: "POST" });
1621
+ else await handleLogin(state, req, res);
1622
+ return;
1623
+ }
1624
+ if (pathname === "/auth/logout") {
1625
+ if (req.method !== "POST") respondJson(res, 405, { error: "method not allowed" }, { allow: "POST" });
1626
+ else handleLogout(state, req, res);
1627
+ return;
1628
+ }
1629
+ respondJson(res, 404, { error: "not found" });
1630
+ }
1631
+ async function handleLogin(state, req, res) {
1632
+ const json = wantsJson(req);
1633
+ if (!state.enabled) {
1634
+ respondJson(res, 409, { error: "auth is disabled: no secret is configured" });
1635
+ return;
1636
+ }
1637
+ if (originVerdict(req, state.policy) === "foreign") {
1638
+ respondJson(res, 403, { error: "origin not allowed" });
1639
+ return;
1640
+ }
1641
+ const ip = clientIp(req, state.policy.trustProxy);
1642
+ const blocked = loginBlockedMs(state.throttle, ip, Date.now());
1643
+ if (blocked > 0) {
1644
+ const retryAfter = String(Math.ceil(blocked / 1e3));
1645
+ if (json) respondJson(res, 429, { error: "too many failed attempts" }, { "retry-after": retryAfter });
1646
+ else respondRedirect(res, "/?auth=throttled", { "retry-after": retryAfter });
1647
+ return;
1648
+ }
1649
+ const candidate = await readLoginSecret(req, res);
1650
+ if (candidate === void 0) return;
1651
+ if (!state.secretMatches(candidate)) {
1652
+ recordFailure(state.throttle, ip, Date.now());
1653
+ if (json) respondJson(res, 401, { error: "invalid secret" });
1654
+ else respondRedirect(res, "/?auth=failed");
1655
+ return;
1656
+ }
1657
+ forgiveIp(state.throttle, ip);
1658
+ const cookie = setCookieValue(state.sessions, mintSession(state.sessions), req);
1659
+ if (json) res.writeHead(204, {
1660
+ "set-cookie": cookie,
1661
+ "cache-control": "no-store"
1662
+ }).end();
1663
+ else respondRedirect(res, "/", { "set-cookie": cookie });
1664
+ }
1665
+ async function readLoginSecret(req, res) {
1666
+ const body = await readBody(req, MAX_LOGIN_BODY_BYTES);
1667
+ if (body === null) {
1668
+ respondJson(res, 413, { error: "body too large" });
1669
+ res.once("finish", () => req.destroy());
1670
+ return;
1671
+ }
1672
+ const contentType = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
1673
+ let candidate;
1674
+ if (contentType === "application/x-www-form-urlencoded") candidate = new URLSearchParams(body).get("secret");
1675
+ else if (contentType === "application/json") try {
1676
+ candidate = JSON.parse(body).secret;
1677
+ } catch {
1678
+ respondJson(res, 400, { error: "invalid body" });
1679
+ return;
1680
+ }
1681
+ else {
1682
+ respondJson(res, 415, { error: "expected application/x-www-form-urlencoded or application/json" });
1683
+ return;
1684
+ }
1685
+ if (typeof candidate !== "string" || candidate === "") {
1686
+ respondJson(res, 400, { error: "missing secret" });
1687
+ return;
1688
+ }
1689
+ return candidate;
1690
+ }
1691
+ function handleLogout(state, req, res) {
1692
+ const json = wantsJson(req);
1693
+ if (state.enabled) {
1694
+ if (originVerdict(req, state.policy) === "foreign") {
1695
+ respondJson(res, 403, { error: "origin not allowed" });
1696
+ return;
1664
1697
  }
1665
- return {
1666
- action: "/auth/login",
1667
- field: "secret",
1668
- error: reason === "failed" ? "Invalid access key. Try again." : reason === "throttled" ? "Too many failed attempts. Wait a few minutes, then try again." : void 0
1669
- };
1670
- };
1698
+ revokeSession(state.sessions, req);
1699
+ }
1700
+ const cookie = clearCookieValue(state.sessions, req);
1701
+ if (json) res.writeHead(204, {
1702
+ "set-cookie": cookie,
1703
+ "cache-control": "no-store"
1704
+ }).end();
1705
+ else respondRedirect(res, "/", { "set-cookie": cookie });
1706
+ }
1707
+ function loginPage(req) {
1708
+ let reason = null;
1709
+ try {
1710
+ reason = new URL(req.url ?? "/", "http://internal").searchParams.get("auth");
1711
+ } catch {
1712
+ reason = null;
1713
+ }
1671
1714
  return {
1672
- enabled,
1673
- authenticate,
1674
- handleAuthRequest,
1675
- hasValidSession: (req) => enabled ? hasSession(req) : true,
1676
- loginPage
1715
+ action: "/auth/login",
1716
+ field: "secret",
1717
+ error: reason === "failed" ? "Invalid access key. Try again." : reason === "throttled" ? "Too many failed attempts. Wait a few minutes, then try again." : void 0
1677
1718
  };
1678
1719
  }
1720
+ function respondRedirect(res, location, headers) {
1721
+ res.writeHead(303, {
1722
+ location,
1723
+ "cache-control": "no-store",
1724
+ ...headers
1725
+ }).end();
1726
+ }
1727
+ function wantsJson(req) {
1728
+ return (req.headers.accept ?? "").includes("application/json");
1729
+ }
1679
1730
  //#endregion
1680
1731
  //#region src/config.ts
1681
1732
  const CONFIG_BASENAMES = [
@@ -1684,6 +1735,41 @@ const CONFIG_BASENAMES = [
1684
1735
  "workerdeck.config.cjs"
1685
1736
  ];
1686
1737
  var ConfigError = class extends Error {};
1738
+ const SWITCHES = /* @__PURE__ */ new Map([
1739
+ ["-h", (f) => f.help = true],
1740
+ ["--help", (f) => f.help = true],
1741
+ ["-v", (f) => f.version = true],
1742
+ ["--version", (f) => f.version = true],
1743
+ ["--no-profile-store", (f) => f.profileStore = false],
1744
+ ["--fs-write", (f) => f.fsWrite = true],
1745
+ ["--shell", (f) => f.shell = true],
1746
+ ["--trust-proxy", (f) => f.trustProxy = true],
1747
+ ["--no-parking-store", (f) => f.parking = false],
1748
+ ["--insecure", (f) => f.insecure = true],
1749
+ ["--no-web", (f) => f.web = false],
1750
+ ["--no-keep-awake", (f) => f.keepAwake = false],
1751
+ ["--open", (f) => f.open = true],
1752
+ ["--hot-reload", (f) => f.hotReload = true]
1753
+ ]);
1754
+ const VALUED = /* @__PURE__ */ new Map([
1755
+ ["-c", (f, v) => f.config = v],
1756
+ ["--config", (f, v) => f.config = v],
1757
+ ["-p", (f, v, name) => f.port = parsePort(v, name)],
1758
+ ["--port", (f, v, name) => f.port = parsePort(v, name)],
1759
+ ["--host", (f, v) => f.host = v],
1760
+ ["--auth-key", (f, v) => f.authKey = v],
1761
+ ["--profile", (f, v) => f.profiles.push(parseProfile(v))],
1762
+ ["--profile-root", (f, v) => f.profileRoots.push(resolve(v))],
1763
+ ["--cwd-root", (f, v) => f.cwdRoots.push(resolve(v))],
1764
+ ["--fs-root", (f, v) => f.fsRoots.push(resolve(v))],
1765
+ ["--shell-agent-write", (f, v, name) => f.shellAgentWrite = parseShellAgentWrite(v, name)],
1766
+ ["--allowed-origin", (f, v) => f.allowedOrigins.push(v)],
1767
+ ["--allowed-host", (f, v) => f.allowedHosts.push(v)],
1768
+ ["--insecure-host", (f, v) => f.insecureHosts.push(v)],
1769
+ ["--approval-timeout", (f, v, name) => f.approvalTimeoutMs = parseDuration(v, name)],
1770
+ ["--state-dir", (f, v) => f.stateDir = resolve(v)],
1771
+ ["--cors-origin", (f, v) => f.corsOrigins.push(v)]
1772
+ ]);
1687
1773
  function parseDuration(raw, source) {
1688
1774
  if (raw === "none" || raw === "never") return null;
1689
1775
  const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw);
@@ -1717,129 +1803,33 @@ function parseArgs(argv) {
1717
1803
  insecureHosts: [],
1718
1804
  corsOrigins: []
1719
1805
  };
1720
- const next = (i, name) => {
1721
- const value = argv[i + 1];
1722
- if (value === void 0 || value.startsWith("-")) throw new ConfigError(`${name} requires a value`);
1723
- return value;
1724
- };
1725
1806
  for (let i = 0; i < argv.length; i++) {
1726
1807
  const arg = argv[i];
1727
- switch (arg) {
1728
- case "-h":
1729
- case "--help":
1730
- flags.help = true;
1731
- break;
1732
- case "-v":
1733
- case "--version":
1734
- flags.version = true;
1735
- break;
1736
- case "-c":
1737
- case "--config":
1738
- flags.config = next(i, arg);
1739
- i++;
1740
- break;
1741
- case "-p":
1742
- case "--port":
1743
- flags.port = parsePort(next(i, arg), arg);
1744
- i++;
1745
- break;
1746
- case "--host":
1747
- flags.host = next(i, arg);
1748
- i++;
1749
- break;
1750
- case "--auth-key":
1751
- flags.authKey = next(i, arg);
1752
- i++;
1753
- break;
1754
- case "--profile": {
1755
- const raw = next(i, arg);
1756
- i++;
1757
- const eq = raw.indexOf("=");
1758
- if (eq <= 0) throw new ConfigError(`--profile expects name=dir, got: ${raw}`);
1759
- const name = raw.slice(0, eq);
1760
- const dir = raw.slice(eq + 1);
1761
- if (!dir) throw new ConfigError(`--profile ${name}= is missing a directory`);
1762
- flags.profiles.push({
1763
- name,
1764
- configDir: resolve(dir)
1765
- });
1766
- break;
1767
- }
1768
- case "--profile-root":
1769
- flags.profileRoots.push(resolve(next(i, arg)));
1770
- i++;
1771
- break;
1772
- case "--no-profile-store":
1773
- flags.profileStore = false;
1774
- break;
1775
- case "--cwd-root":
1776
- flags.cwdRoots.push(resolve(next(i, arg)));
1777
- i++;
1778
- break;
1779
- case "--fs-root":
1780
- flags.fsRoots.push(resolve(next(i, arg)));
1781
- i++;
1782
- break;
1783
- case "--fs-write":
1784
- flags.fsWrite = true;
1785
- break;
1786
- case "--shell":
1787
- flags.shell = true;
1788
- break;
1789
- case "--shell-agent-write":
1790
- flags.shellAgentWrite = parseShellAgentWrite(next(i, arg), arg);
1791
- i++;
1792
- break;
1793
- case "--allowed-origin":
1794
- flags.allowedOrigins.push(next(i, arg));
1795
- i++;
1796
- break;
1797
- case "--allowed-host":
1798
- flags.allowedHosts.push(next(i, arg));
1799
- i++;
1800
- break;
1801
- case "--insecure-host":
1802
- flags.insecureHosts.push(next(i, arg));
1803
- i++;
1804
- break;
1805
- case "--trust-proxy":
1806
- flags.trustProxy = true;
1807
- break;
1808
- case "--approval-timeout":
1809
- flags.approvalTimeoutMs = parseDuration(next(i, arg), arg);
1810
- i++;
1811
- break;
1812
- case "--state-dir":
1813
- flags.stateDir = resolve(next(i, arg));
1814
- i++;
1815
- break;
1816
- case "--no-parking-store":
1817
- flags.parking = false;
1818
- break;
1819
- case "--insecure":
1820
- flags.insecure = true;
1821
- break;
1822
- case "--no-web":
1823
- flags.web = false;
1824
- break;
1825
- case "--no-keep-awake":
1826
- flags.keepAwake = false;
1827
- break;
1828
- case "--cors-origin":
1829
- flags.corsOrigins.push(next(i, arg));
1830
- i++;
1831
- break;
1832
- case "--open":
1833
- flags.open = true;
1834
- break;
1835
- case "--hot-reload":
1836
- flags.hotReload = true;
1837
- break;
1838
- default: throw new ConfigError(`unknown option: ${arg}`);
1808
+ const toggle = SWITCHES.get(arg);
1809
+ if (toggle) {
1810
+ toggle(flags);
1811
+ continue;
1839
1812
  }
1813
+ const take = VALUED.get(arg);
1814
+ if (!take) throw new ConfigError(`unknown option: ${arg}`);
1815
+ const value = argv[i + 1];
1816
+ if (value === void 0 || value.startsWith("-")) throw new ConfigError(`${arg} requires a value`);
1817
+ take(flags, value, arg);
1818
+ i++;
1840
1819
  }
1841
1820
  return flags;
1842
1821
  }
1822
+ function parseProfile(raw) {
1823
+ const eq = raw.indexOf("=");
1824
+ if (eq <= 0) throw new ConfigError(`--profile expects name=dir, got: ${raw}`);
1825
+ const name = raw.slice(0, eq);
1826
+ const dir = raw.slice(eq + 1);
1827
+ if (!dir) throw new ConfigError(`--profile ${name}= is missing a directory`);
1828
+ return {
1829
+ name,
1830
+ configDir: resolve(dir)
1831
+ };
1832
+ }
1843
1833
  async function loadConfigFile(explicit, cwd = process.cwd()) {
1844
1834
  let path = null;
1845
1835
  if (explicit) {
@@ -1911,10 +1901,9 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
1911
1901
  const bindHost = host.trim().replace(/^\[|\]$/g, "").toLowerCase();
1912
1902
  const generateAuthKey = !authKey && !hostAuthenticates && !loaded.options.allowUnauthenticated && !isLoopback(host) && !flags.insecure && !insecureHosts.has(bindHost);
1913
1903
  const stateDir = flags.parking === false || loaded.options.stateDir === null ? null : flags.stateDir ?? env.WORKERDECK_STATE_DIR ?? loaded.options.stateDir ?? defaultStateDir(loaded.path);
1914
- const envCwdRoots = env.WORKERDECK_CWD_ROOTS?.split(":").filter(Boolean).map((p) => resolve(cwd, p));
1915
- const cwdRoots = flags.cwdRoots.length ? flags.cwdRoots : envCwdRoots;
1916
- const envFsRoots = env.WORKERDECK_FS_ROOTS?.split(":").filter(Boolean).map((p) => resolve(cwd, p));
1917
- const fsRoots = flags.fsRoots.length ? flags.fsRoots : envFsRoots;
1904
+ const envRoots = (name) => env[name]?.split(":").filter(Boolean).map((p) => resolve(cwd, p));
1905
+ const cwdRoots = flags.cwdRoots.length ? flags.cwdRoots : envRoots("WORKERDECK_CWD_ROOTS");
1906
+ const fsRoots = flags.fsRoots.length ? flags.fsRoots : envRoots("WORKERDECK_FS_ROOTS");
1918
1907
  const auth = {
1919
1908
  ...loaded.options.auth,
1920
1909
  secret: authKey,
@@ -2192,15 +2181,36 @@ function resolveWithinRoot(root, pathname) {
2192
2181
  if (candidate !== base && !candidate.startsWith(base + sep)) return null;
2193
2182
  return candidate;
2194
2183
  }
2184
+ function dashboardCsp(html) {
2185
+ return [
2186
+ "default-src 'self'",
2187
+ ["script-src 'self' 'wasm-unsafe-eval'", ...[...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi)].map((match) => `'sha256-${createHash("sha256").update(match[1], "utf8").digest("base64")}'`)].join(" "),
2188
+ "style-src 'self' 'unsafe-inline'",
2189
+ "img-src 'self' data: blob: http://127.0.0.1:* http://localhost:*",
2190
+ "font-src 'self' data:",
2191
+ "connect-src 'self' http: https: ws: wss:",
2192
+ "worker-src 'self' blob:",
2193
+ "object-src 'none'",
2194
+ "base-uri 'self'",
2195
+ "form-action 'self'",
2196
+ "frame-ancestors 'none'"
2197
+ ].join("; ");
2198
+ }
2199
+ function htmlHeaders(html) {
2200
+ return {
2201
+ "content-security-policy": dashboardCsp(html),
2202
+ "x-frame-options": "DENY",
2203
+ "x-content-type-options": "nosniff",
2204
+ "referrer-policy": "same-origin"
2205
+ };
2206
+ }
2195
2207
  function sendHtml(req, res, status, html, cache) {
2196
2208
  const body = Buffer.from(html, "utf8");
2197
2209
  res.writeHead(status, {
2198
2210
  "content-type": "text/html; charset=utf-8",
2199
2211
  "content-length": body.byteLength,
2200
2212
  "cache-control": cache,
2201
- "x-frame-options": "DENY",
2202
- "x-content-type-options": "nosniff",
2203
- "referrer-policy": "same-origin"
2213
+ ...htmlHeaders(html)
2204
2214
  });
2205
2215
  res.end(req.method === "HEAD" ? void 0 : body);
2206
2216
  }
@@ -2214,8 +2224,15 @@ async function serveFile(req, res, filePath, options = {}) {
2214
2224
  } catch {
2215
2225
  return "not-found";
2216
2226
  }
2227
+ const contentType = contentTypeFor(filePath);
2228
+ if (contentType.startsWith("text/html")) {
2229
+ const html = await readFile(filePath, "utf8").catch(() => void 0);
2230
+ if (html === void 0) return "not-found";
2231
+ sendHtml(req, res, 200, html, options.immutable ? "public, max-age=31536000, immutable" : "no-cache, must-revalidate");
2232
+ return "served";
2233
+ }
2217
2234
  res.writeHead(200, {
2218
- "content-type": contentTypeFor(filePath),
2235
+ "content-type": contentType,
2219
2236
  "content-length": size,
2220
2237
  "cache-control": options.immutable ? "public, max-age=31536000, immutable" : "no-cache, must-revalidate",
2221
2238
  "x-content-type-options": "nosniff"
@@ -2252,6 +2269,12 @@ function createHostGuard(allowedHosts) {
2252
2269
  return isLoopbackHostname(hostname) || allowedHosts.has(hostname);
2253
2270
  };
2254
2271
  }
2272
+ function isOperatorPrincipal(principal, policyDeclared) {
2273
+ if (principal === null || principal === void 0 || principal === false) return false;
2274
+ const { operator, scope } = typeof principal === "object" ? principal : {};
2275
+ if (typeof operator === "boolean") return operator;
2276
+ return !(typeof scope === "object" && scope !== null && Object.keys(scope).length > 0) && !policyDeclared;
2277
+ }
2255
2278
  function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
2256
2279
  return async (req, res) => {
2257
2280
  if (!hostAllowed(req)) {
@@ -2318,27 +2341,20 @@ async function startInstance(config, options = {}) {
2318
2341
  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");
2319
2342
  if (config.corsOrigins.length > 0 && !config.hostAuthenticates && !auth.enabled) throw new Error("refusing to serve: corsOrigins is set but this instance has no auth - a cross-origin page could drive it with no credential. Set --auth-key.");
2320
2343
  const hostAllowed = createHostGuard(config.allowedHosts);
2321
- const authenticateHost = (req) => hostAllowed(req) ? auth.authenticate(req) : null;
2344
+ const authenticateRequest = config.hostAuthenticates ? config.options.authenticate : auth.authenticate;
2345
+ const policyDeclared = config.options.authorizeSession !== void 0;
2346
+ const authenticatePush = async (req) => {
2347
+ if (!hostAllowed(req)) return null;
2348
+ const principal = await authenticateRequest(req);
2349
+ return isOperatorPrincipal(principal, policyDeclared) ? principal : null;
2350
+ };
2322
2351
  const apns = config.apns === void 0 ? void 0 : await createApnsForwarder({
2323
2352
  config: config.apns,
2324
2353
  stateDir: config.stateDir,
2325
- authenticate: authenticateHost
2326
- });
2327
- const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest ?? createApnsRoute(null, authenticateHost));
2328
- const parking = { ...config.options.parking };
2329
- if (config.stateDir && !parking.store) parking.store = createFileSessionStore({
2330
- dir: join(config.stateDir, "parked"),
2331
- onError: (error, context) => {
2332
- process.stderr.write(`[workerdeck] parked-session store ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}\n`);
2333
- }
2334
- });
2335
- const profileStore = config.profileStore && config.stateDir ? createFileProfileStore(join(config.stateDir, "profiles.json")) : void 0;
2336
- const shell = { ...config.options.shell };
2337
- if (config.stateDir && shell.artifactDir === void 0) shell.artifactDir = join(config.stateDir, "shells");
2338
- const spend = { ...config.options.spend };
2339
- if (config.stateDir && !spend.store) spend.store = createFileSpendStore(join(config.stateDir, "spend.json"), (error) => {
2340
- process.stderr.write(`[workerdeck] spend ledger write failed: ${error instanceof Error ? error.message : String(error)}\n`);
2354
+ authenticate: authenticatePush
2341
2355
  });
2356
+ const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest ?? createApnsRoute(null, authenticatePush));
2357
+ const { parking, profileStore, shell, spend } = stateDirDefaults(config);
2342
2358
  const server = createWorkerServer({
2343
2359
  ...config.options,
2344
2360
  ...profileStore ? { profileStore } : {},
@@ -2355,7 +2371,7 @@ async function startInstance(config, options = {}) {
2355
2371
  },
2356
2372
  fallback,
2357
2373
  ...config.corsOrigins.length ? { cors: { origins: config.corsOrigins } } : {},
2358
- authenticate: config.hostAuthenticates ? (req) => hostAllowed(req) ? config.options.authenticate(req) : null : (req) => hostAllowed(req) ? auth.authenticate(req) : null
2374
+ authenticate: (req) => hostAllowed(req) ? authenticateRequest(req) : null
2359
2375
  });
2360
2376
  const wake = config.keepAwake ? createWakeLock({ onUnavailable: (reason) => {
2361
2377
  if (!options.quiet) process.stderr.write(`[workerdeck] not holding the machine awake: ${reason}\n`);
@@ -2391,30 +2407,18 @@ async function startInstance(config, options = {}) {
2391
2407
  resolveClosed = r;
2392
2408
  });
2393
2409
  if (!options.quiet) {
2394
- const line = (text) => void process.stdout.write(`${text}\n`);
2395
- line("");
2396
- line(` workerdeck ${url}`);
2397
- if (config.hostAuthenticates) line(" auth: the config file supplies its own `authenticate`");
2398
- else if (generated?.source === "created") {
2399
- line(` auth: generated key ${generated.key}`);
2400
- line(` stored in ${generated.path} - later starts reuse it without printing it`);
2401
- } else if (generated?.source === "ephemeral") {
2402
- line(` auth: generated key ${generated.key}`);
2403
- line(" ephemeral - no state dir to keep it, so the next start mints a new one");
2404
- } else if (generated?.source === "stored") line(` auth: shared key from ${generated.path}`);
2405
- else if (auth.enabled) line(" auth: shared key - browsers sign in, services send a header");
2406
- else line(" NO AUTH - anyone who can reach this port gets a session");
2407
- if (!config.web) line(" dashboard: off - bare gateway, /v1 and /auth only");
2408
- if (!config.keepAwake) line(" keep-awake: off - this machine may sleep mid-turn");
2409
- if (config.corsOrigins.length) line(` cors: ${config.corsOrigins.join(", ")} may call /v1 (still key-gated)`);
2410
- line(config.stateDir ? ` parked sessions persist in ${join(config.stateDir, "parked")}` : " parked sessions are in memory only - a restart drops them");
2411
- if (apns) {
2412
- const count = apns.deviceCount();
2413
- const cards = apns.activity.count();
2414
- line(` push: APNs forwarder on ${config.apns?.topic} - ${count === 0 ? "no devices registered yet" : `${count} device(s)`}${cards === 0 ? "" : `, ${cards} live card(s)`}`);
2415
- }
2416
- if (config.configPath) line(` config ${config.configPath}`);
2417
- line("");
2410
+ const push = apns && {
2411
+ topic: config.apns?.topic,
2412
+ devices: apns.deviceCount(),
2413
+ cards: apns.activity.count()
2414
+ };
2415
+ process.stdout.write(bannerLines({
2416
+ url,
2417
+ config,
2418
+ generated,
2419
+ authEnabled: auth.enabled,
2420
+ push
2421
+ }).join("\n") + "\n");
2418
2422
  }
2419
2423
  return {
2420
2424
  server,
@@ -2435,7 +2439,52 @@ async function startInstance(config, options = {}) {
2435
2439
  }
2436
2440
  };
2437
2441
  }
2442
+ function stateDirDefaults(config) {
2443
+ const { stateDir } = config;
2444
+ const parking = { ...config.options.parking };
2445
+ const shell = { ...config.options.shell };
2446
+ const spend = { ...config.options.spend };
2447
+ if (!stateDir) return {
2448
+ parking,
2449
+ shell,
2450
+ spend
2451
+ };
2452
+ parking.store ??= createFileSessionStore({
2453
+ dir: join(stateDir, "parked"),
2454
+ onError: (error, context) => {
2455
+ process.stderr.write(`[workerdeck] parked-session store ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}\n`);
2456
+ }
2457
+ });
2458
+ if (shell.artifactDir === void 0) shell.artifactDir = join(stateDir, "shells");
2459
+ spend.store ??= createFileSpendStore(join(stateDir, "spend.json"), (error) => {
2460
+ process.stderr.write(`[workerdeck] spend ledger write failed: ${error instanceof Error ? error.message : String(error)}\n`);
2461
+ });
2462
+ const profileStore = config.profileStore ? createFileProfileStore(join(stateDir, "profiles.json")) : void 0;
2463
+ return {
2464
+ parking,
2465
+ shell,
2466
+ spend,
2467
+ ...profileStore ? { profileStore } : {}
2468
+ };
2469
+ }
2470
+ function bannerLines({ url, config, generated, authEnabled, push }) {
2471
+ const lines = ["", ` workerdeck ${url}`];
2472
+ if (config.hostAuthenticates) lines.push(" auth: the config file supplies its own `authenticate`");
2473
+ else if (generated?.source === "created") lines.push(` auth: generated key ${generated.key}`, ` stored in ${generated.path} - later starts reuse it without printing it`);
2474
+ else if (generated?.source === "ephemeral") lines.push(` auth: generated key ${generated.key}`, " ephemeral - no state dir to keep it, so the next start mints a new one");
2475
+ else if (generated?.source === "stored") lines.push(` auth: shared key from ${generated.path}`);
2476
+ else if (authEnabled) lines.push(" auth: shared key - browsers sign in, services send a header");
2477
+ else lines.push(" NO AUTH - anyone who can reach this port gets a session");
2478
+ if (!config.web) lines.push(" dashboard: off - bare gateway, /v1 and /auth only");
2479
+ if (!config.keepAwake) lines.push(" keep-awake: off - this machine may sleep mid-turn");
2480
+ if (config.corsOrigins.length) lines.push(` cors: ${config.corsOrigins.join(", ")} may call /v1 (still key-gated)`);
2481
+ lines.push(config.stateDir ? ` parked sessions persist in ${join(config.stateDir, "parked")}` : " parked sessions are in memory only - a restart drops them");
2482
+ if (push) lines.push(` push: APNs forwarder on ${push.topic} - ${push.devices === 0 ? "no devices registered yet" : `${push.devices} device(s)`}${push.cards === 0 ? "" : `, ${push.cards} live card(s)`}`);
2483
+ if (config.configPath) lines.push(` config ${config.configPath}`);
2484
+ lines.push("");
2485
+ return lines;
2486
+ }
2438
2487
  //#endregion
2439
2488
  export { createActivityRegistry as C, createDeviceRoute as S, createApnsForwarder as _, ConfigError as a, createApnsRoute 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, createApnsClient as v, createActivityRoute as w, createDeviceRegistry as x, loadApnsKey as y };
2440
2489
 
2441
- //# sourceMappingURL=instance-CTA80T2q.mjs.map
2490
+ //# sourceMappingURL=instance-BmQhZ3Px.mjs.map