@antelopejs/dms-frontend 0.9.3 → 0.10.1
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 +46 -29
- package/dist/commands/build-action.d.ts +3 -0
- package/dist/commands/build-action.js +77 -8
- package/dist/commands/build.js +7 -0
- package/dist/commands/clean-action.js +1 -1
- package/dist/commands/clean.js +1 -1
- package/dist/commands/dev-action.js +4 -3
- package/dist/commands/dev.js +1 -1
- package/dist/commands/prepare-action.js +1 -1
- package/dist/commands/start-action.js +3 -24
- package/dist/commands/workspace-task.js +2 -1
- package/dist/config.js +1 -0
- package/dist/derived-outputs.d.ts +1 -0
- package/dist/derived-outputs.js +6 -4
- package/dist/discovery.js +17 -6
- package/dist/entry-weight.js +15 -0
- package/dist/fs-sync.js +7 -4
- package/dist/help.js +1 -1
- package/dist/index.js +2 -1
- package/dist/internal/built-workspace.d.ts +4 -0
- package/dist/internal/built-workspace.js +43 -0
- package/dist/internal/ensure.d.ts +1 -0
- package/dist/internal/ensure.js +8 -0
- package/dist/internal/locale-catalogs.d.ts +8 -3
- package/dist/internal/locale-catalogs.js +16 -6
- package/dist/internal/manifest-decoder.d.ts +7 -0
- package/dist/internal/manifest-decoder.js +81 -0
- package/dist/internal/own-release.d.ts +5 -0
- package/dist/internal/own-release.js +20 -0
- package/dist/internal/plain-object.d.ts +1 -0
- package/dist/internal/plain-object.js +6 -0
- package/dist/internal/prepared-workspace.d.ts +12 -0
- package/dist/internal/prepared-workspace.js +74 -0
- package/dist/internal/text-list.d.ts +1 -0
- package/dist/internal/text-list.js +10 -0
- package/dist/internal/vite-manifest.d.ts +6 -0
- package/dist/internal/vite-manifest.js +24 -0
- package/dist/layer-watch.js +25 -24
- package/dist/layers.d.ts +1 -1
- package/dist/layers.js +3 -1
- package/dist/locale-files.d.ts +6 -3
- package/dist/locale-files.js +15 -9
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +46 -32
- package/dist/materialize.js +6 -2
- package/dist/module-set.js +2 -1
- package/dist/node-options.d.ts +1 -0
- package/dist/node-options.js +8 -0
- package/dist/output.d.ts +15 -9
- package/dist/output.js +30 -18
- package/dist/ports.d.ts +3 -1
- package/dist/ports.js +1 -1
- package/dist/private-values.d.ts +1 -1
- package/dist/private-values.js +5 -3
- package/dist/project-modules.d.ts +5 -1
- package/dist/project-modules.js +23 -15
- package/dist/renderer-range.js +5 -3
- package/dist/server-process.js +1 -1
- package/dist/verify-source-build.js +10 -2
- package/dist/verify-source-client-bundle.d.ts +1 -0
- package/dist/verify-source-client-bundle.js +30 -0
- package/dist/verify-source-runner.js +36 -94
- package/dist/verify-source-sources.js +49 -11
- package/dist/verify-source-workspace.d.ts +2 -0
- package/dist/verify-source-workspace.js +111 -0
- package/dist/workspace-setup.d.ts +1 -0
- package/dist/workspace-setup.js +4 -3
- package/package.json +11 -7
- package/skills/dms-frontend/SKILL.md +1 -1
- package/templates/vue/app-runtime.ts +2 -9
- package/templates/vue/backend-boundary.mjs +132 -0
- package/templates/vue/client-chunking.mjs +2 -0
- package/templates/vue/client-server-guard.mjs +16 -0
- package/templates/vue/compress-assets.mjs +9 -1
- package/templates/vue/entry-weight.mjs +29 -3
- package/templates/vue/frontend-build.ts +4 -3
- package/templates/vue/frontend-module.ts +1 -0
- package/templates/vue/frontend-runtime/module-registration.ts +2 -2
- package/templates/vue/frontend-runtime/state.ts +2 -2
- package/templates/vue/frontend-runtime/types.ts +8 -2
- package/templates/vue/head-order.mjs +10 -2
- package/templates/vue/icon-api.mjs +2 -0
- package/templates/vue/icon-scan.mjs +4 -0
- package/templates/vue/server/auth/backend.mjs +42 -5
- package/templates/vue/server/auth/client-ip.mjs +52 -4
- package/templates/vue/server/auth/establish-endpoints.mjs +11 -7
- package/templates/vue/server/auth/session.mjs +86 -4
- package/templates/vue/server/auth-declarations.mjs +46 -0
- package/templates/vue/server/auth-hooks.mjs +55 -7
- package/templates/vue/server/backend-proxies.mjs +78 -18
- package/templates/vue/server/backend-request.mjs +61 -0
- package/templates/vue/server/backend-response.mjs +23 -6
- package/templates/vue/server/built-files.mjs +7 -0
- package/templates/vue/server/cli-report.mjs +75 -12
- package/templates/vue/server/client-error.mjs +5 -0
- package/templates/vue/server/client-manifest.mjs +23 -1
- package/templates/vue/server/content.mjs +40 -10
- package/templates/vue/server/dev-private-files.mjs +17 -1
- package/templates/vue/server/dev-styles.mjs +38 -0
- package/templates/vue/server/email-bundle-watch.mjs +9 -3
- package/templates/vue/server/email.mjs +14 -6
- package/templates/vue/server/favicon.mjs +3 -0
- package/templates/vue/server/generated-files.mjs +221 -0
- package/templates/vue/server/homepage.mjs +21 -9
- package/templates/vue/server/html-tag.mjs +4 -0
- package/templates/vue/server/icons.mjs +93 -16
- package/templates/vue/server/in-place-render.mjs +35 -0
- package/templates/vue/server/inertia.mjs +76 -3
- package/templates/vue/server/module-event.mjs +118 -25
- package/templates/vue/server/module-route-types.mjs +78 -0
- package/templates/vue/server/module-routes.mjs +151 -96
- package/templates/vue/server/module-server.mjs +121 -40
- package/templates/vue/server/module-testing.mjs +36 -12
- package/templates/vue/server/page-props.mjs +94 -3
- package/templates/vue/server/page-redirects.mjs +37 -0
- package/templates/vue/server/path-patterns.mjs +35 -12
- package/templates/vue/server/proxy-compression.mjs +22 -0
- package/templates/vue/server/proxy-headers.mjs +12 -2
- package/templates/vue/server/proxy-redirects.mjs +21 -5
- package/templates/vue/server/proxy-request.mjs +58 -0
- package/templates/vue/server/proxy-transfers.mjs +44 -10
- package/templates/vue/server/public-endpoints.mjs +139 -28
- package/templates/vue/server/public-options.mjs +20 -6
- package/templates/vue/server/request-failure.mjs +24 -1
- package/templates/vue/server/request-routing.mjs +32 -10
- package/templates/vue/server/request-target.mjs +30 -11
- package/templates/vue/server/route-rules.mjs +48 -18
- package/templates/vue/server/ssr-module.mjs +32 -0
- package/templates/vue/server/static-files.mjs +35 -7
- package/templates/vue/server/type-guards.mjs +71 -0
- package/templates/vue/server/vite-errors.mjs +45 -7
- package/templates/vue/server/web-body.mjs +5 -1
- package/templates/vue/server-module.ts +12 -2
- package/templates/vue/server.mjs +162 -157
- package/templates/vue/sfc-global-types.mjs +112 -13
- package/templates/vue/ssr-renderer.ts +2 -2
- package/templates/vue/ui-inertia-page.mjs +16 -1
- package/templates/vue/ui-kbd.mjs +2 -1
- package/templates/vue/ui-request-state.mjs +2 -1
- package/templates/vue/vite.config.ts +9 -0
|
@@ -1,5 +1,21 @@
|
|
|
1
|
+
import { isString } from "../type-guards.mjs";
|
|
2
|
+
|
|
1
3
|
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
|
|
2
4
|
|
|
5
|
+
/**
|
|
6
|
+
* A request, whose socket is a TLS one (`encrypted`) when the server itself
|
|
7
|
+
* terminates TLS.
|
|
8
|
+
*
|
|
9
|
+
* @typedef {import("node:http").IncomingMessage & {
|
|
10
|
+
* socket: import("node:net").Socket & { encrypted?: boolean }
|
|
11
|
+
* }} ForwardedRequest
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string | undefined} value A header name from the environment
|
|
16
|
+
* @returns {string | undefined} The lowercase name, `undefined` when it is not
|
|
17
|
+
* a header name
|
|
18
|
+
*/
|
|
3
19
|
function headerName(value) {
|
|
4
20
|
const name = (value ?? "").trim().toLowerCase();
|
|
5
21
|
return HEADER_NAME.test(name) ? name : undefined;
|
|
@@ -17,18 +33,26 @@ function trustedHops() {
|
|
|
17
33
|
* so its presence also vouches for the platform's `X-Forwarded-*`. On a path
|
|
18
34
|
* the platform does not front (a private call from another service), it is
|
|
19
35
|
* absent and the request falls back to `DMS_TRUSTED_PROXY_HOPS`.
|
|
36
|
+
*
|
|
37
|
+
* @param {ForwardedRequest} request
|
|
38
|
+
* @returns {string | undefined}
|
|
20
39
|
*/
|
|
21
40
|
function platformClientIp(request) {
|
|
22
41
|
const name = headerName(process.env.DMS_CLIENT_IP_HEADER);
|
|
23
42
|
if (!name) return undefined;
|
|
24
43
|
const value = request.headers?.[name];
|
|
25
|
-
return
|
|
44
|
+
return isString(value) && value.trim() ? value.trim() : undefined;
|
|
26
45
|
}
|
|
27
46
|
|
|
47
|
+
/** @param {ForwardedRequest} request */
|
|
28
48
|
function isProxyTrusted(request) {
|
|
29
49
|
return Boolean(platformClientIp(request)) || trustedHops() > 0;
|
|
30
50
|
}
|
|
31
51
|
|
|
52
|
+
/**
|
|
53
|
+
* @param {ForwardedRequest} request
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
32
56
|
export function requestScheme(request) {
|
|
33
57
|
if (isProxyTrusted(request)) {
|
|
34
58
|
const forwarded = String(request.headers["x-forwarded-proto"] ?? "")
|
|
@@ -41,6 +65,10 @@ export function requestScheme(request) {
|
|
|
41
65
|
return request.socket?.encrypted ? "https" : "http";
|
|
42
66
|
}
|
|
43
67
|
|
|
68
|
+
/**
|
|
69
|
+
* @param {ForwardedRequest} request
|
|
70
|
+
* @returns {string | undefined}
|
|
71
|
+
*/
|
|
44
72
|
function requestHost(request) {
|
|
45
73
|
if (!isProxyTrusted(request)) return request.headers.host;
|
|
46
74
|
return (
|
|
@@ -62,8 +90,12 @@ function requestHost(request) {
|
|
|
62
90
|
* proxy wrote it (`DMS_TRUSTED_PROXY_HOPS` above `0`): otherwise any caller
|
|
63
91
|
* could have, and the socket's address replaces it. The scheme and the host
|
|
64
92
|
* are read the same way as the CSRF origin check reads them.
|
|
93
|
+
*
|
|
94
|
+
* @param {ForwardedRequest} request
|
|
95
|
+
* @returns {Record<string, string>}
|
|
65
96
|
*/
|
|
66
97
|
export function forwardedHeaders(request) {
|
|
98
|
+
/** @type {Record<string, string>} */
|
|
67
99
|
const headers = {
|
|
68
100
|
"x-forwarded-for": forwardedFor(request),
|
|
69
101
|
"x-forwarded-proto": requestScheme(request),
|
|
@@ -74,6 +106,10 @@ export function forwardedHeaders(request) {
|
|
|
74
106
|
return headers;
|
|
75
107
|
}
|
|
76
108
|
|
|
109
|
+
/**
|
|
110
|
+
* @param {ForwardedRequest} request
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
77
113
|
function forwardedFor(request) {
|
|
78
114
|
const platformIp = platformClientIp(request);
|
|
79
115
|
if (platformIp) return platformIp;
|
|
@@ -92,12 +128,19 @@ function forwardedFor(request) {
|
|
|
92
128
|
* address this server resolved, so the backend reads the same address
|
|
93
129
|
* without counting hops. Always written, never relayed: a value the caller
|
|
94
130
|
* sent is replaced. None when the variable is unset.
|
|
131
|
+
*
|
|
132
|
+
* @param {ForwardedRequest} request
|
|
133
|
+
* @returns {Record<string, string>}
|
|
95
134
|
*/
|
|
96
135
|
export function clientIpHeader(request) {
|
|
97
136
|
const name = headerName(process.env.DMS_CLIENT_IP_HEADER);
|
|
98
137
|
return name ? { [name]: clientIp(request) } : {};
|
|
99
138
|
}
|
|
100
139
|
|
|
140
|
+
/**
|
|
141
|
+
* @param {ForwardedRequest} request
|
|
142
|
+
* @returns {boolean}
|
|
143
|
+
*/
|
|
101
144
|
export function isSameOrigin(request) {
|
|
102
145
|
const origin = request.headers.origin;
|
|
103
146
|
if (!origin) return false;
|
|
@@ -111,6 +154,10 @@ export function isSameOrigin(request) {
|
|
|
111
154
|
}
|
|
112
155
|
}
|
|
113
156
|
|
|
157
|
+
/**
|
|
158
|
+
* @param {ForwardedRequest} request
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
114
161
|
export function clientIp(request) {
|
|
115
162
|
const platformIp = platformClientIp(request);
|
|
116
163
|
if (platformIp) return platformIp;
|
|
@@ -136,6 +183,9 @@ const DEFAULT_COUNTRY_HEADER = "CF-IPCountry";
|
|
|
136
183
|
* from a trusted reverse proxy (the platform's client IP header, or
|
|
137
184
|
* `DMS_TRUSTED_PROXY_HOPS` above `0`): otherwise any caller could have set
|
|
138
185
|
* it, so nothing is relayed.
|
|
186
|
+
*
|
|
187
|
+
* @param {ForwardedRequest} request
|
|
188
|
+
* @returns {Record<string, string>}
|
|
139
189
|
*/
|
|
140
190
|
export function clientCountryHeader(request) {
|
|
141
191
|
const name = headerName(
|
|
@@ -143,9 +193,7 @@ export function clientCountryHeader(request) {
|
|
|
143
193
|
);
|
|
144
194
|
if (!name || !isProxyTrusted(request)) return {};
|
|
145
195
|
const value = request.headers?.[name];
|
|
146
|
-
return
|
|
147
|
-
? { [name]: value.trim() }
|
|
148
|
-
: {};
|
|
196
|
+
return isString(value) && value.trim() ? { [name]: value.trim() } : {};
|
|
149
197
|
}
|
|
150
198
|
|
|
151
199
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { isObjectLike, isString } from "../type-guards.mjs";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Absolute backend API paths only: no scheme, no authority, no query string,
|
|
@@ -17,6 +18,7 @@ const DECLARED_ENDPOINTS_FILE = new URL(
|
|
|
17
18
|
import.meta.url,
|
|
18
19
|
);
|
|
19
20
|
|
|
21
|
+
/** @type {unknown[] | undefined} */
|
|
20
22
|
let declaredEndpoints;
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -28,13 +30,15 @@ let declaredEndpoints;
|
|
|
28
30
|
* workspace built from a DMS that predates the declaration simply has no file,
|
|
29
31
|
* and the list falls back to whatever the environment names.
|
|
30
32
|
*
|
|
31
|
-
* @param file Generated file to read
|
|
32
|
-
* @returns The declared paths, unfiltered
|
|
33
|
+
* @param {URL | string} [file] Generated file to read
|
|
34
|
+
* @returns {unknown[]} The declared paths, unfiltered
|
|
33
35
|
*/
|
|
34
36
|
export function readDeclaredEstablishEndpoints(file = DECLARED_ENDPOINTS_FILE) {
|
|
35
37
|
try {
|
|
38
|
+
/** @type {unknown} */
|
|
36
39
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
37
|
-
|
|
40
|
+
const endpoints = isObjectLike(parsed) ? parsed.endpoints : undefined;
|
|
41
|
+
return Array.isArray(endpoints) ? endpoints : [];
|
|
38
42
|
} catch {
|
|
39
43
|
return [];
|
|
40
44
|
}
|
|
@@ -54,8 +58,8 @@ export function readDeclaredEstablishEndpoints(file = DECLARED_ENDPOINTS_FILE) {
|
|
|
54
58
|
* (`DMS_AUTH_ESTABLISH_ENDPOINTS`). The module that owns sign-in reads them as
|
|
55
59
|
* `event.app.authEstablishEndpoints`.
|
|
56
60
|
*
|
|
57
|
-
* @param declaration Comma-separated absolute backend paths from the environment
|
|
58
|
-
* @param declared Paths declared by the backend's frontend modules
|
|
61
|
+
* @param {string} [declaration] Comma-separated absolute backend paths from the environment
|
|
62
|
+
* @param {unknown[]} [declared] Paths declared by the backend's frontend modules
|
|
59
63
|
* @returns The well-formed backend API paths, without duplicates
|
|
60
64
|
*/
|
|
61
65
|
export function allowedEstablishEndpoints(
|
|
@@ -67,8 +71,8 @@ export function allowedEstablishEndpoints(
|
|
|
67
71
|
.map((entry) => entry.trim());
|
|
68
72
|
return [
|
|
69
73
|
...new Set(
|
|
70
|
-
[...declared, ...fromEnvironment].
|
|
71
|
-
(entry)
|
|
74
|
+
[...declared, ...fromEnvironment].flatMap((entry) =>
|
|
75
|
+
isString(entry) && BACKEND_API_PATH.test(entry) ? [entry] : [],
|
|
72
76
|
),
|
|
73
77
|
),
|
|
74
78
|
];
|
|
@@ -9,6 +9,21 @@ export const SESSION_COOKIE = "dms_session";
|
|
|
9
9
|
const REQUEST_SESSION = Symbol("dms-request-session");
|
|
10
10
|
const MAX_AGE = 60 * 60 * 24 * 30;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The session a module writes: the `DmsSession` of `#dms/server`.
|
|
14
|
+
*
|
|
15
|
+
* @typedef {import("../../server-module.ts").DmsSession} Session
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A request, and the session `setRequestSession` set on it for the rest of the
|
|
20
|
+
* request.
|
|
21
|
+
*
|
|
22
|
+
* @typedef {import("node:http").IncomingMessage & {
|
|
23
|
+
* [REQUEST_SESSION]?: Session | undefined
|
|
24
|
+
* }} SessionRequest
|
|
25
|
+
*/
|
|
26
|
+
|
|
12
27
|
/**
|
|
13
28
|
* A suffix that keeps this instance's cookies apart from another's on the same
|
|
14
29
|
* host. Browsers scope cookies by hostname, not port: without it, two DMS
|
|
@@ -31,7 +46,11 @@ function cookieScope() {
|
|
|
31
46
|
return createHash("sha256").update(canonical).digest("hex").slice(0, 12);
|
|
32
47
|
}
|
|
33
48
|
|
|
34
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The name `name` takes in this instance's cookie jar.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} name
|
|
53
|
+
*/
|
|
35
54
|
export function scopedCookieName(name) {
|
|
36
55
|
return `${name}_${cookieScope()}`;
|
|
37
56
|
}
|
|
@@ -46,6 +65,10 @@ function key() {
|
|
|
46
65
|
return createHash("sha256").update(secret).digest();
|
|
47
66
|
}
|
|
48
67
|
|
|
68
|
+
/**
|
|
69
|
+
* @param {unknown} value A session, or the value of a module's sealed cookie
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
49
72
|
function encode(value) {
|
|
50
73
|
const iv = randomBytes(12);
|
|
51
74
|
const cipher = createCipheriv("aes-256-gcm", key(), iv);
|
|
@@ -58,6 +81,11 @@ function encode(value) {
|
|
|
58
81
|
);
|
|
59
82
|
}
|
|
60
83
|
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} value The cookie's value
|
|
86
|
+
* @returns {any} What was sealed, as the JSON it was written from, `undefined`
|
|
87
|
+
* when it does not open
|
|
88
|
+
*/
|
|
61
89
|
function decode(value) {
|
|
62
90
|
try {
|
|
63
91
|
const payload = Buffer.from(value, "base64url");
|
|
@@ -68,13 +96,21 @@ function decode(value) {
|
|
|
68
96
|
);
|
|
69
97
|
decipher.setAuthTag(payload.subarray(12, 28));
|
|
70
98
|
return JSON.parse(
|
|
71
|
-
Buffer.concat([
|
|
99
|
+
Buffer.concat([
|
|
100
|
+
decipher.update(payload.subarray(28)),
|
|
101
|
+
decipher.final(),
|
|
102
|
+
]).toString(),
|
|
72
103
|
);
|
|
73
104
|
} catch {
|
|
74
105
|
return undefined;
|
|
75
106
|
}
|
|
76
107
|
}
|
|
77
108
|
|
|
109
|
+
/**
|
|
110
|
+
* @param {import("node:http").IncomingMessage} request
|
|
111
|
+
* @param {string} name
|
|
112
|
+
* @returns {string | undefined}
|
|
113
|
+
*/
|
|
78
114
|
function cookieValue(request, name) {
|
|
79
115
|
const scoped = scopedCookieName(name);
|
|
80
116
|
const cookies = request.headers.cookie?.split(";") ?? [];
|
|
@@ -86,6 +122,9 @@ function cookieValue(request, name) {
|
|
|
86
122
|
/**
|
|
87
123
|
* Cookie attributes. Without a `maxAge` the cookie lasts as long as the
|
|
88
124
|
* browser session: it is gone once the browser is closed.
|
|
125
|
+
*
|
|
126
|
+
* @param {number | undefined} maxAge
|
|
127
|
+
* @param {string} [path]
|
|
89
128
|
*/
|
|
90
129
|
function attributes(maxAge, path = "/") {
|
|
91
130
|
const secure = process.env.DMS_COOKIE_SECURE !== "false" ? "; Secure" : "";
|
|
@@ -93,32 +132,56 @@ function attributes(maxAge, path = "/") {
|
|
|
93
132
|
return `Path=${path}; HttpOnly; SameSite=Lax${lifetime}${secure}`;
|
|
94
133
|
}
|
|
95
134
|
|
|
96
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* A session outlives the browser only when it is written `persistent: true`.
|
|
137
|
+
*
|
|
138
|
+
* @param {Session} session
|
|
139
|
+
*/
|
|
97
140
|
function lifetime(session) {
|
|
98
141
|
return session.persistent === true ? MAX_AGE : undefined;
|
|
99
142
|
}
|
|
100
143
|
|
|
144
|
+
/**
|
|
145
|
+
* @param {import("node:http").ServerResponse} response
|
|
146
|
+
* @param {string} name
|
|
147
|
+
* @param {string} value
|
|
148
|
+
* @param {string} cookieAttributes
|
|
149
|
+
*/
|
|
101
150
|
function appendCookie(response, name, value, cookieAttributes) {
|
|
102
151
|
const cookie = `${scopedCookieName(name)}=${value}; ${cookieAttributes}`;
|
|
103
152
|
const current = response.getHeader("set-cookie");
|
|
104
153
|
response.setHeader(
|
|
105
154
|
"set-cookie",
|
|
106
155
|
current
|
|
107
|
-
? [cookie, ...(Array.isArray(current) ? current : [current])]
|
|
156
|
+
? [cookie, ...(Array.isArray(current) ? current : [String(current)])]
|
|
108
157
|
: cookie,
|
|
109
158
|
);
|
|
110
159
|
}
|
|
111
160
|
|
|
161
|
+
/**
|
|
162
|
+
* @param {SessionRequest} request
|
|
163
|
+
* @returns {Session | undefined} The session set for the request, else the one
|
|
164
|
+
* the cookie seals, `undefined` when there is none
|
|
165
|
+
*/
|
|
112
166
|
export function readSession(request) {
|
|
113
167
|
if (REQUEST_SESSION in request) return request[REQUEST_SESSION];
|
|
114
168
|
const value = cookieValue(request, SESSION_COOKIE);
|
|
169
|
+
// oxlint-disable-next-line typescript/no-unsafe-return -- `decode` returns the JSON this server sealed under `SESSION_COOKIE`, untyped because `JSON.parse` is; the AES-GCM tag rejects any cookie it did not write.
|
|
115
170
|
return value ? decode(value) : undefined;
|
|
116
171
|
}
|
|
117
172
|
|
|
173
|
+
/**
|
|
174
|
+
* @param {SessionRequest} request
|
|
175
|
+
* @param {Session | undefined} session
|
|
176
|
+
*/
|
|
118
177
|
export function setRequestSession(request, session) {
|
|
119
178
|
request[REQUEST_SESSION] = session;
|
|
120
179
|
}
|
|
121
180
|
|
|
181
|
+
/**
|
|
182
|
+
* @param {import("node:http").ServerResponse} response
|
|
183
|
+
* @param {Session} session
|
|
184
|
+
*/
|
|
122
185
|
export function writeSession(response, session) {
|
|
123
186
|
appendCookie(
|
|
124
187
|
response,
|
|
@@ -128,19 +191,38 @@ export function writeSession(response, session) {
|
|
|
128
191
|
);
|
|
129
192
|
}
|
|
130
193
|
|
|
194
|
+
/** @param {import("node:http").ServerResponse} response */
|
|
131
195
|
export function clearSession(response) {
|
|
132
196
|
appendCookie(response, SESSION_COOKIE, "", attributes(0));
|
|
133
197
|
}
|
|
134
198
|
|
|
199
|
+
/**
|
|
200
|
+
* @param {import("node:http").IncomingMessage} request
|
|
201
|
+
* @param {string} name
|
|
202
|
+
* @returns {unknown} What was sealed, as the JSON it was written from,
|
|
203
|
+
* `undefined` when there is none or it does not open
|
|
204
|
+
*/
|
|
135
205
|
export function readSealedCookie(request, name) {
|
|
136
206
|
const value = cookieValue(request, name);
|
|
137
207
|
return value ? decode(value) : undefined;
|
|
138
208
|
}
|
|
139
209
|
|
|
210
|
+
/**
|
|
211
|
+
* @param {import("node:http").ServerResponse} response
|
|
212
|
+
* @param {string} name
|
|
213
|
+
* @param {unknown} value
|
|
214
|
+
* @param {number | undefined} maxAge
|
|
215
|
+
* @param {string} [path]
|
|
216
|
+
*/
|
|
140
217
|
export function writeSealedCookie(response, name, value, maxAge, path = "/") {
|
|
141
218
|
appendCookie(response, name, encode(value), attributes(maxAge, path));
|
|
142
219
|
}
|
|
143
220
|
|
|
221
|
+
/**
|
|
222
|
+
* @param {import("node:http").ServerResponse} response
|
|
223
|
+
* @param {string} name
|
|
224
|
+
* @param {string} [path]
|
|
225
|
+
*/
|
|
144
226
|
export function clearSealedCookie(response, name, path = "/") {
|
|
145
227
|
appendCookie(response, name, "", attributes(0, path));
|
|
146
228
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { authRejection } from "./auth-hooks.mjs";
|
|
2
|
+
import { compilePathPattern } from "./path-patterns.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @import { AuthProvider, DeclaredModule } from "./module-route-types.mjs"
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The auth hooks: those of the highest-priority module that registers them.
|
|
10
|
+
* A module registering them twice, or after another one, is reported.
|
|
11
|
+
*
|
|
12
|
+
* @param {DeclaredModule[]} modules
|
|
13
|
+
* @param {(message: string) => void} warn
|
|
14
|
+
*/
|
|
15
|
+
export function resolveAuth(modules, warn) {
|
|
16
|
+
/** @type {AuthProvider | undefined} */
|
|
17
|
+
let provider;
|
|
18
|
+
for (const module of modules) {
|
|
19
|
+
for (const auth of module.declared.auth) {
|
|
20
|
+
const reason = authRejection(auth);
|
|
21
|
+
if (reason) {
|
|
22
|
+
warn(
|
|
23
|
+
`Frontend module ${module.id} registers auth hooks, which are ignored: ${reason}.`,
|
|
24
|
+
);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (provider) {
|
|
28
|
+
warn(
|
|
29
|
+
`Frontend module ${module.id} registers auth hooks, which are ignored: ${provider.id} (priority ${provider.priority ?? 0}) provides them.`,
|
|
30
|
+
);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
provider = {
|
|
34
|
+
id: module.id,
|
|
35
|
+
priority: module.priority ?? 0,
|
|
36
|
+
signInPath: auth.signInPath,
|
|
37
|
+
signInExempt: auth.signInExempt?.flatMap(
|
|
38
|
+
(pattern) => compilePathPattern(pattern) ?? [],
|
|
39
|
+
),
|
|
40
|
+
refreshSession: auth.refreshSession?.bind(auth),
|
|
41
|
+
publicSession: auth.publicSession?.bind(auth),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return provider;
|
|
46
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readSession, setRequestSession } from "./auth/session.mjs";
|
|
2
2
|
import { createServerEvent } from "./module-event.mjs";
|
|
3
|
-
import { compilePathPattern } from "./path-patterns.mjs";
|
|
3
|
+
import { compilePathPattern, PATH_PATTERN_FORMS } from "./path-patterns.mjs";
|
|
4
|
+
import { isFunction, isRecord, isString } from "./type-guards.mjs";
|
|
4
5
|
|
|
5
6
|
const DEFAULT_SIGN_IN_PATH = "/auth";
|
|
6
7
|
const DEFAULT_ONBOARDING_PATH = "/onboarding";
|
|
@@ -12,16 +13,58 @@ const HOOK_ROUTE = Object.freeze({ bodyLimit: 0 });
|
|
|
12
13
|
* sign-in page and everything below it, whole segments only (`/auth` exempts
|
|
13
14
|
* `/auth/callback`, never `/authors`), and the onboarding page. Derived from
|
|
14
15
|
* the sign-in path in use, or a custom one would redirect to itself.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} signInPath
|
|
15
18
|
*/
|
|
16
19
|
function defaultExempt(signInPath) {
|
|
17
20
|
const signIn = signInPath === "/" ? "/" : `${signInPath}/**`;
|
|
18
|
-
return [signIn, DEFAULT_ONBOARDING_PATH].
|
|
21
|
+
return [signIn, DEFAULT_ONBOARDING_PATH].flatMap(
|
|
22
|
+
(pattern) => compilePathPattern(pattern) ?? [],
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const AUTH_HOOKS = ["refreshSession", "publicSession"];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Why an auth hooks registration is ignored, or `undefined`.
|
|
30
|
+
*
|
|
31
|
+
* @param {unknown} auth
|
|
32
|
+
*/
|
|
33
|
+
export function authRejection(auth) {
|
|
34
|
+
if (!isRecord(auth)) return "it is not an object";
|
|
35
|
+
for (const hook of AUTH_HOOKS)
|
|
36
|
+
if (auth[hook] !== undefined && !isFunction(auth[hook]))
|
|
37
|
+
return `${hook} is not a function`;
|
|
38
|
+
if (
|
|
39
|
+
auth.signInPath !== undefined &&
|
|
40
|
+
(!isString(auth.signInPath) ||
|
|
41
|
+
!compilePathPattern(auth.signInPath) ||
|
|
42
|
+
auth.signInPath.includes("*"))
|
|
43
|
+
)
|
|
44
|
+
return "signInPath is not a page path";
|
|
45
|
+
return exemptionRejection(auth.signInExempt);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @param {unknown} signInExempt */
|
|
49
|
+
function exemptionRejection(signInExempt) {
|
|
50
|
+
if (signInExempt === undefined) return undefined;
|
|
51
|
+
if (!Array.isArray(signInExempt))
|
|
52
|
+
return `signInExempt is not a list of ${PATH_PATTERN_FORMS} patterns`;
|
|
53
|
+
for (const pattern of signInExempt)
|
|
54
|
+
if (!compilePathPattern(pattern))
|
|
55
|
+
return isString(pattern)
|
|
56
|
+
? `signInExempt holds "${pattern}", which is not ${PATH_PATTERN_FORMS}`
|
|
57
|
+
: "signInExempt holds an entry that is not a string";
|
|
58
|
+
return undefined;
|
|
19
59
|
}
|
|
20
60
|
|
|
21
61
|
/**
|
|
22
62
|
* The page props `user` and `session` of `session`, from the provider's
|
|
23
63
|
* `publicSession`. The server adds the `updatedAt` stamp it wrote itself,
|
|
24
64
|
* which the browser compares to drop a page restored from its cache.
|
|
65
|
+
*
|
|
66
|
+
* @param {NonNullable<import("./module-route-types.mjs").AuthProvider["publicSession"]>} publicSession
|
|
67
|
+
* @param {import("./module-event.mjs").Session | undefined} session
|
|
25
68
|
*/
|
|
26
69
|
function hookView(publicSession, session) {
|
|
27
70
|
if (!session) return {};
|
|
@@ -40,9 +83,9 @@ function hookView(publicSession, session) {
|
|
|
40
83
|
* `dms.server.ts`. Without one, a page carries no session and a refused session
|
|
41
84
|
* is not renewed.
|
|
42
85
|
*
|
|
43
|
-
* @param provider The resolved auth hooks, `undefined` when no module
|
|
86
|
+
* @param {import("./module-route-types.mjs").AuthProvider | undefined} provider The resolved auth hooks, `undefined` when no module
|
|
44
87
|
* registers any
|
|
45
|
-
* @param context `(id) => ({ module, app })`, a module's event context
|
|
88
|
+
* @param {(id: string) => import("./module-server.mjs").ModuleContext} context `(id) => ({ module, app })`, a module's event context
|
|
46
89
|
* @returns `{ provider, signInPath, isSignInExempt, signInLocation, view,
|
|
47
90
|
* refresh }`
|
|
48
91
|
*/
|
|
@@ -54,16 +97,21 @@ export function serverAuth(provider, context) {
|
|
|
54
97
|
return {
|
|
55
98
|
provider: provider?.id,
|
|
56
99
|
signInPath,
|
|
57
|
-
isSignInExempt: (pathname) =>
|
|
100
|
+
isSignInExempt: (/** @type {string} */ pathname) =>
|
|
58
101
|
exempt.some((pattern) => pattern.test(pathname)),
|
|
59
|
-
signInLocation: (requestUrl) =>
|
|
102
|
+
signInLocation: (/** @type {string} */ requestUrl) =>
|
|
60
103
|
`${signInPath}?redirect=${encodeURIComponent(requestUrl)}`,
|
|
61
104
|
view: publicSession
|
|
62
|
-
? (
|
|
105
|
+
? (
|
|
106
|
+
/** @type {import("./module-event.mjs").Session | undefined} */ session,
|
|
107
|
+
) => hookView(publicSession, session)
|
|
63
108
|
: () => ({}),
|
|
64
109
|
/**
|
|
65
110
|
* Renews the request's session after the backend refused it. Returns the
|
|
66
111
|
* new session, which the rest of the request runs with, or `undefined`.
|
|
112
|
+
*
|
|
113
|
+
* @param {import("./request-target.mjs").ServerRequest} request
|
|
114
|
+
* @param {import("node:http").ServerResponse} response
|
|
67
115
|
*/
|
|
68
116
|
async refresh(request, response) {
|
|
69
117
|
if (!refreshSession) return undefined;
|
|
@@ -1,4 +1,36 @@
|
|
|
1
|
-
import { compilePathPattern } from "./path-patterns.mjs";
|
|
1
|
+
import { compilePathPattern, PATH_PATTERN_FORMS } from "./path-patterns.mjs";
|
|
2
|
+
import { isNonNullObject, isString } from "./type-guards.mjs";
|
|
3
|
+
|
|
4
|
+
/** @typedef {import("../server-module.ts").DmsBackendProxyOptions} ProxyOptions */
|
|
5
|
+
/**
|
|
6
|
+
* A backend prefix the server proxies, compiled from a pattern `proxyRejection`
|
|
7
|
+
* accepted.
|
|
8
|
+
*
|
|
9
|
+
* @typedef {object} BackendProxy
|
|
10
|
+
* @property {import("./path-patterns.mjs").CompiledPathPattern} pattern
|
|
11
|
+
* @property {string} path The pattern as declared
|
|
12
|
+
* @property {string} owner Who declared it
|
|
13
|
+
* @property {NonNullable<ProxyOptions["auth"]>} auth
|
|
14
|
+
* @property {number} [maxBodyBytes]
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* What `registerBackendProxy` declared, per call.
|
|
18
|
+
*
|
|
19
|
+
* @typedef {object} ProxyDeclaration
|
|
20
|
+
* @property {string} id The declaring frontend module
|
|
21
|
+
* @property {string} pattern
|
|
22
|
+
* @property {ProxyOptions} [options]
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* What a backend module declared in the manifest, or an operator in
|
|
26
|
+
* `DMS_BACKEND_PROXIES`.
|
|
27
|
+
*
|
|
28
|
+
* @typedef {object} ManifestProxy
|
|
29
|
+
* @property {string} [module]
|
|
30
|
+
* @property {string} [path]
|
|
31
|
+
* @property {ProxyOptions["auth"]} [auth]
|
|
32
|
+
* @property {number} [maxBodyBytes]
|
|
33
|
+
*/
|
|
2
34
|
|
|
3
35
|
/** How the proxy authenticates a request to the backend. */
|
|
4
36
|
export const PROXY_AUTH_MODES = Object.freeze(["session", "none"]);
|
|
@@ -6,8 +38,10 @@ const DEFAULT_AUTH = "session";
|
|
|
6
38
|
/** The renderer's own paths, never a backend's. */
|
|
7
39
|
const RENDERER_PREFIXES = ["/api/_dms", "/@", "/assets", "/node_modules"];
|
|
8
40
|
|
|
41
|
+
/** @param {string} pattern */
|
|
9
42
|
function literalPrefix(pattern) {
|
|
10
43
|
const segments = pattern === "/" ? [] : pattern.slice(1).split("/");
|
|
44
|
+
/** @type {string[]} */
|
|
11
45
|
const literal = [];
|
|
12
46
|
for (const segment of segments) {
|
|
13
47
|
if (segment === "*" || segment === "**") break;
|
|
@@ -16,10 +50,16 @@ function literalPrefix(pattern) {
|
|
|
16
50
|
return `/${literal.join("/")}`;
|
|
17
51
|
}
|
|
18
52
|
|
|
19
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Why a proxy declaration is ignored, or `undefined`.
|
|
55
|
+
*
|
|
56
|
+
* @param {string | undefined} pattern
|
|
57
|
+
* @param {ProxyOptions | undefined} options
|
|
58
|
+
*/
|
|
20
59
|
export function proxyRejection(pattern, options) {
|
|
21
60
|
const compiled = compilePathPattern(pattern);
|
|
22
|
-
if (!compiled
|
|
61
|
+
if (!compiled || !isString(pattern))
|
|
62
|
+
return `the pattern is not ${PATH_PATTERN_FORMS}`;
|
|
23
63
|
const prefix = literalPrefix(pattern);
|
|
24
64
|
if (prefix === "/")
|
|
25
65
|
return "its first segment is not literal: it would take every page";
|
|
@@ -29,7 +69,7 @@ export function proxyRejection(pattern, options) {
|
|
|
29
69
|
)
|
|
30
70
|
)
|
|
31
71
|
return `${RENDERER_PREFIXES.join(", ")} are the renderer's own paths`;
|
|
32
|
-
if (options !== undefined && (
|
|
72
|
+
if (options !== undefined && !isNonNullObject(options))
|
|
33
73
|
return "its options are not an object";
|
|
34
74
|
if (options?.auth !== undefined && !PROXY_AUTH_MODES.includes(options.auth))
|
|
35
75
|
return `auth is not one of ${PROXY_AUTH_MODES.map((mode) => `"${mode}"`).join(", ")}`;
|
|
@@ -41,10 +81,16 @@ export function proxyRejection(pattern, options) {
|
|
|
41
81
|
return undefined;
|
|
42
82
|
}
|
|
43
83
|
|
|
44
|
-
|
|
84
|
+
/**
|
|
85
|
+
* @param {import("./path-patterns.mjs").CompiledPathPattern} compiled A pattern `proxyRejection` accepted
|
|
86
|
+
* @param {ProxyOptions | undefined} options
|
|
87
|
+
* @param {string} owner Who declared it
|
|
88
|
+
* @returns {BackendProxy}
|
|
89
|
+
*/
|
|
90
|
+
function compileProxy(compiled, options, owner) {
|
|
45
91
|
return {
|
|
46
|
-
pattern:
|
|
47
|
-
path: pattern,
|
|
92
|
+
pattern: compiled,
|
|
93
|
+
path: compiled.pattern,
|
|
48
94
|
owner,
|
|
49
95
|
auth: options?.auth ?? DEFAULT_AUTH,
|
|
50
96
|
maxBodyBytes: options?.maxBodyBytes,
|
|
@@ -60,26 +106,35 @@ function compileProxy(pattern, options, owner) {
|
|
|
60
106
|
* (`backendProxies`, next to its `publicEndpoints`). A path no prefix covers is
|
|
61
107
|
* never sent to the backend.
|
|
62
108
|
*
|
|
63
|
-
* @param declared `{ id, pattern, options }` per `registerBackendProxy` call,
|
|
109
|
+
* @param {readonly ProxyDeclaration[]} declared `{ id, pattern, options }` per `registerBackendProxy` call,
|
|
64
110
|
* from the highest-priority module down
|
|
65
|
-
* @param manifest `{ module, path, auth?, maxBodyBytes? }` per backend
|
|
111
|
+
* @param {readonly (ManifestProxy | null | undefined)[]} manifest `{ module, path, auth?, maxBodyBytes? }` per backend
|
|
66
112
|
* module declaration
|
|
67
|
-
* @param warn Reports a declaration problem
|
|
68
|
-
* @returns The proxies, the most specific pattern first: a request reaches
|
|
113
|
+
* @param {(message: string) => void} warn Reports a declaration problem
|
|
114
|
+
* @returns {BackendProxy[]} The proxies, the most specific pattern first: a request reaches
|
|
69
115
|
* the first that matches
|
|
70
116
|
*/
|
|
71
117
|
export function resolveBackendProxies(declared, manifest, warn) {
|
|
118
|
+
/** @type {Map<string | undefined, BackendProxy>} */
|
|
72
119
|
const kept = new Map();
|
|
120
|
+
/**
|
|
121
|
+
* @param {string | undefined} pattern
|
|
122
|
+
* @param {ProxyOptions | undefined} options
|
|
123
|
+
* @param {string} owner
|
|
124
|
+
*/
|
|
73
125
|
const add = (pattern, options, owner) => {
|
|
74
|
-
const
|
|
126
|
+
const compiled = compilePathPattern(pattern);
|
|
75
127
|
const current = kept.get(pattern);
|
|
76
|
-
|
|
128
|
+
const reason =
|
|
129
|
+
proxyRejection(pattern, options) ??
|
|
130
|
+
(current && `${current.owner} declares it`);
|
|
131
|
+
if (!compiled || reason) {
|
|
77
132
|
warn(
|
|
78
|
-
`${owner} declares the backend proxy "${pattern}", which is ignored: ${reason
|
|
133
|
+
`${owner} declares the backend proxy "${pattern}", which is ignored: ${reason}.`,
|
|
79
134
|
);
|
|
80
135
|
return;
|
|
81
136
|
}
|
|
82
|
-
kept.set(pattern, compileProxy(
|
|
137
|
+
kept.set(pattern, compileProxy(compiled, options, owner));
|
|
83
138
|
};
|
|
84
139
|
for (const { id, pattern, options } of declared)
|
|
85
140
|
add(pattern, options, `Frontend module ${id}`);
|
|
@@ -94,7 +149,12 @@ export function resolveBackendProxies(declared, manifest, warn) {
|
|
|
94
149
|
);
|
|
95
150
|
}
|
|
96
151
|
|
|
97
|
-
/**
|
|
152
|
+
/**
|
|
153
|
+
* The backend proxy a path reaches, or `undefined`.
|
|
154
|
+
*
|
|
155
|
+
* @param {readonly BackendProxy[]} proxies
|
|
156
|
+
* @param {string} pathname
|
|
157
|
+
*/
|
|
98
158
|
export function matchBackendProxy(proxies, pathname) {
|
|
99
159
|
return proxies.find((proxy) => proxy.pattern.test(pathname));
|
|
100
160
|
}
|
|
@@ -103,8 +163,8 @@ export function matchBackendProxy(proxies, pathname) {
|
|
|
103
163
|
* The lines the server prints at startup: each backend prefix, who declared
|
|
104
164
|
* it and how it authenticates, then who owns the rest.
|
|
105
165
|
*
|
|
106
|
-
* @param proxies What `resolveBackendProxies` returned
|
|
107
|
-
* @param routeCount The number of module routes
|
|
166
|
+
* @param {readonly BackendProxy[]} proxies What `resolveBackendProxies` returned
|
|
167
|
+
* @param {number} routeCount The number of module routes
|
|
108
168
|
*/
|
|
109
169
|
export function routingReport(proxies, routeCount) {
|
|
110
170
|
const width = Math.max(0, ...proxies.map((proxy) => proxy.path.length));
|