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:
CIAgent
2026-08-25 01:54:20 +00:00
parent 3246442906
commit 3b8345dfe0
22 changed files with 1468 additions and 1 deletions
+1
View File
@@ -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;
}
+117
View File
@@ -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 },
);
}
+5
View File
@@ -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 -1
View File
@@ -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;
+1
View File
@@ -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:*",
+2
View File
@@ -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"],
+16
View File
@@ -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/**"],
},
);
+33
View File
@@ -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"
}
}
+39
View File
@@ -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";
+166
View File
@@ -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]);
}
+137
View File
@@ -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`;
}
+74
View File
@@ -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;
};
}
+130
View File
@@ -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;
}
+191
View File
@@ -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
});
});
});
+139
View File
@@ -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);
});
});
+122
View File
@@ -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`);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "tests", "node_modules"]
}
+15
View File
@@ -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"],
},
},
});
+31
View File
@@ -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':