@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 +53 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +3 -1
- package/dist/help.js +4 -0
- package/dist/layers.js +1 -0
- package/dist/materialize.js +7 -0
- package/dist/workspace.d.ts +7 -0
- package/package.json +1 -1
- package/templates/vue/email-markup.ts +14 -0
- package/templates/vue/email-renderer.ts +2 -1
- package/templates/vue/server/auth/oauth.mjs +20 -7
- package/templates/vue/server/public-endpoints.mjs +192 -0
- package/templates/vue/server/request-target.mjs +53 -0
- package/templates/vue/server.mjs +12 -9
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
package/dist/materialize.js
CHANGED
|
@@ -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
|
}
|
package/dist/workspace.d.ts
CHANGED
|
@@ -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
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|
package/templates/vue/server.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
RequestBodyError,
|
|
11
11
|
UpstreamError,
|
|
12
12
|
} from "./server/auth/backend.mjs";
|
|
13
|
-
import {
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
response
|
|
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(
|
|
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.
|