@newhomestar/sdk 0.7.21 → 0.7.24
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 +13 -13
- package/dist/credentials.js +38 -196
- package/dist/index.d.ts +3 -2
- package/dist/index.js +10 -7
- package/dist/next.d.ts +249 -0
- package/dist/next.js +17 -1
- package/package.json +1 -1
package/dist/credentials.d.ts
CHANGED
|
@@ -37,26 +37,26 @@ export declare class TokenExchangeError extends Error {
|
|
|
37
37
|
*/
|
|
38
38
|
export declare function createPlatformClient(): SupabaseClient;
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* fetches a fresh token instead of re-using the revoked one.
|
|
40
|
+
* No-op — token cache has been removed.
|
|
41
|
+
* Retained for backward compatibility with integration code that calls it on 401.
|
|
43
42
|
*/
|
|
44
43
|
export declare function clearTokenCache(slug: string, userId?: string): void;
|
|
45
44
|
/**
|
|
46
45
|
* Resolve credentials for an integration using the HTTP callback strategy.
|
|
47
46
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
47
|
+
* Token caching has been intentionally removed — every call fetches fresh
|
|
48
|
+
* credentials from the auth server to prevent stale/revoked tokens from
|
|
49
|
+
* being reused. The auth server and Vault maintain their own caching layer.
|
|
50
|
+
*
|
|
51
|
+
* On 401 from the integration API, call again with `{ forceRefresh: true }`
|
|
52
|
+
* to send X-Nova-Token-Invalid: true and force the auth server to discard
|
|
53
|
+
* any token it has stored for this integration.
|
|
54
54
|
*
|
|
55
55
|
* @param authBaseUrl - Auth server base URL (AUTH_ISSUER_BASE_URL)
|
|
56
|
-
* @param slug - Integration slug (e.g., "jira", "
|
|
57
|
-
* @param bearerToken - JWT from the inbound request (to
|
|
58
|
-
* @param userId - Optional user ID for
|
|
59
|
-
* @param options - forceRefresh: true →
|
|
56
|
+
* @param slug - Integration slug (e.g., "jira", "bamboohr")
|
|
57
|
+
* @param bearerToken - JWT from the inbound request (forwarded to auth server)
|
|
58
|
+
* @param userId - Optional user ID (for standard/user-level OAuth flows)
|
|
59
|
+
* @param options - forceRefresh: true → send X-Nova-Token-Invalid header
|
|
60
60
|
*/
|
|
61
61
|
export declare function resolveCredentialsViaHttp(authBaseUrl: string, slug: string, bearerToken: string, userId?: string, options?: {
|
|
62
62
|
forceRefresh?: boolean;
|
package/dist/credentials.js
CHANGED
|
@@ -7,14 +7,16 @@
|
|
|
7
7
|
// - 'standard' → authorization_code (per-user OAuth redirect)
|
|
8
8
|
//
|
|
9
9
|
// Credential resolution strategy:
|
|
10
|
-
// HTTP callback (AUTH_ISSUER_BASE_URL) — container calls auth server with JWT
|
|
10
|
+
// HTTP callback (AUTH_ISSUER_BASE_URL) — container calls auth server with JWT.
|
|
11
11
|
// The container receives a JWT from the inbound request, forwards it to the
|
|
12
12
|
// auth server, which decrypts credentials from Vault and returns them.
|
|
13
13
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// NOTE: In-memory token caching has been intentionally removed.
|
|
15
|
+
// Every call to resolveCredentialsViaHttp() fetches fresh credentials from
|
|
16
|
+
// the auth server to prevent stale/revoked tokens from being reused.
|
|
17
|
+
// The auth server and Vault maintain their own caching layer.
|
|
18
|
+
// On a 401 from the integration API, ctx.fetch() automatically retries once
|
|
19
|
+
// with forceRefresh=true to bust the auth server's cached token too.
|
|
18
20
|
//
|
|
19
21
|
// Every integration gets this for free via ctx.resolveCredentials() / ctx.fetch().
|
|
20
22
|
import { createClient } from "@supabase/supabase-js";
|
|
@@ -74,37 +76,23 @@ export function createPlatformClient() {
|
|
|
74
76
|
}
|
|
75
77
|
return _platformClient;
|
|
76
78
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
// ─── Token Cache (DISABLED) ─────────────────────────────────────────────────
|
|
80
|
+
//
|
|
81
|
+
// The in-memory token cache has been removed to prevent stale/revoked tokens
|
|
82
|
+
// from being re-used after a provider invalidates them server-side.
|
|
83
|
+
// Every call to resolveCredentialsViaHttp() now fetches fresh credentials from
|
|
84
|
+
// the auth server. The auth server (and Vault) provide their own caching layer.
|
|
85
|
+
//
|
|
86
|
+
// clearTokenCache() is retained as a no-op for backward compatibility with any
|
|
87
|
+
// integration code that calls it explicitly.
|
|
80
88
|
/** System user UUID for client_credentials/mTLS connections (audit trail) */
|
|
81
89
|
const SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
|
|
82
|
-
function getCacheKey(integrationSlug, userId) {
|
|
83
|
-
return `${integrationSlug}:${userId ?? SYSTEM_USER_ID}`;
|
|
84
|
-
}
|
|
85
90
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* fetches a fresh token instead of re-using the revoked one.
|
|
91
|
+
* No-op — token cache has been removed.
|
|
92
|
+
* Retained for backward compatibility with integration code that calls it on 401.
|
|
89
93
|
*/
|
|
90
94
|
export function clearTokenCache(slug, userId) {
|
|
91
|
-
|
|
92
|
-
const deleted = _tokenCache.delete(key);
|
|
93
|
-
console.log(`[nova-sdk] 🗑️ Cleared in-memory token cache for "${slug}" (key=${key}, existed=${deleted})`);
|
|
94
|
-
}
|
|
95
|
-
function getCachedToken(key) {
|
|
96
|
-
const cached = _tokenCache.get(key);
|
|
97
|
-
if (!cached) {
|
|
98
|
-
console.log(`[nova-sdk] 💭 Cache miss for key="${key}"`);
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
|
-
if (new Date().getTime() >= cached.expiresAt.getTime() - TOKEN_EXPIRY_BUFFER_MS) {
|
|
102
|
-
console.log(`[nova-sdk] 🔄 In-memory cached token expired for key="${key}" (expired at ${cached.expiresAt.toISOString()})`);
|
|
103
|
-
_tokenCache.delete(key);
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
106
|
-
console.log(`[nova-sdk] ⚡ Cache hit for key="${key}" (expires ${cached.expiresAt.toISOString()})`);
|
|
107
|
-
return cached;
|
|
95
|
+
console.log(`[nova-sdk] 🗑️ clearTokenCache("${slug}") called — cache is disabled, no-op`);
|
|
108
96
|
}
|
|
109
97
|
// ─── mTLS Agent Cache ───────────────────────────────────────────────────────
|
|
110
98
|
// Cache mTLS agents per integration slug (cert/key don't change during container lifetime)
|
|
@@ -189,60 +177,6 @@ async function performTokenExchange(slug, params) {
|
|
|
189
177
|
}
|
|
190
178
|
return res.json();
|
|
191
179
|
}
|
|
192
|
-
// ─── Client-Side Refresh Grant ──────────────────────────────────────────────
|
|
193
|
-
/**
|
|
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).
|
|
197
|
-
*
|
|
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.
|
|
200
|
-
*/
|
|
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;
|
|
206
|
-
}
|
|
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
|
-
};
|
|
238
|
-
}
|
|
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;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
180
|
/**
|
|
247
181
|
* Fetch integration credentials from the auth server over HTTP.
|
|
248
182
|
* Requires a valid JWT Bearer token (the same one that authenticated the
|
|
@@ -285,105 +219,34 @@ async function fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken, fo
|
|
|
285
219
|
/**
|
|
286
220
|
* Resolve credentials for an integration using the HTTP callback strategy.
|
|
287
221
|
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
222
|
+
* Token caching has been intentionally removed — every call fetches fresh
|
|
223
|
+
* credentials from the auth server to prevent stale/revoked tokens from
|
|
224
|
+
* being reused. The auth server and Vault maintain their own caching layer.
|
|
225
|
+
*
|
|
226
|
+
* On 401 from the integration API, call again with `{ forceRefresh: true }`
|
|
227
|
+
* to send X-Nova-Token-Invalid: true and force the auth server to discard
|
|
228
|
+
* any token it has stored for this integration.
|
|
294
229
|
*
|
|
295
230
|
* @param authBaseUrl - Auth server base URL (AUTH_ISSUER_BASE_URL)
|
|
296
|
-
* @param slug - Integration slug (e.g., "jira", "
|
|
297
|
-
* @param bearerToken - JWT from the inbound request (to
|
|
298
|
-
* @param userId - Optional user ID for
|
|
299
|
-
* @param options - forceRefresh: true →
|
|
231
|
+
* @param slug - Integration slug (e.g., "jira", "bamboohr")
|
|
232
|
+
* @param bearerToken - JWT from the inbound request (forwarded to auth server)
|
|
233
|
+
* @param userId - Optional user ID (for standard/user-level OAuth flows)
|
|
234
|
+
* @param options - forceRefresh: true → send X-Nova-Token-Invalid header
|
|
300
235
|
*/
|
|
301
236
|
export async function resolveCredentialsViaHttp(authBaseUrl, slug, bearerToken, userId, options) {
|
|
302
237
|
const effectiveUserId = userId ?? SYSTEM_USER_ID;
|
|
303
|
-
const cacheKey = getCacheKey(slug, effectiveUserId);
|
|
304
238
|
const forceRefresh = options?.forceRefresh ?? false;
|
|
305
239
|
console.log(`[nova-sdk] 🔑 resolveCredentialsViaHttp: slug="${slug}" userId="${effectiveUserId}" forceRefresh=${forceRefresh} authBaseUrl="${authBaseUrl}"`);
|
|
306
|
-
// ──
|
|
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
|
-
};
|
|
319
|
-
}
|
|
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);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
else {
|
|
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})`);
|
|
356
|
-
}
|
|
357
|
-
// ── Step 3: Fetch credentials from auth server ────────────────────────
|
|
240
|
+
// ── Fetch credentials from auth server (always fresh — no local cache) ──
|
|
358
241
|
console.log(`[nova-sdk] 📡 Fetching fresh credentials from auth server for "${slug}"`);
|
|
359
242
|
const creds = await fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken, forceRefresh);
|
|
360
|
-
// ──
|
|
243
|
+
// ── Standard auth — use pre-resolved access token from auth server ────
|
|
361
244
|
if (creds.authMode === "standard") {
|
|
362
245
|
if (creds.accessToken) {
|
|
363
246
|
const expiresAt = creds.expiresAt
|
|
364
247
|
? new Date(creds.expiresAt)
|
|
365
248
|
: new Date(Date.now() + 3600 * 1000); // default 1h if not provided
|
|
366
|
-
console.log(`[nova-sdk] ✅ Standard auth:
|
|
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
|
-
}
|
|
249
|
+
console.log(`[nova-sdk] ✅ Standard auth: received access token for "${slug}" — expires ${expiresAt.toISOString()}`);
|
|
387
250
|
return {
|
|
388
251
|
accessToken: creds.accessToken,
|
|
389
252
|
expiresAt,
|
|
@@ -391,11 +254,11 @@ export async function resolveCredentialsViaHttp(authBaseUrl, slug, bearerToken,
|
|
|
391
254
|
authMode: "standard",
|
|
392
255
|
};
|
|
393
256
|
}
|
|
394
|
-
// Standard auth but no access token
|
|
257
|
+
// Standard auth but no access token — user hasn't authorized yet
|
|
395
258
|
console.error(`[nova-sdk] ❌ Standard auth: no access token returned for "${slug}" — user must authorize via OAuth popup`);
|
|
396
259
|
throw new ConnectionNotFoundError(slug, `${effectiveUserId} (no stored access token — user must authorize via OAuth popup first)`);
|
|
397
260
|
}
|
|
398
|
-
// ──
|
|
261
|
+
// ── Validate required fields (client_credentials / mTLS) ─────────────
|
|
399
262
|
if (!creds.clientId) {
|
|
400
263
|
throw new CredentialsNotConfiguredError(slug, "client_id is not set");
|
|
401
264
|
}
|
|
@@ -406,7 +269,7 @@ export async function resolveCredentialsViaHttp(authBaseUrl, slug, bearerToken,
|
|
|
406
269
|
throw new CredentialsNotConfiguredError(slug, "token_endpoint is not set");
|
|
407
270
|
}
|
|
408
271
|
console.log(`[nova-sdk] 🔧 Credentials received: authMode=${creds.authMode} clientId=${creds.clientId?.slice(0, 8)}... tokenEndpoint=${creds.tokenEndpoint}`);
|
|
409
|
-
// ──
|
|
272
|
+
// ── Build mTLS agent if needed ────────────────────────────────────────
|
|
410
273
|
let httpsAgent;
|
|
411
274
|
if (creds.authMode === "mtls") {
|
|
412
275
|
if (!creds.mtlsCert || !creds.mtlsKey) {
|
|
@@ -414,7 +277,7 @@ export async function resolveCredentialsViaHttp(authBaseUrl, slug, bearerToken,
|
|
|
414
277
|
}
|
|
415
278
|
httpsAgent = buildMtlsAgentFromPem(slug, creds.mtlsCert, creds.mtlsKey);
|
|
416
279
|
}
|
|
417
|
-
// ──
|
|
280
|
+
// ── Perform token exchange (client_credentials / mTLS) ───────────────
|
|
418
281
|
console.log(`[nova-sdk] 🔄 Performing ${creds.authMode} token exchange at ${creds.tokenEndpoint} for "${slug}"`);
|
|
419
282
|
const tokenResponse = await performTokenExchange(slug, {
|
|
420
283
|
tokenEndpoint: creds.tokenEndpoint,
|
|
@@ -423,28 +286,7 @@ export async function resolveCredentialsViaHttp(authBaseUrl, slug, bearerToken,
|
|
|
423
286
|
httpsAgent,
|
|
424
287
|
});
|
|
425
288
|
const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000);
|
|
426
|
-
console.log(`[nova-sdk] ✅ Token exchange successful for "${slug}" — expires ${expiresAt.toISOString()} (${tokenResponse.expires_in}s
|
|
427
|
-
// ── Step 7: Cache in memory with refresh metadata ─────────────────────
|
|
428
|
-
const cacheEntry = {
|
|
429
|
-
accessToken: tokenResponse.access_token,
|
|
430
|
-
expiresAt,
|
|
431
|
-
httpsAgent,
|
|
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
|
-
}
|
|
289
|
+
console.log(`[nova-sdk] ✅ Token exchange successful for "${slug}" — expires ${expiresAt.toISOString()} (${tokenResponse.expires_in}s)`);
|
|
448
290
|
return {
|
|
449
291
|
accessToken: tokenResponse.access_token,
|
|
450
292
|
expiresAt,
|
package/dist/index.d.ts
CHANGED
|
@@ -170,8 +170,9 @@ export interface ActionCtx {
|
|
|
170
170
|
auth?: JWTPayload;
|
|
171
171
|
/**
|
|
172
172
|
* Resolve OAuth credentials for the current integration (or a named slug).
|
|
173
|
-
* Handles all auth modes (mtls, client_credentials, standard)
|
|
174
|
-
*
|
|
173
|
+
* Handles all auth modes (mtls, client_credentials, standard).
|
|
174
|
+
* Always fetches fresh credentials from the auth server — no local token cache.
|
|
175
|
+
* On 401, use ctx.fetch() which automatically retries with forceRefresh=true.
|
|
175
176
|
*
|
|
176
177
|
* @param slug - Integration slug (defaults to the current worker's name)
|
|
177
178
|
* @param userId - User ID for standard OAuth; omit for server flows
|
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 { resolveCredentialsViaHttp as _resolveCredentialsViaHttp, detectCredentialStrategy, integrationFetch as _integrationFetch,
|
|
9
|
+
import { resolveCredentialsViaHttp as _resolveCredentialsViaHttp, detectCredentialStrategy, integrationFetch as _integrationFetch, } 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 });
|
|
@@ -545,7 +545,10 @@ export async function generateOpenAPISpec(def) {
|
|
|
545
545
|
* AUTH_ISSUER_BASE_URL → calls the auth server with the inbound JWT to
|
|
546
546
|
* retrieve credentials, then performs the OAuth token exchange locally.
|
|
547
547
|
*
|
|
548
|
-
*
|
|
548
|
+
* No local token cache — every resolveCredentials() call fetches fresh from
|
|
549
|
+
* the auth server. A per-request _lastCreds variable avoids duplicate calls
|
|
550
|
+
* within the same handler invocation.
|
|
551
|
+
* On 401, ctx.fetch() retries once with forceRefresh=true (X-Nova-Token-Invalid).
|
|
549
552
|
*
|
|
550
553
|
* @param defaultSlug - Integration slug (e.g., "jira", "bamboohr")
|
|
551
554
|
* @param authToken - JWT Bearer token from the inbound request (forwarded to auth server)
|
|
@@ -577,14 +580,14 @@ function buildCredentialCtx(defaultSlug, authToken) {
|
|
|
577
580
|
const response = await _integrationFetch(url, creds, options);
|
|
578
581
|
// ── 401 auto-retry ───────────────────────────────────────────────────────
|
|
579
582
|
// If the API returns 401 AND the caller did NOT supply explicit credentials
|
|
580
|
-
// (3rd argument),
|
|
581
|
-
//
|
|
583
|
+
// (3rd argument), force-refresh from the auth server (sends
|
|
584
|
+
// X-Nova-Token-Invalid: true so the auth server also discards its token),
|
|
585
|
+
// then retry the request once.
|
|
582
586
|
//
|
|
583
587
|
// If credentials were passed explicitly (3rd arg), skip retry — the caller
|
|
584
588
|
// is managing their own token lifecycle.
|
|
585
589
|
if (response.status === 401 && !credentials) {
|
|
586
|
-
console.warn(`[nova-sdk] ⚠️ 401 Unauthorized from ${url} —
|
|
587
|
-
_clearTokenCache(defaultSlug);
|
|
590
|
+
console.warn(`[nova-sdk] ⚠️ 401 Unauthorized from ${url} — force-refreshing credentials for "${defaultSlug}" (X-Nova-Token-Invalid)`);
|
|
588
591
|
_lastCreds = null;
|
|
589
592
|
if (strategy !== "http") {
|
|
590
593
|
console.error(`[nova-sdk] ❌ Cannot force-refresh: strategy is "${strategy}" (not "http")`);
|
|
@@ -595,7 +598,7 @@ function buildCredentialCtx(defaultSlug, authToken) {
|
|
|
595
598
|
console.error("[nova-sdk] ❌ Cannot force-refresh: no authToken available in request context");
|
|
596
599
|
return response; // return original 401
|
|
597
600
|
}
|
|
598
|
-
console.log(`[nova-sdk] 🔄 Force-refreshing token for "${defaultSlug}" via auth server`);
|
|
601
|
+
console.log(`[nova-sdk] 🔄 Force-refreshing token for "${defaultSlug}" via auth server (forceRefresh=true)`);
|
|
599
602
|
const freshCreds = await _resolveCredentialsViaHttp(authBaseUrl, defaultSlug, authToken, undefined, { forceRefresh: true });
|
|
600
603
|
_lastCreds = freshCreds;
|
|
601
604
|
// Only retry if we actually got a different token — guards against infinite loop
|
package/dist/next.d.ts
CHANGED
|
@@ -50,6 +50,183 @@ export interface ParamMeta {
|
|
|
50
50
|
order?: number;
|
|
51
51
|
group?: string;
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* A single FGA relation definition for an object type.
|
|
55
|
+
* Declared in `defineService().fga.relations[]` and auto-registered in the
|
|
56
|
+
* IAM service's `entity_relation_definitions` table at `nova services push` time.
|
|
57
|
+
*/
|
|
58
|
+
export interface NovaFgaRelationDef {
|
|
59
|
+
/** Object type this relation applies to (e.g. 'community', 'trnVideo') */
|
|
60
|
+
object_type: string;
|
|
61
|
+
/** The relation name (e.g. 'admin', 'editor', 'member', 'viewer') */
|
|
62
|
+
relation: string;
|
|
63
|
+
/**
|
|
64
|
+
* Relations this relation implies (e.g. admin implies ['editor', 'member']).
|
|
65
|
+
* The IAM DB trigger pre-expands implied relations into entity_computed_access
|
|
66
|
+
* automatically — no application code needed for propagation.
|
|
67
|
+
*/
|
|
68
|
+
implies?: string[];
|
|
69
|
+
/** Priority for ordering (default: 0). Higher = evaluated first. */
|
|
70
|
+
priority?: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* IAM resource declaration for `defineService().iam.resources[]`.
|
|
74
|
+
* Declares how the service's permission namespace maps to the Odyssey UI resource tree.
|
|
75
|
+
* Used by `nova services push` to set resource types, hierarchy levels, and parent-child
|
|
76
|
+
* relationships between resources.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* iam: {
|
|
81
|
+
* resources: [
|
|
82
|
+
* { name: 'HRIS Admin', resource_path: 'hris_admin', resource_type: 'module', level: 50,
|
|
83
|
+
* description: 'HRIS module — unified HR and payroll data' },
|
|
84
|
+
* { name: 'Employees', resource_path: 'hris_employees', resource_type: 'block', level: 60,
|
|
85
|
+
* parent_resource_path: 'hris_admin', description: 'Employee records' },
|
|
86
|
+
* ],
|
|
87
|
+
* }
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export interface NovaIamResourceDef {
|
|
91
|
+
/** Human-readable display name shown in Odyssey UI (e.g. 'Employees', 'HRIS Admin') */
|
|
92
|
+
name: string;
|
|
93
|
+
/** Unique resource path — must match the permission slug prefix (e.g. 'hris_employees') */
|
|
94
|
+
resource_path: string;
|
|
95
|
+
/** Position in the resource hierarchy */
|
|
96
|
+
resource_type: 'system' | 'module' | 'block' | 'resource';
|
|
97
|
+
/** Hierarchy level: 50=module, 60=block, 80=resource */
|
|
98
|
+
level: number;
|
|
99
|
+
/** Optional description shown in Odyssey UI */
|
|
100
|
+
description?: string;
|
|
101
|
+
/**
|
|
102
|
+
* resource_path of the parent resource.
|
|
103
|
+
* Resolved to parentResourceId at `nova services push` time by the IAM service.
|
|
104
|
+
* e.g. set to 'hris_admin' to nest this resource under the HRIS Admin module.
|
|
105
|
+
*/
|
|
106
|
+
parent_resource_path?: string;
|
|
107
|
+
/** Arbitrary metadata stored on the resource record */
|
|
108
|
+
metadata?: Record<string, unknown>;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* FGA check declared on a novaEndpoint() — runtime-only.
|
|
112
|
+
* Describes which relation on which object a request's subject must hold.
|
|
113
|
+
* The schema (relation hierarchy) is declared in `defineService().fga.relations`.
|
|
114
|
+
*
|
|
115
|
+
* Used by `nova.assertFga(req, context)` at runtime (Phase 2).
|
|
116
|
+
* Stored on the endpoint record in the platform DB for Odyssey UI display.
|
|
117
|
+
*/
|
|
118
|
+
export interface NovaFgaCheckDef {
|
|
119
|
+
/** Object type to check (e.g. 'community', 'trnVideo') */
|
|
120
|
+
object_type: string;
|
|
121
|
+
/** Relation the subject must hold (e.g. 'member', 'viewer', 'editor') */
|
|
122
|
+
relation: string;
|
|
123
|
+
/**
|
|
124
|
+
* Where to resolve object_id from in the request:
|
|
125
|
+
* 'path' → Next.js route params (e.g. params.id for /communities/:id)
|
|
126
|
+
* 'query' → URL query string param
|
|
127
|
+
*/
|
|
128
|
+
object_id_from: 'path' | 'query';
|
|
129
|
+
/** The param name to extract object_id from (e.g. 'id', 'community_id') */
|
|
130
|
+
object_id_param: string;
|
|
131
|
+
/** Optional tenant_id param name for multi-tenant scoped checks */
|
|
132
|
+
tenant_id_param?: string;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Service manifest definition — the code-first single source of truth for a
|
|
136
|
+
* Nova service. `nova services push` reads this to generate nova-service.yaml
|
|
137
|
+
* and sync all service metadata to the platform.
|
|
138
|
+
*
|
|
139
|
+
* Place this in `src/service.ts` (or `src/app/service.ts`) and export as `service`.
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```ts
|
|
143
|
+
* // src/service.ts
|
|
144
|
+
* import { defineService } from '@newhomestar/sdk/next';
|
|
145
|
+
*
|
|
146
|
+
* export const service = defineService({
|
|
147
|
+
* slug: 'community',
|
|
148
|
+
* name: 'Community Service',
|
|
149
|
+
* category: 'social',
|
|
150
|
+
* runtime: 'nextjs',
|
|
151
|
+
* apiDir: './src/app',
|
|
152
|
+
*
|
|
153
|
+
* fga: {
|
|
154
|
+
* relations: [
|
|
155
|
+
* { object_type: 'community', relation: 'admin', implies: ['editor', 'member'], priority: 100 },
|
|
156
|
+
* { object_type: 'community', relation: 'editor', implies: ['viewer'], priority: 50 },
|
|
157
|
+
* { object_type: 'community', relation: 'member', implies: ['viewer'], priority: 30 },
|
|
158
|
+
* { object_type: 'community', relation: 'viewer', implies: [], priority: 10 },
|
|
159
|
+
* ],
|
|
160
|
+
* },
|
|
161
|
+
* });
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
export interface NovaServiceDef {
|
|
165
|
+
/** Unique service slug (e.g. 'community', 'hris', 'iam') */
|
|
166
|
+
slug: string;
|
|
167
|
+
/** Human-readable service name */
|
|
168
|
+
name: string;
|
|
169
|
+
/** Service description */
|
|
170
|
+
description?: string;
|
|
171
|
+
/** Category for grouping in Odyssey UI (e.g. 'platform', 'social', 'hris') */
|
|
172
|
+
category?: string;
|
|
173
|
+
/** Service version (default: '1.0.0') */
|
|
174
|
+
version?: string;
|
|
175
|
+
/** Runtime mode (default: 'nextjs') */
|
|
176
|
+
runtime?: 'nextjs' | 'sdk';
|
|
177
|
+
/** Path to route files directory (default: './src/app') */
|
|
178
|
+
apiDir?: string;
|
|
179
|
+
/** Public base URL for Odyssey UI API tester */
|
|
180
|
+
baseUrl?: string;
|
|
181
|
+
/** Shared Zod schemas registered in platform DB on push */
|
|
182
|
+
schemas?: Array<{
|
|
183
|
+
slug: string;
|
|
184
|
+
name: string;
|
|
185
|
+
file: string;
|
|
186
|
+
description?: string;
|
|
187
|
+
version?: string;
|
|
188
|
+
schemaType?: string;
|
|
189
|
+
}>;
|
|
190
|
+
/** Environment variable declarations */
|
|
191
|
+
envSpec?: Array<{
|
|
192
|
+
name: string;
|
|
193
|
+
secret?: boolean;
|
|
194
|
+
default?: string;
|
|
195
|
+
}>;
|
|
196
|
+
/** Container resource requests */
|
|
197
|
+
resources?: {
|
|
198
|
+
cpu?: string;
|
|
199
|
+
memory?: string;
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* FGA schema — defines relation hierarchies for all object types owned by
|
|
203
|
+
* this service. Registered in IAM `entity_relation_definitions` at push time.
|
|
204
|
+
*
|
|
205
|
+
* This MUST be pushed before any `entity_grants` are assigned, as the
|
|
206
|
+
* trigger-based expansion relies on these definitions to propagate implied
|
|
207
|
+
* relations through `entity_computed_access`.
|
|
208
|
+
*/
|
|
209
|
+
fga?: {
|
|
210
|
+
relations: NovaFgaRelationDef[];
|
|
211
|
+
};
|
|
212
|
+
/**
|
|
213
|
+
* IAM resource hierarchy — declares how this service's permission namespace
|
|
214
|
+
* maps to the Odyssey UI resource tree (module → block → resource).
|
|
215
|
+
*
|
|
216
|
+
* Resources declared here override the auto-derived defaults (which default
|
|
217
|
+
* to resource_type: 'module', level: 50, no parent). Use this to:
|
|
218
|
+
* - Declare a parent module resource (e.g. 'hris_admin')
|
|
219
|
+
* - Nest entity resources as blocks under that module
|
|
220
|
+
* - Control display names, descriptions, and hierarchy levels
|
|
221
|
+
*
|
|
222
|
+
* At `nova services push` time the CLI reads this, merges with auto-derived
|
|
223
|
+
* resources from permission slugs, and sends `parent_resource_path` to the
|
|
224
|
+
* IAM service which resolves it to `parentResourceId` in the DB.
|
|
225
|
+
*/
|
|
226
|
+
iam?: {
|
|
227
|
+
resources: NovaIamResourceDef[];
|
|
228
|
+
};
|
|
229
|
+
}
|
|
53
230
|
/**
|
|
54
231
|
* An event type emitted by a service endpoint.
|
|
55
232
|
* Defined inline in `novaEndpoint()` and auto-registered in the platform
|
|
@@ -218,6 +395,47 @@ export interface NovaEndpointDef<I extends ZodTypeAny = ZodTypeAny, O extends Zo
|
|
|
218
395
|
* ```
|
|
219
396
|
*/
|
|
220
397
|
events?: NovaEndpointEventDef[];
|
|
398
|
+
/**
|
|
399
|
+
* FGA (Fine-Grained Authorization) check(s) required to access this endpoint.
|
|
400
|
+
*
|
|
401
|
+
* The authenticated subject (user) must hold the declared `relation` on the
|
|
402
|
+
* specified `object_type:object_id` to be granted access.
|
|
403
|
+
*
|
|
404
|
+
* The FGA schema (relation hierarchy) is declared separately in
|
|
405
|
+
* `defineService().fga.relations` and registered at `nova services push` time.
|
|
406
|
+
* The fgaCheck here is **runtime-only** — it describes how to resolve the
|
|
407
|
+
* object_id from the current request and which relation to verify.
|
|
408
|
+
*
|
|
409
|
+
* Runtime enforcement is via `nova.assertFga(req, { path: params })` (Phase 2).
|
|
410
|
+
* At push time, the declarations are stored on the endpoint record in the
|
|
411
|
+
* platform DB for Odyssey UI display.
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* ```ts
|
|
415
|
+
* // Single check: user must be a member of the community
|
|
416
|
+
* fgaCheck: {
|
|
417
|
+
* object_type: 'community',
|
|
418
|
+
* relation: 'member',
|
|
419
|
+
* object_id_from: 'path',
|
|
420
|
+
* object_id_param: 'id',
|
|
421
|
+
* }
|
|
422
|
+
*
|
|
423
|
+
* // Multiple checks: user must be member of community AND editor of the video
|
|
424
|
+
* fgaCheck: [
|
|
425
|
+
* { object_type: 'community', relation: 'member', object_id_from: 'path', object_id_param: 'community_id' },
|
|
426
|
+
* { object_type: 'trnVideo', relation: 'editor', object_id_from: 'path', object_id_param: 'video_id' },
|
|
427
|
+
* ],
|
|
428
|
+
* fgaMode: 'all',
|
|
429
|
+
* ```
|
|
430
|
+
*/
|
|
431
|
+
fgaCheck?: NovaFgaCheckDef | NovaFgaCheckDef[];
|
|
432
|
+
/**
|
|
433
|
+
* When multiple `fgaCheck` entries are declared, controls evaluation logic:
|
|
434
|
+
* 'all' → every check must pass (default — AND logic, most restrictive)
|
|
435
|
+
* 'any' → at least one check must pass (OR logic)
|
|
436
|
+
* Ignored when fgaCheck is a single object.
|
|
437
|
+
*/
|
|
438
|
+
fgaMode?: 'all' | 'any';
|
|
221
439
|
}
|
|
222
440
|
export type NovaEndpoint<I extends ZodTypeAny = ZodTypeAny, O extends ZodTypeAny = ZodTypeAny> = NovaEndpointDef<I, O> & {
|
|
223
441
|
/**
|
|
@@ -370,9 +588,23 @@ export declare function buildPrismaPage(input: CursorPageInputType): {
|
|
|
370
588
|
id: 'desc';
|
|
371
589
|
}>;
|
|
372
590
|
};
|
|
591
|
+
/**
|
|
592
|
+
* Convert a snake_case string to camelCase.
|
|
593
|
+
* Used internally to normalise sort column names from API query params
|
|
594
|
+
* (snake_case) to Prisma model field names (camelCase).
|
|
595
|
+
*
|
|
596
|
+
* @example
|
|
597
|
+
* ```ts
|
|
598
|
+
* snakeToCamel('modified_at') // → 'modifiedAt'
|
|
599
|
+
* snakeToCamel('createdAt') // → 'createdAt' (no-op)
|
|
600
|
+
* ```
|
|
601
|
+
*/
|
|
602
|
+
export declare function snakeToCamel(s: string): string;
|
|
373
603
|
/**
|
|
374
604
|
* Build Prisma `skip`, `take`, and `orderBy` from an `OffsetPageInput`.
|
|
375
605
|
* Handles offset pagination and sort direction.
|
|
606
|
+
* Sort column names are automatically converted from snake_case (API convention)
|
|
607
|
+
* to camelCase (Prisma convention) via `snakeToCamel()`.
|
|
376
608
|
* Does NOT handle `search` or `filters` — apply those to the `where` clause manually.
|
|
377
609
|
*
|
|
378
610
|
* @example
|
|
@@ -497,6 +729,8 @@ export declare const CURSOR_PAGE_PARAMS: Record<string, ParamMeta>;
|
|
|
497
729
|
export declare const OFFSET_PAGE_PARAMS: Record<string, ParamMeta>;
|
|
498
730
|
/**
|
|
499
731
|
* Metadata for a Nova service — passed to `defineService()`.
|
|
732
|
+
* Extends `NovaServiceDef` to include FGA relation schema and IAM resource
|
|
733
|
+
* hierarchy declarations consumed by `nova services push`.
|
|
500
734
|
*/
|
|
501
735
|
export interface ServiceDef {
|
|
502
736
|
/** Unique service slug (snake_case). e.g. "nova_ticketing_service" */
|
|
@@ -509,6 +743,21 @@ export interface ServiceDef {
|
|
|
509
743
|
category?: string;
|
|
510
744
|
/** Semver version string — defaults to "1.0.0" */
|
|
511
745
|
version?: string;
|
|
746
|
+
/**
|
|
747
|
+
* FGA schema — relation hierarchies for object types owned by this service.
|
|
748
|
+
* Registered in IAM `entity_relation_definitions` at `nova services push` time.
|
|
749
|
+
*/
|
|
750
|
+
fga?: {
|
|
751
|
+
relations: NovaFgaRelationDef[];
|
|
752
|
+
};
|
|
753
|
+
/**
|
|
754
|
+
* IAM resource hierarchy — maps permission namespace to the Odyssey UI tree.
|
|
755
|
+
* Overrides auto-derived resource names/types/levels and sets parent-child links.
|
|
756
|
+
* See `NovaIamResourceDef` for field documentation.
|
|
757
|
+
*/
|
|
758
|
+
iam?: {
|
|
759
|
+
resources: NovaIamResourceDef[];
|
|
760
|
+
};
|
|
512
761
|
}
|
|
513
762
|
/**
|
|
514
763
|
* defineService() — declare Nova service identity and auto-set NOVA_SERVICE_SLUG.
|
package/dist/next.js
CHANGED
|
@@ -191,9 +191,25 @@ export function buildPrismaPage(input) {
|
|
|
191
191
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
|
192
192
|
};
|
|
193
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Convert a snake_case string to camelCase.
|
|
196
|
+
* Used internally to normalise sort column names from API query params
|
|
197
|
+
* (snake_case) to Prisma model field names (camelCase).
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```ts
|
|
201
|
+
* snakeToCamel('modified_at') // → 'modifiedAt'
|
|
202
|
+
* snakeToCamel('createdAt') // → 'createdAt' (no-op)
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
export function snakeToCamel(s) {
|
|
206
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
207
|
+
}
|
|
194
208
|
/**
|
|
195
209
|
* Build Prisma `skip`, `take`, and `orderBy` from an `OffsetPageInput`.
|
|
196
210
|
* Handles offset pagination and sort direction.
|
|
211
|
+
* Sort column names are automatically converted from snake_case (API convention)
|
|
212
|
+
* to camelCase (Prisma convention) via `snakeToCamel()`.
|
|
197
213
|
* Does NOT handle `search` or `filters` — apply those to the `where` clause manually.
|
|
198
214
|
*
|
|
199
215
|
* @example
|
|
@@ -203,7 +219,7 @@ export function buildPrismaPage(input) {
|
|
|
203
219
|
* ```
|
|
204
220
|
*/
|
|
205
221
|
export function buildPrismaOffsetPage(input, defaultSort = 'createdAt') {
|
|
206
|
-
const col = input.sort ?? defaultSort;
|
|
222
|
+
const col = snakeToCamel(input.sort ?? defaultSort);
|
|
207
223
|
return {
|
|
208
224
|
skip: input.offset,
|
|
209
225
|
take: input.limit,
|
package/package.json
CHANGED