Skip to content

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 .

FilePurpose
redoc.standalone.jsThe 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.sha256Bare-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.
VERSIONThe 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.
LICENSEThe 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 test

The script:

  1. Refuses to proceed without a <version> argument (exits 64 on a missing positional or unknown flag).
  2. Downloads bundles/redoc.standalone.js from jsdelivr for the requested tag and computes its SHA-256 with sha256sum.
  3. Compares the computed digest against the value supplied via --sha256, falling back to the on-disk redoc.standalone.js.sha256 when the requested version equals the VERSION file. A mismatch exits non-zero without mutating any of the four files — the workspace test tests/workspace/update_redoc_script_test.go asserts 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.
  4. Downloads the upstream LICENSE and refuses to write it unless the body contains the literal substring MIT (so an upstream re-licence to an incompatible licence is caught at vendoring time, not at audit time).
  5. Atomically moves the verified bundle, the new digest, the new VERSION, and the new LICENSE into place via mktemp -p + mv so 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

HeaderValueWhy
Content-Typetext/html; charset=utf-8The endpoint serves the rendered HTML shell.
Content-Security-Policydefault-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-OptionsnosniffPrevents MIME-sniffing attacks against the served HTML — the browser must honour the declared text/html content-type.

GET /v1/docs/assets/{asset}

HeaderValueWhy
Content-Typeapplication/javascript; charset=utf-8The endpoint serves the vendored ReDoc standalone bundle.
Cache-Controlpublic, max-age=3600The 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-OptionsnosniffPrevents 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:

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.json document 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 enumerates GetDocs and GetDocsAsset alongside the other meta endpoints.
  • ../../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 the PLEXSPHERE_REDOC_ASSET_DIR override the workspace test relies on.