Skip to content

Commit d5e849a

Browse files
bchapuisclaude
andcommitted
Add X integration with OAuth 2.0 PKCE and 12 workflow nodes
Adds a complete X (formerly Twitter) integration: - OAuth 2.0 provider with PKCE support (code verifier embedded in state) - 12 workflow nodes: get/search/create/delete posts, get user, list followers/following/mentions/user posts, like, repost, follow - Usage costs proportional to X API pricing (10/20/30 credits) - Subscription-gated nodes bypass billing check in non-production envs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9aa13e9 commit d5e849a

34 files changed

Lines changed: 1963 additions & 15 deletions

apps/api/.dev.vars.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ INTEGRATION_REDDIT_CLIENT_SECRET=CHANGE_ME
5454
INTEGRATION_GITHUB_CLIENT_ID=CHANGE_ME
5555
INTEGRATION_GITHUB_CLIENT_SECRET=CHANGE_ME
5656

57+
# X integration
58+
INTEGRATION_X_CLIENT_ID=CHANGE_ME
59+
INTEGRATION_X_CLIENT_SECRET=CHANGE_ME
60+
5761
TWILIO_ACCOUNT_SID=CHANGE_ME
5862
TWILIO_AUTH_TOKEN=CHANGE_ME
5963
TWILIO_PHONE_NUMBER=CHANGE_ME

apps/api/src/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export interface Bindings {
5050
INTEGRATION_REDDIT_CLIENT_SECRET?: string;
5151
INTEGRATION_LINKEDIN_CLIENT_ID?: string;
5252
INTEGRATION_LINKEDIN_CLIENT_SECRET?: string;
53+
INTEGRATION_X_CLIENT_ID?: string;
54+
INTEGRATION_X_CLIENT_SECRET?: string;
5355
TWILIO_ACCOUNT_SID?: string;
5456
TWILIO_AUTH_TOKEN?: string;
5557
TWILIO_PHONE_NUMBER?: string;

apps/api/src/db/queries.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2154,11 +2154,18 @@ export async function getOrganizationBillingInfo(
21542154
/**
21552155
* Derive user plan from organization billing info.
21562156
* Pro if has active subscription OR canceled but still in billing period.
2157+
* In non-production environments, always grants pro so all nodes are available during development.
21572158
*/
2158-
export function resolveOrganizationPlan(billingInfo: {
2159-
subscriptionStatus: string | null;
2160-
currentPeriodEnd: Date | null;
2161-
}): string {
2159+
export function resolveOrganizationPlan(
2160+
billingInfo: {
2161+
subscriptionStatus: string | null;
2162+
currentPeriodEnd: Date | null;
2163+
},
2164+
cloudflareEnv?: string
2165+
): string {
2166+
if (cloudflareEnv && cloudflareEnv !== "production") {
2167+
return "pro";
2168+
}
21622169
const hasProAccess =
21632170
billingInfo.subscriptionStatus === "active" ||
21642171
(billingInfo.subscriptionStatus === "canceled" &&

apps/api/src/db/schema/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export const IntegrationProvider = {
7171
GITHUB: "github",
7272
REDDIT: "reddit",
7373
LINKEDIN: "linkedin",
74+
X: "x",
7475
} as const;
7576

7677
export type IntegrationProviderType =

apps/api/src/email.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ async function triggerWorkflowForEmail({
179179
userId: "email_trigger",
180180
organizationId,
181181
computeCredits: billingInfo.computeCredits,
182-
userPlan: resolveOrganizationPlan(billingInfo),
182+
userPlan: resolveOrganizationPlan(billingInfo, env.CLOUDFLARE_ENV),
183183
workflow: {
184184
id: workflow.id,
185185
name: workflow.name,
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { Context } from "hono";
2+
import type { ApiContext } from "../../context";
3+
import { OAuthProvider } from "../OAuthProvider";
4+
import type { XToken, XUser } from "../types";
5+
6+
/**
7+
* Generate a random code verifier for PKCE (43-128 chars, URL-safe)
8+
*/
9+
function generateCodeVerifier(): string {
10+
const array = new Uint8Array(32);
11+
crypto.getRandomValues(array);
12+
return base64UrlEncode(array);
13+
}
14+
15+
/**
16+
* Base64 URL encode (no padding, URL-safe chars)
17+
*/
18+
function base64UrlEncode(buffer: Uint8Array): string {
19+
return btoa(String.fromCharCode(...buffer))
20+
.replace(/\+/g, "-")
21+
.replace(/\//g, "_")
22+
.replace(/=+$/, "");
23+
}
24+
25+
/**
26+
* Generate SHA-256 code challenge from verifier
27+
*/
28+
async function generateCodeChallenge(verifier: string): Promise<string> {
29+
const data = new TextEncoder().encode(verifier);
30+
const digest = await crypto.subtle.digest("SHA-256", data);
31+
return base64UrlEncode(new Uint8Array(digest));
32+
}
33+
34+
/**
35+
* Delimiter used to embed code_verifier in the state parameter.
36+
* X/Twitter passes state back unchanged in the callback, so the
37+
* verifier survives the round-trip without needing external storage.
38+
*/
39+
const PKCE_DELIMITER = "~";
40+
41+
export class XProvider extends OAuthProvider<XToken, XUser> {
42+
readonly name = "x";
43+
readonly displayName = "X";
44+
readonly authorizationEndpoint = "https://x.com/i/oauth2/authorize";
45+
readonly tokenEndpoint = "https://api.x.com/2/oauth2/token";
46+
readonly userInfoEndpoint =
47+
"https://api.x.com/2/users/me?user.fields=id,name,username,profile_image_url,description";
48+
readonly scopes = [
49+
"tweet.read",
50+
"tweet.write",
51+
"users.read",
52+
"follows.read",
53+
"follows.write",
54+
"like.read",
55+
"like.write",
56+
"offline.access",
57+
];
58+
59+
// Token refresh configuration
60+
readonly refreshEnabled = true;
61+
readonly refreshEndpoint = "https://api.x.com/2/oauth2/token";
62+
63+
/**
64+
* Override initiateAuth to inject PKCE parameters.
65+
* Generates a code_verifier, computes the S256 challenge, and embeds
66+
* the verifier in the state so it can be retrieved on callback.
67+
*/
68+
async initiateAuth(c: Context<ApiContext>): Promise<string> {
69+
// Get the base authorization URL (includes signed state)
70+
const authUrl = await super.initiateAuth(c);
71+
const url = new URL(authUrl);
72+
73+
// Generate PKCE pair
74+
const codeVerifier = generateCodeVerifier();
75+
const codeChallenge = await generateCodeChallenge(codeVerifier);
76+
77+
// Add PKCE parameters
78+
url.searchParams.set("code_challenge", codeChallenge);
79+
url.searchParams.set("code_challenge_method", "S256");
80+
81+
// Embed verifier in state (X returns state unchanged on callback)
82+
const state = url.searchParams.get("state")!;
83+
url.searchParams.set("state", `${state}${PKCE_DELIMITER}${codeVerifier}`);
84+
85+
return url.toString();
86+
}
87+
88+
/**
89+
* Override handleCallback to extract the PKCE code_verifier from state
90+
* and include it in the token exchange.
91+
*/
92+
async handleCallback(
93+
c: Context<ApiContext>,
94+
code: string,
95+
stateParam: string
96+
): Promise<{ orgId: string }> {
97+
// Split state to extract code_verifier
98+
const delimiterIndex = stateParam.lastIndexOf(PKCE_DELIMITER);
99+
if (delimiterIndex === -1) {
100+
throw new Error("Missing PKCE code_verifier in state");
101+
}
102+
const actualState = stateParam.substring(0, delimiterIndex);
103+
const codeVerifier = stateParam.substring(delimiterIndex + 1);
104+
105+
// Validate the original signed state
106+
const { organizationId, orgId } = await this.validateState(c, actualState);
107+
108+
// Get credentials
109+
const { clientId, clientSecret } = this.getClientCredentials(c.env);
110+
const redirectUri = this.getRedirectUri(c.env);
111+
112+
// Exchange code for token with PKCE verifier
113+
const response = await fetch(this.tokenEndpoint, {
114+
method: "POST",
115+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
116+
body: new URLSearchParams({
117+
grant_type: "authorization_code",
118+
code,
119+
client_id: clientId,
120+
client_secret: clientSecret,
121+
redirect_uri: redirectUri,
122+
code_verifier: codeVerifier,
123+
}),
124+
});
125+
126+
if (!response.ok) {
127+
const errorText = await response.text();
128+
console.error("X token exchange failed:", errorText);
129+
throw new Error(`Token exchange failed: ${errorText}`);
130+
}
131+
132+
const token = (await response.json()) as XToken;
133+
134+
// Fetch user info
135+
const user = await this.getUserInfo(token.access_token);
136+
137+
// Create integration
138+
await this.createIntegration(organizationId, token, user, c.env);
139+
140+
return { orgId };
141+
}
142+
143+
/**
144+
* X API v2 wraps user info in a { data: ... } envelope
145+
*/
146+
protected parseUserResponse(data: { data: XUser }): XUser {
147+
return data.data;
148+
}
149+
150+
protected formatIntegrationName(user: XUser): string {
151+
return `@${user.username}`;
152+
}
153+
154+
protected formatUserMetadata(user: XUser): Record<string, string> {
155+
return {
156+
userId: user.id,
157+
username: user.username,
158+
name: user.name,
159+
...(user.profile_image_url && {
160+
profileImageUrl: user.profile_image_url,
161+
}),
162+
...(user.description && { description: user.description }),
163+
};
164+
}
165+
166+
extractAccessToken(token: XToken): string {
167+
return token.access_token;
168+
}
169+
170+
extractRefreshToken(token: XToken): string | undefined {
171+
return token.refresh_token;
172+
}
173+
174+
extractExpiresAt(token: XToken): Date {
175+
return new Date(Date.now() + token.expires_in * 1000);
176+
}
177+
}

apps/api/src/oauth/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { GoogleCalendarProvider } from "./providers/GoogleCalendarProvider";
55
import { GoogleMailProvider } from "./providers/GoogleMailProvider";
66
import { LinkedInProvider } from "./providers/LinkedInProvider";
77
import { RedditProvider } from "./providers/RedditProvider";
8+
import { XProvider } from "./providers/XProvider";
89

910
// Instantiate all providers
1011
const providers = {
@@ -14,6 +15,7 @@ const providers = {
1415
linkedin: new LinkedInProvider(),
1516
reddit: new RedditProvider(),
1617
github: new GitHubProvider(),
18+
x: new XProvider(),
1719
} as const;
1820

1921
export type ProviderName = keyof typeof providers;

apps/api/src/oauth/types.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,28 @@ export interface RedditUser {
144144
icon_img?: string;
145145
}
146146

147+
/**
148+
* X OAuth token response
149+
*/
150+
export interface XToken {
151+
access_token: string;
152+
refresh_token?: string;
153+
expires_in: number;
154+
token_type: string;
155+
scope: string;
156+
}
157+
158+
/**
159+
* X user information (from /2/users/me)
160+
*/
161+
export interface XUser {
162+
id: string;
163+
name: string;
164+
username: string;
165+
profile_image_url?: string;
166+
description?: string;
167+
}
168+
147169
/**
148170
* GitHub OAuth token response
149171
*/

apps/api/src/queue.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ async function executeWorkflow(
4242
userId: "queue_trigger",
4343
organizationId: workflowInfo.organizationId,
4444
computeCredits: billingInfo.computeCredits,
45-
userPlan: resolveOrganizationPlan(billingInfo),
45+
userPlan: resolveOrganizationPlan(billingInfo, env.CLOUDFLARE_ENV),
4646
workflow: {
4747
id: workflowInfo.id,
4848
name: workflowData.name,

apps/api/src/routes/discord-webhook.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ async function executeWorkflow(
299299
userId: "discord_trigger",
300300
organizationId,
301301
computeCredits: billingInfo.computeCredits,
302-
userPlan: resolveOrganizationPlan(billingInfo),
302+
userPlan: resolveOrganizationPlan(billingInfo, env.CLOUDFLARE_ENV),
303303
workflow: {
304304
id: workflow.id,
305305
name: workflow.name,

0 commit comments

Comments
 (0)