@on-belay/sdk 1.0.0 → 2.0.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/dist/index.d.mts CHANGED
@@ -1,531 +1,468 @@
1
1
  /**
2
- * @on-belay/sdk — Exported Types
2
+ * @on-belay/sdk@2.0.0Public types
3
3
  *
4
- * All types used by fieldset authors are exported from here.
5
- * Fieldsets import from "@on-belay/sdk" only — never from platform internals.
4
+ * All types used by external fieldset authors are exported from here. The
5
+ * surface matches `docs/onbelay-platform/specs/external-fieldset-sdk-v2-spec.md`
6
+ * §4 and §A5.
7
+ *
8
+ * Importantly: every type here is portable. None depend on Prisma, the
9
+ * Next.js runtime, or any platform internal. The SDK is consumed by services
10
+ * running outside the On Belay monorepo.
11
+ */
12
+ /**
13
+ * Per-call config bag. Every HTTP-bound SDK function accepts an optional
14
+ * `OnbelayConfig` as its trailing argument. The defaults read from env vars on
15
+ * every call so a long-lived service automatically picks up rotated tokens.
16
+ */
17
+ interface OnbelayConfig {
18
+ /** Defaults to `process.env.ONBELAY_PROXY_URL`. Base URL only — paths are appended by the SDK. */
19
+ proxyUrl?: string;
20
+ /** Defaults to `process.env.ONBELAY_FIELDSET_TOKEN`. */
21
+ token?: string;
22
+ /** Required. Declared by the developer's service so the SDK can attach defense-in-depth slug params. */
23
+ fieldsetSlug: string;
24
+ /** Override for testing. Defaults to global `fetch`. */
25
+ fetch?: typeof fetch;
26
+ }
27
+ /**
28
+ * Thrown by every HTTP-bound function after a transport-level failure has been
29
+ * retried once per spec §4.6 (250ms fixed delay, no jitter). Transport
30
+ * failures are: network error, connect refused, abort/timeout, or HTTP 502/503/504.
31
+ *
32
+ * Other 5xx responses (500, 505+) throw immediately with `attempts === 1`.
33
+ *
34
+ * Carries the last observed status, the URL the SDK was hitting, and how many
35
+ * attempts were made (`1` if the first failure was non-retryable, `2` after
36
+ * the retry was issued and also failed).
6
37
  */
7
- type ProxyResult = {
38
+ declare class OnbelayTransportError extends Error {
39
+ readonly status: number;
40
+ readonly url: string;
41
+ readonly attempts: number;
42
+ constructor(message: string, opts: {
43
+ status: number;
44
+ url: string;
45
+ attempts: number;
46
+ });
47
+ }
48
+ /**
49
+ * Thrown by HTTP-bound functions that return raw values (not `ProxyResult`)
50
+ * when the platform returns a 4xx error code. `code` is the platform's error
51
+ * code (e.g. `org_not_enrolled`, `fieldset_mismatch`).
52
+ *
53
+ * `executeProxyCall` does NOT throw this — it returns a typed `ProxyResult`
54
+ * envelope so the caller can branch without a try/catch.
55
+ */
56
+ declare class OnbelayProtocolError extends Error {
57
+ readonly status: number;
58
+ readonly url: string;
59
+ readonly code: string;
60
+ constructor(message: string, opts: {
61
+ status: number;
62
+ url: string;
63
+ code: string;
64
+ });
65
+ }
66
+ type ProxyErrorCode = "invalid_token" | "operation_not_permitted" | "org_not_enrolled" | "integration_not_connected" | "fieldset_inactive" | "invalid_request" | "upstream_error" | "proxy_error" | "rate_limit_exceeded";
67
+ type ProxyResult<T = unknown> = {
68
+ ok: true;
8
69
  status: number;
9
- data: unknown;
10
- blocked?: boolean;
11
- error?: string;
70
+ data: T;
71
+ } | {
72
+ ok: false;
73
+ status: number;
74
+ blocked: true;
75
+ error: ProxyErrorCode;
76
+ } | {
77
+ ok: false;
78
+ status: number;
79
+ blocked: false;
80
+ error: string;
12
81
  };
13
- type ConnectedIntegration = {
82
+ interface ConnectedIntegration {
14
83
  slug: string;
15
84
  status: "active" | "error" | "pending";
85
+ /**
86
+ * Server-side allowlisted extra config. For v2.0.0:
87
+ * shopify → `{ shopDomain }`
88
+ * all others → `{}`
89
+ * Encrypted columns (apiKeyEnc, apiSecretEnc) are NEVER returned.
90
+ */
16
91
  extraConfig: Record<string, string | null>;
17
- };
18
- type OrgContext = {
92
+ }
93
+ interface OrgContext {
19
94
  orgId: string;
20
95
  orgName: string;
21
96
  connectedIntegrations: ConnectedIntegration[];
22
- };
23
- type PortfolioOrg = {
97
+ }
98
+ /**
99
+ * Spec §A5 lists `EnrolledOrg` in `types.ts`. The function `getEnrolledOrgs`
100
+ * returns `Promise<string[]>` per spec §4.4 / shim contract — `EnrolledOrg`
101
+ * is a forward-compatible richer shape exposed for consumers that want a
102
+ * named type for "an enrolled org" beyond the bare orgId list.
103
+ */
104
+ interface EnrolledOrg {
24
105
  orgId: string;
25
- orgName: string;
26
- orgSlug: string;
27
- };
28
- type FieldsetManifest = {
29
- /** Unique kebab-case slug. Immutable after first deploy. */
30
- slug: string;
31
- /** Display name shown in the dashboard. */
32
- name: string;
33
- /** Short description shown in the fieldset catalog. */
34
- description: string;
35
- /**
36
- * Execution model.
37
- * - 'per-org': standard fieldset, enrolled per STANDARD org. Default.
38
- * - 'portfolio': enrolled once at an AGENCY org level; has access to all child orgs.
39
- *
40
- * Omitting this field is treated as 'per-org' for backward compatibility.
41
- */
42
- executionModel?: "per-org" | "portfolio";
43
- /** Integration slugs the org must have connected before enrolling. */
44
- requiredIntegrations: string[];
45
- /** Integration slugs that improve the fieldset but are not required. */
46
- optionalIntegrations?: string[];
47
- /**
48
- * Every (integrationSlug, operationKey) pair this fieldset ever calls via executeProxyCall.
49
- * The proxy rejects any call not in this manifest.
50
- */
51
- requiredOperations: Array<{
52
- slug: string;
53
- operationKey: string;
54
- }>;
55
- /**
56
- * Billing configuration. Required if the fieldset calls recordPublish().
57
- * Each key is a contentType string. freeAllowance is per-org, per-contentType.
58
- */
59
- billing?: {
60
- contentTypes: Record<string, {
61
- label: string;
62
- freeAllowance: number;
63
- }>;
106
+ }
107
+ /**
108
+ * v2.0.0 ships with these as documented examples — `contentType` is a free-form
109
+ * string so developers can pick their own keys. Listed here for spec §A5
110
+ * compliance ("ContentType in types.ts").
111
+ */
112
+ type ContentType = string;
113
+ interface PublishResult {
114
+ count: number;
115
+ freeAllowance: number;
116
+ billable: boolean;
117
+ }
118
+ /**
119
+ * Body of every webhook the platform sends to an external fieldset.
120
+ *
121
+ * - `runId` (Decision D-A) stable across Inngest retries within the same
122
+ * (enrollment, calendar day, triggerType). Use this for handler-side dedupe.
123
+ * - `fieldsetToken` is NOT in this payload (Decision G-A). The developer reads
124
+ * `process.env.ONBELAY_FIELDSET_TOKEN` directly.
125
+ * - `actor` is present only on `triggerType === "manual"` runs (Decision H).
126
+ * - `config` (Decision F-A) contains ONLY the `fieldset` namespace of
127
+ * `OrgFieldset.config`. The `admin` namespace is never sent.
128
+ * - `neonConnectionString` is present only when `OrgFieldset.neonConnectionStringEnc`
129
+ * is non-null.
130
+ */
131
+ interface WebhookPayload {
132
+ orgId: string;
133
+ fieldsetSlug: string;
134
+ triggerType: "scheduled" | "manual" | "user_triggered" | "enrollment_changed" | "unenrollment";
135
+ timestamp: string;
136
+ runId: string;
137
+ neonConnectionString?: string;
138
+ config?: Record<string, unknown>;
139
+ actor?: {
140
+ userId: string;
141
+ email: string;
64
142
  };
143
+ }
144
+ interface WebhookHandlerContext {
145
+ payload: WebhookPayload;
146
+ rawBody: string;
147
+ }
148
+ interface WebhookHandlerOptions {
149
+ /** ONBELAY_WEBHOOK_SECRET — used for HMAC-SHA256 verification. */
150
+ secret: string;
65
151
  /**
66
- * Default values written into OrgFieldset.config at enrollment time.
67
- * The fieldset reads these back via getFieldsetConfig().
68
- */
69
- configDefaults?: Record<string, unknown>;
70
- /**
71
- * Migration file names (relative to the fieldset's migrations/ directory), in order.
72
- * The platform runs these at enrollment time.
73
- */
74
- migrations?: string[];
75
- /**
76
- * Exported Inngest function names from the fieldset's inngest/ directory.
77
- * The platform registers these at app startup.
152
+ * The fieldset this service implements. Payloads addressed to a different
153
+ * fieldset are rejected with 401 to defend against misrouted dispatch.
78
154
  */
79
- inngestFunctions?: string[];
80
- };
81
- /** Opaque Inngest function type — returned by createScheduler / createOrgRunner / createPortfolioRunner. */
82
- type InngestFunction = any;
83
- /** Opaque Inngest step object — provided by Inngest to function handlers. */
84
- type InngestStep = any;
85
- /** Opaque Inngest event object — full event payload including data, id, ts. */
86
- type InngestEvent = any;
87
- type SchedulerConfig = {
88
- /** Inngest function ID — must be unique across the platform. E.g. "content-engine-scheduler". */
89
- id: string;
90
- /** The fieldset slug — used to query enrolled orgs via getEnrolledOrgs. */
91
155
  fieldsetSlug: string;
92
- /** Cron expression (UTC). E.g. "0 9 * * 1-5" for weekdays at 9am UTC. */
93
- cron: string;
94
- /** The event name to emit for each enrolled org. E.g. "content-engine/run.org". */
95
- eventName: string;
96
- /**
97
- * Optional: extra event data to include per org event.
98
- * Called once per org — return any serializable data to merge into the event payload.
99
- */
100
- eventData?: (orgId: string) => Record<string, unknown>;
101
- /** Maximum concurrent scheduler runs. Default: 1. */
102
- concurrency?: number;
103
- };
104
- type OrgRunContext = {
105
- /** The org this run is for. */
156
+ /** User code. Throw 500 platform retries (§6.5). Return 200. */
157
+ onTrigger: (ctx: WebhookHandlerContext) => Promise<void>;
158
+ /** Defaults to 300 (5 min) per spec §6.3. */
159
+ maxAgeSeconds?: number;
160
+ }
161
+ interface WebhookResult {
162
+ status: number;
163
+ body: string;
164
+ headers: Record<string, string>;
165
+ }
166
+ interface WebhookVerifyOptions {
167
+ maxAgeSeconds?: number;
168
+ }
169
+ interface DashboardTokenPayload {
106
170
  orgId: string;
107
- /** Inngest step object — use step.run() to create durable, retriable steps. */
108
- step: InngestStep;
109
- /** Full Inngest event — includes any extra data from the scheduler. */
110
- event: InngestEvent;
111
- };
112
- type OrgRunnerConfig = {
113
- /** Inngest function ID. E.g. "content-engine-runner". */
114
- id: string;
115
- /** Event that triggers this runner. Must match scheduler's eventName. */
116
- triggerEvent: string;
117
- /** Fieldset slug — used for enrollment verification inside the runner. */
118
- fieldsetSlug: string;
119
- /**
120
- * Concurrency key expression. Default: "event.data.orgId".
121
- * One concurrent run per unique key value at a time.
122
- */
123
- concurrencyKey?: string;
124
- /** Default: 1. */
125
- concurrencyLimit?: number;
126
- /** Number of Inngest retries on failure. Default: 2. */
127
- retries?: number;
128
- /** Inngest function timeout in minutes. Default: 10. */
129
- timeoutMinutes?: number;
130
- /** The fieldset's run logic. Receives orgId, step, and the full event. */
131
- run: (context: OrgRunContext) => Promise<unknown>;
132
- };
133
- type PortfolioRunContext = {
134
- /** The enrolled AGENCY org. */
135
- parentOrgId: string;
136
- /** Pre-fetched client orgs via getPortfolioOrgs — iterate these in isolation. */
137
- clientOrgs: PortfolioOrg[];
138
- /** Inngest step object. */
139
- step: InngestStep;
140
- /** Full Inngest event. */
141
- event: InngestEvent;
142
- };
143
- type PortfolioRunnerConfig = {
144
- /** Inngest function ID. E.g. "pe-revenue-tracker-runner". */
145
- id: string;
146
- /** Event that triggers this runner. E.g. "pe-revenue-tracker/run.portfolio". */
147
- triggerEvent: string;
148
- /** Fieldset slug — used for enrollment verification. */
171
+ userId: string;
149
172
  fieldsetSlug: string;
150
- /** Default: 1. */
151
- concurrencyLimit?: number;
152
- /** Default: 2. */
153
- retries?: number;
154
- /** Default: 15 minutes (portfolio runs aggregate across N orgs). */
155
- timeoutMinutes?: number;
156
- /** The fieldset's portfolio run logic. */
157
- run: (context: PortfolioRunContext) => Promise<unknown>;
158
- };
173
+ iat?: number;
174
+ exp?: number;
175
+ }
159
176
 
160
177
  /**
161
- * @on-belay/sdk — executeProxyCall
178
+ * @on-belay/sdk@2.0.0 — executeProxyCall + shared HTTP transport
162
179
  *
163
- * The only way a fieldset may call a third-party API. Routes through the
164
- * On Belay proxy layer, which handles credential retrieval, decryption, token
165
- * refresh, audit logging, and org-scoping.
180
+ * `executeProxyCall` is the only sanctioned way for an external fieldset to call
181
+ * a third-party API. Every call is dispatched as
182
+ * `POST {ONBELAY_PROXY_URL}/api/sdk/proxy` with a Bearer token; the platform
183
+ * resolves credentials, refreshes tokens, audits the call, and proxies through.
166
184
  *
167
- * Fieldsets never construct AuthResult objects or touch proxy-handler.ts directly.
185
+ * The HTTP transport (`onbelayFetch`) is exported privately for the other
186
+ * HTTP-bound SDK modules (`org.ts`, `config.ts`, `enrollment.ts`, `billing.ts`)
187
+ * to share — they all enforce the same retry/timeout semantics from spec §4.6.
168
188
  *
169
- * Dual-mode execution:
170
- * - Internal mode (no ONBELAY_FIELDSET_TOKEN env var): direct import from platform
171
- * internals. Used by fieldsets running inside the On Belay monorepo.
172
- * - External mode (ONBELAY_FIELDSET_TOKEN is set): HTTP POST to ONBELAY_PROXY_URL.
173
- * Used by external fieldsets deployed as standalone services (e.g. on Railway).
189
+ * Spec contract: §4.4 (signature), §4.6 (network behavior), §5.2 `POST /api/sdk/proxy`.
174
190
  */
175
191
 
176
192
  /**
177
- * Execute an authenticated API call to a third-party integration on behalf of an org.
193
+ * Execute an authenticated API call to a third-party integration on behalf of
194
+ * an org. Returns a typed `ProxyResult` envelope — the caller MUST check `ok`
195
+ * before reading `data`.
178
196
  *
179
- * This is the ONLY way a fieldset may call any external API.
197
+ * Endpoint: `POST /api/sdk/proxy`. Spec §4.4, §5.2.
180
198
  *
181
- * Dispatches to external HTTP mode when ONBELAY_FIELDSET_TOKEN is set in the environment,
182
- * otherwise falls back to direct internal platform imports (monorepo mode).
199
+ * Returns:
200
+ * - `{ ok: true, status, data }` on 2xx upstream.
201
+ * - `{ ok: false, blocked: true, status, error: ProxyErrorCode }` on 4xx
202
+ * from the platform (token, permission, enrollment, integration, etc.).
203
+ * - `{ ok: false, blocked: false, status, error }` on upstream errors.
183
204
  *
184
- * @param orgId - The org on whose behalf the call is made. For portfolio runners
185
- * acting on a client, pass the clientOrgId here.
186
- * @param fieldsetSlug - The fieldset's slug (from its manifest). Used to construct
187
- * the fieldset-master AuthResult and enforce operation permissions.
188
- * @param integrationSlug - The On Belay integration slug (e.g. "shopify", "hubspot").
189
- * @param operationKey - The operation key declared in the fieldset manifest (e.g. "shopify.products.list").
190
- * @param path - The API path (e.g. "/admin/api/2024-01/products.json").
191
- * @param options - Optional: method, body, queryParams, _portfolioParentOrgId.
192
- *
193
- * @returns ProxyResult — check result.blocked before using result.data.
194
- *
195
- * Error behavior:
196
- * - result.blocked === true: integration not connected or operation not in manifest. Log and skip.
197
- * - result.status >= 500: upstream API error. Throw to let Inngest retry.
198
- * - Throws if fieldsetSlug is not registered: developer error, fix before deploying.
205
+ * Throws `OnbelayTransportError` on transport failure or 5xx after one retry.
199
206
  */
200
- declare function executeProxyCall(orgId: string, fieldsetSlug: string, integrationSlug: string, operationKey: string, path: string, options?: {
207
+ declare function executeProxyCall<T = unknown>(orgId: string, integrationSlug: string, operationKey: string, path: string, options?: {
201
208
  method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
202
209
  body?: Record<string, unknown>;
203
210
  queryParams?: Record<string, string>;
204
- /**
205
- * Portfolio runners only. When set, the SDK validates that orgId is a client
206
- * of this parent org before dispatching the proxy call. Returns blocked: true
207
- * if orgId is not a valid client of the parent.
208
- *
209
- * NOTE: Only available in internal mode. In external mode this field is ignored
210
- * — parent validation is enforced server-side by the proxy endpoint.
211
- *
212
- * TODO: platform-team (P0 #15) — When proxy-handler gains native portfolio
213
- * parent validation, delegate there instead of doing it here.
214
- */
215
- _portfolioParentOrgId?: string;
216
- }): Promise<ProxyResult>;
211
+ }, config?: OnbelayConfig): Promise<ProxyResult<T>>;
217
212
 
218
213
  /**
219
- * @on-belay/sdk — validateWebhookSignature
214
+ * @on-belay/sdk@2.0.0getOrgContext
220
215
  *
221
- * Self-contained HMAC-SHA256 webhook signature verification.
222
- * Mirrors the implementation in src/lib/webhook-signing.ts but has zero
223
- * imports from platform internals safe for npm publishing.
216
+ * Returns the calling fieldset's view of one enrolled org — orgName +
217
+ * connected integrations + server-allowlisted `extraConfig`. Encrypted
218
+ * credential columns are NEVER returned (spec §5.2).
224
219
  *
225
- * Usage:
226
- * import { validateWebhookSignature } from "@on-belay/sdk"
227
- *
228
- * app.post("/api/webhook", express.raw({ type: "application/json" }), (req, res) => {
229
- * const isValid = validateWebhookSignature(
230
- * req.body,
231
- * req.headers["x-onbelay-signature"] as string,
232
- * process.env.ONBELAY_WEBHOOK_SECRET!,
233
- * req.headers["x-onbelay-timestamp"] as string,
234
- * { maxAgeSeconds: 300 }
235
- * )
236
- * if (!isValid) return res.status(401).json({ error: "invalid_signature" })
237
- * // ... handle webhook
238
- * })
220
+ * Endpoint: `GET /api/sdk/orgs/:orgId/context`. Spec §4.4 / §5.2.
239
221
  */
240
- interface WebhookVerifyOptions {
241
- maxAgeSeconds?: number;
242
- }
222
+
243
223
  /**
244
- * Verifies an inbound webhook signature from On Belay.
245
- * Uses constant-time comparison to prevent timing side-channel attacks.
246
- * Returns false (never throws) on any mismatch or error.
247
- *
248
- * @param rawBody - Raw request body before JSON.parse (string or Buffer).
249
- * Must be the exact bytes received — do NOT parse first.
250
- * @param signatureHeader - Value of X-Onbelay-Signature header ("sha256=<hex>")
251
- * @param secret - Your webhook secret (ONBELAY_WEBHOOK_SECRET env var)
252
- * @param timestamp - Optional. Value of X-Onbelay-Timestamp header (ISO8601).
253
- * When provided, the function rejects payloads older than
254
- * maxAgeSeconds (replay-attack defense).
255
- * @param options - Optional. { maxAgeSeconds?: number } — defaults to 300 (5 min).
256
- *
257
- * @returns true if the signature is valid (and timestamp is within age limit when provided)
224
+ * Look up the calling fieldset's view of one enrolled org.
225
+ *
226
+ * @throws `OnbelayProtocolError` on 4xx (`org_not_enrolled`, `org_not_found`,
227
+ * `invalid_token`, `rate_limit_exceeded`).
228
+ * @throws `OnbelayTransportError` on transport failure / 5xx after one retry.
258
229
  */
259
- declare function validateWebhookSignature(rawBody: string | Buffer, signatureHeader: string, secret: string, timestamp?: string, options?: WebhookVerifyOptions): boolean;
230
+ declare function getOrgContext(orgId: string, config?: OnbelayConfig): Promise<OrgContext>;
260
231
 
261
232
  /**
262
- * @on-belay/sdk — validateDashboardToken
233
+ * @on-belay/sdk@2.0.0getFieldsetConfig / setFieldsetConfig
263
234
  *
264
- * Browser and Node.js compatible JWT verification using jose.
265
- * Validates a dashboard context token issued by the On Belay platform.
235
+ * Read/write the **fieldset namespace** of `OrgFieldset.config` JSON for one
236
+ * enrolled org. The `admin` namespace (used by org admins for config the
237
+ * developer should not see) is server-protected — `setFieldsetConfig` cannot
238
+ * touch it.
266
239
  *
267
- * Usage:
268
- * import { validateDashboardToken } from "@on-belay/sdk"
269
- *
270
- * app.post("/api/my-data", express.json(), async (req, res) => {
271
- * const context = await validateDashboardToken(
272
- * req.body.token,
273
- * process.env.ONBELAY_DASHBOARD_SECRET!
274
- * )
275
- * if (!context) return res.status(401).json({ error: "invalid_token" })
276
- *
277
- * const { orgId, userId, fieldsetSlug } = context
278
- * // act on behalf of this org
279
- * })
280
- */
281
- interface DashboardTokenPayload {
282
- orgId: string;
283
- userId: string;
284
- fieldsetSlug: string;
285
- iat?: number;
286
- exp?: number;
287
- }
288
- /**
289
- * Validates a dashboard context token issued by the On Belay platform.
290
- * Returns the decoded payload on success, or null on any validation failure
291
- * (expired, bad signature, malformed). Never throws.
240
+ * Endpoints:
241
+ * GET /api/sdk/orgs/:orgId/config?fieldset=<slug>
242
+ * PATCH /api/sdk/orgs/:orgId/config
292
243
  *
293
- * @param token - The JWT string received via postMessage from the platform
294
- * @param secret - process.env.ONBELAY_DASHBOARD_SECRET
244
+ * Spec §4.4, §5.2, Decision F-A (§A3 namespace migration).
295
245
  */
296
- declare function validateDashboardToken(token: string, secret: string): Promise<{
297
- orgId: string;
298
- userId: string;
299
- fieldsetSlug: string;
300
- } | null>;
301
246
 
302
247
  /**
303
- * @on-belay/sdk getOrgContext
248
+ * Returns the contents of `OrgFieldset.config.fieldset` (or `{}` if absent).
249
+ * The `admin` namespace is never returned to the SDK.
304
250
  *
305
- * Returns org name and connected integrations for a given org.
306
- * Read-only. Does not expose encrypted fields or credentials.
251
+ * @throws `OnbelayProtocolError` on 4xx (`org_not_enrolled`, `fieldset_mismatch`,
252
+ * `invalid_token`).
253
+ * @throws `OnbelayTransportError` on transport failure / 5xx after one retry.
307
254
  */
308
-
255
+ declare function getFieldsetConfig<T = Record<string, unknown>>(orgId: string, config?: OnbelayConfig): Promise<T>;
309
256
  /**
310
- * Returns the org's name and list of connected integrations with their status
311
- * and extraConfig. Read-only credentials are never exposed here.
312
- *
313
- * @throws If orgId does not exist in the database.
257
+ * Patch-merges into the **fieldset namespace** of `OrgFieldset.config`. The
258
+ * server performs a transactional `SELECT FOR UPDATE merge → UPDATE` so
259
+ * concurrent writes do not lose updates.
260
+ *
261
+ * Forbidden: a top-level `admin` key in the patch returns
262
+ * `OnbelayProtocolError` with code `forbidden_namespace` — the developer
263
+ * cannot write to admin-set config via this SDK.
264
+ *
265
+ * Size caps:
266
+ * - SDK rejects patches >32KB (serialized) before they leave the process.
267
+ * - Server rejects merged config.fieldset >64KB.
268
+ *
269
+ * @throws `OnbelayProtocolError` on 4xx (`org_not_enrolled`, `invalid_patch`,
270
+ * `fieldset_mismatch`, `forbidden_namespace`, `config_too_large`,
271
+ * `payload_too_large`).
272
+ * @throws `OnbelayTransportError` on transport failure / 5xx after one retry.
314
273
  */
315
- declare function getOrgContext(orgId: string): Promise<OrgContext>;
274
+ declare function setFieldsetConfig<T = Record<string, unknown>>(orgId: string, patch: Partial<T>, config?: OnbelayConfig): Promise<void>;
316
275
 
317
276
  /**
318
- * @on-belay/sdk — getFieldsetConfig / setFieldsetConfig
277
+ * @on-belay/sdk@2.0.0isEnrolled / getEnrolledOrgs
319
278
  *
320
- * Read and write the OrgFieldset.config JSON blob for an org+fieldset pair.
321
- * Config is plaintext do NOT store credentials, tokens, or sensitive values here.
322
- */
323
- /**
324
- * Returns the OrgFieldset.config for this org and fieldset.
325
- * Returns {} (empty object cast to T) if no config has been set yet.
279
+ * Enrollment-state queries scoped to the calling fieldset (the token
280
+ * identifies the fieldset; clients cannot list other fieldsets' enrollments).
326
281
  *
327
- * @param orgId - The org ID.
328
- * @param fieldsetSlug - The fieldset's slug.
329
- * @returns The config cast to T. The fieldset declares T to get type safety.
282
+ * Endpoints:
283
+ * GET /api/sdk/orgs/:orgId/enrolled?fieldset=<slug> → { enrolled: boolean }
284
+ * GET /api/sdk/enrollments → { orgs: string[] }
330
285
  *
331
- * @throws If orgId does not exist.
286
+ * Spec §4.4, §5.2. `isEnrolled` returns `false` for orgs that don't exist
287
+ * the platform must not leak existence (brightline §3.11).
332
288
  */
333
- declare function getFieldsetConfig<T = Record<string, unknown>>(orgId: string, fieldsetSlug: string): Promise<T>;
289
+
334
290
  /**
335
- * Merges patch into the existing OrgFieldset.config. Non-destructive:
336
- * keys not in patch are preserved.
291
+ * Returns `true` only when the calling fieldset has an active enrollment for
292
+ * this org. Returns `false` for unknown orgs, suspended enrollments, and any
293
+ * other non-active state.
337
294
  *
338
- * @param orgId - The org ID.
339
- * @param fieldsetSlug - The fieldset's slug.
340
- * @param patch - Partial config to merge in.
341
- *
342
- * @throws If the OrgFieldset row does not exist (org not enrolled in this fieldset).
343
- * Call isEnrolled() before calling this if enrollment is uncertain.
295
+ * @throws `OnbelayProtocolError` on 4xx (`invalid_token`, `fieldset_mismatch`,
296
+ * `rate_limit_exceeded`).
297
+ * @throws `OnbelayTransportError` on transport failure / 5xx after one retry.
344
298
  */
345
- declare function setFieldsetConfig<T = Record<string, unknown>>(orgId: string, fieldsetSlug: string, patch: Partial<T>): Promise<void>;
346
-
299
+ declare function isEnrolled(orgId: string, config?: OnbelayConfig): Promise<boolean>;
347
300
  /**
348
- * @on-belay/sdk recordPublish
349
- *
350
- * Records a billing event for a publish (writeback) action.
351
- * Increments the PublishCounter for this org+fieldset+contentType.
352
- * If Stripe metered billing is configured, fires the usage event.
301
+ * Returns the orgIds of every org with an active enrollment in the calling
302
+ * fieldset. The fieldsetId is derived server-side from the token — clients
303
+ * cannot list another fieldset's enrollments.
353
304
  *
354
- * Call at writeback time, NOT at generation time.
305
+ * @throws `OnbelayProtocolError` on 4xx (`invalid_token`, `rate_limit_exceeded`).
306
+ * @throws `OnbelayTransportError` on transport failure / 5xx after one retry.
355
307
  */
308
+ declare function getEnrolledOrgs(config?: OnbelayConfig): Promise<string[]>;
309
+
356
310
  /**
357
- * Records a publish billing event for an org+fieldset+contentType combination.
358
- *
359
- * - Upserts a PublishCounter row (increments publishCount).
360
- * - Fires a Stripe metered billing event when STRIPE_SECRET_KEY is configured.
361
- * Stripe failures are logged but do NOT throw — the publish proceeds regardless.
362
- * If the Prisma upsert fails, it throws so the Inngest step retries.
311
+ * @on-belay/sdk@2.0.0 recordPublish
363
312
  *
364
- * IMPORTANT: Call this at writeback time, not generation time. Billing is
365
- * triggered by publish-to-live, not by content generation.
313
+ * Increments the org's PublishCounter for this fieldset and content type.
314
+ * v2.0.0 does NOT support an idempotency key (Decision B in spec §15) — the
315
+ * server's atomic upsert on `(orgId, fieldsetId, contentType)` is the only
316
+ * concurrency guarantee. Strict idempotency requires a `BillingIdempotency`
317
+ * table that is deferred to v2.1.
366
318
  *
367
- * IMPORTANT: This call and the writeback itself must be inside the SAME
368
- * step.run() block to ensure atomicity — if the writeback fails, the billing
369
- * event does not fire (Inngest retries the entire step).
319
+ * Endpoint: `POST /api/sdk/orgs/:orgId/billing/publish`. Spec §4.4 / §5.2.
370
320
  *
371
- * @param orgId - The org being billed.
372
- * @param fieldsetSlug - The fieldset slug.
373
- * @param contentType - The content type (must match a key in the fieldset's
374
- * manifest billing.contentTypes). E.g. "product_description".
375
- * @param metadata - Optional metadata to log with the billing event.
321
+ * Call at writeback time (not generation time). Billing is incurred when
322
+ * content goes live for the org, not when it is generated.
376
323
  */
377
- declare function recordPublish(orgId: string, fieldsetSlug: string, contentType: string, metadata?: Record<string, unknown>): Promise<void>;
378
324
 
379
325
  /**
380
- * @on-belay/sdk isEnrolled / getEnrolledOrgs
326
+ * Record a billable publish event. Returns the new running count, the row's
327
+ * `freeAllowance` (10 by default after the §A2 migration), and whether this
328
+ * publish is billable (`count > freeAllowance`).
381
329
  *
382
- * Enrollment state queries for fieldset schedulers and runners.
330
+ * @throws `OnbelayProtocolError` on 4xx (`org_not_enrolled`,
331
+ * `invalid_content_type`, `invalid_token`, `rate_limit_exceeded`).
332
+ * @throws `OnbelayTransportError` on transport failure / 5xx after one retry.
383
333
  */
334
+ declare function recordPublish(orgId: string, contentType: string, metadata?: Record<string, unknown>, config?: OnbelayConfig): Promise<PublishResult>;
335
+
384
336
  /**
385
- * Returns true if the org has an active OrgFieldset record for this fieldset.
386
- * Returns false for any non-active status (suspended, expired, disabled) or if
387
- * no record exists.
337
+ * @on-belay/sdk@2.0.0 validateWebhookSignature
388
338
  *
389
- * Use this inside runners to gate execution. Do NOT use it to gate proxy calls —
390
- * the proxy already enforces enrollment.
339
+ * Self-contained HMAC-SHA256 webhook signature verification.
391
340
  *
392
- * @param orgId - The org to check.
393
- * @param fieldsetSlug - The fieldset slug.
394
- */
395
- declare function isEnrolled(orgId: string, fieldsetSlug: string): Promise<boolean>;
396
- /**
397
- * Returns an array of orgId strings for all orgs with an active enrollment
398
- * in this fieldset.
341
+ * Identical wire-level contract to 1.0.0 — kept verbatim per spec §4.2.
342
+ * Pure crypto, no platform imports, safe for npm publishing.
399
343
  *
400
- * Use this inside a scheduler to discover orgs to fan out to. Do NOT query
401
- * OrgFieldset directly from fieldset code — this is the canonical path.
344
+ * Usage:
345
+ * import { validateWebhookSignature } from "@on-belay/sdk"
402
346
  *
403
- * @param fieldsetSlug - The fieldset slug.
404
- * @returns Array of org IDs with active enrollment.
347
+ * const isValid = validateWebhookSignature(
348
+ * rawBody,
349
+ * req.headers["x-onbelay-signature"],
350
+ * process.env.ONBELAY_WEBHOOK_SECRET!,
351
+ * req.headers["x-onbelay-timestamp"],
352
+ * { maxAgeSeconds: 300 }
353
+ * )
405
354
  */
406
- declare function getEnrolledOrgs(fieldsetSlug: string): Promise<string[]>;
407
355
 
408
356
  /**
409
- * @on-belay/sdk getPortfolioOrgs
357
+ * Verifies an inbound webhook signature from On Belay.
410
358
  *
411
- * Returns client orgs for a portfolio (AGENCY) org. Used by portfolio runners
412
- * to discover which client orgs to iterate over.
359
+ * - Constant-time comparison via `timingSafeEqual`.
360
+ * - Returns `false` (never throws) on any mismatch or error.
361
+ * - Optional timestamp argument enforces replay-attack defense: payloads
362
+ * older than `maxAgeSeconds` (default 300) and more than 30s in the future
363
+ * are rejected.
364
+ *
365
+ * @param rawBody Raw request body before JSON.parse — exact bytes.
366
+ * @param signatureHeader Value of `X-Onbelay-Signature` (`sha256=<hex>`).
367
+ * @param secret `ONBELAY_WEBHOOK_SECRET` env var.
368
+ * @param timestamp Optional. `X-Onbelay-Timestamp` header (ISO8601).
369
+ * @param options Optional. `{ maxAgeSeconds }` — defaults to 300.
413
370
  */
371
+ declare function validateWebhookSignature(rawBody: string | Buffer, signatureHeader: string, secret: string, timestamp?: string, options?: WebhookVerifyOptions): boolean;
414
372
 
415
373
  /**
416
- * Returns all client orgs that are children of the given parentOrgId.
417
- * These are the orgs a portfolio fieldset is authorized to read from and
418
- * act on behalf of.
374
+ * @on-belay/sdk@2.0.0 validateDashboardToken
419
375
  *
420
- * Data isolation law: process each client org in isolation inside its own
421
- * step.run() block. Aggregate only scalar outputs across client contexts
422
- * never raw credential-adjacent data.
376
+ * Browser- and Node-compatible JWT verification using `jose`. Validates a
377
+ * dashboard context token issued by the On Belay platform when an org admin
378
+ * opens a fieldset's embedded UI.
423
379
  *
424
- * @param parentOrgId - The enrolled AGENCY org.
425
- * @returns Array of client orgs. Returns [] if no clients — not an error.
380
+ * Identical contract to 1.0.0 (spec §4.2). Pure crypto, no platform imports.
426
381
  *
427
- * @throws If parentOrgId does not exist.
428
- * @throws If parentOrgId resolves to a non-AGENCY org.
382
+ * Usage:
383
+ * const ctx = await validateDashboardToken(token, process.env.ONBELAY_DASHBOARD_SECRET!)
384
+ * if (!ctx) return new Response("invalid_token", { status: 401 })
385
+ * // ctx.orgId, ctx.userId, ctx.fieldsetSlug
429
386
  */
430
- declare function getPortfolioOrgs(parentOrgId: string): Promise<PortfolioOrg[]>;
431
387
 
432
388
  /**
433
- * @on-belay/sdk createScheduler / createOrgRunner / createPortfolioRunner
434
- *
435
- * Inngest function builders for fieldsets. These cover the standard
436
- * scheduler → fan-out → per-org runner pattern.
389
+ * Verify and decode a dashboard context token. Returns the decoded payload on
390
+ * success or `null` on any failure (expired, bad signature, malformed, wrong
391
+ * issuer). Never throws.
437
392
  *
438
- * For complex multi-stage pipelines (like the marketing agent), write
439
- * Inngest functions directly using inngest.createFunction — do not stretch
440
- * these builders to cover cases they weren't designed for.
393
+ * @param token JWT string received via postMessage from the platform.
394
+ * @param secret `ONBELAY_DASHBOARD_SECRET` env var.
441
395
  */
396
+ declare function validateDashboardToken(token: string, secret: string): Promise<{
397
+ orgId: string;
398
+ userId: string;
399
+ fieldsetSlug: string;
400
+ } | null>;
442
401
 
443
402
  /**
444
- * Creates an Inngest function that runs on a cron schedule, queries enrolled
445
- * orgs via getEnrolledOrgs, and fans out one event per org.
446
- *
447
- * Returns the Inngest function object for registration in route.ts.
448
- *
449
- * Use this for the standard scheduler pattern. If your scheduler needs custom
450
- * logic beyond fan-out, write inngest.createFunction directly.
451
- *
452
- * @example
453
- * export const myFieldsetScheduler = createScheduler({
454
- * id: "my-fieldset-scheduler",
455
- * fieldsetSlug: "my-fieldset",
456
- * cron: "0 9 * * 1-5",
457
- * eventName: "my-fieldset/run.org",
458
- * })
459
- */
460
- declare function createScheduler(config: SchedulerConfig): InngestFunction;
461
- /**
462
- * Creates an Inngest function triggered by triggerEvent. Extracts orgId from
463
- * event.data, verifies enrollment (short-circuits if not enrolled), and calls
464
- * run(context) which the fieldset implements.
465
- *
466
- * @example
467
- * export const myFieldsetRunner = createOrgRunner({
468
- * id: "my-fieldset-runner",
469
- * triggerEvent: "my-fieldset/run.org",
470
- * fieldsetSlug: "my-fieldset",
471
- * async run({ orgId, step }) {
472
- * await step.run("do-work", async () => { ... })
473
- * },
474
- * })
403
+ * @on-belay/sdk@2.0.0 createOnbelayWebhookHandler
404
+ *
405
+ * Framework-agnostic webhook handler factory. The returned function takes a
406
+ * raw body string + headers map and returns `{ status, body, headers }` so it
407
+ * can be wrapped in any framework (Next.js Route Handlers, Express, Fastify,
408
+ * Hono).
409
+ *
410
+ * Spec §4.4 (signature), §4.4 numbered behavior list:
411
+ * 1. Validate `X-Onbelay-Signature` HMAC against the raw body. Invalid → 401.
412
+ * 2. Validate `X-Onbelay-Timestamp` is within `maxAgeSeconds`. Stale → 401.
413
+ * 3. Parse JSON. Validate `payload.fieldsetSlug === options.fieldsetSlug`. Mismatch → 401.
414
+ * 4. Call `onTrigger(ctx)`. Throw → 500. Success → 200.
415
+ *
416
+ * The handler does NOT verify `runId` uniqueness — Inngest re-issues identical
417
+ * runIds on retry, and the developer is expected to dedupe in their own
418
+ * persistence layer (spec §6.1, §6.7).
475
419
  */
476
- declare function createOrgRunner(config: OrgRunnerConfig): InngestFunction;
477
- /**
478
- * Creates an Inngest function for portfolio (AGENCY org level) fieldsets.
479
- * Extracts parentOrgId from event.data, verifies enrollment, fetches client
480
- * orgs via getPortfolioOrgs, then calls run(context) with the full client list.
481
- *
482
- * The runner does NOT fan out to per-client Inngest events automatically.
483
- * Fieldsets receive the full client org list and iterate internally using
484
- * step.run() blocks for per-client retry granularity.
485
- *
486
- * @example
487
- * export const myPortfolioRunner = createPortfolioRunner({
488
- * id: "my-portfolio-runner",
489
- * triggerEvent: "my-fieldset/run.portfolio",
490
- * fieldsetSlug: "my-fieldset",
491
- * async run({ parentOrgId, clientOrgs, step }) {
492
- * for (const client of clientOrgs) {
493
- * await step.run(`process-${client.orgId}`, async () => { ... })
494
- * }
495
- * },
496
- * })
497
- */
498
- declare function createPortfolioRunner(config: PortfolioRunnerConfig): InngestFunction;
499
420
 
500
421
  /**
501
- * @on-belay/sdk defineFieldset
502
- *
503
- * Helper to define a fieldset manifest with type checking.
504
- * Use this in every fieldset's fieldset.manifest.ts file.
505
- *
506
- * @example
507
- * import { defineFieldset } from "@on-belay/sdk"
508
- *
509
- * export default defineFieldset({
510
- * slug: "content-engine",
511
- * name: "Content Engine",
512
- * description: "AI-powered product and blog content for Shopify brands.",
513
- * executionModel: "per-org",
514
- * requiredIntegrations: ["shopify"],
515
- * requiredOperations: [
516
- * { slug: "shopify", operationKey: "shopify.products.list" },
517
- * ],
518
- * })
422
+ * Build a Web-Standards webhook handler bound to one fieldset.
423
+ *
424
+ * @param options.secret `ONBELAY_WEBHOOK_SECRET`.
425
+ * @param options.fieldsetSlug The fieldset this service implements; payloads
426
+ * addressed to a different slug return 401.
427
+ * @param options.onTrigger User code. Throw → 500 → platform retries.
428
+ * @param options.maxAgeSeconds Optional. Defaults to 300 (spec §6.3).
429
+ *
430
+ * @returns `(rawBody, headers) => Promise<WebhookResult>` — caller is
431
+ * responsible for translating the result into framework-native
432
+ * Request/Response objects.
519
433
  */
434
+ declare function createOnbelayWebhookHandler(options: WebhookHandlerOptions): (rawBody: string, headers: Record<string, string | undefined>) => Promise<WebhookResult>;
520
435
 
521
436
  /**
522
- * Define a fieldset manifest. This is a typed identity function it returns
523
- * the manifest object unchanged but provides TypeScript type checking at
524
- * authoring time.
437
+ * @on-belay/sdk@2.0.0OnbelayClient
438
+ *
439
+ * Convenience class that holds an `OnbelayConfig` and exposes the SDK's
440
+ * HTTP-bound functions as instance methods. Pure ergonomic wrapper — every
441
+ * method delegates to the equivalent free function with the bound config.
525
442
  *
526
- * The platform reads the default export of fieldset.manifest.ts at registration
527
- * and enrollment time.
443
+ * Spec §4.4.1.
444
+ *
445
+ * Use it when you have a single fieldset slug for the entire process and want
446
+ * to avoid passing the config bag to every call:
447
+ *
448
+ * const onbelay = new OnbelayClient({ fieldsetSlug: "my-fieldset" })
449
+ * await onbelay.recordPublish(orgId, "report")
528
450
  */
529
- declare function defineFieldset(manifest: FieldsetManifest): FieldsetManifest;
530
451
 
531
- export { type ConnectedIntegration, type DashboardTokenPayload, type FieldsetManifest, type InngestEvent, type InngestFunction, type InngestStep, type OrgContext, type OrgRunContext, type OrgRunnerConfig, type PortfolioOrg, type PortfolioRunContext, type PortfolioRunnerConfig, type ProxyResult, type SchedulerConfig, type WebhookVerifyOptions, createOrgRunner, createPortfolioRunner, createScheduler, defineFieldset, executeProxyCall, getEnrolledOrgs, getFieldsetConfig, getOrgContext, getPortfolioOrgs, isEnrolled, recordPublish, setFieldsetConfig, validateDashboardToken, validateWebhookSignature };
452
+ declare class OnbelayClient {
453
+ private readonly config;
454
+ constructor(config: OnbelayConfig);
455
+ executeProxyCall<T = unknown>(orgId: string, integrationSlug: string, operationKey: string, path: string, options?: {
456
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
457
+ body?: Record<string, unknown>;
458
+ queryParams?: Record<string, string>;
459
+ }): Promise<ProxyResult<T>>;
460
+ getOrgContext(orgId: string): Promise<OrgContext>;
461
+ getFieldsetConfig<T = Record<string, unknown>>(orgId: string): Promise<T>;
462
+ setFieldsetConfig<T = Record<string, unknown>>(orgId: string, patch: Partial<T>): Promise<void>;
463
+ recordPublish(orgId: string, contentType: string, metadata?: Record<string, unknown>): Promise<PublishResult>;
464
+ isEnrolled(orgId: string): Promise<boolean>;
465
+ getEnrolledOrgs(): Promise<string[]>;
466
+ }
467
+
468
+ export { type ConnectedIntegration, type ContentType, type DashboardTokenPayload, type EnrolledOrg, OnbelayClient, type OnbelayConfig, OnbelayProtocolError, OnbelayTransportError, type OrgContext, type ProxyErrorCode, type ProxyResult, type PublishResult, type WebhookHandlerContext, type WebhookHandlerOptions, type WebhookPayload, type WebhookResult, type WebhookVerifyOptions, createOnbelayWebhookHandler, executeProxyCall, getEnrolledOrgs, getFieldsetConfig, getOrgContext, isEnrolled, recordPublish, setFieldsetConfig, validateDashboardToken, validateWebhookSignature };