@newhomestar/sdk 0.8.3 → 0.8.5

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 ADDED
@@ -0,0 +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
+ ```