@newhomestar/sdk 0.7.26 → 0.8.0

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
@@ -124,9 +124,8 @@ export interface NovaFgaCheckDef {
124
124
  * Where to resolve object_id from in the request:
125
125
  * 'path' → Next.js route params (e.g. params.id for /communities/:id)
126
126
  * 'query' → URL query string param
127
- * 'body' → JSON request body (for POST/PATCH endpoints where the id is in the body)
128
127
  */
129
- object_id_from: 'path' | 'query' | 'body';
128
+ object_id_from: 'path' | 'query';
130
129
  /** The param name to extract object_id from (e.g. 'id', 'community_id') */
131
130
  object_id_param: string;
132
131
  /** Optional tenant_id param name for multi-tenant scoped checks */
package/package.json CHANGED
@@ -1,58 +1,58 @@
1
- {
2
- "name": "@newhomestar/sdk",
3
- "version": "0.7.26",
4
- "description": "Type-safe SDK for building Nova pipelines (workers & functions)",
5
- "homepage": "https://github.com/newhomestar/nova-node-sdk#readme",
6
- "bugs": {
7
- "url": "https://github.com/newhomestar/nova-node-sdk/issues"
8
- },
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/newhomestar/nova-node-sdk.git"
12
- },
13
- "license": "ISC",
14
- "author": "Christian Gomez",
15
- "type": "module",
16
- "main": "dist/index.js",
17
- "types": "dist/index.d.ts",
18
- "exports": {
19
- ".": {
20
- "import": "./dist/index.js",
21
- "types": "./dist/index.d.ts"
22
- },
23
- "./next": {
24
- "import": "./dist/next.js",
25
- "types": "./dist/next.d.ts"
26
- },
27
- "./events": {
28
- "import": "./dist/events.js",
29
- "types": "./dist/events.d.ts"
30
- }
31
- },
32
- "files": [
33
- "dist"
34
- ],
35
- "scripts": {
36
- "build": "tsc"
37
- },
38
- "dependencies": {
39
- "@openfga/sdk": "^0.9.0",
40
- "@orpc/openapi": "1.7.4",
41
- "@orpc/server": "1.7.4",
42
- "@supabase/supabase-js": "^2.39.0",
43
- "body-parser": "^1.20.2",
44
- "dotenv": "^16.4.3",
45
- "express": "^4.18.2",
46
- "express-oauth2-jwt-bearer": "^1.7.4",
47
- "undici": "^7.24.4",
48
- "yaml": "^2.7.1"
49
- },
50
- "peerDependencies": {
51
- "zod": ">=4.0.0"
52
- },
53
- "devDependencies": {
54
- "@types/node": "^20.11.17",
55
- "typescript": "^5.4.4",
56
- "zod": "^4.3.0"
57
- }
58
- }
1
+ {
2
+ "name": "@newhomestar/sdk",
3
+ "version": "0.8.0",
4
+ "description": "Type-safe SDK for building Nova pipelines (workers & functions)",
5
+ "homepage": "https://github.com/newhomestar/nova-node-sdk#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/newhomestar/nova-node-sdk/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/newhomestar/nova-node-sdk.git"
12
+ },
13
+ "license": "ISC",
14
+ "author": "Christian Gomez",
15
+ "type": "module",
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "import": "./dist/index.js",
21
+ "types": "./dist/index.d.ts"
22
+ },
23
+ "./next": {
24
+ "import": "./dist/next.js",
25
+ "types": "./dist/next.d.ts"
26
+ },
27
+ "./events": {
28
+ "import": "./dist/events.js",
29
+ "types": "./dist/events.d.ts"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsc"
37
+ },
38
+ "dependencies": {
39
+ "@openfga/sdk": "^0.9.0",
40
+ "@orpc/openapi": "1.7.4",
41
+ "@orpc/server": "1.7.4",
42
+ "@supabase/supabase-js": "^2.39.0",
43
+ "body-parser": "^1.20.2",
44
+ "dotenv": "^16.4.3",
45
+ "express": "^4.18.2",
46
+ "express-oauth2-jwt-bearer": "^1.7.4",
47
+ "undici": "^7.24.4",
48
+ "yaml": "^2.7.1"
49
+ },
50
+ "peerDependencies": {
51
+ "zod": ">=4.0.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.11.17",
55
+ "typescript": "^5.4.4",
56
+ "zod": "^4.3.0"
57
+ }
58
+ }