@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.
Files changed (56) hide show
  1. package/README.md +12 -4
  2. package/docs/auth-validation.md +11 -0
  3. package/docs/auth.md +33 -0
  4. package/docs/cli.md +73 -3
  5. package/docs/deployment.md +84 -7
  6. package/docs/getting-started.md +69 -6
  7. package/docs/github-actions.md +39 -0
  8. package/docs/release.md +5 -5
  9. package/docs/server.md +34 -4
  10. package/docs/settings.md +5 -0
  11. package/docs/ui-guidelines.md +12 -4
  12. package/package.json +2 -4
  13. package/src/build/build-binary.ts +67 -24
  14. package/src/cli/api-command.ts +53 -16
  15. package/src/cli/credentials.ts +275 -4
  16. package/src/cli/device-auth.ts +83 -22
  17. package/src/cli/environment-auth.ts +57 -0
  18. package/src/cli/index.ts +1 -0
  19. package/src/package-resolution.ts +34 -0
  20. package/src/server/auth/device-auth.ts +2 -2
  21. package/src/server/auth/passkeys.ts +16 -16
  22. package/src/server/auth/request-origin.ts +97 -16
  23. package/src/server/authentication.ts +265 -0
  24. package/src/server/create-web-app-server.ts +85 -1391
  25. package/src/server/framework-endpoints.ts +451 -0
  26. package/src/server/index.ts +1 -0
  27. package/src/server/public-route-dispatch.ts +85 -0
  28. package/src/server/request-schemas.ts +144 -0
  29. package/src/server/responses.ts +69 -3
  30. package/src/server/route-dispatch.ts +109 -0
  31. package/src/server/runtime-config.ts +93 -1
  32. package/src/server/same-origin.ts +1 -1
  33. package/src/server/server-lifecycle.ts +138 -0
  34. package/src/server/server-types.ts +87 -0
  35. package/src/server/web-document.ts +532 -0
  36. package/src/web/WebAppRoot.tsx +69 -1214
  37. package/src/web/api-client.ts +14 -4
  38. package/src/web/app-shell.tsx +198 -0
  39. package/src/web/auth-screens.tsx +186 -0
  40. package/src/web/components/index.tsx +10 -3
  41. package/src/web/index.ts +1 -0
  42. package/src/web/mobile-hooks.ts +194 -0
  43. package/src/web/mobile.ts +12 -0
  44. package/src/web/root-types.ts +61 -0
  45. package/src/web/routing.ts +61 -0
  46. package/src/web/settings/account-section.tsx +52 -0
  47. package/src/web/settings/resource-state.tsx +17 -0
  48. package/src/web/settings/security-section.tsx +119 -0
  49. package/src/web/settings/sessions-section.tsx +66 -0
  50. package/src/web/settings/settings-view.tsx +96 -0
  51. package/src/web/settings/shutdown-section.tsx +95 -0
  52. package/src/web/settings/user-management.tsx +123 -0
  53. package/src/web/settings-view.tsx +3 -0
  54. package/src/web/sidebar-state.ts +108 -0
  55. package/src/web/sidebar-tree.tsx +89 -0
  56. 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
+ });
@@ -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 async function parseJson<T>(req: Request): Promise<T> {
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
- return await req.json() as T;
97
+ value = JSON.parse(text);
33
98
  } catch {
34
- throw new Error("Request body must be valid JSON");
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: readEnv(envPrefix, "PUBLIC_BASE_URL"),
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.publicBaseUrl).origin;
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
+ }