@camstack/server 1.2.577 → 1.2.579

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.
@@ -7,15 +7,16 @@ exports.appConfirmRefusalPage = appConfirmRefusalPage;
7
7
  * The hub-rendered confirmation page of a native app sign-in (D858):
8
8
  * "Sign in the CamStack app as <username>?".
9
9
  *
10
- * Same discipline as the passkey page (`app-passkey-page.ts`): plain HTML, one
11
- * inline script and one style pinned by SHA-256 in the CSP, same-origin
12
- * requests only, no framing, no third-party asset. The page carries the
10
+ * Same discipline as the passkey page (`app-passkey-page.ts`): plain HTML on the
11
+ * shared shell (`app-page-shell.ts`, which owns the look and the style hash),
12
+ * one inline script pinned by SHA-256 in the CSP, same-origin requests only, no
13
+ * framing, no third-party asset. The page carries the
13
14
  * one-shot token in a `data-` attribute (never in the script), so the script
14
15
  * hash is a constant. Continue POSTs the token and navigates to the
15
16
  * `location` the hub answers; Cancel is a plain link to the app with
16
17
  * `error=cancelled`.
17
18
  */
18
- const node_crypto_1 = require("node:crypto");
19
+ const app_page_shell_js_1 = require("./app-page-shell.js");
19
20
  const PAGE_SCRIPT = `(function () {
20
21
  var btn = document.getElementById('go');
21
22
  var msg = document.getElementById('msg');
@@ -39,26 +40,33 @@ const PAGE_SCRIPT = `(function () {
39
40
  });
40
41
  });
41
42
  })();`;
42
- const PAGE_STYLE = `body{font-family:system-ui,sans-serif;max-width:26rem;margin:4rem auto;padding:0 1rem;text-align:center}button{font:inherit;padding:.75rem 1.25rem;border-radius:.5rem;border:1px solid #888;cursor:pointer}p#msg{color:#b00020;min-height:1.5em}a{display:inline-block;margin-top:1rem}`;
43
- const sha256 = (text) => `'sha256-${(0, node_crypto_1.createHash)('sha256').update(text).digest('base64')}'`;
44
- /** The CSP: the one script and style by hash, same-origin requests, no framing, no form posts. */
45
- exports.APP_CONFIRM_CSP = [
46
- "default-src 'none'",
47
- `script-src ${sha256(PAGE_SCRIPT)}`,
48
- `style-src ${sha256(PAGE_STYLE)}`,
49
- "connect-src 'self'",
50
- "base-uri 'none'",
51
- "form-action 'none'",
52
- "frame-ancestors 'none'",
53
- ].join('; ');
54
- /** CSP of the refusal page: nothing runs, nothing loads. */
55
- exports.APP_CONFIRM_REFUSAL_CSP = "default-src 'none'; frame-ancestors 'none'";
56
- const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
43
+ /** The CSP: the shell's style and this page's one script by hash, same-origin requests, no framing, no form posts. */
44
+ exports.APP_CONFIRM_CSP = (0, app_page_shell_js_1.appPageCsp)({ script: PAGE_SCRIPT });
45
+ /** CSP of the refusal page: the shell's style only, nothing runs. */
46
+ exports.APP_CONFIRM_REFUSAL_CSP = (0, app_page_shell_js_1.appPageCsp)();
57
47
  /** The confirmation page for `username`; `cancelHref` is the app callback carrying `error=cancelled`. */
58
48
  function appConfirmPage(input) {
59
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Sign in the CamStack app</title><style>${PAGE_STYLE}</style></head><body><h1>Sign in the CamStack app?</h1><p>The CamStack app on this device will be signed in as <strong>${escapeHtml(input.username)}</strong>.</p><p>Continue only if you just started this from the CamStack app.</p><button id="go" type="button" data-confirm="${escapeHtml(input.token)}">Continue</button><p id="msg" role="alert"></p><a href="${escapeHtml(input.cancelHref)}">Cancel</a><noscript><p>JavaScript is required to continue.</p></noscript><script>${PAGE_SCRIPT}</script></body></html>`;
49
+ return (0, app_page_shell_js_1.renderAppPage)({
50
+ title: 'Sign in the CamStack app',
51
+ heading: 'Sign in the CamStack app?',
52
+ noReferrer: true,
53
+ script: PAGE_SCRIPT,
54
+ body: [
55
+ `<p>The CamStack app on this device will be signed in as <strong>${(0, app_page_shell_js_1.escapeHtml)(input.username)}</strong>.</p>`,
56
+ (0, app_page_shell_js_1.appHint)('Continue only if you just started this from the CamStack app.'),
57
+ (0, app_page_shell_js_1.appButton)('Continue', `id="go" data-confirm="${(0, app_page_shell_js_1.escapeHtml)(input.token)}"`),
58
+ (0, app_page_shell_js_1.appErrorLine)('msg'),
59
+ (0, app_page_shell_js_1.appCancelLink)(input.cancelHref, 'Cancel'),
60
+ '<noscript><p class="error">JavaScript is required to continue.</p></noscript>',
61
+ ].join(''),
62
+ });
60
63
  }
61
64
  /** A confirmation that is gone (expired, used, or opened in another browser): a sentence, nothing runs. */
62
65
  function appConfirmRefusalPage() {
63
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Confirmation expired</title></head><body><h1>Confirmation expired</h1><p>This sign-in confirmation is no longer valid.</p><p>Close this window and start the sign-in again from the app.</p></body></html>`;
66
+ return (0, app_page_shell_js_1.renderAppPage)({
67
+ title: 'Confirmation expired',
68
+ heading: 'Confirmation expired',
69
+ body: (0, app_page_shell_js_1.appParagraph)('This sign-in confirmation is no longer valid.') +
70
+ (0, app_page_shell_js_1.appHint)('Close this window and start the sign-in again from the app.'),
71
+ });
64
72
  }
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.appCancelLink = exports.appHiddenField = exports.appSubmitButton = exports.appErrorLine = exports.appButton = exports.appHint = exports.appParagraph = exports.APP_PAGE_STYLE = exports.APP_PAGE_LOGOS = void 0;
4
+ exports.appPageCsp = appPageCsp;
5
+ exports.escapeHtml = escapeHtml;
6
+ exports.renderAppPage = renderAppPage;
7
+ /**
8
+ * The one shell of every standalone page the hub renders for a native app
9
+ * sign-in (D858): the passkey page, the confirmation page, the refusal pages.
10
+ *
11
+ * These pages are server-rendered HTML with no admin-ui bundle and a strict
12
+ * CSP, so they cannot borrow the admin login's React components - but they must
13
+ * not look like a different product either. Everything visual lives HERE and
14
+ * nowhere else:
15
+ *
16
+ * - the colours, radii, spacing and font stack are GENERATED from the theme
17
+ * the admin UI is built on (`@camstack/ui-library/theme/tokens`) - no hex is
18
+ * typed in this file, so a theme change reaches these pages by itself;
19
+ * - the card, primary button, cancel link, error line and muted hint repeat the
20
+ * admin login page's look (`Login.tsx`: `rounded-xl border bg-surface p-6`,
21
+ * `bg-primary rounded-lg` button, `bg-danger/10` error);
22
+ * - the stylesheet is ONE inline `<style>` and the CSP pins it by the SHA-256
23
+ * of the very string that is served, computed at module load - there is no
24
+ * hand-pasted hash that can fall out of step with the CSS.
25
+ */
26
+ const node_crypto_1 = require("node:crypto");
27
+ const tokens_1 = require("@camstack/ui-library/theme/tokens");
28
+ /** The brand mark and wordmark, one per colour scheme (the files under `addon-admin-ui/public/brand`). */
29
+ const LOGO_DARK = '<svg class="logo logo-dark" role="img" aria-label="CamStack" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 48" fill="none"><g transform="translate(4, 2) scale(0.68)"><rect x="8" y="28" width="48" height="28" rx="6" fill="#292524"/><rect x="12" y="16" width="40" height="28" rx="6" fill="#44403c"/><rect x="16" y="4" width="32" height="28" rx="6" fill="#f59e42"/><circle cx="32" cy="18" r="8" fill="#0c0a09" opacity="0.3"/><circle cx="32" cy="18" r="5" fill="#0c0a09" opacity="0.2"/><circle cx="32" cy="18" r="2.5" fill="#fafaf9" opacity="0.8"/></g><text x="52" y="31" font-family="system-ui, -apple-system, sans-serif" font-size="26" font-weight="700" letter-spacing="-0.5" fill="#fafaf9">Cam<tspan fill="#f59e42">Stack</tspan></text></svg>';
30
+ const LOGO_LIGHT = '<svg class="logo logo-light" role="img" aria-label="CamStack" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 48" fill="none"><g transform="translate(4, 2) scale(0.68)"><rect x="8" y="28" width="48" height="28" rx="6" fill="#d6d3d1"/><rect x="12" y="16" width="40" height="28" rx="6" fill="#a8a29e"/><rect x="16" y="4" width="32" height="28" rx="6" fill="#e67e22"/><circle cx="32" cy="18" r="8" fill="#fafaf9" opacity="0.3"/><circle cx="32" cy="18" r="5" fill="#fafaf9" opacity="0.2"/><circle cx="32" cy="18" r="2.5" fill="#1c1917" opacity="0.7"/></g><text x="52" y="31" font-family="system-ui, -apple-system, sans-serif" font-size="26" font-weight="700" letter-spacing="-0.5" fill="#1c1917">Cam<tspan fill="#e67e22">Stack</tspan></text></svg>';
31
+ /** The logo markup, exposed so a spec can hold it against the brand files. */
32
+ exports.APP_PAGE_LOGOS = {
33
+ dark: LOGO_DARK,
34
+ light: LOGO_LIGHT,
35
+ };
36
+ /**
37
+ * Component rules, written against the token variables. Sizes mirror the admin
38
+ * login page (Tailwind `text-sm`/`text-xs`, `rounded-xl`, `p-6`, `py-2.5`),
39
+ * with a 44 px touch target for the phone this opens on.
40
+ */
41
+ const COMPONENT_CSS = [
42
+ '*,*::before,*::after{box-sizing:border-box}',
43
+ `html{background:var(--color-background)}`,
44
+ `body{margin:0;min-height:100vh;min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:max(var(--spacing-xl),env(safe-area-inset-top)) max(var(--spacing-xl),env(safe-area-inset-right)) max(var(--spacing-xl),env(safe-area-inset-bottom)) max(var(--spacing-xl),env(safe-area-inset-left));background:var(--color-background);color:var(--color-foreground);font-family:var(--font-family);font-size:14px;line-height:20px;-webkit-text-size-adjust:100%}`,
45
+ `main{width:100%;max-width:24rem}`,
46
+ `.brand{display:flex;justify-content:center;margin-bottom:var(--spacing-3xl)}`,
47
+ `.logo{height:3rem;width:auto}`,
48
+ `.logo-dark{display:none}`,
49
+ `@media (prefers-color-scheme:dark){.logo-dark{display:block}.logo-light{display:none}}`,
50
+ `.card{display:flex;flex-direction:column;gap:var(--spacing-xl);padding:var(--spacing-2xl);border:1px solid var(--color-border);border-radius:var(--radius-xl);background:var(--color-surface);box-shadow:0 20px 25px -5px rgb(0 0 0/.1),0 8px 10px -6px rgb(0 0 0/.1)}`,
51
+ `.card h1{margin:0;font-size:16px;line-height:22px;font-weight:600;text-align:center;color:var(--color-foreground)}`,
52
+ `.card p{margin:0;color:var(--color-foreground-muted)}`,
53
+ `.card strong{color:var(--color-foreground);font-weight:600;overflow-wrap:anywhere}`,
54
+ `.card .hint{font-size:12px;line-height:16px;color:var(--color-foreground-subtle)}`,
55
+ `.btn{display:flex;align-items:center;justify-content:center;width:100%;min-height:44px;padding:10px 16px;border:1px solid transparent;border-radius:var(--radius-lg);background:var(--color-primary);color:var(--color-primary-foreground);font:inherit;font-weight:600;text-decoration:none;cursor:pointer;box-shadow:0 4px 6px -1px color-mix(in srgb,var(--color-primary) 20%,transparent)}`,
56
+ `.btn:hover{background:color-mix(in srgb,var(--color-primary) 90%,transparent)}`,
57
+ `.btn:disabled{opacity:.5;cursor:not-allowed;box-shadow:none}`,
58
+ `.btn.secondary{background:var(--color-background);color:var(--color-foreground);border-color:var(--color-border);box-shadow:none}`,
59
+ `.btn.secondary:hover{background:var(--color-surface-hover)}`,
60
+ `.link{display:block;text-align:center;padding:var(--spacing-md);font-size:12px;line-height:16px;color:var(--color-foreground-subtle);text-decoration:none}`,
61
+ `.link:hover{color:var(--color-foreground)}`,
62
+ `.error{padding:8px 12px;border:1px solid color-mix(in srgb,var(--color-danger) 20%,transparent);border-radius:var(--radius-md);background:color-mix(in srgb,var(--color-danger) 10%,transparent);color:var(--color-danger);font-size:12px;line-height:16px}`,
63
+ `.card p.error{color:var(--color-danger)}`,
64
+ `.error:empty{display:none}`,
65
+ `.btn:focus-visible,.link:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}`,
66
+ `code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;overflow-wrap:anywhere}`,
67
+ `form{display:contents}`,
68
+ `.actions{display:flex;flex-direction:column;gap:var(--spacing-md)}`,
69
+ ].join('');
70
+ /** The one stylesheet, as served: the generated tokens followed by the component rules. */
71
+ exports.APP_PAGE_STYLE = `${(0, tokens_1.themeToRootCss)(tokens_1.defaultTheme)}${COMPONENT_CSS}`;
72
+ const sha256Source = (text) => `'sha256-${(0, node_crypto_1.createHash)('sha256').update(text).digest('base64')}'`;
73
+ /**
74
+ * The CSP for a page rendered by {@link renderAppPage}: the inline style and
75
+ * (when there is one) the inline script by hash, same-origin requests only, no
76
+ * framing, no base rewrite. The hashes are computed from the strings the page
77
+ * is built from, so they cannot disagree with it.
78
+ */
79
+ function appPageCsp(options = {}) {
80
+ return [
81
+ "default-src 'none'",
82
+ ...(options.script === undefined ? [] : [`script-src ${sha256Source(options.script)}`]),
83
+ `style-src ${sha256Source(exports.APP_PAGE_STYLE)}`,
84
+ ...(options.script === undefined ? [] : ["connect-src 'self'"]),
85
+ "base-uri 'none'",
86
+ `form-action ${options.formToSelf === true ? "'self'" : "'none'"}`,
87
+ "frame-ancestors 'none'",
88
+ ].join('; ');
89
+ }
90
+ /** Escapes text for an HTML text node or a double-quoted attribute. */
91
+ function escapeHtml(text) {
92
+ return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
93
+ }
94
+ /** A full page: doctype, viewport (with safe-area fit), tokens, logo, a card holding `body`. */
95
+ function renderAppPage(input) {
96
+ const referrer = input.noReferrer === true ? '<meta name="referrer" content="no-referrer">' : '';
97
+ const script = input.script === undefined ? '' : `<script>${input.script}</script>`;
98
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark">${referrer}<title>${escapeHtml(input.title)}</title><style>${exports.APP_PAGE_STYLE}</style></head><body><main><div class="brand">${LOGO_DARK}${LOGO_LIGHT}</div><div class="card"><h1>${escapeHtml(input.heading)}</h1>${input.body}</div></main>${script}</body></html>`;
99
+ }
100
+ /** A paragraph of the card (`text` is escaped). */
101
+ const appParagraph = (text) => `<p>${escapeHtml(text)}</p>`;
102
+ exports.appParagraph = appParagraph;
103
+ /** A smaller, muted paragraph (`text` is escaped). */
104
+ const appHint = (text) => `<p class="hint">${escapeHtml(text)}</p>`;
105
+ exports.appHint = appHint;
106
+ /** The primary button; `attributes` is trusted markup (already escaped), `label` is escaped. */
107
+ const appButton = (label, attributes) => `<button class="btn" type="button" ${attributes}>${escapeHtml(label)}</button>`;
108
+ exports.appButton = appButton;
109
+ /** The error line a script fills in; empty it renders nothing. */
110
+ const appErrorLine = (id) => `<p class="error" id="${id}" role="alert"></p>`;
111
+ exports.appErrorLine = appErrorLine;
112
+ /** A submit button of a form (`name`/`value` are escaped); `secondary` is the outlined, non-default choice. */
113
+ const appSubmitButton = (label, name, value, secondary = false) => `<button class="${secondary ? 'btn secondary' : 'btn'}" type="submit" name="${escapeHtml(name)}" value="${escapeHtml(value)}">${escapeHtml(label)}</button>`;
114
+ exports.appSubmitButton = appSubmitButton;
115
+ /** A hidden form field (`name`/`value` are escaped). */
116
+ const appHiddenField = (name, value) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`;
117
+ exports.appHiddenField = appHiddenField;
118
+ /** The quiet link under the actions (Cancel); `href` is escaped here. */
119
+ const appCancelLink = (href, label) => `<a class="link" href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
120
+ exports.appCancelLink = appCancelLink;
@@ -19,7 +19,7 @@ exports.appPasskeyRefusalPage = appPasskeyRefusalPage;
19
19
  * specific is ever placed in the script; the only server-rendered value is the
20
20
  * (allow-listed, HTML-escaped) cancel link.
21
21
  */
22
- const node_crypto_1 = require("node:crypto");
22
+ const app_page_shell_js_1 = require("./app-page-shell.js");
23
23
  const PAGE_SCRIPT = `(function () {
24
24
  var q = new URLSearchParams(location.search);
25
25
  var base = location.pathname.replace(/\\/+$/, '');
@@ -107,29 +107,34 @@ const PAGE_SCRIPT = `(function () {
107
107
  }
108
108
  btn.addEventListener('click', go);
109
109
  })();`;
110
- const PAGE_STYLE = `body{font-family:system-ui,sans-serif;max-width:26rem;margin:4rem auto;padding:0 1rem;text-align:center}button{font:inherit;padding:.75rem 1.25rem;border-radius:.5rem;border:1px solid #888;cursor:pointer}p#msg{color:#b00020;min-height:1.5em}a{display:inline-block;margin-top:1rem}`;
111
- const sha256 = (text) => `'sha256-${(0, node_crypto_1.createHash)('sha256').update(text).digest('base64')}'`;
112
110
  /**
113
- * The page's CSP: the one script and one style by hash, same-origin requests
114
- * only, no framing, no base rewrite, no form posts, no plugins.
111
+ * The page's CSP: the shell's style and the one script by hash, same-origin
112
+ * requests only, no framing, no base rewrite, no form posts, no plugins.
115
113
  */
116
- exports.APP_PASSKEY_CSP = [
117
- "default-src 'none'",
118
- `script-src ${sha256(PAGE_SCRIPT)}`,
119
- `style-src ${sha256(PAGE_STYLE)}`,
120
- "connect-src 'self'",
121
- "base-uri 'none'",
122
- "form-action 'none'",
123
- "frame-ancestors 'none'",
124
- ].join('; ');
125
- const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
114
+ exports.APP_PASSKEY_CSP = (0, app_page_shell_js_1.appPageCsp)({ script: PAGE_SCRIPT });
126
115
  /** The page; `cancelHref` is the app callback carrying `error=cancelled`. */
127
116
  function appPasskeyPage(cancelHref) {
128
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Sign in with a passkey</title><style>${PAGE_STYLE}</style></head><body><h1>CamStack</h1><button id="go" type="button">Sign in with a passkey</button><p id="msg" role="alert"></p><a href="${escapeHtml(cancelHref)}">Cancel</a><noscript><p>JavaScript is required to use a passkey.</p></noscript><script>${PAGE_SCRIPT}</script></body></html>`;
117
+ return (0, app_page_shell_js_1.renderAppPage)({
118
+ title: 'Sign in with a passkey',
119
+ heading: 'Sign in to CamStack',
120
+ noReferrer: true,
121
+ script: PAGE_SCRIPT,
122
+ body: [
123
+ (0, app_page_shell_js_1.appParagraph)('Use a passkey saved on this device to sign in to the CamStack app.'),
124
+ (0, app_page_shell_js_1.appButton)('Sign in with a passkey', 'id="go"'),
125
+ (0, app_page_shell_js_1.appErrorLine)('msg'),
126
+ (0, app_page_shell_js_1.appCancelLink)(cancelHref, 'Cancel'),
127
+ '<noscript><p class="error">JavaScript is required to use a passkey.</p></noscript>',
128
+ ].join(''),
129
+ });
129
130
  }
130
131
  /** The page for a request the hub refuses to serve: a sentence, no script, no assets. */
131
132
  function appPasskeyRefusalPage(message) {
132
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign-in request refused</title></head><body><h1>Sign-in request refused</h1><p>${escapeHtml(message)}</p><p>Close this window and try again from the app.</p></body></html>`;
133
+ return (0, app_page_shell_js_1.renderAppPage)({
134
+ title: 'Sign-in request refused',
135
+ heading: 'Sign-in request refused',
136
+ body: (0, app_page_shell_js_1.appParagraph)(message) + (0, app_page_shell_js_1.appHint)('Close this window and try again from the app.'),
137
+ });
133
138
  }
134
- /** CSP of the refusal page: nothing runs, nothing loads. */
135
- exports.APP_PASSKEY_REFUSAL_CSP = "default-src 'none'; frame-ancestors 'none'";
139
+ /** CSP of the refusal page: the shell's style only, nothing runs. */
140
+ exports.APP_PASSKEY_REFUSAL_CSP = (0, app_page_shell_js_1.appPageCsp)();
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.APP_REFUSED_CSP = void 0;
4
+ exports.appRefusedPage = appRefusedPage;
5
+ exports.registerAppRefusedRoute = registerAppRefusedRoute;
6
+ /**
7
+ * `GET /auth/app/refused?reason=<code>` - the page that says why a native app
8
+ * sign-in could not start (D858).
9
+ *
10
+ * The OIDC start lives in an addon, which cannot reach the hub's theme; it
11
+ * redirects here with a reason CODE and the hub renders the sentence in the
12
+ * same shell as every other app page. The query is never echoed: an unknown or
13
+ * absent code gets a generic sentence, a known one its fixed words.
14
+ */
15
+ const types_1 = require("@camstack/types");
16
+ const app_page_shell_js_1 = require("./app-page-shell.js");
17
+ const GENERIC_REFUSAL = 'The sign-in request was refused.';
18
+ /** The refusal page for `reason` (a known code, or anything: unknown renders the generic sentence). */
19
+ function appRefusedPage(reason) {
20
+ const message = (0, types_1.isAppStartRefusal)(reason) ? types_1.APP_START_REFUSAL_MESSAGES[reason] : GENERIC_REFUSAL;
21
+ return (0, app_page_shell_js_1.renderAppPage)({
22
+ title: 'Sign-in request refused',
23
+ heading: 'Sign-in request refused',
24
+ body: (0, app_page_shell_js_1.appParagraph)(message) + (0, app_page_shell_js_1.appHint)('Close this window and try again from the app.'),
25
+ });
26
+ }
27
+ /** CSP of the refusal page: the shell's style only, nothing runs. */
28
+ exports.APP_REFUSED_CSP = (0, app_page_shell_js_1.appPageCsp)();
29
+ function registerAppRefusedRoute(fastify) {
30
+ fastify.get(types_1.APP_REFUSED_PATH, async (request, reply) => {
31
+ reply.header('Cache-Control', 'no-store');
32
+ reply.header('Referrer-Policy', 'no-referrer');
33
+ reply.header('Content-Security-Policy', exports.APP_REFUSED_CSP);
34
+ reply.type('text/html; charset=utf-8');
35
+ return reply.status(400).send(appRefusedPage(request.query['reason']));
36
+ });
37
+ }
@@ -56,8 +56,9 @@ const CONFIG_DEFAULTS = {
56
56
  };
57
57
  /**
58
58
  * Bootstrap env overrides. No SECRET is among them (D783, D797): the session
59
- * signing key and the cluster secret live in the keychain, and the first-boot
60
- * admin is fixed (`admin` / `changeme`).
59
+ * signing key lives in the keychain, the first-boot admin is fixed
60
+ * (`admin` / `changeme`), and there is no cluster secret at all (D857 — a
61
+ * node's identity is its mesh certificate).
61
62
  */
62
63
  const ENV_VAR_MAP = {
63
64
  CAMSTACK_PORT: 'server.port',
@@ -66,7 +67,8 @@ const ENV_VAR_MAP = {
66
67
  CAMSTACK_TLS_ENABLED: 'tls.enabled',
67
68
  };
68
69
  /**
69
- * Env variables that once carried a secret into the hub. Ignored since D797 —
70
+ * Env variables that once carried a secret into the hub. Ignored since D797
71
+ * (`CAMSTACK_CLUSTER_SECRET`: the secret itself was removed by D857) —
70
72
  * still named so a container template that sets them is told, not silently
71
73
  * obeyed or silently dropped. Only their PRESENCE is read, never the value.
72
74
  */
@@ -194,8 +196,8 @@ function loadBootstrapConfig(configPath) {
194
196
  const retired = retiredSecretSources(process.env, raw);
195
197
  if (retired.length > 0) {
196
198
  console.warn(`[Phase1] WARN ${retired.join(', ')} ignored — no secret is read from the environment or ` +
197
- 'config.yaml any more (D797). The session signing key and the cluster secret live in the ' +
198
- 'keychain (System → Cluster for the cluster secret); a fresh hub seeds admin / changeme. ' +
199
+ 'config.yaml any more (D797). The session signing key lives in the ' +
200
+ 'keychain; a fresh hub seeds admin / changeme. ' +
199
201
  'Remove these entries — after importing their values with scripts/ops/keychain-migrate-hub.mjs.');
200
202
  }
201
203
  const ignored = ignoredTlsCertSources(process.env, raw);
@@ -469,16 +469,6 @@ class AgentRegistryService {
469
469
  this.moleculer.setOnAgentRegistered((agentId) => {
470
470
  void this.reconcileAgentAddons(agentId, 'node-registration');
471
471
  });
472
- // D802: the mesh `transition` ends only once every agent this hub has
473
- // EVER seen has authenticated — offline ones included. `null` (store not
474
- // wired or not answering) never counts as "no agents".
475
- this.moleculer.setKnownAgentsProvider(async () => {
476
- const store = this.historyStore;
477
- if (!store)
478
- return null;
479
- const rows = await store.tryGetAll();
480
- return rows === null ? null : rows.filter((r) => !r.isHub).map((r) => r.id);
481
- });
482
472
  this.broker.localBus.on('$node.connected', ({ node }) => {
483
473
  const kind = classifyNode(node.id);
484
474
  if (!kind)
@@ -2,132 +2,61 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HubMeshGuard = void 0;
4
4
  /**
5
- * The hub's mesh guard (D802) — one object owning everything that decides
6
- * whether another node may talk to the hub:
5
+ * The hub's mesh guard (D802, D857) — the one object that decides whether
6
+ * another node may talk to the hub.
7
7
  *
8
- * - the {@link MeshAuthenticator} (who proved what, on which connection);
9
- * - the broker middleware built on it (installed at broker construction);
10
- * - the System → Cluster status slot;
11
- * - the automatic end of `transition` ({@link decideMeshAutoEnforce}), checked
12
- * after every successful join and once a minute.
8
+ * A node is admitted by its CERTIFICATE: the mutual-TLS handshake chained to
9
+ * the mesh CA, the leaf CN is the node id it speaks as, and the serial is not
10
+ * revoked. The transport answers that (`TlsTcpTransporter.verifiedPeer`,
11
+ * published through the process peer lookup); this class turns the answer
12
+ * into the broker middleware and says, once per node and minute, who was
13
+ * turned away and why. There is no shared secret, no mode and no transition.
13
14
  *
14
15
  * Kept out of `moleculer.service.ts` so that file only wires it.
15
16
  */
16
17
  const system_1 = require("@camstack/system");
17
- const types_1 = require("@camstack/types");
18
- /** How often `transition` is re-checked when nothing joins. */
19
- const AUTO_ENFORCE_INTERVAL_MS = 60_000;
18
+ /** At most one "refused" line per node per this long. */
19
+ const REFUSED_LOG_INTERVAL_MS = 60_000;
20
20
  class HubMeshGuard {
21
21
  ownNodeId;
22
22
  logger;
23
- authenticator;
24
- startedAt = Date.now();
25
- knownAgents = null;
26
- listConnected = () => [];
27
- timer = null;
28
- evaluating = false;
29
- lastWaitingFor = '';
30
- constructor(ownNodeId, logger) {
23
+ now;
24
+ gate;
25
+ lastLoggedAt = new Map();
26
+ constructor(ownNodeId, logger, now = Date.now, isVerifiedPeer = system_1.isProcessVerifiedMeshPeer) {
31
27
  this.ownNodeId = ownNodeId;
32
28
  this.logger = logger;
33
- this.authenticator = new system_1.MeshAuthenticator({
29
+ this.now = now;
30
+ this.gate = new system_1.MeshIdentityGate({
34
31
  ownNodeId,
35
- gate: system_1.getProcessClusterSecretGate,
36
- mode: system_1.getProcessMeshAuthMode,
37
- isVerifiedPeer: system_1.isProcessVerifiedMeshPeer,
38
- onAuthenticated: (nodeId) => {
39
- this.logger.info('node authenticated to the mesh (challenge–response)', {
40
- meta: { nodeId },
41
- });
42
- void this.evaluateAutoEnforce();
43
- },
44
- onTolerated: (nodeId, action) => {
45
- this.logger.warn('node is calling the hub WITHOUT having authenticated — tolerated while System → ' +
46
- 'Cluster is in Transition. Enter the cluster secret in this agent.', { meta: { nodeId, firstCall: action } });
47
- },
32
+ isVerifiedPeer,
33
+ onRefused: (nodeId, what) => this.noteRefused(nodeId, what),
48
34
  });
49
- (0, system_1.setProcessMeshAuthStatusSource)(() => this.authenticator.snapshot());
50
35
  }
51
36
  /** The middleware the hub broker must be constructed with. */
52
37
  middleware() {
53
38
  return (0, system_1.createMeshAccessMiddleware)({
54
39
  ownNodeId: this.ownNodeId,
55
- authenticator: this.authenticator,
40
+ gate: this.gate,
56
41
  logger: {
57
42
  warn: (message, meta) => this.logger.warn(message, meta ? { meta } : undefined),
58
43
  },
59
44
  });
60
45
  }
61
- /** Start the periodic auto-enforce check; `listConnected` reads the live node list. */
62
- start(listConnected) {
63
- this.listConnected = listConnected;
64
- if (this.timer !== null)
65
- return;
66
- this.timer = setInterval(() => void this.evaluateAutoEnforce(), AUTO_ENFORCE_INTERVAL_MS);
67
- this.timer.unref?.();
68
- }
69
- /** Wired by `AgentRegistryService`, which owns the cluster-node history. */
70
- setKnownAgentsProvider(provider) {
71
- this.knownAgents = provider;
72
- }
73
- /** A node's connection dropped. */
46
+ /** A node's connection dropped: forget the throttle, not an identity (there is none to forget). */
74
47
  forget(nodeId) {
75
- this.authenticator.forget(nodeId);
76
- }
77
- stop() {
78
- if (this.timer !== null)
79
- clearInterval(this.timer);
80
- this.timer = null;
81
- (0, system_1.setProcessMeshAuthStatusSource)(null);
48
+ this.lastLoggedAt.delete(nodeId);
82
49
  }
83
- /** Public for the spec; the timer and every join call it. */
84
- async evaluateAutoEnforce() {
85
- if (this.evaluating || (0, system_1.getProcessMeshAuthMode)() !== 'transition')
50
+ noteRefused(nodeId, what) {
51
+ const at = this.now();
52
+ const last = this.lastLoggedAt.get(nodeId);
53
+ if (last !== undefined && at - last < REFUSED_LOG_INTERVAL_MS)
86
54
  return;
87
- this.evaluating = true;
88
- try {
89
- let knownAgents = null;
90
- if (this.knownAgents !== null) {
91
- try {
92
- knownAgents = await this.knownAgents();
93
- }
94
- catch {
95
- knownAgents = null;
96
- }
97
- }
98
- const decision = (0, system_1.decideMeshAutoEnforce)({
99
- mode: (0, system_1.getProcessMeshAuthMode)(),
100
- gate: (0, system_1.getProcessClusterSecretGate)().kind,
101
- uptimeMs: Date.now() - this.startedAt,
102
- knownAgents,
103
- connected: this.listConnected(),
104
- toleratedEver: this.authenticator.everToleratedNodeIds(),
105
- authenticatedEver: this.authenticator.everAuthenticatedNodeIds(),
106
- ownNodeId: this.ownNodeId,
107
- });
108
- if (!decision.enforce) {
109
- const waiting = decision.waitingFor.join(',');
110
- if (waiting.length > 0 && waiting !== this.lastWaitingFor) {
111
- this.logger.info('mesh transition: still waiting for agents to authenticate', {
112
- meta: { waitingFor: decision.waitingFor },
113
- });
114
- }
115
- this.lastWaitingFor = waiting;
116
- return;
117
- }
118
- const done = await (0, system_1.requestProcessMeshEnforcement)(decision.reason);
119
- if (done) {
120
- this.logger.info('mesh authentication switched to ENFORCED', {
121
- meta: { reason: decision.reason },
122
- });
123
- }
124
- }
125
- catch (err) {
126
- this.logger.warn('mesh auto-enforce check failed', { meta: { error: (0, types_1.errMsg)(err) } });
127
- }
128
- finally {
129
- this.evaluating = false;
130
- }
55
+ if (this.lastLoggedAt.size >= 256)
56
+ this.lastLoggedAt.clear();
57
+ this.lastLoggedAt.set(nodeId, at);
58
+ this.logger.warn('a node without a valid mesh certificate called the hub — refused. Pair it from ' +
59
+ 'System → Cluster → Agents', { tags: { nodeId }, meta: { firstCall: what } });
131
60
  }
132
61
  }
133
62
  exports.HubMeshGuard = HubMeshGuard;
@@ -26,9 +26,9 @@ class MoleculerService {
26
26
  }
27
27
  logger;
28
28
  /**
29
- * D802 — who may talk to the hub over the mesh: the challenge–response join,
30
- * the broker middleware that refuses / tolerates unauthenticated nodes and
31
- * strips forged identities, and the automatic end of `transition`.
29
+ * D802, D857 — who may talk to the hub over the mesh: the broker middleware
30
+ * that admits a node by its CERTIFICATE (verified at the TLS handshake),
31
+ * refuses the rest and strips forged identities.
32
32
  */
33
33
  meshGuard;
34
34
  /**
@@ -191,15 +191,13 @@ class MoleculerService {
191
191
  // moleculer→eventemitter2 whose types are unresolvable at this
192
192
  // boundary, so the inference falls to `error` and trips
193
193
  // `no-unsafe-assignment`. Going via `unknown` documents the boundary.
194
- // No cluster secret here (D797): the broker is created before the keychain
195
- // can answer, and the Moleculer namespace was never authentication (TCP
196
- // ignores it). The secret gates
197
- // `$hub.registerNode`, read from the keychain at call time.
194
+ // No shared secret (D857): the Moleculer namespace was never authentication (TCP
195
+ // ignores it); a node is admitted by the certificate it presented at the TLS handshake.
198
196
  this.meshGuard = new hub_mesh_guard_js_1.HubMeshGuard('hub', this.logging.createLogger('mesh-auth'));
199
197
  const broker = (0, system_1.createBroker)({
200
198
  nodeID: 'hub',
201
199
  mode: 'hub',
202
- // D802: every action and event another node sends passes the mesh gate.
200
+ // D802, D857: every action and event another node sends passes the certificate gate.
203
201
  middlewares: [this.meshGuard.middleware()],
204
202
  logLevel: this.config.get('moleculer.logLevel') ?? 'warn',
205
203
  ...(tcpPort && !Number.isNaN(tcpPort) ? { tcpPort } : {}),
@@ -224,23 +222,6 @@ class MoleculerService {
224
222
  setOnAgentRegistered(cb) {
225
223
  this.onAgentRegisteredCb = cb;
226
224
  }
227
- /**
228
- * D802: the cluster-node history, for the automatic end of the mesh
229
- * `transition` — wired by `AgentRegistryService`, which owns the history.
230
- */
231
- setKnownAgentsProvider(provider) {
232
- this.meshGuard.setKnownAgentsProvider(provider);
233
- }
234
- /** Every node Moleculer currently lists as available (this hub included). */
235
- listAvailableNodeIds() {
236
- const registry = this.broker.registry;
237
- try {
238
- return (registry?.getNodeList?.({ onlyAvailable: true }) ?? []).map((n) => n.id);
239
- }
240
- catch {
241
- return [];
242
- }
243
- }
244
225
  async onModuleInit() {
245
226
  const logger = this.logging.createLogger('moleculer');
246
227
  const hubDeps = {
@@ -292,10 +273,8 @@ class MoleculerService {
292
273
  onUnregisterNode: (nodeId) => {
293
274
  this.removeNodeFromRegistry(nodeId);
294
275
  },
295
- // System → Cluster, from the keychain (D797): off / on / unknown.
296
- clusterSecretGate: system_1.getProcessClusterSecretGate,
297
- // D802: challenge–response join + who may register.
298
- meshAuth: this.meshGuard.authenticator,
276
+ // D857: a node registers only itself, and only with a certificate identity.
277
+ meshIdentity: this.meshGuard.gate,
299
278
  };
300
279
  const hubService = (0, system_1.createHubService)(hubDeps);
301
280
  this.brokerSafe.createService(hubService);
@@ -639,7 +618,6 @@ class MoleculerService {
639
618
  // the incarnation) and this call is refused if the id has been reclaimed;
640
619
  // remote nodes register no owner at all, so they clean up as before.
641
620
  this.removeNodeFromRegistry(node.id);
642
- // D802: a node authenticates again on its next connection.
643
621
  this.meshGuard.forget(node.id);
644
622
  // Release A: its next connection decides again whether it is `tls` or `clear`.
645
623
  (0, system_1.getBrokerTlsTransporter)(this.broker)?.forgetPeer(node.id);
@@ -717,7 +695,6 @@ class MoleculerService {
717
695
  });
718
696
  await this.brokerSafe.start();
719
697
  logger.info('Moleculer broker started (TCP transport)');
720
- this.meshGuard.start(() => this.listAvailableNodeIds());
721
698
  // Construct the CapRouteResolver now that both the broker and
722
699
  // localChildRegistry are ready. The resolver reads live registry state
723
700
  // via closure accessors on every call (not a frozen snapshot), so new
@@ -1392,7 +1369,6 @@ class MoleculerService {
1392
1369
  async onModuleDestroy() {
1393
1370
  (0, system_1.setProcessMeshTlsEnabler)(null);
1394
1371
  (0, system_1.setProcessMeshPeerTransportLookup)(null);
1395
- this.meshGuard.stop();
1396
1372
  if (this.agentReadinessPullTimer !== null) {
1397
1373
  clearInterval(this.agentReadinessPullTimer);
1398
1374
  this.agentReadinessPullTimer = null;