@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.
- package/README.md +4 -3
- package/docs/auth-validation.md +24 -5
- package/docs/auth.md +33 -9
- package/docs/cli.md +45 -0
- package/docs/deployment.md +3 -0
- package/docs/getting-started.md +47 -7
- package/docs/github-actions.md +8 -3
- package/docs/realtime.md +7 -2
- package/docs/server.md +89 -4
- package/docs/settings.md +15 -5
- package/docs/sidebar.md +3 -3
- package/docs/ui-guidelines.md +8 -2
- package/package.json +2 -1
- package/src/cli/api-command.ts +119 -0
- package/src/cli/credentials.ts +67 -0
- package/src/cli/device-auth.ts +179 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/runtime.ts +51 -0
- 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 +451 -54
- package/src/server/index.ts +1 -0
- package/src/server/realtime/bus.ts +8 -2
- package/src/server/route-catalog.ts +147 -0
- package/src/server/routes.ts +35 -4
- package/src/server/runtime-config.ts +1 -1
- package/src/web/WebAppRoot.tsx +336 -30
- package/src/web/api-client.ts +64 -0
- package/src/web/components/index.tsx +44 -11
- package/src/web/index.ts +2 -0
- package/src/web/realtime/useRealtime.ts +2 -2
- package/src/web/render.tsx +26 -0
- package/src/web/styles.css +104 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Server } from "bun";
|
|
2
|
-
import type {
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
208
|
-
const
|
|
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/
|
|
212
|
-
const headers = await
|
|
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
|
|
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
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
400
|
-
const
|
|
401
|
-
|
|
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
|
});
|