@pablozaiden/webapp 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- import type { Server } from "bun";
2
- import type { ApiKeySummary, LogLevelName, ThemePreference, WebAppConfigResponse } from "../contracts";
1
+ import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
2
+ import type { CurrentUser, LogLevelName, ThemePreference, WebAppConfigResponse, WebAppUserRole } from "../contracts";
3
3
  import { authenticateApiKey, assertScopes, createApiKey, deleteApiKey, listApiKeys } from "./auth/api-keys";
4
4
  import {
5
5
  approveDevice,
@@ -17,11 +17,16 @@ import {
17
17
  } from "./auth/device-auth";
18
18
  import {
19
19
  beginAuthentication,
20
- beginRegistration,
20
+ beginBootstrapRegistration,
21
+ beginOwnerPasskeySetup,
22
+ beginSetupRegistration,
21
23
  completeAuthentication,
22
- completeRegistration,
24
+ completeBootstrapRegistration,
25
+ completeOwnerPasskeySetup,
26
+ completeSetupRegistration,
23
27
  deletePasskey,
24
- hasPasskeySession,
28
+ getPasskeySessionUser,
29
+ getSetupDetails,
25
30
  isPasskeyAuthRequired,
26
31
  logoutHeaders,
27
32
  passkeyStatus,
@@ -29,10 +34,12 @@ import {
29
34
  import { sqliteWebAppStore } from "./auth/sqlite-store";
30
35
  import type { WebAppStore } from "./auth/store";
31
36
  import { AuthError, type AuthenticatedRequestState } from "./auth/types";
37
+ import { audit, assertValidUsername, createSetupLinkRecord, createUserRecord, summarizeUser } from "./auth/users";
38
+ import { nowIso, randomToken } from "./auth/crypto";
32
39
  import { createLogger, setLogLevel } from "./logger";
33
40
  import { createRealtimeBus, type RealtimeBus, type WebSocketData } from "./realtime/bus";
34
41
  import { readRuntimeConfig, safeRuntimeConfig, type RuntimeConfig } from "./runtime-config";
35
- import { matchRoute, type RouteTable } from "./routes";
42
+ import { matchRoute, type RouteAuth, type RouteTable, type UserIdSelector, type UserOwnedResource, type UserScopedRealtimePublisher } from "./routes";
36
43
  import { checkSameOrigin } from "./same-origin";
37
44
  import { errorResponse, jsonResponse, methodNotAllowed, notFound, parseJson, successResponse, withSecurityHeaders } from "./responses";
38
45
 
@@ -43,6 +50,8 @@ export interface WebAppServerConfig<TEvent = unknown> {
43
50
  version?: string;
44
51
  store?: WebAppStore;
45
52
  routes?: RouteTable<TEvent>;
53
+ publicRoutes?: Record<string, PublicRouteDefinition>;
54
+ websockets?: Record<string, Partial<WebSocketHandler<WebAppWebSocketData>>>;
46
55
  auth?: {
47
56
  passkeys?: boolean | { rpName?: string; userName?: string; userDisplayName?: string };
48
57
  apiKeys?: boolean;
@@ -53,6 +62,24 @@ export interface WebAppServerConfig<TEvent = unknown> {
53
62
  };
54
63
  }
55
64
 
65
+ export const WEBAPP_SOCKET_HANDLER = "webappSocketHandler";
66
+
67
+ export type WebAppWebSocketData = WebSocketData & {
68
+ webappSocketHandler?: string;
69
+ [key: string]: unknown;
70
+ };
71
+
72
+ export type PublicRouteAsset = Response | Blob | ArrayBuffer | Uint8Array | string;
73
+ export type PublicRouteHandler = (req: Request) => PublicRouteAsset | undefined | Promise<PublicRouteAsset | undefined>;
74
+ export type PublicRouteValue = PublicRouteAsset | PublicRouteHandler;
75
+ export type PublicRouteDefinition =
76
+ | PublicRouteValue
77
+ | {
78
+ GET?: PublicRouteValue;
79
+ HEAD?: PublicRouteValue;
80
+ headers?: HeadersInit;
81
+ };
82
+
56
83
  export interface WebAppServer<TEvent = unknown> {
57
84
  config: RuntimeConfig;
58
85
  store: WebAppStore;
@@ -89,9 +116,14 @@ function authErrorResponse(error: unknown): Response {
89
116
  if (error instanceof AuthError) {
90
117
  return errorResponse(error.status, error.code, error.message);
91
118
  }
92
- if (error instanceof Error) {
93
- return errorResponse(400, "request_failed", error.message);
119
+ return errorResponse(500, "request_failed", "Request failed");
120
+ }
121
+
122
+ function routeHandlerErrorResponse(error: unknown): Response {
123
+ if (error instanceof AuthError) {
124
+ return errorResponse(error.status, error.code, error.message);
94
125
  }
126
+ log.error("Unhandled route handler error", { error: error instanceof Error ? error.message : String(error) });
95
127
  return errorResponse(500, "request_failed", "Request failed");
96
128
  }
97
129
 
@@ -115,6 +147,24 @@ function htmlResponse(index: unknown): Response {
115
147
  return index as Response;
116
148
  }
117
149
 
150
+ function publicAssetResponse(asset: PublicRouteAsset, extraHeaders?: HeadersInit): Response {
151
+ const response = asset instanceof Response
152
+ ? asset
153
+ : typeof asset === "string"
154
+ ? new Response(asset, { headers: { "content-type": "text/plain; charset=utf-8" } })
155
+ : new Response(asset as BodyInit);
156
+ if (extraHeaders) {
157
+ for (const [name, value] of new Headers(extraHeaders)) {
158
+ response.headers.set(name, value);
159
+ }
160
+ }
161
+ return withSecurityHeaders(response);
162
+ }
163
+
164
+ function secureDynamicResponse(response: Response): Response {
165
+ return response instanceof Response ? withSecurityHeaders(response) : response;
166
+ }
167
+
118
168
  function canRespondWithIndex(index: unknown): boolean {
119
169
  return index instanceof Response || typeof index === "string" || index instanceof Blob;
120
170
  }
@@ -123,6 +173,102 @@ function scopesFromBearer(claims: { scope: string }): string[] {
123
173
  return claims.scope.split(/\s+/).filter(Boolean);
124
174
  }
125
175
 
176
+ function currentUser(auth: AuthenticatedRequestState): CurrentUser | undefined {
177
+ return auth.kind === "anonymous" ? undefined : auth.user;
178
+ }
179
+
180
+ function requireUser(auth: AuthenticatedRequestState): CurrentUser {
181
+ const user = currentUser(auth);
182
+ if (!user) {
183
+ throw new AuthError("authentication_required", "Authentication is required", 401);
184
+ }
185
+ return user;
186
+ }
187
+
188
+ function requireAdmin(auth: AuthenticatedRequestState): CurrentUser {
189
+ const user = requireUser(auth);
190
+ if (!user.isAdmin) {
191
+ throw new AuthError("admin_required", "Admin permissions are required", 403);
192
+ }
193
+ return user;
194
+ }
195
+
196
+ function requireOwner(auth: AuthenticatedRequestState): CurrentUser {
197
+ const user = requireUser(auth);
198
+ if (!user.isOwner) {
199
+ throw new AuthError("owner_required", "Owner permissions are required", 403);
200
+ }
201
+ return user;
202
+ }
203
+
204
+ function assertUser(auth: AuthenticatedRequestState, userId: string): CurrentUser {
205
+ const user = requireUser(auth);
206
+ if (user.id !== userId) {
207
+ throw new AuthError("forbidden", "You cannot access another user's resource", 403);
208
+ }
209
+ return user;
210
+ }
211
+
212
+ function ownedUserId<TResource>(resource: TResource, getUserId?: UserIdSelector<TResource>): string | undefined {
213
+ if (getUserId) {
214
+ return getUserId(resource);
215
+ }
216
+ if (typeof resource === "object" && resource !== null && "userId" in resource && typeof resource.userId === "string") {
217
+ return resource.userId;
218
+ }
219
+ throw new AuthError("route_misconfigured", "Owned resource helpers require a string userId field or a getUserId selector", 500);
220
+ }
221
+
222
+ function requireOwned<TResource extends UserOwnedResource>(auth: AuthenticatedRequestState, resource: TResource | null | undefined): TResource;
223
+ function requireOwned<TResource>(auth: AuthenticatedRequestState, resource: TResource | null | undefined, getUserId: UserIdSelector<TResource>): TResource;
224
+ function requireOwned<TResource>(auth: AuthenticatedRequestState, resource: TResource | null | undefined, getUserId?: UserIdSelector<TResource>): TResource {
225
+ const user = requireUser(auth);
226
+ const resourceUserId = resource ? ownedUserId(resource, getUserId) : undefined;
227
+ if (!resource || resourceUserId !== user.id) {
228
+ throw new AuthError("not_found", "Resource not found", 404);
229
+ }
230
+ return resource;
231
+ }
232
+
233
+ function filterOwned<TResource extends UserOwnedResource>(auth: AuthenticatedRequestState, resources: readonly TResource[]): TResource[];
234
+ function filterOwned<TResource>(auth: AuthenticatedRequestState, resources: readonly TResource[], getUserId: UserIdSelector<TResource>): TResource[];
235
+ function filterOwned<TResource>(auth: AuthenticatedRequestState, resources: readonly TResource[], getUserId?: UserIdSelector<TResource>): TResource[] {
236
+ const user = requireUser(auth);
237
+ return resources.filter((resource) => ownedUserId(resource, getUserId) === user.id);
238
+ }
239
+
240
+ function createFilterOwned(auth: AuthenticatedRequestState) {
241
+ function contextFilterOwned<TResource extends UserOwnedResource>(resources: readonly TResource[]): TResource[];
242
+ function contextFilterOwned<TResource>(resources: readonly TResource[], getUserId: UserIdSelector<TResource>): TResource[];
243
+ function contextFilterOwned<TResource>(resources: readonly TResource[], getUserId?: UserIdSelector<TResource>): TResource[] {
244
+ return filterOwned(auth, resources, getUserId as UserIdSelector<TResource>);
245
+ }
246
+ return contextFilterOwned;
247
+ }
248
+
249
+ function createRequireOwned(auth: AuthenticatedRequestState) {
250
+ function contextRequireOwned<TResource extends UserOwnedResource>(resource: TResource | null | undefined): TResource;
251
+ function contextRequireOwned<TResource>(resource: TResource | null | undefined, getUserId: UserIdSelector<TResource>): TResource;
252
+ function contextRequireOwned<TResource>(resource: TResource | null | undefined, getUserId?: UserIdSelector<TResource>): TResource {
253
+ return requireOwned(auth, resource, getUserId as UserIdSelector<TResource>);
254
+ }
255
+ return contextRequireOwned;
256
+ }
257
+
258
+ function requiresAuth(routeAuth: RouteAuth): boolean {
259
+ return routeAuth !== "public" && routeAuth !== "optional";
260
+ }
261
+
262
+ function enforceRouteAuth(routeAuth: RouteAuth, auth: AuthenticatedRequestState): void {
263
+ if (routeAuth === "user") {
264
+ requireUser(auth);
265
+ } else if (routeAuth === "admin") {
266
+ requireAdmin(auth);
267
+ } else if (routeAuth === "owner") {
268
+ requireOwner(auth);
269
+ }
270
+ }
271
+
126
272
  export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<TEvent>): WebAppServer<TEvent> {
127
273
  const config = readRuntimeConfig({ appName: input.appName, envPrefix: input.envPrefix });
128
274
  const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
@@ -133,6 +279,8 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
133
279
  const version = input.version ?? "0.0.0-development";
134
280
  const wsPath = input.realtime?.path ?? "/api/ws";
135
281
  const routes = input.routes ?? {};
282
+ const publicRoutes = input.publicRoutes ?? {};
283
+ const appWebsockets = input.websockets ?? {};
136
284
  const passkeysEnabled = input.auth?.passkeys !== false;
137
285
  const apiKeysEnabled = input.auth?.apiKeys ?? false;
138
286
  const deviceAuthEnabled = input.auth?.deviceAuth ?? false;
@@ -143,7 +291,16 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
143
291
  if (deviceAuthEnabled) {
144
292
  try {
145
293
  const claims = await verifyAccessToken(store, config, token);
146
- return { kind: "bearer", claims };
294
+ const user = store.getUserById(claims.sub);
295
+ if (user) {
296
+ return { kind: "bearer", claims, user: {
297
+ id: user.id,
298
+ username: user.username,
299
+ role: user.role,
300
+ isOwner: user.role === "owner",
301
+ isAdmin: user.role === "owner" || user.role === "admin",
302
+ } };
303
+ }
147
304
  } catch {
148
305
  // Fall through to API keys. Both use Bearer by design.
149
306
  }
@@ -156,22 +313,34 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
156
313
  }
157
314
  return errorResponse(401, "invalid_token", "Bearer token is invalid");
158
315
  }
316
+ if (passkeysEnabled) {
317
+ const user = getPasskeySessionUser(req, store, config);
318
+ if (user) {
319
+ return { kind: "passkey", user };
320
+ }
321
+ }
159
322
  if (passkeysEnabled && isPasskeyAuthRequired(store, config)) {
160
- if (!hasPasskeySession(req, store)) {
323
+ const user = getPasskeySessionUser(req, store, config);
324
+ if (!user) {
161
325
  return required ? errorResponse(401, "authentication_required", "Passkey authentication is required", undefined, {
162
326
  headers: { "x-webapp-passkey-required": "true" },
163
327
  }) : { kind: "anonymous" };
164
328
  }
165
- return { kind: "passkey" };
166
329
  }
167
330
  return { kind: "anonymous" };
168
331
  }
169
332
 
170
333
  function configResponse(req: Request): WebAppConfigResponse {
334
+ const user = passkeysEnabled ? getPasskeySessionUser(req, store, config) : undefined;
171
335
  return {
172
336
  appName: config.appName,
173
337
  version,
338
+ currentUser: user,
174
339
  passkeyAuth: passkeyStatus(req, store, config, passkeysEnabled),
340
+ userManagement: {
341
+ enabled: passkeysEnabled,
342
+ canManageUsers: Boolean(user?.isAdmin),
343
+ },
175
344
  logLevel: {
176
345
  level: (config.logLevelFromEnv ? config.logLevel : store.getLogLevelPreference() ?? config.logLevel) as LogLevelName,
177
346
  fromEnv: config.logLevelFromEnv,
@@ -181,6 +350,29 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
181
350
  };
182
351
  }
183
352
 
353
+ function setupUrl(req: Request, token: string): string {
354
+ const url = new URL(req.url);
355
+ url.pathname = "/setup";
356
+ url.search = `?token=${encodeURIComponent(token)}`;
357
+ url.hash = "";
358
+ return url.toString();
359
+ }
360
+
361
+ function createSetupLink(req: Request, userId: string, kind: "invite" | "reset" | "owner-reset", actorUserId?: string) {
362
+ const token = randomToken(32);
363
+ const record = createSetupLinkRecord({ userId, token, kind, createdByUserId: actorUserId });
364
+ store.createSetupLink(record);
365
+ return { url: setupUrl(req, token), expiresAt: record.expiresAt };
366
+ }
367
+
368
+ function ensureAdmin(auth: AuthenticatedRequestState): CurrentUser {
369
+ return requireAdmin(auth);
370
+ }
371
+
372
+ function sanitizeRole(role: WebAppUserRole | undefined): WebAppUserRole {
373
+ return role === "admin" ? "admin" : "user";
374
+ }
375
+
184
376
  async function handleBuiltIn(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined> {
185
377
  const url = new URL(req.url);
186
378
  const path = url.pathname;
@@ -198,18 +390,41 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
198
390
  if (originFailure) return originFailure;
199
391
  if (!server) return errorResponse(400, "websocket_unavailable", "WebSocket server is unavailable");
200
392
  const filters = Object.fromEntries(url.searchParams.entries());
201
- const upgraded = server.upgrade(req, { data: { filters } });
393
+ const upgraded = server.upgrade(req, { data: { filters, userId: currentUser(auth)?.id } });
202
394
  return upgraded ? undefined : errorResponse(400, "websocket_upgrade_failed", "WebSocket upgrade failed");
203
395
  }
204
396
  if (path === "/api/passkey-auth/status" && req.method === "GET") {
205
397
  return jsonResponse(passkeyStatus(req, store, config, passkeysEnabled));
206
398
  }
207
- if (passkeysEnabled && path === "/api/passkey-auth/registration/options" && req.method === "POST") {
208
- const result = await beginRegistration(req, store, config);
399
+ if (passkeysEnabled && path === "/api/passkey-auth/bootstrap/options" && req.method === "POST") {
400
+ const body = await parseJson<{ username?: string }>(req);
401
+ const result = await beginBootstrapRegistration(req, store, config, body.username ?? "");
402
+ return addHeaders(jsonResponse(result.options), result.headers);
403
+ }
404
+ if (passkeysEnabled && path === "/api/passkey-auth/bootstrap/verify" && req.method === "POST") {
405
+ const headers = await completeBootstrapRegistration(req, store, config, await parseJson(req));
406
+ return addHeaders(successResponse(), headers);
407
+ }
408
+ if (passkeysEnabled && path === "/api/passkey-auth/owner-setup/options" && req.method === "POST") {
409
+ const result = await beginOwnerPasskeySetup(req, store, config);
209
410
  return addHeaders(jsonResponse(result.options), result.headers);
210
411
  }
211
- if (passkeysEnabled && path === "/api/passkey-auth/registration/verify" && req.method === "POST") {
212
- const headers = await completeRegistration(req, store, config, await parseJson(req));
412
+ if (passkeysEnabled && path === "/api/passkey-auth/owner-setup/verify" && req.method === "POST") {
413
+ const headers = await completeOwnerPasskeySetup(req, store, config, await parseJson(req));
414
+ return addHeaders(successResponse(), headers);
415
+ }
416
+ if (passkeysEnabled && path === "/api/user-setup" && req.method === "GET") {
417
+ const token = url.searchParams.get("token") ?? "";
418
+ return jsonResponse(getSetupDetails(store, token));
419
+ }
420
+ if (passkeysEnabled && path === "/api/user-setup/options" && req.method === "POST") {
421
+ const body = await parseJson<{ token?: string }>(req);
422
+ const result = await beginSetupRegistration(req, store, config, body.token ?? "");
423
+ return addHeaders(jsonResponse(result.options), result.headers);
424
+ }
425
+ if (passkeysEnabled && path === "/api/user-setup/verify" && req.method === "POST") {
426
+ const body = await parseJson<{ token?: string; response?: unknown }>(req);
427
+ const headers = await completeSetupRegistration(req, store, config, body.token ?? "", body.response as never);
213
428
  return addHeaders(successResponse(), headers);
214
429
  }
215
430
  if (passkeysEnabled && path === "/api/passkey-auth/authentication/options" && req.method === "POST") {
@@ -228,19 +443,19 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
228
443
  if (auth instanceof Response) return auth;
229
444
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
230
445
  if (originFailure) return originFailure;
231
- return addHeaders(successResponse(), deletePasskey(req, store, config));
446
+ return addHeaders(successResponse(), deletePasskey(req, store, config, requireUser(auth).id));
232
447
  }
233
448
  if (apiKeysEnabled && path === "/api/api-keys" && req.method === "GET") {
234
449
  const auth = await authorize(req, true);
235
450
  if (auth instanceof Response) return auth;
236
- return jsonResponse(listApiKeys(store));
451
+ return jsonResponse(listApiKeys(store, requireUser(auth).id));
237
452
  }
238
453
  if (apiKeysEnabled && path === "/api/api-keys" && req.method === "POST") {
239
454
  const auth = await authorize(req, true);
240
455
  if (auth instanceof Response) return auth;
241
456
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
242
457
  if (originFailure) return originFailure;
243
- return jsonResponse(createApiKey(store, await parseJson(req)));
458
+ return jsonResponse(createApiKey(store, requireUser(auth), await parseJson(req)));
244
459
  }
245
460
  const apiKeyDelete = /^\/api\/api-keys\/([^/]+)$/.exec(path);
246
461
  if (apiKeysEnabled && apiKeyDelete && req.method === "DELETE") {
@@ -248,7 +463,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
248
463
  if (auth instanceof Response) return auth;
249
464
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
250
465
  if (originFailure) return originFailure;
251
- deleteApiKey(store, decodeURIComponent(apiKeyDelete[1]!));
466
+ deleteApiKey(store, requireUser(auth).id, decodeURIComponent(apiKeyDelete[1]!));
252
467
  return successResponse();
253
468
  }
254
469
  if (deviceAuthEnabled && path === "/api/auth/device" && req.method === "POST") {
@@ -268,7 +483,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
268
483
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
269
484
  if (originFailure) return originFailure;
270
485
  const body = await parseJson<{ userCode?: string; user_code?: string }>(req);
271
- return jsonResponse(approveDevice(store, body.userCode ?? body.user_code ?? ""));
486
+ return jsonResponse(approveDevice(store, body.userCode ?? body.user_code ?? "", requireUser(auth).id));
272
487
  }
273
488
  if (deviceAuthEnabled && path === "/api/auth/device/deny" && req.method === "POST") {
274
489
  const auth = await authorize(req, true);
@@ -297,7 +512,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
297
512
  if (deviceAuthEnabled && path === "/api/auth/sessions" && req.method === "GET") {
298
513
  const auth = await authorize(req, true);
299
514
  if (auth instanceof Response) return auth;
300
- return jsonResponse(listAuthSessions(store));
515
+ return jsonResponse(listAuthSessions(store, requireUser(auth).id));
301
516
  }
302
517
  const sessionDelete = /^\/api\/auth\/sessions\/([^/]+)$/.exec(path);
303
518
  if (deviceAuthEnabled && sessionDelete && req.method === "DELETE") {
@@ -305,7 +520,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
305
520
  if (auth instanceof Response) return auth;
306
521
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
307
522
  if (originFailure) return originFailure;
308
- return revokeAuthSession(store, decodeURIComponent(sessionDelete[1]!)) ? successResponse() : notFound();
523
+ return revokeAuthSession(store, requireUser(auth).id, decodeURIComponent(sessionDelete[1]!)) ? successResponse() : notFound();
309
524
  }
310
525
  if (deviceAuthEnabled && path === "/.well-known/jwks.json" && req.method === "GET") {
311
526
  return jsonResponse(await jwks(store));
@@ -313,23 +528,107 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
313
528
  if (deviceAuthEnabled && path === "/.well-known/openid-configuration" && req.method === "GET") {
314
529
  return jsonResponse(discovery(req, config));
315
530
  }
531
+ if (passkeysEnabled && path === "/api/users" && req.method === "GET") {
532
+ const auth = await authorize(req, true);
533
+ if (auth instanceof Response) return auth;
534
+ ensureAdmin(auth);
535
+ return jsonResponse(store.listUsers().map(summarizeUser));
536
+ }
537
+ if (passkeysEnabled && path === "/api/users" && req.method === "POST") {
538
+ const auth = await authorize(req, true);
539
+ if (auth instanceof Response) return auth;
540
+ const actor = ensureAdmin(auth);
541
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
542
+ if (originFailure) return originFailure;
543
+ const body = await parseJson<{ username?: string; role?: WebAppUserRole }>(req);
544
+ const username = assertValidUsername(body.username ?? "");
545
+ if (store.getUserByUsername(username)) {
546
+ return errorResponse(409, "username_exists", "Username already exists");
547
+ }
548
+ const user = createUserRecord({ username, role: sanitizeRole(body.role) });
549
+ store.createUser(user);
550
+ const setupLink = createSetupLink(req, user.id, "invite", actor.id);
551
+ audit(store, { eventType: "user_created", actorUserId: actor.id, targetUserId: user.id, metadata: { role: user.role } });
552
+ return jsonResponse({ user: summarizeUser(store.getUserById(user.id) ?? user), setupLink }, { status: 201 });
553
+ }
554
+ const userRolePatch = /^\/api\/users\/([^/]+)\/role$/.exec(path);
555
+ if (passkeysEnabled && userRolePatch && req.method === "PATCH") {
556
+ const auth = await authorize(req, true);
557
+ if (auth instanceof Response) return auth;
558
+ const actor = ensureAdmin(auth);
559
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
560
+ if (originFailure) return originFailure;
561
+ const userId = decodeURIComponent(userRolePatch[1]!);
562
+ const target = store.getUserById(userId);
563
+ if (!target) return notFound();
564
+ if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner role cannot be changed");
565
+ const body = await parseJson<{ role?: WebAppUserRole }>(req);
566
+ const role = sanitizeRole(body.role);
567
+ store.setUserRole(userId, role, nowIso());
568
+ audit(store, { eventType: "user_role_changed", actorUserId: actor.id, targetUserId: userId, metadata: { role } });
569
+ return jsonResponse(summarizeUser(store.getUserById(userId) ?? target));
570
+ }
571
+ const userReset = /^\/api\/users\/([^/]+)\/reset$/.exec(path);
572
+ if (passkeysEnabled && userReset && req.method === "POST") {
573
+ const auth = await authorize(req, true);
574
+ if (auth instanceof Response) return auth;
575
+ const actor = ensureAdmin(auth);
576
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
577
+ if (originFailure) return originFailure;
578
+ const userId = decodeURIComponent(userReset[1]!);
579
+ const target = store.getUserById(userId);
580
+ if (!target) return notFound();
581
+ if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner cannot be reset");
582
+ const timestamp = nowIso();
583
+ store.deletePendingSetupLinksForUser(userId, timestamp);
584
+ store.deletePasskeysForUser(userId);
585
+ store.deleteApiKeysForUser(userId);
586
+ store.revokeRefreshSessionsForUser(userId, timestamp);
587
+ store.incrementUserAuthVersion(userId, timestamp);
588
+ const setupLink = createSetupLink(req, userId, "reset", actor.id);
589
+ audit(store, { eventType: "user_reset", actorUserId: actor.id, targetUserId: userId });
590
+ return jsonResponse({ user: summarizeUser(store.getUserById(userId) ?? target), setupLink });
591
+ }
592
+ const userDelete = /^\/api\/users\/([^/]+)$/.exec(path);
593
+ if (passkeysEnabled && userDelete && req.method === "DELETE") {
594
+ const auth = await authorize(req, true);
595
+ if (auth instanceof Response) return auth;
596
+ const actor = ensureAdmin(auth);
597
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
598
+ if (originFailure) return originFailure;
599
+ const userId = decodeURIComponent(userDelete[1]!);
600
+ const target = store.getUserById(userId);
601
+ if (!target) return notFound();
602
+ if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner cannot be deleted");
603
+ if (!store.deleteUser(userId)) return notFound();
604
+ audit(store, { eventType: "user_deleted", actorUserId: actor.id, metadata: { deletedUserId: userId, username: target.username } });
605
+ return successResponse();
606
+ }
607
+ if (passkeysEnabled && path === "/api/audit-events" && req.method === "GET") {
608
+ const auth = await authorize(req, true);
609
+ if (auth instanceof Response) return auth;
610
+ ensureAdmin(auth);
611
+ return jsonResponse(store.listAuditEvents(100));
612
+ }
316
613
  if (path === "/api/preferences/theme") {
317
614
  const auth = await authorize(req, true);
318
615
  if (auth instanceof Response) return auth;
616
+ const user = requireUser(auth);
319
617
  if (req.method === "GET") {
320
- return jsonResponse({ theme: store.getThemePreference() ?? "system" });
618
+ return jsonResponse({ theme: store.getThemePreference(user.id) ?? "system" });
321
619
  }
322
620
  if (req.method === "PUT") {
323
621
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
324
622
  if (originFailure) return originFailure;
325
623
  const body = await parseJson<{ theme: ThemePreference }>(req);
326
- store.setThemePreference(body.theme);
624
+ store.setThemePreference(body.theme, user.id);
327
625
  return successResponse({ theme: body.theme });
328
626
  }
329
627
  }
330
628
  if (path === "/api/preferences/log-level") {
331
629
  const auth = await authorize(req, true);
332
630
  if (auth instanceof Response) return auth;
631
+ ensureAdmin(auth);
333
632
  if (req.method === "GET") {
334
633
  return jsonResponse({ level: store.getLogLevelPreference() ?? config.logLevel, fromEnv: config.logLevelFromEnv });
335
634
  }
@@ -346,6 +645,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
346
645
  if (path === "/api/server/kill" && req.method === "POST") {
347
646
  const auth = await authorize(req, true);
348
647
  if (auth instanceof Response) return auth;
648
+ ensureAdmin(auth);
349
649
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
350
650
  if (originFailure) return originFailure;
351
651
  setTimeout(() => process.exit(0), 100);
@@ -360,65 +660,162 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
360
660
  return undefined;
361
661
  }
362
662
 
363
- async function handleRequest(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined> {
663
+ async function handlePublicRoute(req: Request): Promise<Response | undefined> {
364
664
  const url = new URL(req.url);
665
+ const route = publicRoutes[url.pathname];
666
+ if (!route) {
667
+ return undefined;
668
+ }
669
+ const methodName = req.method === "HEAD" ? "HEAD" : req.method === "GET" ? "GET" : undefined;
670
+ if (!methodName) {
671
+ return withSecurityHeaders(methodNotAllowed());
672
+ }
673
+ const definition = typeof route === "object" && route !== null && !(route instanceof Response) && !(route instanceof Blob) && !(route instanceof ArrayBuffer) && !(route instanceof Uint8Array) && ("GET" in route || "HEAD" in route || "headers" in route)
674
+ ? route
675
+ : undefined;
676
+ const value = definition ? definition[methodName] ?? (methodName === "HEAD" ? definition.GET : undefined) : route as PublicRouteValue;
677
+ if (!value) {
678
+ return withSecurityHeaders(methodNotAllowed());
679
+ }
680
+ const asset = typeof value === "function" ? await value(req) : value;
681
+ if (!asset) {
682
+ return withSecurityHeaders(notFound());
683
+ }
684
+ const response = publicAssetResponse(asset, definition?.headers);
685
+ if (req.method === "HEAD") {
686
+ return new Response(null, { status: response.status, statusText: response.statusText, headers: response.headers });
687
+ }
688
+ return response;
689
+ }
690
+
691
+ async function handleMatchedRoute(req: Request, matched: NonNullable<ReturnType<typeof matchRoute<TEvent>>>, server?: Server<WebAppWebSocketData>): Promise<Response | undefined> {
692
+ const handler = matched.route[method(req) ?? "GET"];
693
+ if (!handler) {
694
+ return withSecurityHeaders(methodNotAllowed());
695
+ }
696
+ const routeAuth = matched.route.auth ?? "required";
697
+ const required = requiresAuth(routeAuth);
698
+ const auth = await authorize(req, required);
699
+ if (auth instanceof Response) {
700
+ return withSecurityHeaders(auth);
701
+ }
702
+ try {
703
+ enforceRouteAuth(routeAuth, auth);
704
+ if (matched.route.userParam) {
705
+ const paramValue = matched.params[matched.route.userParam];
706
+ if (!paramValue) {
707
+ throw new AuthError("route_misconfigured", `Route userParam "${matched.route.userParam}" is missing from matched params`, 500);
708
+ }
709
+ assertUser(auth, paramValue);
710
+ }
711
+ if (routeAuth !== "public" && (auth.kind === "api-key" || auth.kind === "bearer")) {
712
+ assertScopes(auth.kind === "api-key" ? auth.scopes : scopesFromBearer(auth.claims), matched.route.scopes ?? []);
713
+ }
714
+ } catch (error) {
715
+ return withSecurityHeaders(authErrorResponse(error));
716
+ }
717
+ const current = () => requireUser(auth);
718
+ const userRealtime = {
719
+ publishChanged: (resource, options = {}) => realtime.publishChanged(resource, { ...options, target: { ...options.target, userId: current().id } }),
720
+ publishEntityChanged: (resource, id, options = {}) => realtime.publishEntityChanged(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
721
+ publishDeleted: (resource, id, options = {}) => realtime.publishDeleted(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
722
+ publishSettingsChanged: (options = {}) => realtime.publishSettingsChanged({ ...options, target: { ...options.target, userId: current().id } }),
723
+ } satisfies UserScopedRealtimePublisher<TEvent>;
724
+ const originFailure = checkSameOrigin(req, config, auth, matched.route.sameOrigin ?? "mutations");
725
+ if (originFailure) {
726
+ return withSecurityHeaders(originFailure);
727
+ }
728
+ try {
729
+ const response = await handler(req, {
730
+ params: matched.params,
731
+ auth,
732
+ user: currentUser(auth),
733
+ requireUser: () => requireUser(auth),
734
+ requireAdmin: () => requireAdmin(auth),
735
+ requireOwner: () => requireOwner(auth),
736
+ assertUser: (userId) => assertUser(auth, userId),
737
+ filterOwned: createFilterOwned(auth),
738
+ requireOwned: createRequireOwned(auth),
739
+ realtime,
740
+ userRealtime,
741
+ server,
742
+ });
743
+ return response ? withSecurityHeaders(response) : undefined;
744
+ } catch (error) {
745
+ return withSecurityHeaders(routeHandlerErrorResponse(error));
746
+ }
747
+ }
748
+
749
+ async function handleRequest(req: Request, server?: Server<WebAppWebSocketData>): Promise<Response | undefined> {
750
+ const url = new URL(req.url);
751
+ const publicRoute = await handlePublicRoute(req);
752
+ if (publicRoute) {
753
+ return publicRoute;
754
+ }
755
+ const matched = matchRoute(routes, url.pathname);
365
756
  if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/.well-known/") || url.pathname === "/device") {
366
757
  const builtIn = await handleBuiltIn(req, server);
367
758
  if (builtIn) {
368
- return withSecurityHeaders(builtIn);
759
+ return secureDynamicResponse(builtIn);
369
760
  }
370
- const matched = matchRoute(routes, url.pathname);
371
761
  if (!matched) {
372
762
  return withSecurityHeaders(notFound());
373
763
  }
374
- const handler = matched.route[method(req) ?? "GET"];
375
- if (!handler) {
376
- return withSecurityHeaders(methodNotAllowed());
377
- }
378
- const required = (matched.route.auth ?? "required") === "required";
379
- const auth = await authorize(req, required);
380
- if (auth instanceof Response) {
381
- return withSecurityHeaders(auth);
382
- }
383
- try {
384
- if ((matched.route.auth ?? "required") !== "public" && (auth.kind === "api-key" || auth.kind === "bearer")) {
385
- assertScopes(auth.kind === "api-key" ? auth.scopes : scopesFromBearer(auth.claims), matched.route.scopes ?? []);
386
- }
387
- } catch (error) {
388
- return withSecurityHeaders(authErrorResponse(error));
389
- }
390
- const originFailure = checkSameOrigin(req, config, auth, matched.route.sameOrigin ?? "mutations");
391
- if (originFailure) {
392
- return withSecurityHeaders(originFailure);
393
- }
394
- return withSecurityHeaders(await handler(req, { params: matched.params, auth, realtime, server }));
764
+ return handleMatchedRoute(req, matched, server);
765
+ }
766
+ if (matched) {
767
+ return handleMatchedRoute(req, matched, server);
395
768
  }
396
769
  return htmlResponse(input.index);
397
770
  }
398
771
 
399
- function start(): Server<WebSocketData> {
400
- const dynamicHandler = (req: Request, server: Server<WebSocketData>) => handleRequest(req, server);
401
- const server = Bun.serve<WebSocketData>({
772
+ function customHandler(socket: ServerWebSocket<WebAppWebSocketData>): Partial<WebSocketHandler<WebAppWebSocketData>> | undefined {
773
+ const handlerName = socket.data.webappSocketHandler;
774
+ return handlerName ? appWebsockets[handlerName] : undefined;
775
+ }
776
+
777
+ function start(): Server<WebAppWebSocketData> {
778
+ const dynamicHandler = (req: Request, server: Server<WebAppWebSocketData>) => handleRequest(req, server);
779
+ const server = Bun.serve<WebAppWebSocketData>({
402
780
  hostname: config.host,
403
781
  port: config.port,
404
782
  routes: {
405
783
  "/api/*": dynamicHandler,
406
784
  "/.well-known/*": dynamicHandler,
407
- "/device": dynamicHandler,
785
+ "/device": deviceAuthEnabled && !canRespondWithIndex(input.index) ? input.index as never : dynamicHandler,
786
+ "/setup": passkeysEnabled && !canRespondWithIndex(input.index) ? input.index as never : dynamicHandler,
408
787
  "/*": canRespondWithIndex(input.index) ? dynamicHandler : input.index as never,
409
788
  },
410
789
  websocket: {
411
790
  open(socket) {
791
+ const handler = customHandler(socket);
792
+ if (handler?.open) {
793
+ handler.open(socket);
794
+ return;
795
+ }
412
796
  realtime.add(socket);
413
797
  },
414
798
  message(socket, message) {
799
+ const handler = customHandler(socket);
800
+ if (handler?.message) {
801
+ handler.message(socket, message);
802
+ return;
803
+ }
415
804
  if (message === "ping") {
416
805
  socket.send(JSON.stringify({ type: "pong" }));
417
806
  }
418
807
  },
419
- close(socket) {
808
+ close(socket, code, reason) {
809
+ const handler = customHandler(socket);
810
+ if (handler?.close) {
811
+ handler.close(socket, code, reason);
812
+ return;
813
+ }
420
814
  realtime.remove(socket);
421
815
  },
816
+ drain(socket) {
817
+ customHandler(socket)?.drain?.(socket);
818
+ },
422
819
  },
423
820
  development: config.development,
424
821
  });