@pablozaiden/webapp 0.5.8 → 0.6.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 +12 -4
- package/docs/auth-validation.md +11 -0
- package/docs/auth.md +33 -0
- package/docs/cli.md +73 -3
- package/docs/deployment.md +84 -7
- package/docs/getting-started.md +69 -6
- package/docs/github-actions.md +39 -0
- package/docs/release.md +5 -5
- package/docs/server.md +34 -4
- package/docs/settings.md +5 -0
- package/docs/ui-guidelines.md +12 -4
- package/package.json +2 -4
- package/src/build/build-binary.ts +67 -24
- package/src/cli/api-command.ts +53 -16
- package/src/cli/credentials.ts +275 -4
- package/src/cli/device-auth.ts +83 -22
- package/src/cli/environment-auth.ts +57 -0
- package/src/cli/index.ts +1 -0
- package/src/package-resolution.ts +34 -0
- package/src/server/auth/device-auth.ts +2 -2
- package/src/server/auth/passkeys.ts +16 -16
- package/src/server/auth/request-origin.ts +97 -16
- package/src/server/authentication.ts +265 -0
- package/src/server/create-web-app-server.ts +85 -1391
- package/src/server/framework-endpoints.ts +451 -0
- package/src/server/index.ts +1 -0
- package/src/server/public-route-dispatch.ts +85 -0
- package/src/server/request-schemas.ts +144 -0
- package/src/server/responses.ts +69 -3
- package/src/server/route-dispatch.ts +109 -0
- package/src/server/runtime-config.ts +93 -1
- package/src/server/same-origin.ts +1 -1
- package/src/server/server-lifecycle.ts +138 -0
- package/src/server/server-types.ts +87 -0
- package/src/server/web-document.ts +532 -0
- package/src/web/WebAppRoot.tsx +69 -1214
- package/src/web/api-client.ts +14 -4
- package/src/web/app-shell.tsx +198 -0
- package/src/web/auth-screens.tsx +186 -0
- package/src/web/components/index.tsx +10 -3
- package/src/web/index.ts +1 -0
- package/src/web/mobile-hooks.ts +194 -0
- package/src/web/mobile.ts +12 -0
- package/src/web/root-types.ts +61 -0
- package/src/web/routing.ts +61 -0
- package/src/web/settings/account-section.tsx +52 -0
- package/src/web/settings/resource-state.tsx +17 -0
- package/src/web/settings/security-section.tsx +119 -0
- package/src/web/settings/sessions-section.tsx +66 -0
- package/src/web/settings/settings-view.tsx +96 -0
- package/src/web/settings/shutdown-section.tsx +95 -0
- package/src/web/settings/user-management.tsx +123 -0
- package/src/web/settings-view.tsx +3 -0
- package/src/web/sidebar-state.ts +108 -0
- package/src/web/sidebar-tree.tsx +89 -0
- package/src/web/styles.css +76 -64
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
|
|
3
|
+
|
|
4
|
+
const nonEmptyString = z.string().min(1);
|
|
5
|
+
const optionalClientId = z.string().max(200).optional();
|
|
6
|
+
const authenticatorTransport = z.enum(["ble", "cable", "hybrid", "internal", "nfc", "smart-card", "usb"]);
|
|
7
|
+
const clientExtensionResults = z.object({
|
|
8
|
+
appid: z.boolean().optional(),
|
|
9
|
+
credProps: z.object({ rk: z.boolean().optional() }).optional(),
|
|
10
|
+
hmacCreateSecret: z.boolean().optional(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const registrationResponseSchema: z.ZodType<RegistrationResponseJSON> = z.object({
|
|
14
|
+
id: nonEmptyString,
|
|
15
|
+
rawId: nonEmptyString,
|
|
16
|
+
response: z.object({
|
|
17
|
+
clientDataJSON: nonEmptyString,
|
|
18
|
+
attestationObject: nonEmptyString,
|
|
19
|
+
authenticatorData: nonEmptyString.optional(),
|
|
20
|
+
transports: z.array(authenticatorTransport).optional(),
|
|
21
|
+
publicKeyAlgorithm: z.number().optional(),
|
|
22
|
+
publicKey: nonEmptyString.optional(),
|
|
23
|
+
}),
|
|
24
|
+
authenticatorAttachment: z.enum(["cross-platform", "platform"]).optional(),
|
|
25
|
+
clientExtensionResults,
|
|
26
|
+
type: z.literal("public-key"),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const authenticationResponseSchema: z.ZodType<AuthenticationResponseJSON> = z.object({
|
|
30
|
+
id: nonEmptyString,
|
|
31
|
+
rawId: nonEmptyString,
|
|
32
|
+
response: z.object({
|
|
33
|
+
clientDataJSON: nonEmptyString,
|
|
34
|
+
authenticatorData: nonEmptyString,
|
|
35
|
+
signature: nonEmptyString,
|
|
36
|
+
userHandle: nonEmptyString.optional(),
|
|
37
|
+
}),
|
|
38
|
+
authenticatorAttachment: z.enum(["cross-platform", "platform"]).optional(),
|
|
39
|
+
clientExtensionResults,
|
|
40
|
+
type: z.literal("public-key"),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const passkeyBootstrapOptionsSchema = z.object({
|
|
44
|
+
username: z.string().max(32).optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const setupOptionsSchema = z.object({
|
|
48
|
+
token: z.string().max(512).optional(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const setupVerificationSchema = z.object({
|
|
52
|
+
token: nonEmptyString,
|
|
53
|
+
response: registrationResponseSchema,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const createApiKeyRequestSchema = z.object({
|
|
57
|
+
name: z.string().max(200).optional(),
|
|
58
|
+
scopes: z.array(nonEmptyString).optional(),
|
|
59
|
+
prefix: z.string().min(1).max(32).regex(/^[A-Za-z0-9_-]+$/).optional(),
|
|
60
|
+
expiresAt: z.string().refine((value) => Number.isFinite(Date.parse(value)), "expiresAt must be a valid date").optional(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const deviceAuthorizationRequestSchema = z.object({
|
|
64
|
+
client_id: optionalClientId,
|
|
65
|
+
clientId: optionalClientId,
|
|
66
|
+
scope: z.string().max(512).optional(),
|
|
67
|
+
}).superRefine((value, context) => {
|
|
68
|
+
if (value.client_id !== undefined && value.clientId !== undefined && value.client_id !== value.clientId) {
|
|
69
|
+
context.addIssue({ code: "custom", path: ["clientId"], message: "client_id and clientId must match when both are provided" });
|
|
70
|
+
}
|
|
71
|
+
}).transform(({ client_id, clientId, scope }) => ({
|
|
72
|
+
clientId: clientId ?? client_id,
|
|
73
|
+
scope,
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
const deviceCodeActionInput = z.object({
|
|
77
|
+
userCode: nonEmptyString.optional(),
|
|
78
|
+
user_code: nonEmptyString.optional(),
|
|
79
|
+
}).superRefine((value, context) => {
|
|
80
|
+
if (value.userCode === undefined && value.user_code === undefined) {
|
|
81
|
+
context.addIssue({ code: "custom", path: ["userCode"], message: "userCode is required" });
|
|
82
|
+
} else if (value.userCode !== undefined && value.user_code !== undefined && value.userCode !== value.user_code) {
|
|
83
|
+
context.addIssue({ code: "custom", path: ["userCode"], message: "user_code and userCode must match when both are provided" });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export const deviceCodeActionRequestSchema = deviceCodeActionInput.transform(({ userCode, user_code }) => ({
|
|
88
|
+
userCode: userCode ?? user_code!,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
const tokenRequestFields = {
|
|
92
|
+
grant_type: z.enum(["refresh_token", "urn:ietf:params:oauth:grant-type:device_code"]).optional(),
|
|
93
|
+
device_code: nonEmptyString.optional(),
|
|
94
|
+
refresh_token: nonEmptyString.optional(),
|
|
95
|
+
client_id: optionalClientId,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const tokenRequestSchema = z.object(tokenRequestFields).superRefine((value, context) => {
|
|
99
|
+
if (value.grant_type === "urn:ietf:params:oauth:grant-type:device_code" && value.device_code === undefined) {
|
|
100
|
+
context.addIssue({ code: "custom", path: ["device_code"], message: "device_code is required for device-code grants" });
|
|
101
|
+
}
|
|
102
|
+
if (value.grant_type === "refresh_token" && value.refresh_token === undefined) {
|
|
103
|
+
context.addIssue({ code: "custom", path: ["refresh_token"], message: "refresh_token is required for refresh grants" });
|
|
104
|
+
}
|
|
105
|
+
if (value.grant_type === undefined && value.device_code === undefined && value.refresh_token === undefined) {
|
|
106
|
+
context.addIssue({ code: "custom", path: ["grant_type"], message: "A device_code or refresh_token is required" });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export const refreshTokenRequestSchema = z.object({
|
|
111
|
+
grant_type: z.literal("refresh_token").optional(),
|
|
112
|
+
refresh_token: nonEmptyString,
|
|
113
|
+
client_id: optionalClientId,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const revokeRefreshTokenRequestSchema = z.object({
|
|
117
|
+
refreshToken: nonEmptyString.optional(),
|
|
118
|
+
refresh_token: nonEmptyString.optional(),
|
|
119
|
+
}).superRefine((value, context) => {
|
|
120
|
+
if (value.refreshToken === undefined && value.refresh_token === undefined) {
|
|
121
|
+
context.addIssue({ code: "custom", path: ["refreshToken"], message: "refresh_token is required" });
|
|
122
|
+
} else if (value.refreshToken !== undefined && value.refresh_token !== undefined && value.refreshToken !== value.refresh_token) {
|
|
123
|
+
context.addIssue({ code: "custom", path: ["refreshToken"], message: "refresh_token and refreshToken must match when both are provided" });
|
|
124
|
+
}
|
|
125
|
+
}).transform(({ refreshToken, refresh_token }) => ({
|
|
126
|
+
refreshToken: refreshToken ?? refresh_token!,
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
export const createUserRequestSchema = z.object({
|
|
130
|
+
username: z.string().max(32).optional(),
|
|
131
|
+
role: z.enum(["admin", "user"]).optional(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export const userRoleRequestSchema = z.object({
|
|
135
|
+
role: z.enum(["admin", "user"]),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export const themePreferenceRequestSchema = z.object({
|
|
139
|
+
theme: z.enum(["system", "light", "dark"]),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export const logLevelPreferenceRequestSchema = z.object({
|
|
143
|
+
level: z.enum(["trace", "debug", "info", "warn", "error"]),
|
|
144
|
+
});
|
package/src/server/responses.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import type { WebAppErrorResponse } from "../contracts";
|
|
2
3
|
|
|
3
4
|
export function jsonResponse<T>(data: T, init: ResponseInit = {}): Response {
|
|
@@ -27,12 +28,77 @@ export function methodNotAllowed(): Response {
|
|
|
27
28
|
return errorResponse(405, "method_not_allowed", "Method not allowed");
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
export
|
|
31
|
+
export class InvalidJsonError extends Error {
|
|
32
|
+
readonly code = "invalid_json";
|
|
33
|
+
readonly status = 400;
|
|
34
|
+
|
|
35
|
+
constructor() {
|
|
36
|
+
super("Request body must be valid JSON");
|
|
37
|
+
this.name = "InvalidJsonError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RequestBodyValidationIssue {
|
|
42
|
+
path: Array<string | number>;
|
|
43
|
+
code: string;
|
|
44
|
+
message: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class InvalidRequestBodyError extends Error {
|
|
48
|
+
readonly code = "invalid_request_body";
|
|
49
|
+
readonly status = 400;
|
|
50
|
+
readonly details: RequestBodyValidationIssue[];
|
|
51
|
+
|
|
52
|
+
constructor(error: z.ZodError) {
|
|
53
|
+
super("Request body failed validation");
|
|
54
|
+
this.name = "InvalidRequestBodyError";
|
|
55
|
+
this.details = error.issues.map((issue) => ({
|
|
56
|
+
path: issue.path.map((segment) => typeof segment === "symbol" ? String(segment) : segment),
|
|
57
|
+
code: issue.code,
|
|
58
|
+
message: issue.message,
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function requestBodyErrorResponse(error: unknown): Response | undefined {
|
|
64
|
+
if (error instanceof InvalidJsonError || error instanceof InvalidRequestBodyError) {
|
|
65
|
+
return errorResponse(error.status, error.code, error.message, "details" in error ? error.details : undefined);
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validateJson<TSchema extends z.ZodTypeAny>(value: unknown, schema: TSchema): z.infer<TSchema> {
|
|
71
|
+
const result = schema.safeParse(value);
|
|
72
|
+
if (!result.success) {
|
|
73
|
+
throw new InvalidRequestBodyError(result.error);
|
|
74
|
+
}
|
|
75
|
+
return result.data;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function parseUnknownJson(req: Request): Promise<unknown> {
|
|
79
|
+
try {
|
|
80
|
+
return await req.json();
|
|
81
|
+
} catch {
|
|
82
|
+
throw new InvalidJsonError();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function parseJson<TSchema extends z.ZodTypeAny>(req: Request, schema: TSchema): Promise<z.infer<TSchema>> {
|
|
87
|
+
return validateJson(await parseUnknownJson(req), schema);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function parseOptionalJson<TSchema extends z.ZodTypeAny>(req: Request, schema: TSchema): Promise<z.infer<TSchema> | undefined> {
|
|
91
|
+
const text = await req.text();
|
|
92
|
+
if (text.length === 0) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
let value: unknown;
|
|
31
96
|
try {
|
|
32
|
-
|
|
97
|
+
value = JSON.parse(text);
|
|
33
98
|
} catch {
|
|
34
|
-
throw new
|
|
99
|
+
throw new InvalidJsonError();
|
|
35
100
|
}
|
|
101
|
+
return validateJson(value, schema);
|
|
36
102
|
}
|
|
37
103
|
|
|
38
104
|
export function applySecurityHeaders(headers: Headers): Headers {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Server } from "bun";
|
|
2
|
+
import { assertScopes } from "./auth/api-keys";
|
|
3
|
+
import { AuthError } from "./auth/types";
|
|
4
|
+
import { authErrorResponse, enforceRouteAuth, requiresAuth, scopesFromBearer, type Authentication } from "./authentication";
|
|
5
|
+
import type { RuntimeConfig } from "./runtime-config";
|
|
6
|
+
import { createLogger } from "./logger";
|
|
7
|
+
import { checkSameOrigin } from "./same-origin";
|
|
8
|
+
import type { RealtimeBus } from "./realtime/bus";
|
|
9
|
+
import { matchRoute, type HttpMethod, type RouteTable, type UserScopedRealtimePublisher } from "./routes";
|
|
10
|
+
import type { WebAppWebSocketData } from "./server-types";
|
|
11
|
+
import { errorResponse, requestBodyErrorResponse, withSecurityHeaders, methodNotAllowed } from "./responses";
|
|
12
|
+
|
|
13
|
+
export interface RouteDispatchResult {
|
|
14
|
+
matched: boolean;
|
|
15
|
+
response?: Response;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RouteDispatcherDependencies<TEvent = unknown> {
|
|
19
|
+
config: RuntimeConfig;
|
|
20
|
+
routes: RouteTable<TEvent>;
|
|
21
|
+
authentication: Authentication;
|
|
22
|
+
realtime: RealtimeBus<TEvent>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const log = createLogger("webapp:routes");
|
|
26
|
+
|
|
27
|
+
function method(req: Request): HttpMethod | undefined {
|
|
28
|
+
const value = req.method.toUpperCase();
|
|
29
|
+
return value === "GET" || value === "POST" || value === "PUT" || value === "PATCH" || value === "DELETE" ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function routeHandlerErrorResponse(error: unknown): Response {
|
|
33
|
+
const requestBodyFailure = requestBodyErrorResponse(error);
|
|
34
|
+
if (requestBodyFailure) {
|
|
35
|
+
return requestBodyFailure;
|
|
36
|
+
}
|
|
37
|
+
if (error instanceof AuthError) {
|
|
38
|
+
return errorResponse(error.status, error.code, error.message);
|
|
39
|
+
}
|
|
40
|
+
log.error("Unhandled route handler error", { error: error instanceof Error ? error.message : String(error) });
|
|
41
|
+
return errorResponse(500, "request_failed", "Request failed");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createRouteDispatcher<TEvent = unknown>(dependencies: RouteDispatcherDependencies<TEvent>) {
|
|
45
|
+
const { config, routes, authentication, realtime } = dependencies;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
async dispatch(req: Request, server?: Server<WebAppWebSocketData>): Promise<RouteDispatchResult> {
|
|
49
|
+
const matched = matchRoute(routes, new URL(req.url).pathname);
|
|
50
|
+
if (!matched) {
|
|
51
|
+
return { matched: false };
|
|
52
|
+
}
|
|
53
|
+
const handler = matched.route[method(req) ?? "GET"];
|
|
54
|
+
if (!handler) {
|
|
55
|
+
return { matched: true, response: withSecurityHeaders(methodNotAllowed()) };
|
|
56
|
+
}
|
|
57
|
+
const routeAuth = matched.route.auth ?? "required";
|
|
58
|
+
const auth = await authentication.authorize(req, requiresAuth(routeAuth));
|
|
59
|
+
if (auth instanceof Response) {
|
|
60
|
+
return { matched: true, response: withSecurityHeaders(auth) };
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
enforceRouteAuth(routeAuth, auth, authentication);
|
|
64
|
+
if (matched.route.userParam) {
|
|
65
|
+
const paramValue = matched.params[matched.route.userParam];
|
|
66
|
+
if (!paramValue) {
|
|
67
|
+
throw new AuthError("route_misconfigured", `Route userParam "${matched.route.userParam}" is missing from matched params`, 500);
|
|
68
|
+
}
|
|
69
|
+
authentication.assertUser(auth, paramValue);
|
|
70
|
+
}
|
|
71
|
+
if (routeAuth !== "public" && (auth.kind === "api-key" || auth.kind === "bearer")) {
|
|
72
|
+
assertScopes(auth.kind === "api-key" ? auth.scopes : scopesFromBearer(auth.claims), matched.route.scopes ?? []);
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return { matched: true, response: withSecurityHeaders(authErrorResponse(error)) };
|
|
76
|
+
}
|
|
77
|
+
const current = () => authentication.requireUser(auth);
|
|
78
|
+
const userRealtime = {
|
|
79
|
+
publishChanged: (resource, options = {}) => realtime.publishChanged(resource, { ...options, target: { ...options.target, userId: current().id } }),
|
|
80
|
+
publishEntityChanged: (resource, id, options = {}) => realtime.publishEntityChanged(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
|
|
81
|
+
publishDeleted: (resource, id, options = {}) => realtime.publishDeleted(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
|
|
82
|
+
publishSettingsChanged: (options = {}) => realtime.publishSettingsChanged({ ...options, target: { ...options.target, userId: current().id } }),
|
|
83
|
+
} satisfies UserScopedRealtimePublisher<TEvent>;
|
|
84
|
+
const originFailure = checkSameOrigin(req, config, auth, matched.route.sameOrigin ?? "mutations");
|
|
85
|
+
if (originFailure) {
|
|
86
|
+
return { matched: true, response: withSecurityHeaders(originFailure) };
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const response = await handler(req, {
|
|
90
|
+
params: matched.params,
|
|
91
|
+
auth,
|
|
92
|
+
user: authentication.currentUser(auth),
|
|
93
|
+
requireUser: () => authentication.requireUser(auth),
|
|
94
|
+
requireAdmin: () => authentication.requireAdmin(auth),
|
|
95
|
+
requireOwner: () => authentication.requireOwner(auth),
|
|
96
|
+
assertUser: (userId) => authentication.assertUser(auth, userId),
|
|
97
|
+
filterOwned: authentication.createFilterOwned(auth),
|
|
98
|
+
requireOwned: authentication.createRequireOwned(auth),
|
|
99
|
+
realtime,
|
|
100
|
+
userRealtime,
|
|
101
|
+
server,
|
|
102
|
+
});
|
|
103
|
+
return { matched: true, response: response ? withSecurityHeaders(response) : undefined };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return { matched: true, response: withSecurityHeaders(routeHandlerErrorResponse(error)) };
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { LogLevelName } from "../contracts";
|
|
2
2
|
|
|
3
|
+
export const TRUST_PROXY_HEADERS = ["proto", "host", "prefix"] as const;
|
|
4
|
+
export type TrustProxyHeader = typeof TRUST_PROXY_HEADERS[number];
|
|
5
|
+
export type TrustProxyChain = "first" | "last";
|
|
6
|
+
|
|
7
|
+
export interface TrustProxyConfig {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
headers: readonly TrustProxyHeader[];
|
|
10
|
+
chain: TrustProxyChain;
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
export interface RuntimeConfig {
|
|
4
14
|
appName: string;
|
|
5
15
|
envPrefix: string;
|
|
@@ -12,6 +22,7 @@ export interface RuntimeConfig {
|
|
|
12
22
|
sameOriginDisabled: boolean;
|
|
13
23
|
publicBaseUrl?: string;
|
|
14
24
|
authIssuer?: string;
|
|
25
|
+
trustProxy: TrustProxyConfig;
|
|
15
26
|
development: false | { hmr: true; console: true };
|
|
16
27
|
}
|
|
17
28
|
|
|
@@ -59,6 +70,74 @@ function parseLogLevel(raw: string | undefined, fallback: LogLevelName, name: st
|
|
|
59
70
|
return raw as LogLevelName;
|
|
60
71
|
}
|
|
61
72
|
|
|
73
|
+
function parseBoolean(raw: string | undefined, fallback: boolean, name: string): boolean {
|
|
74
|
+
if (raw === undefined) {
|
|
75
|
+
return fallback;
|
|
76
|
+
}
|
|
77
|
+
const normalized = raw.toLowerCase();
|
|
78
|
+
if (normalized === "true" || normalized === "1" || normalized === "yes") {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
if (normalized === "false" || normalized === "0" || normalized === "no") {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`${name} must be true or false; received "${raw}"`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parsePublicBaseUrl(raw: string | undefined, name: string): string | undefined {
|
|
88
|
+
if (!raw) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
let parsed: URL;
|
|
92
|
+
try {
|
|
93
|
+
parsed = new URL(raw);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new Error(`${name} must be a valid absolute http(s) origin; received "${raw}"`);
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
|
99
|
+
parsed.username ||
|
|
100
|
+
parsed.password ||
|
|
101
|
+
parsed.origin === "null" ||
|
|
102
|
+
parsed.pathname !== "/" ||
|
|
103
|
+
parsed.search ||
|
|
104
|
+
parsed.hash
|
|
105
|
+
) {
|
|
106
|
+
throw new Error(`${name} must be a valid absolute http(s) origin; received "${raw}"`);
|
|
107
|
+
}
|
|
108
|
+
return parsed.origin;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseTrustProxyHeaders(raw: string | undefined, enabled: boolean, name: string): TrustProxyHeader[] {
|
|
112
|
+
if (raw === undefined) {
|
|
113
|
+
return enabled ? [...TRUST_PROXY_HEADERS] : [];
|
|
114
|
+
}
|
|
115
|
+
const values = raw.split(",").map((value) => value.trim().toLowerCase());
|
|
116
|
+
if (values.length === 0 || values.some((value) => !value)) {
|
|
117
|
+
throw new Error(`${name} must be a comma-separated list of proto, host, and prefix; received "${raw}"`);
|
|
118
|
+
}
|
|
119
|
+
const headers: TrustProxyHeader[] = [];
|
|
120
|
+
for (const value of values) {
|
|
121
|
+
const header = TRUST_PROXY_HEADERS.find((candidate) => candidate === value);
|
|
122
|
+
if (!header) {
|
|
123
|
+
throw new Error(`${name} must contain only proto, host, and prefix; received "${raw}"`);
|
|
124
|
+
}
|
|
125
|
+
if (headers.includes(header)) {
|
|
126
|
+
throw new Error(`${name} must not contain duplicate values; received "${raw}"`);
|
|
127
|
+
}
|
|
128
|
+
headers.push(header);
|
|
129
|
+
}
|
|
130
|
+
return headers;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseTrustProxyChain(raw: string | undefined, name: string): TrustProxyChain {
|
|
134
|
+
const value = raw?.toLowerCase() ?? "first";
|
|
135
|
+
if (value === "first" || value === "last") {
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`${name} must be first or last; received "${raw}"`);
|
|
139
|
+
}
|
|
140
|
+
|
|
62
141
|
export function readRuntimeConfig(input: {
|
|
63
142
|
appName: string;
|
|
64
143
|
envPrefix: string;
|
|
@@ -67,6 +146,13 @@ export function readRuntimeConfig(input: {
|
|
|
67
146
|
const envPrefix = assertEnvPrefix(input.envPrefix);
|
|
68
147
|
const logLevelRaw = readEnv(envPrefix, "LOG_LEVEL");
|
|
69
148
|
const logLevel = parseLogLevel(logLevelRaw, input.defaultLogLevel ?? "info", envName(envPrefix, "LOG_LEVEL"));
|
|
149
|
+
const publicBaseUrl = parsePublicBaseUrl(readEnv(envPrefix, "PUBLIC_BASE_URL"), envName(envPrefix, "PUBLIC_BASE_URL"));
|
|
150
|
+
const trustProxyEnabled = parseBoolean(readEnv(envPrefix, "TRUST_PROXY"), false, envName(envPrefix, "TRUST_PROXY"));
|
|
151
|
+
const trustProxy = {
|
|
152
|
+
enabled: trustProxyEnabled,
|
|
153
|
+
headers: parseTrustProxyHeaders(readEnv(envPrefix, "TRUST_PROXY_HEADERS"), trustProxyEnabled, envName(envPrefix, "TRUST_PROXY_HEADERS")),
|
|
154
|
+
chain: parseTrustProxyChain(readEnv(envPrefix, "TRUST_PROXY_CHAIN"), envName(envPrefix, "TRUST_PROXY_CHAIN")),
|
|
155
|
+
} satisfies TrustProxyConfig;
|
|
70
156
|
return {
|
|
71
157
|
appName: input.appName,
|
|
72
158
|
envPrefix,
|
|
@@ -77,8 +163,9 @@ export function readRuntimeConfig(input: {
|
|
|
77
163
|
logLevelFromEnv: Boolean(logLevelRaw),
|
|
78
164
|
passkeyDisabled: isTruthyEnv(readEnv(envPrefix, "DISABLE_PASSKEY")),
|
|
79
165
|
sameOriginDisabled: isTruthyEnv(readEnv(envPrefix, "DISABLE_SAME_ORIGIN_CHECK")),
|
|
80
|
-
publicBaseUrl
|
|
166
|
+
publicBaseUrl,
|
|
81
167
|
authIssuer: readEnv(envPrefix, "AUTH_ISSUER"),
|
|
168
|
+
trustProxy,
|
|
82
169
|
development: process.env["NODE_ENV"] === "production" ? false : { hmr: true, console: true },
|
|
83
170
|
};
|
|
84
171
|
}
|
|
@@ -96,6 +183,11 @@ export function safeRuntimeConfig(config: RuntimeConfig): Record<string, unknown
|
|
|
96
183
|
sameOriginDisabled: config.sameOriginDisabled,
|
|
97
184
|
publicBaseUrl: config.publicBaseUrl,
|
|
98
185
|
authIssuer: config.authIssuer,
|
|
186
|
+
trustProxy: {
|
|
187
|
+
enabled: config.trustProxy.enabled,
|
|
188
|
+
headers: [...config.trustProxy.headers],
|
|
189
|
+
chain: config.trustProxy.chain,
|
|
190
|
+
},
|
|
99
191
|
production: config.development === false,
|
|
100
192
|
};
|
|
101
193
|
}
|
|
@@ -18,7 +18,7 @@ export function checkSameOrigin(req: Request, config: RuntimeConfig, auth: Authe
|
|
|
18
18
|
if (mode !== "always" && (auth.kind === "api-key" || auth.kind === "bearer")) {
|
|
19
19
|
return undefined;
|
|
20
20
|
}
|
|
21
|
-
const expectedOrigin = getRequestOriginInfo(req, config
|
|
21
|
+
const expectedOrigin = getRequestOriginInfo(req, config).origin;
|
|
22
22
|
const origin = req.headers.get("origin");
|
|
23
23
|
if (origin) {
|
|
24
24
|
return origin === expectedOrigin ? undefined : errorResponse(403, "same_origin_required", "Request origin is not allowed");
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
|
|
2
|
+
import { createLogger } from "./logger";
|
|
3
|
+
import type { RealtimeBus } from "./realtime/bus";
|
|
4
|
+
import type { WebAppWebSocketData, WebAppServerConfig } from "./server-types";
|
|
5
|
+
import type { RuntimeConfig } from "./runtime-config";
|
|
6
|
+
import { safeRuntimeConfig } from "./runtime-config";
|
|
7
|
+
import type { WebDocument, WebDocumentProvider } from "./web-document";
|
|
8
|
+
|
|
9
|
+
export interface ServerLifecycleDependencies<TEvent = unknown> {
|
|
10
|
+
config: RuntimeConfig;
|
|
11
|
+
version: string;
|
|
12
|
+
deviceAuthEnabled: boolean;
|
|
13
|
+
publicRoutes: Readonly<Record<string, unknown>>;
|
|
14
|
+
appWebsockets: NonNullable<WebAppServerConfig["websockets"]>;
|
|
15
|
+
realtime: RealtimeBus<TEvent>;
|
|
16
|
+
ensureWebDocument: () => Promise<WebDocument>;
|
|
17
|
+
documentProvider: WebDocumentProvider;
|
|
18
|
+
handleRequest: (req: Request, server?: Server<WebAppWebSocketData>) => Promise<Response | undefined>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const log = createLogger("webapp:server");
|
|
22
|
+
|
|
23
|
+
export function createServerLifecycle<TEvent = unknown>(dependencies: ServerLifecycleDependencies<TEvent>) {
|
|
24
|
+
const {
|
|
25
|
+
config,
|
|
26
|
+
version,
|
|
27
|
+
deviceAuthEnabled,
|
|
28
|
+
publicRoutes,
|
|
29
|
+
appWebsockets,
|
|
30
|
+
realtime,
|
|
31
|
+
ensureWebDocument,
|
|
32
|
+
documentProvider,
|
|
33
|
+
handleRequest,
|
|
34
|
+
} = dependencies;
|
|
35
|
+
|
|
36
|
+
function customHandler(socket: ServerWebSocket<WebAppWebSocketData>): Partial<WebSocketHandler<WebAppWebSocketData>> | undefined {
|
|
37
|
+
const handlerName = socket.data.webappSocketHandler;
|
|
38
|
+
return handlerName ? appWebsockets[handlerName] : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function start(): Promise<Server<WebAppWebSocketData>> {
|
|
42
|
+
const webDocument = await ensureWebDocument();
|
|
43
|
+
const dynamicHandler = (req: Request, server: Server<WebAppWebSocketData>) => handleRequest(req, server);
|
|
44
|
+
const publicRouteHandlers = Object.fromEntries([
|
|
45
|
+
...Object.keys(webDocument.generatedPublicRoutes),
|
|
46
|
+
...Object.keys(publicRoutes),
|
|
47
|
+
].map((path) => [path, dynamicHandler]));
|
|
48
|
+
const spaFallbackRoute = {
|
|
49
|
+
GET: dynamicHandler,
|
|
50
|
+
HEAD: dynamicHandler,
|
|
51
|
+
POST: dynamicHandler,
|
|
52
|
+
PUT: dynamicHandler,
|
|
53
|
+
PATCH: dynamicHandler,
|
|
54
|
+
DELETE: dynamicHandler,
|
|
55
|
+
OPTIONS: dynamicHandler,
|
|
56
|
+
};
|
|
57
|
+
// Bun only transforms HTMLBundle modules/HMR when the bundle is mounted directly.
|
|
58
|
+
// Wrapping it in a handler or Response, or adding route-level headers, serves
|
|
59
|
+
// untransformed module paths and breaks generated document routes.
|
|
60
|
+
const spaDocumentRoute = webDocument.bundle ? {
|
|
61
|
+
...spaFallbackRoute,
|
|
62
|
+
GET: webDocument.bundle as never,
|
|
63
|
+
HEAD: webDocument.bundle as never,
|
|
64
|
+
} : spaFallbackRoute;
|
|
65
|
+
const entryRoute = webDocument.bundle ? { [webDocument.entryPublicPath]: webDocument.bundle as never } : {};
|
|
66
|
+
const server = Bun.serve<WebAppWebSocketData>({
|
|
67
|
+
hostname: config.host,
|
|
68
|
+
port: config.port,
|
|
69
|
+
routes: {
|
|
70
|
+
...publicRouteHandlers,
|
|
71
|
+
...entryRoute,
|
|
72
|
+
"/api/*": dynamicHandler,
|
|
73
|
+
"/.well-known/*": dynamicHandler,
|
|
74
|
+
"/device": deviceAuthEnabled ? spaDocumentRoute : dynamicHandler,
|
|
75
|
+
"/setup": spaDocumentRoute,
|
|
76
|
+
"/*": spaDocumentRoute,
|
|
77
|
+
},
|
|
78
|
+
websocket: {
|
|
79
|
+
open(socket) {
|
|
80
|
+
const handler = customHandler(socket);
|
|
81
|
+
if (handler?.open) {
|
|
82
|
+
handler.open(socket);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
realtime.add(socket);
|
|
86
|
+
},
|
|
87
|
+
message(socket, message) {
|
|
88
|
+
const handler = customHandler(socket);
|
|
89
|
+
if (handler?.message) {
|
|
90
|
+
handler.message(socket, message);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (message === "ping") {
|
|
94
|
+
socket.send(JSON.stringify({ type: "pong" }));
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
close(socket, code, reason) {
|
|
98
|
+
const handler = customHandler(socket);
|
|
99
|
+
if (handler?.close) {
|
|
100
|
+
handler.close(socket, code, reason);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
realtime.remove(socket);
|
|
104
|
+
},
|
|
105
|
+
drain(socket) {
|
|
106
|
+
customHandler(socket)?.drain?.(socket);
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
development: config.development,
|
|
110
|
+
});
|
|
111
|
+
const stop = server.stop.bind(server);
|
|
112
|
+
server.stop = ((closeActiveConnections?: boolean) => {
|
|
113
|
+
stop(closeActiveConnections);
|
|
114
|
+
documentProvider.dispose(webDocument);
|
|
115
|
+
}) as typeof server.stop;
|
|
116
|
+
log.info(`${config.appName} server running`, { url: String(server.url) });
|
|
117
|
+
return server;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function runFromCli(argv = Bun.argv.slice(2)): Promise<void> {
|
|
121
|
+
const command = argv[0] ?? "serve";
|
|
122
|
+
if (command === "serve") {
|
|
123
|
+
await start();
|
|
124
|
+
return await new Promise(() => undefined);
|
|
125
|
+
}
|
|
126
|
+
if (command === "version") {
|
|
127
|
+
console.log(version);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (command === "config") {
|
|
131
|
+
console.log(JSON.stringify(safeRuntimeConfig(config), null, 2));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
throw new Error(`Unknown command: ${command}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { start, runFromCli };
|
|
138
|
+
}
|