@camstack/server 1.2.603 → 1.2.605

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.
@@ -2,31 +2,32 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.UPLOAD_TRPC_PATH = void 0;
4
4
  exports.authorizeUploadRequest = authorizeUploadRequest;
5
- const scope_access_js_1 = require("./trpc/scope-access.js");
6
- exports.UPLOAD_TRPC_PATH = 'addons.installPackage';
7
5
  /**
8
- * Validate a `cst_*` scoped token via the `user-management` cap singleton.
9
- * Returns null when the singleton isn't mounted yet (boot race) or the
10
- * token doesn't validate. Caller treats both as auth failure.
6
+ * Shared auth guard for the REST upload routes (`/api/addons/upload` and
7
+ * `/api/server-upload`).
8
+ *
9
+ * Every credential (a session, a unified token, a legacy `csa_` / `cst_`)
10
+ * goes through the hub's one verifier; what may upload is decided by its
11
+ * scopes alone.
12
+ *
13
+ * REST endpoints share the scope-gate of the `addons.installPackage` cap
14
+ * method (semantically equivalent — both install a tarball under the
15
+ * system-scope `addons` cap with `create` access). Reusing the shared scope
16
+ * matcher keeps the gate in sync with the tRPC middleware — no parallel
17
+ * REST-only ACL. (Why not `addons.upload`? There is no `upload` method on
18
+ * the cap definition, so `METHOD_ACCESS_MAP` has no row for it and
19
+ * `checkScopeAccess` would fall through to deny.)
11
20
  */
12
- async function validateScopedTokenViaCap(addonRegistry, token) {
13
- const capRegistry = addonRegistry.getCapabilityRegistry();
14
- // Documented type boundary: the registry returns the provider untyped; the
15
- // `user-management` cap's `validateScopedToken` surface is asserted here
16
- // (same shape the tRPC middleware relies on).
17
- const userMgmt = capRegistry.getSingleton('user-management');
18
- if (!userMgmt)
19
- return null;
20
- return userMgmt.validateScopedToken({ token });
21
- }
22
- function isUploadAllowed(scoped) {
23
- return (0, scope_access_js_1.checkScopeAccess)(scoped.scopes, exports.UPLOAD_TRPC_PATH).ok;
24
- }
21
+ const trpc_context_js_1 = require("./trpc/trpc.context.js");
22
+ const principal_authorization_js_1 = require("./trpc/principal-authorization.js");
23
+ exports.UPLOAD_TRPC_PATH = 'addons.installPackage';
25
24
  /**
26
- * Run the full upload auth chain. Returns `{ok: true}` on success, or the
27
- * HTTP status + error message the route should reply with:
25
+ * The upload gate: the ONE verifier, then the ONE scope matcher on the
26
+ * `addons.installPackage` path (D892) — `'*'`, or a grant of `create` on
27
+ * `addons`. Answers:
28
28
  * - 401 `Unauthorized` when no Authorization header is present,
29
- * - 403 `Forbidden: <reason>` for any recognised-but-insufficient token.
29
+ * - 403 `Forbidden: <reason>` for a bearer that authenticates nobody or
30
+ * holds no such grant.
30
31
  */
31
32
  async function authorizeUploadRequest(args) {
32
33
  const { authHeader, authService, addonRegistry } = args;
@@ -34,36 +35,12 @@ async function authorizeUploadRequest(args) {
34
35
  return { ok: false, status: 401, error: 'Unauthorized' };
35
36
  }
36
37
  const token = authHeader.replace('Bearer ', '');
37
- let authReason;
38
- // Try JWT first — fastest path + carries the isAdmin flag directly.
39
- try {
40
- const payload = authService.verifyToken(token);
41
- if (payload.isAdmin) {
42
- return { ok: true };
43
- }
44
- authReason = 'JWT is not admin';
45
- }
46
- catch {
47
- // Not a JWT (or invalid signature) — fall through to scoped-token path.
48
- }
49
- try {
50
- const record = await validateScopedTokenViaCap(addonRegistry, token);
51
- if (!record) {
52
- authReason = authReason ?? 'token not recognised';
53
- }
54
- else if (isUploadAllowed(record)) {
55
- return { ok: true };
56
- }
57
- else {
58
- authReason = `scoped token lacks create access on '${exports.UPLOAD_TRPC_PATH}'`;
59
- }
60
- }
61
- catch (err) {
62
- authReason = `scoped token validation failed: ${err instanceof Error ? err.message : String(err)}`;
38
+ const principal = await (0, trpc_context_js_1.hubCredentialVerifier)({ authService, addonRegistry }).verify(token);
39
+ if (principal === null) {
40
+ return { ok: false, status: 403, error: 'Forbidden: token not recognised' };
63
41
  }
64
- return {
65
- ok: false,
66
- status: 403,
67
- error: `Forbidden: ${authReason ?? 'admin or upload-scoped token required'}`,
68
- };
42
+ const verdict = (0, principal_authorization_js_1.authorizeProcedure)(principal.scopes, exports.UPLOAD_TRPC_PATH, undefined);
43
+ if (verdict.ok)
44
+ return { ok: true };
45
+ return { ok: false, status: 403, error: `Forbidden: ${verdict.reason}` };
69
46
  }
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.authorizeAddonBearer = authorizeAddonBearer;
4
+ exports.addonRouteCaller = addonRouteCaller;
5
+ const types_1 = require("@camstack/types");
6
+ const principal_authorization_js_1 = require("../api/trpc/principal-authorization.js");
7
+ async function authorizeAddonBearer(input) {
8
+ const principal = await input.verifier.verify(input.bearer, input.facts ?? {});
9
+ if (principal === null) {
10
+ input.logger.warn('addon route: refused a bearer that authenticates nobody', {
11
+ meta: { addonId: input.target.addonId, method: input.target.method },
12
+ });
13
+ return { ok: false, status: 401, error: 'Invalid token' };
14
+ }
15
+ const verdict = (0, principal_authorization_js_1.authorizeAddonRoute)(principal.scopes, input.target);
16
+ if (!verdict.ok) {
17
+ input.logger.warn('addon route: refused a principal outside its scopes', {
18
+ meta: {
19
+ addonId: input.target.addonId,
20
+ method: input.target.method,
21
+ reason: verdict.reason,
22
+ credential: principal.auditTag,
23
+ credentialId: principal.credentialId,
24
+ },
25
+ });
26
+ return { ok: false, status: verdict.status, error: verdict.error };
27
+ }
28
+ return { ok: true, principal, shareHeaders: verdict.shareHeaders };
29
+ }
30
+ function addonRouteCaller(principal) {
31
+ const scoped = principal.scopes;
32
+ return {
33
+ user: { id: principal.subjectId, username: principal.username, isAdmin: principal.isAdmin },
34
+ ...(principal.isAdmin || principal.userDelegated
35
+ ? {}
36
+ : {
37
+ scopedToken: {
38
+ id: principal.credentialId,
39
+ userId: principal.subjectId,
40
+ scopes: [...(0, types_1.capabilityScopesOf)(scoped)],
41
+ },
42
+ }),
43
+ };
44
+ }
@@ -1,25 +1,27 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SHARE_DEVICES_HEADER = exports.SHARE_KIND_HEADER = void 0;
4
- exports.gateAddonShareToken = gateAddonShareToken;
5
4
  exports.stripShareHeaders = stripShareHeaders;
5
+ /**
6
+ * The data-plane gate's answer to a `csv_*` share token.
7
+ *
8
+ * A share link is a bearer credential for ONE view of a FEW devices, not a
9
+ * session: the hub's tRPC layer already confines it to a fail-closed method
10
+ * list per scope kind (`share-view-access.ts`). The data planes are reached
11
+ * outside tRPC, and until this module the gate resolved session JWTs and
12
+ * `cst_*` only — so the events embed under a share link listed its events
13
+ * and got 401 on every thumbnail (`EVENTS-EMBED.md` §7).
14
+ *
15
+ * Two rules. The first lives in the one scope matcher
16
+ * (`authorizeAddonRoute`, D892): a plane serves a share principal only when it
17
+ * OPTED IN to that kind (`AddonDataPlaneServeOptions.share`) — a plane that
18
+ * never reads the scope would otherwise serve every device to a link scoped to
19
+ * two. The second is here: the scope's devices reach the plane in headers the
20
+ * HUB sets after validation, and whatever the client sent under those names is
21
+ * stripped first — the gate is the only writer.
22
+ */
6
23
  exports.SHARE_KIND_HEADER = 'x-camstack-share-kind';
7
24
  exports.SHARE_DEVICES_HEADER = 'x-camstack-share-devices';
8
- function gateAddonShareToken(input) {
9
- if (input.record === null)
10
- return { ok: false, status: 401, error: 'Invalid token' };
11
- const kind = input.record.scope.kind;
12
- if (!(input.endpoint.share ?? []).includes(kind)) {
13
- return { ok: false, status: 403, error: 'Share token not accepted here' };
14
- }
15
- return {
16
- ok: true,
17
- headers: {
18
- [exports.SHARE_KIND_HEADER]: kind,
19
- [exports.SHARE_DEVICES_HEADER]: input.record.scope.deviceIds.join(','),
20
- },
21
- };
22
- }
23
25
  /** The forwarded header set without any share header the client supplied. */
24
26
  function stripShareHeaders(headers) {
25
27
  const out = {};
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LEGACY_SHARE_TOKEN_PREFIX = exports.LEGACY_SCOPED_TOKEN_PREFIX = exports.LEGACY_ADMIN_TOKEN_PREFIX = void 0;
4
+ exports.createCredentialVerifier = createCredentialVerifier;
5
+ const types_1 = require("@camstack/types");
6
+ const trpc_error_principal_js_1 = require("../api/trpc/trpc-error-principal.js");
7
+ /** Legacy wire prefixes — only to choose an adapter. */
8
+ exports.LEGACY_ADMIN_TOKEN_PREFIX = 'csa_';
9
+ exports.LEGACY_SCOPED_TOKEN_PREFIX = 'cst_';
10
+ exports.LEGACY_SHARE_TOKEN_PREFIX = 'csv_';
11
+ function stringClaim(value) {
12
+ return typeof value === 'string' ? value : undefined;
13
+ }
14
+ /** What a non-admin person's session reaches on addon routes: every flavour, as before D892. */
15
+ const SESSION_ADDON_ROUTES = {
16
+ type: 'addon-routes',
17
+ access: [...types_1.ALL_METHOD_ACCESS],
18
+ };
19
+ // ── Live scopes for a non-admin session ────────────────────────────────
20
+ /**
21
+ * The JWT embeds scopes at login, so an admin's scope edit reached a live
22
+ * session only at the next login (2026-08-15). The store is re-read per user,
23
+ * memoized: a change propagates within the TTL and a busy session costs one
24
+ * store read per window. The JWT's copy is the fallback when the store is
25
+ * unreachable, so a hiccup never locks anybody out mid-request.
26
+ */
27
+ const SCOPE_REFRESH_TTL_MS = 30_000;
28
+ const SCOPE_REFRESH_MISS_TTL_MS = 5_000;
29
+ const SCOPE_MEMO_MAX = 512;
30
+ function createCredentialVerifier(deps) {
31
+ const now = deps.now ?? Date.now;
32
+ const scopeMemo = deps.scopeMemo ?? new Map();
33
+ const listUsersOnce = async () => {
34
+ const users = deps.users();
35
+ if (users === null || typeof users.listUsers !== 'function')
36
+ return null;
37
+ try {
38
+ return await users.listUsers();
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ };
44
+ const liveScopes = async (userId) => {
45
+ const at = now();
46
+ const cached = scopeMemo.get(userId);
47
+ if (cached !== undefined &&
48
+ at - cached.at < (cached.scopes === null ? SCOPE_REFRESH_MISS_TTL_MS : SCOPE_REFRESH_TTL_MS)) {
49
+ return cached.scopes;
50
+ }
51
+ const fresh = (await listUsersOnce())?.find((u) => u.id === userId);
52
+ const scopes = fresh === undefined ? null : (0, types_1.normalizeTokenScopes)(fresh.scopes ?? []);
53
+ scopeMemo.set(userId, { at, scopes });
54
+ if (scopeMemo.size > SCOPE_MEMO_MAX) {
55
+ for (const [key, entry] of scopeMemo) {
56
+ if (at - entry.at > SCOPE_REFRESH_TTL_MS * 4)
57
+ scopeMemo.delete(key);
58
+ }
59
+ }
60
+ return scopes;
61
+ };
62
+ const subjectFacts = async (userId) => {
63
+ const all = await listUsersOnce();
64
+ if (all === null)
65
+ return null;
66
+ const user = all.find((u) => u.id === userId);
67
+ return user === undefined
68
+ ? { exists: false, isAdmin: false }
69
+ : { exists: true, isAdmin: user.isAdmin };
70
+ };
71
+ const auditAdminUse = (principal, facts, name) => {
72
+ const internal = deps.internal();
73
+ if (internal === null)
74
+ return;
75
+ void internal.audit
76
+ .record({
77
+ action: 'admin-token-used',
78
+ userId: principal.subjectId,
79
+ ...(facts.ip !== undefined ? { ip: facts.ip } : {}),
80
+ detail: {
81
+ tokenId: principal.credentialId,
82
+ name,
83
+ format: principal.legacy ? 'legacy' : 'token',
84
+ ...(facts.path !== undefined ? { path: facts.path } : {}),
85
+ },
86
+ })
87
+ .catch(() => { });
88
+ };
89
+ // ── Legacy adapters ──────────────────────────────────────────────────
90
+ const legacyAdminToken = async (token, facts) => {
91
+ const internal = deps.internal();
92
+ if (internal === null)
93
+ return null;
94
+ // Validation (creator still exists and is still an admin, not revoked, not
95
+ // expired) is local-auth's.
96
+ const record = await internal.adminTokens.validate(token).catch(() => null);
97
+ if (record === null)
98
+ return null;
99
+ const principal = {
100
+ subjectId: record.createdBy,
101
+ username: `admin-token:${record.name}`,
102
+ scopes: [types_1.ADMIN_SCOPE],
103
+ isAdmin: true,
104
+ credentialId: record.id,
105
+ auditTag: 'legacy-admin-token',
106
+ interactive: false,
107
+ userDelegated: false,
108
+ legacy: true,
109
+ };
110
+ auditAdminUse(principal, facts, record.name);
111
+ return principal;
112
+ };
113
+ const legacyScopedToken = async (token) => {
114
+ const users = deps.users();
115
+ if (users === null)
116
+ return null;
117
+ const record = await users.validateScopedToken({ token }).catch(() => null);
118
+ if (record === null)
119
+ return null;
120
+ return {
121
+ subjectId: record.userId,
122
+ username: `scoped:${record.tokenPrefix}`,
123
+ // A v2 `device:targets` grant migrates to a v3 selector here, so the
124
+ // matcher only ever sees v3.
125
+ scopes: (0, types_1.normalizeTokenScopes)(record.scopes),
126
+ isAdmin: false,
127
+ credentialId: record.id,
128
+ auditTag: 'legacy-scoped-token',
129
+ interactive: false,
130
+ userDelegated: false,
131
+ legacy: true,
132
+ };
133
+ };
134
+ const legacyShareToken = async (token) => {
135
+ const shares = deps.shareTokens();
136
+ if (shares === null)
137
+ return null;
138
+ const record = await shares.validate(token).catch(() => null);
139
+ if (record === null)
140
+ return null;
141
+ return {
142
+ subjectId: record.userId,
143
+ username: `share:${record.tokenPrefix}`,
144
+ scopes: [
145
+ {
146
+ type: 'share-view',
147
+ grant: {
148
+ kind: record.scope.kind,
149
+ deviceIds: [...record.scope.deviceIds],
150
+ ...(record.scope.talk !== undefined ? { talk: record.scope.talk } : {}),
151
+ },
152
+ },
153
+ ],
154
+ isAdmin: false,
155
+ credentialId: record.id,
156
+ auditTag: 'legacy-share-token',
157
+ interactive: false,
158
+ userDelegated: false,
159
+ legacy: true,
160
+ };
161
+ };
162
+ // ── The unified token ────────────────────────────────────────────────
163
+ const unifiedToken = async (payload, facts) => {
164
+ const claims = types_1.CredentialTokenClaimsSchema.safeParse(payload);
165
+ if (!claims.success)
166
+ return null;
167
+ const rows = deps.tokenRows();
168
+ if (rows === null)
169
+ return null;
170
+ // Revocation and expiry live on the row, looked up by `jti` every time.
171
+ const row = await rows.lookupLive(claims.data.jti).catch(() => null);
172
+ if (row === null || row.subjectId !== claims.data.sub)
173
+ return null;
174
+ // The subject must still exist; an admin token dies with its subject's admin bit.
175
+ const subject = await subjectFacts(row.subjectId);
176
+ if (subject === null || !subject.exists)
177
+ return null;
178
+ if ((0, types_1.scopesGrantAdmin)(row.scopes) && !subject.isAdmin)
179
+ return null;
180
+ const principal = {
181
+ subjectId: row.subjectId,
182
+ username: `token:${row.label}`,
183
+ // The ROW's scopes, never the claim's: the row is what the operator listed.
184
+ scopes: row.scopes,
185
+ isAdmin: (0, types_1.scopesGrantAdmin)(row.scopes),
186
+ credentialId: row.id,
187
+ auditTag: 'token',
188
+ interactive: false,
189
+ userDelegated: false,
190
+ legacy: false,
191
+ };
192
+ if (principal.isAdmin)
193
+ auditAdminUse(principal, facts, row.label);
194
+ return principal;
195
+ };
196
+ // ── Session and account-link JWTs ────────────────────────────────────
197
+ const hubJwt = async (token, facts) => {
198
+ let payload;
199
+ try {
200
+ payload = deps.verifyJwt(token);
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ if (payload.kind === types_1.CREDENTIAL_TOKEN_KIND)
206
+ return unifiedToken(payload, facts);
207
+ // A pre-v2 JWT (no `isAdmin`) would degrade into "non-admin, no scopes";
208
+ // refuse it so the client signs in again.
209
+ if (typeof payload.isAdmin !== 'boolean')
210
+ return null;
211
+ // Every token this hub mints verifies under one key, so a valid signature
212
+ // means "we minted this", never "this is an API credential" (D103). Only a
213
+ // session and an `oauth-access` link are credentials; a code, a refresh
214
+ // token, a bridge, a TOTP challenge are refused.
215
+ const classified = (0, types_1.classifyBearerPrincipal)(payload);
216
+ if (classified.kind === 'not-a-credential') {
217
+ deps.logger?.warn('auth: refused a bearer that is not an API credential', {
218
+ meta: { reason: classified.reason, userId: stringClaim(payload.userId) ?? 'unknown' },
219
+ });
220
+ return null;
221
+ }
222
+ const userId = stringClaim(payload.userId) ?? 'unknown';
223
+ const username = stringClaim(payload.username) ?? 'unknown';
224
+ if (classified.kind === 'integration') {
225
+ // An account link is governed by the grant the operator consented to,
226
+ // never by the linking user's record (2026-08-19), and never admin.
227
+ const identity = (0, trpc_error_principal_js_1.readCredentialIdentity)(payload);
228
+ return {
229
+ subjectId: userId,
230
+ username,
231
+ scopes: (0, types_1.normalizeTokenScopes)(payload.scopes ?? []),
232
+ isAdmin: false,
233
+ credentialId: identity.sessionId ?? `oauth:${userId}`,
234
+ auditTag: 'integration',
235
+ interactive: false,
236
+ userDelegated: true,
237
+ legacy: false,
238
+ ...(identity.sessionId !== undefined ? { oauthSessionId: identity.sessionId } : {}),
239
+ ...(identity.credential !== undefined ? { credential: identity.credential } : {}),
240
+ };
241
+ }
242
+ const sessionId = stringClaim(payload.sid);
243
+ const base = {
244
+ subjectId: userId,
245
+ username,
246
+ credentialId: sessionId ?? `session:${userId}`,
247
+ auditTag: 'session',
248
+ interactive: true,
249
+ userDelegated: true,
250
+ legacy: false,
251
+ ...(sessionId !== undefined ? { sessionId } : {}),
252
+ };
253
+ if (payload.isAdmin)
254
+ return { ...base, scopes: [types_1.ADMIN_SCOPE], isAdmin: true };
255
+ const fresh = await liveScopes(userId);
256
+ const capability = fresh ?? (0, types_1.normalizeTokenScopes)(payload.scopes ?? []);
257
+ return { ...base, scopes: [...capability, SESSION_ADDON_ROUTES], isAdmin: false };
258
+ };
259
+ return {
260
+ async verify(bearer, facts = {}) {
261
+ if (bearer === null || bearer === undefined || bearer === '')
262
+ return null;
263
+ try {
264
+ if (bearer.startsWith(exports.LEGACY_ADMIN_TOKEN_PREFIX))
265
+ return await legacyAdminToken(bearer, facts);
266
+ if (bearer.startsWith(exports.LEGACY_SHARE_TOKEN_PREFIX))
267
+ return await legacyShareToken(bearer);
268
+ if (bearer.startsWith(exports.LEGACY_SCOPED_TOKEN_PREFIX))
269
+ return await legacyScopedToken(bearer);
270
+ return await hubJwt(bearer, facts);
271
+ }
272
+ catch {
273
+ return null;
274
+ }
275
+ },
276
+ };
277
+ }
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scopesWithin = scopesWithin;
4
+ const types_1 = require("@camstack/types");
5
+ const principal_visibility_js_1 = require("../api/trpc/principal-visibility.js");
6
+ function accessCovered(asked, held) {
7
+ return asked.every((a) => held.includes(a));
8
+ }
9
+ /** The grant without its access list, as a comparable key. */
10
+ function grantKey(scope) {
11
+ if (scope.type === 'device') {
12
+ return JSON.stringify({
13
+ type: scope.type,
14
+ selector: scope.selector,
15
+ includeLinked: scope.includeLinked ?? null,
16
+ });
17
+ }
18
+ return JSON.stringify({ type: scope.type, target: scope.target });
19
+ }
20
+ function describe(scope) {
21
+ if (scope === types_1.ADMIN_SCOPE)
22
+ return "'*'";
23
+ if (scope.type === 'share-view')
24
+ return `share-view:${scope.grant.kind}`;
25
+ if (scope.type === 'addon-routes')
26
+ return `addon-routes[${scope.access.join(',')}]`;
27
+ if (scope.type === 'device')
28
+ return `device[${scope.access.join(',')}]`;
29
+ return `${scope.type}:${scope.target}[${scope.access.join(',')}]`;
30
+ }
31
+ function scopesWithin(requested, held, lookup) {
32
+ if ((0, types_1.scopesGrantAdmin)(held))
33
+ return { ok: true };
34
+ const heldCapability = (0, types_1.capabilityScopesOf)(held);
35
+ for (const scope of requested) {
36
+ if (scope === types_1.ADMIN_SCOPE) {
37
+ return { ok: false, reason: "only an admin can mint '*'" };
38
+ }
39
+ if (scope.type === 'share-view') {
40
+ const viewable = (0, principal_visibility_js_1.filterViewableDeviceIds)(heldCapability, scope.grant.deviceIds, lookup);
41
+ if (viewable.length !== scope.grant.deviceIds.length) {
42
+ return { ok: false, reason: 'a share-view grant names a device you cannot view' };
43
+ }
44
+ continue;
45
+ }
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));
48
+ if (!covered)
49
+ return { ok: false, reason: `you do not hold ${describe(scope)}` };
50
+ continue;
51
+ }
52
+ const key = grantKey(scope);
53
+ const covered = heldCapability.some((h) => grantKey(h) === key && accessCovered(scope.access, h.access));
54
+ if (!covered)
55
+ return { ok: false, reason: `you do not hold ${describe(scope)}` };
56
+ }
57
+ return { ok: true };
58
+ }
@@ -391,6 +391,7 @@ class AddonRegistryService {
391
391
  clusterNodeHistoryStore = null;
392
392
  clusterAgentRecords = null;
393
393
  shareTokenService = null;
394
+ credentialTokenService = null;
394
395
  // Device architecture — in-memory registry of live IDevice instances.
395
396
  // Persistence is owned by the `device-manager` capability addon.
396
397
  deviceRegistry = new system_1.DeviceRegistry();
@@ -1381,6 +1382,10 @@ class AddonRegistryService {
1381
1382
  setShareTokenService(service) {
1382
1383
  this.shareTokenService = service;
1383
1384
  }
1385
+ /** The unified token store (D892), bound to its `core-auth` records with the share links. */
1386
+ setCredentialTokenService(service) {
1387
+ this.credentialTokenService = service;
1388
+ }
1384
1389
  async wireHubMainOrphanRecords(store) {
1385
1390
  const recordsCap = this.capabilityRegistry.getSingleton('records');
1386
1391
  if (recordsCap === null || recordsCap === undefined)
@@ -1400,6 +1405,18 @@ class AddonRegistryService {
1400
1405
  await coreRecords.declare(record_kinds_js_1.coreAuthRecordKinds);
1401
1406
  const shareKind = coreRecords.kind(record_kinds_js_1.coreAuthRecordKinds['share-view-tokens']);
1402
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
+ });
1418
+ });
1419
+ }
1403
1420
  }
1404
1421
  }
1405
1422
  /**