Harden API contracts with request IDs and audit telemetry
Some checks are pending
CI / test (push) Waiting to run
Some checks are pending
CI / test (push) Waiting to run
This commit is contained in:
parent
e2445980f9
commit
31a47346ea
@ -25,6 +25,7 @@ Open:
|
|||||||
|
|
||||||
Version metadata:
|
Version metadata:
|
||||||
- REST and MCP tool responses include `api_version` and `schema_version`.
|
- REST and MCP tool responses include `api_version` and `schema_version`.
|
||||||
|
- REST responses also include `request_id` and `x-request-id` for request correlation.
|
||||||
- Current values: `api_version=0.3.0`, `schema_version=1.0.0`.
|
- Current values: `api_version=0.3.0`, `schema_version=1.0.0`.
|
||||||
- Compatibility policy (current): additive, backward-compatible fields may be introduced in the same API minor version.
|
- Compatibility policy (current): additive, backward-compatible fields may be introduced in the same API minor version.
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ Docs:
|
|||||||
- `docs/operations-runbook.md`
|
- `docs/operations-runbook.md`
|
||||||
- `docs/quality-gates.md`
|
- `docs/quality-gates.md`
|
||||||
- `docs/phase4-execution-plan.md`
|
- `docs/phase4-execution-plan.md`
|
||||||
|
- `docs/api-mcp-contracts.md`
|
||||||
|
|
||||||
CI:
|
CI:
|
||||||
- `.gitea/workflows/ci.yml` runs syntax checks + full test suite on push/PR.
|
- `.gitea/workflows/ci.yml` runs syntax checks + full test suite on push/PR.
|
||||||
|
|||||||
68
docs/api-mcp-contracts.md
Normal file
68
docs/api-mcp-contracts.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# API + MCP Contract Policy
|
||||||
|
|
||||||
|
This document defines compatibility expectations for Schemeta API and MCP tools.
|
||||||
|
|
||||||
|
## Version Fields
|
||||||
|
|
||||||
|
All successful API/MCP structured responses include:
|
||||||
|
|
||||||
|
- `api_version`
|
||||||
|
- `schema_version`
|
||||||
|
|
||||||
|
HTTP API responses also include:
|
||||||
|
|
||||||
|
- `request_id` (response field + `x-request-id` header)
|
||||||
|
|
||||||
|
## Compatibility Policy
|
||||||
|
|
||||||
|
- Additive changes are allowed within the same `api_version` minor release.
|
||||||
|
- Breaking shape changes require a new API major version.
|
||||||
|
- `schema_version` increments when SJM schema semantics or required fields change.
|
||||||
|
|
||||||
|
## HTTP API Contracts
|
||||||
|
|
||||||
|
Stable endpoints:
|
||||||
|
|
||||||
|
- `POST /compile`
|
||||||
|
- `POST /analyze`
|
||||||
|
- `POST /layout/auto`
|
||||||
|
- `POST /layout/tidy`
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /mcp/ui-bundle`
|
||||||
|
|
||||||
|
Error envelope shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"request_id": "uuid-or-forwarded-id",
|
||||||
|
"error": {
|
||||||
|
"code": "stable_machine_code",
|
||||||
|
"message": "human-readable description"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Tool Contracts
|
||||||
|
|
||||||
|
Stable tool names:
|
||||||
|
|
||||||
|
- `schemeta_compile`
|
||||||
|
- `schemeta_analyze`
|
||||||
|
- `schemeta_ui_bundle`
|
||||||
|
|
||||||
|
Contract rules:
|
||||||
|
|
||||||
|
- Tool names are stable across minor versions.
|
||||||
|
- Input schemas may gain optional fields additively.
|
||||||
|
- Structured response fields are additive-only within a minor.
|
||||||
|
|
||||||
|
## Persistence and Restore
|
||||||
|
|
||||||
|
UI workspace persistence is expected to support:
|
||||||
|
|
||||||
|
- deterministic JSON export/import roundtrip
|
||||||
|
- local snapshot restore path (`schemeta:snapshots:v2`)
|
||||||
|
- reset-to-sample baseline recovery for QA loops
|
||||||
|
|
||||||
|
These are validated in release checklist + browser regression flow.
|
||||||
@ -35,6 +35,18 @@ This runbook covers baseline production operation for Schemeta API + UI.
|
|||||||
- `GET /mcp/ui-bundle`
|
- `GET /mcp/ui-bundle`
|
||||||
- Metadata for MCP UI embedding.
|
- Metadata for MCP UI embedding.
|
||||||
|
|
||||||
|
## Request Correlation and Audit Logs
|
||||||
|
|
||||||
|
- Every response includes `x-request-id`.
|
||||||
|
- API envelopes include `request_id` for correlation in clients and logs.
|
||||||
|
- Server emits one JSON audit log entry per request on response finish with:
|
||||||
|
- `request_id`
|
||||||
|
- `method`
|
||||||
|
- `path`
|
||||||
|
- `status`
|
||||||
|
- `duration_ms`
|
||||||
|
- `client`
|
||||||
|
|
||||||
## Production Checks
|
## Production Checks
|
||||||
|
|
||||||
1. Verify process liveness:
|
1. Verify process liveness:
|
||||||
@ -78,6 +90,6 @@ This runbook covers baseline production operation for Schemeta API + UI.
|
|||||||
|
|
||||||
## Observability Recommendations
|
## Observability Recommendations
|
||||||
|
|
||||||
- Add structured request logs at reverse proxy layer.
|
- Structured request logs are emitted by the app; keep proxy logs for edge-level traces.
|
||||||
- Track latency percentiles for `/compile` and `/analyze`.
|
- Track latency percentiles for `/compile` and `/analyze`.
|
||||||
- Track per-endpoint status code rates and top warning/error IDs.
|
- Track per-endpoint status code rates and top warning/error IDs.
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { extname, join, normalize } from "node:path";
|
import { extname, join, normalize } from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
@ -38,14 +39,18 @@ function json(res, status, payload) {
|
|||||||
res.end(JSON.stringify(payload, null, 2));
|
res.end(JSON.stringify(payload, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function errorEnvelope(code, message) {
|
export function errorEnvelope(code, message, details = {}) {
|
||||||
return {
|
const out = {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: {
|
error: {
|
||||||
code,
|
code,
|
||||||
message
|
message
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if (details && typeof details === "object") {
|
||||||
|
Object.assign(out, details);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuthorizedRequest(req) {
|
export function isAuthorizedRequest(req) {
|
||||||
@ -149,11 +154,32 @@ export function parsePayloadOptions(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function withEnvelopeMeta(payload) {
|
export function withEnvelopeMeta(payload) {
|
||||||
return {
|
const out = {
|
||||||
api_version: API_VERSION,
|
api_version: API_VERSION,
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
...payload
|
...payload
|
||||||
};
|
};
|
||||||
|
if (!out.request_id && payload?.request_id) {
|
||||||
|
out.request_id = payload.request_id;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestIdFrom(req) {
|
||||||
|
const headerId = req.headers?.["x-request-id"];
|
||||||
|
if (typeof headerId === "string" && headerId.trim()) {
|
||||||
|
return headerId.trim().slice(0, 128);
|
||||||
|
}
|
||||||
|
return randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditLog(entry) {
|
||||||
|
const line = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
service: "schemeta",
|
||||||
|
...entry
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify(line));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientKey(req) {
|
function clientKey(req) {
|
||||||
@ -194,8 +220,22 @@ function withinRateLimit(req) {
|
|||||||
|
|
||||||
export function createRequestHandler() {
|
export function createRequestHandler() {
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
|
const requestId = requestIdFrom(req);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
res.setHeader("x-request-id", requestId);
|
||||||
|
res.on("finish", () => {
|
||||||
|
auditLog({
|
||||||
|
request_id: requestId,
|
||||||
|
method: req.method,
|
||||||
|
path: req.url,
|
||||||
|
status: res.statusCode,
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
client: clientKey(req)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (!req.url || !req.method) {
|
if (!req.url || !req.method) {
|
||||||
return json(res, 400, errorEnvelope("invalid_request", "Invalid request."));
|
return json(res, 400, errorEnvelope("invalid_request", "Invalid request.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathname = new URL(req.url, "http://localhost").pathname;
|
const pathname = new URL(req.url, "http://localhost").pathname;
|
||||||
@ -211,6 +251,7 @@ export function createRequestHandler() {
|
|||||||
return json(res, 200, {
|
return json(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
service: "schemeta",
|
service: "schemeta",
|
||||||
|
request_id: requestId,
|
||||||
api_version: API_VERSION,
|
api_version: API_VERSION,
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
status: "ok",
|
status: "ok",
|
||||||
@ -221,6 +262,7 @@ export function createRequestHandler() {
|
|||||||
if (req.method === "GET" && pathname === "/mcp/ui-bundle") {
|
if (req.method === "GET" && pathname === "/mcp/ui-bundle") {
|
||||||
return json(res, 200, {
|
return json(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
request_id: requestId,
|
||||||
name: "schemeta-workspace",
|
name: "schemeta-workspace",
|
||||||
version: "0.2.0",
|
version: "0.2.0",
|
||||||
api_version: API_VERSION,
|
api_version: API_VERSION,
|
||||||
@ -234,56 +276,56 @@ export function createRequestHandler() {
|
|||||||
if (req.method === "POST" && pathname === "/analyze") {
|
if (req.method === "POST" && pathname === "/analyze") {
|
||||||
if (!isAuthorizedRequest(req)) {
|
if (!isAuthorizedRequest(req)) {
|
||||||
res.setHeader("WWW-Authenticate", "Bearer");
|
res.setHeader("WWW-Authenticate", "Bearer");
|
||||||
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token."));
|
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
if (!withinRateLimit(req)) {
|
if (!withinRateLimit(req)) {
|
||||||
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."));
|
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const parsed = parsePayloadOptions(body);
|
const parsed = parsePayloadOptions(body);
|
||||||
return json(res, 200, withEnvelopeMeta(analyze(parsed.payload, parsed.options)));
|
return json(res, 200, withEnvelopeMeta({ request_id: requestId, ...analyze(parsed.payload, parsed.options) }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
||||||
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`));
|
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`, { request_id: requestId }));
|
||||||
}
|
}
|
||||||
if (err?.code === "INVALID_JSON") {
|
if (err?.code === "INVALID_JSON") {
|
||||||
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload."));
|
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
return json(res, 500, errorEnvelope("internal_error", "Request failed."));
|
return json(res, 500, errorEnvelope("internal_error", "Request failed.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && pathname === "/compile") {
|
if (req.method === "POST" && pathname === "/compile") {
|
||||||
if (!isAuthorizedRequest(req)) {
|
if (!isAuthorizedRequest(req)) {
|
||||||
res.setHeader("WWW-Authenticate", "Bearer");
|
res.setHeader("WWW-Authenticate", "Bearer");
|
||||||
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token."));
|
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
if (!withinRateLimit(req)) {
|
if (!withinRateLimit(req)) {
|
||||||
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."));
|
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const parsed = parsePayloadOptions(body);
|
const parsed = parsePayloadOptions(body);
|
||||||
return json(res, 200, withEnvelopeMeta(compile(parsed.payload, parsed.options)));
|
return json(res, 200, withEnvelopeMeta({ request_id: requestId, ...compile(parsed.payload, parsed.options) }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
||||||
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`));
|
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`, { request_id: requestId }));
|
||||||
}
|
}
|
||||||
if (err?.code === "INVALID_JSON") {
|
if (err?.code === "INVALID_JSON") {
|
||||||
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload."));
|
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
return json(res, 500, errorEnvelope("internal_error", "Request failed."));
|
return json(res, 500, errorEnvelope("internal_error", "Request failed.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && pathname === "/layout/auto") {
|
if (req.method === "POST" && pathname === "/layout/auto") {
|
||||||
if (!isAuthorizedRequest(req)) {
|
if (!isAuthorizedRequest(req)) {
|
||||||
res.setHeader("WWW-Authenticate", "Bearer");
|
res.setHeader("WWW-Authenticate", "Bearer");
|
||||||
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token."));
|
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
if (!withinRateLimit(req)) {
|
if (!withinRateLimit(req)) {
|
||||||
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."));
|
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
@ -293,6 +335,7 @@ export function createRequestHandler() {
|
|||||||
const laidOut = applyLayoutToModel(model, { respectLocks: false });
|
const laidOut = applyLayoutToModel(model, { respectLocks: false });
|
||||||
return json(res, 200, {
|
return json(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
request_id: requestId,
|
||||||
api_version: API_VERSION,
|
api_version: API_VERSION,
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
model: laidOut,
|
model: laidOut,
|
||||||
@ -300,22 +343,22 @@ export function createRequestHandler() {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
||||||
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`));
|
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`, { request_id: requestId }));
|
||||||
}
|
}
|
||||||
if (err?.code === "INVALID_JSON") {
|
if (err?.code === "INVALID_JSON") {
|
||||||
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload."));
|
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
return json(res, 500, errorEnvelope("internal_error", "Layout auto failed."));
|
return json(res, 500, errorEnvelope("internal_error", "Layout auto failed.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && pathname === "/layout/tidy") {
|
if (req.method === "POST" && pathname === "/layout/tidy") {
|
||||||
if (!isAuthorizedRequest(req)) {
|
if (!isAuthorizedRequest(req)) {
|
||||||
res.setHeader("WWW-Authenticate", "Bearer");
|
res.setHeader("WWW-Authenticate", "Bearer");
|
||||||
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token."));
|
return json(res, 401, errorEnvelope("unauthorized", "Missing or invalid API token.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
if (!withinRateLimit(req)) {
|
if (!withinRateLimit(req)) {
|
||||||
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."));
|
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
@ -325,6 +368,7 @@ export function createRequestHandler() {
|
|||||||
const laidOut = applyLayoutToModel(model, { respectLocks: true });
|
const laidOut = applyLayoutToModel(model, { respectLocks: true });
|
||||||
return json(res, 200, {
|
return json(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
request_id: requestId,
|
||||||
api_version: API_VERSION,
|
api_version: API_VERSION,
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
model: laidOut,
|
model: laidOut,
|
||||||
@ -332,12 +376,12 @@ export function createRequestHandler() {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
if (err?.code === "PAYLOAD_TOO_LARGE") {
|
||||||
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`));
|
return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`, { request_id: requestId }));
|
||||||
}
|
}
|
||||||
if (err?.code === "INVALID_JSON") {
|
if (err?.code === "INVALID_JSON") {
|
||||||
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload."));
|
return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
return json(res, 500, errorEnvelope("internal_error", "Layout tidy failed."));
|
return json(res, 500, errorEnvelope("internal_error", "Layout tidy failed.", { request_id: requestId }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,7 +392,7 @@ export function createRequestHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return json(res, 404, errorEnvelope("not_found", "Not found."));
|
return json(res, 404, errorEnvelope("not_found", "Not found.", { request_id: requestId }));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,11 +17,12 @@ import {
|
|||||||
|
|
||||||
test("REST compile contract shape is stable with version metadata", () => {
|
test("REST compile contract shape is stable with version metadata", () => {
|
||||||
const parsed = parsePayloadOptions({ payload: fixture, options: { render_mode: "schematic_stub" } });
|
const parsed = parsePayloadOptions({ payload: fixture, options: { render_mode: "schematic_stub" } });
|
||||||
const body = withRestEnvelopeMeta(compile(parsed.payload, parsed.options));
|
const body = withRestEnvelopeMeta({ request_id: "req-test-1", ...compile(parsed.payload, parsed.options) });
|
||||||
|
|
||||||
assert.equal(body.ok, true);
|
assert.equal(body.ok, true);
|
||||||
assert.equal(body.api_version, REST_API_VERSION);
|
assert.equal(body.api_version, REST_API_VERSION);
|
||||||
assert.equal(body.schema_version, REST_SCHEMA_VERSION);
|
assert.equal(body.schema_version, REST_SCHEMA_VERSION);
|
||||||
|
assert.equal(body.request_id, "req-test-1");
|
||||||
assert.ok(Array.isArray(body.errors));
|
assert.ok(Array.isArray(body.errors));
|
||||||
assert.ok(Array.isArray(body.warnings));
|
assert.ok(Array.isArray(body.warnings));
|
||||||
assert.ok(Array.isArray(body.bus_groups));
|
assert.ok(Array.isArray(body.bus_groups));
|
||||||
@ -42,10 +43,11 @@ test("REST analyze contract shape is stable with version metadata", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("REST error envelope exposes stable code/message fields", () => {
|
test("REST error envelope exposes stable code/message fields", () => {
|
||||||
const body = errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.");
|
const body = errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.", { request_id: "req-test-2" });
|
||||||
assert.equal(body.ok, false);
|
assert.equal(body.ok, false);
|
||||||
assert.equal(body.error.code, "rate_limited");
|
assert.equal(body.error.code, "rate_limited");
|
||||||
assert.equal(typeof body.error.message, "string");
|
assert.equal(typeof body.error.message, "string");
|
||||||
|
assert.equal(body.request_id, "req-test-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("MCP schemeta_compile returns structured content with version metadata", () => {
|
test("MCP schemeta_compile returns structured content with version metadata", () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user