Rounds out Postline's crossposting: media everywhere, tokens that stay alive, and a brand-new X/Twitter provider. All app-owned (app/) so a framework upgrade can't clobber it — the vendored drivers are subclassed, never patched. Four commits:
1. LinkedIn image upload (#18)
The vendored LinkedIn driver could only post text or an article card. An app-owned subclass adds the versioned Images API flow (initializeUpload → PUT bytes → content.media URN), wired through LinkedInService.
2. Proactive OAuth token refresh (#19)
Connected OAuth providers went stale silently. Now Threads (short→long-lived + th_refresh_token), LinkedIn (captures the dropped refresh_token, refreshes near expiry), and Instagram (long-lived user-token exchange) stay alive. New social_identities.token_expires_at; refresh is best-effort, guarded, idempotent, and runs on the scheduled path.
3. Instagram/Threads uploaded-file media (#18)
IG/Threads publish an image by a public URL Meta fetches server-side. New unauthenticated GET /postline/media route serves a stored image by strictly-validated basename (no traversal; unguessable UUID). Queue path attaches a public url in hydrateContent; immediate-publish persists the upload then deletes it. URL-only providers use the url; byte-upload providers use bytes.
4. X/Twitter crossposting (new)
@stacksjs/socials only ships a sign-in provider for Twitter (no publish), so this is a full app-owned driver like Mastodon's:
- OAuth 2.0 + PKCE connect (S256 challenge, refresh via
offline.access),POST /2/tweets, image upload viaPOST /2/media/upload, reply-chained threads. TwitterService+ the same proactive token refresh; wired into CrosspostService, DriverRegistry, ProvidersStatusAction;/postline/twitterroutes + Actions; the Accounts "Planned" stub is now a real OAuth connect card; the composer picks X up automatically. No migration —'twitter'was already whitelisted in every provider CHECK.
Verification
bun test tests/unit— 74 pass / 0 fail (38 new: 10 LinkedIn media, 8 token primitives, 9 media hosting, 11 X/Twitter)bun run typecheck— exit 0 ·pickier— clean
Caveats / honest limits
- Not live-verified — no real LinkedIn / Threads / Instagram / X credentials here. Driver primitives are unit-tested against the documented API shapes; live HTTP hasn't been exercised.
- X/Twitter posting requires a paid API tier; its media endpoint (
/2/media/upload) follows X's current v2 docs and may need live tuning per tier. - Run the migration (
token_expires_at) before OAuth providers connect. - IG/Threads media needs a publicly reachable instance (
STORAGE_PUBLIC_URL/APP_URL). - Does not touch #17 (real Bluesky publish proof — needs a live app password).