@newhomestar/sdk
v0.8.18
Published
Type-safe SDK for building Nova pipelines (workers & functions)
Readme
@newhomestar/sdk
Type-safe SDK for building Nova integrations, workers, and services.
Code-first: TypeScript + Zod is the single source of truth.
Build:nova integrations build→dist/+nova-integration.yaml+ JSON schemas.
Push:nova integrations push→ Docker image + full config sync to platform DB.
Version: 0.8.3
Runtime: Node 20 + esbuild CJS bundle
Schema system: Zod v4 → JSON Schema at build time
Table of Contents
- Installation
- Quick Start
- Export Paths
- Core Concepts
- Running the Integration
- Events System (
@newhomestar/sdk/events) - Credential Resolution
- Parameter Metadata (
ParamMeta) - Sync Mappings
- Webhook Configuration
- Dual Database Pattern
nova-integration.yamlSpec- CLI Reference (
nova integrations) - Environment Variables
- Validation Rules
Installation
yarn add @newhomestar/sdk zod
# or
npm install @newhomestar/sdk zodPeer dependency: zod >= 4.0.0
Quick Start
import { defineIntegration, schema, event, action, runHttpServer } from "@newhomestar/sdk";
import { z } from "zod";
const Employee = schema("entity", z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
}));
const integration = defineIntegration({
slug: "my_provider",
name: "My Provider",
integrationType: "oauth2",
queue: "my_provider_queue",
baseUrl: "https://api.provider.com",
authorizationEndpoint: "https://provider.com/authorize",
tokenEndpoint: "https://provider.com/token",
scopes: ["openid", "profile"],
schemas: { employee: Employee },
events: {
employee_synced: event("outbound", { payload: Employee, category: "sync" }),
},
actions: {
health: action({
method: "GET", path: "/health",
input: z.object({}),
output: z.object({ ok: z.boolean() }),
handler: async () => ({ ok: true }),
}),
listEmployees: action({
method: "GET", path: "/employees",
scopes: ["employee:read"],
input: z.object({ limit: z.number().default(100) }),
output: z.object({ employees: z.array(z.any()), total: z.number() }),
handler: async (input, ctx) => {
const creds = await ctx.resolveCredentials();
const res = await ctx.fetch("https://api.provider.com/employees");
const data = await res.json();
return { employees: data.items, total: data.total };
},
}),
},
});
export default integration;
// Runtime
runHttpServer(integration as any, { port: 8000 });Export Paths
The SDK exposes three subpath exports:
| Import Path | Description |
|---|---|
| @newhomestar/sdk | Core: defineIntegration, action, schema, event, runHttpServer, runDualMode, runWorker, credential helpers |
| @newhomestar/sdk/events | Events system: withServiceEventOutbox, startPollConsumer, startInboundConsumer, queueEvent, logEvent, isIntegrationSync |
| @newhomestar/sdk/next | Next.js helpers (for service projects) |
Core Concepts
defineIntegration()
The central function that registers an integration definition — the single source of truth for both the containerized runtime AND the platform configuration.
import { defineIntegration } from "@newhomestar/sdk";
export default defineIntegration({
// ── Identity ──
slug: "bamboohr", // snake_case, unique across platform
name: "BambooHR",
displayName: "BambooHR HRIS", // shown in admin UI
description: "...",
// ── Classification ──
integrationType: "oauth2", // "oidc" | "oauth2" | "api_key"
category: "hris",
tags: ["hr", "employee-sync"],
// ── Branding ──
logoUrl: "https://...",
color: "#73C41D",
icon: "https://...",
// ── OAuth/OIDC Endpoints ──
authorizationEndpoint: "https://...",
tokenEndpoint: "https://...",
userinfoEndpoint: "https://...", // optional
revocationEndpoint: "https://...", // optional
jwksUri: "https://...", // OIDC only
baseUrl: "https://api.bamboohr.com/",
scopes: ["openid", "profile"],
// ── Container Runtime ──
queue: "bamboohr_queue",
resources: { cpu: "500m", memory: "512Mi" },
envSpec: [
{ name: "NOVA_EVENTS_SERVICE_URL", secret: false },
{ name: "NOVA_SERVICE_TOKEN", secret: true },
// ...
],
// ── Configuration ──
schemas: { /* ... */ },
events: { /* ... */ },
actions: { /* ... */ },
syncMappings: { /* ... */ }, // optional
webhooks: { /* ... */ }, // optional
});7-Phase Normalization Pipeline
When defineIntegration() is called, it runs these phases in order:
| Phase | Description |
|---|---|
| 1. Normalize schemas | Fills slug and name on lean schema() results from their dictionary key (e.g., key employee → slug: "employee", name: "Employee") |
| 2. Normalize events | Fills slug/name on lean event() results; resolves direct payload schema object references → string slugs for payloadSchema |
| 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 |
| 4. Zod validation | Runs IntegrationDefSchema.parse(def) — structural validation of all fields |
| 5. Cross-validate event schemas | Checks that every event.payloadSchema string reference exists in schemas |
| 6. Cross-validate function schemas | Checks that requestSchema / responseSchema references exist in schemas |
| 7. Validate webhook handler | Checks that webhooks.handler references an existing action |
IntegrationDef Field Reference
| Field | Required | Type | Description |
|---|---|---|---|
| slug | ✅ | string | Unique snake_case identifier (e.g., "bamboohr") |
| name | ✅ | string | Human-readable name |
| integrationType | ✅ | "oidc" \| "oauth2" \| "api_key" | Auth type |
| queue | ✅ | string | PGMQ queue name for async processing |
| baseUrl | ✅ | string | Base URL for external API calls |
| actions | ✅ | Record<string, ActionDef> | Runtime action handlers |
| schemas | ✅ | Record<string, IntegrationSchemaDef> | Zod schemas → JSON Schema on build |
| events | ✅ | Record<string, IntegrationEventDef> | Event definitions |
| description | | string | Short description |
| category | | string | Grouping (e.g., "hris", "crm") |
| logoUrl | | string | Logo for admin dashboard |
| color | | string | Brand color hex code |
| displayName | | string | Admin UI display name |
| tags | | string[] | Discovery/filtering tags |
| authorizationEndpoint | OAuth2/OIDC | string | OAuth authorize URL |
| tokenEndpoint | OAuth2/OIDC | string | OAuth token URL |
| userinfoEndpoint | | string | OIDC userinfo URL |
| revocationEndpoint | | string | Token revocation URL |
| jwksUri | OIDC recommended | string | JWKS URI for token verification |
| scopes | | string[] | OAuth scopes to request |
| resources | | { cpu, memory } | Container resource limits |
| envSpec | | Array<{ name, secret, default? }> | Environment variable spec |
| syncMappings | | Record<string, SyncMappingDef> | Field mapping rules |
| webhooks | | WebhookConfig | Inbound webhook types |
| functions | Auto-generated | Record<string, IntegrationFunctionDef> | Auto-extracted from actions (Phase 3) |
schema()
Lean helper to define Zod schemas. Slug and name are inferred from the key in schemas: {}.
import { schema } from "@newhomestar/sdk";
import { z } from "zod";
// slug = "employee", name = "Employee", schemaType = "entity"
const Employee = schema("entity", z.object({
id: z.string(),
email: z.string().email(),
firstName: z.string(),
lastName: z.string(),
status: z.enum(["active", "inactive", "terminated"]),
}), {
version: "1.0.0",
description: "BambooHR employee record",
});Schema Types
| Type | Use for |
|---|---|
| entity | Domain objects (employees, contacts, issues) |
| request | API request body shapes |
| response | API response shapes |
| webhook_payload | Inbound webhook payload shapes |
| configuration | Integration settings/config |
Verbose Alternative
import { integrationSchema } from "@newhomestar/sdk";
const Employee = integrationSchema({
name: "Employee",
slug: "employee",
schemaType: "entity",
schema: z.object({ /* ... */ }),
version: "1.0.0",
});event()
Lean helper to define integration events. Slug and name are inferred from the key.
import { event } from "@newhomestar/sdk";
const events = {
// slug = "employee_synced", name = "Employee Synced"
employee_synced: event("outbound", {
payload: Employee, // direct ref to schema() result (type-safe!)
category: "sync",
severity: "info", // "info" | "warning" | "error" | "critical"
}),
webhook_received: event("inbound", {
payload: WebhookPayload,
category: "webhook",
}),
sync_failed: event("outbound", {
severity: "error",
category: "sync",
}),
};Event Directions
| Direction | Meaning |
|---|---|
| outbound | Integration → Platform (e.g., sync completed, data changed) |
| inbound | Platform → Integration (e.g., webhook received) |
| bidirectional | Both directions |
Outbound events are auto-registered in the platform event_types table when you run nova integrations push.
action()
The universal action builder. Every action is also auto-registered as a function in the platform DB.
import { action } from "@newhomestar/sdk";
import { z } from "zod";
const syncEmployees = action({
// ── HTTP Routing ──
method: "POST", // GET | POST | PUT | DELETE | PATCH
path: "/employees/sync",
// ── Zod I/O ──
input: z.object({
tenantId: z.string().uuid(),
since: z.string().datetime().optional(),
}),
output: z.object({
synced: z.number(),
errors: z.number(),
}),
// ── Function Metadata ──
name: "syncEmployees", // optional, defaults to key name
description: "Full employee sync",
scopes: ["employee:read"], // presence → auto-register as function
category: "employees",
// ── Parameter Metadata (for admin UI form builder) ──
params: {
tenantId: { in: "body", uiType: "uuid", label: "Tenant ID", required: true },
since: { in: "body", uiType: "datetime", label: "Since" },
},
// ── Triggers ──
triggers: [
{ type: "schedule", cron: "0 */6 * * *", timezone: "UTC", description: "Every 6 hours" },
{ type: "event", events: ["hris.employee_updated"] },
],
// ── Sync Metadata (for DataSync UI tab) ──
sync: {
entityType: "employee",
direction: "to_nova", // "to_nova" | "from_nova" | "bidirectional"
label: "BambooHR Employees",
description: "Import all employees from BambooHR",
},
// ── Expandable Relations (batch foreign-key resolution) ──
expandable: {
supervisor: {
model: "employee",
resolver: async (ids, ctx) => {
// Batch-fetch supervisors by ID → Map<id, fullObject>
},
},
},
// ── Handler ──
async handler(input, ctx) {
ctx.progress(10, { status: "starting" });
const res = await ctx.fetch("https://api.bamboohr.com/v1/employees/directory");
const data = await res.json();
ctx.progress(100, { status: "complete" });
return { synced: data.employees.length, errors: 0 };
},
});action() Full Field Reference
| Field | Type | Description |
|---|---|---|
| method | string | HTTP method (default: "POST") |
| path | string | Route path (default: /{workerName}/{actionName}) |
| input | ZodType | Zod schema for input validation |
| output | ZodType | Zod schema for output validation |
| handler | (input, ctx) => Promise<output> | Action implementation |
| name | string | Display name (default: key name) |
| description | string | Human-readable description |
| scopes | string[] | OAuth scopes → auto-registers as a platform function |
| category | string | Grouping in the admin UI |
| params | Record<string, ParamMeta> | Per-field metadata for path/query/body routing + UI hints |
| triggers | Array<EventTrigger \| ScheduleTrigger> | Event subscriptions and cron schedules |
| sync | { entityType, direction, label, description? } | DataSync tab metadata |
| expandable | Record<string, { model, resolver }> | ?expand=field1,field2 batch resolvers |
| capabilities | Array<Capability> | Legacy: webhook, scheduled, queue, stream triggers |
| fga | { resourceType, relation, resourceIdKey? } | OpenFGA authorization hints |
| events | string \| string[] | Shorthand for triggers: [{ type: 'event', events: [...] }] |
ActionCtx
Every action handler receives a context object (ctx) with these methods:
async handler(input: Input, ctx: ActionCtx) {
// ── Job tracking ──
ctx.jobId; // unique job ID (e.g., "http-1234567890")
ctx.progress(50, { step: "fetching" }); // report progress to platform
// ── Authentication ──
ctx.authToken; // raw Bearer token from inbound request
ctx.auth; // validated JWT payload (sub, iss, exp, aud, etc.)
// ── Credential Resolution ──
const creds = await ctx.resolveCredentials();
// OR: const creds = await ctx.resolveCredentials("other_integration", userId);
// ── mTLS-aware Fetch (with 401 auto-retry) ──
const res = await ctx.fetch("https://api.provider.com/data");
// OR: const res = await ctx.fetch(url, { method: "POST", body: "..." }, creds);
// ── HTTP headers (HTTP mode only) ──
ctx.headers; // raw request headers
// ── SSE worker mode only ──
ctx.read_ct; // message delivery count
await ctx.heartbeat?.(30); // extend visibility timeout by 30s
}ctx.resolveCredentials() Flow
- Calls
GET {AUTH_ISSUER_BASE_URL}/api/integrations/{slug}/credentialswith the inbound JWT - Auth server decrypts credentials from Vault and returns them
- SDK performs OAuth token exchange locally (
client_credentialsormTLS) - Returns
ResolvedCredentialswithaccessToken,expiresAt,authMode,httpsAgent
ctx.fetch() 401 Auto-Retry
- Makes the request with current
accessToken - If 401 → sends
X-Nova-Token-Invalid: trueto auth server → gets fresh credentials - If the new token is different → retries once automatically
- If same token → returns original 401 (prevents infinite loops)
Running the Integration
runHttpServer()
Starts an Express HTTP server with one route per action.
import { runHttpServer } from "@newhomestar/sdk";
runHttpServer(integration as any, {
port: 8000,
issuerBaseURL: "https://auth.newhomeconnect.dev",
audience: "starfleet",
publicPaths: ["/webhooks"], // exempt from JWKS auth
skipAuth: false, // or set NOVA_SKIP_AUTH=true
});Features:
- JWKS JWT authentication via
express-oauth2-jwt-bearer(RS256) /healthand/healthcheckare auto-exempted from auth- Smart input extraction: params metadata → path/query/body/header; type coercion for query strings
?expand=field1,field2— batch foreign-key resolution viaexpandableconfig
runDualMode()
Starts both HTTP server and queue consumer concurrently.
import { runDualMode } from "@newhomestar/sdk";
runDualMode(integration as any, { port: 8000 });
// HTTP API + SSE/pgmq queue consumer in background⚠️ Warning: If you also use
startPollConsumer()for inbound event processing, userunHttpServer()instead ofrunDualMode()to avoid competing consumers on the same queue.
runWorker()
Queue-only mode. Automatically selects:
- SSE mode (preferred): when
NOVA_EVENTS_SERVICE_URL+NOVA_SERVICE_TOKENare set - Legacy pgmq mode: when
RUNTIME_SUPABASE_*env vars are set
Which Runtime Mode to Use
| Scenario | Recommended Mode |
|---|---|
| Integration with HTTP API + separate poll consumer | runHttpServer() + startPollConsumer() |
| Integration with HTTP API + built-in SSE consumer | runDualMode() |
| Pure queue worker (no HTTP) | runWorker() |
| Background/headless sync loops | createIntegrationClient() + setImmediate(processLoop) |
Events System
Import from @newhomestar/sdk/events:
import {
withServiceEventOutbox,
withEventOutbox,
withWebhookEvent,
queueEvent,
logEvent,
startOutboxRelay,
startInboundConsumer,
startPollConsumer,
isIntegrationSync,
NovaEventsClient,
} from "@newhomestar/sdk/events";Outbound Events (Producer)
withServiceEventOutbox() — Preferred
Atomic: data write + outbox row in one Prisma $transaction, then best-effort relay. Auto-stamps x-source/x-integration-id from request headers.
const employee = await withServiceEventOutbox(db, req, async (tx, emit) => {
const row = await tx.hrisEmployee.update({ where: { id }, data });
emit("employee.updated", { id: row.id, firstName: row.firstName });
return row;
});withEventOutbox() — Legacy
Same atomicity but with a { events, result } return shape:
const employee = await withEventOutbox(db, async (tx) => {
const row = await tx.hrisEmployee.update({ where: { id }, data });
return {
events: [{ entity_type: "employee", action: "updated", entity_id: id }],
result: row,
};
});queueEvent() — Stateless
No transaction context. Directly POSTs to the Events Service.
await queueEvent({
event_slug: "bamboohr.employee_synced",
source_service: "bamboohr",
attributes: { synced: 150, errors: 0 },
});logEvent() — Audit Only
Records an event in the audit log without queue fan-out.
await logEvent({
event_slug: "bamboohr.employee_viewed",
entity_id: employeeId,
performed_by_id: userId,
});Inbound Events (Consumer)
startPollConsumer() — Recommended for DO App Platform
HTTP pull-based consumer. No persistent connections or proxy timeout issues.
const consumer = startPollConsumer(db, {
queueName: "bamboohr_queue",
batch: 5, // messages per poll
vt: 60, // visibility timeout (seconds)
idleDelayMs: 2000, // sleep when queue is empty
handlers: {
"hris.employee_created": async (tx, event) => {
if (isIntegrationSync(event.payload)) return { status: "skipped" };
await tx.bambooEmployee.upsert({ /* ... */ });
return { status: "processed" };
},
"hris.employee_updated": async (tx, event) => {
// ...
return { status: "processed" };
},
},
defaultHandler: async (_tx, event) => {
console.warn(`Unhandled event: ${(event.payload as any).topic}`);
return { status: "skipped" };
},
});
// Graceful shutdown
process.on("SIGTERM", () => consumer.abort());startInboundConsumer() — SSE-Based
Long-lived SSE connection. Best for non-DO deployments.
const consumer = startInboundConsumer(db, {
queueName: "bamboohr_queue",
handlers: { /* same as poll consumer */ },
vt: 30,
maxReconnectDelay: 30_000,
});Webhook ACID Processing
withWebhookEvent()
Atomically processes inbound webhooks with idempotency:
const { status } = await withWebhookEvent(db, {
idempotencyKey: `bamboohr:${webhookId}:${timestamp}`,
eventType: "bamboohr.employee.updated",
queueName: "bamboohr_webhooks",
payload: rawBody,
}, async (tx, emit) => {
await tx.bambooEmployee.upsert({ /* ... */ });
emit("employee.upserted", { id: employee.id });
});
// Returns { status: "processed" } or { status: "duplicate" }ACID guarantee: idempotency check → inbound_events INSERT → handler → outbox rows → mark processed — all in one $transaction. Rolls back entirely on failure.
Outbox Relay
Start once at service boot:
startOutboxRelay(db, {
intervalMs: 60_000, // poll every 60s
maxAttempts: 5, // give up after 5 attempts
});Retry schedule (exponential backoff): 2^attempts × 5s → 5s, 10s, 20s, 40s, 80s.
Echo Loop Prevention
import { isIntegrationSync } from "@newhomestar/sdk/events";
// In a consumer handler:
if (isIntegrationSync(event.payload)) {
return { status: "skipped" }; // Don't write back to the system that caused this event
}Checks for x-source: integration_sync in headers or metadata.source === "integration_sync" in event payloads.
Topic Format Convention
{source_service}.{entity}_{action}Examples:
hris.employee_createdbamboohr.user_syncednova_ticketing_service.ticket_updated
Credential Resolution
HTTP Callback Strategy
The only credential resolution strategy. No direct database access needed.
Container ──JWT──> Auth Server ──Vault──> Decrypted Credentials
(AUTH_ISSUER_BASE_URL)Flow:
- Container receives request with JWT (validated by JWKS middleware)
- Action calls
ctx.resolveCredentials() - SDK calls
GET {AUTH_ISSUER_BASE_URL}/api/integrations/{slug}/credentialswithAuthorization: Bearer {jwt} - Auth server verifies JWT, decrypts credentials from Vault, returns them
- SDK performs local OAuth token exchange (
client_credentials/mTLS) - Container uses the
accessTokento call the integration API
ctx.resolveCredentials() and ctx.fetch()
// Auto-resolve for the current integration
const creds = await ctx.resolveCredentials();
// Resolve for a different integration
const creds = await ctx.resolveCredentials("other_integration", userId);
// ctx.fetch() — resolves credentials automatically + 401 auto-retry
const res = await ctx.fetch("https://api.provider.com/employees");
// ctx.fetch() with explicit credentials
const res = await ctx.fetch(url, { method: "POST", body: "..." }, creds);Auth modes returned by ResolvedCredentials:
standard— authorization_code (per-user, pre-resolved access token)client_credentials— server-to-server OAuth (SDK performs token exchange)mtls— client_credentials + mTLS cert/key (SDK usesnode:httpsagent)
createIntegrationClient()
For background/headless sync loops that don't have an inbound JWT:
import { createIntegrationClient } from "@newhomestar/sdk";
const client = createIntegrationClient("bamboohr");
// Resolves via NOVA_SERVICE_TOKEN (not user JWT)
const res = await client.fetch("https://api.bamboohr.com/v1/employees/directory");
const data = await res.json();
// Auto-refreshes credentials when they expire (60s buffer)
// Auto-retries on 401 with fresh credentialsemitPlatformEvent()
Emit PGMQ events to the platform database for cross-service communication:
import { createPlatformClient, emitPlatformEvent } from "@newhomestar/sdk";
const platformDB = createPlatformClient();
await emitPlatformEvent(platformDB, "employee.sync_complete", "bamboohr", {
tenantId, synced: 150, errors: 0,
});Parameter Metadata (ParamMeta)
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.
params: {
id: { in: "path", uiType: "text", label: "Employee ID", required: true },
asOfDate: { in: "query", uiType: "date", label: "As-of Date", placeholder: "YYYY-MM-DD" },
syncType: { in: "body", uiType: "select", label: "Sync Type", options: [
{ label: "Full", value: "full" },
{ label: "Incremental", value: "incremental" },
]},
limit: { in: "query", uiType: "number", label: "Limit", defaultValue: 100, min: 1, max: 500 },
}ParamMeta Field Reference
| Field | Type | Description |
|---|---|---|
| in | "path" \| "query" \| "body" \| "header" | Where the parameter goes in the HTTP request |
| uiType | ParamUiType | UI widget: text, textarea, number, integer, boolean, date, datetime, select, multiselect, password, email, url, uuid, json, hidden |
| label | string | Human-readable label |
| description | string | Help text below the input |
| placeholder | string | Placeholder text |
| required | boolean | Overrides Zod's optional/required |
| defaultValue | unknown | Default value in UI |
| options | Array<{ label, value }> | For select/multiselect |
| min / max | number | Range for numbers, length for strings |
| step | number | Increment for number inputs |
| pattern | string | Regex for frontend validation |
| order | number | Display priority (lower = higher) |
| group | string | Visual grouping (e.g., "Identity", "Options") |
Convention fallback (when params not provided):
- Fields matching
:paramin path → path params - Remaining fields for GET → query params
- Remaining fields for POST/PUT/PATCH → body params
- UI type inferred from Zod type (string→text, number→number, boolean→boolean, enum→select)
Sync Mappings
Declare how integration entities map to Nova service schemas. Seeded into integration_sync_pairs + integration_field_mappings on push.
syncMappings: {
employee: {
service: "hris",
targetSchema: "employee",
direction: "integration_to_service",
fields: [
{ source: "workEmail", target: "work_email" },
{ source: "firstName", target: "first_name" },
{ source: "lastName", target: "last_name" },
{ source: "status", target: "employment_status",
transform: "status = 'Active' ? 'ACTIVE' : 'INACTIVE'" }, // JSONata
{ source: "supervisor", sourcePath: ["supervisor", "id"], target: "manager_id" },
],
},
time_off_request: {
service: "hris",
targetSchema: "time_off_request",
direction: "integration_to_service",
fields: [ /* ... */ ],
},
},Webhook Configuration
Declares inbound webhook types the provider supports:
webhooks: {
handler: "handleWebhook", // must reference an existing action key
types: {
employee_changes: {
label: "Employee Changes",
description: "Fires when employee records are created, updated, or deleted",
produces: ["bamboohr.employee.created", "bamboohr.employee.updated", "bamboohr.employee.deleted"],
authentication: {
method: "hmac_sha256",
signatureHeader: "X-BambooHR-Signature",
secretSource: "platform_generated",
},
fields: [
{ name: "events", label: "Event Types", type: "multiselect", required: true,
options: [
{ label: "Created", value: "created" },
{ label: "Updated", value: "updated" },
{ label: "Deleted", value: "deleted" },
]},
],
},
},
},Synced to app_webhook_types on push. The Odyssey UI renders configuration forms from the fields array.
Dual Database Pattern
Each integration connects to two databases:
┌─────────────────────────┐ ┌────────────────────────────┐
│ Platform DB │ │ Integration DB │
│ (read-only) │ │ (read-write) │
│ │ │ │
│ • OAuth tokens │ │ • domain tables │
│ • Tenant config │ │ • sync jobs │
│ • PGMQ queues │ │ • webhook events │
│ │ │ • inbound/outbound events │
│ PLATFORM_SUPABASE_URL │ │ INTEGRATION_SUPABASE_URL │
│ getPlatformClient() │ │ getIntegrationClient() │
└─────────────────────────┘ └────────────────────────────┘import { createClient, type SupabaseClient } from "@supabase/supabase-js";
function getPlatformClient(): SupabaseClient {
return createClient(
process.env.PLATFORM_SUPABASE_URL!,
process.env.PLATFORM_SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
}
function getIntegrationClient(): SupabaseClient {
return createClient(
process.env.INTEGRATION_SUPABASE_URL!,
process.env.INTEGRATION_SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
}nova-integration.yaml Spec
Generated by nova integrations build. Full annotated example:
apiVersion: nova.dev/v1
kind: Integration
metadata:
slug: bamboohr
name: BambooHR
displayName: BambooHR HRIS
description: Connects BambooHR HRIS to Nova
category: hris
tags: [hr, employee-sync]
logoUrl: https://...
color: "#73C41D"
spec:
type: oauth2 # oidc | oauth2 | api_key
endpoints:
authorization: https://bamboohr.com/authorize.php
token: https://bamboohr.com/token.php
baseUrl: https://api.bamboohr.com/api/gateway.php/newhomestar
scopes: [employee, time_off, time_tracking] # merged from all action scopes
runtime:
type: integration
image: registry.digitalocean.com/nhc/bamboohr:main
queue: bamboohr_queue
command: [node, dist/index.cjs]
resources: { cpu: 500m, memory: 512Mi }
envSpec:
- { name: NOVA_EVENTS_SERVICE_URL, secret: false }
- { name: NOVA_SERVICE_TOKEN, secret: true }
actions:
- name: syncEmployees
async: true
triggers:
- { type: schedule, cron: "0 */6 * * *", timezone: UTC }
scopes: [employee:read]
sync:
entityType: employee
direction: to_nova
label: BambooHR Employees
input: { ... } # JSON Schema (converted from Zod)
output: { ... }
schema:
input: ./schemas/syncEmployees.input.json
output: ./schemas/syncEmployees.output.json
schemas:
- slug: employee
name: Employee
type: entity
schema: { type: object, properties: { ... } }
version: "1.0.0"
fieldCount: 42
events:
- slug: employee_synced
name: Employee Synced
direction: outbound
category: sync
severity: info
payloadSchema: employee
functions: # auto-extracted from actions
- slug: sync_employees
name: Sync Employees
httpMethod: POST
endpointPath: /employees/sync
requiredScopes: [employee:read]
category: employees
syncMappings:
- sourceEntity: employee
service: hris
targetSchema: employee
direction: integration_to_service
fields:
- { source: workEmail, target: work_email }
- { source: status, target: employment_status,
transform: "status = 'Active' ? 'ACTIVE' : 'INACTIVE'" }
webhookTypes:
- slug: employee_changes
label: Employee Changes
produces: [bamboohr.employee.created, bamboohr.employee.updated]
handler: handle_webhook
build:
dockerfile: ./Dockerfile
context: .
ui:
category: hris
color: "#73C41D"CLI Reference
nova integrations new
Scaffolds a new integration project.
nova integrations new [directory]Interactive prompts:
- Integration slug (snake_case) — if no directory arg
- Integration type: OIDC, OAuth2, or API Key
Generates:
| File | Purpose |
|---|---|
| src/index.ts | Full starter integration with defineIntegration(), schemas, events, actions, poll consumer |
| src/lib/db.ts | Prisma singleton client |
| package.json | @newhomestar/[email protected], zod, prisma |
| prisma/schema.prisma | Baseline Prisma schema with outbox models |
| Dockerfile | Distroless Node 20 container |
| tsconfig.json | TypeScript configuration |
| Agents.md | LLM agent instructions |
| supabase/ | Local Supabase config + migrations directory |
Post-scaffold: runs git init, corepack enable, yarn install
Optional AI assist: generates starter schemas/events from a plain-English description using OpenAI.
nova integrations build
Bundles the integration and generates all build artifacts.
nova integrations build [-o dist] [--dry-run] [--skip-bundle]8-Step Pipeline:
| Step | What happens |
|---|---|
| 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) |
| 2. Dynamic import | Loads the build-time bundle, finds export default defineIntegration({...}) |
| 3. Validate | Runs validateIntegration() — checks slug, name, schemas, baseUrl, actions |
| 4. Load Zod v4 | From integration's node_modules; uses z.toJSONSchema() for schema conversion |
| 5. Convert schemas | Each schemas.* entry → JSON Schema file at dist/schemas/{slug}.json |
| 6. Convert events + functions | Extracts metadata; converts __inputZod/__outputZod → inputSchema/outputSchema JSON |
| 7. Convert actions | Zod I/O → JSON Schema; serializes triggers, scopes, sync blocks |
| 8. Pass 2 esbuild | Production bundle with ignoreAnnotations: true (zod fully inlined, zero node_modules at runtime) |
Scope rollup: auto-collects all scopes from all actions, extracts base names (employee:read → employee), merges with top-level def.scopes.
Output:
dist/index.cjs— production bundle (~50MB image in distroless Docker)dist/schemas/*.json— JSON Schema files per entity and action I/Onova-integration.yaml— the full spec document
nova integrations push
Builds, pushes Docker image, and syncs all configuration to the platform.
nova integrations push [--skip-build] [--skip-secrets-fetch] [-d|--destructive] [--tag <t>] [--registry <r>]11-Step Pipeline:
| Step | Description |
|---|---|
| 1. Fetch secrets | POST {odysseyUrl}/api/secrets using stored CLI session token |
| 2. Build integration | Runs buildIntegration() → spec + nova-integration.yaml |
| 3. Docker registry login | doctl registry login (for DigitalOcean registry) |
| 4. Docker build | docker build --platform linux/amd64 -t {registry}/{slug}:{tag} . |
| 5. Docker push | docker push {image} |
| 6. Register container | POST /api/integrations — metadata, image, queue, actions |
| 7. Sync config | POST /api/integrations/config — upsert schemas, events, functions |
| 8. Seed sync mappings | POST /api/integrations/sync-mappings — integration_sync_pairs + integration_field_mappings |
| 8b. Sync entity types | POST /api/integrations/sync-entities — DataSync tab entries from action.sync blocks |
| 8c. Sync webhook types | POST /api/integrations/webhook-types — app_webhook_types rows |
| 9. Register outbound events | POST /api/integration-events/sync + POST {NOVA_EVENTS_SERVICE_URL}/event-types |
| 10. Register triggers | Ensure PGMQ queue exists; upsert event_subscriptions for each trigger topic |
| 11. Sync trigger registry | POST /api/event-triggers/sync — platform DB for UI display |
Tag defaulting: current git branch name (sanitized to [a-z0-9.-])
--destructive flag: deletes all existing endpoints/schemas/events before upserting (guarantees clean slate).
Environment Variables
Runtime (Container)
| Variable | Secret | Required By |
|---|---|---|
| NOVA_EVENTS_SERVICE_URL | No | startPollConsumer, queueEvent, withEventOutbox |
| NOVA_SERVICE_TOKEN | Yes | Events Service auth, createIntegrationClient() |
| AUTH_ISSUER_BASE_URL | No | ctx.resolveCredentials() — auth server URL |
| AUTH_AUDIENCE | No | JWKS audience claim (default: "starfleet") |
| PLATFORM_SUPABASE_URL | No | createPlatformClient(), emitPlatformEvent() |
| PLATFORM_SUPABASE_SERVICE_ROLE_KEY | Yes | Platform DB service role |
| INTEGRATION_SUPABASE_URL | No | Integration's own database |
| INTEGRATION_SUPABASE_SERVICE_ROLE_KEY | Yes | Integration DB service role |
| NOVA_SERVICE_SLUG | No | Stamped as source_service on outbox events |
| PORT | No | HTTP server port (default: 8000) |
| NOVA_SKIP_AUTH | No | Set "true" to disable JWKS auth (dev only) |
| DATABASE_URL | Yes | Prisma connection string |
CLI / Build
| Variable | Secret | Used By |
|---|---|---|
| DIGITALOCEAN_ACCESS_TOKEN | Yes | Docker registry login (nova integrations push) |
| NPM_TOKEN | Yes | Docker build arg for private npm packages |
| GIT_SHA | No | Docker image tag fallback |
| NOVA_DOCKER_REGISTRY | No | Override registry prefix (default: registry.digitalocean.com/nhc) |
| ODYSSEY_UI_URL | No | Override Odyssey UI URL (default from CLI config) |
| OPENAI_API_KEY | Yes | AI-assisted scaffolding (nova integrations new) |
Validation Rules
validateIntegration() runs automatically during nova integrations build. Here's what it checks:
Errors (block build/push)
| Check | Condition |
|---|---|
| slug required | Must be present and non-empty |
| name required | Must be present and non-empty |
| queue required | Must be present and non-empty |
| baseUrl required | Must be present and non-empty |
| At least 1 action | actions must have ≥1 entry |
| OAuth endpoints | authorizationEndpoint + tokenEndpoint required for oauth2/oidc |
Warnings (non-blocking)
| Check | Condition |
|---|---|
| No scopes | OAuth2/OIDC should have ≥1 scope |
| Missing OIDC fields | jwksUri, userinfoEndpoint recommended for OIDC |
| Non-HTTPS URLs | All endpoint URLs should use HTTPS (except localhost) |
| No schemas | At least one entity schema recommended |
| No health action | health or healthCheck action recommended |
| No envSpec | Environment variables won't be validated at deploy time |
Architecture Diagram
src/index.ts
└─ defineIntegration({
schemas: { employee: schema("entity", EmployeeZod) }
events: { employee_synced: event("outbound", ...) }
actions: { syncEmployees: action({ scopes, triggers, sync, handler }) }
})
│
│ nova integrations build
▼
dist/index.cjs (production bundle — runs in distroless Docker)
dist/schemas/ (JSON Schema files)
nova-integration.yaml (spec document)
│
│ nova integrations push
▼
┌─────────────────────────────────────────────────────────────────┐
│ Docker Registry registry.digitalocean.com/nhc/{slug}:{tag} │
├─────────────────────────────────────────────────────────────────┤
│ Platform DB app_integrations │
│ app_integration_schemas │
│ app_integration_events │
│ app_integration_functions │
│ integration_sync_pairs │
│ integration_field_mappings │
│ app_webhook_types │
│ app_integration_sync_entities │
├─────────────────────────────────────────────────────────────────┤
│ Events Service event_types (outbound event registration) │
│ event_subscriptions (PGMQ queue routing) │
├─────────────────────────────────────────────────────────────────┤
│ Orchestrator Container registered, queue ensured │
└─────────────────────────────────────────────────────────────────┘