← 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 · TypeScriptimport { 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()
}