@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
@@ -14,6 +14,7 @@ const cap_route_error_formatter_js_1 = require("./cap-route-error-formatter.js")
14
14
  const device_config_secret_redaction_js_1 = require("./device-config-secret-redaction.js");
15
15
  const principal_visibility_js_1 = require("./principal-visibility.js");
16
16
  const localized_schema_paths_js_1 = require("./localized-schema-paths.js");
17
+ const principal_authorization_js_1 = require("./principal-authorization.js");
17
18
  const scope_access_js_1 = require("./scope-access.js");
18
19
  const share_view_access_js_1 = require("./share-view-access.js");
19
20
  const user_error_js_1 = require("./user-error.js");
@@ -91,24 +92,20 @@ exports.publicProcedure = t.procedure;
91
92
  */
92
93
  exports.createCallerFactory = t.createCallerFactory;
93
94
  /**
94
- * Caps-only authenticated procedure (v2).
95
+ * The authenticated procedure (D892).
95
96
  *
96
- * - `isAdmin: true` → pass-through. Admin's `scopes` field is ignored.
97
- * - `isAdmin: false` → `METHOD_ACCESS_MAP[path]` lookup + scope match.
98
- * The caller's scope set must grant the required (capName, access)
99
- * pair via one of the three forms (`category`/`capability`/`addon`).
97
+ * The principal's `scopes` decide, through ONE matcher
98
+ * (`authorizeProcedure`, `principal-authorization.ts`):
100
99
  *
101
- * Hand-written core routers (`auth.*`, `system.info`, etc.) are not
102
- * codegen'd from cap definitions and therefore not in
103
- * `METHOD_ACCESS_MAP`. Those routes carry their own gating via
104
- * `adminProcedure` when destructive; the bare `protectedProcedure`
105
- * authentication check is the only gate. The middleware skips the
106
- * scope-check for unknown paths so the SDK boot probe (`auth.me`) and
107
- * `system.info` reach non-admin / scoped-token callers — without
108
- * pulling every core route into the codegen map.
100
+ * - a `share-view` grant restricts it to the fail-closed embed allowlist
101
+ * (`share-view-access.ts`) — enumerated methods, in-scope devices only —
102
+ * and its answers are projected to that perimeter;
103
+ * - `'*'` passes;
104
+ * - otherwise a cap route (`METHOD_ACCESS_MAP`) is scope-matched, and a
105
+ * hand-written core route (`auth.*`, `system.info` …) needs authentication
106
+ * only — `adminProcedure` on top gates what is destructive.
109
107
  *
110
- * Single source of authority for caps: `isAdmin`. The legacy role enum
111
- * collapsed onto this boolean in v2.
108
+ * What kind of credential the principal came from is never read here.
112
109
  */
113
110
  exports.protectedProcedure = localized.use(async ({ ctx, next, path, getRawInput }) => {
114
111
  if (!ctx.user) {
@@ -120,93 +117,55 @@ exports.protectedProcedure = localized.use(async ({ ctx, next, path, getRawInput
120
117
  if (ctx.sessionLive !== undefined && !ctx.sessionLive()) {
121
118
  throw (0, user_error_js_1.trpcUserError)('UNAUTHORIZED', 'auth.session-ended', undefined, 'Session ended — sign in again');
122
119
  }
123
- // Share-view principals (`csv_*` tokens) NEVER reach the admin bypass,
124
- // the hand-written-route pass-through, or the scope matcher below —
125
- // they are gated exclusively by the fail-closed embed-surface allowlist
126
- // (`share-view-access.ts`): enumerated methods only, in-scope devices only.
127
- if (ctx.user.shareView) {
128
- const rawInput = await getRawInput();
129
- const result = (0, share_view_access_js_1.checkShareViewAccess)(ctx.user.shareView.scope, path, rawInput);
130
- if (!result.ok) {
131
- throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
132
- }
133
- // `deviceManager.listAll` is allowlisted for the embed's tile names,
134
- // but its raw rows describe EVERY device (config incl. credentials).
135
- // Project the output down to the in-scope whitelisted rows.
120
+ const user = ctx.user;
121
+ const verdict = (0, principal_authorization_js_1.authorizeProcedure)(user.scopes ?? [], path, await getRawInput(), ctx.deviceScopeLookup);
122
+ if (!verdict.ok) {
123
+ throw new server_1.TRPCError({ code: 'FORBIDDEN', message: verdict.reason });
124
+ }
125
+ // Spread+reassign of `user` narrows downstream ctx from `User | null` to
126
+ // `User` so `adminProcedure` can read fields without re-checking.
127
+ if (verdict.mode === 'all') {
128
+ return next({ ctx: { ...ctx, user } });
129
+ }
130
+ if (verdict.mode === 'share') {
131
+ const grant = verdict.grant;
132
+ // `deviceManager.listAll` is allowlisted for the embed's tile names, but
133
+ // its raw rows describe EVERY device (config incl. credentials): project
134
+ // it down to the in-scope whitelisted rows. The events surface answers
135
+ // with ROWS carrying a `deviceId`, and two of its methods can reach
136
+ // devices outside the scope — this is where that answer is cut.
136
137
  if (path === 'deviceManager.listAll') {
137
- const out = await next({ ctx: { ...ctx, user: ctx.user } });
138
- if (out.ok) {
139
- return { ...out, data: (0, share_view_access_js_1.projectListAllForShareScope)(out.data, ctx.user.shareView.scope) };
140
- }
141
- return out;
138
+ const out = await next({ ctx: { ...ctx, user } });
139
+ return out.ok ? { ...out, data: (0, share_view_access_js_1.projectListAllForShareScope)(out.data, grant) } : out;
142
140
  }
143
- // The events surface answers with ROWS carrying a `deviceId`, and two of
144
- // its three methods can legitimately reach devices outside the scope: a
145
- // partially in-scope `listRecentTracks`, and a `searchObjectEvents` with
146
- // no `deviceId` at all (which means EVERY camera). The enumeration lets
147
- // those calls through — this is where the answer is cut. Without it the
148
- // allowlist would be a doorman who checks the ticket and then hands over
149
- // the whole building.
150
141
  if (share_view_access_js_1.SHARE_SCOPE_PROJECTED_METHODS.has(path)) {
151
- const out = await next({ ctx: { ...ctx, user: ctx.user } });
152
- if (out.ok) {
153
- return { ...out, data: (0, share_view_access_js_1.projectShareScopeRows)(out.data, ctx.user.shareView.scope) };
154
- }
155
- return out;
156
- }
157
- return next({ ctx: { ...ctx, user: ctx.user } });
158
- }
159
- // Spread+reassign of `user` narrows downstream ctx from `User | null`
160
- // to `User` so `adminProcedure` can read fields
161
- // without re-checking.
162
- if (ctx.user.isAdmin) {
163
- return next({ ctx: { ...ctx, user: ctx.user } });
164
- }
165
- // Everything below this line is a NON-ADMIN principal.
166
- //
167
- // A hand-written core route has no cap entry. Authentication has
168
- // already passed; defer further gating to any explicit
169
- // `adminProcedure` chained on top of this one. A cap route is
170
- // scope-matched first.
171
- if (path in system_1.METHOD_ACCESS_MAP) {
172
- // Device-scope caps may be gated by a `device:N` scope. Resolve the
173
- // raw input once so the matcher can read `input.deviceId` without
174
- // re-doing the Zod parse (tRPC caches the parsed input downstream).
175
- // The `getDeviceAncestors` hook lets the matcher walk parent → child
176
- // accessory inheritance (grant on Reolink also covers its siren / PIR).
177
- const rawInput = await getRawInput();
178
- const result = (0, scope_access_js_1.checkScopeAccess)(ctx.user.scopes ?? [], path, rawInput, ctx.deviceScopeLookup);
179
- if (!result.ok) {
180
- throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
142
+ const out = await next({ ctx: { ...ctx, user } });
143
+ return out.ok ? { ...out, data: (0, share_view_access_js_1.projectShareScopeRows)(out.data, grant) } : out;
181
144
  }
145
+ return next({ ctx: { ...ctx, user } });
182
146
  }
183
147
  // A non-admin who is allowed to READ a device is not thereby allowed to
184
148
  // read the credential that opens it. `deviceManager.getDevice` &co. are
185
149
  // `auth: 'protected'`, so a read-only `category:system` grant reaches
186
- // them — and the raw row carries `config.password` in plaintext for
187
- // every camera in the deployment. The value is withheld HERE, at the
188
- // transport boundary, so in-process callers (`ctx.api`, the pipeline
189
- // orchestrator, the drivers that must actually connect) are unaffected.
150
+ // them — and the raw row carries `config.password` in plaintext. The value
151
+ // is withheld HERE, at the transport boundary, so in-process callers
152
+ // (`ctx.api`, the drivers that must actually connect) are unaffected.
190
153
  const redactConfigSecrets = device_config_secret_redaction_js_1.NON_ADMIN_CONFIG_REDACTED_METHODS.get(path);
191
- const out = await next({ ctx: { ...ctx, user: ctx.user } });
154
+ const out = await next({ ctx: { ...ctx, user } });
192
155
  if (!out.ok)
193
156
  return out;
194
157
  // A device-scoped principal ENUMERATES only its own devices (F1 #4). The
195
158
  // matcher lets a `device`-selector grant reach `deviceManager.listAll`
196
- // precisely because the answer is cut here; without this the grant would
197
- // hand back the whole fleet — names, locations, online state.
159
+ // precisely because the answer is cut here.
198
160
  if (scope_access_js_1.DEVICE_ENUMERATION_METHODS.has(path)) {
199
- const projected = (0, principal_visibility_js_1.projectListAllForPrincipal)(out.data, ctx.user.isAdmin, ctx.user.scopes ?? [], ctx.deviceFleet?.() ?? []);
161
+ const projected = (0, principal_visibility_js_1.projectListAllForPrincipal)(out.data, false, verdict.scopes, ctx.deviceFleet?.() ?? []);
200
162
  return {
201
163
  ...out,
202
164
  data: redactConfigSecrets ? redactConfigSecrets(projected) : projected,
203
165
  };
204
166
  }
205
- // Batch device methods (`deviceIds[]` / `targets[].deviceId` inputs — the
206
- // snapshot overview/links and the merged events feed) need no response
207
- // projection: the matcher already required EVERY device the request names
208
- // to be covered (see `referencedDeviceIds`), so the answer can only
209
- // contain rows the caller is entitled to.
167
+ // Batch device methods need no response projection: the matcher already
168
+ // required EVERY device the request names to be covered.
210
169
  if (redactConfigSecrets) {
211
170
  return { ...out, data: redactConfigSecrets(out.data) };
212
171
  }
@@ -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");
@@ -351,17 +352,22 @@ 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
  : {}),
364
369
  ...(services.sessionBacking !== undefined ? { sessions: services.sessionBacking } : {}),
370
+ credentialTokens: services.credentialTokenService ?? null,
365
371
  logger: services.loggingService.createLogger('auth'),
366
372
  }),
367
373
  // NOT MOUNTED — `mount: { kind: 'skip' }` legacy provider shapes
@@ -1,32 +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
- const scope_access_js_1 = require("./trpc/scope-access.js");
6
- exports.UPLOAD_TRPC_PATH = 'addons.installPackage';
7
4
  /**
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.
5
+ * Shared auth guard for the REST upload routes (`/api/addons/upload` and
6
+ * `/api/server-upload`).
7
+ *
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).
11
13
  */
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
- }
14
+ const trpc_context_js_1 = require("./trpc/trpc.context.js");
25
15
  /**
26
- * Run the full upload auth chain. Returns `{ok: true}` on success, or the
27
- * HTTP status + error message the route should reply with:
28
- * - 401 `Unauthorized` when no Authorization header is present,
29
- * - 403 `Forbidden: <reason>` for any recognised-but-insufficient token.
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.
30
20
  */
31
21
  async function authorizeUploadRequest(args) {
32
22
  const { authHeader, authService, addonRegistry } = args;
@@ -34,36 +24,11 @@ async function authorizeUploadRequest(args) {
34
24
  return { ok: false, status: 401, error: 'Unauthorized' };
35
25
  }
36
26
  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)}`;
27
+ const principal = await (0, trpc_context_js_1.hubCredentialVerifier)({ authService, addonRegistry }).verify(token);
28
+ if (principal === null) {
29
+ return { ok: false, status: 401, error: 'Unauthorized: token not recognised' };
63
30
  }
64
- return {
65
- ok: false,
66
- status: 403,
67
- error: `Forbidden: ${authReason ?? 'admin or upload-scoped token required'}`,
68
- };
31
+ if (!principal.isAdmin)
32
+ return { ok: false, status: 403, error: 'Forbidden: admin only' };
33
+ return { ok: true };
69
34
  }
@@ -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 share-link 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
+ * scoped tokens 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,236 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createCredentialVerifier = createCredentialVerifier;
4
+ const types_1 = require("@camstack/types");
5
+ const trpc_error_principal_js_1 = require("../api/trpc/trpc-error-principal.js");
6
+ const scope_escalation_js_1 = require("./scope-escalation.js");
7
+ function stringClaim(value) {
8
+ return typeof value === 'string' ? value : undefined;
9
+ }
10
+ /** What a non-admin person's session reaches on addon routes: every flavour, as before D892. */
11
+ const SESSION_ADDON_ROUTES = {
12
+ type: 'addon-routes',
13
+ access: [...types_1.ALL_METHOD_ACCESS],
14
+ };
15
+ // ── Live scopes for a non-admin session ────────────────────────────────
16
+ /**
17
+ * The JWT embeds scopes at login, so an admin's scope edit reached a live
18
+ * session only at the next login (2026-08-15). The store is re-read per user,
19
+ * memoized: a change propagates within the TTL and a busy session costs one
20
+ * store read per window. The JWT's copy is the fallback when the store is
21
+ * unreachable, so a hiccup never locks anybody out mid-request.
22
+ */
23
+ const SCOPE_REFRESH_TTL_MS = 30_000;
24
+ const SCOPE_REFRESH_MISS_TTL_MS = 5_000;
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;
33
+ function createCredentialVerifier(deps) {
34
+ const now = deps.now ?? Date.now;
35
+ const scopeMemo = deps.scopeMemo ?? new Map();
36
+ const usersMemo = deps.usersMemo ?? { entry: null };
37
+ const listUsersOnce = async () => {
38
+ const at = now();
39
+ const users = deps.users();
40
+ if (users === null || typeof users.listUsers !== 'function')
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
+ }
46
+ try {
47
+ const fresh = await users.listUsers();
48
+ usersMemo.entry = { at, source: users, users: fresh };
49
+ return fresh;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ };
55
+ const liveScopes = async (userId) => {
56
+ const at = now();
57
+ const cached = scopeMemo.get(userId);
58
+ if (cached !== undefined &&
59
+ at - cached.at < (cached.scopes === null ? SCOPE_REFRESH_MISS_TTL_MS : SCOPE_REFRESH_TTL_MS)) {
60
+ return cached.scopes;
61
+ }
62
+ const fresh = (await listUsersOnce())?.find((u) => u.id === userId);
63
+ const scopes = fresh === undefined ? null : (0, types_1.normalizeTokenScopes)(fresh.scopes ?? []);
64
+ scopeMemo.set(userId, { at, scopes });
65
+ if (scopeMemo.size > SCOPE_MEMO_MAX) {
66
+ for (const [key, entry] of scopeMemo) {
67
+ if (at - entry.at > SCOPE_REFRESH_TTL_MS * 4)
68
+ scopeMemo.delete(key);
69
+ }
70
+ }
71
+ return scopes;
72
+ };
73
+ const subjectFacts = async (userId) => {
74
+ const all = await listUsersOnce();
75
+ if (all === null)
76
+ return null;
77
+ const user = all.find((u) => u.id === userId);
78
+ return user === undefined
79
+ ? { exists: false, isAdmin: false }
80
+ : { exists: true, isAdmin: user.isAdmin };
81
+ };
82
+ const auditAdminUse = (principal, facts, name) => {
83
+ const internal = deps.internal();
84
+ if (internal === null)
85
+ return;
86
+ void internal.audit
87
+ .record({
88
+ action: 'admin-token-used',
89
+ userId: principal.subjectId,
90
+ ...(facts.ip !== undefined ? { ip: facts.ip } : {}),
91
+ detail: {
92
+ tokenId: principal.credentialId,
93
+ name,
94
+ ...(facts.path !== undefined ? { path: facts.path } : {}),
95
+ },
96
+ })
97
+ .catch(() => { });
98
+ };
99
+ // ── The unified token ────────────────────────────────────────────────
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)
104
+ return null;
105
+ return (0, scope_escalation_js_1.intersectScopes)(row.scopes, live, deps.deviceScopeLookup);
106
+ };
107
+ const unifiedToken = async (claims, facts) => {
108
+ const rows = deps.tokenRows();
109
+ if (rows === null)
110
+ return null;
111
+ // Revocation and expiry live on the row, looked up by `jti` every time.
112
+ const row = await rows.lookupLive(claims.jti).catch(() => null);
113
+ if (row === null || row.subjectId !== claims.sub)
114
+ return null;
115
+ // The subject must still exist; an admin token dies with its subject's admin bit.
116
+ const subject = await subjectFacts(row.subjectId);
117
+ if (subject === null || !subject.exists)
118
+ return null;
119
+ if ((0, types_1.scopesGrantAdmin)(row.scopes) && !subject.isAdmin)
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;
127
+ const principal = {
128
+ subjectId: row.subjectId,
129
+ username: `token:${row.label}`,
130
+ scopes,
131
+ isAdmin: (0, types_1.scopesGrantAdmin)(scopes),
132
+ credentialId: row.id,
133
+ auditTag: 'token',
134
+ interactive: false,
135
+ userDelegated: false,
136
+ };
137
+ if (principal.isAdmin)
138
+ auditAdminUse(principal, facts, row.label);
139
+ return principal;
140
+ };
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
+ };
151
+ const hubJwt = async (token, facts) => {
152
+ let payload;
153
+ try {
154
+ payload = deps.verifyJwt(token);
155
+ }
156
+ catch {
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);
161
+ }
162
+ // A pre-v2 JWT (no `isAdmin`) would degrade into "non-admin, no scopes";
163
+ // refuse it so the client signs in again.
164
+ if (typeof payload.isAdmin !== 'boolean')
165
+ return null;
166
+ // Every token this hub mints verifies under one key, so a valid signature
167
+ // means "we minted this", never "this is an API credential" (D103). Only a
168
+ // session and an `oauth-access` link are credentials; a code, a refresh
169
+ // token, a bridge, a TOTP challenge are refused.
170
+ const classified = (0, types_1.classifyBearerPrincipal)(payload);
171
+ if (classified.kind === 'not-a-credential') {
172
+ deps.logger?.warn('auth: refused a bearer that is not an API credential', {
173
+ meta: { reason: classified.reason, userId: stringClaim(payload.userId) ?? 'unknown' },
174
+ });
175
+ return null;
176
+ }
177
+ const userId = stringClaim(payload.userId) ?? 'unknown';
178
+ const username = stringClaim(payload.username) ?? 'unknown';
179
+ if (classified.kind === 'integration') {
180
+ // An account link is governed by the grant the operator consented to,
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;
187
+ const identity = (0, trpc_error_principal_js_1.readCredentialIdentity)(payload);
188
+ return {
189
+ subjectId: userId,
190
+ username,
191
+ scopes: (0, types_1.normalizeTokenScopes)(payload.scopes ?? []),
192
+ isAdmin: false,
193
+ credentialId: identity.sessionId ?? `oauth:${userId}`,
194
+ auditTag: 'integration',
195
+ interactive: false,
196
+ userDelegated: true,
197
+ ...(identity.sessionId !== undefined ? { oauthSessionId: identity.sessionId } : {}),
198
+ ...(identity.credential !== undefined ? { credential: identity.credential } : {}),
199
+ };
200
+ }
201
+ const sessionId = stringClaim(payload.sid);
202
+ const base = {
203
+ subjectId: userId,
204
+ username,
205
+ credentialId: sessionId ?? `session:${userId}`,
206
+ auditTag: 'session',
207
+ interactive: true,
208
+ userDelegated: true,
209
+ ...(sessionId !== undefined ? { sessionId } : {}),
210
+ };
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
+ }
216
+ const fresh = await liveScopes(userId);
217
+ const capability = fresh ?? (0, types_1.normalizeTokenScopes)(payload.scopes ?? []);
218
+ return {
219
+ ...base,
220
+ scopes: [...capability, SESSION_ADDON_ROUTES, types_1.PREFERENCES_SELF_SCOPE],
221
+ isAdmin: false,
222
+ };
223
+ };
224
+ return {
225
+ async verify(bearer, facts = {}) {
226
+ if (bearer === null || bearer === undefined || bearer === '')
227
+ return null;
228
+ try {
229
+ return await hubJwt(bearer, facts);
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ },
235
+ };
236
+ }