@camstack/server 1.2.579 → 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.
@@ -41,6 +41,7 @@ const auth_revoke_credentials_procedures_js_1 = require("./auth-revoke-credentia
41
41
  const auth_reauth_procedures_js_1 = require("./auth-reauth.procedures.js");
42
42
  const auth_device_cert_procedures_js_1 = require("./auth-device-cert.procedures.js");
43
43
  const auth_sessions_procedures_js_1 = require("./auth-sessions.procedures.js");
44
+ const server_name_js_1 = require("./server-name.js");
44
45
  /**
45
46
  * A public procedure limited per (procedure, REAL client address) — see
46
47
  * `auth-limits.ts` for the tiers and why the address is the forwarded client.
@@ -459,6 +460,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
459
460
  return refuse('this sign-in already started a step-up');
460
461
  }
461
462
  };
463
+ const serverNameReader = (0, server_name_js_1.serverNameReaderFor)(registry);
462
464
  const aggregateLoginMethods = async () => {
463
465
  if (!registry)
464
466
  return [];
@@ -1504,5 +1506,14 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
1504
1506
  .input(zod_1.z.void())
1505
1507
  .output(zod_1.z.array(PublicLoginMethodSchema).readonly())
1506
1508
  .query(async () => aggregateLoginMethods()),
1509
+ /**
1510
+ * The hub's human name (D861), public like `listLoginMethods` and exposing
1511
+ * nothing else. A separate method on purpose: `listLoginMethods` stays the bare
1512
+ * array every installed viewer already parses.
1513
+ */
1514
+ getServerName: trpc_middleware_js_1.publicProcedure
1515
+ .input(zod_1.z.void())
1516
+ .output(types_1.ServerNameInfoSchema)
1517
+ .query(async () => serverNameReader.read()),
1507
1518
  });
1508
1519
  }
@@ -87,6 +87,7 @@ const failure_contributions_js_1 = require("./failure-contributions.js");
87
87
  const load_contributions_js_1 = require("./load-contributions.js");
88
88
  const logging_settings_js_1 = require("./logging-settings.js");
89
89
  const request_census_settings_js_1 = require("./request-census-settings.js");
90
+ const server_name_js_1 = require("./server-name.js");
90
91
  const site_location_js_1 = require("./site-location.js");
91
92
  // ── system ──────────────────────────────────────────────────────────
92
93
  function getRetention(registry) {
@@ -95,6 +96,7 @@ function getRetention(registry) {
95
96
  function buildSystemProvider(feature, registry, logger, census = (0, http_request_census_singleton_js_1.getHttpRequestCensus)()) {
96
97
  const siteLocation = buildSiteLocationService(registry, logger);
97
98
  const store = buildSettingsStoreGateway(registry);
99
+ const serverName = new server_name_js_1.ServerNameReader({ store, ...(logger !== undefined ? { logger } : {}) });
98
100
  const requestCensus = new request_census_settings_js_1.RequestCensusService({
99
101
  census,
100
102
  store,
@@ -130,7 +132,7 @@ function buildSystemProvider(feature, registry, logger, census = (0, http_reques
130
132
  ...(logger !== undefined ? { logger } : {}),
131
133
  });
132
134
  return {
133
- info: async () => feature.getManifest(),
135
+ info: async () => ({ ...feature.getManifest(), serverName: await serverName.read() }),
134
136
  health: async () => ({ status: 'ok', uptime: process.uptime() }),
135
137
  featureFlags: async () => feature.getManifest(),
136
138
  networkAddresses: async () => {
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ /**
3
+ * Reads the hub's human name (D861) from the one row that holds it,
4
+ * `system-settings` / `server.name`, and falls back to the machine hostname.
5
+ *
6
+ * Read-only on purpose: the only writer is the `system-config` addon's form
7
+ * (System -> Settings), which validates with the same `ServerNameSchema`. Nothing
8
+ * here writes a default, at boot or on a read.
9
+ *
10
+ * A read that FAILS is not an unset name: it answers the hostname default for
11
+ * display (a title is never worth a failed page) but says so in the log, so a
12
+ * hub that looks unnamed because its store is down is not mistaken for one that
13
+ * was never named.
14
+ */
15
+ var __importDefault = (this && this.__importDefault) || function (mod) {
16
+ return (mod && mod.__esModule) ? mod : { "default": mod };
17
+ };
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.ServerNameReader = void 0;
20
+ exports.serverNameReaderFor = serverNameReaderFor;
21
+ const node_os_1 = __importDefault(require("node:os"));
22
+ const types_1 = require("@camstack/types");
23
+ const SYSTEM_SETTINGS_COLLECTION = 'system-settings';
24
+ class ServerNameReader {
25
+ deps;
26
+ constructor(deps) {
27
+ this.deps = deps;
28
+ }
29
+ async read() {
30
+ const hostname = (this.deps.hostname ?? node_os_1.default.hostname)();
31
+ try {
32
+ const stored = await this.deps.store.get({
33
+ collection: SYSTEM_SETTINGS_COLLECTION,
34
+ key: types_1.SERVER_NAME_SETTING_KEY,
35
+ });
36
+ return (0, types_1.resolveServerName)(stored, hostname);
37
+ }
38
+ catch (err) {
39
+ this.deps.logger?.warn('server name read failed - answering the hostname default', {
40
+ meta: { error: (0, types_1.errMsg)(err) },
41
+ });
42
+ return (0, types_1.resolveServerName)(undefined, hostname);
43
+ }
44
+ }
45
+ }
46
+ exports.ServerNameReader = ServerNameReader;
47
+ /**
48
+ * A reader that resolves the `settings-store` cap lazily from the registry (it
49
+ * boots after the routers are built). Without a registry, or before the cap is
50
+ * up, the read throws and {@link ServerNameReader} answers the hostname default.
51
+ */
52
+ function serverNameReaderFor(registry, logger) {
53
+ return new ServerNameReader({
54
+ store: {
55
+ get: async (input) => {
56
+ const provider = registry?.getSingleton('settings-store') ?? null;
57
+ if (provider === null)
58
+ throw new Error('settings-store unavailable: cap not registered yet');
59
+ return provider.get(input);
60
+ },
61
+ },
62
+ ...(logger !== undefined ? { logger } : {}),
63
+ });
64
+ }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderConsentPage = renderConsentPage;
4
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");
5
6
  /** Allow/Deny consent screen on the hub's shared page shell (the look of the
6
7
  * app sign-in pages). Submitting POSTs to the same path with the hidden query
7
8
  * fields + `consent=allow|deny`. */
@@ -9,17 +10,19 @@ function renderConsentPage(input) {
9
10
  const hidden = Object.entries(input.hidden)
10
11
  .map(([name, value]) => (0, app_page_shell_js_1.appHiddenField)(name, value))
11
12
  .join('');
13
+ const t = (0, app_page_strings_js_1.appStrings)(input.lang).consent;
12
14
  const destination = input.redirectUri
13
- ? `<p class="hint">Sending the authorization to <code>${(0, app_page_shell_js_1.escapeHtml)(input.redirectUri)}</code></p>`
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>`
14
16
  : '';
15
17
  return (0, app_page_shell_js_1.renderAppPage)({
16
- title: `CamStack · Authorize ${input.displayName}`,
17
- heading: `Authorize ${input.displayName}`,
18
+ lang: input.lang,
19
+ title: `${t.titlePrefix}${input.displayName}`,
20
+ heading: `${t.headingPrefix}${input.displayName}`,
18
21
  body: [
19
- `<p>Signed in as <strong>${(0, app_page_shell_js_1.escapeHtml)(input.username)}</strong></p>`,
20
- (0, app_page_shell_js_1.appHint)(`This will grant: ${input.scopeSummary}`),
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}`),
21
24
  destination,
22
- `<form method="POST">${hidden}<div class="actions">${(0, app_page_shell_js_1.appSubmitButton)('Allow', 'consent', 'allow')}${(0, app_page_shell_js_1.appSubmitButton)('Deny', 'consent', 'deny', true)}</div></form>`,
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>`,
23
26
  ].join(''),
24
27
  });
25
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),
@@ -364,6 +364,7 @@ async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens
364
364
  return {
365
365
  user,
366
366
  req: opts.req,
367
+ ...(user === null && token !== null && token !== '' ? { credentialRejected: true } : {}),
367
368
  ...(user !== null && sessionId !== undefined
368
369
  ? {
369
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
  /**
@@ -17,9 +17,11 @@ exports.appConfirmRefusalPage = appConfirmRefusalPage;
17
17
  * `error=cancelled`.
18
18
  */
19
19
  const app_page_shell_js_1 = require("./app-page-shell.js");
20
+ const app_page_strings_js_1 = require("./app-page-strings.js");
20
21
  const PAGE_SCRIPT = `(function () {
21
22
  var btn = document.getElementById('go');
22
23
  var msg = document.getElementById('msg');
24
+ var S = function (k) { return document.body.getAttribute('data-s-' + k) || ''; };
23
25
  var APP_CALLBACK = /^camstack(\\.dev)?:\\/\\/auth-callback(\\?|$)/;
24
26
  btn.addEventListener('click', function () {
25
27
  btn.disabled = true;
@@ -32,11 +34,11 @@ const PAGE_SCRIPT = `(function () {
32
34
  cache: 'no-store'
33
35
  }).then(function (r) { return r.json(); }).then(function (body) {
34
36
  if (typeof body.location !== 'string' || !APP_CALLBACK.test(body.location)) {
35
- throw new Error(body.error || 'Sign-in failed.');
37
+ throw new Error('bad-location');
36
38
  }
37
39
  location.assign(body.location);
38
40
  }).catch(function () {
39
- msg.textContent = 'This confirmation expired. Start the sign-in again from the app.';
41
+ msg.textContent = S('confirm-expired');
40
42
  });
41
43
  });
42
44
  })();`;
@@ -46,27 +48,31 @@ exports.APP_CONFIRM_CSP = (0, app_page_shell_js_1.appPageCsp)({ script: PAGE_SCR
46
48
  exports.APP_CONFIRM_REFUSAL_CSP = (0, app_page_shell_js_1.appPageCsp)();
47
49
  /** The confirmation page for `username`; `cancelHref` is the app callback carrying `error=cancelled`. */
48
50
  function appConfirmPage(input) {
51
+ const t = (0, app_page_strings_js_1.appStrings)(input.lang);
49
52
  return (0, app_page_shell_js_1.renderAppPage)({
50
- title: 'Sign in the CamStack app',
51
- heading: 'Sign in the CamStack app?',
53
+ lang: input.lang,
54
+ title: t.confirm.title,
55
+ heading: t.confirm.heading,
52
56
  noReferrer: true,
53
57
  script: PAGE_SCRIPT,
58
+ scriptStrings: { 'confirm-expired': t.script.confirmExpired },
54
59
  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)}"`),
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)}"`),
58
63
  (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>',
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>`,
61
66
  ].join(''),
62
67
  });
63
68
  }
64
69
  /** A confirmation that is gone (expired, used, or opened in another browser): a sentence, nothing runs. */
65
- function appConfirmRefusalPage() {
70
+ function appConfirmRefusalPage(lang) {
71
+ const t = (0, app_page_strings_js_1.appStrings)(lang).confirmExpired;
66
72
  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.'),
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),
71
77
  });
72
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),
@@ -95,7 +95,10 @@ function escapeHtml(text) {
95
95
  function renderAppPage(input) {
96
96
  const referrer = input.noReferrer === true ? '<meta name="referrer" content="no-referrer">' : '';
97
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>`;
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>`;
99
102
  }
100
103
  /** A paragraph of the card (`text` is escaped). */
101
104
  const appParagraph = (text) => `<p>${escapeHtml(text)}</p>`;
@@ -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
+ }
@@ -20,12 +20,14 @@ exports.appPasskeyRefusalPage = appPasskeyRefusalPage;
20
20
  * (allow-listed, HTML-escaped) cancel link.
21
21
  */
22
22
  const app_page_shell_js_1 = require("./app-page-shell.js");
23
+ const app_page_strings_js_1 = require("./app-page-strings.js");
23
24
  const PAGE_SCRIPT = `(function () {
24
25
  var q = new URLSearchParams(location.search);
25
26
  var base = location.pathname.replace(/\\/+$/, '');
26
27
  var btn = document.getElementById('go');
27
28
  var msg = document.getElementById('msg');
28
- var KEYS = ['code_challenge', 'code_challenge_method', 'state', 'purpose', 'redirect'];
29
+ var S = function (k) { return document.body.getAttribute('data-s-' + k) || ''; };
30
+ var KEYS = ['code_challenge', 'code_challenge_method', 'state', 'purpose', 'redirect', 'lang'];
29
31
  var APP_CALLBACK = /^camstack(\\.dev)?:\\/\\/auth-callback(\\?|$)/;
30
32
  function fromB64u(s) {
31
33
  s = s.replace(/-/g, '+').replace(/_/g, '/');
@@ -58,10 +60,10 @@ const PAGE_SCRIPT = `(function () {
58
60
  btn.disabled = true;
59
61
  msg.textContent = '';
60
62
  if (!window.PublicKeyCredential || !navigator.credentials) {
61
- return fail('This browser cannot use passkeys.');
63
+ return fail(S('passkey-unsupported'));
62
64
  }
63
65
  post('/begin', flow()).then(function (begin) {
64
- if (!begin.ok) throw new Error(begin.body.error || 'Passkeys are not available.');
66
+ if (!begin.ok) throw new Error('unavailable');
65
67
  var opts = begin.body.optionsJSON;
66
68
  var pk = {
67
69
  challenge: fromB64u(opts.challenge),
@@ -98,11 +100,15 @@ const PAGE_SCRIPT = `(function () {
98
100
  }
99
101
  var target = done.body && done.body.location;
100
102
  if (typeof target !== 'string' || !APP_CALLBACK.test(target)) {
101
- throw new Error((done.body && done.body.error) || 'Sign-in failed.');
103
+ throw new Error('failed');
102
104
  }
103
105
  location.assign(target);
104
106
  }).catch(function (e) {
105
- fail(e && e.name === 'NotAllowedError' ? 'The passkey request was cancelled.' : 'Sign-in failed. Try again.');
107
+ fail(
108
+ e && e.name === 'NotAllowedError' ? S('passkey-cancelled')
109
+ : e && e.message === 'unavailable' ? S('passkey-unavailable')
110
+ : S('sign-in-failed')
111
+ );
106
112
  });
107
113
  }
108
114
  btn.addEventListener('click', go);
@@ -113,27 +119,37 @@ const PAGE_SCRIPT = `(function () {
113
119
  */
114
120
  exports.APP_PASSKEY_CSP = (0, app_page_shell_js_1.appPageCsp)({ script: PAGE_SCRIPT });
115
121
  /** The page; `cancelHref` is the app callback carrying `error=cancelled`. */
116
- function appPasskeyPage(cancelHref) {
122
+ function appPasskeyPage(cancelHref, lang) {
123
+ const t = (0, app_page_strings_js_1.appStrings)(lang);
117
124
  return (0, app_page_shell_js_1.renderAppPage)({
118
- title: 'Sign in with a passkey',
119
- heading: 'Sign in to CamStack',
125
+ lang,
126
+ title: t.passkey.title,
127
+ heading: t.passkey.heading,
120
128
  noReferrer: true,
121
129
  script: PAGE_SCRIPT,
130
+ scriptStrings: {
131
+ 'passkey-unsupported': t.script.passkeyUnsupported,
132
+ 'passkey-unavailable': t.script.passkeyUnavailable,
133
+ 'passkey-cancelled': t.script.passkeyCancelled,
134
+ 'sign-in-failed': t.script.signInFailed,
135
+ },
122
136
  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"'),
137
+ (0, app_page_shell_js_1.appParagraph)(t.passkey.body),
138
+ (0, app_page_shell_js_1.appButton)(t.passkey.button, 'id="go"'),
125
139
  (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>',
140
+ (0, app_page_shell_js_1.appCancelLink)(cancelHref, t.passkey.cancel),
141
+ `<noscript><p class="error">${(0, app_page_shell_js_1.escapeHtml)(t.passkey.noscript)}</p></noscript>`,
128
142
  ].join(''),
129
143
  });
130
144
  }
131
145
  /** The page for a request the hub refuses to serve: a sentence, no script, no assets. */
132
- function appPasskeyRefusalPage(message) {
146
+ function appPasskeyRefusalPage(message, lang) {
147
+ const t = (0, app_page_strings_js_1.appStrings)(lang).refused;
133
148
  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.'),
149
+ lang,
150
+ title: t.title,
151
+ heading: t.title,
152
+ body: (0, app_page_shell_js_1.appParagraph)(message) + (0, app_page_shell_js_1.appHint)(t.hint),
137
153
  });
138
154
  }
139
155
  /** CSP of the refusal page: the shell's style only, nothing runs. */
@@ -22,6 +22,7 @@ const types_1 = require("@camstack/types");
22
22
  const zod_1 = require("zod");
23
23
  const app_confirm_routes_js_1 = require("./app-confirm-routes.js");
24
24
  const app_passkey_page_js_1 = require("./app-passkey-page.js");
25
+ const app_page_strings_js_1 = require("./app-page-strings.js");
25
26
  const auth_rate_limit_js_1 = require("./auth-rate-limit.js");
26
27
  const session_mint_js_1 = require("./session-mint.js");
27
28
  exports.APP_PASSKEY_PATH = '/auth/app/passkey';
@@ -32,6 +33,7 @@ const FinishBodySchema = zod_1.z.object({
32
33
  state: zod_1.z.string().optional(),
33
34
  purpose: zod_1.z.string().optional(),
34
35
  redirect: zod_1.z.string().optional(),
36
+ lang: zod_1.z.string().optional(),
35
37
  });
36
38
  function noStore(reply) {
37
39
  reply.header('Cache-Control', 'no-store');
@@ -53,6 +55,10 @@ function registerAppPasskeyRoutes(fastify, deps) {
53
55
  noStore(reply);
54
56
  reply.type('text/html; charset=utf-8');
55
57
  const parsed = (0, types_1.parseAppFlowRequest)(request.query);
58
+ const lang = (0, app_page_strings_js_1.resolveAppPageLang)({
59
+ lang: parsed.ok ? parsed.request.lang : request.query['lang'],
60
+ acceptLanguage: request.headers['accept-language'],
61
+ });
56
62
  if (!parsed.ok) {
57
63
  deps.logger.warn('Passkey app sign-in refused at the page', {
58
64
  meta: { reason: parsed.reason },
@@ -60,11 +66,11 @@ function registerAppPasskeyRoutes(fastify, deps) {
60
66
  reply.header('Content-Security-Policy', app_passkey_page_js_1.APP_PASSKEY_REFUSAL_CSP);
61
67
  return reply
62
68
  .status(400)
63
- .send((0, app_passkey_page_js_1.appPasskeyRefusalPage)(types_1.APP_FLOW_REFUSAL_MESSAGES[parsed.reason]));
69
+ .send((0, app_passkey_page_js_1.appPasskeyRefusalPage)((0, app_page_strings_js_1.appStrings)(lang).refused.reasons[parsed.reason], lang));
64
70
  }
65
71
  reply.header('Content-Security-Policy', app_passkey_page_js_1.APP_PASSKEY_CSP);
66
72
  const cancel = (0, types_1.appFlowLocation)(parsed.request.redirect, { error: 'cancelled' }, parsed.request.state);
67
- return reply.send((0, app_passkey_page_js_1.appPasskeyPage)(cancel));
73
+ return reply.send((0, app_passkey_page_js_1.appPasskeyPage)(cancel, lang));
68
74
  });
69
75
  fastify.post(`${exports.APP_PASSKEY_PATH}/begin`, async (request, reply) => {
70
76
  noStore(reply);
@@ -103,6 +109,7 @@ function registerAppPasskeyRoutes(fastify, deps) {
103
109
  state: body.data.state,
104
110
  purpose: body.data.purpose,
105
111
  redirect: body.data.redirect,
112
+ lang: body.data.lang,
106
113
  });
107
114
  if (!parsed.ok)
108
115
  return reply.status(400).send({ error: types_1.APP_FLOW_REFUSAL_MESSAGES[parsed.reason] });
@@ -8,20 +8,22 @@ exports.registerAppRefusedRoute = registerAppRefusedRoute;
8
8
  * sign-in could not start (D858).
9
9
  *
10
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
11
+ * redirects here with a reason CODE (and the app's optional `lang`) and the hub renders the
12
+ * sentence, in the language of `lang`/`Accept-Language`, in the same shell as every other app page. The query is never echoed: an unknown or
13
13
  * absent code gets a generic sentence, a known one its fixed words.
14
14
  */
15
15
  const types_1 = require("@camstack/types");
16
16
  const app_page_shell_js_1 = require("./app-page-shell.js");
17
- const GENERIC_REFUSAL = 'The sign-in request was refused.';
17
+ const app_page_strings_js_1 = require("./app-page-strings.js");
18
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;
19
+ function appRefusedPage(reason, lang) {
20
+ const t = (0, app_page_strings_js_1.appStrings)(lang).refused;
21
+ const message = (0, types_1.isAppStartRefusal)(reason) ? t.reasons[reason] : t.generic;
21
22
  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.'),
23
+ lang,
24
+ title: t.title,
25
+ heading: t.title,
26
+ body: (0, app_page_shell_js_1.appParagraph)(message) + (0, app_page_shell_js_1.appHint)(t.hint),
25
27
  });
26
28
  }
27
29
  /** CSP of the refusal page: the shell's style only, nothing runs. */
@@ -32,6 +34,10 @@ function registerAppRefusedRoute(fastify) {
32
34
  reply.header('Referrer-Policy', 'no-referrer');
33
35
  reply.header('Content-Security-Policy', exports.APP_REFUSED_CSP);
34
36
  reply.type('text/html; charset=utf-8');
35
- return reply.status(400).send(appRefusedPage(request.query['reason']));
37
+ const lang = (0, app_page_strings_js_1.resolveAppPageLang)({
38
+ lang: request.query['lang'],
39
+ acceptLanguage: request.headers['accept-language'],
40
+ });
41
+ return reply.status(400).send(appRefusedPage(request.query['reason'], lang));
36
42
  });
37
43
  }
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ /**
3
+ * What a login session still HOLDS on the hub — its open WebSockets and its
4
+ * live media sessions — so that revoking the session ends them.
5
+ *
6
+ * A revoked `sid` is refused on the NEXT call (`ctx.sessionLive`), which is not
7
+ * the same as ending anything: the socket stayed open, the client kept polling
8
+ * as an anonymous principal and its stream kept playing until the app was
9
+ * killed (2026-10-16, "Sign out all my sessions"). The login-session table
10
+ * announces every revocation (`onSessionsRevoked`); this registry turns that
11
+ * into closed sockets and closed media, touching ONLY the sessions named.
12
+ *
13
+ * - A socket closes with {@link SESSION_ENDED_CLOSE_CODE} / {@link SESSION_ENDED_CLOSE_REASON}
14
+ * so the client can tell "your session ended" from a network drop.
15
+ * - A media hold is whatever the creating call registered as its way to end
16
+ * the session (the WebRTC `closeSession`); recorded playback rides the same
17
+ * cap, so the same hold covers it.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.SessionConnections = exports.SESSION_ENDED_CLOSE_REASON = exports.SESSION_ENDED_CLOSE_CODE = void 0;
21
+ exports.getSessionConnections = getSessionConnections;
22
+ exports.__resetSessionConnectionsForTests = __resetSessionConnectionsForTests;
23
+ /** WebSocket close code a client recognises as "the hub ended this session". */
24
+ exports.SESSION_ENDED_CLOSE_CODE = 4401;
25
+ exports.SESSION_ENDED_CLOSE_REASON = 'session-ended';
26
+ /** Bound on live media holds kept per login session (oldest dropped past it). */
27
+ const MAX_MEDIA_HOLDS_PER_SESSION = 256;
28
+ class SessionConnections {
29
+ sockets = new Set();
30
+ media = new Map();
31
+ logger = null;
32
+ setLogger(logger) {
33
+ this.logger = logger;
34
+ }
35
+ /** Track an authenticated socket; returns the untrack (call on socket close). */
36
+ trackSocket(socket, identity) {
37
+ const tracked = { socket, identity };
38
+ this.sockets.add(tracked);
39
+ return () => {
40
+ this.sockets.delete(tracked);
41
+ };
42
+ }
43
+ /** Track a live media session opened under `sessionId`; returns the untrack. */
44
+ trackMedia(sessionId, hold) {
45
+ const holds = this.media.get(sessionId) ?? new Map();
46
+ holds.set(hold.mediaSessionId, hold);
47
+ if (holds.size > MAX_MEDIA_HOLDS_PER_SESSION) {
48
+ const oldest = holds.keys().next();
49
+ if (!oldest.done)
50
+ holds.delete(oldest.value);
51
+ }
52
+ this.media.set(sessionId, holds);
53
+ return () => {
54
+ const current = this.media.get(sessionId);
55
+ current?.delete(hold.mediaSessionId);
56
+ if (current?.size === 0)
57
+ this.media.delete(sessionId);
58
+ };
59
+ }
60
+ /** A media session the client closed itself: nothing left to end. */
61
+ forgetMedia(sessionId, mediaSessionId) {
62
+ const current = this.media.get(sessionId);
63
+ current?.delete(mediaSessionId);
64
+ if (current?.size === 0)
65
+ this.media.delete(sessionId);
66
+ }
67
+ /** End what the revoked sessions hold. Sessions not named are never touched. */
68
+ endSessions(input) {
69
+ const named = new Set(input.sessionIds);
70
+ let sockets = 0;
71
+ for (const tracked of [...this.sockets]) {
72
+ const { sessionId, userId } = tracked.identity;
73
+ const hit = sessionId !== undefined
74
+ ? named.has(sessionId)
75
+ : input.allOfUser && input.userId !== null && userId === input.userId;
76
+ if (!hit)
77
+ continue;
78
+ this.sockets.delete(tracked);
79
+ try {
80
+ tracked.socket.close(exports.SESSION_ENDED_CLOSE_CODE, exports.SESSION_ENDED_CLOSE_REASON);
81
+ sockets += 1;
82
+ }
83
+ catch (error) {
84
+ this.logger?.warn('Closing a socket of an ended session failed', {
85
+ meta: { error: error instanceof Error ? error.message : String(error) },
86
+ });
87
+ }
88
+ }
89
+ let mediaSessions = 0;
90
+ for (const sessionId of named) {
91
+ const holds = this.media.get(sessionId);
92
+ if (holds === undefined)
93
+ continue;
94
+ this.media.delete(sessionId);
95
+ for (const hold of holds.values()) {
96
+ mediaSessions += 1;
97
+ void this.closeMedia(hold, input.reason);
98
+ }
99
+ }
100
+ if (sockets > 0 || mediaSessions > 0) {
101
+ this.logger?.info('Revoked sessions ended their connections', {
102
+ meta: { reason: input.reason, sessions: input.sessionIds.length, sockets, mediaSessions },
103
+ });
104
+ }
105
+ return { sockets, mediaSessions };
106
+ }
107
+ async closeMedia(hold, reason) {
108
+ try {
109
+ await hold.close();
110
+ this.logger?.info('Media session closed by session revocation', {
111
+ tags: { deviceId: hold.deviceId },
112
+ meta: { mediaSessionId: hold.mediaSessionId, reason },
113
+ });
114
+ }
115
+ catch (error) {
116
+ this.logger?.warn('Closing a media session of an ended session failed', {
117
+ tags: { deviceId: hold.deviceId },
118
+ meta: {
119
+ mediaSessionId: hold.mediaSessionId,
120
+ error: error instanceof Error ? error.message : String(error),
121
+ },
122
+ });
123
+ }
124
+ }
125
+ }
126
+ exports.SessionConnections = SessionConnections;
127
+ let instance = null;
128
+ /** The process-wide registry (the WS factory, the WebRTC wrapper and the revoke listener share it). */
129
+ function getSessionConnections() {
130
+ instance ??= new SessionConnections();
131
+ return instance;
132
+ }
133
+ /** Test-only. */
134
+ function __resetSessionConnectionsForTests() {
135
+ instance = null;
136
+ }
@@ -82,7 +82,13 @@ async function finishSsoApp(bridge, deps) {
82
82
  const parked = deps.confirmations.create({
83
83
  userId: record.id,
84
84
  username: record.username,
85
- app: { codeChallenge: app.codeChallenge, state: app.state, purpose: app.purpose, redirect },
85
+ app: {
86
+ codeChallenge: app.codeChallenge,
87
+ state: app.state,
88
+ purpose: app.purpose,
89
+ redirect,
90
+ ...(app.lang !== undefined ? { lang: app.lang } : {}),
91
+ },
86
92
  authMethod: 'sso',
87
93
  });
88
94
  return { kind: 'confirm', ...parked };
package/dist/main.js CHANGED
@@ -80,6 +80,7 @@ const core_cap_bridge_1 = require("./api/trpc/core-cap-bridge");
80
80
  const http_request_census_singleton_1 = require("./api/trpc/http-request-census.singleton");
81
81
  const http_request_census_hook_1 = require("./api/trpc/http-request-census-hook");
82
82
  const trpc_context_1 = require("./api/trpc/trpc.context");
83
+ const session_connections_js_1 = require("./auth/session-connections.js");
83
84
  const trpc_router_1 = require("./api/trpc/trpc.router");
84
85
  const trpc_error_device_tags_1 = require("./api/trpc/trpc-error-device-tags");
85
86
  const trpc_error_principal_1 = require("./api/trpc/trpc-error-principal");
@@ -1356,6 +1357,19 @@ async function bootstrap() {
1356
1357
  (0, ws_request_census_1.attachWsCensusSession)(httpRequestCensus, client, req, app.get(logging_service_1.LoggingService).createLogger('tRPC:ws'));
1357
1358
  wsSlowConsumerGuard.attach(client, (0, ws_slow_consumer_guard_js_1.wsConnectionFacts)(req));
1358
1359
  });
1360
+ // Revoking a login session ends what it holds: its sockets (close 4401
1361
+ // `session-ended`) and its live media. See `auth/session-connections.ts`.
1362
+ const sessionConnections = (0, session_connections_js_1.getSessionConnections)();
1363
+ sessionConnections.setLogger(app.get(logging_service_1.LoggingService).createLogger('session-revocation'));
1364
+ (0, system_1.onSessionsRevoked)((event) => {
1365
+ sessionConnections.endSessions({
1366
+ sessionIds: event.sessionIds,
1367
+ userId: event.userId,
1368
+ allOfUser: event.allOfUser,
1369
+ exceptSessionId: event.exceptSessionId,
1370
+ reason: event.reason,
1371
+ });
1372
+ });
1359
1373
  (0, ws_1.applyWSSHandler)({
1360
1374
  wss,
1361
1375
  router: appRouter,
@@ -1376,6 +1390,23 @@ async function bootstrap() {
1376
1390
  const principal = (0, trpc_error_principal_1.describeTrpcPrincipal)(wsCtx.user);
1377
1391
  (0, ws_request_census_1.identifyWsCensusSession)(opts.res, principal);
1378
1392
  wsSlowConsumerGuard.identify(opts.res, principal);
1393
+ const connectionLogger = app.get(logging_service_1.LoggingService).createLogger('tRPC:ws');
1394
+ if (wsCtx.credentialRejected === true) {
1395
+ // A bearer that resolves to nobody (revoked, expired): say so with
1396
+ // the close code the client recognises, instead of leaving an
1397
+ // anonymous socket open for the client to poll.
1398
+ connectionLogger.info('Closing a socket whose credential was refused', {
1399
+ meta: { principal, code: session_connections_js_1.SESSION_ENDED_CLOSE_CODE },
1400
+ });
1401
+ opts.res.close(session_connections_js_1.SESSION_ENDED_CLOSE_CODE, session_connections_js_1.SESSION_ENDED_CLOSE_REASON);
1402
+ }
1403
+ else if (wsCtx.user !== null) {
1404
+ const untrack = sessionConnections.trackSocket(opts.res, {
1405
+ userId: wsCtx.user.id,
1406
+ sessionId: wsCtx.user.sessionId,
1407
+ });
1408
+ opts.res.once('close', untrack);
1409
+ }
1379
1410
  return wsCtx;
1380
1411
  },
1381
1412
  onError: ({ path: trpcPath, error, ctx, input, }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.579",
3
+ "version": "1.2.580",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
@@ -38,19 +38,19 @@
38
38
  ]
39
39
  },
40
40
  "dependencies": {
41
- "@camstack/addon-admin-ui": "1.2.427",
42
- "@camstack/addon-agent-ui": "1.2.234",
43
- "@camstack/addon-auth": "1.2.146",
41
+ "@camstack/addon-admin-ui": "1.2.428",
42
+ "@camstack/addon-agent-ui": "1.2.235",
43
+ "@camstack/addon-auth": "1.2.147",
44
44
  "@camstack/addon-decoder-nodeav": "1.2.123",
45
45
  "@camstack/addon-notifiers": "1.2.134",
46
- "@camstack/addon-pipeline": "1.2.369",
46
+ "@camstack/addon-pipeline": "1.2.370",
47
47
  "@camstack/addon-pipeline-orchestrator": "1.2.323",
48
48
  "@camstack/addon-post-analysis": "1.2.369",
49
49
  "@camstack/develop": "0.1.11",
50
50
  "@camstack/sdk": "1.2.131",
51
- "@camstack/system": "1.2.436",
52
- "@camstack/types": "1.2.372",
53
- "@camstack/ui-library": "1.2.290",
51
+ "@camstack/system": "1.2.437",
52
+ "@camstack/types": "1.2.373",
53
+ "@camstack/ui-library": "1.2.291",
54
54
  "@fastify/compress": "^9.0.0",
55
55
  "@fastify/cookie": "^11.0.2",
56
56
  "@fastify/cors": "^11.2.0",