@taprootio/integration-sdk 0.0.0-stage → 0.1.2

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -2
  3. package/fixtures/manifest.valid.json +28 -0
  4. package/fixtures/webhooks/v1/common.schema.json +45 -0
  5. package/fixtures/webhooks/v1/examples/installation.created.json +1 -0
  6. package/fixtures/webhooks/v1/examples/installation.resumed.json +1 -0
  7. package/fixtures/webhooks/v1/examples/installation.suspended.json +1 -0
  8. package/fixtures/webhooks/v1/examples/installation.uninstalled.json +1 -0
  9. package/fixtures/webhooks/v1/examples/installation.updated.json +1 -0
  10. package/fixtures/webhooks/v1/examples/page.deleted.json +1 -0
  11. package/fixtures/webhooks/v1/examples/page.published.json +1 -0
  12. package/fixtures/webhooks/v1/examples/ping.json +1 -0
  13. package/fixtures/webhooks/v1/examples/placement.created.json +1 -0
  14. package/fixtures/webhooks/v1/examples/placement.deleted.json +1 -0
  15. package/fixtures/webhooks/v1/examples/placement.updated.json +1 -0
  16. package/fixtures/webhooks/v1/examples/publish.completed.json +1 -0
  17. package/fixtures/webhooks/v1/examples/publish.failed.json +1 -0
  18. package/fixtures/webhooks/v1/examples/sync.requested.json +1 -0
  19. package/fixtures/webhooks/v1/installation.created.schema.json +22 -0
  20. package/fixtures/webhooks/v1/installation.resumed.schema.json +8 -0
  21. package/fixtures/webhooks/v1/installation.suspended.schema.json +8 -0
  22. package/fixtures/webhooks/v1/installation.uninstalled.schema.json +8 -0
  23. package/fixtures/webhooks/v1/installation.updated.schema.json +8 -0
  24. package/fixtures/webhooks/v1/page.deleted.schema.json +23 -0
  25. package/fixtures/webhooks/v1/page.published.schema.json +23 -0
  26. package/fixtures/webhooks/v1/ping.schema.json +18 -0
  27. package/fixtures/webhooks/v1/placement.created.schema.json +23 -0
  28. package/fixtures/webhooks/v1/placement.deleted.schema.json +23 -0
  29. package/fixtures/webhooks/v1/placement.updated.schema.json +23 -0
  30. package/fixtures/webhooks/v1/publish.completed.schema.json +21 -0
  31. package/fixtures/webhooks/v1/publish.failed.schema.json +22 -0
  32. package/fixtures/webhooks/v1/schema-index.json +19 -0
  33. package/fixtures/webhooks/v1/signature-vector.json +9 -0
  34. package/fixtures/webhooks/v1/sync.requested.schema.json +22 -0
  35. package/index.d.ts +258 -0
  36. package/package.json +42 -4
  37. package/schema/taproot-integration-manifest.v1.schema.json +82 -0
  38. package/src/bytes.js +51 -0
  39. package/src/client.js +409 -0
  40. package/src/constants.js +17 -0
  41. package/src/errors.js +18 -0
  42. package/src/handoff.js +223 -0
  43. package/src/index.js +16 -0
  44. package/src/json.js +56 -0
  45. package/src/manifest.js +284 -0
  46. package/src/openapi-contract.d.ts +73 -0
  47. package/src/openapi-contract.js +1035 -0
  48. package/src/webhooks.js +269 -0
package/src/client.js ADDED
@@ -0,0 +1,409 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isIP } from "node:net";
3
+
4
+ import { asBytes, isPlainObject, MAX_API_REQUEST_BYTES, MAX_API_RESPONSE_BYTES } from "./bytes.js";
5
+ import { DEFAULT_RETRY_ATTEMPTS } from "./constants.js";
6
+ import { IntegrationApiError, IntegrationSdkError } from "./errors.js";
7
+ import { INTEGRATION_OPENAPI_OPERATIONS as API } from "./openapi-contract.js";
8
+
9
+ const RETRYABLE_STATUSES = new Set([408, 429, 502, 503, 504]);
10
+ const MAX_TIMEOUT_MS = 60_000;
11
+
12
+ function nonEmptyString(value, code) {
13
+ if (typeof value !== "string" || value.trim().length === 0) throw new IntegrationSdkError(code, "A non-empty string is required.");
14
+ return value;
15
+ }
16
+
17
+ function normalizeBaseUrl(value) {
18
+ let parsed;
19
+ try { parsed = new URL(value); } catch { throw new IntegrationSdkError("invalid_base_url", "baseUrl must be an absolute HTTPS URL."); }
20
+ const loopback = parsed.hostname === "localhost" || parsed.hostname === "::1" || (isIP(parsed.hostname) === 4 && parsed.hostname.startsWith("127."));
21
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) throw new IntegrationSdkError("invalid_base_url", "baseUrl must use HTTPS (HTTP is allowed only for explicit loopback tests).");
22
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) throw new IntegrationSdkError("invalid_base_url", "baseUrl must not include credentials, a query, or a fragment.");
23
+ return parsed.toString().replace(/\/$/, "");
24
+ }
25
+
26
+ function presignedUploadUrl(value) {
27
+ let parsed;
28
+ try { parsed = new URL(value); } catch { throw new IntegrationSdkError("invalid_upload_url", "Presigned upload URL must be absolute."); }
29
+ const loopback = parsed.hostname === "localhost" || parsed.hostname === "::1" || (isIP(parsed.hostname) === 4 && parsed.hostname.startsWith("127."));
30
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) throw new IntegrationSdkError("invalid_upload_url", "Presigned upload URL must use HTTPS.");
31
+ if (parsed.username || parsed.password || parsed.hash) throw new IntegrationSdkError("invalid_upload_url", "Presigned upload URL must not contain credentials or a fragment.");
32
+ return parsed.toString();
33
+ }
34
+
35
+ function uploadHeaders(value, bodyLength) {
36
+ if (!isPlainObject(value) || Object.keys(value).length > 32) throw new IntegrationSdkError("invalid_upload_headers", "requiredHeaders must be a bounded object.");
37
+ const headers = new Headers();
38
+ for (const [name, headerValue] of Object.entries(value)) {
39
+ const lowerName = name.toLowerCase();
40
+ const contentLength = lowerName === "content-length";
41
+ const canonicalLength = typeof headerValue === "string" && /^(?:0|[1-9][0-9]*)$/.test(headerValue) &&
42
+ Number.isSafeInteger(Number(headerValue)) && Number(headerValue) === bodyLength;
43
+ if (name.length > 256 || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || typeof headerValue !== "string" || headerValue.length > 4096 || /[\r\n]/.test(headerValue) ||
44
+ ["authorization", "cookie", "proxy-authorization", "host"].includes(lowerName) || (contentLength && !canonicalLength)) {
45
+ throw new IntegrationSdkError("invalid_upload_headers", "Required upload headers contain a forbidden or invalid value.");
46
+ }
47
+ try { headers.set(name, headerValue); }
48
+ catch { throw new IntegrationSdkError("invalid_upload_headers", "Required upload headers contain a forbidden or invalid value."); }
49
+ }
50
+ return headers;
51
+ }
52
+
53
+ function operationPath(operation, values) {
54
+ return API[operation].path.replace(/\{([A-Za-z]+)\}/g, (_, name) => encodeURIComponent(nonEmptyString(values[name], `invalid_${name}`)));
55
+ }
56
+
57
+ function withIdempotency(body, idempotencyKey) {
58
+ nonEmptyString(idempotencyKey, "idempotency_key_required");
59
+ if (idempotencyKey.length > 128 || idempotencyKey.trim() !== idempotencyKey || /[\u0000-\u001f\u007f]/.test(idempotencyKey)) {
60
+ throw new IntegrationSdkError("invalid_idempotency_key", "Idempotency keys are limited to 128 non-control characters without surrounding whitespace.");
61
+ }
62
+ return { ...body, idempotencyKey };
63
+ }
64
+
65
+ function delay(milliseconds) {
66
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
67
+ }
68
+
69
+ function retryDelay(attempt) {
70
+ return Math.min(1_000, 100 * (2 ** (attempt - 1))) + Math.floor(Math.random() * 50);
71
+ }
72
+
73
+ function placementConflictCode(response, bytes) {
74
+ if (response.status !== 400 || bytes.length === 0) return undefined;
75
+ let value;
76
+ try { value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); }
77
+ catch { return undefined; }
78
+ if (!isPlainObject(value) || !Array.isArray(value.details) || value.details.length > 32) return undefined;
79
+ for (const detail of value.details) {
80
+ if (!isPlainObject(detail) || detail["@type"] !== "type.googleapis.com/google.rpc.BadRequest.FieldViolation") continue;
81
+ if (detail.field === "ConfigurationRevision" && detail.description === "configuration_revision_conflict") {
82
+ return "configuration_revision_conflict";
83
+ }
84
+ if (detail.field === "ExpectedFragmentRevision" && detail.description === "fragment_revision_conflict") {
85
+ return "fragment_revision_conflict";
86
+ }
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ function responseError(response, bytes) {
92
+ // Error bodies are untrusted and may echo request values. Never promote them into an Error.
93
+ return new IntegrationApiError(response.status, placementConflictCode(response, bytes) ?? `http_${response.status}`, "Taproot rejected the request.");
94
+ }
95
+
96
+ function readWithAbort(reader, signal) {
97
+ return new Promise((resolve, reject) => {
98
+ if (signal.aborted) {
99
+ reject(new IntegrationSdkError("request_timeout", "The request exceeded its timeout."));
100
+ return;
101
+ }
102
+ let settled = false;
103
+ const finish = (callback, value) => {
104
+ if (settled) return;
105
+ settled = true;
106
+ signal.removeEventListener("abort", abort);
107
+ callback(value);
108
+ };
109
+ const abort = () => finish(reject, new IntegrationSdkError("request_timeout", "The request exceeded its timeout."));
110
+ signal.addEventListener("abort", abort, { once: true });
111
+ reader.read().then((result) => finish(resolve, result), (error) => finish(reject, error));
112
+ });
113
+ }
114
+
115
+ async function readBoundedResponse(response, signal) {
116
+ const contentLength = response.headers.get("content-length");
117
+ if (contentLength && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_API_RESPONSE_BYTES)) {
118
+ throw new IntegrationSdkError("response_too_large", "Taproot response exceeds the SDK response limit.", { status: response.status });
119
+ }
120
+ if (!response.body) return new Uint8Array();
121
+ const reader = response.body.getReader();
122
+ const chunks = [];
123
+ let length = 0;
124
+ try {
125
+ for (;;) {
126
+ const { done, value } = await readWithAbort(reader, signal);
127
+ if (done) break;
128
+ length += value.length;
129
+ if (length > MAX_API_RESPONSE_BYTES) {
130
+ await reader.cancel();
131
+ throw new IntegrationSdkError("response_too_large", "Taproot response exceeds the SDK response limit.", { status: response.status });
132
+ }
133
+ chunks.push(value);
134
+ }
135
+ } finally {
136
+ reader.releaseLock();
137
+ }
138
+ const result = new Uint8Array(length);
139
+ let offset = 0;
140
+ for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; }
141
+ return result;
142
+ }
143
+
144
+ function parseResponse(bytes, response) {
145
+ if (bytes.length === 0) return undefined;
146
+ try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); }
147
+ catch { throw new IntegrationApiError(response.status, "invalid_api_response", "Taproot returned invalid JSON."); }
148
+ }
149
+
150
+ function serializeRequest(body) {
151
+ let value;
152
+ try { value = JSON.stringify(body); } catch { throw new IntegrationSdkError("invalid_request", "Request body must be JSON serializable."); }
153
+ if (typeof value !== "string" || new TextEncoder().encode(value).length > MAX_API_REQUEST_BYTES) {
154
+ throw new IntegrationSdkError("request_too_large", "Request exceeds the SDK request limit.");
155
+ }
156
+ return value;
157
+ }
158
+
159
+ function isRetryableFailure(error) {
160
+ return error instanceof IntegrationApiError
161
+ ? RETRYABLE_STATUSES.has(error.status)
162
+ : error instanceof IntegrationSdkError && (error.code === "request_timeout" || error.code === "request_failed");
163
+ }
164
+
165
+ class BoundedSemaphore {
166
+ #active = 0;
167
+ #waiting = [];
168
+ constructor(limit, maximumWaiting) { this.limit = limit; this.maximumWaiting = maximumWaiting; }
169
+ async run(action) {
170
+ if (this.#active >= this.limit) {
171
+ if (this.#waiting.length >= this.maximumWaiting) throw new IntegrationSdkError("concurrency_queue_full", "The SDK request queue is full.");
172
+ await new Promise((resolve) => this.#waiting.push(resolve));
173
+ }
174
+ this.#active++;
175
+ try { return await action(); }
176
+ finally {
177
+ this.#active--;
178
+ this.#waiting.shift()?.();
179
+ }
180
+ }
181
+ }
182
+
183
+ /** Creates a UUID that identifies one logical mutation across retries and process recovery. */
184
+ export function createIdempotencyKey() {
185
+ return randomUUID();
186
+ }
187
+
188
+ /**
189
+ * Typed, integration-facing REST client. `baseUrl` is the canonical API base (for example
190
+ * `https://app.example/api`); all methods add the reviewed `/v1` contract path. Credentials are
191
+ * provided once at construction and are used only by methods with their matching authority.
192
+ */
193
+ export class TaprootIntegrationClient {
194
+ #baseUrl;
195
+ #fetch;
196
+ #installationCredential;
197
+ #publisherCredential;
198
+ #timeoutMs;
199
+ #attempts;
200
+ #semaphore;
201
+
202
+ constructor({
203
+ baseUrl,
204
+ fetch: fetchImplementation = globalThis.fetch,
205
+ installationCredential,
206
+ publisherCredential,
207
+ timeoutMs = 10_000,
208
+ maxAttempts = DEFAULT_RETRY_ATTEMPTS,
209
+ maxConcurrentRequests = 4,
210
+ maxQueuedRequests = 32,
211
+ }) {
212
+ this.#baseUrl = normalizeBaseUrl(baseUrl);
213
+ if (typeof fetchImplementation !== "function") throw new IntegrationSdkError("fetch_required", "A Fetch-compatible implementation is required.");
214
+ this.#fetch = fetchImplementation;
215
+ if (installationCredential !== undefined) this.#installationCredential = nonEmptyString(installationCredential, "invalid_installation_credential");
216
+ if (publisherCredential !== undefined) this.#publisherCredential = nonEmptyString(publisherCredential, "invalid_publisher_credential");
217
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > MAX_TIMEOUT_MS) throw new IntegrationSdkError("invalid_timeout", "timeoutMs must be an integer from 100 through 60000.");
218
+ if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 3) throw new IntegrationSdkError("invalid_retry_attempts", "maxAttempts must be an integer from one through three.");
219
+ if (!Number.isSafeInteger(maxConcurrentRequests) || maxConcurrentRequests < 1 || maxConcurrentRequests > 8) throw new IntegrationSdkError("invalid_concurrency", "maxConcurrentRequests must be an integer from one through eight.");
220
+ if (!Number.isSafeInteger(maxQueuedRequests) || maxQueuedRequests < 0 || maxQueuedRequests > 64) throw new IntegrationSdkError("invalid_queue_limit", "maxQueuedRequests must be an integer from zero through 64.");
221
+ this.#timeoutMs = timeoutMs;
222
+ this.#attempts = maxAttempts;
223
+ this.#semaphore = new BoundedSemaphore(maxConcurrentRequests, maxQueuedRequests);
224
+ }
225
+
226
+ #url(path) { return new URL(path.replace(/^\//, ""), `${this.#baseUrl}/`).toString(); }
227
+
228
+ #credential(kind) {
229
+ const credential = kind === "installation" ? this.#installationCredential : this.#publisherCredential;
230
+ if (!credential) throw new IntegrationSdkError(`missing_${kind}_credential`, `This operation requires an ${kind} credential.`);
231
+ return credential;
232
+ }
233
+
234
+ async #request({ path, method = "POST", body, credential, idempotencyKey, retry = false }) {
235
+ const headers = new Headers({ accept: "application/json" });
236
+ if (credential) headers.set("authorization", `Bearer ${this.#credential(credential)}`);
237
+ if (body !== undefined) headers.set("content-type", "application/json");
238
+ if (idempotencyKey) headers.set("idempotency-key", idempotencyKey);
239
+ const encodedBody = body === undefined ? undefined : serializeRequest(body);
240
+ return this.#semaphore.run(async () => {
241
+ let lastError;
242
+ const attempts = retry ? this.#attempts : 1;
243
+ for (let attempt = 1; attempt <= attempts; attempt++) {
244
+ const signal = AbortSignal.timeout(this.#timeoutMs);
245
+ try {
246
+ const response = await this.#fetch(this.#url(path), { method, headers, body: encodedBody, redirect: "manual", credentials: "omit", referrerPolicy: "no-referrer", signal });
247
+ if (response.redirected || response.status >= 300 && response.status < 400) throw new IntegrationSdkError("redirect_rejected", "Taproot responses must not redirect.");
248
+ const bytes = await readBoundedResponse(response, signal);
249
+ if (response.ok) return parseResponse(bytes, response);
250
+ lastError = responseError(response, bytes);
251
+ if (!retry || !RETRYABLE_STATUSES.has(response.status) || attempt === attempts) throw lastError;
252
+ } catch (error) {
253
+ if (error instanceof IntegrationSdkError || error instanceof IntegrationApiError) {
254
+ lastError = error;
255
+ if (!retry || !isRetryableFailure(error) || attempt === attempts) throw error;
256
+ } else {
257
+ lastError = signal.aborted
258
+ ? new IntegrationSdkError("request_timeout", "The request exceeded its timeout.")
259
+ : new IntegrationSdkError("request_failed", "The request did not receive a usable response.");
260
+ if (!retry || attempt === attempts) throw lastError;
261
+ }
262
+ }
263
+ await delay(retryDelay(attempt));
264
+ }
265
+ throw lastError;
266
+ });
267
+ }
268
+
269
+ /** Claim responses are deliberately never retried: a lost successful response cannot reveal the one-time secret again. */
270
+ async claimInstallation({ installationId, claimCode }) {
271
+ try {
272
+ return await this.#request({
273
+ path: operationPath("claimInstallation", { installationId }),
274
+ body: { installationId, claimCode: nonEmptyString(claimCode, "invalid_claim_code") }, credential: "publisher",
275
+ });
276
+ } catch (error) {
277
+ const successfulResponseWasUnreadable = error instanceof IntegrationSdkError && error.status >= 200 && error.status < 300 &&
278
+ (error.code === "response_too_large" || error.code === "invalid_api_response");
279
+ if (error instanceof IntegrationSdkError && (error.code === "request_failed" || error.code === "request_timeout" || successfulResponseWasUnreadable)) {
280
+ throw new IntegrationSdkError("claim_response_unknown", "Claim response was lost. Do not retry blindly; validate the installation and use the owner reissue flow if no secret was stored.", { status: error.status });
281
+ }
282
+ throw error;
283
+ }
284
+ }
285
+
286
+ validateInstallation({ installationId }) {
287
+ return this.#request({ path: operationPath("validateInstallation", { installationId }), method: "GET", credential: "installation", retry: true });
288
+ }
289
+
290
+ createSync({ installationId, mode, requestedSyncId, idempotencyKey }) {
291
+ return this.#request({ path: operationPath("createSync", { installationId }), credential: "installation", idempotencyKey,
292
+ body: withIdempotency({ installationId, mode: nonEmptyString(mode, "invalid_sync_mode"), requestedSyncId: requestedSyncId ?? "" }, idempotencyKey), retry: true });
293
+ }
294
+
295
+ completeSync({ installationId, syncId, summary = "", idempotencyKey }) {
296
+ return this.#finishSync("complete", { installationId, syncId, summary, idempotencyKey });
297
+ }
298
+
299
+ abortSync({ installationId, syncId, summary = "", idempotencyKey }) {
300
+ return this.#finishSync("abort", { installationId, syncId, summary, idempotencyKey });
301
+ }
302
+
303
+ #finishSync(action, { installationId, syncId, summary, idempotencyKey }) {
304
+ const id = nonEmptyString(installationId, "invalid_installation_id");
305
+ const operation = action === "complete" ? "completeSync" : "abortSync";
306
+ return this.#request({ path: operationPath(operation, { installationId: id, syncId }), credential: "installation", idempotencyKey,
307
+ body: withIdempotency({ installationId: id, syncId, summary }, idempotencyKey), retry: true });
308
+ }
309
+
310
+ putManagedPage({ installationId, syncId = "", page, idempotencyKey }) {
311
+ const id = nonEmptyString(installationId, "invalid_installation_id");
312
+ if (!isPlainObject(page)) throw new IntegrationSdkError("invalid_page", "page must be an object matching IntegrationManagedPageContent.");
313
+ return this.#request({ path: operationPath("putManagedPage", { installationId: id }), credential: "installation", idempotencyKey,
314
+ body: withIdempotency({ installationId: id, syncId, page }, idempotencyKey), retry: true });
315
+ }
316
+
317
+ deleteManagedPage({ installationId, syncId = "", externalId, idempotencyKey }) {
318
+ return this.#deleteContent("deleteManagedPage", { installationId, syncId, key: externalId, idempotencyKey });
319
+ }
320
+
321
+ putDataDocument({ installationId, syncId = "", key, json, idempotencyKey }) {
322
+ const id = nonEmptyString(installationId, "invalid_installation_id");
323
+ return this.#request({ path: operationPath("putDataDocument", { installationId: id }), credential: "installation", idempotencyKey,
324
+ body: withIdempotency({ installationId: id, syncId, key: nonEmptyString(key, "invalid_data_key"), json: nonEmptyString(json, "invalid_data_json") }, idempotencyKey), retry: true });
325
+ }
326
+
327
+ deleteDataDocument({ installationId, syncId = "", key, idempotencyKey }) {
328
+ return this.#deleteContent("deleteDataDocument", { installationId, syncId, key, idempotencyKey });
329
+ }
330
+
331
+ #deleteContent(operation, { installationId, syncId, key, idempotencyKey }) {
332
+ const id = nonEmptyString(installationId, "invalid_installation_id");
333
+ return this.#request({ path: operationPath(operation, { installationId: id }), credential: "installation", idempotencyKey,
334
+ body: withIdempotency({ installationId: id, syncId, key: nonEmptyString(key, "invalid_content_key") }, idempotencyKey), retry: true });
335
+ }
336
+
337
+ requestMediaUpload({ installationId, fileName, contentType, fileSize, contentHash, width, height }) {
338
+ const id = nonEmptyString(installationId, "invalid_installation_id");
339
+ return this.#request({ path: operationPath("requestMediaUpload", { installationId: id }), credential: "installation",
340
+ body: { installationId: id, fileName: nonEmptyString(fileName, "invalid_file_name"), contentType: nonEmptyString(contentType, "invalid_content_type"), fileSize, contentHash: nonEmptyString(contentHash, "invalid_content_hash"), width, height } });
341
+ }
342
+
343
+ confirmMediaUpload({ installationId, uploadId }) {
344
+ const id = nonEmptyString(installationId, "invalid_installation_id");
345
+ return this.#request({ path: operationPath("confirmMediaUpload", { installationId: id }), credential: "installation", body: { installationId: id, uploadId: nonEmptyString(uploadId, "invalid_upload_id") } });
346
+ }
347
+
348
+ async putMedia({ installationId, fileName, contentType, fileSize, contentHash, width, height, body }) {
349
+ const requested = await this.requestMediaUpload({ installationId, fileName, contentType, fileSize, contentHash, width, height });
350
+ if (requested.isDuplicate) return requested.image;
351
+ await this.uploadPresigned({ url: requested.presignedUrl, requiredHeaders: requested.requiredHeaders, body });
352
+ return (await this.confirmMediaUpload({ installationId, uploadId: requested.uploadId })).image;
353
+ }
354
+
355
+ async uploadPresigned({ url, requiredHeaders = {}, body }) {
356
+ const bytes = asBytes(body, "invalid_upload_body");
357
+ const endpoint = presignedUploadUrl(url);
358
+ const headers = uploadHeaders(requiredHeaders, bytes.length);
359
+ return this.#semaphore.run(async () => {
360
+ let response;
361
+ try {
362
+ response = await this.#fetch(endpoint, { method: "PUT", headers, body: bytes, redirect: "manual", credentials: "omit", referrerPolicy: "no-referrer", signal: AbortSignal.timeout(this.#timeoutMs) });
363
+ } catch (error) {
364
+ throw new IntegrationSdkError("upload_response_unknown", "Upload response was lost. Retry the same upload bytes only when the caller can prove the upload remains pending.");
365
+ }
366
+ if (response.redirected || response.status >= 300 && response.status < 400) throw new IntegrationSdkError("redirect_rejected", "Object-store upload responses must not redirect.");
367
+ if (!response.ok) throw new IntegrationApiError(response.status, `upload_http_${response.status}`, "The object-store upload failed.");
368
+ });
369
+ }
370
+
371
+ listPlacements({ installationId }) {
372
+ return this.#request({ path: operationPath("listPlacements", { installationId }), method: "GET", credential: "installation", retry: true });
373
+ }
374
+
375
+ putFragment({ installationId, placementId, configurationRevision, expectedFragmentRevision, html, structuredData = [], syncId = "", idempotencyKey }) {
376
+ return this.#fragment("put", { installationId, placementId, configurationRevision, expectedFragmentRevision, html, structuredData, syncId, idempotencyKey });
377
+ }
378
+
379
+ deleteFragment({ installationId, placementId, configurationRevision, expectedFragmentRevision, syncId = "", idempotencyKey }) {
380
+ return this.#fragment("delete", { installationId, placementId, configurationRevision, expectedFragmentRevision, syncId, idempotencyKey });
381
+ }
382
+
383
+ #fragment(action, { installationId, placementId, configurationRevision, expectedFragmentRevision, html, structuredData, syncId, idempotencyKey }) {
384
+ const id = nonEmptyString(installationId, "invalid_installation_id");
385
+ const placement = nonEmptyString(placementId, "invalid_placement_id");
386
+ if (!Number.isSafeInteger(configurationRevision) || configurationRevision < 0) throw new IntegrationSdkError("invalid_configuration_revision", "configurationRevision must be a non-negative integer.");
387
+ const common = { installationId: id, placementId: placement, configurationRevision, syncId };
388
+ const operation = action === "put" ? { ...common, html: nonEmptyString(html, "invalid_fragment_html"), structuredData } : common;
389
+ if (expectedFragmentRevision !== undefined) operation.expectedFragmentRevision = expectedFragmentRevision;
390
+ return this.#request({ path: operationPath(action === "put" ? "putFragment" : "deleteFragment", { installationId: id, placementId: placement }), credential: "installation", idempotencyKey,
391
+ body: withIdempotency(operation, idempotencyKey), retry: true });
392
+ }
393
+
394
+ requestBundleUpload({ integrationId, contentHash, byteLength, runtimeMajor }) {
395
+ const id = nonEmptyString(integrationId, "invalid_integration_id");
396
+ return this.#request({ path: operationPath("requestBundleUpload", { integrationId: id }), credential: "publisher",
397
+ body: { integrationId: id, contentHash: nonEmptyString(contentHash, "invalid_content_hash"), byteLength, runtimeMajor } });
398
+ }
399
+
400
+ confirmBundleUpload({ integrationId, uploadId }) {
401
+ const id = nonEmptyString(integrationId, "invalid_integration_id");
402
+ return this.#request({ path: operationPath("confirmBundleUpload", { integrationId: id, uploadId }), credential: "publisher", body: { integrationId: id, uploadId } });
403
+ }
404
+
405
+ confirmHandoff({ installationId, token }) {
406
+ const id = nonEmptyString(installationId, "invalid_installation_id");
407
+ return this.#request({ path: operationPath("confirmHandoff", { installationId: id }), credential: "installation", body: { installationId: id, token: nonEmptyString(token, "invalid_handoff_token") } });
408
+ }
409
+ }
@@ -0,0 +1,17 @@
1
+ import { INTEGRATION_WEBHOOK_EVENTS } from "./openapi-contract.js";
2
+
3
+ export const INTEGRATION_SDK_VERSION = "0.1.2";
4
+ export const WEBHOOK_VERSION = "1";
5
+ export const WEBHOOK_EVENT_TYPES = Object.freeze(Object.keys(INTEGRATION_WEBHOOK_EVENTS));
6
+ export const INTEGRATION_CAPABILITIES = Object.freeze([
7
+ "pages.manage_own",
8
+ "data.manage_own",
9
+ "media.manage_own",
10
+ "publish",
11
+ "site.pages.read",
12
+ "fragments.manage_own",
13
+ ]);
14
+ export const HANDOFF_JWKS_PATH = ".well-known/taproot-integrations-jwks.json";
15
+ export const HANDOFF_LIFETIME_SECONDS = 5 * 60;
16
+ export const DEFAULT_TIMESTAMP_TOLERANCE_SECONDS = 5 * 60;
17
+ export const DEFAULT_RETRY_ATTEMPTS = 3;
package/src/errors.js ADDED
@@ -0,0 +1,18 @@
1
+ /** A safe, machine-readable rejection from a public integration contract. */
2
+ export class IntegrationSdkError extends Error {
3
+ constructor(code, message = code, options = {}) {
4
+ super(message, options);
5
+ this.name = "IntegrationSdkError";
6
+ this.code = code;
7
+ if (options.status !== undefined) this.status = options.status;
8
+ if (options.details !== undefined) this.details = options.details;
9
+ }
10
+ }
11
+
12
+ /** The HTTP response from Taproot was valid HTTP but not a successful API result. */
13
+ export class IntegrationApiError extends IntegrationSdkError {
14
+ constructor(status, code = "api_error", message = "Taproot rejected the request.", details) {
15
+ super(code, message, { status, details });
16
+ this.name = "IntegrationApiError";
17
+ }
18
+ }