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:
157
src/auth/customers.ts
Normal file
157
src/auth/customers.ts
Normal 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
40
src/auth/google.ts
Normal 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
52
src/auth/litellm.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user