Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ ENCRYPTION_KEY="ZGV2LW9ubHktZW5jcnlwdGlvbi1rZXktMzItYnl0ZXM="
# RESEND_KEY=""
# EMAIL_FROM=""

# Connecting bank accounts through Plaid: the keys from your Plaid dashboard. In sandbox, Plaid's
# test banks sign in with user_good and pass_good.
# PLAID_CLIENT_ID=""
# PLAID_SECRET=""
# PLAID_ENV="sandbox"

# Who can create an account: addresses and domains, comma-separated. Unset, anyone can.
# ALLOWED_SIGNUP_EMAILS=""

Expand Down
6 changes: 3 additions & 3 deletions AGENTS.md

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ services:
ALLOWED_SIGNUP_EMAILS: ${ALLOWED_SIGNUP_EMAILS:-}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
PLAID_CLIENT_ID: ${PLAID_CLIENT_ID:-}
PLAID_SECRET: ${PLAID_SECRET:-}
PLAID_ENV: ${PLAID_ENV:-}
RESEND_KEY: ${RESEND_KEY:-}
EMAIL_FROM: ${EMAIL_FROM:-}
TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-}
Expand Down
22 changes: 19 additions & 3 deletions packages/api/src/auth/google.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,18 @@ const mockEnv: {
GOOGLE_CLIENT_ID: string | undefined;
GOOGLE_CLIENT_SECRET: string;
ALLOWED_SIGNUP_EMAILS: string[] | undefined;
PLAID_CLIENT_ID: string | undefined;
PLAID_SECRET: string;
PLAID_ENV: string;
} = {
NODE_ENV: 'production',
ALLOWED_SIGNUP_EMAILS: undefined,
APP_URL: 'https://app.example.com',
GOOGLE_CLIENT_ID: 'google-client-id',
GOOGLE_CLIENT_SECRET: 'google-client-secret',
PLAID_CLIENT_ID: undefined,
PLAID_SECRET: 'plaid-secret',
PLAID_ENV: 'sandbox',
};

mock.module('../utils/env', () => ({env: mockEnv}));
Expand Down Expand Up @@ -136,6 +142,7 @@ beforeEach(() => {
mockEnv.APP_URL = 'https://app.example.com';
mockEnv.GOOGLE_CLIENT_ID = 'google-client-id';
mockEnv.ALLOWED_SIGNUP_EMAILS = undefined;
mockEnv.PLAID_CLIENT_ID = undefined;
mockTokenExchangeSuccess();
getAccountByProvider.mockResolvedValue(Ok(null));
getUserByEmail.mockResolvedValue(Ok(null));
Expand All @@ -152,12 +159,12 @@ describe('handleSignInMethodsRoute', () => {

const configured = makeRes();
handleSignInMethodsRoute({} as never, configured as never);
expect(configured.json).toHaveBeenCalledWith({google: true, needs: {google: true}});
expect(configured.json).toHaveBeenCalledWith({google: true, needs: {google: true, plaid: false}});

mockEnv.GOOGLE_CLIENT_ID = undefined;
const unconfigured = makeRes();
handleSignInMethodsRoute({} as never, unconfigured as never);
expect(unconfigured.json).toHaveBeenCalledWith({google: false, needs: {google: false}});
expect(unconfigured.json).toHaveBeenCalledWith({google: false, needs: {google: false, plaid: false}});
});

test('says a Google connection can start with the client alone, as its start route does', async () => {
Expand All @@ -166,7 +173,16 @@ describe('handleSignInMethodsRoute', () => {

const res = makeRes();
handleSignInMethodsRoute({} as never, res as never);
expect(res.json).toHaveBeenCalledWith({google: false, needs: {google: true}});
expect(res.json).toHaveBeenCalledWith({google: false, needs: {google: true, plaid: false}});
});

test('says a bank can be connected once the server has its Plaid keys', async () => {
const {handleSignInMethodsRoute} = await importRoutes();
mockEnv.PLAID_CLIENT_ID = 'plaid-client-id';

const res = makeRes();
handleSignInMethodsRoute({} as never, res as never);
expect(res.json).toHaveBeenCalledWith({google: true, needs: {google: true, plaid: true}});
});
});

Expand Down
5 changes: 3 additions & 2 deletions packages/api/src/auth/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {createOAuthAccount, getAccountByProvider} from '@proxy/db/auth';
import {createUserFromOAuthProfile, deleteUser, getUserByEmail, getUserFromId} from '@proxy/db/user';
import {formatFetchError, httpRequest, parseCookieHeader} from '@proxy/utils';

import {plaidClient} from '../connections/plaid/plaid-client';
import {log, serializeError} from '../observability/log';
import {env} from '../utils/env';
import {googleOAuthClient} from '../utils/google-oauth-client';
Expand Down Expand Up @@ -66,10 +67,10 @@ function getOAuthConfig(): OAuthConfig | null {
* `GET /auth/methods` — the sign-in methods beside the magic link, so the login page offers only
* those set up, and what the server has of what integrations need (`needs`), so the catalog offers
* only the ones whose sign-in can start: a Google connection needs the client, as its start route
* checks.
* checks, and a bank connected through Plaid the server's Plaid keys.
*/
export function handleSignInMethodsRoute(_req: Request, res: Response): void {
res.json({google: getOAuthConfig() !== null, needs: {google: googleOAuthClient() !== null}});
res.json({google: getOAuthConfig() !== null, needs: {google: googleOAuthClient() !== null, plaid: plaidClient() !== null}});
}

const statePayloadSchema = z.object({
Expand Down
48 changes: 33 additions & 15 deletions packages/api/src/connections/disconnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,51 @@ import {countSameAccountConnections} from '@proxy/db/connection';

import {log, serializeError} from '../observability/log';
import {readGoogleCredential, revokeGoogleTokens} from './google/google-oauth';
import {removeItem} from './plaid/plaid-api';
import {readPlaidCredential, requirePlaidClient} from './plaid/plaid-client';

/**
* What is undone at the provider before a connection is deleted, where the provider has a way:
* Google's grant is revoked, so its tokens stop working there too. Only Google has one so far. A
* grant is the account's for the app's Google client as a whole, every refresh token it has, so
* it is kept while another connection, in this organization or any other, is for the same
* account, by Google's id for it or its address. A failure is logged and the connection is
* deleted anyway; the person can still revoke it at myaccount.google.com/permissions.
* What is undone at the provider before a connection is deleted, where the provider has a way.
* Google's grant is revoked, so its tokens stop working there too. A grant is the account's for the
* app's Google client as a whole, every refresh token it has, so it is kept while another
* connection, in this organization or any other, is for the same account, by Google's id for it or
* its address. A Plaid bank login is removed at Plaid, which also ends what Plaid bills for it; each
* connection is a login of its own. A failure is logged and the connection is deleted anyway; the
* person can still revoke Google's at myaccount.google.com/permissions, and Plaid's at my.plaid.com.
*/

type DisconnectedConnection = {id: string; integrationId: string; credential: string | null};

export async function beforeDisconnect(connection: DisconnectedConnection): Promise<void> {
if (connection.integrationId !== 'google') {
return;
}

const revoked = await Do<void, ApiError>(async ($) => {
function revokeGoogle(connection: DisconnectedConnection) {
return Do<void, ApiError>(async ($) => {
const sharing = $(await countSameAccountConnections(connection.id));
if (sharing > 1) {
log.info('Kept the Google grant another connection uses', {connectionId: connection.id});
return;
}
$(await revokeGoogleTokens($(readGoogleCredential(connection.credential))));
});
if (revoked.isErr()) {
const error = revoked.error;
log.warn('Revoking a Google connection at Google failed', {connectionId: connection.id, kind: error.kind, ...('cause' in error ? serializeError(error.cause) : {})});
}

function removePlaidItem(connection: DisconnectedConnection) {
return Do<void, ApiError>(async ($) => {
$(await removeItem($(requirePlaidClient()), $(readPlaidCredential(connection.credential)).accessToken));
});
}

export async function beforeDisconnect(connection: DisconnectedConnection): Promise<void> {
if (connection.integrationId !== 'google' && connection.integrationId !== 'plaid') {
return;
}

const undone = connection.integrationId === 'google' ? await revokeGoogle(connection) : await removePlaidItem(connection);
if (undone.isErr()) {
const error = undone.error;
log.warn('Undoing a connection at its provider failed', {
connectionId: connection.id,
integrationId: connection.integrationId,
kind: error.kind,
...('cause' in error ? serializeError(error.cause) : {}),
});
}
}
128 changes: 128 additions & 0 deletions packages/api/src/connections/plaid/plaid-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type {Result} from 'ts-results-es';
import {z} from 'zod';

import {type ApiError, Do} from '@proxy/utils';

import {plaidRequest, type PlaidClient} from './plaid-client';

/**
* The parts of Plaid's API Personal Agent Proxy uses: Link tokens for connecting a bank and signing
* in to it again, the public token's exchange, a bank login's accounts and transactions, and
* removing the login, which ends what Plaid bills for it. Only Transactions is asked for: its
* accounts come with their balances as of Plaid's last update, several times a day, without the
* per-request fee of Plaid's live Balance.
*/

// As far back as Plaid fetches: two years before the bank was connected.
const DAYS_REQUESTED = 730;

const linkTokenSchema = z.object({link_token: z.string()});

/**
* A Link token for the web app's Plaid Link: a new bank login, or, with `accessToken`, Link's
* update mode for one already connected, where the person signs in to the bank again or changes
* which accounts it shares. `userId` is Personal Agent Proxy's user, as Plaid asks.
*/
export function createLinkToken(client: PlaidClient, args: {userId: string; accessToken: string | null}): Promise<Result<string, ApiError>> {
return Do(async ($) => {
const request = {client_name: 'Personal Agent Proxy', language: 'en', country_codes: ['US', 'CA'], user: {client_user_id: args.userId}};
const body =
args.accessToken === null
? {...request, products: ['transactions'], transactions: {days_requested: DAYS_REQUESTED}}
: {...request, access_token: args.accessToken, update: {account_selection_enabled: true}};
return $(await plaidRequest(client, '/link/token/create', body, linkTokenSchema)).link_token;
});
}

const exchangeSchema = z.object({access_token: z.string(), item_id: z.string()});

/** The access token and login id for the public token Link answered with. */
export function exchangePublicToken(client: PlaidClient, publicToken: string): Promise<Result<{accessToken: string; itemId: string}, ApiError>> {
return Do(async ($) => {
const answer = $(await plaidRequest(client, '/item/public_token/exchange', {public_token: publicToken}, exchangeSchema));
return {accessToken: answer.access_token, itemId: answer.item_id};
});
}

const accountSchema = z.object({
account_id: z.string(),
name: z.string(),
official_name: z.string().nullable().optional(),
mask: z.string().nullable().optional(),
type: z.string(),
subtype: z.string().nullable().optional(),
balances: z.object({
current: z.number().nullable().optional(),
available: z.number().nullable().optional(),
limit: z.number().nullable().optional(),
iso_currency_code: z.string().nullable().optional(),
unofficial_currency_code: z.string().nullable().optional(),
}),
});

export type PlaidAccount = z.infer<typeof accountSchema>;

const itemSchema = z.object({item_id: z.string(), institution_id: z.string().nullable().optional(), institution_name: z.string().nullable().optional()});

const accountsSchema = z.object({accounts: z.array(accountSchema), item: itemSchema});

/** The bank login's accounts, with their balances as of Plaid's last update, and the bank's name. */
export function getAccounts(client: PlaidClient, accessToken: string): Promise<Result<{accounts: PlaidAccount[]; institution: string | null}, ApiError>> {
return Do(async ($) => {
const answer = $(await plaidRequest(client, '/accounts/get', {access_token: accessToken}, accountsSchema));
return {accounts: answer.accounts, institution: answer.item.institution_name ?? null};
});
}

const transactionSchema = z.object({
transaction_id: z.string(),
account_id: z.string(),
// Money leaving the account is positive, as Plaid writes it; money coming in negative.
amount: z.number(),
iso_currency_code: z.string().nullable().optional(),
unofficial_currency_code: z.string().nullable().optional(),
date: z.string(),
name: z.string(),
merchant_name: z.string().nullable().optional(),
pending: z.boolean(),
personal_finance_category: z.object({primary: z.string(), detailed: z.string()}).nullable().optional(),
location: z
.object({
city: z.string().nullable().optional(),
region: z.string().nullable().optional(),
country: z.string().nullable().optional(),
})
.nullable()
.optional(),
website: z.string().nullable().optional(),
});

export type PlaidTransaction = z.infer<typeof transactionSchema>;

const transactionsSchema = z.object({accounts: z.array(accountSchema), transactions: z.array(transactionSchema), total_transactions: z.number()});

export type TransactionsQuery = {from: string; to: string; accountId: string | null; count: number; offset: number};

/**
* The transactions between two days, both included, newest first: `count` of them from `offset`
* on, of one account or of all of them, with how many there are in all and the accounts.
*/
export function getTransactions(
client: PlaidClient,
accessToken: string,
query: TransactionsQuery,
): Promise<Result<{accounts: PlaidAccount[]; transactions: PlaidTransaction[]; total: number}, ApiError>> {
return Do(async ($) => {
const options = {count: query.count, offset: query.offset, ...(query.accountId === null ? {} : {account_ids: [query.accountId]})};
const body = {access_token: accessToken, start_date: query.from, end_date: query.to, options};
const answer = $(await plaidRequest(client, '/transactions/get', body, transactionsSchema));
return {accounts: answer.accounts, transactions: answer.transactions, total: answer.total_transactions};
});
}

/** Removes the bank login at Plaid: its access token stops working, and Plaid stops billing for it. */
export function removeItem(client: PlaidClient, accessToken: string): Promise<Result<void, ApiError>> {
return Do(async ($) => {
$(await plaidRequest(client, '/item/remove', {access_token: accessToken}, z.object({})));
});
}
Loading
Loading