@newhomestar/sdk 0.7.18 → 0.7.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/credentials.d.ts +30 -46
- package/dist/credentials.js +284 -493
- package/dist/index.d.ts +2 -2
- package/dist/index.js +37 -44
- package/dist/workerSchema.d.ts +2 -2
- package/package.json +1 -1
package/dist/credentials.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
46
|
+
* Resolve credentials for an integration using the HTTP callback strategy.
|
|
56
47
|
*
|
|
57
|
-
*
|
|
58
|
-
* 1.
|
|
59
|
-
* 2.
|
|
60
|
-
* 3.
|
|
61
|
-
* 4.
|
|
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
|
|
64
|
-
* @param slug - Integration slug (e.g.,
|
|
65
|
-
* @param
|
|
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
|
|
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
|
|
89
|
-
*
|
|
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
|
|
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>;
|
package/dist/credentials.js
CHANGED
|
@@ -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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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.
|
|
16
|
-
// 3. Fresh token exchange (cold path
|
|
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
|
-
// ───
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
// ───
|
|
192
|
+
// ─── Client-Side Refresh Grant ──────────────────────────────────────────────
|
|
222
193
|
/**
|
|
223
|
-
*
|
|
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
|
-
*
|
|
226
|
-
*
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
.
|
|
286
|
-
.
|
|
287
|
-
.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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 (!
|
|
331
|
-
throw new CredentialsNotConfiguredError(
|
|
402
|
+
if (!creds.clientSecret) {
|
|
403
|
+
throw new CredentialsNotConfiguredError(slug, "client_secret not available");
|
|
332
404
|
}
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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 (
|
|
341
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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:
|
|
412
|
-
authMode:
|
|
451
|
+
integrationId: `http:${slug}`,
|
|
452
|
+
authMode: creds.authMode,
|
|
413
453
|
httpsAgent,
|
|
414
454
|
};
|
|
415
455
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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,
|
|
419
|
-
export type { ResolvedCredentials,
|
|
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 {
|
|
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
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
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
|
-
*
|
|
549
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
578
|
-
`Set
|
|
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
|
|
585
|
-
// If the API returns 401 AND the caller
|
|
586
|
-
// (
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
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
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
package/dist/workerSchema.d.ts
CHANGED
|
@@ -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