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
10 changes: 5 additions & 5 deletions AGENTS.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/api/src/connections/mysql/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {sqlConnectRoutes} from '../sql/route';

/** Connecting a MySQL or MariaDB database: Test connection, and Connect. */
export const {handleTest: handleTestMysqlRoute, handleConnect: handleConnectMysqlRoute} = sqlConnectRoutes('mysql');
20 changes: 1 addition & 19 deletions packages/api/src/connections/notion/notion-properties.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {describe, expect, test} from 'bun:test';

import {notionValues, propertyLines, readPropertyLines, shapeOfType, shapeOfValue} from './notion-properties';
import {notionValues, propertyLines, shapeOfType, shapeOfValue} from './notion-properties';

describe('propertyLines', () => {
test("writes each property on a line, lists joined, leaving out empty ones and what Notion doesn't resolve", () => {
Expand All @@ -9,24 +9,6 @@ describe('propertyLines', () => {
});
});

describe('readPropertyLines', () => {
const names = ['Status', 'Status Details', 'Notes'];

test('reads a value by the property it starts with, running on over the lines after it', () => {
expect(readPropertyLines('Status: Contacted\nStatus Details: Sent on Monday\nNotes: first line\nsecond line: still notes\n', names).unwrap()).toEqual({
Status: 'Contacted',
'Status Details': 'Sent on Monday',
Notes: 'first line\nsecond line: still notes\n',
});
expect(readPropertyLines('Status:', names).unwrap()).toEqual({Status: ''});
expect(readPropertyLines('', names).unwrap()).toEqual({});
});

test('turns down a first line that is no property', () => {
expect(readPropertyLines('Owner: Sam', names).unwrapErr()).toMatchObject({kind: 'validation_error'});
});
});

describe('notionValues', () => {
test("writes a value the way Notion's page tools take it, and clears an empty one", () => {
expect(notionValues('Status', 'text', ' Contacted ').unwrap()).toEqual({Status: 'Contacted'});
Expand Down
35 changes: 4 additions & 31 deletions packages/api/src/connections/notion/notion-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import {ApiErr, type ApiError} from '@proxy/utils';
import {propertyText, type NotionProperties} from './notion-mcp';

/**
* A database row's properties as an agent reads and writes them: one "Name: value" a line, a value
* running on over the lines after it until the next property's name. Only properties written
* differently from how they were read are sent back to Notion, which checks each against the
* database and turns down what doesn't fit, so a value only has to be written the way it reads.
* A database row's properties as an agent reads and writes them: one "Name: value" a line
* (`readValueLines` in the records reads them back). Only properties written differently from how
* they were read are sent back to Notion, which checks each against the database and turns down
* what doesn't fit, so a value only has to be written the way it reads.
*/

/** How Notion takes a property's value: text, a number, a list (pages, people, options) or a date. */
Expand Down Expand Up @@ -52,33 +52,6 @@ export function propertyLines(properties: NotionProperties): string {
.join('\n');
}

/**
* The values "Name: value" lines give, by property, for the properties `names` lists. A line that
* doesn't start with one of them goes on the value before it; one before any is turned down.
*/
export function readPropertyLines(text: string, names: string[]): Result<Record<string, string>, ApiError> {
// The longest name first, so "Status Details" isn't read as "Status".
const byLength = [...names].sort((a, b) => b.length - a.length);
const values: Record<string, string> = {};
let current: string | null = null;
for (const line of text.split('\n')) {
const name = byLength.find((candidate) => line === `${candidate}:` || line.startsWith(`${candidate}: `));
if (name !== undefined) {
current = name;
values[name] = line.slice(name.length + 1).replace(/^ /, '');
continue;
}
if (current !== null) {
values[current] = `${values[current]}\n${line}`;
continue;
}
if (line.trim() !== '') {
return Err(ApiErr.validationError(`“${line}” doesn’t start with one of the properties: ${names.join(', ')}`));
}
}
return Ok(values);
}

/** What Notion's page tools take for one property written as text; an empty one clears it. */
export function notionValues(name: string, shape: PropertyShape, text: string): Result<Record<string, NotionValue>, ApiError> {
const value = text.trim();
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/connections/postgres/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {sqlConnectRoutes} from '../sql/route';

/** Connecting a PostgreSQL database: Test connection, and Connect. */
export const {handleTest: handleTestPostgresRoute, handleConnect: handleConnectPostgresRoute} = sqlConnectRoutes('postgres');
22 changes: 22 additions & 0 deletions packages/api/src/connections/sql/credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {z} from 'zod';

/** The databases Personal Agent Proxy speaks to over their own protocol, each an integration. */
export type SqlDialect = 'postgres' | 'mysql';

// How the connection is encrypted: verifying the server's certificate against the host's name,
// encrypted but taking any certificate, as a self-signed one, or in the clear.
export const TLS_MODES = ['verify', 'require', 'off'] as const;

export type TlsMode = (typeof TLS_MODES)[number];

/** What a database connection stores, encrypted: where the database is and the user it signs in as. */
export const sqlCredentialSchema = z.object({
host: z.string(),
port: z.number().int(),
database: z.string(),
username: z.string(),
password: z.string(),
tls: z.enum(TLS_MODES),
});

export type SqlCredential = z.infer<typeof sqlCredentialSchema>;
112 changes: 112 additions & 0 deletions packages/api/src/connections/sql/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {beforeEach, describe, expect, mock, test} from 'bun:test';
import {Err, Ok, type Result} from 'ts-results-es';

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

const listConnections = mock();
const createConnection = mock();

mock.module('@proxy/db/connection', () => ({listConnections, createConnection}));

const getUserOrgId = mock();

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

const checkDatabase = mock();

mock.module('./sql-client', () => ({checkDatabase}));

const encryptSecret = mock((plaintext: string): Result<string, ApiError> => Ok(`encrypted:${plaintext}`));

mock.module('../../utils/secret-crypto', () => ({encryptSecret}));

const createdRow = {
id: 'conn-1',
integrationId: 'postgres',
account: 'reader@db.example.com/app',
createdAt: new Date('2026-10-01T00:00:00Z'),
defaults: [{actionId: 'read'}, {actionId: 'query'}],
};

const body = {host: 'db.example.com', port: 5432, database: 'app', username: 'reader', password: 'secret', tls: 'verify'};

function makeRequest(body: unknown) {
return {user: {userId: 'user-1', email: 'alex@example.com'}, params: {}, body} as never;
}

beforeEach(() => {
mock.clearAllMocks();
getUserOrgId.mockResolvedValue(Ok('org-1'));
listConnections.mockResolvedValue(Ok([]));
createConnection.mockResolvedValue(Ok(createdRow));
checkDatabase.mockResolvedValue(Ok(undefined));
});

describe('sqlConnectRoutes', () => {
test('signs in first, then stores the encrypted credential with reading on by default', async () => {
const {sqlConnectRoutes} = await import('./route');
const result = await sqlConnectRoutes('postgres').handleConnect(makeRequest({...body, host: ' DB.example.com '}));

const credential = {...body, host: 'DB.example.com'};
expect(checkDatabase).toHaveBeenCalledWith('postgres', credential);
expect(createConnection).toHaveBeenCalledWith({
orgId: 'org-1',
integrationId: 'postgres',
account: 'reader@DB.example.com/app',
credential: `encrypted:${JSON.stringify(credential)}`,
defaults: ['read', 'query'],
});
expect(result.unwrap()).toMatchObject({id: 'conn-1', defaults: ['read', 'query']});
});

test('connects a MySQL database as its own integration, an IPv6 address without its brackets', async () => {
const {sqlConnectRoutes} = await import('./route');
await sqlConnectRoutes('mysql').handleConnect(makeRequest({...body, host: '[2001:db8::7]', port: 3306}));

expect(checkDatabase).toHaveBeenCalledWith('mysql', {...body, host: '2001:db8::7', port: 3306});
expect(createConnection).toHaveBeenCalledWith(expect.objectContaining({integrationId: 'mysql', account: 'reader@2001:db8::7/app'}));
});

test('saves nothing when the sign-in fails', async () => {
const {sqlConnectRoutes} = await import('./route');
checkDatabase.mockResolvedValue(Err(ApiErr.credentialsRejected()));
const result = await sqlConnectRoutes('postgres').handleConnect(makeRequest(body));

expect(result.unwrapErr().kind).toBe('credentials_rejected');
expect(createConnection).not.toHaveBeenCalled();
});

test('the same database as the same user is connected once; as another user it is another connection', async () => {
const {sqlConnectRoutes} = await import('./route');
listConnections.mockResolvedValue(Ok([{id: 'conn-0', integrationId: 'postgres', account: 'reader@db.example.com/app', defaults: []}]));

expect((await sqlConnectRoutes('postgres').handleConnect(makeRequest(body))).unwrapErr().kind).toBe('conflict');
expect((await sqlConnectRoutes('postgres').handleConnect(makeRequest({...body, username: 'writer'}))).isOk()).toBe(true);
expect((await sqlConnectRoutes('mysql').handleConnect(makeRequest(body))).isOk()).toBe(true);
});

test('refuses a host with a path or spaces, a port out of range and an unknown TLS mode', async () => {
const {sqlConnectRoutes} = await import('./route');
const {handleConnect} = sqlConnectRoutes('postgres');
for (const bad of [{host: 'db.example.com/app'}, {host: 'db example.com'}, {port: 70000}, {port: '5432'}, {tls: 'maybe'}, {database: ''}, {username: ' '}]) {
expect((await handleConnect(makeRequest({...body, ...bad}))).unwrapErr().statusCode, JSON.stringify(bad)).toBe(400);
}
expect(checkDatabase).not.toHaveBeenCalled();
});

test('an empty password is taken, as a local database may have none', async () => {
const {sqlConnectRoutes} = await import('./route');
expect((await sqlConnectRoutes('postgres').handleTest(makeRequest({...body, password: ''}))).isOk()).toBe(true);
expect(checkDatabase).toHaveBeenCalledWith('postgres', {...body, password: ''});
});

test('testing signs in and saves nothing', async () => {
const {sqlConnectRoutes} = await import('./route');
expect((await sqlConnectRoutes('mysql').handleTest(makeRequest(body))).isOk()).toBe(true);
expect(checkDatabase).toHaveBeenCalledWith('mysql', body);
expect(createConnection).not.toHaveBeenCalled();

checkDatabase.mockResolvedValue(Err(ApiErr.providerUnreachable(new Error('refused'))));
expect((await sqlConnectRoutes('mysql').handleTest(makeRequest(body))).unwrapErr().kind).toBe('provider_unreachable');
});
});
74 changes: 74 additions & 0 deletions packages/api/src/connections/sql/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {Err, type Result} from 'ts-results-es';
import {z} from 'zod';

import {ApiErr, type ApiError, Do, parseSchema} from '@proxy/utils';
import {createConnection, listConnections} from '@proxy/db/connection';

import type {AuthenticatedRequest} from '../../server/middleware/require-auth';
import {encryptSecret} from '../../utils/secret-crypto';
import {requireUserOrgId} from '../../utils/user-org';
import {toConnectionResponse, type ConnectionResponse} from '../connection-response';
import {TLS_MODES, type SqlCredential, type SqlDialect} from './credential';
import {checkDatabase} from './sql-client';

// A host is a name or an address, in brackets when IPv6: no spaces, no path.
const hostSchema = z
.string()
.trim()
.min(1)
.max(253)
.regex(/^[a-zA-Z0-9.\-:[\]]+$/, 'Not a host')
.transform((host) => host.replace(/^\[(.*)\]$/, '$1'));

// A database connection as typed in: the server, the database and the user. The password may be
// empty, as a local database's can be.
const connectBodySchema = z.object({
host: hostSchema,
port: z.number().int().min(1).max(65535),
database: z.string().trim().min(1).max(200),
username: z.string().trim().min(1).max(200),
password: z.string().max(1000),
tls: z.enum(TLS_MODES),
});

// What agents get with a new database connection until its page says otherwise: what only reads.
const DEFAULTS = ['read', 'query'];

function accountOf(credential: SqlCredential): string {
return `${credential.username}@${credential.host}/${credential.database}`;
}

/**
* The routes connecting a database of one dialect: testing a sign-in, which saves nothing, and
* connecting, which signs in first, so a wrong password, a server out of reach or a private
* address is reported now rather than on an agent's first request.
*/
export function sqlConnectRoutes(dialect: SqlDialect): {
handleTest: (request: AuthenticatedRequest) => Promise<Result<void, ApiError>>;
handleConnect: (request: AuthenticatedRequest) => Promise<Result<ConnectionResponse, ApiError>>;
} {
return {
handleTest: (request) =>
Do(async ($) => {
const credential = $(parseSchema(connectBodySchema, request.body));
$(await checkDatabase(dialect, credential));
}),
handleConnect: (request) =>
Do(async ($) => {
const credential = $(parseSchema(connectBodySchema, request.body));
const account = accountOf(credential);

const orgId = $(await requireUserOrgId(request));
const existing = $(await listConnections(orgId));
if (existing.some((connection) => connection.integrationId === dialect && connection.account === account)) {
return $(Err(ApiErr.conflict('This database is already connected as this user')));
}

const encrypted = $(encryptSecret(JSON.stringify(credential)));
$(await checkDatabase(dialect, credential));

const row = $(await createConnection({orgId, integrationId: dialect, account, credential: encrypted, defaults: DEFAULTS}));
return toConnectionResponse(row);
}),
};
}
Loading
Loading