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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

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/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/TwitterService.tsadded+406-0
Changes to app/Services/Social/TwitterService.ts
@@ -0,0 +1,406 @@
1import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types'
2import { db } from '@stacksjs/database'
3import { env } from '@stacksjs/env'
4import { TwitterApiError, TwitterDriver } from './Drivers/TwitterDriver'
5import { ensureAccount, expiresAt, isExpiringSoon, now, uuid } from './support'
6
7const database = db as any
8
9type SocialIdentityRow = {
10 id: number
11 handle: string
12 display_name?: string | null
13 provider: 'twitter'
14 external_id?: string | null
15 auth_status: 'connected' | 'expired' | 'revoked' | 'missing'
16 access_token?: string | null
17 refresh_token?: string | null
18 token_expires_at?: string | null
19 account_id?: number | null
20 social_driver_id?: number | null
21}
22
23type SocialDriverRow = {
24 id: number
25 provider: 'twitter'
26 display_name: string
27 status: 'active' | 'planned' | 'disabled'
28 character_limit: number
29}
30
31interface TwitterConfig {
32 clientId: string
33 clientSecret: string
34 redirectUrl: string
35 accessToken: string
36 scopes: string[]
37}
38
39function randomState(): string {
40 const bytes = new Uint8Array(24)
41 crypto.getRandomValues(bytes)
42 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
43}
44
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.
49 private pendingState: string | null = null
50 private pendingVerifier: string | null = null
51
52 private config(): TwitterConfig {
53 return {
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'],
59 }
60 }
61
62 async status() {
63 const identity = await this.findIdentity()
64 const cfg = this.config()
65 const connected = identity?.auth_status === 'connected' && Boolean(identity.access_token)
66
67 return {
68 connected,
69 provider: 'twitter',
70 handle: identity?.handle || null,
71 displayName: identity?.display_name || null,
72 did: identity?.external_id || null,
73 authStatus: connected ? 'connected' : (identity?.handle ? identity.auth_status : 'missing'),
74 characterLimit: this.driver.characterLimit,
75 canPublish: connected,
76 configuredFromEnv: Boolean(cfg.accessToken),
77 oauthConfigured: Boolean(cfg.clientId && cfg.redirectUrl),
78 }
79 }
80
81 /** Build the X consent URL (with PKCE) and remember the CSRF state + verifier. */
82 async getAuthUrl(): Promise<string> {
83 const cfg = this.config()
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.')
86
87 this.pendingState = randomState()
88 const { url, codeVerifier } = await this.driver.createAuthorization({
89 clientId: cfg.clientId,
90 redirectUrl: cfg.redirectUrl,
91 scopes: cfg.scopes,
92 state: this.pendingState,
93 })
94 this.pendingVerifier = codeVerifier
95 return url
96 }
97
98 /** Handle the OAuth redirect: exchange the code and store the tokens. */
99 async handleCallback(code: string, state: string) {
100 const cfg = this.config()
101 if (!code) throw new Error('X/Twitter did not return an authorization code.')
102 if (this.pendingState && state !== this.pendingState) {
103 throw new Error('X/Twitter OAuth state mismatch. Please start the connection again.')
104 }
105 const codeVerifier = this.pendingVerifier
106 if (!codeVerifier) throw new Error('Missing PKCE verifier — please start the X/Twitter connection again.')
107 this.pendingState = null
108 this.pendingVerifier = null
109
110 const token = await this.driver.exchangeCode({
111 clientId: cfg.clientId,
112 clientSecret: cfg.clientSecret || undefined,
113 redirectUrl: cfg.redirectUrl,
114 code,
115 codeVerifier,
116 })
117
118 const profile = await this.driver.getProfile(token.accessToken)
119 const identity = await this.saveSession({
120 accessToken: token.accessToken,
121 refreshToken: token.refreshToken,
122 expiresIn: token.expiresIn,
123 userId: profile.id,
124 username: profile.username,
125 name: profile.name,
126 })
127 return this.publicIdentity(identity)
128 }
129
130 /** Connect using a pre-obtained token from the environment. */
131 async connectFromEnv() {
132 const cfg = this.config()
133 if (!cfg.accessToken) {
134 throw new Error('Set TWITTER_ACCESS_TOKEN (or connect via OAuth) before using X/Twitter.')
135 }
136
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 })
144 return this.publicIdentity(identity)
145 }
146
147 /**
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.
151 */
152 async publishToPost(
153 post: { id: number, body: string },
154 content?: PublishContent,
155 ): Promise<CrosspostTargetResult> {
156 const driver = await this.ensureDriver()
157
158 let identity: SocialIdentityRow
159 try {
160 identity = await this.requireIdentity()
161 }
162 catch (error) {
163 return { provider: 'twitter', ok: false, error: messageOf(error) }
164 }
165
166 if (post.body.length > this.driver.characterLimit) {
167 return {
168 provider: 'twitter',
169 ok: false,
170 error: `Twitter posts must be ${this.driver.characterLimit} characters or fewer.`,
171 }
172 }
173
174 const targetUuid = uuid()
175 const createdAt = now()
176 await database.insertInto('post_targets').values({
177 uuid: targetUuid,
178 provider: 'twitter',
179 status: 'publishing',
180 post_id: post.id,
181 social_driver_id: driver.id,
182 social_identity_id: identity.id,
183 created_at: createdAt,
184 updated_at: createdAt,
185 }).execute()
186
187 const target = await database
188 .selectFrom('post_targets')
189 .selectAll()
190 .where('uuid', '=', targetUuid)
191 .executeTakeFirstOrThrow()
192
193 try {
194 const published = await this.driver.publish({
195 handle: identity.handle,
196 did: identity.external_id || undefined,
197 accessToken: identity.access_token || undefined,
198 }, {
199 text: post.body,
200 ...(content?.media?.length ? { media: content.media } : {}),
201 ...(content?.reply ? { reply: content.reply } : {}),
202 })
203
204 await database.updateTable('post_targets').set({
205 status: 'published',
206 remote_uri: published.uri || null,
207 remote_cid: published.cid || null,
208 failure_reason: null,
209 updated_at: now(),
210 }).where('id', '=', target.id).execute()
211
212 return {
213 provider: 'twitter',
214 ok: true,
215 url: published.url,
216 uri: published.uri,
217 cid: published.cid,
218 targetId: Number(target.id),
219 }
220 }
221 catch (error) {
222 const message = messageOf(error)
223 if (error instanceof TwitterApiError && error.isAuthError) {
224 await this.markExpired(identity.id)
225 }
226 await database.updateTable('post_targets').set({
227 status: 'failed',
228 failure_reason: message,
229 updated_at: now(),
230 }).where('id', '=', target.id).execute()
231
232 return { provider: 'twitter', ok: false, error: message, targetId: Number(target.id) }
233 }
234 }
235
236 private async requireIdentity(): Promise<SocialIdentityRow> {
237 const existing = await this.findIdentity()
238 if (existing?.access_token && existing.auth_status === 'connected') {
239 return await this.refreshIfExpiring(existing)
240 }
241
242 if (this.config().accessToken) {
243 await this.connectFromEnv()
244 const connected = await this.findIdentity()
245 if (connected?.access_token) return connected
246 }
247
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 }
284 }
285
286 private async findIdentity(): Promise<SocialIdentityRow | undefined> {
287 return await database
288 .selectFrom('social_identities')
289 .selectAll()
290 .where('provider', '=', 'twitter')
291 .orderBy('updated_at', 'desc')
292 .executeTakeFirst()
293 }
294
295 private async saveSession(input: { accessToken: string, refreshToken?: string, expiresIn?: number, userId: string, username: string, name?: string }): Promise<SocialIdentityRow> {
296 const accountId = await ensureAccount()
297 const driver = await this.ensureDriver()
298 const existing = await this.findIdentity()
299 const savedAt = now()
300
301 const values = {
302 handle: input.username,
303 display_name: input.name || input.username,
304 provider: 'twitter',
305 external_id: input.userId,
306 auth_status: 'connected',
307 access_token: input.accessToken,
308 refresh_token: input.refreshToken ?? null,
309 token_expires_at: expiresAt(input.expiresIn),
310 account_id: accountId,
311 social_driver_id: driver.id,
312 updated_at: savedAt,
313 }
314
315 if (existing) {
316 await database.updateTable('social_identities').set(values).where('id', '=', existing.id).execute()
317 }
318 else {
319 await database.insertInto('social_identities').values({
320 uuid: uuid(),
321 ...values,
322 created_at: savedAt,
323 }).execute()
324 }
325
326 return await this.findIdentity() as SocialIdentityRow
327 }
328
329 private async markExpired(id: number): Promise<void> {
330 await database.updateTable('social_identities').set({
331 auth_status: 'expired',
332 updated_at: now(),
333 }).where('id', '=', id).execute()
334 }
335
336 private async ensureDriver(): Promise<SocialDriverRow> {
337 const existing = await database
338 .selectFrom('social_drivers')
339 .selectAll()
340 .where('provider', '=', 'twitter')
341 .executeTakeFirst()
342
343 if (existing) {
344 if (existing.status !== 'active' || existing.character_limit !== this.driver.characterLimit) {
345 await database.updateTable('social_drivers').set({
346 status: 'active',
347 character_limit: this.driver.characterLimit,
348 updated_at: now(),
349 }).where('id', '=', existing.id).execute()
350 }
351
352 return await database
353 .selectFrom('social_drivers')
354 .selectAll()
355 .where('id', '=', existing.id)
356 .executeTakeFirstOrThrow()
357 }
358
359 const driverUuid = uuid()
360 await database.insertInto('social_drivers').values({
361 uuid: driverUuid,
362 provider: 'twitter',
363 display_name: 'X (Twitter)',
364 status: 'active',
365 character_limit: this.driver.characterLimit,
366 capabilities: JSON.stringify({ posts: true, timelines: false, oauth: true }),
367 created_at: now(),
368 updated_at: now(),
369 }).execute()
370
371 return await database
372 .selectFrom('social_drivers')
373 .selectAll()
374 .where('uuid', '=', driverUuid)
375 .executeTakeFirstOrThrow()
376 }
377
378 private publicIdentity(row: SocialIdentityRow | undefined) {
379 if (!row) {
380 return {
381 connected: false,
382 provider: 'twitter',
383 handle: null,
384 displayName: null,
385 did: null,
386 authStatus: 'missing',
387 }
388 }
389
390 const connected = row.auth_status === 'connected' && Boolean(row.access_token)
391 return {
392 connected,
393 provider: 'twitter',
394 handle: row.handle,
395 displayName: row.display_name || null,
396 did: row.external_id || null,
397 authStatus: connected ? 'connected' : 'missing',
398 }
399 }
400}
401
402function messageOf(error: unknown): string {
403 return error instanceof Error ? error.message : String(error)
404}
405
406export const twitter = new TwitterService()
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+7-0
Changes to routes/api.ts
@@ -57,6 +57,13 @@ route.group({ prefix: '/postline/threads' }, () => {
5757 route.get('/callback', 'Actions/Postline/ThreadsCallbackAction').skipCsrf()
5858})
5959
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
6067route.group({ prefix: '/postline/mastodon' }, () => {
6168 route.get('/status', 'Actions/Postline/MastodonStatusAction').middleware('auth').skipCsrf()
6269 route.post('/connect', 'Actions/Postline/MastodonConnectAction').middleware('auth').skipCsrf()
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})