@opencoredev/social-sdk 0.2.1 → 0.4.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.
Files changed (63) hide show
  1. package/dist/cli-request.d.ts +15 -0
  2. package/dist/cli-request.js +193 -0
  3. package/dist/cli.d.ts +4 -3
  4. package/dist/cli.js +19 -21
  5. package/dist/cloud/common.d.ts +7 -6
  6. package/dist/cloud/common.js +35 -54
  7. package/dist/cloud/lifecycle.js +31 -35
  8. package/dist/cloud/media.d.ts +2 -2
  9. package/dist/cloud/media.js +13 -3
  10. package/dist/cloud/outcomes.d.ts +4 -3
  11. package/dist/cloud/outcomes.js +8 -15
  12. package/dist/cloud/post-for-me.js +41 -49
  13. package/dist/cloud/zernio.js +58 -98
  14. package/dist/core/client.js +79 -99
  15. package/dist/core/fields.d.ts +14 -0
  16. package/dist/core/fields.js +14 -0
  17. package/dist/core/idempotency.d.ts +7 -2
  18. package/dist/core/idempotency.js +37 -20
  19. package/dist/core/pagination.js +8 -7
  20. package/dist/core/types.d.ts +3 -2
  21. package/dist/platforms/bluesky.d.ts +65 -1
  22. package/dist/platforms/bluesky.js +675 -276
  23. package/dist/platforms/instagram.d.ts +2 -0
  24. package/dist/platforms/instagram.js +130 -105
  25. package/dist/platforms/linkedin.d.ts +58 -1
  26. package/dist/platforms/linkedin.js +877 -107
  27. package/dist/platforms/threads.d.ts +13 -1
  28. package/dist/platforms/threads.js +204 -302
  29. package/dist/platforms/tiktok.d.ts +4 -0
  30. package/dist/platforms/tiktok.js +140 -124
  31. package/dist/platforms/webhook-adapter.d.ts +9 -0
  32. package/dist/platforms/webhook-adapter.js +24 -0
  33. package/dist/platforms/x-engagement.js +7 -12
  34. package/dist/platforms/x-stream.d.ts +83 -0
  35. package/dist/platforms/x-stream.js +350 -0
  36. package/dist/platforms/x.d.ts +87 -0
  37. package/dist/platforms/x.js +648 -120
  38. package/dist/platforms/youtube-upload.d.ts +1 -1
  39. package/dist/platforms/youtube-upload.js +6 -2
  40. package/dist/platforms/youtube.d.ts +28 -4
  41. package/dist/platforms/youtube.js +291 -133
  42. package/dist/server/bluesky-oauth.d.ts +177 -0
  43. package/dist/server/bluesky-oauth.js +1229 -0
  44. package/dist/server/connections.d.ts +14 -0
  45. package/dist/server/connections.js +10 -2
  46. package/dist/server/egress.d.ts +14 -0
  47. package/dist/server/egress.js +115 -0
  48. package/dist/server/oauth-internal.d.ts +6 -0
  49. package/dist/server/oauth-internal.js +66 -0
  50. package/dist/server/oauth.d.ts +1 -1
  51. package/dist/server/oauth.js +46 -99
  52. package/dist/server/webhooks.d.ts +136 -3
  53. package/dist/server/webhooks.js +639 -25
  54. package/dist/testing/index.js +14 -28
  55. package/dist/transport/http.d.ts +1 -1
  56. package/dist/transport/http.js +0 -1
  57. package/dist/transport/json.d.ts +7 -0
  58. package/dist/transport/json.js +32 -4
  59. package/dist/transport/upload.d.ts +1 -1
  60. package/dist/transport/upload.js +46 -38
  61. package/dist/transport/validation.d.ts +16 -5
  62. package/dist/transport/validation.js +29 -7
  63. package/package.json +2 -2
@@ -1,13 +1,9 @@
1
- /* oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-unknown-parameters, anti-slop/no-unsafe-dictionary-type, anti-slop/require-safety-comment-for-type-assertion -- webhook bodies are unknown until validated by the decoder. */
2
1
  import { parseJson } from "../transport/json.js";
3
2
  import { SocialError } from "../core/errors.js";
4
- import { array, object, optionalString, string } from "../transport/validation.js";
5
- function denied() {
6
- throw new SocialError({
7
- code: "unauthorized",
8
- operation: "webhooks.verify",
9
- message: "Webhook authentication failed. Check the configured endpoint secret and preserve the raw request bytes.",
10
- });
3
+ import { definedFields } from "../core/fields.js";
4
+ import { array, isFiniteNumber, isJsonArray, isJsonObject, isString, object, optionalString, string, } from "../transport/validation.js";
5
+ function denied(message = "Webhook authentication failed. Check the configured endpoint secret and preserve the raw request bytes.") {
6
+ throw new SocialError({ code: "unauthorized", operation: "webhooks.verify", message });
11
7
  }
12
8
  function checkedBytes(body, maxBytes) {
13
9
  if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || body.byteLength > maxBytes)
@@ -46,24 +42,35 @@ export async function verifyPostForMeWebhook(input) {
46
42
  };
47
43
  }
48
44
  const sensitive = /^(access.?token|refresh.?token|authorization|cookie|secret|signature|code|password|signed.?url|upload.?url)$/i;
49
- function clean(value, depth = 0) {
45
+ /** Objects and arrays: the values a truthy `typeof value === "object"` check admits. */
46
+ function isStructured(value) {
47
+ return isJsonObject(value) || isJsonArray(value);
48
+ }
49
+ function checkDepth(depth) {
50
50
  if (depth > 32)
51
51
  throw new SocialError({
52
52
  code: "invalid_input",
53
53
  operation: "webhooks.decode",
54
54
  message: "Webhook payload nesting exceeds the limit.",
55
55
  });
56
- if (value === null || typeof value === "boolean" || typeof value === "number")
57
- return value;
58
- if (typeof value === "string") {
56
+ }
57
+ function clean(value, depth) {
58
+ checkDepth(depth);
59
+ if (isString(value)) {
59
60
  if (/https:\/\//i.test(value) && /[?&](x-amz-|signature|token|sig|key)=?/i.test(value))
60
61
  return "[redacted URL]";
61
62
  return value;
62
63
  }
63
- if (Array.isArray(value))
64
+ if (isJsonArray(value))
64
65
  return value.map((item) => clean(item, depth + 1));
66
+ if (isJsonObject(value))
67
+ return cleanObject(value, depth);
68
+ return value;
69
+ }
70
+ function cleanObject(value, depth = 0) {
71
+ checkDepth(depth);
65
72
  const result = {};
66
- for (const [key, entry] of Object.entries(object(value)))
73
+ for (const [key, entry] of Object.entries(value))
67
74
  result[key] = sensitive.test(key) ? "[redacted]" : clean(entry, depth + 1);
68
75
  return result;
69
76
  }
@@ -103,16 +110,15 @@ export async function decodeWebhook(input) {
103
110
  else if (originalType === "comment.received")
104
111
  type = "comment.received";
105
112
  const dataValue = input.provider === "zernio" ? payload : object(payload["data"]);
106
- const cleaned = clean(dataValue);
107
- if (typeof cleaned !== "object" || cleaned === null || Array.isArray(cleaned))
108
- throw new Error("Expected normalized object");
113
+ const cleaned = cleanObject(dataValue);
109
114
  // IDs only locate application mappings; they never grant tenant ownership.
110
115
  const accountIds = [];
111
116
  const direct = optionalString(dataValue["accountId"]) ?? optionalString(dataValue["social_account_id"]);
112
117
  if (direct)
113
118
  accountIds.push(direct);
114
- if (dataValue["account"] && typeof dataValue["account"] === "object") {
115
- const account = object(dataValue["account"]);
119
+ const accountValue = dataValue["account"];
120
+ if (isStructured(accountValue)) {
121
+ const account = object(accountValue);
116
122
  const accountId = optionalString(account["accountId"]) ??
117
123
  optionalString(account["_id"]) ??
118
124
  optionalString(account["id"]);
@@ -121,8 +127,9 @@ export async function decodeWebhook(input) {
121
127
  }
122
128
  if (input.provider === "post-for-me" && originalType.startsWith("social.account."))
123
129
  accountIds.push(string(dataValue["id"]));
124
- if (dataValue["post"] && typeof dataValue["post"] === "object") {
125
- const post = object(dataValue["post"]);
130
+ const postValue = dataValue["post"];
131
+ if (isStructured(postValue)) {
132
+ const post = object(postValue);
126
133
  if (post["platforms"])
127
134
  for (const value of array(post["platforms"])) {
128
135
  const entry = object(value);
@@ -133,14 +140,12 @@ export async function decodeWebhook(input) {
133
140
  }
134
141
  if (input.provider === "post-for-me" && Array.isArray(dataValue["social_accounts"])) {
135
142
  for (const item of dataValue["social_accounts"]) {
136
- const id = typeof item === "string" ? item : optionalString(object(item)["id"]);
143
+ const id = isString(item) ? item : optionalString(object(item)["id"]);
137
144
  if (id)
138
145
  accountIds.push(id);
139
146
  }
140
147
  }
141
- const post = dataValue["post"] && typeof dataValue["post"] === "object"
142
- ? object(dataValue["post"])
143
- : undefined;
148
+ const post = isStructured(postValue) ? object(postValue) : undefined;
144
149
  const backendRecordId = input.provider === "zernio"
145
150
  ? (optionalString(post?.["id"]) ?? optionalString(post?.["_id"]))
146
151
  : originalType === "social.post.result.created"
@@ -202,3 +207,612 @@ export async function acceptWebhook(input) {
202
207
  });
203
208
  return { state, quarantined };
204
209
  }
210
+ const encoder = new TextEncoder();
211
+ const defaultMaxBytes = 1024 * 1024;
212
+ function challengeRefused(code) {
213
+ throw new SocialError({
214
+ code,
215
+ operation: "webhooks.challenge",
216
+ message: "Webhook handshake refused. Check the configured token or topic and the request query.",
217
+ });
218
+ }
219
+ async function hmacKey(secret, hash, usages) {
220
+ return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash }, false, usages);
221
+ }
222
+ function hexBytes(value, length) {
223
+ if (value.length !== length * 2 || !/^[0-9a-f]+$/i.test(value))
224
+ return undefined;
225
+ const bytes = new Uint8Array(length);
226
+ for (let index = 0; index < length; index++)
227
+ bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
228
+ return bytes;
229
+ }
230
+ function base64Bytes(value, length) {
231
+ if (value.length !== Math.ceil(length / 3) * 4 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value))
232
+ return undefined;
233
+ let binary;
234
+ try {
235
+ binary = atob(value);
236
+ }
237
+ catch {
238
+ return undefined;
239
+ }
240
+ if (binary.length !== length)
241
+ return undefined;
242
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
243
+ }
244
+ function base64(bytes) {
245
+ let binary = "";
246
+ for (const byte of new Uint8Array(bytes))
247
+ binary += String.fromCharCode(byte);
248
+ return btoa(binary);
249
+ }
250
+ async function hmacMatches(secret, hash, signature, data) {
251
+ // WebCrypto compares the keyed digest itself, avoiding a JS early-exit comparison.
252
+ return crypto.subtle.verify("HMAC", await hmacKey(secret, hash, ["verify"]), signature, data);
253
+ }
254
+ async function sameSecret(expected, supplied) {
255
+ // Compare fixed-size keyed digests so the token length and prefix do not leak through timing.
256
+ const key = await hmacKey(expected, "SHA-256", ["sign", "verify"]);
257
+ const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(expected));
258
+ return crypto.subtle.verify("HMAC", key, digest, encoder.encode(supplied));
259
+ }
260
+ function bodyVerified(method) {
261
+ return { valid: true, method, bodyAuthenticated: true, signedTimestamp: false };
262
+ }
263
+ /**
264
+ * Verify a Meta webhook POST for Instagram or Threads. Meta signs the raw body with
265
+ * HMAC-SHA256 using the app secret and sends `X-Hub-Signature-256: sha256=<hex>`.
266
+ */
267
+ export async function verifyMetaWebhook(input) {
268
+ const body = checkedBytes(input.body, input.maxBytes ?? defaultMaxBytes);
269
+ if (!input.secret)
270
+ denied();
271
+ const match = /^sha256=([0-9a-f]{64})$/i.exec(input.headers.get("X-Hub-Signature-256") ?? "");
272
+ const signature = match?.[1] === undefined ? undefined : hexBytes(match[1], 32);
273
+ if (!signature || !(await hmacMatches(input.secret, "SHA-256", signature, body)))
274
+ denied();
275
+ return bodyVerified("hmac-sha256");
276
+ }
277
+ /**
278
+ * Answer Meta's GET verification request. The verify token is compared in constant
279
+ * time and only a bounded, token-safe `hub.challenge` is echoed.
280
+ */
281
+ export async function answerMetaWebhookChallenge(input) {
282
+ const token = input.query.get("hub.verify_token");
283
+ const challenge = input.query.get("hub.challenge");
284
+ if (!input.verifyToken ||
285
+ input.query.get("hub.mode") !== "subscribe" ||
286
+ !token ||
287
+ token.length > 4096 ||
288
+ !challenge ||
289
+ !/^[A-Za-z0-9_-]{1,256}$/.test(challenge) ||
290
+ !(await sameSecret(input.verifyToken, token)))
291
+ challengeRefused("unauthorized");
292
+ return {
293
+ status: 200,
294
+ headers: { "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff" },
295
+ body: challenge,
296
+ };
297
+ }
298
+ /**
299
+ * Verify an X webhook POST. X signs the raw body with HMAC-SHA256 and sends
300
+ * `sha256=<base64>` in `X-Twitter-Webhooks-Signature-OAuth2` (OAuth 2.0 client secret)
301
+ * or the legacy `X-Twitter-Webhooks-Signature` (OAuth 1.0 consumer secret). When the
302
+ * OAuth 2.0 header is present it alone decides; the legacy header is checked only when
303
+ * the OAuth 2.0 header is absent. Pass the secret that matches the header X sends.
304
+ */
305
+ export async function verifyXWebhook(input) {
306
+ const body = checkedBytes(input.body, input.maxBytes ?? defaultMaxBytes);
307
+ if (!input.secret)
308
+ denied();
309
+ // X says to verify the OAuth 2.0 header when present. The legacy header is only a
310
+ // fallback when the OAuth 2.0 header is absent, so a bad OAuth 2.0 signature can
311
+ // never be rescued by a matching legacy one.
312
+ const header = input.headers.get("X-Twitter-Webhooks-Signature-OAuth2") ??
313
+ input.headers.get("X-Twitter-Webhooks-Signature");
314
+ const match = /^sha256=([A-Za-z0-9+/]{43}=)$/.exec(header ?? "");
315
+ const signature = match?.[1] === undefined ? undefined : base64Bytes(match[1], 32);
316
+ if (!signature || !(await hmacMatches(input.secret, "SHA-256", signature, body)))
317
+ denied();
318
+ return bodyVerified("hmac-sha256");
319
+ }
320
+ /**
321
+ * Answer X's CRC GET request with `{"response_token":"sha256=<base64>"}`, an
322
+ * HMAC-SHA256 of `crc_token` keyed with the same secret used for signatures.
323
+ */
324
+ export async function answerXWebhookChallenge(input) {
325
+ const crcToken = input.query.get("crc_token");
326
+ if (!input.secret || !crcToken || encoder.encode(crcToken).byteLength > 1024)
327
+ challengeRefused("unauthorized");
328
+ const digest = await crypto.subtle.sign("HMAC", await hmacKey(input.secret, "SHA-256", ["sign"]), encoder.encode(crcToken));
329
+ const responseToken = `sha256=${base64(digest)}`;
330
+ return {
331
+ status: 200,
332
+ headers: { "Content-Type": "application/json" },
333
+ body: JSON.stringify({ response_token: responseToken }),
334
+ responseToken,
335
+ };
336
+ }
337
+ function webSubAlgorithm(name) {
338
+ switch (name) {
339
+ case "sha1":
340
+ return { hash: "SHA-1", bytes: 20, method: "hmac-sha1" };
341
+ case "sha256":
342
+ return { hash: "SHA-256", bytes: 32, method: "hmac-sha256" };
343
+ case "sha384":
344
+ return { hash: "SHA-384", bytes: 48, method: "hmac-sha384" };
345
+ case "sha512":
346
+ return { hash: "SHA-512", bytes: 64, method: "hmac-sha512" };
347
+ default:
348
+ return undefined;
349
+ }
350
+ }
351
+ /**
352
+ * Verify a YouTube push notification delivered through the PubSubHubbub hub. The
353
+ * hub only signs deliveries when the subscription was created with `hub.secret`; it
354
+ * sends `X-Hub-Signature: <method>=<hex>`, and the reported method is returned.
355
+ */
356
+ export async function verifyYouTubeWebhook(input) {
357
+ const body = checkedBytes(input.body, input.maxBytes ?? defaultMaxBytes);
358
+ if (!input.secret)
359
+ denied();
360
+ const match = /^(sha1|sha256|sha384|sha512)=([0-9a-f]+)$/i.exec(input.headers.get("X-Hub-Signature") ?? "");
361
+ const algorithm = webSubAlgorithm(match?.[1]?.toLowerCase());
362
+ const signature = algorithm === undefined || match?.[2] === undefined
363
+ ? undefined
364
+ : hexBytes(match[2], algorithm.bytes);
365
+ if (!algorithm ||
366
+ !signature ||
367
+ !(await hmacMatches(input.secret, algorithm.hash, signature, body)))
368
+ denied();
369
+ return bodyVerified(algorithm.method);
370
+ }
371
+ /**
372
+ * Answer the hub's GET verification of intent. The topic must be one the
373
+ * application is currently subscribing to or unsubscribing from; otherwise this
374
+ * throws `not_found`, which WebSub expects as an HTTP 404.
375
+ */
376
+ export function answerYouTubeWebhookChallenge(input) {
377
+ const mode = input.query.get("hub.mode");
378
+ const topic = input.query.get("hub.topic");
379
+ const challenge = input.query.get("hub.challenge");
380
+ const lease = input.query.get("hub.lease_seconds");
381
+ if ((mode !== "subscribe" && mode !== "unsubscribe") ||
382
+ !topic ||
383
+ !input.topics.includes(topic) ||
384
+ !challenge ||
385
+ !/^[+\-./0-9=A-Z_a-z]{1,512}$/.test(challenge) ||
386
+ (lease !== null && !/^\d{1,10}$/.test(lease)))
387
+ challengeRefused("not_found");
388
+ const response = {
389
+ status: 200,
390
+ headers: { "Content-Type": "application/octet-stream", "X-Content-Type-Options": "nosniff" },
391
+ body: challenge,
392
+ mode,
393
+ topic,
394
+ };
395
+ return mode === "subscribe" && lease !== null
396
+ ? { ...response, leaseSeconds: Number(lease) }
397
+ : response;
398
+ }
399
+ /**
400
+ * Verify a TikTok webhook POST. `TikTok-Signature: t=<unix seconds>,s=<hex>` carries an
401
+ * HMAC-SHA256 of `<t>.<raw body>` keyed with the app's client secret. TikTok leaves
402
+ * the replay window to the receiver; this defaults to 300 seconds.
403
+ */
404
+ export async function verifyTikTokWebhook(input) {
405
+ const body = checkedBytes(input.body, input.maxBytes ?? defaultMaxBytes);
406
+ const tolerance = input.toleranceSeconds ?? 300;
407
+ if (!Number.isSafeInteger(tolerance) || tolerance <= 0)
408
+ throw new SocialError({
409
+ code: "invalid_config",
410
+ operation: "webhooks.verify",
411
+ message: "TikTok webhook tolerance must be a positive whole number of seconds.",
412
+ });
413
+ if (!input.secret)
414
+ denied();
415
+ let timestamp;
416
+ let signatureHex;
417
+ for (const part of (input.headers.get("TikTok-Signature") ?? "").split(",")) {
418
+ const separator = part.indexOf("=");
419
+ if (separator < 0)
420
+ denied();
421
+ const key = part.slice(0, separator).trim();
422
+ const value = part.slice(separator + 1).trim();
423
+ if (key === "t") {
424
+ if (timestamp !== undefined)
425
+ denied();
426
+ timestamp = value;
427
+ }
428
+ else if (key === "s") {
429
+ if (signatureHex !== undefined)
430
+ denied();
431
+ signatureHex = value;
432
+ }
433
+ }
434
+ const signature = signatureHex === undefined ? undefined : hexBytes(signatureHex, 32);
435
+ if (!timestamp || !/^\d{1,12}$/.test(timestamp) || !signature)
436
+ denied();
437
+ const prefix = encoder.encode(`${timestamp}.`);
438
+ const signed = new Uint8Array(prefix.byteLength + body.byteLength);
439
+ signed.set(prefix);
440
+ signed.set(body, prefix.byteLength);
441
+ if (!(await hmacMatches(input.secret, "SHA-256", signature, signed)))
442
+ denied();
443
+ const seconds = Number(timestamp);
444
+ const now = Math.floor((input.now?.() ?? new Date()).getTime() / 1000);
445
+ if (Math.abs(now - seconds) > tolerance)
446
+ denied("Webhook signature is valid but its timestamp is outside the accepted window.");
447
+ return {
448
+ valid: true,
449
+ method: "hmac-sha256",
450
+ bodyAuthenticated: true,
451
+ signedTimestamp: true,
452
+ signedAt: new Date(seconds * 1000).toISOString(),
453
+ };
454
+ }
455
+ /**
456
+ * Verify a LinkedIn webhook POST. `X-LI-Signature` carries only the lowercase hex
457
+ * HMAC-SHA256 of the literal `hmacsha256=` followed by the raw body, keyed with the
458
+ * app's client secret. LinkedIn sends no signed timestamp.
459
+ */
460
+ export async function verifyLinkedInWebhook(input) {
461
+ const body = checkedBytes(input.body, input.maxBytes ?? defaultMaxBytes);
462
+ if (!input.secret)
463
+ denied();
464
+ const signature = hexBytes((input.headers.get("X-LI-Signature") ?? "").trim(), 32);
465
+ if (!signature)
466
+ denied();
467
+ const prefix = encoder.encode("hmacsha256=");
468
+ const signed = new Uint8Array(prefix.byteLength + body.byteLength);
469
+ signed.set(prefix);
470
+ signed.set(body, prefix.byteLength);
471
+ if (!(await hmacMatches(input.secret, "SHA-256", signature, signed)))
472
+ denied();
473
+ return bodyVerified("hmac-sha256");
474
+ }
475
+ /**
476
+ * Answer LinkedIn's GET validation, which LinkedIn repeats every 2 hours. The
477
+ * response is `{ challengeCode, challengeResponse }`, where `challengeResponse` is the
478
+ * lowercase hex HMAC-SHA256 of `challengeCode` keyed with the client secret. For
479
+ * parent-child applications LinkedIn adds `applicationId`; pass `secretForApplication`
480
+ * to pick that application's client secret. An unknown application is refused.
481
+ */
482
+ export async function answerLinkedInWebhookChallenge(input) {
483
+ const challengeCode = input.query.get("challengeCode");
484
+ const applicationId = input.query.get("applicationId");
485
+ if (!challengeCode || !/^[A-Za-z0-9-]{1,128}$/.test(challengeCode))
486
+ challengeRefused("unauthorized");
487
+ if (applicationId !== null && !/^[A-Za-z0-9_-]{1,128}$/.test(applicationId))
488
+ challengeRefused("unauthorized");
489
+ const secret = applicationId !== null && input.secretForApplication
490
+ ? input.secretForApplication(applicationId)
491
+ : input.secret;
492
+ if (!secret)
493
+ challengeRefused("unauthorized");
494
+ const digest = await crypto.subtle.sign("HMAC", await hmacKey(secret, "SHA-256", ["sign"]), encoder.encode(challengeCode));
495
+ const challengeResponse = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
496
+ const response = {
497
+ status: 200,
498
+ headers: { "Content-Type": "application/json" },
499
+ body: JSON.stringify({ challengeCode, challengeResponse }),
500
+ challengeCode,
501
+ challengeResponse,
502
+ };
503
+ return applicationId === null ? response : { ...response, applicationId };
504
+ }
505
+ function malformed(platform) {
506
+ throw new SocialError({
507
+ code: "invalid_input",
508
+ operation: "webhooks.decode",
509
+ message: `Webhook body does not match the documented ${platform} notification shape.`,
510
+ });
511
+ }
512
+ function instagramFieldType(field) {
513
+ return field === "comments" || field === "live_comments" ? "comment.received" : "unknown";
514
+ }
515
+ function decodeInstagram(payload) {
516
+ const entries = array(payload["entry"]);
517
+ // A delivery without a documented container is malformed. A well-formed entry with
518
+ // a field the SDK does not map still decodes, as an `unknown` event.
519
+ if (payload["object"] !== "instagram" || entries.length === 0)
520
+ malformed("instagram");
521
+ const items = [];
522
+ const accountIds = [];
523
+ for (const value of entries) {
524
+ const entry = object(value);
525
+ const id = optionalString(entry["id"]);
526
+ if (id)
527
+ accountIds.push(id);
528
+ if (entry["changes"] !== undefined)
529
+ for (const change of array(entry["changes"])) {
530
+ const field = string(object(change)["field"]);
531
+ items.push({ originalType: field, type: instagramFieldType(field) });
532
+ }
533
+ if (entry["changed_fields"] !== undefined)
534
+ for (const value of array(entry["changed_fields"])) {
535
+ const field = string(value);
536
+ items.push({ originalType: field, type: instagramFieldType(field) });
537
+ }
538
+ if (entry["messaging"] !== undefined)
539
+ for (const item of array(entry["messaging"])) {
540
+ const messaging = object(item);
541
+ const message = messaging["message"];
542
+ if (message !== undefined) {
543
+ const echo = object(message)["is_echo"] === true;
544
+ items.push({
545
+ originalType: echo ? "message_echoes" : "messages",
546
+ type: echo ? "unknown" : "message.received",
547
+ });
548
+ }
549
+ else if (messaging["reaction"] !== undefined)
550
+ items.push({ originalType: "message_reactions", type: "unknown" });
551
+ else if (messaging["read"] !== undefined)
552
+ items.push({ originalType: "messaging_seen", type: "unknown" });
553
+ else if (messaging["postback"] !== undefined)
554
+ items.push({ originalType: "messaging_postbacks", type: "unknown" });
555
+ else
556
+ items.push({ originalType: "messaging", type: "unknown" });
557
+ }
558
+ }
559
+ return { items, accountIds, data: payload };
560
+ }
561
+ function nestedString(value, key) {
562
+ return value === undefined ? undefined : optionalString(object(value)[key]);
563
+ }
564
+ function decodeThreads(payload) {
565
+ const values = object(payload["values"]);
566
+ const field = string(values["field"]);
567
+ const value = object(values["value"]);
568
+ // Threads identifies the owner differently per field. An unknown owner stays empty
569
+ // so acceptWebhook quarantines the event instead of guessing a tenant.
570
+ const owner = field === "replies"
571
+ ? nestedString(value["root_post"], "owner_id")
572
+ : field === "delete"
573
+ ? nestedString(value["owner"], "owner_id")
574
+ : field === "mentions"
575
+ ? optionalString(payload["target_id"])
576
+ : undefined;
577
+ const type = field === "replies" ? "comment.received" : field === "delete" ? "post.removed" : "unknown";
578
+ return {
579
+ items: [{ originalType: field, type }],
580
+ accountIds: owner ? [owner] : [],
581
+ data: payload,
582
+ };
583
+ }
584
+ function xEventType(key) {
585
+ if (key === "direct_message_events")
586
+ return "message.received";
587
+ if (key === "tweet_delete_events")
588
+ return "post.removed";
589
+ return "unknown";
590
+ }
591
+ function decodeX(payload) {
592
+ const items = [];
593
+ const accountIds = [];
594
+ const forUser = optionalString(payload["for_user_id"]);
595
+ if (forUser)
596
+ accountIds.push(forUser);
597
+ for (const [key, value] of Object.entries(payload))
598
+ if (key.endsWith("_events") && isJsonArray(value))
599
+ items.push({ originalType: key, type: xEventType(key) });
600
+ // X Activity API wraps each event in `data` with an `event_type`. It is a documented
601
+ // container, but its event types are not mapped, so it decodes as `unknown`.
602
+ const activity = payload["data"];
603
+ const activityType = isJsonObject(activity) ? optionalString(activity["event_type"]) : undefined;
604
+ if (activityType)
605
+ items.push({ originalType: activityType, type: "unknown" });
606
+ if (payload["user_event"] !== undefined) {
607
+ const userEvent = object(payload["user_event"]);
608
+ if (userEvent["revoke"] === undefined)
609
+ items.push({ originalType: "user_event", type: "unknown" });
610
+ else {
611
+ const userId = nestedString(object(userEvent["revoke"])["source"], "user_id");
612
+ if (userId)
613
+ accountIds.push(userId);
614
+ items.push({ originalType: "user_event.revoke", type: "account.updated" });
615
+ }
616
+ }
617
+ // `{}` or a body with only `for_user_id` carries no documented event container.
618
+ if (items.length === 0)
619
+ malformed("x");
620
+ return { items, accountIds, data: payload };
621
+ }
622
+ function xmlEntity(whole, entity) {
623
+ const name = entity.toLowerCase();
624
+ if (name === "amp")
625
+ return "&";
626
+ if (name === "lt")
627
+ return "<";
628
+ if (name === "gt")
629
+ return ">";
630
+ if (name === "quot")
631
+ return '"';
632
+ if (name === "apos")
633
+ return "'";
634
+ const code = name.startsWith("#x")
635
+ ? Number.parseInt(name.slice(2), 16)
636
+ : Number.parseInt(name.slice(1), 10);
637
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
638
+ }
639
+ function xmlText(value) {
640
+ const cdata = /^<!\[CDATA\[([\s\S]*)\]\]>$/.exec(value.trim());
641
+ if (cdata?.[1] !== undefined)
642
+ return cdata[1];
643
+ return value.trim().replace(/&(#x[0-9a-f]{1,6}|#\d{1,7}|amp|lt|gt|quot|apos);/gi, xmlEntity);
644
+ }
645
+ function xmlElement(block, name) {
646
+ const match = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`).exec(block);
647
+ return match?.[1] === undefined ? undefined : xmlText(match[1]);
648
+ }
649
+ function xmlAttribute(attributes, name) {
650
+ const match = new RegExp(`\\s${name}\\s*=\\s*"([^"]*)"`).exec(` ${attributes}`);
651
+ return match?.[1] === undefined ? undefined : xmlText(match[1]);
652
+ }
653
+ function decodeYouTube(text) {
654
+ // The hub delivers a small Atom document. Declarations are never needed, so they
655
+ // are rejected rather than expanded.
656
+ if (/<!DOCTYPE|<!ENTITY/i.test(text) || !/<feed[\s>]/.test(text))
657
+ malformed("youtube");
658
+ const items = [];
659
+ const accountIds = [];
660
+ const videos = [];
661
+ const deleted = [];
662
+ for (const match of text.matchAll(/<entry[\s>][\s\S]*?<\/entry>/g)) {
663
+ const block = match[0];
664
+ const videoId = xmlElement(block, "yt:videoId");
665
+ const channelId = xmlElement(block, "yt:channelId");
666
+ if (!videoId || !channelId)
667
+ malformed("youtube");
668
+ const title = xmlElement(block, "title");
669
+ const published = xmlElement(block, "published");
670
+ const updated = xmlElement(block, "updated");
671
+ // Absent Atom fields stay absent rather than becoming undefined.
672
+ const video = {
673
+ videoId,
674
+ channelId,
675
+ ...definedFields({ title, published, updated }),
676
+ };
677
+ accountIds.push(channelId);
678
+ videos.push(video);
679
+ items.push({ originalType: "yt:video", type: "unknown" });
680
+ }
681
+ // Atom tombstones (RFC 6721). YouTube's guide does not list deletion as a trigger,
682
+ // so this only decodes a tombstone if the hub sends one.
683
+ for (const match of text.matchAll(/<at:deleted-entry(\s[^>]*?)?(?:\/>|>([\s\S]*?)<\/at:deleted-entry>)/g)) {
684
+ const attributes = match[1] ?? "";
685
+ const ref = xmlAttribute(attributes, "ref");
686
+ const videoId = ref?.startsWith("yt:video:") ? ref.slice("yt:video:".length) : undefined;
687
+ if (!videoId)
688
+ malformed("youtube");
689
+ const when = xmlAttribute(attributes, "when");
690
+ const uri = xmlElement(match[2] ?? "", "uri");
691
+ const channelId = uri === undefined ? undefined : /\/channel\/([\w-]+)$/.exec(uri)?.[1];
692
+ if (channelId)
693
+ accountIds.push(channelId);
694
+ deleted.push({
695
+ videoId,
696
+ ...definedFields({ deletedAt: when || undefined, channelId: channelId || undefined }),
697
+ });
698
+ items.push({ originalType: "at:deleted-entry", type: "post.removed" });
699
+ }
700
+ if (items.length === 0)
701
+ malformed("youtube");
702
+ return { items, accountIds, data: { videos, deleted } };
703
+ }
704
+ function decodeLinkedIn(payload) {
705
+ const kind = string(payload["type"]);
706
+ const items = [];
707
+ const accountIds = [];
708
+ let lastModifiedAt;
709
+ if (kind === "ORGANIZATION_SOCIAL_ACTION_NOTIFICATIONS" && payload["notifications"] !== undefined)
710
+ for (const value of array(payload["notifications"])) {
711
+ const notification = object(value);
712
+ const action = string(notification["action"]);
713
+ const organization = optionalString(notification["organizationalEntity"]);
714
+ const modified = notification["lastModifiedAt"];
715
+ if (organization)
716
+ accountIds.push(organization);
717
+ if (isFiniteNumber(modified) && Number.isSafeInteger(modified) && modified > 0)
718
+ lastModifiedAt = Math.max(lastModifiedAt ?? 0, modified);
719
+ // Only a member comment is clearly an inbound comment. ADMIN_COMMENT is the
720
+ // page's own comment, and edits, deletions, likes, shares, and mentions have no
721
+ // normalized type, so they stay `unknown` with the action as originalType.
722
+ items.push({
723
+ originalType: action,
724
+ type: action === "COMMENT" ? "comment.received" : "unknown",
725
+ });
726
+ }
727
+ if (items.length === 0)
728
+ items.push({ originalType: kind, type: "unknown" });
729
+ return {
730
+ items,
731
+ accountIds,
732
+ data: payload,
733
+ occurredAt: lastModifiedAt === undefined ? undefined : new Date(lastModifiedAt).toISOString(),
734
+ };
735
+ }
736
+ function tiktokEventType(event) {
737
+ if (event === "authorization.removed")
738
+ return "account.updated";
739
+ if (event.startsWith("post.publish.") ||
740
+ event === "video.upload.failed" ||
741
+ event === "video.publish.completed")
742
+ return "publication.updated";
743
+ return "unknown";
744
+ }
745
+ function decodeTikTok(payload) {
746
+ const event = string(payload["event"]);
747
+ const createTime = payload["create_time"];
748
+ const rawContent = payload["content"];
749
+ // TikTok documents `content` as a serialized JSON object. Anything else is malformed;
750
+ // parse and object errors fall through to decodePlatformWebhook's invalid_input.
751
+ const content = rawContent === undefined ? undefined : object(parseJson(string(rawContent)));
752
+ const publishId = content === undefined ? undefined : optionalString(content["publish_id"]);
753
+ const openId = optionalString(payload["user_openid"]);
754
+ return {
755
+ items: [{ originalType: event, type: tiktokEventType(event) }],
756
+ accountIds: openId ? [openId] : [],
757
+ data: content === undefined ? payload : { ...payload, content },
758
+ backendRecordId: event.startsWith("post.publish.") ? publishId : undefined,
759
+ occurredAt: isFiniteNumber(createTime) && Number.isSafeInteger(createTime) && createTime > 0
760
+ ? new Date(createTime * 1000).toISOString()
761
+ : undefined,
762
+ };
763
+ }
764
+ function decodeDelivery(platform, text) {
765
+ if (platform === "youtube")
766
+ return decodeYouTube(text);
767
+ const payload = object(parseJson(text));
768
+ if (platform === "instagram")
769
+ return decodeInstagram(payload);
770
+ if (platform === "threads")
771
+ return decodeThreads(payload);
772
+ if (platform === "x")
773
+ return decodeX(payload);
774
+ if (platform === "linkedin")
775
+ return decodeLinkedIn(payload);
776
+ return decodeTikTok(payload);
777
+ }
778
+ /**
779
+ * Decode a verified direct-platform delivery into one normalized event. A single
780
+ * delivery can batch several notifications (Meta batches up to 1000 updates); the
781
+ * event keeps them all in `data` and reports a specific `type` only when every
782
+ * notification maps to the same type. None of these platforms sends a delivery ID,
783
+ * so identity is always an exact-body digest.
784
+ */
785
+ export async function decodePlatformWebhook(input) {
786
+ const bytes = checkedBytes(input.body, input.maxBytes ?? defaultMaxBytes);
787
+ let delivery;
788
+ try {
789
+ delivery = decodeDelivery(input.platform, new TextDecoder("utf-8", { fatal: true }).decode(bytes));
790
+ }
791
+ catch (error) {
792
+ if (error instanceof SocialError)
793
+ throw error;
794
+ malformed(input.platform);
795
+ }
796
+ const types = [...new Set(delivery.items.map((item) => item.type))];
797
+ const originalTypes = [...new Set(delivery.items.map((item) => item.originalType))];
798
+ const cleaned = cleanObject(delivery.data);
799
+ const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (byte) => byte.toString(16).padStart(2, "0")).join("");
800
+ const event = {
801
+ version: 1,
802
+ id,
803
+ identity: "body-digest",
804
+ backend: input.backend,
805
+ provider: input.platform,
806
+ type: types.length === 1 && types[0] !== undefined ? types[0] : "unknown",
807
+ originalType: originalTypes.length === 0 ? "unknown" : originalTypes.join(","),
808
+ receivedAt: input.receivedAt ?? new Date().toISOString(),
809
+ accountIds: [...new Set(delivery.accountIds)],
810
+ data: cleaned,
811
+ };
812
+ const withRecord = delivery.backendRecordId === undefined
813
+ ? event
814
+ : { ...event, backendRecordId: delivery.backendRecordId };
815
+ return delivery.occurredAt === undefined
816
+ ? withRecord
817
+ : { ...withRecord, occurredAt: delivery.occurredAt };
818
+ }