@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.
- package/dist/cli-request.d.ts +15 -0
- package/dist/cli-request.js +193 -0
- package/dist/cli.d.ts +4 -3
- package/dist/cli.js +19 -21
- package/dist/cloud/common.d.ts +7 -6
- package/dist/cloud/common.js +35 -54
- package/dist/cloud/lifecycle.js +31 -35
- package/dist/cloud/media.d.ts +2 -2
- package/dist/cloud/media.js +13 -3
- package/dist/cloud/outcomes.d.ts +4 -3
- package/dist/cloud/outcomes.js +8 -15
- package/dist/cloud/post-for-me.js +41 -49
- package/dist/cloud/zernio.js +58 -98
- package/dist/core/client.js +79 -99
- package/dist/core/fields.d.ts +14 -0
- package/dist/core/fields.js +14 -0
- package/dist/core/idempotency.d.ts +7 -2
- package/dist/core/idempotency.js +37 -20
- package/dist/core/pagination.js +8 -7
- package/dist/core/types.d.ts +3 -2
- package/dist/platforms/bluesky.d.ts +65 -1
- package/dist/platforms/bluesky.js +675 -276
- package/dist/platforms/instagram.d.ts +2 -0
- package/dist/platforms/instagram.js +130 -105
- package/dist/platforms/linkedin.d.ts +58 -1
- package/dist/platforms/linkedin.js +877 -107
- package/dist/platforms/threads.d.ts +13 -1
- package/dist/platforms/threads.js +204 -302
- package/dist/platforms/tiktok.d.ts +4 -0
- package/dist/platforms/tiktok.js +140 -124
- package/dist/platforms/webhook-adapter.d.ts +9 -0
- package/dist/platforms/webhook-adapter.js +24 -0
- package/dist/platforms/x-engagement.js +7 -12
- package/dist/platforms/x-stream.d.ts +83 -0
- package/dist/platforms/x-stream.js +350 -0
- package/dist/platforms/x.d.ts +87 -0
- package/dist/platforms/x.js +648 -120
- package/dist/platforms/youtube-upload.d.ts +1 -1
- package/dist/platforms/youtube-upload.js +6 -2
- package/dist/platforms/youtube.d.ts +28 -4
- package/dist/platforms/youtube.js +291 -133
- package/dist/server/bluesky-oauth.d.ts +177 -0
- package/dist/server/bluesky-oauth.js +1229 -0
- package/dist/server/connections.d.ts +14 -0
- package/dist/server/connections.js +10 -2
- package/dist/server/egress.d.ts +14 -0
- package/dist/server/egress.js +115 -0
- package/dist/server/oauth-internal.d.ts +6 -0
- package/dist/server/oauth-internal.js +66 -0
- package/dist/server/oauth.d.ts +1 -1
- package/dist/server/oauth.js +46 -99
- package/dist/server/webhooks.d.ts +136 -3
- package/dist/server/webhooks.js +639 -25
- package/dist/testing/index.js +14 -28
- package/dist/transport/http.d.ts +1 -1
- package/dist/transport/http.js +0 -1
- package/dist/transport/json.d.ts +7 -0
- package/dist/transport/json.js +32 -4
- package/dist/transport/upload.d.ts +1 -1
- package/dist/transport/upload.js +46 -38
- package/dist/transport/validation.d.ts +16 -5
- package/dist/transport/validation.js +29 -7
- package/package.json +2 -2
package/dist/server/webhooks.js
CHANGED
|
@@ -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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 (
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|