also looking at this
feat: crossposting — media (LinkedIn/IG/Threads), OAuth token refresh, and X/Twitter
#30
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.
| @@ -1,1 +1,42 @@ | ||
| 1 | export { InstagramApiError, InstagramPublishingDriver as InstagramDriver } from '@stacksjs/socials' | |
| 1 | import { InstagramApiError, InstagramPublishingDriver } from '@stacksjs/socials' | |
| 2 | ||
| 3 | export { InstagramApiError } | |
| 4 | ||
| 5 | export 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 | */ | |
| 21 | export 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 | } | |
| @@ -1,4 +1,4 @@ | ||
| 1 | import type { PublishedPost, PublishPostInput, SocialIdentityCredentials } from '@stacksjs/socials' | |
| 1 | import type { LinkedInTokenExchangeInput, PublishedPost, PublishPostInput, SocialIdentityCredentials } from '@stacksjs/socials' | |
| 2 | 2 | import { escapeLinkedInText, LinkedInApiError, LinkedInPublishingDriver } from '@stacksjs/socials' |
| 3 | 3 | |
| 4 | 4 | export { LinkedInApiError } |
| @@ -7,6 +7,20 @@ interface InitializeUploadResponse { | ||
| 7 | 7 | value?: { uploadUrl?: string, image?: string } |
| 8 | 8 | } |
| 9 | 9 | |
| 10 | export interface LinkedInRefreshInput { | |
| 11 | clientId: string | |
| 12 | clientSecret: string | |
| 13 | refreshToken: string | |
| 14 | } | |
| 15 | ||
| 16 | export 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 | ||
| 10 | 24 | /** |
| 11 | 25 | * App-owned LinkedIn driver. Extends the vendored `@stacksjs/socials` driver to |
| 12 | 26 | * add image posting — the base driver only supports text and article link |
| @@ -19,6 +33,78 @@ interface InitializeUploadResponse { | ||
| 19 | 33 | * single `uploadImage` step is added. |
| 20 | 34 | */ |
| 21 | 35 | export 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 | ||
| 22 | 108 | /** |
| 23 | 109 | * Register and upload one image via LinkedIn's versioned Images API, then |
| 24 | 110 | * return its `urn:li:image:...` id for attachment. |
| @@ -1,1 +1,66 @@ | ||
| 1 | export { ThreadsApiError, ThreadsPublishingDriver as ThreadsDriver } from '@stacksjs/socials' | |
| 1 | import { ThreadsApiError, ThreadsPublishingDriver } from '@stacksjs/socials' | |
| 2 | ||
| 3 | export { ThreadsApiError } | |
| 4 | ||
| 5 | export 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 | */ | |
| 19 | export 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 | } | |
| @@ -2,7 +2,7 @@ import type { CrosspostTargetResult, PublishContent } from '../../Support/Social | ||
| 2 | 2 | import { db } from '@stacksjs/database' |
| 3 | 3 | import { env } from '@stacksjs/env' |
| 4 | 4 | import { InstagramApiError, InstagramDriver } from './Drivers/InstagramDriver' |
| 5 | import { ensureAccount, now, uuid } from './support' | |
| 5 | import { ensureAccount, expiresAt, now, uuid } from './support' | |
| 6 | 6 | |
| 7 | 7 | const database = db as any |
| 8 | 8 | |
| @@ -15,6 +15,7 @@ type SocialIdentityRow = { | ||
| 15 | 15 | auth_status: 'connected' | 'expired' | 'revoked' | 'missing' |
| 16 | 16 | access_token?: string | null |
| 17 | 17 | refresh_token?: string | null |
| 18 | token_expires_at?: string | null | |
| 18 | 19 | account_id?: number | null |
| 19 | 20 | social_driver_id?: number | null |
| 20 | 21 | } |
| @@ -118,11 +119,25 @@ export class InstagramService { | ||
| 118 | 119 | code, |
| 119 | 120 | }) |
| 120 | 121 | |
| 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) | |
| 122 | 136 | const identity = await this.saveSession({ |
| 123 | 137 | accessToken: account.pageAccessToken, |
| 124 | 138 | igUserId: account.igUserId, |
| 125 | 139 | username: account.username, |
| 140 | expiresIn, | |
| 126 | 141 | }) |
| 127 | 142 | return this.publicIdentity(identity) |
| 128 | 143 | } |
| @@ -260,7 +275,7 @@ export class InstagramService { | ||
| 260 | 275 | .executeTakeFirst() |
| 261 | 276 | } |
| 262 | 277 | |
| 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> { | |
| 264 | 279 | const accountId = await ensureAccount() |
| 265 | 280 | const driver = await this.ensureDriver() |
| 266 | 281 | const existing = await this.findIdentity() |
| @@ -275,6 +290,7 @@ export class InstagramService { | ||
| 275 | 290 | auth_status: 'connected', |
| 276 | 291 | access_token: input.accessToken, |
| 277 | 292 | refresh_token: null, |
| 293 | token_expires_at: expiresAt(input.expiresIn), | |
| 278 | 294 | account_id: accountId, |
| 279 | 295 | social_driver_id: driver.id, |
| 280 | 296 | updated_at: savedAt, |
| @@ -2,7 +2,7 @@ import type { CrosspostTargetResult, PublishContent } from '../../Support/Social | ||
| 2 | 2 | import { db } from '@stacksjs/database' |
| 3 | 3 | import { env } from '@stacksjs/env' |
| 4 | 4 | import { LinkedInApiError, LinkedInDriver } from './Drivers/LinkedInDriver' |
| 5 | import { ensureAccount, now, uuid } from './support' | |
| 5 | import { ensureAccount, expiresAt, isExpiringSoon, now, uuid } from './support' | |
| 6 | 6 | |
| 7 | 7 | const database = db as any |
| 8 | 8 | |
| @@ -15,6 +15,7 @@ type SocialIdentityRow = { | ||
| 15 | 15 | auth_status: 'connected' | 'expired' | 'revoked' | 'missing' |
| 16 | 16 | access_token?: string | null |
| 17 | 17 | refresh_token?: string | null |
| 18 | token_expires_at?: string | null | |
| 18 | 19 | account_id?: number | null |
| 19 | 20 | social_driver_id?: number | null |
| 20 | 21 | } |
| @@ -125,6 +126,8 @@ export class LinkedInService { | ||
| 125 | 126 | |
| 126 | 127 | const identity = await this.saveSession({ |
| 127 | 128 | accessToken: token.accessToken, |
| 129 | refreshToken: token.refreshToken, | |
| 130 | expiresIn: token.expiresIn, | |
| 128 | 131 | authorUrn, |
| 129 | 132 | name: profile?.name, |
| 130 | 133 | }) |
| @@ -239,7 +242,9 @@ export class LinkedInService { | ||
| 239 | 242 | |
| 240 | 243 | private async requireIdentity(): Promise<SocialIdentityRow> { |
| 241 | 244 | const existing = await this.findIdentity() |
| 242 | 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 | } | |
| 243 | 248 | |
| 244 | 249 | // Fall back to an env-configured token if one is available. |
| 245 | 250 | if (this.config().accessToken) { |
| @@ -251,6 +256,41 @@ export class LinkedInService { | ||
| 251 | 256 | throw new Error('Connect LinkedIn before publishing.') |
| 252 | 257 | } |
| 253 | 258 | |
| 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 | ||
| 254 | 294 | private async findIdentity(): Promise<SocialIdentityRow | undefined> { |
| 255 | 295 | return await database |
| 256 | 296 | .selectFrom('social_identities') |
| @@ -260,7 +300,7 @@ export class LinkedInService { | ||
| 260 | 300 | .executeTakeFirst() |
| 261 | 301 | } |
| 262 | 302 | |
| 263 | 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> { | |
| 264 | 304 | const accountId = await ensureAccount() |
| 265 | 305 | const driver = await this.ensureDriver() |
| 266 | 306 | const existing = await this.findIdentity() |
| @@ -274,7 +314,8 @@ export class LinkedInService { | ||
| 274 | 314 | external_id: input.authorUrn, |
| 275 | 315 | auth_status: 'connected', |
| 276 | 316 | access_token: input.accessToken, |
| 277 | refresh_token: null, | |
| 317 | refresh_token: input.refreshToken ?? null, | |
| 318 | token_expires_at: expiresAt(input.expiresIn), | |
| 278 | 319 | account_id: accountId, |
| 279 | 320 | social_driver_id: driver.id, |
| 280 | 321 | updated_at: savedAt, |
| @@ -2,7 +2,7 @@ import type { CrosspostTargetResult, PublishContent } from '../../Support/Social | ||
| 2 | 2 | import { db } from '@stacksjs/database' |
| 3 | 3 | import { env } from '@stacksjs/env' |
| 4 | 4 | import { ThreadsApiError, ThreadsDriver } from './Drivers/ThreadsDriver' |
| 5 | import { ensureAccount, now, uuid } from './support' | |
| 5 | import { ensureAccount, expiresAt, isExpiringSoon, now, uuid } from './support' | |
| 6 | 6 | |
| 7 | 7 | const database = db as any |
| 8 | 8 | |
| @@ -15,6 +15,7 @@ type SocialIdentityRow = { | ||
| 15 | 15 | auth_status: 'connected' | 'expired' | 'revoked' | 'missing' |
| 16 | 16 | access_token?: string | null |
| 17 | 17 | refresh_token?: string | null |
| 18 | token_expires_at?: string | null | |
| 18 | 19 | account_id?: number | null |
| 19 | 20 | social_driver_id?: number | null |
| 20 | 21 | } |
| @@ -118,11 +119,25 @@ export class ThreadsService { | ||
| 118 | 119 | code, |
| 119 | 120 | }) |
| 120 | 121 | |
| 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) | |
| 122 | 136 | const identity = await this.saveSession({ |
| 123 | 137 | accessToken: account.accessToken, |
| 124 | 138 | threadsUserId: account.threadsUserId, |
| 125 | 139 | username: account.username, |
| 140 | expiresIn, | |
| 126 | 141 | }) |
| 127 | 142 | return this.publicIdentity(identity) |
| 128 | 143 | } |
| @@ -235,7 +250,9 @@ export class ThreadsService { | ||
| 235 | 250 | |
| 236 | 251 | private async requireIdentity(): Promise<SocialIdentityRow> { |
| 237 | 252 | 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 | } | |
| 239 | 256 | |
| 240 | 257 | if (this.config().accessToken) { |
| 241 | 258 | await this.connectFromEnv() |
| @@ -246,6 +263,32 @@ export class ThreadsService { | ||
| 246 | 263 | throw new Error('Connect Threads before publishing.') |
| 247 | 264 | } |
| 248 | 265 | |
| 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 | ||
| 249 | 292 | private async findIdentity(): Promise<SocialIdentityRow | undefined> { |
| 250 | 293 | return await database |
| 251 | 294 | .selectFrom('social_identities') |
| @@ -255,7 +298,7 @@ export class ThreadsService { | ||
| 255 | 298 | .executeTakeFirst() |
| 256 | 299 | } |
| 257 | 300 | |
| 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> { | |
| 259 | 302 | const accountId = await ensureAccount() |
| 260 | 303 | const driver = await this.ensureDriver() |
| 261 | 304 | const existing = await this.findIdentity() |
| @@ -270,6 +313,7 @@ export class ThreadsService { | ||
| 270 | 313 | auth_status: 'connected', |
| 271 | 314 | access_token: input.accessToken, |
| 272 | 315 | refresh_token: null, |
| 316 | token_expires_at: expiresAt(input.expiresIn), | |
| 273 | 317 | account_id: accountId, |
| 274 | 318 | social_driver_id: driver.id, |
| 275 | 319 | updated_at: savedAt, |
| @@ -7,6 +7,29 @@ export function now(): string { | ||
| 7 | 7 | return new Date().toISOString().slice(0, 19).replace('T', ' ') |
| 8 | 8 | } |
| 9 | 9 | |
| 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 | */ | |
| 15 | export 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 | */ | |
| 26 | export 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 | ||
| 10 | 33 | export function uuid(): string { |
| 11 | 34 | return crypto.randomUUID() |
| 12 | 35 | } |
| @@ -0,0 +1,47 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { InstagramApiError, InstagramDriver } from '../../app/Services/Social/Drivers/InstagramDriver' | |
| 3 | ||
| 4 | const realFetch = globalThis.fetch | |
| 5 | afterEach(() => { globalThis.fetch = realFetch }) | |
| 6 | ||
| 7 | interface Captured { url: string, method: string } | |
| 8 | ||
| 9 | function 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 | ||
| 24 | describe('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 | }) | |
| @@ -165,3 +165,51 @@ describe('LinkedInDriver.publish', () => { | ||
| 165 | 165 | expect(postRequest(cap)).toBeUndefined() |
| 166 | 166 | }) |
| 167 | 167 | }) |
| 168 | ||
| 169 | interface TokenCall { url: string, body: string } | |
| 170 | ||
| 171 | function 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 | ||
| 178 | describe('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 | }) | |
| @@ -0,0 +1,61 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { ThreadsApiError, ThreadsDriver } from '../../app/Services/Social/Drivers/ThreadsDriver' | |
| 3 | ||
| 4 | const realFetch = globalThis.fetch | |
| 5 | afterEach(() => { globalThis.fetch = realFetch }) | |
| 6 | ||
| 7 | interface Captured { url: string, method: string } | |
| 8 | ||
| 9 | function 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 | ||
| 28 | describe('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 | }) | |