mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(agent-readiness): RFC 9727 API catalog + native openapi.yaml serve (#3343)
* feat(agent-readiness): RFC 9727 API catalog + native openapi.yaml serve Closes #3309, part of epic #3306. - Serves the sebuf-bundled OpenAPI spec natively at https://www.worldmonitor.app/openapi.yaml with correct application/yaml content-type (no Mintlify proxy hop). Build-time copy from docs/api/worldmonitor.openapi.yaml. - Publishes RFC 9727 API catalog at /.well-known/api-catalog with service-desc pointing at the native URL, status rel pointing at /api/health, and a separate anchor for the MCP endpoint referencing its SEP-1649 card (#3311). Refs PR #3341 (sebuf v0.11.1 bundle landed). * test(deploy-config): update SPA catch-all regex assertion The deploy-config guardrail hard-codes the SPA catch-all regex string and asserts its Cache-Control is no-cache. The prior commit added openapi.yaml to the exclusion list; this updates the test to match so the guardrail continues to protect HTML entry caching. * fix(agent-readiness): address Greptile review on PR #3343 - Extract openapi.yaml copy into named script `build:openapi` and prefix every web-variant build (build:full/tech/finance/happy/ commodity). prebuild delegates to the same script so the default `npm run build` path is unchanged. Swap shell `cp` for Node's cpSync for cross-platform safety. - Bump service-desc MIME type in /.well-known/api-catalog from application/yaml to application/vnd.oai.openapi (IANA-registered OpenAPI media type). Endpoint Content-Type stays application/yaml for browser/tool compatibility. * fix(agent-readiness): P1 health href + guardrail tests on PR #3343 - status.href in /.well-known/api-catalog was pointing at https://api.worldmonitor.app/health (which serves the SPA HTML, not a health response). Corrected to /api/health, which returns the real {"status":"HEALTHY",...} JSON from api/health.js. - Extend tests/deploy-config.test.mjs with assertions that would have caught this regression: linkset structure, status/service- desc href shapes, and presence of build:openapi across every web-variant build script.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -69,6 +69,9 @@ scripts/rebuild-military-bases.mjs
|
||||
# Build artifacts (generated by esbuild/tsc, not source code)
|
||||
api/data/city-coords.js
|
||||
|
||||
# OpenAPI bundle copied at build time from docs/api/ for native Vercel serve
|
||||
/public/openapi.yaml
|
||||
|
||||
# Runtime artifacts (generated by sidecar/tools, not source code)
|
||||
api-cache.json
|
||||
verbose-mode.json
|
||||
|
||||
12
package.json
12
package.json
@@ -23,14 +23,16 @@
|
||||
"postinstall": "cd blog-site && npm ci --prefer-offline",
|
||||
"build:blog": "cd blog-site && npm run build && rm -rf ../public/blog && mkdir -p ../public/blog && cp -r dist/* ../public/blog/",
|
||||
"build:pro": "cd pro-test && npm install && npm run build",
|
||||
"build:openapi": "node -e \"require('fs').cpSync('docs/api/worldmonitor.openapi.yaml', 'public/openapi.yaml')\"",
|
||||
"prebuild": "npm run build:openapi",
|
||||
"build": "npm run build:blog && tsc && vite build",
|
||||
"build:sidecar-sebuf": "node scripts/build-sidecar-sebuf.mjs",
|
||||
"build:desktop": "node scripts/build-sidecar-sebuf.mjs && node scripts/build-sidecar-handlers.mjs && tsc && vite build",
|
||||
"build:full": "npm run build:blog && cross-env-shell VITE_VARIANT=full \"tsc && vite build\"",
|
||||
"build:tech": "cross-env-shell VITE_VARIANT=tech \"tsc && vite build\"",
|
||||
"build:finance": "cross-env-shell VITE_VARIANT=finance \"tsc && vite build\"",
|
||||
"build:happy": "cross-env-shell VITE_VARIANT=happy \"tsc && vite build\"",
|
||||
"build:commodity": "cross-env-shell VITE_VARIANT=commodity \"tsc && vite build\"",
|
||||
"build:full": "npm run build:openapi && npm run build:blog && cross-env-shell VITE_VARIANT=full \"tsc && vite build\"",
|
||||
"build:tech": "npm run build:openapi && cross-env-shell VITE_VARIANT=tech \"tsc && vite build\"",
|
||||
"build:finance": "npm run build:openapi && cross-env-shell VITE_VARIANT=finance \"tsc && vite build\"",
|
||||
"build:happy": "npm run build:openapi && cross-env-shell VITE_VARIANT=happy \"tsc && vite build\"",
|
||||
"build:commodity": "npm run build:openapi && cross-env-shell VITE_VARIANT=commodity \"tsc && vite build\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:api": "tsc --noEmit -p tsconfig.api.json",
|
||||
"typecheck:all": "tsc --noEmit && tsc --noEmit -p tsconfig.api.json",
|
||||
|
||||
34
public/.well-known/api-catalog
Normal file
34
public/.well-known/api-catalog
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"linkset": [
|
||||
{
|
||||
"anchor": "https://api.worldmonitor.app/",
|
||||
"service-desc": [
|
||||
{
|
||||
"href": "https://www.worldmonitor.app/openapi.yaml",
|
||||
"type": "application/vnd.oai.openapi"
|
||||
}
|
||||
],
|
||||
"service-doc": [
|
||||
{
|
||||
"href": "https://www.worldmonitor.app/docs/documentation",
|
||||
"type": "text/html"
|
||||
}
|
||||
],
|
||||
"status": [
|
||||
{
|
||||
"href": "https://api.worldmonitor.app/api/health",
|
||||
"type": "application/json"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"anchor": "https://worldmonitor.app/mcp",
|
||||
"service-desc": [
|
||||
{
|
||||
"href": "https://worldmonitor.app/.well-known/mcp/server-card.json",
|
||||
"type": "application/json"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -16,7 +16,7 @@ const getCacheHeaderValue = (sourcePath) => {
|
||||
|
||||
describe('deploy/cache configuration guardrails', () => {
|
||||
it('disables caching for HTML entry routes on Vercel', () => {
|
||||
const spaNoCache = getCacheHeaderValue('/((?!api|mcp|oauth|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|\\.well-known|wm-widget-sandbox\\.html).*)');
|
||||
const spaNoCache = getCacheHeaderValue('/((?!api|mcp|oauth|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|openapi\\.yaml|\\.well-known|wm-widget-sandbox\\.html).*)');
|
||||
assert.equal(spaNoCache, 'no-cache, no-store, must-revalidate');
|
||||
});
|
||||
|
||||
@@ -281,6 +281,101 @@ describe('brief magazine CSP override', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Agent readiness: RFC 9727 API catalog at /.well-known/api-catalog and
|
||||
// the build-time copy of the OpenAPI spec from docs/api/ into public/.
|
||||
// These guardrails protect against:
|
||||
// (1) the status endpoint href drifting away from /api/health (the
|
||||
// real JSON endpoint; the apex /health serves the SPA HTML);
|
||||
// (2) variant build scripts dropping the `npm run build:openapi`
|
||||
// prefix and silently shipping web bundles without the spec;
|
||||
// (3) the openapi source under docs/ being deleted without a
|
||||
// matching removal of the build step.
|
||||
describe('agent readiness: api-catalog + openapi build', () => {
|
||||
const apiCatalog = JSON.parse(
|
||||
readFileSync(resolve(__dirname, '../public/.well-known/api-catalog'), 'utf-8')
|
||||
);
|
||||
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
|
||||
|
||||
it('api anchor is first and points at the api host root', () => {
|
||||
assert.equal(apiCatalog.linkset[0].anchor, 'https://api.worldmonitor.app/');
|
||||
});
|
||||
|
||||
it('status href points at /api/health (SPA lives at /health — would 200 HTML and look healthy)', () => {
|
||||
const statusHref = apiCatalog.linkset[0].status[0].href;
|
||||
assert.ok(
|
||||
statusHref.startsWith('https://api.worldmonitor.app'),
|
||||
`status href must be on api.worldmonitor.app, got: ${statusHref}`
|
||||
);
|
||||
assert.ok(
|
||||
statusHref.endsWith('/api/health'),
|
||||
`status href must end with /api/health (real JSON endpoint), got: ${statusHref}`
|
||||
);
|
||||
});
|
||||
|
||||
it('service-desc points at /openapi.yaml with the OpenAPI media type', () => {
|
||||
const serviceDesc = apiCatalog.linkset[0]['service-desc'][0];
|
||||
assert.ok(
|
||||
serviceDesc.href.endsWith('/openapi.yaml'),
|
||||
`service-desc href must end with /openapi.yaml, got: ${serviceDesc.href}`
|
||||
);
|
||||
assert.equal(serviceDesc.type, 'application/vnd.oai.openapi');
|
||||
});
|
||||
|
||||
it('has a second anchor for the MCP server-card', () => {
|
||||
const mcpEntry = apiCatalog.linkset.find((entry) => entry.anchor === 'https://worldmonitor.app/mcp');
|
||||
assert.ok(mcpEntry, 'linkset must contain an anchor for https://worldmonitor.app/mcp');
|
||||
const mcpServiceDesc = mcpEntry['service-desc']?.[0];
|
||||
assert.ok(mcpServiceDesc, 'mcp anchor must have a service-desc entry');
|
||||
assert.ok(
|
||||
mcpServiceDesc.href.endsWith('/.well-known/mcp/server-card.json'),
|
||||
`mcp service-desc href must end with /.well-known/mcp/server-card.json, got: ${mcpServiceDesc.href}`
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes a build:openapi script that copies docs/api → public/openapi.yaml', () => {
|
||||
const buildOpenapi = pkg.scripts['build:openapi'];
|
||||
assert.ok(buildOpenapi, 'package.json must define scripts["build:openapi"]');
|
||||
assert.ok(
|
||||
buildOpenapi.includes('docs/api/worldmonitor.openapi.yaml'),
|
||||
`build:openapi must reference docs/api/worldmonitor.openapi.yaml, got: ${buildOpenapi}`
|
||||
);
|
||||
assert.ok(
|
||||
buildOpenapi.includes('public/openapi.yaml'),
|
||||
`build:openapi must write to public/openapi.yaml, got: ${buildOpenapi}`
|
||||
);
|
||||
});
|
||||
|
||||
it('every web-variant build chains npm run build:openapi', () => {
|
||||
// build:desktop and build:pro are intentionally excluded — Tauri
|
||||
// sidecar builds and the standalone pro-test workspace don't ship
|
||||
// the OpenAPI spec.
|
||||
const webVariants = ['build:full', 'build:tech', 'build:finance', 'build:happy', 'build:commodity'];
|
||||
for (const variant of webVariants) {
|
||||
const script = pkg.scripts[variant];
|
||||
assert.ok(script, `package.json must define scripts["${variant}"]`);
|
||||
assert.ok(
|
||||
script.includes('npm run build:openapi'),
|
||||
`scripts["${variant}"] must chain "npm run build:openapi" so the web bundle ships the spec; got: ${script}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a prebuild hook so the default `npm run build` path also copies the spec', () => {
|
||||
assert.ok(pkg.scripts.prebuild, 'package.json must define scripts["prebuild"] (default build path uses it)');
|
||||
});
|
||||
|
||||
it('openapi source exists at docs/api/worldmonitor.openapi.yaml', () => {
|
||||
// Catches the class of regression where someone cleans generated
|
||||
// artifacts and forgets to regenerate before committing — the
|
||||
// prebuild step would then fail silently at deploy time.
|
||||
const openapiPath = resolve(__dirname, '../docs/api/worldmonitor.openapi.yaml');
|
||||
assert.ok(
|
||||
existsSync(openapiPath),
|
||||
`docs/api/worldmonitor.openapi.yaml must exist — without it, build:openapi fails at deploy time`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// PR history: #3204 / #3206 forced the resvg linux-x64-gnu native
|
||||
// binding into the carousel function via vercel.json
|
||||
// `functions.includeFiles`. That entire workaround became unnecessary
|
||||
|
||||
20
vercel.json
20
vercel.json
@@ -12,7 +12,7 @@
|
||||
{ "source": "/oauth/token", "destination": "/api/oauth/token" },
|
||||
{ "source": "/oauth/register", "destination": "/api/oauth/register" },
|
||||
{ "source": "/oauth/authorize", "destination": "/api/oauth/authorize" },
|
||||
{ "source": "/((?!api|mcp|oauth|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|\\.well-known|wm-widget-sandbox\\.html).*)", "destination": "/index.html" }
|
||||
{ "source": "/((?!api|mcp|oauth|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|openapi\\.yaml|\\.well-known|wm-widget-sandbox\\.html).*)", "destination": "/index.html" }
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
@@ -55,6 +55,14 @@
|
||||
{ "key": "Cache-Control", "value": "public, max-age=3600" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/.well-known/api-catalog",
|
||||
"headers": [
|
||||
{ "key": "Content-Type", "value": "application/linkset+json" },
|
||||
{ "key": "Access-Control-Allow-Origin", "value": "*" },
|
||||
{ "key": "Cache-Control", "value": "public, max-age=3600" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/.well-known/(.*)",
|
||||
"headers": [
|
||||
@@ -62,6 +70,14 @@
|
||||
{ "key": "Cache-Control", "value": "public, max-age=3600" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/openapi.yaml",
|
||||
"headers": [
|
||||
{ "key": "Content-Type", "value": "application/yaml; charset=utf-8" },
|
||||
{ "key": "Access-Control-Allow-Origin", "value": "*" },
|
||||
{ "key": "Cache-Control", "value": "public, max-age=3600" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/docs/:path*",
|
||||
"headers": [
|
||||
@@ -112,7 +128,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/((?!api|mcp|oauth|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|\\.well-known|wm-widget-sandbox\\.html).*)",
|
||||
"source": "/((?!api|mcp|oauth|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|openapi\\.yaml|\\.well-known|wm-widget-sandbox\\.html).*)",
|
||||
"headers": [
|
||||
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user