Merge phase/03-byom into milestone/v0.1-bootstrap
Wave C (BYOM) complete. ---ci--- phase: 3 milestone: v0.1 status: complete ---/ci--- # Conflicts: # apps/control-plane/package.json # apps/control-plane/tsconfig.json # pnpm-lock.yaml
This commit is contained in:
@@ -21,6 +21,7 @@ out/
|
||||
|
||||
# local secrets store (dev fallback for SecretProvider)
|
||||
.secrets/
|
||||
.secrets-test/
|
||||
|
||||
# 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 },
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@coreci/db", "@coreci/auth", "@coreci/audit", "@coreci/secrets", "@coreci/config", "@coreci/runtime"],
|
||||
transpilePackages: ["@coreci/db", "@coreci/auth", "@coreci/audit", "@coreci/secrets", "@coreci/config", "@coreci/runtime", "@coreci/byom"],
|
||||
};
|
||||
export default nextConfig;
|
||||
@@ -13,18 +13,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@coreci/auth": "workspace:*",
|
||||
"@coreci/byom": "workspace:*",
|
||||
"@coreci/config": "workspace:*",
|
||||
"@coreci/db": "workspace:*",
|
||||
"@coreci/runtime": "workspace:*",
|
||||
"@coreci/secrets": "workspace:*",
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
|
||||
@@ -3,16 +3,10 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"jsx": "preserve",
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["node", "@types/react"],
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
|
||||
@@ -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
+58
@@ -17,6 +17,9 @@ importers:
|
||||
'@coreci/auth':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/auth
|
||||
'@coreci/byom':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/byom
|
||||
'@coreci/config':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/config
|
||||
@@ -38,6 +41,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.8(react@19.2.8)
|
||||
ws:
|
||||
specifier: ^8.18.0
|
||||
version: 8.21.3
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
@@ -48,6 +54,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.5(@types/react@19.2.18)
|
||||
'@types/ws':
|
||||
specifier: ^8.5.0
|
||||
version: 8.18.1
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.9.3
|
||||
@@ -83,6 +92,34 @@ importers:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.9(@types/node@22.20.1)(lightningcss@1.33.0)(supports-color@7.2.0)
|
||||
|
||||
packages/byom:
|
||||
dependencies:
|
||||
'@coreci/config':
|
||||
specifier: workspace:*
|
||||
version: link:../config
|
||||
'@coreci/db':
|
||||
specifier: workspace:*
|
||||
version: link:../db
|
||||
'@coreci/secrets':
|
||||
specifier: workspace:*
|
||||
version: link:../secrets
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.20.1
|
||||
eslint:
|
||||
specifier: ^9.0.0
|
||||
version: 9.39.5(supports-color@7.2.0)
|
||||
tsx:
|
||||
specifier: ^4.19.0
|
||||
version: 4.23.12
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.9(@types/node@22.20.1)(lightningcss@1.33.0)(supports-color@7.2.0)
|
||||
|
||||
packages/config:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
@@ -1194,6 +1231,9 @@ packages:
|
||||
'@types/react@19.2.18':
|
||||
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@vitest/coverage-v8@2.1.9':
|
||||
resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==}
|
||||
peerDependencies:
|
||||
@@ -2111,6 +2151,18 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.21.3:
|
||||
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xmlhttprequest-ssl@2.0.0:
|
||||
resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -2999,6 +3051,10 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 22.20.1
|
||||
|
||||
'@vitest/coverage-v8@2.1.9(supports-color@7.2.0)(vitest@2.1.9(@types/node@22.20.1)(lightningcss@1.33.0)(supports-color@7.2.0))':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
@@ -3957,6 +4013,8 @@ snapshots:
|
||||
|
||||
ws@8.17.1: {}
|
||||
|
||||
ws@8.21.3: {}
|
||||
|
||||
xmlhttprequest-ssl@2.0.0: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
Reference in New Issue
Block a user