@newhomestar/sdk 0.5.2 → 0.6.5

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.
@@ -0,0 +1,754 @@
1
+ // Nova SDK — Credential Resolution & mTLS-aware Fetch
2
+ // =====================================================
3
+ // Generic infrastructure for resolving OAuth credentials for ANY integration.
4
+ // Handles three auth modes:
5
+ // - 'mtls' → client_credentials + mTLS cert/key
6
+ // - 'client_credentials' → client_credentials grant (server-to-server)
7
+ // - 'standard' → authorization_code (per-user OAuth redirect)
8
+ //
9
+ // Two credential resolution strategies:
10
+ // A. Direct DB (PLATFORM_SUPABASE_*) — container has direct DB access
11
+ // B. HTTP callback (AUTH_ISSUER_BASE_URL) — container calls auth server with JWT
12
+ //
13
+ // Tokens are cached:
14
+ // 1. In-memory Map (hot path — no I/O)
15
+ // 2. user_app_connections DB rows (durable — vault-encrypted) [strategy A only]
16
+ // 3. Fresh token exchange (cold path — network + vault I/O)
17
+ //
18
+ // Every integration gets this for free via ctx.resolveCredentials() / ctx.fetch().
19
+ import { createClient } from "@supabase/supabase-js";
20
+ import https from "node:https";
21
+ // ─── Error Classes ──────────────────────────────────────────────────────────
22
+ export class IntegrationNotFoundError extends Error {
23
+ constructor(slug) {
24
+ super(`Integration "${slug}" not found or not registered`);
25
+ this.name = "IntegrationNotFoundError";
26
+ }
27
+ }
28
+ export class IntegrationDisabledError extends Error {
29
+ constructor(slug) {
30
+ super(`Integration "${slug}" is disabled. Enable it in the admin dashboard.`);
31
+ this.name = "IntegrationDisabledError";
32
+ }
33
+ }
34
+ export class CredentialsNotConfiguredError extends Error {
35
+ constructor(slug, detail) {
36
+ super(`Integration "${slug}" credentials not configured: ${detail}`);
37
+ this.name = "CredentialsNotConfiguredError";
38
+ }
39
+ }
40
+ export class ConnectionNotFoundError extends Error {
41
+ constructor(slug, userId) {
42
+ super(`No active connection found for user "${userId}" on integration "${slug}". The user must authorize first.`);
43
+ this.name = "ConnectionNotFoundError";
44
+ }
45
+ }
46
+ export class TokenExchangeError extends Error {
47
+ constructor(slug, detail) {
48
+ super(`Token exchange failed for "${slug}": ${detail}`);
49
+ this.name = "TokenExchangeError";
50
+ }
51
+ }
52
+ // ─── Platform Client Singleton ──────────────────────────────────────────────
53
+ let _platformClient = null;
54
+ /**
55
+ * Creates / returns the singleton Platform DB client.
56
+ * Reads from PLATFORM_SUPABASE_URL + PLATFORM_SUPABASE_SERVICE_ROLE_KEY.
57
+ */
58
+ export function createPlatformClient() {
59
+ if (!_platformClient) {
60
+ const url = process.env.PLATFORM_SUPABASE_URL;
61
+ const key = process.env.PLATFORM_SUPABASE_SERVICE_ROLE_KEY;
62
+ if (!url || !key) {
63
+ throw new Error("[nova-sdk] Missing PLATFORM_SUPABASE_URL or PLATFORM_SUPABASE_SERVICE_ROLE_KEY. " +
64
+ "These should be injected by the Nova orchestrator at container runtime.");
65
+ }
66
+ _platformClient = createClient(url, key, {
67
+ auth: { autoRefreshToken: false, persistSession: false },
68
+ });
69
+ console.log("[nova-sdk] ✅ Platform DB client initialized");
70
+ }
71
+ return _platformClient;
72
+ }
73
+ const _tokenCache = new Map();
74
+ /** Token buffer — refresh tokens 5 minutes before actual expiry */
75
+ const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
76
+ /** System user UUID for client_credentials/mTLS connections (audit trail) */
77
+ const SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
78
+ function getCacheKey(integrationSlug, userId) {
79
+ return `${integrationSlug}:${userId ?? SYSTEM_USER_ID}`;
80
+ }
81
+ function getCachedToken(key) {
82
+ const cached = _tokenCache.get(key);
83
+ if (!cached)
84
+ return null;
85
+ if (new Date().getTime() >= cached.expiresAt.getTime() - TOKEN_EXPIRY_BUFFER_MS) {
86
+ console.log(`[nova-sdk] 🔄 In-memory cached token expired for ${key}`);
87
+ _tokenCache.delete(key);
88
+ return null;
89
+ }
90
+ return cached;
91
+ }
92
+ // ─── Vault Helpers ──────────────────────────────────────────────────────────
93
+ async function getVaultSecret(platformDB, vaultId, label) {
94
+ if (!vaultId)
95
+ return null;
96
+ console.log(`[nova-sdk] 🔐 Reading vault secret: ${label} (${vaultId.slice(0, 8)}...)`);
97
+ const { data, error } = await platformDB.rpc("get_vault_secret", { p_id: vaultId });
98
+ if (error) {
99
+ if (error.code === "PGRST116")
100
+ return null;
101
+ throw new Error(`[nova-sdk] Vault read failed for ${label}: ${error.message}`);
102
+ }
103
+ return data ?? null;
104
+ }
105
+ async function storeVaultSecret(platformDB, name, value, description) {
106
+ console.log(`[nova-sdk] 🔐 Storing vault secret: ${name}`);
107
+ const { data, error } = await platformDB.rpc("create_vault_secret", {
108
+ p_secret: value,
109
+ p_name: name,
110
+ p_description: description ?? `Nova integration credential: ${name}`,
111
+ });
112
+ if (error) {
113
+ throw new Error(`[nova-sdk] Vault store failed for ${name}: ${error.message}`);
114
+ }
115
+ return data;
116
+ }
117
+ async function updateVaultSecret(platformDB, vaultId, newValue) {
118
+ const { error } = await platformDB.rpc("update_vault_secret", {
119
+ p_id: vaultId,
120
+ p_secret: newValue,
121
+ });
122
+ if (error) {
123
+ throw new Error(`[nova-sdk] Vault update failed for ${vaultId}: ${error.message}`);
124
+ }
125
+ }
126
+ // ─── mTLS Agent Builder ─────────────────────────────────────────────────────
127
+ // Cache mTLS agents per integration slug (cert/key don't change during container lifetime)
128
+ const _mtlsAgents = new Map();
129
+ async function buildMtlsAgent(platformDB, config) {
130
+ const existing = _mtlsAgents.get(config.slug);
131
+ if (existing)
132
+ return existing;
133
+ if (!config.mtls_cert_vault_id || !config.mtls_key_vault_id) {
134
+ throw new CredentialsNotConfiguredError(config.slug, "mTLS cert and/or key not stored in vault. Upload PEM files via admin dashboard.");
135
+ }
136
+ const [certPem, keyPem] = await Promise.all([
137
+ getVaultSecret(platformDB, config.mtls_cert_vault_id, "mtls_cert"),
138
+ getVaultSecret(platformDB, config.mtls_key_vault_id, "mtls_key"),
139
+ ]);
140
+ if (!certPem || !keyPem) {
141
+ throw new CredentialsNotConfiguredError(config.slug, "mTLS cert or key vault entries are empty. Re-upload PEM files.");
142
+ }
143
+ console.log(`[nova-sdk] 🔒 Built mTLS agent for "${config.slug}" (cert: ${certPem.length} bytes, key: ${keyPem.length} bytes)`);
144
+ const agent = new https.Agent({ cert: certPem, key: keyPem, keepAlive: true });
145
+ _mtlsAgents.set(config.slug, agent);
146
+ return agent;
147
+ }
148
+ async function performTokenExchange(slug, params) {
149
+ const { tokenEndpoint, clientId, clientSecret, httpsAgent, grantType = "client_credentials", } = params;
150
+ const body = new URLSearchParams({
151
+ grant_type: grantType,
152
+ client_id: clientId,
153
+ client_secret: clientSecret,
154
+ });
155
+ // For mTLS, we must use node:https directly because global fetch()
156
+ // doesn't support custom TLS agents.
157
+ if (httpsAgent) {
158
+ return new Promise((resolve, reject) => {
159
+ const url = new URL(tokenEndpoint);
160
+ const reqOptions = {
161
+ hostname: url.hostname,
162
+ port: url.port || 443,
163
+ path: url.pathname + url.search,
164
+ method: "POST",
165
+ agent: httpsAgent,
166
+ headers: {
167
+ "Content-Type": "application/x-www-form-urlencoded",
168
+ "Content-Length": Buffer.byteLength(body.toString()),
169
+ Accept: "application/json",
170
+ },
171
+ };
172
+ const req = https.request(reqOptions, (res) => {
173
+ let data = "";
174
+ res.on("data", (chunk) => (data += chunk));
175
+ res.on("end", () => {
176
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
177
+ try {
178
+ resolve(JSON.parse(data));
179
+ }
180
+ catch {
181
+ reject(new TokenExchangeError(slug, `Invalid JSON response: ${data.slice(0, 200)}`));
182
+ }
183
+ }
184
+ else {
185
+ reject(new TokenExchangeError(slug, `HTTP ${res.statusCode}: ${data.slice(0, 500)}`));
186
+ }
187
+ });
188
+ });
189
+ req.on("error", (err) => {
190
+ reject(new TokenExchangeError(slug, `mTLS request error: ${err.message}`));
191
+ });
192
+ req.write(body.toString());
193
+ req.end();
194
+ });
195
+ }
196
+ // Non-mTLS: use regular fetch
197
+ const res = await fetch(tokenEndpoint, {
198
+ method: "POST",
199
+ headers: {
200
+ "Content-Type": "application/x-www-form-urlencoded",
201
+ Accept: "application/json",
202
+ },
203
+ body: body.toString(),
204
+ });
205
+ if (!res.ok) {
206
+ const errText = await res.text();
207
+ throw new TokenExchangeError(slug, `HTTP ${res.status}: ${errText.slice(0, 500)}`);
208
+ }
209
+ return res.json();
210
+ }
211
+ // ─── Main: resolveCredentials() ─────────────────────────────────────────────
212
+ /**
213
+ * Resolves an access token for an integration, handling all auth modes.
214
+ *
215
+ * Flow:
216
+ * 1. Look up integration config from app_integrations (by slug)
217
+ * 2. Check auth_mode → route to server or user flow
218
+ * 3. Check in-memory cache → DB (user_app_connections) → exchange if needed
219
+ * 4. Cache the result and return
220
+ *
221
+ * @param platformDB - Platform Supabase client (project-starfleet-auth)
222
+ * @param slug - Integration slug (e.g., 'adp', 'salesforce')
223
+ * @param userId - User ID for standard OAuth; optional for server flows
224
+ */
225
+ export async function resolveCredentials(platformDB, slug, userId) {
226
+ console.log(`[nova-sdk] 🔑 Resolving credentials for "${slug}" (userId: ${userId ?? "system"})`);
227
+ // ── Step 1: Look up integration config ────────────────────────────────
228
+ const { data: integration, error: intError } = await platformDB
229
+ .from("app_integrations")
230
+ .select("id, slug, name, auth_mode, client_id, client_secret_vault_id, token_endpoint, mtls_cert_vault_id, mtls_key_vault_id, is_active")
231
+ .eq("slug", slug)
232
+ .single();
233
+ if (intError || !integration) {
234
+ console.error(`[nova-sdk] ❌ Integration "${slug}" not found:`, intError?.message);
235
+ throw new IntegrationNotFoundError(slug);
236
+ }
237
+ if (!integration.is_active) {
238
+ console.error(`[nova-sdk] ❌ Integration "${slug}" is disabled`);
239
+ throw new IntegrationDisabledError(slug);
240
+ }
241
+ const config = integration;
242
+ console.log(`[nova-sdk] 📋 Integration: ${config.name} | auth_mode=${config.auth_mode} | id=${config.id}`);
243
+ // ── Step 2: Route by auth_mode ────────────────────────────────────────
244
+ switch (config.auth_mode) {
245
+ case "mtls":
246
+ case "client_credentials":
247
+ return resolveServerCredentials(platformDB, config, userId);
248
+ case "standard":
249
+ if (!userId) {
250
+ throw new Error(`[nova-sdk] userId is required for standard OAuth (authorization_code) flow on "${slug}"`);
251
+ }
252
+ return resolveUserCredentials(platformDB, config, userId);
253
+ default:
254
+ throw new Error(`[nova-sdk] Unknown auth_mode: ${config.auth_mode}`);
255
+ }
256
+ }
257
+ // ─── Server Credentials (client_credentials / mTLS) ─────────────────────────
258
+ async function resolveServerCredentials(platformDB, config, userId) {
259
+ const effectiveUserId = userId ?? SYSTEM_USER_ID;
260
+ const cacheKey = getCacheKey(config.slug, effectiveUserId);
261
+ // ── Check in-memory cache ─────────────────────────────────────────────
262
+ const cached = getCachedToken(cacheKey);
263
+ if (cached) {
264
+ console.log(`[nova-sdk] ⚡ Using in-memory cached token (expires ${cached.expiresAt.toISOString()})`);
265
+ return {
266
+ accessToken: cached.accessToken,
267
+ expiresAt: cached.expiresAt,
268
+ integrationId: config.id,
269
+ authMode: config.auth_mode,
270
+ httpsAgent: cached.httpsAgent,
271
+ };
272
+ }
273
+ // ── Check DB cache (user_app_connections) ─────────────────────────────
274
+ const { data: existingConn } = await platformDB
275
+ .from("user_app_connections")
276
+ .select("id, access_token_vault_id, refresh_token_vault_id, token_expires_at, is_active")
277
+ .eq("integration_id", config.id)
278
+ .eq("user_id", effectiveUserId)
279
+ .eq("is_active", true)
280
+ .single();
281
+ if (existingConn?.access_token_vault_id && existingConn.token_expires_at) {
282
+ const expiresAt = new Date(existingConn.token_expires_at);
283
+ const now = new Date();
284
+ if (expiresAt.getTime() - TOKEN_EXPIRY_BUFFER_MS > now.getTime()) {
285
+ console.log(`[nova-sdk] 📦 Found valid cached token in DB (expires ${expiresAt.toISOString()})`);
286
+ const accessToken = await getVaultSecret(platformDB, existingConn.access_token_vault_id, "cached_access_token");
287
+ if (accessToken) {
288
+ const httpsAgent = config.auth_mode === "mtls"
289
+ ? await buildMtlsAgent(platformDB, config)
290
+ : undefined;
291
+ _tokenCache.set(cacheKey, { accessToken, expiresAt, httpsAgent });
292
+ return {
293
+ accessToken,
294
+ expiresAt,
295
+ integrationId: config.id,
296
+ authMode: config.auth_mode,
297
+ httpsAgent,
298
+ };
299
+ }
300
+ console.warn(`[nova-sdk] ⚠️ Vault secret missing for cached token — will re-exchange`);
301
+ }
302
+ else {
303
+ console.log(`[nova-sdk] 🔄 Cached token expired (was ${expiresAt.toISOString()}) — re-exchanging`);
304
+ }
305
+ }
306
+ else {
307
+ console.log(`[nova-sdk] 📭 No cached token found in DB — performing initial token exchange`);
308
+ }
309
+ // ── Exchange for new token ────────────────────────────────────────────
310
+ return exchangeAndCacheServerToken(platformDB, config, effectiveUserId, existingConn?.id);
311
+ }
312
+ async function exchangeAndCacheServerToken(platformDB, config, userId, existingConnectionId) {
313
+ // Validate required fields
314
+ if (!config.client_id) {
315
+ throw new CredentialsNotConfiguredError(config.slug, "client_id is not set");
316
+ }
317
+ if (!config.client_secret_vault_id) {
318
+ throw new CredentialsNotConfiguredError(config.slug, "client_secret is not stored in vault");
319
+ }
320
+ if (!config.token_endpoint) {
321
+ throw new CredentialsNotConfiguredError(config.slug, "token_endpoint is not set");
322
+ }
323
+ // Decrypt client_secret from vault
324
+ const clientSecret = await getVaultSecret(platformDB, config.client_secret_vault_id, "client_secret");
325
+ if (!clientSecret) {
326
+ throw new CredentialsNotConfiguredError(config.slug, "client_secret vault entry is empty");
327
+ }
328
+ // Build mTLS agent if needed
329
+ let httpsAgent;
330
+ if (config.auth_mode === "mtls") {
331
+ httpsAgent = await buildMtlsAgent(platformDB, config);
332
+ }
333
+ console.log(`[nova-sdk] 🔄 Exchanging client_credentials at ${config.token_endpoint} for "${config.slug}"`);
334
+ const tokenResponse = await performTokenExchange(config.slug, {
335
+ tokenEndpoint: config.token_endpoint,
336
+ clientId: config.client_id,
337
+ clientSecret,
338
+ httpsAgent,
339
+ });
340
+ const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000);
341
+ const cacheKey = getCacheKey(config.slug, userId);
342
+ console.log(`[nova-sdk] ✅ Token exchange successful for "${config.slug}" — expires ${expiresAt.toISOString()} (${tokenResponse.expires_in}s)`);
343
+ // ── Store token in vault + user_app_connections ───────────────────────
344
+ const vaultName = `connection:${userId}:${config.slug}:access_token`;
345
+ let accessTokenVaultId;
346
+ if (existingConnectionId) {
347
+ const { data: conn } = await platformDB
348
+ .from("user_app_connections")
349
+ .select("access_token_vault_id")
350
+ .eq("id", existingConnectionId)
351
+ .single();
352
+ if (conn?.access_token_vault_id) {
353
+ await updateVaultSecret(platformDB, conn.access_token_vault_id, tokenResponse.access_token);
354
+ accessTokenVaultId = conn.access_token_vault_id;
355
+ }
356
+ else {
357
+ accessTokenVaultId = await storeVaultSecret(platformDB, vaultName, tokenResponse.access_token);
358
+ }
359
+ await platformDB
360
+ .from("user_app_connections")
361
+ .update({
362
+ access_token_vault_id: accessTokenVaultId,
363
+ token_expires_at: expiresAt.toISOString(),
364
+ token_scope: tokenResponse.scope ?? null,
365
+ last_used_at: new Date().toISOString(),
366
+ is_active: true,
367
+ })
368
+ .eq("id", existingConnectionId);
369
+ console.log(`[nova-sdk] 📦 Updated existing connection ${existingConnectionId}`);
370
+ }
371
+ else {
372
+ accessTokenVaultId = await storeVaultSecret(platformDB, vaultName, tokenResponse.access_token);
373
+ const { error: insertError } = await platformDB
374
+ .from("user_app_connections")
375
+ .upsert({
376
+ user_id: userId,
377
+ integration_id: config.id,
378
+ access_token_vault_id: accessTokenVaultId,
379
+ token_expires_at: expiresAt.toISOString(),
380
+ token_scope: tokenResponse.scope ?? null,
381
+ is_active: true,
382
+ last_used_at: new Date().toISOString(),
383
+ metadata: { auth_mode: config.auth_mode, grant_type: "client_credentials" },
384
+ }, { onConflict: "user_id,integration_id" });
385
+ if (insertError) {
386
+ console.error(`[nova-sdk] ⚠️ Failed to cache connection:`, insertError.message);
387
+ }
388
+ else {
389
+ console.log(`[nova-sdk] 📦 Created new connection for user ${userId}`);
390
+ }
391
+ }
392
+ // Populate in-memory cache
393
+ _tokenCache.set(cacheKey, {
394
+ accessToken: tokenResponse.access_token,
395
+ expiresAt,
396
+ httpsAgent,
397
+ });
398
+ return {
399
+ accessToken: tokenResponse.access_token,
400
+ expiresAt,
401
+ integrationId: config.id,
402
+ authMode: config.auth_mode,
403
+ httpsAgent,
404
+ };
405
+ }
406
+ // ─── User Credentials (authorization_code / standard) ───────────────────────
407
+ async function resolveUserCredentials(platformDB, config, userId) {
408
+ const cacheKey = getCacheKey(config.slug, userId);
409
+ // Check in-memory cache
410
+ const cached = getCachedToken(cacheKey);
411
+ if (cached) {
412
+ console.log(`[nova-sdk] ⚡ Using in-memory cached user token (expires ${cached.expiresAt.toISOString()})`);
413
+ return {
414
+ accessToken: cached.accessToken,
415
+ expiresAt: cached.expiresAt,
416
+ integrationId: config.id,
417
+ authMode: "standard",
418
+ };
419
+ }
420
+ // Look up user's connection
421
+ const { data: conn, error: connError } = await platformDB
422
+ .from("user_app_connections")
423
+ .select("id, access_token_vault_id, refresh_token_vault_id, token_expires_at, is_active")
424
+ .eq("integration_id", config.id)
425
+ .eq("user_id", userId)
426
+ .eq("is_active", true)
427
+ .single();
428
+ if (connError || !conn) {
429
+ throw new ConnectionNotFoundError(config.slug, userId);
430
+ }
431
+ if (!conn.access_token_vault_id) {
432
+ throw new ConnectionNotFoundError(config.slug, userId);
433
+ }
434
+ const expiresAt = conn.token_expires_at ? new Date(conn.token_expires_at) : new Date(0);
435
+ const now = new Date();
436
+ // Check if token is still valid
437
+ if (expiresAt.getTime() - TOKEN_EXPIRY_BUFFER_MS > now.getTime()) {
438
+ const accessToken = await getVaultSecret(platformDB, conn.access_token_vault_id, "user_access_token");
439
+ if (accessToken) {
440
+ _tokenCache.set(cacheKey, { accessToken, expiresAt });
441
+ console.log(`[nova-sdk] ✅ User token valid (expires ${expiresAt.toISOString()})`);
442
+ return {
443
+ accessToken,
444
+ expiresAt,
445
+ integrationId: config.id,
446
+ authMode: "standard",
447
+ };
448
+ }
449
+ }
450
+ // Token expired — try refresh
451
+ if (conn.refresh_token_vault_id) {
452
+ console.log(`[nova-sdk] 🔄 User token expired — refreshing...`);
453
+ return refreshUserToken(platformDB, config, userId, conn.id, conn.refresh_token_vault_id);
454
+ }
455
+ throw new ConnectionNotFoundError(config.slug, `${userId} (token expired and no refresh token available — user must re-authorize)`);
456
+ }
457
+ async function refreshUserToken(platformDB, config, userId, connectionId, refreshTokenVaultId) {
458
+ if (!config.token_endpoint) {
459
+ throw new CredentialsNotConfiguredError(config.slug, "token_endpoint not set — cannot refresh");
460
+ }
461
+ const refreshToken = await getVaultSecret(platformDB, refreshTokenVaultId, "refresh_token");
462
+ if (!refreshToken) {
463
+ throw new ConnectionNotFoundError(config.slug, `${userId} (refresh token missing from vault)`);
464
+ }
465
+ const clientSecret = config.client_secret_vault_id
466
+ ? await getVaultSecret(platformDB, config.client_secret_vault_id, "client_secret")
467
+ : null;
468
+ const body = new URLSearchParams({
469
+ grant_type: "refresh_token",
470
+ refresh_token: refreshToken,
471
+ client_id: config.client_id ?? "",
472
+ ...(clientSecret ? { client_secret: clientSecret } : {}),
473
+ });
474
+ const res = await fetch(config.token_endpoint, {
475
+ method: "POST",
476
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
477
+ body: body.toString(),
478
+ });
479
+ if (!res.ok) {
480
+ const errText = await res.text();
481
+ console.error(`[nova-sdk] ❌ Refresh token exchange failed for "${config.slug}": ${res.status}`, errText);
482
+ throw new TokenExchangeError(config.slug, `refresh failed: ${res.status} ${errText}`);
483
+ }
484
+ const tokenData = (await res.json());
485
+ const expiresAt = new Date(Date.now() + (tokenData.expires_in ?? 3600) * 1000);
486
+ // Update vault + connection
487
+ const { data: existingConn } = await platformDB
488
+ .from("user_app_connections")
489
+ .select("access_token_vault_id, refresh_token_vault_id")
490
+ .eq("id", connectionId)
491
+ .single();
492
+ if (existingConn?.access_token_vault_id) {
493
+ await updateVaultSecret(platformDB, existingConn.access_token_vault_id, tokenData.access_token);
494
+ }
495
+ if (tokenData.refresh_token && existingConn?.refresh_token_vault_id) {
496
+ await updateVaultSecret(platformDB, existingConn.refresh_token_vault_id, tokenData.refresh_token);
497
+ }
498
+ await platformDB
499
+ .from("user_app_connections")
500
+ .update({
501
+ token_expires_at: expiresAt.toISOString(),
502
+ token_scope: tokenData.scope ?? null,
503
+ last_used_at: new Date().toISOString(),
504
+ })
505
+ .eq("id", connectionId);
506
+ // Cache it
507
+ const cacheKey = getCacheKey(config.slug, userId);
508
+ _tokenCache.set(cacheKey, { accessToken: tokenData.access_token, expiresAt });
509
+ console.log(`[nova-sdk] ✅ User token refreshed for "${config.slug}" (expires ${expiresAt.toISOString()})`);
510
+ return {
511
+ accessToken: tokenData.access_token,
512
+ expiresAt,
513
+ integrationId: config.id,
514
+ authMode: "standard",
515
+ };
516
+ }
517
+ // ─── integrationFetch: mTLS-aware fetch wrapper ─────────────────────────────
518
+ /**
519
+ * Generic mTLS-aware fetch for integration API calls.
520
+ * If the resolved credentials include an httpsAgent (mTLS), uses node:https.
521
+ * Otherwise, uses global fetch().
522
+ *
523
+ * Works with ANY integration — not ADP-specific.
524
+ */
525
+ export async function integrationFetch(url, credentials, options = {}) {
526
+ const { accessToken, httpsAgent } = credentials;
527
+ if (httpsAgent) {
528
+ // Use node:https for mTLS
529
+ return new Promise((resolve, reject) => {
530
+ const parsedUrl = new URL(url);
531
+ const reqOptions = {
532
+ hostname: parsedUrl.hostname,
533
+ port: parsedUrl.port || 443,
534
+ path: parsedUrl.pathname + parsedUrl.search,
535
+ method: options.method ?? "GET",
536
+ agent: httpsAgent,
537
+ headers: {
538
+ Authorization: `Bearer ${accessToken}`,
539
+ Accept: "application/json",
540
+ ...options.headers,
541
+ ...(options.body ? { "Content-Type": "application/json" } : {}),
542
+ },
543
+ };
544
+ const req = https.request(reqOptions, (res) => {
545
+ const chunks = [];
546
+ res.on("data", (chunk) => chunks.push(chunk));
547
+ res.on("end", () => {
548
+ const bodyStr = Buffer.concat(chunks).toString("utf-8");
549
+ resolve(new Response(bodyStr, {
550
+ status: res.statusCode ?? 500,
551
+ statusText: res.statusMessage ?? "Unknown",
552
+ headers: new Headers(res.headers),
553
+ }));
554
+ });
555
+ });
556
+ req.on("error", (err) => reject(err));
557
+ if (options.body)
558
+ req.write(options.body);
559
+ req.end();
560
+ });
561
+ }
562
+ // Non-mTLS: regular fetch
563
+ return fetch(url, {
564
+ method: options.method ?? "GET",
565
+ headers: {
566
+ Authorization: `Bearer ${accessToken}`,
567
+ Accept: "application/json",
568
+ ...options.headers,
569
+ ...(options.body ? { "Content-Type": "application/json" } : {}),
570
+ },
571
+ body: options.body,
572
+ });
573
+ }
574
+ // ─── emitPlatformEvent: PGMQ event emission ────────────────────────────────
575
+ /**
576
+ * Emit a PGMQ event to the platform database.
577
+ * Used for cross-service communication (e.g., sync complete, webhook received).
578
+ */
579
+ export async function emitPlatformEvent(platformDB, topic, integrationSlug, payload) {
580
+ console.log(`[nova-sdk] 📤 Emitting event: ${topic} (integration: ${integrationSlug})`);
581
+ const { error } = await platformDB.rpc("pgmq_send", {
582
+ queue_name: "platform_events",
583
+ message: JSON.stringify({
584
+ topic,
585
+ integration: integrationSlug,
586
+ timestamp: new Date().toISOString(),
587
+ ...payload,
588
+ }),
589
+ });
590
+ if (error) {
591
+ console.error(`[nova-sdk] ⚠️ Failed to emit PGMQ event "${topic}":`, error.message);
592
+ // Don't throw — event emission failure shouldn't abort the operation
593
+ }
594
+ else {
595
+ console.log(`[nova-sdk] ✅ Event emitted: ${topic}`);
596
+ }
597
+ }
598
+ /**
599
+ * Build an mTLS agent from PEM strings (received via HTTP, not from vault).
600
+ * Cached per slug — cert/key don't change during container lifetime.
601
+ */
602
+ function buildMtlsAgentFromPem(slug, certPem, keyPem) {
603
+ const existing = _mtlsAgents.get(slug);
604
+ if (existing)
605
+ return existing;
606
+ console.log(`[nova-sdk] 🔒 Built mTLS agent for "${slug}" from HTTP response (cert: ${certPem.length} bytes, key: ${keyPem.length} bytes)`);
607
+ const agent = new https.Agent({ cert: certPem, key: keyPem, keepAlive: true });
608
+ _mtlsAgents.set(slug, agent);
609
+ return agent;
610
+ }
611
+ /**
612
+ * Fetch integration credentials from the auth server over HTTP.
613
+ * Requires a valid JWT Bearer token (the same one that authenticated the
614
+ * inbound request to this container).
615
+ *
616
+ * @param authBaseUrl - Auth server base URL (e.g., "http://localhost:3001")
617
+ * @param slug - Integration slug (e.g., "adp")
618
+ * @param bearerToken - JWT to forward as Authorization header
619
+ */
620
+ async function fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken) {
621
+ const url = `${authBaseUrl}/api/integrations/${encodeURIComponent(slug)}/credentials`;
622
+ console.log(`[nova-sdk] 🌐 Fetching credentials via HTTP: GET ${url}`);
623
+ const res = await fetch(url, {
624
+ method: "GET",
625
+ headers: {
626
+ Authorization: `Bearer ${bearerToken}`,
627
+ Accept: "application/json",
628
+ },
629
+ });
630
+ if (!res.ok) {
631
+ const errBody = await res.text().catch(() => "");
632
+ if (res.status === 401) {
633
+ throw new Error(`[nova-sdk] Auth server rejected JWT for credential fetch (401). ` +
634
+ `Ensure the JWT is valid and signed by the auth server. ${errBody}`);
635
+ }
636
+ if (res.status === 404) {
637
+ throw new IntegrationNotFoundError(slug);
638
+ }
639
+ throw new Error(`[nova-sdk] Credential fetch failed: HTTP ${res.status} ${errBody.slice(0, 300)}`);
640
+ }
641
+ const data = (await res.json());
642
+ console.log(`[nova-sdk] ✅ Received credentials for "${slug}" via HTTP (authMode: ${data.authMode})`);
643
+ return data;
644
+ }
645
+ /**
646
+ * Resolve credentials for an integration using the HTTP callback strategy.
647
+ *
648
+ * This is the main entry point for Strategy B. It:
649
+ * 1. Checks the in-memory cache
650
+ * 2. Fetches decrypted credentials from the auth server via HTTP
651
+ * 3. Performs the OAuth token exchange (client_credentials or mTLS)
652
+ * 4. Caches the resulting access token in memory
653
+ *
654
+ * @param authBaseUrl - Auth server base URL
655
+ * @param slug - Integration slug
656
+ * @param bearerToken - JWT to authenticate with the auth server
657
+ * @param userId - Optional user ID (for cache key differentiation)
658
+ */
659
+ export async function resolveCredentialsViaHttp(authBaseUrl, slug, bearerToken, userId) {
660
+ const effectiveUserId = userId ?? SYSTEM_USER_ID;
661
+ const cacheKey = getCacheKey(slug, effectiveUserId);
662
+ console.log(`[nova-sdk] 🔑 Resolving credentials for "${slug}" via HTTP (userId: ${effectiveUserId})`);
663
+ // ── Step 1: Check in-memory cache ─────────────────────────────────────
664
+ const cached = getCachedToken(cacheKey);
665
+ if (cached) {
666
+ // Derive authMode: use stored value if available, otherwise infer from httpsAgent
667
+ const cachedAuthMode = cached.authMode ?? (cached.httpsAgent ? "mtls" : "client_credentials");
668
+ console.log(`[nova-sdk] ⚡ Using in-memory cached token (expires ${cached.expiresAt.toISOString()}, authMode: ${cachedAuthMode})`);
669
+ return {
670
+ accessToken: cached.accessToken,
671
+ expiresAt: cached.expiresAt,
672
+ integrationId: `http:${slug}`, // No DB UUID available in HTTP mode
673
+ authMode: cachedAuthMode,
674
+ httpsAgent: cached.httpsAgent,
675
+ };
676
+ }
677
+ // ── Step 2: Fetch credentials from auth server ────────────────────────
678
+ const creds = await fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken);
679
+ // ── Step 2b: Standard auth — use pre-resolved access token if available ──
680
+ if (creds.authMode === "standard") {
681
+ if (creds.accessToken) {
682
+ const expiresAt = creds.expiresAt
683
+ ? new Date(creds.expiresAt)
684
+ : new Date(Date.now() + 3600 * 1000); // default 1h if not provided
685
+ console.log(`[nova-sdk] ✅ Using pre-resolved access token for "${slug}" (standard auth, HTTP mode) — expires ${expiresAt.toISOString()}`);
686
+ // Cache in memory (include authMode so cache hits return correct mode)
687
+ _tokenCache.set(cacheKey, { accessToken: creds.accessToken, expiresAt, authMode: "standard" });
688
+ return {
689
+ accessToken: creds.accessToken,
690
+ expiresAt,
691
+ integrationId: `http:${slug}`,
692
+ authMode: "standard",
693
+ };
694
+ }
695
+ // Standard auth but no access token returned — user hasn't authorized yet
696
+ throw new ConnectionNotFoundError(slug, `${effectiveUserId} (no stored access token — user must authorize via OAuth popup first)`);
697
+ }
698
+ // ── Step 3: Validate required fields (client_credentials / mTLS only) ──
699
+ if (!creds.clientId) {
700
+ throw new CredentialsNotConfiguredError(slug, "client_id is not set");
701
+ }
702
+ if (!creds.clientSecret) {
703
+ throw new CredentialsNotConfiguredError(slug, "client_secret not available");
704
+ }
705
+ if (!creds.tokenEndpoint) {
706
+ throw new CredentialsNotConfiguredError(slug, "token_endpoint is not set");
707
+ }
708
+ // ── Step 4: Build mTLS agent if needed ────────────────────────────────
709
+ let httpsAgent;
710
+ if (creds.authMode === "mtls") {
711
+ if (!creds.mtlsCert || !creds.mtlsKey) {
712
+ throw new CredentialsNotConfiguredError(slug, "mTLS cert/key not returned by auth server");
713
+ }
714
+ httpsAgent = buildMtlsAgentFromPem(slug, creds.mtlsCert, creds.mtlsKey);
715
+ }
716
+ // ── Step 5: Perform token exchange (client_credentials / mTLS) ────────
717
+ console.log(`[nova-sdk] 🔄 Exchanging client_credentials at ${creds.tokenEndpoint} for "${slug}" (HTTP mode)`);
718
+ const tokenResponse = await performTokenExchange(slug, {
719
+ tokenEndpoint: creds.tokenEndpoint,
720
+ clientId: creds.clientId,
721
+ clientSecret: creds.clientSecret,
722
+ httpsAgent,
723
+ });
724
+ const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000);
725
+ console.log(`[nova-sdk] ✅ Token exchange successful for "${slug}" (HTTP mode) — expires ${expiresAt.toISOString()} (${tokenResponse.expires_in}s)`);
726
+ // ── Step 6: Cache in memory ───────────────────────────────────────────
727
+ _tokenCache.set(cacheKey, {
728
+ accessToken: tokenResponse.access_token,
729
+ expiresAt,
730
+ httpsAgent,
731
+ });
732
+ return {
733
+ accessToken: tokenResponse.access_token,
734
+ expiresAt,
735
+ integrationId: `http:${slug}`,
736
+ authMode: creds.authMode,
737
+ httpsAgent,
738
+ };
739
+ }
740
+ /**
741
+ * Detect which credential resolution strategy to use.
742
+ * Returns 'db' if PLATFORM_SUPABASE_* are available, 'http' if AUTH_ISSUER_BASE_URL
743
+ * is set, or 'none' if neither is configured.
744
+ */
745
+ export function detectCredentialStrategy() {
746
+ const hasDB = !!process.env.PLATFORM_SUPABASE_URL &&
747
+ !!process.env.PLATFORM_SUPABASE_SERVICE_ROLE_KEY;
748
+ const hasAuth = !!process.env.AUTH_ISSUER_BASE_URL;
749
+ if (hasDB)
750
+ return "db";
751
+ if (hasAuth)
752
+ return "http";
753
+ return "none";
754
+ }