@vellumai/assistant 0.4.13 → 0.4.15

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 (133) hide show
  1. package/ARCHITECTURE.md +77 -38
  2. package/README.md +10 -12
  3. package/package.json +1 -1
  4. package/src/__tests__/actor-token-service.test.ts +108 -522
  5. package/src/__tests__/channel-approval-routes.test.ts +92 -239
  6. package/src/__tests__/channel-approval.test.ts +100 -0
  7. package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
  8. package/src/__tests__/conversation-routes.test.ts +11 -4
  9. package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
  10. package/src/__tests__/mcp-health-check.test.ts +65 -0
  11. package/src/__tests__/permission-types.test.ts +33 -0
  12. package/src/__tests__/scan-result-store.test.ts +121 -0
  13. package/src/__tests__/session-agent-loop.test.ts +120 -0
  14. package/src/__tests__/session-approval-overrides.test.ts +205 -0
  15. package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
  16. package/src/amazon/client.ts +8 -5
  17. package/src/approvals/guardian-decision-primitive.ts +14 -9
  18. package/src/approvals/guardian-request-resolvers.ts +2 -2
  19. package/src/calls/call-controller.ts +2 -2
  20. package/src/calls/twilio-routes.ts +2 -2
  21. package/src/cli/mcp.ts +3 -3
  22. package/src/cli.ts +24 -0
  23. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
  24. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
  25. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
  26. package/src/config/bundled-skills/messaging/SKILL.md +49 -14
  27. package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
  28. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
  29. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
  30. package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
  31. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
  32. package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
  33. package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
  34. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
  35. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
  36. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
  37. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
  38. package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
  39. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  40. package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
  41. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
  42. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
  43. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
  44. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
  45. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
  46. package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
  47. package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
  48. package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
  49. package/src/daemon/approval-generators.ts +6 -3
  50. package/src/daemon/handlers/config-ingress.ts +2 -6
  51. package/src/daemon/handlers/guardian-actions.ts +1 -1
  52. package/src/daemon/handlers/sessions.ts +4 -1
  53. package/src/daemon/handlers/shared.ts +3 -0
  54. package/src/daemon/handlers/skills.ts +32 -0
  55. package/src/daemon/ipc-contract/messages.ts +3 -1
  56. package/src/daemon/ipc-handler.ts +24 -0
  57. package/src/daemon/ipc-validate.ts +1 -1
  58. package/src/daemon/lifecycle.ts +6 -8
  59. package/src/daemon/server.ts +8 -3
  60. package/src/daemon/session-agent-loop.ts +19 -1
  61. package/src/daemon/session-attachments.ts +2 -1
  62. package/src/daemon/session-history.ts +2 -2
  63. package/src/daemon/session-process.ts +5 -9
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session-tool-setup.ts +216 -69
  66. package/src/daemon/session.ts +24 -1
  67. package/src/events/domain-events.ts +1 -1
  68. package/src/events/tool-domain-event-publisher.ts +5 -10
  69. package/src/influencer/client.ts +8 -7
  70. package/src/messaging/providers/gmail/client.ts +33 -1
  71. package/src/messaging/providers/gmail/mime-builder.ts +5 -1
  72. package/src/messaging/providers/sms/adapter.ts +3 -7
  73. package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
  74. package/src/messaging/providers/whatsapp/adapter.ts +3 -7
  75. package/src/notifications/adapters/sms.ts +2 -2
  76. package/src/notifications/adapters/telegram.ts +2 -2
  77. package/src/permissions/prompter.ts +2 -0
  78. package/src/permissions/types.ts +11 -1
  79. package/src/runtime/approval-conversation-turn.ts +4 -0
  80. package/src/runtime/auth/__tests__/context.test.ts +130 -0
  81. package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
  82. package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
  83. package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
  84. package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
  85. package/src/runtime/auth/__tests__/policy.test.ts +29 -0
  86. package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
  87. package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
  88. package/src/runtime/auth/__tests__/subject.test.ts +149 -0
  89. package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
  90. package/src/runtime/auth/context.ts +62 -0
  91. package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
  92. package/src/runtime/auth/external-assistant-id.ts +69 -0
  93. package/src/runtime/auth/index.ts +37 -0
  94. package/src/runtime/auth/middleware.ts +127 -0
  95. package/src/runtime/auth/policy.ts +17 -0
  96. package/src/runtime/auth/route-policy.ts +261 -0
  97. package/src/runtime/auth/scopes.ts +64 -0
  98. package/src/runtime/auth/subject.ts +68 -0
  99. package/src/runtime/auth/token-service.ts +275 -0
  100. package/src/runtime/auth/types.ts +79 -0
  101. package/src/runtime/channel-approval-parser.ts +11 -5
  102. package/src/runtime/channel-approval-types.ts +1 -1
  103. package/src/runtime/channel-approvals.ts +22 -1
  104. package/src/runtime/guardian-action-followup-executor.ts +2 -2
  105. package/src/runtime/guardian-context-resolver.ts +15 -0
  106. package/src/runtime/guardian-decision-types.ts +23 -6
  107. package/src/runtime/guardian-outbound-actions.ts +4 -22
  108. package/src/runtime/guardian-reply-router.ts +5 -3
  109. package/src/runtime/http-server.ts +210 -182
  110. package/src/runtime/http-types.ts +11 -1
  111. package/src/runtime/local-actor-identity.ts +25 -0
  112. package/src/runtime/pending-interactions.ts +1 -0
  113. package/src/runtime/routes/approval-routes.ts +42 -59
  114. package/src/runtime/routes/channel-route-shared.ts +9 -41
  115. package/src/runtime/routes/channel-routes.ts +0 -2
  116. package/src/runtime/routes/conversation-routes.ts +39 -49
  117. package/src/runtime/routes/events-routes.ts +15 -22
  118. package/src/runtime/routes/guardian-action-routes.ts +46 -51
  119. package/src/runtime/routes/guardian-approval-interception.ts +6 -5
  120. package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
  121. package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
  122. package/src/runtime/routes/inbound-message-handler.ts +39 -45
  123. package/src/runtime/routes/pairing-routes.ts +9 -9
  124. package/src/runtime/routes/secret-routes.ts +90 -45
  125. package/src/runtime/routes/surface-action-routes.ts +12 -2
  126. package/src/runtime/routes/trust-rules-routes.ts +13 -0
  127. package/src/runtime/routes/twilio-routes.ts +3 -3
  128. package/src/runtime/session-approval-overrides.ts +86 -0
  129. package/src/security/keychain-to-encrypted-migration.ts +8 -1
  130. package/src/skills/frontmatter.ts +44 -1
  131. package/src/tools/permission-checker.ts +226 -74
  132. package/src/runtime/actor-token-service.ts +0 -234
  133. package/src/runtime/middleware/actor-token.ts +0 -265
@@ -0,0 +1,62 @@
1
+ /**
2
+ * AuthContext builder — combines sub parsing and scope resolution into
3
+ * a normalized AuthContext that downstream code can consume without
4
+ * knowing about JWT internals.
5
+ */
6
+
7
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
8
+ import { resolveScopeProfile } from './scopes.js';
9
+ import { parseSub } from './subject.js';
10
+ import type { AuthContext, TokenClaims } from './types.js';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Result type
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export type BuildAuthContextResult =
17
+ | { ok: true; context: AuthContext }
18
+ | { ok: false; reason: string };
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Builder
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Build a normalized AuthContext from verified JWT claims.
26
+ *
27
+ * Parses the sub claim and resolves the scope profile into a concrete
28
+ * set of scopes. Returns a failure result if the sub is malformed.
29
+ *
30
+ * When the token audience is `vellum-daemon`, the assistantId is forced
31
+ * to DAEMON_INTERNAL_ASSISTANT_ID ('self') regardless of what the JWT
32
+ * sub encodes. Daemon code must never derive internal scoping from
33
+ * externally-provided assistant IDs.
34
+ */
35
+ export function buildAuthContext(claims: TokenClaims): BuildAuthContextResult {
36
+ const subResult = parseSub(claims.sub);
37
+ if (!subResult.ok) {
38
+ return { ok: false, reason: subResult.reason };
39
+ }
40
+
41
+ const scopes = resolveScopeProfile(claims.scope_profile);
42
+
43
+ // Daemon-audience tokens always scope to the internal assistant ID,
44
+ // preventing external assistant IDs from leaking into daemon-side
45
+ // storage and routing.
46
+ const assistantId = claims.aud === 'vellum-daemon'
47
+ ? DAEMON_INTERNAL_ASSISTANT_ID
48
+ : subResult.assistantId;
49
+
50
+ const context: AuthContext = {
51
+ subject: claims.sub,
52
+ principalType: subResult.principalType,
53
+ assistantId,
54
+ actorPrincipalId: subResult.actorPrincipalId,
55
+ sessionId: subResult.sessionId,
56
+ scopeProfile: claims.scope_profile,
57
+ scopes,
58
+ policyEpoch: claims.policy_epoch,
59
+ };
60
+
61
+ return { ok: true, context };
62
+ }
@@ -1,37 +1,51 @@
1
1
  /**
2
- * Refresh token service — mint, rotate, and validate refresh tokens.
2
+ * JWT credential minting and rotation service.
3
3
  *
4
- * Implements rotating single-use refresh tokens with:
5
- * - Absolute expiry (365 days)
6
- * - Inactivity expiry (90 days since last refresh)
7
- * - Replay detection (reuse of rotated token revokes entire family)
4
+ * Replaces the legacy actor-token-service + actor-refresh-token-service with
5
+ * JWT-based access tokens (aud=vellum-gateway) and opaque refresh tokens.
6
+ *
7
+ * Access tokens are standard JWTs with:
8
+ * - aud: 'vellum-gateway'
9
+ * - sub: 'actor:<externalAssistantId>:<guardianPrincipalId>'
10
+ * - scope_profile: 'actor_client_v1'
11
+ * - policy_epoch: CURRENT_POLICY_EPOCH
12
+ * - 30-day TTL
13
+ *
14
+ * Refresh tokens remain opaque random bytes with hash-only storage,
15
+ * family tracking, and replay detection — reusing the existing
16
+ * actor-refresh-token-store infrastructure.
8
17
  */
9
18
 
10
19
  import { createHash, randomBytes } from 'node:crypto';
11
20
 
12
- import { getDb } from '../memory/db.js';
13
- import { getLogger } from '../util/logger.js';
21
+ import { getDb } from '../../memory/db.js';
22
+ import { getLogger } from '../../util/logger.js';
14
23
  import {
15
24
  createRefreshTokenRecord,
16
25
  findByTokenHash as findRefreshByHash,
17
26
  markRotated,
18
27
  revokeByDeviceBinding as revokeRefreshTokensByDevice,
19
28
  revokeFamily,
20
- } from './actor-refresh-token-store.js';
21
- import { hashToken, mintActorToken } from './actor-token-service.js';
29
+ } from '../actor-refresh-token-store.js';
22
30
  import {
23
31
  createActorTokenRecord,
24
32
  revokeByDeviceBinding as revokeActorTokensByDevice,
25
- } from './actor-token-store.js';
33
+ } from '../actor-token-store.js';
34
+ import { getExternalAssistantId } from './external-assistant-id.js';
35
+ import { CURRENT_POLICY_EPOCH } from './policy.js';
36
+ import { hashToken, mintToken } from './token-service.js';
26
37
 
27
- const log = getLogger('actor-refresh-token-service');
38
+ const log = getLogger('credential-service');
28
39
 
29
40
  // ---------------------------------------------------------------------------
30
41
  // Constants
31
42
  // ---------------------------------------------------------------------------
32
43
 
33
- /** Access token TTL: 30 days (reduced from 90). */
34
- const ACCESS_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
44
+ /** Access token TTL: 30 days in seconds. */
45
+ const ACCESS_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
46
+
47
+ /** Access token TTL in ms (for refresh-after hints). */
48
+ const ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL_SECONDS * 1000;
35
49
 
36
50
  /** Refresh token absolute expiry: 365 days from issuance. */
37
51
  const REFRESH_ABSOLUTE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
@@ -53,51 +67,83 @@ export type RefreshErrorCode =
53
67
  | 'device_binding_mismatch'
54
68
  | 'revoked';
55
69
 
56
- export interface RefreshResult {
57
- guardianPrincipalId: string;
58
- actorToken: string;
59
- actorTokenExpiresAt: number;
70
+ export interface CredentialPairResult {
71
+ accessToken: string;
72
+ accessTokenExpiresAt: number;
60
73
  refreshToken: string;
61
74
  refreshTokenExpiresAt: number;
62
75
  refreshAfter: number;
76
+ guardianPrincipalId: string;
63
77
  }
64
78
 
65
- export interface MintRefreshTokenResult {
79
+ export interface RotateResult {
80
+ guardianPrincipalId: string;
81
+ accessToken: string;
82
+ accessTokenExpiresAt: number;
66
83
  refreshToken: string;
67
- refreshTokenHash: string;
68
84
  refreshTokenExpiresAt: number;
69
85
  refreshAfter: number;
70
86
  }
71
87
 
72
88
  // ---------------------------------------------------------------------------
73
- // Mint a fresh refresh token (used by bootstrap/pairing)
89
+ // Internal: refresh token helpers
74
90
  // ---------------------------------------------------------------------------
75
91
 
76
- /** Hash a raw refresh token for storage. Reuses the actor-token hash function. */
92
+ function generateRefreshToken(): string {
93
+ return randomBytes(32).toString('base64url');
94
+ }
95
+
77
96
  function hashRefreshToken(token: string): string {
78
97
  return hashToken(token);
79
98
  }
80
99
 
81
- /** Generate a cryptographically random refresh token. */
82
- function generateRefreshToken(): string {
83
- return randomBytes(32).toString('base64url');
100
+ // ---------------------------------------------------------------------------
101
+ // Internal: mint a JWT access token
102
+ // ---------------------------------------------------------------------------
103
+
104
+ function mintAccessToken(guardianPrincipalId: string): {
105
+ token: string;
106
+ tokenHash: string;
107
+ expiresAt: number;
108
+ issuedAt: number;
109
+ } {
110
+ const externalAssistantId = getExternalAssistantId();
111
+ const sub = `actor:${externalAssistantId}:${guardianPrincipalId}`;
112
+
113
+ const token = mintToken({
114
+ aud: 'vellum-gateway',
115
+ sub,
116
+ scope_profile: 'actor_client_v1',
117
+ policy_epoch: CURRENT_POLICY_EPOCH,
118
+ ttlSeconds: ACCESS_TOKEN_TTL_SECONDS,
119
+ });
120
+
121
+ const now = Date.now();
122
+ return {
123
+ token,
124
+ tokenHash: hashToken(token),
125
+ expiresAt: now + ACCESS_TOKEN_TTL_MS,
126
+ issuedAt: now,
127
+ };
84
128
  }
85
129
 
86
- /**
87
- * Mint a new refresh token and persist its hash.
88
- * Called during bootstrap, pairing, and rotation.
89
- */
90
- export function mintRefreshToken(params: {
130
+ // ---------------------------------------------------------------------------
131
+ // Internal: mint a fresh refresh token and persist its hash
132
+ // ---------------------------------------------------------------------------
133
+
134
+ function mintRefreshTokenInternal(params: {
91
135
  assistantId: string;
92
136
  guardianPrincipalId: string;
93
137
  hashedDeviceId: string;
94
138
  platform: string;
95
139
  familyId?: string;
96
- /** When provided (during rotation), inherit the parent token's absolute expiry
97
- * instead of computing a fresh one. This ensures refresh rotation resets the
98
- * inactivity window but does NOT extend the absolute session lifetime. */
99
140
  absoluteExpiresAt?: number;
100
- }): MintRefreshTokenResult {
141
+ }): {
142
+ refreshToken: string;
143
+ refreshTokenHash: string;
144
+ refreshTokenExpiresAt: number;
145
+ refreshAfter: number;
146
+ } {
101
147
  const now = Date.now();
102
148
  const familyId = params.familyId ?? randomBytes(16).toString('hex');
103
149
  const refreshToken = generateRefreshToken();
@@ -125,9 +171,15 @@ export function mintRefreshToken(params: {
125
171
  };
126
172
  }
127
173
 
174
+ // ---------------------------------------------------------------------------
175
+ // Public: mint credential pair (access token + refresh token)
176
+ // ---------------------------------------------------------------------------
177
+
128
178
  /**
129
- * Mint both an access token and a refresh token for initial credential issuance.
179
+ * Mint a JWT access token and an opaque refresh token for initial issuance.
130
180
  * Used by bootstrap and pairing flows.
181
+ *
182
+ * Revokes any existing credentials for the device before minting.
131
183
  */
132
184
  export function mintCredentialPair(params: {
133
185
  assistantId: string;
@@ -135,39 +187,26 @@ export function mintCredentialPair(params: {
135
187
  deviceId: string;
136
188
  guardianPrincipalId: string;
137
189
  hashedDeviceId: string;
138
- }): {
139
- actorToken: string;
140
- actorTokenExpiresAt: number;
141
- refreshToken: string;
142
- refreshTokenExpiresAt: number;
143
- refreshAfter: number;
144
- guardianPrincipalId: string;
145
- } {
190
+ }): CredentialPairResult {
146
191
  // Revoke any existing credentials for this device
147
192
  revokeActorTokensByDevice(params.assistantId, params.guardianPrincipalId, params.hashedDeviceId);
148
193
  revokeRefreshTokensByDevice(params.assistantId, params.guardianPrincipalId, params.hashedDeviceId);
149
194
 
150
- // Mint new access token with 30-day TTL
151
- const { token: actorToken, tokenHash: actorTokenHash, claims } = mintActorToken({
152
- assistantId: params.assistantId,
153
- platform: params.platform,
154
- deviceId: params.deviceId,
155
- guardianPrincipalId: params.guardianPrincipalId,
156
- ttlMs: ACCESS_TOKEN_TTL_MS,
157
- });
195
+ // Mint new JWT access token
196
+ const access = mintAccessToken(params.guardianPrincipalId);
158
197
 
159
198
  createActorTokenRecord({
160
- tokenHash: actorTokenHash,
199
+ tokenHash: access.tokenHash,
161
200
  assistantId: params.assistantId,
162
201
  guardianPrincipalId: params.guardianPrincipalId,
163
202
  hashedDeviceId: params.hashedDeviceId,
164
203
  platform: params.platform,
165
- issuedAt: claims.iat,
166
- expiresAt: claims.exp,
204
+ issuedAt: access.issuedAt,
205
+ expiresAt: access.expiresAt,
167
206
  });
168
207
 
169
208
  // Mint new refresh token
170
- const refresh = mintRefreshToken({
209
+ const refresh = mintRefreshTokenInternal({
171
210
  assistantId: params.assistantId,
172
211
  guardianPrincipalId: params.guardianPrincipalId,
173
212
  hashedDeviceId: params.hashedDeviceId,
@@ -175,8 +214,8 @@ export function mintCredentialPair(params: {
175
214
  });
176
215
 
177
216
  return {
178
- actorToken,
179
- actorTokenExpiresAt: claims.exp!,
217
+ accessToken: access.token,
218
+ accessTokenExpiresAt: access.expiresAt,
180
219
  refreshToken: refresh.refreshToken,
181
220
  refreshTokenExpiresAt: refresh.refreshTokenExpiresAt,
182
221
  refreshAfter: refresh.refreshAfter,
@@ -185,19 +224,20 @@ export function mintCredentialPair(params: {
185
224
  }
186
225
 
187
226
  // ---------------------------------------------------------------------------
188
- // Rotate (the core refresh operation)
227
+ // Public: rotate credentials
189
228
  // ---------------------------------------------------------------------------
190
229
 
191
230
  /**
192
231
  * Rotate credentials: validate refresh token, revoke old, mint new pair.
193
232
  *
194
- * Returns either a successful result or an error code.
233
+ * Returns either a successful result or an error code. The rotation is
234
+ * wrapped in a SQLite transaction for atomicity.
195
235
  */
196
236
  export function rotateCredentials(params: {
197
237
  refreshToken: string;
198
238
  platform: string;
199
239
  deviceId: string;
200
- }): { ok: true; result: RefreshResult } | { ok: false; error: RefreshErrorCode } {
240
+ }): { ok: true; result: RotateResult } | { ok: false; error: RefreshErrorCode } {
201
241
  const refreshTokenHash = hashRefreshToken(params.refreshToken);
202
242
  const hashedDeviceId = createHash('sha256').update(params.deviceId).digest('hex');
203
243
 
@@ -245,12 +285,10 @@ export function rotateCredentials(params: {
245
285
  return { ok: false, error: 'device_binding_mismatch' };
246
286
  }
247
287
 
248
- // Wrap the entire rotate-revoke-remint sequence in a transaction so that
249
- // partial failures (e.g., DB write error after revoking old tokens) roll back
250
- // atomically instead of stranding device credentials.
288
+ // Wrap the entire rotate-revoke-remint sequence in a transaction
251
289
  const db = getDb();
252
290
  return db.transaction(() => {
253
- // Mark old refresh token as rotated (atomic CAS — fails if a concurrent request already consumed it)
291
+ // Mark old refresh token as rotated (atomic CAS)
254
292
  const didRotate = markRotated(refreshTokenHash);
255
293
  if (!didRotate) {
256
294
  return { ok: false as const, error: 'refresh_reuse_detected' as const };
@@ -259,28 +297,23 @@ export function rotateCredentials(params: {
259
297
  // Revoke old access tokens for this device
260
298
  revokeActorTokensByDevice(record.assistantId, record.guardianPrincipalId, record.hashedDeviceId);
261
299
 
262
- // Mint new access token
263
- const { token: actorToken, tokenHash: actorTokenHash, claims } = mintActorToken({
264
- assistantId: record.assistantId,
265
- platform: params.platform,
266
- deviceId: params.deviceId,
267
- guardianPrincipalId: record.guardianPrincipalId,
268
- ttlMs: ACCESS_TOKEN_TTL_MS,
269
- });
300
+ // Mint new JWT access token
301
+ const access = mintAccessToken(record.guardianPrincipalId);
270
302
 
271
303
  createActorTokenRecord({
272
- tokenHash: actorTokenHash,
304
+ tokenHash: access.tokenHash,
273
305
  assistantId: record.assistantId,
274
306
  guardianPrincipalId: record.guardianPrincipalId,
275
307
  hashedDeviceId: record.hashedDeviceId,
276
308
  platform: params.platform,
277
- issuedAt: claims.iat,
278
- expiresAt: claims.exp,
309
+ issuedAt: access.issuedAt,
310
+ expiresAt: access.expiresAt,
279
311
  });
280
312
 
281
- // Mint new refresh token in the same family, inheriting the parent's absolute
282
- // expiry so rotation resets inactivity but never extends the session lifetime.
283
- const refresh = mintRefreshToken({
313
+ // Mint new refresh token in the same family, inheriting the parent's
314
+ // absolute expiry so rotation resets inactivity but never extends
315
+ // the session lifetime.
316
+ const refresh = mintRefreshTokenInternal({
284
317
  assistantId: record.assistantId,
285
318
  guardianPrincipalId: record.guardianPrincipalId,
286
319
  hashedDeviceId: record.hashedDeviceId,
@@ -298,8 +331,8 @@ export function rotateCredentials(params: {
298
331
  ok: true as const,
299
332
  result: {
300
333
  guardianPrincipalId: record.guardianPrincipalId,
301
- actorToken,
302
- actorTokenExpiresAt: claims.exp!,
334
+ accessToken: access.token,
335
+ accessTokenExpiresAt: access.expiresAt,
303
336
  refreshToken: refresh.refreshToken,
304
337
  refreshTokenExpiresAt: refresh.refreshTokenExpiresAt,
305
338
  refreshAfter: refresh.refreshAfter,
@@ -0,0 +1,69 @@
1
+ /**
2
+ * External assistant ID resolver.
3
+ *
4
+ * Reads the external assistant ID from the lockfile for use in
5
+ * edge-facing JWT tokens (aud=vellum-gateway). The external ID is
6
+ * needed because the gateway must identify which assistant the token
7
+ * belongs to, while the daemon internally uses 'self'.
8
+ *
9
+ * The value is cached in memory after the first successful read.
10
+ * Falls back to 'self' if the lockfile is unreadable or has no
11
+ * assistant entries.
12
+ */
13
+
14
+ import { getLogger } from '../../util/logger.js';
15
+ import { readLockfile } from '../../util/platform.js';
16
+
17
+ const log = getLogger('external-assistant-id');
18
+
19
+ let cached: string | undefined;
20
+
21
+ /**
22
+ * Get the external assistant ID from the lockfile.
23
+ *
24
+ * Resolution order:
25
+ * 1. Cached in-memory value (populated on first call)
26
+ * 2. Most recently hatched entry in lockfile assistants array
27
+ * (sorted by `hatchedAt` descending) → assistantId
28
+ * 3. Fallback: 'self'
29
+ */
30
+ export function getExternalAssistantId(): string {
31
+ if (cached !== undefined) {
32
+ return cached;
33
+ }
34
+
35
+ try {
36
+ const lockData = readLockfile();
37
+ if (lockData) {
38
+ const assistants = lockData.assistants as Array<Record<string, unknown>> | undefined;
39
+ if (assistants && assistants.length > 0) {
40
+ // Sort by hatchedAt descending to use the most recent entry,
41
+ // matching the pattern used elsewhere in the codebase.
42
+ const sorted = [...assistants].sort((a, b) => {
43
+ const dateA = new Date((a.hatchedAt as string) || 0).getTime();
44
+ const dateB = new Date((b.hatchedAt as string) || 0).getTime();
45
+ return dateB - dateA;
46
+ });
47
+ const latest = sorted[0];
48
+ if (typeof latest.assistantId === 'string') {
49
+ cached = latest.assistantId;
50
+ log.info({ externalAssistantId: cached }, 'Resolved external assistant ID from lockfile');
51
+ return cached;
52
+ }
53
+ }
54
+ }
55
+ } catch (err) {
56
+ log.warn({ err }, 'Failed to read lockfile for external assistant ID — falling back to self');
57
+ }
58
+
59
+ cached = 'self';
60
+ return cached;
61
+ }
62
+
63
+ /**
64
+ * Reset the cached external assistant ID. Used by tests to force
65
+ * re-resolution on the next call.
66
+ */
67
+ export function resetExternalAssistantIdCache(): void {
68
+ cached = undefined;
69
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Auth module barrel export.
3
+ *
4
+ * Re-exports all public types and functions from the auth subsystem
5
+ * so consumers can import from a single path.
6
+ */
7
+
8
+ export type { BuildAuthContextResult } from './context.js';
9
+ export { buildAuthContext } from './context.js';
10
+ export type { CredentialPairResult, RefreshErrorCode, RotateResult } from './credential-service.js';
11
+ export { mintCredentialPair, rotateCredentials } from './credential-service.js';
12
+ export { getExternalAssistantId, resetExternalAssistantIdCache } from './external-assistant-id.js';
13
+ export type { AuthenticateResult } from './middleware.js';
14
+ export { authenticateRequest } from './middleware.js';
15
+ export { CURRENT_POLICY_EPOCH, isStaleEpoch } from './policy.js';
16
+ export type { RoutePolicy } from './route-policy.js';
17
+ export { enforcePolicy, getPolicy, registerPolicy } from './route-policy.js';
18
+ export { hasAllScopes, hasScope, resolveScopeProfile } from './scopes.js';
19
+ export type { ParseSubResult } from './subject.js';
20
+ export { parseSub } from './subject.js';
21
+ export type { VerifyResult } from './token-service.js';
22
+ export {
23
+ hashToken,
24
+ initAuthSigningKey,
25
+ loadOrCreateSigningKey,
26
+ mintDaemonDeliveryToken,
27
+ mintToken,
28
+ verifyToken,
29
+ } from './token-service.js';
30
+ export type {
31
+ AuthContext,
32
+ PrincipalType,
33
+ Scope,
34
+ ScopeProfile,
35
+ TokenAudience,
36
+ TokenClaims,
37
+ } from './types.js';
@@ -0,0 +1,127 @@
1
+ /**
2
+ * JWT bearer auth middleware for the runtime HTTP server.
3
+ *
4
+ * Extracts `Authorization: Bearer <token>`, verifies the JWT with
5
+ * `aud=vellum-daemon`, and builds an AuthContext from the claims.
6
+ *
7
+ * Replaces both the legacy bearer shared-secret check and the
8
+ * actor-token HMAC middleware with a single JWT verification path.
9
+ *
10
+ * In dev mode (DISABLE_HTTP_AUTH + VELLUM_UNSAFE_AUTH_BYPASS), JWT
11
+ * verification is skipped and a synthetic AuthContext is constructed
12
+ * so downstream code always has a typed context to consume.
13
+ */
14
+
15
+ import { isHttpAuthDisabled } from '../../config/env.js';
16
+ import { getLogger } from '../../util/logger.js';
17
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
18
+ import { extractBearerToken } from '../middleware/auth.js';
19
+ import { buildAuthContext } from './context.js';
20
+ import { resolveScopeProfile } from './scopes.js';
21
+ import { verifyToken } from './token-service.js';
22
+ import type { AuthContext } from './types.js';
23
+
24
+ const log = getLogger('auth-middleware');
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Result type
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export type AuthenticateResult =
31
+ | { ok: true; context: AuthContext }
32
+ | { ok: false; response: Response };
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Dev bypass synthetic context
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Construct a synthetic AuthContext for dev mode when auth is bypassed.
40
+ * Grants the broadest profile so all routes are accessible during
41
+ * local development.
42
+ */
43
+ function buildDevBypassContext(): AuthContext {
44
+ return {
45
+ subject: `actor:${DAEMON_INTERNAL_ASSISTANT_ID}:dev-bypass`,
46
+ principalType: 'actor',
47
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
48
+ actorPrincipalId: 'dev-bypass',
49
+ scopeProfile: 'actor_client_v1',
50
+ scopes: resolveScopeProfile('actor_client_v1'),
51
+ policyEpoch: Number.MAX_SAFE_INTEGER,
52
+ };
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Main entry point
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Authenticate an incoming HTTP request via JWT bearer token.
61
+ *
62
+ * Returns an AuthContext on success, or an error Response on failure.
63
+ * The caller should return the error Response directly to the client.
64
+ */
65
+ export function authenticateRequest(req: Request): AuthenticateResult {
66
+ // Dev bypass: skip JWT verification entirely
67
+ if (isHttpAuthDisabled()) {
68
+ return { ok: true, context: buildDevBypassContext() };
69
+ }
70
+
71
+ const path = new URL(req.url).pathname;
72
+
73
+ const rawToken = extractBearerToken(req);
74
+ if (!rawToken) {
75
+ log.warn({ reason: 'missing_token', path }, 'Auth denied: missing Authorization header');
76
+ return {
77
+ ok: false,
78
+ response: Response.json(
79
+ { error: { code: 'UNAUTHORIZED', message: 'Missing Authorization header' } },
80
+ { status: 401 },
81
+ ),
82
+ };
83
+ }
84
+
85
+ // Verify the JWT with audience = vellum-daemon
86
+ const verifyResult = verifyToken(rawToken, 'vellum-daemon');
87
+ if (!verifyResult.ok) {
88
+ // Stale policy epoch gets a specific error code so clients can refresh
89
+ if (verifyResult.reason === 'stale_policy_epoch') {
90
+ log.warn({ reason: 'stale_policy_epoch', path }, 'Auth denied: stale policy epoch');
91
+ return {
92
+ ok: false,
93
+ response: Response.json(
94
+ { error: { code: 'refresh_required', message: 'Token policy epoch is stale; refresh required' } },
95
+ { status: 401 },
96
+ ),
97
+ };
98
+ }
99
+
100
+ log.warn({ reason: verifyResult.reason, path }, 'Auth denied: JWT verification failed');
101
+ return {
102
+ ok: false,
103
+ response: Response.json(
104
+ { error: { code: 'UNAUTHORIZED', message: `Invalid token: ${verifyResult.reason}` } },
105
+ { status: 401 },
106
+ ),
107
+ };
108
+ }
109
+
110
+ // Build normalized AuthContext from verified claims
111
+ const contextResult = buildAuthContext(verifyResult.claims);
112
+ if (!contextResult.ok) {
113
+ log.warn(
114
+ { reason: contextResult.reason, path, sub: verifyResult.claims.sub },
115
+ 'Auth denied: invalid JWT claims',
116
+ );
117
+ return {
118
+ ok: false,
119
+ response: Response.json(
120
+ { error: { code: 'UNAUTHORIZED', message: `Invalid token claims: ${contextResult.reason}` } },
121
+ { status: 401 },
122
+ ),
123
+ };
124
+ }
125
+
126
+ return { ok: true, context: contextResult.context };
127
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Policy epoch management.
3
+ *
4
+ * The policy epoch is a monotonic counter embedded in every JWT. When
5
+ * the auth policy changes (e.g., scope profiles are redefined), the
6
+ * epoch is bumped, and tokens carrying a stale epoch are rejected.
7
+ * This gives us a hard revocation mechanism without maintaining a
8
+ * per-token blocklist.
9
+ */
10
+
11
+ /** Current policy epoch — bump this when auth policy changes. */
12
+ export const CURRENT_POLICY_EPOCH = 1;
13
+
14
+ /** Returns true if the given epoch is older than the current policy. */
15
+ export function isStaleEpoch(epoch: number): boolean {
16
+ return epoch < CURRENT_POLICY_EPOCH;
17
+ }