@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.
@@ -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;
@@ -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
- console.log(`[nova-sdk] 📥 mTLS response: HTTP ${res.statusCode} for ${options.method ?? "GET"} ${url}`);
348
- resolve(new Response(bodyStr, {
349
- status: res.statusCode ?? 500,
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, detectCredentialStrategy, clearTokenCache, integrationFetch, emitPlatformEvent,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newhomestar/sdk",
3
- "version": "0.7.26",
3
+ "version": "0.8.1",
4
4
  "description": "Type-safe SDK for building Nova pipelines (workers & functions)",
5
5
  "homepage": "https://github.com/newhomestar/nova-node-sdk#readme",
6
6
  "bugs": {