CORS¶
Browsers refuse to let a page on one origin read responses from another unless the server opts in with CORS headers. jero ships CORS as a built-in policy object — not a wrapper around your app — compiled at wiring into exactly the header pairs each route needs.
The policy is a CORS Struct:
from jero import CORS
CORS(
allow_origins=("https://app.example",), # or "*" (the default)
allow_methods=("GET", "POST", "PUT", "PATCH", "DELETE"),
allow_headers=("content-type", "authorization"),
allow_credentials=False,
max_age=600,
)
Everything is validated loud at startup: allow_credentials=True with "*" is
spec-forbidden and a WiringError, as are malformed origins (https://app.example/ is
a URL, not an origin) and unknown methods.
A complete example¶
from jero import CORS, BaseApp, Endpoint, Struct
class Status(Struct):
ok: bool
class HealthEndpoint(Endpoint, path="/healthz"):
def get(self) -> Status:
return Status(ok=True)
class App(BaseApp):
async def wire(self) -> None:
self._include_cors(CORS()) # app-wide default: any origin
self._include_endpoint(HealthEndpoint())
app = App()
Run it with granian --interface asgi app:app and every response from /healthz
carries access-control-allow-origin: *.
Scoping: default, override, opt out¶
_include_cors sets the app-wide default; every include inherits it. The cors=
keyword on _include_resource / _include_endpoint overrides it per include, and
CORS.OFF removes it — CORS scope is deployment policy, so it lives at the mount, not
on the Resource/Endpoint class:
from jero import CORS, BaseApp, Endpoint, Struct
class Value(Struct):
value: int
class PublicEndpoint(Endpoint, path="/public"):
def get(self) -> Value:
return Value(value=1)
class PartnerEndpoint(Endpoint, path="/partner"):
def get(self) -> Value:
return Value(value=2)
class InternalEndpoint(Endpoint, path="/internal"):
def get(self) -> Value:
return Value(value=3)
class App(BaseApp):
async def wire(self) -> None:
self._include_cors(CORS()) # the default: any origin
self._include_endpoint(PublicEndpoint()) # inherits the default
self._include_endpoint(
PartnerEndpoint(),
cors=CORS(allow_origins=("https://partner.example",), allow_credentials=True),
)
self._include_endpoint(InternalEndpoint(), cors=CORS.OFF) # no CORS at all
app = App()
An app that never calls _include_cors serves no CORS headers anywhere — the feature
is pure opt-in, and includes can still opt in individually with their own cors=.
Semantics worth knowing¶
- Preflights answer per (path, requested method). The
Access-Control-Request-Methodheader selects which route's policy replies, so a publicGETand a restrictedPOSTon one path each answer preflights with their own policy. - Error responses carry the route's pairs. A browser page must be able to read the 401/422 problem body, so problem responses leave with the failing route's CORS pairs on them. Unrouted 404s carry the app default only.
- Allow-list responses always carry
Vary: Origin— even when the origin didn't match — so shared caches never serve one origin's response to another. HEADridesGET. A preflight requestingHEADis answered by theGETroute's policy and allowed wheneverGETis — mirroring how routing auto-serves HEAD from GET handlers. ListingHEADexplicitly inallow_methodsalso works.- The docs routes are covered too.
/openapi.json,/docs, and the favicon carry the app-default policy (and global middleware headers), so a hosted tool on another origin can fetch the spec.
What it costs¶
Which tier a policy compiles to depends only on allow_origins:
| policy | per-request cost |
|---|---|
allow_origins="*" |
zero — constant pairs baked into each covered route's header block at wiring |
| an origin tuple | one header scan + a memoized verdict per origin (the first sighting pays the lookup + echo build), plus a constant Vary: Origin pair |
Preflights (OPTIONS with Access-Control-Request-Method) ride the existing cold
OPTIONS branch and never touch the hot path.
CORS is the first consumer of the compiled middleware machinery — the middleware guide shows the same tiers as a user-facing protocol, including how to rebuild CORS yourself.