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: 2 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/api/src/agent-auth/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('handleAgentLoginRoute', () => {

describe('authenticateAgentRequest', () => {
test('looks the session up by the hash of the cookie', async () => {
getSignedInAgent.mockResolvedValue(Ok({agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots'}));
getSignedInAgent.mockResolvedValue(Ok({agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots', experiments: []}));

const {authenticateAgentRequest} = await import('./session');
const result = await authenticateAgentRequest({cookie: 'proxy.agent-session=abc'});
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/agent-side/feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const saveAgentFeedback = mock();

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

const agent = {agentId: 'agent-1', orgId: 'org-1', providerId: 'claude', name: 'Claude'};
const agent = {agentId: 'agent-1', orgId: 'org-1', providerId: 'claude', name: 'Claude', experiments: []};

beforeEach(() => {
mock.clearAllMocks();
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/agent-side/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const sendAgentFeedback = mock();

mock.module('./feedback', () => ({sendAgentFeedback}));

const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots'};
const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots', experiments: []};

function makeRequest(params: Record<string, string>, body: unknown = undefined) {
return {agent: signedIn, params, body, query: {}} as never;
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/agent-side/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function integration(id: string): Integration {
return found;
}

const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots'};
const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots', experiments: []};
const caller = {agent: signedIn, via: 'mcp' as const};

const infoConnection = {id: 'info-1', integrationId: 'info', account: "Alex's Workspace", createdAt: new Date(), tools: null, name: null, defaults: [{actionId: 'readAddresses'}], credential: null};
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/mcp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ mock.module('../observability/log', () => ({

mock.module('../utils/env', () => ({env: {NODE_ENV: 'production', APP_URL: 'https://app.example.com'}}));

const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'claude', name: 'Claude'};
const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'claude', name: 'Claude', experiments: []};
const mailbox = {id: 'mail-1', integrationId: 'email', account: 'alex@example.com', actions: ['read', 'archive'], tools: null, name: null};

function makeRes() {
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/me/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const user = {

function makeRequest() {
return {
user: {userId: 'user-1', email: 'test@example.com', name: 'Test User'},
user: {userId: 'user-1', email: 'test@example.com', name: 'Test User', experiments: []},
};
}

Expand Down Expand Up @@ -70,6 +70,7 @@ describe('handleMeRoute', () => {
image: 'https://example.com/avatar.png',
},
onboarded: true,
experiments: [],
});
expect(getUserFromId).toHaveBeenCalledWith('user-1');
expect(getUserOrganization).toHaveBeenCalledWith('user-1');
Expand Down
5 changes: 4 additions & 1 deletion packages/api/src/me/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {Response} from 'express';
import {Err, type Result} from 'ts-results-es';

import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils';
import {ApiErr, type ApiError, Do, type Experiment, requirePresent} from '@proxy/utils';
import {listConnectionsWithCredential} from '@proxy/db/connection';
import {getUserOrganization, markOrganizationOnboarded} from '@proxy/db/organization';
import {deleteUser, getUserFromId} from '@proxy/db/user';
Expand All @@ -16,6 +16,8 @@ export type MeResponse = {
user: {id: string; email: string; name: string | null; image: string | null};
/** False until the welcome flow is finished or skipped; the web app shows it instead of the app until then. */
onboarded: boolean;
/** The experiments on for the organization, for the web app's `useExperiment`. */
experiments: Experiment[];
};

/** Who is signed in. The web app's guard: 401 means "send them to /login". */
Expand All @@ -31,6 +33,7 @@ export function handleMeRoute(request: AuthenticatedRequest): Promise<Result<MeR
return {
user: {id: user.id, email: user.email, name: user.name, image: user.image},
onboarded: organization.onboardedAt !== null,
experiments: request.user.experiments,
};
});
}
Expand Down
5 changes: 4 additions & 1 deletion packages/api/src/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type {IncomingHttpHeaders} from 'node:http';

import {getAuthenticatedSessionDatabase} from '@proxy/db/auth';
import {ApiErr, type ApiError, getSessionTokenFromHeader} from '@proxy/utils';
import {ApiErr, type ApiError, type Experiment, getSessionTokenFromHeader} from '@proxy/utils';
import {Err, Ok, type Result} from 'ts-results-es';

export type AuthenticatedApiRequest = {
userId: string;
email: string;
name?: string;
/** The experiments on for the user's organization; ask with `hasExperiment(request.user, …)`. */
experiments: Experiment[];
};

/** Authenticates a user from the session cookie. */
Expand All @@ -30,5 +32,6 @@ export async function authenticateApiRequest(headers: IncomingHttpHeaders): Prom
userId: sessionResult.value.userId,
email: sessionResult.value.user.email,
name: sessionResult.value.user.name ?? undefined,
experiments: sessionResult.value.experiments,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- The experiments on for an organization, and insiders, who have all of them; both set from the
-- internal admin.
ALTER TABLE "Organization" ADD COLUMN "experiments" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "insider" BOOLEAN NOT NULL DEFAULT false;
4 changes: 4 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ model Organization {
slug String @unique
// When the welcome flow was finished or skipped; until then the web app shows it instead of the app.
onboardedAt DateTime?
// Experiments on for the organization, by id (EXPERIMENTS in @proxy/utils; an id no longer there
// is ignored), and an insider has all of them. Both set from the internal admin, never in the app.
experiments String[] @default([])
insider Boolean @default(false)

createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
Expand Down
26 changes: 26 additions & 0 deletions packages/db/src/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {Prisma} from '@prisma/client';
import {ApiErr} from '@proxy/utils';

import {isProviderConflict, isUsernameConflict} from './agent';
import {toSignedInAgent} from './signed-in-agent';

function uniqueViolation(target: string[]) {
return ApiErr.dbError(
Expand All @@ -27,3 +28,28 @@ describe('agent unique conflicts', () => {
expect(isProviderConflict(ApiErr.forbidden())).toBe(false);
});
});

describe('toSignedInAgent', () => {
const agent = {
id: 'agent-1',
orgId: 'org-1',
providerId: 'claude',
name: 'Claude',
revokedAt: null,
org: {insider: false, experiments: ['shipped-long-ago']},
};

test("carries its organization's experiments, without ids that are no longer experiments", () => {
expect(toSignedInAgent(agent)).toEqual({
agentId: 'agent-1',
orgId: 'org-1',
providerId: 'claude',
name: 'Claude',
experiments: [],
});
});

test('is no one once revoked', () => {
expect(toSignedInAgent({...agent, revokedAt: new Date()})).toBeNull();
});
});
24 changes: 5 additions & 19 deletions packages/db/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {Result} from 'ts-results-es';
import {type ApiError, wrapDb} from '@proxy/utils';

import {db} from '.';
import {signedInAgentSelect, type SignedInAgent, toSignedInAgent} from './signed-in-agent';

export type AgentRow = {
id: string;
Expand Down Expand Up @@ -161,12 +162,7 @@ export async function createAgentSession(data: {
return (await wrapDb(() => db.agentSession.create({data}))).map(() => undefined);
}

export type SignedInAgent = {
agentId: string;
orgId: string;
providerId: string;
name: string;
};
export type {SignedInAgent} from './signed-in-agent';

/** `Ok(null)` means no live session: unknown, expired, or the agent has been revoked. */
export async function getSignedInAgent(
Expand All @@ -176,24 +172,14 @@ export async function getSignedInAgent(
await wrapDb(() =>
db.agentSession.findUnique({
where: {tokenHash},
select: {
expires: true,
agent: {
select: {id: true, orgId: true, providerId: true, name: true, revokedAt: true},
},
},
select: {expires: true, agent: {select: signedInAgentSelect}},
}),
)
).map((session) => {
if (!session || session.expires <= new Date() || session.agent.revokedAt !== null) {
if (!session || session.expires <= new Date()) {
return null;
}
return {
agentId: session.agent.id,
orgId: session.agent.orgId,
providerId: session.agent.providerId,
name: session.agent.name,
};
return toSignedInAgent(session.agent);
});
}

Expand Down
12 changes: 11 additions & 1 deletion packages/db/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {Ok, Result} from 'ts-results-es';

import {wrapDb, type ApiError} from '@proxy/utils';
import {type ApiError, type Experiment, organizationExperiments, wrapDb} from '@proxy/utils';

import {db, Prisma} from '.';

Expand All @@ -11,6 +11,8 @@ export type AuthenticatedSession = {
email: string;
name: string | null;
};
/** The experiments on for the user's organization. */
experiments: Experiment[];
expires: Date;
};

Expand All @@ -28,6 +30,12 @@ export async function getAuthenticatedSessionDatabase(
select: {
name: true,
email: true,
// Their organization, the first they joined as getUserOrganization reads it.
orgMembers: {
select: {org: {select: {insider: true, experiments: true}}},
orderBy: {createdAt: 'asc'},
take: 1,
},
},
},
expires: true,
Expand All @@ -47,13 +55,15 @@ export async function getAuthenticatedSessionDatabase(
return Ok(null);
}

const organization = session.user.orgMembers[0]?.org;
return Ok({
sessionToken: session.sessionToken,
userId: session.userId,
user: {
email: session.user.email,
name: session.user.name,
},
experiments: organization ? organizationExperiments(organization) : [],
expires: session.expires,
});
}
Expand Down
12 changes: 4 additions & 8 deletions packages/db/src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {Result} from 'ts-results-es';
import {type ApiError, wrapDb} from '@proxy/utils';

import {db} from '.';
import type {SignedInAgent} from './agent';
import {signedInAgentSelect, type SignedInAgent, toSignedInAgent} from './signed-in-agent';

// Agents signing in over MCP with OAuth: the clients that registered themselves, the codes they
// exchange once, and the tokens of each client signed in as an agent. Codes and tokens are kept
Expand Down Expand Up @@ -131,18 +131,14 @@ export async function getAgentByAccessToken(
await wrapDb(() =>
db.oAuthGrant.findUnique({
where: {accessTokenHash},
select: {
accessExpires: true,
agent: {select: {id: true, orgId: true, providerId: true, name: true, revokedAt: true}},
},
select: {accessExpires: true, agent: {select: signedInAgentSelect}},
}),
)
).map((grant) => {
if (!grant || grant.accessExpires <= new Date() || grant.agent.revokedAt !== null) {
if (!grant || grant.accessExpires <= new Date()) {
return null;
}
const {agent} = grant;
return {agentId: agent.id, orgId: agent.orgId, providerId: agent.providerId, name: agent.name};
return toSignedInAgent(grant.agent);
});
}

Expand Down
41 changes: 41 additions & 0 deletions packages/db/src/signed-in-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type {Prisma} from '@prisma/client';

import {type Experiment, organizationExperiments} from '@proxy/utils';

// An agent signed in with its cookie (agent.ts) or an MCP client's token (oauth.ts), read the same
// way by both. A module of its own, so tests that mock either of them still get it.

export type SignedInAgent = {
agentId: string;
orgId: string;
providerId: string;
name: string;
/** The experiments on for the agent's organization. */
experiments: Experiment[];
};

/** What a signed-in agent is read from, with a cookie or an MCP client's token. */
export const signedInAgentSelect = {
id: true,
orgId: true,
providerId: true,
name: true,
revokedAt: true,
org: {select: {insider: true, experiments: true}},
} satisfies Prisma.AgentSelect;

/** `null` for a revoked agent. */
export function toSignedInAgent(
agent: Prisma.AgentGetPayload<{select: typeof signedInAgentSelect}>,
): SignedInAgent | null {
if (agent.revokedAt !== null) {
return null;
}
return {
agentId: agent.id,
orgId: agent.orgId,
providerId: agent.providerId,
name: agent.name,
experiments: organizationExperiments(agent.org),
};
}
24 changes: 24 additions & 0 deletions packages/utils/src/experiments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {describe, expect, it} from 'bun:test';

import {EXPERIMENTS, isExperiment, organizationExperiments} from './experiments';

describe('organizationExperiments', () => {
it('drops ids that are no longer experiments', () => {
expect(organizationExperiments({insider: false, experiments: ['shipped-long-ago']})).toEqual(
[],
);
});

it('turns every experiment on for an insider, whatever it has of its own', () => {
expect(organizationExperiments({insider: true, experiments: ['shipped-long-ago']})).toEqual(
Object.keys(EXPERIMENTS).filter(isExperiment),
);
});
});

describe('isExperiment', () => {
it('does not take what every object has', () => {
expect(isExperiment('toString')).toBe(false);
expect(isExperiment('constructor')).toBe(false);
});
});
33 changes: 33 additions & 0 deletions packages/utils/src/experiments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Features being tried out, each turned on for some organizations before everyone gets it, from
* the internal admin (`Organization.experiments`, or all of them for an insider organization). The
* key is the id kept in the database, the value what the admin calls it. When an experiment ships
* or is dropped, delete it here, and the compiler points at every place that asked for it; its id
* left in the database is ignored.
*/
export const EXPERIMENTS = {} as const satisfies Record<string, string>;

export type Experiment = keyof typeof EXPERIMENTS;

export function isExperiment(id: string): id is Experiment {
return Object.hasOwn(EXPERIMENTS, id);
}

/** What is on for an organization: every experiment for an insider, else its own that still exist. */
export function organizationExperiments(organization: {
insider: boolean;
experiments: string[];
}): Experiment[] {
if (organization.insider) {
return Object.keys(EXPERIMENTS).filter(isExperiment);
}
return organization.experiments.filter(isExperiment);
}

/** Whether a signed-in person or agent (`request.user`, `SignedInAgent`, the web app's `Me`) has the experiment on. */
export function hasExperiment(
holder: {experiments: Experiment[]},
experiment: Experiment,
): boolean {
return holder.experiments.includes(experiment);
}
Loading
Loading