@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.
- 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/auth.router.js +11 -0
- package/dist/api/core/cap-providers.js +3 -1
- package/dist/api/core/certificates.router.js +2 -2
- package/dist/api/core/server-name.js +64 -0
- package/dist/api/oauth2/consent-page.js +22 -31
- package/dist/api/oauth2/oauth2-routes.js +5 -0
- package/dist/api/trpc/trpc.context.js +3 -3
- package/dist/api/trpc/trpc.router.js +29 -2
- package/dist/auth/app-confirm-page.js +38 -24
- package/dist/auth/app-confirm-routes.js +8 -1
- package/dist/auth/app-page-shell.js +123 -0
- package/dist/auth/app-page-strings.js +157 -0
- package/dist/auth/app-passkey-page.js +47 -26
- package/dist/auth/app-passkey-routes.js +9 -2
- package/dist/auth/app-refused-route.js +43 -0
- package/dist/auth/session-connections.js +136 -0
- package/dist/auth/sso-finish-app.js +7 -1
- 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 +33 -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
|
@@ -1,37 +1,28 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.renderConsentPage = renderConsentPage;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*
|
|
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(([
|
|
12
|
-
.join('
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
.
|
|
20
|
-
.
|
|
21
|
-
|
|
22
|
-
.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
324
|
-
*
|
|
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) =>
|
|
205
|
-
|
|
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
|
|
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");
|
|
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(
|
|
37
|
+
throw new Error('bad-location');
|
|
35
38
|
}
|
|
36
39
|
location.assign(body.location);
|
|
37
40
|
}).catch(function () {
|
|
38
|
-
msg.textContent = '
|
|
41
|
+
msg.textContent = S('confirm-expired');
|
|
39
42
|
});
|
|
40
43
|
});
|
|
41
44
|
})();`;
|
|
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)};`);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|