@antelopejs/dms-frontend 0.5.2 → 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 +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/auth/session.mjs +43 -15
- package/templates/vue/server/public-endpoints.mjs +192 -0
- package/templates/vue/server.mjs +9 -7
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"))
|
|
@@ -14,6 +14,32 @@ const MAX_AGE = 60 * 60 * 24 * 30;
|
|
|
14
14
|
const UUID =
|
|
15
15
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* A suffix that keeps this instance's cookies apart from another's on the same
|
|
19
|
+
* host. Browsers scope cookies by hostname, not port: without it, two DMS
|
|
20
|
+
* frontends on localhost:3000 and localhost:3001 would overwrite each other's
|
|
21
|
+
* session, each failing to decrypt the other's and signing the user out. The
|
|
22
|
+
* suffix follows the backend, so a restart keeps the cookies it wrote.
|
|
23
|
+
*/
|
|
24
|
+
function cookieScope() {
|
|
25
|
+
const backendUrl = (process.env.DMS_API_BASE_URL ?? "").trim();
|
|
26
|
+
let canonical = backendUrl;
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(backendUrl);
|
|
29
|
+
if (url.hostname === "localhost" || url.hostname === "[::1]")
|
|
30
|
+
url.hostname = "127.0.0.1";
|
|
31
|
+
canonical = url.toString().replace(/\/$/, "");
|
|
32
|
+
} catch {
|
|
33
|
+
// Not a URL: the raw string still tells two backends apart.
|
|
34
|
+
}
|
|
35
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 12);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The name `name` takes in this instance's cookie jar. */
|
|
39
|
+
export function scopedCookieName(name) {
|
|
40
|
+
return `${name}_${cookieScope()}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
17
43
|
function key() {
|
|
18
44
|
const secret = process.env.DMS_SESSION_SECRET;
|
|
19
45
|
if (!secret || secret.length < 32)
|
|
@@ -54,10 +80,11 @@ function decode(value) {
|
|
|
54
80
|
}
|
|
55
81
|
|
|
56
82
|
function cookieValue(request, name) {
|
|
83
|
+
const scoped = scopedCookieName(name);
|
|
57
84
|
const cookies = request.headers.cookie?.split(";") ?? [];
|
|
58
85
|
return cookies
|
|
59
86
|
.map((part) => part.trim().split("="))
|
|
60
|
-
.find(([key]) => key ===
|
|
87
|
+
.find(([key]) => key === scoped)?.[1];
|
|
61
88
|
}
|
|
62
89
|
|
|
63
90
|
/**
|
|
@@ -83,7 +110,8 @@ function lifetime(persistent) {
|
|
|
83
110
|
return persistent ? MAX_AGE : undefined;
|
|
84
111
|
}
|
|
85
112
|
|
|
86
|
-
function appendCookie(response,
|
|
113
|
+
function appendCookie(response, name, value, cookieAttributes) {
|
|
114
|
+
const cookie = `${scopedCookieName(name)}=${value}; ${cookieAttributes}`;
|
|
87
115
|
const current = response.getHeader("set-cookie");
|
|
88
116
|
response.setHeader(
|
|
89
117
|
"set-cookie",
|
|
@@ -122,12 +150,14 @@ export function setRequestSession(request, session) {
|
|
|
122
150
|
export function writeSession(response, session) {
|
|
123
151
|
appendCookie(
|
|
124
152
|
response,
|
|
125
|
-
|
|
153
|
+
SESSION_COOKIE,
|
|
154
|
+
encode(session),
|
|
155
|
+
attributes(lifetime(isPersistent(session))),
|
|
126
156
|
);
|
|
127
157
|
}
|
|
128
158
|
|
|
129
159
|
export function clearSession(response) {
|
|
130
|
-
appendCookie(response,
|
|
160
|
+
appendCookie(response, SESSION_COOKIE, "", attributes(0));
|
|
131
161
|
}
|
|
132
162
|
|
|
133
163
|
export function readAccounts(request) {
|
|
@@ -139,7 +169,9 @@ function writeAccountIndex(response, accounts) {
|
|
|
139
169
|
const persistent = Object.values(accounts).some(isPersistent);
|
|
140
170
|
appendCookie(
|
|
141
171
|
response,
|
|
142
|
-
|
|
172
|
+
ACCOUNT_INDEX_COOKIE,
|
|
173
|
+
encode(accounts),
|
|
174
|
+
attributes(lifetime(persistent)),
|
|
143
175
|
);
|
|
144
176
|
}
|
|
145
177
|
|
|
@@ -156,7 +188,9 @@ export function storeAccount(request, response, session, accountId) {
|
|
|
156
188
|
writeAccountIndex(response, accounts);
|
|
157
189
|
appendCookie(
|
|
158
190
|
response,
|
|
159
|
-
|
|
191
|
+
accountCookieName(id),
|
|
192
|
+
encode({ refreshToken: session.refreshToken, ...accountDescriptor }),
|
|
193
|
+
attributes(lifetime(accountDescriptor.persistent)),
|
|
160
194
|
);
|
|
161
195
|
return accountDescriptor;
|
|
162
196
|
}
|
|
@@ -186,10 +220,7 @@ export function removeAccount(request, response, accountId) {
|
|
|
186
220
|
delete accounts[accountId];
|
|
187
221
|
writeAccountIndex(response, accounts);
|
|
188
222
|
if (UUID.test(accountId ?? ""))
|
|
189
|
-
appendCookie(
|
|
190
|
-
response,
|
|
191
|
-
`${accountCookieName(accountId)}=; ${attributes(0)}`,
|
|
192
|
-
);
|
|
223
|
+
appendCookie(response, accountCookieName(accountId), "", attributes(0));
|
|
193
224
|
}
|
|
194
225
|
|
|
195
226
|
export function readSealedCookie(request, name) {
|
|
@@ -204,12 +235,9 @@ export function writeSealedCookie(
|
|
|
204
235
|
maxAge,
|
|
205
236
|
path = "/auth/oauth",
|
|
206
237
|
) {
|
|
207
|
-
appendCookie(
|
|
208
|
-
response,
|
|
209
|
-
`${name}=${encode(value)}; ${attributes(maxAge, path)}`,
|
|
210
|
-
);
|
|
238
|
+
appendCookie(response, name, encode(value), attributes(maxAge, path));
|
|
211
239
|
}
|
|
212
240
|
|
|
213
241
|
export function clearSealedCookie(response, name, path = "/auth/oauth") {
|
|
214
|
-
appendCookie(response,
|
|
242
|
+
appendCookie(response, name, "", attributes(0, path));
|
|
215
243
|
}
|
|
@@ -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
|
+
}
|
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,
|
|
@@ -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
|
-
|
|
236
|
-
|
|
237
|
-
response
|
|
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
|
});
|