workerdeck 2.15.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/cli.mjs +19 -6
- package/build/cli.mjs.map +1 -1
- package/build/{hot-reload-CP_sewvR.mjs → hot-reload-Duq3g2Lm.mjs} +3 -3
- package/build/hot-reload-Duq3g2Lm.mjs.map +1 -0
- package/build/index.d.mts +39 -35
- package/build/index.mjs +1 -1
- package/build/{instance-Ca_DJTO5.mjs → instance-BmQhZ3Px.mjs} +687 -629
- package/build/instance-BmQhZ3Px.mjs.map +1 -0
- package/build/{reload-command-Bou3X_hM.mjs → reload-command-CSaFgxYs.mjs} +2 -2
- package/build/{reload-command-Bou3X_hM.mjs.map → reload-command-CSaFgxYs.mjs.map} +1 -1
- package/package.json +4 -4
- package/build/hot-reload-CP_sewvR.mjs.map +0 -1
- package/build/instance-Ca_DJTO5.mjs.map +0 -1
|
@@ -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/
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
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
|
|
143
|
+
const file = jsonRecordFile(options, FILENAME$1, "activities");
|
|
68
144
|
const records = /* @__PURE__ */ new Map();
|
|
69
|
-
if (
|
|
70
|
-
|
|
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
|
|
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
|
|
199
|
+
return;
|
|
175
200
|
}
|
|
176
201
|
if (req.method === "DELETE") {
|
|
177
|
-
await
|
|
202
|
+
await activities.removeByUpdateToken(token);
|
|
178
203
|
res.writeHead(204).end();
|
|
179
|
-
return
|
|
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
|
|
209
|
+
return;
|
|
185
210
|
}
|
|
186
|
-
if (!isEnvironment
|
|
211
|
+
if (!isEnvironment(body.environment)) {
|
|
187
212
|
respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
|
|
188
|
-
return
|
|
213
|
+
return;
|
|
189
214
|
}
|
|
190
|
-
const deviceToken = typeof body.deviceToken === "string" && TOKEN_PATTERN
|
|
191
|
-
if (await
|
|
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
|
|
222
|
+
return;
|
|
198
223
|
}
|
|
199
224
|
respondJson(res, 200, {
|
|
200
225
|
attached: true,
|
|
201
226
|
sessionId
|
|
202
227
|
});
|
|
203
|
-
|
|
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
|
|
258
|
+
const file = jsonRecordFile(options, FILENAME, "devices");
|
|
237
259
|
const devices = /* @__PURE__ */ new Map();
|
|
238
|
-
if (
|
|
239
|
-
|
|
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
|
|
290
|
+
return;
|
|
320
291
|
}
|
|
321
292
|
if (req.method === "DELETE") {
|
|
322
|
-
await
|
|
293
|
+
await devices.remove(token);
|
|
323
294
|
res.writeHead(204).end();
|
|
324
|
-
return
|
|
295
|
+
return;
|
|
325
296
|
}
|
|
326
297
|
if (!isEnvironment(body.environment)) {
|
|
327
298
|
respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
|
|
328
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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/
|
|
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
|
|
1372
|
-
return
|
|
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
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
if (
|
|
1398
|
-
|
|
1399
|
-
|
|
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
|
-
|
|
1402
|
-
const
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
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
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
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
|
-
|
|
1543
|
-
|
|
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
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
const
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
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
|
-
|
|
1618
|
-
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
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
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
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
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
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);
|
|
@@ -1697,6 +1783,10 @@ function parseDuration(raw, source) {
|
|
|
1697
1783
|
const ms = Number(match[1]) * scale;
|
|
1698
1784
|
return ms > 0 ? ms : null;
|
|
1699
1785
|
}
|
|
1786
|
+
function parseShellAgentWrite(value, name) {
|
|
1787
|
+
if (value === "read-only" || value === "gated" || value === "allow") return value;
|
|
1788
|
+
throw new ConfigError(`${name} must be read-only, gated or allow (got '${value}')`);
|
|
1789
|
+
}
|
|
1700
1790
|
function parsePort(raw, source) {
|
|
1701
1791
|
const port = Number(raw);
|
|
1702
1792
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new ConfigError(`${source}: not a valid port: ${raw}`);
|
|
@@ -1713,125 +1803,33 @@ function parseArgs(argv) {
|
|
|
1713
1803
|
insecureHosts: [],
|
|
1714
1804
|
corsOrigins: []
|
|
1715
1805
|
};
|
|
1716
|
-
const next = (i, name) => {
|
|
1717
|
-
const value = argv[i + 1];
|
|
1718
|
-
if (value === void 0 || value.startsWith("-")) throw new ConfigError(`${name} requires a value`);
|
|
1719
|
-
return value;
|
|
1720
|
-
};
|
|
1721
1806
|
for (let i = 0; i < argv.length; i++) {
|
|
1722
1807
|
const arg = argv[i];
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
break;
|
|
1728
|
-
case "-v":
|
|
1729
|
-
case "--version":
|
|
1730
|
-
flags.version = true;
|
|
1731
|
-
break;
|
|
1732
|
-
case "-c":
|
|
1733
|
-
case "--config":
|
|
1734
|
-
flags.config = next(i, arg);
|
|
1735
|
-
i++;
|
|
1736
|
-
break;
|
|
1737
|
-
case "-p":
|
|
1738
|
-
case "--port":
|
|
1739
|
-
flags.port = parsePort(next(i, arg), arg);
|
|
1740
|
-
i++;
|
|
1741
|
-
break;
|
|
1742
|
-
case "--host":
|
|
1743
|
-
flags.host = next(i, arg);
|
|
1744
|
-
i++;
|
|
1745
|
-
break;
|
|
1746
|
-
case "--auth-key":
|
|
1747
|
-
flags.authKey = next(i, arg);
|
|
1748
|
-
i++;
|
|
1749
|
-
break;
|
|
1750
|
-
case "--profile": {
|
|
1751
|
-
const raw = next(i, arg);
|
|
1752
|
-
i++;
|
|
1753
|
-
const eq = raw.indexOf("=");
|
|
1754
|
-
if (eq <= 0) throw new ConfigError(`--profile expects name=dir, got: ${raw}`);
|
|
1755
|
-
const name = raw.slice(0, eq);
|
|
1756
|
-
const dir = raw.slice(eq + 1);
|
|
1757
|
-
if (!dir) throw new ConfigError(`--profile ${name}= is missing a directory`);
|
|
1758
|
-
flags.profiles.push({
|
|
1759
|
-
name,
|
|
1760
|
-
configDir: resolve(dir)
|
|
1761
|
-
});
|
|
1762
|
-
break;
|
|
1763
|
-
}
|
|
1764
|
-
case "--profile-root":
|
|
1765
|
-
flags.profileRoots.push(resolve(next(i, arg)));
|
|
1766
|
-
i++;
|
|
1767
|
-
break;
|
|
1768
|
-
case "--no-profile-store":
|
|
1769
|
-
flags.profileStore = false;
|
|
1770
|
-
break;
|
|
1771
|
-
case "--cwd-root":
|
|
1772
|
-
flags.cwdRoots.push(resolve(next(i, arg)));
|
|
1773
|
-
i++;
|
|
1774
|
-
break;
|
|
1775
|
-
case "--fs-root":
|
|
1776
|
-
flags.fsRoots.push(resolve(next(i, arg)));
|
|
1777
|
-
i++;
|
|
1778
|
-
break;
|
|
1779
|
-
case "--fs-write":
|
|
1780
|
-
flags.fsWrite = true;
|
|
1781
|
-
break;
|
|
1782
|
-
case "--shell":
|
|
1783
|
-
flags.shell = true;
|
|
1784
|
-
break;
|
|
1785
|
-
case "--allowed-origin":
|
|
1786
|
-
flags.allowedOrigins.push(next(i, arg));
|
|
1787
|
-
i++;
|
|
1788
|
-
break;
|
|
1789
|
-
case "--allowed-host":
|
|
1790
|
-
flags.allowedHosts.push(next(i, arg));
|
|
1791
|
-
i++;
|
|
1792
|
-
break;
|
|
1793
|
-
case "--insecure-host":
|
|
1794
|
-
flags.insecureHosts.push(next(i, arg));
|
|
1795
|
-
i++;
|
|
1796
|
-
break;
|
|
1797
|
-
case "--trust-proxy":
|
|
1798
|
-
flags.trustProxy = true;
|
|
1799
|
-
break;
|
|
1800
|
-
case "--approval-timeout":
|
|
1801
|
-
flags.approvalTimeoutMs = parseDuration(next(i, arg), arg);
|
|
1802
|
-
i++;
|
|
1803
|
-
break;
|
|
1804
|
-
case "--state-dir":
|
|
1805
|
-
flags.stateDir = resolve(next(i, arg));
|
|
1806
|
-
i++;
|
|
1807
|
-
break;
|
|
1808
|
-
case "--no-parking-store":
|
|
1809
|
-
flags.parking = false;
|
|
1810
|
-
break;
|
|
1811
|
-
case "--insecure":
|
|
1812
|
-
flags.insecure = true;
|
|
1813
|
-
break;
|
|
1814
|
-
case "--no-web":
|
|
1815
|
-
flags.web = false;
|
|
1816
|
-
break;
|
|
1817
|
-
case "--no-keep-awake":
|
|
1818
|
-
flags.keepAwake = false;
|
|
1819
|
-
break;
|
|
1820
|
-
case "--cors-origin":
|
|
1821
|
-
flags.corsOrigins.push(next(i, arg));
|
|
1822
|
-
i++;
|
|
1823
|
-
break;
|
|
1824
|
-
case "--open":
|
|
1825
|
-
flags.open = true;
|
|
1826
|
-
break;
|
|
1827
|
-
case "--hot-reload":
|
|
1828
|
-
flags.hotReload = true;
|
|
1829
|
-
break;
|
|
1830
|
-
default: throw new ConfigError(`unknown option: ${arg}`);
|
|
1808
|
+
const toggle = SWITCHES.get(arg);
|
|
1809
|
+
if (toggle) {
|
|
1810
|
+
toggle(flags);
|
|
1811
|
+
continue;
|
|
1831
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++;
|
|
1832
1819
|
}
|
|
1833
1820
|
return flags;
|
|
1834
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
|
+
}
|
|
1835
1833
|
async function loadConfigFile(explicit, cwd = process.cwd()) {
|
|
1836
1834
|
let path = null;
|
|
1837
1835
|
if (explicit) {
|
|
@@ -1903,10 +1901,9 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
|
|
|
1903
1901
|
const bindHost = host.trim().replace(/^\[|\]$/g, "").toLowerCase();
|
|
1904
1902
|
const generateAuthKey = !authKey && !hostAuthenticates && !loaded.options.allowUnauthenticated && !isLoopback(host) && !flags.insecure && !insecureHosts.has(bindHost);
|
|
1905
1903
|
const stateDir = flags.parking === false || loaded.options.stateDir === null ? null : flags.stateDir ?? env.WORKERDECK_STATE_DIR ?? loaded.options.stateDir ?? defaultStateDir(loaded.path);
|
|
1906
|
-
const
|
|
1907
|
-
const cwdRoots = flags.cwdRoots.length ? flags.cwdRoots :
|
|
1908
|
-
const
|
|
1909
|
-
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");
|
|
1910
1907
|
const auth = {
|
|
1911
1908
|
...loaded.options.auth,
|
|
1912
1909
|
secret: authKey,
|
|
@@ -1940,9 +1937,10 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
|
|
|
1940
1937
|
...fsRoots?.length ? { roots: fsRoots } : {},
|
|
1941
1938
|
...flags.fsWrite ? { write: true } : {}
|
|
1942
1939
|
};
|
|
1943
|
-
if (flags.shell) options.shell = {
|
|
1940
|
+
if (flags.shell || flags.shellAgentWrite !== void 0) options.shell = {
|
|
1944
1941
|
...loaded.options.shell,
|
|
1945
|
-
enabled: true
|
|
1942
|
+
...flags.shell ? { enabled: true } : {},
|
|
1943
|
+
...flags.shellAgentWrite !== void 0 ? { agentWrite: flags.shellAgentWrite } : {}
|
|
1946
1944
|
};
|
|
1947
1945
|
if (flags.profiles.length) options.profiles = flags.profiles;
|
|
1948
1946
|
if (flags.approvalTimeoutMs !== void 0) options.approvalTimeoutMs = flags.approvalTimeoutMs;
|
|
@@ -2183,15 +2181,36 @@ function resolveWithinRoot(root, pathname) {
|
|
|
2183
2181
|
if (candidate !== base && !candidate.startsWith(base + sep)) return null;
|
|
2184
2182
|
return candidate;
|
|
2185
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
|
+
}
|
|
2186
2207
|
function sendHtml(req, res, status, html, cache) {
|
|
2187
2208
|
const body = Buffer.from(html, "utf8");
|
|
2188
2209
|
res.writeHead(status, {
|
|
2189
2210
|
"content-type": "text/html; charset=utf-8",
|
|
2190
2211
|
"content-length": body.byteLength,
|
|
2191
2212
|
"cache-control": cache,
|
|
2192
|
-
|
|
2193
|
-
"x-content-type-options": "nosniff",
|
|
2194
|
-
"referrer-policy": "same-origin"
|
|
2213
|
+
...htmlHeaders(html)
|
|
2195
2214
|
});
|
|
2196
2215
|
res.end(req.method === "HEAD" ? void 0 : body);
|
|
2197
2216
|
}
|
|
@@ -2205,8 +2224,15 @@ async function serveFile(req, res, filePath, options = {}) {
|
|
|
2205
2224
|
} catch {
|
|
2206
2225
|
return "not-found";
|
|
2207
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
|
+
}
|
|
2208
2234
|
res.writeHead(200, {
|
|
2209
|
-
"content-type":
|
|
2235
|
+
"content-type": contentType,
|
|
2210
2236
|
"content-length": size,
|
|
2211
2237
|
"cache-control": options.immutable ? "public, max-age=31536000, immutable" : "no-cache, must-revalidate",
|
|
2212
2238
|
"x-content-type-options": "nosniff"
|
|
@@ -2243,6 +2269,12 @@ function createHostGuard(allowedHosts) {
|
|
|
2243
2269
|
return isLoopbackHostname(hostname) || allowedHosts.has(hostname);
|
|
2244
2270
|
};
|
|
2245
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
|
+
}
|
|
2246
2278
|
function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
|
|
2247
2279
|
return async (req, res) => {
|
|
2248
2280
|
if (!hostAllowed(req)) {
|
|
@@ -2309,27 +2341,20 @@ async function startInstance(config, options = {}) {
|
|
|
2309
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");
|
|
2310
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.");
|
|
2311
2343
|
const hostAllowed = createHostGuard(config.allowedHosts);
|
|
2312
|
-
const
|
|
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
|
+
};
|
|
2313
2351
|
const apns = config.apns === void 0 ? void 0 : await createApnsForwarder({
|
|
2314
2352
|
config: config.apns,
|
|
2315
2353
|
stateDir: config.stateDir,
|
|
2316
|
-
authenticate:
|
|
2317
|
-
});
|
|
2318
|
-
const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest ?? createApnsRoute(null, authenticateHost));
|
|
2319
|
-
const parking = { ...config.options.parking };
|
|
2320
|
-
if (config.stateDir && !parking.store) parking.store = createFileSessionStore({
|
|
2321
|
-
dir: join(config.stateDir, "parked"),
|
|
2322
|
-
onError: (error, context) => {
|
|
2323
|
-
process.stderr.write(`[workerdeck] parked-session store ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2324
|
-
}
|
|
2325
|
-
});
|
|
2326
|
-
const profileStore = config.profileStore && config.stateDir ? createFileProfileStore(join(config.stateDir, "profiles.json")) : void 0;
|
|
2327
|
-
const shell = { ...config.options.shell };
|
|
2328
|
-
if (config.stateDir && shell.artifactDir === void 0) shell.artifactDir = join(config.stateDir, "shells");
|
|
2329
|
-
const spend = { ...config.options.spend };
|
|
2330
|
-
if (config.stateDir && !spend.store) spend.store = createFileSpendStore(join(config.stateDir, "spend.json"), (error) => {
|
|
2331
|
-
process.stderr.write(`[workerdeck] spend ledger write failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2354
|
+
authenticate: authenticatePush
|
|
2332
2355
|
});
|
|
2356
|
+
const fallback = createFallback(auth, webRoot, hostAllowed, apns?.handleRequest ?? createApnsRoute(null, authenticatePush));
|
|
2357
|
+
const { parking, profileStore, shell, spend } = stateDirDefaults(config);
|
|
2333
2358
|
const server = createWorkerServer({
|
|
2334
2359
|
...config.options,
|
|
2335
2360
|
...profileStore ? { profileStore } : {},
|
|
@@ -2346,7 +2371,7 @@ async function startInstance(config, options = {}) {
|
|
|
2346
2371
|
},
|
|
2347
2372
|
fallback,
|
|
2348
2373
|
...config.corsOrigins.length ? { cors: { origins: config.corsOrigins } } : {},
|
|
2349
|
-
authenticate:
|
|
2374
|
+
authenticate: (req) => hostAllowed(req) ? authenticateRequest(req) : null
|
|
2350
2375
|
});
|
|
2351
2376
|
const wake = config.keepAwake ? createWakeLock({ onUnavailable: (reason) => {
|
|
2352
2377
|
if (!options.quiet) process.stderr.write(`[workerdeck] not holding the machine awake: ${reason}\n`);
|
|
@@ -2382,30 +2407,18 @@ async function startInstance(config, options = {}) {
|
|
|
2382
2407
|
resolveClosed = r;
|
|
2383
2408
|
});
|
|
2384
2409
|
if (!options.quiet) {
|
|
2385
|
-
const
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
else line(" NO AUTH - anyone who can reach this port gets a session");
|
|
2398
|
-
if (!config.web) line(" dashboard: off - bare gateway, /v1 and /auth only");
|
|
2399
|
-
if (!config.keepAwake) line(" keep-awake: off - this machine may sleep mid-turn");
|
|
2400
|
-
if (config.corsOrigins.length) line(` cors: ${config.corsOrigins.join(", ")} may call /v1 (still key-gated)`);
|
|
2401
|
-
line(config.stateDir ? ` parked sessions persist in ${join(config.stateDir, "parked")}` : " parked sessions are in memory only - a restart drops them");
|
|
2402
|
-
if (apns) {
|
|
2403
|
-
const count = apns.deviceCount();
|
|
2404
|
-
const cards = apns.activity.count();
|
|
2405
|
-
line(` push: APNs forwarder on ${config.apns?.topic} - ${count === 0 ? "no devices registered yet" : `${count} device(s)`}${cards === 0 ? "" : `, ${cards} live card(s)`}`);
|
|
2406
|
-
}
|
|
2407
|
-
if (config.configPath) line(` config ${config.configPath}`);
|
|
2408
|
-
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");
|
|
2409
2422
|
}
|
|
2410
2423
|
return {
|
|
2411
2424
|
server,
|
|
@@ -2426,7 +2439,52 @@ async function startInstance(config, options = {}) {
|
|
|
2426
2439
|
}
|
|
2427
2440
|
};
|
|
2428
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
|
+
}
|
|
2429
2487
|
//#endregion
|
|
2430
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 };
|
|
2431
2489
|
|
|
2432
|
-
//# sourceMappingURL=instance-
|
|
2490
|
+
//# sourceMappingURL=instance-BmQhZ3Px.mjs.map
|