@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.
- package/dist/connections.d.ts +92 -0
- package/dist/connections.js +197 -0
- package/dist/credentials.js +48 -0
- package/dist/integration.d.ts +1 -1
- package/dist/integrationSpec.d.ts +1 -1
- package/dist/workerSchema.d.ts +1 -1
- package/package.json +5 -1
|
@@ -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
|
+
}
|
package/dist/credentials.js
CHANGED
|
@@ -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",
|
package/dist/integration.d.ts
CHANGED
package/dist/workerSchema.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@newhomestar/sdk",
|
|
3
|
-
"version": "0.8.
|
|
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": [
|