@pablozaiden/webapp 0.2.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 +1 -0
- package/docs/cli.md +45 -0
- package/docs/deployment.md +3 -0
- package/docs/getting-started.md +14 -0
- package/docs/server.md +50 -0
- package/docs/settings.md +4 -0
- package/docs/ui-guidelines.md +3 -1
- 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/server/create-web-app-server.ts +160 -61
- package/src/server/index.ts +1 -0
- package/src/server/route-catalog.ts +147 -0
- package/src/server/routes.ts +12 -2
- package/src/web/WebAppRoot.tsx +106 -6
- package/src/web/api-client.ts +64 -0
- package/src/web/index.ts +1 -0
- package/src/web/realtime/useRealtime.ts +2 -2
- package/src/web/styles.css +39 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Server } from "bun";
|
|
1
|
+
import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
|
|
2
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 {
|
|
@@ -50,6 +50,8 @@ export interface WebAppServerConfig<TEvent = unknown> {
|
|
|
50
50
|
version?: string;
|
|
51
51
|
store?: WebAppStore;
|
|
52
52
|
routes?: RouteTable<TEvent>;
|
|
53
|
+
publicRoutes?: Record<string, PublicRouteDefinition>;
|
|
54
|
+
websockets?: Record<string, Partial<WebSocketHandler<WebAppWebSocketData>>>;
|
|
53
55
|
auth?: {
|
|
54
56
|
passkeys?: boolean | { rpName?: string; userName?: string; userDisplayName?: string };
|
|
55
57
|
apiKeys?: boolean;
|
|
@@ -60,6 +62,24 @@ export interface WebAppServerConfig<TEvent = unknown> {
|
|
|
60
62
|
};
|
|
61
63
|
}
|
|
62
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
|
+
|
|
63
83
|
export interface WebAppServer<TEvent = unknown> {
|
|
64
84
|
config: RuntimeConfig;
|
|
65
85
|
store: WebAppStore;
|
|
@@ -127,6 +147,20 @@ function htmlResponse(index: unknown): Response {
|
|
|
127
147
|
return index as Response;
|
|
128
148
|
}
|
|
129
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
|
+
|
|
130
164
|
function secureDynamicResponse(response: Response): Response {
|
|
131
165
|
return response instanceof Response ? withSecurityHeaders(response) : response;
|
|
132
166
|
}
|
|
@@ -245,6 +279,8 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
245
279
|
const version = input.version ?? "0.0.0-development";
|
|
246
280
|
const wsPath = input.realtime?.path ?? "/api/ws";
|
|
247
281
|
const routes = input.routes ?? {};
|
|
282
|
+
const publicRoutes = input.publicRoutes ?? {};
|
|
283
|
+
const appWebsockets = input.websockets ?? {};
|
|
248
284
|
const passkeysEnabled = input.auth?.passkeys !== false;
|
|
249
285
|
const apiKeysEnabled = input.auth?.apiKeys ?? false;
|
|
250
286
|
const deviceAuthEnabled = input.auth?.deviceAuth ?? false;
|
|
@@ -624,78 +660,123 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
624
660
|
return undefined;
|
|
625
661
|
}
|
|
626
662
|
|
|
627
|
-
async function
|
|
663
|
+
async function handlePublicRoute(req: Request): Promise<Response | undefined> {
|
|
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> {
|
|
628
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);
|
|
629
756
|
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/.well-known/") || url.pathname === "/device") {
|
|
630
757
|
const builtIn = await handleBuiltIn(req, server);
|
|
631
758
|
if (builtIn) {
|
|
632
759
|
return secureDynamicResponse(builtIn);
|
|
633
760
|
}
|
|
634
|
-
const matched = matchRoute(routes, url.pathname);
|
|
635
761
|
if (!matched) {
|
|
636
762
|
return withSecurityHeaders(notFound());
|
|
637
763
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
const routeAuth = matched.route.auth ?? "required";
|
|
643
|
-
const required = requiresAuth(routeAuth);
|
|
644
|
-
const auth = await authorize(req, required);
|
|
645
|
-
if (auth instanceof Response) {
|
|
646
|
-
return withSecurityHeaders(auth);
|
|
647
|
-
}
|
|
648
|
-
try {
|
|
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")) {
|
|
658
|
-
assertScopes(auth.kind === "api-key" ? auth.scopes : scopesFromBearer(auth.claims), matched.route.scopes ?? []);
|
|
659
|
-
}
|
|
660
|
-
} catch (error) {
|
|
661
|
-
return withSecurityHeaders(authErrorResponse(error));
|
|
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>;
|
|
670
|
-
const originFailure = checkSameOrigin(req, config, auth, matched.route.sameOrigin ?? "mutations");
|
|
671
|
-
if (originFailure) {
|
|
672
|
-
return withSecurityHeaders(originFailure);
|
|
673
|
-
}
|
|
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
|
-
}
|
|
764
|
+
return handleMatchedRoute(req, matched, server);
|
|
765
|
+
}
|
|
766
|
+
if (matched) {
|
|
767
|
+
return handleMatchedRoute(req, matched, server);
|
|
692
768
|
}
|
|
693
769
|
return htmlResponse(input.index);
|
|
694
770
|
}
|
|
695
771
|
|
|
696
|
-
function
|
|
697
|
-
const
|
|
698
|
-
|
|
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>({
|
|
699
780
|
hostname: config.host,
|
|
700
781
|
port: config.port,
|
|
701
782
|
routes: {
|
|
@@ -707,16 +788,34 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
707
788
|
},
|
|
708
789
|
websocket: {
|
|
709
790
|
open(socket) {
|
|
791
|
+
const handler = customHandler(socket);
|
|
792
|
+
if (handler?.open) {
|
|
793
|
+
handler.open(socket);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
710
796
|
realtime.add(socket);
|
|
711
797
|
},
|
|
712
798
|
message(socket, message) {
|
|
799
|
+
const handler = customHandler(socket);
|
|
800
|
+
if (handler?.message) {
|
|
801
|
+
handler.message(socket, message);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
713
804
|
if (message === "ping") {
|
|
714
805
|
socket.send(JSON.stringify({ type: "pong" }));
|
|
715
806
|
}
|
|
716
807
|
},
|
|
717
|
-
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
|
+
}
|
|
718
814
|
realtime.remove(socket);
|
|
719
815
|
},
|
|
816
|
+
drain(socket) {
|
|
817
|
+
customHandler(socket)?.drain?.(socket);
|
|
818
|
+
},
|
|
720
819
|
},
|
|
721
820
|
development: config.development,
|
|
722
821
|
});
|
package/src/server/index.ts
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { HttpMethod, RouteAuth, RouteDefinition, RouteTable, SameOriginMode } from "./routes";
|
|
2
|
+
|
|
3
|
+
const HTTP_METHODS: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
4
|
+
|
|
5
|
+
export interface RouteCatalogEntry {
|
|
6
|
+
path: string;
|
|
7
|
+
cliPath: string;
|
|
8
|
+
methods: HttpMethod[];
|
|
9
|
+
auth: RouteAuth;
|
|
10
|
+
sameOrigin: SameOriginMode;
|
|
11
|
+
scopes: string[];
|
|
12
|
+
description?: string;
|
|
13
|
+
tags: string[];
|
|
14
|
+
requestSchema?: unknown;
|
|
15
|
+
querySchema?: unknown;
|
|
16
|
+
responseSchema?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RouteCatalogMatch {
|
|
20
|
+
entry: RouteCatalogEntry;
|
|
21
|
+
path: string;
|
|
22
|
+
params: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function trimSlashes(value: string): string {
|
|
26
|
+
return value.replace(/^\/+|\/+$/g, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defaultCliPath(path: string): string {
|
|
30
|
+
return trimSlashes(path.startsWith("/api/") ? path.slice("/api/".length) : path);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function methodsFor(route: RouteDefinition): HttpMethod[] {
|
|
34
|
+
return HTTP_METHODS.filter((method) => typeof route[method] === "function");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createRouteCatalog<TEvent = unknown>(routes: RouteTable<TEvent>): RouteCatalogEntry[] {
|
|
38
|
+
return Object.entries(routes)
|
|
39
|
+
.filter(([, route]) => route.catalog !== false)
|
|
40
|
+
.map(([path, route]) => ({
|
|
41
|
+
path,
|
|
42
|
+
cliPath: route.cliPath ?? defaultCliPath(path),
|
|
43
|
+
methods: methodsFor(route),
|
|
44
|
+
auth: route.auth ?? "required",
|
|
45
|
+
sameOrigin: route.sameOrigin ?? "mutations",
|
|
46
|
+
scopes: route.scopes ?? [],
|
|
47
|
+
description: route.description,
|
|
48
|
+
tags: route.tags ?? [],
|
|
49
|
+
requestSchema: route.requestSchema,
|
|
50
|
+
querySchema: route.querySchema,
|
|
51
|
+
responseSchema: route.responseSchema,
|
|
52
|
+
}))
|
|
53
|
+
.filter((entry) => entry.methods.length > 0)
|
|
54
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeApiPath(input: string): string {
|
|
58
|
+
const [path = ""] = input.trim().split(/[?#]/);
|
|
59
|
+
if (!path) {
|
|
60
|
+
throw new Error("API endpoint is required");
|
|
61
|
+
}
|
|
62
|
+
if (path.startsWith("/api/") || path === "/api") {
|
|
63
|
+
return path;
|
|
64
|
+
}
|
|
65
|
+
if (path.startsWith("api/")) {
|
|
66
|
+
return `/${path}`;
|
|
67
|
+
}
|
|
68
|
+
if (path.startsWith("/")) {
|
|
69
|
+
return path;
|
|
70
|
+
}
|
|
71
|
+
return `/api/${path}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeCliPath(input: string): string {
|
|
75
|
+
const [path = ""] = input.trim().split(/[?#]/);
|
|
76
|
+
return trimSlashes(path.startsWith("/api/") ? path.slice("/api/".length) : path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function decodePathPart(value: string): string | undefined {
|
|
80
|
+
try {
|
|
81
|
+
return decodeURIComponent(value);
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function matchPattern(pattern: string, value: string): Record<string, string> | undefined {
|
|
88
|
+
const patternParts = trimSlashes(pattern).split("/").filter(Boolean);
|
|
89
|
+
const valueParts = trimSlashes(value).split("/").filter(Boolean);
|
|
90
|
+
if (patternParts.length !== valueParts.length) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const params: Record<string, string> = {};
|
|
94
|
+
for (let index = 0; index < patternParts.length; index += 1) {
|
|
95
|
+
const patternPart = patternParts[index]!;
|
|
96
|
+
const valuePart = valueParts[index]!;
|
|
97
|
+
if (patternPart.startsWith(":")) {
|
|
98
|
+
const decoded = decodePathPart(valuePart);
|
|
99
|
+
if (decoded === undefined) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
params[patternPart.slice(1)] = decoded;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (patternPart !== valuePart) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return params;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function concretePath(pattern: string, params: Record<string, string>): string {
|
|
113
|
+
return trimSlashes(pattern)
|
|
114
|
+
.split("/")
|
|
115
|
+
.map((part) => part.startsWith(":") ? encodeURIComponent(params[part.slice(1)] ?? "") : part)
|
|
116
|
+
.join("/")
|
|
117
|
+
.replace(/^/, "/");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function specificity(entry: RouteCatalogEntry): number {
|
|
121
|
+
return entry.path.split("/").reduce((score, part) => score + (part && !part.startsWith(":") ? 2 : 1), 0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function findRouteCatalogEntry(catalog: readonly RouteCatalogEntry[], input: string): RouteCatalogMatch | undefined {
|
|
125
|
+
const apiPath = normalizeApiPath(input);
|
|
126
|
+
const cliPath = normalizeCliPath(input);
|
|
127
|
+
const sorted = [...catalog].sort((left, right) => specificity(right) - specificity(left));
|
|
128
|
+
for (const entry of sorted) {
|
|
129
|
+
if (entry.path === apiPath) {
|
|
130
|
+
return { entry, path: apiPath, params: {} };
|
|
131
|
+
}
|
|
132
|
+
if (entry.cliPath === cliPath) {
|
|
133
|
+
return { entry, path: entry.path, params: {} };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const entry of sorted) {
|
|
137
|
+
const apiParams = matchPattern(entry.path, apiPath);
|
|
138
|
+
if (apiParams) {
|
|
139
|
+
return { entry, path: apiPath, params: apiParams };
|
|
140
|
+
}
|
|
141
|
+
const cliParams = matchPattern(entry.cliPath, cliPath);
|
|
142
|
+
if (cliParams) {
|
|
143
|
+
return { entry, path: concretePath(entry.path, cliParams), params: cliParams };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
package/src/server/routes.ts
CHANGED
|
@@ -36,14 +36,24 @@ export interface RouteContext<TParams extends Record<string, string> = Record<st
|
|
|
36
36
|
export type WebAppRouteHandler<TParams extends Record<string, string> = Record<string, string>, TEvent = unknown> = (
|
|
37
37
|
req: Request,
|
|
38
38
|
ctx: RouteContext<TParams, TEvent>,
|
|
39
|
-
) => Response | Promise<Response>;
|
|
39
|
+
) => Response | undefined | Promise<Response | undefined>;
|
|
40
|
+
|
|
41
|
+
export interface RouteMetadata {
|
|
42
|
+
description?: string;
|
|
43
|
+
cliPath?: string;
|
|
44
|
+
tags?: string[];
|
|
45
|
+
requestSchema?: unknown;
|
|
46
|
+
querySchema?: unknown;
|
|
47
|
+
responseSchema?: unknown;
|
|
48
|
+
catalog?: boolean;
|
|
49
|
+
}
|
|
40
50
|
|
|
41
51
|
export type RouteDefinition<TEvent = unknown> = {
|
|
42
52
|
auth?: RouteAuth;
|
|
43
53
|
sameOrigin?: SameOriginMode;
|
|
44
54
|
scopes?: string[];
|
|
45
55
|
userParam?: string;
|
|
46
|
-
} & Partial<Record<HttpMethod, WebAppRouteHandler<Record<string, string>, TEvent>>>;
|
|
56
|
+
} & RouteMetadata & Partial<Record<HttpMethod, WebAppRouteHandler<Record<string, string>, TEvent>>>;
|
|
47
57
|
|
|
48
58
|
export type RouteTable<TEvent = unknown> = Record<string, RouteDefinition<TEvent>>;
|
|
49
59
|
|
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -487,6 +487,7 @@ function UserManagement({ config }: { config: WebAppConfigResponse }) {
|
|
|
487
487
|
const [username, setUsername] = useState("");
|
|
488
488
|
const [role, setRole] = useState<WebAppUserRole>("user");
|
|
489
489
|
const [setupLink, setSetupLink] = useState<string>();
|
|
490
|
+
const [userToDelete, setUserToDelete] = useState<WebAppUserSummary>();
|
|
490
491
|
const [error, setError] = useState<string>();
|
|
491
492
|
|
|
492
493
|
const refreshUsers = useCallback(async () => {
|
|
@@ -535,6 +536,7 @@ function UserManagement({ config }: { config: WebAppConfigResponse }) {
|
|
|
535
536
|
try {
|
|
536
537
|
setError(undefined);
|
|
537
538
|
await json(`/api/users/${encodeURIComponent(user.id)}`, { method: "DELETE" });
|
|
539
|
+
setUserToDelete(undefined);
|
|
538
540
|
await refreshUsers();
|
|
539
541
|
} catch (err) {
|
|
540
542
|
setError(err instanceof Error ? err.message : String(err));
|
|
@@ -575,23 +577,73 @@ function UserManagement({ config }: { config: WebAppConfigResponse }) {
|
|
|
575
577
|
</select>
|
|
576
578
|
) : <Badge variant="success">Owner</Badge>}
|
|
577
579
|
{user.role !== "owner" ? <Button type="button" onClick={() => void resetUser(user)}>Reset</Button> : null}
|
|
578
|
-
<Button type="button" variant="danger" disabled={user.role === "owner"} onClick={() =>
|
|
580
|
+
<Button type="button" variant="danger" disabled={user.role === "owner"} onClick={() => setUserToDelete(user)}>Delete</Button>
|
|
579
581
|
</div>
|
|
580
582
|
</div>
|
|
581
583
|
))}
|
|
582
584
|
</div>
|
|
585
|
+
<ConfirmDialog
|
|
586
|
+
open={Boolean(userToDelete)}
|
|
587
|
+
title="Delete user?"
|
|
588
|
+
message={userToDelete ? `This permanently deletes "${userToDelete.username}" and revokes their setup links, API keys, passkeys and device sessions.` : ""}
|
|
589
|
+
confirmLabel="Delete user"
|
|
590
|
+
danger
|
|
591
|
+
onCancel={() => setUserToDelete(undefined)}
|
|
592
|
+
onConfirm={() => userToDelete && void deleteUser(userToDelete)}
|
|
593
|
+
/>
|
|
583
594
|
</FormSection>
|
|
584
595
|
);
|
|
585
596
|
}
|
|
586
597
|
|
|
598
|
+
const KILL_SERVER_COUNTDOWN_SECONDS = 15;
|
|
599
|
+
|
|
600
|
+
function computeProgressPercent(countdown: number, total: number): number {
|
|
601
|
+
return total <= 0 ? 0 : (countdown / total) * 100;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function useCountdownReload(active: boolean, onComplete: () => void, durationSeconds = KILL_SERVER_COUNTDOWN_SECONDS): { countdown: number; progressPercent: number } {
|
|
605
|
+
const [countdown, setCountdown] = useState(durationSeconds);
|
|
606
|
+
|
|
607
|
+
useEffect(() => {
|
|
608
|
+
if (!active) {
|
|
609
|
+
setCountdown(durationSeconds);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
setCountdown(durationSeconds);
|
|
614
|
+
const interval = window.setInterval(() => {
|
|
615
|
+
setCountdown((previous) => {
|
|
616
|
+
const next = previous - 1;
|
|
617
|
+
if (next <= 0) {
|
|
618
|
+
window.clearInterval(interval);
|
|
619
|
+
onComplete();
|
|
620
|
+
return 0;
|
|
621
|
+
}
|
|
622
|
+
return next;
|
|
623
|
+
});
|
|
624
|
+
}, 1000);
|
|
625
|
+
|
|
626
|
+
return () => window.clearInterval(interval);
|
|
627
|
+
}, [active, durationSeconds, onComplete]);
|
|
628
|
+
|
|
629
|
+
return {
|
|
630
|
+
countdown,
|
|
631
|
+
progressPercent: computeProgressPercent(countdown, durationSeconds),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
587
635
|
function SettingsView({ config, refresh, customSections, theme, setTheme }: { config: WebAppConfigResponse; refresh: () => Promise<void>; customSections: SettingsSection[]; theme: ThemePreference; setTheme: (theme: ThemePreference) => void }) {
|
|
588
636
|
const [apiKeys, setApiKeys] = useState<ApiKeySummary[]>([]);
|
|
589
637
|
const [authSessions, setAuthSessions] = useState<AuthSessionSummary[]>([]);
|
|
590
638
|
const [createdToken, setCreatedToken] = useState<string>();
|
|
591
639
|
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeySummary>();
|
|
640
|
+
const [authSessionToRevoke, setAuthSessionToRevoke] = useState<AuthSessionSummary>();
|
|
592
641
|
const [confirmDeletePasskey, setConfirmDeletePasskey] = useState(false);
|
|
642
|
+
const [confirmKillServer, setConfirmKillServer] = useState(false);
|
|
593
643
|
const [killRequested, setKillRequested] = useState(false);
|
|
594
644
|
const [error, setError] = useState<string>();
|
|
645
|
+
const reloadPage = useCallback(() => window.location.reload(), []);
|
|
646
|
+
const { countdown, progressPercent } = useCountdownReload(killRequested, reloadPage);
|
|
595
647
|
|
|
596
648
|
const refreshApiKeys = useCallback(async () => {
|
|
597
649
|
if (config.apiKeys.enabled) {
|
|
@@ -648,10 +700,25 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
648
700
|
await refresh();
|
|
649
701
|
}
|
|
650
702
|
|
|
703
|
+
async function revokeAuthSession(session: AuthSessionSummary) {
|
|
704
|
+
try {
|
|
705
|
+
setError(undefined);
|
|
706
|
+
await json(`/api/auth/sessions/${session.id}`, { method: "DELETE" });
|
|
707
|
+
setAuthSessionToRevoke(undefined);
|
|
708
|
+
await refreshAuthSessions();
|
|
709
|
+
} catch (err) {
|
|
710
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
651
714
|
async function killServer() {
|
|
715
|
+
setError(undefined);
|
|
716
|
+
setConfirmKillServer(false);
|
|
717
|
+
const response = await fetch("/api/server/kill", { method: "POST" });
|
|
718
|
+
if (!response.ok) {
|
|
719
|
+
throw new Error("Failed to kill server. Please try again.");
|
|
720
|
+
}
|
|
652
721
|
setKillRequested(true);
|
|
653
|
-
await fetch("/api/server/kill", { method: "POST" });
|
|
654
|
-
setTimeout(() => window.location.reload(), 3500);
|
|
655
722
|
}
|
|
656
723
|
|
|
657
724
|
return (
|
|
@@ -729,7 +796,7 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
729
796
|
{authSessions.length ? authSessions.map((session) => (
|
|
730
797
|
<div className="wapp-list-row" key={session.id}>
|
|
731
798
|
<span><strong>{session.clientId}</strong><small>{session.scope} · {session.active ? "active" : "inactive"} · {session.updatedAt}</small></span>
|
|
732
|
-
<Button type="button" variant="danger" disabled={!session.active} onClick={() =>
|
|
799
|
+
<Button type="button" variant="danger" disabled={!session.active} onClick={() => setAuthSessionToRevoke(session)}>Revoke</Button>
|
|
733
800
|
</div>
|
|
734
801
|
)) : <EmptyState title="No device sessions" />}
|
|
735
802
|
</div>
|
|
@@ -741,8 +808,23 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
741
808
|
|
|
742
809
|
{config.currentUser?.isAdmin ? (
|
|
743
810
|
<FormSection title="Server operations">
|
|
744
|
-
|
|
745
|
-
|
|
811
|
+
<div className="wapp-settings-row">
|
|
812
|
+
<div>
|
|
813
|
+
<strong>Kill server</strong>
|
|
814
|
+
<p>Stop the server process. If your deployment restarts it automatically, the app will come back after a moment.</p>
|
|
815
|
+
{killRequested ? (
|
|
816
|
+
<div className="wapp-shutdown-countdown" aria-live="polite">
|
|
817
|
+
<div className="wapp-shutdown-message">Server is shutting down... Reloading in {countdown}s</div>
|
|
818
|
+
<div className="wapp-shutdown-progress" aria-hidden="true">
|
|
819
|
+
<div className="wapp-shutdown-progress-bar" style={{ width: `${progressPercent}%` }} />
|
|
820
|
+
</div>
|
|
821
|
+
</div>
|
|
822
|
+
) : null}
|
|
823
|
+
</div>
|
|
824
|
+
<div className="wapp-row-actions">
|
|
825
|
+
<Button type="button" variant="danger" disabled={killRequested} onClick={() => setConfirmKillServer(true)}>Kill server</Button>
|
|
826
|
+
</div>
|
|
827
|
+
</div>
|
|
746
828
|
</FormSection>
|
|
747
829
|
) : null}
|
|
748
830
|
|
|
@@ -765,6 +847,24 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
765
847
|
onConfirm={() => apiKeyToDelete && void deleteKey(apiKeyToDelete.id)}
|
|
766
848
|
/>
|
|
767
849
|
<ConfirmDialog open={confirmDeletePasskey} title="Delete passkey?" message="This removes the configured passkey and invalidates browser sessions." confirmLabel="Delete passkey" danger onCancel={() => setConfirmDeletePasskey(false)} onConfirm={() => void deleteConfiguredPasskey()} />
|
|
850
|
+
<ConfirmDialog
|
|
851
|
+
open={Boolean(authSessionToRevoke)}
|
|
852
|
+
title="Revoke device session?"
|
|
853
|
+
message={authSessionToRevoke ? `This revokes the active "${authSessionToRevoke.clientId}" refresh-token session.` : ""}
|
|
854
|
+
confirmLabel="Revoke session"
|
|
855
|
+
danger
|
|
856
|
+
onCancel={() => setAuthSessionToRevoke(undefined)}
|
|
857
|
+
onConfirm={() => authSessionToRevoke && void revokeAuthSession(authSessionToRevoke)}
|
|
858
|
+
/>
|
|
859
|
+
<ConfirmDialog
|
|
860
|
+
open={confirmKillServer}
|
|
861
|
+
title="Kill server?"
|
|
862
|
+
message="Are you sure you want to kill the server?"
|
|
863
|
+
confirmLabel="Kill server"
|
|
864
|
+
danger
|
|
865
|
+
onCancel={() => setConfirmKillServer(false)}
|
|
866
|
+
onConfirm={() => void killServer().catch((err) => setError(String(err)))}
|
|
867
|
+
/>
|
|
768
868
|
</div>
|
|
769
869
|
);
|
|
770
870
|
}
|