@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.
- package/ARCHITECTURE.md +23 -6
- package/bun.lock +51 -0
- package/docs/trusted-contact-access.md +8 -0
- package/package.json +2 -1
- package/src/__tests__/actor-token-service.test.ts +4 -4
- package/src/__tests__/call-controller.test.ts +37 -0
- package/src/__tests__/channel-delivery-store.test.ts +2 -2
- package/src/__tests__/gateway-client-managed-outbound.test.ts +147 -0
- package/src/__tests__/guardian-dispatch.test.ts +39 -1
- package/src/__tests__/guardian-routing-state.test.ts +8 -30
- package/src/__tests__/non-member-access-request.test.ts +7 -0
- package/src/__tests__/notification-decision-fallback.test.ts +232 -0
- package/src/__tests__/notification-decision-strategy.test.ts +304 -8
- package/src/__tests__/notification-guardian-path.test.ts +38 -1
- package/src/__tests__/relay-server.test.ts +65 -5
- package/src/__tests__/send-endpoint-busy.test.ts +29 -1
- package/src/__tests__/tool-grant-request-escalation.test.ts +1 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +2 -2
- package/src/__tests__/trusted-contact-multichannel.test.ts +1 -1
- package/src/calls/call-controller.ts +15 -0
- package/src/calls/relay-server.ts +45 -11
- package/src/calls/types.ts +1 -0
- package/src/daemon/providers-setup.ts +0 -8
- package/src/daemon/session-slash.ts +35 -2
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/039-actor-refresh-token-records.ts +51 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +1 -1
- package/src/memory/schema.ts +19 -0
- package/src/notifications/README.md +8 -1
- package/src/notifications/copy-composer.ts +160 -30
- package/src/notifications/decision-engine.ts +98 -1
- package/src/runtime/actor-refresh-token-service.ts +309 -0
- package/src/runtime/actor-refresh-token-store.ts +157 -0
- package/src/runtime/actor-token-service.ts +3 -3
- package/src/runtime/gateway-client.ts +239 -0
- package/src/runtime/http-server.ts +2 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +10 -24
- package/src/runtime/routes/guardian-refresh-routes.ts +53 -0
- package/src/runtime/routes/pairing-routes.ts +60 -50
- 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:
|
|
147
|
-
const DEFAULT_TOKEN_TTL_MS =
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
110
|
-
|
|
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:
|
|
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
|
+
}
|