@antelopejs/dms-frontend 0.5.3 → 0.5.4

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.4",
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
+ }
@@ -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,
@@ -120,15 +120,17 @@ async function developmentServer() {
120
120
  return vitePromise;
121
121
  }
122
122
 
123
- function backendHeaders(request) {
123
+ function backendHeaders(request, publicEndpoint) {
124
124
  const headers = relayedHeaders(request, [
125
125
  "accept",
126
126
  "content-type",
127
+ "if-match",
127
128
  "user-agent",
128
129
  "x-content-language",
129
130
  "x-realtime-session",
131
+ ...(publicEndpoint?.headers ?? []),
130
132
  ]);
131
- const session = readSession(request);
133
+ const session = publicEndpoint ? undefined : readSession(request);
132
134
  if (session?.accessToken)
133
135
  headers.authorization = `Bearer ${session.accessToken}`;
134
136
  return headers;
@@ -232,9 +234,9 @@ export function requestOwnership(method, pathname) {
232
234
  }
233
235
 
234
236
  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));
237
+ const { publicEndpoint, isRefused } = proxyAccess(request, SAFE_METHODS);
238
+ if (isRefused) {
239
+ refuseCrossOrigin(response);
238
240
  return true;
239
241
  }
240
242
  const target = new URL(request.url, process.env.DMS_API_BASE_URL);
@@ -245,7 +247,7 @@ async function proxy(request, response, fallbackOnNotFound = false) {
245
247
  const upstream = await fetch(target, {
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
  });