@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
@@ -0,0 +1,269 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ import { asBytes, isPlainObject, MAX_WEBHOOK_BODY_BYTES, parseJsonBytes, readHeader, sha256Hex } from "./bytes.js";
4
+ import { DEFAULT_TIMESTAMP_TOLERANCE_SECONDS, WEBHOOK_VERSION } from "./constants.js";
5
+ import { IntegrationSdkError } from "./errors.js";
6
+ import { INTEGRATION_WEBHOOK_EVENTS } from "./openapi-contract.js";
7
+
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ const EMPTY_UUID = "00000000-0000-0000-0000-000000000000";
10
+ const SIGNATURE = /^t=([0-9]{1,16}),k=([0-9a-f-]{36}),v1=([0-9a-f]{64})$/i;
11
+ const EVENT_TYPES = new Set(Object.keys(INTEGRATION_WEBHOOK_EVENTS));
12
+
13
+ function uuid(value, code, allowEmpty = false) {
14
+ if (typeof value !== "string" || !UUID.test(value) || (!allowEmpty && value === EMPTY_UUID)) {
15
+ throw new IntegrationSdkError(code, "The webhook contains an invalid UUID.");
16
+ }
17
+ return value.toLowerCase();
18
+ }
19
+
20
+ function dateTime(value, code) {
21
+ if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
22
+ throw new IntegrationSdkError(code, "The webhook contains an invalid timestamp.");
23
+ }
24
+ return value;
25
+ }
26
+
27
+ function exactObject(value, required, code) {
28
+ if (!isPlainObject(value) || Object.keys(value).length !== required.length ||
29
+ required.some((key) => !Object.hasOwn(value, key))) {
30
+ throw new IntegrationSdkError(code, "The webhook event does not match the v1 contract.");
31
+ }
32
+ return value;
33
+ }
34
+
35
+ function string(value, code) {
36
+ if (typeof value !== "string" || value.length === 0) throw new IntegrationSdkError(code, "Expected a non-empty string.");
37
+ return value;
38
+ }
39
+
40
+ function nonNegativeInteger(value, code) {
41
+ if (!Number.isSafeInteger(value) || value < 0) throw new IntegrationSdkError(code, "Expected a non-negative integer.");
42
+ return value;
43
+ }
44
+
45
+ function installationState(value, code) {
46
+ exactObject(value, [
47
+ "status", "revision", "grantedCapabilities", "publishPolicy", "contractVersion",
48
+ "sourceKind", "sourceEngagementId", "sourceTerminationReason",
49
+ ], code);
50
+ string(value.status, code);
51
+ nonNegativeInteger(value.revision, code);
52
+ if (!Array.isArray(value.grantedCapabilities) || value.grantedCapabilities.some((item) => typeof item !== "string")) {
53
+ throw new IntegrationSdkError(code, "The installation capabilities are invalid.");
54
+ }
55
+ if (value.publishPolicy !== "auto" && value.publishPolicy !== "stage") throw new IntegrationSdkError(code, "The publish policy is invalid.");
56
+ string(value.contractVersion, code);
57
+ if (value.sourceKind !== "owner" && value.sourceKind !== "engagement") throw new IntegrationSdkError(code, "The source kind is invalid.");
58
+ if (value.sourceEngagementId !== null) uuid(value.sourceEngagementId, code);
59
+ if (value.sourceTerminationReason !== null && typeof value.sourceTerminationReason !== "string") {
60
+ throw new IntegrationSdkError(code, "The source termination reason is invalid.");
61
+ }
62
+ }
63
+
64
+ function publishedCandidate(value, code) {
65
+ exactObject(value, ["candidateId", "revision"], code);
66
+ uuid(value.candidateId, code);
67
+ nonNegativeInteger(value.revision, code);
68
+ }
69
+
70
+ function validateEventData(type, data) {
71
+ const code = "invalid_event";
72
+ switch (type) {
73
+ case "ping":
74
+ exactObject(data, ["purpose"], code); string(data.purpose, code); return;
75
+ case "installation.created":
76
+ exactObject(data, ["installation", "claimCode", "claimExpiresAt"], code);
77
+ installationState(data.installation, code); string(data.claimCode, code); dateTime(data.claimExpiresAt, code); return;
78
+ case "installation.updated":
79
+ case "installation.suspended":
80
+ case "installation.resumed":
81
+ case "installation.uninstalled":
82
+ installationState(data, code); return;
83
+ case "sync.requested":
84
+ exactObject(data, ["reason", "syncId", "candidateIds"], code);
85
+ string(data.reason, code); uuid(data.syncId, code);
86
+ if (!Array.isArray(data.candidateIds)) throw new IntegrationSdkError(code, "Candidate IDs must be an array.");
87
+ data.candidateIds.forEach((value) => uuid(value, code)); return;
88
+ case "placement.created":
89
+ case "placement.updated":
90
+ case "placement.deleted":
91
+ exactObject(data, ["placementId", "componentId", "placementRevision", "config"], code);
92
+ uuid(data.placementId, code); string(data.componentId, code); nonNegativeInteger(data.placementRevision, code); return;
93
+ case "publish.completed":
94
+ exactObject(data, ["deploymentId", "candidates"], code); uuid(data.deploymentId, code);
95
+ if (!Array.isArray(data.candidates)) throw new IntegrationSdkError(code, "Candidates must be an array.");
96
+ data.candidates.forEach((value) => publishedCandidate(value, code)); return;
97
+ case "publish.failed":
98
+ exactObject(data, ["deploymentId", "candidateIds", "code"], code); uuid(data.deploymentId, code);
99
+ if (!Array.isArray(data.candidateIds)) throw new IntegrationSdkError(code, "Candidate IDs must be an array.");
100
+ data.candidateIds.forEach((value) => uuid(value, code)); string(data.code, code); return;
101
+ case "page.published":
102
+ exactObject(data, ["pageId", "externalId", "publishedRevision", "url"], code);
103
+ uuid(data.pageId, code); string(data.externalId, code); nonNegativeInteger(data.publishedRevision, code); string(data.url, code); return;
104
+ case "page.deleted":
105
+ exactObject(data, ["pageId", "externalId", "revision", "url"], code);
106
+ uuid(data.pageId, code); string(data.externalId, code); nonNegativeInteger(data.revision, code); string(data.url, code); return;
107
+ default:
108
+ throw new IntegrationSdkError(code, "The webhook event type is unsupported.");
109
+ }
110
+ }
111
+
112
+ /** Parses an event only after its raw bytes have passed signature verification. */
113
+ export function parseWebhookEvent(body) {
114
+ const event = parseJsonBytes(asBytes(body), "invalid_json");
115
+ exactObject(event, ["id", "type", "version", "occurredAt", "integrationId", "installationId", "siteId", "data"], "invalid_event");
116
+ if (!EVENT_TYPES.has(event.type) || event.version !== WEBHOOK_VERSION || !isPlainObject(event.data)) {
117
+ throw new IntegrationSdkError("invalid_event", "The event is not a supported v1 webhook.");
118
+ }
119
+ event.id = uuid(event.id, "invalid_event");
120
+ event.integrationId = uuid(event.integrationId, "invalid_event");
121
+ const isPing = event.type === "ping";
122
+ event.installationId = uuid(event.installationId, "invalid_event", isPing);
123
+ event.siteId = uuid(event.siteId, "invalid_event", isPing);
124
+ dateTime(event.occurredAt, "invalid_event");
125
+ validateEventData(event.type, event.data);
126
+ return event;
127
+ }
128
+
129
+ function strictBase64(value) {
130
+ if (typeof value !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
131
+ throw new IntegrationSdkError("invalid_signing_key", "Signing secrets must be non-empty base64 strings.");
132
+ }
133
+ const decoded = Buffer.from(value, "base64");
134
+ if (decoded.length === 0) throw new IntegrationSdkError("invalid_signing_key", "Signing secrets must not be empty.");
135
+ return decoded;
136
+ }
137
+
138
+ function signingSecret(signingSecrets, keyId) {
139
+ let value;
140
+ if (signingSecrets instanceof Map) {
141
+ for (const [candidate, secret] of signingSecrets) if (String(candidate).toLowerCase() === keyId) value = secret;
142
+ } else if (isPlainObject(signingSecrets)) {
143
+ for (const [candidate, secret] of Object.entries(signingSecrets)) if (candidate.toLowerCase() === keyId) value = secret;
144
+ } else {
145
+ throw new IntegrationSdkError("invalid_signing_keys", "Signing secrets must be a key-id map.");
146
+ }
147
+ if (value === undefined) throw new IntegrationSdkError("unknown_signing_key", "The signing key is not configured.");
148
+ if (value instanceof Uint8Array) {
149
+ if (value.length === 0) throw new IntegrationSdkError("invalid_signing_key", "Signing secrets must not be empty.");
150
+ return value;
151
+ }
152
+ return strictBase64(value);
153
+ }
154
+
155
+ function parseSignature(value) {
156
+ if (typeof value !== "string") throw new IntegrationSdkError("missing_signature", "Taproot-Signature is required.");
157
+ const match = SIGNATURE.exec(value);
158
+ if (!match) throw new IntegrationSdkError("invalid_signature", "Taproot-Signature is malformed.");
159
+ const timestamp = Number(match[1]);
160
+ if (!Number.isSafeInteger(timestamp) || timestamp < 0) throw new IntegrationSdkError("invalid_signature", "The signature timestamp is invalid.");
161
+ return { timestamp, keyId: uuid(match[2], "invalid_signature"), signature: match[3].toLowerCase() };
162
+ }
163
+
164
+ function asMilliseconds(now) {
165
+ if (now instanceof Date) return now.getTime();
166
+ if (typeof now === "number" && Number.isFinite(now)) return now;
167
+ throw new IntegrationSdkError("invalid_now", "now must be a Date or Unix milliseconds.");
168
+ }
169
+
170
+ /**
171
+ * Verifies Taproot's `t=<unix>,k=<key id>,v1=<hmac>` envelope over `ASCII(t + ".") + body`.
172
+ * `body` must be the unmodified received request bytes. Parse or decode it only through the
173
+ * returned event after this function succeeds.
174
+ */
175
+ export function verifyWebhookSignature({
176
+ headers,
177
+ body,
178
+ signingSecrets,
179
+ now = new Date(),
180
+ timestampToleranceSeconds = DEFAULT_TIMESTAMP_TOLERANCE_SECONDS,
181
+ }) {
182
+ const bytes = asBytes(body);
183
+ if (bytes.length > MAX_WEBHOOK_BODY_BYTES) throw new IntegrationSdkError("body_too_large", "Webhook body exceeds 1 MiB.");
184
+ if (!Number.isSafeInteger(timestampToleranceSeconds) || timestampToleranceSeconds < 0 || timestampToleranceSeconds > 3600) {
185
+ throw new IntegrationSdkError("invalid_timestamp_tolerance", "Timestamp tolerance must be an integer from zero through 3600 seconds.");
186
+ }
187
+ const parsed = parseSignature(readHeader(headers, "Taproot-Signature"));
188
+ if (Math.abs(Math.floor(asMilliseconds(now) / 1000) - parsed.timestamp) > timestampToleranceSeconds) {
189
+ throw new IntegrationSdkError("timestamp_out_of_range", "Webhook timestamp is outside the permitted tolerance.");
190
+ }
191
+ let expected;
192
+ try {
193
+ expected = createHmac("sha256", signingSecret(signingSecrets, parsed.keyId))
194
+ .update(Buffer.from(`${parsed.timestamp}.`, "ascii"))
195
+ .update(bytes)
196
+ .digest();
197
+ } catch (error) {
198
+ if (error instanceof IntegrationSdkError) throw error;
199
+ throw new IntegrationSdkError("unknown_signing_key", "The signing key is not configured.");
200
+ }
201
+ const received = Buffer.from(parsed.signature, "hex");
202
+ if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
203
+ throw new IntegrationSdkError("invalid_signature", "Webhook HMAC verification failed.");
204
+ }
205
+ const event = parseWebhookEvent(bytes);
206
+ const deliveryId = uuid(readHeader(headers, "Taproot-Delivery-Id"), "invalid_delivery_header");
207
+ const headerInstallationId = uuid(readHeader(headers, "Taproot-Installation-Id"), "invalid_installation_header", event.type === "ping");
208
+ const headerEvent = readHeader(headers, "Taproot-Event");
209
+ if (deliveryId !== event.id || headerInstallationId !== event.installationId || headerEvent !== event.type) {
210
+ throw new IntegrationSdkError("header_body_mismatch", "Signed webhook headers do not match the event body.");
211
+ }
212
+ return Object.freeze({
213
+ event: Object.freeze(event),
214
+ deliveryId,
215
+ eventType: event.type,
216
+ installationId: event.installationId,
217
+ keyId: parsed.keyId,
218
+ timestamp: parsed.timestamp,
219
+ body: bytes,
220
+ bodySha256: sha256Hex(bytes),
221
+ });
222
+ }
223
+
224
+ function validReceiptClaim(value) {
225
+ return isPlainObject(value) && ["claimed", "duplicate", "in_progress", "conflict"].includes(value.state) &&
226
+ (value.state !== "claimed" || typeof value.token === "string");
227
+ }
228
+
229
+ /**
230
+ * Claims an already-verified delivery in caller-provided durable storage. A receipt store must
231
+ * make this operation atomic across restarts. Return `duplicate` only for the same immutable
232
+ * receipt that has already been delivered; return `conflict` when a delivery id is reused with
233
+ * different signed metadata. A failed receipt or expired processing lease must return `claimed`
234
+ * with a fresh token so the delivery is processed again, never `duplicate` or `in_progress`.
235
+ */
236
+ export async function claimWebhookReceipt({ store, verified, now = new Date() }) {
237
+ if (!store || typeof store.claim !== "function") {
238
+ throw new IntegrationSdkError("receipt_store_required", "A durable WebhookReceiptStore with claim() is required before acknowledgement.");
239
+ }
240
+ const receipt = Object.freeze({
241
+ deliveryId: verified.deliveryId,
242
+ eventType: verified.eventType,
243
+ installationId: verified.event.installationId,
244
+ integrationId: verified.event.integrationId,
245
+ occurredAt: verified.event.occurredAt,
246
+ keyId: verified.keyId,
247
+ bodySha256: verified.bodySha256,
248
+ receivedAt: new Date(asMilliseconds(now)).toISOString(),
249
+ });
250
+ const claim = await store.claim(receipt);
251
+ if (!validReceiptClaim(claim)) throw new IntegrationSdkError("invalid_receipt_claim", "Receipt storage returned an invalid claim result.");
252
+ return Object.freeze({ receipt, ...claim });
253
+ }
254
+
255
+ /** Marks a claimed durable receipt after processing. It does not imply exactly-once external work. */
256
+ export async function finishWebhookReceipt({ store, verified, token, status, failureCode, now = new Date() }) {
257
+ if (!store || typeof store.finish !== "function") throw new IntegrationSdkError("receipt_store_required", "A durable WebhookReceiptStore with finish() is required.");
258
+ if (typeof token !== "string" || token.length === 0 || !["delivered", "failed"].includes(status)) {
259
+ throw new IntegrationSdkError("invalid_receipt_completion", "Receipt completion is invalid.");
260
+ }
261
+ await store.finish({
262
+ deliveryId: verified.deliveryId,
263
+ bodySha256: verified.bodySha256,
264
+ token,
265
+ status,
266
+ failureCode: status === "failed" ? failureCode ?? "processing_failed" : undefined,
267
+ completedAt: new Date(asMilliseconds(now)).toISOString(),
268
+ });
269
+ }