@pablozaiden/webapp 0.5.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +11 -3
  2. package/docs/auth-validation.md +11 -0
  3. package/docs/auth.md +25 -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/sidebar.md +2 -0
  12. package/docs/ui-guidelines.md +12 -4
  13. package/package.json +2 -4
  14. package/src/build/build-binary.ts +67 -24
  15. package/src/cli/api-command.ts +53 -16
  16. package/src/cli/credentials.ts +275 -4
  17. package/src/cli/device-auth.ts +83 -22
  18. package/src/cli/environment-auth.ts +57 -0
  19. package/src/cli/index.ts +1 -0
  20. package/src/package-resolution.ts +34 -0
  21. package/src/server/auth/device-auth.ts +2 -2
  22. package/src/server/auth/passkeys.ts +16 -16
  23. package/src/server/auth/request-origin.ts +97 -16
  24. package/src/server/authentication.ts +265 -0
  25. package/src/server/create-web-app-server.ts +85 -1391
  26. package/src/server/framework-endpoints.ts +451 -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 +67 -0
  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 -1132
  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 +78 -61
@@ -1,4 +1,4 @@
1
- import { createJsonFileStore, type JsonFileStore } from "./credentials";
1
+ import { createJsonFileStore, type JsonFileStore, type JsonFileStoreLockOptions } from "./credentials";
2
2
 
3
3
  export interface StoredDeviceCredentials {
4
4
  baseUrl: string;
@@ -12,6 +12,22 @@ export interface StoredDeviceCredentials {
12
12
  updatedAt: string;
13
13
  }
14
14
 
15
+ type DeviceCredentialsStoreWrite = {
16
+ write(value: StoredDeviceCredentials): Promise<void>;
17
+ };
18
+
19
+ type ReadableDeviceCredentialsStore = DeviceCredentialsStoreWrite & {
20
+ read(): Promise<StoredDeviceCredentials | undefined>;
21
+ withLock?: <T>(callback: () => Promise<T>, options?: JsonFileStoreLockOptions) => Promise<T>;
22
+ };
23
+
24
+ export type DeviceCredentialsStore =
25
+ | (DeviceCredentialsStoreWrite & {
26
+ read?: () => Promise<StoredDeviceCredentials | undefined>;
27
+ withLock?: never;
28
+ })
29
+ | ReadableDeviceCredentialsStore;
30
+
15
31
  export function normalizeBaseUrl(value: string): string {
16
32
  const url = new URL(value.trim());
17
33
  if (url.protocol !== "http:" && url.protocol !== "https:") {
@@ -90,48 +106,93 @@ function tokenError(body: unknown, status: number): string {
90
106
  return `Request failed with status ${status}`;
91
107
  }
92
108
 
93
- function tokenCredentials(baseUrl: string, clientId: string, tokenSet: Record<string, unknown>, now: Date): StoredDeviceCredentials {
94
- const expiresIn = Number(tokenSet["expires_in"] ?? 0);
109
+ function tokenCredentials(baseUrl: string, clientId: string, tokenSet: unknown, now: Date): StoredDeviceCredentials {
110
+ if (!tokenSet || typeof tokenSet !== "object" || Array.isArray(tokenSet)) {
111
+ throw new Error("Token response is invalid");
112
+ }
113
+ const record = tokenSet as Record<string, unknown>;
114
+ const accessToken = record["access_token"];
115
+ const refreshToken = record["refresh_token"];
116
+ const tokenType = record["token_type"];
117
+ const expiresIn = record["expires_in"];
118
+ const scope = record["scope"];
119
+ if (
120
+ typeof accessToken !== "string" ||
121
+ accessToken.length === 0 ||
122
+ typeof refreshToken !== "string" ||
123
+ refreshToken.length === 0 ||
124
+ tokenType !== "Bearer" ||
125
+ typeof expiresIn !== "number" ||
126
+ !Number.isFinite(expiresIn) ||
127
+ expiresIn < 0 ||
128
+ (scope !== undefined && typeof scope !== "string")
129
+ ) {
130
+ throw new Error("Token response is invalid");
131
+ }
95
132
  return {
96
133
  baseUrl,
97
134
  clientId,
98
- accessToken: String(tokenSet["access_token"] ?? ""),
99
- refreshToken: String(tokenSet["refresh_token"] ?? ""),
135
+ accessToken,
136
+ refreshToken,
100
137
  tokenType: "Bearer",
101
- scope: String(tokenSet["scope"] ?? ""),
138
+ scope: scope ?? "",
102
139
  accessTokenExpiresAt: new Date(now.getTime() + expiresIn * 1000).toISOString(),
103
140
  createdAt: now.toISOString(),
104
141
  updatedAt: now.toISOString(),
105
142
  };
106
143
  }
107
144
 
108
- export async function refreshDeviceCredentials(input: {
109
- credentials: StoredDeviceCredentials;
110
- store?: { write(value: StoredDeviceCredentials): Promise<void> };
111
- fetchFn?: typeof fetch;
112
- now?: () => Date;
113
- }): Promise<StoredDeviceCredentials | undefined> {
114
- if (!isExpired(input.credentials, (input.now ?? (() => new Date()))())) {
115
- return input.credentials;
116
- }
117
- const now = input.now?.() ?? new Date();
118
- const { response, body } = await requestJson(input.fetchFn ?? fetch, `${input.credentials.baseUrl}/api/auth/token`, {
145
+ async function refreshCredentialsOnce(
146
+ credentials: StoredDeviceCredentials,
147
+ store: DeviceCredentialsStore | undefined,
148
+ fetchFn: typeof fetch,
149
+ now: () => Date,
150
+ ): Promise<StoredDeviceCredentials | undefined> {
151
+ const issuedAt = now();
152
+ const { response, body } = await requestJson(fetchFn, `${credentials.baseUrl}/api/auth/token`, {
119
153
  method: "POST",
120
154
  headers: { "content-type": "application/json" },
121
155
  body: JSON.stringify({
122
156
  grant_type: "refresh_token",
123
- refresh_token: input.credentials.refreshToken,
124
- client_id: input.credentials.clientId,
157
+ refresh_token: credentials.refreshToken,
158
+ client_id: credentials.clientId,
125
159
  }),
126
160
  });
127
161
  if (!response.ok) {
128
162
  return undefined;
129
163
  }
130
- const next = tokenCredentials(input.credentials.baseUrl, input.credentials.clientId, body as Record<string, unknown>, now);
131
- await input.store?.write(next);
164
+ const next = tokenCredentials(credentials.baseUrl, credentials.clientId, body, issuedAt);
165
+ await store?.write(next);
132
166
  return next;
133
167
  }
134
168
 
169
+ export async function refreshDeviceCredentials(input: {
170
+ credentials: StoredDeviceCredentials;
171
+ store?: DeviceCredentialsStore;
172
+ fetchFn?: typeof fetch;
173
+ now?: () => Date;
174
+ }): Promise<StoredDeviceCredentials | undefined> {
175
+ const now = input.now ?? (() => new Date());
176
+ if (!isExpired(input.credentials, now())) {
177
+ return input.credentials;
178
+ }
179
+ const fetchFn = input.fetchFn ?? fetch;
180
+ const store = input.store;
181
+ if (store?.withLock) {
182
+ return store.withLock(async () => {
183
+ const current = await store.read();
184
+ if (!current) {
185
+ throw new Error("Credentials store is unavailable after acquiring refresh lock");
186
+ }
187
+ if (!isExpired(current, now())) {
188
+ return current;
189
+ }
190
+ return refreshCredentialsOnce(current, store, fetchFn, now);
191
+ });
192
+ }
193
+ return refreshCredentialsOnce(input.credentials, store, fetchFn, now);
194
+ }
195
+
135
196
  export async function runDeviceAuthCommand(input: {
136
197
  baseUrl: string;
137
198
  clientId: string;
@@ -168,7 +229,7 @@ export async function runDeviceAuthCommand(input: {
168
229
  }),
169
230
  });
170
231
  if (token.response.ok) {
171
- await input.store.write(tokenCredentials(baseUrl, input.clientId, token.body as Record<string, unknown>, now()));
232
+ await input.store.write(tokenCredentials(baseUrl, input.clientId, token.body, now()));
172
233
  out(`Authenticated with ${baseUrl}`);
173
234
  return 0;
174
235
  }
@@ -0,0 +1,57 @@
1
+ import { assertEnvPrefix } from "../server/runtime-config";
2
+ import { normalizeBaseUrl } from "./device-auth";
3
+
4
+ export interface CliAuthEnvironmentNames {
5
+ baseUrl: string;
6
+ apiKey: string;
7
+ }
8
+
9
+ export interface EnvironmentApiKeyAuth {
10
+ baseUrl: string;
11
+ apiKey: string;
12
+ source: "explicit-base-url" | "environment";
13
+ }
14
+
15
+ export type CliEnvironment = Readonly<Record<string, string | undefined>>;
16
+
17
+ function nonEmptyEnvValue(environment: CliEnvironment, name: string): string | undefined {
18
+ const value = environment[name];
19
+ if (typeof value !== "string") return undefined;
20
+ const trimmed = value.trim();
21
+ return trimmed || undefined;
22
+ }
23
+
24
+ export function cliAuthEnvironmentNames(envPrefix: string): CliAuthEnvironmentNames {
25
+ const prefix = assertEnvPrefix(envPrefix);
26
+ return {
27
+ baseUrl: `${prefix}_BASE_URL`,
28
+ apiKey: `${prefix}_API_KEY`,
29
+ };
30
+ }
31
+
32
+ export function resolveEnvironmentApiKeyAuth(input: {
33
+ envPrefix: string;
34
+ explicitBaseUrl?: string;
35
+ environment?: CliEnvironment;
36
+ }): EnvironmentApiKeyAuth | undefined {
37
+ const names = cliAuthEnvironmentNames(input.envPrefix);
38
+ const environment = input.environment ?? process.env;
39
+ const apiKey = nonEmptyEnvValue(environment, names.apiKey);
40
+ if (!apiKey) return undefined;
41
+
42
+ if (input.explicitBaseUrl !== undefined) {
43
+ return {
44
+ baseUrl: normalizeBaseUrl(input.explicitBaseUrl),
45
+ apiKey,
46
+ source: "explicit-base-url",
47
+ };
48
+ }
49
+
50
+ const baseUrl = nonEmptyEnvValue(environment, names.baseUrl);
51
+ if (!baseUrl) return undefined;
52
+ return {
53
+ baseUrl: normalizeBaseUrl(baseUrl),
54
+ apiKey,
55
+ source: "environment",
56
+ };
57
+ }
package/src/cli/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./runtime";
2
2
  export * from "./credentials";
3
3
  export * from "./device-auth";
4
+ export * from "./environment-auth";
4
5
  export * from "./api-command";
@@ -0,0 +1,34 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+
4
+ const REACT_DOM_CLIENT_SPECIFIER = "react-dom/client";
5
+
6
+ export function findPackageRoot(startDirectory: string): string {
7
+ const start = resolve(startDirectory);
8
+ let current = start;
9
+
10
+ while (true) {
11
+ if (existsSync(resolve(current, "package.json"))) {
12
+ return current;
13
+ }
14
+ const parent = dirname(current);
15
+ if (parent === current) {
16
+ throw new Error(`Unable to find an application package root from "${start}". Add a package.json to the application package.`);
17
+ }
18
+ current = parent;
19
+ }
20
+ }
21
+
22
+ export function resolveReactDomClient(applicationRoot: string, resolutionContext: string): string {
23
+ const root = resolve(applicationRoot);
24
+ const context = resolve(resolutionContext);
25
+
26
+ try {
27
+ return Bun.resolveSync(REACT_DOM_CLIENT_SPECIFIER, context);
28
+ } catch (error) {
29
+ throw new Error(
30
+ `Unable to resolve "${REACT_DOM_CLIENT_SPECIFIER}" from application package "${root}" using "${context}". Install "react-dom" in the application package and ensure its React peer dependencies are declared.`,
31
+ { cause: error },
32
+ );
33
+ }
34
+ }
@@ -11,7 +11,7 @@ import type { AuthSessionSummary, CurrentUser, DeviceAuthorizationResponse, Devi
11
11
  import type { RuntimeConfig } from "../runtime-config";
12
12
  import type { RefreshSessionRecord, WebAppStore } from "./store";
13
13
  import { addSeconds, isExpired, nowIso, randomToken, sha256 } from "./crypto";
14
- import { getRequestOriginInfo } from "./request-origin";
14
+ import { getRequestBaseUrl } from "./request-origin";
15
15
  import { AuthError, type AccessTokenClaims } from "./types";
16
16
  import { toCurrentUser } from "./users";
17
17
 
@@ -40,7 +40,7 @@ function generateUserCode(): string {
40
40
  }
41
41
 
42
42
  function getPublicBaseUrl(req: Request, config: RuntimeConfig): string {
43
- return config.publicBaseUrl ?? getRequestOriginInfo(req).origin;
43
+ return getRequestBaseUrl(req, config);
44
44
  }
45
45
 
46
46
  async function createSigningKey(): Promise<SigningKeyPair> {
@@ -83,10 +83,10 @@ function getCookie(req: Request, name: string): string | undefined {
83
83
  return undefined;
84
84
  }
85
85
 
86
- function cookieHeader(req: Request, name: string, value: string, maxAge: number, secure: boolean): string {
86
+ function cookieHeader(req: Request, config: RuntimeConfig, name: string, value: string, maxAge: number, secure: boolean): string {
87
87
  return [
88
88
  `${name}=${value}`,
89
- `Path=${getCookiePath(req)}`,
89
+ `Path=${getCookiePath(req, config)}`,
90
90
  "HttpOnly",
91
91
  "SameSite=Strict",
92
92
  `Max-Age=${maxAge}`,
@@ -94,12 +94,12 @@ function cookieHeader(req: Request, name: string, value: string, maxAge: number,
94
94
  ].filter(Boolean).join("; ");
95
95
  }
96
96
 
97
- function expiredCookie(req: Request, name: string, secure: boolean): string {
98
- return cookieHeader(req, name, "", 0, secure);
97
+ function expiredCookie(req: Request, config: RuntimeConfig, name: string, secure: boolean): string {
98
+ return cookieHeader(req, config, name, "", 0, secure);
99
99
  }
100
100
 
101
101
  function setSessionHeaders(req: Request, store: WebAppStore, config: RuntimeConfig, user: UserRecord): Headers {
102
- const origin = getRequestOriginInfo(req, config.publicBaseUrl);
102
+ const origin = getRequestOriginInfo(req, config);
103
103
  const secret = getSecret(store);
104
104
  const payload: SessionPayload = {
105
105
  nonce: randomToken(16),
@@ -108,16 +108,16 @@ function setSessionHeaders(req: Request, store: WebAppStore, config: RuntimeConf
108
108
  expiresAt: Date.now() + SESSION_TTL_SECONDS * 1000,
109
109
  };
110
110
  const headers = new Headers();
111
- headers.append("set-cookie", cookieHeader(req, SESSION_COOKIE, encodeSigned(payload, secret), SESSION_TTL_SECONDS, origin.secure));
112
- headers.append("set-cookie", expiredCookie(req, CHALLENGE_COOKIE, origin.secure));
111
+ headers.append("set-cookie", cookieHeader(req, config, SESSION_COOKIE, encodeSigned(payload, secret), SESSION_TTL_SECONDS, origin.secure));
112
+ headers.append("set-cookie", expiredCookie(req, config, CHALLENGE_COOKIE, origin.secure));
113
113
  return headers;
114
114
  }
115
115
 
116
116
  function setChallengeHeaders(req: Request, store: WebAppStore, config: RuntimeConfig, payload: Omit<ChallengePayload, "expiresAt">): Headers {
117
- const origin = getRequestOriginInfo(req, config.publicBaseUrl);
117
+ const origin = getRequestOriginInfo(req, config);
118
118
  const secret = getSecret(store);
119
119
  const headers = new Headers();
120
- headers.append("set-cookie", cookieHeader(req, CHALLENGE_COOKIE, encodeSigned({
120
+ headers.append("set-cookie", cookieHeader(req, config, CHALLENGE_COOKIE, encodeSigned({
121
121
  ...payload,
122
122
  expiresAt: Date.now() + CHALLENGE_TTL_SECONDS * 1000,
123
123
  }, secret), CHALLENGE_TTL_SECONDS, origin.secure));
@@ -145,7 +145,7 @@ function webauthnRpId(hostname: string): string {
145
145
  }
146
146
 
147
147
  async function beginRegistrationForUser(req: Request, store: WebAppStore, config: RuntimeConfig, user: { id: string; username: string }, type: ChallengePayload["type"], setupTokenHash?: string) {
148
- const origin = getRequestOriginInfo(req, config.publicBaseUrl);
148
+ const origin = getRequestOriginInfo(req, config);
149
149
  const rpID = webauthnRpId(origin.hostname);
150
150
  const options = await generateRegistrationOptions({
151
151
  rpName: config.appName,
@@ -163,7 +163,7 @@ async function beginRegistrationForUser(req: Request, store: WebAppStore, config
163
163
  }
164
164
 
165
165
  async function verifyAndSavePasskey(req: Request, store: WebAppStore, config: RuntimeConfig, response: RegistrationResponseJSON, user: UserRecord, challenge: ChallengePayload): Promise<Headers> {
166
- const origin = getRequestOriginInfo(req, config.publicBaseUrl);
166
+ const origin = getRequestOriginInfo(req, config);
167
167
  const rpID = webauthnRpId(origin.hostname);
168
168
  const verification = await verifyRegistrationResponse({
169
169
  response,
@@ -334,7 +334,7 @@ export async function beginAuthentication(req: Request, store: WebAppStore, conf
334
334
  if (passkeys.length === 0) {
335
335
  throw new AuthError("passkey_missing", "No passkey is configured", 409);
336
336
  }
337
- const origin = getRequestOriginInfo(req, config.publicBaseUrl);
337
+ const origin = getRequestOriginInfo(req, config);
338
338
  const rpID = webauthnRpId(origin.hostname);
339
339
  const options = await generateAuthenticationOptions({
340
340
  rpID,
@@ -354,7 +354,7 @@ export async function completeAuthentication(req: Request, store: WebAppStore, c
354
354
  if (!passkey || !user) {
355
355
  throw new AuthError("passkey_not_found", "Passkey credential is not registered", 404);
356
356
  }
357
- const origin = getRequestOriginInfo(req, config.publicBaseUrl);
357
+ const origin = getRequestOriginInfo(req, config);
358
358
  const rpID = webauthnRpId(origin.hostname);
359
359
  const verification = await verifyAuthenticationResponse({
360
360
  response,
@@ -380,10 +380,10 @@ export async function completeAuthentication(req: Request, store: WebAppStore, c
380
380
  }
381
381
 
382
382
  export function logoutHeaders(req: Request, config: RuntimeConfig): Headers {
383
- const origin = getRequestOriginInfo(req, config.publicBaseUrl);
383
+ const origin = getRequestOriginInfo(req, config);
384
384
  const headers = new Headers();
385
- headers.append("set-cookie", expiredCookie(req, SESSION_COOKIE, origin.secure));
386
- headers.append("set-cookie", expiredCookie(req, CHALLENGE_COOKIE, origin.secure));
385
+ headers.append("set-cookie", expiredCookie(req, config, SESSION_COOKIE, origin.secure));
386
+ headers.append("set-cookie", expiredCookie(req, config, CHALLENGE_COOKIE, origin.secure));
387
387
  return headers;
388
388
  }
389
389
 
@@ -1,33 +1,114 @@
1
+ import type { RuntimeConfig, TrustProxyHeader } from "../runtime-config";
2
+
1
3
  export interface RequestOriginInfo {
2
4
  origin: string;
3
5
  hostname: string;
4
6
  secure: boolean;
7
+ pathPrefix: string;
8
+ }
9
+
10
+ const FORWARDED_HEADER_NAMES: Record<TrustProxyHeader, string> = {
11
+ proto: "x-forwarded-proto",
12
+ host: "x-forwarded-host",
13
+ prefix: "x-forwarded-prefix",
14
+ };
15
+
16
+ function forwardedValue(req: Request, config: Pick<RuntimeConfig, "trustProxy">, header: TrustProxyHeader): string | undefined {
17
+ if (!config.trustProxy.enabled || !config.trustProxy.headers.includes(header)) {
18
+ return undefined;
19
+ }
20
+ const raw = req.headers.get(FORWARDED_HEADER_NAMES[header]);
21
+ if (raw === null) {
22
+ return undefined;
23
+ }
24
+ const values = raw.split(",").map((value) => value.trim()).filter(Boolean);
25
+ if (values.length === 0) {
26
+ return undefined;
27
+ }
28
+ return config.trustProxy.chain === "last" ? values[values.length - 1] : values[0];
5
29
  }
6
30
 
7
- export function getRequestOriginInfo(req: Request, publicBaseUrl?: string): RequestOriginInfo {
8
- if (publicBaseUrl) {
9
- const parsed = new URL(publicBaseUrl);
31
+ function parseOrigin(protocol: string, host: string): RequestOriginInfo | undefined {
32
+ if (!protocol || !host || /[\u0000-\u0020\u007f]/.test(host) || /[/?#\\]/.test(host)) {
33
+ return undefined;
34
+ }
35
+ try {
36
+ const parsed = new URL(`${protocol}://${host}`);
37
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
38
+ return undefined;
39
+ }
10
40
  return {
11
41
  origin: parsed.origin,
12
42
  hostname: parsed.hostname,
13
43
  secure: parsed.protocol === "https:",
44
+ pathPrefix: "/",
14
45
  };
46
+ } catch {
47
+ return undefined;
15
48
  }
49
+ }
50
+
51
+ function directOrigin(req: Request): RequestOriginInfo {
16
52
  const url = new URL(req.url);
17
- const forwardedProto = req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
18
- const forwardedHost = req.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
19
- const proto = forwardedProto || url.protocol.replace(":", "");
20
- const host = forwardedHost || req.headers.get("host") || url.host;
21
- const origin = `${proto}://${host}`;
22
- const parsed = new URL(origin);
23
- return {
24
- origin: parsed.origin,
25
- hostname: parsed.hostname,
26
- secure: parsed.protocol === "https:",
53
+ const protocol = url.protocol.replace(/:$/, "").toLowerCase();
54
+ const host = req.headers.get("host")?.trim() || url.host;
55
+ return parseOrigin(protocol, host) ?? {
56
+ origin: url.origin,
57
+ hostname: url.hostname,
58
+ secure: url.protocol === "https:",
59
+ pathPrefix: "/",
27
60
  };
28
61
  }
29
62
 
30
- export function getCookiePath(req: Request): string {
31
- const prefix = req.headers.get("x-forwarded-prefix")?.trim().replace(/\/+$/, "");
32
- return prefix || "/";
63
+ function forwardedProtocol(value: string | undefined): string | undefined {
64
+ const normalized = value?.toLowerCase();
65
+ return normalized === "http" || normalized === "https" ? normalized : undefined;
66
+ }
67
+
68
+ function forwardedHost(value: string | undefined): string | undefined {
69
+ if (!value || !parseOrigin("http", value)) {
70
+ return undefined;
71
+ }
72
+ return value;
73
+ }
74
+
75
+ function forwardedPrefix(value: string | undefined): string | undefined {
76
+ if (!value || !value.startsWith("/") || /[\u0000-\u0020\u007f?#\\]/.test(value)) {
77
+ return undefined;
78
+ }
79
+ const normalized = value.replace(/\/+$/, "");
80
+ if (!normalized || normalized === "/") {
81
+ return "/";
82
+ }
83
+ if (normalized.startsWith("//")) {
84
+ return undefined;
85
+ }
86
+ return normalized;
87
+ }
88
+
89
+ export function getRequestOriginInfo(req: Request, config: Pick<RuntimeConfig, "publicBaseUrl" | "trustProxy">): RequestOriginInfo {
90
+ const direct = directOrigin(req);
91
+ const prefix = forwardedPrefix(forwardedValue(req, config, "prefix")) ?? "/";
92
+ if (config.publicBaseUrl) {
93
+ const parsed = new URL(config.publicBaseUrl);
94
+ return {
95
+ origin: parsed.origin,
96
+ hostname: parsed.hostname,
97
+ secure: parsed.protocol === "https:",
98
+ pathPrefix: prefix,
99
+ };
100
+ }
101
+ const protocol = forwardedProtocol(forwardedValue(req, config, "proto")) ?? new URL(direct.origin).protocol.replace(/:$/, "");
102
+ const host = forwardedHost(forwardedValue(req, config, "host")) ?? new URL(direct.origin).host;
103
+ const resolved = parseOrigin(protocol, host);
104
+ return resolved ? { ...resolved, pathPrefix: prefix } : { ...direct, pathPrefix: prefix };
105
+ }
106
+
107
+ export function getCookiePath(req: Request, config: Pick<RuntimeConfig, "publicBaseUrl" | "trustProxy">): string {
108
+ return getRequestOriginInfo(req, config).pathPrefix;
109
+ }
110
+
111
+ export function getRequestBaseUrl(req: Request, config: Pick<RuntimeConfig, "publicBaseUrl" | "trustProxy">): string {
112
+ const { origin, pathPrefix } = getRequestOriginInfo(req, config);
113
+ return `${origin}${pathPrefix === "/" ? "" : pathPrefix}`;
33
114
  }