@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/handoff.js ADDED
@@ -0,0 +1,223 @@
1
+ import { webcrypto } from "node:crypto";
2
+ import { isIP } from "node:net";
3
+
4
+ import { HANDOFF_JWKS_PATH, HANDOFF_LIFETIME_SECONDS } from "./constants.js";
5
+ import { IntegrationSdkError } from "./errors.js";
6
+ import { isPlainObject } from "./bytes.js";
7
+ import { DuplicateJsonKeyError, parseJsonWithoutDuplicateKeys } from "./json.js";
8
+
9
+ const MAX_TOKEN_LENGTH = 8 * 1024;
10
+ const MAX_JWKS_BYTES = 64 * 1024;
11
+ const MAX_JWKS_KEYS = 16;
12
+ const MAX_JWKS_CACHE_SECONDS = 300;
13
+ const MIN_UNKNOWN_KID_REFRESH_MILLISECONDS = 30_000;
14
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ const EMPTY_UUID = "00000000-0000-0000-0000-000000000000";
16
+ const cache = new Map();
17
+ const refreshes = new Map();
18
+
19
+ function invalid(message = "The settings handoff is invalid.") {
20
+ return new IntegrationSdkError("invalid_handoff", message);
21
+ }
22
+
23
+ function canonicalUuid(value) {
24
+ if (typeof value !== "string" || !UUID.test(value) || value.toLowerCase() === EMPTY_UUID) throw invalid();
25
+ return value.toLowerCase();
26
+ }
27
+
28
+ function epochMilliseconds(now) {
29
+ if (now instanceof Date) return now.getTime();
30
+ if (typeof now === "number" && Number.isFinite(now)) return now;
31
+ throw new IntegrationSdkError("invalid_now", "now must be a Date or Unix milliseconds.");
32
+ }
33
+
34
+ function decodeBase64Url(value) {
35
+ if (typeof value !== "string" || value.length === 0 || !/^[A-Za-z0-9_-]+$/.test(value)) throw invalid();
36
+ const bytes = Buffer.from(value, "base64url");
37
+ if (bytes.length === 0 || bytes.toString("base64url") !== value) throw invalid();
38
+ return bytes;
39
+ }
40
+
41
+ function parseJson(bytes) {
42
+ let source;
43
+ try {
44
+ source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
45
+ return parseJsonWithoutDuplicateKeys(source);
46
+ } catch (error) {
47
+ if (error instanceof DuplicateJsonKeyError) throw invalid("The settings handoff contains duplicate JSON claims.");
48
+ if (error instanceof IntegrationSdkError) throw error;
49
+ throw invalid();
50
+ }
51
+ }
52
+
53
+ function exactKeys(value, keys) {
54
+ return isPlainObject(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
55
+ }
56
+
57
+ function parseToken(token) {
58
+ if (typeof token !== "string" || token.length === 0 || token.length > MAX_TOKEN_LENGTH) throw invalid();
59
+ const parts = token.split(".");
60
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) throw invalid();
61
+ return {
62
+ header: parseJson(decodeBase64Url(parts[0])),
63
+ payload: parseJson(decodeBase64Url(parts[1])),
64
+ signature: decodeBase64Url(parts[2]),
65
+ signed: new TextEncoder().encode(`${parts[0]}.${parts[1]}`),
66
+ };
67
+ }
68
+
69
+ function jwksCacheSeconds(value) {
70
+ const match = /(?:^|,)\s*max-age=(\d+)/i.exec(value ?? "");
71
+ return match ? Math.min(Number(match[1]), MAX_JWKS_CACHE_SECONDS) : MAX_JWKS_CACHE_SECONDS;
72
+ }
73
+
74
+ async function readJwks(response) {
75
+ const contentLength = response.headers.get("content-length");
76
+ if (contentLength && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_JWKS_BYTES)) throw new IntegrationSdkError("jwks_unavailable", "JWKS exceeds the public handoff limit.");
77
+ const reader = response.body?.getReader();
78
+ if (!reader) throw new IntegrationSdkError("jwks_unavailable", "JWKS response has no body.");
79
+ const chunks = [];
80
+ let length = 0;
81
+ try {
82
+ for (;;) {
83
+ const { done, value } = await reader.read();
84
+ if (done) break;
85
+ length += value.length;
86
+ if (length > MAX_JWKS_BYTES) throw new IntegrationSdkError("jwks_unavailable", "JWKS exceeds the public handoff limit.");
87
+ chunks.push(value);
88
+ }
89
+ } finally { reader.releaseLock(); }
90
+ const bytes = new Uint8Array(length);
91
+ let offset = 0;
92
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
93
+ const document = parseJson(bytes);
94
+ if (!exactKeys(document, ["keys"]) || !Array.isArray(document.keys) || document.keys.length > MAX_JWKS_KEYS) {
95
+ throw new IntegrationSdkError("jwks_unavailable", "JWKS does not match the Taproot handoff contract.");
96
+ }
97
+ const keys = new Map();
98
+ for (const key of document.keys) {
99
+ if (!exactKeys(key, ["kty", "use", "alg", "kid", "n", "e"]) || key.kty !== "RSA" || key.use !== "sig" || key.alg !== "RS256" ||
100
+ typeof key.kid !== "string" || key.kid.length === 0 || key.kid.length > 128 || typeof key.n !== "string" || typeof key.e !== "string" || keys.has(key.kid)) {
101
+ throw new IntegrationSdkError("jwks_unavailable", "JWKS does not contain valid handoff signing keys.");
102
+ }
103
+ try {
104
+ keys.set(key.kid, await webcrypto.subtle.importKey("jwk", key, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]));
105
+ } catch { throw new IntegrationSdkError("jwks_unavailable", "JWKS contains an invalid RSA key."); }
106
+ }
107
+ return keys;
108
+ }
109
+
110
+ function resolveJwksUrl(issuer, supplied) {
111
+ let base;
112
+ try { base = new URL(issuer); } catch { throw new IntegrationSdkError("invalid_issuer", "issuer must be an absolute URL."); }
113
+ const validOrigin = (url) => {
114
+ const loopback = url.hostname === "localhost" || url.hostname === "::1" || (isIP(url.hostname) === 4 && url.hostname.startsWith("127."));
115
+ return url.protocol === "https:" || (url.protocol === "http:" && loopback);
116
+ };
117
+ if (!validOrigin(base) || base.username || base.password || base.search || base.hash) throw new IntegrationSdkError("invalid_issuer", "issuer must be HTTPS (or loopback HTTP) without credentials, a query, or a fragment.");
118
+ if (supplied === undefined) return new URL(HANDOFF_JWKS_PATH, `${base.toString().replace(/\/$/, "")}/`).toString();
119
+ let configured;
120
+ try { configured = new URL(supplied); } catch { throw new IntegrationSdkError("invalid_jwks_url", "jwksUrl must be an absolute URL."); }
121
+ if (!validOrigin(configured) || configured.username || configured.password || configured.search || configured.hash) {
122
+ throw new IntegrationSdkError("invalid_jwks_url", "jwksUrl must be HTTPS (or loopback HTTP) without credentials, a query, or a fragment.");
123
+ }
124
+ return configured.toString();
125
+ }
126
+
127
+ async function refreshJwks(url, fetchImplementation, now) {
128
+ const existingRefresh = refreshes.get(url);
129
+ if (existingRefresh) return existingRefresh;
130
+ const refresh = (async () => {
131
+ let response;
132
+ try {
133
+ response = await fetchImplementation(url, { method: "GET", redirect: "manual", cache: "no-store", referrerPolicy: "no-referrer", signal: AbortSignal.timeout(10_000) });
134
+ } catch { throw new IntegrationSdkError("jwks_unavailable", "The handoff JWKS could not be loaded."); }
135
+ if (response.redirected || response.status >= 300 && response.status < 400 || !response.ok) throw new IntegrationSdkError("jwks_unavailable", "The handoff JWKS could not be loaded.");
136
+ const keys = await readJwks(response);
137
+ cache.set(url, {
138
+ keys,
139
+ fetchedAt: now,
140
+ expiresAt: now + jwksCacheSeconds(response.headers.get("cache-control")) * 1_000,
141
+ });
142
+ return keys;
143
+ })();
144
+ refreshes.set(url, refresh);
145
+ try { return await refresh; }
146
+ finally {
147
+ if (refreshes.get(url) === refresh) refreshes.delete(url);
148
+ }
149
+ }
150
+
151
+ async function keyFor({ issuer, jwksUrl, fetch: fetchImplementation, now, jwks }, kid) {
152
+ if (jwks !== undefined) {
153
+ const response = new Response(JSON.stringify(jwks), { headers: { "content-type": "application/jwk-set+json" } });
154
+ return (await readJwks(response)).get(kid);
155
+ }
156
+ if (typeof fetchImplementation !== "function") throw new IntegrationSdkError("fetch_required", "Fetching JWKS requires a Fetch-compatible implementation.");
157
+ const url = resolveJwksUrl(issuer, jwksUrl);
158
+ const existing = cache.get(url);
159
+ if (existing && existing.expiresAt > now && existing.keys.has(kid)) return existing.keys.get(kid);
160
+ if (existing && !existing.keys.has(kid) && existing.fetchedAt + MIN_UNKNOWN_KID_REFRESH_MILLISECONDS > now) return undefined;
161
+ const keys = await refreshJwks(url, fetchImplementation, now);
162
+ return keys.get(kid);
163
+ }
164
+
165
+ /**
166
+ * Verifies the five-minute RS256 settings handoff against the dedicated Taproot JWK set. A valid
167
+ * token is still only an invitation: call `confirmHandoff` once before creating a local session.
168
+ */
169
+ export async function verifyHandoff({ token, issuer, audience, installationId, jwksUrl, jwks, fetch: fetchImplementation = globalThis.fetch, now = new Date() }) {
170
+ const parsed = parseToken(token);
171
+ if (!exactKeys(parsed.header, ["alg", "typ", "kid"]) || parsed.header.alg !== "RS256" || parsed.header.typ !== "JWT" ||
172
+ typeof parsed.header.kid !== "string" || parsed.header.kid.length === 0 || parsed.header.kid.length > 128) throw invalid();
173
+ const nowMilliseconds = epochMilliseconds(now);
174
+ const key = await keyFor({ issuer, jwksUrl, jwks, fetch: fetchImplementation, now: nowMilliseconds }, parsed.header.kid);
175
+ if (!key || !await webcrypto.subtle.verify("RSASSA-PKCS1-v1_5", key, parsed.signature, parsed.signed)) throw invalid();
176
+ const payload = parsed.payload;
177
+ const fields = ["iss", "aud", "sub", "site_id", "installation_id", "credential_id", "role", "engagement_id", "jti", "iat", "exp"];
178
+ if (!exactKeys(payload, fields) || payload.iss !== issuer || canonicalUuid(payload.aud) !== canonicalUuid(audience) ||
179
+ canonicalUuid(payload.installation_id) !== canonicalUuid(installationId) || !Number.isSafeInteger(payload.iat) || !Number.isSafeInteger(payload.exp)) throw invalid();
180
+ const issuedAt = payload.iat * 1_000;
181
+ const expiresAt = payload.exp * 1_000;
182
+ if (payload.iat <= 0 || payload.exp <= 0 || issuedAt > nowMilliseconds + 60_000 || expiresAt <= nowMilliseconds || expiresAt - issuedAt !== HANDOFF_LIFETIME_SECONDS * 1_000) throw invalid();
183
+ const role = payload.role;
184
+ const engagementId = payload.engagement_id === null ? null : canonicalUuid(payload.engagement_id);
185
+ if ((role !== "owner" && role !== "engagement") || (role === "owner") !== (engagementId === null)) throw invalid();
186
+ return Object.freeze({
187
+ userId: canonicalUuid(payload.sub),
188
+ siteId: canonicalUuid(payload.site_id),
189
+ installationId: canonicalUuid(payload.installation_id),
190
+ integrationId: canonicalUuid(payload.aud),
191
+ credentialId: canonicalUuid(payload.credential_id),
192
+ jti: canonicalUuid(payload.jti),
193
+ role,
194
+ engagementId,
195
+ issuedAt: new Date(issuedAt).toISOString(),
196
+ expiresAt: new Date(expiresAt).toISOString(),
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Consumes the authoritative, single-use Taproot handoff after local verification. The caller
202
+ * must discard the URL token before this call and, if the response is lost, begin a new handoff.
203
+ */
204
+ export async function confirmHandoff({ client, claims, token, now = new Date(), maximumSessionLifetimeSeconds = 24 * 60 * 60 }) {
205
+ if (!client || typeof client.confirmHandoff !== "function" || !isPlainObject(claims)) throw new IntegrationSdkError("invalid_handoff_confirmation", "A client and verified claims are required.");
206
+ if (!Number.isSafeInteger(maximumSessionLifetimeSeconds) || maximumSessionLifetimeSeconds < 1 || maximumSessionLifetimeSeconds > 24 * 60 * 60) throw new IntegrationSdkError("invalid_session_lifetime", "maximumSessionLifetimeSeconds must be from one through 86400.");
207
+ const confirmed = await client.confirmHandoff({ installationId: claims.installationId, token });
208
+ for (const field of ["userId", "siteId", "installationId", "integrationId", "credentialId", "role", "engagementId"]) {
209
+ if ((confirmed[field] || null) !== (claims[field] || null)) throw new IntegrationSdkError("handoff_confirmation_mismatch", "Taproot confirmation did not match the verified handoff.");
210
+ }
211
+ const expiresAt = Date.parse(confirmed.expiresAt);
212
+ const nowMilliseconds = epochMilliseconds(now);
213
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= nowMilliseconds || expiresAt > nowMilliseconds + maximumSessionLifetimeSeconds * 1_000) {
214
+ throw new IntegrationSdkError("invalid_handoff_confirmation", "Taproot returned an invalid settings session expiry.");
215
+ }
216
+ return Object.freeze({ ...claims, sessionExpiresAt: new Date(expiresAt).toISOString() });
217
+ }
218
+
219
+ /** Clears process-local public-key cache entries, primarily for controlled test or key-rollover recovery. */
220
+ export function clearHandoffJwksCache(jwksUrl) {
221
+ if (jwksUrl === undefined) return cache.clear();
222
+ cache.delete(new URL(jwksUrl).toString());
223
+ }
package/src/index.js ADDED
@@ -0,0 +1,16 @@
1
+ export {
2
+ DEFAULT_RETRY_ATTEMPTS,
3
+ DEFAULT_TIMESTAMP_TOLERANCE_SECONDS,
4
+ HANDOFF_JWKS_PATH,
5
+ HANDOFF_LIFETIME_SECONDS,
6
+ INTEGRATION_CAPABILITIES,
7
+ INTEGRATION_SDK_VERSION,
8
+ WEBHOOK_EVENT_TYPES,
9
+ WEBHOOK_VERSION,
10
+ } from "./constants.js";
11
+ export { IntegrationApiError, IntegrationSdkError } from "./errors.js";
12
+ export { INTEGRATION_OPENAPI_OPERATIONS, INTEGRATION_OPENAPI_SCHEMAS, INTEGRATION_WEBHOOK_EVENTS } from "./openapi-contract.js";
13
+ export { assertValidManifest, validateFragment, validateManifest } from "./manifest.js";
14
+ export { createIdempotencyKey, TaprootIntegrationClient } from "./client.js";
15
+ export { clearHandoffJwksCache, confirmHandoff, verifyHandoff } from "./handoff.js";
16
+ export { claimWebhookReceipt, finishWebhookReceipt, parseWebhookEvent, verifyWebhookSignature } from "./webhooks.js";
package/src/json.js ADDED
@@ -0,0 +1,56 @@
1
+ export class DuplicateJsonKeyError extends Error {
2
+ constructor(path, key) {
3
+ super("JSON contains duplicate object keys.");
4
+ this.name = "DuplicateJsonKeyError";
5
+ this.path = path;
6
+ this.key = key;
7
+ }
8
+ }
9
+
10
+ function childPath(context) {
11
+ if (!context) return [];
12
+ return context.object ? [...context.path, context.key] : [...context.path, context.index];
13
+ }
14
+
15
+ /**
16
+ * Rejects duplicate keys in every object before returning parsed JSON. JavaScript's JSON parser
17
+ * keeps the last duplicate key, while the public Taproot contracts treat every object as closed.
18
+ */
19
+ export function parseJsonWithoutDuplicateKeys(source) {
20
+ const value = JSON.parse(source);
21
+ const contexts = [];
22
+
23
+ for (let index = 0; index < source.length; index++) {
24
+ const character = source[index];
25
+ if (character === '"') {
26
+ const start = index;
27
+ let escaped = false;
28
+ for (++index; index < source.length; index++) {
29
+ if (!escaped && source[index] === '"') break;
30
+ escaped = !escaped && source[index] === "\\";
31
+ if (escaped && source[index] !== "\\") escaped = false;
32
+ }
33
+
34
+ const context = contexts.at(-1);
35
+ if (context?.object && context.expectingKey) {
36
+ const key = JSON.parse(source.slice(start, index + 1));
37
+ if (context.keys.has(key)) throw new DuplicateJsonKeyError(context.path, key);
38
+ context.keys.add(key);
39
+ context.key = key;
40
+ context.expectingKey = false;
41
+ }
42
+ } else if (character === "{") {
43
+ contexts.push({ object: true, expectingKey: true, key: undefined, keys: new Set(), path: childPath(contexts.at(-1)) });
44
+ } else if (character === "[") {
45
+ contexts.push({ object: false, index: 0, path: childPath(contexts.at(-1)) });
46
+ } else if (character === "}" || character === "]") {
47
+ contexts.pop();
48
+ } else if (character === ",") {
49
+ const context = contexts.at(-1);
50
+ if (context?.object) context.expectingKey = true;
51
+ else if (context) context.index++;
52
+ }
53
+ }
54
+
55
+ return value;
56
+ }
@@ -0,0 +1,284 @@
1
+ import { INTEGRATION_CAPABILITIES } from "./constants.js";
2
+ import { IntegrationSdkError } from "./errors.js";
3
+ import { asBytes, isPlainObject } from "./bytes.js";
4
+ import { DuplicateJsonKeyError, parseJsonWithoutDuplicateKeys } from "./json.js";
5
+
6
+ const ROOT_FIELDS = new Set([
7
+ "slug", "displayName", "webhookEndpointUrl", "settingsUrl", "requestedCapabilities",
8
+ "offeredComponents", "externalScriptOrigins", "defaultPublishPolicy", "recommendedUninstallDisposition",
9
+ "contractVersion", "pricingOptions", "description", "externalScriptOriginReasons",
10
+ "externalConnectOrigins", "externalConnectOriginReasons",
11
+ ]);
12
+ const REQUIRED_ROOT_FIELDS = [
13
+ "slug", "displayName", "webhookEndpointUrl", "settingsUrl", "requestedCapabilities",
14
+ "offeredComponents", "externalScriptOrigins", "defaultPublishPolicy", "recommendedUninstallDisposition",
15
+ "contractVersion", "pricingOptions",
16
+ ];
17
+ const IDENTIFIER = /^[A-Za-z0-9_.-]+$/;
18
+ const VERSION = /^[A-Za-z0-9.-]+$/;
19
+ const SLUG = /^[a-z][a-z0-9-]*$/;
20
+ const MAX_MANIFEST_BYTES = 1024 * 1024;
21
+ const MAX_SCHEMA_DEPTH = 12;
22
+ const MAX_SCHEMA_NODES = 256;
23
+ const MAX_SCHEMA_PROPERTIES = 32;
24
+ const MAX_SCHEMA_ENUM_VALUES = 64;
25
+ const MAX_QUANTITY = 100_000;
26
+ const MAX_SCHEMA_BYTES = 64 * 1024;
27
+ const TEXT_ENCODER = new TextEncoder();
28
+
29
+ function failure(field, code) {
30
+ throw new IntegrationSdkError(code, `Manifest validation failed for ${field}.`, { details: { field, code } });
31
+ }
32
+
33
+ function object(value, field, allowed, required) {
34
+ if (!isPlainObject(value)) failure(field, "invalid_object");
35
+ for (const key of Object.keys(value)) if (!allowed.has(key)) failure(field, "unknown_property");
36
+ for (const key of required) if (!Object.hasOwn(value, key)) failure(field, "missing_property");
37
+ }
38
+
39
+ function text(value, field, maximum) {
40
+ if (typeof value !== "string" || value.trim().length === 0 || value.length > maximum || /[\u0000-\u001f\u007f]/.test(value)) {
41
+ failure(field, "invalid_value");
42
+ }
43
+ return value;
44
+ }
45
+
46
+ function array(value, field, maximum, itemMaximum) {
47
+ if (!Array.isArray(value) || value.length > maximum) failure(field, "invalid_value");
48
+ const seen = new Set();
49
+ for (const item of value) {
50
+ text(item, field, itemMaximum);
51
+ if (seen.has(item)) failure(field, "invalid_value");
52
+ seen.add(item);
53
+ }
54
+ return value;
55
+ }
56
+
57
+ function endpoint(value, field, originOnly = false) {
58
+ text(value, field, 2048);
59
+ let parsed;
60
+ try { parsed = new URL(value); } catch { failure(field, "invalid_url"); }
61
+ if (parsed.protocol !== "https:" || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash ||
62
+ !parsed.hostname || /^\d/.test(parsed.hostname) || parsed.hostname.includes(":")) {
63
+ failure(field, "invalid_url");
64
+ }
65
+ if (originOnly && parsed.pathname !== "/") failure(field, "invalid_origin");
66
+ return originOnly ? `https://${parsed.hostname.toLowerCase()}` : parsed.toString();
67
+ }
68
+
69
+ function positiveInteger(value, field, nullable = false) {
70
+ if (nullable && value === null) return null;
71
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_QUANTITY) failure(field, "invalid_value");
72
+ return value;
73
+ }
74
+
75
+ function validateSchema(schema, field, depth, state) {
76
+ if (depth > MAX_SCHEMA_DEPTH || ++state.nodes > MAX_SCHEMA_NODES || !isPlainObject(schema)) failure(field, "invalid_schema");
77
+ const allowed = new Set(["type", "properties", "required", "items", "enum", "additionalProperties", "minLength", "maxLength", "minimum", "maximum"]);
78
+ object(schema, field, allowed, ["type"]);
79
+ if (!["object", "array", "string", "number", "integer", "boolean"].includes(schema.type)) failure(field, "invalid_schema");
80
+ let declared;
81
+ if (Object.hasOwn(schema, "properties")) {
82
+ if (schema.type !== "object" || !isPlainObject(schema.properties) || Object.keys(schema.properties).length > MAX_SCHEMA_PROPERTIES) failure(field, "invalid_schema");
83
+ declared = new Set();
84
+ for (const [name, child] of Object.entries(schema.properties)) {
85
+ text(name, field, 64);
86
+ if (declared.has(name)) failure(field, "invalid_schema");
87
+ declared.add(name);
88
+ validateSchema(child, field, depth + 1, state);
89
+ }
90
+ }
91
+ if (Object.hasOwn(schema, "required")) {
92
+ if (schema.type !== "object" || !declared || !Array.isArray(schema.required) || schema.required.length > MAX_SCHEMA_PROPERTIES) failure(field, "invalid_schema");
93
+ const required = new Set();
94
+ for (const name of schema.required) {
95
+ if (typeof name !== "string" || !declared.has(name) || required.has(name)) failure(field, "invalid_schema");
96
+ required.add(name);
97
+ }
98
+ }
99
+ if (schema.type === "array" && !Object.hasOwn(schema, "items")) failure(field, "invalid_schema");
100
+ if (Object.hasOwn(schema, "items")) {
101
+ if (schema.type !== "array") failure(field, "invalid_schema");
102
+ validateSchema(schema.items, field, depth + 1, state);
103
+ }
104
+ if (Object.hasOwn(schema, "enum")) {
105
+ if (!Array.isArray(schema.enum) || schema.enum.length === 0 || schema.enum.length > MAX_SCHEMA_ENUM_VALUES ||
106
+ schema.enum.some((item) => !["string", "number", "boolean"].includes(typeof item) ||
107
+ (typeof item === "string" && (item.length > 256 || /[\u0000-\u001f\u007f]/.test(item))) ||
108
+ (typeof item === "number" && !Number.isFinite(item)))) failure(field, "invalid_schema");
109
+ }
110
+ if (Object.hasOwn(schema, "additionalProperties") && (schema.type !== "object" || schema.additionalProperties !== false)) failure(field, "invalid_schema");
111
+ for (const key of ["minLength", "maxLength"]) {
112
+ if (Object.hasOwn(schema, key) && (schema.type !== "string" || !Number.isSafeInteger(schema[key]) || schema[key] < 0 || schema[key] > MAX_QUANTITY)) failure(field, "invalid_schema");
113
+ }
114
+ if (Object.hasOwn(schema, "minLength") && Object.hasOwn(schema, "maxLength") && schema.minLength > schema.maxLength) failure(field, "invalid_schema");
115
+ for (const key of ["minimum", "maximum"]) {
116
+ if (Object.hasOwn(schema, key) && (!["number", "integer"].includes(schema.type) || typeof schema[key] !== "number" || !Number.isFinite(schema[key]))) failure(field, "invalid_schema");
117
+ }
118
+ if (Object.hasOwn(schema, "minimum") && Object.hasOwn(schema, "maximum") && schema.minimum > schema.maximum) failure(field, "invalid_schema");
119
+ }
120
+
121
+ function origins(manifest, name) {
122
+ if (!Object.hasOwn(manifest, name)) return [];
123
+ if (!Array.isArray(manifest[name]) || manifest[name].length > 16) failure(name, "invalid_value");
124
+ const values = manifest[name].map((value) => endpoint(value, name, true));
125
+ if (new Set(values).size !== values.length) failure(name, "duplicate_value");
126
+ return values;
127
+ }
128
+
129
+ function originReasons(manifest, name, values) {
130
+ if (!Object.hasOwn(manifest, name)) return;
131
+ if (!isPlainObject(manifest[name]) || Object.keys(manifest[name]).length !== values.length) failure(name, "invalid_value");
132
+ for (const [origin, reason] of Object.entries(manifest[name])) {
133
+ if (!values.includes(origin)) failure(name, "invalid_value");
134
+ text(reason, name, 500);
135
+ }
136
+ }
137
+
138
+ function jsonByteLength(value, field, code) {
139
+ let serialized;
140
+ try { serialized = JSON.stringify(value); } catch { failure(field, code); }
141
+ if (typeof serialized !== "string") failure(field, code);
142
+ return TEXT_ENCODER.encode(serialized).length;
143
+ }
144
+
145
+ function serializedJsonBytes(value) {
146
+ try {
147
+ const serialized = JSON.stringify(value);
148
+ return typeof serialized === "string" ? TEXT_ENCODER.encode(serialized) : undefined;
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ }
153
+
154
+ function duplicateKeyFailure(error) {
155
+ const path = error.path;
156
+ if (path.length === 0) failure("$", "unknown_property");
157
+
158
+ if (path[0] === "offeredComponents" && Number.isSafeInteger(path[1])) {
159
+ const field = `offeredComponents[${path[1]}]`;
160
+ if (path[2] === "configSchema" && path.at(-1) === "properties") failure(field, "invalid_schema");
161
+ failure(field, "unknown_property");
162
+ }
163
+
164
+ if (path[0] === "pricingOptions" && Number.isSafeInteger(path[1])) {
165
+ failure(`pricingOptions[${path[1]}]`, "unknown_property");
166
+ }
167
+
168
+ if (path.length === 1 && (path[0] === "externalScriptOriginReasons" || path[0] === "externalConnectOriginReasons")) {
169
+ failure(path[0], "invalid_value");
170
+ }
171
+
172
+ failure("$", "invalid_json");
173
+ }
174
+
175
+ function parseManifestJson(bytes) {
176
+ let source;
177
+ try { source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { failure("$", "invalid_json"); }
178
+ try { return parseJsonWithoutDuplicateKeys(source); } catch (error) {
179
+ if (error instanceof DuplicateJsonKeyError) duplicateKeyFailure(error);
180
+ failure("$", "invalid_json");
181
+ }
182
+ }
183
+
184
+ function normalizeManifestInput(input) {
185
+ if (typeof input === "string" || input instanceof Uint8Array || input instanceof ArrayBuffer) {
186
+ const bytes = asBytes(input, "invalid_json");
187
+ if (bytes.length > MAX_MANIFEST_BYTES) failure("$", "size_exceeded");
188
+ return { value: parseManifestJson(bytes), serializationFailed: false };
189
+ }
190
+
191
+ let plainObject;
192
+ try { plainObject = isPlainObject(input); } catch { failure("$", "invalid_object"); }
193
+ if (!plainObject) return { value: input, serializationFailed: false };
194
+
195
+ const bytes = serializedJsonBytes(input);
196
+ if (bytes && bytes.length > MAX_MANIFEST_BYTES) failure("$", "size_exceeded");
197
+ return { value: input, serializationFailed: !bytes };
198
+ }
199
+
200
+ function validateManifestOrThrow(input) {
201
+ const normalized = normalizeManifestInput(input);
202
+ const value = normalized.value;
203
+ object(value, "$", ROOT_FIELDS, REQUIRED_ROOT_FIELDS);
204
+ if (!SLUG.test(text(value.slug, "slug", 64))) failure("slug", "invalid_value");
205
+ text(value.displayName, "displayName", 100);
206
+ endpoint(value.webhookEndpointUrl, "webhookEndpointUrl");
207
+ endpoint(value.settingsUrl, "settingsUrl");
208
+ const capabilities = array(value.requestedCapabilities, "requestedCapabilities", 32, 128);
209
+ if (capabilities.some((capability) => !INTEGRATION_CAPABILITIES.includes(capability))) failure("requestedCapabilities", "unavailable_capability");
210
+ if (!Array.isArray(value.offeredComponents) || value.offeredComponents.length > 32) failure("offeredComponents", "invalid_value");
211
+ const componentIds = new Set();
212
+ for (let index = 0; index < value.offeredComponents.length; index++) {
213
+ const component = value.offeredComponents[index];
214
+ const field = `offeredComponents[${index}]`;
215
+ object(component, field, new Set(["id", "name", "configSchema"]), ["id", "name", "configSchema"]);
216
+ if (!IDENTIFIER.test(text(component.id, field, 64)) || componentIds.has(component.id)) failure(field, "invalid_identifier");
217
+ componentIds.add(component.id);
218
+ text(component.name, field, 100);
219
+ if (jsonByteLength(component.configSchema, field, "invalid_schema") > MAX_SCHEMA_BYTES) failure(field, "invalid_schema");
220
+ validateSchema(component.configSchema, field, 0, { nodes: 0 });
221
+ }
222
+ const scriptOrigins = origins(value, "externalScriptOrigins");
223
+ const connectOrigins = origins(value, "externalConnectOrigins");
224
+ originReasons(value, "externalScriptOriginReasons", scriptOrigins);
225
+ originReasons(value, "externalConnectOriginReasons", connectOrigins);
226
+ if (!["auto", "stage"].includes(value.defaultPublishPolicy)) failure("defaultPublishPolicy", "invalid_value");
227
+ if (!["freeze", "remove"].includes(value.recommendedUninstallDisposition)) failure("recommendedUninstallDisposition", "invalid_value");
228
+ if (!VERSION.test(text(value.contractVersion, "contractVersion", 64))) failure("contractVersion", "invalid_value");
229
+ if (!Array.isArray(value.pricingOptions) || value.pricingOptions.length > 16) failure("pricingOptions", "invalid_value");
230
+ const optionIds = new Set();
231
+ value.pricingOptions.forEach((option, index) => {
232
+ const field = `pricingOptions[${index}]`;
233
+ object(option, field, new Set(["id", "version", "quantityMin", "quantityMax", "cadence", "catalogueItemKey"]),
234
+ ["id", "version", "quantityMin", "quantityMax", "cadence", "catalogueItemKey"]);
235
+ if (!IDENTIFIER.test(text(option.id, field, 64)) || optionIds.has(option.id) || !VERSION.test(text(option.version, field, 64))) failure(field, "invalid_value");
236
+ optionIds.add(option.id);
237
+ const minimum = positiveInteger(option.quantityMin, field);
238
+ const maximum = positiveInteger(option.quantityMax, field, true);
239
+ if (maximum !== null && maximum < minimum) failure(field, "invalid_value");
240
+ if (option.cadence === "free" ? option.catalogueItemKey !== null :
241
+ (!["monthly", "annual"].includes(option.cadence) || !IDENTIFIER.test(text(option.catalogueItemKey, field, 128)))) failure(field, "invalid_value");
242
+ });
243
+ if (Object.hasOwn(value, "description")) text(value.description, "description", 2000);
244
+ if (normalized.serializationFailed) failure("$", "invalid_json");
245
+ return value;
246
+ }
247
+
248
+ /**
249
+ * Checks the bounded public manifest grammar before registration. DNS resolution, public-route
250
+ * validation, and all authorization decisions remain server-authoritative.
251
+ */
252
+ export function validateManifest(input) {
253
+ try { return { ok: true, value: validateManifestOrThrow(input) }; }
254
+ catch (error) {
255
+ if (error instanceof IntegrationSdkError) {
256
+ return { ok: false, errors: [{ field: error.details?.field ?? "$", code: error.code }] };
257
+ }
258
+ throw error;
259
+ }
260
+ }
261
+
262
+ export function assertValidManifest(input) {
263
+ const result = validateManifest(input);
264
+ if (!result.ok) {
265
+ const first = result.errors[0];
266
+ throw new IntegrationSdkError(first.code, `Manifest validation failed for ${first.field}.`, { details: first });
267
+ }
268
+ return result.value;
269
+ }
270
+
271
+ /**
272
+ * Advisory preflight only. It catches a few common server rejections but intentionally does not
273
+ * implement a second HTML sanitizer. Taproot validates the final fragment on every write.
274
+ */
275
+ export function validateFragment(html) {
276
+ const errors = [];
277
+ if (typeof html !== "string" || html.length === 0) errors.push({ code: "invalid_html", message: "A non-empty HTML fragment is required." });
278
+ if (typeof html === "string" && new TextEncoder().encode(html).length > 64 * 1024) errors.push({ code: "size_exceeded", message: "Fragment exceeds the public preflight size limit." });
279
+ if (typeof html === "string" && /<\/?(?:script|iframe|object|embed|form|input|svg|math)\b/i.test(html)) errors.push({ code: "element_not_allowed", message: "The fragment contains an element the server normally rejects." });
280
+ if (typeof html === "string" && /\son[a-z]+\s*=/i.test(html)) errors.push({ code: "attribute_not_allowed", message: "Event-handler attributes are not accepted." });
281
+ if (typeof html === "string" && /(?:href|src)\s*=\s*['"]?\s*(?:javascript:|data:|\/\\|\\\\)/i.test(html)) errors.push({ code: "invalid_url", message: "The fragment contains an unsafe URL form." });
282
+ if (typeof html === "string" && /<(?!template\b[^>]*\bshadowrootmode\s*=)[^>]*\bstyle\b/i.test(html)) errors.push({ code: "element_not_allowed", message: "Styles are only supported inside an allowed declarative shadow root." });
283
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
284
+ }