@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.
- package/dist/agent/agent-config.js +2 -11
- package/dist/agent/agent-http-auth.js +91 -78
- package/dist/agent/agent-http.js +89 -139
- package/dist/agent/agent-hub-registration.js +31 -58
- package/dist/agent/agent-json-file.js +59 -0
- package/dist/agent/agent-mesh-gate.js +57 -0
- package/dist/agent/agent-node-certificates.js +7 -7
- package/dist/agent/agent-pairing-routes.js +1 -4
- package/dist/agent/agent-pairing-store.js +8 -8
- package/dist/agent/agent-service.js +0 -9
- package/dist/agent/main.js +41 -99
- package/dist/api/core/certificates.router.js +2 -2
- package/dist/api/oauth2/consent-page.js +19 -31
- package/dist/api/trpc/trpc.context.js +2 -3
- package/dist/auth/app-confirm-page.js +29 -21
- package/dist/auth/app-page-shell.js +120 -0
- package/dist/auth/app-passkey-page.js +24 -19
- package/dist/auth/app-refused-route.js +37 -0
- package/dist/boot/boot-config.js +7 -5
- package/dist/core/agent/agent-registry.service.js +0 -10
- package/dist/core/moleculer/hub-mesh-guard.js +30 -101
- package/dist/core/moleculer/moleculer.service.js +8 -32
- package/dist/main.js +2 -2
- package/package.json +9 -9
- package/dist/agent/agent-mesh-enroll.js +0 -317
- package/dist/agent/agent-mesh-join.js +0 -140
- package/dist/api/mesh/enroll.routes.js +0 -34
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
exports.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
135
|
-
exports.APP_PASSKEY_REFUSAL_CSP =
|
|
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
|
+
}
|
package/dist/boot/boot-config.js
CHANGED
|
@@ -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
|
|
60
|
-
*
|
|
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
|
|
198
|
-
'keychain
|
|
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
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
29
|
+
this.now = now;
|
|
30
|
+
this.gate = new system_1.MeshIdentityGate({
|
|
34
31
|
ownNodeId,
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
195
|
-
//
|
|
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
|
|
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
|
-
//
|
|
296
|
-
|
|
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;
|