import { listAccessibleAccounts, type AccessibleAccount } from './accounts-resolve.ts' import cloudflareApiV4 from './api-v4.ts' import { DEFAULT_CLOUDFLARE_ACCOUNT, getAccountInfo, KODY_CLOUDFLARE_ACCOUNT_ID, pickAuthOptions, resolveApiTokenSecretName, resolveKnownAccountId, type CloudflareAuthOptions, } from './auth.ts' /** * Production Kody Cloudflare account id. Used as the default target for * account-scoped token verification when `account: 'kody'`. */ export { KODY_CLOUDFLARE_ACCOUNT_ID } export type VerifyApiTokenInput = CloudflareAuthOptions & { /** * Cloudflare account id for account-scoped token verification * (`GET /accounts/{account_id}/tokens/verify`). Required when the token is * account-scoped and `account` is not `kody` (which defaults to * {@link KODY_CLOUDFLARE_ACCOUNT_ID}). */ accountId?: string } export type VerifyApiTokenResult = { /** True when Cloudflare reported the token as usable. */ ok: boolean /** Which verify endpoint succeeded, or null when neither did. */ kind: 'user' | 'account' | null /** Cloudflare token id when returned by a successful verify call. */ tokenId: string | null /** Cloudflare token status (`active`, etc.) when returned. */ status: string | null /** * Cloudflare account id to use with this token: explicit `accountId`, the * alias's known account (`kody`, `pages`), or the only account GET /accounts * returned. `null` when ambiguous — see `accessibleAccounts`. */ accountId: string | null /** * Accounts GET /accounts returned for this token (id + name). Empty for * tokens without account-read access (the `pages` token) even when valid. */ accessibleAccounts: AccessibleAccount[] /** Kody secret name that was used (never the token value). */ secretName: string /** HTTP status from the successful verify, or the last attempt. */ httpStatus: number | null /** Compact error list from failed attempts. */ errors: Array<{ code?: number; message?: string }> /** * Agent-facing guidance. Especially important when `/user/tokens/verify` * returns 401 for a valid account-scoped token. */ guidance: string } type CloudflareError = { code?: number; message?: string } function asErrors(errors: unknown): Array { if (!Array.isArray(errors)) return [] return errors.map((error) => { if (!error || typeof error !== 'object') return {} const record = error as CloudflareError return { code: typeof record.code === 'number' ? record.code : undefined, message: typeof record.message === 'string' ? record.message : undefined, } }) } function resolveAccountIdForVerify(params: VerifyApiTokenInput): string | null { if (params.apiTokenSecret && !params.accountId) return null return resolveKnownAccountId({ account: params.account, accountId: params.accountId }) } function pickAccountId(known: string | null, accessible: AccessibleAccount[]): string | null { if (known) return known return accessible.length === 1 ? accessible[0].id : null } function accountsNote(accountId: string | null, accessible: AccessibleAccount[]): string { if (accessible.length > 1 && !accountId) { return ` Token sees ${accessible.length} accounts (${accessible .map((account) => `${account.name} ${account.id}`) .join(', ')}); pass accountId to account-scoped helpers.` } if (accessible.length === 0 && accountId) { return ` GET /accounts returned none (normal for narrowly scoped tokens); using known account ${accountId}.` } return '' } /** * Verify a Cloudflare API token without misreading account-scoped tokens. * * **Do not use `/user/tokens/verify` alone.** Account API tokens (Manage * Account → API Tokens) often return `401 Invalid API Token` on the user * endpoint even when fully valid. Use: * * - User tokens (My Profile → API Tokens): `GET /user/tokens/verify` * - Account tokens: `GET /accounts/{account_id}/tokens/verify` * * This helper tries the user endpoint first, then the account endpoint when an * `accountId` is known (aliases `kody` and `pages` carry their account ids). * On success it also lists `accessibleAccounts` (GET /accounts) and returns * the `accountId` to use with account-scoped helpers when it is unambiguous. * * A successful verify only means the token is alive. Permission checks still * need resource probes: `403` = lacks permission; `400`/`404` on a mutating * call against a bad/missing target = authorized for that operation. * * @param params.account - Token alias (`default`, `kody`, `pages`). * @param params.apiTokenSecret - Explicit Kody secret name when no alias fits. * @param params.accountId - Cloudflare account id for account-scoped verify. * @returns Compact verification result (never includes the token value). * @example * import verifyApiToken from 'kody:@kentcdodds/cloudflare/verify' * const result = await verifyApiToken({ account: 'kody' }) * // => { ok: true, kind: 'account', status: 'active', accountId: '…', accessibleAccounts: [{ id, name }], ... } */ export default async function verifyApiToken( params: VerifyApiTokenInput = {}, ): Promise { const auth = pickAuthOptions(params) const secretName = resolveApiTokenSecretName(auth) const accountAlias = params.account ?? DEFAULT_CLOUDFLARE_ACCOUNT const accountId = resolveAccountIdForVerify(params) const userVerify = await cloudflareApiV4({ ...auth, path: '/client/v4/user/tokens/verify', }) if (userVerify.success) { const result = userVerify.result && typeof userVerify.result === 'object' ? (userVerify.result as { id?: string; status?: string }) : {} const listed = await listAccessibleAccounts(auth) const resolved = pickAccountId(accountId, listed.accounts) return { ok: true, kind: 'user', tokenId: typeof result.id === 'string' ? result.id : null, status: typeof result.status === 'string' ? result.status : null, accountId: resolved, accessibleAccounts: listed.accounts, secretName, httpStatus: userVerify.httpStatus, errors: [], guidance: `Token for secret "${secretName}" verified via /user/tokens/verify ` + `(user-scoped / profile token). Alias: ${accountAlias}.` + accountsNote(resolved, listed.accounts), } } if (accountId) { const accountVerify = await cloudflareApiV4({ ...auth, path: `/client/v4/accounts/${accountId}/tokens/verify`, }) if (accountVerify.success) { const result = accountVerify.result && typeof accountVerify.result === 'object' ? (accountVerify.result as { id?: string; status?: string }) : {} const listed = await listAccessibleAccounts(auth) return { ok: true, kind: 'account', tokenId: typeof result.id === 'string' ? result.id : null, status: typeof result.status === 'string' ? result.status : null, accountId, accessibleAccounts: listed.accounts, secretName, httpStatus: accountVerify.httpStatus, errors: [], guidance: `Token for secret "${secretName}" verified via ` + `/accounts/${accountId}/tokens/verify (account-scoped token). ` + `A prior /user/tokens/verify 401 is expected for account tokens and ` + `must not be treated as “token invalid.”`, } } return { ok: false, kind: null, tokenId: null, status: null, accountId, accessibleAccounts: [], secretName, httpStatus: accountVerify.httpStatus, errors: [ ...asErrors(userVerify.errors), ...asErrors(accountVerify.errors), ], guidance: `Both verify endpoints failed for secret "${secretName}". ` + `User verify HTTP ${String(userVerify.httpStatus)}; account verify ` + `HTTP ${String(accountVerify.httpStatus)} against account ${accountId}. ` + `The stored secret value is likely wrong, revoked, or expired — not merely under-scoped.`, } } const aliasHint = accountAlias === 'kody' ? '' : ` For the Kody account token, call verifyApiToken({ account: 'kody' }). ` + `For other account-scoped tokens, pass accountId.` return { ok: false, kind: null, tokenId: null, status: null, accountId: null, accessibleAccounts: [], secretName, httpStatus: userVerify.httpStatus, errors: asErrors(userVerify.errors), guidance: `/user/tokens/verify failed for secret "${secretName}" ` + `(HTTP ${String(userVerify.httpStatus)}). This is NOT proof the token is ` + `invalid — account-scoped tokens commonly 401 here. Retry with accountId ` + `(GET /accounts/{account_id}/tokens/verify).${aliasHint} ` + `Alias info: ${getAccountInfo(accountAlias).label}.`, } }