@newhomestar/sdk 0.8.14 → 0.8.17

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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The consolidated payload returned by the auth server's
3
+ * `POST /api/integrations/resolve` endpoint, deserialized for ergonomic use.
4
+ */
5
+ export interface ResolvedConnection {
6
+ /** UUID of the Nova `app_user_connections` row */
7
+ connectionId: string;
8
+ /** Integration slug, e.g. "jira" */
9
+ integrationSlug: string;
10
+ /** The external entity UUID this connection scope was resolved for */
11
+ remoteId: string;
12
+ /** External user id at the provider (e.g. Jira accountId), null if not captured */
13
+ externalUserId: string | null;
14
+ /** Decrypted OAuth access token — bearer for provider API calls */
15
+ accessToken: string;
16
+ /** Token expiry as a JS Date, or null if the provider didn't return one */
17
+ tokenExpiresAt: Date | null;
18
+ /** Wizard-saved per-account config (e.g. `{ webhookProjects: ["SUPPORT"] }`) */
19
+ config: Record<string, unknown>;
20
+ /** Whether the user completed all required wizard fields */
21
+ isComplete: boolean;
22
+ }
23
+ /** Options for resolveConnection() */
24
+ export interface ResolveConnectionOptions {
25
+ /** Integration slug (e.g. "jira", "bamboohr") */
26
+ slug: string;
27
+ /** External entity UUID (e.g. TicketingAccount.id, HrisCompany.id) */
28
+ remoteId: string;
29
+ /**
30
+ * Override the auth server URL. Defaults to `process.env.AUTH_ISSUER_BASE_URL`
31
+ * — the same env var used by `resolveCredentialsViaServiceToken`.
32
+ */
33
+ authUrl?: string;
34
+ /**
35
+ * Override the service token. Defaults to `process.env.NOVA_SERVICE_TOKEN`.
36
+ */
37
+ serviceToken?: string;
38
+ /**
39
+ * When true, bypass the in-memory cache and force a fresh round-trip.
40
+ */
41
+ forceRefresh?: boolean;
42
+ }
43
+ export declare class ConnectionResolutionError extends Error {
44
+ readonly status?: number | undefined;
45
+ readonly responseBody?: unknown | undefined;
46
+ constructor(message: string, status?: number | undefined, responseBody?: unknown | undefined);
47
+ }
48
+ export declare class ConfigNotFoundError extends ConnectionResolutionError {
49
+ constructor(slug: string, remoteId: string);
50
+ }
51
+ export declare class ConnectionTokenExpiredError extends ConnectionResolutionError {
52
+ constructor(slug: string, remoteId: string);
53
+ }
54
+ /**
55
+ * Resolve a Nova connection + access token + saved config from an external
56
+ * entity's `remote_id`.
57
+ *
58
+ * Typical use inside an outbound event handler:
59
+ *
60
+ * ```ts
61
+ * const conn = await resolveConnection({
62
+ * slug: "jira",
63
+ * remoteId: event.attributes.account,
64
+ * });
65
+ *
66
+ * await fetch(`${jiraBaseUrl}/issue`, {
67
+ * method: "POST",
68
+ * headers: { Authorization: `Bearer ${conn.accessToken}` },
69
+ * body: JSON.stringify({
70
+ * fields: { project: { key: conn.config.webhookProjects[0] }, ... },
71
+ * }),
72
+ * });
73
+ * ```
74
+ *
75
+ * Cache: results are memoized in-process for 30 minutes, evicted automatically
76
+ * when the token is within 5 minutes of expiry. On 401 from the provider API,
77
+ * call `invalidateConnection()` and retry to force a refresh.
78
+ */
79
+ export declare function resolveConnection(options: ResolveConnectionOptions): Promise<ResolvedConnection>;
80
+ /**
81
+ * Drop a single cached resolution. Use this after the provider returns 401
82
+ * (the cached token is invalid) before retrying.
83
+ */
84
+ export declare function invalidateConnection(params: {
85
+ slug: string;
86
+ remoteId: string;
87
+ }): void;
88
+ /**
89
+ * Drop all cached resolutions. Primarily useful in tests; production callers
90
+ * should prefer `invalidateConnection` for the specific scope that 401'd.
91
+ */
92
+ export declare function clearConnectionCache(): void;
@@ -0,0 +1,197 @@
1
+ // Nova SDK — Connection Resolution (`remote_id` → token + config)
2
+ // ================================================================
3
+ // One-shot lookup for outbound integration handlers that receive a platform
4
+ // event payload containing only an external entity id (e.g. a TicketingAccount
5
+ // UUID from `nova_ticketing_service.ticket_created.attributes.account`).
6
+ //
7
+ // Calls the auth server's `POST /api/integrations/resolve` endpoint and
8
+ // returns a single bundle containing:
9
+ // • the Nova connection that owns the remote scope,
10
+ // • the wizard-saved per-account config (e.g. Jira `webhookProjects`),
11
+ // • a valid access token (auth server auto-refreshes if expired).
12
+ //
13
+ // Authentication uses NOVA_SERVICE_TOKEN — intended for background /
14
+ // event-handler contexts that do not have an inbound user JWT.
15
+ //
16
+ // Caching: in-memory keyed `${slug}::${remoteId}` with a 30-minute TTL.
17
+ // Entries are evicted automatically when the access token is within 5 minutes
18
+ // of its expiry so the next call fetches a refreshed token.
19
+ //
20
+ // Public API:
21
+ // resolveConnection({ slug, remoteId, ... }) → ResolvedConnection
22
+ // invalidateConnection({ slug, remoteId }) → void
23
+ // clearConnectionCache() → void
24
+ // ─── Error Classes ──────────────────────────────────────────────────────────
25
+ export class ConnectionResolutionError extends Error {
26
+ status;
27
+ responseBody;
28
+ constructor(message, status, responseBody) {
29
+ super(message);
30
+ this.status = status;
31
+ this.responseBody = responseBody;
32
+ this.name = "ConnectionResolutionError";
33
+ }
34
+ }
35
+ export class ConfigNotFoundError extends ConnectionResolutionError {
36
+ constructor(slug, remoteId) {
37
+ super(`No saved config for integration "${slug}" with remote_id "${remoteId}". ` +
38
+ `The user must complete the connect wizard for this account before events can be processed.`, 404);
39
+ this.name = "ConfigNotFoundError";
40
+ }
41
+ }
42
+ export class ConnectionTokenExpiredError extends ConnectionResolutionError {
43
+ constructor(slug, remoteId) {
44
+ super(`Access token expired and could not be refreshed for "${slug}" / remote_id "${remoteId}". ` +
45
+ `User must re-connect.`, 401);
46
+ this.name = "ConnectionTokenExpiredError";
47
+ }
48
+ }
49
+ // ─── Cache ──────────────────────────────────────────────────────────────────
50
+ /** Max age of any cache entry, regardless of token expiry. 30 minutes. */
51
+ const CACHE_TTL_MS = 30 * 60 * 1000;
52
+ /** Re-fetch when the token is within this window of expiry. 5 minutes. */
53
+ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
54
+ const _cache = new Map();
55
+ function cacheKey(slug, remoteId) {
56
+ return `${slug.toLowerCase()}::${remoteId}`;
57
+ }
58
+ function isCacheEntryFresh(entry) {
59
+ const now = Date.now();
60
+ // Hard TTL cap
61
+ if (now - entry.cachedAt > CACHE_TTL_MS)
62
+ return false;
63
+ // Token-expiry buffer
64
+ const expiresAt = entry.value.tokenExpiresAt;
65
+ if (expiresAt && expiresAt.getTime() - now < TOKEN_REFRESH_BUFFER_MS) {
66
+ return false;
67
+ }
68
+ return true;
69
+ }
70
+ // ─── Public API ─────────────────────────────────────────────────────────────
71
+ /**
72
+ * Resolve a Nova connection + access token + saved config from an external
73
+ * entity's `remote_id`.
74
+ *
75
+ * Typical use inside an outbound event handler:
76
+ *
77
+ * ```ts
78
+ * const conn = await resolveConnection({
79
+ * slug: "jira",
80
+ * remoteId: event.attributes.account,
81
+ * });
82
+ *
83
+ * await fetch(`${jiraBaseUrl}/issue`, {
84
+ * method: "POST",
85
+ * headers: { Authorization: `Bearer ${conn.accessToken}` },
86
+ * body: JSON.stringify({
87
+ * fields: { project: { key: conn.config.webhookProjects[0] }, ... },
88
+ * }),
89
+ * });
90
+ * ```
91
+ *
92
+ * Cache: results are memoized in-process for 30 minutes, evicted automatically
93
+ * when the token is within 5 minutes of expiry. On 401 from the provider API,
94
+ * call `invalidateConnection()` and retry to force a refresh.
95
+ */
96
+ export async function resolveConnection(options) {
97
+ const { slug, remoteId, forceRefresh = false } = options;
98
+ const authUrl = options.authUrl ?? process.env.AUTH_ISSUER_BASE_URL;
99
+ const serviceToken = options.serviceToken ?? process.env.NOVA_SERVICE_TOKEN;
100
+ if (!authUrl) {
101
+ throw new ConnectionResolutionError(`[nova-sdk] resolveConnection("${slug}"): AUTH_ISSUER_BASE_URL is not set. ` +
102
+ `Pass options.authUrl or set the environment variable.`);
103
+ }
104
+ if (!serviceToken) {
105
+ throw new ConnectionResolutionError(`[nova-sdk] resolveConnection("${slug}"): NOVA_SERVICE_TOKEN is not set. ` +
106
+ `Pass options.serviceToken or set the environment variable.`);
107
+ }
108
+ // ── Cache lookup ────────────────────────────────────────────────────
109
+ const key = cacheKey(slug, remoteId);
110
+ if (!forceRefresh) {
111
+ const entry = _cache.get(key);
112
+ if (entry && isCacheEntryFresh(entry)) {
113
+ return entry.value;
114
+ }
115
+ if (entry) {
116
+ console.log(`[nova-sdk] 🗑️ resolveConnection cache stale for ${key} — refetching`);
117
+ _cache.delete(key);
118
+ }
119
+ }
120
+ else {
121
+ _cache.delete(key);
122
+ }
123
+ // ── HTTP call ───────────────────────────────────────────────────────
124
+ const endpoint = `${authUrl.replace(/\/+$/, "")}/api/integrations/resolve`;
125
+ console.log(`[nova-sdk] 🌐 resolveConnection: POST ${endpoint} slug="${slug}" remote_id=${remoteId}`);
126
+ let res;
127
+ try {
128
+ res = await fetch(endpoint, {
129
+ method: "POST",
130
+ headers: {
131
+ "Content-Type": "application/json",
132
+ Accept: "application/json",
133
+ Authorization: `Bearer ${serviceToken}`,
134
+ },
135
+ body: JSON.stringify({
136
+ integration_slug: slug,
137
+ remote_id: remoteId,
138
+ }),
139
+ });
140
+ }
141
+ catch (err) {
142
+ throw new ConnectionResolutionError(`[nova-sdk] resolveConnection: network error calling ${endpoint}: ${err?.message ?? err}`);
143
+ }
144
+ if (!res.ok) {
145
+ let body = null;
146
+ try {
147
+ body = await res.json();
148
+ }
149
+ catch {
150
+ /* swallow parse errors — body may be empty */
151
+ }
152
+ if (res.status === 404 && body?.error === "config_not_found") {
153
+ throw new ConfigNotFoundError(slug, remoteId);
154
+ }
155
+ if (res.status === 401 && body?.error === "token_expired") {
156
+ throw new ConnectionTokenExpiredError(slug, remoteId);
157
+ }
158
+ const msg = body?.message ??
159
+ body?.error ??
160
+ `HTTP ${res.status} from ${endpoint}`;
161
+ throw new ConnectionResolutionError(`[nova-sdk] resolveConnection failed: ${msg}`, res.status, body);
162
+ }
163
+ const data = (await res.json());
164
+ const resolved = {
165
+ connectionId: data.connection_id,
166
+ integrationSlug: data.integration_slug,
167
+ remoteId: data.remote_id,
168
+ externalUserId: data.external_user_id,
169
+ accessToken: data.access_token,
170
+ tokenExpiresAt: data.token_expires_at ? new Date(data.token_expires_at) : null,
171
+ config: data.config ?? {},
172
+ isComplete: data.is_complete,
173
+ };
174
+ console.log(`[nova-sdk] ✅ resolveConnection: cached ${key} (token expires ${resolved.tokenExpiresAt?.toISOString() ?? "n/a"})`);
175
+ _cache.set(key, { value: resolved, cachedAt: Date.now() });
176
+ return resolved;
177
+ }
178
+ /**
179
+ * Drop a single cached resolution. Use this after the provider returns 401
180
+ * (the cached token is invalid) before retrying.
181
+ */
182
+ export function invalidateConnection(params) {
183
+ const key = cacheKey(params.slug, params.remoteId);
184
+ const existed = _cache.delete(key);
185
+ if (existed) {
186
+ console.log(`[nova-sdk] 🗑️ invalidateConnection: cleared ${key}`);
187
+ }
188
+ }
189
+ /**
190
+ * Drop all cached resolutions. Primarily useful in tests; production callers
191
+ * should prefer `invalidateConnection` for the specific scope that 401'd.
192
+ */
193
+ export function clearConnectionCache() {
194
+ const size = _cache.size;
195
+ _cache.clear();
196
+ console.log(`[nova-sdk] 🗑️ clearConnectionCache: cleared ${size} entries`);
197
+ }
@@ -190,6 +190,54 @@ async function performTokenExchange(slug, params) {
190
190
  async function fetchCredentialsFromAuthServer(authBaseUrl, slug, bearerToken, forceRefresh = false) {
191
191
  const url = `${authBaseUrl}/api/integrations/${encodeURIComponent(slug)}/credentials`;
192
192
  console.log(`[nova-sdk] 🌐 Fetching credentials via HTTP: GET ${url}${forceRefresh ? " (force-refresh)" : ""}`);
193
+ // ── Outgoing bearer-token preview ──────────────────────────────────────────
194
+ // We log the JWT's structural claims (NOT the signature, NOT secrets) so we
195
+ // can correlate the SDK's outgoing token against what the auth server says
196
+ // it received on the other side. This is invaluable when diagnosing 401s
197
+ // from `resolveCredentialsViaServiceToken` (the relay path).
198
+ try {
199
+ const parts = bearerToken.split(".");
200
+ if (parts.length === 3) {
201
+ // base64url → JSON. atob handles base64; we normalize url-safe chars first.
202
+ const b64urlDecode = (s) => {
203
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
204
+ // Pad to a multiple of 4
205
+ const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
206
+ if (typeof Buffer !== "undefined") {
207
+ return Buffer.from(padded, "base64").toString("utf8");
208
+ }
209
+ // Browser-safe fallback
210
+ // eslint-disable-next-line no-undef
211
+ return decodeURIComponent(escape(atob(padded)));
212
+ };
213
+ const header = JSON.parse(b64urlDecode(parts[0]));
214
+ const payload = JSON.parse(b64urlDecode(parts[1]));
215
+ console.log(`[nova-sdk] 🪪 Outgoing bearer header:`, {
216
+ alg: header.alg,
217
+ typ: header.typ,
218
+ kid: header.kid,
219
+ });
220
+ console.log(`[nova-sdk] 🪪 Outgoing bearer payload preview:`, {
221
+ iss: payload.iss,
222
+ aud: payload.aud,
223
+ sub: payload.sub,
224
+ client_id: payload.client_id,
225
+ scope: payload.scope,
226
+ service_name: payload.service_name,
227
+ exp: payload.exp,
228
+ iat: payload.iat,
229
+ secondsUntilExp: typeof payload.exp === "number"
230
+ ? payload.exp - Math.floor(Date.now() / 1000)
231
+ : null,
232
+ });
233
+ }
234
+ else {
235
+ console.log(`[nova-sdk] 🪪 Outgoing bearer is NOT a 3-part JWT (parts=${parts.length}, length=${bearerToken.length}). This is expected if the SDK is using INTERNAL_API_SECRET as a service token.`);
236
+ }
237
+ }
238
+ catch (decodeErr) {
239
+ console.warn(`[nova-sdk] ⚠️ Failed to decode outgoing bearer for preview:`, decodeErr instanceof Error ? decodeErr.message : String(decodeErr));
240
+ }
193
241
  const headers = {
194
242
  Authorization: `Bearer ${bearerToken}`,
195
243
  Accept: "application/json",
@@ -770,8 +770,8 @@ export declare const IntegrationDefSchema: z.ZodObject<{
770
770
  slug: z.ZodString;
771
771
  name: z.ZodString;
772
772
  httpMethod: z.ZodEnum<{
773
- GET: "GET";
774
773
  POST: "POST";
774
+ GET: "GET";
775
775
  PATCH: "PATCH";
776
776
  DELETE: "DELETE";
777
777
  PUT: "PUT";
@@ -160,8 +160,8 @@ export declare const IntegrationSpecSchema: z.ZodObject<{
160
160
  slug: z.ZodString;
161
161
  name: z.ZodString;
162
162
  httpMethod: z.ZodEnum<{
163
- GET: "GET";
164
163
  POST: "POST";
164
+ GET: "GET";
165
165
  PATCH: "PATCH";
166
166
  DELETE: "DELETE";
167
167
  PUT: "PUT";
@@ -27,8 +27,8 @@ export declare const WorkerDefSchema: z.ZodObject<{
27
27
  input: z.ZodAny;
28
28
  output: z.ZodAny;
29
29
  method: z.ZodOptional<z.ZodEnum<{
30
- GET: "GET";
31
30
  POST: "POST";
31
+ GET: "GET";
32
32
  PATCH: "PATCH";
33
33
  DELETE: "DELETE";
34
34
  PUT: "PUT";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newhomestar/sdk",
3
- "version": "0.8.14",
3
+ "version": "0.8.17",
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": {
@@ -27,6 +27,10 @@
27
27
  "./events": {
28
28
  "import": "./dist/events.js",
29
29
  "types": "./dist/events.d.ts"
30
+ },
31
+ "./connections": {
32
+ "import": "./dist/connections.js",
33
+ "types": "./dist/connections.d.ts"
30
34
  }
31
35
  },
32
36
  "files": [