@oxchannels/sdk 1.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/LICENSE +7 -0
- package/QUICKSTART.md +604 -0
- package/README.md +193 -0
- package/contracts/openapi-agent-bootstrap-contract-v2.json +1266 -0
- package/contracts/openapi-dashboard-contract-v2.json +98996 -0
- package/contracts/openapi-partner-contract-v2.json +14808 -0
- package/contracts/openapi-public-contract-v2.json +13489 -0
- package/dist/client.d.ts +83 -0
- package/dist/client.js +661 -0
- package/dist/generated/agent-bootstrap-contract-v2-schema.d.ts +398 -0
- package/dist/generated/agent-bootstrap-contract-v2-schema.js +1 -0
- package/dist/generated/client.d.ts +1 -0
- package/dist/generated/client.js +2 -0
- package/dist/generated/contracts/api-errors.d.ts +83 -0
- package/dist/generated/contracts/api-errors.js +296 -0
- package/dist/generated/contracts/json-data.d.ts +16 -0
- package/dist/generated/contracts/json-data.js +308 -0
- package/dist/generated/dashboard-contract-v2-schema.d.ts +45429 -0
- package/dist/generated/dashboard-contract-v2-schema.js +1 -0
- package/dist/generated/partner-contract-v2-schema.d.ts +6458 -0
- package/dist/generated/partner-contract-v2-schema.js +1 -0
- package/dist/generated/public-contract-v2-schema.d.ts +4112 -0
- package/dist/generated/public-contract-v2-schema.js +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1 -0
- package/generated/provenance.json +87 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2026 Ox Studio
|
|
2
|
+
|
|
3
|
+
All rights reserved.
|
|
4
|
+
|
|
5
|
+
This source code and its documentation are proprietary and confidential. No
|
|
6
|
+
permission is granted to use, copy, modify, distribute, sublicense, or publish
|
|
7
|
+
this software without prior written authorization from the copyright holder.
|
package/QUICKSTART.md
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
# SDK quickstart
|
|
2
|
+
|
|
3
|
+
Status: **PRODUCTION_RELEASE_SDK_1_0**
|
|
4
|
+
Reviewed: **2026-09-06**
|
|
5
|
+
Owners: **API and Integration Lead**
|
|
6
|
+
|
|
7
|
+
This guide takes an external system from `npm install` to a post published on every target a
|
|
8
|
+
workspace can publish to, through `@oxchannels/sdk`. It uses the partner surface only: one service
|
|
9
|
+
key, HTTPS, no browser, no OAuth. Every path and field below is taken from the sealed partner
|
|
10
|
+
contract; the complete program is
|
|
11
|
+
[`packages/openapi-client/examples/publish-everywhere.ts`](../../packages/openapi-client/examples/publish-everywhere.ts),
|
|
12
|
+
which the package type-checks on every test run, so this page cannot describe a call the SDK does
|
|
13
|
+
not have.
|
|
14
|
+
|
|
15
|
+
<!-- generated:distribution-identity start -->
|
|
16
|
+
|
|
17
|
+
| Property | Value |
|
|
18
|
+
| ----------------------- | -------------------------------- |
|
|
19
|
+
| Sealed distribution | `1.15.0` |
|
|
20
|
+
| Contract set | `2.0.0` |
|
|
21
|
+
| Wire version | `v1` |
|
|
22
|
+
| Published operations | 442 |
|
|
23
|
+
| Required version header | `OxChannels-Contract-Set: 2.0.0` |
|
|
24
|
+
|
|
25
|
+
<!-- generated:distribution-identity end -->
|
|
26
|
+
|
|
27
|
+
## 1. Install and create the client
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install @oxchannels/sdk
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
OxChannels is in a closed beta: the package is public on npm, but every call needs an API key
|
|
34
|
+
issued by the OxChannels team for your workspace, and contracts may still change between beta
|
|
35
|
+
releases. This guide ships inside the package as `QUICKSTART.md`.
|
|
36
|
+
|
|
37
|
+
The SDK is ESM only and needs a runtime with WHATWG `fetch` (Node 24 is what it is tested on). One
|
|
38
|
+
client per service key; the key is created in the panel under API keys and is passed to the SDK as
|
|
39
|
+
`serviceKey`. The SDK adds `Authorization: Bearer ...` and `OxChannels-Contract-Set: 2.0.0` to
|
|
40
|
+
every request and refuses any base URL that is not HTTPS (except exact loopback hosts, behind an
|
|
41
|
+
explicit opt-in).
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { createOxStudioClient } from '@oxchannels/sdk';
|
|
45
|
+
|
|
46
|
+
const client = createOxStudioClient({
|
|
47
|
+
baseUrl: 'https://api.oxchannels.com',
|
|
48
|
+
serviceKey: () => process.env.OXS_API_KEY!,
|
|
49
|
+
correlationId: () => crypto.randomUUID(),
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Every non-2xx response is thrown as `OxChannelsApiErrorV2` (with the stable `code`, `retryable`,
|
|
54
|
+
`effectEvidence`, trace and correlation IDs), so whenever a call returns, `data` is the typed
|
|
55
|
+
success body.
|
|
56
|
+
|
|
57
|
+
## 2. Confirm the key: `GET /v1/client-context`
|
|
58
|
+
|
|
59
|
+
The first request tells you which workspace the key belongs to and which scopes it carries.
|
|
60
|
+
Publishing needs `context:read` (both context reads) and `operations:write` (media, publications
|
|
61
|
+
and actions); webhooks need `webhooks:manage`. A key without a scope fails the guarded operation
|
|
62
|
+
with `FORBIDDEN`, so check up front.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const { data: me } = await client.GET('/v1/client-context');
|
|
66
|
+
// me.workspace.id, me.client.scopes, me.entitlements
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 3. Where can this workspace publish: `GET /v1/integration/publishing-context`
|
|
70
|
+
|
|
71
|
+
One read returns profiles, account groups and every target with its readiness and, for `READY`
|
|
72
|
+
targets, the capability snapshot the platform will hold you to:
|
|
73
|
+
|
|
74
|
+
| Field | Meaning |
|
|
75
|
+
| ------------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
76
|
+
| `targets[].readiness` | `READY`, `NEEDS_ATTENTION` or `UNAVAILABLE`; only `READY` targets accept publications |
|
|
77
|
+
| `targets[].blocker` | For the other two: a closed code plus a `remedy` (`FIX_IN_OXCHANNELS`, `RETRY_LATER`, ...) |
|
|
78
|
+
| `targets[].capability` | `generation` and `snapshotHash` you must echo back, plus `featureCodes` (`TEXT`, `IMAGE`, `VIDEO`, ...) |
|
|
79
|
+
| `targets[].connectorKey` | Provider key (`facebook`, `mastodon`, `youtube`, ...) |
|
|
80
|
+
| `observedAt` | When this snapshot was taken |
|
|
81
|
+
|
|
82
|
+
The snapshot is not a cache to keep: it changes whenever the provider connection is refreshed and a
|
|
83
|
+
capability changes, and a publication built on an old one is refused (section 9).
|
|
84
|
+
|
|
85
|
+
## 4. Upload media once: `POST /v1/integration/media`, upload, `finalize`
|
|
86
|
+
|
|
87
|
+
Media is uploaded once per workspace and referenced by id from any number of publications:
|
|
88
|
+
|
|
89
|
+
1. `POST /v1/integration/media` with `byteLength`, `detectedMime`, `sha256` (base64url of the
|
|
90
|
+
raw SHA-256) and an optional `clientReference`. The response is the asset (`id`, `state`) plus a
|
|
91
|
+
short-lived `uploadUrl` and the `requiredHeaders` the object store insists on.
|
|
92
|
+
2. `PUT` the bytes to `uploadUrl` with exactly those headers.
|
|
93
|
+
3. `POST /v1/integration/media/{mediaAssetId}/finalize`.
|
|
94
|
+
4. Poll `GET /v1/integration/media/{mediaAssetId}` until `state` is `READY`. After `finalize` the
|
|
95
|
+
asset passes through `QUARANTINED` and `SCANNING`; `REJECTED` and `PURGED` are terminal.
|
|
96
|
+
|
|
97
|
+
A publication that references an asset which is not `READY` is refused, so wait before section 5.
|
|
98
|
+
Assets larger than one request use the multipart operations under
|
|
99
|
+
`/v1/integration/media/multipart`; a file that already sits behind a public URL can be pulled by the
|
|
100
|
+
platform with `POST /v1/integration/media/url-acquisitions`.
|
|
101
|
+
|
|
102
|
+
How the bytes reach the provider is not your concern, but it explains the readiness blockers you
|
|
103
|
+
may see. Each provider declares its media modes in the channel catalogue
|
|
104
|
+
(`packages/channel-catalog/src/source.ts`, `capabilityDefaults.mediaModes`):
|
|
105
|
+
|
|
106
|
+
| Provider | Media modes | What the platform does with your asset |
|
|
107
|
+
| ----------------- | ---------------------------------- | -------------------------------------------------------------- |
|
|
108
|
+
| `facebook` | `PUBLIC_URL`, `CONTAINER_ASYNC` | Provider fetches from a public URL, then processes a container |
|
|
109
|
+
| `instagram` | `PUBLIC_URL`, `CONTAINER_ASYNC` | Provider fetches from a public URL, then processes a container |
|
|
110
|
+
| `threads` | `PUBLIC_URL`, `CONTAINER_ASYNC` | Provider fetches from a public URL, then processes a container |
|
|
111
|
+
| `tiktok` | `PUBLIC_URL`, `CONTAINER_ASYNC` | Provider fetches from a public URL, then processes a container |
|
|
112
|
+
| `pinterest` | `PUBLIC_URL`, `MULTIPART` | Provider fetches from a public URL, or chunked upload |
|
|
113
|
+
| `google-business` | `PUBLIC_URL` | Provider fetches from a public URL |
|
|
114
|
+
| `site-plugin` | `PUBLIC_URL` | Your site fetches from a public URL |
|
|
115
|
+
| `bluesky` | `BINARY_STREAM`, `CONTAINER_ASYNC` | Platform streams the bytes; video goes through a container |
|
|
116
|
+
| `snapchat` | `BINARY_STREAM`, `CONTAINER_ASYNC` | Platform streams the bytes; video goes through a container |
|
|
117
|
+
| `linkedin` | `BINARY_STREAM`, `RESUMABLE` | Platform streams the bytes, resumable for large files |
|
|
118
|
+
| `mastodon` | `BINARY_STREAM` | Platform streams the bytes |
|
|
119
|
+
| `reddit` | `BINARY_STREAM` | Platform streams the bytes |
|
|
120
|
+
| `telegram` | `BINARY_STREAM` | Platform streams the bytes |
|
|
121
|
+
| `whatsapp` | `BINARY_STREAM` | Platform streams the bytes |
|
|
122
|
+
| `x-twitter` | `CONTAINER_ASYNC` | Chunked provider upload, processed asynchronously |
|
|
123
|
+
| `youtube` | `RESUMABLE` | Resumable provider upload |
|
|
124
|
+
| `discord` | `MULTIPART` | Chunked provider upload |
|
|
125
|
+
|
|
126
|
+
`PUBLIC_URL` providers download from the platform's media host, which is why a workspace that
|
|
127
|
+
publishes images to Meta or TikTok needs a public media domain configured on the deployment; the
|
|
128
|
+
other modes never expose your asset by URL.
|
|
129
|
+
|
|
130
|
+
## 5. One publication per target: `POST /v1/integration/publications`
|
|
131
|
+
|
|
132
|
+
A publication is the content variant for one target, built against the capability you were shown:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
const { data: publication } = await client.POST('/v1/integration/publications', {
|
|
136
|
+
body: {
|
|
137
|
+
targetId: target.targetId,
|
|
138
|
+
capabilityGeneration: target.capability.generation,
|
|
139
|
+
capabilityHash: target.capability.snapshotHash,
|
|
140
|
+
body: 'Hello from @oxchannels/sdk',
|
|
141
|
+
locale: 'en',
|
|
142
|
+
mediaAssetIds: [imageAssetId],
|
|
143
|
+
options: { publishVariant: 'IMAGE' },
|
|
144
|
+
clientReference: `${workflowRunId}:${workflowTaskId}:${target.targetId}`,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
// publication.item.id, publication.revision.id, publication.replayed
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- `capabilityGeneration` and `capabilityHash` are the two values from section 3. They are the
|
|
151
|
+
contract: if the target's snapshot moved since you read it, the request is refused rather than
|
|
152
|
+
published against limits the channel no longer has.
|
|
153
|
+
- `options` is the closed per-provider metadata object (title, privacy, first comment, thread
|
|
154
|
+
items, ...). Anything the provider does not accept is rejected before the effect.
|
|
155
|
+
- `options.publishVariant` is required whenever the target's capability snapshot lists
|
|
156
|
+
`publishVariants`, and every official connector does (`TEXT`, `FEED`, `IMAGE`, `PHOTO`,
|
|
157
|
+
`PHOTO_DRAFT`, `VIDEO`, ...). The list lives in the target catalog, `GET /v1/targets`
|
|
158
|
+
(scope `targets:read`, field `official[].capabilitySnapshot.limits.publishVariants`), not in the
|
|
159
|
+
publishing context. A missing or unlisted variant is refused as `NOT_FOUND` without further
|
|
160
|
+
detail, so pick the variant before you build the publication: text only -> `TEXT` (Facebook:
|
|
161
|
+
`FEED`), one image -> `IMAGE` (Facebook: `PHOTO`, TikTok: `PHOTO` or `PHOTO_DRAFT`).
|
|
162
|
+
- TikTok additionally requires the creator's decisions in `options`: `privacyLevel` (one of the
|
|
163
|
+
values in `publishChoices.privacyLevel`), `disableComment`, `brandContent`, `brandOrganic`.
|
|
164
|
+
A `PHOTO_DRAFT` accepts only `privacyLevel: 'SELF_ONLY'`, interactions disabled and all
|
|
165
|
+
disclosures `false`.
|
|
166
|
+
- Image bytes: send JPEG when the same asset goes to Instagram or TikTok; their
|
|
167
|
+
`allowedMimeTypes` do not include `image/png`, and the mismatch is refused here, at publication
|
|
168
|
+
creation, not at publish time.
|
|
169
|
+
- `clientReference` makes the create idempotent: the same reference with the same body replays the
|
|
170
|
+
earlier publication (`replayed: true`); the same reference with a different body is
|
|
171
|
+
`IDEMPOTENCY_CONFLICT`. Include the target id in it, because the fingerprint covers the target.
|
|
172
|
+
|
|
173
|
+
## 6. Submit the action: `POST /v1/integration/publishing-actions`
|
|
174
|
+
|
|
175
|
+
An action fans one content revision out to its targets. The response is `202 Accepted` with the
|
|
176
|
+
per-target ledger the action will be reported through:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
const { data: action } = await client.POST('/v1/integration/publishing-actions', {
|
|
180
|
+
body: {
|
|
181
|
+
mode: 'PUBLISH_NOW',
|
|
182
|
+
contentRevisionId: publication.revision.id,
|
|
183
|
+
targetIds: [target.targetId],
|
|
184
|
+
oxStudioRun: {
|
|
185
|
+
schemaVersion: 'ox-studio-run.v1',
|
|
186
|
+
workflowRunId,
|
|
187
|
+
workflowTaskId,
|
|
188
|
+
taskGeneration: 1,
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
// action.actionId, action.outcome ('ACCEPTED' | 'IN_PROGRESS' | ...), action.targets[]
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
- `targetIds` takes between 1 and 20 distinct ids, each of which must have a variant on that
|
|
196
|
+
revision. A partner publication produces a revision with exactly one variant, so submit one action
|
|
197
|
+
per publication; a target without a variant is reported as `REJECTED` with `VARIANT_MISSING`,
|
|
198
|
+
it does not fail the whole action.
|
|
199
|
+
- `mode` is `PUBLISH_NOW`, `SCHEDULE` (add `schedule`: civil time, time zone, DST policy, late
|
|
200
|
+
window) or `QUEUE` (add `queue`).
|
|
201
|
+
- `oxStudioRun` is your idempotency identity (section 9).
|
|
202
|
+
|
|
203
|
+
## 7. Follow it: `GET /v1/integration/publishing-actions/{actionId}`
|
|
204
|
+
|
|
205
|
+
Poll until `outcome` is terminal: `SUCCEEDED`, `PARTIALLY_SUCCEEDED` or `FAILED`
|
|
206
|
+
(`ACCEPTED` and `IN_PROGRESS` are not). Each entry in `targets[]` settles on its own lane with a
|
|
207
|
+
`state` (`PENDING`, `ACCEPTED`, `SUCCEEDED`, `FAILED`, `REJECTED`, `UNKNOWN`), `retryable`, and on
|
|
208
|
+
failure a closed `failure.code` with a `remedy`. `UNKNOWN` means the platform lost the provider's
|
|
209
|
+
answer after the effect may have started; it is reconciled, never blindly retried, and
|
|
210
|
+
`POST .../publishing-actions/{actionId}/retry` is the only way to try again.
|
|
211
|
+
|
|
212
|
+
## 8. Stop polling: `POST /v1/webhook-subscriptions`
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
const { data: subscription } = await client.POST('/v1/webhook-subscriptions', {
|
|
216
|
+
body: {
|
|
217
|
+
destinationUrl: 'https://example.com/oxchannels/events',
|
|
218
|
+
eventAllowlist: ['operation.status.changed.v1'],
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
// subscription.id, subscription.signingSecret (shown exactly once), subscription.currentKeyId
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`operation.status.changed.v1` fires for every operation state change behind your actions. The
|
|
225
|
+
`signingSecret` is revealed once; store it before the response is gone. Delivery is at-least-once
|
|
226
|
+
and signed; the envelope, the signature bytes and the replay rules are in
|
|
227
|
+
[Webhooks](./webhooks.md). Keep a reconciliation pass on section 7 regardless: a webhook is a
|
|
228
|
+
latency optimisation over state you can already read.
|
|
229
|
+
|
|
230
|
+
## 9. Stale snapshots, retries and idempotency
|
|
231
|
+
|
|
232
|
+
Two places tell you the capability you built on has moved:
|
|
233
|
+
|
|
234
|
+
- `POST /v1/integration/publications` answers `404 NOT_FOUND` when the target no longer has the
|
|
235
|
+
snapshot (`capabilityGeneration` plus `capabilityHash`) you sent, or is no longer active. Re-read
|
|
236
|
+
the publishing context, take the target's new `capability`, and create the publication again.
|
|
237
|
+
The example does exactly this, once per target.
|
|
238
|
+
- An already accepted action can settle a target as `FAILED` with `failure.code`
|
|
239
|
+
`CAPABILITY_STALE` and `remedy` `REBUILD_VARIANT`: create a new publication for that target from
|
|
240
|
+
a fresh context and submit a new action for it.
|
|
241
|
+
|
|
242
|
+
Retries are safe because every write is idempotent on something you own:
|
|
243
|
+
|
|
244
|
+
| Write | Key | Replay answer |
|
|
245
|
+
| ------------------ | ------------------------------------------------------------------- | --------------------------------- |
|
|
246
|
+
| media create | `clientReference` | the existing asset |
|
|
247
|
+
| publication create | `clientReference` (include the target id) | `replayed: true`, same revision |
|
|
248
|
+
| publishing action | `oxStudioRun` (`workflowRunId`, `workflowTaskId`, `taskGeneration`) | `replayed: true`, same `actionId` |
|
|
249
|
+
|
|
250
|
+
A plain retry after a timeout keeps all three `oxStudioRun` values, so the derived key stays
|
|
251
|
+
stable and you get the earlier action back. Bump `taskGeneration` only when you deliberately
|
|
252
|
+
abandon the previous attempt; the same task with a different revision under the same generation is
|
|
253
|
+
`409 IDEMPOTENCY_CONFLICT`. The wider rules (`retryable`, `effectEvidence`, what never to retry)
|
|
254
|
+
are in [Idempotency and retries](./idempotency.md).
|
|
255
|
+
|
|
256
|
+
## The complete program
|
|
257
|
+
|
|
258
|
+
`packages/openapi-client/examples/publish-everywhere.ts`, verbatim:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
/**
|
|
262
|
+
* Publish one post to every ready target of a workspace through `@oxchannels/sdk`.
|
|
263
|
+
*
|
|
264
|
+
* This file is the single source for the code shown in `README.md` and in
|
|
265
|
+
* `docs/guides/sdk-quickstart.md`; it is type-checked by `pnpm test` against the built package.
|
|
266
|
+
* Every path and field below comes from the sealed partner contract
|
|
267
|
+
* (`src/generated/partner-contract-v2-schema.ts`), so a contract change that would break a
|
|
268
|
+
* consumer breaks this example first.
|
|
269
|
+
*
|
|
270
|
+
* Run it with Node 24 after `npm install @oxchannels/sdk`:
|
|
271
|
+
*
|
|
272
|
+
* OXS_API_KEY=... node --experimental-strip-types publish-everywhere.ts
|
|
273
|
+
*/
|
|
274
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
275
|
+
import { readFile } from 'node:fs/promises';
|
|
276
|
+
import {
|
|
277
|
+
OxChannelsApiErrorV2,
|
|
278
|
+
createOxStudioClient,
|
|
279
|
+
type OxStudioClient,
|
|
280
|
+
type PartnerContractV2Paths,
|
|
281
|
+
} from '@oxchannels/sdk';
|
|
282
|
+
|
|
283
|
+
type PublishingContext =
|
|
284
|
+
PartnerContractV2Paths['/v1/integration/publishing-context']['get']['responses'][200]['content']['application/json'];
|
|
285
|
+
type PublishingTarget = PublishingContext['targets'][number];
|
|
286
|
+
type PublishingAction =
|
|
287
|
+
PartnerContractV2Paths['/v1/integration/publishing-actions/{actionId}']['get']['responses'][200]['content']['application/json'];
|
|
288
|
+
|
|
289
|
+
export interface PublishEverywhereInput {
|
|
290
|
+
/** `https://api.oxchannels.com` in production. HTTP is refused except for exact loopback hosts. */
|
|
291
|
+
readonly baseUrl: string;
|
|
292
|
+
/** Service key created in the panel (API keys). Passed to the SDK as `serviceKey`. */
|
|
293
|
+
readonly apiKey: string;
|
|
294
|
+
/** Post text; the per-target limits come back in the publishing context. */
|
|
295
|
+
readonly body: string;
|
|
296
|
+
/** BCP 47 tag, for example `en` or `pl`. */
|
|
297
|
+
readonly locale: string;
|
|
298
|
+
/** Optional local image to attach to every target that accepts media. */
|
|
299
|
+
readonly image?: { readonly path: string; readonly mime: string };
|
|
300
|
+
/** Optional HTTPS endpoint that should receive `operation.status.changed.v1`. */
|
|
301
|
+
readonly webhookUrl?: string;
|
|
302
|
+
/**
|
|
303
|
+
* Your own execution identity. The action's idempotency key is derived from it, so a plain
|
|
304
|
+
* retry keeps the same three values and a deliberate re-run bumps `taskGeneration`.
|
|
305
|
+
*/
|
|
306
|
+
readonly run: {
|
|
307
|
+
readonly workflowRunId: string;
|
|
308
|
+
readonly workflowTaskId: string;
|
|
309
|
+
readonly taskGeneration: number;
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface PublishEverywhereResult {
|
|
314
|
+
readonly targetId: string;
|
|
315
|
+
readonly connectorKey: string;
|
|
316
|
+
readonly actionId: string | null;
|
|
317
|
+
readonly outcome: PublishingAction['outcome'] | 'SKIPPED';
|
|
318
|
+
readonly detail: string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const POLL_INTERVAL_MS = 1_000;
|
|
322
|
+
const MAX_POLLS = 120;
|
|
323
|
+
const TERMINAL_OUTCOMES: ReadonlySet<PublishingAction['outcome']> = new Set([
|
|
324
|
+
'SUCCEEDED',
|
|
325
|
+
'PARTIALLY_SUCCEEDED',
|
|
326
|
+
'FAILED',
|
|
327
|
+
]);
|
|
328
|
+
|
|
329
|
+
function required<T>(value: T | undefined, what: string): T {
|
|
330
|
+
if (value === undefined) throw new Error(`${what}: the API returned no body`);
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function sleep(ms: number): Promise<void> {
|
|
335
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 1. One client per service key. The SDK sets `OxChannels-Contract-Set` and `Authorization`. */
|
|
339
|
+
export function createClient(input: PublishEverywhereInput): OxStudioClient {
|
|
340
|
+
return createOxStudioClient({
|
|
341
|
+
baseUrl: input.baseUrl,
|
|
342
|
+
serviceKey: () => input.apiKey,
|
|
343
|
+
correlationId: () => randomUUID(),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 2. Who am I, and what may this key do? Fails fast on a wrong key or missing scope. */
|
|
348
|
+
export async function readClientContext(client: OxStudioClient) {
|
|
349
|
+
const context = required((await client.GET('/v1/client-context')).data, 'GET /v1/client-context');
|
|
350
|
+
return { workspaceId: context.workspace.id, scopes: context.client.scopes };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** 3. Where can this workspace publish right now? Only `READY` targets carry a capability. */
|
|
354
|
+
export async function readPublishingContext(client: OxStudioClient): Promise<PublishingContext> {
|
|
355
|
+
return required(
|
|
356
|
+
(await client.GET('/v1/integration/publishing-context')).data,
|
|
357
|
+
'GET /v1/integration/publishing-context',
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 4. Upload media once, reuse the asset id for every target. The asset is scanned after
|
|
363
|
+
* `finalize`; publish only after it reports `READY`.
|
|
364
|
+
*/
|
|
365
|
+
export async function uploadImage(
|
|
366
|
+
client: OxStudioClient,
|
|
367
|
+
image: NonNullable<PublishEverywhereInput['image']>,
|
|
368
|
+
): Promise<string> {
|
|
369
|
+
const bytes = await readFile(image.path);
|
|
370
|
+
const created = required(
|
|
371
|
+
(
|
|
372
|
+
await client.POST('/v1/integration/media', {
|
|
373
|
+
body: {
|
|
374
|
+
byteLength: bytes.byteLength,
|
|
375
|
+
detectedMime: image.mime,
|
|
376
|
+
sha256: createHash('sha256').update(bytes).digest('base64url'),
|
|
377
|
+
clientReference: `image:${image.path}`,
|
|
378
|
+
},
|
|
379
|
+
})
|
|
380
|
+
).data,
|
|
381
|
+
'POST /v1/integration/media',
|
|
382
|
+
);
|
|
383
|
+
if (created.uploadUrl === undefined) {
|
|
384
|
+
throw new Error(`media ${created.id} is ${created.state} and has no upload grant`);
|
|
385
|
+
}
|
|
386
|
+
const upload = await fetch(created.uploadUrl, {
|
|
387
|
+
method: 'PUT',
|
|
388
|
+
headers: created.requiredHeaders ?? {},
|
|
389
|
+
body: bytes,
|
|
390
|
+
});
|
|
391
|
+
if (!upload.ok) throw new Error(`object store rejected the upload: HTTP ${upload.status}`);
|
|
392
|
+
|
|
393
|
+
await client.POST('/v1/integration/media/{mediaAssetId}/finalize', {
|
|
394
|
+
params: { path: { mediaAssetId: created.id } },
|
|
395
|
+
});
|
|
396
|
+
for (let attempt = 0; attempt < MAX_POLLS; attempt += 1) {
|
|
397
|
+
const asset = required(
|
|
398
|
+
(
|
|
399
|
+
await client.GET('/v1/integration/media/{mediaAssetId}', {
|
|
400
|
+
params: { path: { mediaAssetId: created.id } },
|
|
401
|
+
})
|
|
402
|
+
).data,
|
|
403
|
+
'GET /v1/integration/media/{mediaAssetId}',
|
|
404
|
+
);
|
|
405
|
+
if (asset.state === 'READY') return asset.id;
|
|
406
|
+
if (asset.state === 'REJECTED' || asset.state === 'PURGED') {
|
|
407
|
+
throw new Error(`media ${asset.id} ended in state ${asset.state}`);
|
|
408
|
+
}
|
|
409
|
+
await sleep(POLL_INTERVAL_MS);
|
|
410
|
+
}
|
|
411
|
+
throw new Error(`media ${created.id} did not become READY in time`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 5. One publication per target. The request carries the capability generation and hash the
|
|
416
|
+
* context reported for that target, so the platform can refuse content built against a stale
|
|
417
|
+
* snapshot instead of publishing something the channel no longer accepts.
|
|
418
|
+
*/
|
|
419
|
+
export async function createPublication(
|
|
420
|
+
client: OxStudioClient,
|
|
421
|
+
input: PublishEverywhereInput,
|
|
422
|
+
target: PublishingTarget,
|
|
423
|
+
mediaAssetIds: readonly string[],
|
|
424
|
+
): Promise<string> {
|
|
425
|
+
if (target.capability === null) throw new Error(`target ${target.targetId} has no capability`);
|
|
426
|
+
const publication = required(
|
|
427
|
+
(
|
|
428
|
+
await client.POST('/v1/integration/publications', {
|
|
429
|
+
body: {
|
|
430
|
+
targetId: target.targetId,
|
|
431
|
+
capabilityGeneration: target.capability.generation,
|
|
432
|
+
capabilityHash: target.capability.snapshotHash,
|
|
433
|
+
body: input.body,
|
|
434
|
+
locale: input.locale,
|
|
435
|
+
mediaAssetIds: [...mediaAssetIds],
|
|
436
|
+
options: {},
|
|
437
|
+
clientReference: `${input.run.workflowRunId}:${input.run.workflowTaskId}:${target.targetId}`,
|
|
438
|
+
},
|
|
439
|
+
})
|
|
440
|
+
).data,
|
|
441
|
+
'POST /v1/integration/publications',
|
|
442
|
+
);
|
|
443
|
+
return publication.revision.id;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** 6. Submit the action and 7. poll it until every target settled. */
|
|
447
|
+
export async function submitAndAwaitAction(
|
|
448
|
+
client: OxStudioClient,
|
|
449
|
+
input: PublishEverywhereInput,
|
|
450
|
+
contentRevisionId: string,
|
|
451
|
+
targetIds: readonly string[],
|
|
452
|
+
): Promise<PublishingAction> {
|
|
453
|
+
const accepted = required(
|
|
454
|
+
(
|
|
455
|
+
await client.POST('/v1/integration/publishing-actions', {
|
|
456
|
+
body: {
|
|
457
|
+
mode: 'PUBLISH_NOW',
|
|
458
|
+
contentRevisionId,
|
|
459
|
+
targetIds: [...targetIds],
|
|
460
|
+
oxStudioRun: { schemaVersion: 'ox-studio-run.v1', ...input.run },
|
|
461
|
+
},
|
|
462
|
+
})
|
|
463
|
+
).data,
|
|
464
|
+
'POST /v1/integration/publishing-actions',
|
|
465
|
+
);
|
|
466
|
+
let action: PublishingAction = accepted;
|
|
467
|
+
for (
|
|
468
|
+
let attempt = 0;
|
|
469
|
+
attempt < MAX_POLLS && !TERMINAL_OUTCOMES.has(action.outcome);
|
|
470
|
+
attempt += 1
|
|
471
|
+
) {
|
|
472
|
+
await sleep(POLL_INTERVAL_MS);
|
|
473
|
+
action = required(
|
|
474
|
+
(
|
|
475
|
+
await client.GET('/v1/integration/publishing-actions/{actionId}', {
|
|
476
|
+
params: { path: { actionId: accepted.actionId } },
|
|
477
|
+
})
|
|
478
|
+
).data,
|
|
479
|
+
'GET /v1/integration/publishing-actions/{actionId}',
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
return action;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** 8. Ask to be told instead of polling. Keep `signingSecret`: it is shown exactly once. */
|
|
486
|
+
export async function subscribeToOperationChanges(
|
|
487
|
+
client: OxStudioClient,
|
|
488
|
+
destinationUrl: string,
|
|
489
|
+
): Promise<{ subscriptionId: string; signingSecret: string }> {
|
|
490
|
+
const subscription = required(
|
|
491
|
+
(
|
|
492
|
+
await client.POST('/v1/webhook-subscriptions', {
|
|
493
|
+
body: { destinationUrl, eventAllowlist: ['operation.status.changed.v1'] },
|
|
494
|
+
})
|
|
495
|
+
).data,
|
|
496
|
+
'POST /v1/webhook-subscriptions',
|
|
497
|
+
);
|
|
498
|
+
return { subscriptionId: subscription.id, signingSecret: subscription.signingSecret };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function isStaleSnapshot(error: unknown): boolean {
|
|
502
|
+
// A publication built against a snapshot the target no longer has is refused as NOT_FOUND
|
|
503
|
+
// (the closed error set has no dedicated code for it yet). Re-read the context and retry once.
|
|
504
|
+
return error instanceof OxChannelsApiErrorV2 && error.code === 'NOT_FOUND';
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export async function publishEverywhere(
|
|
508
|
+
input: PublishEverywhereInput,
|
|
509
|
+
): Promise<readonly PublishEverywhereResult[]> {
|
|
510
|
+
const client = createClient(input);
|
|
511
|
+
const { scopes } = await readClientContext(client);
|
|
512
|
+
for (const scope of ['context:read', 'operations:write']) {
|
|
513
|
+
if (!scopes.includes(scope)) throw new Error(`service key lacks the ${scope} scope`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
let context = await readPublishingContext(client);
|
|
517
|
+
const mediaAssetIds = input.image === undefined ? [] : [await uploadImage(client, input.image)];
|
|
518
|
+
const results: PublishEverywhereResult[] = [];
|
|
519
|
+
|
|
520
|
+
for (const initialTarget of context.targets) {
|
|
521
|
+
let target: PublishingTarget = initialTarget;
|
|
522
|
+
if (target.readiness !== 'READY' || target.capability === null) {
|
|
523
|
+
results.push({
|
|
524
|
+
targetId: target.targetId,
|
|
525
|
+
connectorKey: target.connectorKey,
|
|
526
|
+
actionId: null,
|
|
527
|
+
outcome: 'SKIPPED',
|
|
528
|
+
detail:
|
|
529
|
+
target.blocker === null
|
|
530
|
+
? target.readiness
|
|
531
|
+
: `${target.blocker.code}: ${target.blocker.remedy}`,
|
|
532
|
+
});
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const attachMedia = target.capability.featureCodes.includes('IMAGE') ? mediaAssetIds : [];
|
|
536
|
+
|
|
537
|
+
let contentRevisionId: string;
|
|
538
|
+
try {
|
|
539
|
+
contentRevisionId = await createPublication(client, input, target, attachMedia);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
if (!isStaleSnapshot(error)) throw error;
|
|
542
|
+
context = await readPublishingContext(client);
|
|
543
|
+
const refreshed = context.targets.find((candidate) => candidate.targetId === target.targetId);
|
|
544
|
+
if (refreshed === undefined || refreshed.readiness !== 'READY') {
|
|
545
|
+
results.push({
|
|
546
|
+
targetId: target.targetId,
|
|
547
|
+
connectorKey: target.connectorKey,
|
|
548
|
+
actionId: null,
|
|
549
|
+
outcome: 'SKIPPED',
|
|
550
|
+
detail: 'target disappeared or lost readiness while publishing',
|
|
551
|
+
});
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
target = refreshed;
|
|
555
|
+
contentRevisionId = await createPublication(client, input, target, attachMedia);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Each partner publication yields a revision with one target variant, so one action per
|
|
559
|
+
// revision. The endpoint itself accepts up to 20 target ids when a revision carries a
|
|
560
|
+
// variant for each of them.
|
|
561
|
+
const action = await submitAndAwaitAction(client, input, contentRevisionId, [target.targetId]);
|
|
562
|
+
const settled = action.targets.find((entry) => entry.targetId === target.targetId);
|
|
563
|
+
results.push({
|
|
564
|
+
targetId: target.targetId,
|
|
565
|
+
connectorKey: target.connectorKey,
|
|
566
|
+
actionId: action.actionId,
|
|
567
|
+
outcome: action.outcome,
|
|
568
|
+
detail:
|
|
569
|
+
settled?.failure === null || settled?.failure === undefined
|
|
570
|
+
? (settled?.state ?? 'UNKNOWN')
|
|
571
|
+
: `${settled.failure.code}: ${settled.failure.remedy}`,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (input.webhookUrl !== undefined) {
|
|
576
|
+
const subscription = await subscribeToOperationChanges(client, input.webhookUrl);
|
|
577
|
+
console.log(`webhook ${subscription.subscriptionId} created; store the signing secret now`);
|
|
578
|
+
}
|
|
579
|
+
return results;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (process.env['OXS_API_KEY'] !== undefined) {
|
|
583
|
+
const results = await publishEverywhere({
|
|
584
|
+
baseUrl: process.env['OXS_API_BASE'] ?? 'https://api.oxchannels.com',
|
|
585
|
+
apiKey: process.env['OXS_API_KEY'],
|
|
586
|
+
body: 'Hello from @oxchannels/sdk',
|
|
587
|
+
locale: 'en',
|
|
588
|
+
run: { workflowRunId: randomUUID(), workflowTaskId: 'publish-everywhere', taskGeneration: 1 },
|
|
589
|
+
});
|
|
590
|
+
for (const result of results) {
|
|
591
|
+
console.log(`${result.connectorKey} ${result.targetId}: ${result.outcome} (${result.detail})`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
## What this guide does not give you
|
|
597
|
+
|
|
598
|
+
- Connecting a provider account. Targets appear in the publishing context only after a connection
|
|
599
|
+
exists and its capability snapshot is fresh; that happens in the panel or through
|
|
600
|
+
[Headless connect](./headless-connect.md).
|
|
601
|
+
- Any promise that a channel is live for your workspace. `readiness` and `blocker` are the truth
|
|
602
|
+
for right now; the sealed contract only promises the shape of the answer.
|
|
603
|
+
- Exactly-once delivery. Every layer here is at-least-once with idempotency on your identifiers,
|
|
604
|
+
see section 9.
|