diff --git a/.gitignore b/.gitignore index 90d67cd..9e612e1 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ out/ # local secrets store (dev fallback for SecretProvider) .secrets/ +.secrets-test/ # coverage coverage/ diff --git a/apps/control-plane/app/api/byom/_lib/auth.ts b/apps/control-plane/app/api/byom/_lib/auth.ts new file mode 100644 index 0000000..8f6b403 --- /dev/null +++ b/apps/control-plane/app/api/byom/_lib/auth.ts @@ -0,0 +1,89 @@ +/** + * Inline admin auth guard for the BYOM routes (Wave C). + * + * TODO(Wave B): replace this placeholder with the real RBAC middleware from + * @coreci/auth (createAuthMiddleware / authenticate). Wave B's middleware + * verifies the `coreci_session` cookie, resolves the tenant, and enforces + * RBAC (Admin-only for BYOM config). On this branch @coreci/auth is not yet + * wired, so this guard does a minimal cookie-presence check + reads the + * tenant id from a header/env for local development. The full RBAC + * enforcement (REQ-005: Viewer → 403, Operator → 200 on read) applies at + * the Wave B merge. + * + * The contract the real middleware will implement: + * - read `coreci_session` httpOnly cookie + * - verifySession → SessionData { id, tenantId, role } + * - enforceRbac(role, method, path) → 403 if not allowed + * - attach req.user = { id, tenantId, role } + */ + +import type { NextRequest } from "next/server"; + +export interface AuthContext { + tenantId: string; + userId: string; + role: "admin" | "operator" | "viewer"; +} + +export class AuthError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.name = "AuthError"; + this.status = status; + } +} + +/** The cookie name carrying the signed session JWT (matches @coreci/auth). */ +export const SESSION_COOKIE = "coreci_session"; + +/** + * Resolve the authenticated admin context for a BYOM route request. + * + * Throws AuthError(401) if no session cookie is present, AuthError(403) if the + * caller's role is not admin. In local dev the tenant id may be supplied via + * the `x-coreci-tenant-id` header (the Wave B middleware will read it from the + * verified session instead). + */ +export function requireAdmin(req: NextRequest): AuthContext { + const cookie = req.cookies.get(SESSION_COOKIE)?.value; + if (!cookie) { + throw new AuthError(401, "Unauthorized: no session. Sign in via SSO first."); + } + + // TODO(Wave B): verifySession(cookie) → SessionData. Until then, accept a + // dev header for the tenant id + role. Default to admin so the BYOM happy + // path is walkable in local dev before Wave B lands. + const tenantId = req.headers.get("x-coreci-tenant-id") ?? process.env.CORECI_DEV_TENANT_ID; + if (!tenantId) { + throw new AuthError(400, "Missing tenant id. Set x-coreci-tenant-id or CORECI_DEV_TENANT_ID for local dev."); + } + + const role = (req.headers.get("x-coreci-role") ?? "admin") as AuthContext["role"]; + if (role !== "admin") { + throw new AuthError(403, `Forbidden: BYOM configuration requires the Admin role (got ${role}).`); + } + + const userId = req.headers.get("x-coreci-user-id") ?? "00000000-0000-0000-0000-000000000000"; + + return { tenantId, userId, role }; +} + +/** + * Resolve an authenticated (read) context for GET /api/byom. Operators and + * Viewers may read the BYOM status; only Admins may mutate. TODO(Wave B): the + * real RBAC map drives this. + */ +export function requireRead(req: NextRequest): AuthContext { + const cookie = req.cookies.get(SESSION_COOKIE)?.value; + if (!cookie) { + throw new AuthError(401, "Unauthorized: no session. Sign in via SSO first."); + } + const tenantId = req.headers.get("x-coreci-tenant-id") ?? process.env.CORECI_DEV_TENANT_ID; + if (!tenantId) { + throw new AuthError(400, "Missing tenant id. Set x-coreci-tenant-id or CORECI_DEV_TENANT_ID for local dev."); + } + const role = (req.headers.get("x-coreci-role") ?? "admin") as AuthContext["role"]; + const userId = req.headers.get("x-coreci-user-id") ?? "00000000-0000-0000-0000-000000000000"; + return { tenantId, userId, role }; +} \ No newline at end of file diff --git a/apps/control-plane/app/api/byom/_lib/runtime.ts b/apps/control-plane/app/api/byom/_lib/runtime.ts new file mode 100644 index 0000000..1a1e3f6 --- /dev/null +++ b/apps/control-plane/app/api/byom/_lib/runtime.ts @@ -0,0 +1,46 @@ +/** + * BYOM API bootstrap — constructs the DbClient + SecretProvider for the + * BYOM route handlers. + * + * M1 dev/test: PGlite (in-process) + LocalEncryptedProvider. + * Prod: pg Pool + AwsSecretsManagerProvider (selected via @coreci/config). + * + * The runtime is constructed per-request in M1 for simplicity. A pooled + * singleton is an M3 optimization; the per-request construction is correct + * because PGlite is in-process and the LocalEncryptedProvider is file-backed. + */ + +import { createDb, setDbClient, type DbClient } from "@coreci/db"; +import { + LocalEncryptedProvider, + AwsSecretsManagerProvider, + type SecretProvider, +} from "@coreci/secrets"; + +export interface ByomRuntime { + db: DbClient; + secrets: SecretProvider; +} + +let cached: ByomRuntime | null = null; + +/** + * Get the shared BYOM runtime. M1 caches a single PGlite + LocalEncryptedProvider + * instance per process (the dev story). Prod will swap to a pg Pool + AWS SM + * provider, also cached. The DB migrations are expected to have been run out of + * band (the control plane's boot runs `pnpm migrate`). + */ +export async function getByomRuntime(): Promise { + if (cached) return cached; + + const db = await createDb(); + setDbClient(db); + + // M1 dev/test uses the local-encrypted provider. Prod swaps via SECRETS_PROVIDER. + const provider = process.env.SECRETS_PROVIDER === "aws-sm" + ? new AwsSecretsManagerProvider({ region: process.env.AWS_REGION ?? "us-east-1" }) + : new LocalEncryptedProvider(); + + cached = { db, secrets: provider }; + return cached; +} \ No newline at end of file diff --git a/apps/control-plane/app/api/byom/route.ts b/apps/control-plane/app/api/byom/route.ts new file mode 100644 index 0000000..57d1891 --- /dev/null +++ b/apps/control-plane/app/api/byom/route.ts @@ -0,0 +1,117 @@ +/** + * POST /api/byom — save + validate a BYOM endpoint (Admin only). + * REQ-006: configure BYOM endpoint (URL in DB, key in secret manager). + * REQ-007: validate-on-save test inference call (OpenAI-compatible). + * + * GET /api/byom — return the current BYOM endpoint status (read). + * + * DELETE /api/byom — delete the BYOM endpoint (Admin only). + * + * TODO(Wave B): the inline auth guard in ./_lib/auth.ts is replaced by the real + * RBAC middleware at the Wave B merge (createAuthMiddleware + enforceRbac). + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { saveEndpoint, getEndpoint, deleteEndpoint, type ByomConfigRequest } from "@coreci/byom"; +import { getByomRuntime } from "./_lib/runtime.js"; +import { requireAdmin, requireRead, AuthError } from "./_lib/auth.js"; + +export const runtime = "nodejs"; + +/** POST /api/byom — Admin saves a BYOM endpoint (validate-on-save). */ +export async function POST(req: NextRequest): Promise { + let ctx; + try { + ctx = requireAdmin(req); + } catch (err) { + return toAuthResponse(err); + } + + let body: ByomConfigRequest; + try { + const parsed = (await req.json()) as Partial; + if (typeof parsed.url !== "string" || typeof parsed.apiKey !== "string" || !parsed.url || !parsed.apiKey) { + return NextResponse.json( + { error: "invalid_request", detail: "Body must include non-empty `url` and `apiKey`." }, + { status: 400 }, + ); + } + body = { url: parsed.url, apiKey: parsed.apiKey }; + } catch { + return NextResponse.json( + { error: "invalid_request", detail: "Body must be valid JSON with `url` and `apiKey`." }, + { status: 400 }, + ); + } + + const { db, secrets } = await getByomRuntime(); + const { endpointId, validation } = await saveEndpoint(db, secrets, ctx.tenantId, body.url, body.apiKey); + + if (validation.ok) { + return NextResponse.json({ + ok: true, + endpointId, + validated: true, + }); + } + // Edge 11: validation failed → save blocked, error details surfaced. + return NextResponse.json( + { + ok: false, + endpointId, + validated: false, + error: validation.error ?? "unknown", + detail: validation.detail ?? "Validation failed.", + }, + { status: 422 }, + ); +} + +/** GET /api/byom — return the current BYOM endpoint status. */ +export async function GET(req: NextRequest): Promise { + let ctx; + try { + ctx = requireRead(req); + } catch (err) { + return toAuthResponse(err); + } + + const { db } = await getByomRuntime(); + const endpoint = await getEndpoint(db, ctx.tenantId); + + if (!endpoint) { + return NextResponse.json({ configured: false, validated: false }); + } + return NextResponse.json({ + configured: true, + validated: endpoint.validated, + endpointId: endpoint.id, + url: endpoint.url, + }); +} + +/** DELETE /api/byom — Admin deletes the BYOM endpoint. */ +export async function DELETE(req: NextRequest): Promise { + let ctx; + try { + ctx = requireAdmin(req); + } catch (err) { + return toAuthResponse(err); + } + + const { db, secrets } = await getByomRuntime(); + await deleteEndpoint(db, secrets, ctx.tenantId); + return NextResponse.json({ ok: true, deleted: true }); +} + +function toAuthResponse(err: unknown): NextResponse { + if (err instanceof AuthError) { + return NextResponse.json({ error: err.status === 401 ? "unauthorized" : "forbidden", detail: err.message }, { + status: err.status, + }); + } + return NextResponse.json( + { error: "internal_error", detail: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ); +} \ No newline at end of file diff --git a/apps/control-plane/app/api/byom/test-inference/route.ts b/apps/control-plane/app/api/byom/test-inference/route.ts new file mode 100644 index 0000000..020cecd --- /dev/null +++ b/apps/control-plane/app/api/byom/test-inference/route.ts @@ -0,0 +1,103 @@ +/** + * POST /api/byom/test-inference — G-001 plan-time proxy for REQ-008 (Admin only). + * + * Calls the BYOM routing shim with a trivial payload so REQ-008's "100% of LLM + * inference calls routed to BYOM, verified via outbound traffic log" can be + * exercised in M1 in the absence of M3 chat orchestration. + * + * [G-001] This endpoint is a plan-time proxy for REQ-008 and is MARKED FOR M3 + * DEPRECATION once the chat orchestrator (REQ-033) drives real inference. The + * routing shim itself (@coreci/byom routeInference) persists; only this proxy + * endpoint goes away. + * + * REQ-009 semantics: + * - unconfigured → ByomUnconfiguredError → 400 actionable + * - unreachable → ByomUnreachableError → 503 actionable + * - no inference attempted when unconfigured (the shim throws before fetch) + * + * TODO(Wave B): replace the inline auth guard with the real RBAC middleware. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { + routeInference, + ByomUnconfiguredError, + ByomUnreachableError, + type ChatCompletionRequest, +} from "@coreci/byom"; +import { getByomRuntime } from "../_lib/runtime.js"; +import { requireAdmin, AuthError } from "../_lib/auth.js"; + +export const runtime = "nodejs"; + +/** Default trivial payload for the test-inference proxy (OpenAI-compatible, D-001). */ +const DEFAULT_PAYLOAD: ChatCompletionRequest = { + model: "test", + messages: [{ role: "user", content: "ping" }], + max_tokens: 1, +}; + +/** POST /api/byom/test-inference — route a trivial inference call to BYOM (G-001). */ +export async function POST(req: NextRequest): Promise { + let ctx; + try { + ctx = requireAdmin(req); + } catch (err) { + return toAuthResponse(err); + } + + // The caller may override the trivial payload (e.g. to test a specific model). + let payload: ChatCompletionRequest = DEFAULT_PAYLOAD; + try { + const text = await req.text(); + if (text.trim().length > 0) { + const parsed = (await JSON.parse(text)) as Partial; + if (typeof parsed.model === "string" && Array.isArray(parsed.messages)) { + payload = { + model: parsed.model, + messages: parsed.messages, + ...(parsed.max_tokens !== undefined ? { max_tokens: parsed.max_tokens } : {}), + ...(parsed.temperature !== undefined ? { temperature: parsed.temperature } : {}), + ...(parsed.stream !== undefined ? { stream: parsed.stream } : {}), + }; + } + } + } catch { + // fall through with the default payload + } + + const { db, secrets } = await getByomRuntime(); + + try { + const response = await routeInference(db, secrets, ctx.tenantId, payload); + return NextResponse.json({ ok: true, response }); + } catch (err) { + if (err instanceof ByomUnconfiguredError) { + // REQ-009: unconfigured → 400 actionable, no inference attempted. + return NextResponse.json( + { ok: false, error: "byom_unconfigured", detail: err.message }, + { status: 400 }, + ); + } + if (err instanceof ByomUnreachableError) { + // REQ-009, Edge 1: unreachable mid-workflow → 503 actionable. + return NextResponse.json( + { ok: false, error: "byom_unreachable", detail: err.message }, + { status: 503 }, + ); + } + return toAuthResponse(err); + } +} + +function toAuthResponse(err: unknown): NextResponse { + if (err instanceof AuthError) { + return NextResponse.json({ error: err.status === 401 ? "unauthorized" : "forbidden", detail: err.message }, { + status: err.status, + }); + } + return NextResponse.json( + { error: "internal_error", detail: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ); +} \ No newline at end of file diff --git a/apps/control-plane/next-env.d.ts b/apps/control-plane/next-env.d.ts new file mode 100644 index 0000000..911f40c --- /dev/null +++ b/apps/control-plane/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. \ No newline at end of file diff --git a/apps/control-plane/next.config.mjs b/apps/control-plane/next.config.mjs index 4502583..794afe5 100644 --- a/apps/control-plane/next.config.mjs +++ b/apps/control-plane/next.config.mjs @@ -1,5 +1,5 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - transpilePackages: ["@coreci/db", "@coreci/auth", "@coreci/audit", "@coreci/secrets", "@coreci/config", "@coreci/runtime"], + transpilePackages: ["@coreci/db", "@coreci/auth", "@coreci/audit", "@coreci/secrets", "@coreci/config", "@coreci/runtime", "@coreci/byom"], }; export default nextConfig; \ No newline at end of file diff --git a/apps/control-plane/package.json b/apps/control-plane/package.json index 6d64f19..9d7fda8 100644 --- a/apps/control-plane/package.json +++ b/apps/control-plane/package.json @@ -12,6 +12,7 @@ "test": "vitest run" }, "dependencies": { + "@coreci/byom": "workspace:*", "@coreci/db": "workspace:*", "@coreci/secrets": "workspace:*", "@coreci/config": "workspace:*", diff --git a/apps/control-plane/tsconfig.json b/apps/control-plane/tsconfig.json index f512984..5f54bb6 100644 --- a/apps/control-plane/tsconfig.json +++ b/apps/control-plane/tsconfig.json @@ -3,6 +3,8 @@ "compilerOptions": { "rootDir": ".", "outDir": "./dist", + "moduleResolution": "bundler", + "module": "ESNext", "jsx": "preserve", "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["node", "@types/react"], diff --git a/packages/byom/eslint.config.js b/packages/byom/eslint.config.js new file mode 100644 index 0000000..3c22db1 --- /dev/null +++ b/packages/byom/eslint.config.js @@ -0,0 +1,16 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + }, + }, + { + ignores: ["dist/**", "node_modules/**", "coverage/**"], + }, +); \ No newline at end of file diff --git a/packages/byom/package.json b/packages/byom/package.json new file mode 100644 index 0000000..e0ce1c7 --- /dev/null +++ b/packages/byom/package.json @@ -0,0 +1,33 @@ +{ + "name": "@coreci/byom", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "lint": "eslint src --max-warnings 0", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@coreci/config": "workspace:*", + "@coreci/db": "workspace:*", + "@coreci/secrets": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "eslint": "^9.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} \ No newline at end of file diff --git a/packages/byom/src/index.ts b/packages/byom/src/index.ts new file mode 100644 index 0000000..e1aaca3 --- /dev/null +++ b/packages/byom/src/index.ts @@ -0,0 +1,39 @@ +/** + * @coreci/byom — Bring Your Own Model: endpoint registry, validate-on-save, + * and the OpenAI-compatible routing shim (Wave C, REQ-006..009). + * + * D-001: the wire protocol is OpenAI-compatible `/v1/chat/completions`. + * G-001: the routing shim is the single egress point for LLM inference; the + * M1 test-inference proxy endpoint drives it until M3 orchestration lands. + * + * Exports: + * - types: ByomEndpoint, ByomConfigRequest, ByomValidationResult, + * ChatCompletionRequest, ChatCompletionResponse + * - validator: validateEndpoint (REQ-007) + * - repository: saveEndpoint, getEndpoint, deleteEndpoint (REQ-006) + * - router: routeInference, ByomUnconfiguredError, ByomUnreachableError (REQ-008, REQ-009) + */ + +export type { + ByomEndpoint, + ByomConfigRequest, + ByomValidationResult, + ChatCompletionRequest, + ChatCompletionResponse, +} from "./types.js"; + +export { validateEndpoint } from "./validator.js"; + +export { + saveEndpoint, + getEndpoint, + deleteEndpoint, + BYOM_SECRET_NAME, + type SaveEndpointResult, +} from "./repository.js"; + +export { + routeInference, + ByomUnconfiguredError, + ByomUnreachableError, +} from "./router.js"; \ No newline at end of file diff --git a/packages/byom/src/repository.ts b/packages/byom/src/repository.ts new file mode 100644 index 0000000..6c1e3e2 --- /dev/null +++ b/packages/byom/src/repository.ts @@ -0,0 +1,166 @@ +/** + * @coreci/byom/repository — BYOM endpoint registry (REQ-006, REQ-007). + * + * The full save flow: + * 1. secrets.put(tenantId, "byom", apiKey) → secret_ref + * 2. Inside withTenant: INSERT byom_endpoints (url, secret_ref, validated=false) + * + audit append (config event) + * 3. validateEndpoint(url, apiKey) + * 4. On validation ok: UPDATE validated=true, validated_at=now() + audit (validation ok) + * 5. On validation fail: DELETE the row (rollback) + audit (validation fail) — Edge 11 + * 6. Return { endpointId, validation } + * + * The validate-on-save call happens OUTSIDE the withTenant transaction (it's a + * network call), but the post-validation UPDATE/DELETE + audit append happen + * inside a second withTenant transaction (audit-halt rule: audit write failure + * rolls back the enclosing DB transaction). The initial INSERT + config audit + * is its own withTenant transaction so a config event is recorded even if the + * later validation network call hangs. + * + * REQ-040: only secret_ref is stored in the DB; the raw key never touches a column. + */ + +import type { DbClient } from "@coreci/db"; +import { withTenant, appendAudit, setDbClient, type ScopedClient } from "@coreci/db"; +import type { SecretProvider } from "@coreci/secrets"; +import type { ByomEndpoint, ByomValidationResult } from "./types.js"; +import { validateEndpoint } from "./validator.js"; + +/** The secret name under which a tenant's BYOM API key is stored. */ +export const BYOM_SECRET_NAME = "byom"; + +export interface SaveEndpointResult { + endpointId: string; + validation: ByomValidationResult; +} + +/** + * Save a BYOM endpoint for a tenant (REQ-006 + REQ-007). + * + * Stores the key via SecretProvider, inserts the endpoint row (validated=false), + * runs validate-on-save, then marks validated=true OR deletes the row on + * validation failure (Edge 11). Audit entries are appended for the config and + * validation events. + */ +export async function saveEndpoint( + db: DbClient, + secrets: SecretProvider, + tenantId: string, + url: string, + apiKey: string, +): Promise { + // Pin the DbClient for withTenant (DI — callers pass the client; we don't use a global). + setDbClient(db); + // 1. Store the key in the secret manager (REQ-040). The DB holds only secret_ref. + const secretRef = await secrets.put(tenantId, BYOM_SECRET_NAME, apiKey); + + // 2. INSERT the endpoint row (validated=false) + audit the config event. + // Separate transaction so the config event is durable even if validation hangs. + const endpointId = await withTenant(tenantId, async (c) => { + const ins = await c.query<{ id: string }>( + `INSERT INTO byom_endpoints (tenant_id, url, secret_ref, validated) + VALUES ($1, $2, $3, false) + RETURNING id`, + [tenantId, url, secretRef], + ); + const id = ins.rows[0]?.id; + if (!id) { + throw new Error("saveEndpoint: INSERT did not return an id"); + } + await appendAudit(c, { + tenantId, + eventType: "config", + payload: { what: "byom", url, validated: false }, + }); + return id; + }); + + // 3. Validate-on-save (network call — outside the DB transaction). + const validation = await validateEndpoint(url, apiKey); + + if (validation.ok) { + // 4. Validation ok → mark validated + audit (validation ok). + await withTenant(tenantId, async (c) => { + await c.query( + `UPDATE byom_endpoints SET validated = true, validated_at = now() WHERE id = $1`, + [endpointId], + ); + await appendAudit(c, { + tenantId, + eventType: "validation", + payload: { what: "byom", ok: true, endpointId }, + }); + }); + } else { + // 5. Validation fail → DELETE the row (rollback) + audit (validation fail). Edge 11. + await withTenant(tenantId, async (c) => { + await deleteEndpointRow(c, endpointId); + await appendAudit(c, { + tenantId, + eventType: "validation", + payload: { + what: "byom", + ok: false, + endpointId, + error: validation.error ?? "unknown", + detail: validation.detail ?? "", + }, + }); + }); + } + + // 6. Return. + return { endpointId, validation }; +} + +/** + * Read the tenant's validated BYOM endpoint (REQ-008 routing shim uses this). + * Returns null if no validated endpoint is configured (REQ-009 unconfigured). + */ +export async function getEndpoint(db: DbClient, tenantId: string): Promise { + setDbClient(db); + return withTenant(tenantId, async (c) => { + const res = await c.query<{ id: string; tenant_id: string; url: string; validated: boolean }>( + `SELECT id, tenant_id, url, validated + FROM byom_endpoints + WHERE tenant_id = $1 AND validated = true + ORDER BY created_at DESC + LIMIT 1`, + [tenantId], + ); + const row = res.rows[0]; + if (!row) return null; + return { + id: row.id, + tenantId: row.tenant_id, + url: row.url, + validated: row.validated, + }; + }); +} + +/** + * Delete the tenant's BYOM endpoint + the stored secret (REQ-006 management). + * Removes all endpoint rows for the tenant (validated or not) and the secret. + */ +export async function deleteEndpoint( + db: DbClient, + secrets: SecretProvider, + tenantId: string, +): Promise { + setDbClient(db); + await withTenant(tenantId, async (c) => { + await c.query(`DELETE FROM byom_endpoints WHERE tenant_id = $1`, [tenantId]); + await appendAudit(c, { + tenantId, + eventType: "config", + payload: { what: "byom", action: "delete" }, + }); + }); + await secrets.delete(tenantId, BYOM_SECRET_NAME); +} + +/** Delete a single endpoint row by id (used by saveEndpoint's validation-fail path). */ +async function deleteEndpointRow(c: ScopedClient, endpointId: string): Promise { + await c.query(`DELETE FROM byom_endpoints WHERE id = $1`, [endpointId]); +} \ No newline at end of file diff --git a/packages/byom/src/router.ts b/packages/byom/src/router.ts new file mode 100644 index 0000000..6bb4333 --- /dev/null +++ b/packages/byom/src/router.ts @@ -0,0 +1,137 @@ +/** + * @coreci/byom/router — BYOM routing shim (REQ-008, REQ-009, G-001). + * + * `routeInference(db, secrets, tenantId, payload)` resolves the tenant's + * validated BYOM endpoint, fetches the API key from the secret manager, and + * POSTs the OpenAI-compatible chat completion request to `{url}/v1/chat/completions` + * (D-001). On 200 → returns the parsed ChatCompletionResponse. On + * unconfigured/unreachable → throws ByomUnconfiguredError / ByomUnreachableError + * (REQ-009, Edge 1) so the API gateway can return a clear actionable error and + * NO inference is attempted. + * + * [G-001] Scope note: in M1 there is no M3 chat orchestrator to drive inference, + * so the control plane exposes `POST /api/byom/test-inference` (Admin only) as a + * plan-time proxy to satisfy REQ-008 ("100% of LLM inference calls routed to + * BYOM, verified via outbound traffic log"). This shim is the single egress + * point for that proxy and for the future M3 orchestrator. Marked for M3 + * deprecation of the proxy endpoint — the shim itself persists. + * + * REQ-009 error semantics: + * - ByomUnconfiguredError → no validated BYOM endpoint for the tenant → 400. + * - ByomUnreachableError → endpoint unreachable OR rejected the key (401/403 + * is treated as unreachable for the operator, since a previously-validated + * endpoint returning 401/403 means the key was revoked or the endpoint + * moved) → 503. + */ + +import type { DbClient } from "@coreci/db"; +import type { SecretProvider } from "@coreci/secrets"; +import type { ChatCompletionRequest, ChatCompletionResponse } from "./types.js"; +import { getEndpoint, BYOM_SECRET_NAME } from "./repository.js"; + +/** Thrown when no validated BYOM endpoint exists for the tenant (REQ-009 → 400). */ +export class ByomUnconfiguredError extends Error { + constructor(tenantId: string) { + super( + `BYOM is not configured for tenant ${tenantId}. Configure a BYOM endpoint in the dashboard before sending prompts.`, + ); + this.name = "ByomUnconfiguredError"; + } +} + +/** Thrown when the BYOM endpoint is unreachable or rejected the key (REQ-009, Edge 1 → 503). */ +export class ByomUnreachableError extends Error { + override readonly cause: unknown | undefined; + constructor(message: string, cause?: unknown) { + super(message); + this.name = "ByomUnreachableError"; + this.cause = cause; + } +} + +/** + * Route an inference call to the tenant's configured BYOM endpoint (REQ-008). + * + * 1. Resolve the tenant's validated endpoint. If none → ByomUnconfiguredError. + * 2. Resolve the API key via secrets.get → unwrap. + * 3. POST to `{endpoint.url}/v1/chat/completions` with Authorization: Bearer. + * 4. On 200 → return parsed ChatCompletionResponse. + * On connection error → ByomUnreachableError. + * On 401/403 → ByomUnreachableError (treated as unreachable for the operator). + */ +export async function routeInference( + db: DbClient, + secrets: SecretProvider, + tenantId: string, + payload: ChatCompletionRequest, +): Promise { + const endpoint = await getEndpoint(db, tenantId); + if (!endpoint) { + throw new ByomUnconfiguredError(tenantId); + } + + const key = await secrets.get(tenantId, BYOM_SECRET_NAME); + const url = joinChatCompletions(endpoint.url); + + let res: Response; + try { + res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${key.unwrap()}`, + }, + body: JSON.stringify(payload), + }); + } catch (err) { + throw new ByomUnreachableError( + `BYOM endpoint at ${endpoint.url} is unreachable: ${err instanceof Error ? err.message : String(err)}`, + err, + ); + } + + if (res.status === 401 || res.status === 403) { + let detail: string; + try { + detail = await res.text(); + } catch { + detail = `HTTP ${res.status}`; + } + throw new ByomUnreachableError( + `BYOM endpoint at ${endpoint.url} rejected the API key (HTTP ${res.status}). The key may have been revoked or the endpoint moved. ${detail}`, + ); + } + + if (!res.ok) { + let detail: string; + try { + detail = await res.text(); + } catch { + detail = `HTTP ${res.status}`; + } + throw new ByomUnreachableError( + `BYOM endpoint at ${endpoint.url} returned HTTP ${res.status}: ${detail}`, + ); + } + + let body: unknown; + try { + body = await res.json(); + } catch (err) { + throw new ByomUnreachableError( + `BYOM endpoint at ${endpoint.url} returned 200 but the body was not valid JSON: ${ + err instanceof Error ? err.message : String(err) + }`, + err, + ); + } + return body as ChatCompletionResponse; +} + +/** Join a base URL with `/v1/chat/completions` (mirrors validator.ts). */ +function joinChatCompletions(baseUrl: string): string { + const trimmed = baseUrl.replace(/\/+$/, ""); + if (trimmed.endsWith("/v1/chat/completions")) return trimmed; + if (trimmed.endsWith("/v1")) return `${trimmed}/chat/completions`; + return `${trimmed}/v1/chat/completions`; +} \ No newline at end of file diff --git a/packages/byom/src/types.ts b/packages/byom/src/types.ts new file mode 100644 index 0000000..419e1cf --- /dev/null +++ b/packages/byom/src/types.ts @@ -0,0 +1,74 @@ +/** + * @coreci/byom/types — OpenAI-compatible BYOM types (D-001). + * + * The BYOM routing shim speaks the OpenAI-compatible `/v1/chat/completions` + * wire protocol (D-001). These types model the input from the admin form, the + * validate-on-save result, and the chat completion request/response contract. + * + * REQ-006 (configure BYOM endpoint), REQ-007 (validate-on-save), + * REQ-008 (route inference to BYOM), REQ-009 (reject when unconfigured/unreachable). + */ + +/** + * A BYOM endpoint registered for a tenant. The URL lives in the DB; the API + * key lives in the secret manager (only a SecretRef is stored in the DB — REQ-040). + */ +export interface ByomEndpoint { + id: string; + tenantId: string; + url: string; + validated: boolean; +} + +/** + * Input from the admin BYOM config form (REQ-006). The `apiKey` is the raw + * secret from the form; it is stored via SecretProvider.put and NEVER persisted + * to a DB column (REQ-040). + */ +export interface ByomConfigRequest { + url: string; + apiKey: string; +} + +/** + * Result of validate-on-save (REQ-007). On `ok: false`, `error` is a stable + * machine code and `detail` is a human-readable diagnostic surfaced to the + * dashboard's red error panel. + */ +export interface ByomValidationResult { + ok: boolean; + error?: "connection_failed" | "auth_failed" | "invalid_response"; + detail?: string; +} + +/** + * OpenAI-compatible `/v1/chat/completions` request contract (D-001). + * This is the payload the routing shim POSTs to the tenant's BYOM endpoint + * and the shape the test-inference proxy endpoint accepts. + */ +export interface ChatCompletionRequest { + model: string; + messages: { role: string; content: string }[]; + max_tokens?: number; + temperature?: number; + stream?: boolean; +} + +/** + * OpenAI-compatible `/v1/chat/completions` response contract (D-001). + * Only the fields M1 consumes are typed; OpenAI returns additional fields + * (object, system_fingerprint, ...) which are ignored by the routing shim. + */ +export interface ChatCompletionResponse { + id: string; + choices: { + index: number; + message: { role: string; content: string }; + finish_reason: string; + }[]; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} \ No newline at end of file diff --git a/packages/byom/src/validator.ts b/packages/byom/src/validator.ts new file mode 100644 index 0000000..4aacee6 --- /dev/null +++ b/packages/byom/src/validator.ts @@ -0,0 +1,130 @@ +/** + * @coreci/byom/validator — validate-on-save (REQ-007). + * + * `validateEndpoint(url, apiKey)` sends a trivial test inference call to the + * configured BYOM endpoint's OpenAI-compatible `/v1/chat/completions` route + * (D-001) with a minimal payload and a 10s timeout. On 200 → { ok: true }. + * On any failure → { ok: false, error, detail }. + * + * Error codes: + * - connection_failed: the endpoint could not be reached (network error, timeout, 5xx, 4xx other than 401/403) + * - auth_failed: the endpoint rejected the API key (401/403) + * - invalid_response: the endpoint returned 200 but the body is not a valid chat completion + * + * Uses the global `fetch` (Node 18+). No external HTTP dependency. + */ + +import type { ByomValidationResult } from "./types.js"; + +/** Validate timeout — REQ-007 UX target is <10s synchronous feedback. */ +const VALIDATE_TIMEOUT_MS = 10_000; + +/** The trivial test prompt sent during validate-on-save. */ +const TEST_PAYLOAD = { + model: "test", + messages: [{ role: "user", content: "ping" }], + max_tokens: 1, +}; + +/** + * Validate a BYOM endpoint by sending a trivial test inference call. + * Returns { ok: true } on a 200 with a parseable chat-completion body, or + * { ok: false, error, detail } on any failure. + */ +export async function validateEndpoint(url: string, apiKey: string): Promise { + const endpoint = joinChatCompletions(url); + + let res: Response; + try { + res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(TEST_PAYLOAD), + signal: AbortSignal.timeout(VALIDATE_TIMEOUT_MS), + }); + } catch (err) { + return { + ok: false, + error: "connection_failed", + detail: err instanceof Error ? err.message : String(err), + }; + } + + // 401/403 → the key was rejected. + if (res.status === 401 || res.status === 403) { + let detail: string; + try { + detail = await res.text(); + } catch { + detail = `HTTP ${res.status}`; + } + return { + ok: false, + error: "auth_failed", + detail: `BYOM endpoint rejected the API key (HTTP ${res.status}): ${detail}`, + }; + } + + // Any other non-200 → connection_failed (covers 5xx, 404, 422, timeouts surfaced as 504, etc.). + if (!res.ok) { + let detail: string; + try { + detail = await res.text(); + } catch { + detail = `HTTP ${res.status}`; + } + return { + ok: false, + error: "connection_failed", + detail: `BYOM endpoint returned HTTP ${res.status}: ${detail}`, + }; + } + + // 200 — verify the body is a valid OpenAI-compatible chat completion. + let body: unknown; + try { + body = await res.json(); + } catch (err) { + return { + ok: false, + error: "invalid_response", + detail: `BYOM endpoint returned 200 but the body was not valid JSON: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + + if (!isChatCompletionLike(body)) { + return { + ok: false, + error: "invalid_response", + detail: "BYOM endpoint returned 200 but the body did not match the OpenAI chat completion shape (missing choices)", + }; + } + + return { ok: true }; +} + +/** + * Join a base URL with `/v1/chat/completions`, tolerating a trailing slash on + * the configured URL. The admin supplies e.g. `https://byom.example.com` (or + * `https://byom.example.com/v1`); we always POST to `/v1/chat/completions`. + */ +function joinChatCompletions(baseUrl: string): string { + const trimmed = baseUrl.replace(/\/+$/, ""); + // If the operator already included `/v1/chat/completions`, don't double it. + if (trimmed.endsWith("/v1/chat/completions")) return trimmed; + // If the operator included `/v1`, append only `/chat/completions`. + if (trimmed.endsWith("/v1")) return `${trimmed}/chat/completions`; + return `${trimmed}/v1/chat/completions`; +} + +/** Minimal structural check for an OpenAI chat-completion response. */ +function isChatCompletionLike(body: unknown): boolean { + if (body === null || typeof body !== "object") return false; + const b = body as Record; + return Array.isArray(b.choices) && b.choices.length >= 0; +} \ No newline at end of file diff --git a/packages/byom/tests/repository.test.ts b/packages/byom/tests/repository.test.ts new file mode 100644 index 0000000..159c99f --- /dev/null +++ b/packages/byom/tests/repository.test.ts @@ -0,0 +1,191 @@ +/** + * repository.test.ts — BYOM endpoint registry (REQ-006, REQ-007, Edge 11). + * + * Uses PGlite via @coreci/db createDb + migration 0001_init.sql + the + * LocalEncryptedProvider. Mocks `fetch` for the validate-on-save call. + * + * Cases: + * - saveEndpoint with validation ok → validated=true, row present, secret stored + * - saveEndpoint with validation fail → row deleted (Edge 11), secret still removed-from-DB? (secret stays so re-save can reuse; we assert row gone) + * - getEndpoint returns the validated endpoint (and null when none) + * - deleteEndpoint removes the row + the secret + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createDb } from "@coreci/db"; +import { setDbClient } from "@coreci/db"; +import { withTenant } from "@coreci/db"; +import { LocalEncryptedProvider, type SecretProvider } from "@coreci/secrets"; +import { saveEndpoint, getEndpoint, deleteEndpoint, BYOM_SECRET_NAME } from "../src/repository.js"; + +const T1 = "00000000-0000-0000-0000-000000000001"; +const MASTER_KEY = "test-master-key-for-byom-repository-tests-32+"; + +async function runMigration(db: { exec: (t: string) => Promise }): Promise { + const sql = await readFile( + join(import.meta.dirname, "..", "..", "db", "migrations", "0001_init.sql"), + "utf8", + ); + await db.exec(sql); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const OK_BODY = { + id: "chatcmpl-1", + choices: [{ index: 0, message: { role: "assistant", content: "pong" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, +}; + +describe("BYOM repository (REQ-006, REQ-007, Edge 11)", () => { + let db: Awaited>; + let secrets: SecretProvider; + // Unique per-test secrets dir so tests don't share the local-encrypted store. + let secretsDir: string; + + beforeAll(async () => { + db = await createDb({ mode: "pglite" }); + setDbClient(db); + await runMigration(db); + }); + + beforeEach(async () => { + // Fresh secrets dir + provider per test for isolation. + secretsDir = join(import.meta.dirname, ".secrets-test", `repo-${Math.random().toString(36).slice(2)}`); + secrets = new LocalEncryptedProvider({ baseDir: secretsDir, masterKey: MASTER_KEY }); + + // Ensure tenant T1 exists (RLS with CHECK requires the FK parent row). + await db.query(`INSERT INTO tenants (id, name) VALUES ($1, 'T1') ON CONFLICT DO NOTHING`, [T1]); + + // Clean leftover byom_endpoints + audit_log rows for T1 (reset the chain so + // the next audit append is a fresh genesis row). Disable RLS to mutate. + await db.query("ALTER TABLE byom_endpoints DISABLE ROW LEVEL SECURITY"); + await db.query(`DELETE FROM byom_endpoints WHERE tenant_id = $1`, [T1]); + await db.query("ALTER TABLE byom_endpoints ENABLE ROW LEVEL SECURITY"); + await db.query("ALTER TABLE audit_log DISABLE ROW LEVEL SECURITY"); + await db.query(`DELETE FROM audit_log WHERE tenant_id = $1`, [T1]); + await db.query("ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("saveEndpoint with validation ok → row validated=true, secret stored", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(OK_BODY))); + + const { endpointId, validation } = await saveEndpoint(db, secrets, T1, "https://byom.example.com", "sk-secret"); + + expect(validation.ok).toBe(true); + expect(endpointId).toBeTruthy(); + + // Row is present + validated. + const ep = await getEndpoint(db, T1); + expect(ep).not.toBeNull(); + expect(ep!.id).toBe(endpointId); + expect(ep!.url).toBe("https://byom.example.com"); + expect(ep!.validated).toBe(true); + + // Secret is retrievable (key in the secret manager, not the DB — REQ-040). + const sv = await secrets.get(T1, BYOM_SECRET_NAME); + expect(sv.unwrap()).toBe("sk-secret"); + + // DB row does NOT contain the plaintext key (REQ-040 scan). + await db.query("ALTER TABLE byom_endpoints DISABLE ROW LEVEL SECURITY"); + const rows = await db.query<{ url: string; secret_ref: string }>( + "SELECT url, secret_ref FROM byom_endpoints WHERE tenant_id = $1", + [T1], + ); + await db.query("ALTER TABLE byom_endpoints ENABLE ROW LEVEL SECURITY"); + for (const r of rows.rows) { + expect(r.secret_ref).not.toContain("sk-secret"); + expect(r.url).not.toContain("sk-secret"); + } + }); + + it("saveEndpoint with validation fail → row deleted (Edge 11)", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("nope", { status: 401 }))); + + const { endpointId, validation } = await saveEndpoint(db, secrets, T1, "https://bad.example.com", "sk-bad"); + + expect(validation.ok).toBe(false); + expect(validation.error).toBe("auth_failed"); + expect(endpointId).toBeTruthy(); + + // Row was deleted (rollback) — getEndpoint returns null. + const ep = await getEndpoint(db, T1); + expect(ep).toBeNull(); + + // No byom_endpoints rows remain for the tenant. + await db.query("ALTER TABLE byom_endpoints DISABLE ROW LEVEL SECURITY"); + const rows = await db.query<{ id: string }>( + "SELECT id FROM byom_endpoints WHERE tenant_id = $1", + [T1], + ); + await db.query("ALTER TABLE byom_endpoints ENABLE ROW LEVEL SECURITY"); + expect(rows.rows).toHaveLength(0); + + // Audit entries for config + validation-fail were still written (Edge 11 + // surfaces errors but the events are auditable). + await db.query("ALTER TABLE audit_log DISABLE ROW LEVEL SECURITY"); + const audit = await db.query<{ event_type: string; payload: any }>( + "SELECT event_type, payload FROM audit_log WHERE tenant_id = $1 ORDER BY id", + [T1], + ); + await db.query("ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY"); + const types = audit.rows.map((r) => r.event_type); + expect(types).toContain("config"); + expect(types).toContain("validation"); + const valEntry = audit.rows.find((r) => r.event_type === "validation"); + expect(valEntry?.payload?.ok).toBe(false); + }); + + it("getEndpoint returns null when no validated endpoint exists", async () => { + const ep = await getEndpoint(db, T1); + expect(ep).toBeNull(); + }); + + it("deleteEndpoint removes the row + the secret", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(OK_BODY))); + await saveEndpoint(db, secrets, T1, "https://byom.example.com", "sk-to-delete"); + expect(await getEndpoint(db, T1)).not.toBeNull(); + + await deleteEndpoint(db, secrets, T1); + + expect(await getEndpoint(db, T1)).toBeNull(); + + // Secret is gone. + await expect(secrets.get(T1, BYOM_SECRET_NAME)).rejects.toThrow(); + + // No byom_endpoints rows remain. + await db.query("ALTER TABLE byom_endpoints DISABLE ROW LEVEL SECURITY"); + const rows = await db.query<{ id: string }>( + "SELECT id FROM byom_endpoints WHERE tenant_id = $1", + [T1], + ); + await db.query("ALTER TABLE byom_endpoints ENABLE ROW LEVEL SECURITY"); + expect(rows.rows).toHaveLength(0); + }); + + it("saveEndpoint appends audit entries inside withTenant (audit-halt)", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(OK_BODY))); + await saveEndpoint(db, secrets, T1, "https://byom.example.com", "sk-audited"); + + // Confirm a separate withTenant still works (the audit chain is consistent). + await withTenant(T1, async (c) => { + const res = await c.query<{ count: string }>( + "SELECT count(*) AS count FROM audit_log WHERE tenant_id = $1", + [T1], + ); + const n = Number(res.rows[0]?.count ?? 0); + expect(n).toBeGreaterThanOrEqual(2); // config + validation + }); + }); +}); \ No newline at end of file diff --git a/packages/byom/tests/router.test.ts b/packages/byom/tests/router.test.ts new file mode 100644 index 0000000..f037174 --- /dev/null +++ b/packages/byom/tests/router.test.ts @@ -0,0 +1,139 @@ +/** + * router.test.ts — BYOM routing shim (REQ-008, REQ-009, Edge 1, G-001). + * + * Cases: + * - routeInference success → POSTs to the configured endpoint with the bearer key, returns the parsed body + * - no validated endpoint → ByomUnconfiguredError + * - endpoint unreachable (fetch throws) → ByomUnreachableError + * - endpoint returns 401/403 → ByomUnreachableError + * - endpoint returns 5xx → ByomUnreachableError + * + * Uses PGlite + migration + LocalEncryptedProvider for the endpoint registry, + * and mocks `fetch` for the outbound inference call. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createDb } from "@coreci/db"; +import { setDbClient } from "@coreci/db"; +import { LocalEncryptedProvider, type SecretProvider } from "@coreci/secrets"; +import { saveEndpoint, BYOM_SECRET_NAME } from "../src/repository.js"; +import { routeInference, ByomUnconfiguredError, ByomUnreachableError } from "../src/router.js"; +import type { ChatCompletionRequest, ChatCompletionResponse } from "../src/types.js"; + +const T1 = "00000000-0000-0000-0000-000000000001"; +const MASTER_KEY = "test-master-key-for-byom-router-tests-32+"; + +async function runMigration(db: { exec: (t: string) => Promise }): Promise { + const sql = await readFile( + join(import.meta.dirname, "..", "..", "db", "migrations", "0001_init.sql"), + "utf8", + ); + await db.exec(sql); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** A fetch mock that returns a fresh OK response on every call (Response bodies are single-use). */ +function okFetchMock(body: unknown = OK_RESPONSE): ReturnType { + return vi.fn().mockImplementation(() => Promise.resolve(jsonResponse(body))); +} + +const OK_RESPONSE: ChatCompletionResponse = { + id: "chatcmpl-routed", + choices: [{ index: 0, message: { role: "assistant", content: "hello" }, finish_reason: "stop" }], + usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }, +}; + +const PAYLOAD: ChatCompletionRequest = { + model: "gpt-test", + messages: [{ role: "user", content: "hi" }], +}; + +describe("BYOM routing shim (REQ-008, REQ-009, G-001)", () => { + let db: Awaited>; + let secrets: SecretProvider; + + beforeAll(async () => { + db = await createDb({ mode: "pglite" }); + setDbClient(db); + await runMigration(db); + }); + + beforeEach(async () => { + const secretsDir = join(import.meta.dirname, ".secrets-test", `router-${Math.random().toString(36).slice(2)}`); + secrets = new LocalEncryptedProvider({ baseDir: secretsDir, masterKey: MASTER_KEY }); + await db.query(`INSERT INTO tenants (id, name) VALUES ($1, 'T1') ON CONFLICT DO NOTHING`, [T1]); + + // Clean leftover byom_endpoints + audit_log rows for T1 (reset the chain). + await db.query("ALTER TABLE byom_endpoints DISABLE ROW LEVEL SECURITY"); + await db.query(`DELETE FROM byom_endpoints WHERE tenant_id = $1`, [T1]); + await db.query("ALTER TABLE byom_endpoints ENABLE ROW LEVEL SECURITY"); + await db.query("ALTER TABLE audit_log DISABLE ROW LEVEL SECURITY"); + await db.query(`DELETE FROM audit_log WHERE tenant_id = $1`, [T1]); + await db.query("ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("routes inference to the configured endpoint and returns the parsed response", async () => { + // Save a validated endpoint (mock fetch for the validate-on-save call). + vi.stubGlobal("fetch", okFetchMock()); + await saveEndpoint(db, secrets, T1, "https://byom.example.com", "sk-route"); + + // Now route a real inference call. fetch is still mocked to the OK body. + const out = await routeInference(db, secrets, T1, PAYLOAD); + expect(out.id).toBe("chatcmpl-routed"); + expect(out.choices[0]?.message.content).toBe("hello"); + + // Verify the outbound call used the bearer key + the /v1/chat/completions URL. + const calls = (fetch as unknown as ReturnType).mock.calls; + // find the call to /v1/chat/completions with our payload (the validate call also posts there) + const routed = calls.find(([u, init]) => { + const body = (init as RequestInit).body as string; + return u === "https://byom.example.com/v1/chat/completions" && body.includes('"gpt-test"'); + }) as [string, RequestInit] | undefined; + expect(routed).toBeDefined(); + expect((routed![1].headers as Record).Authorization).toBe("Bearer sk-route"); + }); + + it("throws ByomUnconfiguredError when no validated endpoint exists (REQ-009)", async () => { + vi.stubGlobal("fetch", vi.fn()); + await expect(routeInference(db, secrets, T1, PAYLOAD)).rejects.toBeInstanceOf(ByomUnconfiguredError); + // No inference attempted (fetch not called). + expect(fetch).not.toHaveBeenCalled(); + }); + + it("throws ByomUnreachableError when the endpoint is unreachable (Edge 1)", async () => { + vi.stubGlobal("fetch", okFetchMock()); + await saveEndpoint(db, secrets, T1, "https://byom.example.com", "sk-unreachable"); + + // Now make the routed call throw (simulate network failure). + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); + await expect(routeInference(db, secrets, T1, PAYLOAD)).rejects.toBeInstanceOf(ByomUnreachableError); + }); + + it("throws ByomUnreachableError on 401/403 (treated as unreachable for the operator)", async () => { + vi.stubGlobal("fetch", okFetchMock()); + await saveEndpoint(db, secrets, T1, "https://byom.example.com", "sk-revoked"); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("unauthorized", { status: 401 }))); + await expect(routeInference(db, secrets, T1, PAYLOAD)).rejects.toBeInstanceOf(ByomUnreachableError); + }); + + it("throws ByomUnreachableError on 5xx", async () => { + vi.stubGlobal("fetch", okFetchMock()); + await saveEndpoint(db, secrets, T1, "https://byom.example.com", "sk-500"); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("bad gateway", { status: 502 }))); + await expect(routeInference(db, secrets, T1, PAYLOAD)).rejects.toBeInstanceOf(ByomUnreachableError); + }); +}); \ No newline at end of file diff --git a/packages/byom/tests/validator.test.ts b/packages/byom/tests/validator.test.ts new file mode 100644 index 0000000..fb7a604 --- /dev/null +++ b/packages/byom/tests/validator.test.ts @@ -0,0 +1,122 @@ +/** + * validator.test.ts — validateEndpoint (REQ-007). + * + * Mocks the global `fetch` to assert: + * - 200 with a valid chat-completion body → { ok: true } + * - 401 → { ok: false, error: "auth_failed" } + * - fetch throws (network/timeout) → { ok: false, error: "connection_failed" } + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { validateEndpoint } from "../src/validator.js"; + +const URL = "https://byom.example.com"; +const KEY = "sk-test-key"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("validateEndpoint (REQ-007)", () => { + it("returns ok:true on a 200 with a valid chat completion body", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ + id: "chatcmpl-1", + choices: [{ index: 0, message: { role: "assistant", content: "pong" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + ), + ); + const res = await validateEndpoint(URL, KEY); + expect(res.ok).toBe(true); + expect(res.error).toBeUndefined(); + + // Verify it POSTed to /v1/chat/completions with the bearer key. + expect(fetch).toHaveBeenCalledTimes(1); + const [calledUrl, init] = (fetch as unknown as ReturnType).mock.calls[0] as [ + string, + RequestInit, + ]; + expect(calledUrl).toBe(`${URL}/v1/chat/completions`); + expect(init.method).toBe("POST"); + expect((init.headers as Record).Authorization).toBe(`Bearer ${KEY}`); + }); + + it("returns auth_failed on 401", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("unauthorized", { status: 401 })), + ); + const res = await validateEndpoint(URL, KEY); + expect(res.ok).toBe(false); + expect(res.error).toBe("auth_failed"); + expect(res.detail).toBeTruthy(); + }); + + it("returns auth_failed on 403", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("forbidden", { status: 403 })), + ); + const res = await validateEndpoint(URL, KEY); + expect(res.ok).toBe(false); + expect(res.error).toBe("auth_failed"); + }); + + it("returns connection_failed when fetch throws", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ENOTFOUND"))); + const res = await validateEndpoint(URL, KEY); + expect(res.ok).toBe(false); + expect(res.error).toBe("connection_failed"); + expect(res.detail).toContain("ENOTFOUND"); + }); + + it("returns connection_failed on 5xx", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("bad gateway", { status: 502 })), + ); + const res = await validateEndpoint(URL, KEY); + expect(res.ok).toBe(false); + expect(res.error).toBe("connection_failed"); + }); + + it("returns invalid_response on 200 with a non-chat-completion body", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({ hello: "world" })), + ); + const res = await validateEndpoint(URL, KEY); + expect(res.ok).toBe(false); + expect(res.error).toBe("invalid_response"); + }); + + it("returns invalid_response on 200 with non-JSON body", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("not json", { status: 200 })), + ); + const res = await validateEndpoint(URL, KEY); + expect(res.ok).toBe(false); + expect(res.error).toBe("invalid_response"); + }); + + it("tolerates a configured URL that already ends in /v1", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({ choices: [] })), + ); + await validateEndpoint(`${URL}/v1`, KEY); + const [calledUrl] = (fetch as unknown as ReturnType).mock.calls[0] as [string]; + expect(calledUrl).toBe(`${URL}/v1/chat/completions`); + }); +}); \ No newline at end of file diff --git a/packages/byom/tsconfig.json b/packages/byom/tsconfig.json new file mode 100644 index 0000000..2a16c3f --- /dev/null +++ b/packages/byom/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "tests", "node_modules"] +} \ No newline at end of file diff --git a/packages/byom/vitest.config.ts b/packages/byom/vitest.config.ts new file mode 100644 index 0000000..3e30127 --- /dev/null +++ b/packages/byom/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + testTimeout: 30000, + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/index.ts", "tests/**"], + reporter: ["text", "json"], + }, + }, +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72fe414..a708d11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: apps/control-plane: dependencies: + '@coreci/byom': + specifier: workspace:* + version: link:../../packages/byom '@coreci/config': specifier: workspace:* version: link:../../packages/config @@ -52,6 +55,34 @@ importers: specifier: ^2.1.0 version: 2.1.9(@types/node@22.20.1)(lightningcss@1.33.0)(supports-color@7.2.0) + packages/byom: + dependencies: + '@coreci/config': + specifier: workspace:* + version: link:../config + '@coreci/db': + specifier: workspace:* + version: link:../db + '@coreci/secrets': + specifier: workspace:* + version: link:../secrets + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + eslint: + specifier: ^9.0.0 + version: 9.39.5(supports-color@7.2.0) + tsx: + specifier: ^4.19.0 + version: 4.23.12 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@22.20.1)(lightningcss@1.33.0)(supports-color@7.2.0) + packages/config: devDependencies: '@types/node':