@newhomestar/sdk 0.8.6 → 0.8.7
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/README.md +1187 -1187
- package/dist/next.d.ts +17 -0
- package/package.json +58 -58
package/README.md
CHANGED
|
@@ -1,1187 +1,1187 @@
|
|
|
1
|
-
# @newhomestar/sdk
|
|
2
|
-
|
|
3
|
-
> Type-safe SDK for building Nova integrations, workers, and services.
|
|
4
|
-
> Code-first: TypeScript + Zod is the **single source of truth**.
|
|
5
|
-
> Build: `nova integrations build` → `dist/` + `nova-integration.yaml` + JSON schemas.
|
|
6
|
-
> Push: `nova integrations push` → Docker image + full config sync to platform DB.
|
|
7
|
-
|
|
8
|
-
**Version:** 0.8.3
|
|
9
|
-
**Runtime:** Node 20 + esbuild CJS bundle
|
|
10
|
-
**Schema system:** Zod v4 → JSON Schema at build time
|
|
11
|
-
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
## Table of Contents
|
|
15
|
-
|
|
16
|
-
- [Installation](#installation)
|
|
17
|
-
- [Quick Start](#quick-start)
|
|
18
|
-
- [Export Paths](#export-paths)
|
|
19
|
-
- [Core Concepts](#core-concepts)
|
|
20
|
-
- [`defineIntegration()`](#defineintegration)
|
|
21
|
-
- [`schema()`](#schema)
|
|
22
|
-
- [`event()`](#event)
|
|
23
|
-
- [`action()`](#action)
|
|
24
|
-
- [`ActionCtx`](#actionctx)
|
|
25
|
-
- [Running the Integration](#running-the-integration)
|
|
26
|
-
- [`runHttpServer()`](#runhttpserver)
|
|
27
|
-
- [`runDualMode()`](#rundualmode)
|
|
28
|
-
- [`runWorker()`](#runworker)
|
|
29
|
-
- [Which runtime mode to use](#which-runtime-mode-to-use)
|
|
30
|
-
- [Events System (`@newhomestar/sdk/events`)](#events-system)
|
|
31
|
-
- [Outbound Events (Producer)](#outbound-events-producer)
|
|
32
|
-
- [Inbound Events (Consumer)](#inbound-events-consumer)
|
|
33
|
-
- [Webhook ACID Processing](#webhook-acid-processing)
|
|
34
|
-
- [Outbox Relay](#outbox-relay)
|
|
35
|
-
- [Echo Loop Prevention](#echo-loop-prevention)
|
|
36
|
-
- [Topic Format Convention](#topic-format-convention)
|
|
37
|
-
- [Credential Resolution](#credential-resolution)
|
|
38
|
-
- [HTTP Callback Strategy](#http-callback-strategy)
|
|
39
|
-
- [`ctx.resolveCredentials()` and `ctx.fetch()`](#ctxresolvecredentials-and-ctxfetch)
|
|
40
|
-
- [`createIntegrationClient()`](#createintegrationclient)
|
|
41
|
-
- [`emitPlatformEvent()`](#emitplatformevent)
|
|
42
|
-
- [Parameter Metadata (`ParamMeta`)](#parameter-metadata-parammeta)
|
|
43
|
-
- [Sync Mappings](#sync-mappings)
|
|
44
|
-
- [Webhook Configuration](#webhook-configuration)
|
|
45
|
-
- [Dual Database Pattern](#dual-database-pattern)
|
|
46
|
-
- [`nova-integration.yaml` Spec](#nova-integrationyaml-spec)
|
|
47
|
-
- [CLI Reference (`nova integrations`)](#cli-reference)
|
|
48
|
-
- [`nova integrations new`](#nova-integrations-new)
|
|
49
|
-
- [`nova integrations build`](#nova-integrations-build)
|
|
50
|
-
- [`nova integrations push`](#nova-integrations-push)
|
|
51
|
-
- [Environment Variables](#environment-variables)
|
|
52
|
-
- [Validation Rules](#validation-rules)
|
|
53
|
-
|
|
54
|
-
---
|
|
55
|
-
|
|
56
|
-
## Installation
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
yarn add @newhomestar/sdk zod
|
|
60
|
-
# or
|
|
61
|
-
npm install @newhomestar/sdk zod
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Peer dependency: `zod >= 4.0.0`
|
|
65
|
-
|
|
66
|
-
---
|
|
67
|
-
|
|
68
|
-
## Quick Start
|
|
69
|
-
|
|
70
|
-
```typescript
|
|
71
|
-
import { defineIntegration, schema, event, action, runHttpServer } from "@newhomestar/sdk";
|
|
72
|
-
import { z } from "zod";
|
|
73
|
-
|
|
74
|
-
const Employee = schema("entity", z.object({
|
|
75
|
-
id: z.string(),
|
|
76
|
-
email: z.string().email(),
|
|
77
|
-
name: z.string(),
|
|
78
|
-
}));
|
|
79
|
-
|
|
80
|
-
const integration = defineIntegration({
|
|
81
|
-
slug: "my_provider",
|
|
82
|
-
name: "My Provider",
|
|
83
|
-
integrationType: "oauth2",
|
|
84
|
-
queue: "my_provider_queue",
|
|
85
|
-
baseUrl: "https://api.provider.com",
|
|
86
|
-
authorizationEndpoint: "https://provider.com/authorize",
|
|
87
|
-
tokenEndpoint: "https://provider.com/token",
|
|
88
|
-
scopes: ["openid", "profile"],
|
|
89
|
-
|
|
90
|
-
schemas: { employee: Employee },
|
|
91
|
-
events: {
|
|
92
|
-
employee_synced: event("outbound", { payload: Employee, category: "sync" }),
|
|
93
|
-
},
|
|
94
|
-
actions: {
|
|
95
|
-
health: action({
|
|
96
|
-
method: "GET", path: "/health",
|
|
97
|
-
input: z.object({}),
|
|
98
|
-
output: z.object({ ok: z.boolean() }),
|
|
99
|
-
handler: async () => ({ ok: true }),
|
|
100
|
-
}),
|
|
101
|
-
listEmployees: action({
|
|
102
|
-
method: "GET", path: "/employees",
|
|
103
|
-
scopes: ["employee:read"],
|
|
104
|
-
input: z.object({ limit: z.number().default(100) }),
|
|
105
|
-
output: z.object({ employees: z.array(z.any()), total: z.number() }),
|
|
106
|
-
handler: async (input, ctx) => {
|
|
107
|
-
const creds = await ctx.resolveCredentials();
|
|
108
|
-
const res = await ctx.fetch("https://api.provider.com/employees");
|
|
109
|
-
const data = await res.json();
|
|
110
|
-
return { employees: data.items, total: data.total };
|
|
111
|
-
},
|
|
112
|
-
}),
|
|
113
|
-
},
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
export default integration;
|
|
117
|
-
|
|
118
|
-
// Runtime
|
|
119
|
-
runHttpServer(integration as any, { port: 8000 });
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
---
|
|
123
|
-
|
|
124
|
-
## Export Paths
|
|
125
|
-
|
|
126
|
-
The SDK exposes three subpath exports:
|
|
127
|
-
|
|
128
|
-
| Import Path | Description |
|
|
129
|
-
|---|---|
|
|
130
|
-
| `@newhomestar/sdk` | Core: `defineIntegration`, `action`, `schema`, `event`, `runHttpServer`, `runDualMode`, `runWorker`, credential helpers |
|
|
131
|
-
| `@newhomestar/sdk/events` | Events system: `withServiceEventOutbox`, `startPollConsumer`, `startInboundConsumer`, `queueEvent`, `logEvent`, `isIntegrationSync` |
|
|
132
|
-
| `@newhomestar/sdk/next` | Next.js helpers (for service projects) |
|
|
133
|
-
|
|
134
|
-
---
|
|
135
|
-
|
|
136
|
-
## Core Concepts
|
|
137
|
-
|
|
138
|
-
### `defineIntegration()`
|
|
139
|
-
|
|
140
|
-
The central function that registers an integration definition — the **single source of truth** for both the containerized runtime AND the platform configuration.
|
|
141
|
-
|
|
142
|
-
```typescript
|
|
143
|
-
import { defineIntegration } from "@newhomestar/sdk";
|
|
144
|
-
|
|
145
|
-
export default defineIntegration({
|
|
146
|
-
// ── Identity ──
|
|
147
|
-
slug: "bamboohr", // snake_case, unique across platform
|
|
148
|
-
name: "BambooHR",
|
|
149
|
-
displayName: "BambooHR HRIS", // shown in admin UI
|
|
150
|
-
description: "...",
|
|
151
|
-
|
|
152
|
-
// ── Classification ──
|
|
153
|
-
integrationType: "oauth2", // "oidc" | "oauth2" | "api_key"
|
|
154
|
-
category: "hris",
|
|
155
|
-
tags: ["hr", "employee-sync"],
|
|
156
|
-
|
|
157
|
-
// ── Branding ──
|
|
158
|
-
logoUrl: "https://...",
|
|
159
|
-
color: "#73C41D",
|
|
160
|
-
icon: "https://...",
|
|
161
|
-
|
|
162
|
-
// ── OAuth/OIDC Endpoints ──
|
|
163
|
-
authorizationEndpoint: "https://...",
|
|
164
|
-
tokenEndpoint: "https://...",
|
|
165
|
-
userinfoEndpoint: "https://...", // optional
|
|
166
|
-
revocationEndpoint: "https://...", // optional
|
|
167
|
-
jwksUri: "https://...", // OIDC only
|
|
168
|
-
baseUrl: "https://api.bamboohr.com/",
|
|
169
|
-
scopes: ["openid", "profile"],
|
|
170
|
-
|
|
171
|
-
// ── Container Runtime ──
|
|
172
|
-
queue: "bamboohr_queue",
|
|
173
|
-
resources: { cpu: "500m", memory: "512Mi" },
|
|
174
|
-
envSpec: [
|
|
175
|
-
{ name: "NOVA_EVENTS_SERVICE_URL", secret: false },
|
|
176
|
-
{ name: "NOVA_SERVICE_TOKEN", secret: true },
|
|
177
|
-
// ...
|
|
178
|
-
],
|
|
179
|
-
|
|
180
|
-
// ── Configuration ──
|
|
181
|
-
schemas: { /* ... */ },
|
|
182
|
-
events: { /* ... */ },
|
|
183
|
-
actions: { /* ... */ },
|
|
184
|
-
syncMappings: { /* ... */ }, // optional
|
|
185
|
-
webhooks: { /* ... */ }, // optional
|
|
186
|
-
});
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
#### 7-Phase Normalization Pipeline
|
|
190
|
-
|
|
191
|
-
When `defineIntegration()` is called, it runs these phases in order:
|
|
192
|
-
|
|
193
|
-
| Phase | Description |
|
|
194
|
-
|---|---|
|
|
195
|
-
| **1. Normalize schemas** | Fills `slug` and `name` on lean `schema()` results from their dictionary key (e.g., key `employee` → slug: `"employee"`, name: `"Employee"`) |
|
|
196
|
-
| **2. Normalize events** | Fills `slug`/`name` on lean `event()` results; resolves direct payload schema object references → string slugs for `payloadSchema` |
|
|
197
|
-
| **3. Auto-extract functions** | Every action is promoted to an `IntegrationFunctionDef`. HTTP method, path, scopes, capabilities are copied. `__inputZod`, `__outputZod`, `__paramsMeta` stored as non-enumerable props for the build step to convert to JSON Schema |
|
|
198
|
-
| **4. Zod validation** | Runs `IntegrationDefSchema.parse(def)` — structural validation of all fields |
|
|
199
|
-
| **5. Cross-validate event schemas** | Checks that every `event.payloadSchema` string reference exists in `schemas` |
|
|
200
|
-
| **6. Cross-validate function schemas** | Checks that `requestSchema` / `responseSchema` references exist in `schemas` |
|
|
201
|
-
| **7. Validate webhook handler** | Checks that `webhooks.handler` references an existing action |
|
|
202
|
-
|
|
203
|
-
#### `IntegrationDef` Field Reference
|
|
204
|
-
|
|
205
|
-
| Field | Required | Type | Description |
|
|
206
|
-
|---|---|---|---|
|
|
207
|
-
| `slug` | ✅ | `string` | Unique snake_case identifier (e.g., `"bamboohr"`) |
|
|
208
|
-
| `name` | ✅ | `string` | Human-readable name |
|
|
209
|
-
| `integrationType` | ✅ | `"oidc" \| "oauth2" \| "api_key"` | Auth type |
|
|
210
|
-
| `queue` | ✅ | `string` | PGMQ queue name for async processing |
|
|
211
|
-
| `baseUrl` | ✅ | `string` | Base URL for external API calls |
|
|
212
|
-
| `actions` | ✅ | `Record<string, ActionDef>` | Runtime action handlers |
|
|
213
|
-
| `schemas` | ✅ | `Record<string, IntegrationSchemaDef>` | Zod schemas → JSON Schema on build |
|
|
214
|
-
| `events` | ✅ | `Record<string, IntegrationEventDef>` | Event definitions |
|
|
215
|
-
| `description` | | `string` | Short description |
|
|
216
|
-
| `category` | | `string` | Grouping (e.g., `"hris"`, `"crm"`) |
|
|
217
|
-
| `logoUrl` | | `string` | Logo for admin dashboard |
|
|
218
|
-
| `color` | | `string` | Brand color hex code |
|
|
219
|
-
| `displayName` | | `string` | Admin UI display name |
|
|
220
|
-
| `tags` | | `string[]` | Discovery/filtering tags |
|
|
221
|
-
| `authorizationEndpoint` | OAuth2/OIDC | `string` | OAuth authorize URL |
|
|
222
|
-
| `tokenEndpoint` | OAuth2/OIDC | `string` | OAuth token URL |
|
|
223
|
-
| `userinfoEndpoint` | | `string` | OIDC userinfo URL |
|
|
224
|
-
| `revocationEndpoint` | | `string` | Token revocation URL |
|
|
225
|
-
| `jwksUri` | OIDC recommended | `string` | JWKS URI for token verification |
|
|
226
|
-
| `scopes` | | `string[]` | OAuth scopes to request |
|
|
227
|
-
| `resources` | | `{ cpu, memory }` | Container resource limits |
|
|
228
|
-
| `envSpec` | | `Array<{ name, secret, default? }>` | Environment variable spec |
|
|
229
|
-
| `syncMappings` | | `Record<string, SyncMappingDef>` | Field mapping rules |
|
|
230
|
-
| `webhooks` | | `WebhookConfig` | Inbound webhook types |
|
|
231
|
-
| `functions` | Auto-generated | `Record<string, IntegrationFunctionDef>` | Auto-extracted from actions (Phase 3) |
|
|
232
|
-
|
|
233
|
-
---
|
|
234
|
-
|
|
235
|
-
### `schema()`
|
|
236
|
-
|
|
237
|
-
Lean helper to define Zod schemas. Slug and name are inferred from the key in `schemas: {}`.
|
|
238
|
-
|
|
239
|
-
```typescript
|
|
240
|
-
import { schema } from "@newhomestar/sdk";
|
|
241
|
-
import { z } from "zod";
|
|
242
|
-
|
|
243
|
-
// slug = "employee", name = "Employee", schemaType = "entity"
|
|
244
|
-
const Employee = schema("entity", z.object({
|
|
245
|
-
id: z.string(),
|
|
246
|
-
email: z.string().email(),
|
|
247
|
-
firstName: z.string(),
|
|
248
|
-
lastName: z.string(),
|
|
249
|
-
status: z.enum(["active", "inactive", "terminated"]),
|
|
250
|
-
}), {
|
|
251
|
-
version: "1.0.0",
|
|
252
|
-
description: "BambooHR employee record",
|
|
253
|
-
});
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
#### Schema Types
|
|
257
|
-
|
|
258
|
-
| Type | Use for |
|
|
259
|
-
|---|---|
|
|
260
|
-
| `entity` | Domain objects (employees, contacts, issues) |
|
|
261
|
-
| `request` | API request body shapes |
|
|
262
|
-
| `response` | API response shapes |
|
|
263
|
-
| `webhook_payload` | Inbound webhook payload shapes |
|
|
264
|
-
| `configuration` | Integration settings/config |
|
|
265
|
-
|
|
266
|
-
#### Verbose Alternative
|
|
267
|
-
|
|
268
|
-
```typescript
|
|
269
|
-
import { integrationSchema } from "@newhomestar/sdk";
|
|
270
|
-
|
|
271
|
-
const Employee = integrationSchema({
|
|
272
|
-
name: "Employee",
|
|
273
|
-
slug: "employee",
|
|
274
|
-
schemaType: "entity",
|
|
275
|
-
schema: z.object({ /* ... */ }),
|
|
276
|
-
version: "1.0.0",
|
|
277
|
-
});
|
|
278
|
-
```
|
|
279
|
-
|
|
280
|
-
---
|
|
281
|
-
|
|
282
|
-
### `event()`
|
|
283
|
-
|
|
284
|
-
Lean helper to define integration events. Slug and name are inferred from the key.
|
|
285
|
-
|
|
286
|
-
```typescript
|
|
287
|
-
import { event } from "@newhomestar/sdk";
|
|
288
|
-
|
|
289
|
-
const events = {
|
|
290
|
-
// slug = "employee_synced", name = "Employee Synced"
|
|
291
|
-
employee_synced: event("outbound", {
|
|
292
|
-
payload: Employee, // direct ref to schema() result (type-safe!)
|
|
293
|
-
category: "sync",
|
|
294
|
-
severity: "info", // "info" | "warning" | "error" | "critical"
|
|
295
|
-
}),
|
|
296
|
-
|
|
297
|
-
webhook_received: event("inbound", {
|
|
298
|
-
payload: WebhookPayload,
|
|
299
|
-
category: "webhook",
|
|
300
|
-
}),
|
|
301
|
-
|
|
302
|
-
sync_failed: event("outbound", {
|
|
303
|
-
severity: "error",
|
|
304
|
-
category: "sync",
|
|
305
|
-
}),
|
|
306
|
-
};
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
#### Event Directions
|
|
310
|
-
|
|
311
|
-
| Direction | Meaning |
|
|
312
|
-
|---|---|
|
|
313
|
-
| `outbound` | Integration → Platform (e.g., sync completed, data changed) |
|
|
314
|
-
| `inbound` | Platform → Integration (e.g., webhook received) |
|
|
315
|
-
| `bidirectional` | Both directions |
|
|
316
|
-
|
|
317
|
-
Outbound events are **auto-registered** in the platform `event_types` table when you run `nova integrations push`.
|
|
318
|
-
|
|
319
|
-
---
|
|
320
|
-
|
|
321
|
-
### `action()`
|
|
322
|
-
|
|
323
|
-
The universal action builder. Every action is also auto-registered as a function in the platform DB.
|
|
324
|
-
|
|
325
|
-
```typescript
|
|
326
|
-
import { action } from "@newhomestar/sdk";
|
|
327
|
-
import { z } from "zod";
|
|
328
|
-
|
|
329
|
-
const syncEmployees = action({
|
|
330
|
-
// ── HTTP Routing ──
|
|
331
|
-
method: "POST", // GET | POST | PUT | DELETE | PATCH
|
|
332
|
-
path: "/employees/sync",
|
|
333
|
-
|
|
334
|
-
// ── Zod I/O ──
|
|
335
|
-
input: z.object({
|
|
336
|
-
tenantId: z.string().uuid(),
|
|
337
|
-
since: z.string().datetime().optional(),
|
|
338
|
-
}),
|
|
339
|
-
output: z.object({
|
|
340
|
-
synced: z.number(),
|
|
341
|
-
errors: z.number(),
|
|
342
|
-
}),
|
|
343
|
-
|
|
344
|
-
// ── Function Metadata ──
|
|
345
|
-
name: "syncEmployees", // optional, defaults to key name
|
|
346
|
-
description: "Full employee sync",
|
|
347
|
-
scopes: ["employee:read"], // presence → auto-register as function
|
|
348
|
-
category: "employees",
|
|
349
|
-
|
|
350
|
-
// ── Parameter Metadata (for admin UI form builder) ──
|
|
351
|
-
params: {
|
|
352
|
-
tenantId: { in: "body", uiType: "uuid", label: "Tenant ID", required: true },
|
|
353
|
-
since: { in: "body", uiType: "datetime", label: "Since" },
|
|
354
|
-
},
|
|
355
|
-
|
|
356
|
-
// ── Triggers ──
|
|
357
|
-
triggers: [
|
|
358
|
-
{ type: "schedule", cron: "0 */6 * * *", timezone: "UTC", description: "Every 6 hours" },
|
|
359
|
-
{ type: "event", events: ["hris.employee_updated"] },
|
|
360
|
-
],
|
|
361
|
-
|
|
362
|
-
// ── Sync Metadata (for DataSync UI tab) ──
|
|
363
|
-
sync: {
|
|
364
|
-
entityType: "employee",
|
|
365
|
-
direction: "to_nova", // "to_nova" | "from_nova" | "bidirectional"
|
|
366
|
-
label: "BambooHR Employees",
|
|
367
|
-
description: "Import all employees from BambooHR",
|
|
368
|
-
},
|
|
369
|
-
|
|
370
|
-
// ── Expandable Relations (batch foreign-key resolution) ──
|
|
371
|
-
expandable: {
|
|
372
|
-
supervisor: {
|
|
373
|
-
model: "employee",
|
|
374
|
-
resolver: async (ids, ctx) => {
|
|
375
|
-
// Batch-fetch supervisors by ID → Map<id, fullObject>
|
|
376
|
-
},
|
|
377
|
-
},
|
|
378
|
-
},
|
|
379
|
-
|
|
380
|
-
// ── Handler ──
|
|
381
|
-
async handler(input, ctx) {
|
|
382
|
-
ctx.progress(10, { status: "starting" });
|
|
383
|
-
const res = await ctx.fetch("https://api.bamboohr.com/v1/employees/directory");
|
|
384
|
-
const data = await res.json();
|
|
385
|
-
ctx.progress(100, { status: "complete" });
|
|
386
|
-
return { synced: data.employees.length, errors: 0 };
|
|
387
|
-
},
|
|
388
|
-
});
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
#### `action()` Full Field Reference
|
|
392
|
-
|
|
393
|
-
| Field | Type | Description |
|
|
394
|
-
|---|---|---|
|
|
395
|
-
| `method` | `string` | HTTP method (default: `"POST"`) |
|
|
396
|
-
| `path` | `string` | Route path (default: `/{workerName}/{actionName}`) |
|
|
397
|
-
| `input` | `ZodType` | Zod schema for input validation |
|
|
398
|
-
| `output` | `ZodType` | Zod schema for output validation |
|
|
399
|
-
| `handler` | `(input, ctx) => Promise<output>` | Action implementation |
|
|
400
|
-
| `name` | `string` | Display name (default: key name) |
|
|
401
|
-
| `description` | `string` | Human-readable description |
|
|
402
|
-
| `scopes` | `string[]` | OAuth scopes → auto-registers as a platform function |
|
|
403
|
-
| `category` | `string` | Grouping in the admin UI |
|
|
404
|
-
| `params` | `Record<string, ParamMeta>` | Per-field metadata for path/query/body routing + UI hints |
|
|
405
|
-
| `triggers` | `Array<EventTrigger \| ScheduleTrigger>` | Event subscriptions and cron schedules |
|
|
406
|
-
| `sync` | `{ entityType, direction, label, description? }` | DataSync tab metadata |
|
|
407
|
-
| `expandable` | `Record<string, { model, resolver }>` | `?expand=field1,field2` batch resolvers |
|
|
408
|
-
| `capabilities` | `Array<Capability>` | Legacy: webhook, scheduled, queue, stream triggers |
|
|
409
|
-
| `fga` | `{ resourceType, relation, resourceIdKey? }` | OpenFGA authorization hints |
|
|
410
|
-
| `events` | `string \| string[]` | Shorthand for `triggers: [{ type: 'event', events: [...] }]` |
|
|
411
|
-
|
|
412
|
-
---
|
|
413
|
-
|
|
414
|
-
### `ActionCtx`
|
|
415
|
-
|
|
416
|
-
Every action handler receives a context object (`ctx`) with these methods:
|
|
417
|
-
|
|
418
|
-
```typescript
|
|
419
|
-
async handler(input: Input, ctx: ActionCtx) {
|
|
420
|
-
// ── Job tracking ──
|
|
421
|
-
ctx.jobId; // unique job ID (e.g., "http-1234567890")
|
|
422
|
-
ctx.progress(50, { step: "fetching" }); // report progress to platform
|
|
423
|
-
|
|
424
|
-
// ── Authentication ──
|
|
425
|
-
ctx.authToken; // raw Bearer token from inbound request
|
|
426
|
-
ctx.auth; // validated JWT payload (sub, iss, exp, aud, etc.)
|
|
427
|
-
|
|
428
|
-
// ── Credential Resolution ──
|
|
429
|
-
const creds = await ctx.resolveCredentials();
|
|
430
|
-
// OR: const creds = await ctx.resolveCredentials("other_integration", userId);
|
|
431
|
-
|
|
432
|
-
// ── mTLS-aware Fetch (with 401 auto-retry) ──
|
|
433
|
-
const res = await ctx.fetch("https://api.provider.com/data");
|
|
434
|
-
// OR: const res = await ctx.fetch(url, { method: "POST", body: "..." }, creds);
|
|
435
|
-
|
|
436
|
-
// ── HTTP headers (HTTP mode only) ──
|
|
437
|
-
ctx.headers; // raw request headers
|
|
438
|
-
|
|
439
|
-
// ── SSE worker mode only ──
|
|
440
|
-
ctx.read_ct; // message delivery count
|
|
441
|
-
await ctx.heartbeat?.(30); // extend visibility timeout by 30s
|
|
442
|
-
}
|
|
443
|
-
```
|
|
444
|
-
|
|
445
|
-
#### `ctx.resolveCredentials()` Flow
|
|
446
|
-
|
|
447
|
-
1. Calls `GET {AUTH_ISSUER_BASE_URL}/api/integrations/{slug}/credentials` with the inbound JWT
|
|
448
|
-
2. Auth server decrypts credentials from Vault and returns them
|
|
449
|
-
3. SDK performs OAuth token exchange locally (`client_credentials` or `mTLS`)
|
|
450
|
-
4. Returns `ResolvedCredentials` with `accessToken`, `expiresAt`, `authMode`, `httpsAgent`
|
|
451
|
-
|
|
452
|
-
#### `ctx.fetch()` 401 Auto-Retry
|
|
453
|
-
|
|
454
|
-
1. Makes the request with current `accessToken`
|
|
455
|
-
2. If 401 → sends `X-Nova-Token-Invalid: true` to auth server → gets fresh credentials
|
|
456
|
-
3. If the new token is different → retries once automatically
|
|
457
|
-
4. If same token → returns original 401 (prevents infinite loops)
|
|
458
|
-
|
|
459
|
-
---
|
|
460
|
-
|
|
461
|
-
## Running the Integration
|
|
462
|
-
|
|
463
|
-
### `runHttpServer()`
|
|
464
|
-
|
|
465
|
-
Starts an Express HTTP server with one route per action.
|
|
466
|
-
|
|
467
|
-
```typescript
|
|
468
|
-
import { runHttpServer } from "@newhomestar/sdk";
|
|
469
|
-
|
|
470
|
-
runHttpServer(integration as any, {
|
|
471
|
-
port: 8000,
|
|
472
|
-
issuerBaseURL: "https://auth.newhomeconnect.dev",
|
|
473
|
-
audience: "starfleet",
|
|
474
|
-
publicPaths: ["/webhooks"], // exempt from JWKS auth
|
|
475
|
-
skipAuth: false, // or set NOVA_SKIP_AUTH=true
|
|
476
|
-
});
|
|
477
|
-
```
|
|
478
|
-
|
|
479
|
-
**Features:**
|
|
480
|
-
- **JWKS JWT authentication** via `express-oauth2-jwt-bearer` (RS256)
|
|
481
|
-
- `/health` and `/healthcheck` are auto-exempted from auth
|
|
482
|
-
- **Smart input extraction**: params metadata → path/query/body/header; type coercion for query strings
|
|
483
|
-
- **`?expand=field1,field2`** — batch foreign-key resolution via `expandable` config
|
|
484
|
-
|
|
485
|
-
### `runDualMode()`
|
|
486
|
-
|
|
487
|
-
Starts both HTTP server and queue consumer concurrently.
|
|
488
|
-
|
|
489
|
-
```typescript
|
|
490
|
-
import { runDualMode } from "@newhomestar/sdk";
|
|
491
|
-
|
|
492
|
-
runDualMode(integration as any, { port: 8000 });
|
|
493
|
-
// HTTP API + SSE/pgmq queue consumer in background
|
|
494
|
-
```
|
|
495
|
-
|
|
496
|
-
> **⚠️ Warning:** If you also use `startPollConsumer()` for inbound event processing, use `runHttpServer()` instead of `runDualMode()` to avoid competing consumers on the same queue.
|
|
497
|
-
|
|
498
|
-
### `runWorker()`
|
|
499
|
-
|
|
500
|
-
Queue-only mode. Automatically selects:
|
|
501
|
-
- **SSE mode** (preferred): when `NOVA_EVENTS_SERVICE_URL` + `NOVA_SERVICE_TOKEN` are set
|
|
502
|
-
- **Legacy pgmq mode**: when `RUNTIME_SUPABASE_*` env vars are set
|
|
503
|
-
|
|
504
|
-
### Which Runtime Mode to Use
|
|
505
|
-
|
|
506
|
-
| Scenario | Recommended Mode |
|
|
507
|
-
|---|---|
|
|
508
|
-
| Integration with HTTP API + separate poll consumer | `runHttpServer()` + `startPollConsumer()` |
|
|
509
|
-
| Integration with HTTP API + built-in SSE consumer | `runDualMode()` |
|
|
510
|
-
| Pure queue worker (no HTTP) | `runWorker()` |
|
|
511
|
-
| Background/headless sync loops | `createIntegrationClient()` + `setImmediate(processLoop)` |
|
|
512
|
-
|
|
513
|
-
---
|
|
514
|
-
|
|
515
|
-
## Events System
|
|
516
|
-
|
|
517
|
-
Import from `@newhomestar/sdk/events`:
|
|
518
|
-
|
|
519
|
-
```typescript
|
|
520
|
-
import {
|
|
521
|
-
withServiceEventOutbox,
|
|
522
|
-
withEventOutbox,
|
|
523
|
-
withWebhookEvent,
|
|
524
|
-
queueEvent,
|
|
525
|
-
logEvent,
|
|
526
|
-
startOutboxRelay,
|
|
527
|
-
startInboundConsumer,
|
|
528
|
-
startPollConsumer,
|
|
529
|
-
isIntegrationSync,
|
|
530
|
-
NovaEventsClient,
|
|
531
|
-
} from "@newhomestar/sdk/events";
|
|
532
|
-
```
|
|
533
|
-
|
|
534
|
-
### Outbound Events (Producer)
|
|
535
|
-
|
|
536
|
-
#### `withServiceEventOutbox()` — Preferred
|
|
537
|
-
|
|
538
|
-
Atomic: data write + outbox row in one Prisma `$transaction`, then best-effort relay. Auto-stamps `x-source`/`x-integration-id` from request headers.
|
|
539
|
-
|
|
540
|
-
```typescript
|
|
541
|
-
const employee = await withServiceEventOutbox(db, req, async (tx, emit) => {
|
|
542
|
-
const row = await tx.hrisEmployee.update({ where: { id }, data });
|
|
543
|
-
emit("employee.updated", { id: row.id, firstName: row.firstName });
|
|
544
|
-
return row;
|
|
545
|
-
});
|
|
546
|
-
```
|
|
547
|
-
|
|
548
|
-
#### `withEventOutbox()` — Legacy
|
|
549
|
-
|
|
550
|
-
Same atomicity but with a `{ events, result }` return shape:
|
|
551
|
-
|
|
552
|
-
```typescript
|
|
553
|
-
const employee = await withEventOutbox(db, async (tx) => {
|
|
554
|
-
const row = await tx.hrisEmployee.update({ where: { id }, data });
|
|
555
|
-
return {
|
|
556
|
-
events: [{ entity_type: "employee", action: "updated", entity_id: id }],
|
|
557
|
-
result: row,
|
|
558
|
-
};
|
|
559
|
-
});
|
|
560
|
-
```
|
|
561
|
-
|
|
562
|
-
#### `queueEvent()` — Stateless
|
|
563
|
-
|
|
564
|
-
No transaction context. Directly POSTs to the Events Service.
|
|
565
|
-
|
|
566
|
-
```typescript
|
|
567
|
-
await queueEvent({
|
|
568
|
-
event_slug: "bamboohr.employee_synced",
|
|
569
|
-
source_service: "bamboohr",
|
|
570
|
-
attributes: { synced: 150, errors: 0 },
|
|
571
|
-
});
|
|
572
|
-
```
|
|
573
|
-
|
|
574
|
-
#### `logEvent()` — Audit Only
|
|
575
|
-
|
|
576
|
-
Records an event in the audit log **without** queue fan-out.
|
|
577
|
-
|
|
578
|
-
```typescript
|
|
579
|
-
await logEvent({
|
|
580
|
-
event_slug: "bamboohr.employee_viewed",
|
|
581
|
-
entity_id: employeeId,
|
|
582
|
-
performed_by_id: userId,
|
|
583
|
-
});
|
|
584
|
-
```
|
|
585
|
-
|
|
586
|
-
### Inbound Events (Consumer)
|
|
587
|
-
|
|
588
|
-
#### `startPollConsumer()` — Recommended for DO App Platform
|
|
589
|
-
|
|
590
|
-
HTTP pull-based consumer. No persistent connections or proxy timeout issues.
|
|
591
|
-
|
|
592
|
-
```typescript
|
|
593
|
-
const consumer = startPollConsumer(db, {
|
|
594
|
-
queueName: "bamboohr_queue",
|
|
595
|
-
batch: 5, // messages per poll
|
|
596
|
-
vt: 60, // visibility timeout (seconds)
|
|
597
|
-
idleDelayMs: 2000, // sleep when queue is empty
|
|
598
|
-
handlers: {
|
|
599
|
-
"hris.employee_created": async (tx, event) => {
|
|
600
|
-
if (isIntegrationSync(event.payload)) return { status: "skipped" };
|
|
601
|
-
await tx.bambooEmployee.upsert({ /* ... */ });
|
|
602
|
-
return { status: "processed" };
|
|
603
|
-
},
|
|
604
|
-
"hris.employee_updated": async (tx, event) => {
|
|
605
|
-
// ...
|
|
606
|
-
return { status: "processed" };
|
|
607
|
-
},
|
|
608
|
-
},
|
|
609
|
-
defaultHandler: async (_tx, event) => {
|
|
610
|
-
console.warn(`Unhandled event: ${(event.payload as any).topic}`);
|
|
611
|
-
return { status: "skipped" };
|
|
612
|
-
},
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
// Graceful shutdown
|
|
616
|
-
process.on("SIGTERM", () => consumer.abort());
|
|
617
|
-
```
|
|
618
|
-
|
|
619
|
-
#### `startInboundConsumer()` — SSE-Based
|
|
620
|
-
|
|
621
|
-
Long-lived SSE connection. Best for non-DO deployments.
|
|
622
|
-
|
|
623
|
-
```typescript
|
|
624
|
-
const consumer = startInboundConsumer(db, {
|
|
625
|
-
queueName: "bamboohr_queue",
|
|
626
|
-
handlers: { /* same as poll consumer */ },
|
|
627
|
-
vt: 30,
|
|
628
|
-
maxReconnectDelay: 30_000,
|
|
629
|
-
});
|
|
630
|
-
```
|
|
631
|
-
|
|
632
|
-
### Webhook ACID Processing
|
|
633
|
-
|
|
634
|
-
#### `withWebhookEvent()`
|
|
635
|
-
|
|
636
|
-
Atomically processes inbound webhooks with idempotency:
|
|
637
|
-
|
|
638
|
-
```typescript
|
|
639
|
-
const { status } = await withWebhookEvent(db, {
|
|
640
|
-
idempotencyKey: `bamboohr:${webhookId}:${timestamp}`,
|
|
641
|
-
eventType: "bamboohr.employee.updated",
|
|
642
|
-
queueName: "bamboohr_webhooks",
|
|
643
|
-
payload: rawBody,
|
|
644
|
-
}, async (tx, emit) => {
|
|
645
|
-
await tx.bambooEmployee.upsert({ /* ... */ });
|
|
646
|
-
emit("employee.upserted", { id: employee.id });
|
|
647
|
-
});
|
|
648
|
-
// Returns { status: "processed" } or { status: "duplicate" }
|
|
649
|
-
```
|
|
650
|
-
|
|
651
|
-
**ACID guarantee**: idempotency check → inbound_events INSERT → handler → outbox rows → mark processed — all in one `$transaction`. Rolls back entirely on failure.
|
|
652
|
-
|
|
653
|
-
### Outbox Relay
|
|
654
|
-
|
|
655
|
-
Start once at service boot:
|
|
656
|
-
|
|
657
|
-
```typescript
|
|
658
|
-
startOutboxRelay(db, {
|
|
659
|
-
intervalMs: 60_000, // poll every 60s
|
|
660
|
-
maxAttempts: 5, // give up after 5 attempts
|
|
661
|
-
});
|
|
662
|
-
```
|
|
663
|
-
|
|
664
|
-
Retry schedule (exponential backoff): `2^attempts × 5s` → 5s, 10s, 20s, 40s, 80s.
|
|
665
|
-
|
|
666
|
-
### Echo Loop Prevention
|
|
667
|
-
|
|
668
|
-
```typescript
|
|
669
|
-
import { isIntegrationSync } from "@newhomestar/sdk/events";
|
|
670
|
-
|
|
671
|
-
// In a consumer handler:
|
|
672
|
-
if (isIntegrationSync(event.payload)) {
|
|
673
|
-
return { status: "skipped" }; // Don't write back to the system that caused this event
|
|
674
|
-
}
|
|
675
|
-
```
|
|
676
|
-
|
|
677
|
-
Checks for `x-source: integration_sync` in headers or `metadata.source === "integration_sync"` in event payloads.
|
|
678
|
-
|
|
679
|
-
### Topic Format Convention
|
|
680
|
-
|
|
681
|
-
```
|
|
682
|
-
{source_service}.{entity}_{action}
|
|
683
|
-
```
|
|
684
|
-
|
|
685
|
-
Examples:
|
|
686
|
-
- `hris.employee_created`
|
|
687
|
-
- `bamboohr.user_synced`
|
|
688
|
-
- `nova_ticketing_service.ticket_updated`
|
|
689
|
-
|
|
690
|
-
---
|
|
691
|
-
|
|
692
|
-
## Credential Resolution
|
|
693
|
-
|
|
694
|
-
### HTTP Callback Strategy
|
|
695
|
-
|
|
696
|
-
The **only** credential resolution strategy. No direct database access needed.
|
|
697
|
-
|
|
698
|
-
```
|
|
699
|
-
Container ──JWT──> Auth Server ──Vault──> Decrypted Credentials
|
|
700
|
-
(AUTH_ISSUER_BASE_URL)
|
|
701
|
-
```
|
|
702
|
-
|
|
703
|
-
**Flow:**
|
|
704
|
-
1. Container receives request with JWT (validated by JWKS middleware)
|
|
705
|
-
2. Action calls `ctx.resolveCredentials()`
|
|
706
|
-
3. SDK calls `GET {AUTH_ISSUER_BASE_URL}/api/integrations/{slug}/credentials` with `Authorization: Bearer {jwt}`
|
|
707
|
-
4. Auth server verifies JWT, decrypts credentials from Vault, returns them
|
|
708
|
-
5. SDK performs local OAuth token exchange (`client_credentials` / `mTLS`)
|
|
709
|
-
6. Container uses the `accessToken` to call the integration API
|
|
710
|
-
|
|
711
|
-
### `ctx.resolveCredentials()` and `ctx.fetch()`
|
|
712
|
-
|
|
713
|
-
```typescript
|
|
714
|
-
// Auto-resolve for the current integration
|
|
715
|
-
const creds = await ctx.resolveCredentials();
|
|
716
|
-
|
|
717
|
-
// Resolve for a different integration
|
|
718
|
-
const creds = await ctx.resolveCredentials("other_integration", userId);
|
|
719
|
-
|
|
720
|
-
// ctx.fetch() — resolves credentials automatically + 401 auto-retry
|
|
721
|
-
const res = await ctx.fetch("https://api.provider.com/employees");
|
|
722
|
-
|
|
723
|
-
// ctx.fetch() with explicit credentials
|
|
724
|
-
const res = await ctx.fetch(url, { method: "POST", body: "..." }, creds);
|
|
725
|
-
```
|
|
726
|
-
|
|
727
|
-
**Auth modes** returned by `ResolvedCredentials`:
|
|
728
|
-
- `standard` — authorization_code (per-user, pre-resolved access token)
|
|
729
|
-
- `client_credentials` — server-to-server OAuth (SDK performs token exchange)
|
|
730
|
-
- `mtls` — client_credentials + mTLS cert/key (SDK uses `node:https` agent)
|
|
731
|
-
|
|
732
|
-
### `createIntegrationClient()`
|
|
733
|
-
|
|
734
|
-
For background/headless sync loops that don't have an inbound JWT:
|
|
735
|
-
|
|
736
|
-
```typescript
|
|
737
|
-
import { createIntegrationClient } from "@newhomestar/sdk";
|
|
738
|
-
|
|
739
|
-
const client = createIntegrationClient("bamboohr");
|
|
740
|
-
// Resolves via NOVA_SERVICE_TOKEN (not user JWT)
|
|
741
|
-
|
|
742
|
-
const res = await client.fetch("https://api.bamboohr.com/v1/employees/directory");
|
|
743
|
-
const data = await res.json();
|
|
744
|
-
|
|
745
|
-
// Auto-refreshes credentials when they expire (60s buffer)
|
|
746
|
-
// Auto-retries on 401 with fresh credentials
|
|
747
|
-
```
|
|
748
|
-
|
|
749
|
-
### `emitPlatformEvent()`
|
|
750
|
-
|
|
751
|
-
Emit PGMQ events to the platform database for cross-service communication:
|
|
752
|
-
|
|
753
|
-
```typescript
|
|
754
|
-
import { createPlatformClient, emitPlatformEvent } from "@newhomestar/sdk";
|
|
755
|
-
|
|
756
|
-
const platformDB = createPlatformClient();
|
|
757
|
-
await emitPlatformEvent(platformDB, "employee.sync_complete", "bamboohr", {
|
|
758
|
-
tenantId, synced: 150, errors: 0,
|
|
759
|
-
});
|
|
760
|
-
```
|
|
761
|
-
|
|
762
|
-
---
|
|
763
|
-
|
|
764
|
-
## Parameter Metadata (`ParamMeta`)
|
|
765
|
-
|
|
766
|
-
Per-field metadata that tells the platform where each input field goes (path, query, body, header) and what UI widget the admin dashboard should render.
|
|
767
|
-
|
|
768
|
-
```typescript
|
|
769
|
-
params: {
|
|
770
|
-
id: { in: "path", uiType: "text", label: "Employee ID", required: true },
|
|
771
|
-
asOfDate: { in: "query", uiType: "date", label: "As-of Date", placeholder: "YYYY-MM-DD" },
|
|
772
|
-
syncType: { in: "body", uiType: "select", label: "Sync Type", options: [
|
|
773
|
-
{ label: "Full", value: "full" },
|
|
774
|
-
{ label: "Incremental", value: "incremental" },
|
|
775
|
-
]},
|
|
776
|
-
limit: { in: "query", uiType: "number", label: "Limit", defaultValue: 100, min: 1, max: 500 },
|
|
777
|
-
}
|
|
778
|
-
```
|
|
779
|
-
|
|
780
|
-
#### `ParamMeta` Field Reference
|
|
781
|
-
|
|
782
|
-
| Field | Type | Description |
|
|
783
|
-
|---|---|---|
|
|
784
|
-
| `in` | `"path" \| "query" \| "body" \| "header"` | Where the parameter goes in the HTTP request |
|
|
785
|
-
| `uiType` | `ParamUiType` | UI widget: `text`, `textarea`, `number`, `integer`, `boolean`, `date`, `datetime`, `select`, `multiselect`, `password`, `email`, `url`, `uuid`, `json`, `hidden` |
|
|
786
|
-
| `label` | `string` | Human-readable label |
|
|
787
|
-
| `description` | `string` | Help text below the input |
|
|
788
|
-
| `placeholder` | `string` | Placeholder text |
|
|
789
|
-
| `required` | `boolean` | Overrides Zod's optional/required |
|
|
790
|
-
| `defaultValue` | `unknown` | Default value in UI |
|
|
791
|
-
| `options` | `Array<{ label, value }>` | For select/multiselect |
|
|
792
|
-
| `min` / `max` | `number` | Range for numbers, length for strings |
|
|
793
|
-
| `step` | `number` | Increment for number inputs |
|
|
794
|
-
| `pattern` | `string` | Regex for frontend validation |
|
|
795
|
-
| `order` | `number` | Display priority (lower = higher) |
|
|
796
|
-
| `group` | `string` | Visual grouping (e.g., `"Identity"`, `"Options"`) |
|
|
797
|
-
|
|
798
|
-
**Convention fallback** (when `params` not provided):
|
|
799
|
-
- Fields matching `:param` in path → path params
|
|
800
|
-
- Remaining fields for GET → query params
|
|
801
|
-
- Remaining fields for POST/PUT/PATCH → body params
|
|
802
|
-
- UI type inferred from Zod type (string→text, number→number, boolean→boolean, enum→select)
|
|
803
|
-
|
|
804
|
-
---
|
|
805
|
-
|
|
806
|
-
## Sync Mappings
|
|
807
|
-
|
|
808
|
-
Declare how integration entities map to Nova service schemas. Seeded into `integration_sync_pairs` + `integration_field_mappings` on push.
|
|
809
|
-
|
|
810
|
-
```typescript
|
|
811
|
-
syncMappings: {
|
|
812
|
-
employee: {
|
|
813
|
-
service: "hris",
|
|
814
|
-
targetSchema: "employee",
|
|
815
|
-
direction: "integration_to_service",
|
|
816
|
-
fields: [
|
|
817
|
-
{ source: "workEmail", target: "work_email" },
|
|
818
|
-
{ source: "firstName", target: "first_name" },
|
|
819
|
-
{ source: "lastName", target: "last_name" },
|
|
820
|
-
{ source: "status", target: "employment_status",
|
|
821
|
-
transform: "status = 'Active' ? 'ACTIVE' : 'INACTIVE'" }, // JSONata
|
|
822
|
-
{ source: "supervisor", sourcePath: ["supervisor", "id"], target: "manager_id" },
|
|
823
|
-
],
|
|
824
|
-
},
|
|
825
|
-
time_off_request: {
|
|
826
|
-
service: "hris",
|
|
827
|
-
targetSchema: "time_off_request",
|
|
828
|
-
direction: "integration_to_service",
|
|
829
|
-
fields: [ /* ... */ ],
|
|
830
|
-
},
|
|
831
|
-
},
|
|
832
|
-
```
|
|
833
|
-
|
|
834
|
-
---
|
|
835
|
-
|
|
836
|
-
## Webhook Configuration
|
|
837
|
-
|
|
838
|
-
Declares inbound webhook types the provider supports:
|
|
839
|
-
|
|
840
|
-
```typescript
|
|
841
|
-
webhooks: {
|
|
842
|
-
handler: "handleWebhook", // must reference an existing action key
|
|
843
|
-
types: {
|
|
844
|
-
employee_changes: {
|
|
845
|
-
label: "Employee Changes",
|
|
846
|
-
description: "Fires when employee records are created, updated, or deleted",
|
|
847
|
-
produces: ["bamboohr.employee.created", "bamboohr.employee.updated", "bamboohr.employee.deleted"],
|
|
848
|
-
authentication: {
|
|
849
|
-
method: "hmac_sha256",
|
|
850
|
-
signatureHeader: "X-BambooHR-Signature",
|
|
851
|
-
secretSource: "platform_generated",
|
|
852
|
-
},
|
|
853
|
-
fields: [
|
|
854
|
-
{ name: "events", label: "Event Types", type: "multiselect", required: true,
|
|
855
|
-
options: [
|
|
856
|
-
{ label: "Created", value: "created" },
|
|
857
|
-
{ label: "Updated", value: "updated" },
|
|
858
|
-
{ label: "Deleted", value: "deleted" },
|
|
859
|
-
]},
|
|
860
|
-
],
|
|
861
|
-
},
|
|
862
|
-
},
|
|
863
|
-
},
|
|
864
|
-
```
|
|
865
|
-
|
|
866
|
-
Synced to `app_webhook_types` on push. The Odyssey UI renders configuration forms from the `fields` array.
|
|
867
|
-
|
|
868
|
-
---
|
|
869
|
-
|
|
870
|
-
## Dual Database Pattern
|
|
871
|
-
|
|
872
|
-
Each integration connects to **two** databases:
|
|
873
|
-
|
|
874
|
-
```
|
|
875
|
-
┌─────────────────────────┐ ┌────────────────────────────┐
|
|
876
|
-
│ Platform DB │ │ Integration DB │
|
|
877
|
-
│ (read-only) │ │ (read-write) │
|
|
878
|
-
│ │ │ │
|
|
879
|
-
│ • OAuth tokens │ │ • domain tables │
|
|
880
|
-
│ • Tenant config │ │ • sync jobs │
|
|
881
|
-
│ • PGMQ queues │ │ • webhook events │
|
|
882
|
-
│ │ │ • inbound/outbound events │
|
|
883
|
-
│ PLATFORM_SUPABASE_URL │ │ INTEGRATION_SUPABASE_URL │
|
|
884
|
-
│ getPlatformClient() │ │ getIntegrationClient() │
|
|
885
|
-
└─────────────────────────┘ └────────────────────────────┘
|
|
886
|
-
```
|
|
887
|
-
|
|
888
|
-
```typescript
|
|
889
|
-
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
|
890
|
-
|
|
891
|
-
function getPlatformClient(): SupabaseClient {
|
|
892
|
-
return createClient(
|
|
893
|
-
process.env.PLATFORM_SUPABASE_URL!,
|
|
894
|
-
process.env.PLATFORM_SUPABASE_SERVICE_ROLE_KEY!,
|
|
895
|
-
{ auth: { autoRefreshToken: false, persistSession: false } }
|
|
896
|
-
);
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
function getIntegrationClient(): SupabaseClient {
|
|
900
|
-
return createClient(
|
|
901
|
-
process.env.INTEGRATION_SUPABASE_URL!,
|
|
902
|
-
process.env.INTEGRATION_SUPABASE_SERVICE_ROLE_KEY!,
|
|
903
|
-
{ auth: { autoRefreshToken: false, persistSession: false } }
|
|
904
|
-
);
|
|
905
|
-
}
|
|
906
|
-
```
|
|
907
|
-
|
|
908
|
-
---
|
|
909
|
-
|
|
910
|
-
## `nova-integration.yaml` Spec
|
|
911
|
-
|
|
912
|
-
Generated by `nova integrations build`. Full annotated example:
|
|
913
|
-
|
|
914
|
-
```yaml
|
|
915
|
-
apiVersion: nova.dev/v1
|
|
916
|
-
kind: Integration
|
|
917
|
-
metadata:
|
|
918
|
-
slug: bamboohr
|
|
919
|
-
name: BambooHR
|
|
920
|
-
displayName: BambooHR HRIS
|
|
921
|
-
description: Connects BambooHR HRIS to Nova
|
|
922
|
-
category: hris
|
|
923
|
-
tags: [hr, employee-sync]
|
|
924
|
-
logoUrl: https://...
|
|
925
|
-
color: "#73C41D"
|
|
926
|
-
spec:
|
|
927
|
-
type: oauth2 # oidc | oauth2 | api_key
|
|
928
|
-
endpoints:
|
|
929
|
-
authorization: https://bamboohr.com/authorize.php
|
|
930
|
-
token: https://bamboohr.com/token.php
|
|
931
|
-
baseUrl: https://api.bamboohr.com/api/gateway.php/newhomestar
|
|
932
|
-
scopes: [employee, time_off, time_tracking] # merged from all action scopes
|
|
933
|
-
runtime:
|
|
934
|
-
type: integration
|
|
935
|
-
image: registry.digitalocean.com/nhc/bamboohr:main
|
|
936
|
-
queue: bamboohr_queue
|
|
937
|
-
command: [node, dist/index.cjs]
|
|
938
|
-
resources: { cpu: 500m, memory: 512Mi }
|
|
939
|
-
envSpec:
|
|
940
|
-
- { name: NOVA_EVENTS_SERVICE_URL, secret: false }
|
|
941
|
-
- { name: NOVA_SERVICE_TOKEN, secret: true }
|
|
942
|
-
actions:
|
|
943
|
-
- name: syncEmployees
|
|
944
|
-
async: true
|
|
945
|
-
triggers:
|
|
946
|
-
- { type: schedule, cron: "0 */6 * * *", timezone: UTC }
|
|
947
|
-
scopes: [employee:read]
|
|
948
|
-
sync:
|
|
949
|
-
entityType: employee
|
|
950
|
-
direction: to_nova
|
|
951
|
-
label: BambooHR Employees
|
|
952
|
-
input: { ... } # JSON Schema (converted from Zod)
|
|
953
|
-
output: { ... }
|
|
954
|
-
schema:
|
|
955
|
-
input: ./schemas/syncEmployees.input.json
|
|
956
|
-
output: ./schemas/syncEmployees.output.json
|
|
957
|
-
schemas:
|
|
958
|
-
- slug: employee
|
|
959
|
-
name: Employee
|
|
960
|
-
type: entity
|
|
961
|
-
schema: { type: object, properties: { ... } }
|
|
962
|
-
version: "1.0.0"
|
|
963
|
-
fieldCount: 42
|
|
964
|
-
events:
|
|
965
|
-
- slug: employee_synced
|
|
966
|
-
name: Employee Synced
|
|
967
|
-
direction: outbound
|
|
968
|
-
category: sync
|
|
969
|
-
severity: info
|
|
970
|
-
payloadSchema: employee
|
|
971
|
-
functions: # auto-extracted from actions
|
|
972
|
-
- slug: sync_employees
|
|
973
|
-
name: Sync Employees
|
|
974
|
-
httpMethod: POST
|
|
975
|
-
endpointPath: /employees/sync
|
|
976
|
-
requiredScopes: [employee:read]
|
|
977
|
-
category: employees
|
|
978
|
-
syncMappings:
|
|
979
|
-
- sourceEntity: employee
|
|
980
|
-
service: hris
|
|
981
|
-
targetSchema: employee
|
|
982
|
-
direction: integration_to_service
|
|
983
|
-
fields:
|
|
984
|
-
- { source: workEmail, target: work_email }
|
|
985
|
-
- { source: status, target: employment_status,
|
|
986
|
-
transform: "status = 'Active' ? 'ACTIVE' : 'INACTIVE'" }
|
|
987
|
-
webhookTypes:
|
|
988
|
-
- slug: employee_changes
|
|
989
|
-
label: Employee Changes
|
|
990
|
-
produces: [bamboohr.employee.created, bamboohr.employee.updated]
|
|
991
|
-
handler: handle_webhook
|
|
992
|
-
build:
|
|
993
|
-
dockerfile: ./Dockerfile
|
|
994
|
-
context: .
|
|
995
|
-
ui:
|
|
996
|
-
category: hris
|
|
997
|
-
color: "#73C41D"
|
|
998
|
-
```
|
|
999
|
-
|
|
1000
|
-
---
|
|
1001
|
-
|
|
1002
|
-
## CLI Reference
|
|
1003
|
-
|
|
1004
|
-
### `nova integrations new`
|
|
1005
|
-
|
|
1006
|
-
Scaffolds a new integration project.
|
|
1007
|
-
|
|
1008
|
-
```bash
|
|
1009
|
-
nova integrations new [directory]
|
|
1010
|
-
```
|
|
1011
|
-
|
|
1012
|
-
**Interactive prompts:**
|
|
1013
|
-
1. Integration slug (snake_case) — if no directory arg
|
|
1014
|
-
2. Integration type: OIDC, OAuth2, or API Key
|
|
1015
|
-
|
|
1016
|
-
**Generates:**
|
|
1017
|
-
| File | Purpose |
|
|
1018
|
-
|---|---|
|
|
1019
|
-
| `src/index.ts` | Full starter integration with `defineIntegration()`, schemas, events, actions, poll consumer |
|
|
1020
|
-
| `src/lib/db.ts` | Prisma singleton client |
|
|
1021
|
-
| `package.json` | `@newhomestar/sdk@0.6.0`, `zod`, `prisma` |
|
|
1022
|
-
| `prisma/schema.prisma` | Baseline Prisma schema with outbox models |
|
|
1023
|
-
| `Dockerfile` | Distroless Node 20 container |
|
|
1024
|
-
| `tsconfig.json` | TypeScript configuration |
|
|
1025
|
-
| `Agents.md` | LLM agent instructions |
|
|
1026
|
-
| `supabase/` | Local Supabase config + migrations directory |
|
|
1027
|
-
|
|
1028
|
-
**Post-scaffold:** runs `git init`, `corepack enable`, `yarn install`
|
|
1029
|
-
|
|
1030
|
-
**Optional AI assist:** generates starter schemas/events from a plain-English description using OpenAI.
|
|
1031
|
-
|
|
1032
|
-
### `nova integrations build`
|
|
1033
|
-
|
|
1034
|
-
Bundles the integration and generates all build artifacts.
|
|
1035
|
-
|
|
1036
|
-
```bash
|
|
1037
|
-
nova integrations build [-o dist] [--dry-run] [--skip-bundle]
|
|
1038
|
-
```
|
|
1039
|
-
|
|
1040
|
-
**8-Step Pipeline:**
|
|
1041
|
-
|
|
1042
|
-
| Step | What happens |
|
|
1043
|
-
|---|---|
|
|
1044
|
-
| 1. **Pass 1 esbuild** | Bundles `src/index.ts` → `dist/index.build.cjs` with `zod` **external** (so the CLI can use the integration's Zod for `instanceof` checks) |
|
|
1045
|
-
| 2. **Dynamic import** | Loads the build-time bundle, finds `export default defineIntegration({...})` |
|
|
1046
|
-
| 3. **Validate** | Runs `validateIntegration()` — checks slug, name, schemas, baseUrl, actions |
|
|
1047
|
-
| 4. **Load Zod v4** | From integration's `node_modules`; uses `z.toJSONSchema()` for schema conversion |
|
|
1048
|
-
| 5. **Convert schemas** | Each `schemas.*` entry → JSON Schema file at `dist/schemas/{slug}.json` |
|
|
1049
|
-
| 6. **Convert events + functions** | Extracts metadata; converts `__inputZod`/`__outputZod` → `inputSchema`/`outputSchema` JSON |
|
|
1050
|
-
| 7. **Convert actions** | Zod I/O → JSON Schema; serializes `triggers`, `scopes`, `sync` blocks |
|
|
1051
|
-
| 8. **Pass 2 esbuild** | Production bundle with `ignoreAnnotations: true` (zod fully inlined, zero node_modules at runtime) |
|
|
1052
|
-
|
|
1053
|
-
**Scope rollup:** auto-collects all `scopes` from all actions, extracts base names (`employee:read` → `employee`), merges with top-level `def.scopes`.
|
|
1054
|
-
|
|
1055
|
-
**Output:**
|
|
1056
|
-
- `dist/index.cjs` — production bundle (~50MB image in distroless Docker)
|
|
1057
|
-
- `dist/schemas/*.json` — JSON Schema files per entity and action I/O
|
|
1058
|
-
- `nova-integration.yaml` — the full spec document
|
|
1059
|
-
|
|
1060
|
-
### `nova integrations push`
|
|
1061
|
-
|
|
1062
|
-
Builds, pushes Docker image, and syncs all configuration to the platform.
|
|
1063
|
-
|
|
1064
|
-
```bash
|
|
1065
|
-
nova integrations push [--skip-build] [--skip-secrets-fetch] [-d|--destructive] [--tag <t>] [--registry <r>]
|
|
1066
|
-
```
|
|
1067
|
-
|
|
1068
|
-
**11-Step Pipeline:**
|
|
1069
|
-
|
|
1070
|
-
| Step | Description |
|
|
1071
|
-
|---|---|
|
|
1072
|
-
| 1. **Fetch secrets** | `POST {odysseyUrl}/api/secrets` using stored CLI session token |
|
|
1073
|
-
| 2. **Build integration** | Runs `buildIntegration()` → spec + `nova-integration.yaml` |
|
|
1074
|
-
| 3. **Docker registry login** | `doctl registry login` (for DigitalOcean registry) |
|
|
1075
|
-
| 4. **Docker build** | `docker build --platform linux/amd64 -t {registry}/{slug}:{tag} .` |
|
|
1076
|
-
| 5. **Docker push** | `docker push {image}` |
|
|
1077
|
-
| 6. **Register container** | `POST /api/integrations` — metadata, image, queue, actions |
|
|
1078
|
-
| 7. **Sync config** | `POST /api/integrations/config` — upsert schemas, events, functions |
|
|
1079
|
-
| 8. **Seed sync mappings** | `POST /api/integrations/sync-mappings` — `integration_sync_pairs` + `integration_field_mappings` |
|
|
1080
|
-
| 8b. **Sync entity types** | `POST /api/integrations/sync-entities` — DataSync tab entries from `action.sync` blocks |
|
|
1081
|
-
| 8c. **Sync webhook types** | `POST /api/integrations/webhook-types` — `app_webhook_types` rows |
|
|
1082
|
-
| 9. **Register outbound events** | `POST /api/integration-events/sync` + `POST {NOVA_EVENTS_SERVICE_URL}/event-types` |
|
|
1083
|
-
| 10. **Register triggers** | Ensure PGMQ queue exists; upsert `event_subscriptions` for each trigger topic |
|
|
1084
|
-
| 11. **Sync trigger registry** | `POST /api/event-triggers/sync` — platform DB for UI display |
|
|
1085
|
-
|
|
1086
|
-
**Tag defaulting:** current git branch name (sanitized to `[a-z0-9.-]`)
|
|
1087
|
-
|
|
1088
|
-
**`--destructive` flag:** deletes all existing endpoints/schemas/events before upserting (guarantees clean slate).
|
|
1089
|
-
|
|
1090
|
-
---
|
|
1091
|
-
|
|
1092
|
-
## Environment Variables
|
|
1093
|
-
|
|
1094
|
-
### Runtime (Container)
|
|
1095
|
-
|
|
1096
|
-
| Variable | Secret | Required By |
|
|
1097
|
-
|---|---|---|
|
|
1098
|
-
| `NOVA_EVENTS_SERVICE_URL` | No | `startPollConsumer`, `queueEvent`, `withEventOutbox` |
|
|
1099
|
-
| `NOVA_SERVICE_TOKEN` | Yes | Events Service auth, `createIntegrationClient()` |
|
|
1100
|
-
| `AUTH_ISSUER_BASE_URL` | No | `ctx.resolveCredentials()` — auth server URL |
|
|
1101
|
-
| `AUTH_AUDIENCE` | No | JWKS audience claim (default: `"starfleet"`) |
|
|
1102
|
-
| `PLATFORM_SUPABASE_URL` | No | `createPlatformClient()`, `emitPlatformEvent()` |
|
|
1103
|
-
| `PLATFORM_SUPABASE_SERVICE_ROLE_KEY` | Yes | Platform DB service role |
|
|
1104
|
-
| `INTEGRATION_SUPABASE_URL` | No | Integration's own database |
|
|
1105
|
-
| `INTEGRATION_SUPABASE_SERVICE_ROLE_KEY` | Yes | Integration DB service role |
|
|
1106
|
-
| `NOVA_SERVICE_SLUG` | No | Stamped as `source_service` on outbox events |
|
|
1107
|
-
| `PORT` | No | HTTP server port (default: `8000`) |
|
|
1108
|
-
| `NOVA_SKIP_AUTH` | No | Set `"true"` to disable JWKS auth (dev only) |
|
|
1109
|
-
| `DATABASE_URL` | Yes | Prisma connection string |
|
|
1110
|
-
|
|
1111
|
-
### CLI / Build
|
|
1112
|
-
|
|
1113
|
-
| Variable | Secret | Used By |
|
|
1114
|
-
|---|---|---|
|
|
1115
|
-
| `DIGITALOCEAN_ACCESS_TOKEN` | Yes | Docker registry login (`nova integrations push`) |
|
|
1116
|
-
| `NPM_TOKEN` | Yes | Docker build arg for private npm packages |
|
|
1117
|
-
| `GIT_SHA` | No | Docker image tag fallback |
|
|
1118
|
-
| `NOVA_DOCKER_REGISTRY` | No | Override registry prefix (default: `registry.digitalocean.com/nhc`) |
|
|
1119
|
-
| `ODYSSEY_UI_URL` | No | Override Odyssey UI URL (default from CLI config) |
|
|
1120
|
-
| `OPENAI_API_KEY` | Yes | AI-assisted scaffolding (`nova integrations new`) |
|
|
1121
|
-
|
|
1122
|
-
---
|
|
1123
|
-
|
|
1124
|
-
## Validation Rules
|
|
1125
|
-
|
|
1126
|
-
`validateIntegration()` runs automatically during `nova integrations build`. Here's what it checks:
|
|
1127
|
-
|
|
1128
|
-
### Errors (block build/push)
|
|
1129
|
-
|
|
1130
|
-
| Check | Condition |
|
|
1131
|
-
|---|---|
|
|
1132
|
-
| `slug` required | Must be present and non-empty |
|
|
1133
|
-
| `name` required | Must be present and non-empty |
|
|
1134
|
-
| `queue` required | Must be present and non-empty |
|
|
1135
|
-
| `baseUrl` required | Must be present and non-empty |
|
|
1136
|
-
| At least 1 action | `actions` must have ≥1 entry |
|
|
1137
|
-
| OAuth endpoints | `authorizationEndpoint` + `tokenEndpoint` required for oauth2/oidc |
|
|
1138
|
-
|
|
1139
|
-
### Warnings (non-blocking)
|
|
1140
|
-
|
|
1141
|
-
| Check | Condition |
|
|
1142
|
-
|---|---|
|
|
1143
|
-
| No scopes | OAuth2/OIDC should have ≥1 scope |
|
|
1144
|
-
| Missing OIDC fields | `jwksUri`, `userinfoEndpoint` recommended for OIDC |
|
|
1145
|
-
| Non-HTTPS URLs | All endpoint URLs should use HTTPS (except localhost) |
|
|
1146
|
-
| No schemas | At least one entity schema recommended |
|
|
1147
|
-
| No health action | `health` or `healthCheck` action recommended |
|
|
1148
|
-
| No envSpec | Environment variables won't be validated at deploy time |
|
|
1149
|
-
|
|
1150
|
-
---
|
|
1151
|
-
|
|
1152
|
-
## Architecture Diagram
|
|
1153
|
-
|
|
1154
|
-
```
|
|
1155
|
-
src/index.ts
|
|
1156
|
-
└─ defineIntegration({
|
|
1157
|
-
schemas: { employee: schema("entity", EmployeeZod) }
|
|
1158
|
-
events: { employee_synced: event("outbound", ...) }
|
|
1159
|
-
actions: { syncEmployees: action({ scopes, triggers, sync, handler }) }
|
|
1160
|
-
})
|
|
1161
|
-
│
|
|
1162
|
-
│ nova integrations build
|
|
1163
|
-
▼
|
|
1164
|
-
dist/index.cjs (production bundle — runs in distroless Docker)
|
|
1165
|
-
dist/schemas/ (JSON Schema files)
|
|
1166
|
-
nova-integration.yaml (spec document)
|
|
1167
|
-
│
|
|
1168
|
-
│ nova integrations push
|
|
1169
|
-
▼
|
|
1170
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
1171
|
-
│ Docker Registry registry.digitalocean.com/nhc/{slug}:{tag} │
|
|
1172
|
-
├─────────────────────────────────────────────────────────────────┤
|
|
1173
|
-
│ Platform DB app_integrations │
|
|
1174
|
-
│ app_integration_schemas │
|
|
1175
|
-
│ app_integration_events │
|
|
1176
|
-
│ app_integration_functions │
|
|
1177
|
-
│ integration_sync_pairs │
|
|
1178
|
-
│ integration_field_mappings │
|
|
1179
|
-
│ app_webhook_types │
|
|
1180
|
-
│ app_integration_sync_entities │
|
|
1181
|
-
├─────────────────────────────────────────────────────────────────┤
|
|
1182
|
-
│ Events Service event_types (outbound event registration) │
|
|
1183
|
-
│ event_subscriptions (PGMQ queue routing) │
|
|
1184
|
-
├─────────────────────────────────────────────────────────────────┤
|
|
1185
|
-
│ Orchestrator Container registered, queue ensured │
|
|
1186
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
1187
|
-
```
|
|
1
|
+
# @newhomestar/sdk
|
|
2
|
+
|
|
3
|
+
> Type-safe SDK for building Nova integrations, workers, and services.
|
|
4
|
+
> Code-first: TypeScript + Zod is the **single source of truth**.
|
|
5
|
+
> Build: `nova integrations build` → `dist/` + `nova-integration.yaml` + JSON schemas.
|
|
6
|
+
> Push: `nova integrations push` → Docker image + full config sync to platform DB.
|
|
7
|
+
|
|
8
|
+
**Version:** 0.8.3
|
|
9
|
+
**Runtime:** Node 20 + esbuild CJS bundle
|
|
10
|
+
**Schema system:** Zod v4 → JSON Schema at build time
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Installation](#installation)
|
|
17
|
+
- [Quick Start](#quick-start)
|
|
18
|
+
- [Export Paths](#export-paths)
|
|
19
|
+
- [Core Concepts](#core-concepts)
|
|
20
|
+
- [`defineIntegration()`](#defineintegration)
|
|
21
|
+
- [`schema()`](#schema)
|
|
22
|
+
- [`event()`](#event)
|
|
23
|
+
- [`action()`](#action)
|
|
24
|
+
- [`ActionCtx`](#actionctx)
|
|
25
|
+
- [Running the Integration](#running-the-integration)
|
|
26
|
+
- [`runHttpServer()`](#runhttpserver)
|
|
27
|
+
- [`runDualMode()`](#rundualmode)
|
|
28
|
+
- [`runWorker()`](#runworker)
|
|
29
|
+
- [Which runtime mode to use](#which-runtime-mode-to-use)
|
|
30
|
+
- [Events System (`@newhomestar/sdk/events`)](#events-system)
|
|
31
|
+
- [Outbound Events (Producer)](#outbound-events-producer)
|
|
32
|
+
- [Inbound Events (Consumer)](#inbound-events-consumer)
|
|
33
|
+
- [Webhook ACID Processing](#webhook-acid-processing)
|
|
34
|
+
- [Outbox Relay](#outbox-relay)
|
|
35
|
+
- [Echo Loop Prevention](#echo-loop-prevention)
|
|
36
|
+
- [Topic Format Convention](#topic-format-convention)
|
|
37
|
+
- [Credential Resolution](#credential-resolution)
|
|
38
|
+
- [HTTP Callback Strategy](#http-callback-strategy)
|
|
39
|
+
- [`ctx.resolveCredentials()` and `ctx.fetch()`](#ctxresolvecredentials-and-ctxfetch)
|
|
40
|
+
- [`createIntegrationClient()`](#createintegrationclient)
|
|
41
|
+
- [`emitPlatformEvent()`](#emitplatformevent)
|
|
42
|
+
- [Parameter Metadata (`ParamMeta`)](#parameter-metadata-parammeta)
|
|
43
|
+
- [Sync Mappings](#sync-mappings)
|
|
44
|
+
- [Webhook Configuration](#webhook-configuration)
|
|
45
|
+
- [Dual Database Pattern](#dual-database-pattern)
|
|
46
|
+
- [`nova-integration.yaml` Spec](#nova-integrationyaml-spec)
|
|
47
|
+
- [CLI Reference (`nova integrations`)](#cli-reference)
|
|
48
|
+
- [`nova integrations new`](#nova-integrations-new)
|
|
49
|
+
- [`nova integrations build`](#nova-integrations-build)
|
|
50
|
+
- [`nova integrations push`](#nova-integrations-push)
|
|
51
|
+
- [Environment Variables](#environment-variables)
|
|
52
|
+
- [Validation Rules](#validation-rules)
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
yarn add @newhomestar/sdk zod
|
|
60
|
+
# or
|
|
61
|
+
npm install @newhomestar/sdk zod
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Peer dependency: `zod >= 4.0.0`
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Quick Start
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import { defineIntegration, schema, event, action, runHttpServer } from "@newhomestar/sdk";
|
|
72
|
+
import { z } from "zod";
|
|
73
|
+
|
|
74
|
+
const Employee = schema("entity", z.object({
|
|
75
|
+
id: z.string(),
|
|
76
|
+
email: z.string().email(),
|
|
77
|
+
name: z.string(),
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
const integration = defineIntegration({
|
|
81
|
+
slug: "my_provider",
|
|
82
|
+
name: "My Provider",
|
|
83
|
+
integrationType: "oauth2",
|
|
84
|
+
queue: "my_provider_queue",
|
|
85
|
+
baseUrl: "https://api.provider.com",
|
|
86
|
+
authorizationEndpoint: "https://provider.com/authorize",
|
|
87
|
+
tokenEndpoint: "https://provider.com/token",
|
|
88
|
+
scopes: ["openid", "profile"],
|
|
89
|
+
|
|
90
|
+
schemas: { employee: Employee },
|
|
91
|
+
events: {
|
|
92
|
+
employee_synced: event("outbound", { payload: Employee, category: "sync" }),
|
|
93
|
+
},
|
|
94
|
+
actions: {
|
|
95
|
+
health: action({
|
|
96
|
+
method: "GET", path: "/health",
|
|
97
|
+
input: z.object({}),
|
|
98
|
+
output: z.object({ ok: z.boolean() }),
|
|
99
|
+
handler: async () => ({ ok: true }),
|
|
100
|
+
}),
|
|
101
|
+
listEmployees: action({
|
|
102
|
+
method: "GET", path: "/employees",
|
|
103
|
+
scopes: ["employee:read"],
|
|
104
|
+
input: z.object({ limit: z.number().default(100) }),
|
|
105
|
+
output: z.object({ employees: z.array(z.any()), total: z.number() }),
|
|
106
|
+
handler: async (input, ctx) => {
|
|
107
|
+
const creds = await ctx.resolveCredentials();
|
|
108
|
+
const res = await ctx.fetch("https://api.provider.com/employees");
|
|
109
|
+
const data = await res.json();
|
|
110
|
+
return { employees: data.items, total: data.total };
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export default integration;
|
|
117
|
+
|
|
118
|
+
// Runtime
|
|
119
|
+
runHttpServer(integration as any, { port: 8000 });
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Export Paths
|
|
125
|
+
|
|
126
|
+
The SDK exposes three subpath exports:
|
|
127
|
+
|
|
128
|
+
| Import Path | Description |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `@newhomestar/sdk` | Core: `defineIntegration`, `action`, `schema`, `event`, `runHttpServer`, `runDualMode`, `runWorker`, credential helpers |
|
|
131
|
+
| `@newhomestar/sdk/events` | Events system: `withServiceEventOutbox`, `startPollConsumer`, `startInboundConsumer`, `queueEvent`, `logEvent`, `isIntegrationSync` |
|
|
132
|
+
| `@newhomestar/sdk/next` | Next.js helpers (for service projects) |
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Core Concepts
|
|
137
|
+
|
|
138
|
+
### `defineIntegration()`
|
|
139
|
+
|
|
140
|
+
The central function that registers an integration definition — the **single source of truth** for both the containerized runtime AND the platform configuration.
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import { defineIntegration } from "@newhomestar/sdk";
|
|
144
|
+
|
|
145
|
+
export default defineIntegration({
|
|
146
|
+
// ── Identity ──
|
|
147
|
+
slug: "bamboohr", // snake_case, unique across platform
|
|
148
|
+
name: "BambooHR",
|
|
149
|
+
displayName: "BambooHR HRIS", // shown in admin UI
|
|
150
|
+
description: "...",
|
|
151
|
+
|
|
152
|
+
// ── Classification ──
|
|
153
|
+
integrationType: "oauth2", // "oidc" | "oauth2" | "api_key"
|
|
154
|
+
category: "hris",
|
|
155
|
+
tags: ["hr", "employee-sync"],
|
|
156
|
+
|
|
157
|
+
// ── Branding ──
|
|
158
|
+
logoUrl: "https://...",
|
|
159
|
+
color: "#73C41D",
|
|
160
|
+
icon: "https://...",
|
|
161
|
+
|
|
162
|
+
// ── OAuth/OIDC Endpoints ──
|
|
163
|
+
authorizationEndpoint: "https://...",
|
|
164
|
+
tokenEndpoint: "https://...",
|
|
165
|
+
userinfoEndpoint: "https://...", // optional
|
|
166
|
+
revocationEndpoint: "https://...", // optional
|
|
167
|
+
jwksUri: "https://...", // OIDC only
|
|
168
|
+
baseUrl: "https://api.bamboohr.com/",
|
|
169
|
+
scopes: ["openid", "profile"],
|
|
170
|
+
|
|
171
|
+
// ── Container Runtime ──
|
|
172
|
+
queue: "bamboohr_queue",
|
|
173
|
+
resources: { cpu: "500m", memory: "512Mi" },
|
|
174
|
+
envSpec: [
|
|
175
|
+
{ name: "NOVA_EVENTS_SERVICE_URL", secret: false },
|
|
176
|
+
{ name: "NOVA_SERVICE_TOKEN", secret: true },
|
|
177
|
+
// ...
|
|
178
|
+
],
|
|
179
|
+
|
|
180
|
+
// ── Configuration ──
|
|
181
|
+
schemas: { /* ... */ },
|
|
182
|
+
events: { /* ... */ },
|
|
183
|
+
actions: { /* ... */ },
|
|
184
|
+
syncMappings: { /* ... */ }, // optional
|
|
185
|
+
webhooks: { /* ... */ }, // optional
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### 7-Phase Normalization Pipeline
|
|
190
|
+
|
|
191
|
+
When `defineIntegration()` is called, it runs these phases in order:
|
|
192
|
+
|
|
193
|
+
| Phase | Description |
|
|
194
|
+
|---|---|
|
|
195
|
+
| **1. Normalize schemas** | Fills `slug` and `name` on lean `schema()` results from their dictionary key (e.g., key `employee` → slug: `"employee"`, name: `"Employee"`) |
|
|
196
|
+
| **2. Normalize events** | Fills `slug`/`name` on lean `event()` results; resolves direct payload schema object references → string slugs for `payloadSchema` |
|
|
197
|
+
| **3. Auto-extract functions** | Every action is promoted to an `IntegrationFunctionDef`. HTTP method, path, scopes, capabilities are copied. `__inputZod`, `__outputZod`, `__paramsMeta` stored as non-enumerable props for the build step to convert to JSON Schema |
|
|
198
|
+
| **4. Zod validation** | Runs `IntegrationDefSchema.parse(def)` — structural validation of all fields |
|
|
199
|
+
| **5. Cross-validate event schemas** | Checks that every `event.payloadSchema` string reference exists in `schemas` |
|
|
200
|
+
| **6. Cross-validate function schemas** | Checks that `requestSchema` / `responseSchema` references exist in `schemas` |
|
|
201
|
+
| **7. Validate webhook handler** | Checks that `webhooks.handler` references an existing action |
|
|
202
|
+
|
|
203
|
+
#### `IntegrationDef` Field Reference
|
|
204
|
+
|
|
205
|
+
| Field | Required | Type | Description |
|
|
206
|
+
|---|---|---|---|
|
|
207
|
+
| `slug` | ✅ | `string` | Unique snake_case identifier (e.g., `"bamboohr"`) |
|
|
208
|
+
| `name` | ✅ | `string` | Human-readable name |
|
|
209
|
+
| `integrationType` | ✅ | `"oidc" \| "oauth2" \| "api_key"` | Auth type |
|
|
210
|
+
| `queue` | ✅ | `string` | PGMQ queue name for async processing |
|
|
211
|
+
| `baseUrl` | ✅ | `string` | Base URL for external API calls |
|
|
212
|
+
| `actions` | ✅ | `Record<string, ActionDef>` | Runtime action handlers |
|
|
213
|
+
| `schemas` | ✅ | `Record<string, IntegrationSchemaDef>` | Zod schemas → JSON Schema on build |
|
|
214
|
+
| `events` | ✅ | `Record<string, IntegrationEventDef>` | Event definitions |
|
|
215
|
+
| `description` | | `string` | Short description |
|
|
216
|
+
| `category` | | `string` | Grouping (e.g., `"hris"`, `"crm"`) |
|
|
217
|
+
| `logoUrl` | | `string` | Logo for admin dashboard |
|
|
218
|
+
| `color` | | `string` | Brand color hex code |
|
|
219
|
+
| `displayName` | | `string` | Admin UI display name |
|
|
220
|
+
| `tags` | | `string[]` | Discovery/filtering tags |
|
|
221
|
+
| `authorizationEndpoint` | OAuth2/OIDC | `string` | OAuth authorize URL |
|
|
222
|
+
| `tokenEndpoint` | OAuth2/OIDC | `string` | OAuth token URL |
|
|
223
|
+
| `userinfoEndpoint` | | `string` | OIDC userinfo URL |
|
|
224
|
+
| `revocationEndpoint` | | `string` | Token revocation URL |
|
|
225
|
+
| `jwksUri` | OIDC recommended | `string` | JWKS URI for token verification |
|
|
226
|
+
| `scopes` | | `string[]` | OAuth scopes to request |
|
|
227
|
+
| `resources` | | `{ cpu, memory }` | Container resource limits |
|
|
228
|
+
| `envSpec` | | `Array<{ name, secret, default? }>` | Environment variable spec |
|
|
229
|
+
| `syncMappings` | | `Record<string, SyncMappingDef>` | Field mapping rules |
|
|
230
|
+
| `webhooks` | | `WebhookConfig` | Inbound webhook types |
|
|
231
|
+
| `functions` | Auto-generated | `Record<string, IntegrationFunctionDef>` | Auto-extracted from actions (Phase 3) |
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
### `schema()`
|
|
236
|
+
|
|
237
|
+
Lean helper to define Zod schemas. Slug and name are inferred from the key in `schemas: {}`.
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
import { schema } from "@newhomestar/sdk";
|
|
241
|
+
import { z } from "zod";
|
|
242
|
+
|
|
243
|
+
// slug = "employee", name = "Employee", schemaType = "entity"
|
|
244
|
+
const Employee = schema("entity", z.object({
|
|
245
|
+
id: z.string(),
|
|
246
|
+
email: z.string().email(),
|
|
247
|
+
firstName: z.string(),
|
|
248
|
+
lastName: z.string(),
|
|
249
|
+
status: z.enum(["active", "inactive", "terminated"]),
|
|
250
|
+
}), {
|
|
251
|
+
version: "1.0.0",
|
|
252
|
+
description: "BambooHR employee record",
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
#### Schema Types
|
|
257
|
+
|
|
258
|
+
| Type | Use for |
|
|
259
|
+
|---|---|
|
|
260
|
+
| `entity` | Domain objects (employees, contacts, issues) |
|
|
261
|
+
| `request` | API request body shapes |
|
|
262
|
+
| `response` | API response shapes |
|
|
263
|
+
| `webhook_payload` | Inbound webhook payload shapes |
|
|
264
|
+
| `configuration` | Integration settings/config |
|
|
265
|
+
|
|
266
|
+
#### Verbose Alternative
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
import { integrationSchema } from "@newhomestar/sdk";
|
|
270
|
+
|
|
271
|
+
const Employee = integrationSchema({
|
|
272
|
+
name: "Employee",
|
|
273
|
+
slug: "employee",
|
|
274
|
+
schemaType: "entity",
|
|
275
|
+
schema: z.object({ /* ... */ }),
|
|
276
|
+
version: "1.0.0",
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
### `event()`
|
|
283
|
+
|
|
284
|
+
Lean helper to define integration events. Slug and name are inferred from the key.
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
import { event } from "@newhomestar/sdk";
|
|
288
|
+
|
|
289
|
+
const events = {
|
|
290
|
+
// slug = "employee_synced", name = "Employee Synced"
|
|
291
|
+
employee_synced: event("outbound", {
|
|
292
|
+
payload: Employee, // direct ref to schema() result (type-safe!)
|
|
293
|
+
category: "sync",
|
|
294
|
+
severity: "info", // "info" | "warning" | "error" | "critical"
|
|
295
|
+
}),
|
|
296
|
+
|
|
297
|
+
webhook_received: event("inbound", {
|
|
298
|
+
payload: WebhookPayload,
|
|
299
|
+
category: "webhook",
|
|
300
|
+
}),
|
|
301
|
+
|
|
302
|
+
sync_failed: event("outbound", {
|
|
303
|
+
severity: "error",
|
|
304
|
+
category: "sync",
|
|
305
|
+
}),
|
|
306
|
+
};
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
#### Event Directions
|
|
310
|
+
|
|
311
|
+
| Direction | Meaning |
|
|
312
|
+
|---|---|
|
|
313
|
+
| `outbound` | Integration → Platform (e.g., sync completed, data changed) |
|
|
314
|
+
| `inbound` | Platform → Integration (e.g., webhook received) |
|
|
315
|
+
| `bidirectional` | Both directions |
|
|
316
|
+
|
|
317
|
+
Outbound events are **auto-registered** in the platform `event_types` table when you run `nova integrations push`.
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
### `action()`
|
|
322
|
+
|
|
323
|
+
The universal action builder. Every action is also auto-registered as a function in the platform DB.
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
import { action } from "@newhomestar/sdk";
|
|
327
|
+
import { z } from "zod";
|
|
328
|
+
|
|
329
|
+
const syncEmployees = action({
|
|
330
|
+
// ── HTTP Routing ──
|
|
331
|
+
method: "POST", // GET | POST | PUT | DELETE | PATCH
|
|
332
|
+
path: "/employees/sync",
|
|
333
|
+
|
|
334
|
+
// ── Zod I/O ──
|
|
335
|
+
input: z.object({
|
|
336
|
+
tenantId: z.string().uuid(),
|
|
337
|
+
since: z.string().datetime().optional(),
|
|
338
|
+
}),
|
|
339
|
+
output: z.object({
|
|
340
|
+
synced: z.number(),
|
|
341
|
+
errors: z.number(),
|
|
342
|
+
}),
|
|
343
|
+
|
|
344
|
+
// ── Function Metadata ──
|
|
345
|
+
name: "syncEmployees", // optional, defaults to key name
|
|
346
|
+
description: "Full employee sync",
|
|
347
|
+
scopes: ["employee:read"], // presence → auto-register as function
|
|
348
|
+
category: "employees",
|
|
349
|
+
|
|
350
|
+
// ── Parameter Metadata (for admin UI form builder) ──
|
|
351
|
+
params: {
|
|
352
|
+
tenantId: { in: "body", uiType: "uuid", label: "Tenant ID", required: true },
|
|
353
|
+
since: { in: "body", uiType: "datetime", label: "Since" },
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
// ── Triggers ──
|
|
357
|
+
triggers: [
|
|
358
|
+
{ type: "schedule", cron: "0 */6 * * *", timezone: "UTC", description: "Every 6 hours" },
|
|
359
|
+
{ type: "event", events: ["hris.employee_updated"] },
|
|
360
|
+
],
|
|
361
|
+
|
|
362
|
+
// ── Sync Metadata (for DataSync UI tab) ──
|
|
363
|
+
sync: {
|
|
364
|
+
entityType: "employee",
|
|
365
|
+
direction: "to_nova", // "to_nova" | "from_nova" | "bidirectional"
|
|
366
|
+
label: "BambooHR Employees",
|
|
367
|
+
description: "Import all employees from BambooHR",
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
// ── Expandable Relations (batch foreign-key resolution) ──
|
|
371
|
+
expandable: {
|
|
372
|
+
supervisor: {
|
|
373
|
+
model: "employee",
|
|
374
|
+
resolver: async (ids, ctx) => {
|
|
375
|
+
// Batch-fetch supervisors by ID → Map<id, fullObject>
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
// ── Handler ──
|
|
381
|
+
async handler(input, ctx) {
|
|
382
|
+
ctx.progress(10, { status: "starting" });
|
|
383
|
+
const res = await ctx.fetch("https://api.bamboohr.com/v1/employees/directory");
|
|
384
|
+
const data = await res.json();
|
|
385
|
+
ctx.progress(100, { status: "complete" });
|
|
386
|
+
return { synced: data.employees.length, errors: 0 };
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
#### `action()` Full Field Reference
|
|
392
|
+
|
|
393
|
+
| Field | Type | Description |
|
|
394
|
+
|---|---|---|
|
|
395
|
+
| `method` | `string` | HTTP method (default: `"POST"`) |
|
|
396
|
+
| `path` | `string` | Route path (default: `/{workerName}/{actionName}`) |
|
|
397
|
+
| `input` | `ZodType` | Zod schema for input validation |
|
|
398
|
+
| `output` | `ZodType` | Zod schema for output validation |
|
|
399
|
+
| `handler` | `(input, ctx) => Promise<output>` | Action implementation |
|
|
400
|
+
| `name` | `string` | Display name (default: key name) |
|
|
401
|
+
| `description` | `string` | Human-readable description |
|
|
402
|
+
| `scopes` | `string[]` | OAuth scopes → auto-registers as a platform function |
|
|
403
|
+
| `category` | `string` | Grouping in the admin UI |
|
|
404
|
+
| `params` | `Record<string, ParamMeta>` | Per-field metadata for path/query/body routing + UI hints |
|
|
405
|
+
| `triggers` | `Array<EventTrigger \| ScheduleTrigger>` | Event subscriptions and cron schedules |
|
|
406
|
+
| `sync` | `{ entityType, direction, label, description? }` | DataSync tab metadata |
|
|
407
|
+
| `expandable` | `Record<string, { model, resolver }>` | `?expand=field1,field2` batch resolvers |
|
|
408
|
+
| `capabilities` | `Array<Capability>` | Legacy: webhook, scheduled, queue, stream triggers |
|
|
409
|
+
| `fga` | `{ resourceType, relation, resourceIdKey? }` | OpenFGA authorization hints |
|
|
410
|
+
| `events` | `string \| string[]` | Shorthand for `triggers: [{ type: 'event', events: [...] }]` |
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
### `ActionCtx`
|
|
415
|
+
|
|
416
|
+
Every action handler receives a context object (`ctx`) with these methods:
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
async handler(input: Input, ctx: ActionCtx) {
|
|
420
|
+
// ── Job tracking ──
|
|
421
|
+
ctx.jobId; // unique job ID (e.g., "http-1234567890")
|
|
422
|
+
ctx.progress(50, { step: "fetching" }); // report progress to platform
|
|
423
|
+
|
|
424
|
+
// ── Authentication ──
|
|
425
|
+
ctx.authToken; // raw Bearer token from inbound request
|
|
426
|
+
ctx.auth; // validated JWT payload (sub, iss, exp, aud, etc.)
|
|
427
|
+
|
|
428
|
+
// ── Credential Resolution ──
|
|
429
|
+
const creds = await ctx.resolveCredentials();
|
|
430
|
+
// OR: const creds = await ctx.resolveCredentials("other_integration", userId);
|
|
431
|
+
|
|
432
|
+
// ── mTLS-aware Fetch (with 401 auto-retry) ──
|
|
433
|
+
const res = await ctx.fetch("https://api.provider.com/data");
|
|
434
|
+
// OR: const res = await ctx.fetch(url, { method: "POST", body: "..." }, creds);
|
|
435
|
+
|
|
436
|
+
// ── HTTP headers (HTTP mode only) ──
|
|
437
|
+
ctx.headers; // raw request headers
|
|
438
|
+
|
|
439
|
+
// ── SSE worker mode only ──
|
|
440
|
+
ctx.read_ct; // message delivery count
|
|
441
|
+
await ctx.heartbeat?.(30); // extend visibility timeout by 30s
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
#### `ctx.resolveCredentials()` Flow
|
|
446
|
+
|
|
447
|
+
1. Calls `GET {AUTH_ISSUER_BASE_URL}/api/integrations/{slug}/credentials` with the inbound JWT
|
|
448
|
+
2. Auth server decrypts credentials from Vault and returns them
|
|
449
|
+
3. SDK performs OAuth token exchange locally (`client_credentials` or `mTLS`)
|
|
450
|
+
4. Returns `ResolvedCredentials` with `accessToken`, `expiresAt`, `authMode`, `httpsAgent`
|
|
451
|
+
|
|
452
|
+
#### `ctx.fetch()` 401 Auto-Retry
|
|
453
|
+
|
|
454
|
+
1. Makes the request with current `accessToken`
|
|
455
|
+
2. If 401 → sends `X-Nova-Token-Invalid: true` to auth server → gets fresh credentials
|
|
456
|
+
3. If the new token is different → retries once automatically
|
|
457
|
+
4. If same token → returns original 401 (prevents infinite loops)
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
## Running the Integration
|
|
462
|
+
|
|
463
|
+
### `runHttpServer()`
|
|
464
|
+
|
|
465
|
+
Starts an Express HTTP server with one route per action.
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
import { runHttpServer } from "@newhomestar/sdk";
|
|
469
|
+
|
|
470
|
+
runHttpServer(integration as any, {
|
|
471
|
+
port: 8000,
|
|
472
|
+
issuerBaseURL: "https://auth.newhomeconnect.dev",
|
|
473
|
+
audience: "starfleet",
|
|
474
|
+
publicPaths: ["/webhooks"], // exempt from JWKS auth
|
|
475
|
+
skipAuth: false, // or set NOVA_SKIP_AUTH=true
|
|
476
|
+
});
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
**Features:**
|
|
480
|
+
- **JWKS JWT authentication** via `express-oauth2-jwt-bearer` (RS256)
|
|
481
|
+
- `/health` and `/healthcheck` are auto-exempted from auth
|
|
482
|
+
- **Smart input extraction**: params metadata → path/query/body/header; type coercion for query strings
|
|
483
|
+
- **`?expand=field1,field2`** — batch foreign-key resolution via `expandable` config
|
|
484
|
+
|
|
485
|
+
### `runDualMode()`
|
|
486
|
+
|
|
487
|
+
Starts both HTTP server and queue consumer concurrently.
|
|
488
|
+
|
|
489
|
+
```typescript
|
|
490
|
+
import { runDualMode } from "@newhomestar/sdk";
|
|
491
|
+
|
|
492
|
+
runDualMode(integration as any, { port: 8000 });
|
|
493
|
+
// HTTP API + SSE/pgmq queue consumer in background
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
> **⚠️ Warning:** If you also use `startPollConsumer()` for inbound event processing, use `runHttpServer()` instead of `runDualMode()` to avoid competing consumers on the same queue.
|
|
497
|
+
|
|
498
|
+
### `runWorker()`
|
|
499
|
+
|
|
500
|
+
Queue-only mode. Automatically selects:
|
|
501
|
+
- **SSE mode** (preferred): when `NOVA_EVENTS_SERVICE_URL` + `NOVA_SERVICE_TOKEN` are set
|
|
502
|
+
- **Legacy pgmq mode**: when `RUNTIME_SUPABASE_*` env vars are set
|
|
503
|
+
|
|
504
|
+
### Which Runtime Mode to Use
|
|
505
|
+
|
|
506
|
+
| Scenario | Recommended Mode |
|
|
507
|
+
|---|---|
|
|
508
|
+
| Integration with HTTP API + separate poll consumer | `runHttpServer()` + `startPollConsumer()` |
|
|
509
|
+
| Integration with HTTP API + built-in SSE consumer | `runDualMode()` |
|
|
510
|
+
| Pure queue worker (no HTTP) | `runWorker()` |
|
|
511
|
+
| Background/headless sync loops | `createIntegrationClient()` + `setImmediate(processLoop)` |
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
## Events System
|
|
516
|
+
|
|
517
|
+
Import from `@newhomestar/sdk/events`:
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
import {
|
|
521
|
+
withServiceEventOutbox,
|
|
522
|
+
withEventOutbox,
|
|
523
|
+
withWebhookEvent,
|
|
524
|
+
queueEvent,
|
|
525
|
+
logEvent,
|
|
526
|
+
startOutboxRelay,
|
|
527
|
+
startInboundConsumer,
|
|
528
|
+
startPollConsumer,
|
|
529
|
+
isIntegrationSync,
|
|
530
|
+
NovaEventsClient,
|
|
531
|
+
} from "@newhomestar/sdk/events";
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
### Outbound Events (Producer)
|
|
535
|
+
|
|
536
|
+
#### `withServiceEventOutbox()` — Preferred
|
|
537
|
+
|
|
538
|
+
Atomic: data write + outbox row in one Prisma `$transaction`, then best-effort relay. Auto-stamps `x-source`/`x-integration-id` from request headers.
|
|
539
|
+
|
|
540
|
+
```typescript
|
|
541
|
+
const employee = await withServiceEventOutbox(db, req, async (tx, emit) => {
|
|
542
|
+
const row = await tx.hrisEmployee.update({ where: { id }, data });
|
|
543
|
+
emit("employee.updated", { id: row.id, firstName: row.firstName });
|
|
544
|
+
return row;
|
|
545
|
+
});
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
#### `withEventOutbox()` — Legacy
|
|
549
|
+
|
|
550
|
+
Same atomicity but with a `{ events, result }` return shape:
|
|
551
|
+
|
|
552
|
+
```typescript
|
|
553
|
+
const employee = await withEventOutbox(db, async (tx) => {
|
|
554
|
+
const row = await tx.hrisEmployee.update({ where: { id }, data });
|
|
555
|
+
return {
|
|
556
|
+
events: [{ entity_type: "employee", action: "updated", entity_id: id }],
|
|
557
|
+
result: row,
|
|
558
|
+
};
|
|
559
|
+
});
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
#### `queueEvent()` — Stateless
|
|
563
|
+
|
|
564
|
+
No transaction context. Directly POSTs to the Events Service.
|
|
565
|
+
|
|
566
|
+
```typescript
|
|
567
|
+
await queueEvent({
|
|
568
|
+
event_slug: "bamboohr.employee_synced",
|
|
569
|
+
source_service: "bamboohr",
|
|
570
|
+
attributes: { synced: 150, errors: 0 },
|
|
571
|
+
});
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
#### `logEvent()` — Audit Only
|
|
575
|
+
|
|
576
|
+
Records an event in the audit log **without** queue fan-out.
|
|
577
|
+
|
|
578
|
+
```typescript
|
|
579
|
+
await logEvent({
|
|
580
|
+
event_slug: "bamboohr.employee_viewed",
|
|
581
|
+
entity_id: employeeId,
|
|
582
|
+
performed_by_id: userId,
|
|
583
|
+
});
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
### Inbound Events (Consumer)
|
|
587
|
+
|
|
588
|
+
#### `startPollConsumer()` — Recommended for DO App Platform
|
|
589
|
+
|
|
590
|
+
HTTP pull-based consumer. No persistent connections or proxy timeout issues.
|
|
591
|
+
|
|
592
|
+
```typescript
|
|
593
|
+
const consumer = startPollConsumer(db, {
|
|
594
|
+
queueName: "bamboohr_queue",
|
|
595
|
+
batch: 5, // messages per poll
|
|
596
|
+
vt: 60, // visibility timeout (seconds)
|
|
597
|
+
idleDelayMs: 2000, // sleep when queue is empty
|
|
598
|
+
handlers: {
|
|
599
|
+
"hris.employee_created": async (tx, event) => {
|
|
600
|
+
if (isIntegrationSync(event.payload)) return { status: "skipped" };
|
|
601
|
+
await tx.bambooEmployee.upsert({ /* ... */ });
|
|
602
|
+
return { status: "processed" };
|
|
603
|
+
},
|
|
604
|
+
"hris.employee_updated": async (tx, event) => {
|
|
605
|
+
// ...
|
|
606
|
+
return { status: "processed" };
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
defaultHandler: async (_tx, event) => {
|
|
610
|
+
console.warn(`Unhandled event: ${(event.payload as any).topic}`);
|
|
611
|
+
return { status: "skipped" };
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// Graceful shutdown
|
|
616
|
+
process.on("SIGTERM", () => consumer.abort());
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
#### `startInboundConsumer()` — SSE-Based
|
|
620
|
+
|
|
621
|
+
Long-lived SSE connection. Best for non-DO deployments.
|
|
622
|
+
|
|
623
|
+
```typescript
|
|
624
|
+
const consumer = startInboundConsumer(db, {
|
|
625
|
+
queueName: "bamboohr_queue",
|
|
626
|
+
handlers: { /* same as poll consumer */ },
|
|
627
|
+
vt: 30,
|
|
628
|
+
maxReconnectDelay: 30_000,
|
|
629
|
+
});
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
### Webhook ACID Processing
|
|
633
|
+
|
|
634
|
+
#### `withWebhookEvent()`
|
|
635
|
+
|
|
636
|
+
Atomically processes inbound webhooks with idempotency:
|
|
637
|
+
|
|
638
|
+
```typescript
|
|
639
|
+
const { status } = await withWebhookEvent(db, {
|
|
640
|
+
idempotencyKey: `bamboohr:${webhookId}:${timestamp}`,
|
|
641
|
+
eventType: "bamboohr.employee.updated",
|
|
642
|
+
queueName: "bamboohr_webhooks",
|
|
643
|
+
payload: rawBody,
|
|
644
|
+
}, async (tx, emit) => {
|
|
645
|
+
await tx.bambooEmployee.upsert({ /* ... */ });
|
|
646
|
+
emit("employee.upserted", { id: employee.id });
|
|
647
|
+
});
|
|
648
|
+
// Returns { status: "processed" } or { status: "duplicate" }
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
**ACID guarantee**: idempotency check → inbound_events INSERT → handler → outbox rows → mark processed — all in one `$transaction`. Rolls back entirely on failure.
|
|
652
|
+
|
|
653
|
+
### Outbox Relay
|
|
654
|
+
|
|
655
|
+
Start once at service boot:
|
|
656
|
+
|
|
657
|
+
```typescript
|
|
658
|
+
startOutboxRelay(db, {
|
|
659
|
+
intervalMs: 60_000, // poll every 60s
|
|
660
|
+
maxAttempts: 5, // give up after 5 attempts
|
|
661
|
+
});
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
Retry schedule (exponential backoff): `2^attempts × 5s` → 5s, 10s, 20s, 40s, 80s.
|
|
665
|
+
|
|
666
|
+
### Echo Loop Prevention
|
|
667
|
+
|
|
668
|
+
```typescript
|
|
669
|
+
import { isIntegrationSync } from "@newhomestar/sdk/events";
|
|
670
|
+
|
|
671
|
+
// In a consumer handler:
|
|
672
|
+
if (isIntegrationSync(event.payload)) {
|
|
673
|
+
return { status: "skipped" }; // Don't write back to the system that caused this event
|
|
674
|
+
}
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
Checks for `x-source: integration_sync` in headers or `metadata.source === "integration_sync"` in event payloads.
|
|
678
|
+
|
|
679
|
+
### Topic Format Convention
|
|
680
|
+
|
|
681
|
+
```
|
|
682
|
+
{source_service}.{entity}_{action}
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
Examples:
|
|
686
|
+
- `hris.employee_created`
|
|
687
|
+
- `bamboohr.user_synced`
|
|
688
|
+
- `nova_ticketing_service.ticket_updated`
|
|
689
|
+
|
|
690
|
+
---
|
|
691
|
+
|
|
692
|
+
## Credential Resolution
|
|
693
|
+
|
|
694
|
+
### HTTP Callback Strategy
|
|
695
|
+
|
|
696
|
+
The **only** credential resolution strategy. No direct database access needed.
|
|
697
|
+
|
|
698
|
+
```
|
|
699
|
+
Container ──JWT──> Auth Server ──Vault──> Decrypted Credentials
|
|
700
|
+
(AUTH_ISSUER_BASE_URL)
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
**Flow:**
|
|
704
|
+
1. Container receives request with JWT (validated by JWKS middleware)
|
|
705
|
+
2. Action calls `ctx.resolveCredentials()`
|
|
706
|
+
3. SDK calls `GET {AUTH_ISSUER_BASE_URL}/api/integrations/{slug}/credentials` with `Authorization: Bearer {jwt}`
|
|
707
|
+
4. Auth server verifies JWT, decrypts credentials from Vault, returns them
|
|
708
|
+
5. SDK performs local OAuth token exchange (`client_credentials` / `mTLS`)
|
|
709
|
+
6. Container uses the `accessToken` to call the integration API
|
|
710
|
+
|
|
711
|
+
### `ctx.resolveCredentials()` and `ctx.fetch()`
|
|
712
|
+
|
|
713
|
+
```typescript
|
|
714
|
+
// Auto-resolve for the current integration
|
|
715
|
+
const creds = await ctx.resolveCredentials();
|
|
716
|
+
|
|
717
|
+
// Resolve for a different integration
|
|
718
|
+
const creds = await ctx.resolveCredentials("other_integration", userId);
|
|
719
|
+
|
|
720
|
+
// ctx.fetch() — resolves credentials automatically + 401 auto-retry
|
|
721
|
+
const res = await ctx.fetch("https://api.provider.com/employees");
|
|
722
|
+
|
|
723
|
+
// ctx.fetch() with explicit credentials
|
|
724
|
+
const res = await ctx.fetch(url, { method: "POST", body: "..." }, creds);
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
**Auth modes** returned by `ResolvedCredentials`:
|
|
728
|
+
- `standard` — authorization_code (per-user, pre-resolved access token)
|
|
729
|
+
- `client_credentials` — server-to-server OAuth (SDK performs token exchange)
|
|
730
|
+
- `mtls` — client_credentials + mTLS cert/key (SDK uses `node:https` agent)
|
|
731
|
+
|
|
732
|
+
### `createIntegrationClient()`
|
|
733
|
+
|
|
734
|
+
For background/headless sync loops that don't have an inbound JWT:
|
|
735
|
+
|
|
736
|
+
```typescript
|
|
737
|
+
import { createIntegrationClient } from "@newhomestar/sdk";
|
|
738
|
+
|
|
739
|
+
const client = createIntegrationClient("bamboohr");
|
|
740
|
+
// Resolves via NOVA_SERVICE_TOKEN (not user JWT)
|
|
741
|
+
|
|
742
|
+
const res = await client.fetch("https://api.bamboohr.com/v1/employees/directory");
|
|
743
|
+
const data = await res.json();
|
|
744
|
+
|
|
745
|
+
// Auto-refreshes credentials when they expire (60s buffer)
|
|
746
|
+
// Auto-retries on 401 with fresh credentials
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
### `emitPlatformEvent()`
|
|
750
|
+
|
|
751
|
+
Emit PGMQ events to the platform database for cross-service communication:
|
|
752
|
+
|
|
753
|
+
```typescript
|
|
754
|
+
import { createPlatformClient, emitPlatformEvent } from "@newhomestar/sdk";
|
|
755
|
+
|
|
756
|
+
const platformDB = createPlatformClient();
|
|
757
|
+
await emitPlatformEvent(platformDB, "employee.sync_complete", "bamboohr", {
|
|
758
|
+
tenantId, synced: 150, errors: 0,
|
|
759
|
+
});
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
---
|
|
763
|
+
|
|
764
|
+
## Parameter Metadata (`ParamMeta`)
|
|
765
|
+
|
|
766
|
+
Per-field metadata that tells the platform where each input field goes (path, query, body, header) and what UI widget the admin dashboard should render.
|
|
767
|
+
|
|
768
|
+
```typescript
|
|
769
|
+
params: {
|
|
770
|
+
id: { in: "path", uiType: "text", label: "Employee ID", required: true },
|
|
771
|
+
asOfDate: { in: "query", uiType: "date", label: "As-of Date", placeholder: "YYYY-MM-DD" },
|
|
772
|
+
syncType: { in: "body", uiType: "select", label: "Sync Type", options: [
|
|
773
|
+
{ label: "Full", value: "full" },
|
|
774
|
+
{ label: "Incremental", value: "incremental" },
|
|
775
|
+
]},
|
|
776
|
+
limit: { in: "query", uiType: "number", label: "Limit", defaultValue: 100, min: 1, max: 500 },
|
|
777
|
+
}
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
#### `ParamMeta` Field Reference
|
|
781
|
+
|
|
782
|
+
| Field | Type | Description |
|
|
783
|
+
|---|---|---|
|
|
784
|
+
| `in` | `"path" \| "query" \| "body" \| "header"` | Where the parameter goes in the HTTP request |
|
|
785
|
+
| `uiType` | `ParamUiType` | UI widget: `text`, `textarea`, `number`, `integer`, `boolean`, `date`, `datetime`, `select`, `multiselect`, `password`, `email`, `url`, `uuid`, `json`, `hidden` |
|
|
786
|
+
| `label` | `string` | Human-readable label |
|
|
787
|
+
| `description` | `string` | Help text below the input |
|
|
788
|
+
| `placeholder` | `string` | Placeholder text |
|
|
789
|
+
| `required` | `boolean` | Overrides Zod's optional/required |
|
|
790
|
+
| `defaultValue` | `unknown` | Default value in UI |
|
|
791
|
+
| `options` | `Array<{ label, value }>` | For select/multiselect |
|
|
792
|
+
| `min` / `max` | `number` | Range for numbers, length for strings |
|
|
793
|
+
| `step` | `number` | Increment for number inputs |
|
|
794
|
+
| `pattern` | `string` | Regex for frontend validation |
|
|
795
|
+
| `order` | `number` | Display priority (lower = higher) |
|
|
796
|
+
| `group` | `string` | Visual grouping (e.g., `"Identity"`, `"Options"`) |
|
|
797
|
+
|
|
798
|
+
**Convention fallback** (when `params` not provided):
|
|
799
|
+
- Fields matching `:param` in path → path params
|
|
800
|
+
- Remaining fields for GET → query params
|
|
801
|
+
- Remaining fields for POST/PUT/PATCH → body params
|
|
802
|
+
- UI type inferred from Zod type (string→text, number→number, boolean→boolean, enum→select)
|
|
803
|
+
|
|
804
|
+
---
|
|
805
|
+
|
|
806
|
+
## Sync Mappings
|
|
807
|
+
|
|
808
|
+
Declare how integration entities map to Nova service schemas. Seeded into `integration_sync_pairs` + `integration_field_mappings` on push.
|
|
809
|
+
|
|
810
|
+
```typescript
|
|
811
|
+
syncMappings: {
|
|
812
|
+
employee: {
|
|
813
|
+
service: "hris",
|
|
814
|
+
targetSchema: "employee",
|
|
815
|
+
direction: "integration_to_service",
|
|
816
|
+
fields: [
|
|
817
|
+
{ source: "workEmail", target: "work_email" },
|
|
818
|
+
{ source: "firstName", target: "first_name" },
|
|
819
|
+
{ source: "lastName", target: "last_name" },
|
|
820
|
+
{ source: "status", target: "employment_status",
|
|
821
|
+
transform: "status = 'Active' ? 'ACTIVE' : 'INACTIVE'" }, // JSONata
|
|
822
|
+
{ source: "supervisor", sourcePath: ["supervisor", "id"], target: "manager_id" },
|
|
823
|
+
],
|
|
824
|
+
},
|
|
825
|
+
time_off_request: {
|
|
826
|
+
service: "hris",
|
|
827
|
+
targetSchema: "time_off_request",
|
|
828
|
+
direction: "integration_to_service",
|
|
829
|
+
fields: [ /* ... */ ],
|
|
830
|
+
},
|
|
831
|
+
},
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
---
|
|
835
|
+
|
|
836
|
+
## Webhook Configuration
|
|
837
|
+
|
|
838
|
+
Declares inbound webhook types the provider supports:
|
|
839
|
+
|
|
840
|
+
```typescript
|
|
841
|
+
webhooks: {
|
|
842
|
+
handler: "handleWebhook", // must reference an existing action key
|
|
843
|
+
types: {
|
|
844
|
+
employee_changes: {
|
|
845
|
+
label: "Employee Changes",
|
|
846
|
+
description: "Fires when employee records are created, updated, or deleted",
|
|
847
|
+
produces: ["bamboohr.employee.created", "bamboohr.employee.updated", "bamboohr.employee.deleted"],
|
|
848
|
+
authentication: {
|
|
849
|
+
method: "hmac_sha256",
|
|
850
|
+
signatureHeader: "X-BambooHR-Signature",
|
|
851
|
+
secretSource: "platform_generated",
|
|
852
|
+
},
|
|
853
|
+
fields: [
|
|
854
|
+
{ name: "events", label: "Event Types", type: "multiselect", required: true,
|
|
855
|
+
options: [
|
|
856
|
+
{ label: "Created", value: "created" },
|
|
857
|
+
{ label: "Updated", value: "updated" },
|
|
858
|
+
{ label: "Deleted", value: "deleted" },
|
|
859
|
+
]},
|
|
860
|
+
],
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
Synced to `app_webhook_types` on push. The Odyssey UI renders configuration forms from the `fields` array.
|
|
867
|
+
|
|
868
|
+
---
|
|
869
|
+
|
|
870
|
+
## Dual Database Pattern
|
|
871
|
+
|
|
872
|
+
Each integration connects to **two** databases:
|
|
873
|
+
|
|
874
|
+
```
|
|
875
|
+
┌─────────────────────────┐ ┌────────────────────────────┐
|
|
876
|
+
│ Platform DB │ │ Integration DB │
|
|
877
|
+
│ (read-only) │ │ (read-write) │
|
|
878
|
+
│ │ │ │
|
|
879
|
+
│ • OAuth tokens │ │ • domain tables │
|
|
880
|
+
│ • Tenant config │ │ • sync jobs │
|
|
881
|
+
│ • PGMQ queues │ │ • webhook events │
|
|
882
|
+
│ │ │ • inbound/outbound events │
|
|
883
|
+
│ PLATFORM_SUPABASE_URL │ │ INTEGRATION_SUPABASE_URL │
|
|
884
|
+
│ getPlatformClient() │ │ getIntegrationClient() │
|
|
885
|
+
└─────────────────────────┘ └────────────────────────────┘
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
```typescript
|
|
889
|
+
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
|
890
|
+
|
|
891
|
+
function getPlatformClient(): SupabaseClient {
|
|
892
|
+
return createClient(
|
|
893
|
+
process.env.PLATFORM_SUPABASE_URL!,
|
|
894
|
+
process.env.PLATFORM_SUPABASE_SERVICE_ROLE_KEY!,
|
|
895
|
+
{ auth: { autoRefreshToken: false, persistSession: false } }
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function getIntegrationClient(): SupabaseClient {
|
|
900
|
+
return createClient(
|
|
901
|
+
process.env.INTEGRATION_SUPABASE_URL!,
|
|
902
|
+
process.env.INTEGRATION_SUPABASE_SERVICE_ROLE_KEY!,
|
|
903
|
+
{ auth: { autoRefreshToken: false, persistSession: false } }
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
---
|
|
909
|
+
|
|
910
|
+
## `nova-integration.yaml` Spec
|
|
911
|
+
|
|
912
|
+
Generated by `nova integrations build`. Full annotated example:
|
|
913
|
+
|
|
914
|
+
```yaml
|
|
915
|
+
apiVersion: nova.dev/v1
|
|
916
|
+
kind: Integration
|
|
917
|
+
metadata:
|
|
918
|
+
slug: bamboohr
|
|
919
|
+
name: BambooHR
|
|
920
|
+
displayName: BambooHR HRIS
|
|
921
|
+
description: Connects BambooHR HRIS to Nova
|
|
922
|
+
category: hris
|
|
923
|
+
tags: [hr, employee-sync]
|
|
924
|
+
logoUrl: https://...
|
|
925
|
+
color: "#73C41D"
|
|
926
|
+
spec:
|
|
927
|
+
type: oauth2 # oidc | oauth2 | api_key
|
|
928
|
+
endpoints:
|
|
929
|
+
authorization: https://bamboohr.com/authorize.php
|
|
930
|
+
token: https://bamboohr.com/token.php
|
|
931
|
+
baseUrl: https://api.bamboohr.com/api/gateway.php/newhomestar
|
|
932
|
+
scopes: [employee, time_off, time_tracking] # merged from all action scopes
|
|
933
|
+
runtime:
|
|
934
|
+
type: integration
|
|
935
|
+
image: registry.digitalocean.com/nhc/bamboohr:main
|
|
936
|
+
queue: bamboohr_queue
|
|
937
|
+
command: [node, dist/index.cjs]
|
|
938
|
+
resources: { cpu: 500m, memory: 512Mi }
|
|
939
|
+
envSpec:
|
|
940
|
+
- { name: NOVA_EVENTS_SERVICE_URL, secret: false }
|
|
941
|
+
- { name: NOVA_SERVICE_TOKEN, secret: true }
|
|
942
|
+
actions:
|
|
943
|
+
- name: syncEmployees
|
|
944
|
+
async: true
|
|
945
|
+
triggers:
|
|
946
|
+
- { type: schedule, cron: "0 */6 * * *", timezone: UTC }
|
|
947
|
+
scopes: [employee:read]
|
|
948
|
+
sync:
|
|
949
|
+
entityType: employee
|
|
950
|
+
direction: to_nova
|
|
951
|
+
label: BambooHR Employees
|
|
952
|
+
input: { ... } # JSON Schema (converted from Zod)
|
|
953
|
+
output: { ... }
|
|
954
|
+
schema:
|
|
955
|
+
input: ./schemas/syncEmployees.input.json
|
|
956
|
+
output: ./schemas/syncEmployees.output.json
|
|
957
|
+
schemas:
|
|
958
|
+
- slug: employee
|
|
959
|
+
name: Employee
|
|
960
|
+
type: entity
|
|
961
|
+
schema: { type: object, properties: { ... } }
|
|
962
|
+
version: "1.0.0"
|
|
963
|
+
fieldCount: 42
|
|
964
|
+
events:
|
|
965
|
+
- slug: employee_synced
|
|
966
|
+
name: Employee Synced
|
|
967
|
+
direction: outbound
|
|
968
|
+
category: sync
|
|
969
|
+
severity: info
|
|
970
|
+
payloadSchema: employee
|
|
971
|
+
functions: # auto-extracted from actions
|
|
972
|
+
- slug: sync_employees
|
|
973
|
+
name: Sync Employees
|
|
974
|
+
httpMethod: POST
|
|
975
|
+
endpointPath: /employees/sync
|
|
976
|
+
requiredScopes: [employee:read]
|
|
977
|
+
category: employees
|
|
978
|
+
syncMappings:
|
|
979
|
+
- sourceEntity: employee
|
|
980
|
+
service: hris
|
|
981
|
+
targetSchema: employee
|
|
982
|
+
direction: integration_to_service
|
|
983
|
+
fields:
|
|
984
|
+
- { source: workEmail, target: work_email }
|
|
985
|
+
- { source: status, target: employment_status,
|
|
986
|
+
transform: "status = 'Active' ? 'ACTIVE' : 'INACTIVE'" }
|
|
987
|
+
webhookTypes:
|
|
988
|
+
- slug: employee_changes
|
|
989
|
+
label: Employee Changes
|
|
990
|
+
produces: [bamboohr.employee.created, bamboohr.employee.updated]
|
|
991
|
+
handler: handle_webhook
|
|
992
|
+
build:
|
|
993
|
+
dockerfile: ./Dockerfile
|
|
994
|
+
context: .
|
|
995
|
+
ui:
|
|
996
|
+
category: hris
|
|
997
|
+
color: "#73C41D"
|
|
998
|
+
```
|
|
999
|
+
|
|
1000
|
+
---
|
|
1001
|
+
|
|
1002
|
+
## CLI Reference
|
|
1003
|
+
|
|
1004
|
+
### `nova integrations new`
|
|
1005
|
+
|
|
1006
|
+
Scaffolds a new integration project.
|
|
1007
|
+
|
|
1008
|
+
```bash
|
|
1009
|
+
nova integrations new [directory]
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
**Interactive prompts:**
|
|
1013
|
+
1. Integration slug (snake_case) — if no directory arg
|
|
1014
|
+
2. Integration type: OIDC, OAuth2, or API Key
|
|
1015
|
+
|
|
1016
|
+
**Generates:**
|
|
1017
|
+
| File | Purpose |
|
|
1018
|
+
|---|---|
|
|
1019
|
+
| `src/index.ts` | Full starter integration with `defineIntegration()`, schemas, events, actions, poll consumer |
|
|
1020
|
+
| `src/lib/db.ts` | Prisma singleton client |
|
|
1021
|
+
| `package.json` | `@newhomestar/sdk@0.6.0`, `zod`, `prisma` |
|
|
1022
|
+
| `prisma/schema.prisma` | Baseline Prisma schema with outbox models |
|
|
1023
|
+
| `Dockerfile` | Distroless Node 20 container |
|
|
1024
|
+
| `tsconfig.json` | TypeScript configuration |
|
|
1025
|
+
| `Agents.md` | LLM agent instructions |
|
|
1026
|
+
| `supabase/` | Local Supabase config + migrations directory |
|
|
1027
|
+
|
|
1028
|
+
**Post-scaffold:** runs `git init`, `corepack enable`, `yarn install`
|
|
1029
|
+
|
|
1030
|
+
**Optional AI assist:** generates starter schemas/events from a plain-English description using OpenAI.
|
|
1031
|
+
|
|
1032
|
+
### `nova integrations build`
|
|
1033
|
+
|
|
1034
|
+
Bundles the integration and generates all build artifacts.
|
|
1035
|
+
|
|
1036
|
+
```bash
|
|
1037
|
+
nova integrations build [-o dist] [--dry-run] [--skip-bundle]
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
**8-Step Pipeline:**
|
|
1041
|
+
|
|
1042
|
+
| Step | What happens |
|
|
1043
|
+
|---|---|
|
|
1044
|
+
| 1. **Pass 1 esbuild** | Bundles `src/index.ts` → `dist/index.build.cjs` with `zod` **external** (so the CLI can use the integration's Zod for `instanceof` checks) |
|
|
1045
|
+
| 2. **Dynamic import** | Loads the build-time bundle, finds `export default defineIntegration({...})` |
|
|
1046
|
+
| 3. **Validate** | Runs `validateIntegration()` — checks slug, name, schemas, baseUrl, actions |
|
|
1047
|
+
| 4. **Load Zod v4** | From integration's `node_modules`; uses `z.toJSONSchema()` for schema conversion |
|
|
1048
|
+
| 5. **Convert schemas** | Each `schemas.*` entry → JSON Schema file at `dist/schemas/{slug}.json` |
|
|
1049
|
+
| 6. **Convert events + functions** | Extracts metadata; converts `__inputZod`/`__outputZod` → `inputSchema`/`outputSchema` JSON |
|
|
1050
|
+
| 7. **Convert actions** | Zod I/O → JSON Schema; serializes `triggers`, `scopes`, `sync` blocks |
|
|
1051
|
+
| 8. **Pass 2 esbuild** | Production bundle with `ignoreAnnotations: true` (zod fully inlined, zero node_modules at runtime) |
|
|
1052
|
+
|
|
1053
|
+
**Scope rollup:** auto-collects all `scopes` from all actions, extracts base names (`employee:read` → `employee`), merges with top-level `def.scopes`.
|
|
1054
|
+
|
|
1055
|
+
**Output:**
|
|
1056
|
+
- `dist/index.cjs` — production bundle (~50MB image in distroless Docker)
|
|
1057
|
+
- `dist/schemas/*.json` — JSON Schema files per entity and action I/O
|
|
1058
|
+
- `nova-integration.yaml` — the full spec document
|
|
1059
|
+
|
|
1060
|
+
### `nova integrations push`
|
|
1061
|
+
|
|
1062
|
+
Builds, pushes Docker image, and syncs all configuration to the platform.
|
|
1063
|
+
|
|
1064
|
+
```bash
|
|
1065
|
+
nova integrations push [--skip-build] [--skip-secrets-fetch] [-d|--destructive] [--tag <t>] [--registry <r>]
|
|
1066
|
+
```
|
|
1067
|
+
|
|
1068
|
+
**11-Step Pipeline:**
|
|
1069
|
+
|
|
1070
|
+
| Step | Description |
|
|
1071
|
+
|---|---|
|
|
1072
|
+
| 1. **Fetch secrets** | `POST {odysseyUrl}/api/secrets` using stored CLI session token |
|
|
1073
|
+
| 2. **Build integration** | Runs `buildIntegration()` → spec + `nova-integration.yaml` |
|
|
1074
|
+
| 3. **Docker registry login** | `doctl registry login` (for DigitalOcean registry) |
|
|
1075
|
+
| 4. **Docker build** | `docker build --platform linux/amd64 -t {registry}/{slug}:{tag} .` |
|
|
1076
|
+
| 5. **Docker push** | `docker push {image}` |
|
|
1077
|
+
| 6. **Register container** | `POST /api/integrations` — metadata, image, queue, actions |
|
|
1078
|
+
| 7. **Sync config** | `POST /api/integrations/config` — upsert schemas, events, functions |
|
|
1079
|
+
| 8. **Seed sync mappings** | `POST /api/integrations/sync-mappings` — `integration_sync_pairs` + `integration_field_mappings` |
|
|
1080
|
+
| 8b. **Sync entity types** | `POST /api/integrations/sync-entities` — DataSync tab entries from `action.sync` blocks |
|
|
1081
|
+
| 8c. **Sync webhook types** | `POST /api/integrations/webhook-types` — `app_webhook_types` rows |
|
|
1082
|
+
| 9. **Register outbound events** | `POST /api/integration-events/sync` + `POST {NOVA_EVENTS_SERVICE_URL}/event-types` |
|
|
1083
|
+
| 10. **Register triggers** | Ensure PGMQ queue exists; upsert `event_subscriptions` for each trigger topic |
|
|
1084
|
+
| 11. **Sync trigger registry** | `POST /api/event-triggers/sync` — platform DB for UI display |
|
|
1085
|
+
|
|
1086
|
+
**Tag defaulting:** current git branch name (sanitized to `[a-z0-9.-]`)
|
|
1087
|
+
|
|
1088
|
+
**`--destructive` flag:** deletes all existing endpoints/schemas/events before upserting (guarantees clean slate).
|
|
1089
|
+
|
|
1090
|
+
---
|
|
1091
|
+
|
|
1092
|
+
## Environment Variables
|
|
1093
|
+
|
|
1094
|
+
### Runtime (Container)
|
|
1095
|
+
|
|
1096
|
+
| Variable | Secret | Required By |
|
|
1097
|
+
|---|---|---|
|
|
1098
|
+
| `NOVA_EVENTS_SERVICE_URL` | No | `startPollConsumer`, `queueEvent`, `withEventOutbox` |
|
|
1099
|
+
| `NOVA_SERVICE_TOKEN` | Yes | Events Service auth, `createIntegrationClient()` |
|
|
1100
|
+
| `AUTH_ISSUER_BASE_URL` | No | `ctx.resolveCredentials()` — auth server URL |
|
|
1101
|
+
| `AUTH_AUDIENCE` | No | JWKS audience claim (default: `"starfleet"`) |
|
|
1102
|
+
| `PLATFORM_SUPABASE_URL` | No | `createPlatformClient()`, `emitPlatformEvent()` |
|
|
1103
|
+
| `PLATFORM_SUPABASE_SERVICE_ROLE_KEY` | Yes | Platform DB service role |
|
|
1104
|
+
| `INTEGRATION_SUPABASE_URL` | No | Integration's own database |
|
|
1105
|
+
| `INTEGRATION_SUPABASE_SERVICE_ROLE_KEY` | Yes | Integration DB service role |
|
|
1106
|
+
| `NOVA_SERVICE_SLUG` | No | Stamped as `source_service` on outbox events |
|
|
1107
|
+
| `PORT` | No | HTTP server port (default: `8000`) |
|
|
1108
|
+
| `NOVA_SKIP_AUTH` | No | Set `"true"` to disable JWKS auth (dev only) |
|
|
1109
|
+
| `DATABASE_URL` | Yes | Prisma connection string |
|
|
1110
|
+
|
|
1111
|
+
### CLI / Build
|
|
1112
|
+
|
|
1113
|
+
| Variable | Secret | Used By |
|
|
1114
|
+
|---|---|---|
|
|
1115
|
+
| `DIGITALOCEAN_ACCESS_TOKEN` | Yes | Docker registry login (`nova integrations push`) |
|
|
1116
|
+
| `NPM_TOKEN` | Yes | Docker build arg for private npm packages |
|
|
1117
|
+
| `GIT_SHA` | No | Docker image tag fallback |
|
|
1118
|
+
| `NOVA_DOCKER_REGISTRY` | No | Override registry prefix (default: `registry.digitalocean.com/nhc`) |
|
|
1119
|
+
| `ODYSSEY_UI_URL` | No | Override Odyssey UI URL (default from CLI config) |
|
|
1120
|
+
| `OPENAI_API_KEY` | Yes | AI-assisted scaffolding (`nova integrations new`) |
|
|
1121
|
+
|
|
1122
|
+
---
|
|
1123
|
+
|
|
1124
|
+
## Validation Rules
|
|
1125
|
+
|
|
1126
|
+
`validateIntegration()` runs automatically during `nova integrations build`. Here's what it checks:
|
|
1127
|
+
|
|
1128
|
+
### Errors (block build/push)
|
|
1129
|
+
|
|
1130
|
+
| Check | Condition |
|
|
1131
|
+
|---|---|
|
|
1132
|
+
| `slug` required | Must be present and non-empty |
|
|
1133
|
+
| `name` required | Must be present and non-empty |
|
|
1134
|
+
| `queue` required | Must be present and non-empty |
|
|
1135
|
+
| `baseUrl` required | Must be present and non-empty |
|
|
1136
|
+
| At least 1 action | `actions` must have ≥1 entry |
|
|
1137
|
+
| OAuth endpoints | `authorizationEndpoint` + `tokenEndpoint` required for oauth2/oidc |
|
|
1138
|
+
|
|
1139
|
+
### Warnings (non-blocking)
|
|
1140
|
+
|
|
1141
|
+
| Check | Condition |
|
|
1142
|
+
|---|---|
|
|
1143
|
+
| No scopes | OAuth2/OIDC should have ≥1 scope |
|
|
1144
|
+
| Missing OIDC fields | `jwksUri`, `userinfoEndpoint` recommended for OIDC |
|
|
1145
|
+
| Non-HTTPS URLs | All endpoint URLs should use HTTPS (except localhost) |
|
|
1146
|
+
| No schemas | At least one entity schema recommended |
|
|
1147
|
+
| No health action | `health` or `healthCheck` action recommended |
|
|
1148
|
+
| No envSpec | Environment variables won't be validated at deploy time |
|
|
1149
|
+
|
|
1150
|
+
---
|
|
1151
|
+
|
|
1152
|
+
## Architecture Diagram
|
|
1153
|
+
|
|
1154
|
+
```
|
|
1155
|
+
src/index.ts
|
|
1156
|
+
└─ defineIntegration({
|
|
1157
|
+
schemas: { employee: schema("entity", EmployeeZod) }
|
|
1158
|
+
events: { employee_synced: event("outbound", ...) }
|
|
1159
|
+
actions: { syncEmployees: action({ scopes, triggers, sync, handler }) }
|
|
1160
|
+
})
|
|
1161
|
+
│
|
|
1162
|
+
│ nova integrations build
|
|
1163
|
+
▼
|
|
1164
|
+
dist/index.cjs (production bundle — runs in distroless Docker)
|
|
1165
|
+
dist/schemas/ (JSON Schema files)
|
|
1166
|
+
nova-integration.yaml (spec document)
|
|
1167
|
+
│
|
|
1168
|
+
│ nova integrations push
|
|
1169
|
+
▼
|
|
1170
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
1171
|
+
│ Docker Registry registry.digitalocean.com/nhc/{slug}:{tag} │
|
|
1172
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1173
|
+
│ Platform DB app_integrations │
|
|
1174
|
+
│ app_integration_schemas │
|
|
1175
|
+
│ app_integration_events │
|
|
1176
|
+
│ app_integration_functions │
|
|
1177
|
+
│ integration_sync_pairs │
|
|
1178
|
+
│ integration_field_mappings │
|
|
1179
|
+
│ app_webhook_types │
|
|
1180
|
+
│ app_integration_sync_entities │
|
|
1181
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1182
|
+
│ Events Service event_types (outbound event registration) │
|
|
1183
|
+
│ event_subscriptions (PGMQ queue routing) │
|
|
1184
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
1185
|
+
│ Orchestrator Container registered, queue ensured │
|
|
1186
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
1187
|
+
```
|