Appearance
Embedded ReDoc UI for /v1/docs
plexsphere ships a self-contained, offline-first API-documentation surface at /v1/docs that renders a vendoredReDoc bundle against the embedded /v1/openapi.json. The wire-level surface contract (the GetDocs / GetDocsAsset operations, the asset allow-list, the bypass) is owned by the reference page ../reference/api/meta.md.
This document is the contributor procedure for that surface: where the vendored bytes live, how to upgrade them supply-chain-safely, the security headers the handler emits, the bypass entries that let the page render without credentials, and what CI does (and does not) verify on a PR. For the OpenAPI authoring workflow that produces the spec the UI renders, see ./openapi.md. For the operation-level reference that enumerates the two new endpoints, see ../reference/api/meta.md.
Vendored asset layout
The ReDoc bundle is committed to the repository — it is not a runtime dependency, not a go get transitive, and not a git submodule. The four files under internal/transport/http/v1/handlers/assets/redoc/ ship together and are consumed by the //go:embed directive in internal/transport/http/v1/handlers/docs.go .
| File | Purpose |
|---|---|
redoc.standalone.js | The ReDoc standalone bundle that GetDocsAsset serves verbatim under /v1/docs/assets/redoc.standalone.js. Same bytes the embedded HTML shell loads via <script src=…>. |
redoc.standalone.js.sha256 | Bare-hex SHA-256 of redoc.standalone.js on a single line. The vendoring drift test in tests/workspace/redoc_vendoring_test.go hashes the on-disk bundle and refuses any PR whose digest does not match this pin. |
VERSION | The exact upstream tag the bundle was downloaded for (e.g. v2.4.0). Single line. Read by scripts/update-redoc.sh to confirm the operator is bumping a real version, and by the vendoring test to assert non-empty content. |
LICENSE | The upstream MIT licence text. The vendoring test asserts the file contains the literal substring MIT so an upstream re-licence cannot land silently. |
The four files are intentionally co-located in the same directory so the supply-chain trail (LICENSE, VERSION, .sha256) ships next to the bundle the operator is auditing. Only redoc.standalone.js is baked into the binary — the handler binds it via a single-file //go:embed assets/redoc/redoc.standalone.js directive into a package-level []byte, and GetDocsAsset writes that slice directly through the ResponseWriter with no per-request copy (the contract mandates "served via w.Write of the embedded slice (no per-request copy)"; the benchmark BenchmarkGetDocsAsset pins the no-copy contract by reporting allocations on every run). The companion LICENSE / VERSION / .sha256 files are tracked in git as the on-disk source-of-truth for the supply-chain audit and are NOT embedded into the binary because no endpoint currently exposes them. The bundle is the only file the embed-backed GetDocsAsset handler will return — the handler matches the URL parameter against the literal string redoc.standalone.js and returns a problem+json 404 for everything else, so the /v1/docs/assets/ prefix cannot be turned into a generic file server.
Upgrade procedure
Bumping the vendored bundle to a newer ReDoc release is a deliberate, reproducible operation. The repository ships scripts/update-redoc.sh so the upgrade is a one-command operation that fails closed on any checksum mismatch — a partially-downloaded bundle never overwrites a verified one. The script's contract is documented inline in the file header; the operator-facing summary is below.
shell
# Bump to v2.5.0 using the repository's existing pin to verify the
# digest. If `VERSION` already matches the requested tag the script
# uses the on-disk redoc.standalone.js.sha256 as the expected digest;
# otherwise it requires an explicit --sha256 from the release notes.
scripts/update-redoc.sh v2.5.0 --sha256 <hex-from-upstream-release>
# After the script returns 0, regenerate nothing — the bundle is a
# leaf file. Run the workspace tests to confirm the digests line up.
make testThe script:
- Refuses to proceed without a
<version>argument (exits 64 on a missing positional or unknown flag). - Downloads
bundles/redoc.standalone.jsfrom jsdelivr for the requested tag and computes its SHA-256 withsha256sum. - Compares the computed digest against the value supplied via
--sha256, falling back to the on-diskredoc.standalone.js.sha256when the requested version equals theVERSIONfile. A mismatch exits non-zero without mutating any of the four files — the workspace testtests/workspace/update_redoc_script_test.goasserts this fail-closed contract by running the script in a temp dir with a forced-bad checksum and verifying the on-disk state is unchanged. - Downloads the upstream
LICENSEand refuses to write it unless the body contains the literal substringMIT(so an upstream re-licence to an incompatible licence is caught at vendoring time, not at audit time). - Atomically moves the verified bundle, the new digest, the new
VERSION, and the newLICENSEinto place viamktemp -p+mvso a partial run never leaves the asset directory in a half-written state.
The asset directory location is overridable through the PLEXSPHERE_REDOC_ASSET_DIR environment variable; this exists so the workspace test can point the script at a sandbox temp directory. Operators should never set it — leaving it unset writes the verified bytes into the canonical internal/transport/http/v1/handlers/assets/redoc/ directory.
After a successful run, commit all four files together. The drift gate at tests/workspace/redoc_vendoring_test.go and the script-contract gate at tests/workspace/update_redoc_script_test.go will fail the PR if any of them is missing or if the digest is wrong .
CI policy
scripts/update-redoc.sh is not invoked by CI. The script performs an outbound HTTPS download and overwrites tracked files; running it on every PR would either tie the build to jsdelivr's availability or silently update the vendored bytes without an explicit operator decision. CI only verifies the result of a past upgrade: the workspace tests check that the four files exist, that redoc.standalone.js.sha256 matches the on-disk bundle, that the LICENSE says MIT, and that the script fails closed on a forced-bad digest. The bump itself is always a deliberate, human-reviewed commit.
Security headers
Both endpoints are crafted to emit the smallest secure response that still lets ReDoc render the OpenAPI spec. The header values are constants in internal/transport/http/v1/handlers/docs.go so a future refactor cannot quietly relax the posture.
GET /v1/docs
| Header | Value | Why |
|---|---|---|
Content-Type | text/html; charset=utf-8 | The endpoint serves the rendered HTML shell. |
Content-Security-Policy | default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; img-src 'self' data: | default-src 'self' blocks third-party loads; script-src 'self' confines JS to the vendored bundle so a compromised spec document cannot inject inline <script> tags. The 'unsafe-inline' on style-src is the deliberate trade-off documented in docs.go — ReDoc's emotion-based style-injection emits dozens of <style> tags at runtime, so a per-request nonce or a build-time hash list would always be incomplete and the page would render partially-styled. The narrower posture (default-src 'self'; script-src 'self') closes the high-impact XSS path without forking ReDoc. The blob: token on worker-src is required because the vendored ReDoc bundle constructs its syntax-highlight Worker via new Worker(URL.createObjectURL(new Blob([...]))); without it, Firefox surfaces SecurityError: The operation is insecure and the page renders as a ReDoc error overlay. The data: token on img-src admits the bundle's inlined data:image/svg+xml;base64,... schema/format icons. Neither directive admits a third-party origin: 'self' constrains both to the plexsphere host, blob: only matches blobs the page itself constructs, and data: only matches URLs the page itself encodes. |
X-Content-Type-Options | nosniff | Prevents MIME-sniffing attacks against the served HTML — the browser must honour the declared text/html content-type. |
GET /v1/docs/assets/{asset}
| Header | Value | Why |
|---|---|---|
Content-Type | application/javascript; charset=utf-8 | The endpoint serves the vendored ReDoc standalone bundle. |
Cache-Control | public, max-age=3600 | The bundle is content-addressable via its co-located .sha256 file and changes only when scripts/update-redoc.sh re-vendors a new version, so a one-hour public cache is a safe trade-off between staleness and CDN/browser pressure. |
X-Content-Type-Options | nosniff | Prevents MIME-sniffing the JS bytes as a different type. |
The 404 path for unknown assets returns the standard application/problem+json envelope via the shared writeProblem helper with kind = "not-found", so adversarial-path probing against /v1/docs/assets/ produces the same shape every other plexsphere 4xx response uses.
Bypass entries
The docs surface is unauthenticated by design. A caller without credentials needs to be able to read the OpenAPI documentation that explains how to obtain credentials in the first place; gating /v1/docs on authn would create a chicken-and-egg loop that the embedded UI exists specifically to avoid. The same reasoning applies to authz: the rendered shell and the vendored bundle ship no user data and would never produce a meaningful authorization decision — running them through SpiceDB would only generate audit noise .
The two middleware allowlists carry the matching pair of entries:
internal/identity/authn/middleware/middleware.go—DefaultBypass()includes/v1/docs(exact-match leaf) and/v1/docs/assets/(trailing-slash family prefix). The DECISION block at the top of the function spells out the chicken-and-egg rationale.internal/authz/middleware/rebac.go—DefaultBypass()carries the same two entries in the same order. The workspace parity gatetests/workspace/middleware_bypass_parity_test.gorefuses any PR where the two lists drift apart.
The matching semantics matter: the exact-match form on /v1/docs guards against a hypothetical /v1/docsx leaf silently bypassing either middleware (the original blanket HasPrefix matcher was the footgun the rewrite closes), while the trailing-slash family form on /v1/docs/assets/ covers the vendored bundle without listing each filename. The adversarial-path matrix in internal/identity/authn/middleware/bypass_test.go and internal/authz/middleware/rebac_test.go admits /v1/docs and /v1/docs/assets/redoc.standalone.js and rejects /v1/docsx, /v1/docsy, /v1/docs/, /v1/docs/assets, /v1/docs/assetsx, and /v1/docs_bypass_attempt. The end-to-end gate at tests/e2e/openapi-docs/chainsaw-test.yaml proves the surface is reachable anonymously inside a real in-cluster deployment.
When you add a new public read-only surface (a future /v1/health/<x> sub-probe, for example), extend both allowlists in the same PR and add the new path to the adversarial-path matrices — the parity gate will catch a drift, but the adversarial-path tests are what catch a too-broad pattern.
Cross-references
./openapi.md— the spec authoring workflow that produces the/v1/openapi.jsondocument the embedded ReDoc UI renders. Adding or renaming an operation flows through that workflow first; the docs surface picks up the change automatically on the next request.../reference/api/meta.md— operation-level reference that enumeratesGetDocsandGetDocsAssetalongside the othermetaendpoints.../../internal/transport/http/v1/handlers/docs.go— the handler implementation, including the file-level DECISION block that spells out the live-render-vs-static-build, spec-link-vs-inline, and'unsafe-inline'style-src trade-offs.../../scripts/update-redoc.sh— the supply-chain-safe upgrade script, including thePLEXSPHERE_REDOC_ASSET_DIRoverride the workspace test relies on.