@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
@@ -10,7 +10,7 @@ exports.scopedEventVisibility = scopedEventVisibility;
10
10
  * Non-admin principal device visibility — the shared answer to "which cameras
11
11
  * may this scoped caller see?", reused by two hotfixes:
12
12
  *
13
- * - F0.4 `auth.createShareToken` — a scoped user must not mint a `csv_*`
13
+ * - F0.4 `auth.mintToken` (a share-view grant) — a scoped user must not mint a share link
14
14
  * share token for a device it cannot see. The requested deviceIds are
15
15
  * INTERSECTED with what the caller can view.
16
16
  * - F0.5 the event firehose (`live.onEvent`, `systemEvents.subscribe`, …) —
@@ -141,7 +141,7 @@ function projectListAllForPrincipal(data, isAdmin, scopes, fleet) {
141
141
  * view; an event with NO device identity is DROPPED (fail closed — "probably in
142
142
  * scope" is not a security argument).
143
143
  *
144
- * Share-view (`csv_*`) principals are NOT handled here — they keep their own,
144
+ * Share-view principals are NOT handled here — they keep their own,
145
145
  * stricter `liveEventInShareScope` filter, applied by the routers first.
146
146
  */
147
147
  function scopedEventVisibility(user, lookup) {
@@ -181,7 +181,7 @@ function projectListAllForShareScope(data, scope) {
181
181
  * The projection ENGINE: drop every `listAll` row whose id is not in
182
182
  * `allowed`, and project the survivors onto the whitelisted row.
183
183
  *
184
- * Shared by the `csv_*` share path ({@link projectListAllForShareScope}) and
184
+ * Shared by the share-link path ({@link projectListAllForShareScope}) and
185
185
  * the scoped-principal path (`projectListAllForPrincipal` in
186
186
  * `principal-visibility.ts`) so enumeration is cut ONE way. Two projectors
187
187
  * would be two chances to forget `config` — which carries the camera
@@ -34,9 +34,8 @@ function readCredentialIdentity(claims) {
34
34
  *
35
35
  * anonymous
36
36
  * apocaliss92 (admin)
37
- * share:ab12cd34 (legacy-share-token)
38
- * scoped:1a2b3c4d (legacy-scoped-token)
39
37
  * token:ci (token)
38
+ * token:ops (token admin)
40
39
  * apocaliss92 (oauth-access session=910a6179-…)
41
40
  * apocaliss92 (session)
42
41
  */
@@ -24,7 +24,6 @@ function agentFromPrincipal(principal) {
24
24
  auditTag: principal.auditTag,
25
25
  interactive: principal.interactive,
26
26
  userDelegated: principal.userDelegated,
27
- legacy: principal.legacy,
28
27
  ...(principal.sessionId !== undefined ? { sessionId: principal.sessionId } : {}),
29
28
  ...(principal.oauthSessionId !== undefined ? { oauthSessionId: principal.oauthSessionId } : {}),
30
29
  ...(principal.credential !== undefined ? { credential: principal.credential } : {}),
@@ -35,29 +34,31 @@ function agentFromPrincipal(principal) {
35
34
  * process builds (a request builds one; the memo must outlive it).
36
35
  */
37
36
  const hubScopeMemo = new Map();
38
- const hubStores = { shareTokens: null, tokenRows: null };
37
+ /** The user-list memo, shared the same way (every token verify asks "does its subject exist?"). */
38
+ const hubUsersMemo = { entry: null };
39
+ const hubStores = { tokenRows: null };
39
40
  function bindHubCredentialStores(stores) {
40
- if (stores.shareTokens !== undefined)
41
- hubStores.shareTokens = stores.shareTokens;
42
41
  if (stores.tokenRows !== undefined)
43
42
  hubStores.tokenRows = stores.tokenRows;
44
43
  }
45
44
  /**
46
45
  * The hub's verifier over its own stores — the same one for tRPC and the
47
46
  * addon routes. Documented type boundary: the registry hands the
48
- * `user-management` provider back untyped; the two methods the adapters read
49
- * (`validateScopedToken`, `listUsers`) are asserted here.
47
+ * `user-management` provider back untyped; the one method the verifier reads
48
+ * (`listUsers`) is asserted here.
50
49
  */
51
50
  function hubCredentialVerifier(deps) {
52
51
  const provider = () => deps.addonRegistry.getCapabilityRegistry().getSingleton('user-management');
53
52
  return (0, credential_verifier_js_1.createCredentialVerifier)({
54
53
  verifyJwt: (token) => deps.authService.verifyToken(token),
54
+ verifyCredentialJwt: (token) => deps.authService.verifyCredentialToken(token),
55
55
  users: () => provider() ?? null,
56
56
  internal: () => (0, system_1.getUserManagementInternal)(provider()),
57
- shareTokens: () => deps.shareTokens ?? hubStores.shareTokens,
58
57
  tokenRows: () => deps.tokenRows ?? hubStores.tokenRows,
59
58
  logger: deps.logger ?? null,
60
59
  scopeMemo: hubScopeMemo,
60
+ usersMemo: hubUsersMemo,
61
+ ...(deps.deviceScopeLookup !== undefined ? { deviceScopeLookup: deps.deviceScopeLookup } : {}),
61
62
  });
62
63
  }
63
64
  /** Read `req.query` if present (Fastify-only) without losing type safety. */
@@ -155,19 +156,20 @@ function createMeshTrpcContext(principal = 'mesh') {
155
156
  return { user };
156
157
  }
157
158
  /** Context factory for HTTP tRPC requests (Fastify adapter). */
158
- async function createTrpcContext(req, authService, addonRegistry, shareTokens = null, logger = null, tokenRows = null) {
159
+ async function createTrpcContext(req, authService, addonRegistry, logger = null, tokenRows = null) {
160
+ const deviceScopeLookup = makeDeviceScopeLookup(addonRegistry);
159
161
  const verifier = hubCredentialVerifier({
160
162
  authService,
161
163
  addonRegistry,
162
- shareTokens,
163
164
  tokenRows,
164
165
  logger,
166
+ deviceScopeLookup,
165
167
  });
166
168
  return {
167
169
  user: await resolveUser(extractTokenFromRequest(req), verifier, requestFacts(req)),
168
170
  req,
169
171
  lang: (0, request_lang_js_1.resolveRequestLang)((0, request_lang_js_1.requestLangInputFrom)(req.headers, readQuery(req))),
170
- deviceScopeLookup: makeDeviceScopeLookup(addonRegistry),
172
+ deviceScopeLookup,
171
173
  deviceFleet: () => addonRegistry.getPersistedDeviceList(),
172
174
  };
173
175
  }
@@ -176,16 +178,17 @@ async function createTrpcContext(req, authService, addonRegistry, shareTokens =
176
178
  * Token is sent via tRPC connectionParams (a JSON message sent right after
177
179
  * the WS handshake), which is more reliable than query params through proxies.
178
180
  */
179
- async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens = null, logger = null, tokenRows = null) {
181
+ async function createWsTrpcContext(opts, authService, addonRegistry, logger = null, tokenRows = null) {
180
182
  // 1. connectionParams.token (sent by BackendClient's createWSClient)
181
183
  const paramToken = opts.info.connectionParams?.['token'];
182
184
  const token = (typeof paramToken === 'string' ? paramToken : null) ?? extractTokenFromRequest(opts.req);
185
+ const deviceScopeLookup = makeDeviceScopeLookup(addonRegistry);
183
186
  const verifier = hubCredentialVerifier({
184
187
  authService,
185
188
  addonRegistry,
186
- shareTokens,
187
189
  tokenRows,
188
190
  logger,
191
+ deviceScopeLookup,
189
192
  });
190
193
  const user = await resolveUser(token, verifier, requestFacts(opts.req));
191
194
  const sessionId = user?.sessionId;
@@ -205,7 +208,7 @@ async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens
205
208
  }) === null,
206
209
  }
207
210
  : {}),
208
- deviceScopeLookup: makeDeviceScopeLookup(addonRegistry),
211
+ deviceScopeLookup,
209
212
  deviceFleet: () => addonRegistry.getPersistedDeviceList(),
210
213
  };
211
214
  }
@@ -54,6 +54,7 @@ const hwaccel_router_js_1 = require("../core/hwaccel.router.js");
54
54
  const live_events_router_js_1 = require("../core/live-events.router.js");
55
55
  const logs_router_js_1 = require("../core/logs.router.js");
56
56
  const notifications_router_js_1 = require("../core/notifications.router.js");
57
+ const preferences_router_js_1 = require("../core/preferences.router.js");
57
58
  const repl_router_js_1 = require("../core/repl.router.js");
58
59
  const server_management_provider_js_1 = require("../core/server-management.provider.js");
59
60
  const stream_probe_router_js_1 = require("../core/stream-probe.router.js");
@@ -330,7 +331,7 @@ function buildCapabilityRouters(services) {
330
331
  repl: (0, repl_router_js_1.createReplRouter)(services.replEngine),
331
332
  systemEvents: (0, system_events_router_js_1.createSystemEventsRouter)(services.eventBus),
332
333
  capabilities: (0, capabilities_router_js_1.createCapabilitiesRouter)(services.capabilityRegistry, services.configService),
333
- logs: (0, logs_router_js_1.createLogsRouter)(services.loggingService),
334
+ logs: (0, logs_router_js_1.createLogsRouter)(services.loggingService, services.capabilityRegistry),
334
335
  live: (0, live_events_router_js_1.createLiveEventsRouter)(services.eventBus, services.addonRegistry),
335
336
  // stream-probe — fixed core API (ffprobe wrapper), not a cap.
336
337
  streamProbe: (0, stream_probe_router_js_1.createStreamProbeRouter)(services.streamProbe),
@@ -351,13 +352,17 @@ function buildCapabilityRouters(services) {
351
352
  hubDataDir: path.resolve(services.configService.get('server.dataPath') ?? 'camstack-data'),
352
353
  log: services.loggingService.createLogger('certificates'),
353
354
  }),
355
+ // preferences - the caller's own account preferences, synced across their devices (D894).
356
+ preferences: (0, preferences_router_js_1.createPreferencesRouter)({
357
+ preferences: (0, preferences_router_js_1.preferencesFromRegistry)(services.capabilityRegistry),
358
+ }),
354
359
  // agentPairing - hub-initiated pairing of agents, unpairing (D857).
355
360
  agentPairing: (0, agent_pairing_router_js_1.createAgentPairingRouter)({
356
361
  registry: services.capabilityRegistry,
357
362
  nodeClient: (nodeId) => (0, node_certificates_client_js_1.createNodeCertificatesClient)(services.moleculer, nodeId),
358
363
  log: services.loggingService.createLogger('agent-pairing'),
359
364
  }),
360
- auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.shareTokenService, services.handoffCodeService, services.externalLinkIntents, services.nativeLink ?? null, {
365
+ auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.handoffCodeService, services.externalLinkIntents, services.nativeLink ?? null, {
361
366
  ...(services.authNetworkPolicy !== undefined
362
367
  ? { network: services.authNetworkPolicy }
363
368
  : {}),
@@ -1,33 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.UPLOAD_TRPC_PATH = void 0;
4
3
  exports.authorizeUploadRequest = authorizeUploadRequest;
5
4
  /**
6
5
  * Shared auth guard for the REST upload routes (`/api/addons/upload` and
7
6
  * `/api/server-upload`).
8
7
  *
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.)
8
+ * Every credential goes through the hub's one verifier. An upload installs
9
+ * code that runs on the hub, so it is ADMIN only: the principal's scopes must
10
+ * hold `'*'`. A grant of `create` on the `addons` capability is NOT enough —
11
+ * that reach is a capability permission, and an addon tarball or a server
12
+ * closure is arbitrary code execution (security review 2026-10-21).
20
13
  */
21
14
  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';
24
15
  /**
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
- * - 401 `Unauthorized` when no Authorization header is present,
29
- * - 403 `Forbidden: <reason>` for a bearer that authenticates nobody or
30
- * holds no such grant.
16
+ * The upload gate: the ONE verifier, then `'*'` (D892, D895). Answers:
17
+ * - 401 `Unauthorized` when no Authorization header is present, or the
18
+ * bearer authenticates nobody (revoked, expired, never issued),
19
+ * - 403 `Forbidden: admin only` for a principal that is not an admin.
31
20
  */
32
21
  async function authorizeUploadRequest(args) {
33
22
  const { authHeader, authService, addonRegistry } = args;
@@ -37,10 +26,9 @@ async function authorizeUploadRequest(args) {
37
26
  const token = authHeader.replace('Bearer ', '');
38
27
  const principal = await (0, trpc_context_js_1.hubCredentialVerifier)({ authService, addonRegistry }).verify(token);
39
28
  if (principal === null) {
40
- return { ok: false, status: 403, error: 'Forbidden: token not recognised' };
29
+ return { ok: false, status: 401, error: 'Unauthorized: token not recognised' };
41
30
  }
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}` };
31
+ if (!principal.isAdmin)
32
+ return { ok: false, status: 403, error: 'Forbidden: admin only' };
33
+ return { ok: true };
46
34
  }
@@ -3,13 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SHARE_DEVICES_HEADER = exports.SHARE_KIND_HEADER = void 0;
4
4
  exports.stripShareHeaders = stripShareHeaders;
5
5
  /**
6
- * The data-plane gate's answer to a `csv_*` share token.
6
+ * The data-plane gate's answer to a share-link token.
7
7
  *
8
8
  * A share link is a bearer credential for ONE view of a FEW devices, not a
9
9
  * session: the hub's tRPC layer already confines it to a fail-closed method
10
10
  * list per scope kind (`share-view-access.ts`). The data planes are reached
11
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
12
+ * scoped tokens only — so the events embed under a share link listed its events
13
13
  * and got 401 on every thumbnail (`EVENTS-EMBED.md` §7).
14
14
  *
15
15
  * Two rules. The first lives in the one scope matcher
@@ -1,13 +1,9 @@
1
1
  "use strict";
2
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
3
  exports.createCredentialVerifier = createCredentialVerifier;
5
4
  const types_1 = require("@camstack/types");
6
5
  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_';
6
+ const scope_escalation_js_1 = require("./scope-escalation.js");
11
7
  function stringClaim(value) {
12
8
  return typeof value === 'string' ? value : undefined;
13
9
  }
@@ -27,15 +23,30 @@ const SESSION_ADDON_ROUTES = {
27
23
  const SCOPE_REFRESH_TTL_MS = 30_000;
28
24
  const SCOPE_REFRESH_MISS_TTL_MS = 5_000;
29
25
  const SCOPE_MEMO_MAX = 512;
26
+ /**
27
+ * The user list, read at most once per {@link USERS_MEMO_TTL_MS}: every token
28
+ * and account-link verify asks whether its subject still exists and is still
29
+ * an admin, and that must not cost a full store read per request. A deletion
30
+ * or a demotion therefore applies within the window.
31
+ */
32
+ const USERS_MEMO_TTL_MS = 5_000;
30
33
  function createCredentialVerifier(deps) {
31
34
  const now = deps.now ?? Date.now;
32
35
  const scopeMemo = deps.scopeMemo ?? new Map();
36
+ const usersMemo = deps.usersMemo ?? { entry: null };
33
37
  const listUsersOnce = async () => {
38
+ const at = now();
34
39
  const users = deps.users();
35
40
  if (users === null || typeof users.listUsers !== 'function')
36
41
  return null;
42
+ const cached = usersMemo.entry;
43
+ if (cached !== null && cached.source === users && at - cached.at < USERS_MEMO_TTL_MS) {
44
+ return cached.users;
45
+ }
37
46
  try {
38
- return await users.listUsers();
47
+ const fresh = await users.listUsers();
48
+ usersMemo.entry = { at, source: users, users: fresh };
49
+ return fresh;
39
50
  }
40
51
  catch {
41
52
  return null;
@@ -80,96 +91,26 @@ function createCredentialVerifier(deps) {
80
91
  detail: {
81
92
  tokenId: principal.credentialId,
82
93
  name,
83
- format: principal.legacy ? 'legacy' : 'token',
84
94
  ...(facts.path !== undefined ? { path: facts.path } : {}),
85
95
  },
86
96
  })
87
97
  .catch(() => { });
88
98
  };
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
99
  // ── The unified token ────────────────────────────────────────────────
163
- const unifiedToken = async (payload, facts) => {
164
- const claims = types_1.CredentialTokenClaimsSchema.safeParse(payload);
165
- if (!claims.success)
100
+ /** `row`'s scopes cut down to what its (non-admin) subject holds now; `null` when unreadable. */
101
+ const narrowedToSubject = async (row) => {
102
+ const live = await liveScopes(row.subjectId);
103
+ if (live === null)
166
104
  return null;
105
+ return (0, scope_escalation_js_1.intersectScopes)(row.scopes, live, deps.deviceScopeLookup);
106
+ };
107
+ const unifiedToken = async (claims, facts) => {
167
108
  const rows = deps.tokenRows();
168
109
  if (rows === null)
169
110
  return null;
170
111
  // 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)
112
+ const row = await rows.lookupLive(claims.jti).catch(() => null);
113
+ if (row === null || row.subjectId !== claims.sub)
173
114
  return null;
174
115
  // The subject must still exist; an admin token dies with its subject's admin bit.
175
116
  const subject = await subjectFacts(row.subjectId);
@@ -177,33 +118,47 @@ function createCredentialVerifier(deps) {
177
118
  return null;
178
119
  if ((0, types_1.scopesGrantAdmin)(row.scopes) && !subject.isAdmin)
179
120
  return null;
121
+ // The ROW's scopes, never the claim's: the row is what the operator listed.
122
+ // A non-admin's token never outgrows its subject: an admin who narrows the
123
+ // user narrows every token the user minted, at the next verify.
124
+ const scopes = subject.isAdmin ? row.scopes : await narrowedToSubject(row);
125
+ if (scopes === null || scopes.length === 0)
126
+ return null;
180
127
  const principal = {
181
128
  subjectId: row.subjectId,
182
129
  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),
130
+ scopes,
131
+ isAdmin: (0, types_1.scopesGrantAdmin)(scopes),
186
132
  credentialId: row.id,
187
133
  auditTag: 'token',
188
134
  interactive: false,
189
135
  userDelegated: false,
190
- legacy: false,
191
136
  };
192
137
  if (principal.isAdmin)
193
138
  auditAdminUse(principal, facts, row.label);
194
139
  return principal;
195
140
  };
196
141
  // ── Session and account-link JWTs ────────────────────────────────────
142
+ /** A minted token's claims, or `null` when `token` is not one (D895: its own audience). */
143
+ const credentialClaims = (token) => {
144
+ try {
145
+ return deps.verifyCredentialJwt(token);
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ };
197
151
  const hubJwt = async (token, facts) => {
198
152
  let payload;
199
153
  try {
200
154
  payload = deps.verifyJwt(token);
201
155
  }
202
156
  catch {
203
- return null;
157
+ // Not a session-family JWT. A minted token is refused by `verifyJwt` by
158
+ // construction (its audience), and verifies only through its own door.
159
+ const claims = credentialClaims(token);
160
+ return claims === null ? null : unifiedToken(claims, facts);
204
161
  }
205
- if (payload.kind === types_1.CREDENTIAL_TOKEN_KIND)
206
- return unifiedToken(payload, facts);
207
162
  // A pre-v2 JWT (no `isAdmin`) would degrade into "non-admin, no scopes";
208
163
  // refuse it so the client signs in again.
209
164
  if (typeof payload.isAdmin !== 'boolean')
@@ -223,7 +178,12 @@ function createCredentialVerifier(deps) {
223
178
  const username = stringClaim(payload.username) ?? 'unknown';
224
179
  if (classified.kind === 'integration') {
225
180
  // 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.
181
+ // never by the linking user's record (2026-08-19), and never admin. Its
182
+ // subject must still be a user: a link minted for nobody (an empty
183
+ // `userId`) or for a deleted account authenticates nobody.
184
+ const subject = userId === 'unknown' || userId === '' ? null : await subjectFacts(userId);
185
+ if (subject === null || !subject.exists)
186
+ return null;
227
187
  const identity = (0, trpc_error_principal_js_1.readCredentialIdentity)(payload);
228
188
  return {
229
189
  subjectId: userId,
@@ -234,7 +194,6 @@ function createCredentialVerifier(deps) {
234
194
  auditTag: 'integration',
235
195
  interactive: false,
236
196
  userDelegated: true,
237
- legacy: false,
238
197
  ...(identity.sessionId !== undefined ? { oauthSessionId: identity.sessionId } : {}),
239
198
  ...(identity.credential !== undefined ? { credential: identity.credential } : {}),
240
199
  };
@@ -247,26 +206,26 @@ function createCredentialVerifier(deps) {
247
206
  auditTag: 'session',
248
207
  interactive: true,
249
208
  userDelegated: true,
250
- legacy: false,
251
209
  ...(sessionId !== undefined ? { sessionId } : {}),
252
210
  };
253
- if (payload.isAdmin)
254
- return { ...base, scopes: [types_1.ADMIN_SCOPE], isAdmin: true };
211
+ // A person's session always reaches its OWN account preferences (D894);
212
+ // `'*'` does not imply that grant, so the admin session names it too.
213
+ if (payload.isAdmin) {
214
+ return { ...base, scopes: [types_1.ADMIN_SCOPE, types_1.PREFERENCES_SELF_SCOPE], isAdmin: true };
215
+ }
255
216
  const fresh = await liveScopes(userId);
256
217
  const capability = fresh ?? (0, types_1.normalizeTokenScopes)(payload.scopes ?? []);
257
- return { ...base, scopes: [...capability, SESSION_ADDON_ROUTES], isAdmin: false };
218
+ return {
219
+ ...base,
220
+ scopes: [...capability, SESSION_ADDON_ROUTES, types_1.PREFERENCES_SELF_SCOPE],
221
+ isAdmin: false,
222
+ };
258
223
  };
259
224
  return {
260
225
  async verify(bearer, facts = {}) {
261
226
  if (bearer === null || bearer === undefined || bearer === '')
262
227
  return null;
263
228
  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
229
  return await hubJwt(bearer, facts);
271
230
  }
272
231
  catch {
@@ -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
+ }