Skip to content

feat: LinkedIn extended operations — create post (#23)#72

Open
jackulau wants to merge 3 commits into
wespreadjam:mainfrom
jackulau:23
Open

feat: LinkedIn extended operations — create post (#23)#72
jackulau wants to merge 3 commits into
wespreadjam:mainfrom
jackulau:23

Conversation

@jackulau

@jackulau jackulau commented Apr 10, 2026

Copy link
Copy Markdown

Closes #23

Summary

Extends the existing LinkedIn integration (currently ForumScout-based monitoring only) with full posting capabilities. Adds an OAuth2 credential and a linkedinCreatePostNode that publishes to LinkedIn's /v2/ugcPosts endpoint, supporting text posts, article shares, and image posts — authored as either a person or an organization.

What this changes

New

  • packages/nodes/src/integrations/social/linkedin-create-post.ts — the node itself (schemas, types, executor) plus module-private helpers for auth, /v2/me lookup, registerUpload, and binary upload. Everything related to the linkedin_create_post node lives in one file, matching 9 of 10 integrations in this repo.
  • packages/nodes/src/integrations/social/linkedin-create-post.test.ts — 40 vitest tests exercising every branch of the executor with mocked fetch.

Modified

  • packages/nodes/src/integrations/social/credentials.ts — added linkedinCredential alongside the existing twitterCredential, following the same defineOAuth2Credential pattern.
  • packages/core/src/types/node.ts — added a linkedin? field to the NodeCredentials interface so nodes can type-safely access context.credentials.linkedin.accessToken.
  • packages/nodes/src/index.ts, packages/nodes/src/integrations/index.ts, packages/nodes/src/integrations/social/index.ts — barrel exports for the new node, credential, schemas, and types. linkedinCreatePostNode is added to the builtInNodes array so it's picked up by the registry alongside every other built-in node.

Credential — matches issue #23 verbatim

export const linkedinCredential = defineOAuth2Credential({
  name: 'linkedin',
  displayName: 'LinkedIn OAuth2',
  documentationUrl: 'https://learn.microsoft.com/en-us/linkedin/',
  config: {
    authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization',
    tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken',
    scopes: [
      'r_liteprofile',
      'r_emailaddress',
      'w_member_social',
      'r_organization_social',
      'w_organization_social',
    ],
  },
  schema: z.object({
    clientId: z.string(),
    clientSecret: z.string(),
    accessToken: z.string(),
    refreshToken: z.string().optional(),
    expiresAt: z.number(),
  }),
});

Input schema — matches issue #23 verbatim

export const LinkedInCreatePostInputSchema = z.object({
  text: z.string(),
  visibility: z.enum(['PUBLIC', 'CONNECTIONS']),
  postAs: z.enum(['person', 'organization']),
  organizationId: z.string().optional(),
  mediaCategory: z.enum(['NONE', 'IMAGE', 'ARTICLE']).optional(),
  imageUrl: z.string().optional(),
  articleUrl: z.string().optional(),
  articleTitle: z.string().optional(),
  articleDescription: z.string().optional(),
});

Media category operations

The linkedinCreatePost node handles all three mediaCategory values defined by the issue, each exercising a different LinkedIn API flow:

1. NONE — plain text post

Single POST /v2/ugcPosts call with shareMediaCategory: 'NONE' and no media array. The simplest path.

2. ARTICLE — link share with optional title/description

Single POST /v2/ugcPosts call with shareMediaCategory: 'ARTICLE' and a media[] entry containing originalUrl plus optional title.text and description.text. Validates that articleUrl is present before making the API call.

3. IMAGE — image post with full upload orchestration

The most complex flow. Accepts either:

  • A pre-registered asset URN (e.g., urn:li:digitalmediaAsset:abc123) — used directly, skipping the upload sequence. URN detection is case-insensitive and whitespace-tolerant.
  • A plain URL — the node downloads the image, calls POST /v2/assets?action=registerUpload to obtain an upload endpoint, PUTs the binary to that endpoint, then uses the returned asset URN in the ugcPosts request.

Author URN resolution

  • postAs: 'person' → the node calls /v2/me, reads id, and builds urn:li:person:{id} server-side (caller doesn't need to know their own LinkedIn ID).
  • postAs: 'organization' → builds urn:li:organization:{organizationId} from the input field.
  • organizationId is silently ignored when postAs === 'person', matching the semantics of the issue's type signature.

Post ID extraction

LinkedIn's /v2/ugcPosts returns the created post's URN in the X-RestLi-Id response header, not the body (a well-known LinkedIn quirk). The node reads the header, errors cleanly if it's missing or empty, and returns it as output.postId.

Error handling

Every failure mode returns a structured { success: false, error } — the node never throws from its executor. Explicitly handled:

  • Missing accessToken in credentials
  • Missing organizationId when postAs: 'organization'
  • Missing articleUrl when mediaCategory: 'ARTICLE'
  • Missing imageUrl when mediaCategory: 'IMAGE'
  • Failed image download (HTTP error)
  • Image download network failure (fetch rejection)
  • registerUpload returns non-OK
  • registerUpload returns malformed response (missing asset URN or upload URL) — throws a descriptive error, not a cryptic TypeError
  • Binary PUT returns non-OK
  • Binary PUT network failure
  • /v2/ugcPosts returns non-OK
  • /v2/ugcPosts returns 201 with missing or empty X-RestLi-Id header
  • /v2/me returns non-OK or missing id (for person posts)
  • Network failures on any individual endpoint

Project conventions followed

  • One file per integration operationlinkedin-create-post.ts lives alongside linkedin-monitor.ts, twitter-create-tweet.ts, etc.
  • Shared credentials.tslinkedinCredential added next to twitterCredential
  • Uses fetchWithRetry from ../../utils/http.js (same as every other integration)
  • Cross-cutting concerns stay in the engine — no retry/rate-limit/timeout/cache logic in the node, per the project's CLAUDE.md rule on keeping nodes pure
  • Executor returns { success, output | error } — matches NodeExecutionResult contract, never throws
  • Test file uses vitest + vi.stubGlobal('fetch', mockFetch) — same pattern as twitter-extended.test.ts
  • No separate *-client.ts helper file — 9 of 10 existing integrations inline their HTTP logic in operation files; Twitter is the sole exception because OAuth 1.0a signing is genuinely complex. LinkedIn's bearer-token auth doesn't warrant extraction.

Acceptance Criteria

  • Credential definition with Zod schemalinkedinCredential defined via defineOAuth2Credential with all 5 scopes from the issue; z.object schema matches issue [Integration] LinkedIn - Extended operations (posting) #23 exactly (clientId, clientSecret, accessToken required; refreshToken optional; expiresAt required). Tested in linkedin-create-post.test.tsdescribe('linkedin credential').
  • All 3 operations implemented — The linkedinCreatePost node handles all three mediaCategory variants the issue specifies: NONE (text-only), ARTICLE (link share with optional title/description), and IMAGE (with full register/upload/attach flow for URLs and short-circuit for pre-registered URNs). Each variant has both happy-path and failure-mode test coverage.
  • Zod schemas for inputs/outputsLinkedInCreatePostInputSchema exactly matches the issue's declared input shape; LinkedInCreatePostOutputSchema defines postId and author outputs. Both exported from the node file alongside the inferred TypeScript types.
  • Unit tests — 40 vitest tests in linkedin-create-post.test.ts, all mocking fetch via vi.stubGlobal. Coverage includes: credential metadata and scopes, schema validation (required/optional/enum values, expiresAt required), NONE path (person + organization), ARTICLE path (full fields + minimal fields + missing articleUrl), IMAGE path (URN shortcut + case-insensitive detection + whitespace trimming + full URL upload flow for both person and organization), every IMAGE failure mode, X-RestLi-Id header handling (missing + empty), LinkedIn API 422 responses, per-endpoint network failures.
  • Error handling — Every failure path returns a structured { success: false, error: '...' } result; the executor is wrapped in a try/catch so no thrown error escapes. Error messages are specific enough to be actionable ('context.credentials.linkedin.accessToken' literal for missing credentials, 'articleUrl is required when mediaCategory is "ARTICLE"' for missing required fields, 'LinkedIn registerUpload response missing value.asset (asset URN)' for malformed API responses, etc.). See the 'IMAGE failure modes' and 'LinkedIn response handling' describe blocks in the test file for the full matrix.

Test plan

  • npm --workspace=@jam-nodes/nodes test235/235 passing (40 new LinkedIn tests + 195 pre-existing; zero regressions)
  • npm --workspace=@jam-nodes/nodes test -- src/integrations/social/linkedin-create-post.test.ts40/40 passing in isolation
  • No new typecheck errors introduced in LinkedIn files (the repo has pre-existing unrelated tsc --noEmit errors in airtable/apify/slack/etc. that this PR does not touch)
  • Node registered in the builtInNodes array, so it's discoverable alongside all other built-in nodes without additional wiring
  • Manual verification against live LinkedIn API still needed before merge. Every test uses mocked fetch, so the actual HTTP wire format, /v2/me response shape under the requested scope set, and the media upload PUT behavior against a real LinkedIn-supplied upload URL have not been exercised against production LinkedIn. Worth a smoke test with a real OAuth token before shipping.

Extends existing LinkedIn integration (currently monitor-only) with
posting capabilities per issue wespreadjam#23. Adds an OAuth2 credential and
linkedinCreatePostNode supporting text, article, and image posts as
either a person or organization.

- linkedinCredential: OAuth2 with scopes r_liteprofile, r_emailaddress,
  w_member_social, r_organization_social, w_organization_social
- linkedin-client.ts: primitives for /v2/me, registerUpload, binary PUT,
  and a linkedInRequest wrapper that exposes response headers (needed
  for X-RestLi-Id parsing)
- linkedinCreatePostNode: posts to /v2/ugcPosts with NONE, ARTICLE, or
  IMAGE media categories. IMAGE accepts either a pre-registered
  urn:li:digitalmediaAsset URN (case-insensitive, whitespace-tolerant)
  or a plain URL, in which case the node downloads, registers, and
  uploads the binary before posting. Post ID is read from the
  X-RestLi-Id response header.
- 38 new vitest tests covering NONE/ARTICLE/IMAGE happy paths, schema
  validation (empty / 3000-char boundary), credential errors, every
  IMAGE-flow failure mode, missing/empty X-RestLi-Id header, and
  per-endpoint network failures.

Closes wespreadjam#23
Only linkedinCreatePostNode consumed the client helpers, and 9 of 10
integrations in this repo inline HTTP/auth in their operation files
rather than extracting a *-client.ts module. Twitter is the sole
exception because OAuth 1.0a signing is non-trivial — LinkedIn's
bearer-token auth has no comparable complexity.

Moving all helpers into linkedin-create-post.ts as module-private
functions keeps the feature in one place and matches the project
norm. If a second LinkedIn operation arrives, the helpers can be
re-extracted at that point.

No public API or behavior changes. All 38 LinkedIn tests still pass.
Revert three additions that deviated from the credential and input
shapes declared in the issue:

- credentials.ts: expiresAt reverts to required (z.number()), matching
  the issue's credential schema verbatim
- linkedin-create-post.ts: text reverts to plain z.string() (no min/max
  bounds) and mediaCategory drops the .default('NONE') — both now match
  the issue's input type exactly
- linkedin-create-post.test.ts: replaced the schema tests that asserted
  removed validation (empty-text rejection, 3000-char boundary, NONE
  default) with tests that cover the issue's actual shape (missing
  required text, invalid enum values, expiresAt required)

Executor logic unchanged — the existing `input.mediaCategory ?? 'NONE'`
fallback handles the now-optional field at runtime.

40 LinkedIn tests passing; 235 total tests green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Integration] LinkedIn - Extended operations (posting)

1 participant