docs(api): OpenAPI spec, Redoc at /docs, CI spec-lint

- docs/openapi.yaml: the public, token-reachable surface only, with the auth model
  (Bearer st_) and a per-operation x-required-scope (read<write<full). JWT-only routers
  are excluded by design.
- Serve /openapi.yaml + /docs (Redoc via a vendored standalone bundle, no CDN so it
  works air-gapped; /docs is CSP-exempt). docs/ is bundled into the release tarball.
- CI: redocly lint + a public-only guard that fails loudly if a JWT-only path ever leaks
  into the spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-12 13:34:32 -05:00 committed by screentinker
parent dce0d22763
commit c1b9c27f3a
6 changed files with 3619 additions and 1 deletions

View file

@ -33,6 +33,27 @@ jobs:
- run: npm ci
- run: npm test
openapi:
name: OpenAPI spec lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
- name: Lint the public API spec
run: npx --yes @redocly/cli@latest lint docs/openapi.yaml
# Contract integrity: the spec documents ONLY the token-reachable public surface.
# A JWT-only router (admin/auth/provision/...) appearing here is a security flag,
# not a convenience - fail loudly. (The runtime partition test is a separate suite
# that will cross-check the spec against the live mount list.)
- name: Assert spec is public-only
run: |
BAD=$(grep -oE '^ /(admin|auth|workspaces|ai|provision|white-label|status|subscription|stripe|teams|player-debug|contact|tokens)\b' docs/openapi.yaml || true)
if [ -n "$BAD" ]; then echo "::error::JWT-only path(s) leaked into the public spec:"; echo "$BAD"; exit 1; fi
if grep -qE 'unassigned|/prune' docs/openapi.yaml; then echo "::error::token-denied endpoint present in public spec"; exit 1; fi
echo "OK: spec is public-only"
android-test:
name: Android unit tests (Kotlin schedule evaluator vectors)
runs-on: ubuntu-latest

View file

@ -87,7 +87,7 @@ jobs:
--exclude='*.db' --exclude='*.db-wal' --exclude='*.db-shm' --exclude='*.db.*' \
--exclude='server/uploads' --exclude='server/certs' --exclude='server/test' \
--exclude='*.apk' \
server frontend scripts VERSION README.md LICENSE .env.example ScreenTinker.wgt
server frontend scripts docs VERSION README.md LICENSE .env.example ScreenTinker.wgt
echo "TARBALL=$OUT" >> "$GITHUB_ENV"
ls -la "$OUT"

1728
docs/openapi.yaml Normal file

File diff suppressed because it is too large Load diff

17
frontend/api-docs.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>ScreenTinker API Reference</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="description" content="ScreenTinker public API reference"/>
<style>body { margin: 0; padding: 0; }</style>
</head>
<body>
<!-- Self-hosted Redoc: the spec is served at /openapi.yaml and the Redoc bundle is
vendored locally (no CDN) so the docs work on an offline/air-gapped instance.
The <redoc> element auto-initialises from the standalone bundle. -->
<redoc spec-url="/openapi.yaml"></redoc>
<script src="/vendor/redoc.standalone.js"></script>
</body>
</html>

1838
frontend/vendor/redoc.standalone.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -99,6 +99,7 @@ app.use(helmet({
app.use((req, res, next) => {
if (req.path === '/' || req.path === '/landing.html') return next();
if (req.path.startsWith('/player')) return next();
if (req.path === '/docs') return next(); // Redoc API reference needs a relaxed CSP
if (req.path.startsWith('/api/widgets/') && req.path.endsWith('/render')) return next();
if (req.path.startsWith('/api/kiosk/') && req.path.endsWith('/render')) return next();
return dashboardCsp(req, res, next);
@ -188,6 +189,19 @@ app.get('/robots.txt', (req, res) => {
res.sendFile(path.join(config.frontendDir, 'robots.txt'));
});
// Public API reference. /openapi.yaml is the machine-readable contract (served from
// docs/); /docs is the Redoc viewer (frontend/api-docs.html + the vendored standalone
// bundle under /vendor, no CDN so it works air-gapped). /docs is CSP-exempt above
// because Redoc needs a relaxed policy.
app.get('/openapi.yaml', (req, res) => {
res.type('text/yaml');
res.setHeader('Cache-Control', 'public, max-age=300');
res.sendFile(path.join(__dirname, '..', 'docs', 'openapi.yaml'));
});
app.get('/docs', (req, res) => {
res.sendFile(path.join(config.frontendDir, 'api-docs.html'));
});
// Serve frontend static files
// JS/CSS/HTML: no-cache (always revalidate, uses ETag/304)
// Images/fonts/icons: long cache for Cloudflare + browser