@antelopejs/dms-frontend 0.9.3 → 0.10.0
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 +18 -24
- package/dist/commands/build-action.js +3 -2
- package/dist/commands/dev-action.js +2 -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/derived-outputs.js +3 -3
- package/dist/discovery.js +17 -6
- package/dist/entry-weight.js +15 -0
- package/dist/fs-sync.js +7 -4
- 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/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 +2 -1
- package/dist/locale-files.js +4 -0
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +44 -31
- 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/private-values.d.ts +1 -1
- package/dist/private-values.js +5 -3
- package/dist/project-modules.js +13 -8
- 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 +44 -60
- package/dist/verify-source-sources.js +49 -11
- package/dist/workspace-setup.js +1 -1
- package/package.json +6 -4
- package/skills/dms-frontend/SKILL.md +1 -1
- package/templates/vue/app-runtime.ts +2 -9
- package/templates/vue/client-server-guard.mjs +16 -0
- package/templates/vue/compress-assets.mjs +9 -1
- package/templates/vue/entry-weight.mjs +22 -0
- 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 +7 -0
- package/templates/vue/icon-scan.mjs +4 -0
- package/templates/vue/server/auth/backend.mjs +41 -4
- package/templates/vue/server/auth/client-ip.mjs +52 -4
- package/templates/vue/server/auth/establish-endpoints.mjs +10 -6
- package/templates/vue/server/auth/session.mjs +81 -2
- package/templates/vue/server/auth-hooks.mjs +15 -5
- package/templates/vue/server/backend-proxies.mjs +65 -9
- 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 +53 -11
- package/templates/vue/server/client-manifest.mjs +23 -1
- package/templates/vue/server/content.mjs +18 -1
- package/templates/vue/server/dev-private-files.mjs +17 -1
- package/templates/vue/server/dev-styles.mjs +38 -0
- package/templates/vue/server/email.mjs +9 -3
- package/templates/vue/server/favicon.mjs +3 -0
- package/templates/vue/server/generated-files.mjs +211 -0
- package/templates/vue/server/homepage.mjs +21 -9
- package/templates/vue/server/html-tag.mjs +4 -0
- package/templates/vue/server/icons.mjs +80 -7
- package/templates/vue/server/in-place-render.mjs +35 -0
- package/templates/vue/server/inertia.mjs +61 -2
- package/templates/vue/server/module-event.mjs +98 -17
- package/templates/vue/server/module-route-types.mjs +78 -0
- package/templates/vue/server/module-routes.mjs +138 -31
- package/templates/vue/server/module-server.mjs +98 -27
- package/templates/vue/server/module-testing.mjs +14 -5
- package/templates/vue/server/page-props.mjs +92 -2
- package/templates/vue/server/page-redirects.mjs +37 -0
- package/templates/vue/server/path-patterns.mjs +13 -1
- package/templates/vue/server/proxy-compression.mjs +22 -0
- package/templates/vue/server/proxy-headers.mjs +11 -1
- package/templates/vue/server/proxy-redirects.mjs +20 -5
- package/templates/vue/server/proxy-request.mjs +57 -0
- package/templates/vue/server/proxy-transfers.mjs +33 -5
- package/templates/vue/server/public-endpoints.mjs +138 -23
- package/templates/vue/server/public-options.mjs +20 -6
- package/templates/vue/server/request-failure.mjs +23 -0
- package/templates/vue/server/request-routing.mjs +26 -9
- package/templates/vue/server/request-target.mjs +9 -3
- package/templates/vue/server/route-rules.mjs +39 -15
- package/templates/vue/server/ssr-module.mjs +32 -0
- package/templates/vue/server/static-files.mjs +24 -1
- package/templates/vue/server/type-guards.mjs +71 -0
- package/templates/vue/server/vite-errors.mjs +32 -5
- package/templates/vue/server/web-body.mjs +3 -0
- package/templates/vue/server-module.ts +10 -1
- package/templates/vue/server.mjs +132 -148
- 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
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { ofetch } from "ofetch";
|
|
2
1
|
import {
|
|
3
2
|
type App,
|
|
4
3
|
effectScope,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
import {
|
|
20
19
|
type DmsAppContext,
|
|
21
20
|
type DmsRoute,
|
|
21
|
+
type DmsServerFetch,
|
|
22
22
|
type DmsRuntimeConfig,
|
|
23
23
|
} from "./types";
|
|
24
24
|
|
|
@@ -65,7 +65,7 @@ export function forgetBrowserRuntime(runtime: DmsFrontendRuntime): void {
|
|
|
65
65
|
|
|
66
66
|
/** Creates mutable runtime state owned by one Vue application. */
|
|
67
67
|
export function createDmsFrontendRuntime(
|
|
68
|
-
serverFetch?:
|
|
68
|
+
serverFetch?: DmsServerFetch,
|
|
69
69
|
hydratedAsyncData?: Record<string, unknown>,
|
|
70
70
|
isServer = false,
|
|
71
71
|
requestCookies?: string,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type InertiaLinkProps } from "@inertiajs/vue3";
|
|
2
|
-
import { type FetchOptions
|
|
2
|
+
import { type FetchOptions } from "ofetch";
|
|
3
3
|
import {
|
|
4
4
|
type App,
|
|
5
5
|
type ComputedRef,
|
|
@@ -246,6 +246,12 @@ export interface UseAsyncDataOptions<T> {
|
|
|
246
246
|
*/
|
|
247
247
|
refreshInterval?: MaybeRefOrGetter<number | null | undefined>;
|
|
248
248
|
}
|
|
249
|
+
/** What the runtime calls to read the backend: `ofetch`, or a stand-in for it. */
|
|
250
|
+
export type DmsServerFetch = (
|
|
251
|
+
request: string,
|
|
252
|
+
options?: FetchOptions,
|
|
253
|
+
) => Promise<unknown>;
|
|
254
|
+
|
|
249
255
|
/**
|
|
250
256
|
* `useDmsFetch`'s options: ofetch's request options, of which the method, the
|
|
251
257
|
* query, the params and the body may be refs or getters, and the async data
|
|
@@ -271,7 +277,7 @@ export interface UseFetchOptions<T>
|
|
|
271
277
|
context: DmsFetchTransformContext,
|
|
272
278
|
) => T | Promise<T>;
|
|
273
279
|
/** Replaces the fetch function; it receives the run's signal. */
|
|
274
|
-
$fetch?:
|
|
280
|
+
$fetch?: DmsServerFetch;
|
|
275
281
|
}
|
|
276
282
|
export interface DmsErrorData {
|
|
277
283
|
statusCode?: number;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
const SCRIPT = /<script[^>]*\stype="module"[^>]*><\/script>/;
|
|
2
2
|
const STYLESHEET = /[ \t]*<link rel="stylesheet"[^>]*>\n?/;
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} html
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
4
8
|
function deprioritize(html) {
|
|
5
9
|
return html.replace(SCRIPT, (tag) =>
|
|
6
10
|
tag.includes("fetchpriority")
|
|
@@ -24,6 +28,9 @@ function deprioritize(html) {
|
|
|
24
28
|
* HTTP/1.1 the generated server speaks. Kept beside vite.config.ts rather than
|
|
25
29
|
* inside it so the transform can be exercised on its own, the way
|
|
26
30
|
* client-manifest.mjs is.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} html
|
|
33
|
+
* @returns {string}
|
|
27
34
|
*/
|
|
28
35
|
export function orderHeadForFirstPaint(html) {
|
|
29
36
|
const prioritized = deprioritize(html);
|
|
@@ -19,6 +19,10 @@ const SOURCE_SCRIPT_GLOB = "app/**/*.ts";
|
|
|
19
19
|
* fetched from the Iconify API at run time. Every frontend source root lives
|
|
20
20
|
* under the workspace (`frontend-modules/`, enforced when the registry is
|
|
21
21
|
* written), so a relative glob reaches it. Globs are POSIX on every platform.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} viteRoot
|
|
24
|
+
* @param {readonly string[]} sourceRoots
|
|
25
|
+
* @returns {string[]}
|
|
22
26
|
*/
|
|
23
27
|
export function iconScanGlobs(viteRoot, sourceRoots) {
|
|
24
28
|
const sourceGlobs = sourceRoots.map((root) =>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { REDIRECT_STATUSES } from "../proxy-redirects.mjs";
|
|
2
|
+
import { isString } from "../type-guards.mjs";
|
|
2
3
|
import { clientCountryHeader, clientIp, clientIpHeader } from "./client-ip.mjs";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -14,6 +15,10 @@ export const CLIENT_HEADERS = [
|
|
|
14
15
|
];
|
|
15
16
|
|
|
16
17
|
export class UpstreamError extends Error {
|
|
18
|
+
/**
|
|
19
|
+
* @param {number} status
|
|
20
|
+
* @param {string} message
|
|
21
|
+
*/
|
|
17
22
|
constructor(status, message) {
|
|
18
23
|
super(message);
|
|
19
24
|
this.status = status;
|
|
@@ -27,9 +32,15 @@ const PUBLIC_AUTH_MESSAGE = /^error\.[a-z0-9_.-]{1,100}$/;
|
|
|
27
32
|
*/
|
|
28
33
|
export const GENERIC_ERROR_MESSAGE = "error.500.description";
|
|
29
34
|
|
|
35
|
+
/**
|
|
36
|
+
* @param {unknown} data A backend answer: any JSON value, or the text of one
|
|
37
|
+
* that is not JSON
|
|
38
|
+
* @returns {string | undefined}
|
|
39
|
+
*/
|
|
30
40
|
export function publicBackendMessage(data) {
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
/** @type {unknown} */
|
|
42
|
+
const message = isString(data) ? data : data?.message;
|
|
43
|
+
return isString(message) && PUBLIC_AUTH_MESSAGE.test(message)
|
|
33
44
|
? message
|
|
34
45
|
: undefined;
|
|
35
46
|
}
|
|
@@ -37,8 +48,10 @@ export function publicBackendMessage(data) {
|
|
|
37
48
|
/**
|
|
38
49
|
* The headers of a client request the server relays to the backend.
|
|
39
50
|
*
|
|
40
|
-
* @param request The client
|
|
41
|
-
*
|
|
51
|
+
* @param {import("./client-ip.mjs").ForwardedRequest} request The client
|
|
52
|
+
* request
|
|
53
|
+
* @param {readonly string[]} names Lowercase names of the request headers to
|
|
54
|
+
* copy when present
|
|
42
55
|
* @returns The client country header, then the copied headers
|
|
43
56
|
*/
|
|
44
57
|
export function relayedHeaders(request, names) {
|
|
@@ -58,8 +71,22 @@ export function relayedHeaders(request, names) {
|
|
|
58
71
|
* `options.headers` adds request headers; the client's address and the
|
|
59
72
|
* relayed client headers are set after them, so no caller replaces those.
|
|
60
73
|
* A redirect is not followed: it is no JSON answer, so it fails with a 502.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} path
|
|
76
|
+
* @param {import("./client-ip.mjs").ForwardedRequest} request The client
|
|
77
|
+
* request
|
|
78
|
+
* @param {{
|
|
79
|
+
* method?: string,
|
|
80
|
+
* body?: unknown,
|
|
81
|
+
* token?: string,
|
|
82
|
+
* relay?: boolean,
|
|
83
|
+
* headers?: Record<string, string>
|
|
84
|
+
* }} [options]
|
|
85
|
+
* @returns {Promise<unknown>} The answer's JSON, `{}` for a successful one that
|
|
86
|
+
* is not JSON
|
|
61
87
|
*/
|
|
62
88
|
export async function backend(path, request, options = {}) {
|
|
89
|
+
/** @type {Record<string, string | string[]>} */
|
|
63
90
|
const headers = Object.fromEntries(
|
|
64
91
|
Object.entries(options.headers ?? {}).map(([name, value]) => [
|
|
65
92
|
name.toLowerCase(),
|
|
@@ -87,6 +114,7 @@ export async function backend(path, request, options = {}) {
|
|
|
87
114
|
throw new UpstreamError(502, GENERIC_ERROR_MESSAGE);
|
|
88
115
|
}
|
|
89
116
|
const text = await response.text();
|
|
117
|
+
/** @type {unknown} */
|
|
90
118
|
let data;
|
|
91
119
|
try {
|
|
92
120
|
data = JSON.parse(text);
|
|
@@ -105,12 +133,21 @@ export async function backend(path, request, options = {}) {
|
|
|
105
133
|
}
|
|
106
134
|
|
|
107
135
|
export class RequestBodyError extends Error {
|
|
136
|
+
/**
|
|
137
|
+
* @param {number} status
|
|
138
|
+
* @param {string} message
|
|
139
|
+
*/
|
|
108
140
|
constructor(status, message) {
|
|
109
141
|
super(message);
|
|
110
142
|
this.status = status;
|
|
111
143
|
}
|
|
112
144
|
}
|
|
113
145
|
|
|
146
|
+
/**
|
|
147
|
+
* @param {import("node:http").ServerResponse} response
|
|
148
|
+
* @param {number} status
|
|
149
|
+
* @param {unknown} value Anything `JSON.stringify` writes
|
|
150
|
+
*/
|
|
114
151
|
export function json(response, status, value) {
|
|
115
152
|
response.writeHead(status, { "content-type": "application/json" });
|
|
116
153
|
response.end(JSON.stringify(value));
|
|
@@ -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(
|
|
@@ -68,7 +72,7 @@ export function allowedEstablishEndpoints(
|
|
|
68
72
|
return [
|
|
69
73
|
...new Set(
|
|
70
74
|
[...declared, ...fromEnvironment].filter(
|
|
71
|
-
(entry) =>
|
|
75
|
+
(entry) => isString(entry) && BACKEND_API_PATH.test(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");
|
|
@@ -75,6 +103,11 @@ function decode(value) {
|
|
|
75
103
|
}
|
|
76
104
|
}
|
|
77
105
|
|
|
106
|
+
/**
|
|
107
|
+
* @param {import("node:http").IncomingMessage} request
|
|
108
|
+
* @param {string} name
|
|
109
|
+
* @returns {string | undefined}
|
|
110
|
+
*/
|
|
78
111
|
function cookieValue(request, name) {
|
|
79
112
|
const scoped = scopedCookieName(name);
|
|
80
113
|
const cookies = request.headers.cookie?.split(";") ?? [];
|
|
@@ -86,6 +119,9 @@ function cookieValue(request, name) {
|
|
|
86
119
|
/**
|
|
87
120
|
* Cookie attributes. Without a `maxAge` the cookie lasts as long as the
|
|
88
121
|
* browser session: it is gone once the browser is closed.
|
|
122
|
+
*
|
|
123
|
+
* @param {number | undefined} maxAge
|
|
124
|
+
* @param {string} [path]
|
|
89
125
|
*/
|
|
90
126
|
function attributes(maxAge, path = "/") {
|
|
91
127
|
const secure = process.env.DMS_COOKIE_SECURE !== "false" ? "; Secure" : "";
|
|
@@ -93,11 +129,21 @@ function attributes(maxAge, path = "/") {
|
|
|
93
129
|
return `Path=${path}; HttpOnly; SameSite=Lax${lifetime}${secure}`;
|
|
94
130
|
}
|
|
95
131
|
|
|
96
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* A session outlives the browser only when it is written `persistent: true`.
|
|
134
|
+
*
|
|
135
|
+
* @param {Session} session
|
|
136
|
+
*/
|
|
97
137
|
function lifetime(session) {
|
|
98
138
|
return session.persistent === true ? MAX_AGE : undefined;
|
|
99
139
|
}
|
|
100
140
|
|
|
141
|
+
/**
|
|
142
|
+
* @param {import("node:http").ServerResponse} response
|
|
143
|
+
* @param {string} name
|
|
144
|
+
* @param {string} value
|
|
145
|
+
* @param {string} cookieAttributes
|
|
146
|
+
*/
|
|
101
147
|
function appendCookie(response, name, value, cookieAttributes) {
|
|
102
148
|
const cookie = `${scopedCookieName(name)}=${value}; ${cookieAttributes}`;
|
|
103
149
|
const current = response.getHeader("set-cookie");
|
|
@@ -109,16 +155,30 @@ function appendCookie(response, name, value, cookieAttributes) {
|
|
|
109
155
|
);
|
|
110
156
|
}
|
|
111
157
|
|
|
158
|
+
/**
|
|
159
|
+
* @param {SessionRequest} request
|
|
160
|
+
* @returns {Session | undefined} The session set for the request, else the one
|
|
161
|
+
* the cookie seals, `undefined` when there is none
|
|
162
|
+
*/
|
|
112
163
|
export function readSession(request) {
|
|
113
164
|
if (REQUEST_SESSION in request) return request[REQUEST_SESSION];
|
|
114
165
|
const value = cookieValue(request, SESSION_COOKIE);
|
|
166
|
+
// 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
167
|
return value ? decode(value) : undefined;
|
|
116
168
|
}
|
|
117
169
|
|
|
170
|
+
/**
|
|
171
|
+
* @param {SessionRequest} request
|
|
172
|
+
* @param {Session | undefined} session
|
|
173
|
+
*/
|
|
118
174
|
export function setRequestSession(request, session) {
|
|
119
175
|
request[REQUEST_SESSION] = session;
|
|
120
176
|
}
|
|
121
177
|
|
|
178
|
+
/**
|
|
179
|
+
* @param {import("node:http").ServerResponse} response
|
|
180
|
+
* @param {Session} session
|
|
181
|
+
*/
|
|
122
182
|
export function writeSession(response, session) {
|
|
123
183
|
appendCookie(
|
|
124
184
|
response,
|
|
@@ -128,19 +188,38 @@ export function writeSession(response, session) {
|
|
|
128
188
|
);
|
|
129
189
|
}
|
|
130
190
|
|
|
191
|
+
/** @param {import("node:http").ServerResponse} response */
|
|
131
192
|
export function clearSession(response) {
|
|
132
193
|
appendCookie(response, SESSION_COOKIE, "", attributes(0));
|
|
133
194
|
}
|
|
134
195
|
|
|
196
|
+
/**
|
|
197
|
+
* @param {import("node:http").IncomingMessage} request
|
|
198
|
+
* @param {string} name
|
|
199
|
+
* @returns {unknown} What was sealed, as the JSON it was written from,
|
|
200
|
+
* `undefined` when there is none or it does not open
|
|
201
|
+
*/
|
|
135
202
|
export function readSealedCookie(request, name) {
|
|
136
203
|
const value = cookieValue(request, name);
|
|
137
204
|
return value ? decode(value) : undefined;
|
|
138
205
|
}
|
|
139
206
|
|
|
207
|
+
/**
|
|
208
|
+
* @param {import("node:http").ServerResponse} response
|
|
209
|
+
* @param {string} name
|
|
210
|
+
* @param {unknown} value
|
|
211
|
+
* @param {number | undefined} maxAge
|
|
212
|
+
* @param {string} [path]
|
|
213
|
+
*/
|
|
140
214
|
export function writeSealedCookie(response, name, value, maxAge, path = "/") {
|
|
141
215
|
appendCookie(response, name, encode(value), attributes(maxAge, path));
|
|
142
216
|
}
|
|
143
217
|
|
|
218
|
+
/**
|
|
219
|
+
* @param {import("node:http").ServerResponse} response
|
|
220
|
+
* @param {string} name
|
|
221
|
+
* @param {string} [path]
|
|
222
|
+
*/
|
|
144
223
|
export function clearSealedCookie(response, name, path = "/") {
|
|
145
224
|
appendCookie(response, name, "", attributes(0, path));
|
|
146
225
|
}
|
|
@@ -12,6 +12,8 @@ const HOOK_ROUTE = Object.freeze({ bodyLimit: 0 });
|
|
|
12
12
|
* sign-in page and everything below it, whole segments only (`/auth` exempts
|
|
13
13
|
* `/auth/callback`, never `/authors`), and the onboarding page. Derived from
|
|
14
14
|
* the sign-in path in use, or a custom one would redirect to itself.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} signInPath
|
|
15
17
|
*/
|
|
16
18
|
function defaultExempt(signInPath) {
|
|
17
19
|
const signIn = signInPath === "/" ? "/" : `${signInPath}/**`;
|
|
@@ -22,6 +24,9 @@ function defaultExempt(signInPath) {
|
|
|
22
24
|
* The page props `user` and `session` of `session`, from the provider's
|
|
23
25
|
* `publicSession`. The server adds the `updatedAt` stamp it wrote itself,
|
|
24
26
|
* which the browser compares to drop a page restored from its cache.
|
|
27
|
+
*
|
|
28
|
+
* @param {NonNullable<import("./module-route-types.mjs").AuthProvider["publicSession"]>} publicSession
|
|
29
|
+
* @param {import("./module-event.mjs").Session | undefined} session
|
|
25
30
|
*/
|
|
26
31
|
function hookView(publicSession, session) {
|
|
27
32
|
if (!session) return {};
|
|
@@ -40,9 +45,9 @@ function hookView(publicSession, session) {
|
|
|
40
45
|
* `dms.server.ts`. Without one, a page carries no session and a refused session
|
|
41
46
|
* is not renewed.
|
|
42
47
|
*
|
|
43
|
-
* @param provider The resolved auth hooks, `undefined` when no module
|
|
48
|
+
* @param {import("./module-route-types.mjs").AuthProvider | undefined} provider The resolved auth hooks, `undefined` when no module
|
|
44
49
|
* registers any
|
|
45
|
-
* @param context `(id) => ({ module, app })`, a module's event context
|
|
50
|
+
* @param {(id: string) => import("./module-server.mjs").ModuleContext} context `(id) => ({ module, app })`, a module's event context
|
|
46
51
|
* @returns `{ provider, signInPath, isSignInExempt, signInLocation, view,
|
|
47
52
|
* refresh }`
|
|
48
53
|
*/
|
|
@@ -54,16 +59,21 @@ export function serverAuth(provider, context) {
|
|
|
54
59
|
return {
|
|
55
60
|
provider: provider?.id,
|
|
56
61
|
signInPath,
|
|
57
|
-
isSignInExempt: (pathname) =>
|
|
62
|
+
isSignInExempt: (/** @type {string} */ pathname) =>
|
|
58
63
|
exempt.some((pattern) => pattern.test(pathname)),
|
|
59
|
-
signInLocation: (requestUrl) =>
|
|
64
|
+
signInLocation: (/** @type {string} */ requestUrl) =>
|
|
60
65
|
`${signInPath}?redirect=${encodeURIComponent(requestUrl)}`,
|
|
61
66
|
view: publicSession
|
|
62
|
-
? (
|
|
67
|
+
? (
|
|
68
|
+
/** @type {import("./module-event.mjs").Session | undefined} */ session,
|
|
69
|
+
) => hookView(publicSession, session)
|
|
63
70
|
: () => ({}),
|
|
64
71
|
/**
|
|
65
72
|
* Renews the request's session after the backend refused it. Returns the
|
|
66
73
|
* new session, which the rest of the request runs with, or `undefined`.
|
|
74
|
+
*
|
|
75
|
+
* @param {import("node:http").IncomingMessage} request
|
|
76
|
+
* @param {import("node:http").ServerResponse} response
|
|
67
77
|
*/
|
|
68
78
|
async refresh(request, response) {
|
|
69
79
|
if (!refreshSession) return undefined;
|