@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.
- package/dist/api/auth-whoami.js +0 -1
- package/dist/api/core/auth-audit.procedures.js +44 -0
- package/dist/api/core/auth-revoke-credentials.procedures.js +4 -78
- package/dist/api/core/auth-tokens.procedures.js +105 -72
- package/dist/api/core/auth.router.js +14 -280
- package/dist/api/core/cap-providers.js +38 -1
- package/dist/api/core/integration-secrets.js +22 -1
- package/dist/api/core/logs.router.js +219 -49
- package/dist/api/core/preferences.router.js +98 -0
- package/dist/api/oauth2/oauth2-routes.js +40 -15
- package/dist/api/trpc/device-config-secret-redaction.js +2 -2
- package/dist/api/trpc/generated-cap-mounts.js +0 -1
- package/dist/api/trpc/generated-cap-routers.js +53 -42
- package/dist/api/trpc/principal-visibility.js +2 -2
- package/dist/api/trpc/share-view-access.js +1 -1
- package/dist/api/trpc/trpc-error-principal.js +1 -2
- package/dist/api/trpc/trpc.context.js +16 -13
- package/dist/api/trpc/trpc.router.js +7 -2
- package/dist/api/upload-auth.js +13 -25
- package/dist/auth/addon-route-share-gate.js +2 -2
- package/dist/auth/credential-verifier.js +63 -104
- package/dist/auth/embed-auth-route.js +45 -0
- package/dist/auth/live-user.js +19 -0
- package/dist/auth/scope-escalation.js +77 -2
- package/dist/boot/boot-config.js +8 -1
- package/dist/core/addon/addon-registry.service.js +14 -20
- package/dist/core/auth/credential-token.service.js +9 -14
- package/dist/core/auth/handoff-code.service.js +1 -1
- package/dist/core/auth/record-kinds.js +7 -2
- package/dist/core/logging/log-history.js +119 -0
- package/dist/core/logging/logging.service.js +38 -1
- package/dist/main.js +18 -48
- package/package.json +14 -14
- package/dist/api/core/auth-admin-tokens.procedures.js +0 -156
- 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 === '
|
|
47
|
-
const covered = held.some((h) => h !== types_1.ADMIN_SCOPE && h.type === '
|
|
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
|
+
}
|
package/dist/boot/boot-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
1403
|
-
if (
|
|
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
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
217
|
-
|
|
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 (
|
|
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: '
|
|
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: '
|
|
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
|
|
442
|
-
//
|
|
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 —
|
|
477
|
-
//
|
|
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
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
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)({
|
|
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,
|
|
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
|
|
1051
|
-
// cookie
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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,
|
|
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.
|