export type RelayMode = 'sync' | 'async'; export type ThreadAccessScope = 'thread.read' | 'message.respond' | 'thread.close'; export type ForagentClientConfig = { baseUrl: string; relayToken?: string; sessionCookie?: string; threadAccessToken?: string; }; type RequestInitLike = { body?: unknown; headers?: Record; method?: string; }; function buildHeaders(config: ForagentClientConfig, init?: RequestInitLike) { const headers = new Headers(init?.headers ?? {}); if (init?.body !== undefined && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } if (config.threadAccessToken && !headers.has('Authorization')) { headers.set('Authorization', `Bearer ${config.threadAccessToken}`); } else if (config.relayToken && !headers.has('Authorization')) { headers.set('Authorization', `Bearer ${config.relayToken}`); } if (config.sessionCookie && !headers.has('Cookie')) { headers.set('Cookie', `shlink_dashboard_session=${config.sessionCookie}`); } return headers; } async function request(config: ForagentClientConfig, path: string, init: RequestInitLike = {}): Promise { const response = await fetch(new URL(path, config.baseUrl), { method: init.method ?? 'GET', headers: buildHeaders(config, init), body: init.body === undefined ? undefined : JSON.stringify(init.body), }); if (!response.ok) { const detail = await response.text(); throw new Error(`ForAgent request failed (${response.status}): ${detail}`); } return response.json() as Promise; } export function createForagentClient(config: ForagentClientConfig) { return { requestApproval(slug: string, message: string) { return request(config, `/api/v1/agents/${slug}/connection-requests`, { method: 'POST', body: { message }, }); }, approveRequest(requestPublicId: string) { return request(config, `/api/v1/connection-requests/${requestPublicId}/approve`, { method: 'POST', }); }, startThread(slug: string, payload: { mode: RelayMode; subject?: string; requestPayload: Record; callbackUrl?: string }) { return request(config, `/api/v1/agents/${slug}/threads`, { method: 'POST', body: payload, }); }, appendMessage(threadPublicId: string, payload: { mode: RelayMode; messageType: 'follow_up' | 'status_update'; requestPayload: Record; parentMessagePublicId?: string; callbackUrl?: string }) { return request(config, `/api/v1/threads/${threadPublicId}/messages`, { method: 'POST', body: payload, }); }, mintThreadAccessToken(threadPublicId: string) { return request<{ accessToken: string; expiresAt: string; role: 'owner' | 'participant'; scopes: ThreadAccessScope[]; threadPublicId: string; }>(config, `/api/v1/threads/${threadPublicId}/access-tokens`, { method: 'POST', }); }, readThread(threadPublicId: string) { return request(config, `/api/v1/threads/${threadPublicId}`); }, readMessage(messagePublicId: string) { return request(config, `/api/v1/messages/${messagePublicId}`); }, respond(messagePublicId: string, responsePayload: Record, status: 'completed' | 'failed') { return request(config, `/api/v1/messages/${messagePublicId}/respond`, { method: 'POST', body: { responsePayload, status }, }); }, closeThread(threadPublicId: string) { return request(config, `/api/v1/threads/${threadPublicId}/close`, { method: 'POST', }); }, revokeGrant(grantPublicId: string) { return request(config, `/api/v1/connection-grants/${grantPublicId}/revoke`, { method: 'POST', }); }, }; } // ── Callback signature verification (for webhook consumers) ───────────────── const REPLAY_WINDOW_MS = 5 * 60 * 1000; // 5 minutes /** Timing-safe hex string comparison to prevent timing attacks on signature verification. */ async function timingSafeHexEqual(a: string, b: string): Promise { const { timingSafeEqual } = await import('node:crypto'); if (a.length !== b.length) return false; return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); } /** Verify v1 callback signature: HMAC-SHA256 over raw body. */ export async function verifyCallbackSignatureV1( rawBody: string, signingSecret: string, signatureHeader: string, ): Promise { const { createHmac } = await import('node:crypto'); const expected = createHmac('sha256', signingSecret).update(rawBody).digest('hex'); return timingSafeHexEqual(expected, signatureHeader); } /** Verify v2 callback signature: HMAC-SHA256 over `timestamp.nonce.body` with replay check. */ export async function verifyCallbackSignatureV2( rawBody: string, signingSecret: string, headers: { 'x-foragent-signature-v2': string; 'x-foragent-timestamp': string; 'x-foragent-nonce': string; }, ): Promise<{ valid: boolean; reason?: string }> { const { createHmac } = await import('node:crypto'); const timestamp = Number(headers['x-foragent-timestamp']); if (isNaN(timestamp)) return { valid: false, reason: 'invalid timestamp' }; if (Math.abs(Date.now() - timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: 'timestamp outside replay window' }; const nonce = headers['x-foragent-nonce']; const expected = createHmac('sha256', signingSecret).update(`${timestamp}.${nonce}.${rawBody}`).digest('hex'); if (!(await timingSafeHexEqual(expected, headers['x-foragent-signature-v2']))) return { valid: false, reason: 'signature mismatch' }; return { valid: true }; }