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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,27 @@ score, a signal, a campaign or a workspace. One row shape,
paged by `since` and an opaque `cursor`, cacheable for five minutes, sixty
requests a minute per caller. The rule lives in `apps/api/src/public-directory.ts`
and nowhere else; nichedb.dev reads it into its directory collection.

## AutoGTM API

The agent-facing surface: `/api/v1/autogtm/*`. Projects (one per product), campaigns with
budgets in dollars per day, a project-level autopilot that splits the ceiling across campaigns
by reply rate, an inbox with `need_reply` / `replied` / `sent` / `unsubscribed` tabs, replies,
hot leads, named suppress lists for addresses and domains, and an import that makes a campaign
from your own list.

Read `/api/v1/public/llms.txt` first (the quick guide) and `/api/v1/public/openapi.json` for
the schema. Both are keyless and rendered from one table in `apps/api/src/autogtm-docs.ts`, so
a route cannot appear in one and not the other; a test refuses a documented route that does not
exist.

Authenticate with a workspace key: mint one on `/settings` (or `POST /api/v1/api-keys` with a
session) and send it as `X-API-Key` on every request. A key acts with its owner's current
role and cannot mint keys. The service token and its scope headers still work for internal
callers.

A dollar budget becomes `max_contacts_per_day = floor(usd / price_per_contact_usd)` on the
campaign, which the policy engine enforces at send time like any other cap. Nothing in this
surface can send outside the engine: a reply is a `send_email` card marked as a follow-up,
approved by the call itself, and refused with 409 when the lead is suppressed or the budget is
spent.
183 changes: 183 additions & 0 deletions apps/api/src/api-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { afterEach, describe, expect, test } from 'bun:test';
import type { Hono } from 'hono';
import { queryOne } from '@outreachgraph/db';
import { createApp } from './app';
import type { AppEnv, RequestActor } from './context';
import { seedDatabase, SEED, type SeededDatabase } from './test-seed';
import {
actorFromApiKey,
listApiKeys,
mintApiKey,
presentedApiKey,
revokeApiKey,
} from './api-keys';

let active: SeededDatabase | undefined;

afterEach(() => {
active?.cleanup();
active = undefined;
});

const SESSION_ACTOR: RequestActor = {
userId: SEED.userId,
workspaceId: SEED.workspaceId,
organizationId: SEED.organizationId,
role: 'owner',
credential: 'session',
};

/** The real resolver: no `authenticate` override, so headers decide. */
async function realApp(label: string): Promise<{ app: Hono<AppEnv>; seeded: SeededDatabase }> {
const seeded = await seedDatabase(label);
active = seeded;
return { app: createApp({ db: seeded.db }), seeded };
}

describe('minting and using a key', () => {
test('the secret is hashed at rest and shown once', async () => {
const { seeded } = await realApp('key-mint');
const minted = await mintApiKey(seeded.db, {
workspaceId: SEED.workspaceId,
organizationId: SEED.organizationId,
userId: SEED.userId,
name: 'agent',
});

expect(minted.key.startsWith('og_live_')).toBe(true);
expect(minted.prefix).toBe(minted.key.slice(0, minted.prefix.length));

const row = await queryOne<{ key_hash: string; key_prefix: string }>(
seeded.db,
'SELECT key_hash, key_prefix FROM api_keys WHERE id = ?',
[minted.id],
);
expect(row?.key_hash).not.toBe(minted.key);
expect(row?.key_hash).not.toContain(minted.key);

const listed = await listApiKeys(seeded.db, SEED.workspaceId);
expect(listed).toHaveLength(1);
expect(JSON.stringify(listed)).not.toContain(minted.key);
});

test('a key authenticates as its owner, with their current role', async () => {
const { app, seeded } = await realApp('key-auth');
const minted = await mintApiKey(seeded.db, {
workspaceId: SEED.workspaceId,
organizationId: SEED.organizationId,
userId: SEED.userId,
name: 'agent',
});

const actor = await actorFromApiKey(seeded.db, minted.key);
expect(actor?.workspaceId).toBe(SEED.workspaceId);
expect(actor?.role).toBe('owner');
expect(actor?.credential).toBe('api_key');

// Through the app, with the header.
const viaHeader = await app.request('/api/v1/autogtm/projects', {
headers: { 'x-api-key': minted.key },
});
expect(viaHeader.status).toBe(200);

const viaBearer = await app.request('/api/v1/autogtm/projects', {
headers: { authorization: `Bearer ${minted.key}` },
});
expect(viaBearer.status).toBe(200);

const none = await app.request('/api/v1/autogtm/projects');
expect(none.status).toBe(401);

const wrong = await app.request('/api/v1/autogtm/projects', {
headers: { 'x-api-key': `${minted.key.slice(0, -4)}zzzz` },
});
expect(wrong.status).toBe(401);
});

test('a revoked key stops working, and a removed member takes their keys with them', async () => {
const { app, seeded } = await realApp('key-revoke');
const minted = await mintApiKey(seeded.db, {
workspaceId: SEED.workspaceId,
organizationId: SEED.organizationId,
userId: SEED.userId,
name: 'agent',
});

expect(await revokeApiKey(seeded.db, SEED.workspaceId, minted.id)).toBe(true);
expect(await revokeApiKey(seeded.db, SEED.workspaceId, minted.id)).toBe(false);

const gone = await app.request('/api/v1/autogtm/projects', {
headers: { 'x-api-key': minted.key },
});
expect(gone.status).toBe(401);

const second = await mintApiKey(seeded.db, {
workspaceId: SEED.workspaceId,
organizationId: SEED.organizationId,
userId: SEED.userId,
name: 'agent-2',
});
await seeded.db.execute({
sql: 'DELETE FROM organization_members WHERE user_id = ?',
args: [SEED.userId],
});
expect(await actorFromApiKey(seeded.db, second.key)).toBeUndefined();
});

test('a key cannot mint keys; a session can', async () => {
const seeded = await seedDatabase('key-mint-route');
active = seeded;

const asKey = createApp({
db: seeded.db,
authenticate: async () => ({ ...SESSION_ACTOR, credential: 'api_key' }),
});
const refused = await asKey.request('/api/v1/api-keys', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'nested' }),
});
expect(refused.status).toBe(403);

const asSession = createApp({ db: seeded.db, authenticate: async () => SESSION_ACTOR });
const created = await asSession.request('/api/v1/api-keys', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'my agent' }),
});
expect(created.status).toBe(201);
const body = (await created.json()) as { key: { id: string; key: string; name: string } };
expect(body.key.name).toBe('my agent');
expect(body.key.key.startsWith('og_live_')).toBe(true);

const listed = await asSession.request('/api/v1/api-keys');
const list = (await listed.json()) as { keys: { id: string }[] };
expect(list.keys.map((k) => k.id)).toContain(body.key.id);

const revoked = await asSession.request(`/api/v1/api-keys/${body.key.id}`, {
method: 'DELETE',
});
expect(revoked.status).toBe(200);

const unnamed = await asSession.request('/api/v1/api-keys', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
});
expect(unnamed.status).toBe(400);
});
});

describe('presentedApiKey', () => {
test('reads either header and ignores things that are not keys', () => {
const key = 'og_live_0123456789abcdef0123456789abcdef';
expect(presentedApiKey(new Request('http://x', { headers: { 'x-api-key': key } }))).toBe(key);
expect(
presentedApiKey(new Request('http://x', { headers: { authorization: `Bearer ${key}` } })),
).toBe(key);
expect(
presentedApiKey(new Request('http://x', { headers: { authorization: 'Bearer svc_token' } })),
).toBeUndefined();
expect(presentedApiKey(new Request('http://x'))).toBeUndefined();
});
});
174 changes: 174 additions & 0 deletions apps/api/src/api-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Keys a workspace hands to its agents.
*
* The service token was the only machine credential, and it is the wrong
* shape for a customer: one secret for the whole deployment, plus two headers
* naming the workspace it should act on. A key here belongs to one workspace,
* carries one person's authority, is shown once, and dies with a click.
*
* Stored as a SHA-256 digest, the same way sessions are. The prefix kept
* beside it is for the list view — "og_live_8f3a…" tells you which key you
* are about to revoke without telling anyone what the key is.
*/

import { newId } from '@outreachgraph/domain';
import { now, queryAll, queryOne, type Client } from '@outreachgraph/db';
import { hashToken, membershipForWorkspace } from './auth';
import type { RequestActor } from './context';

export const API_KEY_PREFIX = 'og_live_';
/** How many characters of the secret the list shows. */
const VISIBLE = API_KEY_PREFIX.length + 6;

export interface ApiKeySummary {
readonly id: string;
readonly name: string;
readonly prefix: string;
readonly createdAt: string;
readonly lastUsedAt: string | null;
}

export interface MintedApiKey extends ApiKeySummary {
/** The secret. Returned from mint and never again. */
readonly key: string;
}

export class ApiKeyError extends Error {
constructor(message: string) {
super(message);
this.name = 'ApiKeyError';
}
}

export function mintSecret(): string {
const bytes = crypto.getRandomValues(new Uint8Array(24));
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
return `${API_KEY_PREFIX}${hex}`;
}

export function looksLikeApiKey(value: string | null | undefined): value is string {
return typeof value === 'string' && value.startsWith(API_KEY_PREFIX) && value.length > VISIBLE;
}

export async function mintApiKey(
db: Client,
input: {
readonly workspaceId: string;
readonly organizationId: string;
readonly userId: string;
readonly name: string;
},
): Promise<MintedApiKey> {
const name = input.name.trim().slice(0, 100);
if (!name) throw new ApiKeyError('a key needs a name');

const key = mintSecret();
const id = newId('apiKey');
const stamp = now();

await db.execute({
sql: `INSERT INTO api_keys (id, workspace_id, organization_id, user_id, name, key_hash,
key_prefix, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
id,
input.workspaceId,
input.organizationId,
input.userId,
name,
await hashToken(key),
key.slice(0, VISIBLE),
stamp,
],
});

return { id, name, prefix: key.slice(0, VISIBLE), createdAt: stamp, lastUsedAt: null, key };
}

export async function listApiKeys(db: Client, workspaceId: string): Promise<ApiKeySummary[]> {
const rows = await queryAll<{
id: string;
name: string;
key_prefix: string;
created_at: string;
last_used_at: string | null;
}>(
db,
`SELECT id, name, key_prefix, created_at, last_used_at FROM api_keys
WHERE workspace_id = ? AND revoked_at IS NULL
ORDER BY created_at DESC`,
[workspaceId],
);

return rows.map((row) => ({
id: row.id,
name: row.name,
prefix: row.key_prefix,
createdAt: row.created_at,
lastUsedAt: row.last_used_at,
}));
}

/** Revokes rather than deletes, so an audit trail can still name the key. */
export async function revokeApiKey(
db: Client,
workspaceId: string,
keyId: string,
): Promise<boolean> {
const result = await db.execute({
sql: `UPDATE api_keys SET revoked_at = ? WHERE id = ? AND workspace_id = ? AND revoked_at IS NULL`,
args: [now(), keyId, workspaceId],
});
return result.rowsAffected > 0;
}

/**
* The actor a presented key stands for, or undefined.
*
* Role comes from the owner's current membership, not from the key: a person
* demoted to viewer takes their keys down with them, and a person removed
* from the organization leaves keys that authenticate nobody.
*/
export async function actorFromApiKey(
db: Client,
presented: string,
): Promise<RequestActor | undefined> {
if (!looksLikeApiKey(presented)) return undefined;

const row = await queryOne<{ id: string; workspace_id: string; user_id: string }>(
db,
`SELECT id, workspace_id, user_id FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL`,
[await hashToken(presented)],
);
if (!row) return undefined;

const membership = await membershipForWorkspace(db, row.user_id, row.workspace_id);
if (!membership) return undefined;

await db.execute({
sql: 'UPDATE api_keys SET last_used_at = ? WHERE id = ?',
args: [now(), row.id],
});

return {
userId: row.user_id,
workspaceId: membership.workspaceId,
organizationId: membership.organizationId,
role: membership.role,
credential: 'api_key',
};
}

/** The key a request carries, from either header it may use. */
export function presentedApiKey(request: Request): string | undefined {
const direct = request.headers.get('x-api-key');
if (looksLikeApiKey(direct)) return direct;

const bearer = request.headers.get('authorization');
if (bearer?.startsWith('Bearer ')) {
const token = bearer.slice('Bearer '.length).trim();
if (looksLikeApiKey(token)) return token;
}

return undefined;
}
Loading
Loading