ReviewOS

also looking at this

stacks/postline

feat: crossposting — media (LinkedIn/IG/Threads), OAuth token refresh, and X/Twitter

#30
Merged glennmichael123 wants to merge feat/crosspost-media-and-token-refresh into main
28 files +1810 -109
app/Actions/Postline/CrosspostPublishAction.tsmodified+14-3
Changes to app/Actions/Postline/CrosspostPublishAction.ts
@@ -3,7 +3,7 @@ import type { RequestInstance } from '@stacksjs/types'
33import { Action } from '@stacksjs/actions'
44import { response } from '@stacksjs/router'
55import { crosspost, crosspostProviders } from '../../Services/Social/CrosspostService'
6import { readUploadedImage } from '../../Support/Social/uploads'
6import { persistTempMedia, readUploadedImage, removeTempMedia } from '../../Support/Social/uploads'
77
88export default new Action({
99 name: 'Postline Crosspost Publish',
@@ -35,9 +35,17 @@ export default new Action({
3535 const imageAlt = String(request.get('image_alt') || '').trim()
3636
3737 const media: NonNullable<Parameters<typeof crosspost.publish>[2]>['media'] = []
38 let tempMediaFile: string | null = null
3839 const uploadedImage = await readUploadedImage(request.file?.('image'))
39 if (uploadedImage)
40 media.push({ ...uploadedImage, altText: imageAlt || undefined })
40 if (uploadedImage) {
41 // Persist briefly so URL-only providers (Instagram/Threads) can fetch the
42 // upload server-side; byte-upload providers use the bytes. Removed once
43 // publishing finishes (Meta downloads during container creation, so the
44 // file is safe to delete by then).
45 const temp = await persistTempMedia(uploadedImage.bytes, uploadedImage.mimeType)
46 tempMediaFile = temp?.filename ?? null
47 media.push({ ...uploadedImage, url: temp?.url, altText: imageAlt || undefined })
48 }
4149 if (imageUrl)
4250 media.push({ url: imageUrl, altText: imageAlt || undefined })
4351
@@ -66,5 +74,8 @@ export default new Action({
6674 error: error instanceof Error ? error.message : String(error),
6775 }, { status: 422 })
6876 }
77 finally {
78 if (tempMediaFile) await removeTempMedia(tempMediaFile)
79 }
6980 },
7081})
app/Actions/Postline/MediaServeAction.tsadded+45-0
Changes to app/Actions/Postline/MediaServeAction.ts
@@ -0,0 +1,45 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { join } from 'node:path'
3import { Action } from '@stacksjs/actions'
4import { response } from '@stacksjs/router'
5import { isSafeMediaFilename, MEDIA_DIR, mediaContentType } from '../../Support/Social/uploads'
6
7/**
8 * Serve a stored post image so a remote platform can fetch it server-side.
9 *
10 * Instagram and Threads publish an image by handing the Meta Graph API a public
11 * `image_url` it downloads itself — an uploaded file sitting on local disk is
12 * unreachable to them. This route exposes those files at a stable URL.
13 *
14 * It is intentionally UNAUTHENTICATED (Meta fetches it with no session), so it
15 * is deliberately narrow: only a strictly-validated basename is accepted
16 * (`isSafeMediaFilename` rejects any path separator or traversal), the filename
17 * is an unguessable per-upload UUID, and nothing outside the media directory
18 * can be reached.
19 */
20export default new Action({
21 name: 'Postline Media Serve',
22 description: 'Serve a stored post image for remote platforms (Instagram/Threads) to fetch.',
23 method: 'GET',
24
25 async handle(request: RequestInstance) {
26 const file = String(request.get('file') || '').trim()
27 if (!isSafeMediaFilename(file))
28 return response.json({ ok: false, error: 'Invalid media reference.' }, { status: 400 })
29
30 const stored = Bun.file(join(MEDIA_DIR, file))
31 if (!(await stored.exists()))
32 return response.json({ ok: false, error: 'Media not found.' }, { status: 404 })
33
34 const bytes = new Uint8Array(await stored.arrayBuffer())
35 return new Response(bytes as unknown as BodyInit, {
36 status: 200,
37 headers: {
38 'content-type': mediaContentType(file),
39 // Filenames are unique per upload, so the bytes never change — let a
40 // platform re-fetching the same URL hit its cache instead of the app.
41 'cache-control': 'public, max-age=31536000, immutable',
42 },
43 })
44 },
45})
app/Actions/Postline/ProvidersStatusAction.tsmodified+2-0
Changes to app/Actions/Postline/ProvidersStatusAction.ts
@@ -6,6 +6,7 @@ import { instagram } from '../../Services/Social/InstagramService'
66import { linkedin } from '../../Services/Social/LinkedInService'
77import { mastodon } from '../../Services/Social/MastodonService'
88import { threads } from '../../Services/Social/ThreadsService'
9import { twitter } from '../../Services/Social/TwitterService'
910
1011export default new Action({
1112 name: 'Postline Providers Status',
@@ -16,6 +17,7 @@ export default new Action({
1617 try {
1718 const providers = await Promise.all([
1819 bluesky.status(),
20 twitter.status(),
1921 linkedin.status(),
2022 instagram.status(),
2123 threads.status(),
app/Actions/Postline/TwitterAuthAction.tsadded+19-0
Changes to app/Actions/Postline/TwitterAuthAction.ts
@@ -0,0 +1,19 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { twitter } from '../../Services/Social/TwitterService'
4
5export default new Action({
6 name: 'Postline Twitter Auth',
7 description: 'Redirect the user to X/Twitter for OAuth consent.',
8 method: 'GET',
9
10 async handle() {
11 try {
12 return response.redirect(await twitter.getAuthUrl())
13 }
14 catch (error) {
15 const message = error instanceof Error ? error.message : String(error)
16 return response.redirect(`/accounts?twitter=error&message=${encodeURIComponent(message)}`)
17 }
18 },
19})
app/Actions/Postline/TwitterCallbackAction.tsadded+30-0
Changes to app/Actions/Postline/TwitterCallbackAction.ts
@@ -0,0 +1,30 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { response } from '@stacksjs/router'
4import { twitter } from '../../Services/Social/TwitterService'
5
6export default new Action({
7 name: 'Postline Twitter Callback',
8 description: 'Handle the X/Twitter OAuth redirect and store the access token.',
9 method: 'GET',
10
11 async handle(request: RequestInstance) {
12 const code = String(request.get('code') || '')
13 const state = String(request.get('state') || '')
14 const oauthError = String(request.get('error') || '')
15
16 if (oauthError) {
17 const description = String(request.get('error_description') || oauthError)
18 return response.redirect(`/accounts?twitter=error&message=${encodeURIComponent(description)}`)
19 }
20
21 try {
22 await twitter.handleCallback(code, state)
23 return response.redirect('/accounts?twitter=connected')
24 }
25 catch (error) {
26 const message = error instanceof Error ? error.message : String(error)
27 return response.redirect(`/accounts?twitter=error&message=${encodeURIComponent(message)}`)
28 }
29 },
30})
app/Actions/Postline/TwitterConnectAction.tsadded+22-0
Changes to app/Actions/Postline/TwitterConnectAction.ts
@@ -0,0 +1,22 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { twitter } from '../../Services/Social/TwitterService'
4
5export default new Action({
6 name: 'Postline Twitter Connect',
7 description: 'Connect X/Twitter from a pre-obtained access token in the environment.',
8 method: 'POST',
9
10 async handle() {
11 try {
12 const data = await twitter.connectFromEnv()
13 return response.json({ ok: true, data })
14 }
15 catch (error) {
16 return response.json({
17 ok: false,
18 error: error instanceof Error ? error.message : String(error),
19 }, { status: 422 })
20 }
21 },
22})
app/Actions/Postline/TwitterStatusAction.tsadded+21-0
Changes to app/Actions/Postline/TwitterStatusAction.ts
@@ -0,0 +1,21 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { twitter } from '../../Services/Social/TwitterService'
4
5export default new Action({
6 name: 'Postline Twitter Status',
7 description: 'Return the current X/Twitter connection state.',
8 method: 'GET',
9
10 async handle() {
11 try {
12 return response.json({ ok: true, data: await twitter.status() })
13 }
14 catch (error) {
15 return response.json({
16 ok: false,
17 error: error instanceof Error ? error.message : String(error),
18 }, { status: 500 })
19 }
20 },
21})
app/Services/Social/CrosspostService.tsmodified+2-0
Changes to app/Services/Social/CrosspostService.ts
@@ -7,6 +7,7 @@ import { instagram } from './InstagramService'
77import { linkedin } from './LinkedInService'
88import { mastodon } from './MastodonService'
99import { threads } from './ThreadsService'
10import { twitter } from './TwitterService'
1011import { ensureAccount, now, uuid } from './support'
1112
1213const database = db as any
@@ -18,6 +19,7 @@ interface ProviderPublisher {
1819// Each provider owns its own connection/token handling behind `publishToPost`.
1920const publishers: Partial<Record<SocialProvider, ProviderPublisher>> = {
2021 bluesky,
22 twitter,
2123 linkedin,
2224 instagram,
2325 threads,
app/Services/Social/DriverRegistry.tsmodified+2-0
Changes to app/Services/Social/DriverRegistry.ts
@@ -4,9 +4,11 @@ import { BlueskyDriver } from './Drivers/BlueskyDriver'
44import { InstagramDriver } from './Drivers/InstagramDriver'
55import { LinkedInDriver } from './Drivers/LinkedInDriver'
66import { ThreadsDriver } from './Drivers/ThreadsDriver'
7import { TwitterDriver } from './Drivers/TwitterDriver'
78
89const drivers: Partial<Record<SocialProvider, SocialDriver>> = {
910 bluesky: new BlueskyDriver(),
11 twitter: new TwitterDriver(),
1012 linkedin: new LinkedInDriver({ apiVersion: String(env.LINKEDIN_API_VERSION || '202405') }),
1113 instagram: new InstagramDriver({ graphVersion: String(env.INSTAGRAM_GRAPH_VERSION || 'v21.0') }),
1214 threads: new ThreadsDriver({ graphVersion: String(env.THREADS_GRAPH_VERSION || 'v1.0') }),
app/Services/Social/Drivers/InstagramDriver.tsmodified+42-1
Changes to app/Services/Social/Drivers/InstagramDriver.ts
@@ -1,1 +1,42 @@
1export { InstagramApiError, InstagramPublishingDriver as InstagramDriver } from '@stacksjs/socials'
1import { InstagramApiError, InstagramPublishingDriver } from '@stacksjs/socials'
2
3export { InstagramApiError }
4
5export interface InstagramLongLivedToken {
6 accessToken: string
7 expiresIn?: number
8}
9
10/**
11 * App-owned Instagram driver. Extends the vendored `@stacksjs/socials` driver
12 * with the Facebook long-lived-token exchange. Kept in `app/` so a framework
13 * upgrade can't clobber it; publishing and account resolution are inherited.
14 *
15 * Instagram publishes with a Page access token derived from a user token. A
16 * Page token minted from a *long-lived* user token does not expire on its own,
17 * so the "keep the connection alive" step for Instagram is exchanging the
18 * short-lived user token for a long-lived one before resolving the account —
19 * there is no per-publish refresh grant the way Threads/LinkedIn have.
20 */
21export class InstagramDriver extends InstagramPublishingDriver {
22 /** Exchange a short-lived user token for a long-lived (~60-day) one. */
23 async exchangeLongLivedUserToken(clientId: string, clientSecret: string, shortLivedToken: string): Promise<InstagramLongLivedToken> {
24 const query = new URLSearchParams({
25 grant_type: 'fb_exchange_token',
26 client_id: clientId,
27 client_secret: clientSecret,
28 fb_exchange_token: shortLivedToken,
29 })
30
31 const payload = await this.graph<{ access_token?: string, expires_in?: number }>(
32 `/oauth/access_token?${query.toString()}`,
33 { method: 'GET' },
34 )
35
36 if (!payload.access_token) {
37 throw new InstagramApiError('Facebook did not return a long-lived token.', 400, JSON.stringify(payload))
38 }
39
40 return { accessToken: payload.access_token, expiresIn: payload.expires_in }
41 }
42}
app/Services/Social/Drivers/LinkedInDriver.tsmodified+262-1
Changes to app/Services/Social/Drivers/LinkedInDriver.ts
@@ -1,1 +1,262 @@
1export { LinkedInApiError, LinkedInPublishingDriver as LinkedInDriver } from '@stacksjs/socials'
1import type { LinkedInTokenExchangeInput, PublishedPost, PublishPostInput, SocialIdentityCredentials } from '@stacksjs/socials'
2import { escapeLinkedInText, LinkedInApiError, LinkedInPublishingDriver } from '@stacksjs/socials'
3
4export { LinkedInApiError }
5
6interface InitializeUploadResponse {
7 value?: { uploadUrl?: string, image?: string }
8}
9
10export interface LinkedInRefreshInput {
11 clientId: string
12 clientSecret: string
13 refreshToken: string
14}
15
16export interface LinkedInRefreshedToken {
17 accessToken: string
18 expiresIn?: number
19 /** Present only when the app is enrolled in LinkedIn's refresh-token program. */
20 refreshToken?: string
21 refreshTokenExpiresIn?: number
22}
23
24/**
25 * App-owned LinkedIn driver. Extends the vendored `@stacksjs/socials` driver to
26 * add image posting — the base driver only supports text and article link
27 * cards. Kept here in `app/` (not patched into `storage/framework/`) so a
28 * framework upgrade can't clobber it, the same "re-home into app code" rule the
29 * app-owned Mastodon driver and the auth bridge follow.
30 *
31 * Everything else (OAuth `getAuthUrl`/`exchangeCode`/`getProfile`, the versioned
32 * REST plumbing) is inherited unchanged. Only `publish` is overridden, and a
33 * single `uploadImage` step is added.
34 */
35export class LinkedInDriver extends LinkedInPublishingDriver {
36 /**
37 * Exchange an authorization code for a token, additionally surfacing the
38 * `refresh_token` the base driver drops. LinkedIn only returns one when the
39 * app is enrolled in the refresh-token program; when absent, the field is
40 * simply undefined and the connection falls back to reconnect-on-expiry.
41 */
42 async exchangeCode(input: LinkedInTokenExchangeInput): Promise<LinkedInRefreshedToken & { scope?: string }> {
43 const body = new URLSearchParams({
44 grant_type: 'authorization_code',
45 code: input.code,
46 redirect_uri: input.redirectUrl,
47 client_id: input.clientId,
48 client_secret: input.clientSecret,
49 })
50
51 const payload = await this.request<{
52 access_token: string
53 expires_in?: number
54 scope?: string
55 refresh_token?: string
56 refresh_token_expires_in?: number
57 }>(`${this.authBase}/oauth/v2/accessToken`, {
58 method: 'POST',
59 headers: { 'content-type': 'application/x-www-form-urlencoded' },
60 body: body.toString(),
61 })
62
63 if (!payload.access_token) {
64 throw new LinkedInApiError('LinkedIn did not return an access token.', 400, JSON.stringify(payload))
65 }
66
67 return {
68 accessToken: payload.access_token,
69 expiresIn: payload.expires_in,
70 scope: payload.scope,
71 refreshToken: payload.refresh_token,
72 refreshTokenExpiresIn: payload.refresh_token_expires_in,
73 }
74 }
75
76 /** Mint a fresh access token from a stored refresh token. */
77 async refreshAccessToken(input: LinkedInRefreshInput): Promise<LinkedInRefreshedToken> {
78 const body = new URLSearchParams({
79 grant_type: 'refresh_token',
80 refresh_token: input.refreshToken,
81 client_id: input.clientId,
82 client_secret: input.clientSecret,
83 })
84
85 const payload = await this.request<{
86 access_token: string
87 expires_in?: number
88 refresh_token?: string
89 refresh_token_expires_in?: number
90 }>(`${this.authBase}/oauth/v2/accessToken`, {
91 method: 'POST',
92 headers: { 'content-type': 'application/x-www-form-urlencoded' },
93 body: body.toString(),
94 })
95
96 if (!payload.access_token) {
97 throw new LinkedInApiError('LinkedIn did not return a refreshed access token.', 400, JSON.stringify(payload))
98 }
99
100 return {
101 accessToken: payload.access_token,
102 expiresIn: payload.expires_in,
103 refreshToken: payload.refresh_token,
104 refreshTokenExpiresIn: payload.refresh_token_expires_in,
105 }
106 }
107
108 /**
109 * Register and upload one image via LinkedIn's versioned Images API, then
110 * return its `urn:li:image:...` id for attachment.
111 *
112 * 1. POST /rest/images?action=initializeUpload → { uploadUrl, image URN }
113 * 2. PUT the raw bytes to `uploadUrl`
114 *
115 * `owner` is the author URN (`urn:li:person:{sub}`) that owns the asset.
116 */
117 async uploadImage(accessToken: string, owner: string, bytes: Uint8Array, mimeType?: string): Promise<string> {
118 const initialized = await this.request<InitializeUploadResponse>(
119 `${this.apiBase}/rest/images?action=initializeUpload`,
120 {
121 method: 'POST',
122 headers: {
123 'authorization': `Bearer ${accessToken}`,
124 'content-type': 'application/json',
125 'linkedin-version': this.apiVersion,
126 'x-restli-protocol-version': '2.0.0',
127 },
128 body: JSON.stringify({ initializeUploadRequest: { owner } }),
129 },
130 )
131
132 const uploadUrl = initialized.value?.uploadUrl
133 const image = initialized.value?.image
134 if (!uploadUrl || !image) {
135 throw new LinkedInApiError('LinkedIn did not return an image upload URL.', 400, JSON.stringify(initialized))
136 }
137
138 const upload = await fetch(uploadUrl, {
139 method: 'PUT',
140 headers: {
141 'authorization': `Bearer ${accessToken}`,
142 'content-type': mimeType || 'image/jpeg',
143 },
144 body: bytes as unknown as BodyInit,
145 })
146
147 if (!upload.ok) {
148 const text = await upload.text().catch(() => '')
149 throw new LinkedInApiError(
150 `LinkedIn image upload failed (${upload.status}): ${text || upload.statusText}`,
151 upload.status,
152 text,
153 )
154 }
155
156 return image
157 }
158
159 /**
160 * Publish a member share. Extends the base text/article flow with a single
161 * attached image (`post.media[0]`): the image is uploaded first, then
162 * referenced under `content.media`. LinkedIn's `content` is a one-of, so an
163 * image supersedes an article link card when both are present (matching how
164 * Bluesky lets an image override the link card).
165 *
166 * The record shape is duplicated from the base driver by necessity — the base
167 * `publish` builds it inline with no seam to inject media — and is pinned by
168 * tests/unit/linkedin-driver.test.ts so upgrade drift surfaces immediately.
169 */
170 async publish(identity: SocialIdentityCredentials, post: PublishPostInput): Promise<PublishedPost> {
171 if (!identity.accessToken) throw new Error('LinkedIn access token is missing for this identity.')
172
173 // `did` carries the member URN (urn:li:person:{sub}) for LinkedIn identities.
174 const author = identity.did
175 if (!author) throw new Error('LinkedIn member URN is required to publish.')
176
177 if (post.text.length > this.characterLimit) {
178 throw new Error(`LinkedIn posts must be ${this.characterLimit} characters or fewer.`)
179 }
180
181 // Resolve an attached image to bytes — uploaded files arrive as `bytes`;
182 // a bare `url` is fetched first (same handling as the Mastodon driver).
183 const media = post.media?.[0]
184 let imageUrn: string | undefined
185 if (media) {
186 let bytes = media.bytes
187 let mimeType = media.mimeType
188 if (!bytes?.length && media.url) {
189 const response = await fetch(media.url)
190 if (response.ok) {
191 bytes = new Uint8Array(await response.arrayBuffer())
192 mimeType = mimeType || response.headers.get('content-type') || 'image/jpeg'
193 }
194 }
195 if (bytes?.length) {
196 imageUrn = await this.uploadImage(identity.accessToken, author, bytes, mimeType)
197 }
198 }
199
200 const record: Record<string, unknown> = {
201 author,
202 commentary: escapeLinkedInText(post.text),
203 visibility: 'PUBLIC',
204 distribution: {
205 feedDistribution: 'MAIN_FEED',
206 targetEntities: [],
207 thirdPartyDistributionChannels: [],
208 },
209 lifecycleState: 'PUBLISHED',
210 isReshareDisabledByAuthor: false,
211 }
212
213 if (imageUrn) {
214 record.content = {
215 media: {
216 id: imageUrn,
217 ...(media?.altText ? { altText: media.altText } : {}),
218 },
219 }
220 }
221 else if (post.external) {
222 record.content = {
223 article: {
224 source: post.external.uri,
225 title: post.external.title,
226 description: post.external.description || '',
227 },
228 }
229 }
230
231 const response = await fetch(`${this.apiBase}/rest/posts`, {
232 method: 'POST',
233 headers: {
234 'authorization': `Bearer ${identity.accessToken}`,
235 'content-type': 'application/json',
236 'linkedin-version': this.apiVersion,
237 'x-restli-protocol-version': '2.0.0',
238 },
239 body: JSON.stringify(record),
240 })
241
242 const text = await response.text()
243 if (!response.ok) {
244 throw new LinkedInApiError(
245 `LinkedIn API failed (${response.status}): ${text || response.statusText}`,
246 response.status,
247 text,
248 )
249 }
250
251 // The created post URN comes back in a response header, not the body.
252 const uri = response.headers.get('x-restli-id')
253 || response.headers.get('x-linkedin-id')
254 || ''
255
256 return {
257 provider: this.provider,
258 uri,
259 url: uri ? `https://www.linkedin.com/feed/update/${uri}` : undefined,
260 }
261 }
262}
app/Services/Social/Drivers/ThreadsDriver.tsmodified+66-1
Changes to app/Services/Social/Drivers/ThreadsDriver.ts
@@ -1,1 +1,66 @@
1export { ThreadsApiError, ThreadsPublishingDriver as ThreadsDriver } from '@stacksjs/socials'
1import { ThreadsApiError, ThreadsPublishingDriver } from '@stacksjs/socials'
2
3export { ThreadsApiError }
4
5export interface ThreadsLongLivedToken {
6 accessToken: string
7 expiresIn?: number
8}
9
10/**
11 * App-owned Threads driver. Extends the vendored `@stacksjs/socials` driver with
12 * long-lived-token exchange and refresh — the base driver's `exchangeCode` only
13 * mints a short-lived (~1h) token. Kept in `app/` so a framework upgrade can't
14 * clobber it; publishing and timeline are inherited unchanged.
15 *
16 * Both token endpoints are unversioned (no `/vX` prefix), so they can't go
17 * through the base `graph()` helper — they use a small local `tokenRequest`.
18 */
19export class ThreadsDriver extends ThreadsPublishingDriver {
20 /**
21 * Exchange a short-lived token for a long-lived (~60-day) one. Requires the
22 * app's client secret. Called once right after the OAuth code exchange.
23 */
24 async exchangeLongLivedToken(clientSecret: string, shortLivedToken: string): Promise<ThreadsLongLivedToken> {
25 const query = new URLSearchParams({
26 grant_type: 'th_exchange_token',
27 client_secret: clientSecret,
28 access_token: shortLivedToken,
29 })
30 const payload = await this.tokenRequest(`${this.graphBase}/access_token?${query.toString()}`)
31 return { accessToken: payload.access_token, expiresIn: payload.expires_in }
32 }
33
34 /**
35 * Refresh an unexpired long-lived token, extending it another ~60 days.
36 * Threads requires the token be at least 24h old and not yet expired — both
37 * hold when the proactive refresh fires inside the pre-expiry window.
38 */
39 async refreshLongLivedToken(accessToken: string): Promise<ThreadsLongLivedToken> {
40 const query = new URLSearchParams({
41 grant_type: 'th_refresh_token',
42 access_token: accessToken,
43 })
44 const payload = await this.tokenRequest(`${this.graphBase}/refresh_access_token?${query.toString()}`)
45 return { accessToken: payload.access_token, expiresIn: payload.expires_in }
46 }
47
48 private async tokenRequest(url: string): Promise<{ access_token: string, expires_in?: number }> {
49 const response = await fetch(url, { method: 'GET' })
50 const text = await response.text()
51 let json: any = {}
52 try {
53 json = text ? JSON.parse(text) : {}
54 }
55 catch {
56 json = {}
57 }
58
59 if (!response.ok || json?.error || !json?.access_token) {
60 const message = json?.error?.message || json?.error_message || text || response.statusText
61 throw new ThreadsApiError(`Threads token request failed (${response.status}): ${message}`, response.status, text)
62 }
63
64 return json
65 }
66}
app/Services/Social/Drivers/TwitterDriver.tsadded+261-0
Changes to app/Services/Social/Drivers/TwitterDriver.ts
@@ -0,0 +1,261 @@
1import type { PublishedPost, PublishPostInput, SocialIdentityCredentials } from '@stacksjs/socials'
2
3/**
4 * Full app-owned X/Twitter publishing driver. The published `@stacksjs/socials`
5 * ships only a *sign-in* provider for Twitter (getAuthUrl/getAccessToken/
6 * getUserByToken, no `publish`), so — like the Mastodon driver — this is a
7 * complete local implementation rather than a subclass.
8 *
9 * Auth is OAuth 2.0 with PKCE (X's user-context flow). Posting is `POST
10 * /2/tweets`; images go through `POST /2/media/upload` first and attach by
11 * media id. Threads chain via `reply.in_reply_to_tweet_id`.
12 *
13 * NOTE: posting on X requires a paid API tier — this is exercised only against
14 * mocked endpoints in tests; the request shapes follow X's documented v2 API.
15 */
16
17export class TwitterApiError extends Error {
18 constructor(
19 message: string,
20 public status: number,
21 public body: string,
22 ) {
23 super(message)
24 this.name = 'TwitterApiError'
25 }
26
27 get isAuthError(): boolean {
28 return this.status === 401 || this.status === 403
29 }
30}
31
32export interface TwitterDriverOptions {
33 apiBase?: string
34 authorizeBase?: string
35}
36
37export interface TwitterAuthUrlInput {
38 clientId: string
39 redirectUrl: string
40 scopes: string[]
41 state: string
42}
43
44export interface TwitterTokenExchangeInput {
45 clientId: string
46 clientSecret?: string
47 redirectUrl: string
48 code: string
49 codeVerifier: string
50}
51
52export interface TwitterRefreshInput {
53 clientId: string
54 clientSecret?: string
55 refreshToken: string
56}
57
58export interface TwitterToken {
59 accessToken: string
60 refreshToken?: string
61 expiresIn?: number
62 scope?: string
63}
64
65export interface TwitterProfile {
66 id: string
67 username: string
68 name?: string
69}
70
71/** base64url with padding stripped — the encoding OAuth2/PKCE expects. */
72function base64Url(bytes: Uint8Array): string {
73 let binary = ''
74 for (const byte of bytes) binary += String.fromCharCode(byte)
75 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
76}
77
78export class TwitterPublishingDriver {
79 readonly provider: 'twitter' = 'twitter'
80 characterLimit = 280
81
82 protected apiBase: string
83 protected authorizeBase: string
84
85 constructor(options: TwitterDriverOptions = {}) {
86 this.apiBase = options.apiBase || 'https://api.twitter.com'
87 this.authorizeBase = options.authorizeBase || 'https://twitter.com'
88 }
89
90 /**
91 * Build the OAuth 2.0 consent URL with a PKCE challenge, returning both the
92 * URL and the `code_verifier` the caller must stash for the token exchange.
93 */
94 async createAuthorization(input: TwitterAuthUrlInput): Promise<{ url: string, codeVerifier: string }> {
95 const codeVerifier = base64Url(crypto.getRandomValues(new Uint8Array(32)))
96 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
97 const codeChallenge = base64Url(new Uint8Array(digest))
98
99 const query = new URLSearchParams({
100 response_type: 'code',
101 client_id: input.clientId,
102 redirect_uri: input.redirectUrl,
103 scope: input.scopes.join(' '),
104 state: input.state,
105 code_challenge: codeChallenge,
106 code_challenge_method: 'S256',
107 })
108
109 return { url: `${this.authorizeBase}/i/oauth2/authorize?${query.toString()}`, codeVerifier }
110 }
111
112 /** Exchange an authorization code (+ PKCE verifier) for user tokens. */
113 async exchangeCode(input: TwitterTokenExchangeInput): Promise<TwitterToken> {
114 return this.tokenRequest(new URLSearchParams({
115 grant_type: 'authorization_code',
116 code: input.code,
117 redirect_uri: input.redirectUrl,
118 code_verifier: input.codeVerifier,
119 client_id: input.clientId,
120 }), input.clientId, input.clientSecret)
121 }
122
123 /** Mint a fresh access token from a stored refresh token. */
124 async refreshAccessToken(input: TwitterRefreshInput): Promise<TwitterToken> {
125 return this.tokenRequest(new URLSearchParams({
126 grant_type: 'refresh_token',
127 refresh_token: input.refreshToken,
128 client_id: input.clientId,
129 }), input.clientId, input.clientSecret)
130 }
131
132 /** Resolve the authenticated account (id + handle). */
133 async getProfile(accessToken: string): Promise<TwitterProfile> {
134 const payload = await this.request<{ data?: { id: string, username: string, name?: string } }>(
135 `${this.apiBase}/2/users/me?user.fields=username,name`,
136 { headers: { authorization: `Bearer ${accessToken}` } },
137 )
138 if (!payload.data?.id || !payload.data.username) {
139 throw new TwitterApiError('Twitter did not return the authenticated user.', 400, JSON.stringify(payload))
140 }
141 return { id: payload.data.id, username: payload.data.username, name: payload.data.name }
142 }
143
144 /** Upload one image and return its media id for attachment. */
145 async uploadMedia(accessToken: string, bytes: Uint8Array, mimeType: string): Promise<string> {
146 const form = new FormData()
147 form.set('media', new Blob([bytes as unknown as BlobPart], { type: mimeType || 'image/jpeg' }))
148 form.set('media_category', 'tweet_image')
149
150 const payload = await this.request<{ data?: { id?: string }, media_id_string?: string, id?: string }>(
151 `${this.apiBase}/2/media/upload`,
152 { method: 'POST', headers: { authorization: `Bearer ${accessToken}` }, body: form },
153 )
154
155 const mediaId = payload.data?.id || payload.media_id_string || payload.id
156 if (!mediaId) {
157 throw new TwitterApiError('Twitter did not return a media id.', 400, JSON.stringify(payload))
158 }
159 return mediaId
160 }
161
162 async publish(identity: SocialIdentityCredentials, post: PublishPostInput): Promise<PublishedPost> {
163 if (!identity.accessToken) throw new Error('Twitter access token is missing for this identity.')
164 if (post.text.length > this.characterLimit) {
165 throw new Error(`Twitter posts must be ${this.characterLimit} characters or fewer.`)
166 }
167
168 // Resolve an attached image to bytes (uploaded files arrive as bytes; a bare
169 // URL is fetched first) and upload it for its media id.
170 const mediaIds: string[] = []
171 const media = post.media?.[0]
172 if (media) {
173 let bytes = media.bytes
174 let mimeType = media.mimeType
175 if (!bytes?.length && media.url) {
176 const response = await fetch(media.url)
177 if (response.ok) {
178 bytes = new Uint8Array(await response.arrayBuffer())
179 mimeType = mimeType || response.headers.get('content-type') || 'image/jpeg'
180 }
181 }
182 if (bytes?.length) mediaIds.push(await this.uploadMedia(identity.accessToken, bytes, mimeType || 'image/jpeg'))
183 }
184
185 const body: Record<string, unknown> = { text: post.text }
186 if (mediaIds.length) body.media = { media_ids: mediaIds }
187 // Threads chain through the parent tweet id, carried in reply.parent.uri.
188 if (post.reply?.parent?.uri) body.reply = { in_reply_to_tweet_id: post.reply.parent.uri }
189
190 const payload = await this.request<{ data?: { id?: string } }>(
191 `${this.apiBase}/2/tweets`,
192 {
193 method: 'POST',
194 headers: { 'authorization': `Bearer ${identity.accessToken}`, 'content-type': 'application/json' },
195 body: JSON.stringify(body),
196 },
197 )
198
199 const id = payload.data?.id
200 if (!id) throw new TwitterApiError('Twitter did not return a tweet id.', 400, JSON.stringify(payload))
201
202 return {
203 provider: this.provider,
204 // Expose the tweet id as both uri and cid so the crosspost thread-chaining
205 // logic (which needs a cid) can reply-chain subsequent segments.
206 uri: id,
207 cid: id,
208 url: identity.handle
209 ? `https://x.com/${identity.handle}/status/${id}`
210 : `https://x.com/i/web/status/${id}`,
211 }
212 }
213
214 // X's home timeline needs elevated access we don't assume here.
215 async timeline(): Promise<{ items: [] }> {
216 return { items: [] }
217 }
218
219 protected async tokenRequest(body: URLSearchParams, clientId: string, clientSecret?: string): Promise<TwitterToken> {
220 const headers: Record<string, string> = { 'content-type': 'application/x-www-form-urlencoded' }
221 // Confidential clients authenticate the token endpoint with HTTP Basic;
222 // public (PKCE-only) clients send just the client_id in the body.
223 if (clientSecret) headers.authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`
224
225 const payload = await this.request<{
226 access_token?: string
227 refresh_token?: string
228 expires_in?: number
229 scope?: string
230 }>(`${this.apiBase}/2/oauth2/token`, { method: 'POST', headers, body: body.toString() })
231
232 if (!payload.access_token) {
233 throw new TwitterApiError('Twitter did not return an access token.', 400, JSON.stringify(payload))
234 }
235
236 return {
237 accessToken: payload.access_token,
238 refreshToken: payload.refresh_token,
239 expiresIn: payload.expires_in,
240 scope: payload.scope,
241 }
242 }
243
244 protected async request<T>(url: string, init: RequestInit): Promise<T> {
245 const response = await fetch(url, init)
246 const text = await response.text()
247
248 if (!response.ok) {
249 throw new TwitterApiError(
250 `Twitter API failed (${response.status}): ${text || response.statusText}`,
251 response.status,
252 text,
253 )
254 }
255
256 return text ? JSON.parse(text) as T : {} as T
257 }
258}
259
260// Consumers import this as `TwitterDriver`, matching the sibling drivers.
261export { TwitterPublishingDriver as TwitterDriver }
app/Services/Social/InstagramService.tsmodified+19-3
Changes to app/Services/Social/InstagramService.ts
@@ -2,7 +2,7 @@ import type { CrosspostTargetResult, PublishContent } from '../../Support/Social
22import { db } from '@stacksjs/database'
33import { env } from '@stacksjs/env'
44import { InstagramApiError, InstagramDriver } from './Drivers/InstagramDriver'
5import { ensureAccount, now, uuid } from './support'
5import { ensureAccount, expiresAt, now, uuid } from './support'
66
77const database = db as any
88
@@ -15,6 +15,7 @@ type SocialIdentityRow = {
1515 auth_status: 'connected' | 'expired' | 'revoked' | 'missing'
1616 access_token?: string | null
1717 refresh_token?: string | null
18 token_expires_at?: string | null
1819 account_id?: number | null
1920 social_driver_id?: number | null
2021}
@@ -118,11 +119,25 @@ export class InstagramService {
118119 code,
119120 })
120121
121 const account = await this.driver.resolveAccount(token.accessToken)
122 // Exchange the short-lived user token for a long-lived (~60-day) one before
123 // resolving the Page — a Page token minted from a long-lived user token does
124 // not expire on its own, so there's no per-publish refresh grant to run.
125 // Best-effort: fall back to the short-lived token if the exchange fails.
126 let userToken = token.accessToken
127 let expiresIn: number | undefined
128 try {
129 const longLived = await this.driver.exchangeLongLivedUserToken(cfg.clientId, cfg.clientSecret, token.accessToken)
130 userToken = longLived.accessToken
131 expiresIn = longLived.expiresIn
132 }
133 catch {}
134
135 const account = await this.driver.resolveAccount(userToken)
122136 const identity = await this.saveSession({
123137 accessToken: account.pageAccessToken,
124138 igUserId: account.igUserId,
125139 username: account.username,
140 expiresIn,
126141 })
127142 return this.publicIdentity(identity)
128143 }
@@ -260,7 +275,7 @@ export class InstagramService {
260275 .executeTakeFirst()
261276 }
262277
263 private async saveSession(input: { accessToken: string, igUserId: string, username?: string }): Promise<SocialIdentityRow> {
278 private async saveSession(input: { accessToken: string, igUserId: string, username?: string, expiresIn?: number }): Promise<SocialIdentityRow> {
264279 const accountId = await ensureAccount()
265280 const driver = await this.ensureDriver()
266281 const existing = await this.findIdentity()
@@ -275,6 +290,7 @@ export class InstagramService {
275290 auth_status: 'connected',
276291 access_token: input.accessToken,
277292 refresh_token: null,
293 token_expires_at: expiresAt(input.expiresIn),
278294 account_id: accountId,
279295 social_driver_id: driver.id,
280296 updated_at: savedAt,
app/Services/Social/LinkedInService.tsmodified+46-4
Changes to app/Services/Social/LinkedInService.ts
@@ -2,7 +2,7 @@ import type { CrosspostTargetResult, PublishContent } from '../../Support/Social
22import { db } from '@stacksjs/database'
33import { env } from '@stacksjs/env'
44import { LinkedInApiError, LinkedInDriver } from './Drivers/LinkedInDriver'
5import { ensureAccount, now, uuid } from './support'
5import { ensureAccount, expiresAt, isExpiringSoon, now, uuid } from './support'
66
77const database = db as any
88
@@ -15,6 +15,7 @@ type SocialIdentityRow = {
1515 auth_status: 'connected' | 'expired' | 'revoked' | 'missing'
1616 access_token?: string | null
1717 refresh_token?: string | null
18 token_expires_at?: string | null
1819 account_id?: number | null
1920 social_driver_id?: number | null
2021}
@@ -125,6 +126,8 @@ export class LinkedInService {
125126
126127 const identity = await this.saveSession({
127128 accessToken: token.accessToken,
129 refreshToken: token.refreshToken,
130 expiresIn: token.expiresIn,
128131 authorUrn,
129132 name: profile?.name,
130133 })
@@ -204,6 +207,7 @@ export class LinkedInService {
204207 }, {
205208 text: post.body,
206209 ...(content?.external ? { external: content.external } : {}),
210 ...(content?.media?.length ? { media: content.media } : {}),
207211 })
208212
209213 await database.updateTable('post_targets').set({
@@ -238,7 +242,9 @@ export class LinkedInService {
238242
239243 private async requireIdentity(): Promise<SocialIdentityRow> {
240244 const existing = await this.findIdentity()
241 if (existing?.access_token && existing.auth_status === 'connected') return existing
245 if (existing?.access_token && existing.auth_status === 'connected') {
246 return await this.refreshIfExpiring(existing)
247 }
242248
243249 // Fall back to an env-configured token if one is available.
244250 if (this.config().accessToken) {
@@ -250,6 +256,41 @@ export class LinkedInService {
250256 throw new Error('Connect LinkedIn before publishing.')
251257 }
252258
259 /**
260 * Refresh the access token when it's within its pre-expiry window and a
261 * refresh token is on file (LinkedIn only issues one when the app is enrolled
262 * in the refresh-token program). Best-effort: any failure leaves the current
263 * token in place so a real auth failure still surfaces at publish time.
264 */
265 private async refreshIfExpiring(identity: SocialIdentityRow): Promise<SocialIdentityRow> {
266 const cfg = this.config()
267 if (!identity.refresh_token || !isExpiringSoon(identity.token_expires_at)) return identity
268 if (!cfg.clientId || !cfg.clientSecret) return identity
269
270 try {
271 const refreshed = await this.driver.refreshAccessToken({
272 clientId: cfg.clientId,
273 clientSecret: cfg.clientSecret,
274 refreshToken: identity.refresh_token,
275 })
276 const nextExpiry = expiresAt(refreshed.expiresIn)
277 // LinkedIn rotates the refresh token on some grants — keep the new one.
278 const nextRefresh = refreshed.refreshToken ?? identity.refresh_token
279 await database.updateTable('social_identities').set({
280 access_token: refreshed.accessToken,
281 refresh_token: nextRefresh,
282 token_expires_at: nextExpiry,
283 auth_status: 'connected',
284 updated_at: now(),
285 }).where('id', '=', identity.id).execute()
286
287 return { ...identity, access_token: refreshed.accessToken, refresh_token: nextRefresh, token_expires_at: nextExpiry }
288 }
289 catch {
290 return identity
291 }
292 }
293
253294 private async findIdentity(): Promise<SocialIdentityRow | undefined> {
254295 return await database
255296 .selectFrom('social_identities')
@@ -259,7 +300,7 @@ export class LinkedInService {
259300 .executeTakeFirst()
260301 }
261302
262 private async saveSession(input: { accessToken: string, authorUrn: string, name?: string }): Promise<SocialIdentityRow> {
303 private async saveSession(input: { accessToken: string, authorUrn: string, name?: string, refreshToken?: string, expiresIn?: number }): Promise<SocialIdentityRow> {
263304 const accountId = await ensureAccount()
264305 const driver = await this.ensureDriver()
265306 const existing = await this.findIdentity()
@@ -273,7 +314,8 @@ export class LinkedInService {
273314 external_id: input.authorUrn,
274315 auth_status: 'connected',
275316 access_token: input.accessToken,
276 refresh_token: null,
317 refresh_token: input.refreshToken ?? null,
318 token_expires_at: expiresAt(input.expiresIn),
277319 account_id: accountId,
278320 social_driver_id: driver.id,
279321 updated_at: savedAt,
app/Services/Social/QueueService.tsmodified+6-4
Changes to app/Services/Social/QueueService.ts
@@ -1,17 +1,14 @@
11import type { CrosspostTargetResult, PublishContent, SocialProvider } from '../../Support/Social/types'
22import { mkdir, unlink } from 'node:fs/promises'
33import { join } from 'node:path'
4import process from 'node:process'
54import { db } from '@stacksjs/database'
65import { env } from '@stacksjs/env'
6import { MEDIA_DIR, publicMediaUrl } from '../../Support/Social/uploads'
77import { crosspost, crosspostProviders } from './CrosspostService'
88import { ensureAccount, now, uuid } from './support'
99
1010const database = db as any
1111
12/** Uploaded images for queued posts live here until publish. */
13const MEDIA_DIR = join(process.cwd(), 'storage/app/postline-media')
14
1512/** Serialized into posts.content — everything beyond the text itself. */
1613interface StoredContent {
1714 /** Only set when the composer provided an explicit title. */
@@ -379,6 +376,11 @@ export class QueueService {
379376 if (await file.exists()) {
380377 media.push({
381378 bytes: new Uint8Array(await file.arrayBuffer()),
379 // Also expose a public URL so URL-only providers (Instagram,
380 // Threads) can publish the same uploaded file; byte-upload
381 // providers (Bluesky, LinkedIn, Mastodon) keep using `bytes`.
382 // Null when no public base is configured — then it's bytes-only.
383 url: publicMediaUrl(item.path) ?? undefined,
382384 mimeType: item.mimeType,
383385 altText: item.altText,
384386 })
app/Services/Social/ThreadsService.tsmodified+48-4
Changes to app/Services/Social/ThreadsService.ts
@@ -2,7 +2,7 @@ import type { CrosspostTargetResult, PublishContent } from '../../Support/Social
22import { db } from '@stacksjs/database'
33import { env } from '@stacksjs/env'
44import { ThreadsApiError, ThreadsDriver } from './Drivers/ThreadsDriver'
5import { ensureAccount, now, uuid } from './support'
5import { ensureAccount, expiresAt, isExpiringSoon, now, uuid } from './support'
66
77const database = db as any
88
@@ -15,6 +15,7 @@ type SocialIdentityRow = {
1515 auth_status: 'connected' | 'expired' | 'revoked' | 'missing'
1616 access_token?: string | null
1717 refresh_token?: string | null
18 token_expires_at?: string | null
1819 account_id?: number | null
1920 social_driver_id?: number | null
2021}
@@ -118,11 +119,25 @@ export class ThreadsService {
118119 code,
119120 })
120121
121 const account = await this.driver.resolveAccount(token.accessToken)
122 // Upgrade the short-lived (~1h) token to a long-lived (~60-day) one so
123 // scheduled posts keep working, then resolve the account against it. If the
124 // exchange fails, fall back to the short-lived token — publishing still
125 // works, just briefly.
126 let accessToken = token.accessToken
127 let expiresIn = token.expiresIn
128 try {
129 const longLived = await this.driver.exchangeLongLivedToken(cfg.clientSecret, token.accessToken)
130 accessToken = longLived.accessToken
131 expiresIn = longLived.expiresIn
132 }
133 catch {}
134
135 const account = await this.driver.resolveAccount(accessToken)
122136 const identity = await this.saveSession({
123137 accessToken: account.accessToken,
124138 threadsUserId: account.threadsUserId,
125139 username: account.username,
140 expiresIn,
126141 })
127142 return this.publicIdentity(identity)
128143 }
@@ -235,7 +250,9 @@ export class ThreadsService {
235250
236251 private async requireIdentity(): Promise<SocialIdentityRow> {
237252 const existing = await this.findIdentity()
238 if (existing?.access_token && existing.auth_status === 'connected') return existing
253 if (existing?.access_token && existing.auth_status === 'connected') {
254 return await this.refreshIfExpiring(existing)
255 }
239256
240257 if (this.config().accessToken) {
241258 await this.connectFromEnv()
@@ -246,6 +263,32 @@ export class ThreadsService {
246263 throw new Error('Connect Threads before publishing.')
247264 }
248265
266 /**
267 * Refresh a long-lived token that's within its pre-expiry window, extending
268 * it another ~60 days. Best-effort: a refresh failure leaves the current
269 * token in place, so a genuinely dead token still surfaces at publish time
270 * (marking the identity expired and prompting a reconnect) rather than here.
271 */
272 private async refreshIfExpiring(identity: SocialIdentityRow): Promise<SocialIdentityRow> {
273 if (!identity.access_token || !isExpiringSoon(identity.token_expires_at)) return identity
274
275 try {
276 const refreshed = await this.driver.refreshLongLivedToken(identity.access_token)
277 const nextExpiry = expiresAt(refreshed.expiresIn)
278 await database.updateTable('social_identities').set({
279 access_token: refreshed.accessToken,
280 token_expires_at: nextExpiry,
281 auth_status: 'connected',
282 updated_at: now(),
283 }).where('id', '=', identity.id).execute()
284
285 return { ...identity, access_token: refreshed.accessToken, token_expires_at: nextExpiry }
286 }
287 catch {
288 return identity
289 }
290 }
291
249292 private async findIdentity(): Promise<SocialIdentityRow | undefined> {
250293 return await database
251294 .selectFrom('social_identities')
@@ -255,7 +298,7 @@ export class ThreadsService {
255298 .executeTakeFirst()
256299 }
257300
258 private async saveSession(input: { accessToken: string, threadsUserId: string, username?: string }): Promise<SocialIdentityRow> {
301 private async saveSession(input: { accessToken: string, threadsUserId: string, username?: string, expiresIn?: number }): Promise<SocialIdentityRow> {
259302 const accountId = await ensureAccount()
260303 const driver = await this.ensureDriver()
261304 const existing = await this.findIdentity()
@@ -270,6 +313,7 @@ export class ThreadsService {
270313 auth_status: 'connected',
271314 access_token: input.accessToken,
272315 refresh_token: null,
316 token_expires_at: expiresAt(input.expiresIn),
273317 account_id: accountId,
274318 social_driver_id: driver.id,
275319 updated_at: savedAt,
app/Services/Social/LinkedInService.ts app/Services/Social/TwitterService.tscopied+115-82
Changes to app/Services/Social/TwitterService.ts
@@ -1,8 +1,8 @@
11import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types'
22import { db } from '@stacksjs/database'
33import { env } from '@stacksjs/env'
4import { LinkedInApiError, LinkedInDriver } from './Drivers/LinkedInDriver'
5import { ensureAccount, now, uuid } from './support'
4import { TwitterApiError, TwitterDriver } from './Drivers/TwitterDriver'
5import { ensureAccount, expiresAt, isExpiringSoon, now, uuid } from './support'
66
77const database = db as any
88
@@ -10,30 +10,29 @@ type SocialIdentityRow = {
1010 id: number
1111 handle: string
1212 display_name?: string | null
13 provider: 'linkedin'
13 provider: 'twitter'
1414 external_id?: string | null
1515 auth_status: 'connected' | 'expired' | 'revoked' | 'missing'
1616 access_token?: string | null
1717 refresh_token?: string | null
18 token_expires_at?: string | null
1819 account_id?: number | null
1920 social_driver_id?: number | null
2021}
2122
2223type SocialDriverRow = {
2324 id: number
24 provider: 'linkedin'
25 provider: 'twitter'
2526 display_name: string
2627 status: 'active' | 'planned' | 'disabled'
2728 character_limit: number
2829}
2930
30interface LinkedInConfig {
31interface TwitterConfig {
3132 clientId: string
3233 clientSecret: string
3334 redirectUrl: string
34 apiVersion: string
3535 accessToken: string
36 authorUrn: string
3736 scopes: string[]
3837}
3938
@@ -43,25 +42,20 @@ function randomState(): string {
4342 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
4443}
4544
46export class LinkedInService {
47 private driver: LinkedInDriver
48 // OAuth CSRF token stashed between the auth redirect and the callback.
49 // Postline runs single-user, so module memory is an adequate store.
45export class TwitterService {
46 private driver = new TwitterDriver()
47 // OAuth CSRF token + PKCE verifier stashed between the auth redirect and the
48 // callback. Postline runs single-user, so module memory is an adequate store.
5049 private pendingState: string | null = null
50 private pendingVerifier: string | null = null
5151
52 constructor() {
53 this.driver = new LinkedInDriver({ apiVersion: this.config().apiVersion })
54 }
55
56 private config(): LinkedInConfig {
52 private config(): TwitterConfig {
5753 return {
58 clientId: String(env.LINKEDIN_CLIENT_ID || '').trim(),
59 clientSecret: String(env.LINKEDIN_CLIENT_SECRET || '').trim(),
60 redirectUrl: String(env.LINKEDIN_REDIRECT_URL || '').trim(),
61 apiVersion: String(env.LINKEDIN_API_VERSION || '202405').trim(),
62 accessToken: String(env.LINKEDIN_ACCESS_TOKEN || '').trim(),
63 authorUrn: String(env.LINKEDIN_AUTHOR_URN || '').trim(),
64 scopes: ['openid', 'profile', 'w_member_social'],
54 clientId: String(env.TWITTER_CLIENT_ID || '').trim(),
55 clientSecret: String(env.TWITTER_CLIENT_SECRET || '').trim(),
56 redirectUrl: String(env.TWITTER_REDIRECT_URL || '').trim(),
57 accessToken: String(env.TWITTER_ACCESS_TOKEN || '').trim(),
58 scopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'],
6559 }
6660 }
6761
@@ -72,7 +66,7 @@ export class LinkedInService {
7266
7367 return {
7468 connected,
75 provider: 'linkedin',
69 provider: 'twitter',
7670 handle: identity?.handle || null,
7771 displayName: identity?.display_name || null,
7872 did: identity?.external_id || null,
@@ -80,53 +74,55 @@ export class LinkedInService {
8074 characterLimit: this.driver.characterLimit,
8175 canPublish: connected,
8276 configuredFromEnv: Boolean(cfg.accessToken),
83 oauthConfigured: Boolean(cfg.clientId && cfg.clientSecret && cfg.redirectUrl),
77 oauthConfigured: Boolean(cfg.clientId && cfg.redirectUrl),
8478 }
8579 }
8680
87 /** Build the LinkedIn consent URL and remember the CSRF state. */
88 getAuthUrl(): string {
81 /** Build the X consent URL (with PKCE) and remember the CSRF state + verifier. */
82 async getAuthUrl(): Promise<string> {
8983 const cfg = this.config()
90 if (!cfg.clientId || !cfg.clientSecret) {
91 throw new Error('Set LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET to connect with LinkedIn.')
92 }
93 if (!cfg.redirectUrl) {
94 throw new Error('Set LINKEDIN_REDIRECT_URL to connect with LinkedIn.')
95 }
84 if (!cfg.clientId) throw new Error('Set TWITTER_CLIENT_ID to connect X/Twitter.')
85 if (!cfg.redirectUrl) throw new Error('Set TWITTER_REDIRECT_URL to connect X/Twitter.')
9686
9787 this.pendingState = randomState()
98 return this.driver.getAuthUrl({
88 const { url, codeVerifier } = await this.driver.createAuthorization({
9989 clientId: cfg.clientId,
10090 redirectUrl: cfg.redirectUrl,
10191 scopes: cfg.scopes,
10292 state: this.pendingState,
10393 })
94 this.pendingVerifier = codeVerifier
95 return url
10496 }
10597
106 /** Handle the OAuth redirect: exchange the code and store the token. */
98 /** Handle the OAuth redirect: exchange the code and store the tokens. */
10799 async handleCallback(code: string, state: string) {
108100 const cfg = this.config()
109 if (!code) throw new Error('LinkedIn did not return an authorization code.')
101 if (!code) throw new Error('X/Twitter did not return an authorization code.')
110102 if (this.pendingState && state !== this.pendingState) {
111 throw new Error('LinkedIn OAuth state mismatch. Please start the connection again.')
103 throw new Error('X/Twitter OAuth state mismatch. Please start the connection again.')
112104 }
105 const codeVerifier = this.pendingVerifier
106 if (!codeVerifier) throw new Error('Missing PKCE verifier — please start the X/Twitter connection again.')
113107 this.pendingState = null
108 this.pendingVerifier = null
114109
115110 const token = await this.driver.exchangeCode({
116111 clientId: cfg.clientId,
117 clientSecret: cfg.clientSecret,
112 clientSecret: cfg.clientSecret || undefined,
118113 redirectUrl: cfg.redirectUrl,
119114 code,
115 codeVerifier,
120116 })
121117
122 const profile = await this.driver.getProfile(token.accessToken).catch(() => undefined)
123 const authorUrn = profile?.sub ? `urn:li:person:${profile.sub}` : cfg.authorUrn
124 if (!authorUrn) throw new Error('Could not resolve your LinkedIn member URN.')
125
118 const profile = await this.driver.getProfile(token.accessToken)
126119 const identity = await this.saveSession({
127120 accessToken: token.accessToken,
128 authorUrn,
129 name: profile?.name,
121 refreshToken: token.refreshToken,
122 expiresIn: token.expiresIn,
123 userId: profile.id,
124 username: profile.username,
125 name: profile.name,
130126 })
131127 return this.publicIdentity(identity)
132128 }
@@ -135,25 +131,23 @@ export class LinkedInService {
135131 async connectFromEnv() {
136132 const cfg = this.config()
137133 if (!cfg.accessToken) {
138 throw new Error('Set LINKEDIN_ACCESS_TOKEN (or connect via OAuth) before using LinkedIn.')
134 throw new Error('Set TWITTER_ACCESS_TOKEN (or connect via OAuth) before using X/Twitter.')
139135 }
140136
141 let authorUrn = cfg.authorUrn
142 let name: string | undefined
143 if (!authorUrn) {
144 const profile = await this.driver.getProfile(cfg.accessToken)
145 authorUrn = `urn:li:person:${profile.sub}`
146 name = profile.name
147 }
148
149 const identity = await this.saveSession({ accessToken: cfg.accessToken, authorUrn, name })
137 const profile = await this.driver.getProfile(cfg.accessToken)
138 const identity = await this.saveSession({
139 accessToken: cfg.accessToken,
140 userId: profile.id,
141 username: profile.username,
142 name: profile.name,
143 })
150144 return this.publicIdentity(identity)
151145 }
152146
153147 /**
154 * Publish an already-created post row to LinkedIn as a new target. Never
155 * throws — failures are recorded on the target and returned so a crosspost
156 * to other providers can still succeed.
148 * Publish an already-created post row to X/Twitter. Never throws — failures
149 * are recorded on the target and returned so a crosspost to other providers
150 * can still succeed.
157151 */
158152 async publishToPost(
159153 post: { id: number, body: string },
@@ -166,14 +160,14 @@ export class LinkedInService {
166160 identity = await this.requireIdentity()
167161 }
168162 catch (error) {
169 return { provider: 'linkedin', ok: false, error: messageOf(error) }
163 return { provider: 'twitter', ok: false, error: messageOf(error) }
170164 }
171165
172166 if (post.body.length > this.driver.characterLimit) {
173167 return {
174 provider: 'linkedin',
168 provider: 'twitter',
175169 ok: false,
176 error: `LinkedIn posts must be ${this.driver.characterLimit} characters or fewer.`,
170 error: `Twitter posts must be ${this.driver.characterLimit} characters or fewer.`,
177171 }
178172 }
179173
@@ -181,7 +175,7 @@ export class LinkedInService {
181175 const createdAt = now()
182176 await database.insertInto('post_targets').values({
183177 uuid: targetUuid,
184 provider: 'linkedin',
178 provider: 'twitter',
185179 status: 'publishing',
186180 post_id: post.id,
187181 social_driver_id: driver.id,
@@ -203,27 +197,30 @@ export class LinkedInService {
203197 accessToken: identity.access_token || undefined,
204198 }, {
205199 text: post.body,
206 ...(content?.external ? { external: content.external } : {}),
200 ...(content?.media?.length ? { media: content.media } : {}),
201 ...(content?.reply ? { reply: content.reply } : {}),
207202 })
208203
209204 await database.updateTable('post_targets').set({
210205 status: 'published',
211206 remote_uri: published.uri || null,
207 remote_cid: published.cid || null,
212208 failure_reason: null,
213209 updated_at: now(),
214210 }).where('id', '=', target.id).execute()
215211
216212 return {
217 provider: 'linkedin',
213 provider: 'twitter',
218214 ok: true,
219215 url: published.url,
220216 uri: published.uri,
217 cid: published.cid,
221218 targetId: Number(target.id),
222219 }
223220 }
224221 catch (error) {
225222 const message = messageOf(error)
226 if (error instanceof LinkedInApiError && error.isAuthError) {
223 if (error instanceof TwitterApiError && error.isAuthError) {
227224 await this.markExpired(identity.id)
228225 }
229226 await database.updateTable('post_targets').set({
@@ -232,48 +229,84 @@ export class LinkedInService {
232229 updated_at: now(),
233230 }).where('id', '=', target.id).execute()
234231
235 return { provider: 'linkedin', ok: false, error: message, targetId: Number(target.id) }
232 return { provider: 'twitter', ok: false, error: message, targetId: Number(target.id) }
236233 }
237234 }
238235
239236 private async requireIdentity(): Promise<SocialIdentityRow> {
240237 const existing = await this.findIdentity()
241 if (existing?.access_token && existing.auth_status === 'connected') return existing
238 if (existing?.access_token && existing.auth_status === 'connected') {
239 return await this.refreshIfExpiring(existing)
240 }
242241
243 // Fall back to an env-configured token if one is available.
244242 if (this.config().accessToken) {
245243 await this.connectFromEnv()
246244 const connected = await this.findIdentity()
247245 if (connected?.access_token) return connected
248246 }
249247
250 throw new Error('Connect LinkedIn before publishing.')
248 throw new Error('Connect X/Twitter before publishing.')
249 }
250
251 /**
252 * Refresh the access token when it's within its pre-expiry window and a
253 * refresh token is on file (present when connected with the `offline.access`
254 * scope). Best-effort: any failure leaves the current token in place so a real
255 * auth failure still surfaces at publish time.
256 */
257 private async refreshIfExpiring(identity: SocialIdentityRow): Promise<SocialIdentityRow> {
258 const cfg = this.config()
259 if (!identity.refresh_token || !isExpiringSoon(identity.token_expires_at)) return identity
260 if (!cfg.clientId) return identity
261
262 try {
263 const refreshed = await this.driver.refreshAccessToken({
264 clientId: cfg.clientId,
265 clientSecret: cfg.clientSecret || undefined,
266 refreshToken: identity.refresh_token,
267 })
268 const nextExpiry = expiresAt(refreshed.expiresIn)
269 // X rotates the refresh token on every refresh — always keep the new one.
270 const nextRefresh = refreshed.refreshToken ?? identity.refresh_token
271 await database.updateTable('social_identities').set({
272 access_token: refreshed.accessToken,
273 refresh_token: nextRefresh,
274 token_expires_at: nextExpiry,
275 auth_status: 'connected',
276 updated_at: now(),
277 }).where('id', '=', identity.id).execute()
278
279 return { ...identity, access_token: refreshed.accessToken, refresh_token: nextRefresh, token_expires_at: nextExpiry }
280 }
281 catch {
282 return identity
283 }
251284 }
252285
253286 private async findIdentity(): Promise<SocialIdentityRow | undefined> {
254287 return await database
255288 .selectFrom('social_identities')
256289 .selectAll()
257 .where('provider', '=', 'linkedin')
290 .where('provider', '=', 'twitter')
258291 .orderBy('updated_at', 'desc')
259292 .executeTakeFirst()
260293 }
261294
262 private async saveSession(input: { accessToken: string, authorUrn: string, name?: string }): Promise<SocialIdentityRow> {
295 private async saveSession(input: { accessToken: string, refreshToken?: string, expiresIn?: number, userId: string, username: string, name?: string }): Promise<SocialIdentityRow> {
263296 const accountId = await ensureAccount()
264297 const driver = await this.ensureDriver()
265298 const existing = await this.findIdentity()
266299 const savedAt = now()
267 const handle = (input.name || input.authorUrn.replace('urn:li:person:', '')).trim()
268300
269301 const values = {
270 handle,
271 display_name: input.name || null,
272 provider: 'linkedin',
273 external_id: input.authorUrn,
302 handle: input.username,
303 display_name: input.name || input.username,
304 provider: 'twitter',
305 external_id: input.userId,
274306 auth_status: 'connected',
275307 access_token: input.accessToken,
276 refresh_token: null,
308 refresh_token: input.refreshToken ?? null,
309 token_expires_at: expiresAt(input.expiresIn),
277310 account_id: accountId,
278311 social_driver_id: driver.id,
279312 updated_at: savedAt,
@@ -304,7 +337,7 @@ export class LinkedInService {
304337 const existing = await database
305338 .selectFrom('social_drivers')
306339 .selectAll()
307 .where('provider', '=', 'linkedin')
340 .where('provider', '=', 'twitter')
308341 .executeTakeFirst()
309342
310343 if (existing) {
@@ -326,8 +359,8 @@ export class LinkedInService {
326359 const driverUuid = uuid()
327360 await database.insertInto('social_drivers').values({
328361 uuid: driverUuid,
329 provider: 'linkedin',
330 display_name: 'LinkedIn',
362 provider: 'twitter',
363 display_name: 'X (Twitter)',
331364 status: 'active',
332365 character_limit: this.driver.characterLimit,
333366 capabilities: JSON.stringify({ posts: true, timelines: false, oauth: true }),
@@ -346,7 +379,7 @@ export class LinkedInService {
346379 if (!row) {
347380 return {
348381 connected: false,
349 provider: 'linkedin',
382 provider: 'twitter',
350383 handle: null,
351384 displayName: null,
352385 did: null,
@@ -357,7 +390,7 @@ export class LinkedInService {
357390 const connected = row.auth_status === 'connected' && Boolean(row.access_token)
358391 return {
359392 connected,
360 provider: 'linkedin',
393 provider: 'twitter',
361394 handle: row.handle,
362395 displayName: row.display_name || null,
363396 did: row.external_id || null,
@@ -370,4 +403,4 @@ function messageOf(error: unknown): string {
370403 return error instanceof Error ? error.message : String(error)
371404}
372405
373export const linkedin = new LinkedInService()
406export const twitter = new TwitterService()
app/Services/Social/support.tsmodified+23-0
Changes to app/Services/Social/support.ts
@@ -7,6 +7,29 @@ export function now(): string {
77 return new Date().toISOString().slice(0, 19).replace('T', ' ')
88}
99
10/**
11 * A SQLite `YYYY-MM-DD HH:MM:SS` (UTC) timestamp `secondsFromNow` in the
12 * future — used to record when an OAuth access token will expire. Returns null
13 * when the provider didn't report a lifetime, so callers can store NULL.
14 */
15export function expiresAt(secondsFromNow: number | null | undefined): string | null {
16 if (!secondsFromNow || secondsFromNow <= 0) return null
17 return new Date(Date.now() + secondsFromNow * 1000).toISOString().slice(0, 19).replace('T', ' ')
18}
19
20/**
21 * Whether a stored `token_expires_at` (SQLite `YYYY-MM-DD HH:MM:SS`, UTC) is
22 * already past or falls within `withinSeconds` of now — the trigger for a
23 * proactive token refresh before publishing. Unknown/blank/unparseable expiry
24 * returns false: there's nothing to refresh against, so leave the token alone.
25 */
26export function isExpiringSoon(value: string | null | undefined, withinSeconds = 7 * 24 * 60 * 60): boolean {
27 if (!value) return false
28 const expiry = Date.parse(`${String(value).replace(' ', 'T')}Z`)
29 if (Number.isNaN(expiry)) return false
30 return expiry - Date.now() <= withinSeconds * 1000
31}
32
1033export function uuid(): string {
1134 return crypto.randomUUID()
1235}
app/Support/Social/uploads.tsmodified+93-0
Changes to app/Support/Social/uploads.ts
@@ -1,3 +1,11 @@
1import { mkdir, unlink } from 'node:fs/promises'
2import { join } from 'node:path'
3import process from 'node:process'
4import { env } from '@stacksjs/env'
5
6/** Uploaded images for queued posts live here until publish. */
7export const MEDIA_DIR = join(process.cwd(), 'storage/app/postline-media')
8
19/**
210 * Read an uploaded image regardless of shape: the router's multipart
311 * parser stores raw Web `File` objects on `request.files`, while the
@@ -19,3 +27,88 @@ export async function readUploadedImage(uploaded: unknown): Promise<{ bytes: Uin
1927
2028 return null
2129}
30
31/**
32 * A stored media filename is safe to serve only when it's a bare basename with
33 * an extension and no path separators or `..` — i.e. exactly the shape
34 * QueueService writes (`<uuid>.<ext>` / `<uuid>-<hex>.<ext>`). This is the
35 * traversal guard for the public media route; anything else is rejected.
36 */
37export function isSafeMediaFilename(name: string): boolean {
38 return typeof name === 'string' && /^[\w-]+\.[a-z0-9]+$/i.test(name)
39}
40
41/** Map a media filename extension to a content type for inline serving. */
42export function mediaContentType(name: string): string {
43 const ext = name.split('.').pop()?.toLowerCase() || ''
44 switch (ext) {
45 case 'png': return 'image/png'
46 case 'gif': return 'image/gif'
47 case 'webp': return 'image/webp'
48 case 'jpg':
49 case 'jpeg': return 'image/jpeg'
50 default: return 'application/octet-stream'
51 }
52}
53
54/**
55 * Pure URL builder: given a public base and a stored filename, produce the
56 * media URL a remote platform fetches — or null when the base is empty or the
57 * filename is unsafe. Split out from `publicMediaUrl` so the URL shape is
58 * unit-testable without touching env.
59 */
60export function buildMediaUrl(base: string, filename: string): string | null {
61 if (!isSafeMediaFilename(filename)) return null
62 const raw = String(base || '').trim()
63 if (!raw) return null
64
65 const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`
66 const normalized = withScheme.replace(/\/+$/, '')
67 return `${normalized}/postline/media?file=${encodeURIComponent(filename)}`
68}
69
70/**
71 * The public URL Instagram/Threads fetch (via the Meta Graph API) for a stored
72 * media file. Meta pulls the image server-side, so it must be reachable over
73 * the public internet — returns null when no public base is configured
74 * (`STORAGE_PUBLIC_URL` or `APP_URL`), in which case callers fall back to
75 * byte-upload providers only.
76 */
77export function publicMediaUrl(filename: string): string | null {
78 return buildMediaUrl(String(env.STORAGE_PUBLIC_URL || env.APP_URL || ''), filename)
79}
80
81/** Whether a public base is configured — i.e. whether URL-only providers can be
82 * handed a fetchable image URL at all. */
83export function hasPublicMediaBase(): boolean {
84 return Boolean(String(env.STORAGE_PUBLIC_URL || env.APP_URL || '').trim())
85}
86
87/**
88 * Persist uploaded bytes under a fresh name so a URL-only provider (Instagram/
89 * Threads) can fetch them during an immediate publish, returning the file's
90 * public URL. Returns null when no public base is configured (nothing is
91 * written — there'd be no fetchable URL). The caller MUST `removeTempMedia` the
92 * returned filename once publishing finishes.
93 */
94export async function persistTempMedia(bytes: Uint8Array, mimeType?: string): Promise<{ filename: string, url: string } | null> {
95 if (!hasPublicMediaBase()) return null
96
97 const ext = (mimeType || 'image/jpeg').split('/')[1]?.replace(/[^a-z0-9]/gi, '') || 'jpg'
98 const filename = `tmp-${crypto.randomUUID()}.${ext}`
99 await mkdir(MEDIA_DIR, { recursive: true })
100 await Bun.write(join(MEDIA_DIR, filename), bytes)
101
102 const url = publicMediaUrl(filename)
103 if (!url) {
104 await removeTempMedia(filename)
105 return null
106 }
107 return { filename, url }
108}
109
110/** Delete a file previously written by `persistTempMedia`. Never throws. */
111export async function removeTempMedia(filename: string): Promise<void> {
112 if (!isSafeMediaFilename(filename)) return
113 await unlink(join(MEDIA_DIR, filename)).catch(() => {})
114}
database/migrations/1784890991-add-token_expires_at-to-social_identities.sqladded+5-0
Changes to database/migrations/1784890991-add-token_expires_at-to-social_identities.sql
@@ -0,0 +1,5 @@
1-- Track OAuth access-token expiry so LinkedIn / Threads / Instagram tokens can
2-- be proactively refreshed (or exchanged for long-lived ones) before they
3-- lapse, instead of only failing at publish time. NULL means "no known expiry"
4-- (e.g. Bluesky app passwords, Mastodon tokens, env-configured tokens).
5ALTER TABLE "social_identities" ADD COLUMN "token_expires_at" TEXT;
resources/views/accounts.stxmodified+109-6
Changes to resources/views/accounts.stx
@@ -12,7 +12,7 @@
1212 <div>
1313 <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Accounts</p>
1414 <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Publishing drivers</h1>
15 <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">Connect Bluesky, LinkedIn, Instagram, Threads, and Mastodon to publish and crosspost from Postline.</p>
15 <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">Connect Bluesky, X, LinkedIn, Instagram, Threads, and Mastodon to publish and crosspost from Postline.</p>
1616 </div>
1717 </header>
1818
@@ -97,13 +97,17 @@
9797 <p class="sm:col-span-3 m-0 text-xs text-zinc-500">Create a token in your instance's Preferences → Development → New application (needs the <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">write:statuses</code> + <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">write:media</code> scopes).</p>
9898 </form>
9999 </article>
100 <article class="flex gap-5 items-center justify-between p-3.5 bg-zinc-50/80 border border-zinc-200 rounded-xl opacity-80">
100 <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="twitter">
101101 <div>
102 <h2 class="my-0 font-bold text-[15px] text-zinc-700">Twitter/X</h2>
103 <p class="my-1.5 text-[13px] text-zinc-600">Planned</p>
104 <span class="text-xs text-zinc-500">Driver adapter coming after the current set is stable.</span>
102 <h2 class="my-0 font-bold text-[15px]">X (Twitter)</h2>
103 <p class="my-1.5 text-[13px] text-slate-700" data-twitter-channel-handle>Not connected</p>
104 <span class="text-xs text-zinc-500" data-twitter-channel-detail>Connect X to crosspost tweets. Posting needs a paid X API tier.</span>
105 </div>
106 <div class="flex flex-col gap-2 items-end">
107 <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-twitter-channel-status>Setup</span>
108 <button class="inline-flex items-center justify-center px-3 h-9 font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl disabled:opacity-50 transition-colors" type="button" data-twitter-connect>Connect with X</button>
109 <button class="hidden text-xs text-zinc-500 underline hover:text-zinc-700 transition-colors" type="button" data-twitter-connect-env>Use token from .env</button>
105110 </div>
106 <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full">Planned</span>
107111 </article>
108112 </div>
109113 </section>
@@ -289,6 +293,105 @@
289293 startLinkedIn()
290294 }
291295
296 // --- X (Twitter) ---
297 const twitterHandle = root.querySelector('[data-twitter-channel-handle]')
298 const twitterDetail = root.querySelector('[data-twitter-channel-detail]')
299 const twitterStatusPill = root.querySelector('[data-twitter-channel-status]')
300 const twitterConnect = root.querySelector('[data-twitter-connect]')
301 const twitterConnectEnv = root.querySelector('[data-twitter-connect-env]')
302
303 const renderTwitter = (data) => {
304 const connected = Boolean(data?.canPublish)
305 const label = data?.handle ? `@${data.handle}` : 'Not connected'
306 if (twitterHandle) {
307 twitterHandle.textContent = connected ? label : (data?.handle ? `${label} — reconnect` : 'Not connected')
308 }
309 if (twitterDetail) {
310 twitterDetail.textContent = connected
311 ? 'Ready to crosspost tweets from Postline.'
312 : data?.oauthConfigured
313 ? 'Connect X to crosspost tweets. Posting needs a paid X API tier.'
314 : data?.configuredFromEnv
315 ? 'Token found in .env — connect to start crossposting.'
316 : 'Add X (Twitter) API keys to .env, then connect.'
317 }
318 if (twitterStatusPill) {
319 twitterStatusPill.textContent = connected ? 'Connected' : (data?.handle ? 'Reconnect' : 'Setup')
320 twitterStatusPill.className = `whitespace-nowrap rounded-full border px-2.5 py-1 text-[11px] font-bold ${
321 connected
322 ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
323 : data?.handle
324 ? 'border-amber-200 bg-amber-50 text-amber-800'
325 : 'border-zinc-200 text-zinc-500'
326 }`
327 }
328 if (twitterConnect) {
329 twitterConnect.textContent = connected ? 'Reconnect X' : 'Connect with X'
330 if (data && !data.oauthConfigured) twitterConnect.setAttribute('disabled', 'disabled')
331 else twitterConnect.removeAttribute('disabled')
332 }
333 if (twitterConnectEnv) twitterConnectEnv.classList.toggle('hidden', !data?.configuredFromEnv)
334 }
335
336 const refreshTwitter = async () => {
337 try {
338 const response = await fetch('/api/postline/twitter/status')
339 const payload = await response.json()
340 if (!response.ok || !payload.ok) throw new Error(payload.error || 'Could not load X/Twitter status.')
341 renderTwitter(payload.data)
342 }
343 catch (error) {
344 renderTwitter(null)
345 notify(error instanceof Error ? error.message : String(error), 'error', { title: 'X/Twitter' })
346 }
347 }
348
349 twitterConnect?.addEventListener('click', () => {
350 notify('Redirecting to X…', 'info', { title: 'X/Twitter' })
351 window.location.href = '/api/postline/twitter/auth'
352 })
353
354 twitterConnectEnv?.addEventListener('click', async () => {
355 twitterConnectEnv.setAttribute('disabled', 'disabled')
356 notify('Connecting X from .env…', 'info', { title: 'X/Twitter' })
357 try {
358 const response = await fetch('/api/postline/twitter/connect', { method: 'POST' })
359 const payload = await response.json()
360 if (!response.ok || !payload.ok) throw new Error(payload.error || 'X connect failed.')
361 await refreshTwitter()
362 notify('X connected. You can crosspost from the composer.', 'success', { title: 'Connected' })
363 }
364 catch (error) {
365 notify(error instanceof Error ? error.message : String(error), 'error', { title: 'Connect failed' })
366 }
367 finally {
368 twitterConnectEnv.removeAttribute('disabled')
369 }
370 })
371
372 const twitterParams = new URLSearchParams(window.location.search)
373 const twitterResult = twitterParams.get('twitter')
374 if (twitterResult === 'connected') {
375 notify('X connected. You can crosspost from the composer.', 'success', { title: 'Connected' })
376 }
377 else if (twitterResult === 'error') {
378 notify(twitterParams.get('message') || 'X connection failed.', 'error', { title: 'X/Twitter' })
379 }
380 if (twitterResult) {
381 twitterParams.delete('twitter')
382 twitterParams.delete('message')
383 const query = twitterParams.toString()
384 window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}`)
385 }
386
387 const startTwitter = () => void refreshTwitter()
388 if (document.readyState === 'loading') {
389 document.addEventListener('DOMContentLoaded', startTwitter, { once: true })
390 }
391 else {
392 startTwitter()
393 }
394
292395 // --- Instagram ---
293396 const instagramHandle = root.querySelector('[data-instagram-channel-handle]')
294397 const instagramDetail = root.querySelector('[data-instagram-channel-detail]')
routes/api.tsmodified+12-0
Changes to routes/api.ts
@@ -23,6 +23,11 @@ route.post('/register', 'Actions/Postline/RegisterFirstUserAction').skipCsrf().r
2323route.get('/', () => response.text('hello world'))
2424route.get('/coming-soon', 'Controllers/ComingSoonController@index')
2525
26// Public: Instagram/Threads fetch a queued post's image server-side, so this
27// route is intentionally unauthenticated. The filename is an unguessable
28// per-upload UUID and strictly validated against traversal (MediaServeAction).
29route.get('/postline/media', 'Actions/Postline/MediaServeAction').skipCsrf()
30
2631route.group({ prefix: '/postline/bluesky' }, () => {
2732 route.get('/status', 'Actions/Postline/BlueskyStatusAction').middleware('auth').skipCsrf()
2833 route.post('/connect', 'Actions/Postline/BlueskyConnectAction').middleware('auth').skipCsrf()
@@ -52,6 +57,13 @@ route.group({ prefix: '/postline/threads' }, () => {
5257 route.get('/callback', 'Actions/Postline/ThreadsCallbackAction').skipCsrf()
5358})
5459
60route.group({ prefix: '/postline/twitter' }, () => {
61 route.get('/status', 'Actions/Postline/TwitterStatusAction').middleware('auth').skipCsrf()
62 route.post('/connect', 'Actions/Postline/TwitterConnectAction').middleware('auth').skipCsrf()
63 route.get('/auth', 'Actions/Postline/TwitterAuthAction').skipCsrf()
64 route.get('/callback', 'Actions/Postline/TwitterCallbackAction').skipCsrf()
65})
66
5567route.group({ prefix: '/postline/mastodon' }, () => {
5668 route.get('/status', 'Actions/Postline/MastodonStatusAction').middleware('auth').skipCsrf()
5769 route.post('/connect', 'Actions/Postline/MastodonConnectAction').middleware('auth').skipCsrf()
tests/unit/instagram-driver.test.tsadded+47-0
Changes to tests/unit/instagram-driver.test.ts
@@ -0,0 +1,47 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { InstagramApiError, InstagramDriver } from '../../app/Services/Social/Drivers/InstagramDriver'
3
4const realFetch = globalThis.fetch
5afterEach(() => { globalThis.fetch = realFetch })
6
7interface Captured { url: string, method: string }
8
9function mockGraphApi(captured: Captured[], overrides: (url: string) => Response | null = () => null): void {
10 globalThis.fetch = (async (input: any, init: any = {}) => {
11 const url = String(input)
12 const over = overrides(url)
13 if (over) { captured.push({ url, method: init.method || 'GET' }); return over }
14
15 if (url.includes('fb_exchange_token')) {
16 captured.push({ url, method: init.method || 'GET' })
17 return new Response(JSON.stringify({ access_token: 'LONG_LIVED_USER', token_type: 'bearer', expires_in: 5184000 }), { status: 200 })
18 }
19 captured.push({ url, method: init.method || 'GET' })
20 return new Response('{}', { status: 404 })
21 }) as typeof fetch
22}
23
24describe('InstagramDriver.exchangeLongLivedUserToken', () => {
25 test('exchanges a short-lived user token for a long-lived one', async () => {
26 const cap: Captured[] = []; mockGraphApi(cap)
27 const driver = new InstagramDriver()
28 const result = await driver.exchangeLongLivedUserToken('client-1', 'secret-2', 'short-user-token')
29
30 expect(result).toEqual({ accessToken: 'LONG_LIVED_USER', expiresIn: 5184000 })
31 const call = cap.find(c => c.url.includes('fb_exchange_token'))
32 expect(call?.url).toContain('graph.facebook.com')
33 expect(call?.url).toContain('/oauth/access_token')
34 expect(call?.url).toContain('client_id=client-1')
35 expect(call?.url).toContain('fb_exchange_token=short-user-token')
36 })
37
38 test('surfaces a Graph error as InstagramApiError', async () => {
39 const cap: Captured[] = []
40 mockGraphApi(cap, () => new Response(JSON.stringify({ error: { message: 'invalid token' } }), { status: 400 }))
41 const driver = new InstagramDriver()
42 let caught: unknown
43 await driver.exchangeLongLivedUserToken('c', 's', 'bad').catch((e) => { caught = e })
44 expect(caught).toBeInstanceOf(InstagramApiError)
45 expect((caught as InstagramApiError).status).toBe(400)
46 })
47})
tests/unit/linkedin-driver.test.tsadded+215-0
Changes to tests/unit/linkedin-driver.test.ts
@@ -0,0 +1,215 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { LinkedInApiError, LinkedInDriver } from '../../app/Services/Social/Drivers/LinkedInDriver'
3
4const realFetch = globalThis.fetch
5afterEach(() => { globalThis.fetch = realFetch })
6
7const identity = { handle: 'glenn', did: 'urn:li:person:ABC', accessToken: 'tok' }
8
9interface Captured { url: string, method: string, body: any, headers: any }
10
11/**
12 * Mock LinkedIn's versioned REST API. Recognizes the three calls an image post
13 * makes — initializeUpload, the binary PUT, and the /rest/posts create — plus a
14 * bytes fallback for any other GET (used when the driver fetches a media URL).
15 */
16function mockLinkedInApi(captured: Captured[], overrides: (url: string, method: string) => Response | null = () => null): void {
17 globalThis.fetch = (async (input: any, init: any = {}) => {
18 const url = String(input)
19 const method = init.method || 'GET'
20
21 const over = overrides(url, method)
22 if (over) { captured.push({ url, method, body: init.body, headers: init.headers }); return over }
23
24 if (url.includes('action=initializeUpload')) {
25 captured.push({ url, method, body: init.body, headers: init.headers })
26 return new Response(
27 JSON.stringify({ value: { uploadUrl: 'https://upload.linkedin.example/img', image: 'urn:li:image:IMG123' } }),
28 { status: 200 },
29 )
30 }
31 if (method === 'PUT') {
32 captured.push({ url, method, body: init.body, headers: init.headers })
33 return new Response(null, { status: 201 })
34 }
35 if (url.endsWith('/rest/posts')) {
36 const body = init.body ? JSON.parse(String(init.body)) : undefined
37 captured.push({ url, method, body, headers: init.headers })
38 return new Response('', { status: 201, headers: { 'x-restli-id': 'urn:li:share:987' } })
39 }
40 // Fallback: fetching a remote media URL — hand back a few bytes.
41 captured.push({ url, method, body: undefined, headers: init.headers })
42 return new Response(new Uint8Array([9, 9, 9]) as any, { status: 200, headers: { 'content-type': 'image/png' } })
43 }) as typeof fetch
44}
45
46function postRequest(captured: Captured[]): Captured | undefined {
47 return captured.find(c => c.url.endsWith('/rest/posts'))
48}
49
50describe('LinkedInDriver.publish', () => {
51 test('publishes a plain text share with no content block', async () => {
52 const cap: Captured[] = []; mockLinkedInApi(cap)
53 const driver = new LinkedInDriver()
54 const result = await driver.publish(identity, { text: 'hello linkedin' })
55
56 const post = postRequest(cap)
57 expect(post?.body.commentary).toBe('hello linkedin')
58 expect(post?.body.content).toBeUndefined()
59 expect(cap.find(c => c.url.includes('initializeUpload'))).toBeUndefined()
60 expect(result.uri).toBe('urn:li:share:987')
61 expect(result.url).toContain('linkedin.com/feed/update/urn:li:share:987')
62 })
63
64 test('attaches an article link card when external is provided and no image', async () => {
65 const cap: Captured[] = []; mockLinkedInApi(cap)
66 const driver = new LinkedInDriver()
67 await driver.publish(identity, {
68 text: 'read this',
69 external: { uri: 'https://example.com', title: 'Example', description: 'Desc' },
70 })
71
72 const post = postRequest(cap)
73 expect(post?.body.content.article.source).toBe('https://example.com')
74 expect(post?.body.content.media).toBeUndefined()
75 expect(cap.find(c => c.url.includes('initializeUpload'))).toBeUndefined()
76 })
77
78 test('uploads image bytes and references the URN under content.media', async () => {
79 const cap: Captured[] = []; mockLinkedInApi(cap)
80 const driver = new LinkedInDriver()
81 await driver.publish(identity, {
82 text: 'photo',
83 media: [{ bytes: new Uint8Array([1, 2, 3]), mimeType: 'image/png', altText: 'a chart' }],
84 })
85
86 const init = cap.find(c => c.url.includes('initializeUpload'))
87 expect(init).toBeDefined()
88 expect(JSON.parse(String(init!.body)).initializeUploadRequest.owner).toBe('urn:li:person:ABC')
89
90 const put = cap.find(c => c.method === 'PUT')
91 expect(put?.url).toBe('https://upload.linkedin.example/img')
92
93 const post = postRequest(cap)
94 expect(post?.body.content.media.id).toBe('urn:li:image:IMG123')
95 expect(post?.body.content.media.altText).toBe('a chart')
96 expect(post?.body.content.article).toBeUndefined()
97 })
98
99 test('fetches a media URL to bytes when only a url is given', async () => {
100 const cap: Captured[] = []; mockLinkedInApi(cap)
101 const driver = new LinkedInDriver()
102 await driver.publish(identity, { text: 'via url', media: [{ url: 'https://cdn.example/pic.png' }] })
103
104 expect(cap.find(c => c.url === 'https://cdn.example/pic.png')).toBeDefined()
105 expect(cap.find(c => c.url.includes('initializeUpload'))).toBeDefined()
106 expect(postRequest(cap)?.body.content.media.id).toBe('urn:li:image:IMG123')
107 })
108
109 test('an attached image supersedes an article link card', async () => {
110 const cap: Captured[] = []; mockLinkedInApi(cap)
111 const driver = new LinkedInDriver()
112 await driver.publish(identity, {
113 text: 'both',
114 external: { uri: 'https://example.com', title: 'E' },
115 media: [{ bytes: new Uint8Array([1]), mimeType: 'image/jpeg' }],
116 })
117
118 const post = postRequest(cap)
119 expect(post?.body.content.media.id).toBe('urn:li:image:IMG123')
120 expect(post?.body.content.article).toBeUndefined()
121 })
122
123 test('rejects text over the character limit before calling the API', async () => {
124 const cap: Captured[] = []; mockLinkedInApi(cap)
125 const driver = new LinkedInDriver()
126 await expect(driver.publish(identity, { text: 'x'.repeat(3001) })).rejects.toThrow(/3000 characters/)
127 expect(cap).toHaveLength(0)
128 })
129
130 test('rejects when the identity has no member URN', async () => {
131 const cap: Captured[] = []; mockLinkedInApi(cap)
132 const driver = new LinkedInDriver()
133 await expect(driver.publish({ handle: 'glenn', accessToken: 'tok' }, { text: 'hi' })).rejects.toThrow(/member URN/)
134 expect(cap).toHaveLength(0)
135 })
136
137 test('rejects when the identity has no access token', async () => {
138 const cap: Captured[] = []; mockLinkedInApi(cap)
139 const driver = new LinkedInDriver()
140 await expect(driver.publish({ handle: 'glenn', did: 'urn:li:person:ABC' }, { text: 'hi' })).rejects.toThrow(/access token/)
141 expect(cap).toHaveLength(0)
142 })
143
144 test('surfaces a posts API error as LinkedInApiError with status', async () => {
145 const cap: Captured[] = []
146 mockLinkedInApi(cap, url => url.endsWith('/rest/posts') ? new Response('{"message":"nope"}', { status: 401 }) : null)
147 const driver = new LinkedInDriver()
148 let caught: unknown
149 await driver.publish(identity, { text: 'hi' }).catch((e) => { caught = e })
150 expect(caught).toBeInstanceOf(LinkedInApiError)
151 expect((caught as LinkedInApiError).status).toBe(401)
152 expect((caught as LinkedInApiError).isAuthError).toBe(true)
153 })
154
155 test('surfaces an image upload failure as LinkedInApiError', async () => {
156 const cap: Captured[] = []
157 mockLinkedInApi(cap, (_url, method) => method === 'PUT' ? new Response('fail', { status: 500 }) : null)
158 const driver = new LinkedInDriver()
159 let caught: unknown
160 await driver.publish(identity, { text: 'photo', media: [{ bytes: new Uint8Array([1, 2, 3]), mimeType: 'image/png' }] })
161 .catch((e) => { caught = e })
162 expect(caught).toBeInstanceOf(LinkedInApiError)
163 expect((caught as LinkedInApiError).status).toBe(500)
164 // The post itself must never be created when its image failed to upload.
165 expect(postRequest(cap)).toBeUndefined()
166 })
167})
168
169interface TokenCall { url: string, body: string }
170
171function mockTokenEndpoint(captured: TokenCall[], payload: Record<string, unknown>, status = 200): void {
172 globalThis.fetch = (async (input: any, init: any = {}) => {
173 captured.push({ url: String(input), body: String(init.body || '') })
174 return new Response(JSON.stringify(payload), { status })
175 }) as typeof fetch
176}
177
178describe('LinkedInDriver token exchange + refresh', () => {
179 test('exchangeCode surfaces the refresh token the base driver drops', async () => {
180 const cap: TokenCall[] = []
181 mockTokenEndpoint(cap, { access_token: 'AT1', expires_in: 5184000, refresh_token: 'RT1', refresh_token_expires_in: 31536000 })
182 const driver = new LinkedInDriver()
183
184 const token = await driver.exchangeCode({ clientId: 'c', clientSecret: 's', redirectUrl: 'https://app/cb', code: 'abc' })
185
186 expect(cap[0].url).toContain('/oauth/v2/accessToken')
187 expect(cap[0].body).toContain('grant_type=authorization_code')
188 expect(token.accessToken).toBe('AT1')
189 expect(token.refreshToken).toBe('RT1')
190 expect(token.expiresIn).toBe(5184000)
191 })
192
193 test('refreshAccessToken mints a new token from a refresh token', async () => {
194 const cap: TokenCall[] = []
195 mockTokenEndpoint(cap, { access_token: 'AT2', expires_in: 5184000, refresh_token: 'RT2' })
196 const driver = new LinkedInDriver()
197
198 const token = await driver.refreshAccessToken({ clientId: 'c', clientSecret: 's', refreshToken: 'RT1' })
199
200 expect(cap[0].body).toContain('grant_type=refresh_token')
201 expect(cap[0].body).toContain('refresh_token=RT1')
202 expect(token.accessToken).toBe('AT2')
203 expect(token.refreshToken).toBe('RT2')
204 })
205
206 test('refreshAccessToken throws when LinkedIn returns no token', async () => {
207 const cap: TokenCall[] = []
208 mockTokenEndpoint(cap, { error: 'invalid_grant' }, 400)
209 const driver = new LinkedInDriver()
210 let caught: unknown
211 await driver.refreshAccessToken({ clientId: 'c', clientSecret: 's', refreshToken: 'stale' }).catch((e) => { caught = e })
212 expect(caught).toBeInstanceOf(LinkedInApiError)
213 expect((caught as LinkedInApiError).status).toBe(400)
214 })
215})
tests/unit/media-serve.test.tsadded+63-0
Changes to tests/unit/media-serve.test.ts
@@ -0,0 +1,63 @@
1import { describe, expect, test } from 'bun:test'
2import { buildMediaUrl, isSafeMediaFilename, mediaContentType } from '../../app/Support/Social/uploads'
3
4describe('isSafeMediaFilename', () => {
5 test('accepts the UUID basenames QueueService writes', () => {
6 expect(isSafeMediaFilename('9f8c2e1a-1234-4abc-9def-0123456789ab.jpg')).toBe(true)
7 expect(isSafeMediaFilename('9f8c2e1a-1234-4abc-9def-0123456789ab-1a2b3c4d.png')).toBe(true)
8 expect(isSafeMediaFilename('photo123.webp')).toBe(true)
9 })
10
11 test('rejects path traversal and separators', () => {
12 expect(isSafeMediaFilename('../secret.jpg')).toBe(false)
13 expect(isSafeMediaFilename('..%2Fsecret.jpg')).toBe(false)
14 expect(isSafeMediaFilename('sub/dir/photo.jpg')).toBe(false)
15 expect(isSafeMediaFilename('/etc/passwd')).toBe(false)
16 expect(isSafeMediaFilename('photo.jpg/../../x')).toBe(false)
17 })
18
19 test('rejects missing extension, empty, and non-strings', () => {
20 expect(isSafeMediaFilename('noextension')).toBe(false)
21 expect(isSafeMediaFilename('')).toBe(false)
22 expect(isSafeMediaFilename('.hidden')).toBe(false)
23 expect(isSafeMediaFilename(undefined as unknown as string)).toBe(false)
24 })
25})
26
27describe('mediaContentType', () => {
28 test('maps known image extensions', () => {
29 expect(mediaContentType('a.jpg')).toBe('image/jpeg')
30 expect(mediaContentType('a.jpeg')).toBe('image/jpeg')
31 expect(mediaContentType('a.PNG')).toBe('image/png')
32 expect(mediaContentType('a.gif')).toBe('image/gif')
33 expect(mediaContentType('a.webp')).toBe('image/webp')
34 })
35
36 test('falls back to octet-stream for unknown extensions', () => {
37 expect(mediaContentType('a.bin')).toBe('application/octet-stream')
38 })
39})
40
41describe('buildMediaUrl', () => {
42 test('builds a media URL from an https base', () => {
43 expect(buildMediaUrl('https://posts.example.com', 'a.jpg'))
44 .toBe('https://posts.example.com/postline/media?file=a.jpg')
45 })
46
47 test('adds https:// when the base has no scheme, and strips trailing slashes', () => {
48 expect(buildMediaUrl('posts.example.com', 'a.jpg'))
49 .toBe('https://posts.example.com/postline/media?file=a.jpg')
50 expect(buildMediaUrl('https://posts.example.com//', 'a.jpg'))
51 .toBe('https://posts.example.com/postline/media?file=a.jpg')
52 })
53
54 test('returns null when the base is empty', () => {
55 expect(buildMediaUrl('', 'a.jpg')).toBeNull()
56 expect(buildMediaUrl(' ', 'a.jpg')).toBeNull()
57 })
58
59 test('returns null (never a traversing URL) for an unsafe filename', () => {
60 expect(buildMediaUrl('https://posts.example.com', '../../etc/passwd')).toBeNull()
61 expect(buildMediaUrl('https://posts.example.com', 'sub/dir.jpg')).toBeNull()
62 })
63})
tests/unit/threads-driver.test.tsadded+61-0
Changes to tests/unit/threads-driver.test.ts
@@ -0,0 +1,61 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { ThreadsApiError, ThreadsDriver } from '../../app/Services/Social/Drivers/ThreadsDriver'
3
4const realFetch = globalThis.fetch
5afterEach(() => { globalThis.fetch = realFetch })
6
7interface Captured { url: string, method: string }
8
9function mockThreadsTokenApi(captured: Captured[], overrides: (url: string) => Response | null = () => null): void {
10 globalThis.fetch = (async (input: any, init: any = {}) => {
11 const url = String(input)
12 const over = overrides(url)
13 if (over) { captured.push({ url, method: init.method || 'GET' }); return over }
14
15 if (url.includes('th_exchange_token')) {
16 captured.push({ url, method: init.method || 'GET' })
17 return new Response(JSON.stringify({ access_token: 'LONG_LIVED', token_type: 'bearer', expires_in: 5184000 }), { status: 200 })
18 }
19 if (url.includes('th_refresh_token')) {
20 captured.push({ url, method: init.method || 'GET' })
21 return new Response(JSON.stringify({ access_token: 'REFRESHED', token_type: 'bearer', expires_in: 5184000 }), { status: 200 })
22 }
23 captured.push({ url, method: init.method || 'GET' })
24 return new Response('{}', { status: 404 })
25 }) as typeof fetch
26}
27
28describe('ThreadsDriver token lifecycle', () => {
29 test('exchangeLongLivedToken swaps a short-lived token for a ~60-day one', async () => {
30 const cap: Captured[] = []; mockThreadsTokenApi(cap)
31 const driver = new ThreadsDriver()
32 const result = await driver.exchangeLongLivedToken('secret-xyz', 'short-token')
33
34 expect(result).toEqual({ accessToken: 'LONG_LIVED', expiresIn: 5184000 })
35 const call = cap.find(c => c.url.includes('th_exchange_token'))
36 expect(call?.url).toContain('graph.threads.net/access_token')
37 expect(call?.url).toContain('client_secret=secret-xyz')
38 expect(call?.url).toContain('access_token=short-token')
39 })
40
41 test('refreshLongLivedToken extends an existing long-lived token', async () => {
42 const cap: Captured[] = []; mockThreadsTokenApi(cap)
43 const driver = new ThreadsDriver()
44 const result = await driver.refreshLongLivedToken('long-token')
45
46 expect(result).toEqual({ accessToken: 'REFRESHED', expiresIn: 5184000 })
47 const call = cap.find(c => c.url.includes('th_refresh_token'))
48 expect(call?.url).toContain('graph.threads.net/refresh_access_token')
49 expect(call?.url).toContain('access_token=long-token')
50 })
51
52 test('surfaces a token error as ThreadsApiError with status', async () => {
53 const cap: Captured[] = []
54 mockThreadsTokenApi(cap, () => new Response(JSON.stringify({ error: { message: 'bad token' } }), { status: 400 }))
55 const driver = new ThreadsDriver()
56 let caught: unknown
57 await driver.refreshLongLivedToken('nope').catch((e) => { caught = e })
58 expect(caught).toBeInstanceOf(ThreadsApiError)
59 expect((caught as ThreadsApiError).status).toBe(400)
60 })
61})
tests/unit/twitter-driver.test.tsadded+160-0
Changes to tests/unit/twitter-driver.test.ts
@@ -0,0 +1,160 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { TwitterApiError, TwitterDriver } from '../../app/Services/Social/Drivers/TwitterDriver'
3
4const realFetch = globalThis.fetch
5afterEach(() => { globalThis.fetch = realFetch })
6
7const identity = { handle: 'glenn', did: '77', accessToken: 'AT' }
8
9interface Captured { url: string, method: string, body: any, headers: any }
10
11function mockTwitterApi(captured: Captured[], overrides: (url: string) => Response | null = () => null): void {
12 globalThis.fetch = (async (input: any, init: any = {}) => {
13 const url = String(input)
14 const method = init.method || 'GET'
15 const over = overrides(url)
16 if (over) { captured.push({ url, method, body: init.body, headers: init.headers }); return over }
17
18 if (url.includes('/2/oauth2/token')) {
19 captured.push({ url, method, body: init.body, headers: init.headers })
20 return new Response(JSON.stringify({ token_type: 'bearer', expires_in: 7200, access_token: 'NEW_AT', refresh_token: 'NEW_RT', scope: 'tweet.write' }), { status: 200 })
21 }
22 if (url.includes('/2/users/me')) {
23 captured.push({ url, method, body: init.body, headers: init.headers })
24 return new Response(JSON.stringify({ data: { id: '77', username: 'glenn', name: 'Glenn' } }), { status: 200 })
25 }
26 if (url.includes('/2/media/upload')) {
27 captured.push({ url, method, body: init.body, headers: init.headers })
28 return new Response(JSON.stringify({ data: { id: 'media123' } }), { status: 200 })
29 }
30 // /2/tweets
31 const body = init.body ? JSON.parse(String(init.body)) : undefined
32 captured.push({ url, method, body, headers: init.headers })
33 return new Response(JSON.stringify({ data: { id: 'tweet999' } }), { status: 200 })
34 }) as typeof fetch
35}
36
37function tweetRequest(captured: Captured[]): Captured | undefined {
38 return captured.find(c => c.url.endsWith('/2/tweets'))
39}
40
41describe('TwitterDriver.createAuthorization (PKCE)', () => {
42 test('builds a consent URL with an S256 challenge and returns the verifier', async () => {
43 const driver = new TwitterDriver()
44 const { url, codeVerifier } = await driver.createAuthorization({
45 clientId: 'cid',
46 redirectUrl: 'https://app.example/cb',
47 scopes: ['tweet.read', 'tweet.write', 'offline.access'],
48 state: 'st8',
49 })
50
51 expect(url).toContain('twitter.com/i/oauth2/authorize')
52 expect(url).toContain('client_id=cid')
53 expect(url).toContain('state=st8')
54 expect(url).toContain('code_challenge=')
55 expect(url).toContain('code_challenge_method=S256')
56 expect(url).toContain('scope=tweet.read+tweet.write+offline.access')
57 expect(codeVerifier.length).toBeGreaterThan(20)
58 })
59})
60
61describe('TwitterDriver tokens', () => {
62 test('exchangeCode posts the code + PKCE verifier and returns tokens', async () => {
63 const cap: Captured[] = []; mockTwitterApi(cap)
64 const driver = new TwitterDriver()
65 const token = await driver.exchangeCode({ clientId: 'cid', redirectUrl: 'https://app/cb', code: 'abc', codeVerifier: 'ver123' })
66
67 const call = cap.find(c => c.url.includes('/2/oauth2/token'))
68 expect(String(call?.body)).toContain('grant_type=authorization_code')
69 expect(String(call?.body)).toContain('code_verifier=ver123')
70 expect(token.accessToken).toBe('NEW_AT')
71 expect(token.refreshToken).toBe('NEW_RT')
72 expect(token.expiresIn).toBe(7200)
73 })
74
75 test('exchangeCode adds HTTP Basic auth for a confidential client', async () => {
76 const cap: Captured[] = []; mockTwitterApi(cap)
77 const driver = new TwitterDriver()
78 await driver.exchangeCode({ clientId: 'cid', clientSecret: 'secret', redirectUrl: 'https://app/cb', code: 'abc', codeVerifier: 'v' })
79
80 const call = cap.find(c => c.url.includes('/2/oauth2/token'))
81 expect(call?.headers.authorization).toBe(`Basic ${btoa('cid:secret')}`)
82 })
83
84 test('refreshAccessToken uses the refresh_token grant', async () => {
85 const cap: Captured[] = []; mockTwitterApi(cap)
86 const driver = new TwitterDriver()
87 const token = await driver.refreshAccessToken({ clientId: 'cid', refreshToken: 'RT1' })
88
89 const call = cap.find(c => c.url.includes('/2/oauth2/token'))
90 expect(String(call?.body)).toContain('grant_type=refresh_token')
91 expect(String(call?.body)).toContain('refresh_token=RT1')
92 expect(token.accessToken).toBe('NEW_AT')
93 })
94
95 test('getProfile resolves the authenticated account', async () => {
96 const cap: Captured[] = []; mockTwitterApi(cap)
97 const driver = new TwitterDriver()
98 const profile = await driver.getProfile('AT')
99 expect(cap[0].url).toContain('/2/users/me')
100 expect(profile).toEqual({ id: '77', username: 'glenn', name: 'Glenn' })
101 })
102})
103
104describe('TwitterDriver.publish', () => {
105 test('posts a plain tweet and returns the id as both uri and cid', async () => {
106 const cap: Captured[] = []; mockTwitterApi(cap)
107 const driver = new TwitterDriver()
108 const result = await driver.publish(identity, { text: 'hello world' })
109
110 expect(tweetRequest(cap)?.body.text).toBe('hello world')
111 expect(result.uri).toBe('tweet999')
112 expect(result.cid).toBe('tweet999')
113 expect(result.url).toContain('x.com/glenn/status/tweet999')
114 expect(cap.find(c => c.url.includes('/2/media/upload'))).toBeUndefined()
115 })
116
117 test('chains a reply via in_reply_to_tweet_id', async () => {
118 const cap: Captured[] = []; mockTwitterApi(cap)
119 const driver = new TwitterDriver()
120 await driver.publish(identity, {
121 text: 'second',
122 reply: { root: { uri: 't1', cid: 't1' }, parent: { uri: 't1', cid: 't1' } },
123 })
124 expect(tweetRequest(cap)?.body.reply.in_reply_to_tweet_id).toBe('t1')
125 })
126
127 test('uploads image bytes and attaches media_ids', async () => {
128 const cap: Captured[] = []; mockTwitterApi(cap)
129 const driver = new TwitterDriver()
130 await driver.publish(identity, { text: 'photo', media: [{ bytes: new Uint8Array([1, 2, 3]), mimeType: 'image/png' }] })
131
132 expect(cap.find(c => c.url.includes('/2/media/upload'))).toBeDefined()
133 expect(tweetRequest(cap)?.body.media.media_ids).toEqual(['media123'])
134 })
135
136 test('rejects text over the character limit before calling the API', async () => {
137 const cap: Captured[] = []; mockTwitterApi(cap)
138 const driver = new TwitterDriver()
139 await expect(driver.publish(identity, { text: 'x'.repeat(281) })).rejects.toThrow(/280 characters/)
140 expect(cap).toHaveLength(0)
141 })
142
143 test('rejects when the identity has no access token', async () => {
144 const cap: Captured[] = []; mockTwitterApi(cap)
145 const driver = new TwitterDriver()
146 await expect(driver.publish({ handle: 'glenn' }, { text: 'hi' })).rejects.toThrow(/access token/)
147 expect(cap).toHaveLength(0)
148 })
149
150 test('surfaces an API error as TwitterApiError with status', async () => {
151 const cap: Captured[] = []
152 mockTwitterApi(cap, url => url.endsWith('/2/tweets') ? new Response('{"title":"Unauthorized"}', { status: 401 }) : null)
153 const driver = new TwitterDriver()
154 let caught: unknown
155 await driver.publish(identity, { text: 'hi' }).catch((e) => { caught = e })
156 expect(caught).toBeInstanceOf(TwitterApiError)
157 expect((caught as TwitterApiError).status).toBe(401)
158 expect((caught as TwitterApiError).isAuthError).toBe(true)
159 })
160})