@camstack/server 1.2.605 → 1.2.607

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 (35) hide show
  1. package/dist/api/auth-whoami.js +0 -1
  2. package/dist/api/core/auth-audit.procedures.js +44 -0
  3. package/dist/api/core/auth-revoke-credentials.procedures.js +4 -78
  4. package/dist/api/core/auth-tokens.procedures.js +105 -72
  5. package/dist/api/core/auth.router.js +14 -280
  6. package/dist/api/core/cap-providers.js +38 -1
  7. package/dist/api/core/integration-secrets.js +22 -1
  8. package/dist/api/core/logs.router.js +219 -49
  9. package/dist/api/core/preferences.router.js +98 -0
  10. package/dist/api/oauth2/oauth2-routes.js +40 -15
  11. package/dist/api/trpc/device-config-secret-redaction.js +2 -2
  12. package/dist/api/trpc/generated-cap-mounts.js +0 -1
  13. package/dist/api/trpc/generated-cap-routers.js +53 -42
  14. package/dist/api/trpc/principal-visibility.js +2 -2
  15. package/dist/api/trpc/share-view-access.js +1 -1
  16. package/dist/api/trpc/trpc-error-principal.js +1 -2
  17. package/dist/api/trpc/trpc.context.js +16 -13
  18. package/dist/api/trpc/trpc.router.js +7 -2
  19. package/dist/api/upload-auth.js +13 -25
  20. package/dist/auth/addon-route-share-gate.js +2 -2
  21. package/dist/auth/credential-verifier.js +63 -104
  22. package/dist/auth/embed-auth-route.js +45 -0
  23. package/dist/auth/live-user.js +19 -0
  24. package/dist/auth/scope-escalation.js +77 -2
  25. package/dist/boot/boot-config.js +8 -1
  26. package/dist/core/addon/addon-registry.service.js +14 -20
  27. package/dist/core/auth/credential-token.service.js +9 -14
  28. package/dist/core/auth/handoff-code.service.js +1 -1
  29. package/dist/core/auth/record-kinds.js +7 -2
  30. package/dist/core/logging/log-history.js +119 -0
  31. package/dist/core/logging/logging.service.js +38 -1
  32. package/dist/main.js +18 -48
  33. package/package.json +14 -14
  34. package/dist/api/core/auth-admin-tokens.procedures.js +0 -156
  35. package/dist/core/auth/share-token.service.js +0 -459
@@ -1,8 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.scopesWithin = scopesWithin;
4
+ exports.intersectScopes = intersectScopes;
4
5
  const types_1 = require("@camstack/types");
5
6
  const principal_visibility_js_1 = require("../api/trpc/principal-visibility.js");
7
+ const scope_access_js_1 = require("../api/trpc/scope-access.js");
8
+ /**
9
+ * The method a share link's talk-back opens first. Holding it on a device is
10
+ * what lets a caller hand talk-back on that device to whoever holds the link.
11
+ */
12
+ const TALK_BACK_PATH = 'intercom.startSession';
13
+ /** May the holder of `held` talk back on every device of `grant`? */
14
+ function canTalkOn(held, deviceIds, lookup) {
15
+ return deviceIds.every((deviceId) => (0, scope_access_js_1.checkScopeAccess)(held, TALK_BACK_PATH, { deviceId }, lookup).ok);
16
+ }
6
17
  function accessCovered(asked, held) {
7
18
  return asked.every((a) => held.includes(a));
8
19
  }
@@ -24,6 +35,8 @@ function describe(scope) {
24
35
  return `share-view:${scope.grant.kind}`;
25
36
  if (scope.type === 'addon-routes')
26
37
  return `addon-routes[${scope.access.join(',')}]`;
38
+ if (scope.type === 'preferences-self')
39
+ return 'preferences-self';
27
40
  if (scope.type === 'device')
28
41
  return `device[${scope.access.join(',')}]`;
29
42
  return `${scope.type}:${scope.target}[${scope.access.join(',')}]`;
@@ -41,14 +54,23 @@ function scopesWithin(requested, held, lookup) {
41
54
  if (viewable.length !== scope.grant.deviceIds.length) {
42
55
  return { ok: false, reason: 'a share-view grant names a device you cannot view' };
43
56
  }
57
+ if (scope.grant.talk === true && !canTalkOn(heldCapability, scope.grant.deviceIds, lookup)) {
58
+ return { ok: false, reason: 'a share-view grant asks talk-back on a device you cannot talk on' };
59
+ }
44
60
  continue;
45
61
  }
46
- if (scope.type === 'addon-routes') {
47
- const covered = held.some((h) => h !== types_1.ADMIN_SCOPE && h.type === 'addon-routes' && accessCovered(scope.access, h.access));
62
+ if (scope.type === 'preferences-self') {
63
+ const covered = held.some((h) => h !== types_1.ADMIN_SCOPE && h.type === 'preferences-self');
48
64
  if (!covered)
49
65
  return { ok: false, reason: `you do not hold ${describe(scope)}` };
50
66
  continue;
51
67
  }
68
+ if (scope.type === 'addon-routes') {
69
+ return {
70
+ ok: false,
71
+ reason: `only an admin can mint ${describe(scope)} — name the addon instead (addon:<id>)`,
72
+ };
73
+ }
52
74
  const key = grantKey(scope);
53
75
  const covered = heldCapability.some((h) => grantKey(h) === key && accessCovered(scope.access, h.access));
54
76
  if (!covered)
@@ -56,3 +78,56 @@ function scopesWithin(requested, held, lookup) {
56
78
  }
57
79
  return { ok: true };
58
80
  }
81
+ /** A share-view grant cut down to what `held` may still see (and talk on). `null` = nothing left. */
82
+ function narrowShareGrant(grant, held, lookup) {
83
+ const deviceIds = (0, principal_visibility_js_1.filterViewableDeviceIds)(held, grant.deviceIds, lookup);
84
+ if (deviceIds.length === 0)
85
+ return null;
86
+ const talk = grant.talk === true && canTalkOn(held, deviceIds, lookup);
87
+ return {
88
+ kind: grant.kind,
89
+ deviceIds,
90
+ ...(grant.talk !== undefined ? { talk } : {}),
91
+ };
92
+ }
93
+ /**
94
+ * `token`'s scopes cut down to what `held` — its non-admin subject's scopes
95
+ * NOW — still covers (security review 2026-10-21): an admin who narrows a user
96
+ * narrows every token that user minted, at the next verify, rather than
97
+ * leaving the token the reach the user no longer has.
98
+ *
99
+ * - a capability grant keeps the flavours the subject still holds on the
100
+ * SAME grant, and goes when none is left;
101
+ * - a share-view grant keeps the devices the subject can still view (and
102
+ * talk-back only where it can still talk);
103
+ * - `'*'` and `addon-routes` are admin-only and never survive here.
104
+ *
105
+ * Never widens: every output scope is covered by an input scope.
106
+ */
107
+ function intersectScopes(token, held, lookup) {
108
+ const out = [];
109
+ for (const scope of token) {
110
+ if (scope === types_1.ADMIN_SCOPE || scope.type === 'addon-routes')
111
+ continue;
112
+ if (scope.type === 'share-view') {
113
+ const grant = narrowShareGrant(scope.grant, held, lookup);
114
+ if (grant !== null)
115
+ out.push({ type: 'share-view', grant });
116
+ continue;
117
+ }
118
+ // The subject's OWN preferences (D894): every person holds it, so a live
119
+ // subject never loses it — except on a share link. A share-view grant cut
120
+ // to nothing must not leave a preferences token behind.
121
+ if (scope.type === 'preferences-self') {
122
+ if (!token.some((s) => s !== types_1.ADMIN_SCOPE && s.type === 'share-view'))
123
+ out.push(scope);
124
+ continue;
125
+ }
126
+ const key = grantKey(scope);
127
+ const heldAccess = new Set(held.filter((h) => grantKey(h) === key).flatMap((h) => [...h.access]));
128
+ const access = scope.access.filter((a) => heldAccess.has(a));
129
+ if (access.length > 0)
130
+ out.push({ ...scope, access });
131
+ }
132
+ return out;
133
+ }
@@ -43,6 +43,7 @@ exports.retiredSecretSources = retiredSecretSources;
43
43
  exports.loadBootstrapConfig = loadBootstrapConfig;
44
44
  exports.setupInfra = setupInfra;
45
45
  const fs = __importStar(require("node:fs"));
46
+ const os = __importStar(require("node:os"));
46
47
  const path = __importStar(require("node:path"));
47
48
  const types_1 = require("@camstack/types");
48
49
  const yaml = __importStar(require("js-yaml"));
@@ -241,7 +242,13 @@ async function setupInfra(_configPath, bootstrapConfig) {
241
242
  // The certificate comes only from the admin UI (D726): the generated
242
243
  // local CA → leaf (D227), or an operator upload served verbatim.
243
244
  const extraSans = [...core.readExtraSans(dataPath)];
244
- const boot = await core.prepareBootTls(dataPath, extraSans.length > 0 ? { extraSans } : undefined);
245
+ // A local root CREATED at this boot is named after the hub. The stored name (D861)
246
+ // lives in the settings database, which is not open yet, so this is the name the
247
+ // hub shows while unnamed: the hostname. An existing root is never renamed.
248
+ const boot = await core.prepareBootTls(dataPath, {
249
+ hubName: (0, types_1.shortHostname)(os.hostname()),
250
+ ...(extraSans.length > 0 ? { extraSans } : {}),
251
+ });
245
252
  if (boot.uploadedMissing) {
246
253
  console.warn(`[Phase2c] WARN the uploaded TLS certificate is missing from ${path.join(dataPath, 'tls')} — serving the generated one. Upload it again in Network → Local access.`);
247
254
  }
@@ -390,7 +390,6 @@ class AddonRegistryService {
390
390
  integrationRegistry = null;
391
391
  clusterNodeHistoryStore = null;
392
392
  clusterAgentRecords = null;
393
- shareTokenService = null;
394
393
  credentialTokenService = null;
395
394
  // Device architecture — in-memory registry of live IDevice instances.
396
395
  // Persistence is owned by the `device-manager` capability addon.
@@ -1379,10 +1378,7 @@ class AddonRegistryService {
1379
1378
  const kind = records.kind(system_1.clusterNodeHistoryRecordKind);
1380
1379
  clusterStore.bindRecordsKind(() => kind);
1381
1380
  }
1382
- setShareTokenService(service) {
1383
- this.shareTokenService = service;
1384
- }
1385
- /** The unified token store (D892), bound to its `core-auth` records with the share links. */
1381
+ /** The unified token store (D892), bound to its `core-auth` records. */
1386
1382
  setCredentialTokenService(service) {
1387
1383
  this.credentialTokenService = service;
1388
1384
  }
@@ -1399,24 +1395,22 @@ class AddonRegistryService {
1399
1395
  await clusterRecords.declare(system_1.clusterAgentRecordKinds);
1400
1396
  this.clusterAgentRecords = clusterRecords;
1401
1397
  this.bindClusterNodeHistoryRecordsKind();
1402
- const shareTokens = this.shareTokenService;
1403
- if (shareTokens !== null) {
1398
+ const credentialTokens = this.credentialTokenService;
1399
+ if (credentialTokens !== null) {
1404
1400
  const coreRecords = this.recordsForOwner('core-auth', this.secretsForOwner('core-auth'));
1401
+ // Every core-auth kind is declared, `share-view-tokens` included: its
1402
+ // pre-D895 rows stay on disk for the operator to drop, and nothing reads
1403
+ // them.
1405
1404
  await coreRecords.declare(record_kinds_js_1.coreAuthRecordKinds);
1406
- const shareKind = coreRecords.kind(record_kinds_js_1.coreAuthRecordKinds['share-view-tokens']);
1407
- shareTokens.bindShareTokensKind(() => shareKind);
1408
- const credentialTokens = this.credentialTokenService;
1409
- if (credentialTokens !== null) {
1410
- const tokenKind = coreRecords.kind(record_kinds_js_1.coreAuthRecordKinds['credential-tokens']);
1411
- credentialTokens.bindKind(() => tokenKind);
1412
- // Dead rows (expired or revoked > 30 days) go at boot; the verifier
1413
- // already refuses them, so this is housekeeping, never a gate.
1414
- void credentialTokens.reap().catch((error) => {
1415
- this.logger.warn('Token reap failed', {
1416
- meta: { error: error instanceof Error ? error.message : String(error) },
1417
- });
1405
+ const tokenKind = coreRecords.kind(record_kinds_js_1.coreAuthRecordKinds['credential-tokens']);
1406
+ credentialTokens.bindKind(() => tokenKind);
1407
+ // Dead rows (expired or revoked > 30 days) go at boot; the verifier
1408
+ // already refuses them, so this is housekeeping, never a gate.
1409
+ void credentialTokens.reap().catch((error) => {
1410
+ this.logger.warn('Token reap failed', {
1411
+ meta: { error: error instanceof Error ? error.message : String(error) },
1418
1412
  });
1419
- }
1413
+ });
1420
1414
  }
1421
1415
  }
1422
1416
  /**
@@ -160,7 +160,10 @@ class CredentialTokenService {
160
160
  return null;
161
161
  const lastUsedAt = row.data.lastUsedAt ?? null;
162
162
  if (lastUsedAt === null || at - lastUsedAt > TOUCH_EVERY_MS) {
163
- void this.write(row.id, { ...row.data, lastUsedAt: at }).catch(() => { });
163
+ // ONLY `lastUsedAt`, merged into the row as it is when the write lands.
164
+ // Writing back the row read above (un-awaited) could land after a
165
+ // revoke / an expiry move and undo it (security review 2026-10-21).
166
+ void kind.patch(row.id, { lastUsedAt: at }).catch(() => { });
164
167
  }
165
168
  return {
166
169
  id: row.id,
@@ -199,11 +202,11 @@ class CredentialTokenService {
199
202
  if (row === null || (row.data.revokedAt !== null && row.data.revokedAt !== undefined)) {
200
203
  return false;
201
204
  }
202
- await this.write(id, { ...row.data, revokedAt: this.now() });
205
+ await this.kind().patch(id, { revokedAt: this.now() });
203
206
  this.logger?.info('Token revoked', { meta: { tokenId: id, subjectId: row.data.subjectId } });
204
207
  return true;
205
208
  }
206
- /** Move a live token's expiry (a share link's slide). `null` when not live. */
209
+ /** Move a live token's expiry (`null`: never). `null` when not live. */
207
210
  async setExpiry(id, expiresAt) {
208
211
  const row = await this.kind().get(id);
209
212
  if (row === null)
@@ -211,11 +214,10 @@ class CredentialTokenService {
211
214
  const at = this.now();
212
215
  const dead = (row.data.revokedAt !== null && row.data.revokedAt !== undefined) ||
213
216
  (row.data.expiresAt !== null && row.data.expiresAt <= at);
214
- if (dead || expiresAt <= at)
217
+ if (dead || (expiresAt !== null && expiresAt <= at))
215
218
  return null;
216
- const next = { ...row.data, expiresAt };
217
- await this.write(id, next);
218
- return toRecord(id, next);
219
+ await this.kind().patch(id, { expiresAt }, { expiresAt });
220
+ return toRecord(id, { ...row.data, expiresAt });
219
221
  }
220
222
  /** Revoke every live token of `subjectId` (the user was deleted). */
221
223
  async revokeAllFor(subjectId) {
@@ -239,12 +241,5 @@ class CredentialTokenService {
239
241
  }
240
242
  return deleted;
241
243
  }
242
- async write(id, body) {
243
- await this.kind().put({
244
- id,
245
- ...(body.expiresAt !== null ? { expiresAt: body.expiresAt } : {}),
246
- data: body,
247
- });
248
- }
249
244
  }
250
245
  exports.CredentialTokenService = CredentialTokenService;
@@ -60,7 +60,7 @@ const types_1 = require("@camstack/types");
60
60
  * prune-on-mint keeps the map from growing.
61
61
  */
62
62
  const crypto = __importStar(require("node:crypto"));
63
- /** Wire prefix — `chc_` = CamStack Handoff Code (cf. `cst_`/`csv_`). */
63
+ /** Wire prefix — `chc_` = CamStack Handoff Code (not a credential: it is redeemed once for a session). */
64
64
  exports.HANDOFF_CODE_PREFIX = 'chc_';
65
65
  exports.HANDOFF_CODE_TTL_MS = 60_000;
66
66
  /** Hard bound on concurrently-pending codes (mint is auth-gated, so this
@@ -12,6 +12,11 @@ const ShareTokenScopeSchema = zod_1.z.object({
12
12
  talk: zod_1.z.boolean().optional(),
13
13
  });
14
14
  exports.SHARE_VIEW_TOKENS_LEGACY = 'share_view_tokens';
15
+ /**
16
+ * The pre-D892 share-link rows. Nothing writes or reads them since D895 (a
17
+ * share link is a `credential-tokens` row); the kind stays declared so the
18
+ * storage layout is unchanged and the operator drops the rows by hand.
19
+ */
15
20
  const ShareViewTokenBodySchema = zod_1.z.object({
16
21
  userId: zod_1.z.string(),
17
22
  tokenHash: zod_1.z.string(),
@@ -41,14 +46,14 @@ exports.coreAuthRecordKinds = (0, types_1.defineRecordKinds)({
41
46
  'share-view-tokens': {
42
47
  schema: ShareViewTokenBodySchema,
43
48
  class: 'config',
44
- writeTrigger: 'operator mints a share link; validate touches lastUsedAt',
49
+ writeTrigger: 'none since D895 — legacy rows kept on disk for the operator to drop',
45
50
  device: 'none',
46
51
  at: 'none',
47
52
  indexes: [
48
53
  { name: 'hash', fields: ['tokenHash'], unique: true },
49
54
  { name: 'user', fields: ['userId'] },
50
55
  ],
51
- retention: { none: 'revoke only; expiry enforced at validate' },
56
+ retention: { none: 'operator drops the legacy rows by hand (D895)' },
52
57
  adoptsFrom: exports.SHARE_VIEW_TOKENS_LEGACY,
53
58
  },
54
59
  'credential-tokens': {
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_PAGE_LIMIT = exports.RING_LOG_SOURCE = exports.DEFAULT_LOG_SOURCE = void 0;
4
+ exports.readLogHistory = readLogHistory;
5
+ /**
6
+ * `logs.query` — one page of history from the source the operator picked,
7
+ * merged with the hub's in-memory tail (D898).
8
+ *
9
+ * The tail is short (a couple of thousand lines) and exists for two things:
10
+ * the boot replay into destinations that register late, and the lines a file
11
+ * destination has accepted but not yet flushed. Everything older comes from
12
+ * the source. Both carry the same `id`, so the merge dedupes exactly.
13
+ */
14
+ const system_1 = require("@camstack/system");
15
+ /** The default history source: the hub's rotated files. */
16
+ exports.DEFAULT_LOG_SOURCE = 'winston-logging';
17
+ /** The in-memory tail, addressed as a source when no destination can read. */
18
+ exports.RING_LOG_SOURCE = 'ring';
19
+ exports.DEFAULT_PAGE_LIMIT = 200;
20
+ function toReadable(entry) {
21
+ return {
22
+ timestamp: entryTime(entry),
23
+ level: entry.level,
24
+ message: entry.message,
25
+ scope: entry.scope,
26
+ tags: entry.tags,
27
+ meta: entry.meta,
28
+ };
29
+ }
30
+ function entryTime(entry) {
31
+ const ts = entry.timestamp;
32
+ if (ts instanceof Date)
33
+ return ts.getTime();
34
+ if (typeof ts === 'number')
35
+ return ts;
36
+ if (typeof ts === 'string')
37
+ return Date.parse(ts);
38
+ return 0;
39
+ }
40
+ function toReadEntry(entry) {
41
+ const tags = {};
42
+ for (const [key, value] of Object.entries(entry.tags ?? {})) {
43
+ if (typeof value === 'string' || typeof value === 'number')
44
+ tags[key] = value;
45
+ }
46
+ const level = entry.level;
47
+ return {
48
+ ...(entry.id !== undefined ? { id: entry.id } : {}),
49
+ timestamp: entryTime(entry),
50
+ level,
51
+ ...(entry.scope !== undefined ? { scope: entry.scope } : {}),
52
+ message: entry.message,
53
+ ...(Object.keys(tags).length > 0 ? { tags } : {}),
54
+ ...(entry.meta !== undefined ? { meta: entry.meta } : {}),
55
+ };
56
+ }
57
+ function tailMatching(tail, filter, limit) {
58
+ const out = [];
59
+ for (const entry of tail) {
60
+ if (!(0, system_1.lineMatches)(toReadable(entry), filter))
61
+ continue;
62
+ out.push(toReadEntry(entry));
63
+ if (out.length >= limit)
64
+ break;
65
+ }
66
+ return out;
67
+ }
68
+ function entryKey(entry) {
69
+ return (entry.id ?? `${String(entry.timestamp)}|${entry.level}|${entry.scope ?? ''}|${entry.message}`);
70
+ }
71
+ /**
72
+ * The first page (no cursor) merges the tail; a cursor page is the source's
73
+ * alone — the tail only ever holds the NEWEST lines, which the first page
74
+ * already showed.
75
+ */
76
+ async function readLogHistory(args) {
77
+ const { query, tail, provider } = args;
78
+ const limit = query.limit ?? exports.DEFAULT_PAGE_LIMIT;
79
+ const filter = (0, system_1.compileLogFilter)(query);
80
+ const isFirstPage = query.before === undefined && query.after === undefined;
81
+ if (provider === null || provider.read === undefined) {
82
+ if (!isFirstPage) {
83
+ return {
84
+ entries: [],
85
+ nextBefore: null,
86
+ nextAfter: null,
87
+ scannedBytes: 0,
88
+ done: true,
89
+ source: exports.RING_LOG_SOURCE,
90
+ };
91
+ }
92
+ return {
93
+ entries: tailMatching(tail, filter, limit),
94
+ nextBefore: null,
95
+ nextAfter: null,
96
+ scannedBytes: 0,
97
+ done: true,
98
+ source: exports.RING_LOG_SOURCE,
99
+ };
100
+ }
101
+ const page = await provider.read({ ...query, limit });
102
+ if (!isFirstPage)
103
+ return { ...page, source: args.source };
104
+ // Tail lines newer than the oldest line the source returned (or all of them
105
+ // when the source returned nothing yet): the unflushed lines, and the levels
106
+ // the file destination does not keep.
107
+ const oldest = page.entries.at(-1)?.timestamp ?? Number.NEGATIVE_INFINITY;
108
+ const byKey = new Map();
109
+ for (const entry of page.entries)
110
+ byKey.set(entryKey(entry), entry);
111
+ for (const entry of tailMatching(tail, filter, limit)) {
112
+ if (entry.timestamp < oldest)
113
+ continue;
114
+ if (!byKey.has(entryKey(entry)))
115
+ byKey.set(entryKey(entry), entry);
116
+ }
117
+ const entries = [...byKey.values()].toSorted((a, b) => b.timestamp - a.timestamp);
118
+ return { ...page, entries, source: args.source };
119
+ }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LoggingService = void 0;
3
+ exports.LoggingService = exports.HUB_LOG_TAIL_ENTRIES = exports.HISTORY_DESTINATION_ADDON_ID = void 0;
4
4
  const system_1 = require("@camstack/system");
5
5
  const types_1 = require("@camstack/types");
6
6
  /** Baseline tags every hub-local logger gets, so console output never
@@ -13,6 +13,19 @@ const types_1 = require("@camstack/types");
13
13
  * forked workers via `$hub.log` carry their own `tags.pid` which
14
14
  * wins over this baseline (right-biased merge in `writeFromWorker`). */
15
15
  const HUB_BASELINE_TAGS = { agentId: 'hub', nodeId: 'hub', pid: process.pid };
16
+ /**
17
+ * The destination whose files ARE the hub's log history (D898). Once it has
18
+ * registered — and received the boot replay — the in-memory buffer only has
19
+ * to hold a short tail: the instant first page and the lines the file stream
20
+ * has accepted but not yet flushed.
21
+ */
22
+ exports.HISTORY_DESTINATION_ADDON_ID = 'winston-logging';
23
+ /**
24
+ * The tail, in entries, across every addon. ~2k lines is tens of seconds of a
25
+ * busy hub — enough for the first page and the unflushed window, ~0.6 MB.
26
+ * `eventBus.logTailSize` overrides it.
27
+ */
28
+ exports.HUB_LOG_TAIL_ENTRIES = 2000;
16
29
  class LoggingService extends system_1.LogManager {
17
30
  /**
18
31
  * Device-name cache consulted by the formatter when a log line
@@ -21,6 +34,9 @@ class LoggingService extends system_1.LogManager {
21
34
  * updated / removed events. Missing entries fall back to `#<id>`.
22
35
  */
23
36
  deviceNames = new Map();
37
+ /** Tail size applied once the history destination registers. */
38
+ tailEntries;
39
+ shrunkToTail = false;
24
40
  constructor(configService) {
25
41
  // The log buffer is now partitioned per addonId (see PartitionedLogBuffer):
26
42
  // this value caps EACH addon's bucket, not the total. A chatty addon evicts
@@ -39,12 +55,33 @@ class LoggingService extends system_1.LogManager {
39
55
  // Only entries at or below this level are ever discarded to meet the total.
40
56
  const pruneLevel = configService.get('eventBus.logBufferPruneLevel') ?? 'debug';
41
57
  super(perAddonCapacity, { maxTotalEntries, pruneLevel });
58
+ this.tailEntries = configService.get('eventBus.logTailSize') ?? exports.HUB_LOG_TAIL_ENTRIES;
42
59
  // Enriches every emitted LogEntry with `tags.deviceName` before
43
60
  // destinations / subscribers see it — works across bundled copies
44
61
  // of `@camstack/system` (addon packages) because the mutation
45
62
  // happens upstream of their formatters.
46
63
  this.setDeviceNameLookup((id) => this.deviceNames.get(id) ?? null);
47
64
  }
65
+ /**
66
+ * Adds the destination, and — the first time the HISTORY destination
67
+ * arrives — shrinks the buffer to the tail AFTER its boot replay, so the
68
+ * files start with the first boot line however long the boot was.
69
+ *
70
+ * The tail prunes `info` and below first (the lines are on disk); a live
71
+ * addon's `warn`/`error` still outlive them, and whole idle buckets go
72
+ * last, exactly as the buffer's own policy says.
73
+ */
74
+ addDestination(dest, opts = {}) {
75
+ super.addDestination(dest, opts);
76
+ if (!this.shrunkToTail && opts.addonId === exports.HISTORY_DESTINATION_ADDON_ID) {
77
+ this.shrunkToTail = true;
78
+ this.reconfigureBuffer({
79
+ perAddonCapacity: this.tailEntries,
80
+ maxTotalEntries: this.tailEntries,
81
+ pruneLevel: 'info',
82
+ });
83
+ }
84
+ }
48
85
  /** Bulk-refresh from device-manager snapshot. Replaces every entry. */
49
86
  setDeviceNames(entries) {
50
87
  this.deviceNames.clear();
package/dist/main.js CHANGED
@@ -71,6 +71,8 @@ const runner_boot_check_1 = require("./core/runner-health/runner-boot-check");
71
71
  const hub_cluster_url_1 = require("./api/hub-cluster-url");
72
72
  const model_distributor_js_1 = require("./api/model-distributor.js");
73
73
  const oauth2_routes_js_1 = require("./api/oauth2/oauth2-routes.js");
74
+ const embed_auth_route_js_1 = require("./auth/embed-auth-route.js");
75
+ const live_user_js_1 = require("./auth/live-user.js");
74
76
  const server_upload_1 = require("./api/server-upload");
75
77
  const server_upload_propagate_1 = require("./api/server-upload-propagate");
76
78
  const sse_no_compression_js_1 = require("./api/sse-no-compression.js");
@@ -122,7 +124,6 @@ const well_known_app_links_1 = require("./core/app-links/well-known-app-links");
122
124
  const auth_service_1 = require("./core/auth/auth.service");
123
125
  const handoff_code_service_1 = require("./core/auth/handoff-code.service");
124
126
  const credential_token_service_js_1 = require("./core/auth/credential-token.service.js");
125
- const share_token_service_1 = require("./core/auth/share-token.service");
126
127
  const config_service_1 = require("./core/config/config.service");
127
128
  const event_bus_service_1 = require("./core/events/event-bus.service");
128
129
  const feature_service_1 = require("./core/feature/feature.service");
@@ -438,8 +439,8 @@ async function bootstrap() {
438
439
  const sessionLifetimes = () => (0, session_lifetimes_js_1.readSessionLifetimes)((key) => config.get(key));
439
440
  // Register addon upload route (multipart — must be registered before tRPC).
440
441
  // We pass AddonRegistryService so the handler can resolve the
441
- // `user-management` cap singleton at request time (the only working
442
- // path to validate `cst_*` scoped tokens — see addon-upload.ts).
442
+ // `user-management` cap singleton at request time (the credential
443
+ // verifier reads users and admin bits there — see upload-auth.ts).
443
444
  try {
444
445
  const uploadAuthService = app.get(auth_service_1.AuthService);
445
446
  const uploadAddonBridge = app.get(addon_bridge_service_1.AddonBridgeService);
@@ -473,8 +474,8 @@ async function bootstrap() {
473
474
  .getSingleton('local-network')),
474
475
  });
475
476
  console.log('[bootstrap] Server upload propagate route registered at POST /api/server-upload/propagate');
476
- // Companion endpoint: /api/auth/whoami — validates JWT or cst_*
477
- // scoped tokens, returns the resolved identity + scope summary.
477
+ // Companion endpoint: /api/auth/whoami — verifies any hub credential
478
+ // through the one verifier, returns the resolved identity + scopes.
478
479
  // Mirrors the addon-upload auth chain so the CLI can ping for
479
480
  // token-still-valid without consuming a real cap.
480
481
  await (0, auth_whoami_1.registerAuthWhoamiRoute)(fastify, uploadAuthService, uploadAddonRegistry);
@@ -596,17 +597,13 @@ async function bootstrap() {
596
597
  // and the distributed metrics-provider capability — no manual wiring needed.
597
598
  let trpcRegistered = false;
598
599
  let appRouter = null;
599
- // Share-view (`csv_*`) tokens — hub-resident mint/verify/revoke over the
600
- // settings backend (lazy getter: the backend lands after the sqlite-storage
601
- // builtin registers; the service resolves it per call).
602
- const shareTokenService = new share_token_service_1.ShareTokenService(() => addonRegistry.getSettingsBackend(), loggingService.createLogger('share-tokens'));
603
- addonRegistry.setShareTokenService(shareTokenService);
604
- // The unified token store (D892): rows in the `core-auth` records, bound
605
- // with the share links; the JWT is signed with the session signing key.
600
+ // The one token store (D892, D895): rows in the `core-auth` records — admin
601
+ // tokens, scoped tokens and share links alike; the JWT is signed with the
602
+ // session signing key.
606
603
  const credentialTokenService = new credential_token_service_js_1.CredentialTokenService(app.get(auth_service_1.AuthService), loggingService.createLogger('tokens'));
607
604
  addonRegistry.setCredentialTokenService(credentialTokenService);
608
605
  // Every surface that verifies a bearer reads the same stores (D892).
609
- (0, trpc_context_1.bindHubCredentialStores)({ shareTokens: shareTokenService, tokenRows: credentialTokenService });
606
+ (0, trpc_context_1.bindHubCredentialStores)({ tokenRows: credentialTokenService });
610
607
  // One-time native-app login handoff codes (in-memory, 60s TTL, single-use) —
611
608
  // minted by `auth.createHandoffCode`, redeemed by the viewer app via the
612
609
  // public `auth.redeemHandoffCode`.
@@ -638,7 +635,6 @@ async function bootstrap() {
638
635
  });
639
636
  const buildRouter = () => (0, trpc_router_1.buildAppRouter)({
640
637
  authService: app.get(auth_service_1.AuthService),
641
- shareTokenService,
642
638
  credentialTokenService,
643
639
  handoffCodeService,
644
640
  externalLinkIntents,
@@ -808,7 +804,7 @@ async function bootstrap() {
808
804
  trpcOptions: {
809
805
  router: appRouter,
810
806
  createContext: async ({ req }) => {
811
- const trpcCtx = await (0, trpc_context_1.createTrpcContext)(req, authService, addonRegistry, shareTokenService,
807
+ const trpcCtx = await (0, trpc_context_1.createTrpcContext)(req, authService, addonRegistry,
812
808
  // A bearer refused for NOT being an API credential never reaches a
813
809
  // procedure, so `onError` below never sees it — the refusal would
814
810
  // be silent without this. Same reason the addon gate logs (D103).
@@ -1047,37 +1043,11 @@ async function bootstrap() {
1047
1043
  reply.setCookie(c.name, c.value, c.options);
1048
1044
  return reply.send({ ok: true });
1049
1045
  });
1050
- // GET /api/embed-auth?next=<embed-path> — establish the browser session
1051
- // cookie from a Bearer token, then 302 to the embed. A native WebView can
1052
- // pass `Authorization` only on the INITIAL navigation, not on the page's
1053
- // asset sub-requests; the data-plane `authenticated` gate reads the cookie,
1054
- // so we mint it here (mirroring POST /api/auth/session) and bounce to the
1055
- // embed — page + assets then authenticate via the cookie. `next` is
1056
- // restricted to the stream-broker embed prefix (no open redirect).
1057
- fastify.get('/api/embed-auth', async (request, reply) => {
1058
- const authHeader = request.headers.authorization;
1059
- const token = authHeader?.startsWith('Bearer ')
1060
- ? authHeader.slice('Bearer '.length)
1061
- : undefined;
1062
- if (!token)
1063
- return reply.status(401).send({ error: 'Bearer token required' });
1064
- const next = request.query?.next ?? '';
1065
- if (!(0, session_cookie_js_1.isEmbedRedirectTarget)(next))
1066
- return reply.status(400).send({ error: 'invalid next' });
1067
- let ttlSec;
1068
- try {
1069
- const payload = authService.verifyToken(token);
1070
- const expSec = typeof payload.exp === 'number' ? payload.exp : 0;
1071
- ttlSec = Math.max(0, expSec - Math.floor(Date.now() / 1000));
1072
- }
1073
- catch {
1074
- return reply.status(401).send({ error: 'invalid token' });
1075
- }
1076
- const c = (0, session_cookie_js_1.buildSessionCookie)(token, ttlSec, {
1077
- secure: (0, session_cookie_js_1.sessionCookieIsSecure)(request.protocol, request.headers, spa_mount_1.FORWARDED_PREFIX_HEADER),
1078
- });
1079
- reply.setCookie(c.name, c.value, c.options);
1080
- return reply.redirect(next);
1046
+ // GET /api/embed-auth — a person's login session becomes the embed's
1047
+ // cookie (`auth/embed-auth-route.ts`); nothing else this hub signed does.
1048
+ (0, embed_auth_route_js_1.registerEmbedAuthRoute)(fastify, {
1049
+ verifyToken: (token) => authService.verifyToken(token),
1050
+ isLiveUser: (0, live_user_js_1.liveUserCheck)(capabilityRegistry),
1081
1051
  });
1082
1052
  // Every refusal on the addon gate is a request an operator will be asking
1083
1053
  // about ("why did Home Assistant stop actuating?"). Its own scope so the
@@ -1086,7 +1056,6 @@ async function bootstrap() {
1086
1056
  const addonVerifier = (0, trpc_context_1.hubCredentialVerifier)({
1087
1057
  authService,
1088
1058
  addonRegistry,
1089
- shareTokens: shareTokenService,
1090
1059
  logger: addonGateLogger,
1091
1060
  });
1092
1061
  // Addon HTTP API route catch-all: /addon/:addonId/*
@@ -1255,6 +1224,7 @@ async function bootstrap() {
1255
1224
  (0, oauth2_routes_js_1.registerOauth2Routes)(fastify, {
1256
1225
  getRegistry: () => capabilityRegistry,
1257
1226
  verifyToken: (t) => authService.verifyToken(t),
1227
+ isLiveUser: (0, live_user_js_1.liveUserCheck)(capabilityRegistry),
1258
1228
  // The hub's public endpoint from the `local-network` cap (the flagged
1259
1229
  // address, else tunnel, then mesh, then LAN). `localhost` only when the
1260
1230
  // palette names nothing — the dev case, where it is the truth.
@@ -1341,7 +1311,7 @@ async function bootstrap() {
1341
1311
  // A termination here closes with 1006; the guard's close line names it.
1342
1312
  keepAlive: { enabled: true, pingMs: 30_000, pongWaitMs: 10_000 },
1343
1313
  createContext: async (opts) => {
1344
- const wsCtx = await (0, trpc_context_1.createWsTrpcContext)(opts, authService, addonRegistry, shareTokenService, app.get(logging_service_1.LoggingService).createLogger('tRPC:ws'));
1314
+ const wsCtx = await (0, trpc_context_1.createWsTrpcContext)(opts, authService, addonRegistry, app.get(logging_service_1.LoggingService).createLogger('tRPC:ws'));
1345
1315
  // The connection's identity, derived ONCE and reused for every
1346
1316
  // operation on this socket. Frames that arrived while the bearer was
1347
1317
  // still resolving are held by the session and released here.