@pablozaiden/webapp 0.1.0 → 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.
- package/README.md +3 -3
- package/docs/auth-validation.md +24 -5
- package/docs/auth.md +33 -9
- package/docs/getting-started.md +33 -7
- package/docs/github-actions.md +8 -3
- package/docs/realtime.md +7 -2
- package/docs/server.md +39 -4
- package/docs/settings.md +11 -5
- package/docs/sidebar.md +3 -3
- package/docs/ui-guidelines.md +5 -1
- package/package.json +1 -1
- package/src/contracts/index.ts +53 -0
- package/src/server/auth/api-keys.ts +13 -8
- package/src/server/auth/device-auth.ts +34 -11
- package/src/server/auth/passkeys.ts +191 -73
- package/src/server/auth/sqlite-store.ts +394 -44
- package/src/server/auth/store.ts +57 -13
- package/src/server/auth/types.ts +7 -3
- package/src/server/auth/users.ts +83 -0
- package/src/server/create-web-app-server.ts +327 -29
- package/src/server/realtime/bus.ts +8 -2
- package/src/server/routes.ts +23 -2
- package/src/server/runtime-config.ts +1 -1
- package/src/web/WebAppRoot.tsx +233 -27
- package/src/web/components/index.tsx +44 -11
- package/src/web/index.ts +1 -0
- package/src/web/render.tsx +26 -0
- package/src/web/styles.css +65 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
|
-
import type {
|
|
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
|
-
|
|
20
|
+
beginBootstrapRegistration,
|
|
21
|
+
beginOwnerPasskeySetup,
|
|
22
|
+
beginSetupRegistration,
|
|
21
23
|
completeAuthentication,
|
|
22
|
-
|
|
24
|
+
completeBootstrapRegistration,
|
|
25
|
+
completeOwnerPasskeySetup,
|
|
26
|
+
completeSetupRegistration,
|
|
23
27
|
deletePasskey,
|
|
24
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
208
|
-
const
|
|
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/
|
|
212
|
-
const headers = await
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
66
|
+
if (targetMatches(socket, publishOptions?.target) && (!publishOptions?.filter || publishOptions.filter(socket))) {
|
|
61
67
|
socket.send(payload);
|
|
62
68
|
}
|
|
63
69
|
}
|
package/src/server/routes.ts
CHANGED
|
@@ -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") || "
|
|
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,
|