@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,10 +1,41 @@
1
1
  import { remainingBudget } from "../transport/budget.js";
2
+ import { definedFields } from "../core/fields.js";
2
3
  import { defineAdapter } from "../core/adapter.js";
3
4
  import { SocialError } from "../core/errors.js";
4
5
  import { createHttp, HttpError } from "../transport/http.js";
5
- import { array, object, string, optionalString, optionalNumber } from "../transport/validation.js";
6
+ import { array, isJsonObject, object, string, optionalString, optionalNumber, } from "../transport/validation.js";
6
7
  import { upload } from "../transport/upload.js";
7
- import { publicFields } from "../cloud/common.js";
8
+ import { optionsObject, publicFields } from "../cloud/common.js";
9
+ import { verifyLinkedInWebhook } from "../server/webhooks.js";
10
+ import { directWebhooks } from "./webhook-adapter.js";
11
+ /** Documents API file types (PDF, PPT, PPTX, DOC, DOCX), checked 2026-09-24 against version 202609. */
12
+ const linkedInDocumentMimeTypes = [
13
+ "application/pdf",
14
+ "application/vnd.ms-powerpoint",
15
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
16
+ "application/msword",
17
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
18
+ ];
19
+ /** LinkedIn documents "100MB"; the decimal reading is the conservative local bound. */
20
+ const linkedInDocumentMaxBytes = 100_000_000;
21
+ const linkedInDocumentUrn = /^urn:li:document:[a-zA-Z0-9_-]+$/;
22
+ /*
23
+ * Videos API, accessed 2026-09-24:
24
+ * https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api?view=li-lms-2026-09
25
+ * Feed video is MP4, 75 KB to 500 MB and 3 seconds to 30 minutes. initializeUpload returns
26
+ * contiguous part instructions of up to 4,194,304 bytes; each part's ETag is passed to finalizeUpload.
27
+ * Posts API video content, accessed 2026-09-24:
28
+ * https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api?view=li-lms-2026-09
29
+ */
30
+ const linkedInVideoPartBytes = 4 * 1024 * 1024;
31
+ const linkedInVideoMinBytes = 75_000;
32
+ const linkedInVideoMaxBytes = 500 * 1024 * 1024;
33
+ const linkedInVideoUrn = /^urn:li:video:[a-zA-Z0-9_-]+$/;
34
+ const linkedInImageUrn = /^urn:li:image:[a-zA-Z0-9_-]+$/;
35
+ /* LinkedIn documents no processing time or polling interval; these bounds are the SDK's own. */
36
+ const linkedInVideoRetryDelayMs = 5_000;
37
+ const linkedInVideoWaitDefaults = { intervalMs: 5_000, maxChecks: 12 };
38
+ const linkedInVideoWaitLimits = { minIntervalMs: 1_000, maxIntervalMs: 60_000, maxChecks: 60 };
8
39
  export function linkedin(options) {
9
40
  if (!options.auth.accessToken.trim() ||
10
41
  !/^urn:li:(person|organization):[a-zA-Z0-9_-]+$/.test(options.auth.author) ||
@@ -33,20 +64,21 @@ export function linkedin(options) {
33
64
  return await http({
34
65
  timeoutMs: remainingBudget(context),
35
66
  url: new URL(`https://api.linkedin.com${path}`),
36
- headers: {
37
- Authorization: `Bearer ${options.auth.accessToken}`,
38
- "Content-Type": "application/json",
39
- "Linkedin-Version": options.apiVersion,
40
- "X-Restli-Protocol-Version": "2.0.0",
41
- ...extraHeaders,
42
- },
67
+ headers: path.startsWith("/v2/")
68
+ ? { Authorization: `Bearer ${options.auth.accessToken}` }
69
+ : {
70
+ Authorization: `Bearer ${options.auth.accessToken}`,
71
+ "Content-Type": "application/json",
72
+ "Linkedin-Version": options.apiVersion,
73
+ "X-Restli-Protocol-Version": "2.0.0",
74
+ ...extraHeaders,
75
+ },
43
76
  method,
44
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
45
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
46
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
47
- ...(context.signal ? { signal: context.signal } : {}),
48
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
49
- ...(selectedHeaders ? { responseHeaders: selectedHeaders } : {}),
77
+ ...definedFields({
78
+ body: body === undefined ? undefined : JSON.stringify(body),
79
+ signal: context.signal,
80
+ responseHeaders: selectedHeaders,
81
+ }),
50
82
  maxAttempts: method === "GET" ? Math.min(5, context.retryBudget.maxAttempts) : 1,
51
83
  });
52
84
  }
@@ -105,6 +137,119 @@ export function linkedin(options) {
105
137
  });
106
138
  return result;
107
139
  }
140
+ /*
141
+ * Account identity sources (accessed 2026-09-24):
142
+ * - Member: OpenID Connect userinfo, GET https://api.linkedin.com/v2/userinfo, scopes `openid` and
143
+ * `profile`. `sub` is the member ID used in `urn:li:person:{sub}`.
144
+ * https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2
145
+ * https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/share-on-linkedin
146
+ * - Organization: GET /rest/organizations/{id} ("Retrieve an Administered Organization"), scope
147
+ * `rw_organization_admin`, 403 unless the member has the ADMINISTRATOR role.
148
+ * https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-lookup-api?view=li-lms-2026-09
149
+ * - Administered organizations: GET /rest/organizationAcls?q=roleAssignee, scope
150
+ * `rw_organization_admin` or `r_organization_admin`.
151
+ * https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-access-control-by-role?view=li-lms-2026-09
152
+ */
153
+ async function accountRequest(path, context, scopes) {
154
+ try {
155
+ return await request(path, context);
156
+ }
157
+ catch (error) {
158
+ if (error instanceof SocialError && error.code === "missing_permission")
159
+ throw new SocialError({
160
+ code: "missing_permission",
161
+ operation: "accounts.read",
162
+ message: `LinkedIn denied the account read. The token needs ${scopes}.`,
163
+ upstreamStatus: error.upstreamStatus,
164
+ retryDisposition: { kind: "never" },
165
+ });
166
+ throw error;
167
+ }
168
+ }
169
+ async function readAccount(context) {
170
+ const ref = {
171
+ kind: "connected-account",
172
+ version: 1,
173
+ backend: context.backendInstance,
174
+ platform: "linkedin",
175
+ accountId: options.auth.author,
176
+ };
177
+ if (options.auth.author.startsWith("urn:li:person:")) {
178
+ const user = object(await accountRequest("/v2/userinfo", context, "the openid and profile scopes (Sign In with LinkedIn using OpenID Connect)"));
179
+ const sub = optionalString(user["sub"]);
180
+ if (sub === undefined || `urn:li:person:${sub}` !== options.auth.author)
181
+ throw new SocialError({
182
+ code: "unauthorized",
183
+ operation: "accounts.read",
184
+ message: "The authenticated LinkedIn member differs from the configured author URN.",
185
+ });
186
+ const fullName = optionalString(user["name"]) ??
187
+ [optionalString(user["given_name"]), optionalString(user["family_name"])]
188
+ .filter((part) => part !== undefined && part !== "")
189
+ .join(" ");
190
+ return { ref, displayName: fullName || options.auth.author, status: "connected" };
191
+ }
192
+ const organizationId = options.auth.author.slice("urn:li:organization:".length);
193
+ const organization = object(await accountRequest(`/rest/organizations/${encodeURIComponent(organizationId)}`, context, "rw_organization_admin and an approved ADMINISTRATOR role for the organization"));
194
+ const returnedId = optionalNumber(organization["id"]);
195
+ if (returnedId === undefined || String(returnedId) !== organizationId)
196
+ throw new SocialError({
197
+ code: "unauthorized",
198
+ operation: "accounts.read",
199
+ message: "LinkedIn returned a different organization than the configured author URN.",
200
+ });
201
+ const vanityName = optionalString(organization["vanityName"]);
202
+ return Object.assign({
203
+ ref,
204
+ displayName: optionalString(organization["localizedName"]) || options.auth.author,
205
+ status: "connected",
206
+ }, vanityName === undefined ? {} : { handle: vanityName });
207
+ }
208
+ async function listAdministeredOrganizations(input, context) {
209
+ const start = input.cursor === undefined ? 0 : Number(input.cursor);
210
+ const count = input.limit ?? 25;
211
+ if (input.cursor === "" ||
212
+ !Number.isSafeInteger(start) ||
213
+ start < 0 ||
214
+ !Number.isSafeInteger(count) ||
215
+ count < 1 ||
216
+ count > 100)
217
+ throw new SocialError({
218
+ code: "invalid_input",
219
+ operation: "accounts.read",
220
+ message: "LinkedIn requires a nonnegative offset and page size from 1 to 100.",
221
+ });
222
+ const memberUrn = options.auth.author;
223
+ if (!memberUrn.startsWith("urn:li:person:"))
224
+ throw new SocialError({
225
+ code: "unauthorized",
226
+ operation: "accounts.read",
227
+ message: "LinkedIn administered organization lookup requires a member account authorization.",
228
+ });
229
+ const result = object(await accountRequest(`/rest/organizationAcls?q=roleAssignee&roleAssignee=${encodeURIComponent(memberUrn)}&role=ADMINISTRATOR&state=APPROVED&start=${start}&count=${count}`, context, "rw_organization_admin or r_organization_admin"));
230
+ const rows = array(result["elements"]).map(object);
231
+ const items = [];
232
+ for (const row of rows) {
233
+ // LinkedIn documents both `organization` and `organizationTarget` for this field.
234
+ const organization = optionalString(row["organization"]) ?? optionalString(row["organizationTarget"]);
235
+ if (organization === undefined ||
236
+ !/^urn:li:organization:[0-9]+$/.test(organization) ||
237
+ row["role"] !== "ADMINISTRATOR" ||
238
+ row["state"] !== "APPROVED")
239
+ continue;
240
+ items.push({
241
+ organization: `urn:li:organization:${organization.slice("urn:li:organization:".length)}`,
242
+ role: "ADMINISTRATOR",
243
+ state: "APPROVED",
244
+ });
245
+ }
246
+ const paging = result["paging"] === undefined ? {} : object(result["paging"]);
247
+ const total = optionalNumber(paging["total"]);
248
+ const hasNext = array(paging["links"] ?? []).some((link) => object(link)["rel"] === "next");
249
+ return Object.assign({ items }, rows.length > 0 && (hasNext || (total !== undefined && start + rows.length < total))
250
+ ? { nextCursor: String(start + rows.length) }
251
+ : {});
252
+ }
108
253
  async function uploadImage(media, account, context) {
109
254
  authorize(account, context);
110
255
  const source = media.source;
@@ -131,16 +276,12 @@ export function linkedin(options) {
131
276
  url: string(initialized["uploadUrl"]),
132
277
  source: {
133
278
  mimeType: media.mimeType,
134
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
135
- ...(size === undefined ? {} : { size }),
279
+ ...definedFields({ size }),
136
280
  open: source.kind === "blob" ? () => source.blob.stream() : source.open,
137
281
  },
138
282
  allowHost: (host) => host === "www.linkedin.com",
139
283
  maxBytes: 20 * 1024 * 1024,
140
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
141
- ...(options.fetch ? { fetch: options.fetch } : {}),
142
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
143
- ...(context.signal ? { signal: context.signal } : {}),
284
+ ...definedFields({ fetch: options.fetch, signal: context.signal }),
144
285
  });
145
286
  return {
146
287
  kind: "media",
@@ -151,7 +292,389 @@ export function linkedin(options) {
151
292
  mediaId,
152
293
  };
153
294
  }
154
- /* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-unsafe-dictionary-type, anti-slop/no-known-value-widening, anti-slop/no-conditional-empty-object-spread, anti-slop/no-runtime-typeof, anti-slop/require-readable-spacing -- These helpers normalize documented LinkedIn response facets at the transport boundary. */
295
+ function videoPartError(error, part) {
296
+ if (error.kind === "cancelled")
297
+ return new SocialError({
298
+ code: "cancelled",
299
+ operation: "media.upload",
300
+ message: `LinkedIn video part ${part} upload was cancelled. No post was created.`,
301
+ });
302
+ if (error.kind === "timeout")
303
+ return new SocialError({
304
+ code: "timeout",
305
+ operation: "media.upload",
306
+ message: `LinkedIn video part ${part} upload exceeded its elapsed budget. No post was created.`,
307
+ retryDisposition: { kind: "never" },
308
+ });
309
+ if (error.status === 429)
310
+ return new SocialError({
311
+ code: "rate_limited",
312
+ operation: "media.upload",
313
+ message: `LinkedIn rate limited video part ${part}. No post was created.`,
314
+ upstreamStatus: error.status,
315
+ retryDisposition: error.retryAfterMs === undefined
316
+ ? { kind: "never" }
317
+ : { kind: "after-delay", delayMs: error.retryAfterMs },
318
+ });
319
+ return new SocialError({
320
+ code: "media_error",
321
+ operation: "media.upload",
322
+ message: error.status === 401
323
+ ? `LinkedIn rejected video part ${part} because its upload URL expired. Start a new upload.`
324
+ : `LinkedIn did not accept video part ${part}. Start a new upload; no post was created.`,
325
+ upstreamStatus: error.status,
326
+ retryDisposition: { kind: "never" },
327
+ });
328
+ }
329
+ /** Reads one video's public status fields with a single GET after checking the grant and owner. */
330
+ async function readVideoStatus(ref, context) {
331
+ authorize(ref, context);
332
+ if (!linkedInVideoUrn.test(ref.mediaId))
333
+ throw new SocialError({
334
+ code: "invalid_input",
335
+ operation: "media.read",
336
+ message: "Use the urn:li:video reference returned by media.upload.",
337
+ });
338
+ const video = object(await request(`/rest/videos/${encodeURIComponent(ref.mediaId)}`, context));
339
+ if (video["owner"] !== ref.accountId)
340
+ throw new SocialError({
341
+ code: "unauthorized",
342
+ operation: "media.read",
343
+ message: "LinkedIn video belongs to another author.",
344
+ });
345
+ if (video["id"] !== undefined && video["id"] !== ref.mediaId)
346
+ throw new SocialError({
347
+ code: "media_error",
348
+ operation: "media.read",
349
+ message: "LinkedIn returned the status of a different video.",
350
+ });
351
+ return publicFields(video, ["id", "owner", "status", "processingFailureReason", "duration"]);
352
+ }
353
+ function videoWaitCancelled() {
354
+ return new SocialError({
355
+ code: "cancelled",
356
+ operation: "media.read",
357
+ message: "Waiting for the LinkedIn video was cancelled. No post was created.",
358
+ });
359
+ }
360
+ /** Remaining elapsed budget in milliseconds, or 0 once it is exhausted. */
361
+ function budgetLeft(context) {
362
+ try {
363
+ return remainingBudget(context);
364
+ }
365
+ catch {
366
+ return 0;
367
+ }
368
+ }
369
+ /** Sleeps between explicit waitForVideo reads and rejects as soon as the context aborts. */
370
+ function videoWaitDelay(milliseconds, context) {
371
+ if (context.signal?.aborted)
372
+ return Promise.reject(videoWaitCancelled());
373
+ return new Promise((resolve, reject) => {
374
+ const onAbort = () => {
375
+ clearTimeout(timer);
376
+ reject(videoWaitCancelled());
377
+ };
378
+ const timer = setTimeout(() => {
379
+ context.signal?.removeEventListener("abort", onAbort);
380
+ resolve();
381
+ }, milliseconds);
382
+ context.signal?.addEventListener("abort", onAbort, { once: true });
383
+ });
384
+ }
385
+ /** Initializes, uploads every part and finalizes one video. It never waits for processing. */
386
+ async function uploadVideo(media, account, context) {
387
+ authorize(account, context);
388
+ const source = media.source;
389
+ if (media.mimeType !== "video/mp4" || source.kind !== "blob")
390
+ throw new SocialError({
391
+ code: "invalid_input",
392
+ operation: "media.upload",
393
+ message: "Provide one MP4 video as a Blob with mimeType video/mp4.",
394
+ });
395
+ if (media.thumbnail !== undefined)
396
+ throw new SocialError({
397
+ code: "invalid_input",
398
+ operation: "media.upload",
399
+ message: "LinkedIn video thumbnails are not implemented by this adapter.",
400
+ });
401
+ const blob = source.blob;
402
+ if ((media.byteSize !== undefined && media.byteSize !== blob.size) ||
403
+ blob.size < linkedInVideoMinBytes ||
404
+ blob.size > linkedInVideoMaxBytes)
405
+ throw new SocialError({
406
+ code: "invalid_input",
407
+ operation: "media.upload",
408
+ message: "LinkedIn feed video must be 75 KB to 500 MB, and byteSize must match the Blob size.",
409
+ });
410
+ if (media.durationSeconds !== undefined &&
411
+ (!Number.isFinite(media.durationSeconds) ||
412
+ media.durationSeconds < 3 ||
413
+ media.durationSeconds > 1800))
414
+ throw new SocialError({
415
+ code: "invalid_input",
416
+ operation: "media.upload",
417
+ message: "LinkedIn feed video must be 3 seconds to 30 minutes long.",
418
+ });
419
+ try {
420
+ const initialized = object(object(await request("/rest/videos?action=initializeUpload", context, {
421
+ initializeUploadRequest: {
422
+ owner: account.accountId,
423
+ fileSizeBytes: blob.size,
424
+ uploadCaptions: false,
425
+ uploadThumbnail: false,
426
+ },
427
+ }))["value"]);
428
+ const mediaId = string(initialized["video"]);
429
+ // LinkedIn documents an empty upload token for some sessions, so only its type is checked.
430
+ const uploadToken = optionalString(initialized["uploadToken"]);
431
+ const parts = array(initialized["uploadInstructions"]).map((value) => {
432
+ const part = object(value);
433
+ return {
434
+ url: string(part["uploadUrl"]),
435
+ firstByte: optionalNumber(part["firstByte"]),
436
+ lastByte: optionalNumber(part["lastByte"]),
437
+ };
438
+ });
439
+ let nextByte = 0;
440
+ const plan = [];
441
+ for (const part of parts) {
442
+ const { firstByte, lastByte } = part;
443
+ if (firstByte !== nextByte ||
444
+ lastByte === undefined ||
445
+ !Number.isSafeInteger(lastByte) ||
446
+ lastByte < firstByte ||
447
+ lastByte - firstByte + 1 > linkedInVideoPartBytes)
448
+ break;
449
+ plan.push({ url: part.url, firstByte, lastByte });
450
+ nextByte = lastByte + 1;
451
+ }
452
+ if (!linkedInVideoUrn.test(mediaId) ||
453
+ uploadToken === undefined ||
454
+ plan.length === 0 ||
455
+ plan.length !== parts.length ||
456
+ nextByte !== blob.size)
457
+ throw new SocialError({
458
+ code: "media_error",
459
+ operation: "media.upload",
460
+ message: "LinkedIn returned an invalid video identifier or an upload plan that does not cover the file. No bytes were sent.",
461
+ retryDisposition: { kind: "never" },
462
+ });
463
+ const uploadedPartIds = [];
464
+ for (const [index, part] of plan.entries()) {
465
+ const body = blob.slice(part.firstByte, part.lastByte + 1);
466
+ let result;
467
+ try {
468
+ // fetch sends the Blob slice directly; the declared size lets upload() check every byte.
469
+ result = await upload({
470
+ url: part.url,
471
+ source: {
472
+ mimeType: "application/octet-stream",
473
+ size: body.size,
474
+ body,
475
+ open: () => body.stream(),
476
+ },
477
+ allowHost: (host) => host === "www.linkedin.com",
478
+ maxBytes: linkedInVideoPartBytes,
479
+ timeoutMs: remainingBudget(context),
480
+ ...definedFields({ fetch: options.fetch, signal: context.signal }),
481
+ });
482
+ }
483
+ catch (error) {
484
+ if (error instanceof HttpError)
485
+ throw videoPartError(error, index + 1);
486
+ throw error;
487
+ }
488
+ // finalizeUpload takes the ETag value without the HTTP quoting.
489
+ const etag = result.etag?.trim().replace(/^"(.*)"$/, "$1");
490
+ if (!etag)
491
+ throw new SocialError({
492
+ code: "media_error",
493
+ operation: "media.upload",
494
+ message: `LinkedIn accepted video part ${index + 1} without an ETag. Start a new upload.`,
495
+ retryDisposition: { kind: "never" },
496
+ });
497
+ uploadedPartIds.push(etag);
498
+ }
499
+ await request("/rest/videos?action=finalizeUpload", context, {
500
+ finalizeUploadRequest: { video: mediaId, uploadToken, uploadedPartIds },
501
+ });
502
+ return {
503
+ kind: "media",
504
+ version: 1,
505
+ backend: account.backend,
506
+ platform: "linkedin",
507
+ accountId: account.accountId,
508
+ mediaId,
509
+ };
510
+ }
511
+ catch (error) {
512
+ // Malformed upload responses fail before any post request, so no post exists.
513
+ if (error instanceof HttpError)
514
+ throw new SocialError({
515
+ code: "media_error",
516
+ operation: "media.upload",
517
+ message: "LinkedIn returned a malformed video upload response. No post was created.",
518
+ retryDisposition: { kind: "never" },
519
+ });
520
+ throw error;
521
+ }
522
+ }
523
+ async function uploadDocument(media, account, context) {
524
+ authorize(account, context);
525
+ const source = media.source;
526
+ const size = source.kind === "blob" ? source.blob.size : media.byteSize;
527
+ const mimeType = media.mimeType ?? "";
528
+ if (media.kind !== "document" ||
529
+ !linkedInDocumentMimeTypes.includes(mimeType) ||
530
+ (source.kind !== "blob" && source.kind !== "stream"))
531
+ throw new SocialError({
532
+ code: "invalid_input",
533
+ operation: "media.upload",
534
+ message: "Provide PDF, PPT, PPTX, DOC or DOCX bytes as a Blob or replayable stream.",
535
+ });
536
+ if ((source.kind === "blob" && media.byteSize !== undefined && media.byteSize !== size) ||
537
+ (size !== undefined &&
538
+ (!Number.isSafeInteger(size) || size <= 0 || size > linkedInDocumentMaxBytes)))
539
+ throw new SocialError({
540
+ code: "invalid_input",
541
+ operation: "media.upload",
542
+ message: "LinkedIn documents must be non-empty and at most 100 MB, and byteSize must match the Blob size.",
543
+ });
544
+ const initialized = object(object(await request("/rest/documents?action=initializeUpload", context, {
545
+ initializeUploadRequest: { owner: account.accountId },
546
+ }))["value"]);
547
+ const mediaId = string(initialized["document"]);
548
+ if (!linkedInDocumentUrn.test(mediaId))
549
+ throw new SocialError({
550
+ code: "media_error",
551
+ operation: "media.upload",
552
+ message: "LinkedIn returned an invalid document identifier.",
553
+ });
554
+ let transferred;
555
+ try {
556
+ ({ bytes: transferred } = await upload({
557
+ url: string(initialized["uploadUrl"]),
558
+ source: {
559
+ mimeType,
560
+ ...definedFields({ size }),
561
+ open: source.kind === "blob" ? () => source.blob.stream() : source.open,
562
+ },
563
+ allowHost: (host) => host === "www.linkedin.com",
564
+ maxBytes: linkedInDocumentMaxBytes,
565
+ timeoutMs: remainingBudget(context),
566
+ ...definedFields({ fetch: options.fetch, signal: context.signal }),
567
+ }));
568
+ }
569
+ catch (error) {
570
+ if (!(error instanceof HttpError))
571
+ throw error;
572
+ throw new SocialError({
573
+ code: error.kind === "timeout"
574
+ ? "timeout"
575
+ : error.kind === "cancelled"
576
+ ? "cancelled"
577
+ : error.kind === "invalid-input"
578
+ ? "invalid_input"
579
+ : "media_error",
580
+ operation: "media.upload",
581
+ message: `LinkedIn document upload for ${mediaId} did not complete: ${error.message}`,
582
+ upstreamStatus: error.status,
583
+ retryDisposition: { kind: "never" },
584
+ });
585
+ }
586
+ // A stream without byteSize can end empty; LinkedIn cannot publish an empty document.
587
+ if (transferred === 0)
588
+ throw new SocialError({
589
+ code: "invalid_input",
590
+ operation: "media.upload",
591
+ message: `LinkedIn document upload for ${mediaId} sent no bytes. Provide a non-empty document.`,
592
+ retryDisposition: { kind: "never" },
593
+ });
594
+ return {
595
+ kind: "media",
596
+ version: 1,
597
+ backend: account.backend,
598
+ platform: "linkedin",
599
+ accountId: account.accountId,
600
+ mediaId,
601
+ };
602
+ }
603
+ async function readDocument(ref, context, operation) {
604
+ authorize(ref, context);
605
+ if (!linkedInDocumentUrn.test(ref.mediaId))
606
+ throw new SocialError({
607
+ code: "invalid_input",
608
+ operation,
609
+ message: "Use the urn:li:document reference returned by media.upload.",
610
+ });
611
+ const document = object(await request(`/rest/documents/${encodeURIComponent(ref.mediaId)}`, context));
612
+ if (document["owner"] !== ref.accountId ||
613
+ (document["id"] !== undefined && document["id"] !== ref.mediaId))
614
+ throw new SocialError({
615
+ code: "unauthorized",
616
+ operation,
617
+ message: "LinkedIn document belongs to another author.",
618
+ });
619
+ return document;
620
+ }
621
+ /** The Posts API requires a title for documents; take it from caption, then filename. */
622
+ const documentTitle = (media) => media.caption?.trim() || media.filename?.trim() || "";
623
+ const documentIssues = (target) => {
624
+ const media = target.content.media ?? [];
625
+ if (!media.some((item) => item.kind === "document"))
626
+ return [];
627
+ const issues = [];
628
+ if (media.length !== 1)
629
+ issues.push([
630
+ "linkedin.document_count",
631
+ "A LinkedIn document post carries exactly one document and no other media.",
632
+ ]);
633
+ for (const item of media) {
634
+ if (item.kind !== "document")
635
+ continue;
636
+ if (item.source.kind !== "media-ref")
637
+ issues.push([
638
+ "linkedin.document",
639
+ "Upload the document first with media.upload, then publish its account-bound reference.",
640
+ ]);
641
+ else if (item.source.ref.backend !== target.account.backend ||
642
+ item.source.ref.accountId !== target.account.accountId ||
643
+ item.source.ref.platform !== "linkedin" ||
644
+ !linkedInDocumentUrn.test(item.source.ref.mediaId))
645
+ issues.push([
646
+ "linkedin.media_owner",
647
+ "Document reference belongs to another author/backend or has an invalid URN.",
648
+ ]);
649
+ if (!documentTitle(item))
650
+ issues.push([
651
+ "linkedin.document_title",
652
+ "LinkedIn requires a document title. Set caption or filename on the attachment.",
653
+ ]);
654
+ if (item.altText !== undefined)
655
+ issues.push([
656
+ "linkedin.document_alt_text",
657
+ "LinkedIn documents have no verified alt text mapping. Remove altText.",
658
+ ]);
659
+ }
660
+ return issues;
661
+ };
662
+ async function documentContent(media, context) {
663
+ if (media.source.kind !== "media-ref")
664
+ throw new SocialError({
665
+ code: "invalid_input",
666
+ operation: "posts.publish",
667
+ message: "Upload the document first with media.upload.",
668
+ });
669
+ const document = await readDocument(media.source.ref, context, "posts.publish");
670
+ if (document["status"] !== "AVAILABLE")
671
+ throw new SocialError({
672
+ code: "media_error",
673
+ operation: "posts.publish",
674
+ message: "Document is not AVAILABLE. Check its status with the native documentStatus helper, then publish again with a new idempotency key.",
675
+ });
676
+ return { media: { id: media.source.ref.mediaId, title: documentTitle(media) } };
677
+ }
155
678
  const organizationOnly = (account, context, operation) => {
156
679
  authorize(account, context);
157
680
  if (!account.accountId.startsWith("urn:li:organization:"))
@@ -213,8 +736,8 @@ export function linkedin(options) {
213
736
  const number = optionalNumber(item);
214
737
  if (number !== undefined)
215
738
  output[key] = number;
216
- else if (item !== null && typeof item === "object" && !Array.isArray(item)) {
217
- const nestedObject = object(item);
739
+ else if (isJsonObject(item)) {
740
+ const nestedObject = item;
218
741
  const nested = ["pageViews", "uniquePageViews", "clicks", "count"]
219
742
  .map((name) => optionalNumber(nestedObject[name]))
220
743
  .find((candidate) => candidate !== undefined);
@@ -254,12 +777,10 @@ export function linkedin(options) {
254
777
  output.push({
255
778
  dimension,
256
779
  value: label,
257
- ...(optionalNumber(counts["organicFollowerCount"]) === undefined
258
- ? {}
259
- : { organicFollowerCount: optionalNumber(counts["organicFollowerCount"]) }),
260
- ...(optionalNumber(counts["paidFollowerCount"]) === undefined
261
- ? {}
262
- : { paidFollowerCount: optionalNumber(counts["paidFollowerCount"]) }),
780
+ ...definedFields({
781
+ organicFollowerCount: optionalNumber(counts["organicFollowerCount"]),
782
+ paidFollowerCount: optionalNumber(counts["paidFollowerCount"]),
783
+ }),
263
784
  });
264
785
  }
265
786
  }
@@ -275,26 +796,18 @@ export function linkedin(options) {
275
796
  if ((granularity !== "DAY" && granularity !== "WEEK" && granularity !== "MONTH") ||
276
797
  (start === undefined && end === undefined))
277
798
  return undefined;
278
- return {
279
- granularity,
280
- ...(start === undefined ? {} : { start }),
281
- ...(end === undefined ? {} : { end }),
282
- };
799
+ return { granularity, ...definedFields({ start, end }) };
283
800
  };
284
801
  const parseFollowerStatistics = (result, requestedGranularity) => array(result["elements"]).map((value) => {
285
802
  const row = object(value);
286
803
  const gains = row["followerGains"] === undefined ? {} : object(row["followerGains"]);
287
804
  return {
288
805
  organization: string(row["organizationalEntity"]),
289
- ...(parseInterval(row, requestedGranularity) === undefined
290
- ? {}
291
- : { interval: parseInterval(row, requestedGranularity) }),
292
- ...(optionalNumber(gains["organicFollowerGain"]) === undefined
293
- ? {}
294
- : { organicFollowerGain: optionalNumber(gains["organicFollowerGain"]) }),
295
- ...(optionalNumber(gains["paidFollowerGain"]) === undefined
296
- ? {}
297
- : { paidFollowerGain: optionalNumber(gains["paidFollowerGain"]) }),
806
+ ...definedFields({
807
+ interval: parseInterval(row, requestedGranularity),
808
+ organicFollowerGain: optionalNumber(gains["organicFollowerGain"]),
809
+ paidFollowerGain: optionalNumber(gains["paidFollowerGain"]),
810
+ }),
298
811
  breakdowns: followerBreakdowns(row),
299
812
  };
300
813
  });
@@ -334,9 +847,7 @@ export function linkedin(options) {
334
847
  const total = row["totalPageStatistics"] === undefined ? {} : object(row["totalPageStatistics"]);
335
848
  return {
336
849
  organization: string(row["organization"]),
337
- ...(parseInterval(row, requestedGranularity) === undefined
338
- ? {}
339
- : { interval: parseInterval(row, requestedGranularity) }),
850
+ ...definedFields({ interval: parseInterval(row, requestedGranularity) }),
340
851
  views: numberMap(total["views"]),
341
852
  clicks: numberMap(total["clicks"]),
342
853
  breakdowns: pageBreakdowns(row),
@@ -346,13 +857,10 @@ export function linkedin(options) {
346
857
  const row = object(value);
347
858
  return {
348
859
  organization: string(row["organizationalEntity"]),
349
- ...(parseInterval(row, requestedGranularity) === undefined
350
- ? {}
351
- : { interval: parseInterval(row, requestedGranularity) }),
860
+ ...definedFields({ interval: parseInterval(row, requestedGranularity) }),
352
861
  metrics: numberMap(row["totalShareStatistics"]),
353
862
  };
354
863
  });
355
- /* oxlint-enable anti-slop/no-unknown-parameters, anti-slop/no-unsafe-dictionary-type, anti-slop/no-known-value-widening, anti-slop/no-conditional-empty-object-spread, anti-slop/no-runtime-typeof */
356
864
  return defineAdapter({
357
865
  id: "linkedin",
358
866
  capabilities: {
@@ -365,13 +873,25 @@ export function linkedin(options) {
365
873
  platform: "linkedin",
366
874
  operation: "posts.publish",
367
875
  availability: "available",
368
- formats: ["text", "image"],
876
+ formats: [
877
+ "text",
878
+ "image",
879
+ "carousel",
880
+ "video",
881
+ "document",
882
+ ],
369
883
  requiredScopes: [
370
884
  options.auth.author.startsWith("urn:li:organization:")
371
885
  ? "w_organization_social"
372
886
  : "w_member_social",
373
887
  ],
374
- notes: "Explicit author URN and public visibility. Organization role and app product approval required. Registered images must be AVAILABLE before creating a post.",
888
+ notes: "Explicit author URN and public visibility. Organization role and app product approval required. One registered image, 2 to 20 images, one MP4 video, or one document per post; every asset must be AVAILABLE before creating a post.",
889
+ },
890
+ {
891
+ platform: "linkedin",
892
+ operation: "posts.schedule",
893
+ availability: "unsupported-by-platform",
894
+ notes: "The Posts API accepts only lifecycleState PUBLISHED on creation and has no publish-time field.",
375
895
  },
376
896
  {
377
897
  platform: "linkedin",
@@ -384,22 +904,52 @@ export function linkedin(options) {
384
904
  ],
385
905
  notes: "Member read access is restricted. Publication permission does not grant read permission.",
386
906
  },
907
+ {
908
+ platform: "linkedin",
909
+ operation: "accounts.read",
910
+ availability: "available",
911
+ requiredScopes: options.auth.author.startsWith("urn:li:organization:")
912
+ ? ["rw_organization_admin"]
913
+ : ["openid", "profile"],
914
+ notes: options.auth.author.startsWith("urn:li:organization:")
915
+ ? "Returns the configured organization only. Reads /rest/organizations/{id}, which LinkedIn restricts to members with an approved ADMINISTRATOR role."
916
+ : "Returns the configured member only. Reads the OpenID Connect userinfo endpoint and checks that sub matches the author URN. Requires the Sign In with LinkedIn using OpenID Connect product.",
917
+ },
387
918
  {
388
919
  platform: "linkedin",
389
920
  operation: "posts.multi-image",
390
- availability: "not-implemented-by-adapter",
921
+ availability: "available",
391
922
  formats: ["carousel"],
923
+ requiredScopes: [
924
+ options.auth.author.startsWith("urn:li:organization:")
925
+ ? "w_organization_social"
926
+ : "w_member_social",
927
+ ],
928
+ notes: "Publishes 2 to 20 registered images through posts.publish as organic MultiImage content. Every image must be owned by the author and AVAILABLE. Sponsored multi-image posts are not supported.",
392
929
  },
393
930
  {
394
931
  platform: "linkedin",
395
932
  operation: "posts.video",
396
- availability: "not-implemented-by-adapter",
933
+ availability: "available",
397
934
  formats: ["video"],
935
+ requiredScopes: [
936
+ options.auth.author.startsWith("urn:li:organization:")
937
+ ? "w_organization_social"
938
+ : "w_member_social",
939
+ ],
940
+ notes: "Upload one MP4 Blob (75 KB to 500 MB, 3 seconds to 30 minutes) with media.upload, then publish the video URN once it is AVAILABLE. Check processing with native videoStatus (one read) or the opt-in native waitForVideo (bounded reads within the operation budget). Publishing reads the status once and never waits; a processing video fails with no post created. The attachment caption becomes the optional video title.",
398
941
  },
399
942
  {
400
943
  platform: "linkedin",
401
944
  operation: "posts.document",
402
- availability: "not-implemented-by-adapter",
945
+ availability: "available",
946
+ formats: ["document"],
947
+ requiredScopes: [
948
+ options.auth.author.startsWith("urn:li:organization:")
949
+ ? "w_organization_social"
950
+ : "w_member_social",
951
+ ],
952
+ notes: "One PDF, PPT, PPTX, DOC or DOCX file up to 100 MB and 300 pages, uploaded through the Documents API. LinkedIn enforces the page limit during processing. Requires a title and AVAILABLE status.",
403
953
  },
404
954
  { platform: "linkedin", operation: "polls.create", availability: "available" },
405
955
  { platform: "linkedin", operation: "reactions.write", availability: "available" },
@@ -411,6 +961,17 @@ export function linkedin(options) {
411
961
  operation: "posts.removeFromPlatform",
412
962
  availability: "available",
413
963
  },
964
+ {
965
+ platform: "linkedin",
966
+ operation: "comments.delete",
967
+ availability: "available",
968
+ requiredScopes: [
969
+ options.auth.author.startsWith("urn:li:organization:")
970
+ ? "w_organization_social"
971
+ : "w_member_social",
972
+ ],
973
+ notes: "Native deleteComment needs the post URN and the complete commentUrn. LinkedIn does not document which comments an actor may delete; expect only the configured author's own comments to succeed.",
974
+ },
414
975
  {
415
976
  platform: "linkedin",
416
977
  operation: "analytics.organization.read",
@@ -425,11 +986,39 @@ export function linkedin(options) {
425
986
  operation: "articles.create",
426
987
  availability: "approval-dependent",
427
988
  },
989
+ {
990
+ platform: "linkedin",
991
+ operation: "profile.update",
992
+ availability: "approval-dependent",
993
+ notes: "Member profile writes use the Profile Edit API, which LinkedIn restricts to approved developers. This adapter does not implement it.",
994
+ },
428
995
  {
429
996
  platform: "linkedin",
430
997
  operation: "messages.write",
431
998
  availability: "unsupported-by-platform",
432
999
  },
1000
+ {
1001
+ platform: "linkedin",
1002
+ operation: "webhooks.verify",
1003
+ availability: "approval-dependent",
1004
+ requiredScopes: ["rw_organization_admin"],
1005
+ notes: "LinkedIn enables webhooks only for apps with an approved webhook use case. Organization social action notifications also need the Community Management API and an organization administrator. Verifies X-LI-Signature (HMAC-SHA256 over hmacsha256= plus the raw body) with the app client secret; answer the GET validation with answerLinkedInWebhookChallenge.",
1006
+ },
1007
+ {
1008
+ platform: "linkedin",
1009
+ operation: "notifications.read",
1010
+ availability: options.auth.author.startsWith("urn:li:organization:")
1011
+ ? "available"
1012
+ : "account-ineligible",
1013
+ requiredScopes: ["rw_organization_admin"],
1014
+ notes: "Organization social-action notifications from the last 60 days, pulled with offset paging. Requires the Community Management API product and organization administrator access. LinkedIn has no member notification API.",
1015
+ },
1016
+ {
1017
+ platform: "linkedin",
1018
+ operation: "notifications.seen",
1019
+ availability: "unsupported-by-platform",
1020
+ notes: "LinkedIn does not expose a seen or read state for notifications.",
1021
+ },
433
1022
  {
434
1023
  platform: "linkedin",
435
1024
  operation: "analytics.account.read",
@@ -463,7 +1052,7 @@ export function linkedin(options) {
463
1052
  platform: "linkedin",
464
1053
  operation: "media.upload",
465
1054
  availability: "available",
466
- formats: ["image"],
1055
+ formats: ["image", "video", "document"],
467
1056
  },
468
1057
  ...["comments.read", "comments.write", "analytics.read"].map((operation) => ({
469
1058
  platform: "linkedin",
@@ -476,7 +1065,22 @@ export function linkedin(options) {
476
1065
  })),
477
1066
  ],
478
1067
  },
479
- media: { upload: uploadImage },
1068
+ accounts: {
1069
+ async list(_input, context) {
1070
+ return { items: [await readAccount(context)] };
1071
+ },
1072
+ async get(ref, context) {
1073
+ authorize(ref, context);
1074
+ return readAccount(context);
1075
+ },
1076
+ },
1077
+ media: {
1078
+ upload: (media, account, context) => media.kind === "video"
1079
+ ? uploadVideo(media, account, context)
1080
+ : media.kind === "document"
1081
+ ? uploadDocument(media, account, context)
1082
+ : uploadImage(media, account, context),
1083
+ },
480
1084
  posts: {
481
1085
  prepareTarget(target) {
482
1086
  const issues = [];
@@ -488,39 +1092,54 @@ export function linkedin(options) {
488
1092
  fail("linkedin.text", "LinkedIn commentary exceeds 3,000 characters.");
489
1093
  if (target.schedule || target.replyTo || target.content.link)
490
1094
  fail("linkedin.operation", "Scheduling, reply posts and structured links are not supported by this publishing slice.");
491
- if (target.options !== undefined) {
492
- const settings = object(target.options);
493
- if (Object.keys(settings).some((key) => key !== "visibility") ||
494
- (settings["visibility"] !== undefined && settings["visibility"] !== "public"))
495
- fail("linkedin.options", "This slice supports public visibility only; other native options require explicit implementation.");
496
- }
1095
+ const settings = optionsObject(target);
1096
+ if (Object.keys(settings).some((key) => key !== "visibility") ||
1097
+ (settings["visibility"] !== undefined && settings["visibility"] !== "public"))
1098
+ fail("linkedin.options", "This slice supports public visibility only; other native options require explicit implementation.");
497
1099
  const media = target.content.media ?? [];
498
- if (media.length > 1)
499
- fail("linkedin.media_count", "This slice accepts one registered image per post.");
1100
+ for (const [code, message] of documentIssues(target))
1101
+ fail(code, message);
1102
+ // A MultiImage post takes 2 to 20 images; alt text is at most 4,086 characters.
1103
+ // A video post carries exactly one video and cannot be mixed with images.
1104
+ if (media.length > 20)
1105
+ fail("linkedin.media_count", "LinkedIn accepts one image, one video, or a multi-image post of 2 to 20 images.");
1106
+ if (media.length > 1 && media.some((item) => item.kind === "video"))
1107
+ fail("linkedin.media_count", "A LinkedIn video post carries exactly one video and cannot be combined with other media.");
500
1108
  for (const item of media) {
501
- if (item.kind !== "image" || item.source.kind !== "media-ref")
502
- fail("linkedin.media", "Upload an image first with media.upload, then publish its account-bound reference.");
1109
+ if (media.length > 1 && (item.altText?.length ?? 0) > 4086)
1110
+ fail("linkedin.alt_text", "LinkedIn multi-image alt text exceeds 4,086 characters.");
1111
+ if (item.kind === "document")
1112
+ continue;
1113
+ if ((item.kind !== "image" && item.kind !== "video") || item.source.kind !== "media-ref")
1114
+ fail("linkedin.media", "Upload an image or video first with media.upload, then publish its account-bound reference.");
503
1115
  else if (item.source.ref.backend !== target.account.backend ||
504
1116
  item.source.ref.accountId !== target.account.accountId ||
505
1117
  item.source.ref.platform !== "linkedin" ||
506
- !/^urn:li:image:[a-zA-Z0-9_-]+$/.test(item.source.ref.mediaId))
507
- fail("linkedin.media_owner", "Image reference belongs to another author/backend or has an invalid URN.");
1118
+ !(item.kind === "video" ? linkedInVideoUrn : linkedInImageUrn).test(item.source.ref.mediaId))
1119
+ fail("linkedin.media_owner", "Media reference belongs to another author/backend or has an invalid URN for its kind.");
1120
+ else if (item.kind === "video" && item.altText !== undefined)
1121
+ fail("linkedin.video_alt_text", "LinkedIn video posts do not accept alt text.");
508
1122
  }
509
1123
  return issues;
510
1124
  },
511
1125
  async publishTarget(target, context) {
512
1126
  authorize(target.account, context);
513
- const media = target.content.media?.[0];
514
- let content;
515
- if (media?.source.kind === "media-ref") {
516
- authorize(media.source.ref, context);
1127
+ const media = target.content.media ?? [];
1128
+ const entries = [];
1129
+ for (const item of media) {
1130
+ // Documents are read and validated by documentContent below.
1131
+ if (item.kind === "document" || item.source.kind !== "media-ref")
1132
+ continue;
1133
+ authorize(item.source.ref, context);
1134
+ const video = item.kind === "video";
517
1135
  let image;
518
1136
  try {
519
- // SAFETY: object() validates the upstream response as a JSON object.
520
- image = object(await request(`/rest/images/${encodeURIComponent(media.source.ref.mediaId)}`, context));
1137
+ image = object(await request(`/rest/${video ? "videos" : "images"}/${encodeURIComponent(item.source.ref.mediaId)}`, context));
521
1138
  }
522
1139
  catch (error) {
523
- if (!(error instanceof SocialError) ||
1140
+ // Only image reads keep the member-token 403 exception. A video must prove AVAILABLE.
1141
+ if (video ||
1142
+ !(error instanceof SocialError) ||
524
1143
  error.code !== "missing_permission" ||
525
1144
  !options.auth.author.startsWith("urn:li:person:"))
526
1145
  throw error;
@@ -529,22 +1148,61 @@ export function linkedin(options) {
529
1148
  throw new SocialError({
530
1149
  code: "unauthorized",
531
1150
  operation: "posts.publish",
532
- message: "LinkedIn image belongs to a different author.",
1151
+ message: `LinkedIn ${video ? "video" : "image"} belongs to a different author.`,
533
1152
  });
534
- if (image?.["status"] !== undefined && image["status"] !== "AVAILABLE")
1153
+ if (image?.["id"] !== undefined && image["id"] !== item.source.ref.mediaId)
535
1154
  throw new SocialError({
536
1155
  code: "media_error",
537
1156
  operation: "posts.publish",
538
- message: "Image is not AVAILABLE. Check its status explicitly before publishing.",
1157
+ message: `LinkedIn returned a different ${video ? "video" : "image"} than the one attached. No post was created.`,
539
1158
  });
540
- content = {
541
- media: {
542
- id: media.source.ref.mediaId,
543
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
544
- ...(media.altText ? { altText: media.altText } : {}),
545
- },
546
- };
1159
+ if (video && image?.["status"] === "PROCESSING_FAILED")
1160
+ throw new SocialError({
1161
+ code: "media_error",
1162
+ operation: "posts.publish",
1163
+ message: "LinkedIn could not process this video. Upload it again; no post was created.",
1164
+ retryDisposition: { kind: "never" },
1165
+ });
1166
+ if (video &&
1167
+ (image?.["status"] === "PROCESSING" || image?.["status"] === "WAITING_UPLOAD"))
1168
+ // No post exists yet, so publishing again later is safe. The SDK does not retry.
1169
+ throw new SocialError({
1170
+ code: "media_error",
1171
+ operation: "posts.publish",
1172
+ message: "Video is still processing; no post was created. Wait with native waitForVideo or check videoStatus, then publish again with a new idempotency key.",
1173
+ retryDisposition: { kind: "after-delay", delayMs: linkedInVideoRetryDelayMs },
1174
+ });
1175
+ if (video
1176
+ ? image?.["status"] !== "AVAILABLE"
1177
+ : image?.["status"] !== undefined && image["status"] !== "AVAILABLE")
1178
+ throw new SocialError({
1179
+ code: "media_error",
1180
+ operation: "posts.publish",
1181
+ message: video
1182
+ ? "Video is not AVAILABLE. Check it with native videoStatus before publishing."
1183
+ : "Image is not AVAILABLE. Check its status explicitly before publishing.",
1184
+ });
1185
+ // Posts API media.title is optional for video and required only for documents.
1186
+ const title = video ? item.caption?.trim() : undefined;
1187
+ entries.push({
1188
+ id: item.source.ref.mediaId,
1189
+ ...definedFields({
1190
+ title: title || undefined,
1191
+ altText: video ? undefined : item.altText || undefined,
1192
+ }),
1193
+ });
547
1194
  }
1195
+ // MultiImage content takes 2 to 20 image URNs. Source (accessed 2026-09-24):
1196
+ // https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/multiimage-post-api?view=li-lms-2026-09
1197
+ // A single image or video uses content.media; a document post carries only its document.
1198
+ const document = media.find((item) => item.kind === "document");
1199
+ let content;
1200
+ if (document)
1201
+ content = await documentContent(document, context);
1202
+ else if (entries.length > 1)
1203
+ content = { multiImage: { images: entries } };
1204
+ else if (entries[0] !== undefined)
1205
+ content = { media: entries[0] };
548
1206
  const result = object(await request("/rest/posts", context, {
549
1207
  author: target.account.accountId,
550
1208
  commentary: escapeCommentary(target.content.text ?? ""),
@@ -556,8 +1214,7 @@ export function linkedin(options) {
556
1214
  },
557
1215
  lifecycleState: "PUBLISHED",
558
1216
  isReshareDisabledByAuthor: false,
559
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
560
- ...(content ? { content } : {}),
1217
+ ...definedFields({ content }),
561
1218
  }, ["x-restli-id"]));
562
1219
  const id = optionalString(object(result["headers"])["x-restli-id"]);
563
1220
  const base = {
@@ -631,13 +1288,10 @@ export function linkedin(options) {
631
1288
  const paging = result["paging"] === undefined ? {} : object(result["paging"]);
632
1289
  const total = optionalNumber(paging["total"]);
633
1290
  const hasNext = array(paging["links"] ?? []).some((link) => object(link)["rel"] === "next");
634
- return {
635
- items,
636
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
637
- ...(items.length > 0 && (hasNext || (total !== undefined && start + items.length < total))
638
- ? { nextCursor: String(start + items.length) }
639
- : {}),
640
- };
1291
+ const nextCursor = items.length > 0 && (hasNext || (total !== undefined && start + items.length < total))
1292
+ ? String(start + items.length)
1293
+ : undefined;
1294
+ return { items, ...definedFields({ nextCursor }) };
641
1295
  },
642
1296
  async removeFromPlatform(ref, context) {
643
1297
  authorize(ref, context);
@@ -672,8 +1326,7 @@ export function linkedin(options) {
672
1326
  const next = total !== undefined && start + items.length < total
673
1327
  ? String(start + items.length)
674
1328
  : undefined;
675
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
676
- return { items, ...(next === undefined ? {} : { nextCursor: next }) };
1329
+ return { items, ...definedFields({ nextCursor: next }) };
677
1330
  },
678
1331
  async reply(ref, content, context) {
679
1332
  if (!content.text.trim() || content.text.length > 1250)
@@ -723,6 +1376,61 @@ export function linkedin(options) {
723
1376
  return { ...ref, commentId };
724
1377
  },
725
1378
  },
1379
+ notifications: {
1380
+ async list(account, input, context) {
1381
+ authorize(account, context);
1382
+ if (!account.accountId.startsWith("urn:li:organization:"))
1383
+ throw new SocialError({
1384
+ code: "unsupported_capability",
1385
+ operation: "notifications.read",
1386
+ message: "LinkedIn notifications are available for organization accounts only, not member accounts.",
1387
+ });
1388
+ const start = input.cursor === undefined ? 0 : Number(input.cursor);
1389
+ const count = input.limit ?? 25;
1390
+ if (!Number.isSafeInteger(start) ||
1391
+ start < 0 ||
1392
+ input.cursor === "" ||
1393
+ !Number.isSafeInteger(count) ||
1394
+ count < 1 ||
1395
+ count > 100)
1396
+ throw new SocialError({
1397
+ code: "invalid_input",
1398
+ operation: "notifications.read",
1399
+ message: "LinkedIn requires a nonnegative offset and page size from 1 to 100.",
1400
+ });
1401
+ const actions = "LIKE,COMMENT,SHARE,SHARE_MENTION,ADMIN_COMMENT,COMMENT_EDIT,COMMENT_DELETE";
1402
+ const result = object(await request(`/rest/organizationalEntityNotifications?q=criteria&actions=List(${actions})&organizationalEntity=${encodeURIComponent(account.accountId)}&start=${start}&count=${count}`, context));
1403
+ const rows = array(result["elements"]).map(object);
1404
+ if (rows.some((row) => row["organizationalEntity"] !== account.accountId))
1405
+ throw new SocialError({
1406
+ code: "unauthorized",
1407
+ operation: "notifications.read",
1408
+ message: "LinkedIn returned a notification for another organization.",
1409
+ });
1410
+ const items = rows.map((row) => publicFields(row, [
1411
+ "notificationId",
1412
+ "organizationalEntity",
1413
+ "action",
1414
+ "sourcePost",
1415
+ "generatedActivity",
1416
+ "lastModifiedAt",
1417
+ ]));
1418
+ const paging = result["paging"] === undefined ? {} : object(result["paging"]);
1419
+ const total = optionalNumber(paging["total"]);
1420
+ const hasNext = array(paging["links"] ?? []).some((link) => object(link)["rel"] === "next");
1421
+ return Object.assign({ items }, items.length > 0 && (hasNext || (total !== undefined && start + items.length < total))
1422
+ ? { nextCursor: String(start + items.length) }
1423
+ : {});
1424
+ },
1425
+ async markSeen(account, _input, context) {
1426
+ authorize(account, context);
1427
+ throw new SocialError({
1428
+ code: "unsupported_capability",
1429
+ operation: "notifications.seen",
1430
+ message: "LinkedIn does not expose a seen state for organization notifications.",
1431
+ });
1432
+ },
1433
+ },
726
1434
  analytics: {
727
1435
  async getAccountMetrics(account, context) {
728
1436
  authorize(account, context);
@@ -779,7 +1487,18 @@ export function linkedin(options) {
779
1487
  return metrics;
780
1488
  },
781
1489
  },
1490
+ webhooks: directWebhooks("linkedin", (input) => verifyLinkedInWebhook({ ...input, secret: options.webhookSecret ?? "" }), now),
782
1491
  native: {
1492
+ async listAdministeredOrganizations({ account, cursor, limit, context }) {
1493
+ authorize(account, context);
1494
+ if (!account.accountId.startsWith("urn:li:person:"))
1495
+ throw new SocialError({
1496
+ code: "unauthorized",
1497
+ operation: "accounts.read",
1498
+ message: "LinkedIn administered organization lookup requires a member account reference.",
1499
+ });
1500
+ return listAdministeredOrganizations(Object.assign({}, cursor === undefined ? {} : { cursor }, limit === undefined ? {} : { limit }), context);
1501
+ },
783
1502
  async imageStatus(ref, context) {
784
1503
  authorize(ref, context);
785
1504
  const image = object(await request(`/rest/images/${encodeURIComponent(ref.mediaId)}`, context));
@@ -791,12 +1510,51 @@ export function linkedin(options) {
791
1510
  });
792
1511
  return publicFields(image, ["id", "owner", "status"]);
793
1512
  },
1513
+ videoStatus: readVideoStatus,
1514
+ async waitForVideo(ref, context, waitOptions = {}) {
1515
+ const intervalMs = waitOptions.intervalMs ?? linkedInVideoWaitDefaults.intervalMs;
1516
+ const maxChecks = waitOptions.maxChecks ?? linkedInVideoWaitDefaults.maxChecks;
1517
+ if (!Number.isInteger(intervalMs) ||
1518
+ intervalMs < linkedInVideoWaitLimits.minIntervalMs ||
1519
+ intervalMs > linkedInVideoWaitLimits.maxIntervalMs ||
1520
+ !Number.isInteger(maxChecks) ||
1521
+ maxChecks < 1 ||
1522
+ maxChecks > linkedInVideoWaitLimits.maxChecks)
1523
+ throw new SocialError({
1524
+ code: "invalid_input",
1525
+ operation: "media.read",
1526
+ message: "waitForVideo takes an integer intervalMs from 1,000 to 60,000 and maxChecks from 1 to 60.",
1527
+ });
1528
+ let status = await readVideoStatus(ref, context);
1529
+ for (let check = 1; check < maxChecks; check++) {
1530
+ if (status["status"] !== "PROCESSING" && status["status"] !== "WAITING_UPLOAD")
1531
+ break;
1532
+ // Leave room for the next read; return the pending status instead of timing out.
1533
+ if (context.signal?.aborted)
1534
+ throw videoWaitCancelled();
1535
+ if (intervalMs >= budgetLeft(context))
1536
+ break;
1537
+ await videoWaitDelay(intervalMs, context);
1538
+ // A late timer can wake after the deadline; keep the last status instead of timing out.
1539
+ if (budgetLeft(context) <= 0)
1540
+ break;
1541
+ status = await readVideoStatus(ref, context);
1542
+ }
1543
+ return status;
1544
+ },
1545
+ async documentStatus(ref, context) {
1546
+ return publicFields(await readDocument(ref, context, "media.read"), [
1547
+ "id",
1548
+ "owner",
1549
+ "status",
1550
+ ]);
1551
+ },
794
1552
  async registerVideo({ account, context }) {
795
1553
  authorize(account, context);
796
1554
  throw new SocialError({
797
1555
  code: "unsupported_capability",
798
1556
  operation: "posts.video",
799
- message: "LinkedIn video publishing is not implemented by this adapter.",
1557
+ message: "registerVideo is deprecated. Upload the video with media.upload and check it with videoStatus.",
800
1558
  });
801
1559
  },
802
1560
  async createPoll({ account, text, options: pollOptions, duration = "THREE_DAYS", context }) {
@@ -820,8 +1578,7 @@ export function linkedin(options) {
820
1578
  },
821
1579
  }, ["x-restli-id"]));
822
1580
  const id = optionalString(object(result["headers"])["x-restli-id"]);
823
- // SAFETY: result is validated as a JSON object and id is a JSON string.
824
- return (id === undefined ? result : { ...result, id });
1581
+ return id === undefined ? result : { ...result, id };
825
1582
  },
826
1583
  async react({ account, postId, reaction, context }) {
827
1584
  authorize(account, context);
@@ -845,18 +1602,32 @@ export function linkedin(options) {
845
1602
  reshareContext: { parent: postId },
846
1603
  }, ["x-restli-id"]));
847
1604
  const id = optionalString(object(result["headers"])["x-restli-id"]);
848
- // SAFETY: result is validated as a JSON object and id is a JSON string.
849
- return (id === undefined ? result : { ...result, id });
1605
+ return id === undefined ? result : { ...result, id };
850
1606
  },
851
1607
  async updatePost({ account, postId, body, context }) {
852
1608
  authorize(account, context);
853
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
854
- return (await request(`/rest/posts/${encodeURIComponent(postId)}`, context, { patch: { $set: body } }, ["x-restli-id"], "POST", { "X-RestLi-Method": "PARTIAL_UPDATE" }));
1609
+ return object(await request(`/rest/posts/${encodeURIComponent(postId)}`, context, { patch: { $set: body } }, ["x-restli-id"], "POST", { "X-RestLi-Method": "PARTIAL_UPDATE" }));
855
1610
  },
856
1611
  async deletePost({ account, postId, context }) {
857
1612
  authorize(account, context);
858
1613
  await request(`/rest/posts/${encodeURIComponent(postId)}`, context, undefined, undefined, "DELETE");
859
1614
  },
1615
+ async deleteComment({ account, postId, commentId, context }) {
1616
+ // Source: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/comments-api#delete-a-comment
1617
+ // (li-lms-2026-09, page updated 2026-04-28, accessed 2026-09-24).
1618
+ authorize(account, context);
1619
+ const match = /^urn:li:comment:\(urn:li:(?:activity|share|ugcPost):\d+,(\d+)\)$/.exec(commentId);
1620
+ if (!/^urn:li:(share|ugcPost):\d+$/.test(postId) || !match)
1621
+ throw new SocialError({
1622
+ code: "invalid_input",
1623
+ operation: "comments.delete",
1624
+ message: "Provide the share or ugcPost URN and the complete commentUrn returned by comment reads.",
1625
+ });
1626
+ const actor = options.auth.author.startsWith("urn:li:organization:")
1627
+ ? `?actor=${encodeURIComponent(options.auth.author)}`
1628
+ : "";
1629
+ await request(`/rest/socialActions/${encodeURIComponent(postId)}/comments/${match[1]}${actor}`, context, undefined, undefined, "DELETE");
1630
+ },
860
1631
  async organizationAnalytics({ account, context }) {
861
1632
  authorize(account, context);
862
1633
  if (!account.accountId.startsWith("urn:li:organization:"))
@@ -865,8 +1636,7 @@ export function linkedin(options) {
865
1636
  operation: "analytics.organization.read",
866
1637
  message: "Organization analytics requires an organization author.",
867
1638
  });
868
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
869
- return (await request(`/rest/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent(account.accountId)}`, context));
1639
+ return object(await request(`/rest/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent(account.accountId)}`, context));
870
1640
  },
871
1641
  async getOrganizationFollowerStatistics({ account, interval, context }) {
872
1642
  organizationOnly(account, context, "analytics.followers.read");