Skip to content
Draft
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
8 changes: 4 additions & 4 deletions AGENTS.md

Large diffs are not rendered by default.

57 changes: 55 additions & 2 deletions packages/api/src/agent-side/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,28 @@ const card = {id: 'rec-1', values: {label: 'Personal Visa', number: '4242'}, upd
const inboxEmail = {id: 'inbox-7-12', values: {folder: 'Inbox', subject: 'Invoice #20931'}, updatedAt: '2026-10-01T00:00:00.000Z'};
const draft = {id: 'drafts-7-3', values: {folder: 'Draft', from: 'alex@example.com', to: 'sam@example.com', subject: 'Re: Q3 planning', body: 'Draft body'}, updatedAt: '2026-10-01T00:00:00.000Z'};

// Reads and edits tasks by default; completing them is off.
const ticktickConnection = {
id: 'ticktick-1',
integrationId: 'ticktick',
account: 'alex@example.com',
createdAt: new Date(),
defaults: [{actionId: 'read'}, {actionId: 'edit'}],
credential: 'v1.x.y.z',
};
const openTask = {
id: 'task:64f0a1b2c3d4e5f6a7b8c901:64f0a1b2c3d4e5f6a7b8c9a1',
values: {title: 'Buy milk', kind: 'Task', status: 'Open', priority: 'Medium', content: ''},
updatedAt: '2026-10-13T18:39:42.531Z',
};

const archive = mock();
const send = mock();
const sendNew = mock();
const connector = {list: mock(), get: mock(), create: mock(), update: mock(), remove: mock(), commands: {archive, send}, newCommands: {sendNew}};
const complete = mock();
const connector = {list: mock(), get: mock(), create: mock(), update: mock(), remove: mock(), commands: {archive, send, complete}, newCommands: {sendNew}};

function useConnection(connection: typeof infoConnection | typeof emailConnection | typeof notionConnection) {
function useConnection(connection: typeof infoConnection | typeof emailConnection | typeof notionConnection | typeof ticktickConnection) {
loadConnection.mockResolvedValue(Ok({connection, integration: integration(connection.integrationId), connector}));
}

Expand Down Expand Up @@ -325,4 +341,41 @@ describe('nested collections', () => {
expect(connector.list).not.toHaveBeenCalled();
expect(connector.create).not.toHaveBeenCalled();
});

test('editing a TickTick task never completes it: that takes Complete tasks, refused and logged until it is given', async () => {
useConnection(ticktickConnection);
connector.get.mockResolvedValue(Ok(openTask));
connector.update.mockResolvedValue(Ok(openTask));
complete.mockResolvedValue(Ok(null));
const {runAgentTool} = await import('./tools');

const viaEdit = await runAgentTool(caller, 'ticktick-1', 'tasks_update', {id: openTask.id, status: 'Completed'});
expect(viaEdit.unwrapErr()).toMatchObject({kind: 'validation_error', message: expect.stringContaining('takes no status')});
expect(connector.update).not.toHaveBeenCalled();

const denied = await runAgentTool(caller, 'ticktick-1', 'tasks_complete', {id: openTask.id});
expect(denied.unwrapErr().kind).toBe('forbidden');
expect(complete).not.toHaveBeenCalled();
expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({connectionId: 'ticktick-1', collectionId: 'tasks', action: 'complete', outcome: 'denied'});

getAgent.mockResolvedValue(Ok({...agent, grants: [{connectionId: 'ticktick-1', actionId: 'complete', allowed: true}]}));
const allowed = await runAgentTool(caller, 'ticktick-1', 'tasks_complete', {id: openTask.id});
expect(allowed.unwrap()).toEqual({record: null});
expect(complete.mock.calls[0]?.[1]).toBe(openTask.id);
expect(logAgentRequest.mock.calls[1]?.[0]).toMatchObject({action: 'complete', recordTitle: 'Buy milk', outcome: 'allowed'});
});

test('a completed TickTick task is not completed again, and a list is not edited', async () => {
useConnection(ticktickConnection);
getAgent.mockResolvedValue(Ok({...agent, grants: [{connectionId: 'ticktick-1', actionId: 'complete', allowed: true}]}));
const {runAgentTool} = await import('./tools');

connector.get.mockResolvedValue(Ok({...openTask, values: {...openTask.values, status: 'Completed'}}));
expect((await runAgentTool(caller, 'ticktick-1', 'tasks_complete', {id: openTask.id})).unwrapErr().kind).toBe('validation_error');

connector.get.mockResolvedValue(Ok({id: 'project:64f0a1b2c3d4e5f6a7b8c901', values: {title: 'Errands', kind: 'List'}, updatedAt: '', hasChildren: true}));
expect((await runAgentTool(caller, 'ticktick-1', 'tasks_update', {id: 'project:64f0a1b2c3d4e5f6a7b8c901', title: 'Chores'})).unwrapErr().kind).toBe('validation_error');
expect(complete).not.toHaveBeenCalled();
expect(connector.update).not.toHaveBeenCalled();
});
});
6 changes: 6 additions & 0 deletions packages/api/src/connections/ticktick/credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {z} from 'zod';

/** What a TickTick connection stores, encrypted: the personal API token it signs in with. */
export const ticktickCredentialSchema = z.object({token: z.string().min(1)});

export type TickTickCredential = z.infer<typeof ticktickCredentialSchema>;
97 changes: 97 additions & 0 deletions packages/api/src/connections/ticktick/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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 checkToken = mock();

mock.module('./ticktick-api', () => ({checkToken}));

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

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

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

const body = {account: 'alex@example.com', token: 'tp_0123456789abcdef'};

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));
checkToken.mockResolvedValue(Ok(undefined));
});

describe('handleConnectTickTickRoute', () => {
test('checks the token first, then stores it encrypted with reading on by default', async () => {
const {handleConnectTickTickRoute} = await import('./route');
const result = await handleConnectTickTickRoute(makeRequest({account: ' alex@example.com ', token: ' tp_0123456789abcdef\n'}));

expect(checkToken).toHaveBeenCalledWith('tp_0123456789abcdef');
expect(createConnection).toHaveBeenCalledWith({
orgId: 'org-1',
integrationId: 'ticktick',
account: 'alex@example.com',
credential: `encrypted:${JSON.stringify({token: 'tp_0123456789abcdef'})}`,
defaults: ['read'],
});
expect(result.unwrap()).toEqual({id: 'conn-1', integrationId: 'ticktick', account: 'alex@example.com', defaults: ['read']});
});

test('saves nothing when TickTick turns the token down', async () => {
const {handleConnectTickTickRoute} = await import('./route');
checkToken.mockResolvedValue(Err(ApiErr.credentialsRejected()));
const result = await handleConnectTickTickRoute(makeRequest(body));

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

test('an account is connected once by its name', async () => {
const {handleConnectTickTickRoute} = await import('./route');
listConnections.mockResolvedValue(Ok([{id: 'conn-0', integrationId: 'ticktick', account: 'alex@example.com', defaults: []}]));

expect((await handleConnectTickTickRoute(makeRequest(body))).unwrapErr().kind).toBe('conflict');
expect((await handleConnectTickTickRoute(makeRequest({...body, account: 'work'}))).isOk()).toBe(true);
});

test('refuses an empty name, and a token that is empty or has spaces in it', async () => {
const {handleConnectTickTickRoute} = await import('./route');
for (const bad of [{account: ' '}, {token: ''}, {token: 'tp_one two'}, {token: 42}]) {
expect((await handleConnectTickTickRoute(makeRequest({...body, ...bad}))).unwrapErr().statusCode, JSON.stringify(bad)).toBe(400);
}
expect(checkToken).not.toHaveBeenCalled();
});
});

describe('handleTestTickTickRoute', () => {
test('checks the token and saves nothing', async () => {
const {handleTestTickTickRoute} = await import('./route');
expect((await handleTestTickTickRoute(makeRequest(body))).isOk()).toBe(true);
expect(checkToken).toHaveBeenCalledWith('tp_0123456789abcdef');
expect(createConnection).not.toHaveBeenCalled();

checkToken.mockResolvedValue(Err(ApiErr.providerUnreachable(new Error('timeout'))));
expect((await handleTestTickTickRoute(makeRequest(body))).unwrapErr().kind).toBe('provider_unreachable');
});
});
54 changes: 54 additions & 0 deletions packages/api/src/connections/ticktick/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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 type {TickTickCredential} from './credential';
import {checkToken} from './ticktick-api';

// A TickTick account as typed in: the name it is shown by, since the Open API doesn't say whose a
// token is, and the personal API token from TickTick's settings, without spaces.
const connectBodySchema = z.object({
account: z.string().trim().min(1).max(200),
token: z.string().trim().min(1).max(500).regex(/^\S+$/, 'Not an API token'),
});

// What agents get with a new TickTick connection until its page says otherwise: reading.
const DEFAULTS = ['read'];

/** Signs in to TickTick with the token and saves nothing: the Test connection button. */
export function handleTestTickTickRoute(request: AuthenticatedRequest): Promise<Result<void, ApiError>> {
return Do(async ($) => {
const {token} = $(parseSchema(connectBodySchema, request.body));
$(await checkToken(token));
});
}

/**
* Connects a TickTick account with a personal API token. Signs in first, so a wrong or revoked
* token is reported now rather than on an agent's first request. The token can't be limited, so
* agents get only reading until the connection's page says otherwise.
*/
export function handleConnectTickTickRoute(request: AuthenticatedRequest): Promise<Result<ConnectionResponse, ApiError>> {
return Do(async ($) => {
const {account, token} = $(parseSchema(connectBodySchema, request.body));

const orgId = $(await requireUserOrgId(request));
const existing = $(await listConnections(orgId));
if (existing.some((connection) => connection.integrationId === 'ticktick' && connection.account === account)) {
return $(Err(ApiErr.conflict('A TickTick account is already connected by this name')));
}

const credential: TickTickCredential = {token};
const encrypted = $(encryptSecret(JSON.stringify(credential)));
$(await checkToken(token));

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