@newhomestar/sdk 0.7.19 → 0.7.21

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.
@@ -6,25 +6,13 @@ export interface ResolvedCredentials {
6
6
  accessToken: string;
7
7
  /** When the token expires */
8
8
  expiresAt: Date;
9
- /** The app_integrations.id UUID */
9
+ /** The app_integrations.id UUID (or "http:{slug}" in HTTP mode) */
10
10
  integrationId: string;
11
11
  /** Auth mode used */
12
12
  authMode: AuthMode;
13
13
  /** mTLS agent (if auth_mode = 'mtls') — use for ALL API requests to that provider */
14
14
  httpsAgent?: https.Agent;
15
15
  }
16
- export interface IntegrationConfig {
17
- id: string;
18
- slug: string;
19
- name: string;
20
- auth_mode: AuthMode;
21
- client_id: string | null;
22
- client_secret_vault_id: string | null;
23
- token_endpoint: string | null;
24
- mtls_cert_vault_id: string | null;
25
- mtls_key_vault_id: string | null;
26
- is_active: boolean;
27
- }
28
16
  export declare class IntegrationNotFoundError extends Error {
29
17
  constructor(slug: string);
30
18
  }
@@ -43,6 +31,9 @@ export declare class TokenExchangeError extends Error {
43
31
  /**
44
32
  * Creates / returns the singleton Platform DB client.
45
33
  * Reads from PLATFORM_SUPABASE_URL + PLATFORM_SUPABASE_SERVICE_ROLE_KEY.
34
+ *
35
+ * NOTE: This client is used exclusively for PGMQ event emission (emitPlatformEvent).
36
+ * Credential resolution uses the HTTP callback strategy (AUTH_ISSUER_BASE_URL).
46
37
  */
47
38
  export declare function createPlatformClient(): SupabaseClient;
48
39
  /**
@@ -52,19 +43,32 @@ export declare function createPlatformClient(): SupabaseClient;
52
43
  */
53
44
  export declare function clearTokenCache(slug: string, userId?: string): void;
54
45
  /**
55
- * Resolves an access token for an integration, handling all auth modes.
46
+ * Resolve credentials for an integration using the HTTP callback strategy.
56
47
  *
57
- * Flow:
58
- * 1. Look up integration config from app_integrations (by slug)
59
- * 2. Check auth_mode route to server or user flow
60
- * 3. Check in-memory cache DB (user_app_connections) exchange if needed
61
- * 4. Cache the result and return
48
+ * Resolution order:
49
+ * 1. In-memory cache (hot path no I/O)
50
+ * 2. Client-side refresh_token grant (if refresh metadata is cached)
51
+ * 3. Fetch decrypted credentials from the auth server via HTTP
52
+ * 4. OAuth token exchange (client_credentials / mTLS)
53
+ * 5. Cache result + refresh metadata in memory
62
54
  *
63
- * @param platformDB - Platform Supabase client (project-starfleet-auth)
64
- * @param slug - Integration slug (e.g., 'adp', 'salesforce')
65
- * @param userId - User ID for standard OAuth; optional for server flows
55
+ * @param authBaseUrl - Auth server base URL (AUTH_ISSUER_BASE_URL)
56
+ * @param slug - Integration slug (e.g., "jira", "adp")
57
+ * @param bearerToken - JWT from the inbound request (to authenticate with auth server)
58
+ * @param userId - Optional user ID for cache key differentiation (standard auth flows)
59
+ * @param options - forceRefresh: true → skip cache + signal auth server to refresh
66
60
  */
67
- export declare function resolveCredentials(platformDB: SupabaseClient, slug: string, userId?: string): Promise<ResolvedCredentials>;
61
+ export declare function resolveCredentialsViaHttp(authBaseUrl: string, slug: string, bearerToken: string, userId?: string, options?: {
62
+ forceRefresh?: boolean;
63
+ }): Promise<ResolvedCredentials>;
64
+ /**
65
+ * Detect which credential resolution strategy to use.
66
+ * Returns 'http' if AUTH_ISSUER_BASE_URL is set, or 'none' if not configured.
67
+ *
68
+ * NOTE: The legacy 'db' strategy (PLATFORM_SUPABASE_*) has been removed.
69
+ * All credential resolution now goes through the HTTP callback strategy.
70
+ */
71
+ export declare function detectCredentialStrategy(): "http" | "none";
68
72
  /**
69
73
  * Generic mTLS-aware fetch for integration API calls.
70
74
  * If the resolved credentials include an httpsAgent (mTLS), uses node:https.
@@ -80,28 +84,8 @@ export declare function integrationFetch(url: string, credentials: ResolvedCrede
80
84
  /**
81
85
  * Emit a PGMQ event to the platform database.
82
86
  * Used for cross-service communication (e.g., sync complete, webhook received).
83
- */
84
- export declare function emitPlatformEvent(platformDB: SupabaseClient, topic: string, integrationSlug: string, payload: Record<string, unknown>): Promise<void>;
85
- /**
86
- * Resolve credentials for an integration using the HTTP callback strategy.
87
87
  *
88
- * This is the main entry point for Strategy B. It:
89
- * 1. Checks the in-memory cache
90
- * 2. Fetches decrypted credentials from the auth server via HTTP
91
- * 3. Performs the OAuth token exchange (client_credentials or mTLS)
92
- * 4. Caches the resulting access token in memory
93
- *
94
- * @param authBaseUrl - Auth server base URL
95
- * @param slug - Integration slug
96
- * @param bearerToken - JWT to authenticate with the auth server
97
- * @param userId - Optional user ID (for cache key differentiation)
88
+ * NOTE: This uses the platform Supabase client (PLATFORM_SUPABASE_*), which is
89
+ * separate from credential resolution. Credential resolution uses HTTP (AUTH_ISSUER_BASE_URL).
98
90
  */
99
- export declare function resolveCredentialsViaHttp(authBaseUrl: string, slug: string, bearerToken: string, userId?: string, options?: {
100
- forceRefresh?: boolean;
101
- }): Promise<ResolvedCredentials>;
102
- /**
103
- * Detect which credential resolution strategy to use.
104
- * Returns 'db' if PLATFORM_SUPABASE_* are available, 'http' if AUTH_ISSUER_BASE_URL
105
- * is set, or 'none' if neither is configured.
106
- */
107
- export declare function detectCredentialStrategy(): "db" | "http" | "none";
91
+ export declare function emitPlatformEvent(platformDB: SupabaseClient, topic: string, integrationSlug: string, payload: Record<string, unknown>): Promise<void>;
@@ -6,14 +6,15 @@
6
6
  // - 'client_credentials' → client_credentials grant (server-to-server)
7
7
  // - 'standard' → authorization_code (per-user OAuth redirect)
8
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
9
+ // Credential resolution strategy:
10
+ // HTTP callback (AUTH_ISSUER_BASE_URL) — container calls auth server with JWT
11
+ // The container receives a JWT from the inbound request, forwards it to the
12
+ // auth server, which decrypts credentials from Vault and returns them.
12
13
  //
13
- // Tokens are cached:
14
+ // Tokens are cached in-memory with client-side refresh_token support:
14
15
  // 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)
16
+ // 2. Client-side refresh_token grant (if refresh metadata is cached)
17
+ // 3. Fresh credentials fetch from auth server + token exchange (cold path)
17
18
  //
18
19
  // Every integration gets this for free via ctx.resolveCredentials() / ctx.fetch().
19
20
  import { createClient } from "@supabase/supabase-js";
@@ -54,6 +55,9 @@ let _platformClient = null;
54
55
  /**
55
56
  * Creates / returns the singleton Platform DB client.
56
57
  * Reads from PLATFORM_SUPABASE_URL + PLATFORM_SUPABASE_SERVICE_ROLE_KEY.
58
+ *
59
+ * NOTE: This client is used exclusively for PGMQ event emission (emitPlatformEvent).
60
+ * Credential resolution uses the HTTP callback strategy (AUTH_ISSUER_BASE_URL).
57
61
  */
58
62
  export function createPlatformClient() {
59
63
  if (!_platformClient) {
@@ -90,78 +94,45 @@ export function clearTokenCache(slug, userId) {
90
94
  }
91
95
  function getCachedToken(key) {
92
96
  const cached = _tokenCache.get(key);
93
- if (!cached)
97
+ if (!cached) {
98
+ console.log(`[nova-sdk] 💭 Cache miss for key="${key}"`);
94
99
  return null;
100
+ }
95
101
  if (new Date().getTime() >= cached.expiresAt.getTime() - TOKEN_EXPIRY_BUFFER_MS) {
96
- console.log(`[nova-sdk] 🔄 In-memory cached token expired for ${key}`);
102
+ console.log(`[nova-sdk] 🔄 In-memory cached token expired for key="${key}" (expired at ${cached.expiresAt.toISOString()})`);
97
103
  _tokenCache.delete(key);
98
104
  return null;
99
105
  }
106
+ console.log(`[nova-sdk] ⚡ Cache hit for key="${key}" (expires ${cached.expiresAt.toISOString()})`);
100
107
  return cached;
101
108
  }
102
- // ─── Vault Helpers ──────────────────────────────────────────────────────────
103
- async function getVaultSecret(platformDB, vaultId, label) {
104
- if (!vaultId)
105
- return null;
106
- console.log(`[nova-sdk] 🔐 Reading vault secret: ${label} (${vaultId.slice(0, 8)}...)`);
107
- const { data, error } = await platformDB.rpc("get_vault_secret", { p_id: vaultId });
108
- if (error) {
109
- if (error.code === "PGRST116")
110
- return null;
111
- throw new Error(`[nova-sdk] Vault read failed for ${label}: ${error.message}`);
112
- }
113
- return data ?? null;
114
- }
115
- async function storeVaultSecret(platformDB, name, value, description) {
116
- console.log(`[nova-sdk] 🔐 Storing vault secret: ${name}`);
117
- const { data, error } = await platformDB.rpc("create_vault_secret", {
118
- p_secret: value,
119
- p_name: name,
120
- p_description: description ?? `Nova integration credential: ${name}`,
121
- });
122
- if (error) {
123
- throw new Error(`[nova-sdk] Vault store failed for ${name}: ${error.message}`);
124
- }
125
- return data;
126
- }
127
- async function updateVaultSecret(platformDB, vaultId, newValue) {
128
- const { error } = await platformDB.rpc("update_vault_secret", {
129
- p_id: vaultId,
130
- p_secret: newValue,
131
- });
132
- if (error) {
133
- throw new Error(`[nova-sdk] Vault update failed for ${vaultId}: ${error.message}`);
134
- }
135
- }
136
- // ─── mTLS Agent Builder ─────────────────────────────────────────────────────
109
+ // ─── mTLS Agent Cache ───────────────────────────────────────────────────────
137
110
  // Cache mTLS agents per integration slug (cert/key don't change during container lifetime)
138
111
  const _mtlsAgents = new Map();
139
- async function buildMtlsAgent(platformDB, config) {
140
- const existing = _mtlsAgents.get(config.slug);
112
+ /**
113
+ * Build an mTLS agent from PEM strings (received via HTTP, not from vault).
114
+ * Cached per slug — cert/key don't change during container lifetime.
115
+ */
116
+ function buildMtlsAgentFromPem(slug, certPem, keyPem) {
117
+ const existing = _mtlsAgents.get(slug);
141
118
  if (existing)
142
119
  return existing;
143
- if (!config.mtls_cert_vault_id || !config.mtls_key_vault_id) {
144
- throw new CredentialsNotConfiguredError(config.slug, "mTLS cert and/or key not stored in vault. Upload PEM files via admin dashboard.");
145
- }
146
- const [certPem, keyPem] = await Promise.all([
147
- getVaultSecret(platformDB, config.mtls_cert_vault_id, "mtls_cert"),
148
- getVaultSecret(platformDB, config.mtls_key_vault_id, "mtls_key"),
149
- ]);
150
- if (!certPem || !keyPem) {
151
- throw new CredentialsNotConfiguredError(config.slug, "mTLS cert or key vault entries are empty. Re-upload PEM files.");
152
- }
153
- console.log(`[nova-sdk] 🔒 Built mTLS agent for "${config.slug}" (cert: ${certPem.length} bytes, key: ${keyPem.length} bytes)`);
120
+ console.log(`[nova-sdk] 🔒 Built mTLS agent for "${slug}" from HTTP response (cert: ${certPem.length} bytes, key: ${keyPem.length} bytes)`);
154
121
  const agent = new https.Agent({ cert: certPem, key: keyPem, keepAlive: true });
155
- _mtlsAgents.set(config.slug, agent);
122
+ _mtlsAgents.set(slug, agent);
156
123
  return agent;
157
124
  }
158
125
  async function performTokenExchange(slug, params) {
159
- const { tokenEndpoint, clientId, clientSecret, httpsAgent, grantType = "client_credentials", } = params;
126
+ const { tokenEndpoint, clientId, clientSecret, httpsAgent, grantType = "client_credentials", refreshToken, } = params;
160
127
  const body = new URLSearchParams({
161
128
  grant_type: grantType,
162
129
  client_id: clientId,
163
130
  client_secret: clientSecret,
164
131
  });
132
+ if (grantType === "refresh_token" && refreshToken) {
133
+ body.set("refresh_token", refreshToken);
134
+ }
135
+ console.log(`[nova-sdk] 🔄 Token exchange: grant_type=${grantType} endpoint=${tokenEndpoint} slug="${slug}"`);
165
136
  // For mTLS, we must use node:https directly because global fetch()
166
137
  // doesn't support custom TLS agents.
167
138
  if (httpsAgent) {
@@ -218,311 +189,285 @@ async function performTokenExchange(slug, params) {
218
189
  }
219
190
  return res.json();
220
191
  }
221
- // ─── Main: resolveCredentials() ─────────────────────────────────────────────
192
+ // ─── Client-Side Refresh Grant ──────────────────────────────────────────────
222
193
  /**
223
- * Resolves an access token for an integration, handling all auth modes.
194
+ * Attempt a client-side refresh_token grant using cached refresh metadata.
195
+ * Returns null if refresh metadata is not available (caller should fall through
196
+ * to fetching fresh credentials from the auth server).
224
197
  *
225
- * Flow:
226
- * 1. Look up integration config from app_integrations (by slug)
227
- * 2. Check auth_mode → route to server or user flow
228
- * 3. Check in-memory cache → DB (user_app_connections) → exchange if needed
229
- * 4. Cache the result and return
230
- *
231
- * @param platformDB - Platform Supabase client (project-starfleet-auth)
232
- * @param slug - Integration slug (e.g., 'adp', 'salesforce')
233
- * @param userId - User ID for standard OAuth; optional for server flows
198
+ * This avoids a round-trip to the auth server when we already have a valid
199
+ * refresh token reducing latency and auth server load.
234
200
  */
235
- export async function resolveCredentials(platformDB, slug, userId) {
236
- console.log(`[nova-sdk] 🔑 Resolving credentials for "${slug}" (userId: ${userId ?? "system"})`);
237
- // ── Step 1: Look up integration config ────────────────────────────────
238
- const { data: integration, error: intError } = await platformDB
239
- .from("app_integrations")
240
- .select("id, slug, name, auth_mode, client_id, client_secret_vault_id, token_endpoint, mtls_cert_vault_id, mtls_key_vault_id, is_active")
241
- .eq("slug", slug)
242
- .single();
243
- if (intError || !integration) {
244
- console.error(`[nova-sdk] ❌ Integration "${slug}" not found:`, intError?.message);
245
- throw new IntegrationNotFoundError(slug);
201
+ async function performRefreshGrant(slug, cacheKey, cachedEntry) {
202
+ const { refreshToken, tokenEndpoint, clientId, clientSecret, httpsAgent, authMode } = cachedEntry;
203
+ if (!refreshToken || !tokenEndpoint || !clientId || !clientSecret) {
204
+ console.log(`[nova-sdk] 💭 No refresh metadata cached for "${slug}" skipping client-side refresh`);
205
+ return null;
246
206
  }
247
- if (!integration.is_active) {
248
- console.error(`[nova-sdk] ❌ Integration "${slug}" is disabled`);
249
- throw new IntegrationDisabledError(slug);
207
+ console.log(`[nova-sdk] 🔄 Attempting client-side refresh_token grant for "${slug}" (endpoint: ${tokenEndpoint})`);
208
+ try {
209
+ const tokenResponse = await performTokenExchange(slug, {
210
+ tokenEndpoint,
211
+ clientId,
212
+ clientSecret,
213
+ httpsAgent,
214
+ grantType: "refresh_token",
215
+ refreshToken,
216
+ });
217
+ const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000);
218
+ const effectiveAuthMode = authMode ?? "client_credentials";
219
+ console.log(`[nova-sdk] ✅ Client-side refresh successful for "${slug}" — expires ${expiresAt.toISOString()} (${tokenResponse.expires_in}s)`);
220
+ // Update cache with new access token (and new refresh token if rotated)
221
+ _tokenCache.set(cacheKey, {
222
+ accessToken: tokenResponse.access_token,
223
+ expiresAt,
224
+ httpsAgent,
225
+ authMode: effectiveAuthMode,
226
+ refreshToken: tokenResponse.refresh_token ?? refreshToken, // use new RT if provider rotated it
227
+ tokenEndpoint,
228
+ clientId,
229
+ clientSecret,
230
+ });
231
+ return {
232
+ accessToken: tokenResponse.access_token,
233
+ expiresAt,
234
+ integrationId: `http:${slug}`,
235
+ authMode: effectiveAuthMode,
236
+ httpsAgent,
237
+ };
250
238
  }
251
- const config = integration;
252
- console.log(`[nova-sdk] 📋 Integration: ${config.name} | auth_mode=${config.auth_mode} | id=${config.id}`);
253
- // ── Step 2: Route by auth_mode ────────────────────────────────────────
254
- switch (config.auth_mode) {
255
- case "mtls":
256
- case "client_credentials":
257
- return resolveServerCredentials(platformDB, config, userId);
258
- case "standard":
259
- if (!userId) {
260
- throw new Error(`[nova-sdk] userId is required for standard OAuth (authorization_code) flow on "${slug}"`);
261
- }
262
- return resolveUserCredentials(platformDB, config, userId);
263
- default:
264
- throw new Error(`[nova-sdk] Unknown auth_mode: ${config.auth_mode}`);
239
+ catch (err) {
240
+ console.warn(`[nova-sdk] ⚠️ Client-side refresh failed for "${slug}": ${err.message} will re-fetch from auth server`);
241
+ // Clear stale cached entry so we fall through to auth server fetch
242
+ _tokenCache.delete(cacheKey);
243
+ return null;
265
244
  }
266
245
  }
267
- // ─── Server Credentials (client_credentials / mTLS) ─────────────────────────
268
- async function resolveServerCredentials(platformDB, config, userId) {
269
- const effectiveUserId = userId ?? SYSTEM_USER_ID;
270
- const cacheKey = getCacheKey(config.slug, effectiveUserId);
271
- // ── Check in-memory cache ─────────────────────────────────────────────
272
- const cached = getCachedToken(cacheKey);
273
- if (cached) {
274
- console.log(`[nova-sdk] Using in-memory cached token (expires ${cached.expiresAt.toISOString()})`);
275
- return {
276
- accessToken: cached.accessToken,
277
- expiresAt: cached.expiresAt,
278
- integrationId: config.id,
279
- authMode: config.auth_mode,
280
- httpsAgent: cached.httpsAgent,
281
- };
246
+ /**
247
+ * Fetch integration credentials from the auth server over HTTP.
248
+ * Requires a valid JWT Bearer token (the same one that authenticated the
249
+ * inbound request to this container).
250
+ *
251
+ * @param authBaseUrl - Auth server base URL (e.g., "http://localhost:3001")
252
+ * @param slug - Integration slug (e.g., "adp")
253
+ * @param bearerToken - JWT to forward as Authorization header
254
+ * @param forceRefresh - When true, sends X-Nova-Token-Invalid: true to trigger a forced refresh on the auth server
255
+ */
256
+ async function fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken, forceRefresh = false) {
257
+ const url = `${authBaseUrl}/api/integrations/${encodeURIComponent(slug)}/credentials`;
258
+ console.log(`[nova-sdk] 🌐 Fetching credentials via HTTP: GET ${url}${forceRefresh ? " (force-refresh)" : ""}`);
259
+ const headers = {
260
+ Authorization: `Bearer ${bearerToken}`,
261
+ Accept: "application/json",
262
+ };
263
+ // Signal the auth server to discard its stored token and force a fresh exchange
264
+ if (forceRefresh) {
265
+ headers["X-Nova-Token-Invalid"] = "true";
266
+ console.log(`[nova-sdk] 🚨 Sending X-Nova-Token-Invalid: true — auth server will discard cached token`);
282
267
  }
283
- // ── Check DB cache (user_app_connections) ─────────────────────────────
284
- const { data: existingConn } = await platformDB
285
- .from("user_app_connections")
286
- .select("id, access_token_vault_id, refresh_token_vault_id, token_expires_at, is_active")
287
- .eq("integration_id", config.id)
288
- .eq("user_id", effectiveUserId)
289
- .eq("is_active", true)
290
- .single();
291
- if (existingConn?.access_token_vault_id && existingConn.token_expires_at) {
292
- const expiresAt = new Date(existingConn.token_expires_at);
293
- const now = new Date();
294
- if (expiresAt.getTime() - TOKEN_EXPIRY_BUFFER_MS > now.getTime()) {
295
- console.log(`[nova-sdk] 📦 Found valid cached token in DB (expires ${expiresAt.toISOString()})`);
296
- const accessToken = await getVaultSecret(platformDB, existingConn.access_token_vault_id, "cached_access_token");
297
- if (accessToken) {
298
- const httpsAgent = config.auth_mode === "mtls"
299
- ? await buildMtlsAgent(platformDB, config)
300
- : undefined;
301
- _tokenCache.set(cacheKey, { accessToken, expiresAt, httpsAgent });
302
- return {
303
- accessToken,
304
- expiresAt,
305
- integrationId: config.id,
306
- authMode: config.auth_mode,
307
- httpsAgent,
308
- };
309
- }
310
- console.warn(`[nova-sdk] ⚠️ Vault secret missing for cached token will re-exchange`);
268
+ const res = await fetch(url, { method: "GET", headers });
269
+ if (!res.ok) {
270
+ const errBody = await res.text().catch(() => "");
271
+ console.error(`[nova-sdk] ❌ Credential fetch failed for "${slug}": HTTP ${res.status}`, errBody.slice(0, 300));
272
+ if (res.status === 401) {
273
+ throw new Error(`[nova-sdk] Auth server rejected JWT for credential fetch (401). ` +
274
+ `Ensure the JWT is valid and signed by the auth server. ${errBody}`);
275
+ }
276
+ if (res.status === 404) {
277
+ throw new IntegrationNotFoundError(slug);
278
+ }
279
+ throw new Error(`[nova-sdk] Credential fetch failed: HTTP ${res.status} ${errBody.slice(0, 300)}`);
280
+ }
281
+ const data = (await res.json());
282
+ console.log(`[nova-sdk] ✅ Received credentials for "${slug}" via HTTP (authMode: ${data.authMode}, hasAccessToken: ${!!data.accessToken}, hasRefreshToken: ${!!data.refreshToken})`);
283
+ return data;
284
+ }
285
+ /**
286
+ * Resolve credentials for an integration using the HTTP callback strategy.
287
+ *
288
+ * Resolution order:
289
+ * 1. In-memory cache (hot path — no I/O)
290
+ * 2. Client-side refresh_token grant (if refresh metadata is cached)
291
+ * 3. Fetch decrypted credentials from the auth server via HTTP
292
+ * 4. OAuth token exchange (client_credentials / mTLS)
293
+ * 5. Cache result + refresh metadata in memory
294
+ *
295
+ * @param authBaseUrl - Auth server base URL (AUTH_ISSUER_BASE_URL)
296
+ * @param slug - Integration slug (e.g., "jira", "adp")
297
+ * @param bearerToken - JWT from the inbound request (to authenticate with auth server)
298
+ * @param userId - Optional user ID for cache key differentiation (standard auth flows)
299
+ * @param options - forceRefresh: true → skip cache + signal auth server to refresh
300
+ */
301
+ export async function resolveCredentialsViaHttp(authBaseUrl, slug, bearerToken, userId, options) {
302
+ const effectiveUserId = userId ?? SYSTEM_USER_ID;
303
+ const cacheKey = getCacheKey(slug, effectiveUserId);
304
+ const forceRefresh = options?.forceRefresh ?? false;
305
+ console.log(`[nova-sdk] 🔑 resolveCredentialsViaHttp: slug="${slug}" userId="${effectiveUserId}" forceRefresh=${forceRefresh} authBaseUrl="${authBaseUrl}"`);
306
+ // ── Step 1: Check in-memory cache (skip on forceRefresh) ─────────────
307
+ if (!forceRefresh) {
308
+ const cached = getCachedToken(cacheKey);
309
+ if (cached) {
310
+ const cachedAuthMode = cached.authMode ?? (cached.httpsAgent ? "mtls" : "client_credentials");
311
+ console.log(`[nova-sdk] ⚡ Cache hit for "${slug}" — returning cached token (authMode: ${cachedAuthMode}, expires: ${cached.expiresAt.toISOString()})`);
312
+ return {
313
+ accessToken: cached.accessToken,
314
+ expiresAt: cached.expiresAt,
315
+ integrationId: `http:${slug}`,
316
+ authMode: cachedAuthMode,
317
+ httpsAgent: cached.httpsAgent,
318
+ };
311
319
  }
312
- else {
313
- console.log(`[nova-sdk] 🔄 Cached token expired (was ${expiresAt.toISOString()}) re-exchanging`);
320
+ // ── Step 2: Try client-side refresh_token grant ───────────────────
321
+ // The cached entry was evicted (expired), but it may have left refresh metadata
322
+ // behind if we stored it before eviction. Check the map directly (bypassing
323
+ // the expiry check) to get refresh metadata from the evicted entry.
324
+ // Actually, since getCachedToken deletes the entry on expiry, we need to
325
+ // try a refresh using data we stored before the eviction. If the map no
326
+ // longer has the entry, we fall through to the auth server.
327
+ //
328
+ // Alternative: keep an expired entry temporarily for refresh metadata.
329
+ // For simplicity, we store a "refresh-only" entry under a separate key.
330
+ const refreshKey = `${cacheKey}:refresh`;
331
+ const refreshEntry = _tokenCache.get(refreshKey);
332
+ if (refreshEntry) {
333
+ console.log(`[nova-sdk] 🔄 Found refresh metadata for "${slug}" — attempting client-side refresh`);
334
+ const refreshed = await performRefreshGrant(slug, cacheKey, refreshEntry);
335
+ if (refreshed) {
336
+ // Store updated refresh metadata under the refresh key too
337
+ const updatedEntry = _tokenCache.get(cacheKey);
338
+ if (updatedEntry) {
339
+ _tokenCache.set(refreshKey, {
340
+ ...updatedEntry,
341
+ // Extend expiry of refresh key far out (refresh tokens are long-lived)
342
+ expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
343
+ });
344
+ }
345
+ return refreshed;
346
+ }
347
+ // Refresh failed — fall through to auth server
348
+ _tokenCache.delete(refreshKey);
314
349
  }
315
350
  }
316
351
  else {
317
- console.log(`[nova-sdk] 📭 No cached token found in DB — performing initial token exchange`);
352
+ // Force refresh clear cache and refresh entry
353
+ _tokenCache.delete(cacheKey);
354
+ _tokenCache.delete(`${cacheKey}:refresh`);
355
+ console.log(`[nova-sdk] 🗑️ Force refresh: cleared cache for "${slug}" (key=${cacheKey})`);
318
356
  }
319
- // ── Exchange for new token ────────────────────────────────────────────
320
- return exchangeAndCacheServerToken(platformDB, config, effectiveUserId, existingConn?.id);
321
- }
322
- async function exchangeAndCacheServerToken(platformDB, config, userId, existingConnectionId) {
323
- // Validate required fields
324
- if (!config.client_id) {
325
- throw new CredentialsNotConfiguredError(config.slug, "client_id is not set");
357
+ // ── Step 3: Fetch credentials from auth server ────────────────────────
358
+ console.log(`[nova-sdk] 📡 Fetching fresh credentials from auth server for "${slug}"`);
359
+ const creds = await fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken, forceRefresh);
360
+ // ── Step 3b: Standard auth — use pre-resolved access token ───────────
361
+ if (creds.authMode === "standard") {
362
+ if (creds.accessToken) {
363
+ const expiresAt = creds.expiresAt
364
+ ? new Date(creds.expiresAt)
365
+ : new Date(Date.now() + 3600 * 1000); // default 1h if not provided
366
+ console.log(`[nova-sdk] ✅ Standard auth: using pre-resolved access token for "${slug}" — expires ${expiresAt.toISOString()}`);
367
+ // Cache access token
368
+ const cacheEntry = {
369
+ accessToken: creds.accessToken,
370
+ expiresAt,
371
+ authMode: "standard",
372
+ // Cache refresh metadata if provided — enables client-side refresh next time
373
+ refreshToken: creds.refreshToken ?? undefined,
374
+ tokenEndpoint: creds.tokenEndpoint ?? undefined,
375
+ clientId: creds.clientId ?? undefined,
376
+ clientSecret: creds.clientSecret ?? undefined,
377
+ };
378
+ _tokenCache.set(cacheKey, cacheEntry);
379
+ // Also store refresh metadata in the long-lived refresh key
380
+ if (creds.refreshToken && creds.tokenEndpoint) {
381
+ _tokenCache.set(`${cacheKey}:refresh`, {
382
+ ...cacheEntry,
383
+ expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
384
+ });
385
+ console.log(`[nova-sdk] 💾 Cached refresh metadata for "${slug}" (standard auth)`);
386
+ }
387
+ return {
388
+ accessToken: creds.accessToken,
389
+ expiresAt,
390
+ integrationId: `http:${slug}`,
391
+ authMode: "standard",
392
+ };
393
+ }
394
+ // Standard auth but no access token returned — user hasn't authorized yet
395
+ console.error(`[nova-sdk] ❌ Standard auth: no access token returned for "${slug}" — user must authorize via OAuth popup`);
396
+ throw new ConnectionNotFoundError(slug, `${effectiveUserId} (no stored access token — user must authorize via OAuth popup first)`);
326
397
  }
327
- if (!config.client_secret_vault_id) {
328
- throw new CredentialsNotConfiguredError(config.slug, "client_secret is not stored in vault");
398
+ // ── Step 4: Validate required fields (client_credentials / mTLS only) ──
399
+ if (!creds.clientId) {
400
+ throw new CredentialsNotConfiguredError(slug, "client_id is not set");
329
401
  }
330
- if (!config.token_endpoint) {
331
- throw new CredentialsNotConfiguredError(config.slug, "token_endpoint is not set");
402
+ if (!creds.clientSecret) {
403
+ throw new CredentialsNotConfiguredError(slug, "client_secret not available");
332
404
  }
333
- // Decrypt client_secret from vault
334
- const clientSecret = await getVaultSecret(platformDB, config.client_secret_vault_id, "client_secret");
335
- if (!clientSecret) {
336
- throw new CredentialsNotConfiguredError(config.slug, "client_secret vault entry is empty");
405
+ if (!creds.tokenEndpoint) {
406
+ throw new CredentialsNotConfiguredError(slug, "token_endpoint is not set");
337
407
  }
338
- // Build mTLS agent if needed
408
+ console.log(`[nova-sdk] 🔧 Credentials received: authMode=${creds.authMode} clientId=${creds.clientId?.slice(0, 8)}... tokenEndpoint=${creds.tokenEndpoint}`);
409
+ // ── Step 5: Build mTLS agent if needed ────────────────────────────────
339
410
  let httpsAgent;
340
- if (config.auth_mode === "mtls") {
341
- httpsAgent = await buildMtlsAgent(platformDB, config);
411
+ if (creds.authMode === "mtls") {
412
+ if (!creds.mtlsCert || !creds.mtlsKey) {
413
+ throw new CredentialsNotConfiguredError(slug, "mTLS cert/key not returned by auth server");
414
+ }
415
+ httpsAgent = buildMtlsAgentFromPem(slug, creds.mtlsCert, creds.mtlsKey);
342
416
  }
343
- console.log(`[nova-sdk] 🔄 Exchanging client_credentials at ${config.token_endpoint} for "${config.slug}"`);
344
- const tokenResponse = await performTokenExchange(config.slug, {
345
- tokenEndpoint: config.token_endpoint,
346
- clientId: config.client_id,
347
- clientSecret,
417
+ // ── Step 6: Perform token exchange (client_credentials / mTLS) ────────
418
+ console.log(`[nova-sdk] 🔄 Performing ${creds.authMode} token exchange at ${creds.tokenEndpoint} for "${slug}"`);
419
+ const tokenResponse = await performTokenExchange(slug, {
420
+ tokenEndpoint: creds.tokenEndpoint,
421
+ clientId: creds.clientId,
422
+ clientSecret: creds.clientSecret,
348
423
  httpsAgent,
349
424
  });
350
425
  const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000);
351
- const cacheKey = getCacheKey(config.slug, userId);
352
- console.log(`[nova-sdk] Token exchange successful for "${config.slug}" expires ${expiresAt.toISOString()} (${tokenResponse.expires_in}s)`);
353
- // ── Store token in vault + user_app_connections ───────────────────────
354
- const vaultName = `connection:${userId}:${config.slug}:access_token`;
355
- let accessTokenVaultId;
356
- if (existingConnectionId) {
357
- const { data: conn } = await platformDB
358
- .from("user_app_connections")
359
- .select("access_token_vault_id")
360
- .eq("id", existingConnectionId)
361
- .single();
362
- if (conn?.access_token_vault_id) {
363
- await updateVaultSecret(platformDB, conn.access_token_vault_id, tokenResponse.access_token);
364
- accessTokenVaultId = conn.access_token_vault_id;
365
- }
366
- else {
367
- accessTokenVaultId = await storeVaultSecret(platformDB, vaultName, tokenResponse.access_token);
368
- }
369
- await platformDB
370
- .from("user_app_connections")
371
- .update({
372
- access_token_vault_id: accessTokenVaultId,
373
- token_expires_at: expiresAt.toISOString(),
374
- token_scope: tokenResponse.scope ?? null,
375
- last_used_at: new Date().toISOString(),
376
- is_active: true,
377
- })
378
- .eq("id", existingConnectionId);
379
- console.log(`[nova-sdk] 📦 Updated existing connection ${existingConnectionId}`);
380
- }
381
- else {
382
- accessTokenVaultId = await storeVaultSecret(platformDB, vaultName, tokenResponse.access_token);
383
- const { error: insertError } = await platformDB
384
- .from("user_app_connections")
385
- .upsert({
386
- user_id: userId,
387
- integration_id: config.id,
388
- access_token_vault_id: accessTokenVaultId,
389
- token_expires_at: expiresAt.toISOString(),
390
- token_scope: tokenResponse.scope ?? null,
391
- is_active: true,
392
- last_used_at: new Date().toISOString(),
393
- metadata: { auth_mode: config.auth_mode, grant_type: "client_credentials" },
394
- }, { onConflict: "user_id,integration_id" });
395
- if (insertError) {
396
- console.error(`[nova-sdk] ⚠️ Failed to cache connection:`, insertError.message);
397
- }
398
- else {
399
- console.log(`[nova-sdk] 📦 Created new connection for user ${userId}`);
400
- }
401
- }
402
- // Populate in-memory cache
403
- _tokenCache.set(cacheKey, {
426
+ console.log(`[nova-sdk] Token exchange successful for "${slug}" — expires ${expiresAt.toISOString()} (${tokenResponse.expires_in}s, hasRefreshToken: ${!!tokenResponse.refresh_token})`);
427
+ // ── Step 7: Cache in memory with refresh metadata ─────────────────────
428
+ const cacheEntry = {
404
429
  accessToken: tokenResponse.access_token,
405
430
  expiresAt,
406
431
  httpsAgent,
407
- });
432
+ authMode: creds.authMode,
433
+ // Store refresh metadata for client-side refresh next time
434
+ refreshToken: tokenResponse.refresh_token ?? undefined,
435
+ tokenEndpoint: creds.tokenEndpoint,
436
+ clientId: creds.clientId,
437
+ clientSecret: creds.clientSecret,
438
+ };
439
+ _tokenCache.set(cacheKey, cacheEntry);
440
+ // Also persist refresh metadata in a long-lived key (survives access token expiry)
441
+ if (tokenResponse.refresh_token) {
442
+ _tokenCache.set(`${cacheKey}:refresh`, {
443
+ ...cacheEntry,
444
+ expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
445
+ });
446
+ console.log(`[nova-sdk] 💾 Cached refresh metadata for "${slug}"`);
447
+ }
408
448
  return {
409
449
  accessToken: tokenResponse.access_token,
410
450
  expiresAt,
411
- integrationId: config.id,
412
- authMode: config.auth_mode,
451
+ integrationId: `http:${slug}`,
452
+ authMode: creds.authMode,
413
453
  httpsAgent,
414
454
  };
415
455
  }
416
- // ─── User Credentials (authorization_code / standard) ───────────────────────
417
- async function resolveUserCredentials(platformDB, config, userId) {
418
- const cacheKey = getCacheKey(config.slug, userId);
419
- // Check in-memory cache
420
- const cached = getCachedToken(cacheKey);
421
- if (cached) {
422
- console.log(`[nova-sdk] ⚡ Using in-memory cached user token (expires ${cached.expiresAt.toISOString()})`);
423
- return {
424
- accessToken: cached.accessToken,
425
- expiresAt: cached.expiresAt,
426
- integrationId: config.id,
427
- authMode: "standard",
428
- };
429
- }
430
- // Look up user's connection
431
- const { data: conn, error: connError } = await platformDB
432
- .from("user_app_connections")
433
- .select("id, access_token_vault_id, refresh_token_vault_id, token_expires_at, is_active")
434
- .eq("integration_id", config.id)
435
- .eq("user_id", userId)
436
- .eq("is_active", true)
437
- .single();
438
- if (connError || !conn) {
439
- throw new ConnectionNotFoundError(config.slug, userId);
440
- }
441
- if (!conn.access_token_vault_id) {
442
- throw new ConnectionNotFoundError(config.slug, userId);
443
- }
444
- const expiresAt = conn.token_expires_at ? new Date(conn.token_expires_at) : new Date(0);
445
- const now = new Date();
446
- // Check if token is still valid
447
- if (expiresAt.getTime() - TOKEN_EXPIRY_BUFFER_MS > now.getTime()) {
448
- const accessToken = await getVaultSecret(platformDB, conn.access_token_vault_id, "user_access_token");
449
- if (accessToken) {
450
- _tokenCache.set(cacheKey, { accessToken, expiresAt });
451
- console.log(`[nova-sdk] ✅ User token valid (expires ${expiresAt.toISOString()})`);
452
- return {
453
- accessToken,
454
- expiresAt,
455
- integrationId: config.id,
456
- authMode: "standard",
457
- };
458
- }
459
- }
460
- // Token expired — try refresh
461
- if (conn.refresh_token_vault_id) {
462
- console.log(`[nova-sdk] 🔄 User token expired — refreshing...`);
463
- return refreshUserToken(platformDB, config, userId, conn.id, conn.refresh_token_vault_id);
464
- }
465
- throw new ConnectionNotFoundError(config.slug, `${userId} (token expired and no refresh token available — user must re-authorize)`);
466
- }
467
- async function refreshUserToken(platformDB, config, userId, connectionId, refreshTokenVaultId) {
468
- if (!config.token_endpoint) {
469
- throw new CredentialsNotConfiguredError(config.slug, "token_endpoint not set — cannot refresh");
470
- }
471
- const refreshToken = await getVaultSecret(platformDB, refreshTokenVaultId, "refresh_token");
472
- if (!refreshToken) {
473
- throw new ConnectionNotFoundError(config.slug, `${userId} (refresh token missing from vault)`);
474
- }
475
- const clientSecret = config.client_secret_vault_id
476
- ? await getVaultSecret(platformDB, config.client_secret_vault_id, "client_secret")
477
- : null;
478
- const body = new URLSearchParams({
479
- grant_type: "refresh_token",
480
- refresh_token: refreshToken,
481
- client_id: config.client_id ?? "",
482
- ...(clientSecret ? { client_secret: clientSecret } : {}),
483
- });
484
- const res = await fetch(config.token_endpoint, {
485
- method: "POST",
486
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
487
- body: body.toString(),
488
- });
489
- if (!res.ok) {
490
- const errText = await res.text();
491
- console.error(`[nova-sdk] ❌ Refresh token exchange failed for "${config.slug}": ${res.status}`, errText);
492
- throw new TokenExchangeError(config.slug, `refresh failed: ${res.status} ${errText}`);
493
- }
494
- const tokenData = (await res.json());
495
- const expiresAt = new Date(Date.now() + (tokenData.expires_in ?? 3600) * 1000);
496
- // Update vault + connection
497
- const { data: existingConn } = await platformDB
498
- .from("user_app_connections")
499
- .select("access_token_vault_id, refresh_token_vault_id")
500
- .eq("id", connectionId)
501
- .single();
502
- if (existingConn?.access_token_vault_id) {
503
- await updateVaultSecret(platformDB, existingConn.access_token_vault_id, tokenData.access_token);
504
- }
505
- if (tokenData.refresh_token && existingConn?.refresh_token_vault_id) {
506
- await updateVaultSecret(platformDB, existingConn.refresh_token_vault_id, tokenData.refresh_token);
456
+ /**
457
+ * Detect which credential resolution strategy to use.
458
+ * Returns 'http' if AUTH_ISSUER_BASE_URL is set, or 'none' if not configured.
459
+ *
460
+ * NOTE: The legacy 'db' strategy (PLATFORM_SUPABASE_*) has been removed.
461
+ * All credential resolution now goes through the HTTP callback strategy.
462
+ */
463
+ export function detectCredentialStrategy() {
464
+ const hasAuth = !!process.env.AUTH_ISSUER_BASE_URL;
465
+ if (hasAuth) {
466
+ console.log(`[nova-sdk] 🔍 Credential strategy: http (AUTH_ISSUER_BASE_URL=${process.env.AUTH_ISSUER_BASE_URL})`);
467
+ return "http";
507
468
  }
508
- await platformDB
509
- .from("user_app_connections")
510
- .update({
511
- token_expires_at: expiresAt.toISOString(),
512
- token_scope: tokenData.scope ?? null,
513
- last_used_at: new Date().toISOString(),
514
- })
515
- .eq("id", connectionId);
516
- // Cache it
517
- const cacheKey = getCacheKey(config.slug, userId);
518
- _tokenCache.set(cacheKey, { accessToken: tokenData.access_token, expiresAt });
519
- console.log(`[nova-sdk] ✅ User token refreshed for "${config.slug}" (expires ${expiresAt.toISOString()})`);
520
- return {
521
- accessToken: tokenData.access_token,
522
- expiresAt,
523
- integrationId: config.id,
524
- authMode: "standard",
525
- };
469
+ console.log(`[nova-sdk] 🔍 Credential strategy: none (AUTH_ISSUER_BASE_URL not set)`);
470
+ return "none";
526
471
  }
527
472
  // ─── integrationFetch: mTLS-aware fetch wrapper ─────────────────────────────
528
473
  /**
@@ -534,6 +479,7 @@ async function refreshUserToken(platformDB, config, userId, connectionId, refres
534
479
  */
535
480
  export async function integrationFetch(url, credentials, options = {}) {
536
481
  const { accessToken, httpsAgent } = credentials;
482
+ console.log(`[nova-sdk] 🌐 integrationFetch: ${options.method ?? "GET"} ${url} (mTLS: ${!!httpsAgent}, tokenPreview: ${accessToken.slice(0, 12)}...)`);
537
483
  if (httpsAgent) {
538
484
  // Use node:https for mTLS
539
485
  return new Promise((resolve, reject) => {
@@ -556,6 +502,7 @@ export async function integrationFetch(url, credentials, options = {}) {
556
502
  res.on("data", (chunk) => chunks.push(chunk));
557
503
  res.on("end", () => {
558
504
  const bodyStr = Buffer.concat(chunks).toString("utf-8");
505
+ console.log(`[nova-sdk] 📥 mTLS response: HTTP ${res.statusCode} for ${options.method ?? "GET"} ${url}`);
559
506
  resolve(new Response(bodyStr, {
560
507
  status: res.statusCode ?? 500,
561
508
  statusText: res.statusMessage ?? "Unknown",
@@ -570,7 +517,7 @@ export async function integrationFetch(url, credentials, options = {}) {
570
517
  });
571
518
  }
572
519
  // Non-mTLS: regular fetch
573
- return fetch(url, {
520
+ const res = await fetch(url, {
574
521
  method: options.method ?? "GET",
575
522
  headers: {
576
523
  Authorization: `Bearer ${accessToken}`,
@@ -580,11 +527,16 @@ export async function integrationFetch(url, credentials, options = {}) {
580
527
  },
581
528
  body: options.body,
582
529
  });
530
+ console.log(`[nova-sdk] 📥 Response: HTTP ${res.status} for ${options.method ?? "GET"} ${url}`);
531
+ return res;
583
532
  }
584
533
  // ─── emitPlatformEvent: PGMQ event emission ────────────────────────────────
585
534
  /**
586
535
  * Emit a PGMQ event to the platform database.
587
536
  * Used for cross-service communication (e.g., sync complete, webhook received).
537
+ *
538
+ * NOTE: This uses the platform Supabase client (PLATFORM_SUPABASE_*), which is
539
+ * separate from credential resolution. Credential resolution uses HTTP (AUTH_ISSUER_BASE_URL).
588
540
  */
589
541
  export async function emitPlatformEvent(platformDB, topic, integrationSlug, payload) {
590
542
  console.log(`[nova-sdk] 📤 Emitting event: ${topic} (integration: ${integrationSlug})`);
@@ -605,164 +557,3 @@ export async function emitPlatformEvent(platformDB, topic, integrationSlug, payl
605
557
  console.log(`[nova-sdk] ✅ Event emitted: ${topic}`);
606
558
  }
607
559
  }
608
- /**
609
- * Build an mTLS agent from PEM strings (received via HTTP, not from vault).
610
- * Cached per slug — cert/key don't change during container lifetime.
611
- */
612
- function buildMtlsAgentFromPem(slug, certPem, keyPem) {
613
- const existing = _mtlsAgents.get(slug);
614
- if (existing)
615
- return existing;
616
- console.log(`[nova-sdk] 🔒 Built mTLS agent for "${slug}" from HTTP response (cert: ${certPem.length} bytes, key: ${keyPem.length} bytes)`);
617
- const agent = new https.Agent({ cert: certPem, key: keyPem, keepAlive: true });
618
- _mtlsAgents.set(slug, agent);
619
- return agent;
620
- }
621
- /**
622
- * Fetch integration credentials from the auth server over HTTP.
623
- * Requires a valid JWT Bearer token (the same one that authenticated the
624
- * inbound request to this container).
625
- *
626
- * @param authBaseUrl - Auth server base URL (e.g., "http://localhost:3001")
627
- * @param slug - Integration slug (e.g., "adp")
628
- * @param bearerToken - JWT to forward as Authorization header
629
- * @param forceRefresh - When true, sends X-Nova-Token-Invalid: true to trigger a forced refresh
630
- */
631
- async function fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken, forceRefresh = false) {
632
- const url = `${authBaseUrl}/api/integrations/${encodeURIComponent(slug)}/credentials`;
633
- console.log(`[nova-sdk] 🌐 Fetching credentials via HTTP: GET ${url}${forceRefresh ? " (force-refresh)" : ""}`);
634
- const headers = {
635
- Authorization: `Bearer ${bearerToken}`,
636
- Accept: "application/json",
637
- };
638
- // Signal the auth server to discard its stored token and force a refresh
639
- if (forceRefresh) {
640
- headers["X-Nova-Token-Invalid"] = "true";
641
- }
642
- const res = await fetch(url, { method: "GET", headers });
643
- if (!res.ok) {
644
- const errBody = await res.text().catch(() => "");
645
- if (res.status === 401) {
646
- throw new Error(`[nova-sdk] Auth server rejected JWT for credential fetch (401). ` +
647
- `Ensure the JWT is valid and signed by the auth server. ${errBody}`);
648
- }
649
- if (res.status === 404) {
650
- throw new IntegrationNotFoundError(slug);
651
- }
652
- throw new Error(`[nova-sdk] Credential fetch failed: HTTP ${res.status} ${errBody.slice(0, 300)}`);
653
- }
654
- const data = (await res.json());
655
- console.log(`[nova-sdk] ✅ Received credentials for "${slug}" via HTTP (authMode: ${data.authMode})`);
656
- return data;
657
- }
658
- /**
659
- * Resolve credentials for an integration using the HTTP callback strategy.
660
- *
661
- * This is the main entry point for Strategy B. It:
662
- * 1. Checks the in-memory cache
663
- * 2. Fetches decrypted credentials from the auth server via HTTP
664
- * 3. Performs the OAuth token exchange (client_credentials or mTLS)
665
- * 4. Caches the resulting access token in memory
666
- *
667
- * @param authBaseUrl - Auth server base URL
668
- * @param slug - Integration slug
669
- * @param bearerToken - JWT to authenticate with the auth server
670
- * @param userId - Optional user ID (for cache key differentiation)
671
- */
672
- export async function resolveCredentialsViaHttp(authBaseUrl, slug, bearerToken, userId, options) {
673
- const effectiveUserId = userId ?? SYSTEM_USER_ID;
674
- const cacheKey = getCacheKey(slug, effectiveUserId);
675
- const forceRefresh = options?.forceRefresh ?? false;
676
- console.log(`[nova-sdk] 🔑 Resolving credentials for "${slug}" via HTTP (userId: ${effectiveUserId}${forceRefresh ? ", forceRefresh=true" : ""})`);
677
- // ── Step 1: Check in-memory cache (skip if forcing refresh) ──────────
678
- const cached = forceRefresh ? null : getCachedToken(cacheKey);
679
- if (cached) {
680
- // Derive authMode: use stored value if available, otherwise infer from httpsAgent
681
- const cachedAuthMode = cached.authMode ?? (cached.httpsAgent ? "mtls" : "client_credentials");
682
- console.log(`[nova-sdk] ⚡ Using in-memory cached token (expires ${cached.expiresAt.toISOString()}, authMode: ${cachedAuthMode})`);
683
- return {
684
- accessToken: cached.accessToken,
685
- expiresAt: cached.expiresAt,
686
- integrationId: `http:${slug}`, // No DB UUID available in HTTP mode
687
- authMode: cachedAuthMode,
688
- httpsAgent: cached.httpsAgent,
689
- };
690
- }
691
- // ── Step 2: Fetch credentials from auth server ────────────────────────
692
- const creds = await fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken, forceRefresh);
693
- // ── Step 2b: Standard auth — use pre-resolved access token if available ──
694
- if (creds.authMode === "standard") {
695
- if (creds.accessToken) {
696
- const expiresAt = creds.expiresAt
697
- ? new Date(creds.expiresAt)
698
- : new Date(Date.now() + 3600 * 1000); // default 1h if not provided
699
- console.log(`[nova-sdk] ✅ Using pre-resolved access token for "${slug}" (standard auth, HTTP mode) — expires ${expiresAt.toISOString()}`);
700
- // Cache in memory (include authMode so cache hits return correct mode)
701
- _tokenCache.set(cacheKey, { accessToken: creds.accessToken, expiresAt, authMode: "standard" });
702
- return {
703
- accessToken: creds.accessToken,
704
- expiresAt,
705
- integrationId: `http:${slug}`,
706
- authMode: "standard",
707
- };
708
- }
709
- // Standard auth but no access token returned — user hasn't authorized yet
710
- throw new ConnectionNotFoundError(slug, `${effectiveUserId} (no stored access token — user must authorize via OAuth popup first)`);
711
- }
712
- // ── Step 3: Validate required fields (client_credentials / mTLS only) ──
713
- if (!creds.clientId) {
714
- throw new CredentialsNotConfiguredError(slug, "client_id is not set");
715
- }
716
- if (!creds.clientSecret) {
717
- throw new CredentialsNotConfiguredError(slug, "client_secret not available");
718
- }
719
- if (!creds.tokenEndpoint) {
720
- throw new CredentialsNotConfiguredError(slug, "token_endpoint is not set");
721
- }
722
- // ── Step 4: Build mTLS agent if needed ────────────────────────────────
723
- let httpsAgent;
724
- if (creds.authMode === "mtls") {
725
- if (!creds.mtlsCert || !creds.mtlsKey) {
726
- throw new CredentialsNotConfiguredError(slug, "mTLS cert/key not returned by auth server");
727
- }
728
- httpsAgent = buildMtlsAgentFromPem(slug, creds.mtlsCert, creds.mtlsKey);
729
- }
730
- // ── Step 5: Perform token exchange (client_credentials / mTLS) ────────
731
- console.log(`[nova-sdk] 🔄 Exchanging client_credentials at ${creds.tokenEndpoint} for "${slug}" (HTTP mode)`);
732
- const tokenResponse = await performTokenExchange(slug, {
733
- tokenEndpoint: creds.tokenEndpoint,
734
- clientId: creds.clientId,
735
- clientSecret: creds.clientSecret,
736
- httpsAgent,
737
- });
738
- const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000);
739
- console.log(`[nova-sdk] ✅ Token exchange successful for "${slug}" (HTTP mode) — expires ${expiresAt.toISOString()} (${tokenResponse.expires_in}s)`);
740
- // ── Step 6: Cache in memory ───────────────────────────────────────────
741
- _tokenCache.set(cacheKey, {
742
- accessToken: tokenResponse.access_token,
743
- expiresAt,
744
- httpsAgent,
745
- });
746
- return {
747
- accessToken: tokenResponse.access_token,
748
- expiresAt,
749
- integrationId: `http:${slug}`,
750
- authMode: creds.authMode,
751
- httpsAgent,
752
- };
753
- }
754
- /**
755
- * Detect which credential resolution strategy to use.
756
- * Returns 'db' if PLATFORM_SUPABASE_* are available, 'http' if AUTH_ISSUER_BASE_URL
757
- * is set, or 'none' if neither is configured.
758
- */
759
- export function detectCredentialStrategy() {
760
- const hasDB = !!process.env.PLATFORM_SUPABASE_URL &&
761
- !!process.env.PLATFORM_SUPABASE_SERVICE_ROLE_KEY;
762
- const hasAuth = !!process.env.AUTH_ISSUER_BASE_URL;
763
- if (hasDB)
764
- return "db";
765
- if (hasAuth)
766
- return "http";
767
- return "none";
768
- }
package/dist/index.d.ts CHANGED
@@ -415,5 +415,5 @@ export type ScheduleTrigger = {
415
415
  };
416
416
  /** Union of all trigger variants */
417
417
  export type Trigger = EventTrigger | ScheduleTrigger;
418
- export { createPlatformClient, resolveCredentials, resolveCredentialsViaHttp, detectCredentialStrategy, integrationFetch, emitPlatformEvent, IntegrationNotFoundError, IntegrationDisabledError, CredentialsNotConfiguredError, ConnectionNotFoundError, TokenExchangeError, } from "./credentials.js";
419
- export type { ResolvedCredentials, IntegrationConfig, AuthMode, } from "./credentials.js";
418
+ export { createPlatformClient, resolveCredentialsViaHttp, detectCredentialStrategy, clearTokenCache, integrationFetch, emitPlatformEvent, IntegrationNotFoundError, IntegrationDisabledError, CredentialsNotConfiguredError, ConnectionNotFoundError, TokenExchangeError, } from "./credentials.js";
419
+ export type { ResolvedCredentials, AuthMode, } from "./credentials.js";
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { createServer } from "node:http";
6
6
  import { os } from "@orpc/server";
7
7
  import { RPCHandler } from "@orpc/server/node";
8
8
  import { CORSPlugin } from "@orpc/server/plugins";
9
- import { createPlatformClient, resolveCredentials as _resolveCredentials, resolveCredentialsViaHttp as _resolveCredentialsViaHttp, detectCredentialStrategy, integrationFetch as _integrationFetch, clearTokenCache as _clearTokenCache, } from "./credentials.js";
9
+ import { resolveCredentialsViaHttp as _resolveCredentialsViaHttp, detectCredentialStrategy, integrationFetch as _integrationFetch, clearTokenCache as _clearTokenCache, } from "./credentials.js";
10
10
  if (!process.env.RUNTIME_SUPABASE_URL) {
11
11
  // local dev – read .env.local
12
12
  dotenv.config({ path: ".env.local", override: true });
@@ -541,78 +541,70 @@ export async function generateOpenAPISpec(def) {
541
541
  /**
542
542
  * Build resolveCredentials() and fetch() methods bound to this worker's slug.
543
543
  *
544
- * Automatically detects the credential resolution strategy:
545
- * - **Strategy A (DB)**: PLATFORM_SUPABASE_* env vars direct Supabase Vault access
546
- * - **Strategy B (HTTP)**: AUTH_ISSUER_BASE_URL env var call auth server with JWT
544
+ * Uses the HTTP callback strategy exclusively:
545
+ * AUTH_ISSUER_BASE_URL calls the auth server with the inbound JWT to
546
+ * retrieve credentials, then performs the OAuth token exchange locally.
547
547
  *
548
- * @param defaultSlug - Integration slug (e.g., "adp")
549
- * @param authToken - JWT Bearer token from the inbound request (for HTTP strategy)
548
+ * Token cache order: in-memory client-side refresh_token grant → auth server fetch
549
+ *
550
+ * @param defaultSlug - Integration slug (e.g., "jira", "bamboohr")
551
+ * @param authToken - JWT Bearer token from the inbound request (forwarded to auth server)
550
552
  */
551
553
  function buildCredentialCtx(defaultSlug, authToken) {
552
- // Cache the resolved credentials per request to avoid duplicate round-trips
554
+ // Per-request credentials cache avoids duplicate round-trips within a single handler
553
555
  let _lastCreds = null;
554
556
  const strategy = detectCredentialStrategy();
555
557
  const ctxResolveCredentials = async (slug, userId) => {
556
558
  const targetSlug = slug ?? defaultSlug;
557
- if (strategy === "db") {
558
- // Strategy A: Direct DB access via PLATFORM_SUPABASE_*
559
- const platformDB = createPlatformClient();
560
- const creds = await _resolveCredentials(platformDB, targetSlug, userId);
561
- _lastCreds = creds;
562
- return creds;
563
- }
559
+ console.log(`[nova-sdk] 🔑 ctx.resolveCredentials() called: slug="${targetSlug}" userId="${userId ?? "system"}" strategy="${strategy}"`);
564
560
  if (strategy === "http") {
565
- // Strategy B: HTTP callback to auth server with JWT
566
561
  const authBaseUrl = process.env.AUTH_ISSUER_BASE_URL;
567
562
  if (!authToken) {
568
563
  throw new Error(`[nova-sdk] HTTP credential resolution requires a JWT bearer token, ` +
569
564
  `but no authToken was provided in the request context. ` +
570
- `Ensure the request includes an Authorization: Bearer header.`);
565
+ `Ensure the inbound request includes an Authorization: Bearer <jwt> header.`);
571
566
  }
572
567
  const creds = await _resolveCredentialsViaHttp(authBaseUrl, targetSlug, authToken, userId);
573
568
  _lastCreds = creds;
574
569
  return creds;
575
570
  }
576
571
  // strategy === "none"
577
- throw new Error(`[nova-sdk] No credential resolution strategy available. ` +
578
- `Set either PLATFORM_SUPABASE_URL + PLATFORM_SUPABASE_SERVICE_ROLE_KEY (direct DB) ` +
579
- `or AUTH_ISSUER_BASE_URL (HTTP callback) to enable credential resolution.`);
572
+ throw new Error(`[nova-sdk] No credential resolution strategy configured. ` +
573
+ `Set AUTH_ISSUER_BASE_URL to enable HTTP-based credential resolution.`);
580
574
  };
581
575
  const ctxFetch = async (url, options, credentials) => {
582
576
  const creds = credentials ?? _lastCreds ?? await ctxResolveCredentials();
583
577
  const response = await _integrationFetch(url, creds, options);
584
- // ── 401 auto-retry: token may have been revoked ──────────────────────────
585
- // If the API returns 401 AND the caller didn't supply explicit credentials
586
- // (i.e., we used the cached/resolved ones), clear the in-memory cache and
587
- // force-refresh from the auth server, then retry once.
588
- // This handles the "user re-authenticated but container still holds the old
589
- // revoked token" scenario.
578
+ // ── 401 auto-retry ───────────────────────────────────────────────────────
579
+ // If the API returns 401 AND the caller did NOT supply explicit credentials
580
+ // (3rd argument), the token may be stale. Clear the cache and force-refresh
581
+ // from the auth server, then retry the request once.
582
+ //
583
+ // If credentials were passed explicitly (3rd arg), skip retry — the caller
584
+ // is managing their own token lifecycle.
590
585
  if (response.status === 401 && !credentials) {
591
- console.warn(`[nova-sdk] ⚠️ 401 received from ${url} — clearing cache and force-refreshing credentials for "${defaultSlug}"`);
586
+ console.warn(`[nova-sdk] ⚠️ 401 Unauthorized from ${url} — clearing cache and force-refreshing credentials for "${defaultSlug}"`);
592
587
  _clearTokenCache(defaultSlug);
593
588
  _lastCreds = null;
594
- let freshCreds;
595
- if (strategy === "http") {
596
- // HTTP strategy: signal auth server to bypass its stored token and refresh
597
- const authBaseUrl = process.env.AUTH_ISSUER_BASE_URL;
598
- if (!authToken) {
599
- console.error("[nova-sdk] ❌ Cannot force-refresh: no authToken available");
600
- return response; // return original 401
601
- }
602
- freshCreds = await _resolveCredentialsViaHttp(authBaseUrl, defaultSlug, authToken, undefined, { forceRefresh: true });
589
+ if (strategy !== "http") {
590
+ console.error(`[nova-sdk] ❌ Cannot force-refresh: strategy is "${strategy}" (not "http")`);
591
+ return response;
603
592
  }
604
- else {
605
- // DB strategy: just re-resolve (DB always reads latest from vault)
606
- freshCreds = await ctxResolveCredentials();
593
+ const authBaseUrl = process.env.AUTH_ISSUER_BASE_URL;
594
+ if (!authToken) {
595
+ console.error("[nova-sdk] Cannot force-refresh: no authToken available in request context");
596
+ return response; // return original 401
607
597
  }
598
+ console.log(`[nova-sdk] 🔄 Force-refreshing token for "${defaultSlug}" via auth server`);
599
+ const freshCreds = await _resolveCredentialsViaHttp(authBaseUrl, defaultSlug, authToken, undefined, { forceRefresh: true });
608
600
  _lastCreds = freshCreds;
609
- // Only retry if we got a different (fresh) token — avoid an infinite loop
601
+ // Only retry if we actually got a different token — guards against infinite loop
610
602
  if (freshCreds.accessToken !== creds.accessToken) {
611
- console.log(`[nova-sdk] 🔄 Retrying request with refreshed token for "${defaultSlug}"`);
603
+ console.log(`[nova-sdk] 🔄 Retrying ${options?.method ?? "GET"} ${url} with refreshed token`);
612
604
  return _integrationFetch(url, freshCreds, options);
613
605
  }
614
606
  else {
615
- console.warn(`[nova-sdk] ⚠️ Force-refresh returned the same token for "${defaultSlug}" — not retrying`);
607
+ console.warn(`[nova-sdk] ⚠️ Force-refresh returned the same token for "${defaultSlug}" — not retrying (token may be permanently invalid)`);
616
608
  }
617
609
  }
618
610
  return response;
@@ -855,11 +847,10 @@ export function runHttpServer(def, opts = {}) {
855
847
  // ── Log credential resolution strategy ──
856
848
  const credStrategy = detectCredentialStrategy();
857
849
  const strategyLabels = {
858
- db: '🗄️ Direct DB (PLATFORM_SUPABASE_*)',
859
850
  http: '🌐 HTTP callback (AUTH_ISSUER_BASE_URL → auth server)',
860
851
  none: '⚠️ NONE — ctx.resolveCredentials() will throw if called',
861
852
  };
862
- console.log(`[nova] 🔑 Credential strategy: ${strategyLabels[credStrategy]}`);
853
+ console.log(`[nova] 🔑 Credential strategy: ${strategyLabels[credStrategy] ?? credStrategy}`);
863
854
  const port = opts.port ?? (process.env.PORT ? parseInt(process.env.PORT) : 8000);
864
855
  app.listen(port, () => {
865
856
  console.log(`[nova] HTTP server listening on http://localhost:${port}`);
@@ -917,7 +908,9 @@ export { parseIntegrationSpec, IntegrationSpecSchema } from "./integrationSpec.j
917
908
  /*──────────────── Credential Resolution (re-exports) ───────────────*/
918
909
  // These are the first-class SDK exports for integration credential management.
919
910
  // Every integration gets vault-backed token caching, mTLS, and OAuth for free.
920
- export { createPlatformClient, resolveCredentials, resolveCredentialsViaHttp, detectCredentialStrategy, integrationFetch, emitPlatformEvent,
911
+ export { createPlatformClient,
912
+ // resolveCredentials (DB-based strategy removed — use resolveCredentialsViaHttp)
913
+ resolveCredentialsViaHttp, detectCredentialStrategy, clearTokenCache, integrationFetch, emitPlatformEvent,
921
914
  // Error classes
922
915
  IntegrationNotFoundError, IntegrationDisabledError, CredentialsNotConfiguredError, ConnectionNotFoundError, TokenExchangeError, } from "./credentials.js";
923
916
  // // Default export for compatibility
@@ -49,8 +49,6 @@ export declare const WorkerDefSchema: z.ZodObject<{
49
49
  boolean: "boolean";
50
50
  date: "date";
51
51
  text: "text";
52
- uuid: "uuid";
53
- json: "json";
54
52
  textarea: "textarea";
55
53
  integer: "integer";
56
54
  datetime: "datetime";
@@ -59,6 +57,8 @@ export declare const WorkerDefSchema: z.ZodObject<{
59
57
  password: "password";
60
58
  email: "email";
61
59
  url: "url";
60
+ uuid: "uuid";
61
+ json: "json";
62
62
  hidden: "hidden";
63
63
  }>>;
64
64
  label: z.ZodOptional<z.ZodString>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newhomestar/sdk",
3
- "version": "0.7.19",
3
+ "version": "0.7.21",
4
4
  "description": "Type-safe SDK for building Nova pipelines (workers & functions)",
5
5
  "homepage": "https://github.com/newhomestar/nova-node-sdk#readme",
6
6
  "bugs": {