meow-web BFF: How the Dashboard Calls purr-api with a Service JWT
smo1 intermediate 6 min read
ELI5
The dashboard’s browser code never talks to purr-api directly. It talks to its own server at /api/purr/.... That server checks your dashboard cookie, writes a five-minute signed note saying “this is user X, email Y”, and makes the real API call with it. The browser only ever holds the dashboard cookie, which purr-api would not accept.
Technical Deep Dive
Source: meow-web src/app/api/purr/[...path]/route.ts:1-97, src/lib/service-jwt.ts:9-50, src/lib/api.ts:13 (origin/main e5d808c); purr-api internal/middleware/auth.go:221-305 and internal/config/config.go:190-207 (bb6cfc2); contract smo1-io docs/decisions/2026-10-09-meow-purr-bff-service-jwt.md (frontmatter status: draft, sign-off: flight@arno.host — 2026-10-15), read 2026-10-25.
Request path
sequenceDiagram autonumber participant B as Browser participant M as meow-web /api/purr participant P as purr-api B->>M: GET /api/purr/links with smo1_session cookie M->>M: verifyLocalSession, need userId and email M->>M: reject unsafe path segments M->>M: mintServiceJwt HS256, iss meow-web, aud purr-api, ttl 300s M->>P: GET PURR_API_URL/api/links with Authorization Bearer P->>P: validateServiceJWT then EnsureExists user P-->>M: 200 JSON M-->>B: 200 with allowlisted headers onlyWhat the proxy does
| Step | Behaviour | Line |
|---|---|---|
| Session | verifyLocalSession(smo1_session); missing userId or email is 401 unauthorized | 37-40 |
| Path safety | rejects ., .., empty, raw or percent-encoded / or \, encoded .. with 400 bad_request | 29-34, 42-45 |
| Config | missing SERVICE_JWT_SECRET or PURR_API_URL is 500 server_misconfigured, logged by name only | 47-53 |
| Outbound headers | only Authorization: Bearer <service JWT>, Content-Type, Accept. Inbound Cookie and Authorization are never forwarded | 55-62 |
| Fetch | redirect: "manual", cache: "no-store"; network failure is 502 bad_gateway | 67-79 |
| Response headers | allowlist: Content-Type, Content-Disposition, Cache-Control, ETag, Last-Modified. Never Set-Cookie | 18-25, 85-90 |
The token is byte-stable: header and claim key order are fixed (service-jwt.ts:22-49), so tests can assert exact output.
purr-api’s side of the contract
purr-api requires iss=meow-web, aud=purr-api, iat and exp present, at most 300 s apart, 30 s leeway, non-empty sub and email (auth.go:221-224, 267-305). cat and lion refuse to boot without a secret of at least 32 bytes, and any environment refuses a secret equal to JWT_SECRET (config.go:195-207).
Contract status
The decision record is status: draft while carrying a sign-off line dated 2026-10-15. The code on both sides is merged and in production use (the 2026-10-18 production demo walk ran through it). Treat the code as the contract; the record’s status field was never moved.
Key Terms
- BFF — backend-for-frontend: a server route that holds credentials the browser must not
- mintServiceJwt — meow-web’s HS256 signer for
{iss, aud, sub, email, iat, exp} - PASSTHROUGH_HEADERS — the response-header allowlist
- API_PROXY_BASE —
/api/purr, the base every client hook uses (api.ts:13)
Q&A
Q: What does the /api/purr proxy forward to purr-api, and what does it strip?
A: It forwards the method, the path under /api/, the query string, the body for non-GET/HEAD requests, and only the Content-Type and Accept headers, plus its own Bearer service JWT. It strips the inbound Cookie and Authorization headers, and on the way back drops every response header outside the five-name allowlist, including Set-Cookie.
Q: What happens when SERVICE_JWT_SECRET differs between meow-web and purr-api?
A: Every service JWT fails HS256 verification. Because the token claims iss: meow-web, purr-api rejects it instead of trying the legacy path, so every dashboard API call returns 401 while sign-in itself still works.
Q: Why does the browser never hold a credential purr-api accepts?
A: The browser holds only smo1_session, signed with meow-web’s SESSION_SECRET, on app.smo1.io. purr-api does not know that secret, and the service JWT is minted server-side per request and never returned to the client.
Examples
Rotating the shared secret: set the new SERVICE_JWT_SECRET on Railway purr-api and Vercel meow-web, then redeploy both. Between the two deploys every proxied call is a 401: purr-api accepts exactly one secret, so there is no overlap window to hide the switch in. Users do not need to sign in again, because SESSION_SECRET is untouched.
neighbors on the map
- Multi-Strategy Authentication in purr-api debugging 401 errors across different clients
- Rate Limiting: Edge per-IP, Origin per-Tier debugging 429 errors for specific users