@vellumai/assistant 0.4.6 → 0.4.7

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 (42) hide show
  1. package/ARCHITECTURE.md +23 -6
  2. package/bun.lock +51 -0
  3. package/docs/trusted-contact-access.md +8 -0
  4. package/package.json +2 -1
  5. package/src/__tests__/actor-token-service.test.ts +4 -4
  6. package/src/__tests__/call-controller.test.ts +37 -0
  7. package/src/__tests__/channel-delivery-store.test.ts +2 -2
  8. package/src/__tests__/gateway-client-managed-outbound.test.ts +147 -0
  9. package/src/__tests__/guardian-dispatch.test.ts +39 -1
  10. package/src/__tests__/guardian-routing-state.test.ts +8 -30
  11. package/src/__tests__/non-member-access-request.test.ts +7 -0
  12. package/src/__tests__/notification-decision-fallback.test.ts +232 -0
  13. package/src/__tests__/notification-decision-strategy.test.ts +304 -8
  14. package/src/__tests__/notification-guardian-path.test.ts +38 -1
  15. package/src/__tests__/relay-server.test.ts +65 -5
  16. package/src/__tests__/send-endpoint-busy.test.ts +29 -1
  17. package/src/__tests__/tool-grant-request-escalation.test.ts +1 -0
  18. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -0
  19. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +2 -2
  20. package/src/__tests__/trusted-contact-multichannel.test.ts +1 -1
  21. package/src/calls/call-controller.ts +15 -0
  22. package/src/calls/relay-server.ts +45 -11
  23. package/src/calls/types.ts +1 -0
  24. package/src/daemon/providers-setup.ts +0 -8
  25. package/src/daemon/session-slash.ts +35 -2
  26. package/src/memory/db-init.ts +4 -0
  27. package/src/memory/migrations/039-actor-refresh-token-records.ts +51 -0
  28. package/src/memory/migrations/index.ts +1 -0
  29. package/src/memory/migrations/registry.ts +1 -1
  30. package/src/memory/schema.ts +19 -0
  31. package/src/notifications/README.md +8 -1
  32. package/src/notifications/copy-composer.ts +160 -30
  33. package/src/notifications/decision-engine.ts +98 -1
  34. package/src/runtime/actor-refresh-token-service.ts +309 -0
  35. package/src/runtime/actor-refresh-token-store.ts +157 -0
  36. package/src/runtime/actor-token-service.ts +3 -3
  37. package/src/runtime/gateway-client.ts +239 -0
  38. package/src/runtime/http-server.ts +2 -0
  39. package/src/runtime/routes/guardian-bootstrap-routes.ts +10 -24
  40. package/src/runtime/routes/guardian-refresh-routes.ts +53 -0
  41. package/src/runtime/routes/pairing-routes.ts +60 -50
  42. package/src/types/qrcode.d.ts +10 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Hash-only refresh token persistence.
3
+ *
4
+ * Stores SHA-256 hash of each refresh token with family tracking,
5
+ * device binding, and dual expiry (absolute + inactivity).
6
+ */
7
+
8
+ import { and, eq } from 'drizzle-orm';
9
+ import { v4 as uuid } from 'uuid';
10
+
11
+ import { getDb } from '../memory/db.js';
12
+ import { rawChanges } from '../memory/raw-query.js';
13
+ import { actorRefreshTokenRecords } from '../memory/schema.js';
14
+ import { getLogger } from '../util/logger.js';
15
+
16
+ const log = getLogger('actor-refresh-token-store');
17
+
18
+ export type RefreshTokenStatus = 'active' | 'rotated' | 'revoked';
19
+
20
+ export interface RefreshTokenRecord {
21
+ id: string;
22
+ tokenHash: string;
23
+ familyId: string;
24
+ assistantId: string;
25
+ guardianPrincipalId: string;
26
+ hashedDeviceId: string;
27
+ platform: string;
28
+ status: RefreshTokenStatus;
29
+ issuedAt: number;
30
+ absoluteExpiresAt: number;
31
+ inactivityExpiresAt: number;
32
+ lastUsedAt: number | null;
33
+ createdAt: number;
34
+ updatedAt: number;
35
+ }
36
+
37
+ /** Create a new refresh token record (hash-only). */
38
+ export function createRefreshTokenRecord(params: {
39
+ tokenHash: string;
40
+ familyId: string;
41
+ assistantId: string;
42
+ guardianPrincipalId: string;
43
+ hashedDeviceId: string;
44
+ platform: string;
45
+ issuedAt: number;
46
+ absoluteExpiresAt: number;
47
+ inactivityExpiresAt: number;
48
+ }): RefreshTokenRecord {
49
+ const db = getDb();
50
+ const now = Date.now();
51
+ const id = uuid();
52
+
53
+ const row = {
54
+ id,
55
+ tokenHash: params.tokenHash,
56
+ familyId: params.familyId,
57
+ assistantId: params.assistantId,
58
+ guardianPrincipalId: params.guardianPrincipalId,
59
+ hashedDeviceId: params.hashedDeviceId,
60
+ platform: params.platform,
61
+ status: 'active' as const,
62
+ issuedAt: params.issuedAt,
63
+ absoluteExpiresAt: params.absoluteExpiresAt,
64
+ inactivityExpiresAt: params.inactivityExpiresAt,
65
+ lastUsedAt: null,
66
+ createdAt: now,
67
+ updatedAt: now,
68
+ };
69
+
70
+ db.insert(actorRefreshTokenRecords).values(row).run();
71
+ log.info({ id, familyId: params.familyId, platform: params.platform }, 'Refresh token record created');
72
+ return row;
73
+ }
74
+
75
+ /** Look up a refresh token record by hash (ANY status - needed for replay detection). */
76
+ export function findByTokenHash(tokenHash: string): RefreshTokenRecord | null {
77
+ const db = getDb();
78
+ const row = db
79
+ .select()
80
+ .from(actorRefreshTokenRecords)
81
+ .where(eq(actorRefreshTokenRecords.tokenHash, tokenHash))
82
+ .get();
83
+ return row ? rowToRecord(row) : null;
84
+ }
85
+
86
+ /**
87
+ * Atomically mark a refresh token as rotated (used successfully, replaced by a new one).
88
+ * Uses a single conditional UPDATE and checks the affected row count to ensure
89
+ * exactly one row transitioned from 'active' to 'rotated'. This prevents
90
+ * concurrent refresh requests from both succeeding (CAS semantics).
91
+ */
92
+ export function markRotated(tokenHash: string): boolean {
93
+ const db = getDb();
94
+ const now = Date.now();
95
+ db.update(actorRefreshTokenRecords)
96
+ .set({ status: 'rotated', lastUsedAt: now, updatedAt: now })
97
+ .where(
98
+ and(
99
+ eq(actorRefreshTokenRecords.tokenHash, tokenHash),
100
+ eq(actorRefreshTokenRecords.status, 'active'),
101
+ ),
102
+ )
103
+ .run();
104
+ return rawChanges() > 0;
105
+ }
106
+
107
+ /** Revoke all tokens in a family (replay detection response). */
108
+ export function revokeFamily(familyId: string): number {
109
+ const db = getDb();
110
+ const now = Date.now();
111
+ db.update(actorRefreshTokenRecords)
112
+ .set({ status: 'revoked', updatedAt: now })
113
+ .where(eq(actorRefreshTokenRecords.familyId, familyId))
114
+ .run();
115
+ return rawChanges();
116
+ }
117
+
118
+ /** Revoke all active refresh tokens for a device binding. */
119
+ export function revokeByDeviceBinding(
120
+ assistantId: string,
121
+ guardianPrincipalId: string,
122
+ hashedDeviceId: string,
123
+ ): number {
124
+ const db = getDb();
125
+ const now = Date.now();
126
+ db.update(actorRefreshTokenRecords)
127
+ .set({ status: 'revoked', updatedAt: now })
128
+ .where(
129
+ and(
130
+ eq(actorRefreshTokenRecords.assistantId, assistantId),
131
+ eq(actorRefreshTokenRecords.guardianPrincipalId, guardianPrincipalId),
132
+ eq(actorRefreshTokenRecords.hashedDeviceId, hashedDeviceId),
133
+ eq(actorRefreshTokenRecords.status, 'active'),
134
+ ),
135
+ )
136
+ .run();
137
+ return rawChanges();
138
+ }
139
+
140
+ function rowToRecord(row: typeof actorRefreshTokenRecords.$inferSelect): RefreshTokenRecord {
141
+ return {
142
+ id: row.id,
143
+ tokenHash: row.tokenHash,
144
+ familyId: row.familyId,
145
+ assistantId: row.assistantId,
146
+ guardianPrincipalId: row.guardianPrincipalId,
147
+ hashedDeviceId: row.hashedDeviceId,
148
+ platform: row.platform,
149
+ status: row.status as RefreshTokenStatus,
150
+ issuedAt: row.issuedAt,
151
+ absoluteExpiresAt: row.absoluteExpiresAt,
152
+ inactivityExpiresAt: row.inactivityExpiresAt,
153
+ lastUsedAt: row.lastUsedAt,
154
+ createdAt: row.createdAt,
155
+ updatedAt: row.updatedAt,
156
+ };
157
+ }
@@ -143,14 +143,14 @@ export function hashToken(token: string): string {
143
143
  // Mint
144
144
  // ---------------------------------------------------------------------------
145
145
 
146
- /** Default TTL for actor tokens: 90 days in milliseconds. */
147
- const DEFAULT_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
146
+ /** Default TTL for actor tokens: 30 days in milliseconds. */
147
+ const DEFAULT_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
148
148
 
149
149
  /**
150
150
  * Mint a new actor token.
151
151
  *
152
152
  * @param params Token claims (assistantId, platform, deviceId, guardianPrincipalId).
153
- * @param ttlMs Optional TTL in milliseconds. Defaults to 90 days.
153
+ * @param ttlMs Optional TTL in milliseconds. Defaults to 30 days.
154
154
  * Pass `null` explicitly for a non-expiring token.
155
155
  * @returns The raw token, its hash, and the embedded claims.
156
156
  */
@@ -1,3 +1,5 @@
1
+ import { createHash } from 'node:crypto';
2
+
1
3
  import { getLogger } from '../util/logger.js';
2
4
  import type { ApprovalUIMetadata } from './channel-approval-types.js';
3
5
  import type { RuntimeAttachmentMetadata } from './http-types.js';
@@ -5,6 +7,13 @@ import type { RuntimeAttachmentMetadata } from './http-types.js';
5
7
  const log = getLogger('gateway-client');
6
8
 
7
9
  const DELIVERY_TIMEOUT_MS = 30_000;
10
+ const MANAGED_OUTBOUND_SEND_PATH = '/v1/internal/managed-gateway/outbound-send/';
11
+ const MANAGED_CALLBACK_TOKEN_HEADER = 'X-Managed-Gateway-Callback-Token';
12
+ const MANAGED_IDEMPOTENCY_HEADER = 'X-Idempotency-Key';
13
+ const MANAGED_OUTBOUND_MAX_ATTEMPTS = 3;
14
+ const MANAGED_OUTBOUND_RETRY_BASE_MS = 150;
15
+ const SMS_ATTACHMENTS_FALLBACK_TEXT =
16
+ 'I have a media attachment to share, but SMS currently supports text only.';
8
17
 
9
18
  export interface ChannelReplyPayload {
10
19
  chatId: string;
@@ -15,11 +24,26 @@ export interface ChannelReplyPayload {
15
24
  chatAction?: 'typing';
16
25
  }
17
26
 
27
+ interface ManagedOutboundCallbackContext {
28
+ requestUrl: string;
29
+ routeId: string;
30
+ assistantId: string;
31
+ sourceChannel: 'sms' | 'voice';
32
+ sourceUpdateId?: string;
33
+ callbackToken?: string;
34
+ }
35
+
18
36
  export async function deliverChannelReply(
19
37
  callbackUrl: string,
20
38
  payload: ChannelReplyPayload,
21
39
  bearerToken?: string,
22
40
  ): Promise<void> {
41
+ const managedCallback = parseManagedOutboundCallback(callbackUrl);
42
+ if (managedCallback) {
43
+ await deliverManagedOutboundReply(managedCallback, payload, bearerToken);
44
+ return;
45
+ }
46
+
23
47
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
24
48
  if (bearerToken) {
25
49
  headers['Authorization'] = `Bearer ${bearerToken}`;
@@ -51,6 +75,221 @@ export async function deliverChannelReply(
51
75
  }
52
76
  }
53
77
 
78
+ function parseManagedOutboundCallback(
79
+ callbackUrl: string,
80
+ ): ManagedOutboundCallbackContext | null {
81
+ let parsed: URL;
82
+ try {
83
+ parsed = new URL(callbackUrl);
84
+ } catch {
85
+ return null;
86
+ }
87
+
88
+ const normalizedPath = parsed.pathname.endsWith('/')
89
+ ? parsed.pathname
90
+ : `${parsed.pathname}/`;
91
+ if (normalizedPath !== MANAGED_OUTBOUND_SEND_PATH) {
92
+ return null;
93
+ }
94
+
95
+ const routeId = parsed.searchParams.get('route_id')?.trim();
96
+ const assistantId = parsed.searchParams.get('assistant_id')?.trim();
97
+ const sourceChannel = parsed.searchParams.get('source_channel')?.trim();
98
+
99
+ if (!routeId || !assistantId || (sourceChannel !== 'sms' && sourceChannel !== 'voice')) {
100
+ throw new Error(
101
+ 'Managed outbound callback URL is missing required route_id, assistant_id, or source_channel.',
102
+ );
103
+ }
104
+
105
+ const sourceUpdateId = parsed.searchParams.get('source_update_id')?.trim();
106
+ const callbackToken = parsed.searchParams.get('callback_token')?.trim();
107
+
108
+ parsed.searchParams.delete('route_id');
109
+ parsed.searchParams.delete('assistant_id');
110
+ parsed.searchParams.delete('source_channel');
111
+ parsed.searchParams.delete('source_update_id');
112
+ parsed.searchParams.delete('callback_token');
113
+
114
+ return {
115
+ requestUrl: parsed.toString(),
116
+ routeId,
117
+ assistantId,
118
+ sourceChannel,
119
+ sourceUpdateId,
120
+ callbackToken,
121
+ };
122
+ }
123
+
124
+ async function deliverManagedOutboundReply(
125
+ callback: ManagedOutboundCallbackContext,
126
+ payload: ChannelReplyPayload,
127
+ bearerToken?: string,
128
+ ): Promise<void> {
129
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
130
+ if (callback.callbackToken) {
131
+ headers[MANAGED_CALLBACK_TOKEN_HEADER] = callback.callbackToken;
132
+ } else if (bearerToken) {
133
+ headers.Authorization = `Bearer ${bearerToken}`;
134
+ }
135
+
136
+ const hasAttachments = Array.isArray(payload.attachments) && payload.attachments.length > 0;
137
+ const text = payload.approval?.plainTextFallback ?? payload.text;
138
+ const normalizedText =
139
+ typeof text === 'string' && text.trim().length > 0
140
+ ? text
141
+ : hasAttachments
142
+ ? SMS_ATTACHMENTS_FALLBACK_TEXT
143
+ : '';
144
+ if (!normalizedText) {
145
+ throw new Error('Managed outbound delivery requires text or plainTextFallback.');
146
+ }
147
+
148
+ const requestId = buildManagedOutboundRequestId(callback, payload, normalizedText);
149
+ headers[MANAGED_IDEMPOTENCY_HEADER] = requestId;
150
+
151
+ const requestBody = JSON.stringify({
152
+ route_id: callback.routeId,
153
+ assistant_id: callback.assistantId,
154
+ normalized_send: {
155
+ version: 'v1',
156
+ sourceChannel: callback.sourceChannel,
157
+ message: {
158
+ to: payload.chatId,
159
+ content: normalizedText,
160
+ externalMessageId: requestId,
161
+ },
162
+ source: {
163
+ requestId: requestId,
164
+ },
165
+ raw: {
166
+ chatId: payload.chatId,
167
+ text: payload.text ?? null,
168
+ assistantId: payload.assistantId ?? null,
169
+ chatAction: payload.chatAction ?? null,
170
+ hasAttachments,
171
+ sourceUpdateId: callback.sourceUpdateId ?? null,
172
+ },
173
+ },
174
+ });
175
+
176
+ for (let attempt = 1; attempt <= MANAGED_OUTBOUND_MAX_ATTEMPTS; attempt++) {
177
+ let response: Response;
178
+ try {
179
+ response = await fetch(callback.requestUrl, {
180
+ method: 'POST',
181
+ headers,
182
+ body: requestBody,
183
+ signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
184
+ });
185
+ } catch (error) {
186
+ if (attempt < MANAGED_OUTBOUND_MAX_ATTEMPTS) {
187
+ const retryDelayMs = MANAGED_OUTBOUND_RETRY_BASE_MS * attempt;
188
+ log.warn(
189
+ {
190
+ callbackUrl: callback.requestUrl,
191
+ routeId: callback.routeId,
192
+ requestId,
193
+ chatId: payload.chatId,
194
+ attempt,
195
+ retryDelayMs,
196
+ error,
197
+ },
198
+ 'Managed outbound delivery attempt failed before response; retrying',
199
+ );
200
+ await sleep(retryDelayMs);
201
+ continue;
202
+ }
203
+ throw error;
204
+ }
205
+
206
+ if (response.ok) {
207
+ log.info(
208
+ {
209
+ routeId: callback.routeId,
210
+ assistantId: callback.assistantId,
211
+ sourceChannel: callback.sourceChannel,
212
+ requestId,
213
+ chatId: payload.chatId,
214
+ attempt,
215
+ },
216
+ 'Managed outbound delivery accepted',
217
+ );
218
+ return;
219
+ }
220
+
221
+ const responseBody = await response.text().catch(() => '<unreadable>');
222
+ if (response.status >= 500 && response.status < 600 && attempt < MANAGED_OUTBOUND_MAX_ATTEMPTS) {
223
+ const retryDelayMs = MANAGED_OUTBOUND_RETRY_BASE_MS * attempt;
224
+ log.warn(
225
+ {
226
+ callbackUrl: callback.requestUrl,
227
+ routeId: callback.routeId,
228
+ requestId,
229
+ chatId: payload.chatId,
230
+ attempt,
231
+ status: response.status,
232
+ responseBody,
233
+ retryDelayMs,
234
+ },
235
+ 'Managed outbound delivery got retriable upstream response; retrying',
236
+ );
237
+ await sleep(retryDelayMs);
238
+ continue;
239
+ }
240
+
241
+ log.error(
242
+ {
243
+ status: response.status,
244
+ body: responseBody,
245
+ callbackUrl: callback.requestUrl,
246
+ routeId: callback.routeId,
247
+ requestId,
248
+ chatId: payload.chatId,
249
+ attempt,
250
+ },
251
+ 'Managed outbound delivery failed',
252
+ );
253
+ throw new Error(`Managed outbound delivery failed (${response.status}): ${responseBody}`);
254
+ }
255
+ }
256
+
257
+ function buildManagedOutboundRequestId(
258
+ callback: ManagedOutboundCallbackContext,
259
+ payload: ChannelReplyPayload,
260
+ normalizedText: string,
261
+ ): string {
262
+ const bodyMaterial = JSON.stringify({
263
+ callback: {
264
+ routeId: callback.routeId,
265
+ assistantId: callback.assistantId,
266
+ sourceChannel: callback.sourceChannel,
267
+ sourceUpdateId: callback.sourceUpdateId ?? null,
268
+ },
269
+ payload: {
270
+ chatId: payload.chatId,
271
+ text: normalizedText,
272
+ assistantId: payload.assistantId ?? null,
273
+ chatAction: payload.chatAction ?? null,
274
+ hasAttachments: Array.isArray(payload.attachments) && payload.attachments.length > 0,
275
+ approvalRequestId: payload.approval?.requestId ?? null,
276
+ approvalActions: payload.approval?.actions.map(action => action.id) ?? null,
277
+ },
278
+ });
279
+
280
+ const digest = createHash('sha256')
281
+ .update(bodyMaterial)
282
+ .digest('hex')
283
+ .slice(0, 40);
284
+ return `mgw-send-${digest}`;
285
+ }
286
+
287
+ async function sleep(ms: number): Promise<void> {
288
+ await new Promise(resolve => {
289
+ setTimeout(resolve, ms);
290
+ });
291
+ }
292
+
54
293
  /**
55
294
  * Deliver an approval prompt (text + inline keyboard metadata) to the
56
295
  * gateway so it can render the approval UI in the channel.
@@ -132,6 +132,7 @@ import {
132
132
  handleGuardianActionsPending,
133
133
  } from './routes/guardian-action-routes.js';
134
134
  import { handleGuardianBootstrap } from './routes/guardian-bootstrap-routes.js';
135
+ import { handleGuardianRefresh } from './routes/guardian-refresh-routes.js';
135
136
  import { handleGetIdentity,handleHealth } from './routes/identity-routes.js';
136
137
  import {
137
138
  handleBlockMember,
@@ -783,6 +784,7 @@ export class RuntimeHttpServer {
783
784
 
784
785
  // Guardian vellum channel bootstrap
785
786
  if (endpoint === 'integrations/guardian/vellum/bootstrap' && req.method === 'POST') return await handleGuardianBootstrap(req, server);
787
+ if (endpoint === 'integrations/guardian/vellum/refresh' && req.method === 'POST') return await handleGuardianRefresh(req);
786
788
 
787
789
  // Integrations — Twilio config
788
790
  if (endpoint === 'integrations/twilio/config' && req.method === 'GET') return handleGetTwilioConfig();
@@ -18,11 +18,7 @@ import {
18
18
  getActiveBinding,
19
19
  } from '../../memory/guardian-bindings.js';
20
20
  import { getLogger } from '../../util/logger.js';
21
- import { mintActorToken } from '../actor-token-service.js';
22
- import {
23
- createActorTokenRecord,
24
- revokeByDeviceBinding,
25
- } from '../actor-token-store.js';
21
+ import { mintCredentialPair } from '../actor-refresh-token-service.js';
26
22
  import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
27
23
  import { httpError } from '../http-errors.js';
28
24
  import type { ServerWithRequestIP } from '../middleware/actor-token.js';
@@ -98,35 +94,21 @@ export async function handleGuardianBootstrap(req: Request, server: ServerWithRe
98
94
  return httpError('BAD_REQUEST', 'Missing required fields: platform, deviceId', 400);
99
95
  }
100
96
 
101
- if (platform !== 'macos') {
102
- return httpError('BAD_REQUEST', 'Invalid platform. Bootstrap is macOS-only; iOS uses QR pairing.', 400);
97
+ if (platform !== 'macos' && platform !== 'cli') {
98
+ return httpError('BAD_REQUEST', 'Invalid platform. Bootstrap is macOS/CLI-only; iOS uses QR pairing.', 400);
103
99
  }
104
100
 
105
101
  const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
106
102
  const { guardianPrincipalId, isNew } = ensureGuardianPrincipal(assistantId);
107
103
  const hashedDeviceId = hashDeviceId(deviceId);
108
104
 
109
- // Revoke any existing active tokens for this device binding
110
- // so we maintain one-active-token-per-device
111
- revokeByDeviceBinding(assistantId, guardianPrincipalId, hashedDeviceId);
112
-
113
- // Mint a new actor token
114
- const { token, tokenHash, claims } = mintActorToken({
105
+ // Mint credential pair (access token + refresh token)
106
+ const credentials = mintCredentialPair({
115
107
  assistantId,
116
108
  platform,
117
109
  deviceId,
118
110
  guardianPrincipalId,
119
- });
120
-
121
- // Store only the hash
122
- createActorTokenRecord({
123
- tokenHash,
124
- assistantId,
125
- guardianPrincipalId,
126
111
  hashedDeviceId,
127
- platform,
128
- issuedAt: claims.iat,
129
- expiresAt: claims.exp,
130
112
  });
131
113
 
132
114
  log.info(
@@ -136,7 +118,11 @@ export async function handleGuardianBootstrap(req: Request, server: ServerWithRe
136
118
 
137
119
  return Response.json({
138
120
  guardianPrincipalId,
139
- actorToken: token,
121
+ actorToken: credentials.actorToken,
122
+ actorTokenExpiresAt: credentials.actorTokenExpiresAt,
123
+ refreshToken: credentials.refreshToken,
124
+ refreshTokenExpiresAt: credentials.refreshTokenExpiresAt,
125
+ refreshAfter: credentials.refreshAfter,
140
126
  isNew,
141
127
  });
142
128
  } catch (err) {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * POST /v1/integrations/guardian/vellum/refresh
3
+ *
4
+ * Rotates the refresh token and mints a new access token + refresh token pair.
5
+ * This endpoint is the runtime handler proxied through the gateway.
6
+ */
7
+
8
+ import { getLogger } from '../../util/logger.js';
9
+ import { rotateCredentials } from '../actor-refresh-token-service.js';
10
+ import { httpError } from '../http-errors.js';
11
+
12
+ const log = getLogger('guardian-refresh');
13
+
14
+ /**
15
+ * Handle POST /v1/integrations/guardian/vellum/refresh
16
+ *
17
+ * Body: { platform: 'ios' | 'macos', deviceId: string, refreshToken: string }
18
+ * Returns: { guardianPrincipalId, actorToken, actorTokenExpiresAt, refreshToken, refreshTokenExpiresAt, refreshAfter }
19
+ */
20
+ export async function handleGuardianRefresh(req: Request): Promise<Response> {
21
+ try {
22
+ const body = await req.json() as Record<string, unknown>;
23
+ const platform = typeof body.platform === 'string' ? body.platform.trim() : '';
24
+ const deviceId = typeof body.deviceId === 'string' ? body.deviceId.trim() : '';
25
+ const refreshToken = typeof body.refreshToken === 'string' ? body.refreshToken : '';
26
+
27
+ if (!platform || !deviceId || !refreshToken) {
28
+ return httpError('BAD_REQUEST', 'Missing required fields: platform, deviceId, refreshToken', 400);
29
+ }
30
+
31
+ if (platform !== 'ios' && platform !== 'macos') {
32
+ return httpError('BAD_REQUEST', 'Invalid platform. Must be "ios" or "macos".', 400);
33
+ }
34
+
35
+ const result = rotateCredentials({ refreshToken, platform, deviceId });
36
+
37
+ if (!result.ok) {
38
+ const statusCode = result.error === 'refresh_reuse_detected' ? 403
39
+ : result.error === 'device_binding_mismatch' ? 403
40
+ : result.error === 'revoked' ? 403
41
+ : 401;
42
+
43
+ log.warn({ error: result.error, platform }, 'Refresh token rotation failed');
44
+ return Response.json({ error: result.error }, { status: statusCode });
45
+ }
46
+
47
+ log.info({ platform, guardianPrincipalId: result.result.guardianPrincipalId }, 'Refresh token rotation succeeded');
48
+ return Response.json(result.result);
49
+ } catch (err) {
50
+ log.error({ err }, 'Guardian refresh failed');
51
+ return httpError('INTERNAL_ERROR', 'Internal server error', 500);
52
+ }
53
+ }