Summary
The "API Docs" link in the header opens a blank page. Confirmed reproducible against the bare Lambda Function URL: https://33pz7pombdqwu3bdlxp4lqxyra0bsriy.lambda-url.us-east-1.on.aws/api/docs/ returns the Swagger UI HTML (200 OK, 580 bytes) but the browser renders nothing.
Root cause (corrected)
internal/api/handler.go:222 (setSecurityHeaders) applies the same maximally-restrictive Content-Security-Policy to every response from the Lambda, including the inline Swagger UI HTML served by docsHandler:
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
With default-src 'none' and no allow-listed sources, the browser refuses to:
- load the Swagger UI bundle/CSS that
serveDocsUI references (https://unpkg.com/swagger-ui-dist@5/...)
- execute the inline
<script>SwaggerUIBundle({...})</script> that bootstraps the UI
fetch(/api/docs/openapi.yaml) from inside the UI
Result: the HTML reaches the browser but nothing runs → blank page. The Swagger UI HTML body itself is correct (verified via curl) and /api/docs/openapi.yaml returns the full 63 KB spec with the right content-type.
This is a deployment-agnostic issue — there is no CDN/CloudFront in front of the Lambda Function URL; the CSP comes from the Lambda's own response headers.
What the prior body got wrong
The previous diagnosis assumed /docs/ was hitting a SPA bundle via CDN routing. Wrong — no CDN involved. The header-link path (/docs/ vs /api/docs/) is a separate, secondary concern: both paths reach docsHandler thanks to the prefix routes at router.go:234-235, and both produce the same blank page because of the CSP. The CSP is the primary bug.
Files
- CSP source:
internal/api/handler.go:222 — headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
- Docs HTML:
internal/api/handler_docs.go::serveDocsUI — references unpkg.com/swagger-ui-dist@5/* via CDN
- Header link:
frontend/src/index.html:27 — <a href="/docs/" target="_blank">
- Test:
frontend/src/__tests__/html.test.ts:595
Fix candidates
The cleanest path depends on whether we want to keep a CDN dependency or not. Two viable options:
A. Self-host Swagger UI assets, narrow the CSP per-route
- Vendor
swagger-ui.css + swagger-ui-bundle.js from swagger-ui-dist@5 into the binary (//go:embed alongside openapi.yaml).
- Have
docsHandler serve /api/docs/swagger-ui.css and /api/docs/swagger-ui-bundle.js from the embedded bytes (the prefix route already swallows the whole subtree).
- Update
serveDocsUI HTML to point at those same-origin paths.
- In
setSecurityHeaders, special-case the /docs and /api/docs paths to use a relaxed-but-still-tight CSP — e.g. default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'. 'unsafe-inline' is required for the bootstrap <script> block in serveDocsUI; alternatively, move the bootstrap to an external script and drop 'unsafe-inline'.
Pros: no external CDN dependency, smaller blast-radius CSP relaxation, works offline.
B. Keep unpkg CDN, allow-list it from the CSP for the docs path only
- In
setSecurityHeaders, branch on path: for /docs and /api/docs, emit a CSP that adds https://unpkg.com to script-src / style-src, 'unsafe-inline' for the bootstrap, and 'self' for connect-src (the openapi.yaml fetch).
- All other API responses keep the current
default-src 'none' policy.
Pros: smaller code change.
Cons: live dependency on unpkg.com (network, supply-chain).
Header-link path (secondary)
Once the CSP is fixed, the header link /docs/ should also be confirmed to work end-to-end. If the production routing genuinely sends the request to docsHandler (no CDN, so the same Lambda URL → router prefix match), both /docs/ and /api/docs/ will work. If a future deployment puts a CDN/proxy in front of the Lambda with /api/* → Lambda and /* → static SPA, the link should be updated to /api/docs/. Marking this as a follow-up after the CSP fix lands.
Acceptance criteria
Reproduction
curl -sSI -X GET "https://33pz7pombdqwu3bdlxp4lqxyra0bsriy.lambda-url.us-east-1.on.aws/api/docs/" | grep -i 'content-security-policy'
# Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
curl -sS "https://33pz7pombdqwu3bdlxp4lqxyra0bsriy.lambda-url.us-east-1.on.aws/api/docs/" | head
# returns the Swagger UI HTML, but the browser will not execute or load anything because of the CSP
Summary
The "API Docs" link in the header opens a blank page. Confirmed reproducible against the bare Lambda Function URL:
https://33pz7pombdqwu3bdlxp4lqxyra0bsriy.lambda-url.us-east-1.on.aws/api/docs/returns the Swagger UI HTML (200 OK, 580 bytes) but the browser renders nothing.Root cause (corrected)
internal/api/handler.go:222(setSecurityHeaders) applies the same maximally-restrictive Content-Security-Policy to every response from the Lambda, including the inline Swagger UI HTML served bydocsHandler:With
default-src 'none'and no allow-listed sources, the browser refuses to:serveDocsUIreferences (https://unpkg.com/swagger-ui-dist@5/...)<script>SwaggerUIBundle({...})</script>that bootstraps the UIfetch(/api/docs/openapi.yaml)from inside the UIResult: the HTML reaches the browser but nothing runs → blank page. The Swagger UI HTML body itself is correct (verified via curl) and
/api/docs/openapi.yamlreturns the full 63 KB spec with the right content-type.This is a deployment-agnostic issue — there is no CDN/CloudFront in front of the Lambda Function URL; the CSP comes from the Lambda's own response headers.
What the prior body got wrong
The previous diagnosis assumed
/docs/was hitting a SPA bundle via CDN routing. Wrong — no CDN involved. The header-link path (/docs/vs/api/docs/) is a separate, secondary concern: both paths reachdocsHandlerthanks to the prefix routes atrouter.go:234-235, and both produce the same blank page because of the CSP. The CSP is the primary bug.Files
internal/api/handler.go:222—headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"internal/api/handler_docs.go::serveDocsUI— referencesunpkg.com/swagger-ui-dist@5/*via CDNfrontend/src/index.html:27—<a href="/docs/" target="_blank">frontend/src/__tests__/html.test.ts:595Fix candidates
The cleanest path depends on whether we want to keep a CDN dependency or not. Two viable options:
A. Self-host Swagger UI assets, narrow the CSP per-route
swagger-ui.css+swagger-ui-bundle.jsfromswagger-ui-dist@5into the binary (//go:embedalongsideopenapi.yaml).docsHandlerserve/api/docs/swagger-ui.cssand/api/docs/swagger-ui-bundle.jsfrom the embedded bytes (the prefix route already swallows the whole subtree).serveDocsUIHTML to point at those same-origin paths.setSecurityHeaders, special-case the/docsand/api/docspaths to use a relaxed-but-still-tight CSP — e.g.default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'.'unsafe-inline'is required for the bootstrap<script>block inserveDocsUI; alternatively, move the bootstrap to an external script and drop'unsafe-inline'.Pros: no external CDN dependency, smaller blast-radius CSP relaxation, works offline.
B. Keep unpkg CDN, allow-list it from the CSP for the docs path only
setSecurityHeaders, branch on path: for/docsand/api/docs, emit a CSP that addshttps://unpkg.comtoscript-src/style-src,'unsafe-inline'for the bootstrap, and'self'forconnect-src(theopenapi.yamlfetch).default-src 'none'policy.Pros: smaller code change.
Cons: live dependency on unpkg.com (network, supply-chain).
Header-link path (secondary)
Once the CSP is fixed, the header link
/docs/should also be confirmed to work end-to-end. If the production routing genuinely sends the request todocsHandler(no CDN, so the same Lambda URL → router prefix match), both/docs/and/api/docs/will work. If a future deployment puts a CDN/proxy in front of the Lambda with/api/*→ Lambda and/*→ static SPA, the link should be updated to/api/docs/. Marking this as a follow-up after the CSP fix lands.Acceptance criteria
/api/docs/renders Swagger UI in production (no blank page)./api/docs/openapi.yamlstill returns the spec (unchanged).internal/api/handler_security_test.go(or sibling) covering the docs-path CSP exception.Reproduction