@camstack/server 1.2.578 → 1.2.580

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 (37) hide show
  1. package/dist/agent/agent-config.js +2 -11
  2. package/dist/agent/agent-http-auth.js +91 -78
  3. package/dist/agent/agent-http.js +89 -139
  4. package/dist/agent/agent-hub-registration.js +31 -58
  5. package/dist/agent/agent-json-file.js +59 -0
  6. package/dist/agent/agent-mesh-gate.js +57 -0
  7. package/dist/agent/agent-node-certificates.js +7 -7
  8. package/dist/agent/agent-pairing-routes.js +1 -4
  9. package/dist/agent/agent-pairing-store.js +8 -8
  10. package/dist/agent/agent-service.js +0 -9
  11. package/dist/agent/main.js +41 -99
  12. package/dist/api/core/auth.router.js +11 -0
  13. package/dist/api/core/cap-providers.js +3 -1
  14. package/dist/api/core/certificates.router.js +2 -2
  15. package/dist/api/core/server-name.js +64 -0
  16. package/dist/api/oauth2/consent-page.js +22 -31
  17. package/dist/api/oauth2/oauth2-routes.js +5 -0
  18. package/dist/api/trpc/trpc.context.js +3 -3
  19. package/dist/api/trpc/trpc.router.js +29 -2
  20. package/dist/auth/app-confirm-page.js +38 -24
  21. package/dist/auth/app-confirm-routes.js +8 -1
  22. package/dist/auth/app-page-shell.js +123 -0
  23. package/dist/auth/app-page-strings.js +157 -0
  24. package/dist/auth/app-passkey-page.js +47 -26
  25. package/dist/auth/app-passkey-routes.js +9 -2
  26. package/dist/auth/app-refused-route.js +43 -0
  27. package/dist/auth/session-connections.js +136 -0
  28. package/dist/auth/sso-finish-app.js +7 -1
  29. package/dist/boot/boot-config.js +7 -5
  30. package/dist/core/agent/agent-registry.service.js +0 -10
  31. package/dist/core/moleculer/hub-mesh-guard.js +30 -101
  32. package/dist/core/moleculer/moleculer.service.js +8 -32
  33. package/dist/main.js +33 -2
  34. package/package.json +9 -9
  35. package/dist/agent/agent-mesh-enroll.js +0 -317
  36. package/dist/agent/agent-mesh-join.js +0 -140
  37. package/dist/api/mesh/enroll.routes.js +0 -34
@@ -1,37 +1,28 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderConsentPage = renderConsentPage;
4
- function escapeHtml(s) {
5
- return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
6
- }
7
- /** Allow/Deny consent screen. Submitting POSTs to the same path with the
8
- * hidden query fields + `consent=allow|deny`. */
4
+ const app_page_shell_js_1 = require("../../auth/app-page-shell.js");
5
+ const app_page_strings_js_1 = require("../../auth/app-page-strings.js");
6
+ /** Allow/Deny consent screen on the hub's shared page shell (the look of the
7
+ * app sign-in pages). Submitting POSTs to the same path with the hidden query
8
+ * fields + `consent=allow|deny`. */
9
9
  function renderConsentPage(input) {
10
10
  const hidden = Object.entries(input.hidden)
11
- .map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`)
12
- .join('\n ');
13
- return `<!doctype html>
14
- <html lang="en"><head><meta charset="utf-8">
15
- <meta name="viewport" content="width=device-width, initial-scale=1">
16
- <title>CamStack · Authorize ${escapeHtml(input.displayName)}</title>
17
- <style>
18
- body{font-family:system-ui,sans-serif;max-width:480px;margin:4rem auto;padding:0 1rem;color:#1f2937}
19
- .card{border:1px solid #e5e7eb;border-radius:.5rem;padding:1.5rem}
20
- .meta{font-size:.85rem;color:#6b7280;margin:.5rem 0}
21
- button{font-size:1rem;padding:.5rem 1.25rem;border-radius:.375rem;cursor:pointer;border:1px solid transparent}
22
- .allow{background:#2563eb;color:#fff}.deny{background:#f3f4f6;color:#1f2937;border-color:#d1d5db;margin-left:.5rem}
23
- </style></head><body>
24
- <div class="card">
25
- <h1>Authorize ${escapeHtml(input.displayName)}</h1>
26
- <p class="meta">Signed in as <strong>${escapeHtml(input.username)}</strong></p>
27
- <p class="meta">This will grant: ${escapeHtml(input.scopeSummary)}</p>${input.redirectUri
28
- ? `\n <p class="meta">Sending the authorization to <code>${escapeHtml(input.redirectUri)}</code></p>`
29
- : ''}
30
- <form method="POST">
31
- ${hidden}
32
- <button class="allow" name="consent" value="allow" type="submit">Allow</button>
33
- <button class="deny" name="consent" value="deny" type="submit">Deny</button>
34
- </form>
35
- </div>
36
- </body></html>`;
11
+ .map(([name, value]) => (0, app_page_shell_js_1.appHiddenField)(name, value))
12
+ .join('');
13
+ const t = (0, app_page_strings_js_1.appStrings)(input.lang).consent;
14
+ const destination = input.redirectUri
15
+ ? `<p class="hint">${(0, app_page_shell_js_1.escapeHtml)(t.sendingTo)}<code>${(0, app_page_shell_js_1.escapeHtml)(input.redirectUri)}</code></p>`
16
+ : '';
17
+ return (0, app_page_shell_js_1.renderAppPage)({
18
+ lang: input.lang,
19
+ title: `${t.titlePrefix}${input.displayName}`,
20
+ heading: `${t.headingPrefix}${input.displayName}`,
21
+ body: [
22
+ `<p>${(0, app_page_shell_js_1.escapeHtml)(t.signedInAs)}<strong>${(0, app_page_shell_js_1.escapeHtml)(input.username)}</strong></p>`,
23
+ (0, app_page_shell_js_1.appHint)(`${t.grantPrefix}${input.scopeSummary}`),
24
+ destination,
25
+ `<form method="POST">${hidden}<div class="actions">${(0, app_page_shell_js_1.appSubmitButton)(t.allow, 'consent', 'allow')}${(0, app_page_shell_js_1.appSubmitButton)(t.deny, 'consent', 'deny', true)}</div></form>`,
26
+ ].join(''),
27
+ });
37
28
  }
@@ -7,6 +7,7 @@ exports.isRedirectUriAllowed = isRedirectUriAllowed;
7
7
  exports.summariseScopes = summariseScopes;
8
8
  exports.registerOauth2Routes = registerOauth2Routes;
9
9
  const session_cookie_js_1 = require("../../auth/session-cookie.js");
10
+ const app_page_strings_js_1 = require("../../auth/app-page-strings.js");
10
11
  const consent_page_js_1 = require("./consent-page.js");
11
12
  const private_host_js_1 = require("./private-host.js");
12
13
  /**
@@ -341,6 +342,10 @@ function registerOauth2Routes(fastify, deps) {
341
342
  .send({ error: 'invalid_request — redirect_uri not allowed for this integration' });
342
343
  }
343
344
  const html = (0, consent_page_js_1.renderConsentPage)({
345
+ lang: (0, app_page_strings_js_1.resolveAppPageLang)({
346
+ lang: request.query['lang'],
347
+ acceptLanguage: request.headers['accept-language'],
348
+ }),
344
349
  displayName: descriptor.displayName,
345
350
  username: tokenInfo.username ?? '',
346
351
  scopeSummary: summariseScopes(descriptor.requestedScopes),
@@ -320,9 +320,8 @@ function makeDeviceScopeLookup(addonRegistry) {
320
320
  * `principal` names WHO is calling (D802): `mesh` for the hub's own runners
321
321
  * (they reach the bridge over UDS, inside the hub's trust boundary), and
322
322
  * `node:<nodeId>` for another node — whose id the hub's mesh gate has bound
323
- * to a challenge–response proof before the call got here
324
- * (`createMeshAccessMiddleware`; in `transition` mode an unauthenticated node
325
- * is tolerated and its id is only what it claims).
323
+ * to its CERTIFICATE before the call got here (`createMeshAccessMiddleware`:
324
+ * the TLS handshake chained to the mesh CA, the leaf CN is that id, D857).
326
325
  *
327
326
  * Both principals are still admins: that keeps every `adminProcedure` an
328
327
  * agent's addons call working. What is NOT reachable from another node is
@@ -365,6 +364,7 @@ async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens
365
364
  return {
366
365
  user,
367
366
  req: opts.req,
367
+ ...(user === null && token !== null && token !== '' ? { credentialRejected: true } : {}),
368
368
  ...(user !== null && sessionId !== undefined
369
369
  ? {
370
370
  sessionLive: () => authService.checkSession({
@@ -41,6 +41,7 @@ exports.wrapWebrtcSessionProviderWithRelay = wrapWebrtcSessionProviderWithRelay;
41
41
  exports.buildAppRouter = buildAppRouter;
42
42
  const path = __importStar(require("node:path"));
43
43
  const addon_settings_router_js_1 = require("../core/addon-settings.router.js");
44
+ const session_connections_js_1 = require("../../auth/session-connections.js");
44
45
  const agent_pairing_router_js_1 = require("../core/agent-pairing.router.js");
45
46
  const certificates_router_js_1 = require("../core/certificates.router.js");
46
47
  const node_certificates_client_js_1 = require("../../core/agent/node-certificates-client.js");
@@ -199,10 +200,36 @@ function enrichHintsWithClientClass(input, _clientClass) {
199
200
  function wrapWebrtcSessionProviderWithRelay(provider, ctx) {
200
201
  const userAgent = (0, client_ip_js_1.extractUserAgent)(ctx.req);
201
202
  const clientClass = (0, client_ip_js_1.classifyClientRequest)(ctx.req);
203
+ // The login session this request runs under: media opened here dies with it
204
+ // (`session-connections.ts`). A principal with no `sid` has nothing to tie to.
205
+ const authSessionId = ctx.user?.sessionId;
206
+ const hold = (deviceId, mediaSessionId) => {
207
+ if (authSessionId === undefined)
208
+ return;
209
+ (0, session_connections_js_1.getSessionConnections)().trackMedia(authSessionId, {
210
+ deviceId,
211
+ mediaSessionId,
212
+ close: () => provider.closeSession({ deviceId, sessionId: mediaSessionId }),
213
+ });
214
+ };
202
215
  return {
203
216
  ...provider,
204
- createSession: (input) => provider.createSession(enrichHintsWithClientClass(enrichInputWithRelayClass(enrichInputWithUserAgent(input, userAgent), clientClass), clientClass)),
205
- handleOffer: (input) => provider.handleOffer(enrichHintsWithClientClass(enrichInputWithRelayClass(enrichInputWithUserAgent(stripTalkbackFromRequest(input, clientClass), userAgent), clientClass), clientClass)),
217
+ createSession: async (input) => {
218
+ const created = await provider.createSession(enrichHintsWithClientClass(enrichInputWithRelayClass(enrichInputWithUserAgent(input, userAgent), clientClass), clientClass));
219
+ hold(input.deviceId, created.sessionId);
220
+ return created;
221
+ },
222
+ handleOffer: async (input) => {
223
+ const answered = await provider.handleOffer(enrichHintsWithClientClass(enrichInputWithRelayClass(enrichInputWithUserAgent(stripTalkbackFromRequest(input, clientClass), userAgent), clientClass), clientClass));
224
+ hold(input.deviceId, answered.sessionId);
225
+ return answered;
226
+ },
227
+ closeSession: (input) => {
228
+ if (authSessionId !== undefined) {
229
+ (0, session_connections_js_1.getSessionConnections)().forgetMedia(authSessionId, input.sessionId);
230
+ }
231
+ return provider.closeSession(input);
232
+ },
206
233
  };
207
234
  }
208
235
  /**
@@ -7,18 +7,21 @@ 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");
20
+ const app_page_strings_js_1 = require("./app-page-strings.js");
19
21
  const PAGE_SCRIPT = `(function () {
20
22
  var btn = document.getElementById('go');
21
23
  var msg = document.getElementById('msg');
24
+ var S = function (k) { return document.body.getAttribute('data-s-' + k) || ''; };
22
25
  var APP_CALLBACK = /^camstack(\\.dev)?:\\/\\/auth-callback(\\?|$)/;
23
26
  btn.addEventListener('click', function () {
24
27
  btn.disabled = true;
@@ -31,34 +34,45 @@ const PAGE_SCRIPT = `(function () {
31
34
  cache: 'no-store'
32
35
  }).then(function (r) { return r.json(); }).then(function (body) {
33
36
  if (typeof body.location !== 'string' || !APP_CALLBACK.test(body.location)) {
34
- throw new Error(body.error || 'Sign-in failed.');
37
+ throw new Error('bad-location');
35
38
  }
36
39
  location.assign(body.location);
37
40
  }).catch(function () {
38
- msg.textContent = 'This confirmation expired. Start the sign-in again from the app.';
41
+ msg.textContent = S('confirm-expired');
39
42
  });
40
43
  });
41
44
  })();`;
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)};`);
45
+ /** The CSP: the shell's style and this page's one script by hash, same-origin requests, no framing, no form posts. */
46
+ exports.APP_CONFIRM_CSP = (0, app_page_shell_js_1.appPageCsp)({ script: PAGE_SCRIPT });
47
+ /** CSP of the refusal page: the shell's style only, nothing runs. */
48
+ exports.APP_CONFIRM_REFUSAL_CSP = (0, app_page_shell_js_1.appPageCsp)();
57
49
  /** The confirmation page for `username`; `cancelHref` is the app callback carrying `error=cancelled`. */
58
50
  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>`;
51
+ const t = (0, app_page_strings_js_1.appStrings)(input.lang);
52
+ return (0, app_page_shell_js_1.renderAppPage)({
53
+ lang: input.lang,
54
+ title: t.confirm.title,
55
+ heading: t.confirm.heading,
56
+ noReferrer: true,
57
+ script: PAGE_SCRIPT,
58
+ scriptStrings: { 'confirm-expired': t.script.confirmExpired },
59
+ body: [
60
+ `<p>${(0, app_page_shell_js_1.escapeHtml)(t.confirm.signedInAsBefore)}<strong>${(0, app_page_shell_js_1.escapeHtml)(input.username)}</strong>${(0, app_page_shell_js_1.escapeHtml)(t.confirm.signedInAsAfter)}</p>`,
61
+ (0, app_page_shell_js_1.appHint)(t.confirm.hint),
62
+ (0, app_page_shell_js_1.appButton)(t.confirm.continueLabel, `id="go" data-confirm="${(0, app_page_shell_js_1.escapeHtml)(input.token)}"`),
63
+ (0, app_page_shell_js_1.appErrorLine)('msg'),
64
+ (0, app_page_shell_js_1.appCancelLink)(input.cancelHref, t.confirm.cancel),
65
+ `<noscript><p class="error">${(0, app_page_shell_js_1.escapeHtml)(t.confirm.noscript)}</p></noscript>`,
66
+ ].join(''),
67
+ });
60
68
  }
61
69
  /** A confirmation that is gone (expired, used, or opened in another browser): a sentence, nothing runs. */
62
- 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>`;
70
+ function appConfirmRefusalPage(lang) {
71
+ const t = (0, app_page_strings_js_1.appStrings)(lang).confirmExpired;
72
+ return (0, app_page_shell_js_1.renderAppPage)({
73
+ lang,
74
+ title: t.title,
75
+ heading: t.title,
76
+ body: (0, app_page_shell_js_1.appParagraph)(t.body) + (0, app_page_shell_js_1.appHint)(t.hint),
77
+ });
64
78
  }
@@ -17,6 +17,7 @@ const types_1 = require("@camstack/types");
17
17
  const zod_1 = require("zod");
18
18
  const app_confirmations_js_1 = require("./app-confirmations.js");
19
19
  const app_confirm_page_js_1 = require("./app-confirm-page.js");
20
+ const app_page_strings_js_1 = require("./app-page-strings.js");
20
21
  const auth_rate_limit_js_1 = require("./auth-rate-limit.js");
21
22
  const sso_finish_deps_js_1 = require("./sso-finish-deps.js");
22
23
  exports.APP_CONFIRM_PATH = '/auth/app/confirm';
@@ -75,10 +76,16 @@ function registerAppConfirmRoutes(fastify, deps) {
75
76
  meta: { reason: 'unknown-expired-or-other-browser' },
76
77
  });
77
78
  reply.header('Content-Security-Policy', app_confirm_page_js_1.APP_CONFIRM_REFUSAL_CSP);
78
- return reply.status(400).send((0, app_confirm_page_js_1.appConfirmRefusalPage)());
79
+ return reply
80
+ .status(400)
81
+ .send((0, app_confirm_page_js_1.appConfirmRefusalPage)((0, app_page_strings_js_1.resolveAppPageLang)({ acceptLanguage: request.headers['accept-language'] })));
79
82
  }
80
83
  reply.header('Content-Security-Policy', app_confirm_page_js_1.APP_CONFIRM_CSP);
81
84
  return reply.send((0, app_confirm_page_js_1.appConfirmPage)({
85
+ lang: (0, app_page_strings_js_1.resolveAppPageLang)({
86
+ lang: confirmation.app.lang,
87
+ acceptLanguage: request.headers['accept-language'],
88
+ }),
82
89
  username: confirmation.username,
83
90
  token,
84
91
  cancelHref: (0, types_1.appFlowLocation)(confirmation.app.redirect, { error: 'cancelled' }, confirmation.app.state),
@@ -0,0 +1,123 @@
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
+ const strings = Object.entries(input.scriptStrings ?? {})
99
+ .map(([key, value]) => ` data-s-${key}="${escapeHtml(value)}"`)
100
+ .join('');
101
+ return `<!doctype html><html lang="${input.lang}"><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${strings}><main><div class="brand">${LOGO_DARK}${LOGO_LIGHT}</div><div class="card"><h1>${escapeHtml(input.heading)}</h1>${input.body}</div></main>${script}</body></html>`;
102
+ }
103
+ /** A paragraph of the card (`text` is escaped). */
104
+ const appParagraph = (text) => `<p>${escapeHtml(text)}</p>`;
105
+ exports.appParagraph = appParagraph;
106
+ /** A smaller, muted paragraph (`text` is escaped). */
107
+ const appHint = (text) => `<p class="hint">${escapeHtml(text)}</p>`;
108
+ exports.appHint = appHint;
109
+ /** The primary button; `attributes` is trusted markup (already escaped), `label` is escaped. */
110
+ const appButton = (label, attributes) => `<button class="btn" type="button" ${attributes}>${escapeHtml(label)}</button>`;
111
+ exports.appButton = appButton;
112
+ /** The error line a script fills in; empty it renders nothing. */
113
+ const appErrorLine = (id) => `<p class="error" id="${id}" role="alert"></p>`;
114
+ exports.appErrorLine = appErrorLine;
115
+ /** A submit button of a form (`name`/`value` are escaped); `secondary` is the outlined, non-default choice. */
116
+ const appSubmitButton = (label, name, value, secondary = false) => `<button class="${secondary ? 'btn secondary' : 'btn'}" type="submit" name="${escapeHtml(name)}" value="${escapeHtml(value)}">${escapeHtml(label)}</button>`;
117
+ exports.appSubmitButton = appSubmitButton;
118
+ /** A hidden form field (`name`/`value` are escaped). */
119
+ const appHiddenField = (name, value) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`;
120
+ exports.appHiddenField = appHiddenField;
121
+ /** The quiet link under the actions (Cancel); `href` is escaped here. */
122
+ const appCancelLink = (href, label) => `<a class="link" href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
123
+ exports.appCancelLink = appCancelLink;
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.APP_PAGE_STRINGS = exports.APP_PAGE_DEFAULT_LANG = void 0;
4
+ exports.appStrings = appStrings;
5
+ exports.langFromAcceptLanguage = langFromAcceptLanguage;
6
+ exports.resolveAppPageLang = resolveAppPageLang;
7
+ /**
8
+ * Every word the hub's app landing pages show (D858), in both languages, and
9
+ * the one rule that picks the language.
10
+ *
11
+ * The pages (`app-confirm-page.ts`, `app-passkey-page.ts`, `app-refused-route.ts`,
12
+ * the OAuth2 consent page) hold NO prose of their own: they ask {@link appStrings}.
13
+ * A key present in one locale and not the other is a compile error AND a spec
14
+ * failure; a page that renders an English literal is a spec failure
15
+ * (`app-page-strings.spec.ts`).
16
+ *
17
+ * Language choice ({@link resolveAppPageLang}): the `lang` the app sent
18
+ * (allow-listed) wins; else the first supported tag of `Accept-Language`
19
+ * (`it*` -> `it`); else English. Neither input is ever reflected into a page.
20
+ */
21
+ const types_1 = require("@camstack/types");
22
+ exports.APP_PAGE_DEFAULT_LANG = 'en';
23
+ const EN = {
24
+ confirm: {
25
+ title: 'Sign in the CamStack app',
26
+ heading: 'Sign in the CamStack app?',
27
+ signedInAsBefore: 'The CamStack app on this device will be signed in as ',
28
+ signedInAsAfter: '.',
29
+ hint: 'Continue only if you just started this from the CamStack app.',
30
+ continueLabel: 'Continue',
31
+ cancel: 'Cancel',
32
+ noscript: 'JavaScript is required to continue.',
33
+ },
34
+ confirmExpired: {
35
+ title: 'Confirmation expired',
36
+ body: 'This sign-in confirmation is no longer valid.',
37
+ hint: 'Close this window and start the sign-in again from the app.',
38
+ },
39
+ passkey: {
40
+ title: 'Sign in with a passkey',
41
+ heading: 'Sign in to CamStack',
42
+ body: 'Use a passkey saved on this device to sign in to the CamStack app.',
43
+ button: 'Sign in with a passkey',
44
+ cancel: 'Cancel',
45
+ noscript: 'JavaScript is required to use a passkey.',
46
+ },
47
+ refused: {
48
+ title: 'Sign-in request refused',
49
+ hint: 'Close this window and try again from the app.',
50
+ generic: 'The sign-in request was refused.',
51
+ reasons: types_1.APP_START_REFUSAL_MESSAGES,
52
+ },
53
+ consent: {
54
+ titlePrefix: 'CamStack · Authorize ',
55
+ headingPrefix: 'Authorize ',
56
+ signedInAs: 'Signed in as ',
57
+ grantPrefix: 'This will grant: ',
58
+ sendingTo: 'Sending the authorization to ',
59
+ allow: 'Allow',
60
+ deny: 'Deny',
61
+ },
62
+ script: {
63
+ confirmExpired: 'This confirmation expired. Start the sign-in again from the app.',
64
+ passkeyUnsupported: 'This browser cannot use passkeys.',
65
+ passkeyUnavailable: 'Passkeys are not available.',
66
+ passkeyCancelled: 'The passkey request was cancelled.',
67
+ signInFailed: 'Sign-in failed. Try again.',
68
+ },
69
+ };
70
+ const IT = {
71
+ confirm: {
72
+ title: "Accedi all'app CamStack",
73
+ heading: "Accedere all'app CamStack?",
74
+ signedInAsBefore: "L'app CamStack su questo dispositivo accederà come ",
75
+ signedInAsAfter: '.',
76
+ hint: "Continua solo se hai appena avviato l'operazione dall'app CamStack.",
77
+ continueLabel: 'Continua',
78
+ cancel: 'Annulla',
79
+ noscript: 'Per continuare serve JavaScript.',
80
+ },
81
+ confirmExpired: {
82
+ title: 'Conferma scaduta',
83
+ body: 'Questa conferma di accesso non è più valida.',
84
+ hint: "Chiudi questa finestra e riavvia l'accesso dall'app.",
85
+ },
86
+ passkey: {
87
+ title: 'Accedi con una passkey',
88
+ heading: 'Accedi a CamStack',
89
+ body: "Usa una passkey salvata su questo dispositivo per accedere all'app CamStack.",
90
+ button: 'Accedi con una passkey',
91
+ cancel: 'Annulla',
92
+ noscript: 'Per usare una passkey serve JavaScript.',
93
+ },
94
+ refused: {
95
+ title: 'Richiesta di accesso rifiutata',
96
+ hint: "Chiudi questa finestra e riprova dall'app.",
97
+ generic: 'La richiesta di accesso è stata rifiutata.',
98
+ reasons: {
99
+ 'bad-challenge': 'La richiesta di accesso non ha una sfida PKCE valida (S256).',
100
+ 'bad-challenge-method': 'È supportato solo il metodo PKCE S256.',
101
+ 'bad-state': 'La richiesta di accesso non ha uno state valido.',
102
+ 'bad-purpose': 'La richiesta di accesso indica uno scopo sconosciuto.',
103
+ 'bad-redirect': 'La richiesta di accesso indica un indirizzo di ritorno che questo hub non accetta.',
104
+ 'combined-flow': "L'accesso dell'app non può essere combinato con il collegamento o con un popup.",
105
+ },
106
+ },
107
+ consent: {
108
+ titlePrefix: 'CamStack · Autorizza ',
109
+ headingPrefix: 'Autorizza ',
110
+ signedInAs: 'Accesso eseguito come ',
111
+ grantPrefix: 'Concederà: ',
112
+ sendingTo: "Invio dell'autorizzazione a ",
113
+ allow: 'Consenti',
114
+ deny: 'Nega',
115
+ },
116
+ script: {
117
+ confirmExpired: "Questa conferma è scaduta. Riavvia l'accesso dall'app.",
118
+ passkeyUnsupported: 'Questo browser non supporta le passkey.',
119
+ passkeyUnavailable: 'Le passkey non sono disponibili.',
120
+ passkeyCancelled: 'La richiesta della passkey è stata annullata.',
121
+ signInFailed: 'Accesso non riuscito. Riprova.',
122
+ },
123
+ };
124
+ exports.APP_PAGE_STRINGS = { en: EN, it: IT };
125
+ function appStrings(lang) {
126
+ return exports.APP_PAGE_STRINGS[lang];
127
+ }
128
+ /** The first supported language of an `Accept-Language` value (by q, then order), or `undefined`. */
129
+ function langFromAcceptLanguage(header) {
130
+ const value = typeof header === 'string' ? header : header?.join(',');
131
+ if (value === undefined || value.length > 512)
132
+ return undefined;
133
+ const ranked = value
134
+ .split(',')
135
+ .map((part, index) => {
136
+ const [tag = '', ...params] = part.trim().split(';');
137
+ const q = params
138
+ .map((p) => /^\s*q\s*=\s*([0-9.]+)\s*$/i.exec(p)?.[1])
139
+ .find((m) => m !== undefined);
140
+ const weight = q === undefined ? 1 : Number.parseFloat(q);
141
+ return { primary: tag.trim().toLowerCase().split('-')[0] ?? '', weight, index };
142
+ })
143
+ .filter((t) => Number.isFinite(t.weight) && t.weight > 0)
144
+ .sort((a, b) => b.weight - a.weight || a.index - b.index);
145
+ for (const { primary } of ranked) {
146
+ const lang = (0, types_1.parseAppPageLang)(primary);
147
+ if (lang !== undefined)
148
+ return lang;
149
+ }
150
+ return undefined;
151
+ }
152
+ /** The page language: the app's `lang` (allow-listed), else `Accept-Language`, else English. */
153
+ function resolveAppPageLang(input) {
154
+ return ((0, types_1.parseAppPageLang)(input.lang) ??
155
+ langFromAcceptLanguage(input.acceptLanguage) ??
156
+ exports.APP_PAGE_DEFAULT_LANG);
157
+ }