/**
* Shared Plaid transport: sandbox-first hosts, secret-backed client id +
* secret headers, access-token resolution, dry-run mutations, and setup-aware
* errors.
*
* Auth is secret-backed (not OAuth and not a bank login). Defaults are
* `plaidClientId` and `plaidSecret`. Extra apps use `account: "work"` →
* `plaidClientId-work` / `plaidSecret-work`. Item reads use
* `plaidAccessToken` the same way. Placeholders resolve on approved hosts only.
*/
export const SANDBOX_API_BASE_URL = 'https://sandbox.plaid.com'
export const PRODUCTION_API_BASE_URL = 'https://production.plaid.com'
export const SANDBOX_API_HOST = 'sandbox.plaid.com'
export const PRODUCTION_API_HOST = 'production.plaid.com'
export const PLAID_VERSION = '2020-09-14'
export const DASHBOARD_KEYS_URL = 'https://dashboard.plaid.com/developers/keys'
export const DEFAULT_CLIENT_ID_SECRET = 'plaidClientId'
export const DEFAULT_SECRET_SECRET = 'plaidSecret'
export const DEFAULT_ACCESS_TOKEN_SECRET = 'plaidAccessToken'
export const DEFAULT_COUNTRY_CODES = ['US'] as const
export const DEFAULT_PRODUCTS = ['transactions', 'auth', 'identity'] as const
export const SANDBOX_INSTITUTION_ID = 'ins_109508'
const CLIENT_ID_PATTERN = /^plaidClientId(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const SECRET_PATTERN = /^plaidSecret(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const ACCESS_TOKEN_SECRET_PATTERN = /^plaidAccessToken(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/
export type PlaidEnvironment = 'sandbox' | 'production'
export type PlaidAuthInput = {
/** Defaults to sandbox. Pass `production` for live Items. */
environment?: PlaidEnvironment
apiBaseUrl?: string
/** Extra app label. `work` reads `plaidClientId-work`. */
account?: string
clientIdSecret?: string
secretSecret?: string
accessTokenSecret?: string
/**
* Item access token already obtained (for example from
* `exchangePublicToken`). Prefer saving it as `plaidAccessToken` instead of
* pasting it in chat. Never pass a bank password here.
*/
accessToken?: string
}
export type PlaidObject = Record<string, any>
export class PlaidApiError extends Error {
status: number
errorType: string | null
errorCode: string | null
requestId: string | null
details: unknown
setup: { clientIdUrl: string; secretUrl: string; hosts: string[]; dashboard: string }
constructor(
message: string,
input: {
status: number
errorType?: string | null
errorCode?: string | null
requestId?: string | null
details?: unknown
auth?: PlaidAuthInput
},
) {
super(message)
this.name = 'PlaidApiError'
this.status = input.status
this.errorType = input.errorType ?? null
this.errorCode = input.errorCode ?? null
this.requestId = input.requestId ?? null
this.details = input.details ?? null
this.setup = {
clientIdUrl: clientIdSetupUrl(resolveClientIdSecretName(input.auth)),
secretUrl: secretSetupUrl(resolveSecretSecretName(input.auth)),
hosts: [SANDBOX_API_HOST, PRODUCTION_API_HOST],
dashboard: DASHBOARD_KEYS_URL,
}
}
}
export function assertNever(value: never, message: string): never {
throw new Error(message + String(value))
}
export function parseAction<T extends string>(
value: unknown,
allowed: readonly T[],
fallback: T,
label: string,
): T {
const action = (value == null || value === '' ? fallback : value) as unknown
if (typeof action === 'string' && (allowed as readonly string[]).includes(action)) {
return action as T
}
throw new Error(
'Unknown ' + label + ' action: ' + String(action) + '. Valid actions: ' + allowed.join(', '),
)
}
function accountLabel(value: string | undefined): string | null {
const trimmed = (value ?? '').trim()
if (!trimmed || trimmed === 'default') return null
if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) {
throw new Error(
'account must be a short label such as "work" or "sandbox" (letters, numbers, _ or -).',
)
}
return trimmed
}
function suffixName(base: string, account: string | null): string {
return account ? base + '-' + account : base
}
/** Resolve the user-scoped Plaid client-id secret name for this call. */
export function resolveClientIdSecretName(input: PlaidAuthInput = {}): string {
if (input.clientIdSecret != null && String(input.clientIdSecret).trim() !== '') {
const name = String(input.clientIdSecret).trim()
if (!CLIENT_ID_PATTERN.test(name)) {
throw new Error(
'clientIdSecret must be plaidClientId or plaidClientId-<account>. Got ' +
name +
'. Save it at ' +
clientIdSetupUrl(DEFAULT_CLIENT_ID_SECRET) +
'.',
)
}
return name
}
return suffixName(DEFAULT_CLIENT_ID_SECRET, accountLabel(input.account))
}
/** Resolve the user-scoped Plaid secret secret name for this call. */
export function resolveSecretSecretName(input: PlaidAuthInput = {}): string {
if (input.secretSecret != null && String(input.secretSecret).trim() !== '') {
const name = String(input.secretSecret).trim()
if (!SECRET_PATTERN.test(name)) {
throw new Error(
'secretSecret must be plaidSecret or plaidSecret-<account>. Got ' +
name +
'. Save it at ' +
secretSetupUrl(DEFAULT_SECRET_SECRET) +
'.',
)
}
return name
}
return suffixName(DEFAULT_SECRET_SECRET, accountLabel(input.account))
}
/** Resolve the user-scoped Item access-token secret name for this call. */
export function resolveAccessTokenSecretName(input: PlaidAuthInput = {}): string {
if (input.accessTokenSecret != null && String(input.accessTokenSecret).trim() !== '') {
const name = String(input.accessTokenSecret).trim()
if (!ACCESS_TOKEN_SECRET_PATTERN.test(name)) {
throw new Error(
'accessTokenSecret must be plaidAccessToken or plaidAccessToken-<account>. Got ' +
name +
'. Save it at ' +
accessTokenSetupUrl(DEFAULT_ACCESS_TOKEN_SECRET) +
'.',
)
}
return name
}
return suffixName(DEFAULT_ACCESS_TOKEN_SECRET, accountLabel(input.account))
}
export const CLIENT_ID_SETUP_URL =
'https://kody.codes/account/secrets/new?name=plaidClientId&description=Plaid%20API%20client%20id%20from%20dashboard.plaid.com%2Fdevelopers%2Fkeys%20(PLAID_CLIENT_ID)&allowedHosts=sandbox.plaid.com,production.plaid.com&scope=user'
export const SECRET_SETUP_URL =
'https://kody.codes/account/secrets/new?name=plaidSecret&description=Plaid%20API%20secret%20from%20dashboard.plaid.com%2Fdevelopers%2Fkeys%20(PLAID_SECRET)&allowedHosts=sandbox.plaid.com,production.plaid.com&scope=user'
export const ACCESS_TOKEN_SETUP_URL =
'https://kody.codes/account/secrets/new?name=plaidAccessToken&description=Plaid%20Item%20access_token%20from%20Link%20or%20sandbox%20public-token%20exchange.%20Not%20a%20bank%20password.&allowedHosts=sandbox.plaid.com,production.plaid.com&scope=user'
export function clientIdSetupUrl(secretName: string = DEFAULT_CLIENT_ID_SECRET): string {
if (secretName === DEFAULT_CLIENT_ID_SECRET) return CLIENT_ID_SETUP_URL
return CLIENT_ID_SETUP_URL.replace('name=plaidClientId', 'name=' + encodeURIComponent(secretName))
}
export function secretSetupUrl(secretName: string = DEFAULT_SECRET_SECRET): string {
if (secretName === DEFAULT_SECRET_SECRET) return SECRET_SETUP_URL
return SECRET_SETUP_URL.replace('name=plaidSecret', 'name=' + encodeURIComponent(secretName))
}
export function accessTokenSetupUrl(secretName: string = DEFAULT_ACCESS_TOKEN_SECRET): string {
if (secretName === DEFAULT_ACCESS_TOKEN_SECRET) return ACCESS_TOKEN_SETUP_URL
return ACCESS_TOKEN_SETUP_URL.replace(
'name=plaidAccessToken',
'name=' + encodeURIComponent(secretName),
)
}
export function resolveEnvironment(input: PlaidAuthInput = {}): PlaidEnvironment {
const environment = (input.environment ?? 'sandbox') as PlaidEnvironment | string
switch (environment) {
case 'sandbox':
case 'production':
return environment
default: {
const unexpected: never = environment as never
return assertNever(unexpected, 'environment must be "sandbox" or "production". Got ')
}
}
}
export function getPlaidApiBaseUrl(input: PlaidAuthInput = {}) {
if (input.apiBaseUrl) return String(input.apiBaseUrl).replace(/\/+$/, '')
return resolveEnvironment(input) === 'production' ? PRODUCTION_API_BASE_URL : SANDBOX_API_BASE_URL
}
export function getPlaidApiHost(input: PlaidAuthInput = {}) {
return new URL(getPlaidApiBaseUrl(input)).host
}
function clientIdPlaceholder(input: PlaidAuthInput = {}): string {
return '{{secret:' + resolveClientIdSecretName(input) + '|scope=user}}'
}
function secretPlaceholder(input: PlaidAuthInput = {}): string {
return '{{secret:' + resolveSecretSecretName(input) + '|scope=user}}'
}
function accessTokenPlaceholder(input: PlaidAuthInput = {}): string {
return '{{secret:' + resolveAccessTokenSecretName(input) + '|scope=user}}'
}
/** Access token for a live request. Uses a secret placeholder when omitted. */
export function accessTokenForRequest(input: PlaidAuthInput = {}): string {
if (input.accessToken != null && String(input.accessToken).trim() !== '') {
return String(input.accessToken).trim()
}
return accessTokenPlaceholder(input)
}
/**
* Inert preview for dry-run bodies. Never emit a resolvable `{{secret:…}}`
* placeholder into chat or logs.
*/
export function accessTokenForPreview(input: PlaidAuthInput = {}): string {
if (input.accessToken != null && String(input.accessToken).trim() !== '') {
return '<access_token>'
}
return 'secret:' + resolveAccessTokenSecretName(input)
}
export function requireConfirm(input: { confirm?: boolean }, action: string) {
if (input.confirm !== true) {
throw new Error(
'Refusing to ' +
action +
' without confirm: true. This action changes Plaid state; omit confirm (default dry-run) or pass dryRun: true to preview.',
)
}
}
export type PlaidDryRun = {
dryRun: true
action: string
method: 'POST'
path: string
environment: PlaidEnvironment
host: string
body?: Record<string, unknown>
}
export type MutationGuardInput = PlaidAuthInput & {
/** Defaults to true unless `confirm: true` is also set. */
dryRun?: boolean
confirm?: boolean
}
/**
* Mutations and link-token creation default to a dry-run preview. Live calls
* require `confirm: true` (and `dryRun` not true).
*/
export function mutationPreview(
input: MutationGuardInput,
options: {
action: string
method: 'POST'
path: string
body?: Record<string, unknown>
},
): PlaidDryRun | null {
const defaultDryRun = input.dryRun !== false && input.confirm !== true
if (defaultDryRun || input.dryRun === true) {
return {
dryRun: true,
action: options.action,
method: options.method,
path: options.path,
environment: resolveEnvironment(input),
host: getPlaidApiHost(input),
body: options.body,
}
}
requireConfirm(input, options.action)
return null
}
function hintForError(status: number, errorCode: string | null, errorType: string | null): string {
if (status === 401 || errorCode === 'INVALID_API_KEYS') {
return (
' Save plaidClientId and plaidSecret at the prefilled secrets URLs and approve ' +
SANDBOX_API_HOST +
' (and ' +
PRODUCTION_API_HOST +
' if you use production).'
)
}
if (errorCode === 'INVALID_ACCESS_TOKEN' || errorCode === 'INVALID_ACCOUNT_ID') {
return (
' Save the Item access_token as plaidAccessToken (not a bank password) at ' +
ACCESS_TOKEN_SETUP_URL +
', or pass accessToken from a confirmed public-token exchange.'
)
}
if (
errorCode === 'ITEM_LOGIN_REQUIRED' ||
errorCode === 'USER_PERMISSION_REVOKED' ||
errorType === 'ITEM_ERROR'
) {
return (
' The Item needs a Link update — never ask for a bank username or password in chat. Create a link token with update mode, have the owner complete Plaid Link, then exchange the new public_token.'
)
}
if (errorCode === 'PRODUCTS_NOT_SUPPORTED' || errorCode === 'ADDITIONAL_CONSENT_REQUIRED') {
return ' This Item was not created with the product you requested. Recreate it in Sandbox or re-Link with that product consented.'
}
return ''
}
function toPlaidApiError(
response: Response,
parsed: PlaidObject | null,
auth: PlaidAuthInput,
): PlaidApiError {
const errorCode = typeof parsed?.error_code === 'string' ? parsed.error_code : null
const errorType = typeof parsed?.error_type === 'string' ? parsed.error_type : null
const baseMessage =
typeof parsed?.error_message === 'string' && parsed.error_message
? parsed.error_message
: 'Plaid API request failed with status ' + response.status
return new PlaidApiError(baseMessage + hintForError(response.status, errorCode, errorType), {
status: response.status,
errorType,
errorCode,
requestId:
(typeof parsed?.request_id === 'string' ? parsed.request_id : null) ??
response.headers.get('plaid-request-id'),
details: parsed,
auth,
})
}
export type PlaidRequestInput = PlaidAuthInput & {
path: string
body?: Record<string, unknown>
/** When true, include an access_token (from input or plaidAccessToken). */
includeAccessToken?: boolean
}
/**
* Authenticated Plaid POST. Client id + secret go in headers so they never
* appear in returned dry-run bodies. Access tokens stay in the JSON body.
*/
export async function plaidRequest(input: PlaidRequestInput): Promise<any> {
const baseUrl = getPlaidApiBaseUrl(input)
const path = input.path.startsWith('/') ? input.path : '/' + input.path
const url = new URL(path, baseUrl + '/')
const allowedHosts = new Set([SANDBOX_API_HOST, PRODUCTION_API_HOST])
if (!allowedHosts.has(url.host)) {
throw new Error('Plaid requests must stay on sandbox.plaid.com or production.plaid.com.')
}
const body: Record<string, unknown> = { ...(input.body ?? {}) }
if (input.includeAccessToken) {
body.access_token = accessTokenForRequest(input)
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'Plaid-Version': PLAID_VERSION,
'PLAID-CLIENT-ID': clientIdPlaceholder(input),
'PLAID-SECRET': secretPlaceholder(input),
},
body: JSON.stringify(body),
})
const text = await response.text()
let parsed: PlaidObject | null = null
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = { raw: text }
}
if (!response.ok) throw toPlaidApiError(response, parsed, input)
return parsed
}
export function normalizeMoney(balance: unknown) {
if (!balance || typeof balance !== 'object') return null
const record = balance as Record<string, unknown>
return {
available: typeof record.available === 'number' ? record.available : null,
current: typeof record.current === 'number' ? record.current : null,
limit: typeof record.limit === 'number' ? record.limit : null,
iso_currency_code: typeof record.iso_currency_code === 'string' ? record.iso_currency_code : null,
unofficial_currency_code:
typeof record.unofficial_currency_code === 'string' ? record.unofficial_currency_code : null,
}
}
export function normalizeAccount(account: any) {
if (!account || typeof account !== 'object') return null
return {
account_id: account.account_id ?? null,
name: account.name ?? null,
official_name: account.official_name ?? null,
mask: account.mask ?? null,
type: account.type ?? null,
subtype: account.subtype ?? null,
balances: normalizeMoney(account.balances),
}
}
export function normalizeItem(item: any) {
if (!item || typeof item !== 'object') return null
return {
item_id: item.item_id ?? null,
institution_id: item.institution_id ?? null,
institution_name: item.institution_name ?? null,
webhook: item.webhook ?? null,
available_products: Array.isArray(item.available_products) ? item.available_products : [],
billed_products: Array.isArray(item.billed_products) ? item.billed_products : [],
products: Array.isArray(item.products) ? item.products : [],
consent_expiration_time: item.consent_expiration_time ?? null,
error: item.error
? {
error_type: item.error.error_type ?? null,
error_code: item.error.error_code ?? null,
error_message: item.error.error_message ?? null,
}
: null,
}
}
export function normalizeTransaction(txn: any) {
if (!txn || typeof txn !== 'object') return null
return {
transaction_id: txn.transaction_id ?? null,
account_id: txn.account_id ?? null,
amount: typeof txn.amount === 'number' ? txn.amount : null,
iso_currency_code: txn.iso_currency_code ?? null,
date: txn.date ?? null,
authorized_date: txn.authorized_date ?? null,
name: txn.name ?? null,
merchant_name: txn.merchant_name ?? null,
pending: Boolean(txn.pending),
category: Array.isArray(txn.category) ? txn.category : [],
personal_finance_category: txn.personal_finance_category
? {
primary: txn.personal_finance_category.primary ?? null,
detailed: txn.personal_finance_category.detailed ?? null,
}
: null,
}
}
export function normalizeIdentity(identity: any) {
if (!identity || typeof identity !== 'object') return null
const owners = Array.isArray(identity.owners) ? identity.owners : []
return {
account_id: identity.account_id ?? null,
name: identity.name ?? null,
mask: identity.mask ?? null,
type: identity.type ?? null,
subtype: identity.subtype ?? null,
owners: owners.map((owner: any) => ({
names: Array.isArray(owner?.names) ? owner.names : [],
phone_numbers: Array.isArray(owner?.phone_numbers)
? owner.phone_numbers.map((phone: any) => ({
data: phone?.data ?? null,
primary: Boolean(phone?.primary),
type: phone?.type ?? null,
}))
: [],
emails: Array.isArray(owner?.emails)
? owner.emails.map((email: any) => ({
data: email?.data ?? null,
primary: Boolean(email?.primary),
type: email?.type ?? null,
}))
: [],
addresses: Array.isArray(owner?.addresses)
? owner.addresses.map((address: any) => ({
primary: Boolean(address?.primary),
city: address?.data?.city ?? null,
region: address?.data?.region ?? null,
postal_code: address?.data?.postal_code ?? null,
country: address?.data?.country ?? null,
}))
: [],
})),
}
}
export function normalizeInstitution(institution: any) {
if (!institution || typeof institution !== 'object') return null
return {
institution_id: institution.institution_id ?? null,
name: institution.name ?? null,
products: Array.isArray(institution.products) ? institution.products : [],
country_codes: Array.isArray(institution.country_codes) ? institution.country_codes : [],
url: institution.url ?? null,
oauth: Boolean(institution.oauth),
}
}
export function asStringArray(value: unknown, fallback: readonly string[]): string[] {
if (value == null) return [...fallback]
if (!Array.isArray(value) || value.length === 0) {
throw new Error('Expected a non-empty string array.')
}
return value.map((entry) => {
if (typeof entry !== 'string' || entry.trim() === '') {
throw new Error('Expected a non-empty string array.')
}
return entry
})
}
export function requireString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(field + ' is required.')
}
return value.trim()
}
export function isoDate(value: string | Date | undefined, field: string): string | undefined {
if (value == null) return undefined
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) throw new Error('Invalid ' + field + ': ' + String(value))
return date.toISOString().slice(0, 10)
}