Skip to content
← Public packages

@kentcdodds/x

X API v2 helpers for tweets, search, legacy DMs, and encrypted X Chat via a Fly XDK sidecar.

src/accounts.ts

149 lines · 5.7 KB · TypeScript
import { kody } from 'kody:runtime'
import type { XAccountParams, XAccountSummary, ResolvedXOAuth } from './types.ts'

const DEFAULT_INTEGRATION = 'x'

function normalizeIntegrationName(name: string): string {
	return name.trim().toLowerCase()
}

/**
 * Resolve which OAuth integration to use.
 *
 * - `integration` wins when set (exact integration name, e.g. `x-kodykoala`)
 * - `account: 'kodykoala'` → `x-kodykoala` (Kody `<provider>-<purpose>` convention)
 * - `account: 'default'` / `'x'` / omitted → `x`
 * - bare `account` values that already start with `x-` are used as-is
 */
export function resolveIntegrationName(params: XAccountParams = {}): string {
	if (typeof params.integration === 'string' && params.integration.trim()) {
		return normalizeIntegrationName(params.integration)
	}
	if (typeof params.account === 'string' && params.account.trim()) {
		const account = normalizeIntegrationName(params.account)
		if (account === 'default' || account === 'x') return DEFAULT_INTEGRATION
		if (account === DEFAULT_INTEGRATION || account.startsWith('x-')) return account
		return `x-${account}`
	}
	return DEFAULT_INTEGRATION
}

export function accountAliasForIntegration(integrationName: string): string | null {
	const name = normalizeIntegrationName(integrationName)
	if (name === DEFAULT_INTEGRATION) return null
	if (name.startsWith('x-')) return name.slice(2)
	return name
}

function secretPlaceholder(secretName: string): string {
	return `{{secret:${secretName}}}`
}

/**
 * Load OAuth secret/value names for an account from the saved integration.
 * Fork-friendly: no hard-coded account aliases — connect integrations named
 * `x`, `x-work`, `x-brand`, etc. and pass `account: 'work'`.
 */
export async function resolveOAuthAccount(
	params: XAccountParams = {},
): Promise<ResolvedXOAuth> {
	const integrationName = resolveIntegrationName(params)
	const got = await kody.integrationGet({ name: integrationName })
	const cfg = (got && typeof got === 'object' && 'integration' in got
		? (got as { integration: Record<string, unknown> | null }).integration
		: got) as Record<string, unknown> | null

	if (!cfg || typeof cfg !== 'object') {
		throw new Error(
			`No X OAuth integration named "${integrationName}". Connect it at https://kody.codes/connect/oauth?provider=${encodeURIComponent(integrationName)}`,
		)
	}

	// Optional legacy secret mirrors. Live OAuth tokens live on the connection and
	// are used via createAuthenticatedFetch(integrationName) — same pattern as
	// @kentcdodds/github. Do not require accessTokenSecretName / refreshTokenSecretName.
	const accessTokenSecretName =
		typeof cfg.accessTokenSecretName === 'string' && cfg.accessTokenSecretName
			? cfg.accessTokenSecretName
			: null
	const refreshTokenSecretName =
		typeof cfg.refreshTokenSecretName === 'string' && cfg.refreshTokenSecretName
			? cfg.refreshTokenSecretName
			: null
	// Reported for discovery output only. Token rotation goes through the host
	// helper, which resolves the client id itself, so this package never needs it
	// and must not fail when it is absent.
	const clientId = typeof cfg.clientId === 'string' ? cfg.clientId : ''

	return {
		integrationName,
		account: accountAliasForIntegration(integrationName),
		accessTokenSecretName,
		refreshTokenSecretName,
		clientId,
		accessTokenPlaceholder: accessTokenSecretName
			? secretPlaceholder(accessTokenSecretName)
			: null,
		refreshTokenPlaceholder: refreshTokenSecretName
			? secretPlaceholder(refreshTokenSecretName)
			: null,
	}
}

/**
 * List connected X OAuth integrations (`x` and `x-*`).
 * Discovered from the signed-in user's saved integrations — not a hard-coded alias table.
 */
export async function listXAccounts(): Promise<{
	accounts: XAccountSummary[]
	defaultIntegration: string
}> {
	const listed = await kody.integrationList({})
	const items = Array.isArray(listed)
		? listed
		: Array.isArray((listed as { integrations?: unknown[] })?.integrations)
			? (listed as { integrations: unknown[] }).integrations
			: Array.isArray((listed as { items?: unknown[] })?.items)
				? (listed as { items: unknown[] }).items
				: []

	const accounts = items
		.map((item) => {
			const name =
				item && typeof item === 'object' && typeof (item as { name?: unknown }).name === 'string'
					? normalizeIntegrationName((item as { name: string }).name)
					: ''
			if (!name || (name !== DEFAULT_INTEGRATION && !name.startsWith('x-'))) return null
			const accessTokenSecretName =
				item &&
				typeof item === 'object' &&
				typeof (item as { accessTokenSecretName?: unknown }).accessTokenSecretName === 'string'
					? (item as { accessTokenSecretName: string }).accessTokenSecretName
					: null
			return {
				account: accountAliasForIntegration(name),
				integration: name,
				default: name === DEFAULT_INTEGRATION,
				accessTokenSecretName,
				useWhen:
					name === DEFAULT_INTEGRATION
						? 'Default X account. Use when the user does not name a specific brand/bot account.'
						: `Use when the user asks for the ${accountAliasForIntegration(name)} X account (integration ${name}).`,
			} satisfies XAccountSummary
		})
		.filter((entry): entry is XAccountSummary => entry !== null)
		.sort((a, b) => Number(b.default) - Number(a.default) || a.integration.localeCompare(b.integration))

	return { accounts, defaultIntegration: DEFAULT_INTEGRATION }
}

/**
 * List connected X accounts (default export for `kody:@kentcdodds/x/accounts`).
 * @example
 * import listXAccounts from 'kody:@kentcdodds/x/accounts'
 * const { accounts } = await listXAccounts()
 * // => { accounts: [{ account: null, integration: 'x', default: true }, { account: 'kodykoala', integration: 'x-kodykoala', default: false }] }
 */
export default async function listXAccountsEntrypoint() {
	return await listXAccounts()
}