Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/plaid

src/smoke-test.ts

147 lines · 4.6 KB · TypeScript
import { kody } from 'kody:runtime'
import { getAccounts } from './accounts.ts'
import { listInstitutions } from './institutions.ts'
import { getItem } from './item.ts'
import { createLinkToken } from './link.ts'
import {
	ACCESS_TOKEN_SETUP_URL,
	CLIENT_ID_SETUP_URL,
	DASHBOARD_KEYS_URL,
	PRODUCTION_API_HOST,
	SANDBOX_API_HOST,
	SECRET_SETUP_URL,
	accessTokenSetupUrl,
	clientIdSetupUrl,
	resolveAccessTokenSecretName,
	resolveClientIdSecretName,
	resolveSecretSecretName,
	secretSetupUrl,
	type PlaidAuthInput,
} from './plaid-core.ts'
import { createSandboxPublicToken } from './sandbox.ts'

function secretEntries(result: unknown): Array<{ name?: string; scope?: string }> {
	if (result && typeof result === 'object' && Array.isArray((result as { secrets?: unknown }).secrets)) {
		return (result as { secrets: Array<{ name?: string; scope?: string }> }).secrets
	}
	return Array.isArray(result) ? result : []
}

async function listUserSecretNames(): Promise<Set<string>> {
	try {
		const listed = await kody.secret_list({ scope: 'user' })
		const names = new Set<string>()
		for (const entry of secretEntries(listed)) {
			if (entry?.name && (entry.scope === 'user' || !entry.scope)) names.add(entry.name)
		}
		return names
	} catch {
		return new Set()
	}
}

/**
 * Local dry-run self-check plus an optional live institutions read.
 *
 * Without saved client credentials this still returns `{ ok: true, live: false }`
 * and the prefilled setup URLs. It never creates Items, never asks for a bank
 * password, and never writes Plaid state.
 *
 * @example
 * import plaid from 'kody:@kody/plaid'
 * const result = await plaid({ action: 'smoke-test' })
 */
export async function smokeTest(input: PlaidAuthInput = {}) {
	const linkPreview = await createLinkToken({
		...input,
		clientName: 'Kody',
		clientUserId: 'kody-plaid-smoke',
		dryRun: true,
	})
	if (!('dryRun' in linkPreview) || linkPreview.dryRun !== true) {
		throw new Error('createLinkToken dryRun self-check failed.')
	}

	const sandboxPreview = await createSandboxPublicToken({
		...input,
		dryRun: true,
	})
	if (!('dryRun' in sandboxPreview) || sandboxPreview.dryRun !== true) {
		throw new Error('createSandboxPublicToken dryRun self-check failed.')
	}

	const clientIdSecret = resolveClientIdSecretName(input)
	const secretSecret = resolveSecretSecretName(input)
	const accessTokenSecret = resolveAccessTokenSecretName(input)
	const names = await listUserSecretNames()
	const hasClientId = names.has(clientIdSecret)
	const hasSecret = names.has(secretSecret)
	const hasAccessToken = names.has(accessTokenSecret)

	if (!hasClientId || !hasSecret) {
		return {
			ok: true,
			live: false,
			selfCheck: { dryRunCreateLinkToken: true, dryRunCreateSandboxPublicToken: true },
			clientIdSecret,
			secretSecret,
			accessTokenSecret,
			setup: {
				auth: 'client-id-secret',
				hosts: [SANDBOX_API_HOST, PRODUCTION_API_HOST],
				dashboard: DASHBOARD_KEYS_URL,
				nextSteps: [
					'Create a Plaid account and copy the sandbox client_id and secret from ' +
						DASHBOARD_KEYS_URL +
						' (dashboard fields PLAID_CLIENT_ID / PLAID_SECRET).',
					'Save the client id: ' + clientIdSetupUrl(clientIdSecret),
					'Save the secret: ' + secretSetupUrl(secretSecret),
					'Approve hosts ' + SANDBOX_API_HOST + ' and ' + PRODUCTION_API_HOST + ' on both secrets.',
					'Optional Item access_token (not a bank password): ' + accessTokenSetupUrl(accessTokenSecret),
					'Never paste a bank username or password into chat.',
				],
			},
		}
	}

	const institutions = await listInstitutions({ ...input, count: 3 })
	const itemRead = hasAccessToken
		? await getItem(input)
				.then((item) =>
					getAccounts(input).then((accounts) => ({
						item: item.item,
						accountCount: accounts.accounts.length,
						accounts: accounts.accounts,
					})),
				)
				.catch((error: unknown) => ({
					skipped: true,
					reason: error instanceof Error ? error.message : String(error),
				}))
		: {
				skipped: true,
				reason: 'Save an Item access_token as ' + accessTokenSecret + ' to exercise item/account reads.',
			}

	return {
		ok: true,
		live: true,
		selfCheck: { dryRunCreateLinkToken: true, dryRunCreateSandboxPublicToken: true },
		clientIdSecret,
		institutionSample: {
			total: institutions.total,
			sampleCount: institutions.institutions.length,
			sample: institutions.institutions,
		},
		itemRead,
		setup: {
			auth: 'client-id-secret',
			hosts: [SANDBOX_API_HOST, PRODUCTION_API_HOST],
			clientIdUrl: hasClientId ? null : CLIENT_ID_SETUP_URL,
			secretUrl: hasSecret ? null : SECRET_SETUP_URL,
			accessTokenUrl: hasAccessToken ? null : ACCESS_TOKEN_SETUP_URL,
		},
	}
}

export default smokeTest