@on-belay/sdk 2.2.0 → 3.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/CHANGELOG.md ADDED
@@ -0,0 +1,246 @@
1
+ # Changelog — `@on-belay/sdk`
2
+
3
+ ## 3.0.0 — 2026-08-23
4
+
5
+ **A major version by semver, with near-zero real migration work.** Two exported functions
6
+ are gone and 24 change arity, which is unambiguously major — but the 23 are helpers that
7
+ **404 on every call in 2.2.1**, so no working code depends on their current shape. If your
8
+ fieldset compiles against 2.2.1 today and does not call an Acumatica detail helper, it
9
+ compiles against 3.0.0 unchanged.
10
+
11
+ ### Why this release exists
12
+
13
+ `scripts/generate-integration-helpers.ts` imported only `prisma/operation-defs-part1` and
14
+ `-part2`, by name, while the operation catalog had grown to `part15` — so 330 operations
15
+ across 15 integrations were unreachable by construction. On top of that, the generator had
16
+ not been run in full since **2026-05-11** (SDK 2.2.0), so even operations added to part1 and
17
+ part2 after that date never reached the package. `shopmonkey.listInventoryParts` below is one
18
+ of those: it was catalogued on 2026-08-20 in *part2*, a file the generator did read.
19
+
20
+ Both gaps were invisible for the same reason: nothing compared the generated output to the
21
+ catalog. The only changes the generated directory received in those three months were two
22
+ single-file **hand-edits** (#825, #1059) to files whose header says
23
+ *"AUTO-GENERATED — do not edit by hand"*. Both happen to survive this regeneration — the
24
+ `get_video_analytics` deletion because its catalog row was deleted too, and the
25
+ `ShopifySlug` narrowing because it was folded into the generator — but that was luck, and
26
+ nothing was checking.
27
+
28
+ | | 2.2.1 | 3.0.0 |
29
+ |---|---:|---:|
30
+ | Operations exposed | 941 | **1,547** |
31
+ | Integration modules | 75 | **110** |
32
+
33
+ ### Added — 608 helpers, 35 new integration modules
34
+
35
+ `air`, `airtable`, `amazon-ads`, `applovin`, `box`, `celigo`, `cloudflare`, `cloudinary`,
36
+ `feishu`, `gemini`, `gooddata`, `google-business-profile`, `impact`, `instagram-insights`,
37
+ `instagram-public`, `judge-me`, `knocommerce`, `linkedin-pages`, `magento`, `microsoft-ads`,
38
+ `mixpanel`, `okta`, `openai-ads`, `pinterest-organic`, `railway`, `rippling`, `salsify`,
39
+ `semrush`, `sharepoint`, `shopmy`, `smile-io`, `teachable`, `tiktok-organic`,
40
+ `transcriptapi`, `web-search`, `windsor`.
41
+
42
+ 440 of the 606 net-new helpers are in those 35 modules; the other 166 landed on modules that
43
+ already existed. Two examples of what the gap cost in practice:
44
+
45
+ - **`shopmonkey.listInventoryParts`** (`/v3/inventory_part`). A production fieldset
46
+ hand-rolled a raw `executeProxyCall` around this, with the comment *"the SDK exposes only
47
+ a generic `listInventory` (→ /v3/inventory); the spec's op is `list_inventory_parts`
48
+ (→ /v3/inventory_part)"* — `owl-shopmonkey-import/src/lib/inventory-resolve.ts:233`. It is
49
+ a typed helper now, and that workaround can be deleted.
50
+ - **`amazonSeller.pullConnector`** (`/amazon_sp`). The only Amazon operation that returns
51
+ real Amazon data. The SDK previously exposed ten Amazon operations that the proxy silently
52
+ rewrites to Windsor aggregates, and not this one.
53
+
54
+ ### Fixed — 23 Acumatica helpers built a malformed URL and 404'd on every call
55
+
56
+ `analyzePathPattern` converts a regex `pathPattern` into a template literal. A trailing bare
57
+ character class with no quantifier — Acumatica's `[^?/]` — matched none of its substitutions,
58
+ so `split("/")` sheared the class and the emitted path ended in the literal text `[^`.
59
+
60
+ ```
61
+ 2.2.1 /entity/Default/${defaultId}/SalesOrder/[^
62
+ 3.0.0 /entity/Default/${endpointVersion}/SalesOrder/${salesOrderId}
63
+ ```
64
+
65
+ This shipped: `npm unpack @on-belay/sdk@2.2.1` shows the leaked `[^` in `dist/index.js`.
66
+ Affected every detail fetch/update/delete across `SalesOrder`, `Customer`, `Vendor`,
67
+ `StockItem`, `NonStockItem`, `PurchaseOrder`, `Invoice`, `Payment`, `Contact`, `Employee`,
68
+ `GeneralLedgerTransaction`, `Account`, `Bill`.
69
+
70
+ **Each of these helpers now takes one more argument** — the resource key that used to be
71
+ `[^`. That is the breaking change, and it is the fix.
72
+
73
+ Also fixed: `sharepoint.searchDrive` emitted `/root/searchq='[^']*'` instead of
74
+ `/root/search(q='${param1}')`. New in this release, so nothing depended on the broken form.
75
+
76
+ ### Fixed — 14 Google helpers dropped the `:action` suffix from the path
77
+
78
+ `ga4.run_report.*`, `gemini.generateContent`, `gemini.streamGenerateContent`,
79
+ `gemini.embedContent`, `gemini.countTokens` and others emitted the resource path with the
80
+ action suffix **missing**:
81
+
82
+ ```
83
+ 2.2.1 /v1beta/properties/${propertyId}
84
+ 3.0.0 /v1beta/properties/${propertyId}:runReport
85
+ ```
86
+
87
+ A path segment that merely *contained* the parameter placeholder was being collapsed down
88
+ to the placeholder, so anything else in that segment was thrown away. Unlike the Acumatica
89
+ case this leaves nothing regex-shaped behind, so it is invisible to a "did any regex
90
+ punctuation survive?" check — the path is simply, silently, a different endpoint. Our own
91
+ proxy rejects it too, because the proxy validates the request path against the same
92
+ `pathPattern`.
93
+
94
+ The same collapse was dropping the literal `v` from version segments, so 27 Google Ads and
95
+ Salesforce helpers emitted `/${version}/…` where the API needs `/v18/…`. Now
96
+ `/v${version}/…`.
97
+
98
+ ### Fixed — a regex quantifier ate a parameter
99
+
100
+ `judgeMe.getProduct` emitted the literal path `/products/-`. Its pattern is
101
+ `^/products/-?\d+`; the parameter was substituted, then the query-string strip cut from the
102
+ `-?` quantifier onward and took the parameter with it. Now `/products/${productId}` — and
103
+ the helper takes the `productId` argument it always should have.
104
+
105
+ ### Fixed — 10 helpers were unparseable TypeScript
106
+
107
+ 44 catalog keys are dotted (`shopify.themes.list`, `ga4.run_report.overall`). The generator
108
+ left the dots in the function name, emitting `export async function shopify.themes.list<T>(`.
109
+ Ten such keys were already reachable, so the generator produced a file that would not parse.
110
+ Identifiers are now sanitized (`shopifyThemesList`); the dotted string is unchanged as the
111
+ operation key sent to the proxy.
112
+
113
+ ### Not changed — Shopify REST paths still omit `.json`
114
+
115
+ Unchanged from 2.2.1. Recorded because it was changed and reverted twice during review, and
116
+ the reasoning is worth having in one place.
117
+
118
+ ```
119
+ 2.2.1 and 3.0.0 /admin/api/{version}/orders
120
+ catalog pattern ^/admin/api/[^/]+/orders\.json ← requires .json
121
+ ```
122
+
123
+ **Your calls are not failing.** Shopify accepts both forms — 154 json-less `/admin/api/`
124
+ calls over 30 days, **every one status 200**, zero failures. And the platform check that
125
+ *would* reject a json-less path is unreachable from a fieldset: it runs only for
126
+ group-access callers with an enforcing grant, and fieldset tokens never take that branch.
127
+
128
+ ⚠️ **The argument for the other choice, stated fairly:** all 17 of the catalog's
129
+ hand-curated Shopify `/admin/api/` `examplePath` values carry `.json`. That is real, and it
130
+ is why this flipped once. It was judged **documentation** divergence rather than evidence of
131
+ breakage — a 55-path behaviour change needs a functional reason, and there is none for this
132
+ package's consumers. Those 17 appear in `PATH-DIVERGENCE.json` as expected entries.
133
+
134
+ ### Changed — caller-supplies-the-path operations now take that path as a parameter
135
+
136
+ Three patterns are bare prefixes rather than fixed endpoints. `shopify.admin.fetch` says so
137
+ in its own description — *"The path is chosen by the caller, so this operation is broad"* —
138
+ and its example is `/admin/api/2026-01/shop.json`. The generator emitted the prefix with no
139
+ parameter, so the helper could not reach any resource:
140
+
141
+ ```
142
+ 2.2.1 shopifyAdminFetch(orgId, slug, options) → /admin/api/2024-01
143
+ 3.0.0 shopifyAdminFetch(orgId, slug, resourcePath, …) → /admin/api/2026-01/${resourcePath}
144
+ ```
145
+
146
+ 5 operations: `shopify.admin.fetch`, github `get_file_contents` / `create_update_file`,
147
+ cloudinary `get_asset` / `delete_asset`. All five are new in 3.0.0 except
148
+ `shopify.admin.fetch`, whose 2.2.1 form could not be used for anything.
149
+
150
+ ### Known — 5 operations emit a path their pattern rejects, and are still shipped
151
+
152
+ `gmail.searchMessages`, `youtube.getVideo`, `googleDrive.downloadFile`,
153
+ `microsoftTeams.listTeams`, `yotpo.getProductReviews`. Their patterns bake a
154
+ **caller-supplied query value** into the path (`\?q=`, `\?.*id=`, `\?alt=media`, `\?.*resourceProvisioningOptions`), which a path
155
+ template cannot express.
156
+
157
+ **They are shipped, deliberately.** `youtube.getVideo` emits `/youtube/v3/videos` and that
158
+ exact path has **3 live 200s** in the last 90 days — pass the id in `queryParams`. An earlier
159
+ draft of this release omitted several of these; that was reverted. The patterns are
160
+ over-specified, and loosening them is a catalog change tracked separately.
161
+
162
+ All divergences are listed in `src/integrations/PATH-DIVERGENCE.json`.
163
+
164
+ ### Fixed — Shopify Admin API version was `2024-01`
165
+
166
+ Over a year past Shopify's end-of-life for that version. Now `2026-01`, read from the
167
+ platform's own `SHOPIFY_API_VERSION` constant at generate time so the two cannot drift.
168
+
169
+ ### Breaking changes — complete list
170
+
171
+ **27 changes require a caller to change code. 24 of them are path fixes.**
172
+
173
+ | Change | Count | Source |
174
+ |---|---:|---|
175
+ | Acumatica detail helpers take one more argument | 22 | **This release** — the 404 fix |
176
+ | `github.getFileContents` / `createUpdateFile` take a `resourcePath` argument | 2 | **This release** — the bare-prefix fix |
177
+ | `shopify.getAnalyticsReport`: `options.queryParams` → `options.body` | 1 | Catalog |
178
+ | `shopmonkey.getOrderFees` removed | 1 | Catalog |
179
+ | `shopmonkey.listInventory` removed | 1 | Catalog |
180
+
181
+ The last three are **catalog-driven, not authored in the SDK** — upstream operation
182
+ definitions changed and the SDK had simply not been regenerated since. `getAnalyticsReport`
183
+ moved because Shopify **deleted** the REST `reports.json` resource, so the operation is a
184
+ GraphQL POST now. Both `shopmonkey` keys were deleted from the catalog because the endpoints
185
+ no longer exist; `/v3/order/{id}/fee` 404s.
186
+
187
+ **41 parameters were renamed. These are not breaking for positional calls** — arity and
188
+ types are unchanged, and every helper is called positionally. They are corrections to names
189
+ a developer would otherwise trust and get wrong:
190
+
191
+ - `defaultId` → `endpointVersion` (Acumatica). The segment after `/entity/Default/` is the
192
+ endpoint *version* — `Default` is the endpoint *name* — so the old name read as an
193
+ identifier of something called Default.
194
+ - `salesorderId` → `salesOrderId`, `inventoryitemId` → `inventoryItemId`,
195
+ `generalledgertransactionId` → `generalLedgerTransactionId`, `localpostId` → `localPostId`,
196
+ and similar across `acumatica`, `netsuite`, `linkedin-ads`, `gooddata`,
197
+ `google-business-profile`. Names were being derived from a lowercased string, collapsing
198
+ the word boundaries.
199
+ - `accountId` → `adAccountId` on six `redditAds` helpers (catalog-driven).
200
+
201
+ ### Known affected consumers: none
202
+
203
+ `owl-shopmonkey-import` is the only external consumer. It pins `^2.2.0`, so it will **not**
204
+ pick up 3.0.0 automatically, and it calls none of the removed or changed helpers — verified
205
+ against its source: it uses `shopify.listInventoryLevels` and raw `executeProxyCall`.
206
+
207
+ ### Guardrails added, so this cannot recur silently
208
+
209
+ - The generator reads `prisma/operation-defs.ts` — the same aggregate seeded into the live
210
+ `IntegrationOperation` table — and **fails loudly** if any `operation-defs-part*.ts` file
211
+ on disk holds operations the aggregate does not carry. A future `part16` that nobody wires
212
+ in aborts the run and names the missing keys.
213
+ - **A path-divergence report.** After conversion, a value is substituted for every
214
+ `${param}` and the concrete path is tested against the source `pathPattern`. Mismatches
215
+ are written to a committed `src/integrations/PATH-DIVERGENCE.json` and the set is **pinned
216
+ by the test suite**, so it cannot grow unnoticed. Current run: **1,547 of 1,547 emitted,
217
+ 0 omitted, 64 divergences recorded.**
218
+ 🚫 It is deliberately **not** a gate. It was one, briefly, and as a gate it removed 55
219
+ working Shopify helpers plus `youtube.getVideo` — whose "broken" path has three live
220
+ 200s. A divergence says the template and the pattern disagree; it does not say which is
221
+ wrong.
222
+ - Colliding helper names, module filenames, or barrel namespaces abort the run instead of
223
+ silently shadowing.
224
+ - CI asserts the generated output matches the catalog exactly, in both directions, plus
225
+ identifier validity, barrel uniqueness, regression pins on each non-trivial path idiom,
226
+ and the Shopify version mirror. The staleness that hid these operations for three months
227
+ is now a red build, not a silence.
228
+ - ⚠️ **The divergence check replaced an earlier "no regex punctuation survived" check,
229
+ which was blind in both directions** and is the reason three defect classes in this
230
+ release shipped in 2.2.1 at all. A *dropped* literal leaves nothing regex-shaped behind
231
+ (`:runReport` vanishing passed it cleanly), and `?` is both regex punctuation and an
232
+ ordinary URL character (so google_drive's correct path was refused). The round-trip
233
+ subsumes it and needs no list of known idioms, so a shape nobody has seen yet is still
234
+ caught.
235
+ - **The examplePath check is the primary path signal.** 616 ops carry a hand-curated
236
+ `examplePath`; the test asserts every emitted template can produce its own. It caught what
237
+ nothing else could — `shopify.admin.fetch`'s bare prefix passed the pattern round-trip,
238
+ because a prefix pattern matches trivially. Three blind spots in sequence: punctuation
239
+ missed deletions, the round-trip missed under-specification, the example caught it.
240
+ **17 known mismatches**, pinned: 15 shopify + 1 yotpo are the deliberate json-less form
241
+ above, and 1 is `cloudinary.uploadAssetRemote`, where the pattern is a 4-way alternation
242
+ (`^/(image|video|auto|raw)/upload`) collapsed to the first option.
243
+ - Real path defects are pinned by tests that encode what the **upstream API** requires,
244
+ not agreement with a regex: the `:action` suffix and `v` version literal (28,142 live
245
+ google_ads calls, 100% carrying both), acumatica's sheared character class, and
246
+ judge_me's `/products/-`. Evidence about the API is the authority — a pattern is not.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Build a fieldset that runs on the [On Belay](https://app.onbelay.ai) governance
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@on-belay/sdk)](https://www.npmjs.com/package/@on-belay/sdk)
6
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`).
7
+ `@on-belay/sdk` 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
8
 
9
9
  ---
10
10
 
@@ -17,6 +17,8 @@ Build a fieldset that runs on the [On Belay](https://app.onbelay.ai) governance
17
17
  5. [Quickstart — Hello World](#5-quickstart--hello-world)
18
18
  6. [The webhook handler](#6-the-webhook-handler)
19
19
  7. [SDK API reference](#7-sdk-api-reference)
20
+ - [Typed integration helpers](#typed-integration-helpers)
21
+ - [logAction](#logaction)
20
22
  8. [Webhook payload contract](#8-webhook-payload-contract)
21
23
  9. [Embedded UI / dashboard tokens](#9-embedded-ui--dashboard-tokens)
22
24
  10. [Migration from 1.0.0](#10-migration-from-100)
@@ -30,7 +32,7 @@ Build a fieldset that runs on the [On Belay](https://app.onbelay.ai) governance
30
32
  ## 1. Install
31
33
 
32
34
  ```bash
33
- npm install @on-belay/sdk@^2.0.0
35
+ npm install @on-belay/sdk@^2.2.0
34
36
  ```
35
37
 
36
38
  Minimal usage — a complete signed-webhook receiver in five lines:
@@ -106,7 +108,7 @@ Optional: `NEON_CONNECTION_STRING` (per-enrollment, injected when an org has a N
106
108
 
107
109
  ## 5. Quickstart — Hello World
108
110
 
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:
111
+ A fully working sample fieldset lives at [`packages/fieldset-hello-world/`](../fieldset-hello-world/). It is the canonical reference fieldset — start there. The summary:
110
112
 
111
113
  ```bash
112
114
  # 1. Clone the scaffold (or fork the Hello World fieldset).
@@ -261,6 +263,62 @@ Every HTTP-bound function shares one network contract:
261
263
 
262
264
  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
265
 
266
+ ### Typed integration helpers
267
+
268
+ *Added in 2.1.0.* The package ships **75 integration modules** — auto-generated typed wrappers over `executeProxyCall`, one per connected integration (`shopify`, `hubspot`, `klaviyo`, `linear`, …). Each helper has the `operationKey` and upstream path baked in, and returns the same `Promise<ProxyResult<T>>` with the **identical retry and error contract** described above. They are a typed convenience layer, not a new transport.
269
+
270
+ Operations are **namespaced, never bare exports.** Many integrations share operation names (`listOrders`, `listCustomers`), so the SDK exports one namespace object per integration rather than colliding top-level functions. There are three ways to reach a helper:
271
+
272
+ ```ts
273
+ import { shopify, createShopifySubClient, OnbelayClient } from "@on-belay/sdk"
274
+
275
+ // 1. Namespace object — pass orgId + integrationSlug on every call.
276
+ const a = await shopify.listOrders<MyOrder[]>(orgId, "shopify", {
277
+ queryParams: { status: "any" },
278
+ })
279
+
280
+ // 2. Sub-client factory — bind orgId + slug + config once.
281
+ const sc = createShopifySubClient(orgId, "shopify", { fieldsetSlug: "my-fieldset" })
282
+ const b = await sc.listOrders<MyOrder[]>({ queryParams: { status: "any" } })
283
+
284
+ // 3. Via OnbelayClient — the accessor returns the same bound sub-client.
285
+ const client = new OnbelayClient({ fieldsetSlug: "my-fieldset" })
286
+ const c = await client.shopify(orgId).listOrders<MyOrder[]>()
287
+ ```
288
+
289
+ Helpers that target a single resource take the id as a positional argument. `getLocation` is a **Shopify integration helper** (not a top-level SDK function) — it looks up one location:
290
+
291
+ ```ts
292
+ // Namespace form:
293
+ const loc = await shopify.getLocation<{ location: ShopifyLocation }>(
294
+ orgId,
295
+ "shopify",
296
+ locationId,
297
+ )
298
+
299
+ // OnbelayClient form — orgId is already bound by the accessor:
300
+ const loc2 = await client.shopify(orgId).getLocation<{ location: ShopifyLocation }>(
301
+ locationId,
302
+ )
303
+
304
+ if (loc.ok) console.log(loc.data.location.name)
305
+ ```
306
+
307
+ GraphQL integrations (Linear, Monday) expose helpers that post `{ query, variables }` to `/graphql` — pass the GraphQL document through `options.body`:
308
+
309
+ ```ts
310
+ import { linear } from "@on-belay/sdk"
311
+
312
+ const issues = await linear.listIssues<{ issues: { nodes: unknown[] } }>(orgId, {
313
+ body: {
314
+ query: "query { issues(first: 20) { nodes { id title } } }",
315
+ variables: {},
316
+ },
317
+ })
318
+ ```
319
+
320
+ > A typed helper is exactly as permitted as the raw `executeProxyCall` it wraps. It does **not** bypass proxy permission gates — the org must have the integration connected, and the helper's `operationKey` (e.g. `get_location`) must be declared in your fieldset's `requiredOperations`. Calling a helper for an operation you have not declared still returns `403 operation_not_permitted`.
321
+
264
322
  ### `OnbelayConfig`
265
323
 
266
324
  ```ts
@@ -317,7 +375,7 @@ import { executeProxyCall } from "@on-belay/sdk"
317
375
  const result = await executeProxyCall<{ products: Array<{ id: number; title: string }> }>(
318
376
  orgId,
319
377
  "shopify",
320
- "shopify.products.list",
378
+ "list_products",
321
379
  "/admin/api/2024-01/products.json?limit=1",
322
380
  { method: "GET" },
323
381
  { fieldsetSlug: "my-fieldset" },
@@ -337,6 +395,25 @@ console.log(result.data.products[0]?.title)
337
395
 
338
396
  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
397
 
398
+ > **`operationKey` is the operation's own catalog key, matched by exact string
399
+ > equality.** `list_products`, `create_contact`, `shopify.themes.assets.update` —
400
+ > passed alongside a separate `integrationSlug` argument. It is **not** a
401
+ > resource-and-verb path: `"shopify.products.list"` is not a catalog key on anything
402
+ > and is refused. There is no normalization, aliasing or wildcarding: the key you
403
+ > pass must be byte-identical to the one in your `requiredOperations`, which must in
404
+ > turn be a real key on that integration. Some catalog keys contain dots of their
405
+ > own; paste those exactly as written. The
406
+ > [typed integration helpers](#typed-integration-helpers) are generated from the
407
+ > catalog, so using them removes this class of mistake entirely.
408
+ >
409
+ > On the **application form** the pair is written as one string,
410
+ > `integrationSlug.operationKey` — e.g. `shopify.list_products`. A few catalog keys
411
+ > are themselves slug-prefixed, so an entry like `reddit_ads.get_report` reads two
412
+ > ways (the key `reddit_ads.get_report`, or the key `get_report` on `reddit_ads` —
413
+ > two different operations, with different path patterns). Those are rejected rather
414
+ > than guessed; write them with a colon — `reddit_ads:get_report` — which is never
415
+ > ambiguous.
416
+
340
417
  ### `getOrgContext`
341
418
 
342
419
  ```ts
@@ -351,7 +428,7 @@ interface OrgContext {
351
428
  interface ConnectedIntegration {
352
429
  slug: string
353
430
  status: "active" | "error" | "pending"
354
- /** Server-allowlisted public config. v2.0.0: shopify → { shopDomain }, others → {}. */
431
+ /** Server-allowlisted public config. shopify → { shopDomain }, others → {}. */
355
432
  extraConfig: Record<string, string | null>
356
433
  }
357
434
  ```
@@ -425,7 +502,7 @@ function recordPublish(
425
502
 
426
503
  interface PublishResult {
427
504
  count: number // new running count after this publish
428
- freeAllowance: number // 10 by default in 2.0.0
505
+ freeAllowance: number // 10 by default
429
506
  billable: boolean // true once count > freeAllowance
430
507
  }
431
508
  ```
@@ -440,7 +517,7 @@ const result = await recordPublish(orgId, "product_brief", {
440
517
  console.log(`${result.count}/${result.freeAllowance} — billable: ${result.billable}`)
441
518
  ```
442
519
 
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.
520
+ > **No `idempotencyKey`.** The atomic upsert on `(orgId, fieldsetId, contentType)` is the only concurrency guarantee. Strict per-key idempotency requires a `BillingIdempotency` table that is deferred to a future release. Until then, dedupe on `payload.runId` in your handler before calling `recordPublish` so retries don't double-bill.
444
521
 
445
522
  ### `isEnrolled`
446
523
 
@@ -467,6 +544,34 @@ const orgs = await getEnrolledOrgs({ fieldsetSlug: "my-fieldset" })
467
544
  // → ["clk_abc123", "clk_def456"]
468
545
  ```
469
546
 
547
+ ### `logAction`
548
+
549
+ ```ts
550
+ function logAction(
551
+ orgId: string,
552
+ action: string,
553
+ metadata?: Record<string, unknown>,
554
+ config?: OnbelayConfig,
555
+ ): Promise<{ ok: boolean }>
556
+ ```
557
+
558
+ *Added in 2.2.0.* `POST /api/sdk/orgs/:orgId/audit`. Writes a governance-visible `AuditLog` row for an org-scoped action your fieldset took. `action` must match `/^[a-z0-9_-]+$/` and be at most 128 characters; the platform stores it namespaced as `external_fieldset_<action>`. The `orgId`, your fieldset id, and the timestamp are taken from the verified Bearer token — never from the request body — so the row cannot be spoofed to another org or fieldset.
559
+
560
+ > **`logAction` is audit-only — it is NEVER billed.** It does not touch any publish counter. Use `recordPublish` when content goes live and should count toward billing; use `logAction` to record everything else worth an audit trail (a sync started, an org skipped, a threshold crossed).
561
+
562
+ Throws `OnbelayProtocolError` on a 4xx (e.g. `invalid_action` when `action` fails the pattern or length check). Throws `OnbelayTransportError` on transport failure or a platform 5xx after the single retry.
563
+
564
+ ```ts
565
+ const result = await logAction(orgId, "daily_sync_completed", {
566
+ productsScanned: 142,
567
+ runId: payload.runId,
568
+ })
569
+ // Stored as AuditLog action "external_fieldset_daily_sync_completed".
570
+ console.log(result.ok) // → true
571
+ ```
572
+
573
+ Also available as an `OnbelayClient` method — `client.logAction(orgId, action, metadata?)`.
574
+
470
575
  ### `validateWebhookSignature`
471
576
 
472
577
  ```ts
@@ -575,12 +680,20 @@ class OnbelayClient {
575
680
  contentType: string,
576
681
  metadata?: Record<string, unknown>,
577
682
  ): Promise<PublishResult>
683
+ logAction(
684
+ orgId: string,
685
+ action: string,
686
+ metadata?: Record<string, unknown>,
687
+ ): Promise<{ ok: boolean }>
578
688
  isEnrolled(orgId: string): Promise<boolean>
579
689
  getEnrolledOrgs(): Promise<string[]>
690
+
691
+ shopify(orgId: string): ShopifySubClient
692
+ // + one accessor per integration (hubspot, klaviyo, linear, …)
580
693
  }
581
694
  ```
582
695
 
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.
696
+ Convenience class that holds an `OnbelayConfig` and exposes the eight HTTP-bound functions as instance methods, plus one bound sub-client accessor per integration (see [Typed integration helpers](#typed-integration-helpers)). Pure ergonomic wrapper — every method delegates to the equivalent free function or sub-client factory with the bound config. Constructor throws if `fieldsetSlug` is missing.
584
697
 
585
698
  ```ts
586
699
  import { OnbelayClient } from "@on-belay/sdk"
@@ -617,7 +730,7 @@ class OnbelayProtocolError extends Error {
617
730
  }
618
731
  ```
619
732
 
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`.
733
+ Thrown by HTTP-bound functions that return raw values (`getOrgContext`, `getFieldsetConfig`, `setFieldsetConfig`, `recordPublish`, `logAction`, `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
734
 
622
735
  ### Public types
623
736
 
@@ -632,6 +745,8 @@ The full type surface re-exported from the package root:
632
745
  | `EnrolledOrg` | Forward-compatible richer enrollment shape (the function returns `string[]`; this is exposed for typed downstream usage). |
633
746
  | `ContentType` | Free-form string alias used by `recordPublish`. |
634
747
  | `PublishResult` | `recordPublish` return shape. |
748
+ | `LogActionResult` | `logAction` return shape (`{ ok: boolean }`). |
749
+ | `ShopifySlug` | `"shopify"` — accepted by the Shopify integration helpers. |
635
750
  | `WebhookPayload` | Inbound webhook body. See [§8](#8-webhook-payload-contract). |
636
751
  | `WebhookHandlerOptions`, `WebhookHandlerContext`, `WebhookResult` | `createOnbelayWebhookHandler` shapes. |
637
752
  | `WebhookVerifyOptions` | Options for `validateWebhookSignature` / handler. |
@@ -673,7 +788,7 @@ X-Onbelay-Timestamp: <ISO 8601, equal to payload.timestamp>
673
788
  |---|---|---|
674
789
  | `orgId` | yes | The enrolled org this run targets. |
675
790
  | `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. |
791
+ | `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. |
677
792
  | `timestamp` | yes | ISO 8601. Used together with `maxAgeSeconds` for replay protection. |
678
793
  | `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
794
  | `neonConnectionString` | optional | Present only when this org has a Neon branch provisioned. Read/write on your branch only. |
@@ -724,9 +839,27 @@ The full webhook contract is specified in `qa-brightline-dod-external-fieldset-s
724
839
  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
840
 
726
841
  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.
842
+ 2. Platform issues a **1-hour** HS256 JWT and posts `{ type: "onbelay:context", token, proxyUrl, fieldsetSlug }` back to the iframe. There is **no top-level `orgId` or `userId`** — both are inside the JWT and are returned by `validateDashboardToken`. `proxyUrl` is a **base** URL (e.g. `https://app.onbelay.ai`), the same shape as `ONBELAY_PROXY_URL` and `OnbelayConfig.proxyUrl` — the SDK appends `/api/sdk/proxy` itself, so do **not** append it yourself or you will request `.../api/sdk/proxy/api/sdk/proxy`.
728
843
  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.
844
+ 4. The platform re-issues a fresh token on `visibilitychange` **and** on a ~10-minute timer, so your listener must tolerate repeated `onbelay:context` messages — each supersedes the last.
845
+
846
+ > ⚠️ **Do not write the guard yourself — use `isOnbelayContextMessage`.**
847
+ >
848
+ > ```ts
849
+ > import { isOnbelayContextMessage } from "@on-belay/sdk"
850
+ >
851
+ > window.addEventListener("message", (event) => {
852
+ > if (event.origin !== "https://app.onbelay.ai") return
853
+ > if (!isOnbelayContextMessage(event.data)) return
854
+ > // event.data is narrowed: { type, token, proxyUrl, fieldsetSlug }
855
+ > })
856
+ > ```
857
+ >
858
+ > A hand-written guard of the shape `if (!d.token || !d.orgId) return` rejects
859
+ > **every** message, because `orgId` is not at the top level. The symptom is not an
860
+ > error — the UI renders and every API call becomes a silent no-op. This is a real
861
+ > incident, not a hypothetical: it is what happened on `owl-shopmonkey-import`
862
+ > (`docs/fieldsets/owl-shopmonkey-import/specs/qa-test-pass.md:81`).
730
863
 
731
864
  ```ts
732
865
  // Inside your iframe (browser):
@@ -757,7 +890,7 @@ if (!ctx) return new Response(JSON.stringify({ error: "invalid_token" }), { stat
757
890
  const result = await executeProxyCall(
758
891
  ctx.orgId,
759
892
  "shopify",
760
- "shopify.products.list",
893
+ "list_products",
761
894
  "/admin/api/2024-01/products.json",
762
895
  undefined,
763
896
  { fieldsetSlug: ctx.fieldsetSlug },
@@ -774,6 +907,8 @@ A worked example lives in [`packages/fieldset-hello-world/src/app/dashboard/page
774
907
 
775
908
  ## 10. Migration from 1.0.0
776
909
 
910
+ > **Upgrading from 2.0.0 or later? No code changes.** 2.1.0 (typed integration helpers) and 2.2.0 (`logAction`) are purely additive over 2.0.0 — no exports were removed and no existing signature changed. Upgrading from any 2.x release to 2.2.0 is a no-code `npm install @on-belay/sdk@2.2.0`. The steps below apply **only** to services still on 1.0.0.
911
+
777
912
  `@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
913
 
779
914
  ### Removed exports
@@ -791,14 +926,14 @@ These exports do not exist in 2.0.0 — importing them fails at install or compi
791
926
 
792
927
  ### Other breaking changes
793
928
 
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.
929
+ - **`recordPublish` no longer accepts `idempotencyKey`** (Decision B; coming back in a future release with a `BillingIdempotency` table). Dedupe on `payload.runId` in your handler instead.
795
930
  - **`executeProxyCall` signature changed.** New positional arguments: `(orgId, integrationSlug, operationKey, path, options?, config?)`. The `fieldsetSlug` argument is gone — the SDK reads it from `OnbelayConfig`.
796
931
  - **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
932
  - **`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
933
 
799
934
  ### Migration checklist
800
935
 
801
- 1. `npm install @on-belay/sdk@2.0.0`
936
+ 1. `npm install @on-belay/sdk@2.2.0`
802
937
  2. Delete imports of the removed exports listed above.
803
938
  3. Replace your scheduler/runner setup with `createOnbelayWebhookHandler` from §6.
804
939
  4. Update `executeProxyCall` call sites for the new positional signature.
@@ -821,7 +956,7 @@ These exports do not exist in 2.0.0 — importing them fails at install or compi
821
956
  | 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
957
  | Proxy returns `403 org_not_enrolled` | This org is not enrolled in your fieldset, or enrollment was paused. Check `OrgFieldset.status = "active"`. |
823
958
  | 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.) |
959
+ | 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. (The SDK does not surface the `Retry-After` header on `ProxyResult`; a future patch will.) |
825
960
  | `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
961
  | `OnbelayTransportError` with `status === 500` | Platform-side bug, not transient. Not retried. Open a support ticket with the run id. |
827
962
  | 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. |