@antelopejs/dms-frontend 0.5.6 → 0.6.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.
Files changed (71) hide show
  1. package/README.md +376 -18
  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 +8 -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 +84 -0
  23. package/dist/server-process.d.ts +1 -0
  24. package/dist/server-process.js +28 -2
  25. package/dist/verify-source-runner.js +12 -0
  26. package/dist/workspace.d.ts +9 -0
  27. package/package.json +2 -1
  28. package/templates/vue/app-runtime.ts +58 -16
  29. package/templates/vue/client-server-guard.mjs +73 -0
  30. package/templates/vue/email-renderer.ts +17 -4
  31. package/templates/vue/email-runtime.ts +1 -1
  32. package/templates/vue/frontend-module.ts +1817 -259
  33. package/templates/vue/globals.d.ts +1 -1
  34. package/templates/vue/icon-hydration.ts +106 -1
  35. package/templates/vue/index.html +0 -1
  36. package/templates/vue/layer-app-config.ts +92 -0
  37. package/templates/vue/main.ts +10 -2
  38. package/templates/vue/package.json +4 -1
  39. package/templates/vue/server/auth/backend.mjs +13 -19
  40. package/templates/vue/server/auth/client-ip.mjs +27 -0
  41. package/templates/vue/server/auth/establish-endpoints.mjs +70 -0
  42. package/templates/vue/server/auth/session.mjs +6 -97
  43. package/templates/vue/server/auth-hooks.mjs +84 -0
  44. package/templates/vue/server/backend-proxies.mjs +110 -0
  45. package/templates/vue/server/dev-private-files.mjs +99 -0
  46. package/templates/vue/server/email.mjs +11 -33
  47. package/templates/vue/server/favicon.mjs +20 -0
  48. package/templates/vue/server/inertia.mjs +48 -3
  49. package/templates/vue/server/listen-host.mjs +19 -0
  50. package/templates/vue/server/module-event.mjs +265 -0
  51. package/templates/vue/server/module-routes.mjs +513 -0
  52. package/templates/vue/server/module-server.mjs +225 -0
  53. package/templates/vue/server/module-testing.mjs +65 -0
  54. package/templates/vue/server/page-props.mjs +151 -0
  55. package/templates/vue/server/path-patterns.mjs +38 -0
  56. package/templates/vue/server/public-endpoints.mjs +69 -11
  57. package/templates/vue/server/request-routing.mjs +47 -0
  58. package/templates/vue/server/route-rules.mjs +129 -0
  59. package/templates/vue/server-module.ts +294 -0
  60. package/templates/vue/server.mjs +177 -161
  61. package/templates/vue/ssr-renderer.ts +60 -19
  62. package/templates/vue/tsconfig.json +2 -1
  63. package/templates/vue/ui-app-config.ts +18 -6
  64. package/templates/vue/ui-inertia-page.mjs +37 -0
  65. package/templates/vue/vite.config.ts +31 -10
  66. package/templates/vue/vite.email.config.ts +1 -1
  67. package/templates/vue/vite.server.config.ts +57 -0
  68. package/templates/vue/server/auth/oauth.mjs +0 -233
  69. package/templates/vue/server/auth/routes.mjs +0 -444
  70. package/templates/vue/server/render-token.mjs +0 -81
  71. 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,12 @@
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",
12
13
  "build:types": "node prepare-types.mjs",
13
14
  "typecheck": "node --import ./typecheck-loader.mjs ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit",
14
15
  "start": "node server.mjs"
@@ -26,6 +27,7 @@
26
27
  "@vue/server-renderer": "^3.5.0",
27
28
  "@vueuse/core": "^14.3.0",
28
29
  "defu": "^6.1.4",
30
+ "devalue": "^5.9.4",
29
31
  "ofetch": "^1.4.1",
30
32
  "reka-ui": "^2.6.0",
31
33
  "tailwindcss": "^4.0.0",
@@ -37,6 +39,7 @@
37
39
  "vue-i18n": "^11.1.12"
38
40
  },
39
41
  "devDependencies": {
42
+ "@types/node": "^24.0.0",
40
43
  "typescript": "^5.8.2",
41
44
  "vue-tsc": "3.1.1"
42
45
  },
@@ -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
  }
@@ -0,0 +1,84 @@
1
+ // The session logic the server runs outside any route: renewing a session the
2
+ // backend refused, the session a page carries, and where a signed-out visit
3
+ // goes. The module that owns sign-in provides it through `registerAuth` in its
4
+ // `dms.server.ts`. Without one, a page carries no session and a refused
5
+ // session is not renewed.
6
+
7
+ import { readSession, setRequestSession } from "./auth/session.mjs";
8
+ import { createServerEvent } from "./module-event.mjs";
9
+ import { compilePathPattern } from "./path-patterns.mjs";
10
+
11
+ const DEFAULT_SIGN_IN_PATH = "/auth";
12
+ const DEFAULT_ONBOARDING_PATH = "/onboarding";
13
+ /** A hook reads no request body. */
14
+ const HOOK_ROUTE = Object.freeze({ bodyLimit: 0 });
15
+
16
+ /**
17
+ * The pages a signed-out visit reaches when the provider names none: the
18
+ * sign-in page and everything below it, whole segments only (`/auth` exempts
19
+ * `/auth/callback`, never `/authors`), and the onboarding page. Derived from
20
+ * the sign-in path in use, or a custom one would redirect to itself.
21
+ */
22
+ function defaultExempt(signInPath) {
23
+ const signIn = signInPath === "/" ? "/" : `${signInPath}/**`;
24
+ return [signIn, DEFAULT_ONBOARDING_PATH].map(compilePathPattern);
25
+ }
26
+
27
+ /**
28
+ * The page props `user` and `session` of `session`, from the provider's
29
+ * `publicSession`. The server adds the `updatedAt` stamp it wrote itself,
30
+ * which the browser compares to drop a page restored from its cache.
31
+ */
32
+ function hookView(publicSession, session) {
33
+ if (!session) return {};
34
+ const view = publicSession(session) ?? {};
35
+ return view.session
36
+ ? { ...view, session: { ...view.session, updatedAt: session.updatedAt } }
37
+ : view;
38
+ }
39
+
40
+ /**
41
+ * The server's session logic for a set of server modules.
42
+ *
43
+ * @param provider The resolved auth hooks, `undefined` when no module
44
+ * registers any
45
+ * @param context `(id) => ({ module, app })`, a module's event context
46
+ * @returns `{ provider, signInPath, isSignInExempt, signInLocation, view,
47
+ * refresh }`
48
+ */
49
+ export function serverAuth(provider, context) {
50
+ const signInPath = provider?.signInPath ?? DEFAULT_SIGN_IN_PATH;
51
+ const exempt = provider?.signInExempt ?? defaultExempt(signInPath);
52
+ const publicSession = provider?.publicSession;
53
+ const refreshSession = provider?.refreshSession;
54
+ return {
55
+ provider: provider?.id,
56
+ signInPath,
57
+ isSignInExempt: (pathname) =>
58
+ exempt.some((pattern) => pattern.test(pathname)),
59
+ signInLocation: (requestUrl) =>
60
+ `${signInPath}?redirect=${encodeURIComponent(requestUrl)}`,
61
+ view: publicSession
62
+ ? (session) => hookView(publicSession, session)
63
+ : () => ({}),
64
+ /**
65
+ * Renews the request's session after the backend refused it. Returns the
66
+ * new session, which the rest of the request runs with, or `undefined`.
67
+ */
68
+ async refresh(request, response) {
69
+ if (!refreshSession) return undefined;
70
+ const session = readSession(request);
71
+ if (!session) return undefined;
72
+ const event = createServerEvent({
73
+ request,
74
+ response,
75
+ route: HOOK_ROUTE,
76
+ params: {},
77
+ ...context(provider.id),
78
+ });
79
+ const refreshed = (await refreshSession(event, session)) ?? undefined;
80
+ setRequestSession(request, refreshed);
81
+ return refreshed;
82
+ },
83
+ };
84
+ }