@antelopejs/dms-frontend 0.5.3 → 0.5.5

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 CHANGED
@@ -159,7 +159,7 @@ The generated Vue application uses `@inertiajs/vue3`, `@nuxt/ui/vite` with `{ ro
159
159
 
160
160
  Vue modules register through `dms.frontend.ts` and import the SDK from the `#dms/frontend-module` alias, which the loader resolves to the generated `frontend-module.ts`: `defineDmsPlugin`, `useDmsRouter`, the page and session types, and everything else a module needs from the host. Email templates register separately through `dms.email.ts`.
161
161
 
162
- An email entry exports `serverEmailTemplates` and may export a plain `appConfig` object, such as shared branding defaults. Email rendering merges these configurations in manifest-priority order and provides them to `useDmsAppConfig` per render. Public runtime options come from the module manifest; `DMS_CLIENT_BASE_URL` overrides `public.dms.clientBaseUrl`. Email entries must not import the browser frontend module.
162
+ An email entry exports `serverEmailTemplates` and may export a plain `appConfig` object, such as shared branding defaults. Email rendering merges these configurations in manifest-priority order and provides them to `useDmsAppConfig` per render. Public runtime options come from the module manifest; `DMS_CLIENT_BASE_URL` overrides `public.dms.clientBaseUrl`. Email entries must not import the browser frontend module. The rendered HTML carries no Vue hydration comments (`<!--[-->`, `<!---->`, teleport anchors): an email is never hydrated, so the renderer strips them, and keeps every other comment, Outlook's conditional comments included.
163
163
 
164
164
  Email builds ship complete merged translation catalogs as JSON under `dist/server/locales/`, separate from executable JavaScript. Deploy the entire `dist/server` directory. Each render loads only its requested language and the English fallback; arbitrary translation keys and module overrides remain available. Unknown languages fall back to English, while missing or corrupt files for a supported language fail rendering rather than silently dropping translations. The source harness keeps the 256 KiB JavaScript ceiling and reports locale-data bytes separately.
165
165
 
@@ -426,6 +426,57 @@ pair can be turned into a login by a request from the browser. An undeclared
426
426
  endpoint is answered `403` and never called. The route is `POST`-only and
427
427
  same-origin, like every other auth action.
428
428
 
429
+ ### Public endpoints
430
+
431
+ Every state-changing request the server proxies to `/api/` must carry an
432
+ `Origin` matching the frontend's own, and goes out with the signed-in user's
433
+ session. A route meant for callers outside the dashboard — a mail provider's
434
+ delivery webhook, a payment provider's callback — fails both ways: the caller
435
+ sends no `Origin`, and its secret header is not among the few the proxy
436
+ relays.
437
+
438
+ A backend module declares such a route public when it registers its
439
+ frontend:
440
+
441
+ ```ts
442
+ await AddFrontendModule({
443
+ name: "@antelopejs/dms-mailing-frontend-vue",
444
+ sourcePath: path.join(__dirname, "../frontend-vue"),
445
+ renderer: { name: "vue", version: "3" },
446
+ publicEndpoints: [
447
+ {
448
+ path: "/api/mailing/events/*/*",
449
+ methods: ["POST"],
450
+ headers: ["x-mailing-webhook-secret"],
451
+ },
452
+ ],
453
+ });
454
+ ```
455
+
456
+ A request to a public endpoint:
457
+
458
+ - needs no `Origin`;
459
+ - is never sent the session, even from a signed-in browser, so a cross-site
460
+ form posting to it acts as nobody: the route authenticates its caller
461
+ itself (a shared secret, a signature);
462
+ - carries the module's declared headers on top of the usual ones.
463
+
464
+ `path` is an absolute `/api/` path whose first segment is literal (the
465
+ module's own prefix, never `*`) and whose later `*` segments match exactly one
466
+ segment; nothing else matches (no prefix, no query string, no `.` or `..`
467
+ segment). The proxy matches the request's normalized path, the one it sends
468
+ the backend, so `..`, `%2e%2e` or `\` cannot reach another route through a
469
+ public one. `methods` defaults
470
+ to `["POST"]`. `headers` names the module's own `x-` headers; the ones a proxy
471
+ or the DMS gives a meaning to (`x-forwarded-*`, `x-real-ip`, `x-dms-*`,
472
+ `x-inertia*`, method overrides) are refused, as are `authorization` and
473
+ `cookie`. A malformed declaration is dropped whole, with a warning, never
474
+ widened. `DMS_PUBLIC_ENDPOINTS` adds entries of the same shape, as a JSON
475
+ array, for a route no module declares.
476
+
477
+ Every proxied request also relays `If-Match`, so a backend route can refuse a
478
+ write made from a stale copy (`412` or `409`).
479
+
429
480
  ### Workspaces
430
481
 
431
482
  Each canonical backend URL gets its own owner-only workspace under
@@ -529,6 +580,7 @@ browser as before.
529
580
  | | `DMS_HTML_RENDER_SECRET` | Secret that verifies the backend's signed HTML/email render requests; without it, e-mail renders are refused. Defaults to the `htmlRender.serviceSecret` the backend publishes in the frontend manifest |
530
581
  | | `DMS_OAUTH_RELAY_SECRET` | Secret the server presents to the backend's OAuth endpoints; without it, the backend refuses OAuth sign-in. Defaults to the `oauth.relaySecret` the backend publishes in the frontend manifest |
531
582
  | | `DMS_AUTH_ESTABLISH_ENDPOINTS` | Extra backend endpoints `/auth/establish` may open a session from, on top of those the backend's modules declare (comma-separated, empty by default) |
583
+ | | `DMS_PUBLIC_ENDPOINTS` | Extra backend routes the proxy serves to anyone, on top of those the backend's modules declare: a JSON array of `{ path, methods?, headers? }` (see [Public endpoints](#public-endpoints)) |
532
584
  | | `DMS_CLIENT_BASE_URL` | Public frontend URL used in generated links and emails |
533
585
 
534
586
  All of these can be set in the project's `.env` instead of the environment; see [Configuration](#configuration).
package/dist/config.d.ts CHANGED
@@ -11,6 +11,7 @@ export declare const LAYERS_SUBDIR = "frontend-modules";
11
11
  export declare const FRONTEND_MODULE_ENTRY = "dms.frontend.ts";
12
12
  export declare const FRONTEND_BUILD_ENTRY = "dms.frontend.build.ts";
13
13
  export declare const AUTH_ESTABLISH_FILE = "generated-auth-establish.json";
14
+ export declare const PUBLIC_ENDPOINTS_FILE = "generated-public-endpoints.json";
14
15
  export declare const LAYER_COPY_BLOCKLIST: readonly string[];
15
16
  export declare const layerCopyIgnore: ignore.Ignore;
16
17
  export declare const PNPM_LIFECYCLE_SCRIPTS: readonly string[];
package/dist/config.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.Options = exports.UsageError = exports.TAILWIND_SOURCE_GLOB = exports.PNPM_LIFECYCLE_SCRIPTS = exports.layerCopyIgnore = exports.LAYER_COPY_BLOCKLIST = exports.AUTH_ESTABLISH_FILE = exports.FRONTEND_BUILD_ENTRY = exports.FRONTEND_MODULE_ENTRY = exports.LAYERS_SUBDIR = exports.TEMPLATE_FILES = exports.WORKSPACE_DIR_MODE = exports.DEPS_HASH_FILE = exports.DMS_FRONTEND_HOME = void 0;
6
+ exports.Options = exports.UsageError = exports.TAILWIND_SOURCE_GLOB = exports.PNPM_LIFECYCLE_SCRIPTS = exports.layerCopyIgnore = exports.LAYER_COPY_BLOCKLIST = exports.PUBLIC_ENDPOINTS_FILE = exports.AUTH_ESTABLISH_FILE = exports.FRONTEND_BUILD_ENTRY = exports.FRONTEND_MODULE_ENTRY = exports.LAYERS_SUBDIR = exports.TEMPLATE_FILES = exports.WORKSPACE_DIR_MODE = exports.DEPS_HASH_FILE = exports.DMS_FRONTEND_HOME = void 0;
7
7
  exports.writeSecretBearingFile = writeSecretBearingFile;
8
8
  exports.parseBackendUrl = parseBackendUrl;
9
9
  exports.requireBackendUrl = requireBackendUrl;
@@ -56,6 +56,7 @@ exports.TEMPLATE_FILES = [
56
56
  ["email-renderer.ts", "email-renderer.ts"],
57
57
  ["email-runtime.ts", "email-runtime.ts"],
58
58
  ["email-locales.ts", "email-locales.ts"],
59
+ ["email-markup.ts", "email-markup.ts"],
59
60
  ["tsconfig.json", "tsconfig.json"],
60
61
  ["typecheck-loader.mjs", "typecheck-loader.mjs"],
61
62
  ["app-config-stub.mjs", "app-config-stub.mjs"],
@@ -71,6 +72,7 @@ exports.LAYERS_SUBDIR = "frontend-modules";
71
72
  exports.FRONTEND_MODULE_ENTRY = "dms.frontend.ts";
72
73
  exports.FRONTEND_BUILD_ENTRY = "dms.frontend.build.ts";
73
74
  exports.AUTH_ESTABLISH_FILE = "generated-auth-establish.json";
75
+ exports.PUBLIC_ENDPOINTS_FILE = "generated-public-endpoints.json";
74
76
  exports.LAYER_COPY_BLOCKLIST = [
75
77
  "node_modules",
76
78
  "dist",
package/dist/help.js CHANGED
@@ -41,6 +41,10 @@ exports.ENVIRONMENT_VARIABLES = [
41
41
  "DMS_AUTH_ESTABLISH_ENDPOINTS",
42
42
  "More backend endpoints /auth/establish may open a session from, comma-separated",
43
43
  ],
44
+ [
45
+ "DMS_PUBLIC_ENDPOINTS",
46
+ "More backend routes the proxy serves to anyone, as a JSON array",
47
+ ],
44
48
  ["DMS_CLIENT_BASE_URL", "Public frontend URL used in links and e-mails"],
45
49
  ["NO_COLOR", "Same as --no-color"],
46
50
  ["ANTELOPEJS_VERBOSE", "Same as --verbose"],
package/dist/layers.js CHANGED
@@ -126,6 +126,7 @@ function resolveLayer(layerPath, mod) {
126
126
  configKey: mod.configKey,
127
127
  options: mod.options,
128
128
  authEstablishEndpoints: mod.authEstablishEndpoints,
129
+ publicEndpoints: mod.publicEndpoints,
129
130
  };
130
131
  }
131
132
  function buildLayersFromPaths(modules) {
@@ -95,9 +95,16 @@ function writeAuthEstablishEndpoints(workspaceDir, layers) {
95
95
  const endpoints = collectAuthEstablishEndpoints(layers);
96
96
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(workspaceDir, config_1.AUTH_ESTABLISH_FILE), `${JSON.stringify({ endpoints }, null, 2)}\n`);
97
97
  }
98
+ function collectPublicEndpoints(layers) {
99
+ return layers.flatMap((layer) => (Array.isArray(layer.publicEndpoints) ? layer.publicEndpoints : []).filter((endpoint) => typeof endpoint === "object" && endpoint !== null));
100
+ }
101
+ function writePublicEndpoints(workspaceDir, layers) {
102
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(workspaceDir, config_1.PUBLIC_ENDPOINTS_FILE), `${JSON.stringify({ endpoints: collectPublicEndpoints(layers) }, null, 2)}\n`);
103
+ }
98
104
  function writeFrontendModuleRegistry(workspaceDir, layers) {
99
105
  const registry = (0, derived_outputs_1.createFrontendModuleRegistry)(workspaceDir, layers);
100
106
  writeAuthEstablishEndpoints(workspaceDir, layers);
107
+ writePublicEndpoints(workspaceDir, layers);
101
108
  for (const output of derived_outputs_1.DERIVED_OUTPUTS)
102
109
  output.write(workspaceDir, registry);
103
110
  }
@@ -15,6 +15,12 @@ export interface ManifestModule {
15
15
  privateOptions?: FrontendModuleOptions;
16
16
  configKey?: string;
17
17
  authEstablishEndpoints?: string[];
18
+ publicEndpoints?: PublicEndpointDeclaration[];
19
+ }
20
+ export interface PublicEndpointDeclaration {
21
+ path: string;
22
+ methods?: string[];
23
+ headers?: string[];
18
24
  }
19
25
  export interface Manifest {
20
26
  pack: string;
@@ -34,6 +40,7 @@ export interface ResolvedLayer {
34
40
  configKey?: string;
35
41
  options?: FrontendModuleOptions;
36
42
  authEstablishEndpoints?: string[];
43
+ publicEndpoints?: PublicEndpointDeclaration[];
37
44
  }
38
45
  export declare function getWorkspaceDirForKey(key: string): string;
39
46
  export declare function projectWorkspaceKey(projectDir: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The comments Vue's server renderer leaves for hydration: fragment brackets
3
+ * (`<!--[-->`, `<!--]-->`), an empty `v-if` branch (`<!---->`, `<!--v-if-->`)
4
+ * and teleport anchors. An email is never hydrated, so they only weigh on the
5
+ * message and show in its source. Any other comment, such as Outlook's
6
+ * conditional comments (`<!--[if mso]>`), is kept.
7
+ */
8
+ const SSR_MARKERS =
9
+ /<!--(?:\[|\]|v-if|teleport(?: start| end)?(?: anchor)?|)-->/g;
10
+
11
+ /** Removes Vue's hydration markers from server-rendered email markup. */
12
+ export function stripSsrMarkers(html: string): string {
13
+ return html.replace(SSR_MARKERS, "");
14
+ }
@@ -4,6 +4,7 @@ import { type Component, createSSRApp, h } from "vue";
4
4
  import { createI18n } from "vue-i18n";
5
5
  import moduleRegistry from "./generated-frontend-modules.json";
6
6
  import { loadEmailLocaleMessages } from "./email-locales";
7
+ import { stripSsrMarkers } from "./email-markup";
7
8
 
8
9
  interface EmailModule {
9
10
  default: Component;
@@ -155,5 +156,5 @@ export async function renderEmail(
155
156
  ),
156
157
  });
157
158
  registerEmailElements(app);
158
- return `<!doctype html>${await renderToString(app)}`;
159
+ return `<!doctype html>${stripSsrMarkers(await renderToString(app))}`;
159
160
  }
@@ -1,5 +1,5 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
- import { backend, json } from "./backend.mjs";
2
+ import { backend, json, UpstreamError } from "./backend.mjs";
3
3
  import { clientIp } from "./client-ip.mjs";
4
4
  import {
5
5
  clearSealedCookie,
@@ -11,6 +11,8 @@ import {
11
11
  const FLOW = "dms_oauth_flow";
12
12
  const HANDOFF = "dms_oauth_handoff";
13
13
  const COMPLETE = "/auth/oauth/complete";
14
+ const OAUTH_ERROR = "error.oauth.";
15
+ const FAILED = "error.oauth.failed";
14
16
  const FLOW_LIFETIME_MS = 10 * 60 * 1000;
15
17
  const RATE_WINDOW_MS = 60 * 1000;
16
18
  const RATE_LIMIT = 10;
@@ -78,13 +80,24 @@ function errorRedirect(response, key) {
78
80
  redirect(response, `/auth?oauth_error=${encodeURIComponent(key)}`);
79
81
  }
80
82
 
83
+ // The backend's own OAuth key says why a provider sign-in failed; any other
84
+ // failure keeps the generic one.
85
+ function failureRedirect(response, error) {
86
+ errorRedirect(
87
+ response,
88
+ error instanceof UpstreamError && error.message.startsWith(OAUTH_ERROR)
89
+ ? error.message
90
+ : FAILED,
91
+ );
92
+ }
93
+
81
94
  export async function oauthStart(request, response, provider, url) {
82
95
  if (
83
96
  !isValidOAuthProvider(provider) ||
84
97
  request.headers["sec-fetch-site"] === "cross-site" ||
85
98
  rateLimited(request, "start")
86
99
  )
87
- return errorRedirect(response, "error.oauth.failed");
100
+ return errorRedirect(response, FAILED);
88
101
  try {
89
102
  const result = await backend(
90
103
  `/api/auth/oauth/${provider}/authorize-url`,
@@ -104,8 +117,8 @@ export async function oauthStart(request, response, provider, url) {
104
117
  FLOW_LIFETIME_MS / 1000,
105
118
  );
106
119
  redirect(response, result.authorizeUrl);
107
- } catch {
108
- errorRedirect(response, "error.oauth.failed");
120
+ } catch (error) {
121
+ failureRedirect(response, error);
109
122
  }
110
123
  }
111
124
 
@@ -174,14 +187,14 @@ async function completeOAuth(request, response, provider, url, flow) {
174
187
  ? `${COMPLETE}?redirect=${encodeURIComponent(flow.redirect)}`
175
188
  : COMPLETE,
176
189
  );
177
- } catch {
178
- errorRedirect(response, "error.oauth.failed");
190
+ } catch (error) {
191
+ failureRedirect(response, error);
179
192
  }
180
193
  }
181
194
 
182
195
  export async function oauthCallback(request, response, provider, url) {
183
196
  if (!isValidOAuthProvider(provider) || rateLimited(request, "callback"))
184
- return errorRedirect(response, "error.oauth.failed");
197
+ return errorRedirect(response, FAILED);
185
198
  const flow = readSealedCookie(request, FLOW);
186
199
  clearSealedCookie(response, FLOW);
187
200
  if (url.searchParams.has("error"))
@@ -0,0 +1,192 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { json } from "./auth/backend.mjs";
3
+ import { CROSS_ORIGIN_ERROR, isSameOrigin } from "./auth/client-ip.mjs";
4
+
5
+ // Written at the workspace root by the loader, from the endpoints each backend
6
+ // module declared in the frontend manifest. This file sits one directory up.
7
+ const DECLARED_ENDPOINTS_FILE = new URL(
8
+ "../generated-public-endpoints.json",
9
+ import.meta.url,
10
+ );
11
+
12
+ // An absolute backend API path whose first segment is literal (a `*` there
13
+ // would open a route of every module) and whose next ones are literal or `*`
14
+ // (exactly one segment): no prefix match, no query string, no segment that
15
+ // climbs out. Kept in step with `PUBLIC_PATH` in the DMS
16
+ // (`frontend-public-endpoints.ts`), which validates the modules' declarations.
17
+ const PUBLIC_PATH =
18
+ /^\/api\/[A-Za-z0-9][A-Za-z0-9._~-]*(?:\/(?:\*|[A-Za-z0-9][A-Za-z0-9._~-]*))*$/;
19
+ const SEGMENT_WILDCARD = "*";
20
+ const SEGMENT_PATTERN = "[^/]+";
21
+ const ALLOWED_METHODS = new Set([
22
+ "GET",
23
+ "HEAD",
24
+ "POST",
25
+ "PUT",
26
+ "PATCH",
27
+ "DELETE",
28
+ ]);
29
+ const DEFAULT_METHODS = ["POST"];
30
+ // A declared header is a module's own `x-` header. Those the proxy, a reverse
31
+ // proxy or the backend give a meaning to are never taken from the caller.
32
+ // Kept in step with the DMS's `RESERVED_HEADER`.
33
+ const HEADER_NAME = /^x-[a-z0-9-]+$/;
34
+ const RESERVED_HEADER =
35
+ /^x-(?:forwarded-|real-ip$|dms-|inertia|http-method|method-override|original-|rewrite-)/;
36
+
37
+ let declaredEndpoints;
38
+
39
+ /**
40
+ * The public endpoints the modules of this deployment declared, unchecked.
41
+ *
42
+ * @param file Generated file to read
43
+ * @returns The declared entries, or none when the file is absent or broken
44
+ */
45
+ export function readDeclaredPublicEndpoints(file = DECLARED_ENDPOINTS_FILE) {
46
+ try {
47
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
48
+ return Array.isArray(parsed?.endpoints) ? parsed.endpoints : [];
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ function environmentEndpoints(declaration) {
55
+ if (!declaration?.trim()) return [];
56
+ try {
57
+ const parsed = JSON.parse(declaration);
58
+ return Array.isArray(parsed) ? parsed : [];
59
+ } catch {
60
+ console.warn("Ignoring DMS_PUBLIC_ENDPOINTS: not a JSON array");
61
+ return [];
62
+ }
63
+ }
64
+
65
+ function normalizedMethods(methods) {
66
+ if (methods === undefined) return DEFAULT_METHODS;
67
+ if (!Array.isArray(methods) || methods.length === 0) return undefined;
68
+ const upper = methods.map((method) => String(method).toUpperCase());
69
+ return upper.every((method) => ALLOWED_METHODS.has(method))
70
+ ? upper
71
+ : undefined;
72
+ }
73
+
74
+ function normalizedHeaders(headers) {
75
+ if (headers === undefined) return [];
76
+ if (!Array.isArray(headers)) return undefined;
77
+ const lower = headers.map((name) => String(name).toLowerCase());
78
+ return lower.every(
79
+ (name) => HEADER_NAME.test(name) && !RESERVED_HEADER.test(name),
80
+ )
81
+ ? lower
82
+ : undefined;
83
+ }
84
+
85
+ function pathMatcher(path) {
86
+ const pattern = path
87
+ .split("/")
88
+ .map((segment) =>
89
+ segment === SEGMENT_WILDCARD
90
+ ? SEGMENT_PATTERN
91
+ : segment.replaceAll(".", "\\."),
92
+ )
93
+ .join("/");
94
+ return new RegExp(`^${pattern}/?$`);
95
+ }
96
+
97
+ /**
98
+ * A declaration made usable, or `undefined` when any part of it is malformed:
99
+ * a bad entry is dropped whole, never widened to what it might have meant.
100
+ */
101
+ export function compilePublicEndpoint(entry) {
102
+ if (typeof entry !== "object" || entry === null) return undefined;
103
+ if (typeof entry.path !== "string" || !PUBLIC_PATH.test(entry.path))
104
+ return undefined;
105
+ const methods = normalizedMethods(entry.methods);
106
+ const headers = normalizedHeaders(entry.headers);
107
+ if (!methods || !headers) return undefined;
108
+ return {
109
+ path: entry.path,
110
+ methods,
111
+ headers,
112
+ matcher: pathMatcher(entry.path),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * The backend endpoints this deployment serves to anyone: what the backend's
118
+ * modules declared, plus what `DMS_PUBLIC_ENDPOINTS` adds (a JSON array of the
119
+ * same `{ path, methods?, headers? }` entries).
120
+ */
121
+ export function allowedPublicEndpoints(
122
+ declaration = process.env.DMS_PUBLIC_ENDPOINTS,
123
+ declared = (declaredEndpoints ??= readDeclaredPublicEndpoints()),
124
+ ) {
125
+ return [...declared, ...environmentEndpoints(declaration)].flatMap(
126
+ (entry) => {
127
+ const endpoint = compilePublicEndpoint(entry);
128
+ if (!endpoint)
129
+ console.warn(
130
+ `Ignoring malformed public endpoint ${JSON.stringify(entry)}: expected ` +
131
+ '{ path: "/api/<prefix>/…" (segments or *), methods?: ["POST"], headers?: ["x-…"] }',
132
+ );
133
+ return endpoint ? [endpoint] : [];
134
+ },
135
+ );
136
+ }
137
+
138
+ // Compiled once per value of the environment variable, so a malformed entry
139
+ // is reported once rather than on every request.
140
+ let compiled;
141
+
142
+ function currentPublicEndpoints() {
143
+ const declaration = process.env.DMS_PUBLIC_ENDPOINTS;
144
+ if (!compiled || compiled.declaration !== declaration)
145
+ compiled = { declaration, endpoints: allowedPublicEndpoints(declaration) };
146
+ return compiled.endpoints;
147
+ }
148
+
149
+ /**
150
+ * The public endpoint a request reaches, if any.
151
+ *
152
+ * @param method The request's method
153
+ * @param pathname The request's normalized path, without its query string
154
+ * @param endpoints The endpoints to match, this deployment's by default
155
+ */
156
+ export function matchPublicEndpoint(
157
+ method,
158
+ pathname,
159
+ endpoints = currentPublicEndpoints(),
160
+ ) {
161
+ return endpoints.find(
162
+ (endpoint) =>
163
+ endpoint.methods.includes(method) && endpoint.matcher.test(pathname),
164
+ );
165
+ }
166
+
167
+ /**
168
+ * How the proxy treats a request: the public endpoint it reaches, if any, and
169
+ * whether it is refused for lacking a same-site Origin. A state-changing
170
+ * request needs one, unless it reaches a public endpoint, which is why the
171
+ * proxy never sends a public endpoint the session: a cross-site form posting
172
+ * to it acts as nobody.
173
+ *
174
+ * @param request The incoming request
175
+ * @param safeMethods Methods that change nothing and need no Origin
176
+ */
177
+ export function proxyAccess(request, safeMethods) {
178
+ const publicEndpoint = matchPublicEndpoint(
179
+ request.method,
180
+ new URL(request.url, "http://frontend.local").pathname,
181
+ );
182
+ const isRefused =
183
+ !publicEndpoint &&
184
+ !safeMethods.has(request.method) &&
185
+ !isSameOrigin(request);
186
+ return { publicEndpoint, isRefused };
187
+ }
188
+
189
+ /** Answers the 403 of a request `proxyAccess` refused. */
190
+ export function refuseCrossOrigin(response) {
191
+ json(response, 403, CROSS_ORIGIN_ERROR);
192
+ }
@@ -0,0 +1,53 @@
1
+ import { writeBadRequest } from "./request-failure.mjs";
2
+
3
+ // The request target as the server reads it. Node hands `request.url` over
4
+ // verbatim, so it is not always a path: `//host/x`, `/\host/x` and the
5
+ // absolute form `http://host/x` all name another host once resolved, which
6
+ // turned the proxy into an open one. Every routing decision and the URL sent to
7
+ // the backend read the same normalized path from here.
8
+
9
+ const FRONTEND_ORIGIN = "http://frontend.local";
10
+
11
+ /**
12
+ * Whether `target` is an origin-form request target: a path, optionally with a
13
+ * query, that stays on this server once resolved. Anything else (an absolute
14
+ * or authority form, `*`, a path a URL parser reads as another host) is not a
15
+ * request a browser sends to the frontend.
16
+ */
17
+ export function isOriginForm(target) {
18
+ if (typeof target !== "string" || !target.startsWith("/")) return false;
19
+ try {
20
+ return new URL(target, FRONTEND_ORIGIN).origin === FRONTEND_ORIGIN;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * The backend URL a request target is proxied to: the target's normalized path
28
+ * and query on the backend's origin. The path is set, not resolved, so a path
29
+ * normalizing to `//host` (`/x/..//host`) stays a path on the backend.
30
+ *
31
+ * @param target The request target, origin-form
32
+ * @param backendBaseUrl The backend's base URL
33
+ */
34
+ export function backendUrl(
35
+ target,
36
+ backendBaseUrl = process.env.DMS_API_BASE_URL,
37
+ ) {
38
+ const { pathname, search } = new URL(target, FRONTEND_ORIGIN);
39
+ const url = new URL(new URL(backendBaseUrl).origin);
40
+ url.pathname = pathname;
41
+ url.search = search;
42
+ return url;
43
+ }
44
+
45
+ /**
46
+ * Answers 400 to a request whose target is not origin-form, before any route
47
+ * reads it. Returns whether it answered.
48
+ */
49
+ export function refuseForeignTarget(request, response) {
50
+ if (isOriginForm(request.url)) return false;
51
+ writeBadRequest(response);
52
+ return true;
53
+ }
@@ -10,7 +10,7 @@ import {
10
10
  RequestBodyError,
11
11
  UpstreamError,
12
12
  } from "./server/auth/backend.mjs";
13
- import { CROSS_ORIGIN_ERROR, isSameOrigin } from "./server/auth/client-ip.mjs";
13
+ import { proxyAccess, refuseCrossOrigin } from "./server/public-endpoints.mjs";
14
14
  import {
15
15
  handleAuth,
16
16
  publicSession,
@@ -42,6 +42,7 @@ import { HOMEPAGE } from "./server/homepage.mjs";
42
42
  import { htmlTag } from "./server/html-tag.mjs";
43
43
  import * as icons from "./server/icons.mjs";
44
44
  import * as requestFailure from "./server/request-failure.mjs";
45
+ import { backendUrl, refuseForeignTarget } from "./server/request-target.mjs";
45
46
  import { handleTester } from "./server/tester.mjs";
46
47
  import {
47
48
  createInertiaPage,
@@ -120,15 +121,17 @@ async function developmentServer() {
120
121
  return vitePromise;
121
122
  }
122
123
 
123
- function backendHeaders(request) {
124
+ function backendHeaders(request, publicEndpoint) {
124
125
  const headers = relayedHeaders(request, [
125
126
  "accept",
126
127
  "content-type",
128
+ "if-match",
127
129
  "user-agent",
128
130
  "x-content-language",
129
131
  "x-realtime-session",
132
+ ...(publicEndpoint?.headers ?? []),
130
133
  ]);
131
- const session = readSession(request);
134
+ const session = publicEndpoint ? undefined : readSession(request);
132
135
  if (session?.accessToken)
133
136
  headers.authorization = `Bearer ${session.accessToken}`;
134
137
  return headers;
@@ -232,20 +235,19 @@ export function requestOwnership(method, pathname) {
232
235
  }
233
236
 
234
237
  async function proxy(request, response, fallbackOnNotFound = false) {
235
- if (!SAFE_METHODS.has(request.method) && !isSameOrigin(request)) {
236
- response.writeHead(403, { "content-type": JSON_TYPE });
237
- response.end(JSON.stringify(CROSS_ORIGIN_ERROR));
238
+ const { publicEndpoint, isRefused } = proxyAccess(request, SAFE_METHODS);
239
+ if (isRefused) {
240
+ refuseCrossOrigin(response);
238
241
  return true;
239
242
  }
240
- const target = new URL(request.url, process.env.DMS_API_BASE_URL);
241
243
  // Cancelling the body alone misses a client that leaves before the backend
242
244
  // answers: its stream, an SSE feed above all, then stayed open for good.
243
245
  const abandoned = new AbortController();
244
246
  response.once("close", () => abandoned.abort());
245
- const upstream = await fetch(target, {
247
+ const upstream = await fetch(backendUrl(request.url), {
246
248
  signal: abandoned.signal,
247
249
  method: request.method,
248
- headers: backendHeaders(request),
250
+ headers: backendHeaders(request, publicEndpoint),
249
251
  body: ["GET", "HEAD"].includes(request.method) ? undefined : request,
250
252
  duplex: "half",
251
253
  });
@@ -503,6 +505,7 @@ async function writeRequestFailure(error, request, response) {
503
505
 
504
506
  export async function handleRequestSafely(request, response) {
505
507
  try {
508
+ if (refuseForeignTarget(request, response)) return;
506
509
  await handleRequest(request, response);
507
510
  } catch (error) {
508
511
  // An error page failing too must not reject: that would end the process.