feat(agiliton-account): Phase 1 service scaffold

TypeScript + Fastify service implementing:
- Google + Microsoft SSO (POST /v1/auth/sso/{google,microsoft})
- JWT issuance + LiteLLM virtual key provisioning on first login
- AES-256-GCM encrypted virtual key storage in Postgres
- Conversation CRUD (GET/POST/DELETE /v1/conversations, /messages)
- GDPR export + soft-delete (/v1/me/export, /v1/me/delete)
- WebSocket MCP bridge (/v1/mcp-bridge) with JWT auth
- MCP demux endpoint (/mcp/demux/:customer_id/mcp) for LiteLLM tool routing
- DB migration script creating sb_customers, sb_conversations, sb_messages
- 9 unit tests (bridge + crypto), all passing
- Dockerfile + docker-compose targeting port 4100

CF-3032

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-04-10 16:06:16 +03:00
commit 7ab23554c0
26 changed files with 4855 additions and 0 deletions

157
src/auth/customers.ts Normal file
View File

@@ -0,0 +1,157 @@
import { query, queryOne } from "../db/pool.js";
import { encrypt, decrypt } from "../utils/crypto.js";
import { provisionLiteLLMKey } from "./litellm.js";
export interface Customer {
id: string;
email: string;
name: string | null;
sso_provider: string;
sso_sub: string;
tier: string;
system_prompt: string | null;
preferred_model: string | null;
litellm_key_alias: string;
litellm_key_encrypted: Buffer;
created_at: Date;
last_login_at: Date | null;
deleted_at: Date | null;
}
export interface CustomerPublic {
id: string;
email: string;
name: string | null;
tier: string;
system_prompt: string | null;
preferred_model: string | null;
}
export function toPublic(c: Customer): CustomerPublic {
return {
id: c.id,
email: c.email,
name: c.name,
tier: c.tier,
system_prompt: c.system_prompt,
preferred_model: c.preferred_model,
};
}
export function getVirtualKey(c: Customer): string {
return decrypt(c.litellm_key_encrypted);
}
/**
* Upsert a customer from SSO data. Provisions a LiteLLM key on first login.
* Returns the customer row and the plaintext virtual key.
*/
export async function upsertCustomer(params: {
sso_provider: string;
sso_sub: string;
email: string;
name: string;
}): Promise<{ customer: Customer; virtualKey: string; isNew: boolean }> {
// Check for existing customer (not deleted)
const existing = await queryOne<Customer>(
`SELECT * FROM sb_customers
WHERE sso_provider = $1 AND sso_sub = $2 AND deleted_at IS NULL`,
[params.sso_provider, params.sso_sub]
);
if (existing) {
// Update last_login_at and name
await query(
`UPDATE sb_customers SET last_login_at = NOW(), name = $1 WHERE id = $2`,
[params.name, existing.id]
);
existing.last_login_at = new Date();
existing.name = params.name;
return { customer: existing, virtualKey: getVirtualKey(existing), isNew: false };
}
// First login — provision LiteLLM key, then insert
// We need the customer ID before the key alias, so generate a UUID first
const { v4: uuidv4 } = await import("uuid");
const customerId = uuidv4();
const virtualKey = await provisionLiteLLMKey(customerId, params.email);
const keyAlias = `sb-${customerId}`;
const encryptedKey = encrypt(virtualKey);
const [customer] = await query<Customer>(
`INSERT INTO sb_customers
(id, email, name, sso_provider, sso_sub, litellm_key_alias, litellm_key_encrypted, last_login_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
RETURNING *`,
[customerId, params.email, params.name, params.sso_provider, params.sso_sub, keyAlias, encryptedKey]
);
return { customer, virtualKey, isNew: true };
}
export async function getCustomerById(id: string): Promise<Customer | null> {
return queryOne<Customer>(
`SELECT * FROM sb_customers WHERE id = $1 AND deleted_at IS NULL`,
[id]
);
}
export async function updateCustomerConfig(
id: string,
patch: { system_prompt?: string | null; preferred_model?: string | null }
): Promise<void> {
const fields: string[] = [];
const values: unknown[] = [];
let i = 1;
if ("system_prompt" in patch) {
fields.push(`system_prompt = $${i++}`);
values.push(patch.system_prompt);
}
if ("preferred_model" in patch) {
fields.push(`preferred_model = $${i++}`);
values.push(patch.preferred_model);
}
if (fields.length === 0) return;
values.push(id);
await query(
`UPDATE sb_customers SET ${fields.join(", ")} WHERE id = $${i}`,
values
);
}
export async function softDeleteCustomer(id: string): Promise<void> {
await query(
`UPDATE sb_customers SET deleted_at = NOW() WHERE id = $1`,
[id]
);
}
export async function exportCustomerData(id: string): Promise<object> {
const customer = await getCustomerById(id);
if (!customer) return {};
const conversations = await query(
`SELECT id, title, created_at, updated_at FROM sb_conversations
WHERE customer_id = $1 ORDER BY updated_at DESC`,
[id]
);
const messages = await query(
`SELECT m.id, m.conversation_id, m.role, m.content, m.created_at
FROM sb_messages m
JOIN sb_conversations c ON c.id = m.conversation_id
WHERE c.customer_id = $1
ORDER BY m.created_at`,
[id]
);
return {
customer: toPublic(customer),
conversations,
messages,
exported_at: new Date().toISOString(),
};
}

40
src/auth/google.ts Normal file
View File

@@ -0,0 +1,40 @@
import { OAuth2Client } from "google-auth-library";
import { config } from "../config.js";
const client = new OAuth2Client(config.googleClientId, config.googleClientSecret);
export interface GoogleProfile {
sub: string;
email: string;
name: string;
}
/**
* Exchange an authorization code from chrome.identity.launchWebAuthFlow for
* a Google ID token, then verify and return the user profile.
*
* redirectUri must match what was registered in the Google Cloud Console AND
* what chrome.identity uses (typically https://<ext-id>.chromiumapp.org/).
*/
export async function exchangeGoogleCode(
code: string,
redirectUri: string
): Promise<GoogleProfile> {
const { tokens } = await client.getToken({ code, redirect_uri: redirectUri });
if (!tokens.id_token) throw new Error("No id_token in Google response");
const ticket = await client.verifyIdToken({
idToken: tokens.id_token,
audience: config.googleClientId,
});
const payload = ticket.getPayload();
if (!payload?.sub || !payload.email) {
throw new Error("Invalid Google token payload");
}
return {
sub: payload.sub,
email: payload.email,
name: payload.name ?? payload.email,
};
}

52
src/auth/litellm.ts Normal file
View File

@@ -0,0 +1,52 @@
import { config } from "../config.js";
interface LiteLLMKeyResponse {
key: string;
key_alias: string;
}
/**
* Provision a new virtual key for a customer. Called once at first login.
*/
export async function provisionLiteLLMKey(customerId: string, email: string): Promise<string> {
const body = {
key_alias: `sb-${customerId}`,
models: config.defaultModels,
mcp_servers: ["sitebridge"],
max_budget: config.defaultBudgetUsd,
budget_duration: config.defaultBudgetDuration,
rpm_limit: config.defaultRpmLimit,
metadata: { customer_id: customerId, email },
};
const res = await fetch(`${config.litellmUrl}/key/generate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.litellmMasterKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`LiteLLM key/generate failed ${res.status}: ${text}`);
}
const data = (await res.json()) as LiteLLMKeyResponse;
return data.key;
}
/**
* Look up a virtual key's metadata via LiteLLM.
*/
export async function getLiteLLMKeyInfo(keyAlias: string): Promise<unknown> {
const res = await fetch(
`${config.litellmUrl}/key/info?key_alias=${encodeURIComponent(keyAlias)}`,
{
headers: { Authorization: `Bearer ${config.litellmMasterKey}` },
}
);
if (!res.ok) throw new Error(`LiteLLM key/info failed ${res.status}`);
return res.json();
}