@pablozaiden/webapp 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import type { Server } from "bun";
2
- import type { ApiKeySummary, LogLevelName, ThemePreference, WebAppConfigResponse } from "../contracts";
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
 
@@ -89,9 +96,14 @@ function authErrorResponse(error: unknown): Response {
89
96
  if (error instanceof AuthError) {
90
97
  return errorResponse(error.status, error.code, error.message);
91
98
  }
92
- if (error instanceof Error) {
93
- return errorResponse(400, "request_failed", error.message);
99
+ return errorResponse(500, "request_failed", "Request failed");
100
+ }
101
+
102
+ function routeHandlerErrorResponse(error: unknown): Response {
103
+ if (error instanceof AuthError) {
104
+ return errorResponse(error.status, error.code, error.message);
94
105
  }
106
+ log.error("Unhandled route handler error", { error: error instanceof Error ? error.message : String(error) });
95
107
  return errorResponse(500, "request_failed", "Request failed");
96
108
  }
97
109
 
@@ -115,6 +127,10 @@ function htmlResponse(index: unknown): Response {
115
127
  return index as Response;
116
128
  }
117
129
 
130
+ function secureDynamicResponse(response: Response): Response {
131
+ return response instanceof Response ? withSecurityHeaders(response) : response;
132
+ }
133
+
118
134
  function canRespondWithIndex(index: unknown): boolean {
119
135
  return index instanceof Response || typeof index === "string" || index instanceof Blob;
120
136
  }
@@ -123,6 +139,102 @@ function scopesFromBearer(claims: { scope: string }): string[] {
123
139
  return claims.scope.split(/\s+/).filter(Boolean);
124
140
  }
125
141
 
142
+ function currentUser(auth: AuthenticatedRequestState): CurrentUser | undefined {
143
+ return auth.kind === "anonymous" ? undefined : auth.user;
144
+ }
145
+
146
+ function requireUser(auth: AuthenticatedRequestState): CurrentUser {
147
+ const user = currentUser(auth);
148
+ if (!user) {
149
+ throw new AuthError("authentication_required", "Authentication is required", 401);
150
+ }
151
+ return user;
152
+ }
153
+
154
+ function requireAdmin(auth: AuthenticatedRequestState): CurrentUser {
155
+ const user = requireUser(auth);
156
+ if (!user.isAdmin) {
157
+ throw new AuthError("admin_required", "Admin permissions are required", 403);
158
+ }
159
+ return user;
160
+ }
161
+
162
+ function requireOwner(auth: AuthenticatedRequestState): CurrentUser {
163
+ const user = requireUser(auth);
164
+ if (!user.isOwner) {
165
+ throw new AuthError("owner_required", "Owner permissions are required", 403);
166
+ }
167
+ return user;
168
+ }
169
+
170
+ function assertUser(auth: AuthenticatedRequestState, userId: string): CurrentUser {
171
+ const user = requireUser(auth);
172
+ if (user.id !== userId) {
173
+ throw new AuthError("forbidden", "You cannot access another user's resource", 403);
174
+ }
175
+ return user;
176
+ }
177
+
178
+ function ownedUserId<TResource>(resource: TResource, getUserId?: UserIdSelector<TResource>): string | undefined {
179
+ if (getUserId) {
180
+ return getUserId(resource);
181
+ }
182
+ if (typeof resource === "object" && resource !== null && "userId" in resource && typeof resource.userId === "string") {
183
+ return resource.userId;
184
+ }
185
+ throw new AuthError("route_misconfigured", "Owned resource helpers require a string userId field or a getUserId selector", 500);
186
+ }
187
+
188
+ function requireOwned<TResource extends UserOwnedResource>(auth: AuthenticatedRequestState, resource: TResource | null | undefined): TResource;
189
+ function requireOwned<TResource>(auth: AuthenticatedRequestState, resource: TResource | null | undefined, getUserId: UserIdSelector<TResource>): TResource;
190
+ function requireOwned<TResource>(auth: AuthenticatedRequestState, resource: TResource | null | undefined, getUserId?: UserIdSelector<TResource>): TResource {
191
+ const user = requireUser(auth);
192
+ const resourceUserId = resource ? ownedUserId(resource, getUserId) : undefined;
193
+ if (!resource || resourceUserId !== user.id) {
194
+ throw new AuthError("not_found", "Resource not found", 404);
195
+ }
196
+ return resource;
197
+ }
198
+
199
+ function filterOwned<TResource extends UserOwnedResource>(auth: AuthenticatedRequestState, resources: readonly TResource[]): TResource[];
200
+ function filterOwned<TResource>(auth: AuthenticatedRequestState, resources: readonly TResource[], getUserId: UserIdSelector<TResource>): TResource[];
201
+ function filterOwned<TResource>(auth: AuthenticatedRequestState, resources: readonly TResource[], getUserId?: UserIdSelector<TResource>): TResource[] {
202
+ const user = requireUser(auth);
203
+ return resources.filter((resource) => ownedUserId(resource, getUserId) === user.id);
204
+ }
205
+
206
+ function createFilterOwned(auth: AuthenticatedRequestState) {
207
+ function contextFilterOwned<TResource extends UserOwnedResource>(resources: readonly TResource[]): TResource[];
208
+ function contextFilterOwned<TResource>(resources: readonly TResource[], getUserId: UserIdSelector<TResource>): TResource[];
209
+ function contextFilterOwned<TResource>(resources: readonly TResource[], getUserId?: UserIdSelector<TResource>): TResource[] {
210
+ return filterOwned(auth, resources, getUserId as UserIdSelector<TResource>);
211
+ }
212
+ return contextFilterOwned;
213
+ }
214
+
215
+ function createRequireOwned(auth: AuthenticatedRequestState) {
216
+ function contextRequireOwned<TResource extends UserOwnedResource>(resource: TResource | null | undefined): TResource;
217
+ function contextRequireOwned<TResource>(resource: TResource | null | undefined, getUserId: UserIdSelector<TResource>): TResource;
218
+ function contextRequireOwned<TResource>(resource: TResource | null | undefined, getUserId?: UserIdSelector<TResource>): TResource {
219
+ return requireOwned(auth, resource, getUserId as UserIdSelector<TResource>);
220
+ }
221
+ return contextRequireOwned;
222
+ }
223
+
224
+ function requiresAuth(routeAuth: RouteAuth): boolean {
225
+ return routeAuth !== "public" && routeAuth !== "optional";
226
+ }
227
+
228
+ function enforceRouteAuth(routeAuth: RouteAuth, auth: AuthenticatedRequestState): void {
229
+ if (routeAuth === "user") {
230
+ requireUser(auth);
231
+ } else if (routeAuth === "admin") {
232
+ requireAdmin(auth);
233
+ } else if (routeAuth === "owner") {
234
+ requireOwner(auth);
235
+ }
236
+ }
237
+
126
238
  export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<TEvent>): WebAppServer<TEvent> {
127
239
  const config = readRuntimeConfig({ appName: input.appName, envPrefix: input.envPrefix });
128
240
  const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
@@ -143,7 +255,16 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
143
255
  if (deviceAuthEnabled) {
144
256
  try {
145
257
  const claims = await verifyAccessToken(store, config, token);
146
- return { kind: "bearer", claims };
258
+ const user = store.getUserById(claims.sub);
259
+ if (user) {
260
+ return { kind: "bearer", claims, user: {
261
+ id: user.id,
262
+ username: user.username,
263
+ role: user.role,
264
+ isOwner: user.role === "owner",
265
+ isAdmin: user.role === "owner" || user.role === "admin",
266
+ } };
267
+ }
147
268
  } catch {
148
269
  // Fall through to API keys. Both use Bearer by design.
149
270
  }
@@ -156,22 +277,34 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
156
277
  }
157
278
  return errorResponse(401, "invalid_token", "Bearer token is invalid");
158
279
  }
280
+ if (passkeysEnabled) {
281
+ const user = getPasskeySessionUser(req, store, config);
282
+ if (user) {
283
+ return { kind: "passkey", user };
284
+ }
285
+ }
159
286
  if (passkeysEnabled && isPasskeyAuthRequired(store, config)) {
160
- if (!hasPasskeySession(req, store)) {
287
+ const user = getPasskeySessionUser(req, store, config);
288
+ if (!user) {
161
289
  return required ? errorResponse(401, "authentication_required", "Passkey authentication is required", undefined, {
162
290
  headers: { "x-webapp-passkey-required": "true" },
163
291
  }) : { kind: "anonymous" };
164
292
  }
165
- return { kind: "passkey" };
166
293
  }
167
294
  return { kind: "anonymous" };
168
295
  }
169
296
 
170
297
  function configResponse(req: Request): WebAppConfigResponse {
298
+ const user = passkeysEnabled ? getPasskeySessionUser(req, store, config) : undefined;
171
299
  return {
172
300
  appName: config.appName,
173
301
  version,
302
+ currentUser: user,
174
303
  passkeyAuth: passkeyStatus(req, store, config, passkeysEnabled),
304
+ userManagement: {
305
+ enabled: passkeysEnabled,
306
+ canManageUsers: Boolean(user?.isAdmin),
307
+ },
175
308
  logLevel: {
176
309
  level: (config.logLevelFromEnv ? config.logLevel : store.getLogLevelPreference() ?? config.logLevel) as LogLevelName,
177
310
  fromEnv: config.logLevelFromEnv,
@@ -181,6 +314,29 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
181
314
  };
182
315
  }
183
316
 
317
+ function setupUrl(req: Request, token: string): string {
318
+ const url = new URL(req.url);
319
+ url.pathname = "/setup";
320
+ url.search = `?token=${encodeURIComponent(token)}`;
321
+ url.hash = "";
322
+ return url.toString();
323
+ }
324
+
325
+ function createSetupLink(req: Request, userId: string, kind: "invite" | "reset" | "owner-reset", actorUserId?: string) {
326
+ const token = randomToken(32);
327
+ const record = createSetupLinkRecord({ userId, token, kind, createdByUserId: actorUserId });
328
+ store.createSetupLink(record);
329
+ return { url: setupUrl(req, token), expiresAt: record.expiresAt };
330
+ }
331
+
332
+ function ensureAdmin(auth: AuthenticatedRequestState): CurrentUser {
333
+ return requireAdmin(auth);
334
+ }
335
+
336
+ function sanitizeRole(role: WebAppUserRole | undefined): WebAppUserRole {
337
+ return role === "admin" ? "admin" : "user";
338
+ }
339
+
184
340
  async function handleBuiltIn(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined> {
185
341
  const url = new URL(req.url);
186
342
  const path = url.pathname;
@@ -198,18 +354,41 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
198
354
  if (originFailure) return originFailure;
199
355
  if (!server) return errorResponse(400, "websocket_unavailable", "WebSocket server is unavailable");
200
356
  const filters = Object.fromEntries(url.searchParams.entries());
201
- const upgraded = server.upgrade(req, { data: { filters } });
357
+ const upgraded = server.upgrade(req, { data: { filters, userId: currentUser(auth)?.id } });
202
358
  return upgraded ? undefined : errorResponse(400, "websocket_upgrade_failed", "WebSocket upgrade failed");
203
359
  }
204
360
  if (path === "/api/passkey-auth/status" && req.method === "GET") {
205
361
  return jsonResponse(passkeyStatus(req, store, config, passkeysEnabled));
206
362
  }
207
- if (passkeysEnabled && path === "/api/passkey-auth/registration/options" && req.method === "POST") {
208
- const result = await beginRegistration(req, store, config);
363
+ if (passkeysEnabled && path === "/api/passkey-auth/bootstrap/options" && req.method === "POST") {
364
+ const body = await parseJson<{ username?: string }>(req);
365
+ const result = await beginBootstrapRegistration(req, store, config, body.username ?? "");
366
+ return addHeaders(jsonResponse(result.options), result.headers);
367
+ }
368
+ if (passkeysEnabled && path === "/api/passkey-auth/bootstrap/verify" && req.method === "POST") {
369
+ const headers = await completeBootstrapRegistration(req, store, config, await parseJson(req));
370
+ return addHeaders(successResponse(), headers);
371
+ }
372
+ if (passkeysEnabled && path === "/api/passkey-auth/owner-setup/options" && req.method === "POST") {
373
+ const result = await beginOwnerPasskeySetup(req, store, config);
209
374
  return addHeaders(jsonResponse(result.options), result.headers);
210
375
  }
211
- if (passkeysEnabled && path === "/api/passkey-auth/registration/verify" && req.method === "POST") {
212
- const headers = await completeRegistration(req, store, config, await parseJson(req));
376
+ if (passkeysEnabled && path === "/api/passkey-auth/owner-setup/verify" && req.method === "POST") {
377
+ const headers = await completeOwnerPasskeySetup(req, store, config, await parseJson(req));
378
+ return addHeaders(successResponse(), headers);
379
+ }
380
+ if (passkeysEnabled && path === "/api/user-setup" && req.method === "GET") {
381
+ const token = url.searchParams.get("token") ?? "";
382
+ return jsonResponse(getSetupDetails(store, token));
383
+ }
384
+ if (passkeysEnabled && path === "/api/user-setup/options" && req.method === "POST") {
385
+ const body = await parseJson<{ token?: string }>(req);
386
+ const result = await beginSetupRegistration(req, store, config, body.token ?? "");
387
+ return addHeaders(jsonResponse(result.options), result.headers);
388
+ }
389
+ if (passkeysEnabled && path === "/api/user-setup/verify" && req.method === "POST") {
390
+ const body = await parseJson<{ token?: string; response?: unknown }>(req);
391
+ const headers = await completeSetupRegistration(req, store, config, body.token ?? "", body.response as never);
213
392
  return addHeaders(successResponse(), headers);
214
393
  }
215
394
  if (passkeysEnabled && path === "/api/passkey-auth/authentication/options" && req.method === "POST") {
@@ -228,19 +407,19 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
228
407
  if (auth instanceof Response) return auth;
229
408
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
230
409
  if (originFailure) return originFailure;
231
- return addHeaders(successResponse(), deletePasskey(req, store, config));
410
+ return addHeaders(successResponse(), deletePasskey(req, store, config, requireUser(auth).id));
232
411
  }
233
412
  if (apiKeysEnabled && path === "/api/api-keys" && req.method === "GET") {
234
413
  const auth = await authorize(req, true);
235
414
  if (auth instanceof Response) return auth;
236
- return jsonResponse(listApiKeys(store));
415
+ return jsonResponse(listApiKeys(store, requireUser(auth).id));
237
416
  }
238
417
  if (apiKeysEnabled && path === "/api/api-keys" && req.method === "POST") {
239
418
  const auth = await authorize(req, true);
240
419
  if (auth instanceof Response) return auth;
241
420
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
242
421
  if (originFailure) return originFailure;
243
- return jsonResponse(createApiKey(store, await parseJson(req)));
422
+ return jsonResponse(createApiKey(store, requireUser(auth), await parseJson(req)));
244
423
  }
245
424
  const apiKeyDelete = /^\/api\/api-keys\/([^/]+)$/.exec(path);
246
425
  if (apiKeysEnabled && apiKeyDelete && req.method === "DELETE") {
@@ -248,7 +427,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
248
427
  if (auth instanceof Response) return auth;
249
428
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
250
429
  if (originFailure) return originFailure;
251
- deleteApiKey(store, decodeURIComponent(apiKeyDelete[1]!));
430
+ deleteApiKey(store, requireUser(auth).id, decodeURIComponent(apiKeyDelete[1]!));
252
431
  return successResponse();
253
432
  }
254
433
  if (deviceAuthEnabled && path === "/api/auth/device" && req.method === "POST") {
@@ -268,7 +447,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
268
447
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
269
448
  if (originFailure) return originFailure;
270
449
  const body = await parseJson<{ userCode?: string; user_code?: string }>(req);
271
- return jsonResponse(approveDevice(store, body.userCode ?? body.user_code ?? ""));
450
+ return jsonResponse(approveDevice(store, body.userCode ?? body.user_code ?? "", requireUser(auth).id));
272
451
  }
273
452
  if (deviceAuthEnabled && path === "/api/auth/device/deny" && req.method === "POST") {
274
453
  const auth = await authorize(req, true);
@@ -297,7 +476,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
297
476
  if (deviceAuthEnabled && path === "/api/auth/sessions" && req.method === "GET") {
298
477
  const auth = await authorize(req, true);
299
478
  if (auth instanceof Response) return auth;
300
- return jsonResponse(listAuthSessions(store));
479
+ return jsonResponse(listAuthSessions(store, requireUser(auth).id));
301
480
  }
302
481
  const sessionDelete = /^\/api\/auth\/sessions\/([^/]+)$/.exec(path);
303
482
  if (deviceAuthEnabled && sessionDelete && req.method === "DELETE") {
@@ -305,7 +484,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
305
484
  if (auth instanceof Response) return auth;
306
485
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
307
486
  if (originFailure) return originFailure;
308
- return revokeAuthSession(store, decodeURIComponent(sessionDelete[1]!)) ? successResponse() : notFound();
487
+ return revokeAuthSession(store, requireUser(auth).id, decodeURIComponent(sessionDelete[1]!)) ? successResponse() : notFound();
309
488
  }
310
489
  if (deviceAuthEnabled && path === "/.well-known/jwks.json" && req.method === "GET") {
311
490
  return jsonResponse(await jwks(store));
@@ -313,23 +492,107 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
313
492
  if (deviceAuthEnabled && path === "/.well-known/openid-configuration" && req.method === "GET") {
314
493
  return jsonResponse(discovery(req, config));
315
494
  }
495
+ if (passkeysEnabled && path === "/api/users" && req.method === "GET") {
496
+ const auth = await authorize(req, true);
497
+ if (auth instanceof Response) return auth;
498
+ ensureAdmin(auth);
499
+ return jsonResponse(store.listUsers().map(summarizeUser));
500
+ }
501
+ if (passkeysEnabled && path === "/api/users" && req.method === "POST") {
502
+ const auth = await authorize(req, true);
503
+ if (auth instanceof Response) return auth;
504
+ const actor = ensureAdmin(auth);
505
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
506
+ if (originFailure) return originFailure;
507
+ const body = await parseJson<{ username?: string; role?: WebAppUserRole }>(req);
508
+ const username = assertValidUsername(body.username ?? "");
509
+ if (store.getUserByUsername(username)) {
510
+ return errorResponse(409, "username_exists", "Username already exists");
511
+ }
512
+ const user = createUserRecord({ username, role: sanitizeRole(body.role) });
513
+ store.createUser(user);
514
+ const setupLink = createSetupLink(req, user.id, "invite", actor.id);
515
+ audit(store, { eventType: "user_created", actorUserId: actor.id, targetUserId: user.id, metadata: { role: user.role } });
516
+ return jsonResponse({ user: summarizeUser(store.getUserById(user.id) ?? user), setupLink }, { status: 201 });
517
+ }
518
+ const userRolePatch = /^\/api\/users\/([^/]+)\/role$/.exec(path);
519
+ if (passkeysEnabled && userRolePatch && req.method === "PATCH") {
520
+ const auth = await authorize(req, true);
521
+ if (auth instanceof Response) return auth;
522
+ const actor = ensureAdmin(auth);
523
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
524
+ if (originFailure) return originFailure;
525
+ const userId = decodeURIComponent(userRolePatch[1]!);
526
+ const target = store.getUserById(userId);
527
+ if (!target) return notFound();
528
+ if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner role cannot be changed");
529
+ const body = await parseJson<{ role?: WebAppUserRole }>(req);
530
+ const role = sanitizeRole(body.role);
531
+ store.setUserRole(userId, role, nowIso());
532
+ audit(store, { eventType: "user_role_changed", actorUserId: actor.id, targetUserId: userId, metadata: { role } });
533
+ return jsonResponse(summarizeUser(store.getUserById(userId) ?? target));
534
+ }
535
+ const userReset = /^\/api\/users\/([^/]+)\/reset$/.exec(path);
536
+ if (passkeysEnabled && userReset && req.method === "POST") {
537
+ const auth = await authorize(req, true);
538
+ if (auth instanceof Response) return auth;
539
+ const actor = ensureAdmin(auth);
540
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
541
+ if (originFailure) return originFailure;
542
+ const userId = decodeURIComponent(userReset[1]!);
543
+ const target = store.getUserById(userId);
544
+ if (!target) return notFound();
545
+ if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner cannot be reset");
546
+ const timestamp = nowIso();
547
+ store.deletePendingSetupLinksForUser(userId, timestamp);
548
+ store.deletePasskeysForUser(userId);
549
+ store.deleteApiKeysForUser(userId);
550
+ store.revokeRefreshSessionsForUser(userId, timestamp);
551
+ store.incrementUserAuthVersion(userId, timestamp);
552
+ const setupLink = createSetupLink(req, userId, "reset", actor.id);
553
+ audit(store, { eventType: "user_reset", actorUserId: actor.id, targetUserId: userId });
554
+ return jsonResponse({ user: summarizeUser(store.getUserById(userId) ?? target), setupLink });
555
+ }
556
+ const userDelete = /^\/api\/users\/([^/]+)$/.exec(path);
557
+ if (passkeysEnabled && userDelete && req.method === "DELETE") {
558
+ const auth = await authorize(req, true);
559
+ if (auth instanceof Response) return auth;
560
+ const actor = ensureAdmin(auth);
561
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
562
+ if (originFailure) return originFailure;
563
+ const userId = decodeURIComponent(userDelete[1]!);
564
+ const target = store.getUserById(userId);
565
+ if (!target) return notFound();
566
+ if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner cannot be deleted");
567
+ if (!store.deleteUser(userId)) return notFound();
568
+ audit(store, { eventType: "user_deleted", actorUserId: actor.id, metadata: { deletedUserId: userId, username: target.username } });
569
+ return successResponse();
570
+ }
571
+ if (passkeysEnabled && path === "/api/audit-events" && req.method === "GET") {
572
+ const auth = await authorize(req, true);
573
+ if (auth instanceof Response) return auth;
574
+ ensureAdmin(auth);
575
+ return jsonResponse(store.listAuditEvents(100));
576
+ }
316
577
  if (path === "/api/preferences/theme") {
317
578
  const auth = await authorize(req, true);
318
579
  if (auth instanceof Response) return auth;
580
+ const user = requireUser(auth);
319
581
  if (req.method === "GET") {
320
- return jsonResponse({ theme: store.getThemePreference() ?? "system" });
582
+ return jsonResponse({ theme: store.getThemePreference(user.id) ?? "system" });
321
583
  }
322
584
  if (req.method === "PUT") {
323
585
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
324
586
  if (originFailure) return originFailure;
325
587
  const body = await parseJson<{ theme: ThemePreference }>(req);
326
- store.setThemePreference(body.theme);
588
+ store.setThemePreference(body.theme, user.id);
327
589
  return successResponse({ theme: body.theme });
328
590
  }
329
591
  }
330
592
  if (path === "/api/preferences/log-level") {
331
593
  const auth = await authorize(req, true);
332
594
  if (auth instanceof Response) return auth;
595
+ ensureAdmin(auth);
333
596
  if (req.method === "GET") {
334
597
  return jsonResponse({ level: store.getLogLevelPreference() ?? config.logLevel, fromEnv: config.logLevelFromEnv });
335
598
  }
@@ -346,6 +609,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
346
609
  if (path === "/api/server/kill" && req.method === "POST") {
347
610
  const auth = await authorize(req, true);
348
611
  if (auth instanceof Response) return auth;
612
+ ensureAdmin(auth);
349
613
  const originFailure = checkSameOrigin(req, config, auth, "mutations");
350
614
  if (originFailure) return originFailure;
351
615
  setTimeout(() => process.exit(0), 100);
@@ -365,7 +629,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
365
629
  if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/.well-known/") || url.pathname === "/device") {
366
630
  const builtIn = await handleBuiltIn(req, server);
367
631
  if (builtIn) {
368
- return withSecurityHeaders(builtIn);
632
+ return secureDynamicResponse(builtIn);
369
633
  }
370
634
  const matched = matchRoute(routes, url.pathname);
371
635
  if (!matched) {
@@ -375,23 +639,56 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
375
639
  if (!handler) {
376
640
  return withSecurityHeaders(methodNotAllowed());
377
641
  }
378
- const required = (matched.route.auth ?? "required") === "required";
642
+ const routeAuth = matched.route.auth ?? "required";
643
+ const required = requiresAuth(routeAuth);
379
644
  const auth = await authorize(req, required);
380
645
  if (auth instanceof Response) {
381
646
  return withSecurityHeaders(auth);
382
647
  }
383
648
  try {
384
- if ((matched.route.auth ?? "required") !== "public" && (auth.kind === "api-key" || auth.kind === "bearer")) {
649
+ enforceRouteAuth(routeAuth, auth);
650
+ if (matched.route.userParam) {
651
+ const paramValue = matched.params[matched.route.userParam];
652
+ if (!paramValue) {
653
+ throw new AuthError("route_misconfigured", `Route userParam "${matched.route.userParam}" is missing from matched params`, 500);
654
+ }
655
+ assertUser(auth, paramValue);
656
+ }
657
+ if (routeAuth !== "public" && (auth.kind === "api-key" || auth.kind === "bearer")) {
385
658
  assertScopes(auth.kind === "api-key" ? auth.scopes : scopesFromBearer(auth.claims), matched.route.scopes ?? []);
386
659
  }
387
660
  } catch (error) {
388
661
  return withSecurityHeaders(authErrorResponse(error));
389
662
  }
663
+ const current = () => requireUser(auth);
664
+ const userRealtime = {
665
+ publishChanged: (resource, options = {}) => realtime.publishChanged(resource, { ...options, target: { ...options.target, userId: current().id } }),
666
+ publishEntityChanged: (resource, id, options = {}) => realtime.publishEntityChanged(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
667
+ publishDeleted: (resource, id, options = {}) => realtime.publishDeleted(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
668
+ publishSettingsChanged: (options = {}) => realtime.publishSettingsChanged({ ...options, target: { ...options.target, userId: current().id } }),
669
+ } satisfies UserScopedRealtimePublisher<TEvent>;
390
670
  const originFailure = checkSameOrigin(req, config, auth, matched.route.sameOrigin ?? "mutations");
391
671
  if (originFailure) {
392
672
  return withSecurityHeaders(originFailure);
393
673
  }
394
- return withSecurityHeaders(await handler(req, { params: matched.params, auth, realtime, server }));
674
+ try {
675
+ return withSecurityHeaders(await handler(req, {
676
+ params: matched.params,
677
+ auth,
678
+ user: currentUser(auth),
679
+ requireUser: () => requireUser(auth),
680
+ requireAdmin: () => requireAdmin(auth),
681
+ requireOwner: () => requireOwner(auth),
682
+ assertUser: (userId) => assertUser(auth, userId),
683
+ filterOwned: createFilterOwned(auth),
684
+ requireOwned: createRequireOwned(auth),
685
+ realtime,
686
+ userRealtime,
687
+ server,
688
+ }));
689
+ } catch (error) {
690
+ return withSecurityHeaders(routeHandlerErrorResponse(error));
691
+ }
395
692
  }
396
693
  return htmlResponse(input.index);
397
694
  }
@@ -404,7 +701,8 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
404
701
  routes: {
405
702
  "/api/*": dynamicHandler,
406
703
  "/.well-known/*": dynamicHandler,
407
- "/device": dynamicHandler,
704
+ "/device": deviceAuthEnabled && !canRespondWithIndex(input.index) ? input.index as never : dynamicHandler,
705
+ "/setup": passkeysEnabled && !canRespondWithIndex(input.index) ? input.index as never : dynamicHandler,
408
706
  "/*": canRespondWithIndex(input.index) ? dynamicHandler : input.index as never,
409
707
  },
410
708
  websocket: {
@@ -2,6 +2,7 @@ import type { ServerWebSocket } from "bun";
2
2
 
3
3
  export interface WebSocketData {
4
4
  filters?: Record<string, string>;
5
+ userId?: string;
5
6
  }
6
7
 
7
8
  export type RealtimeAction = "created" | "updated" | "changed" | "deleted";
@@ -19,6 +20,7 @@ export type RealtimeTarget = {
19
20
  resource?: string;
20
21
  id?: string;
21
22
  scope?: string;
23
+ userId?: string;
22
24
  } & Record<string, string | undefined>;
23
25
 
24
26
  export interface RealtimePublishOptions {
@@ -31,8 +33,12 @@ export type RealtimeMessage<TEvent> =
31
33
  | { type: "ping" }
32
34
  | { type: "pong" };
33
35
 
34
- function targetMatches(filters: Record<string, string> | undefined, target: RealtimeTarget | undefined): boolean {
36
+ function targetMatches(socket: ServerWebSocket<WebSocketData>, target: RealtimeTarget | undefined): boolean {
35
37
  if (!target) return true;
38
+ if (target.userId !== undefined && socket.data.userId !== target.userId) {
39
+ return false;
40
+ }
41
+ const filters = socket.data.filters;
36
42
  if (!filters) return true;
37
43
  for (const [key, value] of Object.entries(filters)) {
38
44
  if (value !== undefined && target[key] !== value) {
@@ -57,7 +63,7 @@ export class RealtimeBus<TEvent = unknown> {
57
63
  const publishOptions = typeof options === "function" ? { filter: options } : options;
58
64
  const payload = JSON.stringify({ type: "event", event } satisfies RealtimeMessage<TEvent>);
59
65
  for (const socket of this.sockets) {
60
- if (targetMatches(socket.data.filters, publishOptions?.target) && (!publishOptions?.filter || publishOptions.filter(socket))) {
66
+ if (targetMatches(socket, publishOptions?.target) && (!publishOptions?.filter || publishOptions.filter(socket))) {
61
67
  socket.send(payload);
62
68
  }
63
69
  }
@@ -1,15 +1,35 @@
1
1
  import type { Server } from "bun";
2
+ import type { CurrentUser } from "../contracts";
2
3
  import type { AuthenticatedRequestState } from "./auth/types";
3
- import type { RealtimeBus } from "./realtime/bus";
4
+ import type { RealtimeBus, ResourceRealtimeEvent, RealtimeTarget } from "./realtime/bus";
4
5
 
5
- export type RouteAuth = "required" | "public" | "optional";
6
+ export type RouteAuth = "required" | "user" | "admin" | "owner" | "public" | "optional";
6
7
  export type SameOriginMode = "mutations" | "always" | "never";
7
8
  export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
9
+ export type UserOwnedResource = { userId: string };
10
+ export type UserIdSelector<TResource> = (resource: TResource) => string | undefined;
11
+
12
+ export interface UserScopedRealtimePublisher<TEvent = unknown> {
13
+ publishChanged<TPayload = unknown>(resource: string, options?: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action"> & { target?: RealtimeTarget }): void;
14
+ publishEntityChanged<TPayload = unknown>(resource: string, id: string, options?: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action" | "id"> & { target?: RealtimeTarget }): void;
15
+ publishDeleted<TPayload = unknown>(resource: string, id: string, options?: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action" | "id"> & { target?: RealtimeTarget }): void;
16
+ publishSettingsChanged<TPayload = unknown>(options?: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action"> & { target?: RealtimeTarget }): void;
17
+ }
8
18
 
9
19
  export interface RouteContext<TParams extends Record<string, string> = Record<string, string>, TEvent = unknown> {
10
20
  params: TParams;
11
21
  auth: AuthenticatedRequestState;
22
+ user?: CurrentUser;
23
+ requireUser(): CurrentUser;
24
+ requireAdmin(): CurrentUser;
25
+ requireOwner(): CurrentUser;
26
+ assertUser(userId: string): CurrentUser;
27
+ filterOwned<TResource extends UserOwnedResource>(resources: readonly TResource[]): TResource[];
28
+ filterOwned<TResource>(resources: readonly TResource[], getUserId: UserIdSelector<TResource>): TResource[];
29
+ requireOwned<TResource extends UserOwnedResource>(resource: TResource | null | undefined): TResource;
30
+ requireOwned<TResource>(resource: TResource | null | undefined, getUserId: UserIdSelector<TResource>): TResource;
12
31
  realtime: RealtimeBus<TEvent>;
32
+ userRealtime: UserScopedRealtimePublisher<TEvent>;
13
33
  server?: Server<unknown>;
14
34
  }
15
35
 
@@ -22,6 +42,7 @@ export type RouteDefinition<TEvent = unknown> = {
22
42
  auth?: RouteAuth;
23
43
  sameOrigin?: SameOriginMode;
24
44
  scopes?: string[];
45
+ userParam?: string;
25
46
  } & Partial<Record<HttpMethod, WebAppRouteHandler<Record<string, string>, TEvent>>>;
26
47
 
27
48
  export type RouteTable<TEvent = unknown> = Record<string, RouteDefinition<TEvent>>;
@@ -70,7 +70,7 @@ export function readRuntimeConfig(input: {
70
70
  return {
71
71
  appName: input.appName,
72
72
  envPrefix,
73
- host: readEnv(envPrefix, "HOST") || "127.0.0.1",
73
+ host: readEnv(envPrefix, "HOST") || "localhost",
74
74
  port: parsePort(readEnv(envPrefix, "PORT"), envName(envPrefix, "PORT")),
75
75
  dataDir: readEnv(envPrefix, "DATA_DIR") || "./data",
76
76
  logLevel,