@newhomestar/sdk 0.7.26 → 0.8.1
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 +62 -0
- package/dist/credentials.js +187 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -1
- package/dist/next.d.ts +2 -0
- package/dist/next.js +2 -0
- package/dist/userCache.d.ts +74 -0
- package/dist/userCache.js +210 -0
- package/package.json +1 -1
package/dist/credentials.d.ts
CHANGED
|
@@ -89,3 +89,65 @@ export declare function integrationFetch(url: string, credentials: ResolvedCrede
|
|
|
89
89
|
* separate from credential resolution. Credential resolution uses HTTP (AUTH_ISSUER_BASE_URL).
|
|
90
90
|
*/
|
|
91
91
|
export declare function emitPlatformEvent(platformDB: SupabaseClient, topic: string, integrationSlug: string, payload: Record<string, unknown>): Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Fetch integration credentials using a service token (NOVA_SERVICE_TOKEN).
|
|
94
|
+
* Same auth server endpoint as resolveCredentialsViaHttp, but authenticates
|
|
95
|
+
* with the service token instead of a user JWT. Intended for background
|
|
96
|
+
* processing loops that run outside the HTTP request context.
|
|
97
|
+
*
|
|
98
|
+
* @param authBaseUrl - Auth server base URL (AUTH_ISSUER_BASE_URL)
|
|
99
|
+
* @param slug - Integration slug (e.g., "adp", "bamboohr")
|
|
100
|
+
* @param serviceToken - NOVA_SERVICE_TOKEN value
|
|
101
|
+
*/
|
|
102
|
+
export declare function resolveCredentialsViaServiceToken(authBaseUrl: string, slug: string, serviceToken: string): Promise<ResolvedCredentials>;
|
|
103
|
+
/** The client object returned by createIntegrationClient() */
|
|
104
|
+
export interface IntegrationClient {
|
|
105
|
+
/**
|
|
106
|
+
* mTLS-aware fetch with auto-refreshing credentials.
|
|
107
|
+
* Automatically re-resolves credentials when they expire.
|
|
108
|
+
* On 401, forces a credential refresh and retries once.
|
|
109
|
+
*/
|
|
110
|
+
fetch: (url: string, options?: {
|
|
111
|
+
method?: string;
|
|
112
|
+
headers?: Record<string, string>;
|
|
113
|
+
body?: string;
|
|
114
|
+
}) => Promise<Response>;
|
|
115
|
+
/** Force credential re-resolution (e.g., after persistent 401s) */
|
|
116
|
+
refreshCredentials: () => Promise<ResolvedCredentials>;
|
|
117
|
+
/** Get current credentials (resolves if needed) */
|
|
118
|
+
ensureCredentials: () => Promise<ResolvedCredentials>;
|
|
119
|
+
/** The integration slug */
|
|
120
|
+
readonly slug: string;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Create a standalone integration client for background / headless contexts.
|
|
124
|
+
*
|
|
125
|
+
* Uses NOVA_SERVICE_TOKEN to resolve credentials from the auth server.
|
|
126
|
+
* Credentials are cached in memory and auto-refreshed when they expire
|
|
127
|
+
* (with a 60-second buffer before expiry).
|
|
128
|
+
*
|
|
129
|
+
* Designed for the two-phase ledger queue pattern:
|
|
130
|
+
* 1. Action handler seeds ledger rows (fast, no API calls)
|
|
131
|
+
* 2. Background loop processes ledger rows using this client
|
|
132
|
+
*
|
|
133
|
+
* @param slug - Integration slug (e.g., "adp", "bamboohr")
|
|
134
|
+
* @param options - Optional overrides for service token and auth URL
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```typescript
|
|
138
|
+
* // In action handler (has JWT context):
|
|
139
|
+
* const client = createIntegrationClient("adp");
|
|
140
|
+
* setImmediate(() => processLedgerQueue(jobId, tenantId, client));
|
|
141
|
+
*
|
|
142
|
+
* // In background loop:
|
|
143
|
+
* const resp = await client.fetch("https://api.adp.com/hr/v2/workers/123");
|
|
144
|
+
* if (resp.status === 204) continue; // handled correctly — no crash
|
|
145
|
+
* const data = await resp.json();
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export declare function createIntegrationClient(slug: string, options?: {
|
|
149
|
+
/** Override service token (defaults to NOVA_SERVICE_TOKEN env var) */
|
|
150
|
+
serviceToken?: string;
|
|
151
|
+
/** Override auth server URL (defaults to AUTH_ISSUER_BASE_URL env var) */
|
|
152
|
+
authBaseUrl?: string;
|
|
153
|
+
}): IntegrationClient;
|
package/dist/credentials.js
CHANGED
|
@@ -246,6 +246,16 @@ export async function resolveCredentialsViaHttp(authBaseUrl, slug, bearerToken,
|
|
|
246
246
|
const expiresAt = creds.expiresAt
|
|
247
247
|
? new Date(creds.expiresAt)
|
|
248
248
|
: new Date(Date.now() + 3600 * 1000); // default 1h if not provided
|
|
249
|
+
// ── Safety net: if the auth server returned an already-expired token,
|
|
250
|
+
// auto-retry with forceRefresh=true to trigger a server-side refresh.
|
|
251
|
+
// This guards against clock skew or a stale cached token on the auth server.
|
|
252
|
+
// Only retry once (when forceRefresh is not already true) to prevent infinite loops.
|
|
253
|
+
if (expiresAt.getTime() < Date.now() && !forceRefresh) {
|
|
254
|
+
console.warn(`[nova-sdk] ⚠️ Auth server returned an already-expired token for "${slug}" ` +
|
|
255
|
+
`(expired ${expiresAt.toISOString()}, now ${new Date().toISOString()}). ` +
|
|
256
|
+
`Auto-retrying with forceRefresh=true…`);
|
|
257
|
+
return resolveCredentialsViaHttp(authBaseUrl, slug, bearerToken, userId, { forceRefresh: true });
|
|
258
|
+
}
|
|
249
259
|
console.log(`[nova-sdk] ✅ Standard auth: received access token for "${slug}" — expires ${expiresAt.toISOString()}`);
|
|
250
260
|
return {
|
|
251
261
|
accessToken: creds.accessToken,
|
|
@@ -344,9 +354,16 @@ export async function integrationFetch(url, credentials, options = {}) {
|
|
|
344
354
|
res.on("data", (chunk) => chunks.push(chunk));
|
|
345
355
|
res.on("end", () => {
|
|
346
356
|
const bodyStr = Buffer.concat(chunks).toString("utf-8");
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
357
|
+
const statusCode = res.statusCode ?? 500;
|
|
358
|
+
console.log(`[nova-sdk] 📥 mTLS response: HTTP ${statusCode} for ${options.method ?? "GET"} ${url}`);
|
|
359
|
+
// Null-body status codes (204, 304) must NOT include a body per the
|
|
360
|
+
// Fetch spec. Node.js v20's Response constructor enforces this — passing
|
|
361
|
+
// a non-null body with status 204 throws a TypeError that escapes the
|
|
362
|
+
// Promise chain (the https callback is not connected to it), causing
|
|
363
|
+
// an uncaught exception AND a permanently-pending Promise (hang).
|
|
364
|
+
const isNullBodyStatus = statusCode === 204 || statusCode === 304;
|
|
365
|
+
resolve(new Response(isNullBodyStatus ? null : bodyStr, {
|
|
366
|
+
status: statusCode,
|
|
350
367
|
statusText: res.statusMessage ?? "Unknown",
|
|
351
368
|
headers: new Headers(res.headers),
|
|
352
369
|
}));
|
|
@@ -399,3 +416,170 @@ export async function emitPlatformEvent(platformDB, topic, integrationSlug, payl
|
|
|
399
416
|
console.log(`[nova-sdk] ✅ Event emitted: ${topic}`);
|
|
400
417
|
}
|
|
401
418
|
}
|
|
419
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
420
|
+
// Service-Token Credential Resolution (for background / headless contexts)
|
|
421
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
422
|
+
//
|
|
423
|
+
// Background sync loops (fire-and-forget via setImmediate) don't have an
|
|
424
|
+
// inbound JWT. They use NOVA_SERVICE_TOKEN to authenticate with the auth
|
|
425
|
+
// server's credential endpoint instead.
|
|
426
|
+
//
|
|
427
|
+
// The auth server already accepts this token as a Bearer (matched against
|
|
428
|
+
// INTERNAL_API_SECRET env var on the auth server side).
|
|
429
|
+
//
|
|
430
|
+
// For client_credentials / mTLS integrations (like ADP), no userId is needed.
|
|
431
|
+
// For standard auth, the service token path won't have user context — callers
|
|
432
|
+
// must pre-resolve user tokens in the handler while the JWT is available.
|
|
433
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
434
|
+
/**
|
|
435
|
+
* Fetch integration credentials using a service token (NOVA_SERVICE_TOKEN).
|
|
436
|
+
* Same auth server endpoint as resolveCredentialsViaHttp, but authenticates
|
|
437
|
+
* with the service token instead of a user JWT. Intended for background
|
|
438
|
+
* processing loops that run outside the HTTP request context.
|
|
439
|
+
*
|
|
440
|
+
* @param authBaseUrl - Auth server base URL (AUTH_ISSUER_BASE_URL)
|
|
441
|
+
* @param slug - Integration slug (e.g., "adp", "bamboohr")
|
|
442
|
+
* @param serviceToken - NOVA_SERVICE_TOKEN value
|
|
443
|
+
*/
|
|
444
|
+
export async function resolveCredentialsViaServiceToken(authBaseUrl, slug, serviceToken) {
|
|
445
|
+
console.log(`[nova-sdk] 🔑 resolveCredentialsViaServiceToken: slug="${slug}" authBaseUrl="${authBaseUrl}"`);
|
|
446
|
+
// Fetch credentials from auth server using service token as Bearer
|
|
447
|
+
const creds = await fetchCredentialsFromAuthServer(authBaseUrl, slug, serviceToken, false);
|
|
448
|
+
// ── Standard auth — not supported in service-token mode ───────────────
|
|
449
|
+
if (creds.authMode === "standard") {
|
|
450
|
+
// Standard auth needs a userId for user_app_connections lookup.
|
|
451
|
+
// The service token path doesn't carry user context.
|
|
452
|
+
// If the auth server returned a pre-resolved token (org-level), use it.
|
|
453
|
+
if (creds.accessToken) {
|
|
454
|
+
const expiresAt = creds.expiresAt
|
|
455
|
+
? new Date(creds.expiresAt)
|
|
456
|
+
: new Date(Date.now() + 3600 * 1000);
|
|
457
|
+
return {
|
|
458
|
+
accessToken: creds.accessToken,
|
|
459
|
+
expiresAt,
|
|
460
|
+
integrationId: `service:${slug}`,
|
|
461
|
+
authMode: "standard",
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
throw new Error(`[nova-sdk] Standard auth integration "${slug}" requires user context (JWT). ` +
|
|
465
|
+
`Use ctx.resolveCredentials() in the handler, not resolveCredentialsViaServiceToken().`);
|
|
466
|
+
}
|
|
467
|
+
// ── Validate required fields (client_credentials / mTLS) ─────────────
|
|
468
|
+
if (!creds.clientId) {
|
|
469
|
+
throw new CredentialsNotConfiguredError(slug, "client_id is not set");
|
|
470
|
+
}
|
|
471
|
+
if (!creds.clientSecret) {
|
|
472
|
+
throw new CredentialsNotConfiguredError(slug, "client_secret not available");
|
|
473
|
+
}
|
|
474
|
+
if (!creds.tokenEndpoint) {
|
|
475
|
+
throw new CredentialsNotConfiguredError(slug, "token_endpoint is not set");
|
|
476
|
+
}
|
|
477
|
+
// ── Build mTLS agent if needed ────────────────────────────────────────
|
|
478
|
+
let httpsAgent;
|
|
479
|
+
if (creds.authMode === "mtls") {
|
|
480
|
+
if (!creds.mtlsCert || !creds.mtlsKey) {
|
|
481
|
+
throw new CredentialsNotConfiguredError(slug, "mTLS cert/key not returned by auth server");
|
|
482
|
+
}
|
|
483
|
+
httpsAgent = buildMtlsAgentFromPem(slug, creds.mtlsCert, creds.mtlsKey);
|
|
484
|
+
}
|
|
485
|
+
// ── Perform token exchange ────────────────────────────────────────────
|
|
486
|
+
console.log(`[nova-sdk] 🔄 Service-token: performing ${creds.authMode} token exchange for "${slug}"`);
|
|
487
|
+
const tokenResponse = await performTokenExchange(slug, {
|
|
488
|
+
tokenEndpoint: creds.tokenEndpoint,
|
|
489
|
+
clientId: creds.clientId,
|
|
490
|
+
clientSecret: creds.clientSecret,
|
|
491
|
+
httpsAgent,
|
|
492
|
+
});
|
|
493
|
+
const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000);
|
|
494
|
+
console.log(`[nova-sdk] ✅ Service-token: token exchange successful for "${slug}" — expires ${expiresAt.toISOString()}`);
|
|
495
|
+
return {
|
|
496
|
+
accessToken: tokenResponse.access_token,
|
|
497
|
+
expiresAt,
|
|
498
|
+
integrationId: `service:${slug}`,
|
|
499
|
+
authMode: creds.authMode,
|
|
500
|
+
httpsAgent,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Create a standalone integration client for background / headless contexts.
|
|
505
|
+
*
|
|
506
|
+
* Uses NOVA_SERVICE_TOKEN to resolve credentials from the auth server.
|
|
507
|
+
* Credentials are cached in memory and auto-refreshed when they expire
|
|
508
|
+
* (with a 60-second buffer before expiry).
|
|
509
|
+
*
|
|
510
|
+
* Designed for the two-phase ledger queue pattern:
|
|
511
|
+
* 1. Action handler seeds ledger rows (fast, no API calls)
|
|
512
|
+
* 2. Background loop processes ledger rows using this client
|
|
513
|
+
*
|
|
514
|
+
* @param slug - Integration slug (e.g., "adp", "bamboohr")
|
|
515
|
+
* @param options - Optional overrides for service token and auth URL
|
|
516
|
+
*
|
|
517
|
+
* @example
|
|
518
|
+
* ```typescript
|
|
519
|
+
* // In action handler (has JWT context):
|
|
520
|
+
* const client = createIntegrationClient("adp");
|
|
521
|
+
* setImmediate(() => processLedgerQueue(jobId, tenantId, client));
|
|
522
|
+
*
|
|
523
|
+
* // In background loop:
|
|
524
|
+
* const resp = await client.fetch("https://api.adp.com/hr/v2/workers/123");
|
|
525
|
+
* if (resp.status === 204) continue; // handled correctly — no crash
|
|
526
|
+
* const data = await resp.json();
|
|
527
|
+
* ```
|
|
528
|
+
*/
|
|
529
|
+
export function createIntegrationClient(slug, options) {
|
|
530
|
+
const serviceToken = options?.serviceToken ??
|
|
531
|
+
process.env.NOVA_SERVICE_TOKEN;
|
|
532
|
+
const authBaseUrl = options?.authBaseUrl ??
|
|
533
|
+
process.env.AUTH_ISSUER_BASE_URL;
|
|
534
|
+
if (!serviceToken) {
|
|
535
|
+
throw new Error(`[nova-sdk] createIntegrationClient("${slug}"): NOVA_SERVICE_TOKEN is required ` +
|
|
536
|
+
`for background credential resolution. Set it as an environment variable.`);
|
|
537
|
+
}
|
|
538
|
+
if (!authBaseUrl) {
|
|
539
|
+
throw new Error(`[nova-sdk] createIntegrationClient("${slug}"): AUTH_ISSUER_BASE_URL is required. ` +
|
|
540
|
+
`Set it as an environment variable.`);
|
|
541
|
+
}
|
|
542
|
+
let _creds = null;
|
|
543
|
+
/** Resolve credentials, using cached version if still valid (60s buffer) */
|
|
544
|
+
const ensureCredentials = async () => {
|
|
545
|
+
const now = Date.now();
|
|
546
|
+
// Re-resolve if no cached creds, or if they expire within 60 seconds
|
|
547
|
+
if (!_creds || _creds.expiresAt.getTime() < now + 60_000) {
|
|
548
|
+
console.log(`[nova-sdk] 🔄 IntegrationClient("${slug}"): ${_creds ? "refreshing expired" : "resolving initial"} credentials`);
|
|
549
|
+
_creds = await resolveCredentialsViaServiceToken(authBaseUrl, slug, serviceToken);
|
|
550
|
+
}
|
|
551
|
+
return _creds;
|
|
552
|
+
};
|
|
553
|
+
/** Force a fresh credential resolution (busts the in-memory cache) */
|
|
554
|
+
const refreshCredentials = async () => {
|
|
555
|
+
console.log(`[nova-sdk] 🔄 IntegrationClient("${slug}"): force-refreshing credentials`);
|
|
556
|
+
_creds = null;
|
|
557
|
+
return ensureCredentials();
|
|
558
|
+
};
|
|
559
|
+
/** mTLS-aware fetch with auto-refresh + 401 retry */
|
|
560
|
+
const clientFetch = async (url, fetchOptions) => {
|
|
561
|
+
const creds = await ensureCredentials();
|
|
562
|
+
const response = await integrationFetch(url, creds, fetchOptions);
|
|
563
|
+
// ── 401 auto-retry: refresh credentials and retry once ──────────────
|
|
564
|
+
if (response.status === 401) {
|
|
565
|
+
console.warn(`[nova-sdk] ⚠️ IntegrationClient("${slug}"): 401 from ${url} — refreshing credentials and retrying`);
|
|
566
|
+
const freshCreds = await refreshCredentials();
|
|
567
|
+
// Only retry if we got a different token (prevent infinite loops)
|
|
568
|
+
if (freshCreds.accessToken !== creds.accessToken) {
|
|
569
|
+
console.log(`[nova-sdk] 🔄 IntegrationClient("${slug}"): retrying with refreshed token`);
|
|
570
|
+
return integrationFetch(url, freshCreds, fetchOptions);
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
console.warn(`[nova-sdk] ⚠️ IntegrationClient("${slug}"): refresh returned same token — not retrying`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return response;
|
|
577
|
+
};
|
|
578
|
+
console.log(`[nova-sdk] ✅ IntegrationClient created for "${slug}" (auth: ${authBaseUrl}, token: ${serviceToken.slice(0, 8)}...)`);
|
|
579
|
+
return {
|
|
580
|
+
fetch: clientFetch,
|
|
581
|
+
refreshCredentials,
|
|
582
|
+
ensureCredentials,
|
|
583
|
+
slug,
|
|
584
|
+
};
|
|
585
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -416,5 +416,5 @@ export type ScheduleTrigger = {
|
|
|
416
416
|
};
|
|
417
417
|
/** Union of all trigger variants */
|
|
418
418
|
export type Trigger = EventTrigger | ScheduleTrigger;
|
|
419
|
-
export { createPlatformClient, resolveCredentialsViaHttp, detectCredentialStrategy, clearTokenCache, integrationFetch, emitPlatformEvent, IntegrationNotFoundError, IntegrationDisabledError, CredentialsNotConfiguredError, ConnectionNotFoundError, TokenExchangeError, } from "./credentials.js";
|
|
420
|
-
export type { ResolvedCredentials, AuthMode, } from "./credentials.js";
|
|
419
|
+
export { createPlatformClient, resolveCredentialsViaHttp, resolveCredentialsViaServiceToken, createIntegrationClient, detectCredentialStrategy, clearTokenCache, integrationFetch, emitPlatformEvent, IntegrationNotFoundError, IntegrationDisabledError, CredentialsNotConfiguredError, ConnectionNotFoundError, TokenExchangeError, } from "./credentials.js";
|
|
420
|
+
export type { ResolvedCredentials, AuthMode, IntegrationClient, } from "./credentials.js";
|
package/dist/index.js
CHANGED
|
@@ -913,7 +913,11 @@ export { parseIntegrationSpec, IntegrationSpecSchema } from "./integrationSpec.j
|
|
|
913
913
|
// Every integration gets vault-backed token caching, mTLS, and OAuth for free.
|
|
914
914
|
export { createPlatformClient,
|
|
915
915
|
// resolveCredentials (DB-based strategy removed — use resolveCredentialsViaHttp)
|
|
916
|
-
resolveCredentialsViaHttp,
|
|
916
|
+
resolveCredentialsViaHttp,
|
|
917
|
+
// Service-token credential resolution (for background / headless sync loops)
|
|
918
|
+
resolveCredentialsViaServiceToken,
|
|
919
|
+
// Standalone integration client (auto-refresh, mTLS, 401 retry)
|
|
920
|
+
createIntegrationClient, detectCredentialStrategy, clearTokenCache, integrationFetch, emitPlatformEvent,
|
|
917
921
|
// Error classes
|
|
918
922
|
IntegrationNotFoundError, IntegrationDisabledError, CredentialsNotConfiguredError, ConnectionNotFoundError, TokenExchangeError, } from "./credentials.js";
|
|
919
923
|
// // Default export for compatibility
|
package/dist/next.d.ts
CHANGED
|
@@ -790,6 +790,8 @@ export interface ServiceDef {
|
|
|
790
790
|
export declare function defineService<T extends ServiceDef>(def: T): T & {
|
|
791
791
|
readonly __novaService: true;
|
|
792
792
|
};
|
|
793
|
+
export { UserCache, userCache } from './userCache.js';
|
|
794
|
+
export type { UserProfile, UserCacheOptions } from './userCache.js';
|
|
793
795
|
/** @deprecated Use `buildPageResponse` instead */
|
|
794
796
|
export declare function buildPaginatedResponse<T extends {
|
|
795
797
|
id: string;
|
package/dist/next.js
CHANGED
|
@@ -375,6 +375,8 @@ export function defineService(def) {
|
|
|
375
375
|
}
|
|
376
376
|
return { ...def, __novaService: true };
|
|
377
377
|
}
|
|
378
|
+
// ─── User Cache (re-export from userCache.ts) ────────────────────────────────
|
|
379
|
+
export { UserCache, userCache } from './userCache.js';
|
|
378
380
|
// ─── Legacy re-exports (backward compat) ─────────────────────────────────────
|
|
379
381
|
/** @deprecated Use `buildPageResponse` instead */
|
|
380
382
|
export function buildPaginatedResponse(data, count, pageSize) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight user profile shape returned by the Auth Service internal endpoints.
|
|
3
|
+
* Used for display purposes (resolving `created_by` UUIDs to names/emails/avatars).
|
|
4
|
+
*/
|
|
5
|
+
export interface UserProfile {
|
|
6
|
+
id: string;
|
|
7
|
+
email: string | null;
|
|
8
|
+
display_name: string | null;
|
|
9
|
+
first_name: string | null;
|
|
10
|
+
last_name: string | null;
|
|
11
|
+
avatar_url: string | null;
|
|
12
|
+
external_id: number | null;
|
|
13
|
+
}
|
|
14
|
+
export interface UserCacheOptions {
|
|
15
|
+
/** Auth Service base URL. Defaults to AUTH_ISSUER_BASE_URL env var. */
|
|
16
|
+
authServiceURL?: string;
|
|
17
|
+
/** Service-to-service JWT. Defaults to NOVA_SERVICE_TOKEN env var. */
|
|
18
|
+
serviceToken?: string;
|
|
19
|
+
/** Auto-refresh interval in milliseconds. Default: 300_000 (5 min). */
|
|
20
|
+
refreshIntervalMs?: number;
|
|
21
|
+
/** Enable verbose debug logging. Default: true when NODE_ENV !== 'production'. */
|
|
22
|
+
debug?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare class UserCache {
|
|
25
|
+
/** UUID → UserProfile */
|
|
26
|
+
private byId;
|
|
27
|
+
/** external_id (INT) → UUID */
|
|
28
|
+
private byExternalId;
|
|
29
|
+
private readonly authServiceURL;
|
|
30
|
+
private readonly serviceToken;
|
|
31
|
+
private readonly refreshIntervalMs;
|
|
32
|
+
private readonly _debug;
|
|
33
|
+
private timer;
|
|
34
|
+
private _lastRefreshed;
|
|
35
|
+
constructor(options?: UserCacheOptions);
|
|
36
|
+
private log;
|
|
37
|
+
/**
|
|
38
|
+
* Fetch all user profiles from the Auth Service and start the auto-refresh timer.
|
|
39
|
+
* Call once during service startup (e.g., in instrumentation.ts).
|
|
40
|
+
*/
|
|
41
|
+
start(): Promise<void>;
|
|
42
|
+
/** Stop the auto-refresh timer. Call during graceful shutdown. */
|
|
43
|
+
stop(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Force an immediate full refresh from the Auth Service.
|
|
46
|
+
* Paginates through GET /api/internal/users/all until all pages are loaded,
|
|
47
|
+
* then atomically swaps the in-memory maps.
|
|
48
|
+
*/
|
|
49
|
+
refresh(): Promise<void>;
|
|
50
|
+
/** Get a user profile by platform UUID. Returns undefined on cache miss. */
|
|
51
|
+
getUser(uuid: string): UserProfile | undefined;
|
|
52
|
+
/** Get a user profile by legacy external_id (INT). */
|
|
53
|
+
getUserByExternalId(extId: number): UserProfile | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Resolve multiple UUIDs at once. Returns a Map of found profiles.
|
|
56
|
+
*
|
|
57
|
+
* By default, cache misses trigger an HTTP fallback to
|
|
58
|
+
* POST /api/internal/users/resolve on the Auth Service, which backfills
|
|
59
|
+
* the in-memory cache. Set `fetchMissing: false` to skip the HTTP call.
|
|
60
|
+
*/
|
|
61
|
+
resolveMany(uuids: string[], options?: {
|
|
62
|
+
fetchMissing?: boolean;
|
|
63
|
+
}): Promise<Map<string, UserProfile>>;
|
|
64
|
+
/** Check if a UUID exists in the cache. */
|
|
65
|
+
has(uuid: string): boolean;
|
|
66
|
+
/** Cache statistics for health checks and monitoring. */
|
|
67
|
+
get stats(): {
|
|
68
|
+
userCount: number;
|
|
69
|
+
externalIdCount: number;
|
|
70
|
+
lastRefreshed: Date | null;
|
|
71
|
+
isRunning: boolean;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export declare const userCache: UserCache;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// ── User Profile Cache ────────────────────────────────────────────────────────
|
|
2
|
+
// In-memory cache of platform user profiles, loaded from the Auth Service.
|
|
3
|
+
// Mirrors the IAMPermissionCache pattern — startup warm-up, periodic refresh,
|
|
4
|
+
// and HTTP fallback for cache misses.
|
|
5
|
+
//
|
|
6
|
+
// Startup: userCache.start() (called from instrumentation.ts in consuming services)
|
|
7
|
+
// Usage: userCache.getUser(uuid) → UserProfile | undefined
|
|
8
|
+
// userCache.getUserByExternalId(n) → UserProfile | undefined
|
|
9
|
+
// userCache.resolveMany(uuids) → Map<uuid, UserProfile>
|
|
10
|
+
// userCache.stats → { userCount, lastRefreshed, isRunning }
|
|
11
|
+
//
|
|
12
|
+
// Reads: GET /api/internal/users/all on AUTH_ISSUER_BASE_URL
|
|
13
|
+
// POST /api/internal/users/resolve on AUTH_ISSUER_BASE_URL
|
|
14
|
+
// Auth: Bearer NOVA_SERVICE_TOKEN
|
|
15
|
+
// Refresh: Every USER_CACHE_REFRESH_MS (default: 5 minutes)
|
|
16
|
+
//
|
|
17
|
+
// See directory service .nova/user_service.md for full architecture docs.
|
|
18
|
+
// ── Class ─────────────────────────────────────────────────────────────────────
|
|
19
|
+
export class UserCache {
|
|
20
|
+
/** UUID → UserProfile */
|
|
21
|
+
byId = new Map();
|
|
22
|
+
/** external_id (INT) → UUID */
|
|
23
|
+
byExternalId = new Map();
|
|
24
|
+
authServiceURL;
|
|
25
|
+
serviceToken;
|
|
26
|
+
refreshIntervalMs;
|
|
27
|
+
_debug;
|
|
28
|
+
timer = null;
|
|
29
|
+
_lastRefreshed = null;
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.authServiceURL = (options.authServiceURL ??
|
|
32
|
+
process.env.AUTH_ISSUER_BASE_URL ??
|
|
33
|
+
'').replace(/\/+$/, '');
|
|
34
|
+
this.serviceToken =
|
|
35
|
+
options.serviceToken ??
|
|
36
|
+
process.env.NOVA_SERVICE_TOKEN ??
|
|
37
|
+
'';
|
|
38
|
+
this.refreshIntervalMs =
|
|
39
|
+
options.refreshIntervalMs ??
|
|
40
|
+
parseInt(process.env.USER_CACHE_REFRESH_MS ?? '300000', 10);
|
|
41
|
+
this._debug = options.debug ?? (process.env.NODE_ENV !== 'production');
|
|
42
|
+
}
|
|
43
|
+
log(msg) {
|
|
44
|
+
if (this._debug)
|
|
45
|
+
console.log(`[UserCache] ${msg}`);
|
|
46
|
+
}
|
|
47
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
48
|
+
/**
|
|
49
|
+
* Fetch all user profiles from the Auth Service and start the auto-refresh timer.
|
|
50
|
+
* Call once during service startup (e.g., in instrumentation.ts).
|
|
51
|
+
*/
|
|
52
|
+
async start() {
|
|
53
|
+
if (!this.authServiceURL) {
|
|
54
|
+
this.log('⚠️ AUTH_ISSUER_BASE_URL not set — user cache disabled');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!this.serviceToken) {
|
|
58
|
+
this.log('⚠️ NOVA_SERVICE_TOKEN not set — user cache disabled');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.log('🚀 Starting user cache...');
|
|
62
|
+
await this.refresh();
|
|
63
|
+
this.timer = setInterval(() => {
|
|
64
|
+
this.log('⏱ Auto-refresh triggered');
|
|
65
|
+
this.refresh().catch((err) => {
|
|
66
|
+
this.log(`⚠️ Auto-refresh failed: ${err.message}`);
|
|
67
|
+
console.error('[UserCache] ❌', err.message);
|
|
68
|
+
});
|
|
69
|
+
}, this.refreshIntervalMs);
|
|
70
|
+
// Allow the Node.js process to exit even if the timer is still pending
|
|
71
|
+
if (this.timer.unref)
|
|
72
|
+
this.timer.unref();
|
|
73
|
+
const nextMin = Math.round(this.refreshIntervalMs / 1000 / 60);
|
|
74
|
+
this.log(`✅ User cache started — ${this.byId.size} user(s) loaded, next refresh in ${nextMin}m`);
|
|
75
|
+
}
|
|
76
|
+
/** Stop the auto-refresh timer. Call during graceful shutdown. */
|
|
77
|
+
stop() {
|
|
78
|
+
if (this.timer) {
|
|
79
|
+
clearInterval(this.timer);
|
|
80
|
+
this.timer = null;
|
|
81
|
+
this.log('🛑 Auto-refresh stopped');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Force an immediate full refresh from the Auth Service.
|
|
86
|
+
* Paginates through GET /api/internal/users/all until all pages are loaded,
|
|
87
|
+
* then atomically swaps the in-memory maps.
|
|
88
|
+
*/
|
|
89
|
+
async refresh() {
|
|
90
|
+
const newById = new Map();
|
|
91
|
+
const newByExtId = new Map();
|
|
92
|
+
const startMs = Date.now();
|
|
93
|
+
let page = 1;
|
|
94
|
+
// Paginate through all users
|
|
95
|
+
while (true) {
|
|
96
|
+
const url = `${this.authServiceURL}/api/internal/users/all?page=${page}&per_page=1000`;
|
|
97
|
+
this.log(`🔄 Fetching page ${page} from ${url}...`);
|
|
98
|
+
const res = await fetch(url, {
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
Authorization: `Bearer ${this.serviceToken}`,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const errMsg = `refresh() failed on page ${page}: ${res.status} ${res.statusText}`;
|
|
106
|
+
this.log(`❌ ${errMsg}`);
|
|
107
|
+
throw new Error(errMsg);
|
|
108
|
+
}
|
|
109
|
+
const json = (await res.json());
|
|
110
|
+
for (const user of json.users) {
|
|
111
|
+
newById.set(user.id, user);
|
|
112
|
+
if (user.external_id != null) {
|
|
113
|
+
newByExtId.set(user.external_id, user.id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (json.next_page == null)
|
|
117
|
+
break;
|
|
118
|
+
page = json.next_page;
|
|
119
|
+
}
|
|
120
|
+
// Atomically swap the cache
|
|
121
|
+
this.byId = newById;
|
|
122
|
+
this.byExternalId = newByExtId;
|
|
123
|
+
this._lastRefreshed = new Date();
|
|
124
|
+
const elapsedMs = Date.now() - startMs;
|
|
125
|
+
this.log(`✅ Loaded ${newById.size} user(s), ${newByExtId.size} external ID mapping(s) in ${elapsedMs}ms`);
|
|
126
|
+
}
|
|
127
|
+
// ── Query Methods ─────────────────────────────────────────────────────────
|
|
128
|
+
/** Get a user profile by platform UUID. Returns undefined on cache miss. */
|
|
129
|
+
getUser(uuid) {
|
|
130
|
+
return this.byId.get(uuid);
|
|
131
|
+
}
|
|
132
|
+
/** Get a user profile by legacy external_id (INT). */
|
|
133
|
+
getUserByExternalId(extId) {
|
|
134
|
+
const uuid = this.byExternalId.get(extId);
|
|
135
|
+
return uuid ? this.byId.get(uuid) : undefined;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Resolve multiple UUIDs at once. Returns a Map of found profiles.
|
|
139
|
+
*
|
|
140
|
+
* By default, cache misses trigger an HTTP fallback to
|
|
141
|
+
* POST /api/internal/users/resolve on the Auth Service, which backfills
|
|
142
|
+
* the in-memory cache. Set `fetchMissing: false` to skip the HTTP call.
|
|
143
|
+
*/
|
|
144
|
+
async resolveMany(uuids, options) {
|
|
145
|
+
const result = new Map();
|
|
146
|
+
const missing = [];
|
|
147
|
+
for (const uuid of uuids) {
|
|
148
|
+
const cached = this.byId.get(uuid);
|
|
149
|
+
if (cached) {
|
|
150
|
+
result.set(uuid, cached);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
missing.push(uuid);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// HTTP fallback for cache misses
|
|
157
|
+
const shouldFetch = missing.length > 0 &&
|
|
158
|
+
options?.fetchMissing !== false &&
|
|
159
|
+
this.authServiceURL &&
|
|
160
|
+
this.serviceToken;
|
|
161
|
+
if (shouldFetch) {
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetch(`${this.authServiceURL}/api/internal/users/resolve`, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: {
|
|
166
|
+
'Content-Type': 'application/json',
|
|
167
|
+
Authorization: `Bearer ${this.serviceToken}`,
|
|
168
|
+
},
|
|
169
|
+
body: JSON.stringify({ ids: missing }),
|
|
170
|
+
});
|
|
171
|
+
if (res.ok) {
|
|
172
|
+
const json = (await res.json());
|
|
173
|
+
for (const [id, profile] of Object.entries(json.users)) {
|
|
174
|
+
result.set(id, profile);
|
|
175
|
+
// Backfill the in-memory cache so subsequent lookups are instant
|
|
176
|
+
this.byId.set(id, profile);
|
|
177
|
+
if (profile.external_id != null) {
|
|
178
|
+
this.byExternalId.set(profile.external_id, id);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.log(`🔄 Backfilled ${Object.keys(json.users).length} cache miss(es)`);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
this.log(`⚠️ HTTP fallback failed: ${res.status} ${res.statusText}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
this.log(`⚠️ HTTP fallback error: ${err.message}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
/** Check if a UUID exists in the cache. */
|
|
194
|
+
has(uuid) {
|
|
195
|
+
return this.byId.has(uuid);
|
|
196
|
+
}
|
|
197
|
+
// ── Stats ─────────────────────────────────────────────────────────────────
|
|
198
|
+
/** Cache statistics for health checks and monitoring. */
|
|
199
|
+
get stats() {
|
|
200
|
+
return {
|
|
201
|
+
userCount: this.byId.size,
|
|
202
|
+
externalIdCount: this.byExternalId.size,
|
|
203
|
+
lastRefreshed: this._lastRefreshed,
|
|
204
|
+
isRunning: this.timer !== null,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// ── Singleton ─────────────────────────────────────────────────────────────────
|
|
209
|
+
// Module-level singleton — import and use directly from route handlers.
|
|
210
|
+
export const userCache = new UserCache();
|
package/package.json
CHANGED