feat: LinkedIn extended operations — create post (#23)#72
Open
jackulau wants to merge 3 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #23
Summary
Extends the existing LinkedIn integration (currently ForumScout-based monitoring only) with full posting capabilities. Adds an OAuth2 credential and a
linkedinCreatePostNodethat publishes to LinkedIn's/v2/ugcPostsendpoint, 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/melookup,registerUpload, and binary upload. Everything related to thelinkedin_create_postnode 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 mockedfetch.Modified
packages/nodes/src/integrations/social/credentials.ts— addedlinkedinCredentialalongside the existingtwitterCredential, following the samedefineOAuth2Credentialpattern.packages/core/src/types/node.ts— added alinkedin?field to theNodeCredentialsinterface so nodes can type-safely accesscontext.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.linkedinCreatePostNodeis added to thebuiltInNodesarray so it's picked up by the registry alongside every other built-in node.Credential — matches issue #23 verbatim
Input schema — matches issue #23 verbatim
Media category operations
The
linkedinCreatePostnode handles all threemediaCategoryvalues defined by the issue, each exercising a different LinkedIn API flow:1.
NONE— plain text postSingle
POST /v2/ugcPostscall withshareMediaCategory: 'NONE'and nomediaarray. The simplest path.2.
ARTICLE— link share with optional title/descriptionSingle
POST /v2/ugcPostscall withshareMediaCategory: 'ARTICLE'and amedia[]entry containingoriginalUrlplus optionaltitle.textanddescription.text. Validates thatarticleUrlis present before making the API call.3.
IMAGE— image post with full upload orchestrationThe most complex flow. Accepts either:
urn:li:digitalmediaAsset:abc123) — used directly, skipping the upload sequence. URN detection is case-insensitive and whitespace-tolerant.POST /v2/assets?action=registerUploadto obtain an upload endpoint, PUTs the binary to that endpoint, then uses the returned asset URN in theugcPostsrequest.Author URN resolution
postAs: 'person'→ the node calls/v2/me, readsid, and buildsurn:li:person:{id}server-side (caller doesn't need to know their own LinkedIn ID).postAs: 'organization'→ buildsurn:li:organization:{organizationId}from the input field.organizationIdis silently ignored whenpostAs === 'person', matching the semantics of the issue's type signature.Post ID extraction
LinkedIn's
/v2/ugcPostsreturns the created post's URN in theX-RestLi-Idresponse 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 asoutput.postId.Error handling
Every failure mode returns a structured
{ success: false, error }— the node never throws from its executor. Explicitly handled:accessTokenin credentialsorganizationIdwhenpostAs: 'organization'articleUrlwhenmediaCategory: 'ARTICLE'imageUrlwhenmediaCategory: 'IMAGE'registerUploadreturns non-OKregisterUploadreturns malformed response (missing asset URN or upload URL) — throws a descriptive error, not a cryptic TypeError/v2/ugcPostsreturns non-OK/v2/ugcPostsreturns 201 with missing or emptyX-RestLi-Idheader/v2/mereturns non-OK or missingid(for person posts)Project conventions followed
linkedin-create-post.tslives alongsidelinkedin-monitor.ts,twitter-create-tweet.ts, etc.credentials.ts—linkedinCredentialadded next totwitterCredentialfetchWithRetryfrom../../utils/http.js(same as every other integration){ success, output | error }— matchesNodeExecutionResultcontract, never throwsvi.stubGlobal('fetch', mockFetch)— same pattern astwitter-extended.test.ts*-client.tshelper 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
linkedinCredentialdefined viadefineOAuth2Credentialwith all 5 scopes from the issue;z.objectschema matches issue [Integration] LinkedIn - Extended operations (posting) #23 exactly (clientId,clientSecret,accessTokenrequired;refreshTokenoptional;expiresAtrequired). Tested inlinkedin-create-post.test.ts→describe('linkedin credential').linkedinCreatePostnode handles all threemediaCategoryvariants the issue specifies:NONE(text-only),ARTICLE(link share with optional title/description), andIMAGE(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.LinkedInCreatePostInputSchemaexactly matches the issue's declared input shape;LinkedInCreatePostOutputSchemadefinespostIdandauthoroutputs. Both exported from the node file alongside the inferred TypeScript types.linkedin-create-post.test.ts, all mockingfetchviavi.stubGlobal. Coverage includes: credential metadata and scopes, schema validation (required/optional/enum values,expiresAtrequired), 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-Idheader handling (missing + empty), LinkedIn API 422 responses, per-endpoint network failures.{ success: false, error: '...' }result; the executor is wrapped in atry/catchso 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 test— 235/235 passing (40 new LinkedIn tests + 195 pre-existing; zero regressions)npm --workspace=@jam-nodes/nodes test -- src/integrations/social/linkedin-create-post.test.ts— 40/40 passing in isolationtsc --noEmiterrors in airtable/apify/slack/etc. that this PR does not touch)builtInNodesarray, so it's discoverable alongside all other built-in nodes without additional wiringfetch, so the actual HTTP wire format,/v2/meresponse 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.