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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ ENCRYPTION_KEY="ZGV2LW9ubHktZW5jcnlwdGlvbi1rZXktMzItYnl0ZXM="
# PLAID_SECRET=""
# PLAID_ENV="sandbox"

# An OpenRouter key, for the model the home page picks and fills in its widgets with. Without it the
# home page says it is not set up.
# OPENROUTER_KEY=""

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

Expand Down
3 changes: 3 additions & 0 deletions bun.lock

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

1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ services:
PLAID_ENV: ${PLAID_ENV:-}
RESEND_KEY: ${RESEND_KEY:-}
EMAIL_FROM: ${EMAIL_FROM:-}
OPENROUTER_KEY: ${OPENROUTER_KEY:-}
TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-}
logging: *logging

Expand Down
67 changes: 67 additions & 0 deletions packages/api/src/activity/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {afterEach, beforeEach, describe, expect, mock, setSystemTime, test} from 'bun:test';
import {Ok} from 'ts-results-es';

const getUserOrgId = mock();
const listAuditEntries = mock();
const countAuditEntriesByDay = mock();
const countAuditEntriesByAction = mock();

mock.module('@proxy/db/organization', () => ({getUserOrgId}));
mock.module('@proxy/db/audit', () => ({listAuditEntries, countAuditEntriesByDay, countAuditEntriesByAction}));

function makeRequest(query: Record<string, string>) {
return {user: {userId: 'user-1', email: 'test@example.com', name: 'Test User'}, query};
}

beforeEach(() => {
mock.clearAllMocks();
getUserOrgId.mockResolvedValue(Ok('org-1'));
listAuditEntries.mockResolvedValue(Ok([]));
countAuditEntriesByDay.mockResolvedValue(Ok([{day: '2026-10-16', agentId: null, count: 2, denied: 0}]));
countAuditEntriesByAction.mockResolvedValue(Ok([{connectionId: 'c1', collectionId: 'events', action: 'create', outcome: 'allowed', count: 2}]));
// Late on the 15th in UTC: already the 16th in Berlin.
setSystemTime(new Date('2026-10-15T23:30:00Z'));
});

afterEach(() => {
setSystemTime();
});

describe('handleActivitySummaryRoute', () => {
test('counts a year of whole weeks and the past week, by days on the person’s clock', async () => {
const {handleActivitySummaryRoute} = await import('./route');
const result = await handleActivitySummaryRoute(makeRequest({timeZone: 'Europe/Berlin'}) as never);

expect(result.unwrap()).toEqual({
today: '2026-10-16',
days: [{day: '2026-10-16', agentId: null, count: 2, denied: 0}],
week: [{connectionId: 'c1', collectionId: 'events', action: 'create', outcome: 'allowed', count: 2}],
});
expect(countAuditEntriesByDay).toHaveBeenCalledWith('org-1', 'Europe/Berlin', '2025-10-11');
expect(countAuditEntriesByAction).toHaveBeenCalledWith('org-1', 'Europe/Berlin', '2026-10-10');
});

test('refuses what isn’t a time zone before it reaches the database', async () => {
const {handleActivitySummaryRoute} = await import('./route');
const result = await handleActivitySummaryRoute(makeRequest({timeZone: "UTC'; --"}) as never);

expect(result.unwrapErr().kind).toBe('parse_error');
expect(countAuditEntriesByDay).not.toHaveBeenCalled();
});
});

describe('handleListActivityRoute', () => {
test('narrows to one outcome and a span of time', async () => {
const {handleListActivityRoute} = await import('./route');
await handleListActivityRoute(makeRequest({outcome: 'denied', from: '2026-10-14T00:00:00+02:00', to: '2026-10-15T00:00:00+02:00'}) as never);

expect(listAuditEntries).toHaveBeenCalledWith('org-1', {outcome: 'denied', from: new Date('2026-10-13T22:00:00Z'), to: new Date('2026-10-14T22:00:00Z')}, 500);
});

test('what Personal Agent Proxy did itself is the entries without an agent', async () => {
const {handleListActivityRoute} = await import('./route');
await handleListActivityRoute(makeRequest({agentId: 'proxy'}) as never);

expect(listAuditEntries).toHaveBeenCalledWith('org-1', {agentId: null}, 500);
});
});
55 changes: 51 additions & 4 deletions packages/api/src/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type {Result} from 'ts-results-es';
import {z} from 'zod';

import {type ApiError, Do, parseSchema} from '@proxy/utils';
import {listAuditEntries} from '@proxy/db/audit';
import {addDays, dayIn, isTimeZone} from '@proxy/utils/days';
import {type AuditActionCount, type AuditDayCount, countAuditEntriesByAction, countAuditEntriesByDay, listAuditEntries} from '@proxy/db/audit';

import type {AuthenticatedRequest} from '../server/middleware/require-auth';
import {requireUserOrgId} from '../utils/user-org';
Expand All @@ -12,7 +13,8 @@ const MAX_ENTRIES = 500;
export type AuditEntryResponse = {
id: string;
at: string;
agentId: string;
// None for what Personal Agent Proxy did itself, as the home page's widgets write.
agentId: string | null;
connectionId: string;
collectionId: string;
// list, view, create, update or delete, or a command of the collection, such as `send`.
Expand All @@ -24,14 +26,59 @@ export type AuditEntryResponse = {
via: 'web' | 'mcp';
};

const querySchema = z.object({agentId: z.string().min(1).optional(), connectionId: z.string().min(1).optional()});
// `?agentId=proxy` is what Personal Agent Proxy did itself.
const PROXY = 'proxy';

const querySchema = z.object({
agentId: z.string().min(1).optional(),
connectionId: z.string().min(1).optional(),
outcome: z.enum(['allowed', 'denied']).optional(),
// Only what was made from `from` until before `to`, as a day on the person's clock is.
from: z.iso.datetime({offset: true}).optional(),
to: z.iso.datetime({offset: true}).optional(),
});

/** The organization's agent requests, newest first, the latest 500. */
export function handleListActivityRoute(request: AuthenticatedRequest): Promise<Result<{entries: AuditEntryResponse[]}, ApiError>> {
return Do(async ($) => {
const filter = $(parseSchema(querySchema, request.query));
const {agentId, connectionId, outcome, from, to} = $(parseSchema(querySchema, request.query));
const orgId = $(await requireUserOrgId(request));
const filter = {
...(agentId === undefined ? {} : {agentId: agentId === PROXY ? null : agentId}),
...(connectionId === undefined ? {} : {connectionId}),
...(outcome === undefined ? {} : {outcome}),
...(from === undefined ? {} : {from: new Date(from)}),
...(to === undefined ? {} : {to: new Date(to)}),
};
const rows = $(await listAuditEntries(orgId, filter, MAX_ENTRIES));
return {entries: rows.map(({createdAt, ...entry}) => ({...entry, at: createdAt.toISOString()}))};
});
}

// The home page's graph: 53 weeks, so a year of whole weeks ends in this one whatever day it is.
const GRAPH_DAYS = 53 * 7;
// Its summary line: today and the six days before it.
const WEEK_DAYS = 7;

const summaryQuerySchema = z.object({timeZone: z.string().refine(isTimeZone, 'Not a time zone')});

export type ActivitySummaryResponse = {
// Today on the person's clock, `2026-10-16`, the day the graph ends on.
today: string;
// Each agent's requests each day of the graph's, on the person's clock, days without any left
// out; an `agentId` of null is what Personal Agent Proxy did itself.
days: AuditDayCount[];
// The past week's requests by connection, collection, action and outcome, the most first.
week: AuditActionCount[];
};

/** The organization's activity by day for the home page's graph, and the past week's by action. */
export function handleActivitySummaryRoute(request: AuthenticatedRequest): Promise<Result<ActivitySummaryResponse, ApiError>> {
return Do(async ($) => {
const {timeZone} = $(parseSchema(summaryQuerySchema, request.query));
const orgId = $(await requireUserOrgId(request));
const today = dayIn(new Date(), timeZone);
const [days, week] = await Promise.all([countAuditEntriesByDay(orgId, timeZone, addDays(today, 1 - GRAPH_DAYS)), countAuditEntriesByAction(orgId, timeZone, addDays(today, 1 - WEEK_DAYS))]);
return {today, days: $(days), week: $(week)};
});
}
11 changes: 8 additions & 3 deletions packages/api/src/connections/connect-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ import {requireUserOrgId} from '../utils/user-org';
* signing in again works, and ends on the connection's page, or back on the catalog with
* `?error=<integration>` (with `?error=<integration>-unconfigured` when the server lacks what the
* sign-in needs, such as Google's client); a sign-in begun from the welcome flow (`returnTo: 'welcome'`) ends on its
* page for the connection, or its catalog, instead. What the provider's sign-in page is and how a
* page for the connection, or its catalog, instead, and one begun from a home page widget's card
* (`returnTo: 'home'`) back on the home page. What the provider's sign-in page is and how a
* code becomes tokens and a connection is the integration's.
*/

const stateCookieMaxAgeMs = 15 * 60 * 1000;

/** Where a sign-in ends when not on the connection's page: the welcome flow it began from. */
export const returnToSchema = z.enum(['welcome']);
/** Where a sign-in ends when not on the connection's page: the welcome flow or the home page it began from. */
export const returnToSchema = z.enum(['welcome', 'home']);

type ReturnTo = z.infer<typeof returnToSchema>;

Expand Down Expand Up @@ -211,6 +212,10 @@ export function signInFlow<E, C>(args: SignInArgs<E, C>) {
res.redirect(`${appUrl}/welcome/connections/${id}`);
return;
}
if (returnTo === 'home') {
res.redirect(`${appUrl}/`);
return;
}
res.redirect(`${appUrl}/connections/${id}${added ? '?added' : ''}`);
}

Expand Down
21 changes: 15 additions & 6 deletions packages/api/src/connections/google/calendar-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ const calendarSchema = z.object({
description: z.string().optional(),
primary: z.boolean().optional(),
timeZone: z.string().optional(),
// Whether Google Calendar shows it, as ticked in its list, and what the account may do in it:
// `owner` or `writer` make events, `reader` sees them, `freeBusyReader` only when it is busy.
selected: z.boolean().optional(),
accessRole: z.string().optional(),
backgroundColor: z.string().optional(),
});

export type GoogleCalendar = z.infer<typeof calendarSchema>;
Expand Down Expand Up @@ -106,16 +111,20 @@ export function getCalendar(accessToken: string, calendarId: string): Promise<Re
return googleRequest(accessToken, new URL(`${CALENDAR}/users/me/calendarList/${encodeURIComponent(calendarId)}`), calendarSchema);
}

/** A page of a calendar's events ending after `from`, soonest first, matching `search` where there is one. */
export async function listEvents(
accessToken: string,
calendarId: string,
args: {from: string; search: string | null; page: string | null},
): Promise<Result<{events: GoogleEvent[]; nextPage: string | null}, ApiError>> {
export type EventsQuery = {from: string; to?: string; search: string | null; page: string | null};

/**
* A page of a calendar's events ending after `from`, and starting before `to` where it is given,
* soonest first, matching `search` where there is one.
*/
export async function listEvents(accessToken: string, calendarId: string, args: EventsQuery): Promise<Result<{events: GoogleEvent[]; nextPage: string | null}, ApiError>> {
const url = calendarUrl(calendarId, '/events');
url.searchParams.set('singleEvents', 'true');
url.searchParams.set('orderBy', 'startTime');
url.searchParams.set('timeMin', args.from);
if (args.to) {
url.searchParams.set('timeMax', args.to);
}
url.searchParams.set('maxResults', String(EVENTS_PAGE_SIZE));
if (args.search) {
url.searchParams.set('q', args.search);
Expand Down
17 changes: 1 addition & 16 deletions packages/api/src/connections/google/gmail-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ mock.module('@proxy/db/connection', () => ({updateConnectionCredential: mock(),

mock.module('../../utils/env', () => ({env: {NODE_ENV: 'production', APP_URL: 'https://app.example.com', GOOGLE_CLIENT_ID: 'client-1', GOOGLE_CLIENT_SECRET: 'secret-1'}}));

const {attachmentNames, folderOf, folderQuery, htmlToText, mapWithLimit, messageBody} = await import('./gmail-api');
const {attachmentNames, folderOf, folderQuery, htmlToText, messageBody} = await import('./gmail-api');

const base64url = (text: string, encoding: BufferEncoding = 'utf8'): string => Buffer.from(text, encoding).toString('base64url');

Expand Down Expand Up @@ -131,18 +131,3 @@ test('htmlToText takes time in proportion to the email, however many tags are le

expect(performance.now() - started).toBeLessThan(1000);
});

test('mapWithLimit keeps the order and never runs more than the limit at once', async () => {
let running = 0;
let most = 0;
const results = await mapWithLimit([5, 1, 4, 2, 3, 0, 6], 3, async (value) => {
running += 1;
most = Math.max(most, running);
await Bun.sleep(value);
running -= 1;
return value * 10;
});

expect(results).toEqual([50, 10, 40, 20, 30, 0, 60]);
expect(most).toBe(3);
});
26 changes: 6 additions & 20 deletions packages/api/src/connections/google/gmail-api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {Ok, Result} from 'ts-results-es';
import {z} from 'zod';

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

import {googleDelete, googleRequest, googleWrite} from './google-client';

Expand All @@ -25,8 +25,8 @@ const DRAFTS_PAGE_SIZE = 500;
/** Gmail's message and draft ids, as every id an agent sends is checked against before it reaches an address. */
export const GMAIL_ID = /^[A-Za-z0-9_-]+$/;

/** The headers a list reads of each message. */
const LIST_HEADERS = ['Subject', 'From', 'To'];
/** The headers a list reads of each message, and Cc, which the home page's widgets read. */
const LIST_HEADERS = ['Subject', 'From', 'To', 'Cc'];

const headerSchema = z.object({name: z.string(), value: z.string()});

Expand Down Expand Up @@ -314,23 +314,9 @@ export function messageBody(payload: MessagePart | undefined): string {

// ---- Calls -----------------------------------------------------------------------------------

/** Runs `work` on every item, at most `limit` at once, the results in the items' order. */
export async function mapWithLimit<T, R>(items: T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
const results = new Array<R>(items.length);
// One iterator shared by every worker, so each item is taken once.
const queue = items.entries();
const worker = async (): Promise<void> => {
for (const [index, item] of queue) {
results[index] = await work(item);
}
};
await Promise.all(Array.from({length: Math.min(limit, items.length)}, worker));
return results;
}

/** One page of message ids, newest first, in a folder and matching a search, from the page a previous one pointed at. */
export async function listMessageIds(accessToken: string, query: FolderQuery, pageToken: string | null): Promise<Result<Page<string>, ApiError>> {
const params = {maxResults: String(PAGE_SIZE), labelIds: query.labelIds, q: query.q, includeSpamTrash: query.includeSpamTrash ? 'true' : null, pageToken};
/** One page of message ids, newest first, in a folder and matching a search, from the page a previous one pointed at, of 50 unless asked for fewer. */
export async function listMessageIds(accessToken: string, query: FolderQuery, pageToken: string | null, pageSize = PAGE_SIZE): Promise<Result<Page<string>, ApiError>> {
const params = {maxResults: String(pageSize), labelIds: query.labelIds, q: query.q, includeSpamTrash: query.includeSpamTrash ? 'true' : null, pageToken};
const list = await googleRequest(accessToken, gmailUrl('/messages', params), messageListSchema);
return list.map(({messages = [], nextPageToken}) => ({items: messages.map(({id}) => id), nextPage: nextPageToken ?? null}));
}
Expand Down
Loading
Loading