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

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock config before importing bridge
vi.mock("../config.js", () => ({
config: {
mcpBridgeTimeoutMs: 1000,
jwtSecret: "test-secret",
encryptionKey: "0".repeat(64),
litellmUrl: "http://localhost:4000",
litellmMasterKey: "test-key",
mcpBridgeSecret: "bridge-secret",
defaultBudgetUsd: 30,
defaultBudgetDuration: "30d",
defaultRpmLimit: 30,
defaultModels: ["claude-sonnet-4-6"],
googleClientId: "test",
googleClientSecret: "test",
msClientId: "",
msTenantId: "common",
dbUrl: "postgres://localhost/test",
redisUrl: "redis://localhost:6379",
port: 4100,
host: "0.0.0.0",
jwtExpiresIn: "7d",
},
}));
import {
registerCustomerWs,
forwardMcpCall,
isCustomerOnline,
McpBridgeError,
} from "../mcp/bridge.js";
function makeMockWs() {
const listeners: Record<string, Array<(data: unknown) => void>> = {};
return {
send: vi.fn(),
close: vi.fn(),
on: (event: string, cb: (data: unknown) => void) => {
listeners[event] = listeners[event] ?? [];
listeners[event].push(cb);
},
emit: (event: string, data: unknown) => {
listeners[event]?.forEach((cb) => cb(data));
},
_listeners: listeners,
};
}
describe("MCP bridge", () => {
beforeEach(() => {
// Clear sessions between tests by re-importing is complex; use unique IDs
});
it("registers a customer WS and marks them online", () => {
const ws = makeMockWs();
registerCustomerWs("cust-1", ws as never);
expect(isCustomerOnline("cust-1")).toBe(true);
});
it("forwards a call and resolves on WS response", async () => {
const ws = makeMockWs();
registerCustomerWs("cust-2", ws as never);
const callPromise = forwardMcpCall(
"cust-2",
{ method: "tools/call", params: { name: "screenshot" }, id: "req-1" },
5000
);
// Simulate extension responding
ws.emit("message", Buffer.from(JSON.stringify({ id: "req-1", result: { image: "base64data" } })));
const result = await callPromise;
expect(result).toEqual({ image: "base64data" });
});
it("rejects with McpBridgeError when customer is offline", async () => {
await expect(
forwardMcpCall("offline-cust", { method: "tools/call", id: "req-2" }, 100)
).rejects.toThrow(McpBridgeError);
});
it("rejects with McpBridgeError on timeout", async () => {
const ws = makeMockWs();
registerCustomerWs("cust-3", ws as never);
await expect(
forwardMcpCall(
"cust-3",
{ method: "tools/call", id: "req-3" },
50 // 50ms timeout
)
).rejects.toThrow("timeout");
});
it("marks customer offline after WS close", () => {
const ws = makeMockWs();
registerCustomerWs("cust-4", ws as never);
expect(isCustomerOnline("cust-4")).toBe(true);
ws.emit("close", undefined);
expect(isCustomerOnline("cust-4")).toBe(false);
});
it("replaces stale WS on second registration", () => {
const ws1 = makeMockWs();
const ws2 = makeMockWs();
registerCustomerWs("cust-5", ws1 as never);
registerCustomerWs("cust-5", ws2 as never);
expect(ws1.close).toHaveBeenCalled();
expect(isCustomerOnline("cust-5")).toBe(true);
});
});

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("../config.js", () => ({
config: {
encryptionKey: "a".repeat(64), // 32-byte hex key
},
}));
import { encrypt, decrypt } from "../utils/crypto.js";
describe("AES-256-GCM encrypt/decrypt", () => {
it("round-trips a plaintext string", () => {
const original = "sk-litellm-abc123supersecret";
const buf = encrypt(original);
expect(buf.length).toBeGreaterThan(28);
expect(decrypt(buf)).toBe(original);
});
it("produces different ciphertexts for the same input (random IV)", () => {
const key = "same-key-test";
const buf1 = encrypt(key);
const buf2 = encrypt(key);
expect(buf1.toString("hex")).not.toBe(buf2.toString("hex"));
});
it("round-trips a unicode string", () => {
const original = "sk-🔑-unicode-test-αβγ";
expect(decrypt(encrypt(original))).toBe(original);
});
});