@camstack/server 1.2.604 → 1.2.606

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 (38) hide show
  1. package/dist/api/auth-whoami.js +27 -43
  2. package/dist/api/core/auth-audit.procedures.js +44 -0
  3. package/dist/api/core/auth-device-cert.procedures.js +1 -1
  4. package/dist/api/core/auth-reauth.procedures.js +1 -1
  5. package/dist/api/core/auth-revoke-credentials.procedures.js +25 -52
  6. package/dist/api/core/auth-sessions.procedures.js +1 -1
  7. package/dist/api/core/auth-tokens.procedures.js +283 -0
  8. package/dist/api/core/auth.router.js +20 -203
  9. package/dist/api/core/live-events.router.js +1 -1
  10. package/dist/api/core/preferences.router.js +98 -0
  11. package/dist/api/oauth2/oauth2-routes.js +40 -15
  12. package/dist/api/trpc/device-config-secret-redaction.js +2 -2
  13. package/dist/api/trpc/generated-cap-mounts.js +0 -1
  14. package/dist/api/trpc/generated-cap-routers.js +2 -38
  15. package/dist/api/trpc/principal-authorization.js +82 -0
  16. package/dist/api/trpc/principal-visibility.js +3 -3
  17. package/dist/api/trpc/share-view-access.js +1 -1
  18. package/dist/api/trpc/trpc-error-principal.js +9 -9
  19. package/dist/api/trpc/trpc.context.js +87 -258
  20. package/dist/api/trpc/trpc.middleware.js +42 -83
  21. package/dist/api/trpc/trpc.router.js +7 -1
  22. package/dist/api/upload-auth.js +19 -54
  23. package/dist/auth/addon-route-auth.js +44 -0
  24. package/dist/auth/addon-route-share-gate.js +18 -16
  25. package/dist/auth/credential-verifier.js +236 -0
  26. package/dist/auth/embed-auth-route.js +45 -0
  27. package/dist/auth/live-user.js +19 -0
  28. package/dist/auth/scope-escalation.js +133 -0
  29. package/dist/boot/boot-config.js +8 -1
  30. package/dist/core/addon/addon-registry.service.js +18 -7
  31. package/dist/core/auth/credential-token.service.js +245 -0
  32. package/dist/core/auth/handoff-code.service.js +1 -1
  33. package/dist/core/auth/record-kinds.js +38 -2
  34. package/dist/main.js +65 -141
  35. package/package.json +14 -14
  36. package/dist/api/core/auth-admin-tokens.procedures.js +0 -138
  37. package/dist/auth/addon-route-jwt-gate.js +0 -59
  38. package/dist/core/auth/share-token.service.js +0 -459
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerEmbedAuthRoute = registerEmbedAuthRoute;
4
+ const spa_mount_js_1 = require("../api/static/spa-mount.js");
5
+ const session_cookie_js_1 = require("./session-cookie.js");
6
+ /** Seconds the cookie may live for `payload`, or `null` when it is not a live person's session. */
7
+ async function sessionCookieTtl(deps, token) {
8
+ let payload;
9
+ try {
10
+ payload = deps.verifyToken(token);
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ if (typeof payload !== 'object' || payload === null || !(0, session_cookie_js_1.isSessionGradeJwtPayload)(payload)) {
16
+ return null;
17
+ }
18
+ const userId = Reflect.get(payload, 'userId');
19
+ if (typeof userId !== 'string' || !(await deps.isLiveUser(userId)))
20
+ return null;
21
+ const exp = Reflect.get(payload, 'exp');
22
+ const expSec = typeof exp === 'number' ? exp : 0;
23
+ return Math.max(0, expSec - Math.floor(Date.now() / 1000));
24
+ }
25
+ function registerEmbedAuthRoute(fastify, deps) {
26
+ fastify.get('/api/embed-auth', async (request, reply) => {
27
+ const authHeader = request.headers.authorization;
28
+ const token = authHeader?.startsWith('Bearer ')
29
+ ? authHeader.slice('Bearer '.length)
30
+ : undefined;
31
+ if (!token)
32
+ return reply.status(401).send({ error: 'Bearer token required' });
33
+ const next = request.query?.next ?? '';
34
+ if (!(0, session_cookie_js_1.isEmbedRedirectTarget)(next))
35
+ return reply.status(400).send({ error: 'invalid next' });
36
+ const ttlSec = await sessionCookieTtl(deps, token);
37
+ if (ttlSec === null)
38
+ return reply.status(401).send({ error: 'invalid token' });
39
+ const c = (0, session_cookie_js_1.buildSessionCookie)(token, ttlSec, {
40
+ secure: (0, session_cookie_js_1.sessionCookieIsSecure)(request.protocol, request.headers, spa_mount_js_1.FORWARDED_PREFIX_HEADER),
41
+ });
42
+ reply.setCookie(c.name, c.value, c.options);
43
+ return reply.redirect(next);
44
+ });
45
+ }
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.liveUserCheck = liveUserCheck;
4
+ /** A check bound to `registry`'s `user-management` provider. Unreadable ⇒ `false` (fail closed). */
5
+ function liveUserCheck(registry) {
6
+ return async (userId) => {
7
+ if (userId === '')
8
+ return false;
9
+ const users = registry.getSingleton('user-management');
10
+ if (users === null || users === undefined)
11
+ return false;
12
+ try {
13
+ return (await users.listUsers()).some((user) => user.id === userId);
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ };
19
+ }
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scopesWithin = scopesWithin;
4
+ exports.intersectScopes = intersectScopes;
5
+ const types_1 = require("@camstack/types");
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
+ }
17
+ function accessCovered(asked, held) {
18
+ return asked.every((a) => held.includes(a));
19
+ }
20
+ /** The grant without its access list, as a comparable key. */
21
+ function grantKey(scope) {
22
+ if (scope.type === 'device') {
23
+ return JSON.stringify({
24
+ type: scope.type,
25
+ selector: scope.selector,
26
+ includeLinked: scope.includeLinked ?? null,
27
+ });
28
+ }
29
+ return JSON.stringify({ type: scope.type, target: scope.target });
30
+ }
31
+ function describe(scope) {
32
+ if (scope === types_1.ADMIN_SCOPE)
33
+ return "'*'";
34
+ if (scope.type === 'share-view')
35
+ return `share-view:${scope.grant.kind}`;
36
+ if (scope.type === 'addon-routes')
37
+ return `addon-routes[${scope.access.join(',')}]`;
38
+ if (scope.type === 'preferences-self')
39
+ return 'preferences-self';
40
+ if (scope.type === 'device')
41
+ return `device[${scope.access.join(',')}]`;
42
+ return `${scope.type}:${scope.target}[${scope.access.join(',')}]`;
43
+ }
44
+ function scopesWithin(requested, held, lookup) {
45
+ if ((0, types_1.scopesGrantAdmin)(held))
46
+ return { ok: true };
47
+ const heldCapability = (0, types_1.capabilityScopesOf)(held);
48
+ for (const scope of requested) {
49
+ if (scope === types_1.ADMIN_SCOPE) {
50
+ return { ok: false, reason: "only an admin can mint '*'" };
51
+ }
52
+ if (scope.type === 'share-view') {
53
+ const viewable = (0, principal_visibility_js_1.filterViewableDeviceIds)(heldCapability, scope.grant.deviceIds, lookup);
54
+ if (viewable.length !== scope.grant.deviceIds.length) {
55
+ return { ok: false, reason: 'a share-view grant names a device you cannot view' };
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
+ }
60
+ continue;
61
+ }
62
+ if (scope.type === 'preferences-self') {
63
+ const covered = held.some((h) => h !== types_1.ADMIN_SCOPE && h.type === 'preferences-self');
64
+ if (!covered)
65
+ return { ok: false, reason: `you do not hold ${describe(scope)}` };
66
+ continue;
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
+ }
74
+ const key = grantKey(scope);
75
+ const covered = heldCapability.some((h) => grantKey(h) === key && accessCovered(scope.access, h.access));
76
+ if (!covered)
77
+ return { ok: false, reason: `you do not hold ${describe(scope)}` };
78
+ }
79
+ return { ok: true };
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,7 @@ class AddonRegistryService {
390
390
  integrationRegistry = null;
391
391
  clusterNodeHistoryStore = null;
392
392
  clusterAgentRecords = null;
393
- shareTokenService = null;
393
+ credentialTokenService = null;
394
394
  // Device architecture — in-memory registry of live IDevice instances.
395
395
  // Persistence is owned by the `device-manager` capability addon.
396
396
  deviceRegistry = new system_1.DeviceRegistry();
@@ -1378,8 +1378,9 @@ class AddonRegistryService {
1378
1378
  const kind = records.kind(system_1.clusterNodeHistoryRecordKind);
1379
1379
  clusterStore.bindRecordsKind(() => kind);
1380
1380
  }
1381
- setShareTokenService(service) {
1382
- this.shareTokenService = service;
1381
+ /** The unified token store (D892), bound to its `core-auth` records. */
1382
+ setCredentialTokenService(service) {
1383
+ this.credentialTokenService = service;
1383
1384
  }
1384
1385
  async wireHubMainOrphanRecords(store) {
1385
1386
  const recordsCap = this.capabilityRegistry.getSingleton('records');
@@ -1394,12 +1395,22 @@ class AddonRegistryService {
1394
1395
  await clusterRecords.declare(system_1.clusterAgentRecordKinds);
1395
1396
  this.clusterAgentRecords = clusterRecords;
1396
1397
  this.bindClusterNodeHistoryRecordsKind();
1397
- const shareTokens = this.shareTokenService;
1398
- if (shareTokens !== null) {
1398
+ const credentialTokens = this.credentialTokenService;
1399
+ if (credentialTokens !== null) {
1399
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.
1400
1404
  await coreRecords.declare(record_kinds_js_1.coreAuthRecordKinds);
1401
- const shareKind = coreRecords.kind(record_kinds_js_1.coreAuthRecordKinds['share-view-tokens']);
1402
- shareTokens.bindShareTokensKind(() => shareKind);
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) },
1412
+ });
1413
+ });
1403
1414
  }
1404
1415
  }
1405
1416
  /**
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CredentialTokenService = void 0;
37
+ /**
38
+ * The unified token store (D892).
39
+ *
40
+ * A token is a signed JWT `{ kind: 'credential', sub, jti, scopes }` and one
41
+ * row here: the SHA-256 of its `jti`, its label, its scopes, its ONE expiry,
42
+ * when it was last used and whether it was revoked. The verifier reads the
43
+ * row by `jti` on every request — that is what makes revocation immediate and
44
+ * the expiry slidable (a share link's `extend`). The raw token leaves this
45
+ * service exactly once, in `mint`'s answer; neither it nor the `jti` is ever
46
+ * stored or logged.
47
+ *
48
+ * Rows live in the `core-auth` records (`credential-tokens`), declared by the
49
+ * hub beside the share links. Until that binding happens the store refuses to
50
+ * mint and authenticates nothing — it never falls back to another table.
51
+ */
52
+ const crypto = __importStar(require("node:crypto"));
53
+ const types_1 = require("@camstack/types");
54
+ const record_kinds_js_1 = require("./record-kinds.js");
55
+ const TOUCH_EVERY_MS = 60_000;
56
+ const DEAD_RETENTION_MS = 30 * 86_400_000;
57
+ const LABEL_MAX = 80;
58
+ const PAGE = 1000;
59
+ const MAX_PAGES = 100;
60
+ function hashJti(jti) {
61
+ return crypto.createHash('sha256').update(jti).digest('hex');
62
+ }
63
+ /** Constant-time equality of two hex digests. */
64
+ function sameDigest(a, b) {
65
+ const left = Buffer.from(a, 'hex');
66
+ const right = Buffer.from(b, 'hex');
67
+ return left.length === right.length && crypto.timingSafeEqual(left, right);
68
+ }
69
+ function toRecord(id, body) {
70
+ return {
71
+ id,
72
+ subjectId: body.subjectId,
73
+ createdBy: body.createdBy,
74
+ label: body.label,
75
+ scopes: body.scopes,
76
+ createdAt: body.createdAt,
77
+ expiresAt: body.expiresAt,
78
+ lastUsedAt: body.lastUsedAt ?? null,
79
+ revokedAt: body.revokedAt ?? null,
80
+ };
81
+ }
82
+ class CredentialTokenService {
83
+ signer;
84
+ logger;
85
+ now;
86
+ resolveKind = () => null;
87
+ constructor(signer, logger = null, now = Date.now) {
88
+ this.signer = signer;
89
+ this.logger = logger;
90
+ this.now = now;
91
+ }
92
+ /** Hub wiring after `records.forOwner('core-auth').declare(...)`. */
93
+ bindKind(resolver) {
94
+ this.resolveKind = resolver;
95
+ }
96
+ kind() {
97
+ const kind = this.resolveKind();
98
+ if (kind === null) {
99
+ throw (0, types_1.userError)('auth.tokens-unavailable', undefined, 'Tokens are not available yet');
100
+ }
101
+ return kind;
102
+ }
103
+ /** Issue a token. The raw token is in this answer only. */
104
+ async mint(input) {
105
+ const label = input.label.trim().slice(0, LABEL_MAX);
106
+ if (label.length === 0) {
107
+ throw (0, types_1.userError)('auth.token-label-required', undefined, 'A token needs a name');
108
+ }
109
+ const at = this.now();
110
+ if (input.expiresAt !== null && input.expiresAt <= at) {
111
+ throw (0, types_1.userError)('auth.token-expiry-past', undefined, 'A token must expire in the future');
112
+ }
113
+ const jti = crypto.randomBytes(32).toString('base64url');
114
+ const id = crypto.randomUUID();
115
+ const body = {
116
+ subjectId: input.subjectId,
117
+ createdBy: input.createdBy,
118
+ label,
119
+ jtiHash: hashJti(jti),
120
+ scopes: [...input.scopes],
121
+ createdAt: at,
122
+ expiresAt: input.expiresAt,
123
+ lastUsedAt: null,
124
+ revokedAt: null,
125
+ };
126
+ const parsed = record_kinds_js_1.coreAuthRecordKinds['credential-tokens'].schema.safeParse(body);
127
+ if (!parsed.success)
128
+ throw new Error(parsed.error.message);
129
+ const kind = this.kind();
130
+ const token = this.signer.signCredentialToken({
131
+ kind: types_1.CREDENTIAL_TOKEN_KIND,
132
+ sub: input.subjectId,
133
+ jti,
134
+ scopes: [...input.scopes],
135
+ });
136
+ await kind.put({
137
+ id,
138
+ ...(body.expiresAt !== null ? { expiresAt: body.expiresAt } : {}),
139
+ data: parsed.data,
140
+ });
141
+ this.logger?.info('Token minted', {
142
+ meta: { tokenId: id, subjectId: input.subjectId, label, expiresAt: input.expiresAt },
143
+ });
144
+ return { token, record: toRecord(id, parsed.data) };
145
+ }
146
+ /** The live row behind `jti`: unknown, revoked and expired answer `null`. */
147
+ async lookupLive(jti) {
148
+ const kind = this.resolveKind();
149
+ if (kind === null)
150
+ return null;
151
+ const digest = hashJti(jti);
152
+ const page = await kind.query({ where: { jtiHash: digest }, limit: 1 });
153
+ const row = page.records[0];
154
+ if (row === undefined || !sameDigest(row.data.jtiHash, digest))
155
+ return null;
156
+ const at = this.now();
157
+ if (row.data.revokedAt !== null && row.data.revokedAt !== undefined)
158
+ return null;
159
+ if (row.data.expiresAt !== null && row.data.expiresAt <= at)
160
+ return null;
161
+ const lastUsedAt = row.data.lastUsedAt ?? null;
162
+ if (lastUsedAt === null || at - lastUsedAt > TOUCH_EVERY_MS) {
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(() => { });
167
+ }
168
+ return {
169
+ id: row.id,
170
+ subjectId: row.data.subjectId,
171
+ label: row.data.label,
172
+ scopes: row.data.scopes,
173
+ };
174
+ }
175
+ async get(id) {
176
+ const row = await this.kind().get(id);
177
+ return row === null ? null : toRecord(row.id, row.data);
178
+ }
179
+ /** Every row not yet reaped, newest first; `subjectId` narrows to one user's. */
180
+ async list(subjectId) {
181
+ const kind = this.resolveKind();
182
+ if (kind === null)
183
+ return [];
184
+ const out = [];
185
+ let cursor;
186
+ for (let page = 0; page < MAX_PAGES; page += 1) {
187
+ const answer = await kind.query({
188
+ ...(subjectId === undefined ? {} : { where: { subjectId } }),
189
+ limit: PAGE,
190
+ ...(cursor === undefined ? {} : { cursor }),
191
+ });
192
+ out.push(...answer.records.map((row) => toRecord(row.id, row.data)));
193
+ if (answer.nextCursor === null)
194
+ return out.toSorted((a, b) => b.createdAt - a.createdAt);
195
+ cursor = answer.nextCursor;
196
+ }
197
+ throw new Error('credential-tokens: row count exceeds pagination bound');
198
+ }
199
+ /** Revoke one token now. `false` when unknown or already revoked. */
200
+ async revoke(id) {
201
+ const row = await this.kind().get(id);
202
+ if (row === null || (row.data.revokedAt !== null && row.data.revokedAt !== undefined)) {
203
+ return false;
204
+ }
205
+ await this.kind().patch(id, { revokedAt: this.now() });
206
+ this.logger?.info('Token revoked', { meta: { tokenId: id, subjectId: row.data.subjectId } });
207
+ return true;
208
+ }
209
+ /** Move a live token's expiry (`null`: never). `null` when not live. */
210
+ async setExpiry(id, expiresAt) {
211
+ const row = await this.kind().get(id);
212
+ if (row === null)
213
+ return null;
214
+ const at = this.now();
215
+ const dead = (row.data.revokedAt !== null && row.data.revokedAt !== undefined) ||
216
+ (row.data.expiresAt !== null && row.data.expiresAt <= at);
217
+ if (dead || (expiresAt !== null && expiresAt <= at))
218
+ return null;
219
+ await this.kind().patch(id, { expiresAt }, { expiresAt });
220
+ return toRecord(id, { ...row.data, expiresAt });
221
+ }
222
+ /** Revoke every live token of `subjectId` (the user was deleted). */
223
+ async revokeAllFor(subjectId) {
224
+ let revoked = 0;
225
+ for (const token of await this.list(subjectId)) {
226
+ if (token.revokedAt === null && (await this.revoke(token.id)))
227
+ revoked += 1;
228
+ }
229
+ return revoked;
230
+ }
231
+ /** Delete rows expired or revoked more than 30 days ago. */
232
+ async reap() {
233
+ const horizon = this.now() - DEAD_RETENTION_MS;
234
+ let deleted = 0;
235
+ for (const token of await this.list()) {
236
+ const deadAt = token.revokedAt ?? token.expiresAt;
237
+ if (deadAt !== null && deadAt < horizon) {
238
+ await this.kind().delete(token.id);
239
+ deleted += 1;
240
+ }
241
+ }
242
+ return deleted;
243
+ }
244
+ }
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(),
@@ -21,18 +26,49 @@ const ShareViewTokenBodySchema = zod_1.z.object({
21
26
  expiresAt: zod_1.z.number().nullable(),
22
27
  lastUsedAt: zod_1.z.number().nullish(),
23
28
  });
29
+ /**
30
+ * The unified token (D892): one row per token `auth.mintToken` issued — the
31
+ * hashed `jti` the verifier looks up for revocation, the label, the scopes,
32
+ * the ONE expiry (`null` only for a never-expiring share link), last use.
33
+ */
34
+ const CredentialTokenBodySchema = zod_1.z.object({
35
+ subjectId: zod_1.z.string(),
36
+ createdBy: zod_1.z.string(),
37
+ label: zod_1.z.string(),
38
+ jtiHash: zod_1.z.string(),
39
+ scopes: zod_1.z.array(types_1.CredentialScopeSchema).min(1),
40
+ createdAt: zod_1.z.number(),
41
+ expiresAt: zod_1.z.number().nullable(),
42
+ lastUsedAt: zod_1.z.number().nullish(),
43
+ revokedAt: zod_1.z.number().nullish(),
44
+ });
24
45
  exports.coreAuthRecordKinds = (0, types_1.defineRecordKinds)({
25
46
  'share-view-tokens': {
26
47
  schema: ShareViewTokenBodySchema,
27
48
  class: 'config',
28
- writeTrigger: 'operator mints a share link; validate touches lastUsedAt',
49
+ writeTrigger: 'none since D895 — legacy rows kept on disk for the operator to drop',
29
50
  device: 'none',
30
51
  at: 'none',
31
52
  indexes: [
32
53
  { name: 'hash', fields: ['tokenHash'], unique: true },
33
54
  { name: 'user', fields: ['userId'] },
34
55
  ],
35
- retention: { none: 'revoke only; expiry enforced at validate' },
56
+ retention: { none: 'operator drops the legacy rows by hand (D895)' },
36
57
  adoptsFrom: exports.SHARE_VIEW_TOKENS_LEGACY,
37
58
  },
59
+ 'credential-tokens': {
60
+ schema: CredentialTokenBodySchema,
61
+ class: 'config',
62
+ writeTrigger: 'a user mints a token (auth.mintToken or a wrapper); validate touches lastUsedAt at most once a minute; revoke stamps revokedAt',
63
+ device: 'none',
64
+ at: 'none',
65
+ indexes: [
66
+ { name: 'jti', fields: ['jtiHash'], unique: true },
67
+ { name: 'subject', fields: ['subjectId'] },
68
+ ],
69
+ retention: {
70
+ owner: 'core-auth',
71
+ reason: 'the daily reap deletes rows expired or revoked more than 30 days ago',
72
+ },
73
+ },
38
74
  });