#!/usr/bin/env bash set -euo pipefail script_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) root=$script_root if [[ $# -gt 0 ]]; then [[ $# -eq 2 && $1 == "--root" && -d $2 ]] || { echo "usage: auth-docs-smoke.sh [--root DIRECTORY]" >&2 exit 2 } root=$(cd "$2" && pwd -P) fi docs=( "$root/docs/architecture/authentication.md" "$root/docs/install/authentication-local.md" "$root/docs/install/authentication-oidc.md" "$root/docs/install/authentik.md" "$root/docs/testing/authentication-manual-acceptance.md" "$root/docs/architecture/overview.md" "$root/docs/install/local.md" "$root/docs/install/server.md" "$root/docs/install/psd-workspace-setup.md" "$root/docs/install/reverse-proxy-caddy.md" "$root/docs/install/reverse-proxy-nginx.md" "$root/docs/contracts/tht-pi.md" "$root/docs/contracts/workspace-preprocessing-cli.md" "$root/docs/guida-utente.md" "$root/docs/index.md" "$root/README.md" "$root/PROJECT_STATE.md" "$root/mkdocs.yml" ) for path in "${docs[@]}"; do [[ -f "$path" ]] || { echo "auth docs smoke: missing $path" >&2; exit 1; } done corpus=$(mktemp) trap 'rm -f "$corpus"' EXIT cat "${docs[@]}" >"$corpus" required=( "tht auth" "groups" "TOT Admin" "THT_OIDC_CLIENT_SECRET" "THT_AUTHENTIK_API_TOKEN" "Remember me" "oidc_mapped_group_missing" "oidc_callback_failed" "session.read_all" "workspace.secrets.manage" "auth.diagnostics.read" ) for term in "${required[@]}"; do rg -Fq "$term" "$corpus" || { echo "auth docs smoke: missing required term: $term" >&2; exit 1; } done canonical_compose='docker compose --env-file deploy/env/local.env -f compose.yaml -f deploy/compose.local.yaml up --build -d' rg -Fxq "$canonical_compose" "$root/docs/install/local.md" || { echo "auth docs smoke: missing canonical local Compose command" >&2 exit 1 } if rg -n -F 'docker compose --env-file deploy/env/local.env +' "$corpus"; then echo "auth docs smoke: noncanonical local Compose command" >&2 exit 1 fi nav_count=$(awk 'index($0, "architecture/authentication.md") { count++ } END { print count + 0 }' "$root/mkdocs.yml") [[ $nav_count == 1 ]] || { echo "auth docs smoke: authentication navigation must appear exactly once" >&2 exit 1 } python3 - "$root" <<'PY' import pathlib import re import sys root = pathlib.Path(sys.argv[1]) architecture = (root / "docs/architecture/authentication.md").read_text() user_row = "| `user` | `session.use` |" admin_row = ( "| `admin` | `session.use`, `session.read_all`, `session.manage_all`, `settings.manage`, " "`workspace.manage`, `workspace.secrets.manage`, `pi.manage`, `auth.diagnostics.read` |" ) if user_row not in architecture or admin_row not in architecture: raise SystemExit("auth docs smoke: role-to-permission map is not exact") diagnostic_heading = "## Diagnostics and ordering" diagnostic_start = architecture.find(diagnostic_heading) diagnostic_end = architecture.find("\n## ", diagnostic_start + len(diagnostic_heading)) diagnostic_section = architecture[diagnostic_start:diagnostic_end if diagnostic_end >= 0 else None] match = re.search(r"```text\n([\s\S]*?)```", diagnostic_section) expected_codes = [ "auth_ready", "auth_config_incomplete", "auth_config_invalid", "auth_session_store_invalid", "local_user_registry_invalid", "local_admin_missing", "oidc_secret_missing", "oidc_discovery_unreachable", "oidc_issuer_mismatch", "oidc_jwks_unreachable", "oidc_group_catalog_unreachable", "oidc_group_catalog_unauthorized", "oidc_mapped_group_missing", "oidc_mapped_group_ambiguous", "oidc_groups_claim_invalid", "oidc_device_flow_unavailable", ] actual_codes = [] if match is None else [line for line in match.group(1).splitlines() if line] if actual_codes != expected_codes: raise SystemExit("auth docs smoke: diagnostic code union is not exact") for relative, language, forbidden, required in [ ("docs/install/reverse-proxy-caddy.md", "caddyfile", "forward_auth", "forward_auth"), ("docs/install/reverse-proxy-nginx.md", "nginx", "auth_request", "auth_request"), ]: source = (root / relative).read_text() direct_start = source.find("## Direct ThothII-managed OIDC") deprecated_start = source.find("## Deprecated upstream migration mode") if direct_start < 0 or deprecated_start <= direct_start: raise SystemExit(f"auth docs smoke: {relative} does not split direct and deprecated modes") direct = source[direct_start:deprecated_start] deprecated_end = source.find("\n## ", deprecated_start + 4) deprecated = source[deprecated_start:deprecated_end if deprecated_end >= 0 else None] blocks = re.findall(rf"```{language}\n([\s\S]*?)```", direct) direct_code = "\n".join(blocks) if "/api/auth/oidc/login" not in direct or "/api/auth/oidc/callback" not in direct: raise SystemExit(f"auth docs smoke: {relative} omits unchanged public OIDC paths") if re.search(rf"(?m)^\s*{forbidden}\b", direct_code): raise SystemExit(f"auth docs smoke: {relative} applies external auth in direct OIDC mode") deprecated_code = "\n".join( re.findall(rf"```{language}\n([\s\S]*?)```", deprecated) ) if not re.search(rf"(?m)^\s*{required}\b", deprecated_code): raise SystemExit(f"auth docs smoke: {relative} omits scoped deprecated upstream auth") PY if rg -n -i --pcre2 '\bthothii-admin\b|\bthothctl\b' "$corpus"; then echo "auth docs smoke: forbidden obsolete host CLI wording" >&2 exit 1 fi if rg -n -i --pcre2 -- '--password(?!-file)\b(?:[[:space:]]+|=)\S+' "$corpus"; then echo "auth docs smoke: plaintext password option" >&2 exit 1 fi if rg -n -i --pcre2 '(?:^|[,{[:space:]])password[[:space:]]*:[[:space:]]*\S+|"password"[[:space:]]*:[[:space:]]*(?:"[^"]+"|[^,}[:space:]]+)' "$corpus"; then echo "auth docs smoke: plaintext password field" >&2 exit 1 fi if rg -n -i --pcre2 '(?:unmapped|additional|extra)[^.\r\n]{0,160}groups?[^.\r\n]{0,160}(?:generate|produce|emit|cause|trigger|raise|create|result)[^.\r\n]{0,160}(?:warnings?|alerts?|advisory|advisories|notices?|notifications?|noise)|(?:warnings?|alerts?|advisory|advisories|notices?|notifications?|noise)[^.\r\n]{0,160}(?:generate|produce|emit|cause|trigger|raise|create|result)[^.\r\n]{0,160}(?:unmapped|additional|extra)[^.\r\n]{0,160}groups?' "$corpus"; then echo "auth docs smoke: misleading noise claim for unmapped groups" >&2 exit 1 fi # Projected server authentication documentation contract. projection_docs=( "$root/docs/install/server.md" "$root/docs/install/authentication-local.md" "$root/docs/testing/authentication-manual-acceptance.md" "$root/docs/testing/psd-server-project-a-manual.md" "$root/docs/plans/2026-08-20-psd-server-project-a-standalone.md" "$root/PROJECT_STATE.md" ) projection_corpus=$(mktemp) trap 'rm -f "$corpus" "$projection_corpus"' EXIT cat "${projection_docs[@]}" >"$projection_corpus" projection_required=( "canonical authentication root" "runtime projection" "CURRENT" "generations" "root:root 0700/0600" "10001:10001 0700/0600" "THT_AUTH_RUNTIME_ROOT" "auth status --json" "auth publish" "candidate or recovery" "Mac, Windows, and local direct-file authentication" "explicit authorization" ) for term in "${projection_required[@]}"; do rg -Fqi "$term" "$projection_corpus" || { echo "auth docs smoke: missing runtime-projection term: $term" >&2 exit 1 } done if rg -n -i --pcre2 "(?:useradd|groupadd)[^\n]{0,100}10001|10001[^\n]{0,100}(?:useradd|groupadd)" "$projection_corpus"; then echo "auth docs smoke: runtime projection creates a host 10001 identity" >&2 exit 1 fi if rg -n -i --pcre2 '(?:^|[.!?]\s+)(?:(?:mount|bind)\s+(?:the\s+)?(?:canonical authentication root|auth-canonical)\b[^\n]{0,120}(?:core|/run/thothii-auth)|(?:core|the core service)\s+(?:mounts?|binds?)\s+(?:the\s+)?(?:canonical authentication root|auth-canonical)\b)|auth-canonical[^\n]{0,120}:/run/thothii-auth' "$projection_corpus"; then echo "auth docs smoke: canonical authentication is mounted into core" >&2 exit 1 fi if rg -n -i --pcre2 '(?:(?:operators?\s+)?(?:may|can|should|must)\s+(?!not\b|never\b)(?:edit|write|modify|prune)|^\s*(?:sudo\s+)?(?:vi|vim|nano|sed|cp|mv|rm|tee|printf|echo)\b)[^\n]*(?:CURRENT|generations)' "$projection_corpus"; then echo "auth docs smoke: runtime projection permits direct selector or generation edits" >&2 exit 1 fi if rg -n -i --pcre2 '(?:^\s*(?:sudo\s+nginx\s+-T|(?:sudo\s+)?(?:printenv|env)(?:\s|$)|(?:sudo\s+)?(?:diff|cat|less|more|head|tail|yq)\s+[^\n]*(?:secret|auth\.yaml|users\.yaml))|(?:may|can|should|must)\s+(?!not\b|never\b)(?:dump|print|capture|attach|include|run)\b[^\n]*(?:password|auth\.yaml|users\.yaml|YAML|raw environment|nginx\s+-T|secret-bearing diff))' "$projection_corpus"; then echo "auth docs smoke: runtime projection documents unsafe evidence collection" >&2 exit 1 fi if rg -n -i --pcre2 'Project A (?:has been|was|is) started|legacy[- ]stack (?:has been|was|is) changed' "$projection_corpus"; then echo "auth docs smoke: runtime projection claims live Project A or legacy mutation" >&2 exit 1 fi echo "auth docs smoke: required terms and forbidden wording checks passed"