@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 +246 -0
- package/README.md +151 -16
- package/dist/index.d.mts +9938 -3418
- package/dist/index.d.ts +9938 -3418
- package/dist/index.js +13242 -5520
- package/dist/index.mjs +13171 -5520
- package/package.json +4 -3
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
|
[](https://www.npmjs.com/package/@on-belay/sdk)
|
|
6
6
|
|
|
7
|
-
`@on-belay/sdk
|
|
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.
|
|
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
|
|
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
|
-
"
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
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. (
|
|
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. |
|