Skip to main content

The documentation under /docs

The image carries its own copy of the documentation, built at the version the image is, and the server answers it from its own origin at /docs/. It is a static site read once at boot into memory (ODUDU_DOCS_DIR, /app/docs), so no request touches the filesystem; a path that is not a file of the build is 404. It reads no database and takes nothing from the request, so it says nothing about any tenant. ODUDU_DOCS=false registers nothing.

On a new stack on port 3261, with no tenant seeded: the site is the same on any stack. date, x-request-id, connection and keep-alive change on every response and are filtered out in the command.

A bare /docs is sent to the directory. Every response the route produces carries x-robots-tag: noindex, nofollow, redirect included. Fastify's own 400 for a malformed percent-escape and its 404 for a method other than GET or HEAD are not the route's and carry none of the docs headers:

$ curl -sI http://localhost:3261/docs | tr -d '\r' | grep -viE '^(date|x-request-id|connection|keep-alive):'
HTTP/1.1 308 Permanent Redirect
content-security-policy: default-src 'none'
x-frame-options: DENY
referrer-policy: no-referrer
x-content-type-options: nosniff
x-robots-tag: noindex, nofollow
content-language: en
location: /docs/
content-length: 0

The home page runs under a policy computed when the image was built: the one inline script (the colour-mode initialiser, the same on every page) by hash, and the style= attributes the theme writes (the code blocks' colours and a hidden icon sprite) by hash with 'unsafe-hashes', which only style-src-attr carries. There is no 'unsafe-inline' and no 'unsafe-eval', and the policy is the same on every response the route produces (ADR 0029's second amendment). data: images are the theme's own icons. The page revalidates by etag:

$ curl -sI http://localhost:3261/docs/ | tr -d '\r' | grep -viE '^(date|x-request-id|connection|keep-alive):'
HTTP/1.1 200 OK
content-security-policy: default-src 'self'; script-src 'self' 'sha256-XzbohOdLBHFS8lNAlXYAVACbmod6JzK/jbZ8kS4rKFA='; style-src 'self'; style-src-attr 'unsafe-hashes' 'sha256-AIsMoEZ2AFYSNPqPHAPTfeQBkBx0uwe/S138h7oBgqk=' 'sha256-IcqGb0TAbH6mKQhvd8hBx2FAalfysv4UbOSlrgEnNqs=' 'sha256-biLFinpqYMtWHmXfkA1BPeCY0/fNt46SAZ+BBk5YUog=' 'sha256-eslHeDkvJNiffFsT711OoAWNr5i7S9CBs7nlwyVzHxo=' 'sha256-jKE6QZqne5OsrfemNvuLSNoud++NsCOiSlGuIsQns5o=' 'sha256-sDIENtrZwmox3spLJOzzFKFmi3ELZ0v+EkEB+J8SMrA=' 'sha256-wfFRBRN9VtnoKyjFp7nESoyL5GicNVcIrkvfmAPxv1s='; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'
x-frame-options: DENY
referrer-policy: no-referrer
x-content-type-options: nosniff
x-robots-tag: noindex, nofollow
content-language: en
etag: "g0JO9AjGJhhNIfLpiG7FYhXh8Rn"
cache-control: no-cache
content-type: text/html; charset=utf-8
content-length: 10350

The content-length above and the hashed file name below belong to the docs build that ran; every build changes them, so nothing here depends on them.

A file named by its content, in the build's assets directory, is immutable. A file with a fixed name, such as the agent copy llms.txt, a Markdown copy of a page or the search index, revalidates, and carries the same x-robots-tag:

$ curl -sI http://localhost:3261/docs/assets/js/main.52f8d3ec.js | tr -d '\r' | grep -iE '^(HTTP|content-type|cache-control|x-robots-tag|etag)'
HTTP/1.1 200 OK
x-robots-tag: noindex, nofollow
etag: "K3NlSXVr_bqnqZhg4Klex2OVBDU"
cache-control: public, max-age=31536000, immutable
content-type: text/javascript; charset=utf-8
$ curl -sI http://localhost:3261/docs/llms.txt | tr -d '\r' | grep -iE '^(HTTP|content-type|cache-control|x-robots-tag|etag)'
HTTP/1.1 200 OK
x-robots-tag: noindex, nofollow
etag: "mZXvM6DAzatbM2ITwae2rfkA19h"
cache-control: no-cache
content-type: text/plain; charset=utf-8

A path that is not a file of the build is the site's own 404 page with status 404, not the home page, under the same headers. So is every form of traversal, sent as written (--path-as-is) so that curl does not tidy it first, because the lookup is an exact key and nothing is read from disk per request. csp.json, the file the policy is read from, is not a document of the site and is 404 too:

$ curl -sI http://localhost:3261/docs/nope/ | tr -d '\r' | grep -iE '^(HTTP|content-type|cache-control|x-robots-tag)'
HTTP/1.1 404 Not Found
x-robots-tag: noindex, nofollow
cache-control: no-cache
content-type: text/html; charset=utf-8
$ curl -s --path-as-is -o /dev/null -w '%{http_code} %{content_type}\n' http://localhost:3261/docs/../package.json
404 text/html; charset=utf-8
$ curl -s --path-as-is -o /dev/null -w '%{http_code} %{content_type}\n' 'http://localhost:3261/docs/..%2f..%2fetc%2fpasswd'
404 text/html; charset=utf-8
$ docker compose exec -T odudu ls /app/docs/csp.json
/app/docs/csp.json
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3261/docs/csp.json
404

With ODUDU_DOCS_DIR naming a directory that is not there, the server still starts; /docs/* answers 503 and the log names both variables. The same stack, recreated with ODUDU_DOCS_DIR=/nonexistent:

$ curl -sI http://localhost:3261/docs/ | tr -d '\r' | grep -iE '^(HTTP|x-robots-tag|content-security-policy|x-content-type-options)'
HTTP/1.1 503 Service Unavailable
content-security-policy: default-src 'none'
x-content-type-options: nosniff
x-robots-tag: noindex, nofollow
$ docker compose logs odudu | grep -o '"msg":"ODUDU_DOCS_DIR.*'
"msg":"ODUDU_DOCS_DIR (/nonexistent) cannot be served, ENOENT: no such file or directory, scandir '/nonexistent'; /docs/* answers 503; set ODUDU_DOCS=false to turn the documentation off"}

And recreated with ODUDU_DOCS=false, no route is registered, so /docs/ is as unknown as any path the server has no route for:

$ curl -sI http://localhost:3261/docs/ | grep -iE '^(HTTP|content-type)'
HTTP/1.1 404 Not Found
content-type: application/problem+json; charset=utf-8

The site must be at the root of the origin ODUDU_PUBLIC_BASE_URL names. Its links name /docs/, so a reverse proxy that mounts the server under a path prefix breaks them; that is unsupported. The route never builds an address from Host or a forwarded header (a test sends hostile ones and asserts none appears in any response), and the pages carry no absolute link to another origin but the repository.