@pablozaiden/webapp 0.5.7 → 0.6.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.
Files changed (56) hide show
  1. package/README.md +11 -3
  2. package/docs/auth-validation.md +11 -0
  3. package/docs/auth.md +25 -0
  4. package/docs/cli.md +73 -3
  5. package/docs/deployment.md +84 -7
  6. package/docs/getting-started.md +69 -6
  7. package/docs/github-actions.md +39 -0
  8. package/docs/release.md +5 -5
  9. package/docs/server.md +34 -4
  10. package/docs/settings.md +5 -0
  11. package/docs/sidebar.md +2 -0
  12. package/docs/ui-guidelines.md +12 -4
  13. package/package.json +2 -4
  14. package/src/build/build-binary.ts +67 -24
  15. package/src/cli/api-command.ts +53 -16
  16. package/src/cli/credentials.ts +275 -4
  17. package/src/cli/device-auth.ts +83 -22
  18. package/src/cli/environment-auth.ts +57 -0
  19. package/src/cli/index.ts +1 -0
  20. package/src/package-resolution.ts +34 -0
  21. package/src/server/auth/device-auth.ts +2 -2
  22. package/src/server/auth/passkeys.ts +16 -16
  23. package/src/server/auth/request-origin.ts +97 -16
  24. package/src/server/authentication.ts +265 -0
  25. package/src/server/create-web-app-server.ts +85 -1391
  26. package/src/server/framework-endpoints.ts +451 -0
  27. package/src/server/public-route-dispatch.ts +85 -0
  28. package/src/server/request-schemas.ts +144 -0
  29. package/src/server/responses.ts +69 -3
  30. package/src/server/route-dispatch.ts +109 -0
  31. package/src/server/runtime-config.ts +67 -0
  32. package/src/server/same-origin.ts +1 -1
  33. package/src/server/server-lifecycle.ts +138 -0
  34. package/src/server/server-types.ts +87 -0
  35. package/src/server/web-document.ts +532 -0
  36. package/src/web/WebAppRoot.tsx +69 -1132
  37. package/src/web/api-client.ts +14 -4
  38. package/src/web/app-shell.tsx +198 -0
  39. package/src/web/auth-screens.tsx +186 -0
  40. package/src/web/components/index.tsx +10 -3
  41. package/src/web/index.ts +1 -0
  42. package/src/web/mobile-hooks.ts +194 -0
  43. package/src/web/mobile.ts +12 -0
  44. package/src/web/root-types.ts +61 -0
  45. package/src/web/routing.ts +61 -0
  46. package/src/web/settings/account-section.tsx +52 -0
  47. package/src/web/settings/resource-state.tsx +17 -0
  48. package/src/web/settings/security-section.tsx +119 -0
  49. package/src/web/settings/sessions-section.tsx +66 -0
  50. package/src/web/settings/settings-view.tsx +96 -0
  51. package/src/web/settings/shutdown-section.tsx +95 -0
  52. package/src/web/settings/user-management.tsx +123 -0
  53. package/src/web/settings-view.tsx +3 -0
  54. package/src/web/sidebar-state.ts +108 -0
  55. package/src/web/sidebar-tree.tsx +89 -0
  56. package/src/web/styles.css +78 -61
@@ -0,0 +1,451 @@
1
+ import type { Server } from "bun";
2
+ import type { CurrentUser, LogLevelName, WebAppConfigResponse } from "../contracts";
3
+ import {
4
+ createApiKey,
5
+ deleteApiKey,
6
+ listApiKeys,
7
+ } from "./auth/api-keys";
8
+ import {
9
+ approveDevice,
10
+ createDeviceAuthorization,
11
+ denyDevice,
12
+ discovery,
13
+ exchangeDeviceCode,
14
+ exchangeRefreshToken,
15
+ getDeviceVerificationDetails,
16
+ jwks,
17
+ listAuthSessions,
18
+ revokeAuthSession,
19
+ revokeRefreshToken,
20
+ } from "./auth/device-auth";
21
+ import {
22
+ beginAuthentication,
23
+ beginBootstrapRegistration,
24
+ beginOwnerPasskeySetup,
25
+ beginSetupRegistration,
26
+ completeAuthentication,
27
+ completeBootstrapRegistration,
28
+ completeOwnerPasskeySetup,
29
+ completeSetupRegistration,
30
+ deletePasskey,
31
+ getSetupDetails,
32
+ isPasskeyAuthRequired,
33
+ logoutHeaders,
34
+ passkeyStatus,
35
+ } from "./auth/passkeys";
36
+ import { audit, assertValidUsername, createSetupLinkRecord, createUserRecord, summarizeUser } from "./auth/users";
37
+ import { nowIso, randomToken } from "./auth/crypto";
38
+ import type { WebAppStore } from "./auth/store";
39
+ import { getRequestBaseUrl } from "./auth/request-origin";
40
+ import type { Authentication } from "./authentication";
41
+ import { authErrorResponse, tokenError } from "./authentication";
42
+ import { checkSameOrigin } from "./same-origin";
43
+ import type { WebAppServerConfig } from "./server-types";
44
+ import { htmlResponse, type WebDocument } from "./web-document";
45
+ import {
46
+ authenticationResponseSchema,
47
+ createApiKeyRequestSchema,
48
+ createUserRequestSchema,
49
+ deviceAuthorizationRequestSchema,
50
+ deviceCodeActionRequestSchema,
51
+ logLevelPreferenceRequestSchema,
52
+ passkeyBootstrapOptionsSchema,
53
+ refreshTokenRequestSchema,
54
+ registrationResponseSchema,
55
+ revokeRefreshTokenRequestSchema,
56
+ setupOptionsSchema,
57
+ setupVerificationSchema,
58
+ themePreferenceRequestSchema,
59
+ tokenRequestSchema,
60
+ userRoleRequestSchema,
61
+ } from "./request-schemas";
62
+ import type { RuntimeConfig } from "./runtime-config";
63
+ import { setLogLevel } from "./logger";
64
+ import { errorResponse, jsonResponse, notFound, parseJson, successResponse } from "./responses";
65
+ import type { WebSocketData } from "./realtime/bus";
66
+
67
+ export interface FrameworkEndpointDependencies {
68
+ config: RuntimeConfig;
69
+ store: WebAppStore;
70
+ authentication: Authentication;
71
+ version: string;
72
+ wsPath: string;
73
+ passkeysEnabled: boolean;
74
+ apiKeysEnabled: boolean;
75
+ deviceAuthEnabled: boolean;
76
+ configResponse?: WebAppServerConfig["configResponse"];
77
+ onLogLevelChange?: (level: LogLevelName) => void;
78
+ ensureWebDocument: () => Promise<WebDocument>;
79
+ }
80
+
81
+ function addHeaders(response: Response, headers: Headers): Response {
82
+ for (const [name, value] of headers) {
83
+ response.headers.append(name, value);
84
+ }
85
+ return response;
86
+ }
87
+
88
+ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDependencies) {
89
+ const {
90
+ config,
91
+ store,
92
+ authentication,
93
+ version,
94
+ wsPath,
95
+ passkeysEnabled,
96
+ apiKeysEnabled,
97
+ deviceAuthEnabled,
98
+ configResponse: extendConfigResponse,
99
+ onLogLevelChange,
100
+ ensureWebDocument,
101
+ } = dependencies;
102
+
103
+ function configResponse(req: Request): WebAppConfigResponse & Record<string, unknown> {
104
+ const user = authentication.configUser(req);
105
+ const base = {
106
+ appName: config.appName,
107
+ version,
108
+ currentUser: user,
109
+ passkeyAuth: passkeyStatus(req, store, config, passkeysEnabled),
110
+ userManagement: {
111
+ enabled: passkeysEnabled,
112
+ canManageUsers: Boolean(user?.isAdmin),
113
+ },
114
+ logLevel: {
115
+ level: (config.logLevelFromEnv ? config.logLevel : store.getLogLevelPreference() ?? config.logLevel) as LogLevelName,
116
+ fromEnv: config.logLevelFromEnv,
117
+ },
118
+ apiKeys: { enabled: Boolean(apiKeysEnabled) },
119
+ deviceAuth: { enabled: Boolean(deviceAuthEnabled) },
120
+ } satisfies WebAppConfigResponse;
121
+ return { ...(extendConfigResponse?.(req, base) ?? {}), ...base };
122
+ }
123
+
124
+ function setupUrl(req: Request, token: string): string {
125
+ const url = new URL(`${getRequestBaseUrl(req, config)}/setup`);
126
+ url.search = `?token=${encodeURIComponent(token)}`;
127
+ url.hash = "";
128
+ return url.toString();
129
+ }
130
+
131
+ function createSetupLink(req: Request, userId: string, kind: "invite" | "reset" | "owner-reset", actorUserId?: string) {
132
+ const token = randomToken(32);
133
+ const record = createSetupLinkRecord({ userId, token, kind, createdByUserId: actorUserId });
134
+ store.createSetupLink(record);
135
+ return { url: setupUrl(req, token), expiresAt: record.expiresAt };
136
+ }
137
+
138
+ function ensureAdmin(auth: Parameters<Authentication["currentUser"]>[0]): CurrentUser {
139
+ return authentication.requireAdmin(auth);
140
+ }
141
+
142
+ async function handleBuiltIn(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined> {
143
+ const url = new URL(req.url);
144
+ const path = url.pathname;
145
+ try {
146
+ if (path === "/api/health" && req.method === "GET") {
147
+ return successResponse({ ok: true, version });
148
+ }
149
+ if (path === "/api/config" && req.method === "GET") {
150
+ return jsonResponse(configResponse(req));
151
+ }
152
+ if (path === wsPath) {
153
+ const auth = await authentication.authorize(req, true);
154
+ if (auth instanceof Response) return auth;
155
+ const originFailure = checkSameOrigin(req, config, auth, "always");
156
+ if (originFailure) return originFailure;
157
+ if (!server) return errorResponse(400, "websocket_unavailable", "WebSocket server is unavailable");
158
+ const filters = Object.fromEntries(url.searchParams.entries());
159
+ const upgraded = server.upgrade(req, { data: { filters, userId: authentication.currentUser(auth)?.id } });
160
+ return upgraded ? undefined : errorResponse(400, "websocket_upgrade_failed", "WebSocket upgrade failed");
161
+ }
162
+ if (path === "/api/auth/status" && req.method === "GET") {
163
+ const auth = await authentication.authorize(req, true);
164
+ if (auth instanceof Response) return auth;
165
+ const user = authentication.currentUser(auth);
166
+ return jsonResponse({
167
+ authenticated: auth.kind !== "anonymous",
168
+ authKind: auth.kind,
169
+ subject: user?.id ?? null,
170
+ clientId: auth.kind === "bearer" ? auth.claims.clientId : null,
171
+ scope: auth.kind === "bearer" ? auth.claims.scope : null,
172
+ });
173
+ }
174
+ if (path === "/api/passkey-auth/status" && req.method === "GET") {
175
+ return jsonResponse(passkeyStatus(req, store, config, passkeysEnabled));
176
+ }
177
+ if (passkeysEnabled && path === "/api/passkey-auth/bootstrap/options" && req.method === "POST") {
178
+ const body = await parseJson(req, passkeyBootstrapOptionsSchema);
179
+ const result = await beginBootstrapRegistration(req, store, config, body.username ?? "");
180
+ return addHeaders(jsonResponse(result.options), result.headers);
181
+ }
182
+ if (passkeysEnabled && path === "/api/passkey-auth/bootstrap/verify" && req.method === "POST") {
183
+ const headers = await completeBootstrapRegistration(req, store, config, await parseJson(req, registrationResponseSchema));
184
+ return addHeaders(successResponse(), headers);
185
+ }
186
+ if (passkeysEnabled && path === "/api/passkey-auth/owner-setup/options" && req.method === "POST") {
187
+ const result = await beginOwnerPasskeySetup(req, store, config);
188
+ return addHeaders(jsonResponse(result.options), result.headers);
189
+ }
190
+ if (passkeysEnabled && path === "/api/passkey-auth/owner-setup/verify" && req.method === "POST") {
191
+ const headers = await completeOwnerPasskeySetup(req, store, config, await parseJson(req, registrationResponseSchema));
192
+ return addHeaders(successResponse(), headers);
193
+ }
194
+ if (passkeysEnabled && path === "/api/user-setup" && req.method === "GET") {
195
+ const token = url.searchParams.get("token") ?? "";
196
+ return jsonResponse(getSetupDetails(store, token));
197
+ }
198
+ if (passkeysEnabled && path === "/api/user-setup/options" && req.method === "POST") {
199
+ const body = await parseJson(req, setupOptionsSchema);
200
+ const result = await beginSetupRegistration(req, store, config, body.token ?? "");
201
+ return addHeaders(jsonResponse(result.options), result.headers);
202
+ }
203
+ if (passkeysEnabled && path === "/api/user-setup/verify" && req.method === "POST") {
204
+ const body = await parseJson(req, setupVerificationSchema);
205
+ const headers = await completeSetupRegistration(req, store, config, body.token, body.response);
206
+ return addHeaders(successResponse(), headers);
207
+ }
208
+ if (passkeysEnabled && path === "/api/passkey-auth/authentication/options" && req.method === "POST") {
209
+ const result = await beginAuthentication(req, store, config);
210
+ return addHeaders(jsonResponse(result.options), result.headers);
211
+ }
212
+ if (passkeysEnabled && path === "/api/passkey-auth/authentication/verify" && req.method === "POST") {
213
+ const headers = await completeAuthentication(req, store, config, await parseJson(req, authenticationResponseSchema));
214
+ return addHeaders(successResponse(), headers);
215
+ }
216
+ if (passkeysEnabled && path === "/api/passkey-auth/logout" && req.method === "POST") {
217
+ return addHeaders(successResponse(), logoutHeaders(req, config));
218
+ }
219
+ if (passkeysEnabled && path === "/api/passkey-auth/passkey" && req.method === "DELETE") {
220
+ const auth = await authentication.authorize(req, true);
221
+ if (auth instanceof Response) return auth;
222
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
223
+ if (originFailure) return originFailure;
224
+ return addHeaders(successResponse(), deletePasskey(req, store, config, authentication.requireUser(auth).id));
225
+ }
226
+ if (apiKeysEnabled && path === "/api/api-keys" && req.method === "GET") {
227
+ const auth = await authentication.authorize(req, true);
228
+ if (auth instanceof Response) return auth;
229
+ return jsonResponse(listApiKeys(store, authentication.requireUser(auth).id));
230
+ }
231
+ if (apiKeysEnabled && path === "/api/api-keys" && req.method === "POST") {
232
+ const auth = await authentication.authorize(req, true);
233
+ if (auth instanceof Response) return auth;
234
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
235
+ if (originFailure) return originFailure;
236
+ return jsonResponse(createApiKey(store, authentication.requireUser(auth), await parseJson(req, createApiKeyRequestSchema)));
237
+ }
238
+ const apiKeyDelete = /^\/api\/api-keys\/([^/]+)$/.exec(path);
239
+ if (apiKeysEnabled && apiKeyDelete && req.method === "DELETE") {
240
+ const auth = await authentication.authorize(req, true);
241
+ if (auth instanceof Response) return auth;
242
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
243
+ if (originFailure) return originFailure;
244
+ deleteApiKey(store, authentication.requireUser(auth).id, decodeURIComponent(apiKeyDelete[1]!));
245
+ return successResponse();
246
+ }
247
+ if (deviceAuthEnabled && path === "/api/auth/device" && req.method === "POST") {
248
+ const body = await parseJson(req, deviceAuthorizationRequestSchema);
249
+ return jsonResponse(createDeviceAuthorization(req, store, config, body));
250
+ }
251
+ if (deviceAuthEnabled && path === "/api/auth/device/verification" && req.method === "GET") {
252
+ const auth = await authentication.authorize(req, true);
253
+ if (auth instanceof Response) return auth;
254
+ const userCode = url.searchParams.get("user_code")?.trim();
255
+ if (!userCode) return errorResponse(400, "invalid_user_code", "user_code is required");
256
+ return jsonResponse(getDeviceVerificationDetails(store, userCode, passkeysEnabled && isPasskeyAuthRequired(store, config)));
257
+ }
258
+ if (deviceAuthEnabled && path === "/api/auth/device/approve" && req.method === "POST") {
259
+ const auth = await authentication.authorize(req, true);
260
+ if (auth instanceof Response) return auth;
261
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
262
+ if (originFailure) return originFailure;
263
+ const body = await parseJson(req, deviceCodeActionRequestSchema);
264
+ return jsonResponse(approveDevice(store, body.userCode, authentication.requireUser(auth).id));
265
+ }
266
+ if (deviceAuthEnabled && path === "/api/auth/device/deny" && req.method === "POST") {
267
+ const auth = await authentication.authorize(req, true);
268
+ if (auth instanceof Response) return auth;
269
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
270
+ if (originFailure) return originFailure;
271
+ const body = await parseJson(req, deviceCodeActionRequestSchema);
272
+ return jsonResponse(denyDevice(store, body.userCode));
273
+ }
274
+ if (deviceAuthEnabled && path === "/api/auth/refresh" && req.method === "POST") {
275
+ const body = await parseJson(req, refreshTokenRequestSchema);
276
+ try {
277
+ return jsonResponse(await exchangeRefreshToken(store, config, body.refresh_token, body.client_id));
278
+ } catch (error) {
279
+ return tokenError(error);
280
+ }
281
+ }
282
+ if (deviceAuthEnabled && path === "/api/auth/token" && req.method === "POST") {
283
+ const body = await parseJson(req, tokenRequestSchema);
284
+ try {
285
+ if (body.grant_type === "urn:ietf:params:oauth:grant-type:device_code" || body.device_code) {
286
+ return jsonResponse(await exchangeDeviceCode(store, config, body.device_code ?? "", body.client_id));
287
+ }
288
+ return jsonResponse(await exchangeRefreshToken(store, config, body.refresh_token ?? "", body.client_id));
289
+ } catch (error) {
290
+ return tokenError(error);
291
+ }
292
+ }
293
+ if (deviceAuthEnabled && path === "/api/auth/revoke" && req.method === "POST") {
294
+ const body = await parseJson(req, revokeRefreshTokenRequestSchema);
295
+ revokeRefreshToken(store, body.refreshToken);
296
+ return successResponse();
297
+ }
298
+ if (deviceAuthEnabled && path === "/api/auth/sessions" && req.method === "GET") {
299
+ const auth = await authentication.authorize(req, true);
300
+ if (auth instanceof Response) return auth;
301
+ return jsonResponse(listAuthSessions(store, authentication.requireUser(auth).id));
302
+ }
303
+ const sessionDelete = /^\/api\/auth\/sessions\/([^/]+)$/.exec(path);
304
+ if (deviceAuthEnabled && sessionDelete && req.method === "DELETE") {
305
+ const auth = await authentication.authorize(req, true);
306
+ if (auth instanceof Response) return auth;
307
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
308
+ if (originFailure) return originFailure;
309
+ return revokeAuthSession(store, authentication.requireUser(auth).id, decodeURIComponent(sessionDelete[1]!)) ? successResponse() : notFound();
310
+ }
311
+ if (deviceAuthEnabled && path === "/.well-known/jwks.json" && req.method === "GET") {
312
+ return jsonResponse(await jwks(store));
313
+ }
314
+ if (deviceAuthEnabled && path === "/.well-known/openid-configuration" && req.method === "GET") {
315
+ return jsonResponse(discovery(req, config));
316
+ }
317
+ if (passkeysEnabled && path === "/api/users" && req.method === "GET") {
318
+ const auth = await authentication.authorize(req, true);
319
+ if (auth instanceof Response) return auth;
320
+ ensureAdmin(auth);
321
+ return jsonResponse(store.listUsers().map(summarizeUser));
322
+ }
323
+ if (passkeysEnabled && path === "/api/users" && req.method === "POST") {
324
+ const auth = await authentication.authorize(req, true);
325
+ if (auth instanceof Response) return auth;
326
+ const actor = ensureAdmin(auth);
327
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
328
+ if (originFailure) return originFailure;
329
+ const body = await parseJson(req, createUserRequestSchema);
330
+ const username = assertValidUsername(body.username ?? "");
331
+ if (store.getUserByUsername(username)) {
332
+ return errorResponse(409, "username_exists", "Username already exists");
333
+ }
334
+ const user = createUserRecord({ username, role: body.role ?? "user" });
335
+ store.createUser(user);
336
+ const setupLink = createSetupLink(req, user.id, "invite", actor.id);
337
+ audit(store, { eventType: "user_created", actorUserId: actor.id, targetUserId: user.id, metadata: { role: user.role } });
338
+ return jsonResponse({ user: summarizeUser(store.getUserById(user.id) ?? user), setupLink }, { status: 201 });
339
+ }
340
+ const userRolePatch = /^\/api\/users\/([^/]+)\/role$/.exec(path);
341
+ if (passkeysEnabled && userRolePatch && req.method === "PATCH") {
342
+ const auth = await authentication.authorize(req, true);
343
+ if (auth instanceof Response) return auth;
344
+ const actor = ensureAdmin(auth);
345
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
346
+ if (originFailure) return originFailure;
347
+ const userId = decodeURIComponent(userRolePatch[1]!);
348
+ const target = store.getUserById(userId);
349
+ if (!target) return notFound();
350
+ if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner role cannot be changed");
351
+ const body = await parseJson(req, userRoleRequestSchema);
352
+ const role = body.role;
353
+ store.setUserRole(userId, role, nowIso());
354
+ audit(store, { eventType: "user_role_changed", actorUserId: actor.id, targetUserId: userId, metadata: { role } });
355
+ return jsonResponse(summarizeUser(store.getUserById(userId) ?? target));
356
+ }
357
+ const userReset = /^\/api\/users\/([^/]+)\/reset$/.exec(path);
358
+ if (passkeysEnabled && userReset && req.method === "POST") {
359
+ const auth = await authentication.authorize(req, true);
360
+ if (auth instanceof Response) return auth;
361
+ const actor = ensureAdmin(auth);
362
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
363
+ if (originFailure) return originFailure;
364
+ const userId = decodeURIComponent(userReset[1]!);
365
+ const target = store.getUserById(userId);
366
+ if (!target) return notFound();
367
+ if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner cannot be reset");
368
+ const timestamp = nowIso();
369
+ store.deletePendingSetupLinksForUser(userId, timestamp);
370
+ store.deletePasskeysForUser(userId);
371
+ store.deleteApiKeysForUser(userId);
372
+ store.revokeRefreshSessionsForUser(userId, timestamp);
373
+ store.incrementUserAuthVersion(userId, timestamp);
374
+ const setupLink = createSetupLink(req, userId, "reset", actor.id);
375
+ audit(store, { eventType: "user_reset", actorUserId: actor.id, targetUserId: userId });
376
+ return jsonResponse({ user: summarizeUser(store.getUserById(userId) ?? target), setupLink });
377
+ }
378
+ const userDelete = /^\/api\/users\/([^/]+)$/.exec(path);
379
+ if (passkeysEnabled && userDelete && req.method === "DELETE") {
380
+ const auth = await authentication.authorize(req, true);
381
+ if (auth instanceof Response) return auth;
382
+ const actor = ensureAdmin(auth);
383
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
384
+ if (originFailure) return originFailure;
385
+ const userId = decodeURIComponent(userDelete[1]!);
386
+ const target = store.getUserById(userId);
387
+ if (!target) return notFound();
388
+ if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner cannot be deleted");
389
+ if (!store.deleteUser(userId)) return notFound();
390
+ audit(store, { eventType: "user_deleted", actorUserId: actor.id, metadata: { deletedUserId: userId, username: target.username } });
391
+ return successResponse();
392
+ }
393
+ if (passkeysEnabled && path === "/api/audit-events" && req.method === "GET") {
394
+ const auth = await authentication.authorize(req, true);
395
+ if (auth instanceof Response) return auth;
396
+ ensureAdmin(auth);
397
+ return jsonResponse(store.listAuditEvents(100));
398
+ }
399
+ if (path === "/api/preferences/theme") {
400
+ const auth = await authentication.authorize(req, true);
401
+ if (auth instanceof Response) return auth;
402
+ const user = authentication.requireUser(auth);
403
+ if (req.method === "GET") {
404
+ return jsonResponse({ theme: store.getThemePreference(user.id) ?? "system" });
405
+ }
406
+ if (req.method === "PUT") {
407
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
408
+ if (originFailure) return originFailure;
409
+ const body = await parseJson(req, themePreferenceRequestSchema);
410
+ store.setThemePreference(body.theme, user.id);
411
+ return successResponse({ theme: body.theme });
412
+ }
413
+ }
414
+ if (path === "/api/preferences/log-level") {
415
+ const auth = await authentication.authorize(req, true);
416
+ if (auth instanceof Response) return auth;
417
+ ensureAdmin(auth);
418
+ if (req.method === "GET") {
419
+ return jsonResponse({ level: store.getLogLevelPreference() ?? config.logLevel, fromEnv: config.logLevelFromEnv });
420
+ }
421
+ if (req.method === "PUT") {
422
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
423
+ if (originFailure) return originFailure;
424
+ if (config.logLevelFromEnv) return errorResponse(409, "log_level_from_env", "Log level is controlled by environment");
425
+ const body = await parseJson(req, logLevelPreferenceRequestSchema);
426
+ store.setLogLevelPreference(body.level);
427
+ setLogLevel(body.level);
428
+ onLogLevelChange?.(body.level);
429
+ return successResponse({ level: body.level });
430
+ }
431
+ }
432
+ if (path === "/api/server/kill" && req.method === "POST") {
433
+ const auth = await authentication.authorize(req, true);
434
+ if (auth instanceof Response) return auth;
435
+ ensureAdmin(auth);
436
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
437
+ if (originFailure) return originFailure;
438
+ setTimeout(() => process.exit(0), 100);
439
+ return successResponse({ success: true, message: "Server is shutting down" });
440
+ }
441
+ if (deviceAuthEnabled && path === "/device" && req.method === "GET") {
442
+ return htmlResponse(await ensureWebDocument(), req);
443
+ }
444
+ } catch (error) {
445
+ return authErrorResponse(error);
446
+ }
447
+ return undefined;
448
+ }
449
+
450
+ return { handleBuiltIn };
451
+ }
@@ -0,0 +1,85 @@
1
+ import type {
2
+ PublicRouteAsset,
3
+ PublicRouteDefinition,
4
+ PublicRouteValue,
5
+ } from "./server-types";
6
+ import type { WebDocument } from "./web-document";
7
+ import { methodNotAllowed, notFound, withSecurityHeaders } from "./responses";
8
+
9
+ export interface PublicRouteDispatcherDependencies {
10
+ publicRoutes: Readonly<Record<string, PublicRouteDefinition>>;
11
+ generatedRoutePaths: ReadonlySet<string>;
12
+ ensureWebDocument: () => Promise<WebDocument>;
13
+ }
14
+
15
+ function hasOwnPublicRoute(publicRoutes: Readonly<Record<string, PublicRouteDefinition>>, path: string): boolean {
16
+ return Object.prototype.hasOwnProperty.call(publicRoutes, path);
17
+ }
18
+
19
+ function publicAssetResponse(asset: PublicRouteAsset, extraHeaders?: HeadersInit): Response {
20
+ const response = asset instanceof Response
21
+ ? asset
22
+ : typeof asset === "string"
23
+ ? new Response(asset, { headers: { "content-type": "text/plain; charset=utf-8" } })
24
+ : new Response(asset as BodyInit);
25
+ if (extraHeaders) {
26
+ for (const [name, value] of new Headers(extraHeaders)) {
27
+ response.headers.set(name, value);
28
+ }
29
+ }
30
+ return withSecurityHeaders(response);
31
+ }
32
+
33
+ async function handlePublicRouteValue(req: Request, route: PublicRouteDefinition): Promise<Response> {
34
+ const methodName = req.method === "HEAD" ? "HEAD" : req.method === "GET" ? "GET" : undefined;
35
+ if (!methodName) {
36
+ return withSecurityHeaders(methodNotAllowed());
37
+ }
38
+ const definition = typeof route === "object"
39
+ && route !== null
40
+ && !(route instanceof Response)
41
+ && !(route instanceof Blob)
42
+ && !(route instanceof ArrayBuffer)
43
+ && !(route instanceof Uint8Array)
44
+ && ("GET" in route || "HEAD" in route || "headers" in route)
45
+ ? route
46
+ : undefined;
47
+ const value = definition
48
+ ? definition[methodName] ?? (methodName === "HEAD" ? definition.GET : undefined)
49
+ : route as PublicRouteValue;
50
+ if (!value) {
51
+ return withSecurityHeaders(methodNotAllowed());
52
+ }
53
+ const asset = typeof value === "function" ? await value(req) : value;
54
+ if (!asset) {
55
+ return withSecurityHeaders(notFound());
56
+ }
57
+ const response = publicAssetResponse(asset, definition?.headers);
58
+ if (req.method === "HEAD") {
59
+ return new Response(null, { status: response.status, statusText: response.statusText, headers: response.headers });
60
+ }
61
+ return response;
62
+ }
63
+
64
+ export function createPublicRouteDispatcher(dependencies: PublicRouteDispatcherDependencies): (req: Request) => Promise<Response | undefined> {
65
+ const { publicRoutes, generatedRoutePaths, ensureWebDocument } = dependencies;
66
+
67
+ return async function dispatchPublicRoute(req: Request): Promise<Response | undefined> {
68
+ const url = new URL(req.url);
69
+ if (generatedRoutePaths.has(url.pathname)) {
70
+ const webDocument = await ensureWebDocument();
71
+ const generatedRoute = webDocument.generatedPublicRoutes[url.pathname];
72
+ if (generatedRoute) {
73
+ return handlePublicRouteValue(req, generatedRoute);
74
+ }
75
+ }
76
+ if (!hasOwnPublicRoute(publicRoutes, url.pathname)) {
77
+ return undefined;
78
+ }
79
+ const route = publicRoutes[url.pathname];
80
+ if (!route) {
81
+ return undefined;
82
+ }
83
+ return handlePublicRouteValue(req, route);
84
+ };
85
+ }
@@ -0,0 +1,144 @@
1
+ import { z } from "zod";
2
+ import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
3
+
4
+ const nonEmptyString = z.string().min(1);
5
+ const optionalClientId = z.string().max(200).optional();
6
+ const authenticatorTransport = z.enum(["ble", "cable", "hybrid", "internal", "nfc", "smart-card", "usb"]);
7
+ const clientExtensionResults = z.object({
8
+ appid: z.boolean().optional(),
9
+ credProps: z.object({ rk: z.boolean().optional() }).optional(),
10
+ hmacCreateSecret: z.boolean().optional(),
11
+ });
12
+
13
+ export const registrationResponseSchema: z.ZodType<RegistrationResponseJSON> = z.object({
14
+ id: nonEmptyString,
15
+ rawId: nonEmptyString,
16
+ response: z.object({
17
+ clientDataJSON: nonEmptyString,
18
+ attestationObject: nonEmptyString,
19
+ authenticatorData: nonEmptyString.optional(),
20
+ transports: z.array(authenticatorTransport).optional(),
21
+ publicKeyAlgorithm: z.number().optional(),
22
+ publicKey: nonEmptyString.optional(),
23
+ }),
24
+ authenticatorAttachment: z.enum(["cross-platform", "platform"]).optional(),
25
+ clientExtensionResults,
26
+ type: z.literal("public-key"),
27
+ });
28
+
29
+ export const authenticationResponseSchema: z.ZodType<AuthenticationResponseJSON> = z.object({
30
+ id: nonEmptyString,
31
+ rawId: nonEmptyString,
32
+ response: z.object({
33
+ clientDataJSON: nonEmptyString,
34
+ authenticatorData: nonEmptyString,
35
+ signature: nonEmptyString,
36
+ userHandle: nonEmptyString.optional(),
37
+ }),
38
+ authenticatorAttachment: z.enum(["cross-platform", "platform"]).optional(),
39
+ clientExtensionResults,
40
+ type: z.literal("public-key"),
41
+ });
42
+
43
+ export const passkeyBootstrapOptionsSchema = z.object({
44
+ username: z.string().max(32).optional(),
45
+ });
46
+
47
+ export const setupOptionsSchema = z.object({
48
+ token: z.string().max(512).optional(),
49
+ });
50
+
51
+ export const setupVerificationSchema = z.object({
52
+ token: nonEmptyString,
53
+ response: registrationResponseSchema,
54
+ });
55
+
56
+ export const createApiKeyRequestSchema = z.object({
57
+ name: z.string().max(200).optional(),
58
+ scopes: z.array(nonEmptyString).optional(),
59
+ prefix: z.string().min(1).max(32).regex(/^[A-Za-z0-9_-]+$/).optional(),
60
+ expiresAt: z.string().refine((value) => Number.isFinite(Date.parse(value)), "expiresAt must be a valid date").optional(),
61
+ });
62
+
63
+ export const deviceAuthorizationRequestSchema = z.object({
64
+ client_id: optionalClientId,
65
+ clientId: optionalClientId,
66
+ scope: z.string().max(512).optional(),
67
+ }).superRefine((value, context) => {
68
+ if (value.client_id !== undefined && value.clientId !== undefined && value.client_id !== value.clientId) {
69
+ context.addIssue({ code: "custom", path: ["clientId"], message: "client_id and clientId must match when both are provided" });
70
+ }
71
+ }).transform(({ client_id, clientId, scope }) => ({
72
+ clientId: clientId ?? client_id,
73
+ scope,
74
+ }));
75
+
76
+ const deviceCodeActionInput = z.object({
77
+ userCode: nonEmptyString.optional(),
78
+ user_code: nonEmptyString.optional(),
79
+ }).superRefine((value, context) => {
80
+ if (value.userCode === undefined && value.user_code === undefined) {
81
+ context.addIssue({ code: "custom", path: ["userCode"], message: "userCode is required" });
82
+ } else if (value.userCode !== undefined && value.user_code !== undefined && value.userCode !== value.user_code) {
83
+ context.addIssue({ code: "custom", path: ["userCode"], message: "user_code and userCode must match when both are provided" });
84
+ }
85
+ });
86
+
87
+ export const deviceCodeActionRequestSchema = deviceCodeActionInput.transform(({ userCode, user_code }) => ({
88
+ userCode: userCode ?? user_code!,
89
+ }));
90
+
91
+ const tokenRequestFields = {
92
+ grant_type: z.enum(["refresh_token", "urn:ietf:params:oauth:grant-type:device_code"]).optional(),
93
+ device_code: nonEmptyString.optional(),
94
+ refresh_token: nonEmptyString.optional(),
95
+ client_id: optionalClientId,
96
+ };
97
+
98
+ export const tokenRequestSchema = z.object(tokenRequestFields).superRefine((value, context) => {
99
+ if (value.grant_type === "urn:ietf:params:oauth:grant-type:device_code" && value.device_code === undefined) {
100
+ context.addIssue({ code: "custom", path: ["device_code"], message: "device_code is required for device-code grants" });
101
+ }
102
+ if (value.grant_type === "refresh_token" && value.refresh_token === undefined) {
103
+ context.addIssue({ code: "custom", path: ["refresh_token"], message: "refresh_token is required for refresh grants" });
104
+ }
105
+ if (value.grant_type === undefined && value.device_code === undefined && value.refresh_token === undefined) {
106
+ context.addIssue({ code: "custom", path: ["grant_type"], message: "A device_code or refresh_token is required" });
107
+ }
108
+ });
109
+
110
+ export const refreshTokenRequestSchema = z.object({
111
+ grant_type: z.literal("refresh_token").optional(),
112
+ refresh_token: nonEmptyString,
113
+ client_id: optionalClientId,
114
+ });
115
+
116
+ export const revokeRefreshTokenRequestSchema = z.object({
117
+ refreshToken: nonEmptyString.optional(),
118
+ refresh_token: nonEmptyString.optional(),
119
+ }).superRefine((value, context) => {
120
+ if (value.refreshToken === undefined && value.refresh_token === undefined) {
121
+ context.addIssue({ code: "custom", path: ["refreshToken"], message: "refresh_token is required" });
122
+ } else if (value.refreshToken !== undefined && value.refresh_token !== undefined && value.refreshToken !== value.refresh_token) {
123
+ context.addIssue({ code: "custom", path: ["refreshToken"], message: "refresh_token and refreshToken must match when both are provided" });
124
+ }
125
+ }).transform(({ refreshToken, refresh_token }) => ({
126
+ refreshToken: refreshToken ?? refresh_token!,
127
+ }));
128
+
129
+ export const createUserRequestSchema = z.object({
130
+ username: z.string().max(32).optional(),
131
+ role: z.enum(["admin", "user"]).optional(),
132
+ });
133
+
134
+ export const userRoleRequestSchema = z.object({
135
+ role: z.enum(["admin", "user"]),
136
+ });
137
+
138
+ export const themePreferenceRequestSchema = z.object({
139
+ theme: z.enum(["system", "light", "dark"]),
140
+ });
141
+
142
+ export const logLevelPreferenceRequestSchema = z.object({
143
+ level: z.enum(["trace", "debug", "info", "warn", "error"]),
144
+ });