Skip to content

Wire CORS to per-app allowed origins; fix and extend the compose demo - #1

Open
rvegajr wants to merge 2 commits into
mainfrom
feat/cors-and-demo-interrogation
Open

rvegajr wants to merge 2 commits into
mainfrom
feat/cors-and-demo-interrogation

Conversation

@rvegajr

@rvegajr rvegajr commented Sep 21, 2026

Copy link
Copy Markdown
Member

Why

AppEntity.AllowedOriginsJson has been written by the apps API since the initial schema, but nothing ever read it. The host emitted no CORS headers at all — grep -rn 'AddCors|UseCors|WithOrigins' over src/ returned nothing — so no browser client could call the data plane cross-origin, and a preflight got a bare 401 before any negotiation. That also blocked the "full try-it console" that ui/src/pages/Docs.tsx notes as a fast follow.

CORS (spec 08 §7)

  • Default deny — no origin match, no headers emitted.
  • Per-app allowed_origins resolved from the presented API key, cached 60 s by key hash (mirrors ApiKeyAuthenticationHandler).
  • Preflight is anonymous, so an unkeyed request falls back to instance-level Cors:DefaultAllowedOrigins (spec 12 §4).
  • Single-label wildcards — https://*.example.com matches tenant.example.com, but not a.b.example.com, not http://, and not the suffix forgery evilexample.com.
  • Access-Control-Expose-Headers carries the OData and RateLimit headers.

Ordering: the middleware runs before authentication (spec 02 §4 step 4) — a browser preflight carries no credentials by definition. It also sits ahead of the setup gate, so a preflight during first-run setup answers with headers instead of a bare 503.

AuthService.Sha256 becomes public. The middleware has to hash presented keys in exactly the auth handler's format; duplicating that constant invites a silent divergence that breaks origin lookup.

Demo

The compose demo assets were never committed. Two beats were broken:

  • demo.sh waited for status "Ready", which is not a ServiceStatus value. The loop always exhausted its 40×2s budget before printing the correct answer anyway — ~3 min → ~5 s.
  • The REST line printed n/a: the route is /api/rest/<svc>/_table/<table>, and rows come back under .resource.
  • The script's header pointed at the wrong base profile (docker-compose.yml); the overlay is written against docker-compose.minimal.yml.

New Beat 3 shows how to interrogate a service: discovery endpoints (service document, $metadata, per-table _schema including the PII mask flag), the same query over MCP, and a Swagger UI on :8081 browsing all four generated specs. openapi.json requires auth, so demo.sh exports the specs to ./specs (gitignored) and nginx serves them.

Testing

  • CorsTests — 14 tests: preflight allow/deny while anonymous, no-Origin passthrough, per-app origin honored for a keyed request, and a wildcard-matching theory covering the forgery cases. All pass.
  • Unit suite: 125/125.
  • Integration suite: 43/44. The one failure, SqlServerEndToEndTests.SqlServer_full_lifecycle, is pre-existing and environmental — Testcontainers can't start a SQL Server container here (Docker API ... container is not running); that test sends no Origin header, so the new middleware never engages.
  • Verified live against the running stack: allowed preflight returns 204 with Access-Control-Allow-Origin, denied origin returns no CORS headers, wildcard matching behaves as specified.

🤖 Generated with Claude Code

rvegajr and others added 2 commits September 21, 2026 13:49
AppEntity.AllowedOriginsJson has been populated from the apps API since the
initial schema, but nothing ever read it — the host emitted no CORS headers at
all, so no browser client could call the data plane cross-origin.

Implements spec 08 §7:

- Default deny: no Origin match, no CORS headers.
- Per-app allowed_origins resolved from the presented API key, with a 60 s
  memory cache keyed by the key hash (mirrors ApiKeyAuthenticationHandler).
- Preflight carries no custom headers, so an unkeyed request falls back to the
  instance-level Cors:DefaultAllowedOrigins list (spec 12 §4).
- Single-label wildcards (https://*.example.com); an extra label, a scheme
  mismatch, or a suffix forgery (evilexample.com) does not match.
- Access-Control-Expose-Headers carries the OData and RateLimit headers.

Runs before authentication (spec 02 §4 step 4) because a browser preflight is
anonymous by definition — previously OPTIONS returned 401 before any CORS
negotiation could happen. Placed ahead of the setup gate too, so a preflight
during first-run setup still answers with headers rather than a bare 503.

AuthService.Sha256 becomes public: the middleware must hash presented keys with
exactly the same format as the auth handler, and duplicating it invites a silent
divergence that would break origin lookup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The compose demo assets were never committed. Fixes to what was there:

- demo.sh waited for service status "Ready", which is not a ServiceStatus value
  (Pending/Introspecting/Active/Failed/Refreshing/Disabled). The wait always
  exhausted its 40x2s budget before printing the right answer anyway, so the
  script took ~3 min instead of ~5 s. Now breaks on Active|Failed.
- The REST line printed "n/a": the route is /api/rest/<svc>/_table/<table> and
  rows come back under .resource, not .value or a bare array.
- demo.sh's header told you to layer the demo overlay on docker-compose.yml.
  The overlay is written against the minimal profile (its own header explains
  why: the full profile's Postgres system-DB path uses EnsureCreated and fails
  on a SQLite-style filtered index). Corrected to match.

Adds a third beat showing how to interrogate a service: the discovery endpoints
(service document, $metadata, per-table _schema including the PII mask flag),
the same query over MCP, and a Swagger UI on :8081 browsing all four generated
specs. openapi.json requires auth, so Swagger UI cannot fetch it directly —
demo.sh exports the specs to ./specs (gitignored) and nginx serves them.

"Try it out" works: the overlay puts :8081 on Cors:DefaultAllowedOrigins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant