@antelopejs/dms-frontend 0.5.5 → 0.6.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.
Files changed (75) hide show
  1. package/README.md +377 -19
  2. package/dist/app-configs.d.ts +9 -0
  3. package/dist/app-configs.js +52 -0
  4. package/dist/asset-roots.d.ts +7 -0
  5. package/dist/asset-roots.js +34 -0
  6. package/dist/commands/build-action.js +9 -0
  7. package/dist/commands/dev-action.js +88 -19
  8. package/dist/commands/start-action.js +2 -0
  9. package/dist/common.d.ts +1 -0
  10. package/dist/common.js +1 -0
  11. package/dist/config.d.ts +2 -0
  12. package/dist/config.js +9 -3
  13. package/dist/derived-outputs.d.ts +2 -0
  14. package/dist/derived-outputs.js +38 -31
  15. package/dist/help.js +10 -3
  16. package/dist/layer-watch.js +123 -131
  17. package/dist/layers.js +2 -0
  18. package/dist/manifest-secrets.js +5 -5
  19. package/dist/manifest.js +7 -1
  20. package/dist/materialize.js +21 -1
  21. package/dist/module-set.d.ts +16 -0
  22. package/dist/module-set.js +80 -0
  23. package/dist/server-process.d.ts +1 -0
  24. package/dist/server-process.js +28 -2
  25. package/dist/verify-source-build.d.ts +11 -0
  26. package/dist/verify-source-build.js +113 -0
  27. package/dist/verify-source-runner.js +14 -57
  28. package/dist/workspace.d.ts +9 -0
  29. package/package.json +2 -1
  30. package/templates/vue/app-runtime.ts +58 -16
  31. package/templates/vue/client-server-guard.mjs +73 -0
  32. package/templates/vue/email-renderer.ts +17 -4
  33. package/templates/vue/email-runtime.ts +1 -1
  34. package/templates/vue/frontend-module.ts +1817 -259
  35. package/templates/vue/globals.d.ts +1 -1
  36. package/templates/vue/icon-hydration.ts +106 -1
  37. package/templates/vue/index.html +0 -1
  38. package/templates/vue/layer-app-config.ts +92 -0
  39. package/templates/vue/main.ts +10 -2
  40. package/templates/vue/package.json +5 -1
  41. package/templates/vue/prepare-types.mjs +34 -0
  42. package/templates/vue/server/auth/backend.mjs +13 -19
  43. package/templates/vue/server/auth/client-ip.mjs +27 -0
  44. package/templates/vue/server/auth/establish-endpoints.mjs +70 -0
  45. package/templates/vue/server/auth/session.mjs +6 -97
  46. package/templates/vue/server/auth-hooks.mjs +84 -0
  47. package/templates/vue/server/backend-proxies.mjs +110 -0
  48. package/templates/vue/server/dev-private-files.mjs +99 -0
  49. package/templates/vue/server/email.mjs +11 -33
  50. package/templates/vue/server/favicon.mjs +20 -0
  51. package/templates/vue/server/inertia.mjs +48 -3
  52. package/templates/vue/server/listen-host.mjs +19 -0
  53. package/templates/vue/server/module-event.mjs +265 -0
  54. package/templates/vue/server/module-routes.mjs +513 -0
  55. package/templates/vue/server/module-server.mjs +225 -0
  56. package/templates/vue/server/module-testing.mjs +65 -0
  57. package/templates/vue/server/page-props.mjs +151 -0
  58. package/templates/vue/server/path-patterns.mjs +38 -0
  59. package/templates/vue/server/proxy-headers.mjs +25 -0
  60. package/templates/vue/server/public-endpoints.mjs +69 -11
  61. package/templates/vue/server/request-routing.mjs +47 -0
  62. package/templates/vue/server/route-rules.mjs +129 -0
  63. package/templates/vue/server-module.ts +294 -0
  64. package/templates/vue/server.mjs +178 -169
  65. package/templates/vue/ssr-renderer.ts +60 -19
  66. package/templates/vue/tsconfig.json +2 -1
  67. package/templates/vue/ui-app-config.ts +18 -6
  68. package/templates/vue/ui-inertia-page.mjs +37 -0
  69. package/templates/vue/vite.config.ts +31 -10
  70. package/templates/vue/vite.email.config.ts +1 -1
  71. package/templates/vue/vite.server.config.ts +57 -0
  72. package/templates/vue/server/auth/oauth.mjs +0 -233
  73. package/templates/vue/server/auth/routes.mjs +0 -444
  74. package/templates/vue/server/render-token.mjs +0 -81
  75. package/templates/vue/server/tester.mjs +0 -227
@@ -1 +1 @@
1
- declare const defineAppConfig: typeof import("./frontend-module").defineAppConfig;
1
+ declare const defineDmsAppConfig: typeof import("./frontend-module").defineDmsAppConfig;
@@ -7,10 +7,29 @@ import {
7
7
  type IconifyJSON,
8
8
  iconLoaded,
9
9
  } from "@iconify/vue";
10
- import type { App, Component, ComponentPublicInstance } from "vue";
10
+ import {
11
+ type App,
12
+ type Component,
13
+ type ComponentPublicInstance,
14
+ cloneVNode,
15
+ inject,
16
+ type InjectionKey,
17
+ type VNode,
18
+ } from "vue";
11
19
 
12
20
  /** The element a server render writes the data of the icons it drew to. */
13
21
  export const SSR_ICONS_ID = "dms-ssr-icons";
22
+ /** The hidden `<svg>` that holds each drawn icon once, as a `<symbol>`. */
23
+ export const ICON_SPRITE_ID = "dms-icon-sprite";
24
+ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
25
+ const SYMBOL_PREFIX = "dms-icon-";
26
+ const HASH_OFFSET = 0x811c9dc5;
27
+ const HASH_PRIME = 0x01000193;
28
+ const HASH_RADIX = 36;
29
+
30
+ /** The symbols of one server render, by id. */
31
+ type IconSymbols = Map<string, string>;
32
+ const SERVER_SYMBOLS: InjectionKey<IconSymbols> = Symbol("dms-icon-symbols");
14
33
 
15
34
  interface IconProps {
16
35
  icon?: unknown;
@@ -33,6 +52,92 @@ export function drawLoadedIconsAtOnce(): void {
33
52
  default: ({ icon }: IconProps) =>
34
53
  typeof icon !== "string" || iconLoaded(icon),
35
54
  };
55
+ drawIconsFromSprite();
56
+ }
57
+
58
+ /** FNV-1a: the same short digest in a server and in a browser. */
59
+ function digest(text: string): string {
60
+ let hash = HASH_OFFSET;
61
+ for (let index = 0; index < text.length; index++) {
62
+ hash ^= text.charCodeAt(index);
63
+ hash = Math.imul(hash, HASH_PRIME);
64
+ }
65
+ return (hash >>> 0).toString(HASH_RADIX);
66
+ }
67
+
68
+ function symbolMarkup(id: string, viewBox: string, body: string): string {
69
+ return `<symbol id="${id}" viewBox="${viewBox}">${body}</symbol>`;
70
+ }
71
+
72
+ /** Adds a symbol to the page's sprite, which it creates the first time. */
73
+ function addSymbolToDocument(id: string, viewBox: string, body: string): void {
74
+ if (document.getElementById(id)) return;
75
+ let sprite: Element | null = document.getElementById(ICON_SPRITE_ID);
76
+ if (!sprite) {
77
+ const created = document.createElementNS(SVG_NAMESPACE, "svg");
78
+ created.id = ICON_SPRITE_ID;
79
+ created.setAttribute("aria-hidden", "true");
80
+ created.setAttribute("style", "display:none");
81
+ document.body.append(created);
82
+ sprite = created;
83
+ }
84
+ sprite.insertAdjacentHTML("beforeend", symbolMarkup(id, viewBox, body));
85
+ }
86
+
87
+ /**
88
+ * The icon drawn as a `<use>` of its symbol in the page's sprite, rather than
89
+ * with its whole body: an icon shown on every row of a table is sent once.
90
+ * The symbol is keyed by the drawn body and view box, so a flipped or rotated
91
+ * icon is a symbol of its own. Left inline on a server render that collects no
92
+ * symbols, and for an empty icon.
93
+ */
94
+ function spriteIcon(vnode: VNode, symbols: IconSymbols | null): VNode {
95
+ const props = vnode.props as Record<string, unknown> | null;
96
+ const body = props?.innerHTML;
97
+ const viewBox = props?.viewBox;
98
+ if (
99
+ vnode.type !== "svg" ||
100
+ typeof body !== "string" ||
101
+ body === "" ||
102
+ typeof viewBox !== "string"
103
+ )
104
+ return vnode;
105
+ const id = `${SYMBOL_PREFIX}${digest(`${viewBox}\n${body}`)}`;
106
+ if (symbols) symbols.set(id, symbolMarkup(id, viewBox, body));
107
+ else if (typeof document !== "undefined")
108
+ addSymbolToDocument(id, viewBox, body);
109
+ else return vnode;
110
+ return cloneVNode(vnode, { innerHTML: `<use href="#${id}"></use>` });
111
+ }
112
+
113
+ let drawingFromSprite = false;
114
+
115
+ /** Makes every Iconify icon draw itself from the page's sprite. */
116
+ function drawIconsFromSprite(): void {
117
+ if (drawingFromSprite) return;
118
+ drawingFromSprite = true;
119
+ const component = Icon as unknown as {
120
+ setup: (props: unknown, context: unknown) => () => VNode;
121
+ };
122
+ const setup = component.setup;
123
+ component.setup = (props, context) => {
124
+ const symbols = inject(SERVER_SYMBOLS, null);
125
+ const draw = setup(props, context);
126
+ return () => spriteIcon(draw(), symbols);
127
+ };
128
+ }
129
+
130
+ /** Collects the symbols of the icons a server render draws. */
131
+ export function collectDmsIconSymbols(app: App): IconSymbols {
132
+ const symbols: IconSymbols = new Map();
133
+ app.provide(SERVER_SYMBOLS, symbols);
134
+ return symbols;
135
+ }
136
+
137
+ /** The sprite of a server render, each icon it drew once. */
138
+ export function dmsIconSprite(symbols: IconSymbols): string {
139
+ if (!symbols.size) return "";
140
+ return `<svg id="${ICON_SPRITE_ID}" aria-hidden="true" style="display:none">${[...symbols.values()].join("")}</svg>`;
36
141
  }
37
142
 
38
143
  /**
@@ -3,7 +3,6 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <link rel="icon" href="data:," />
7
6
  <title>Antelope DMS</title>
8
7
  </head>
9
8
  <body>
@@ -0,0 +1,92 @@
1
+ // The DMS app config the layers' `app/app.config.ts` make, and how an edited
2
+ // one reaches it while the page is open in development.
3
+ //
4
+ // Lives apart from frontend-module.ts so the merge can be exercised on its own,
5
+ // the way ui-app-config.ts is.
6
+
7
+ import { defu } from "defu";
8
+ import { clonePlain, isPlainRecord, type PlainRecord } from "./ui-app-config";
9
+
10
+ /**
11
+ * The layer configs, highest precedence first, merged into a new object that
12
+ * shares nothing with them: the modules extend what this returns, and must not
13
+ * write into a config module's exports, which a development server keeps from
14
+ * one setup of the modules to the next.
15
+ */
16
+ export function mergeLayerAppConfigs(configs: readonly unknown[]): PlainRecord {
17
+ return defu({}, ...configs.filter(isPlainRecord).map(clonePlain));
18
+ }
19
+
20
+ function isSameValue(left: unknown, right: unknown): boolean {
21
+ if (Array.isArray(left) && Array.isArray(right))
22
+ return (
23
+ left.length === right.length &&
24
+ left.every((item, index) => isSameValue(item, right[index]))
25
+ );
26
+ if (isPlainRecord(left) && isPlainRecord(right)) {
27
+ const keys = Object.keys(left);
28
+ return (
29
+ keys.length === Object.keys(right).length &&
30
+ keys.every((key) => key in right && isSameValue(left[key], right[key]))
31
+ );
32
+ }
33
+ return Object.is(left, right);
34
+ }
35
+
36
+ /**
37
+ * What `after` holds that `before` does not: each value added or changed, down
38
+ * through the records both hold. An array counts as one value.
39
+ */
40
+ export function appConfigChanges(
41
+ before: PlainRecord,
42
+ after: PlainRecord,
43
+ ): PlainRecord {
44
+ const changes: PlainRecord = {};
45
+ for (const [key, value] of Object.entries(after)) {
46
+ const previous = before[key];
47
+ if (isPlainRecord(previous) && isPlainRecord(value)) {
48
+ const nested = appConfigChanges(previous, value);
49
+ if (Object.keys(nested).length > 0) changes[key] = nested;
50
+ } else if (!isSameValue(previous, value)) {
51
+ changes[key] = clonePlain(value);
52
+ }
53
+ }
54
+ return changes;
55
+ }
56
+
57
+ /**
58
+ * `config` with `changes` on top: a record both hold is merged, anything else
59
+ * the changes hold replaces what `config` has, arrays included.
60
+ */
61
+ export function withAppConfigChanges(
62
+ config: PlainRecord,
63
+ changes: PlainRecord,
64
+ ): PlainRecord {
65
+ const result = { ...config };
66
+ for (const [key, value] of Object.entries(changes)) {
67
+ const current = result[key];
68
+ result[key] =
69
+ isPlainRecord(current) && isPlainRecord(value)
70
+ ? withAppConfigChanges(current, value)
71
+ : clonePlain(value);
72
+ }
73
+ return result;
74
+ }
75
+
76
+ /**
77
+ * Make `target` hold what `source` holds, in place: a record both hold is
78
+ * updated rather than replaced, so whoever kept it, a component reading
79
+ * `useDmsAppConfig().branding` for instance, sees the new values too, and a
80
+ * key `source` no longer has is removed.
81
+ */
82
+ export function assignInPlace(target: PlainRecord, source: PlainRecord): void {
83
+ for (const key of Object.keys(target)) {
84
+ if (!(key in source)) delete target[key];
85
+ }
86
+ for (const [key, value] of Object.entries(source)) {
87
+ const current = target[key];
88
+ if (isPlainRecord(current) && isPlainRecord(value))
89
+ assignInPlace(current, value);
90
+ else target[key] = value;
91
+ }
92
+ }
@@ -3,7 +3,12 @@ import { addAPIProvider } from "@iconify/vue";
3
3
  import { createInertiaApp } from "@inertiajs/vue3";
4
4
  import { createHead } from "@unhead/vue/client";
5
5
  import { createApp, createSSRApp, h } from "vue";
6
- import { configureDmsApp, resolveDmsInertiaPage } from "./app-runtime";
6
+ import { appConfigs, syncAppConfigs } from "./app-configs.generated";
7
+ import {
8
+ applyLayerAppConfigs,
9
+ configureDmsApp,
10
+ resolveDmsInertiaPage,
11
+ } from "./app-runtime";
7
12
  import { setupFrontendModules } from "./frontend-module";
8
13
  import { frontendModules } from "./frontend-modules.generated";
9
14
  import { sameOriginIconProvider } from "./icon-api.mjs";
@@ -52,7 +57,10 @@ if (import.meta.env.DEV) adoptViteDevStyles();
52
57
  // the generated server answers those queries from the installed collections.
53
58
  addAPIProvider("", sameOriginIconProvider(location.origin));
54
59
 
55
- await setupFrontendModules(frontendModules);
60
+ await setupFrontendModules(frontendModules, appConfigs);
61
+ // A layer's app config edited during development reaches the page without a
62
+ // reload. A server render imports the new configs instead.
63
+ syncAppConfigs(applyLayerAppConfigs);
56
64
 
57
65
  createInertiaApp({
58
66
  resolve: resolveDmsInertiaPage,
@@ -4,11 +4,13 @@
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
7
- "build": "vite build && node compress-assets.mjs && vite build --ssr ssr-renderer.ts --outDir dist/ssr && vite build --config vite.email.config.ts",
7
+ "build": "vite build && node compress-assets.mjs && vite build --ssr ssr-renderer.ts --outDir dist/ssr && vite build --config vite.email.config.ts && vite build --config vite.server.config.ts",
8
8
  "build:client": "vite build",
9
9
  "build:compress": "node compress-assets.mjs",
10
10
  "build:ssr": "vite build --ssr ssr-renderer.ts --outDir dist/ssr",
11
11
  "build:email": "vite build --config vite.email.config.ts",
12
+ "build:server": "vite build --config vite.server.config.ts",
13
+ "build:types": "node prepare-types.mjs",
12
14
  "typecheck": "node --import ./typecheck-loader.mjs ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit",
13
15
  "start": "node server.mjs"
14
16
  },
@@ -25,6 +27,7 @@
25
27
  "@vue/server-renderer": "^3.5.0",
26
28
  "@vueuse/core": "^14.3.0",
27
29
  "defu": "^6.1.4",
30
+ "devalue": "^5.9.4",
28
31
  "ofetch": "^1.4.1",
29
32
  "reka-ui": "^2.6.0",
30
33
  "tailwindcss": "^4.0.0",
@@ -36,6 +39,7 @@
36
39
  "vue-i18n": "^11.1.12"
37
40
  },
38
41
  "devDependencies": {
42
+ "@types/node": "^24.0.0",
39
43
  "typescript": "^5.8.2",
40
44
  "vue-tsc": "3.1.1"
41
45
  },
@@ -0,0 +1,34 @@
1
+ // Writes the files Vite generates and the type check reads (auto-imports.d.ts
2
+ // and the Nuxt UI templates) without building anything: it resolves
3
+ // vite.config.ts and runs its build hooks over an empty entry, in seconds
4
+ // where a client build takes a minute. `ajs dms verify-source` runs it first
5
+ // (`pnpm run build:types`), so the type check no longer waits for the client
6
+ // bundle.
7
+
8
+ import { build } from "vite";
9
+
10
+ const ENTRY = "dms-prepare-types-entry";
11
+ const RESOLVED_ENTRY = `\0${ENTRY}`;
12
+
13
+ await build({
14
+ configFile: "vite.config.ts",
15
+ plugins: [
16
+ {
17
+ name: "dms-prepare-types-entry",
18
+ enforce: "pre",
19
+ resolveId: (id) => (id === ENTRY ? RESOLVED_ENTRY : null),
20
+ load: (id) => (id === RESOLVED_ENTRY ? "export {};" : null),
21
+ },
22
+ ],
23
+ build: {
24
+ write: false,
25
+ emptyOutDir: false,
26
+ rollupOptions: {
27
+ input: ENTRY,
28
+ // The entry is empty on purpose.
29
+ onwarn: (warning, warn) => {
30
+ if (warning.code !== "EMPTY_BUNDLE") warn(warning);
31
+ },
32
+ },
33
+ },
34
+ });
@@ -5,7 +5,6 @@ const FORWARDED_HEADERS = [
5
5
  "x-content-language",
6
6
  "x-realtime-session",
7
7
  ];
8
- const BODY_LIMIT = 64 * 1024;
9
8
 
10
9
  export class UpstreamError extends Error {
11
10
  constructor(status, message) {
@@ -44,8 +43,20 @@ export function relayedHeaders(request, names) {
44
43
  };
45
44
  }
46
45
 
46
+ /**
47
+ * Calls the backend for a server route, JSON in and out.
48
+ *
49
+ * `options.headers` adds request headers; the client's address and the
50
+ * relayed client headers are set after them, so no caller replaces those.
51
+ */
47
52
  export async function backend(path, request, options = {}) {
48
- const headers = relayedHeaders(request, FORWARDED_HEADERS);
53
+ const headers = Object.fromEntries(
54
+ Object.entries(options.headers ?? {}).map(([name, value]) => [
55
+ name.toLowerCase(),
56
+ String(value),
57
+ ]),
58
+ );
59
+ Object.assign(headers, relayedHeaders(request, FORWARDED_HEADERS));
49
60
  headers["x-forwarded-for"] = clientIp(request);
50
61
  if (options.token) headers.authorization = `Bearer ${options.token}`;
51
62
  if (options.relay)
@@ -74,23 +85,6 @@ export async function backend(path, request, options = {}) {
74
85
  return data;
75
86
  }
76
87
 
77
- export async function body(request) {
78
- const chunks = [];
79
- let size = 0;
80
- for await (const chunk of request) {
81
- size += chunk.length;
82
- if (size > BODY_LIMIT)
83
- throw new RequestBodyError(413, "Request body too large");
84
- chunks.push(chunk);
85
- }
86
- if (!chunks.length) return {};
87
- try {
88
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
89
- } catch {
90
- throw new RequestBodyError(400, "Invalid request body");
91
- }
92
- }
93
-
94
88
  export class RequestBodyError extends Error {
95
89
  constructor(status, message) {
96
90
  super(message);
@@ -26,6 +26,33 @@ function requestHost(request) {
26
26
  );
27
27
  }
28
28
 
29
+ /**
30
+ * The `X-Forwarded-*` headers of a backend request, which tell the backend
31
+ * the client a request comes from: its address, to limit each client, and
32
+ * the scheme and host it asked, to check an Origin against.
33
+ *
34
+ * The client's own `X-Forwarded-For` is extended only when a trusted reverse
35
+ * proxy wrote it (`DMS_TRUSTED_PROXY_HOPS` above `0`): otherwise any caller
36
+ * could have, and the socket's address replaces it. The scheme and the host
37
+ * are read the same way as the CSRF origin check reads them.
38
+ */
39
+ export function forwardedHeaders(request) {
40
+ const socketIp = request.socket?.remoteAddress || "unknown";
41
+ const incoming = trustedHops()
42
+ ? String(request.headers["x-forwarded-for"] ?? "")
43
+ .split(",")
44
+ .map((value) => value.trim())
45
+ .filter(Boolean)
46
+ : [];
47
+ const headers = {
48
+ "x-forwarded-for": [...incoming, socketIp].join(", "),
49
+ "x-forwarded-proto": requestScheme(request),
50
+ };
51
+ const host = requestHost(request);
52
+ if (host) headers["x-forwarded-host"] = host;
53
+ return headers;
54
+ }
55
+
29
56
  export function isSameOrigin(request) {
30
57
  const origin = request.headers.origin;
31
58
  if (!origin) return false;
@@ -0,0 +1,70 @@
1
+ // The backend endpoints a module may open a session from: what the backend's
2
+ // modules declared in the frontend manifest, and what the environment adds
3
+ // (`DMS_AUTH_ESTABLISH_ENDPOINTS`). The module that owns sign-in reads them as
4
+ // `event.app.authEstablishEndpoints`.
5
+
6
+ import { readFileSync } from "node:fs";
7
+
8
+ // Absolute backend API paths only: no scheme, no authority, no query string,
9
+ // and no segment that could climb out of `/api/`.
10
+ const BACKEND_API_PATH =
11
+ /^\/api\/[A-Za-z0-9][A-Za-z0-9._~-]*(?:\/[A-Za-z0-9][A-Za-z0-9._~-]*)*$/;
12
+
13
+ // Written at the workspace root by the loader, from the endpoints each backend
14
+ // module declared in the frontend manifest. This file sits two directories up.
15
+ const DECLARED_ENDPOINTS_FILE = new URL(
16
+ "../../generated-auth-establish.json",
17
+ import.meta.url,
18
+ );
19
+
20
+ let declaredEndpoints;
21
+
22
+ /**
23
+ * Backend endpoints the modules of this deployment declared as session-opening.
24
+ *
25
+ * Read from the workspace rather than from the backend: the file is generated
26
+ * when the workspace is materialized, so the server needs no backend round trip
27
+ * — and no operator — to know which module flows may end on a login. A
28
+ * workspace built from a DMS that predates the declaration simply has no file,
29
+ * and the list falls back to whatever the environment names.
30
+ *
31
+ * @param file Generated file to read
32
+ * @returns The declared paths, unfiltered
33
+ */
34
+ export function readDeclaredEstablishEndpoints(file = DECLARED_ENDPOINTS_FILE) {
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
37
+ return Array.isArray(parsed?.endpoints) ? parsed.endpoints : [];
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Backend endpoints this deployment lets a module open a session from.
45
+ *
46
+ * The union of what the backend's modules declared — the standard case, which
47
+ * needs no configuration — and what the environment adds on top, which is how
48
+ * an operator extends the list for a route no module owns. Everything else
49
+ * stays refused, so no backend endpoint that happens to mint a token pair can
50
+ * be turned into a login by a request from the browser.
51
+ *
52
+ * @param declaration Comma-separated absolute backend paths from the environment
53
+ * @param declared Paths declared by the backend's frontend modules
54
+ * @returns The well-formed backend API paths, without duplicates
55
+ */
56
+ export function allowedEstablishEndpoints(
57
+ declaration = process.env.DMS_AUTH_ESTABLISH_ENDPOINTS,
58
+ declared = (declaredEndpoints ??= readDeclaredEstablishEndpoints()),
59
+ ) {
60
+ const fromEnvironment = (declaration ?? "")
61
+ .split(",")
62
+ .map((entry) => entry.trim());
63
+ return [
64
+ ...new Set(
65
+ [...declared, ...fromEnvironment].filter(
66
+ (entry) => typeof entry === "string" && BACKEND_API_PATH.test(entry),
67
+ ),
68
+ ),
69
+ ];
70
+ }
@@ -3,16 +3,11 @@ import {
3
3
  createDecipheriv,
4
4
  createHash,
5
5
  randomBytes,
6
- randomUUID,
7
6
  } from "node:crypto";
8
7
 
9
- const SESSION_COOKIE = "dms_session";
10
- const ACCOUNT_INDEX_COOKIE = "dms_account_index";
11
- const ACCOUNT_COOKIE_PREFIX = "dms_account_";
8
+ export const SESSION_COOKIE = "dms_session";
12
9
  const REQUEST_SESSION = Symbol("dms-request-session");
13
10
  const MAX_AGE = 60 * 60 * 24 * 30;
14
- const UUID =
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
11
 
17
12
  /**
18
13
  * A suffix that keeps this instance's cookies apart from another's on the same
@@ -98,11 +93,10 @@ function attributes(maxAge, path = "/") {
98
93
  }
99
94
 
100
95
  /**
101
- * Whether a session, or a stored account, outlives the browser. Only a login
102
- * that left "keep me signed in" unchecked says no; a cookie written before
103
- * the flag existed carries none, and keeps the lifetime it was given then.
96
+ * Whether a session outlives the browser. Only a session written with
97
+ * `persistent: false` says no.
104
98
  */
105
- export function isPersistent(session) {
99
+ function isPersistent(session) {
106
100
  return session?.persistent !== false;
107
101
  }
108
102
 
@@ -121,22 +115,6 @@ function appendCookie(response, name, value, cookieAttributes) {
121
115
  );
122
116
  }
123
117
 
124
- function accountCookieName(accountId) {
125
- if (!UUID.test(accountId)) throw new Error("Invalid account id");
126
- return `${ACCOUNT_COOKIE_PREFIX}${accountId.replaceAll("-", "")}`;
127
- }
128
-
129
- function descriptor(session, accountId) {
130
- return {
131
- accountId,
132
- userId: session.user?._id ?? session.user?.id,
133
- email: session.user?.email,
134
- name: session.user?.name,
135
- activeTenantId: session.activeTenantId,
136
- persistent: isPersistent(session),
137
- };
138
- }
139
-
140
118
  export function readSession(request) {
141
119
  if (REQUEST_SESSION in request) return request[REQUEST_SESSION];
142
120
  const value = cookieValue(request, SESSION_COOKIE);
@@ -160,84 +138,15 @@ export function clearSession(response) {
160
138
  appendCookie(response, SESSION_COOKIE, "", attributes(0));
161
139
  }
162
140
 
163
- export function readAccounts(request) {
164
- const value = cookieValue(request, ACCOUNT_INDEX_COOKIE);
165
- return value ? (decode(value) ?? {}) : {};
166
- }
167
-
168
- function writeAccountIndex(response, accounts) {
169
- const persistent = Object.values(accounts).some(isPersistent);
170
- appendCookie(
171
- response,
172
- ACCOUNT_INDEX_COOKIE,
173
- encode(accounts),
174
- attributes(lifetime(persistent)),
175
- );
176
- }
177
-
178
- export function readAccount(request, accountId) {
179
- if (!UUID.test(accountId ?? "")) return undefined;
180
- const value = cookieValue(request, accountCookieName(accountId));
181
- return value ? decode(value) : undefined;
182
- }
183
-
184
- export function storeAccount(request, response, session, accountId) {
185
- const id = accountId ?? randomUUID();
186
- const accountDescriptor = descriptor(session, id);
187
- const accounts = { ...readAccounts(request), [id]: accountDescriptor };
188
- writeAccountIndex(response, accounts);
189
- appendCookie(
190
- response,
191
- accountCookieName(id),
192
- encode({ refreshToken: session.refreshToken, ...accountDescriptor }),
193
- attributes(lifetime(accountDescriptor.persistent)),
194
- );
195
- return accountDescriptor;
196
- }
197
-
198
- export function assertAccountInvariant(session, accountId) {
199
- if (!accountId || session.accountId !== accountId)
200
- throw new Error("Account session invariant violated");
201
- }
202
-
203
- /**
204
- * Write `session` as the current one, and as one of the stored accounts.
205
- * Stamps it with `updatedAt`, which lets the browser tell a page fetched
206
- * before this write, and restored from its cache, from a current one.
207
- */
208
- export function persistAccountSession(request, response, session, accountId) {
209
- const id = accountId ?? session.accountId ?? randomUUID();
210
- session.accountId = id;
211
- session.updatedAt = Date.now();
212
- const account = storeAccount(request, response, session, id);
213
- assertAccountInvariant(session, account.accountId);
214
- writeSession(response, session);
215
- return account;
216
- }
217
-
218
- export function removeAccount(request, response, accountId) {
219
- const accounts = { ...readAccounts(request) };
220
- delete accounts[accountId];
221
- writeAccountIndex(response, accounts);
222
- if (UUID.test(accountId ?? ""))
223
- appendCookie(response, accountCookieName(accountId), "", attributes(0));
224
- }
225
-
226
141
  export function readSealedCookie(request, name) {
227
142
  const value = cookieValue(request, name);
228
143
  return value ? decode(value) : undefined;
229
144
  }
230
145
 
231
- export function writeSealedCookie(
232
- response,
233
- name,
234
- value,
235
- maxAge,
236
- path = "/auth/oauth",
237
- ) {
146
+ export function writeSealedCookie(response, name, value, maxAge, path = "/") {
238
147
  appendCookie(response, name, encode(value), attributes(maxAge, path));
239
148
  }
240
149
 
241
- export function clearSealedCookie(response, name, path = "/auth/oauth") {
150
+ export function clearSealedCookie(response, name, path = "/") {
242
151
  appendCookie(response, name, "", attributes(0, path));
243
152
  }