@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
@@ -1,55 +1,39 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveWhoami = resolveWhoami;
3
4
  exports.registerAuthWhoamiRoute = registerAuthWhoamiRoute;
5
+ const trpc_context_js_1 = require("./trpc/trpc.context.js");
6
+ /** The answer for `bearer`, or `null` when it authenticates nobody. */
7
+ async function resolveWhoami(deps, bearer) {
8
+ const principal = await (0, trpc_context_js_1.hubCredentialVerifier)(deps).verify(bearer);
9
+ if (principal === null)
10
+ return null;
11
+ return {
12
+ ok: true,
13
+ kind: principal.userDelegated ? 'jwt' : 'scoped',
14
+ userId: principal.subjectId,
15
+ username: principal.username,
16
+ isAdmin: principal.isAdmin,
17
+ scopes: principal.scopes,
18
+ credentialId: principal.credentialId,
19
+ credential: principal.auditTag,
20
+ };
21
+ }
4
22
  async function registerAuthWhoamiRoute(fastify, authService, addonRegistry) {
5
23
  fastify.get('/api/auth/whoami', async (request, reply) => {
6
24
  const authHeader = request.headers.authorization;
7
25
  if (!authHeader) {
8
- return reply.status(401).send({ ok: false, error: 'No Authorization header' });
26
+ const refused = { ok: false, error: 'No Authorization header' };
27
+ return reply.status(401).send(refused);
9
28
  }
10
- const token = authHeader.replace('Bearer ', '');
11
- // JWT fast path — same logic as addon-upload auth chain.
12
- try {
13
- const payload = authService.verifyToken(token);
14
- const ok = {
15
- ok: true,
16
- kind: 'jwt',
17
- userId: payload.userId ?? 'unknown',
18
- username: payload.username ?? 'unknown',
19
- isAdmin: payload.isAdmin,
29
+ const answer = await resolveWhoami({ authService, addonRegistry }, authHeader.replace('Bearer ', ''));
30
+ if (answer === null) {
31
+ const refused = {
32
+ ok: false,
33
+ error: 'Token not recognised (revoked, expired, or never issued)',
20
34
  };
21
- return reply.send(ok);
22
- }
23
- catch {
24
- // Not a JWT — try scoped token via cap registry.
25
- }
26
- try {
27
- const capRegistry = addonRegistry.getCapabilityRegistry();
28
- const userMgmt = capRegistry.getSingleton('user-management');
29
- if (!userMgmt) {
30
- return reply.status(503).send({ ok: false, error: 'user-management cap not mounted' });
31
- }
32
- const record = await userMgmt.validateScopedToken({ token });
33
- if (!record) {
34
- return reply
35
- .status(401)
36
- .send({ ok: false, error: 'Token not recognised (revoked, expired, or never issued)' });
37
- }
38
- const ok = {
39
- ok: true,
40
- kind: 'scoped',
41
- userId: record.userId,
42
- username: record.userId,
43
- tokenPrefix: record.tokenPrefix,
44
- scopes: record.scopes,
45
- expiresAt: record.expiresAt ?? null,
46
- createdAt: record.createdAt,
47
- };
48
- return reply.send(ok);
49
- }
50
- catch (err) {
51
- const msg = err instanceof Error ? err.message : String(err);
52
- return reply.status(500).send({ ok: false, error: `Validation failed: ${msg}` });
35
+ return reply.status(401).send(refused);
53
36
  }
37
+ return reply.send(answer);
54
38
  });
55
39
  }
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AuthAuditRecordSchema = void 0;
4
+ exports.createAuthAuditProcedures = createAuthAuditProcedures;
5
+ const server_1 = require("@trpc/server");
6
+ const zod_1 = require("zod");
7
+ const hub_sessions_js_1 = require("../../auth/hub-sessions.js");
8
+ const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
9
+ exports.AuthAuditRecordSchema = zod_1.z.object({
10
+ id: zod_1.z.string(),
11
+ at: zod_1.z.number(),
12
+ action: zod_1.z.string(),
13
+ userId: zod_1.z.string().optional(),
14
+ ip: zod_1.z.string().optional(),
15
+ detail: zod_1.z.record(zod_1.z.string(), zod_1.z.union([zod_1.z.string(), zod_1.z.number(), zod_1.z.boolean()])).optional(),
16
+ });
17
+ function createAuthAuditProcedures(deps) {
18
+ return {
19
+ /** ADMIN — the authentication audit trail, newest first. */
20
+ listAuthAudit: trpc_middleware_js_1.adminProcedure
21
+ .input(zod_1.z
22
+ .object({
23
+ limit: zod_1.z.number().int().min(1).max(500).optional(),
24
+ userId: zod_1.z.string().min(1).optional(),
25
+ })
26
+ .optional())
27
+ .output(zod_1.z.array(exports.AuthAuditRecordSchema))
28
+ .query(async ({ input }) => {
29
+ const internal = deps.registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(deps.registry);
30
+ if (internal === null) {
31
+ throw new server_1.TRPCError({
32
+ code: 'PRECONDITION_FAILED',
33
+ message: 'User management is not available',
34
+ });
35
+ }
36
+ return [
37
+ ...(await internal.audit.list({
38
+ ...(input?.limit !== undefined ? { limit: input.limit } : {}),
39
+ ...(input?.userId !== undefined ? { userId: input.userId } : {}),
40
+ })),
41
+ ];
42
+ }),
43
+ };
44
+ }
@@ -110,7 +110,7 @@ function createDeviceCertProcedures(deps) {
110
110
  }
111
111
  };
112
112
  const assertPerson = (user) => {
113
- if (user.isScoped === true || user.shareView !== undefined || user.adminTokenId !== undefined) {
113
+ if (user.userDelegated !== true) {
114
114
  throw (0, user_error_js_1.trpcUserError)('FORBIDDEN', 'auth.session-required', undefined, 'Requires a signed-in user session');
115
115
  }
116
116
  };
@@ -31,7 +31,7 @@ function isPasswordChecker(value) {
31
31
  }
32
32
  /** Real user sessions only: a scoped or share token cannot prove a password. */
33
33
  function assertPerson(user) {
34
- if (user.isScoped === true || user.shareView !== undefined || user.adminTokenId !== undefined) {
34
+ if (user.userDelegated !== true) {
35
35
  throw (0, user_error_js_1.trpcUserError)('FORBIDDEN', 'auth.session-required', undefined, 'Requires a signed-in user session');
36
36
  }
37
37
  }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RevokeSkipReasonSchema = exports.RevocableCredentialKindSchema = exports.REVOKE_CREDENTIALS_MAX = void 0;
3
+ exports.RevokeSkipReasonSchema = exports.REVOKE_CREDENTIALS_MAX = void 0;
4
4
  exports.createRevokeCredentialsProcedures = createRevokeCredentialsProcedures;
5
5
  const server_1 = require("@trpc/server");
6
6
  const zod_1 = require("zod");
@@ -8,18 +8,9 @@ const hub_sessions_js_1 = require("../../auth/hub-sessions.js");
8
8
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
9
9
  /** Upper bound of one bulk call — a page of a table, several times over. */
10
10
  exports.REVOKE_CREDENTIALS_MAX = 500;
11
- /** What a bulk revoke can target. Each has a per-row revoke on the server. */
12
- exports.RevocableCredentialKindSchema = zod_1.z.enum([
13
- 'share-link',
14
- 'admin-token',
15
- 'login-session',
16
- 'scoped-token',
17
- 'oauth-session',
18
- ]);
19
11
  exports.RevokeSkipReasonSchema = zod_1.z.enum(['current-session', 'not-active']);
20
12
  const RevokeCredentialsInputSchema = zod_1.z
21
13
  .object({
22
- kind: exports.RevocableCredentialKindSchema,
23
14
  ids: zod_1.z.array(zod_1.z.string().min(1)).min(1).max(exports.REVOKE_CREDENTIALS_MAX),
24
15
  /** The count the operator confirmed. Must equal `ids.length`. */
25
16
  confirmCount: zod_1.z.number().int().min(1),
@@ -35,53 +26,35 @@ const RevokeCredentialsOutputSchema = zod_1.z.object({
35
26
  /** Keep the audit row bounded; the ids beyond this are counted, not listed. */
36
27
  const AUDIT_IDS_MAX_CHARS = 4000;
37
28
  function assertSignedInAdminSession(user) {
38
- if (user.isScoped === true ||
39
- user.shareView !== undefined ||
40
- user.credential !== undefined ||
41
- user.adminTokenId !== undefined) {
29
+ if (user.interactive !== true) {
42
30
  throw new server_1.TRPCError({
43
31
  code: 'FORBIDDEN',
44
32
  message: 'Revoking credentials in bulk needs a signed-in admin session',
45
33
  });
46
34
  }
47
35
  }
48
- function requireInternal(registry) {
49
- const internal = registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(registry);
50
- if (internal === null) {
51
- throw new server_1.TRPCError({
52
- code: 'PRECONDITION_FAILED',
53
- message: 'User management is not available',
54
- });
55
- }
56
- return internal;
57
- }
58
- /** The per-row revoke for `kind`, bound to the caller. `null` = revoked. */
59
- function revokerFor(kind, caller, deps) {
60
- if (kind === 'share-link') {
61
- const service = deps.shareTokens;
62
- if (service === null) {
63
- throw new server_1.TRPCError({ code: 'PRECONDITION_FAILED', message: 'Share links are not available' });
64
- }
65
- return async (id) => (await service.revoke({ id, callerUserId: caller.id, callerIsAdmin: true }))
66
- ? null
67
- : 'not-active';
68
- }
69
- const internal = requireInternal(deps.registry);
70
- if (kind === 'admin-token') {
71
- return async (id) => ((await internal.adminTokens.revoke(id)) ? null : 'not-active');
72
- }
73
- if (kind === 'scoped-token') {
74
- return async (id) => ((await internal.scopedTokens.revoke(id)) ? null : 'not-active');
75
- }
76
- if (kind === 'oauth-session') {
77
- return async (id) => ((await internal.oauthSessions.revoke(id)) ? null : 'not-active');
78
- }
36
+ /**
37
+ * Revoke one credential BY ITS ID, whatever it is (D892). Every store is asked
38
+ * in turn; ids are random UUIDs per store, so at most one knows it. The
39
+ * caller's own credential is never revoked here.
40
+ */
41
+ function revokerById(caller, deps) {
42
+ const internal = deps.registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(deps.registry);
79
43
  return async (id) => {
80
- if (id === caller.sessionId)
44
+ if (id === caller.sessionId || id === caller.credentialId)
81
45
  return 'current-session';
82
- if (internal.sessions.check(id).state !== 'active')
83
- return 'not-active';
84
- return (await internal.sessions.revoke(id, 'revoked-by-admin')) ? null : 'not-active';
46
+ const tokens = deps.credentialTokens ?? null;
47
+ if (tokens !== null && (await tokens.get(id)) !== null) {
48
+ return (await tokens.revoke(id)) ? null : 'not-active';
49
+ }
50
+ if (internal !== null) {
51
+ if (internal.sessions.check(id).state === 'active') {
52
+ return (await internal.sessions.revoke(id, 'revoked-by-admin')) ? null : 'not-active';
53
+ }
54
+ if (await internal.oauthSessions.revoke(id))
55
+ return null;
56
+ }
57
+ return 'not-active';
85
58
  };
86
59
  }
87
60
  function auditIds(ids) {
@@ -90,7 +63,7 @@ function auditIds(ids) {
90
63
  }
91
64
  function createRevokeCredentialsProcedures(deps) {
92
65
  return {
93
- /** ADMIN — revoke exactly `ids` of one kind; see the module header. */
66
+ /** ADMIN — revoke exactly `ids`, by credential id; see the module header. */
94
67
  revokeCredentials: trpc_middleware_js_1.adminProcedure
95
68
  .input(RevokeCredentialsInputSchema)
96
69
  .output(RevokeCredentialsOutputSchema)
@@ -102,7 +75,7 @@ function createRevokeCredentialsProcedures(deps) {
102
75
  message: `Confirmed ${input.confirmCount} but ${input.ids.length} were listed — nothing was revoked`,
103
76
  });
104
77
  }
105
- const revokeOne = revokerFor(input.kind, ctx.user, deps);
78
+ const revokeOne = revokerById(ctx.user, deps);
106
79
  const revoked = [];
107
80
  const skipped = [];
108
81
  // Sequential on purpose: the stores are single-writer, and a partial
@@ -122,7 +95,7 @@ function createRevokeCredentialsProcedures(deps) {
122
95
  userId: ctx.user.id,
123
96
  ...(ip !== null ? { ip } : {}),
124
97
  detail: {
125
- kind: input.kind,
98
+ kind: 'by-id',
126
99
  requested: input.ids.length,
127
100
  revoked: revoked.length,
128
101
  skipped: skipped.length,
@@ -51,7 +51,7 @@ function requireSessions(registry) {
51
51
  }
52
52
  /** Real user sessions only — a scoped or share token has no login session to manage. */
53
53
  function assertLoginSession(user) {
54
- if (user.isScoped === true || user.shareView !== undefined || user.adminTokenId !== undefined) {
54
+ if (user.userDelegated !== true) {
55
55
  throw (0, user_error_js_1.trpcUserError)('FORBIDDEN', 'auth.session-required', undefined, 'Requires a signed-in user session');
56
56
  }
57
57
  }
@@ -0,0 +1,283 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TokenSummarySchema = exports.ExtendTokenInputSchema = exports.ListTokensInputSchema = exports.MintTokenInputSchema = exports.TokenExpiresInSchema = exports.TOKEN_MAX_LIFETIME_SEC = void 0;
4
+ exports.summarizeToken = summarizeToken;
5
+ exports.requireCredentialTokens = requireCredentialTokens;
6
+ exports.mintFor = mintFor;
7
+ exports.createTokenProcedures = createTokenProcedures;
8
+ exports.daysToSeconds = daysToSeconds;
9
+ const types_1 = require("@camstack/types");
10
+ const server_1 = require("@trpc/server");
11
+ const zod_1 = require("zod");
12
+ const hub_sessions_js_1 = require("../../auth/hub-sessions.js");
13
+ const scope_escalation_js_1 = require("../../auth/scope-escalation.js");
14
+ const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
15
+ const auth_reauth_procedures_js_1 = require("./auth-reauth.procedures.js");
16
+ const DAY_MS = 86_400_000;
17
+ /** Every token's longest lifetime — the admin token's bound, now everyone's (D816). */
18
+ exports.TOKEN_MAX_LIFETIME_SEC = 90 * 86_400;
19
+ const TOKEN_MIN_LIFETIME_SEC = 60;
20
+ const MAX_SCOPES = 32;
21
+ exports.TokenExpiresInSchema = zod_1.z.union([
22
+ zod_1.z.number().int().min(TOKEN_MIN_LIFETIME_SEC).max(exports.TOKEN_MAX_LIFETIME_SEC),
23
+ zod_1.z.literal('never'),
24
+ ]);
25
+ exports.MintTokenInputSchema = zod_1.z.object({
26
+ scopes: zod_1.z.array(types_1.CredentialScopeSchema).min(1).max(MAX_SCOPES),
27
+ /** Seconds until expiry, or `'never'` (a share-view token only). */
28
+ expiresIn: exports.TokenExpiresInSchema,
29
+ label: zod_1.z.string().trim().min(1).max(80),
30
+ /** Required when `scopes` holds `'*'`. */
31
+ reauthToken: auth_reauth_procedures_js_1.ReauthTokenInput.optional(),
32
+ /**
33
+ * The user the token acts for — an admin only; omitted, the caller. Its
34
+ * scopes are cut to what that user holds at every verify (a token never
35
+ * outgrows its subject).
36
+ */
37
+ subjectId: zod_1.z.string().min(1).optional(),
38
+ });
39
+ exports.ListTokensInputSchema = zod_1.z
40
+ .object({
41
+ /** Only the tokens with exactly this label — how a client finds the one it minted. */
42
+ label: zod_1.z.string().trim().min(1).max(80).optional(),
43
+ })
44
+ .optional();
45
+ exports.ExtendTokenInputSchema = zod_1.z.object({
46
+ id: zod_1.z.string().min(1),
47
+ /** Seconds from now, or `'never'` (a lone share-view token only). */
48
+ expiresIn: exports.TokenExpiresInSchema,
49
+ });
50
+ exports.TokenSummarySchema = zod_1.z.object({
51
+ id: zod_1.z.string(),
52
+ subjectId: zod_1.z.string(),
53
+ label: zod_1.z.string(),
54
+ scopes: zod_1.z.array(types_1.CredentialScopeSchema),
55
+ createdAt: zod_1.z.number(),
56
+ expiresAt: zod_1.z.number().nullable(),
57
+ lastUsedAt: zod_1.z.number().nullable(),
58
+ revokedAt: zod_1.z.number().nullable(),
59
+ });
60
+ function summarizeToken(record) {
61
+ return {
62
+ id: record.id,
63
+ subjectId: record.subjectId,
64
+ label: record.label,
65
+ scopes: [...record.scopes],
66
+ createdAt: record.createdAt,
67
+ expiresAt: record.expiresAt,
68
+ lastUsedAt: record.lastUsedAt,
69
+ revokedAt: record.revokedAt,
70
+ };
71
+ }
72
+ function requireCredentialTokens(service) {
73
+ if (service === null) {
74
+ throw new server_1.TRPCError({ code: 'PRECONDITION_FAILED', message: 'Tokens are not available' });
75
+ }
76
+ return service;
77
+ }
78
+ /**
79
+ * May `user` mint (or extend) a token carrying `scopes`? A person's login
80
+ * session may mint anything within its reach; an account link only a lone
81
+ * share-view grant (D895 — the reach the share-link route always gave it);
82
+ * a minted token never.
83
+ */
84
+ function provenanceAllows(user, scopes) {
85
+ if (user.interactive === true)
86
+ return true;
87
+ return user.userDelegated === true && scopes.length === 1 && (0, types_1.shareViewGrantOf)(scopes) !== null;
88
+ }
89
+ /** The lifetime rules every mint and every extend obey. Throws a BAD_REQUEST. */
90
+ function assertLifetime(scopes, expiresIn) {
91
+ if (expiresIn === 'never' && (0, types_1.shareViewGrantOf)(scopes) === null) {
92
+ throw new server_1.TRPCError({
93
+ code: 'BAD_REQUEST',
94
+ message: 'Only a share link may be minted without an expiry',
95
+ });
96
+ }
97
+ }
98
+ function expiryOf(expiresIn) {
99
+ return expiresIn === 'never' ? null : Date.now() + expiresIn * 1000;
100
+ }
101
+ /**
102
+ * The one mint behind `auth.mintToken`. Refuses; never trims.
103
+ */
104
+ async function mintFor(deps, ctx, request) {
105
+ const user = ctx.user;
106
+ if (!provenanceAllows(user, request.scopes)) {
107
+ throw new server_1.TRPCError({
108
+ code: 'FORBIDDEN',
109
+ message: 'A token can only be minted from a signed-in session',
110
+ });
111
+ }
112
+ const share = (0, types_1.shareViewGrantOf)(request.scopes);
113
+ if (share !== null && request.scopes.length !== 1) {
114
+ throw new server_1.TRPCError({
115
+ code: 'BAD_REQUEST',
116
+ message: 'A share-view grant must be the token’s only scope',
117
+ });
118
+ }
119
+ assertLifetime(request.scopes, request.expiresIn);
120
+ const verdict = (0, scope_escalation_js_1.scopesWithin)(request.scopes, user.scopes ?? [], ctx.deviceScopeLookup);
121
+ if (!verdict.ok) {
122
+ throw new server_1.TRPCError({
123
+ code: 'FORBIDDEN',
124
+ message: `A token cannot carry more than you hold: ${verdict.reason}`,
125
+ });
126
+ }
127
+ if ((0, types_1.scopesGrantAdmin)(request.scopes)) {
128
+ (0, auth_reauth_procedures_js_1.assertReauthenticated)(deps.auth, user, request.reauthToken);
129
+ }
130
+ const subjectId = request.subjectId ?? user.id;
131
+ if (subjectId !== user.id && !user.isAdmin) {
132
+ throw new server_1.TRPCError({ code: 'FORBIDDEN', message: 'Only an admin mints for another user' });
133
+ }
134
+ if (subjectId !== user.id && (0, types_1.scopesGrantAdmin)(request.scopes)) {
135
+ throw new server_1.TRPCError({
136
+ code: 'BAD_REQUEST',
137
+ message: "A '*' token acts for the admin who mints it, never for another user",
138
+ });
139
+ }
140
+ const service = requireCredentialTokens(deps.credentialTokens);
141
+ const minted = await service.mint({
142
+ subjectId,
143
+ createdBy: user.id,
144
+ label: request.label,
145
+ scopes: request.scopes,
146
+ expiresAt: expiryOf(request.expiresIn),
147
+ });
148
+ const internal = deps.registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(deps.registry);
149
+ if (internal !== null) {
150
+ const ip = deps.network.clientAddress(ctx.req).ip;
151
+ await internal.audit
152
+ .record({
153
+ // An admin-capable token keeps the action the audit page already words.
154
+ action: (0, types_1.scopesGrantAdmin)(request.scopes) ? 'admin-token-created' : 'token-minted',
155
+ userId: user.id,
156
+ ...(ip !== null ? { ip } : {}),
157
+ detail: {
158
+ tokenId: minted.record.id,
159
+ name: minted.record.label,
160
+ subjectId,
161
+ scopes: request.scopes.length,
162
+ ...(minted.record.expiresAt !== null ? { expiresAt: minted.record.expiresAt } : {}),
163
+ },
164
+ })
165
+ .catch(() => { });
166
+ }
167
+ return minted;
168
+ }
169
+ function createTokenProcedures(deps) {
170
+ return {
171
+ /** Issue a token carrying `scopes` (see the module header). Shown once. */
172
+ mintToken: trpc_middleware_js_1.protectedProcedure
173
+ .input(exports.MintTokenInputSchema)
174
+ .output(zod_1.z.object({ token: zod_1.z.string(), record: exports.TokenSummarySchema }))
175
+ .mutation(async ({ ctx, input }) => {
176
+ const minted = await mintFor(deps, ctx, {
177
+ scopes: input.scopes,
178
+ expiresIn: input.expiresIn,
179
+ label: input.label,
180
+ ...(input.reauthToken !== undefined ? { reauthToken: input.reauthToken } : {}),
181
+ ...(input.subjectId !== undefined ? { subjectId: input.subjectId } : {}),
182
+ });
183
+ return { token: minted.token, record: summarizeToken(minted.record) };
184
+ }),
185
+ /**
186
+ * The caller's tokens (an admin: everyone's). No secret is ever listed.
187
+ */
188
+ listTokens: trpc_middleware_js_1.protectedProcedure
189
+ .input(exports.ListTokensInputSchema)
190
+ .output(zod_1.z.array(exports.TokenSummarySchema))
191
+ .query(async ({ ctx, input }) => {
192
+ if (ctx.user.userDelegated !== true) {
193
+ throw new server_1.TRPCError({ code: 'FORBIDDEN', message: 'Requires a signed-in user session' });
194
+ }
195
+ const rows = await requireCredentialTokens(deps.credentialTokens).list(ctx.user.isAdmin ? undefined : ctx.user.id);
196
+ const label = input?.label;
197
+ // An account link manages share links only, so it lists only those.
198
+ const shareOnly = ctx.user.interactive !== true;
199
+ return rows
200
+ .filter((row) => label === undefined || row.label === label)
201
+ .filter((row) => !shareOnly || provenanceAllows(ctx.user, row.scopes))
202
+ .map(summarizeToken);
203
+ }),
204
+ /**
205
+ * Move one live token's expiry, in place: the same token keeps working
206
+ * with the new lifetime, so a client renews instead of minting another.
207
+ * The owner or an admin; the same provenance and lifetime rules as a mint
208
+ * (an account link renews only its share links; `'never'` only for one).
209
+ * Shortening is allowed; a revoked or expired token is NOT_FOUND.
210
+ */
211
+ extendToken: trpc_middleware_js_1.protectedProcedure
212
+ .input(exports.ExtendTokenInputSchema)
213
+ .output(exports.TokenSummarySchema)
214
+ .mutation(async ({ ctx, input }) => {
215
+ const service = requireCredentialTokens(deps.credentialTokens);
216
+ const record = await service.get(input.id);
217
+ if (record === null) {
218
+ throw new server_1.TRPCError({ code: 'NOT_FOUND', message: `Token not found: ${input.id}` });
219
+ }
220
+ if (!ctx.user.isAdmin && record.subjectId !== ctx.user.id) {
221
+ throw new server_1.TRPCError({
222
+ code: 'FORBIDDEN',
223
+ message: 'Only the owner or an admin extends a token',
224
+ });
225
+ }
226
+ if (!provenanceAllows(ctx.user, record.scopes)) {
227
+ throw new server_1.TRPCError({
228
+ code: 'FORBIDDEN',
229
+ message: 'A token can only be extended from a signed-in session',
230
+ });
231
+ }
232
+ assertLifetime(record.scopes, input.expiresIn);
233
+ const moved = await service.setExpiry(input.id, expiryOf(input.expiresIn));
234
+ if (moved === null) {
235
+ throw new server_1.TRPCError({ code: 'NOT_FOUND', message: `Token expired or revoked: ${input.id}` });
236
+ }
237
+ return summarizeToken(moved);
238
+ }),
239
+ /** Revoke one token: its owner or an admin. It stops working at once. */
240
+ revokeToken: trpc_middleware_js_1.protectedProcedure
241
+ .input(zod_1.z.object({ id: zod_1.z.string().min(1) }))
242
+ .output(zod_1.z.object({ success: zod_1.z.boolean() }))
243
+ .mutation(async ({ ctx, input }) => {
244
+ if (ctx.user.userDelegated !== true) {
245
+ throw new server_1.TRPCError({ code: 'FORBIDDEN', message: 'Requires a signed-in user session' });
246
+ }
247
+ const service = requireCredentialTokens(deps.credentialTokens);
248
+ const record = await service.get(input.id);
249
+ if (record === null)
250
+ return { success: false };
251
+ if (!ctx.user.isAdmin && record.subjectId !== ctx.user.id) {
252
+ throw new server_1.TRPCError({
253
+ code: 'FORBIDDEN',
254
+ message: 'Only the owner or an admin revokes a token',
255
+ });
256
+ }
257
+ if (!provenanceAllows(ctx.user, record.scopes)) {
258
+ throw new server_1.TRPCError({
259
+ code: 'FORBIDDEN',
260
+ message: 'An account link revokes only its share links',
261
+ });
262
+ }
263
+ const success = await service.revoke(input.id);
264
+ const internal = deps.registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(deps.registry);
265
+ if (success && internal !== null) {
266
+ const ip = deps.network.clientAddress(ctx.req).ip;
267
+ await internal.audit
268
+ .record({
269
+ action: 'token-revoked',
270
+ userId: ctx.user.id,
271
+ ...(ip !== null ? { ip } : {}),
272
+ detail: { tokenId: input.id },
273
+ })
274
+ .catch(() => { });
275
+ }
276
+ return { success };
277
+ }),
278
+ };
279
+ }
280
+ /** Days → the `expiresIn` seconds the mint takes. */
281
+ function daysToSeconds(days) {
282
+ return (days * DAY_MS) / 1000;
283
+ }