feat(P3): Wave C BYOM — endpoint registry, validate-on-save, routing shim
REQ-006: configure BYOM endpoint (URL in DB, key in secret manager) REQ-007: validate-on-save test inference call (OpenAI-compatible) REQ-008: route all inference to BYOM (G-001: test-inference proxy endpoint) REQ-009: reject when unconfigured/unreachable (ByomUnconfiguredError/ByomUnreachableError) ---ci--- phase: 3 milestone: v0.1 status: execute ---/ci---
This commit is contained in:
@@ -21,6 +21,7 @@ out/
|
|||||||
|
|
||||||
# local secrets store (dev fallback for SecretProvider)
|
# local secrets store (dev fallback for SecretProvider)
|
||||||
.secrets/
|
.secrets/
|
||||||
|
.secrets-test/
|
||||||
|
|
||||||
# coverage
|
# coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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<ByomRuntime> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<NextResponse> {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = requireAdmin(req);
|
||||||
|
} catch (err) {
|
||||||
|
return toAuthResponse(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: ByomConfigRequest;
|
||||||
|
try {
|
||||||
|
const parsed = (await req.json()) as Partial<ByomConfigRequest>;
|
||||||
|
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<NextResponse> {
|
||||||
|
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<NextResponse> {
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<NextResponse> {
|
||||||
|
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<ChatCompletionRequest>;
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const 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;
|
export default nextConfig;
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@coreci/byom": "workspace:*",
|
||||||
"@coreci/db": "workspace:*",
|
"@coreci/db": "workspace:*",
|
||||||
"@coreci/secrets": "workspace:*",
|
"@coreci/secrets": "workspace:*",
|
||||||
"@coreci/config": "workspace:*",
|
"@coreci/config": "workspace:*",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"module": "ESNext",
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"types": ["node", "@types/react"],
|
"types": ["node", "@types/react"],
|
||||||
|
|||||||
@@ -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/**"],
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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<SaveEndpointResult> {
|
||||||
|
// 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<ByomEndpoint | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await c.query(`DELETE FROM byom_endpoints WHERE id = $1`, [endpointId]);
|
||||||
|
}
|
||||||
@@ -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<ChatCompletionResponse> {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<ByomValidationResult> {
|
||||||
|
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 `<base>/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<string, unknown>;
|
||||||
|
return Array.isArray(b.choices) && b.choices.length >= 0;
|
||||||
|
}
|
||||||
@@ -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<void> }): Promise<void> {
|
||||||
|
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<ReturnType<typeof createDb>>;
|
||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> }): Promise<void> {
|
||||||
|
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<typeof vi.fn> {
|
||||||
|
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<ReturnType<typeof createDb>>;
|
||||||
|
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<typeof vi.fn>).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<string, string>).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof vi.fn>).mock.calls[0] as [
|
||||||
|
string,
|
||||||
|
RequestInit,
|
||||||
|
];
|
||||||
|
expect(calledUrl).toBe(`${URL}/v1/chat/completions`);
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
expect((init.headers as Record<string, string>).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<typeof vi.fn>).mock.calls[0] as [string];
|
||||||
|
expect(calledUrl).toBe(`${URL}/v1/chat/completions`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["dist", "tests", "node_modules"]
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+31
@@ -14,6 +14,9 @@ importers:
|
|||||||
|
|
||||||
apps/control-plane:
|
apps/control-plane:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@coreci/byom':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/byom
|
||||||
'@coreci/config':
|
'@coreci/config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/config
|
version: link:../../packages/config
|
||||||
@@ -52,6 +55,34 @@ importers:
|
|||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.9(@types/node@22.20.1)(lightningcss@1.33.0)(supports-color@7.2.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:
|
packages/config:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
|
|||||||
Reference in New Issue
Block a user