← vibesdk
worker/services/cloudflare/CloudflareAccountService.ts
14.4 KB
Download file
/**
* Cloudflare Account Service
* Manages Cloudflare accounts and AI Gateways for users
*/
import { eq, and } from 'drizzle-orm';
import { generateId } from '../../utils/idGenerator';
import * as schema from '../../database/schema';
import { BaseService } from '../../database/services/BaseService';
export interface CloudflareAccount {
id: string;
name: string;
email?: string;
}
export interface AIGateway {
id: string;
name: string;
slug: string;
cacheEnabled?: boolean;
}
export interface GatewayCredits {
balance: number;
currency: string;
lastUpdated: Date;
}
/** Shape of Cloudflare API responses we consume. */
interface CfApiEnvelope<T> {
success: boolean;
result?: T;
errors?: Array<{ code: number; message: string }>;
}
interface CfAccountApi {
id: string;
name: string;
email?: string;
}
interface CfGatewayApi {
id: string;
name?: string;
slug?: string;
cache_enabled?: boolean;
}
interface CfCreditBalanceApi {
balance?: number;
}
export type ActiveGatewayWithAccount = {
gateway: typeof schema.aiGateways.$inferSelect;
account: typeof schema.cloudflareAccounts.$inferSelect;
};
export class CloudflareAccountService extends BaseService {
/**
* Fetch all Cloudflare accounts for a given token
*/
async fetchCloudflareAccounts(accessToken: string): Promise<CloudflareAccount[]> {
try {
const response = await fetch('https://api.cloudflare.com/client/v4/accounts', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
this.logger.error('Failed to fetch Cloudflare accounts', {
status: response.status,
statusText: response.statusText,
});
return [];
}
const data = await response.json() as CfApiEnvelope<CfAccountApi[]>;
if (!data.success || !data.result) {
this.logger.warn('Cloudflare API returned unsuccessful response', { data });
return [];
}
return data.result.map((account) => ({
id: account.id,
name: account.name,
email: account.email,
}));
} catch (error) {
this.logger.error('Error fetching Cloudflare accounts', error);
return [];
}
}
/**
* Fetch AI Gateways for a specific Cloudflare account
*/
async fetchAIGateways(accessToken: string, accountId: string): Promise<AIGateway[]> {
try {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai-gateway/gateways`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
this.logger.warn('Failed to fetch AI Gateways', {
accountId,
status: response.status,
});
return [];
}
const data = await response.json() as CfApiEnvelope<CfGatewayApi[]>;
if (!data.success || !data.result) {
return [];
}
return data.result.map((gateway) => ({
id: gateway.id,
name: gateway.name || gateway.id,
slug: gateway.slug || gateway.id,
}));
} catch (error) {
this.logger.error('Error fetching AI Gateways', { accountId, error });
return [];
}
}
/**
* Create a new AI Gateway for an account
*/
async createAIGateway(
accessToken: string,
accountId: string,
gatewayName: string
): Promise<AIGateway | null> {
try {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai-gateway/gateways`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: gatewayName.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
cache_ttl: 3600, // 1 hour cache
cache_enabled: true,
rate_limiting_interval: 60,
rate_limiting_limit: 100,
rate_limiting_technique: 'sliding',
}),
}
);
if (!response.ok) {
const errorText = await response.text();
this.logger.error('Failed to create AI Gateway', {
accountId,
status: response.status,
error: errorText,
});
return null;
}
const data = await response.json() as CfApiEnvelope<CfGatewayApi>;
if (!data.success || !data.result) {
return null;
}
const gateway = data.result;
return {
id: gateway.id,
name: gateway.name || gatewayName,
slug: gateway.slug || gateway.id,
cacheEnabled: gateway.cache_enabled,
};
} catch (error) {
this.logger.error('Error creating AI Gateway', { accountId, error });
return null;
}
}
/**
* Fetch credits for a specific AI Gateway.
*
* Returns `null` on any upstream failure so callers can distinguish
* "unknown balance" (e.g. API outage, parse error) from a genuine $0
* balance and keep the last cached value instead of overwriting it.
* Note: Credits are account-level, not gateway-specific.
*/
async fetchGatewayCredits(
accessToken: string,
accountId: string,
gatewayId: string
): Promise<number | null> {
try {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai-gateway/billing/credit-balance`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
this.logger.warn('Failed to fetch AI Gateway credits', {
accountId,
status: response.status,
});
return null;
}
const data = await response.json() as CfApiEnvelope<CfCreditBalanceApi>;
if (!data.success || !data.result) {
return null;
}
// Extract credit balance (in cents) and convert to dollars
const balanceInCents = data.result.balance || 0;
const balanceInDollars = balanceInCents / 100;
return balanceInDollars;
} catch (error) {
this.logger.error('Error fetching gateway credits', { accountId, gatewayId, error });
return null;
}
}
/**
* Permanently remove all of a user's Cloudflare account + gateway rows.
*
* Used by disconnect so a revoked connection leaves no stale server-side state
* (closes the persistence half of the account-hijack chain).
* Runs both deletes in a single D1 batch so partial failure cannot leave orphans.
*/
async deleteAllForUser(userId: string): Promise<void> {
await this.database.batch([
this.database.delete(schema.aiGateways).where(eq(schema.aiGateways.userId, userId)),
this.database.delete(schema.cloudflareAccounts).where(eq(schema.cloudflareAccounts.userId, userId)),
]);
}
/**
* Check if user has any existing accounts
*/
async hasExistingAccounts(userId: string): Promise<boolean> {
const accounts = await this.database
.select()
.from(schema.cloudflareAccounts)
.where(eq(schema.cloudflareAccounts.userId, userId))
.all();
return accounts.length > 0;
}
/**
* Check if user has any existing gateways
*/
async hasExistingGateways(userId: string): Promise<boolean> {
const gateways = await this.database
.select()
.from(schema.aiGateways)
.where(eq(schema.aiGateways.userId, userId))
.all();
return gateways.length > 0;
}
/**
* Save or update Cloudflare account in database
*/
async saveAccount(
userId: string,
accountId: string,
accountName: string,
accountEmail?: string
): Promise<string> {
const now = new Date();
// Check if account already exists
const existing = await this.database
.select()
.from(schema.cloudflareAccounts)
.where(
and(
eq(schema.cloudflareAccounts.userId, userId),
eq(schema.cloudflareAccounts.accountId, accountId)
)
)
.get();
if (existing) {
await this.database
.update(schema.cloudflareAccounts)
.set({
accountName,
accountEmail,
updatedAt: now,
lastSyncedAt: now,
})
.where(eq(schema.cloudflareAccounts.id, existing.id));
return existing.id;
}
const id = generateId();
await this.database.insert(schema.cloudflareAccounts).values({
id,
userId,
accountId,
accountName,
accountEmail,
createdAt: now,
updatedAt: now,
lastSyncedAt: now,
});
return id;
}
/**
* Save or update AI Gateway in database
*/
async saveGateway(
userId: string,
cloudflareAccountId: string,
gatewayId: string,
gatewayName: string,
gatewaySlug: string,
autoCreated: boolean = false,
creditsRemaining: number | null = null,
setAsActive?: boolean
): Promise<string> {
const now = new Date();
// When creditsRemaining is null the upstream credits API was unavailable
// (see fetchGatewayCredits). Preserve the previously cached value rather
// than clobbering it with a stale/zero reading.
const hasCredits = creditsRemaining !== null;
// Check if gateway already exists
const existing = await this.database
.select()
.from(schema.aiGateways)
.where(
and(
eq(schema.aiGateways.cloudflareAccountId, cloudflareAccountId),
eq(schema.aiGateways.gatewayId, gatewayId)
)
)
.get();
if (existing) {
await this.database
.update(schema.aiGateways)
.set({
gatewayName,
gatewaySlug,
...(hasCredits ? {
creditsRemaining,
creditsLastUpdated: now,
} : {}),
updatedAt: now,
})
.where(eq(schema.aiGateways.id, existing.id));
return existing.id;
}
// Caller should check and pass setAsActive explicitly
const isActive = setAsActive ?? false;
const id = generateId();
await this.database.insert(schema.aiGateways).values({
id,
userId,
cloudflareAccountId,
gatewayId,
gatewayName,
gatewaySlug,
...(hasCredits ? {
creditsRemaining,
creditsLastUpdated: now,
} : {}),
autoCreated,
isActive,
createdAt: now,
updatedAt: now,
});
return id;
}
/**
* Fetch the user's single active gateway together with its owning account.
* Returns `null` when the user has not selected a gateway or the query fails.
* Shared by `getSelectedGatewayWithAccount` and `getUserSelection`.
*/
private async getActiveGatewayWithAccount(userId: string): Promise<ActiveGatewayWithAccount | null> {
try {
const result = await this.database
.select()
.from(schema.aiGateways)
.innerJoin(
schema.cloudflareAccounts,
eq(schema.aiGateways.cloudflareAccountId, schema.cloudflareAccounts.id)
)
.where(
and(
eq(schema.aiGateways.userId, userId),
eq(schema.aiGateways.isActive, true)
)
)
.get();
if (!result) return null;
return {
gateway: result.ai_gateways,
account: result.cloudflare_accounts,
};
} catch (error) {
this.logger.error('Error getting active gateway', { userId, error });
return null;
}
}
/**
* Set user's selected account and gateway (only one gateway can be active at a time)
*/
async setUserSelection(userId: string, accountId: string, gatewayId: string): Promise<boolean> {
try {
// Verify the account and gateway belong to the user
const gateway = await this.database
.select()
.from(schema.aiGateways)
.innerJoin(
schema.cloudflareAccounts,
eq(schema.aiGateways.cloudflareAccountId, schema.cloudflareAccounts.id)
)
.where(
and(
eq(schema.cloudflareAccounts.id, accountId),
eq(schema.aiGateways.id, gatewayId),
eq(schema.cloudflareAccounts.userId, userId)
)
)
.get();
if (!gateway) {
this.logger.warn('Invalid account or gateway selection', { userId, accountId, gatewayId });
return false;
}
// Deactivate all other gateways for this user (only one can be active)
await this.database
.update(schema.aiGateways)
.set({ isActive: false })
.where(eq(schema.aiGateways.userId, userId));
// Activate the selected gateway
await this.database
.update(schema.aiGateways)
.set({ isActive: true })
.where(eq(schema.aiGateways.id, gatewayId));
return true;
} catch (error) {
this.logger.error('Error setting user selection', { userId, accountId, gatewayId, error });
return false;
}
}
/**
* Get the user's active gateway with its owning account row (or null).
*/
async getSelectedGatewayWithAccount(userId: string): Promise<ActiveGatewayWithAccount | null> {
return this.getActiveGatewayWithAccount(userId);
}
/**
* Resolve the account to deploy Workers into. Deploying only needs an
* account ID (no AI Gateway), so this falls back to the user's sole
* connected account when no explicit account+gateway selection exists.
* Returns null only when the target account is ambiguous (multiple
* connected accounts and none selected in Settings).
*/
async getDeployAccount(userId: string): Promise<typeof schema.cloudflareAccounts.$inferSelect | null> {
const selected = await this.getActiveGatewayWithAccount(userId);
if (selected) return selected.account;
try {
const accounts = await this.database
.select()
.from(schema.cloudflareAccounts)
.where(eq(schema.cloudflareAccounts.userId, userId))
.all();
return accounts.length === 1 ? accounts[0] : null;
} catch (error) {
this.logger.error('Error resolving deploy account', { userId, error });
return null;
}
}
/**
* Get user's current selection (active gateway).
* Returns the row IDs (account + gateway) rather than the full rows.
*/
async getUserSelection(userId: string): Promise<{
accountId: string | null;
gatewayId: string | null;
}> {
const active = await this.getActiveGatewayWithAccount(userId);
if (!active) return { accountId: null, gatewayId: null };
return { accountId: active.account.id, gatewayId: active.gateway.id };
}
/**
* Get all user's accounts with their gateways
* Returns ALL accounts and gateways for display in settings UI
*/
async getUserAccountsWithGateways(userId: string) {
try {
const accounts = await this.database
.select()
.from(schema.cloudflareAccounts)
.where(eq(schema.cloudflareAccounts.userId, userId))
.all();
const accountsWithGateways = await Promise.all(
accounts.map(async (account) => {
const gateways = await this.database
.select()
.from(schema.aiGateways)
.where(eq(schema.aiGateways.cloudflareAccountId, account.id))
.all();
return {
...account,
gateways,
};
})
);
return accountsWithGateways;
} catch (error) {
this.logger.error('Error getting user accounts with gateways', { userId, error });
return [];
}
}
}