@on-belay/sdk 2.0.0 → 2.2.0
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 +869 -0
- package/dist/index.d.mts +12413 -26
- package/dist/index.d.ts +12413 -26
- package/dist/index.js +12578 -44
- package/dist/index.mjs +12426 -43
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
# @on-belay/sdk
|
|
2
|
+
|
|
3
|
+
Build a fieldset that runs on the [On Belay](https://app.onbelay.ai) governance and integration platform.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@on-belay/sdk)
|
|
6
|
+
|
|
7
|
+
`@on-belay/sdk@2.0.0` is the HTTP-only contract between the On Belay platform and an external fieldset (a Node service you write and host). The platform calls your service over a signed webhook on a schedule; you call the platform back through `/api/sdk/*` to reach connected integrations on behalf of enrolled orgs. No platform internals are imported. No customer credentials live in your code. Node 20+, one runtime dependency (`jose`).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Contents
|
|
12
|
+
|
|
13
|
+
1. [Install](#1-install)
|
|
14
|
+
2. [What is a fieldset?](#2-what-is-a-fieldset)
|
|
15
|
+
3. [Architecture](#3-architecture)
|
|
16
|
+
4. [Prerequisites](#4-prerequisites)
|
|
17
|
+
5. [Quickstart — Hello World](#5-quickstart--hello-world)
|
|
18
|
+
6. [The webhook handler](#6-the-webhook-handler)
|
|
19
|
+
7. [SDK API reference](#7-sdk-api-reference)
|
|
20
|
+
8. [Webhook payload contract](#8-webhook-payload-contract)
|
|
21
|
+
9. [Embedded UI / dashboard tokens](#9-embedded-ui--dashboard-tokens)
|
|
22
|
+
10. [Migration from 1.0.0](#10-migration-from-100)
|
|
23
|
+
11. [Troubleshooting](#11-troubleshooting)
|
|
24
|
+
12. [Versioning + stability](#12-versioning--stability)
|
|
25
|
+
13. [Links](#13-links)
|
|
26
|
+
14. [License](#14-license)
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 1. Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install @on-belay/sdk@^2.0.0
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Minimal usage — a complete signed-webhook receiver in five lines:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { createOnbelayWebhookHandler } from "@on-belay/sdk"
|
|
40
|
+
|
|
41
|
+
export const handler = createOnbelayWebhookHandler({
|
|
42
|
+
secret: process.env.ONBELAY_WEBHOOK_SECRET!,
|
|
43
|
+
fieldsetSlug: "my-fieldset",
|
|
44
|
+
onTrigger: async ({ payload }) => { /* your work here */ },
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Wrap that handler in any framework that exposes the raw request body (Next.js Route Handlers, Express, Fastify, Hono — see [§6](#6-the-webhook-handler)).
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 2. What is a fieldset?
|
|
53
|
+
|
|
54
|
+
A **fieldset** is an AI workflow that On Belay organizations can enroll in. You ship the workflow as a small HTTP service running on your own Railway project. The On Belay platform manages org enrollment, billing, admin configuration, credential storage and refresh for 60+ integrations (Shopify, HubSpot, Notion, Slack, NetSuite, …), signed webhook delivery on a daily schedule plus owner-triggered manual runs, audit logging, and rate limiting. Your service does the actual work: read the webhook, call connected integrations through the platform proxy, persist run state in your per-enrollment Neon branch, and record publish events for billing.
|
|
55
|
+
|
|
56
|
+
There are two protocols and two secrets:
|
|
57
|
+
|
|
58
|
+
- **Inbound** (platform → you): HMAC-SHA256 signed with `ONBELAY_WEBHOOK_SECRET`. Verified by `validateWebhookSignature` (or the wrapper, `createOnbelayWebhookHandler`).
|
|
59
|
+
- **Outbound** (you → platform): `Authorization: Bearer ONBELAY_FIELDSET_TOKEN` on every `/api/sdk/*` call. The token is read from your env var. **It is never sent in the webhook payload.**
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 3. Architecture
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
┌──────────────────────────────────┐ ┌─────────────────────────────┐
|
|
67
|
+
│ Your Railway service │ │ On Belay Platform │
|
|
68
|
+
│ (Node 20+, any framework) │ │ app.onbelay.ai │
|
|
69
|
+
│ │ │ │
|
|
70
|
+
│ POST /api/onbelay-webhook │◀────────│ external-fieldset- │
|
|
71
|
+
│ (HMAC-signed inbound) │ HTTPS + │ scheduler (Inngest) │
|
|
72
|
+
│ uses ONBELAY_WEBHOOK_SECRET │ HMAC256 │ daily 0 8 * * * UTC │
|
|
73
|
+
│ │ │ │
|
|
74
|
+
│ POST /api/sdk/* │────────▶│ proxy + token middleware │
|
|
75
|
+
│ Authorization: Bearer │ HTTPS │ - decrypt org creds │
|
|
76
|
+
│ ONBELAY_FIELDSET_TOKEN │ Bearer │ - sign upstream calls │
|
|
77
|
+
│ │ │ - audit log + FieldsetRun │
|
|
78
|
+
│ Per-enrollment Neon branch │ │ │
|
|
79
|
+
│ NEON_CONNECTION_STRING │ └────────────┬────────────────┘
|
|
80
|
+
└──────────────────────────────────┘ │
|
|
81
|
+
▼ HTTPS
|
|
82
|
+
Shopify / HubSpot / Notion / …
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Two arrows. Two secrets. The platform talks to upstream APIs on your behalf so credentials never reach your service.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 4. Prerequisites
|
|
90
|
+
|
|
91
|
+
- Node 20 or newer (`fetch`, `AbortController`, `TextEncoder` are built-in).
|
|
92
|
+
- An On Belay account.
|
|
93
|
+
- A fieldset registration: apply at [https://app.onbelay.ai/developer/apply](https://app.onbelay.ai/developer/apply). On approval the platform creates a Railway service from your repo and injects the env vars below.
|
|
94
|
+
- Environment variables (the platform injects all of these on provisioning; do not bake them into your repo):
|
|
95
|
+
|
|
96
|
+
| Variable | Purpose |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `ONBELAY_PROXY_URL` | Base URL for `/api/sdk/*` calls. **Always `https://app.onbelay.ai`** — base URL only; the SDK appends paths internally. |
|
|
99
|
+
| `ONBELAY_FIELDSET_TOKEN` | Bearer token attached to every platform call. The only place token plaintext lives outside the platform DB. |
|
|
100
|
+
| `ONBELAY_WEBHOOK_SECRET` | HMAC-SHA256 secret for verifying inbound webhook signatures. |
|
|
101
|
+
| `ONBELAY_DASHBOARD_SECRET` | HS256 secret for embedded-dashboard JWT validation (only required if you ship an embedded UI). |
|
|
102
|
+
|
|
103
|
+
Optional: `NEON_CONNECTION_STRING` (per-enrollment, injected when an org has a Neon branch), `SENTRY_DSN`, your own `ANTHROPIC_API_KEY`. The platform never shares its Anthropic key with external fieldsets.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## 5. Quickstart — Hello World
|
|
108
|
+
|
|
109
|
+
A fully working sample fieldset lives at [`packages/fieldset-hello-world/`](../fieldset-hello-world/). It is the canonical 2.0.0 reference — start there. The summary:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# 1. Clone the scaffold (or fork the Hello World fieldset).
|
|
113
|
+
git clone https://github.com/Junto-Systems/onbelay-hello-world-fieldset my-fieldset
|
|
114
|
+
cd my-fieldset
|
|
115
|
+
|
|
116
|
+
# 2. Set your slug.
|
|
117
|
+
sed -i '' 's/hello-world/my-fieldset/g' fieldset.manifest.ts package.json src/lib/onbelay-client.ts
|
|
118
|
+
|
|
119
|
+
# 3. Install + boot locally.
|
|
120
|
+
npm install
|
|
121
|
+
cp env.example .env.local
|
|
122
|
+
# Edit .env.local: ONBELAY_FIELDSET_SLUG, ONBELAY_WEBHOOK_SECRET (any string locally),
|
|
123
|
+
# ONBELAY_FIELDSET_TOKEN (any string locally), ONBELAY_PROXY_URL=https://app.onbelay.ai.
|
|
124
|
+
npm run dev
|
|
125
|
+
|
|
126
|
+
# 4. Sign and send a test webhook against your local server (no ngrok needed).
|
|
127
|
+
ONBELAY_WEBHOOK_SECRET="<same as .env.local>" \
|
|
128
|
+
ONBELAY_FIELDSET_SLUG="my-fieldset" \
|
|
129
|
+
ORG_ID="clk_local_test" \
|
|
130
|
+
npm run sign:test-payload
|
|
131
|
+
# Copy the printed curl command and run it. Expected response:
|
|
132
|
+
# {"ok":true}
|
|
133
|
+
|
|
134
|
+
# 5. Push to GitHub, then apply at https://app.onbelay.ai/developer/apply
|
|
135
|
+
# with your repo URL and slug. The platform owner approves, provisions a
|
|
136
|
+
# Railway service, injects env vars, and deploys.
|
|
137
|
+
|
|
138
|
+
# 6. Verify the live deploy.
|
|
139
|
+
curl https://<your-service>.up.railway.app/api/health
|
|
140
|
+
# → { "ok": true, "fieldset": "<slug>", ... }
|
|
141
|
+
|
|
142
|
+
# 7. Trigger a run from the platform UI:
|
|
143
|
+
# /owner/fieldsets/<your-fieldset-id> → "Trigger run".
|
|
144
|
+
# Within 5 minutes you should see a FieldsetRun row marked completed,
|
|
145
|
+
# an AuditLog row with action="external_fieldset_proxy",
|
|
146
|
+
# and an incremented PublishCounter row.
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The scaffold's [README](./scaffold/README.md) covers the project layout, local-dev signing, Railway deploy, per-enrollment migration files, and the idempotency pattern in detail. The Hello World [README](../fieldset-hello-world/README.md) shows the end-to-end verification queries (`FieldsetRun`, `PublishCounter`, `AuditLog`).
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 6. The webhook handler
|
|
154
|
+
|
|
155
|
+
`createOnbelayWebhookHandler` is the recommended entry point. It is framework-agnostic — it takes the raw body string + a headers map and returns `{ status, body, headers }` you can translate into a framework response.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { createOnbelayWebhookHandler } from "@on-belay/sdk"
|
|
159
|
+
|
|
160
|
+
const handler = createOnbelayWebhookHandler({
|
|
161
|
+
secret: process.env.ONBELAY_WEBHOOK_SECRET!,
|
|
162
|
+
fieldsetSlug: "my-fieldset",
|
|
163
|
+
maxAgeSeconds: 300, // optional. default 300.
|
|
164
|
+
onTrigger: async ({ payload, rawBody }) => {
|
|
165
|
+
// payload is the parsed WebhookPayload (see §8)
|
|
166
|
+
// rawBody is the exact bytes used for HMAC verification
|
|
167
|
+
// Throw → handler returns 500 → platform retries (Inngest, up to 3 attempts).
|
|
168
|
+
// Return → handler returns 200.
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Validation behavior
|
|
174
|
+
|
|
175
|
+
The handler runs the spec §6 checks in order. Any failure returns the listed status without invoking `onTrigger`:
|
|
176
|
+
|
|
177
|
+
| Step | Outcome | HTTP | Body |
|
|
178
|
+
|---|---|---|---|
|
|
179
|
+
| Missing `X-Onbelay-Signature` header | 401 | `{"error":"missing_signature"}` |
|
|
180
|
+
| HMAC mismatch | 401 | `{"error":"invalid_signature"}` |
|
|
181
|
+
| `X-Onbelay-Timestamp` older than `maxAgeSeconds` (default 300) or >30s in the future | 401 | `{"error":"invalid_signature"}` |
|
|
182
|
+
| Body is not JSON | 400 | `{"error":"invalid_json"}` |
|
|
183
|
+
| `payload.fieldsetSlug` ≠ `options.fieldsetSlug` | 401 | `{"error":"slug_mismatch"}` |
|
|
184
|
+
| `onTrigger` throws | 500 | `{"error":"handler_failed"}` |
|
|
185
|
+
| `onTrigger` returns | 200 | `{"ok":true}` |
|
|
186
|
+
|
|
187
|
+
### Retry semantics
|
|
188
|
+
|
|
189
|
+
Inngest treats your response as follows:
|
|
190
|
+
- **2xx** — success. `FieldsetRun` is marked `completed`.
|
|
191
|
+
- **4xx** — permanent failure. **No retry.** `FieldsetRun` is marked `failed`.
|
|
192
|
+
- **5xx** or timeout (>30s) — transient. Up to 3 retries with exponential backoff.
|
|
193
|
+
|
|
194
|
+
`runId` (see [§8](#8-webhook-payload-contract)) is stable across retries within the same `(enrollment, calendar day, triggerType)`. Dedupe on `runId` in your handler so a retry is cheap.
|
|
195
|
+
|
|
196
|
+
### Next.js Route Handler example
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
// app/api/onbelay-webhook/route.ts
|
|
200
|
+
import { NextResponse } from "next/server"
|
|
201
|
+
import { createOnbelayWebhookHandler } from "@on-belay/sdk"
|
|
202
|
+
import { handleTrigger } from "@/lib/work"
|
|
203
|
+
|
|
204
|
+
const handler = createOnbelayWebhookHandler({
|
|
205
|
+
secret: process.env.ONBELAY_WEBHOOK_SECRET!,
|
|
206
|
+
fieldsetSlug: "my-fieldset",
|
|
207
|
+
onTrigger: async ({ payload }) => { await handleTrigger(payload) },
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
export async function POST(request: Request): Promise<Response> {
|
|
211
|
+
const rawBody = await request.text()
|
|
212
|
+
const headers: Record<string, string | undefined> = {}
|
|
213
|
+
request.headers.forEach((v, k) => { headers[k.toLowerCase()] = v })
|
|
214
|
+
const result = await handler(rawBody, headers)
|
|
215
|
+
return new NextResponse(result.body, { status: result.status, headers: result.headers })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export const dynamic = "force-dynamic"
|
|
219
|
+
export const runtime = "nodejs"
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Express example
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
import express from "express"
|
|
226
|
+
import { createOnbelayWebhookHandler } from "@on-belay/sdk"
|
|
227
|
+
|
|
228
|
+
const handler = createOnbelayWebhookHandler({
|
|
229
|
+
secret: process.env.ONBELAY_WEBHOOK_SECRET!,
|
|
230
|
+
fieldsetSlug: "my-fieldset",
|
|
231
|
+
onTrigger: async ({ payload }) => { /* … */ },
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const app = express()
|
|
235
|
+
app.post(
|
|
236
|
+
"/api/onbelay-webhook",
|
|
237
|
+
express.raw({ type: "application/json" }), // CRITICAL: must verify against raw bytes
|
|
238
|
+
async (req, res) => {
|
|
239
|
+
const result = await handler(
|
|
240
|
+
req.body.toString("utf8"),
|
|
241
|
+
req.headers as Record<string, string | undefined>,
|
|
242
|
+
)
|
|
243
|
+
res.status(result.status).set(result.headers).send(result.body)
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
> Always pass the **raw** request bytes to the handler. Re-stringifying a parsed JSON object will not produce a byte-identical payload, and the HMAC will fail.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## 7. SDK API reference
|
|
253
|
+
|
|
254
|
+
Every HTTP-bound function shares one network contract:
|
|
255
|
+
|
|
256
|
+
- **Authorization:** `Bearer ${ONBELAY_FIELDSET_TOKEN}` on every call.
|
|
257
|
+
- **Per-request timeout:** 30 seconds (`AbortController`).
|
|
258
|
+
- **Retry:** ONE retry, fixed 250 ms delay, **only** on transport failure (network error / `AbortError`) or HTTP 502/503/504. After the retry also fails, `OnbelayTransportError` is thrown.
|
|
259
|
+
- **Other 5xx (500, 505+):** thrown immediately as `OnbelayTransportError` with `attempts === 1`. Not retried.
|
|
260
|
+
- **4xx:** never a transport failure. `executeProxyCall` returns a typed `ProxyResult` envelope; every other HTTP-bound function throws `OnbelayProtocolError` carrying `{ status, url, code }`.
|
|
261
|
+
|
|
262
|
+
All `/api/sdk/*` success bodies are wrapped on the wire as `{ "data": T }`; errors are `{ "error": { "code", "message" } }`. **The SDK unwraps the envelope for you** — the return types below are the unwrapped `T`. If you bypass the SDK with `curl`, expect the wrapped wire shape.
|
|
263
|
+
|
|
264
|
+
### `OnbelayConfig`
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
interface OnbelayConfig {
|
|
268
|
+
proxyUrl?: string // defaults to process.env.ONBELAY_PROXY_URL
|
|
269
|
+
token?: string // defaults to process.env.ONBELAY_FIELDSET_TOKEN
|
|
270
|
+
fieldsetSlug: string // required — your fieldset's slug
|
|
271
|
+
fetch?: typeof fetch // override for testing
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Every HTTP-bound function accepts an optional `OnbelayConfig` as the trailing argument. Defaults are read from `process.env` on every call — long-lived services pick up rotated tokens automatically without restart.
|
|
276
|
+
|
|
277
|
+
### `executeProxyCall`
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
function executeProxyCall<T = unknown>(
|
|
281
|
+
orgId: string,
|
|
282
|
+
integrationSlug: string,
|
|
283
|
+
operationKey: string,
|
|
284
|
+
path: string,
|
|
285
|
+
options?: {
|
|
286
|
+
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
|
|
287
|
+
body?: Record<string, unknown>
|
|
288
|
+
queryParams?: Record<string, string>
|
|
289
|
+
},
|
|
290
|
+
config?: OnbelayConfig,
|
|
291
|
+
): Promise<ProxyResult<T>>
|
|
292
|
+
|
|
293
|
+
type ProxyResult<T> =
|
|
294
|
+
| { ok: true; status: number; data: T }
|
|
295
|
+
| { ok: false; status: number; blocked: true; error: ProxyErrorCode }
|
|
296
|
+
| { ok: false; status: number; blocked: false; error: string }
|
|
297
|
+
|
|
298
|
+
type ProxyErrorCode =
|
|
299
|
+
| "invalid_token"
|
|
300
|
+
| "operation_not_permitted"
|
|
301
|
+
| "org_not_enrolled"
|
|
302
|
+
| "integration_not_connected"
|
|
303
|
+
| "fieldset_inactive"
|
|
304
|
+
| "invalid_request"
|
|
305
|
+
| "upstream_error"
|
|
306
|
+
| "proxy_error"
|
|
307
|
+
| "rate_limit_exceeded"
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The only sanctioned way for a fieldset to call a third-party API. Dispatched as `POST /api/sdk/proxy` with a JSON body of `{ orgId, integrationSlug, operationKey, path, method, body, queryParams }`. The platform resolves credentials, refreshes tokens if needed, signs the upstream call, audits it, and proxies through.
|
|
311
|
+
|
|
312
|
+
The result envelope **never throws on protocol errors** — branch on `ok` / `blocked` instead of wrapping the call in a `try/catch`. Throws only on transport failure (`OnbelayTransportError`).
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
import { executeProxyCall } from "@on-belay/sdk"
|
|
316
|
+
|
|
317
|
+
const result = await executeProxyCall<{ products: Array<{ id: number; title: string }> }>(
|
|
318
|
+
orgId,
|
|
319
|
+
"shopify",
|
|
320
|
+
"shopify.products.list",
|
|
321
|
+
"/admin/api/2024-01/products.json?limit=1",
|
|
322
|
+
{ method: "GET" },
|
|
323
|
+
{ fieldsetSlug: "my-fieldset" },
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if (!result.ok) {
|
|
327
|
+
if (result.blocked) {
|
|
328
|
+
// Protocol outcome — log and skip cleanly.
|
|
329
|
+
console.warn("proxy blocked:", result.error)
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
throw new Error(`upstream failed: ${result.error}`)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
console.log(result.data.products[0]?.title)
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
The org must have the integration connected; your fieldset's `requiredOperations` must include `operationKey`; the org must be enrolled in your fieldset. Each of those gates returns a different `ProxyErrorCode`.
|
|
339
|
+
|
|
340
|
+
### `getOrgContext`
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
function getOrgContext(orgId: string, config?: OnbelayConfig): Promise<OrgContext>
|
|
344
|
+
|
|
345
|
+
interface OrgContext {
|
|
346
|
+
orgId: string
|
|
347
|
+
orgName: string
|
|
348
|
+
connectedIntegrations: ConnectedIntegration[]
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
interface ConnectedIntegration {
|
|
352
|
+
slug: string
|
|
353
|
+
status: "active" | "error" | "pending"
|
|
354
|
+
/** Server-allowlisted public config. v2.0.0: shopify → { shopDomain }, others → {}. */
|
|
355
|
+
extraConfig: Record<string, string | null>
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
`GET /api/sdk/orgs/:orgId/context`. Returns the calling fieldset's view of one enrolled org. The integration list is filtered to only integrations referenced in your `requiredOperations`. Encrypted credential columns (`apiKeyEnc`, `apiSecretEnc`) are never returned. `extraConfig` is server-allowlisted per integration.
|
|
360
|
+
|
|
361
|
+
Throws `OnbelayProtocolError` on `org_not_enrolled`, `org_not_found`, `invalid_token`, `rate_limit_exceeded`. Throws `OnbelayTransportError` on transport failure.
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
const ctx = await getOrgContext(orgId, { fieldsetSlug: "my-fieldset" })
|
|
365
|
+
const shopify = ctx.connectedIntegrations.find((i) => i.slug === "shopify")
|
|
366
|
+
if (shopify?.status === "active") {
|
|
367
|
+
console.log("shop:", shopify.extraConfig.shopDomain)
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### `getFieldsetConfig`
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
function getFieldsetConfig<T = Record<string, unknown>>(
|
|
375
|
+
orgId: string,
|
|
376
|
+
config?: OnbelayConfig,
|
|
377
|
+
): Promise<T>
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`GET /api/sdk/orgs/:orgId/config?fieldset=<slug>`. Returns the contents of `OrgFieldset.config.fieldset` (or `{}` if absent). The `admin` namespace — keys set by org admins through the platform UI — is server-protected and never returned.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
interface MyConfig { lastSyncedAt?: string; greeting?: string }
|
|
384
|
+
const cfg = await getFieldsetConfig<MyConfig>(orgId, { fieldsetSlug: "my-fieldset" })
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Throws `OnbelayProtocolError` on `org_not_enrolled`, `fieldset_mismatch`, `invalid_token`.
|
|
388
|
+
|
|
389
|
+
### `setFieldsetConfig`
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
function setFieldsetConfig<T = Record<string, unknown>>(
|
|
393
|
+
orgId: string,
|
|
394
|
+
patch: Partial<T>,
|
|
395
|
+
config?: OnbelayConfig,
|
|
396
|
+
): Promise<void>
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
`PATCH /api/sdk/orgs/:orgId/config`. Server-side shallow merge into `OrgFieldset.config.fieldset` inside a `SELECT FOR UPDATE → merge → UPDATE` transaction (concurrent writes do not lose updates). The patch body is the merge — there is no envelope.
|
|
400
|
+
|
|
401
|
+
Forbidden: a top-level `admin` key in `patch` throws `OnbelayProtocolError` with `code: "forbidden_namespace"` (and the platform server enforces the same constraint independently).
|
|
402
|
+
|
|
403
|
+
Size caps:
|
|
404
|
+
- SDK rejects patches > 32 KB (serialized) before they leave the process.
|
|
405
|
+
- Server rejects merged `config.fieldset` > 64 KB.
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
await setFieldsetConfig(orgId, {
|
|
409
|
+
lastSyncedAt: new Date().toISOString(),
|
|
410
|
+
lastRunId: payload.runId,
|
|
411
|
+
}, { fieldsetSlug: "my-fieldset" })
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Throws `OnbelayProtocolError` on `invalid_patch`, `forbidden_namespace`, `config_too_large`, `payload_too_large`, `org_not_enrolled`, `fieldset_mismatch`.
|
|
415
|
+
|
|
416
|
+
### `recordPublish`
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
function recordPublish(
|
|
420
|
+
orgId: string,
|
|
421
|
+
contentType: string,
|
|
422
|
+
metadata?: Record<string, unknown>,
|
|
423
|
+
config?: OnbelayConfig,
|
|
424
|
+
): Promise<PublishResult>
|
|
425
|
+
|
|
426
|
+
interface PublishResult {
|
|
427
|
+
count: number // new running count after this publish
|
|
428
|
+
freeAllowance: number // 10 by default in 2.0.0
|
|
429
|
+
billable: boolean // true once count > freeAllowance
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
`POST /api/sdk/orgs/:orgId/billing/publish`. Atomically increments the org's `PublishCounter` for `(orgId, fieldsetId, contentType)`. **Call at writeback time, not generation time** — billing is incurred when content goes live for the org.
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
const result = await recordPublish(orgId, "product_brief", {
|
|
437
|
+
productId: "gid://shopify/Product/123",
|
|
438
|
+
runId: payload.runId,
|
|
439
|
+
})
|
|
440
|
+
console.log(`${result.count}/${result.freeAllowance} — billable: ${result.billable}`)
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
> **No `idempotencyKey` in 2.0.0.** The atomic upsert on `(orgId, fieldsetId, contentType)` is the only concurrency guarantee. Strict per-key idempotency requires a `BillingIdempotency` table that is deferred to v2.1. Until then, dedupe on `payload.runId` in your handler before calling `recordPublish` so retries don't double-bill.
|
|
444
|
+
|
|
445
|
+
### `isEnrolled`
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
function isEnrolled(orgId: string, config?: OnbelayConfig): Promise<boolean>
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
`GET /api/sdk/orgs/:orgId/enrolled?fieldset=<slug>`. Returns `true` only when the calling fieldset has an active enrollment for `orgId`. **Returns `false` for unknown orgs** — the platform must not leak existence (no `404`).
|
|
452
|
+
|
|
453
|
+
```ts
|
|
454
|
+
if (!(await isEnrolled(orgId, { fieldsetSlug: "my-fieldset" }))) return
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
### `getEnrolledOrgs`
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
function getEnrolledOrgs(config?: OnbelayConfig): Promise<string[]>
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
`GET /api/sdk/enrollments`. Returns the orgIds of every org with an active enrollment in the calling fieldset. The fieldsetId is derived server-side from the token — you cannot list another fieldset's enrollments.
|
|
464
|
+
|
|
465
|
+
```ts
|
|
466
|
+
const orgs = await getEnrolledOrgs({ fieldsetSlug: "my-fieldset" })
|
|
467
|
+
// → ["clk_abc123", "clk_def456"]
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### `validateWebhookSignature`
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
interface WebhookVerifyOptions { maxAgeSeconds?: number }
|
|
474
|
+
|
|
475
|
+
function validateWebhookSignature(
|
|
476
|
+
rawBody: string | Buffer,
|
|
477
|
+
signatureHeader: string,
|
|
478
|
+
secret: string,
|
|
479
|
+
timestamp?: string,
|
|
480
|
+
options?: WebhookVerifyOptions,
|
|
481
|
+
): boolean
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
Pure crypto — no HTTP. Constant-time HMAC-SHA256 compare via `node:crypto.timingSafeEqual`. When `timestamp` is supplied (the `X-Onbelay-Timestamp` header value), additionally rejects payloads older than `maxAgeSeconds` (default 300) or more than 30 seconds in the future. **Never throws** — returns `false` on any mismatch, malformed input, or thrown error.
|
|
485
|
+
|
|
486
|
+
You normally don't call this directly; `createOnbelayWebhookHandler` wraps it. Call it directly only when integrating with a framework the wrapper doesn't fit.
|
|
487
|
+
|
|
488
|
+
```ts
|
|
489
|
+
import { validateWebhookSignature } from "@on-belay/sdk"
|
|
490
|
+
|
|
491
|
+
const ok = validateWebhookSignature(
|
|
492
|
+
rawBody,
|
|
493
|
+
req.headers["x-onbelay-signature"] as string,
|
|
494
|
+
process.env.ONBELAY_WEBHOOK_SECRET!,
|
|
495
|
+
req.headers["x-onbelay-timestamp"] as string,
|
|
496
|
+
{ maxAgeSeconds: 300 },
|
|
497
|
+
)
|
|
498
|
+
if (!ok) return res.status(401).json({ error: "invalid_signature" })
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
### `validateDashboardToken`
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
interface DashboardTokenPayload {
|
|
505
|
+
orgId: string
|
|
506
|
+
userId: string
|
|
507
|
+
fieldsetSlug: string
|
|
508
|
+
iat?: number
|
|
509
|
+
exp?: number
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function validateDashboardToken(
|
|
513
|
+
token: string,
|
|
514
|
+
secret: string,
|
|
515
|
+
): Promise<{ orgId: string; userId: string; fieldsetSlug: string } | null>
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Browser- and Node-compatible HS256 JWT verification (uses `jose`). Validates the dashboard context token issued when an org user opens your embedded UI inside the platform. Returns the decoded payload on success or `null` on any failure (expired, bad signature, malformed, non-string fields). **Never throws.**
|
|
519
|
+
|
|
520
|
+
```ts
|
|
521
|
+
import { validateDashboardToken } from "@on-belay/sdk"
|
|
522
|
+
|
|
523
|
+
const ctx = await validateDashboardToken(token, process.env.ONBELAY_DASHBOARD_SECRET!)
|
|
524
|
+
if (!ctx) return new Response("invalid_token", { status: 401 })
|
|
525
|
+
// ctx.orgId, ctx.userId, ctx.fieldsetSlug
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### `createOnbelayWebhookHandler`
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
interface WebhookHandlerOptions {
|
|
532
|
+
secret: string
|
|
533
|
+
fieldsetSlug: string
|
|
534
|
+
onTrigger: (ctx: { payload: WebhookPayload; rawBody: string }) => Promise<void>
|
|
535
|
+
maxAgeSeconds?: number
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
interface WebhookResult {
|
|
539
|
+
status: number
|
|
540
|
+
body: string
|
|
541
|
+
headers: Record<string, string>
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function createOnbelayWebhookHandler(
|
|
545
|
+
options: WebhookHandlerOptions,
|
|
546
|
+
): (
|
|
547
|
+
rawBody: string,
|
|
548
|
+
headers: Record<string, string | undefined>,
|
|
549
|
+
) => Promise<WebhookResult>
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
Framework-agnostic factory. Returned function takes the raw body string + a header map (case-insensitive lookup) and returns `{ status, body, headers }`. Validates HMAC, replay window, JSON shape, and `fieldsetSlug` match in that order. See [§6](#6-the-webhook-handler) for the response matrix and full examples.
|
|
553
|
+
|
|
554
|
+
The constructor throws synchronously on missing `secret`, missing `fieldsetSlug`, or a non-function `onTrigger`. Build the handler once at module scope.
|
|
555
|
+
|
|
556
|
+
### `OnbelayClient`
|
|
557
|
+
|
|
558
|
+
```ts
|
|
559
|
+
class OnbelayClient {
|
|
560
|
+
constructor(config: OnbelayConfig)
|
|
561
|
+
|
|
562
|
+
executeProxyCall<T = unknown>(
|
|
563
|
+
orgId: string,
|
|
564
|
+
integrationSlug: string,
|
|
565
|
+
operationKey: string,
|
|
566
|
+
path: string,
|
|
567
|
+
options?: { method?, body?, queryParams? },
|
|
568
|
+
): Promise<ProxyResult<T>>
|
|
569
|
+
|
|
570
|
+
getOrgContext(orgId: string): Promise<OrgContext>
|
|
571
|
+
getFieldsetConfig<T = Record<string, unknown>>(orgId: string): Promise<T>
|
|
572
|
+
setFieldsetConfig<T = Record<string, unknown>>(orgId: string, patch: Partial<T>): Promise<void>
|
|
573
|
+
recordPublish(
|
|
574
|
+
orgId: string,
|
|
575
|
+
contentType: string,
|
|
576
|
+
metadata?: Record<string, unknown>,
|
|
577
|
+
): Promise<PublishResult>
|
|
578
|
+
isEnrolled(orgId: string): Promise<boolean>
|
|
579
|
+
getEnrolledOrgs(): Promise<string[]>
|
|
580
|
+
}
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
Convenience class that holds an `OnbelayConfig` and exposes the seven HTTP-bound functions as instance methods. Pure ergonomic wrapper — every method delegates to the equivalent free function with the bound config. Constructor throws if `fieldsetSlug` is missing.
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
import { OnbelayClient } from "@on-belay/sdk"
|
|
587
|
+
|
|
588
|
+
export const onbelay = new OnbelayClient({ fieldsetSlug: "my-fieldset" })
|
|
589
|
+
|
|
590
|
+
// Now per-call config is implicit.
|
|
591
|
+
await onbelay.recordPublish(orgId, "report")
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
Use it when one fieldset slug serves the whole process. The Hello World fieldset uses this pattern in [`src/lib/onbelay-client.ts`](../fieldset-hello-world/src/lib/onbelay-client.ts).
|
|
595
|
+
|
|
596
|
+
### Errors
|
|
597
|
+
|
|
598
|
+
#### `OnbelayTransportError`
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
class OnbelayTransportError extends Error {
|
|
602
|
+
readonly status: number // last observed HTTP status; 0 if no response
|
|
603
|
+
readonly url: string // the URL the SDK was hitting
|
|
604
|
+
readonly attempts: number // 1 (non-retryable failure) or 2 (retry also failed)
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Thrown by every HTTP-bound function on transport failure (network error, `AbortError`, or HTTP 502/503/504 after one retry). Other 5xx (500, 505+) throw immediately with `attempts === 1`. Catch in your handler and let the next scheduled run pick up the work — do not let it propagate past your webhook response.
|
|
609
|
+
|
|
610
|
+
#### `OnbelayProtocolError`
|
|
611
|
+
|
|
612
|
+
```ts
|
|
613
|
+
class OnbelayProtocolError extends Error {
|
|
614
|
+
readonly status: number
|
|
615
|
+
readonly url: string
|
|
616
|
+
readonly code: string // e.g. "org_not_enrolled", "fieldset_mismatch"
|
|
617
|
+
}
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
Thrown by HTTP-bound functions that return raw values (`getOrgContext`, `getFieldsetConfig`, `setFieldsetConfig`, `recordPublish`, `isEnrolled`, `getEnrolledOrgs`) when the platform returns a 4xx. **`executeProxyCall` does NOT throw this** — it returns the typed `ProxyResult` envelope so you can branch without a `try/catch`.
|
|
621
|
+
|
|
622
|
+
### Public types
|
|
623
|
+
|
|
624
|
+
The full type surface re-exported from the package root:
|
|
625
|
+
|
|
626
|
+
| Type | Purpose |
|
|
627
|
+
|---|---|
|
|
628
|
+
| `OnbelayConfig` | Per-call config bag (proxyUrl, token, fieldsetSlug, fetch). |
|
|
629
|
+
| `ProxyResult<T>` | Return envelope for `executeProxyCall`. |
|
|
630
|
+
| `ProxyErrorCode` | The 9 platform-blocked error codes. |
|
|
631
|
+
| `OrgContext`, `ConnectedIntegration` | `getOrgContext` return shape. |
|
|
632
|
+
| `EnrolledOrg` | Forward-compatible richer enrollment shape (the function returns `string[]`; this is exposed for typed downstream usage). |
|
|
633
|
+
| `ContentType` | Free-form string alias used by `recordPublish`. |
|
|
634
|
+
| `PublishResult` | `recordPublish` return shape. |
|
|
635
|
+
| `WebhookPayload` | Inbound webhook body. See [§8](#8-webhook-payload-contract). |
|
|
636
|
+
| `WebhookHandlerOptions`, `WebhookHandlerContext`, `WebhookResult` | `createOnbelayWebhookHandler` shapes. |
|
|
637
|
+
| `WebhookVerifyOptions` | Options for `validateWebhookSignature` / handler. |
|
|
638
|
+
| `DashboardTokenPayload` | Decoded JWT payload used by `validateDashboardToken`. |
|
|
639
|
+
|
|
640
|
+
---
|
|
641
|
+
|
|
642
|
+
## 8. Webhook payload contract
|
|
643
|
+
|
|
644
|
+
Every inbound webhook the platform sends has the following shape. Required fields are always present; optional fields are described below.
|
|
645
|
+
|
|
646
|
+
```ts
|
|
647
|
+
interface WebhookPayload {
|
|
648
|
+
orgId: string
|
|
649
|
+
fieldsetSlug: string
|
|
650
|
+
triggerType:
|
|
651
|
+
| "scheduled"
|
|
652
|
+
| "manual"
|
|
653
|
+
| "user_triggered"
|
|
654
|
+
| "enrollment_changed"
|
|
655
|
+
| "unenrollment"
|
|
656
|
+
timestamp: string // ISO 8601, equal to X-Onbelay-Timestamp
|
|
657
|
+
runId: string // stable per (enrollment, calendar day, triggerType)
|
|
658
|
+
neonConnectionString?: string // present only when org has a Neon branch
|
|
659
|
+
config?: Record<string, unknown> // present only when fieldset namespace is non-empty
|
|
660
|
+
actor?: { userId: string; email: string } // present only when triggerType === "manual"
|
|
661
|
+
}
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
Headers:
|
|
665
|
+
|
|
666
|
+
```
|
|
667
|
+
Content-Type: application/json
|
|
668
|
+
X-Onbelay-Signature: sha256=<hex HMAC-SHA256 of raw body>
|
|
669
|
+
X-Onbelay-Timestamp: <ISO 8601, equal to payload.timestamp>
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
| Field | Required | Notes |
|
|
673
|
+
|---|---|---|
|
|
674
|
+
| `orgId` | yes | The enrolled org this run targets. |
|
|
675
|
+
| `fieldsetSlug` | yes | Your fieldset slug. The handler verifies this matches `options.fieldsetSlug`. |
|
|
676
|
+
| `triggerType` | yes | `scheduled` (daily 8am UTC) or `manual` (owner clicked "Trigger run"). The other three values are reserved for future platform features; the SDK type accepts them but the platform does not currently emit them in 2.0.0. |
|
|
677
|
+
| `timestamp` | yes | ISO 8601. Used together with `maxAgeSeconds` for replay protection. |
|
|
678
|
+
| `runId` | yes | Deterministic: `ofs_<orgFieldsetId>-<YYYYMMDD>-<triggerType>`. Inngest retries on non-200 reuse the same `runId` — dedupe on this and you trivially survive retries. |
|
|
679
|
+
| `neonConnectionString` | optional | Present only when this org has a Neon branch provisioned. Read/write on your branch only. |
|
|
680
|
+
| `config` | optional | Present only when the `fieldset` namespace of `OrgFieldset.config` is non-empty. **Contains only your namespace, never the `admin` namespace.** |
|
|
681
|
+
| `actor` | optional | Present only when `triggerType === "manual"`. Identifies the platform owner who triggered the run. |
|
|
682
|
+
|
|
683
|
+
**The token is not in the payload.** It lives in `process.env.ONBELAY_FIELDSET_TOKEN` — single source of truth. The SDK reads the env var by default on every call so a rotated token is picked up without restart.
|
|
684
|
+
|
|
685
|
+
### Signature verification
|
|
686
|
+
|
|
687
|
+
```
|
|
688
|
+
signature = "sha256=" + hex(HMAC_SHA256(ONBELAY_WEBHOOK_SECRET, rawBody))
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
`rawBody` is the exact bytes the platform serialized. Verify against the raw request body, never against a re-stringified parsed object.
|
|
692
|
+
|
|
693
|
+
### Idempotency pattern
|
|
694
|
+
|
|
695
|
+
Inngest retries on non-200 responses **reuse the same `runId`**. The recommended pattern, persisted in your per-enrollment Neon branch:
|
|
696
|
+
|
|
697
|
+
```sql
|
|
698
|
+
CREATE TABLE IF NOT EXISTS my_fieldset_runs (
|
|
699
|
+
run_id TEXT NOT NULL UNIQUE,
|
|
700
|
+
org_id TEXT NOT NULL,
|
|
701
|
+
status TEXT NOT NULL,
|
|
702
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
703
|
+
);
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
const inserted = await pool.query(
|
|
708
|
+
`INSERT INTO my_fieldset_runs (run_id, org_id, status)
|
|
709
|
+
VALUES ($1, $2, 'pending')
|
|
710
|
+
ON CONFLICT (run_id) DO NOTHING
|
|
711
|
+
RETURNING run_id`,
|
|
712
|
+
[payload.runId, payload.orgId],
|
|
713
|
+
)
|
|
714
|
+
if (inserted.rowCount === 0) return // already processed by an earlier delivery
|
|
715
|
+
// ... do work ...
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
The full webhook contract is specified in `qa-brightline-dod-external-fieldset-sdk-v2.md` §5.6 and the §8 payload reference of the developer docs.
|
|
719
|
+
|
|
720
|
+
---
|
|
721
|
+
|
|
722
|
+
## 9. Embedded UI / dashboard tokens
|
|
723
|
+
|
|
724
|
+
If you ship an embedded UI, the platform renders it as an iframe at `/dashboard/fieldsets/[slug]` with `sandbox="allow-scripts allow-forms"`. The handshake:
|
|
725
|
+
|
|
726
|
+
1. Iframe loads. It posts `{ type: "onbelay:ready" }` to `https://app.onbelay.ai`.
|
|
727
|
+
2. Platform issues a 15-minute HS256 JWT and posts `{ type: "onbelay:context", token, orgId, userId }` back to the iframe.
|
|
728
|
+
3. Iframe sends `token` to **its own backend**, which calls `validateDashboardToken(token, ONBELAY_DASHBOARD_SECRET)` and then performs proxy calls scoped to the validated `orgId`.
|
|
729
|
+
4. On `visibilitychange`, the platform re-issues a fresh token.
|
|
730
|
+
|
|
731
|
+
```ts
|
|
732
|
+
// Inside your iframe (browser):
|
|
733
|
+
window.addEventListener("message", async (event) => {
|
|
734
|
+
if (event.origin !== "https://app.onbelay.ai") return
|
|
735
|
+
if (event.data?.type !== "onbelay:context") return
|
|
736
|
+
|
|
737
|
+
// Send token to YOUR OWN backend — never validate it in the browser,
|
|
738
|
+
// and never bundle ONBELAY_FIELDSET_TOKEN or ONBELAY_DASHBOARD_SECRET.
|
|
739
|
+
const res = await fetch("/api/dashboard-data", {
|
|
740
|
+
method: "POST",
|
|
741
|
+
headers: { "content-type": "application/json" },
|
|
742
|
+
body: JSON.stringify({ token: event.data.token }),
|
|
743
|
+
})
|
|
744
|
+
// ...render
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
window.parent.postMessage({ type: "onbelay:ready" }, "https://app.onbelay.ai")
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
```ts
|
|
751
|
+
// Your backend (Express/Next.js/Hono — same shape):
|
|
752
|
+
import { validateDashboardToken, executeProxyCall } from "@on-belay/sdk"
|
|
753
|
+
|
|
754
|
+
const ctx = await validateDashboardToken(token, process.env.ONBELAY_DASHBOARD_SECRET!)
|
|
755
|
+
if (!ctx) return new Response(JSON.stringify({ error: "invalid_token" }), { status: 401 })
|
|
756
|
+
|
|
757
|
+
const result = await executeProxyCall(
|
|
758
|
+
ctx.orgId,
|
|
759
|
+
"shopify",
|
|
760
|
+
"shopify.products.list",
|
|
761
|
+
"/admin/api/2024-01/products.json",
|
|
762
|
+
undefined,
|
|
763
|
+
{ fieldsetSlug: ctx.fieldsetSlug },
|
|
764
|
+
)
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
> Never call the proxy from the browser. `ONBELAY_FIELDSET_TOKEN` and `ONBELAY_DASHBOARD_SECRET` are server-side only.
|
|
768
|
+
|
|
769
|
+
The platform's CSP must include your Railway domain in `frame-src` for the iframe to render. This is configured by the platform when your fieldset is provisioned — if you see a blank iframe, contact On Belay support.
|
|
770
|
+
|
|
771
|
+
A worked example lives in [`packages/fieldset-hello-world/src/app/dashboard/page.tsx`](../fieldset-hello-world/src/app/dashboard/page.tsx) and [`src/app/api/dashboard-data/route.ts`](../fieldset-hello-world/src/app/api/dashboard-data/route.ts).
|
|
772
|
+
|
|
773
|
+
---
|
|
774
|
+
|
|
775
|
+
## 10. Migration from 1.0.0
|
|
776
|
+
|
|
777
|
+
`@on-belay/sdk@2.0.0` is a complete rewrite. The 1.0.0 SDK depended on platform internals and could not be installed cleanly outside the On Belay monorepo. 2.0.0 is HTTP-only, framework-agnostic, and has exactly one runtime dependency (`jose`).
|
|
778
|
+
|
|
779
|
+
### Removed exports
|
|
780
|
+
|
|
781
|
+
| Removed in 2.0.0 | Replacement |
|
|
782
|
+
|---|---|
|
|
783
|
+
| `createScheduler` | The platform schedules dispatch now. Your service exposes a webhook only. |
|
|
784
|
+
| `createOrgRunner` | The webhook handler IS the runner. Use `createOnbelayWebhookHandler`. |
|
|
785
|
+
| `createPortfolioRunner` | Portfolio fieldsets are out of scope for 2.0.0. |
|
|
786
|
+
| `defineFieldset` | The `Fieldset` row in the platform DB is the manifest source of truth, populated from your application form. |
|
|
787
|
+
| `getNeonCacheClient` | `NEON_CONNECTION_STRING` arrives in the env (and the webhook payload). Use `pg`, `postgres`, or any client directly. |
|
|
788
|
+
| Anything from `cache.ts` | The cache helpers imported platform internals and are removed entirely. |
|
|
789
|
+
|
|
790
|
+
These exports do not exist in 2.0.0 — importing them fails at install or compile time.
|
|
791
|
+
|
|
792
|
+
### Other breaking changes
|
|
793
|
+
|
|
794
|
+
- **`recordPublish` no longer accepts `idempotencyKey`** (Decision B; coming back in v2.1 with a `BillingIdempotency` table). Dedupe on `payload.runId` in your handler instead.
|
|
795
|
+
- **`executeProxyCall` signature changed.** New positional arguments: `(orgId, integrationSlug, operationKey, path, options?, config?)`. The `fieldsetSlug` argument is gone — the SDK reads it from `OnbelayConfig`.
|
|
796
|
+
- **The fieldset token is no longer included in the webhook payload.** Read it from `process.env.ONBELAY_FIELDSET_TOKEN`. The SDK does this by default.
|
|
797
|
+
- **`ONBELAY_PROXY_URL` is base URL only** — `https://app.onbelay.ai`. The SDK appends paths internally. The 1.0.0 value ended in `/api/sdk/proxy`; the platform team runs a backfill script for existing services on cutover.
|
|
798
|
+
|
|
799
|
+
### Migration checklist
|
|
800
|
+
|
|
801
|
+
1. `npm install @on-belay/sdk@2.0.0`
|
|
802
|
+
2. Delete imports of the removed exports listed above.
|
|
803
|
+
3. Replace your scheduler/runner setup with `createOnbelayWebhookHandler` from §6.
|
|
804
|
+
4. Update `executeProxyCall` call sites for the new positional signature.
|
|
805
|
+
5. Drop any reads of `idempotencyKey` from `recordPublish` results.
|
|
806
|
+
6. Stop reading the token from the webhook payload — that field is gone.
|
|
807
|
+
7. Update Railway env var `ONBELAY_PROXY_URL` to `https://app.onbelay.ai` (base URL only).
|
|
808
|
+
8. Add a `runId` dedupe step in your handler. Strongly recommended.
|
|
809
|
+
|
|
810
|
+
---
|
|
811
|
+
|
|
812
|
+
## 11. Troubleshooting
|
|
813
|
+
|
|
814
|
+
| Symptom | Cause + fix |
|
|
815
|
+
|---|---|
|
|
816
|
+
| Webhook returns `401 invalid_signature` | HMAC mismatch. Most common cause: re-stringifying parsed JSON before validating. Capture the raw request body before parsing. In Express use `express.raw({ type: "application/json" })`; in Next.js Route Handlers use `await req.text()` first. Second cause: `ONBELAY_WEBHOOK_SECRET` was rotated and your service was not redeployed. |
|
|
817
|
+
| Webhook returns `401` with timestamp out of window | The `X-Onbelay-Timestamp` header is older than `maxAgeSeconds` (default 300) or more than 30 seconds in the future. Replay-protection is firing. If your server clock is skewed, fix NTP. If you need a longer window for a legitimate reason, raise `maxAgeSeconds` on the handler — but understand you are weakening replay defense. |
|
|
818
|
+
| Webhook returns `401 slug_mismatch` | `options.fieldsetSlug` in your handler does not equal `payload.fieldsetSlug`. Either your env var (`ONBELAY_FIELDSET_SLUG`) drifted from the platform's registered slug, or the platform routed a payload for a different fieldset to your URL. Confirm `Fieldset.slug` in the platform DB. |
|
|
819
|
+
| Token returns `401 invalid_token` / `token_revoked` / `token_expired` | The `ONBELAY_FIELDSET_TOKEN` is invalid, was revoked, or has passed its expiry. Coordinate a token rotation with the platform owner — see the runbook in [`/developer/docs#token-rotation`](https://app.onbelay.ai/developer/docs#token-rotation). |
|
|
820
|
+
| Proxy returns `403 operation_not_permitted` | The `operationKey` you passed is not in your fieldset's `requiredOperations`. Edit your registration to include it (or pick one that's already declared). |
|
|
821
|
+
| Proxy returns `403 integration_not_connected` | The org has not connected the upstream integration. Skip gracefully — do not throw. The org admin will reconnect on their schedule. |
|
|
822
|
+
| Proxy returns `403 org_not_enrolled` | This org is not enrolled in your fieldset, or enrollment was paused. Check `OrgFieldset.status = "active"`. |
|
|
823
|
+
| Proxy returns `403 fieldset_inactive` | The platform owner deactivated your fieldset (kill switch). Contact On Belay support. |
|
|
824
|
+
| Proxy returns `429 rate_limited` | You exceeded the per-token bucket: 60 proxy calls / 60 s, 30 writes / 60 s, 120 reads / 60 s. Wait 60 seconds before retrying — the bucket refills every 60 s. (`@on-belay/sdk@2.0.0` does not surface the `Retry-After` header on `ProxyResult`; a future patch will.) |
|
|
825
|
+
| `OnbelayTransportError` with `attempts === 2` | Platform retried once on a 502/503/504 and the second attempt also failed. Catch in your handler, log to Sentry, and let the next scheduled run pick up the work. |
|
|
826
|
+
| `OnbelayTransportError` with `status === 500` | Platform-side bug, not transient. Not retried. Open a support ticket with the run id. |
|
|
827
|
+
| Inngest is retrying my webhook 3× for the same `runId` | Your handler returned 5xx or timed out (>30 s). Implement idempotency on `runId` so retries are cheap, and offload long work to a background queue while returning 200 quickly. |
|
|
828
|
+
| `ONBELAY_PROXY_URL` ends in `/api/sdk/proxy` | This is the 1.0.0 value. The 2.0.0 SDK appends paths internally. Update Railway to `https://app.onbelay.ai` and redeploy. The platform's backfill script (`scripts/backfill-onbelay-proxy-url.ts`) handles existing services on cutover. |
|
|
829
|
+
|
|
830
|
+
---
|
|
831
|
+
|
|
832
|
+
## 12. Versioning + stability
|
|
833
|
+
|
|
834
|
+
Follows semver. The version line is the contract:
|
|
835
|
+
|
|
836
|
+
- **Major** bump for any removed export, removed field on a public type, or signature change. Includes any change that requires customer code to be edited.
|
|
837
|
+
- **Minor** bump for new optional fields on `WebhookPayload`, new exports, or new optional arguments on existing functions.
|
|
838
|
+
- **Patch** bump for bug fixes that do not change observable behavior.
|
|
839
|
+
|
|
840
|
+
### Stability guarantees in 2.x
|
|
841
|
+
|
|
842
|
+
- Every export listed in [§7](#7-sdk-api-reference) has a stable signature for the lifetime of the 2.x line.
|
|
843
|
+
- The webhook payload may grow (new optional fields). Handlers must tolerate unknown fields.
|
|
844
|
+
- The wire envelope (`{ data: T }` / `{ error: { code, message } }`) is stable.
|
|
845
|
+
- The transport contract (30 s timeout, one retry on 502/503/504, fixed 250 ms delay) is stable.
|
|
846
|
+
- New error codes may be added to `ProxyErrorCode` as new minor releases. Keep your `switch` exhaustive checks tolerant of unknown codes.
|
|
847
|
+
|
|
848
|
+
### Deprecation policy
|
|
849
|
+
|
|
850
|
+
A function or field is marked deprecated in a minor release with a `// @deprecated` JSDoc tag and a migration note in the changelog. It is removed in the next major release. Removed primitives are documented in the "Removed in X.0.0" callout block of the developer docs page.
|
|
851
|
+
|
|
852
|
+
---
|
|
853
|
+
|
|
854
|
+
## 13. Links
|
|
855
|
+
|
|
856
|
+
- **Developer portal:** [https://app.onbelay.ai/developer/docs](https://app.onbelay.ai/developer/docs)
|
|
857
|
+
- **Apply for a fieldset:** [https://app.onbelay.ai/developer/apply](https://app.onbelay.ai/developer/apply)
|
|
858
|
+
- **npm:** [https://www.npmjs.com/package/@on-belay/sdk](https://www.npmjs.com/package/@on-belay/sdk)
|
|
859
|
+
- **Brightline DoD (test contract):** [`docs/onbelay-platform/specs/qa-brightline-dod-external-fieldset-sdk-v2.md`](../../docs/onbelay-platform/specs/qa-brightline-dod-external-fieldset-sdk-v2.md)
|
|
860
|
+
- **Hello World example:** [`packages/fieldset-hello-world/`](../fieldset-hello-world/)
|
|
861
|
+
- **Scaffold:** [`packages/sdk/scaffold/`](./scaffold/)
|
|
862
|
+
- **Hello World source on GitHub:** [Junto-Systems/onbelay-hello-world-fieldset](https://github.com/Junto-Systems/onbelay-hello-world-fieldset)
|
|
863
|
+
- **Email support:** [adam@juntosystems.com](mailto:adam@juntosystems.com)
|
|
864
|
+
|
|
865
|
+
---
|
|
866
|
+
|
|
867
|
+
## 14. License
|
|
868
|
+
|
|
869
|
+
MIT. See `LICENSE` at the repository root.
|