# trentpower.fr
# apache configuration
#
# this file is part of the publication layer.
#
# it defines the public contract of the site: what may be served, how long
# it may be trusted, which browser capabilities are denied, and which artefacts
# are allowed to remain durable as part of the archive.
#
# principle:
# active pages are fresh, signed release artefacts are durable, private material
# is never serveable, and security exceptions must be narrow, named, and reviewed.
#
# this file should read like the public constitution of the site:
# strict by default, explicit in its exceptions, fresh for active pages,
# durable for signed archives, and hostile to accidental disclosure.

# ----------------------------------------------------------
# maintenance mode
# ----------------------------------------------------------
# to enable:
# - uncomment the three maintenance lines below
# - comment out the active DirectoryIndex line
#
# to disable:
# - keep the active DirectoryIndex index.html line uncommented
# - keep the maintenance lines commented out
#
# DirectoryIndex maintenance.html index.html
# ErrorDocument 403 /maintenance.html
# ErrorDocument 404 /maintenance.html
DirectoryIndex index.html

# ----------------------------------------------------------
# public exposure allow-list gate
# ----------------------------------------------------------
# this site serves only the routes, root files, well-known files,
# assets, source mirrors, editorial deliverables, and integrity
# release artefacts declared by tools/public-exposure.json. anything
# not matched by an allow rule below is denied by the final fallback
# at the end of this block.
#
# coherence is enforced by tools/validate_public_exposure.py, which
# (1) walks every file under public/ and proves it matches an allow
# rule, (2) parses every public html page and proves every internal
# href/src/preload/og:image resolves both against an allow rule and
# to a real file on disk. the predeploy gate refuses to ship a build
# whose validator output is anything but green.
#
# when adding a new public route, asset, or artefact:
#   1. update tools/generate_public_exposure_manifest.py
#   2. regenerate tools/public-exposure.json (build.sh runs this)
#   3. add the matching rewriterule below in the appropriate section
#   4. run tools/validate_public_exposure.py — it must stay green
#
# the env=is_versioned_asset flag set at the top of this block is
# consumed by the cache rules further down the file (the immutable
# cache-control header for /styles.css?v=2026-05-17.<sha8>-style
# urls). do not move that env-set rule out of this block — it must
# run before any [l] terminator, otherwise versioned assets would
# fall through to the active-asset cache policy.
<IfModule mod_rewrite.c>
  RewriteEngine On

  # ----- env flag: versioned asset query string -----
  # marks /foo.css?v=YYYY-MM-DD.<sha8> for the immutable header rule
  # below. no [l] — falls through to the deny/allow phases.
  RewriteCond %{QUERY_STRING} ^v=[0-9]{4}-[0-9]{2}-[0-9]{2}\.[0-9a-f]{8}$
  RewriteRule \.(css|js|woff2?)$ - [E=IS_VERSIONED_ASSET:1]

  # ----- phase 1: hard deny for dangerous paths -----
  # these match before any allow so an accidental upload or stray
  # repo file cannot be served even if a later allow pattern
  # accidentally matches its basename.
  RewriteRule (^|/)\.git(/|$) - [F,L]
  RewriteRule (^|/)\.github(/|$) - [F,L]
  RewriteRule (^|/)\.vscode(/|$) - [F,L]
  RewriteRule (^|/)\.idea(/|$) - [F,L]
  RewriteRule (^|/)\.env(\.|$) - [F,L]
  RewriteRule (^|/)\.user\.ini$ - [F,L]
  RewriteRule (^|/)\.htpasswd$ - [F,L]
  RewriteRule (^|/)\.DS_Store$ - [F,L]
  RewriteRule (^|/)id_ed25519(\.pub)?$ - [F,L]
  RewriteRule (^|/)identity_canonical\.json$ - [F,L]
  RewriteRule (^|/)composer\.(json|lock)$ - [F,L]
  RewriteRule (^|/)package(-lock)?\.json$ - [F,L]
  RewriteRule (^|/)(yarn\.lock|pnpm-lock\.yaml)$ - [F,L]

  # ----- phase 2: hard deny for dangerous extensions -----
  RewriteRule \.(php|phar|phtml|asp|aspx|jsp|cgi|pl|py|pyc|pyo|sh|bash|zsh|exe|dll|so)$ - [F,L]
  RewriteRule \.(env|ini|conf|config|yaml|yml|toml|lock|bak|backup|old|orig|tmp|swp)$ - [F,L]
  RewriteRule \.(sql|sqlite|sqlite3|db|db3|log|map|psd|ai|fig|sketch|md)$ - [F,L]
  RewriteRule \.template\.js$ - [F,L]
  RewriteRule (?i)(invoice|credential|password|secret|totp_key)|-key\.txt$ - [F,L]

  # ----- phase 3: hard deny for build / source directories -----
  # belt-and-braces alongside redirectmatch 403 rules later in this
  # file. mod_rewrite runs before mod_alias, so these are the gate
  # that actually returns the 403; the redirectmatch rules below are
  # left in place as documentation of intent.
  RewriteRule ^(node_modules|vendor|private|src|tools|templates|partials|scripts|docs)(/|$) - [F,L]
  RewriteRule ^(_archives|_licences|_rollback|_audit|console_data|reports|assets-source)(/|$) - [F,L]

  # ===== ALLOW-LIST =====

  # ----- root: index + error pages -----
  RewriteRule ^$ - [L]
  RewriteRule ^index\.html$ - [L]
  RewriteRule ^(403|404|500|maintenance)\.html$ - [L]

  # ----- public routes (directory urls + their index.html subrequests) -----
  # mod_dir resolves /privacy/ to /privacy/index.html via an internal
  # subrequest that re-enters .htaccess; both url shapes must be
  # explicitly allowed.
  RewriteRule ^(privacy|security|integrity|verify|source|sw-reset)/?$ - [L]
  RewriteRule ^(privacy|security|integrity|verify|source|sw-reset)/index\.html$ - [L]
  RewriteRule ^security/acknowledgments/?$ - [L]
  RewriteRule ^security/acknowledgments/index\.html$ - [L]
  RewriteRule ^integrity/(releases|verify-locally)/?$ - [L]
  RewriteRule ^integrity/(releases|verify-locally)/index\.html$ - [L]
  RewriteRule ^source/view/?$ - [L]
  RewriteRule ^source/view/index\.html$ - [L]

  # ----- public root text files -----
  RewriteRule ^(robots|humans|llms|ai-usage|pgp|assertion|statement|changelog)\.txt$ - [L]

  # ----- public root json + signed manifests -----
  RewriteRule ^(integrity|site-metadata|attestations|file-metadata|sw-cache-manifest)\.json$ - [L]
  RewriteRule ^integrity\.json\.sig$ - [L]
  RewriteRule ^SHA256SUMS(\.sig)?$ - [L]
  RewriteRule ^sitemap\.xml(\.sha256)?$ - [L]
  RewriteRule ^manifest\.webmanifest$ - [L]

  # ----- public root css + js (active filenames + versioned aliases) -----
  RewriteRule ^(styles|print|fonts-full)\.css$ - [L]
  RewriteRule ^fonts-full\.[0-9]{4}-[0-9]{2}-[0-9]{2}\.[a-f0-9]+\.css$ - [L]
  RewriteRule ^(app|app-enhance|cite|i18n-core|sw)\.js$ - [L]
  RewriteRule ^app-enhance\.[0-9]{4}-[0-9]{2}-[0-9]{2}\.[a-f0-9]+\.js$ - [L]

  # ----- root icons -----
  RewriteRule ^(favicon\.svg|favicon\.ico|apple-touch-icon\.png|icon-(192|512)\.png)$ - [L]

  # ----- .well-known/ (strictly enumerated) -----
  RewriteRule ^\.well-known/(security\.txt|person\.json|webfinger|pgp-key\.asc|attribution\.(sig|txt)|build\.json|publication\.json)$ - [L]

  # ----- fonts (root + critical-path subsets) -----
  RewriteRule ^fonts/(subsets/)?[a-z0-9-]+\.woff2$ - [L]

  # ----- images (architecture, icons, og, portraits, qr, textures) -----
  RewriteRule ^images/architecture/[a-z0-9.-]+\.svg$ - [L]
  RewriteRule ^images/icons/[a-z0-9.-]+\.(png|svg|ico)$ - [L]
  RewriteRule ^images/og/[a-z0-9.-]+\.png$ - [L]
  RewriteRule ^images/portraits/[a-z0-9.-]+\.jpg$ - [L]
  RewriteRule ^images/qr/[a-z0-9.-]+\.svg$ - [L]
  RewriteRule ^images/textures/[a-z0-9.-]+\.svg$ - [L]

  # ----- source mirror (*.txt + manifest + reader scripts) -----
  # the source tree mirrors every public file as plain text. files
  # under /source/ are intentionally readable to anyone who wants to
  # inspect the bytes the live site serves without tooling.
  RewriteRule ^source/index\.html$ - [L]
  RewriteRule ^source/source-manifest\.json$ - [L]
  RewriteRule ^source/view/(source-view|source-view-manifest)\.js$ - [L]
  RewriteRule ^source/[A-Za-z0-9._/-]+\.txt$ - [L]

  # ----- editorial deliverables (docx + html + pdf; .md is denied) -----
  RewriteRule ^editorial/editorial_copy\.json$ - [L]
  RewriteRule ^editorial/(copy-review|editorial-copy-review(\.en)?)\.(html|docx|pdf)$ - [L]

  # ----- verify (unversioned + versioned variants) -----
  RewriteRule ^verify/verify\.js$ - [L]
  RewriteRule ^verify/verification-data\.js$ - [L]
  RewriteRule ^verify/verification-data\.[0-9]{4}-[0-9]{2}-[0-9]{2}\.[a-f0-9]+\.js$ - [L]

  # ----- integrity releases (signed archives + checksums) -----
  # active per-edition directory matches the YYYY-MM-DD form.
  # 2026-02 is the only edition that used a YYYY-MM short form and
  # the only one that bundled its own integrity.json + assets/ tree.
  RewriteRule ^integrity/releases/archive\.css$ - [L]
  RewriteRule ^integrity/releases/[0-9]{4}-[0-9]{2}(-[0-9]{2})?/?$ - [L]
  RewriteRule ^integrity/releases/[0-9]{4}-[0-9]{2}(-[0-9]{2})?/index\.html$ - [L]
  RewriteRule ^integrity/releases/[0-9]{4}-[0-9]{2}(-[0-9]{2})?/SHA256SUMS(\.sig)?$ - [L]
  RewriteRule ^integrity/releases/[0-9]{4}-[0-9]{2}(-[0-9]{2})?/trentpower-fr-[0-9-]+\.zip(\.(sha256|sig))?$ - [L]
  RewriteRule ^integrity/releases/[0-9]{4}-[0-9]{2}(-[0-9]{2})?/trentpower-fr-[0-9-]+\.tar\.gz(\.(sha256|sig))?$ - [L]
  RewriteRule ^integrity/releases/[0-9]{4}-[0-9]{2}(-[0-9]{2})?/integrity-redistributable\.json(\.sig)?$ - [L]
  RewriteRule ^integrity/releases/[0-9]{4}-[0-9]{2}(-[0-9]{2})?/EXCLUDED_FILES\.json(\.sig)?$ - [L]
  RewriteRule ^integrity/releases/[0-9]{4}-[0-9]{2}(-[0-9]{2})?/EXCLUDED_FILES\.txt$ - [L]
  RewriteRule ^integrity/releases/[0-9]{4}-[0-9]{2}(-[0-9]{2})?/release\.json(\.sig)?$ - [L]
  RewriteRule ^integrity/releases/[0-9]{4}-[0-9]{2}(-[0-9]{2})?/builds\.json(\.sig)?$ - [L]
  RewriteRule ^integrity/releases/2026-02/integrity\.json(\.sig)?$ - [L]
  RewriteRule ^integrity/releases/2026-02/assets/[A-Za-z0-9._/-]+\.(css|js|svg|woff2|png|jpe?g|webp|ico)$ - [L]

  # ----- bilingual /en/ + /fr/ static trees -----
  # reserved for the upcoming bilingual split. each language gets its
  # own static html subtree generated by the build, with its own
  # canonical url, hreflang alternates, og locale, and structured
  # metadata. when those trees ship, uncomment the rules below and
  # re-run tools/validate_public_exposure.py — the manifest needs
  # parallel `language_roots: ["/en/", "/fr/"]` for the validator to
  # mirror this allow.
  #
  # RewriteRule ^(en|fr)/?$ - [l]
  # RewriteRule ^(en|fr)/index\.html$ - [l]
  # RewriteRule ^(en|fr)/(privacy|security|integrity|verify|source|sw-reset)/?$ - [l]
  # RewriteRule ^(en|fr)/(privacy|security|integrity|verify|source|sw-reset)/index\.html$ - [l]
  # RewriteRule ^(en|fr)/security/acknowledgments/?$ - [l]
  # RewriteRule ^(en|fr)/security/acknowledgments/index\.html$ - [l]
  # RewriteRule ^(en|fr)/integrity/(releases|verify-locally)/?$ - [l]
  # RewriteRule ^(en|fr)/integrity/(releases|verify-locally)/index\.html$ - [l]
  # RewriteRule ^(en|fr)/integrity/releases/[0-9]{4}-[0-9]{2}(-[0-9]{2})?/?$ - [l]
  # RewriteRule ^(en|fr)/integrity/releases/[0-9]{4}-[0-9]{2}(-[0-9]{2})?/index\.html$ - [l]

  # ===== FINAL FALLBACK: deny =====
  # if a legitimate public asset reaches this rule, the allow-list
  # above is incomplete. update tools/generate_public_exposure_manifest.py
  # and the matching rewriterule, then re-run
  # tools/validate_public_exposure.py before deploying.
  RewriteRule . - [F,L]
</IfModule>

# ----------------------------------------------------------
# publication boundary
# ----------------------------------------------------------
# these headers define the browser-side security contract for the site.
#
# the default position is denial: no framing, no ambient permissions,
# no cross-origin trust, and no script execution outside the signed
# publication path.
#
# do not add:
# - analytics exceptions
# - third-party script origins
# - broad connect-src allowances
# - wildcard font, image, or script sources
# - cache immutability for active clean filenames
#
# if a feature appears to need one of these, redesign the feature first.
<IfModule mod_headers.c>
  Header always set X-Content-Type-Options "nosniff"
  Header always set X-Frame-Options "DENY"
  Header always set Referrer-Policy "no-referrer"
  Header always set X-Permitted-Cross-Domain-Policies "none"
  Header always set X-DNS-Prefetch-Control "off"

  Header always set Cross-Origin-Opener-Policy "same-origin"
  Header always set Cross-Origin-Embedder-Policy "require-corp"

  # exception: og images
  #
  # social scrapers need open graph images to be embeddable cross-origin.
  # keep the global same-origin resource policy, then make the /images/og/
  # exception explicit, narrow, and auditable.
  SetEnvIf Request_URI "^/images/og/" IS_OG_IMAGE=1
  Header always set Cross-Origin-Resource-Policy "same-origin" env=!IS_OG_IMAGE
  Header always set Cross-Origin-Resource-Policy "cross-origin" env=IS_OG_IMAGE

  # permissions policy
  #
  # sensitive browser capabilities are denied by default. the list is trimmed
  # to widely recognised directives so chrome does not warn about obsolete or
  # experimental feature names. `web-share=()`, `ambient-light-sensor=()`
  # and `bluetooth=()` were removed because chrome's parser treats them as
  # unrecognised (not in the standard permissions registry).
  Header always set Permissions-Policy "accelerometer=(), autoplay=(), camera=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), xr-spatial-tracking=()"

  Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"

  # content security policy
  #
  # default-deny csp. inline script is permitted only by explicit hash.
  # do not loosen this policy to make a broken release pass. fix the source,
  # regenerate the hash manifest, then redeploy.
  #
  # json-ld is emitted as application/ld+json and is not treated as executable
  # script by modern browsers, so it does not need a script-src hash.
  Header always set Content-Security-Policy "default-src 'none'; upgrade-insecure-requests; script-src 'self' 'sha256-fSBAwBjansn0JBzMIQdkp+F/c9KhbZoA20+yB/oLT0E=' 'sha256-lZ+4zCyA6aqvdIO6efMAlaNmrMGCB7twwkiPH87cDcE=' 'sha256-HrAeEk/58uT8a2LzPpipjzb6wWC8IUnsWeDQjZSTNtY=' 'sha256-3dleBgMyQvchfeJrEMqLXKsfGpCGKxEg6sa2/DCjI9w='; script-src-attr 'none'; style-src 'self'; style-src-attr 'none'; font-src 'self'; img-src 'self'; manifest-src 'self'; worker-src 'self'; connect-src 'self'; frame-src 'none'; child-src 'none'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'none'; require-trusted-types-for 'script'; trusted-types tp-i18n"

  Header always unset X-Powered-By
</IfModule>

# ----------------------------------------------------------
# identity headers
# ----------------------------------------------------------
# html responses advertise the canonical machine-readable person record.
# this keeps identity discovery close to the document surface without making
# non-html assets carry unnecessary metadata.
<IfModule mod_headers.c>
  <FilesMatch "\.html$">
    Header always append Link "<https://trentpower.fr/.well-known/person.json>; rel=\"alternate\"; type=\"application/ld+json\""
  </FilesMatch>
</IfModule>

# ----------------------------------------------------------
# language editions
# ----------------------------------------------------------
# language editions should be generated as static html, not translated at
# runtime. each edition should carry its own lang attribute, canonical url,
# hreflang alternates, open graph locale, and structured metadata.
#
# the server should remain language-neutral; the build should own language
# logic.

# ----------------------------------------------------------
# freshness and permanence
# ----------------------------------------------------------
# active pages must stay fresh. frozen release artefacts must remain durable.
# clean active filenames therefore revalidate; archived release assets are
# immutable and tied to their signed edition.
<IfModule mod_headers.c>
  # frozen archive assets
  #
  # immutable for one year. identified by url prefix rather than filename
  # pattern because FilesMatch matches base names only.
  #
  # this rule is set before the active-asset rules so the environment flag is
  # available to gate them.
  SetEnvIf Request_URI "^/integrity/releases/[0-9]{4}-[0-9]{2}/assets/" IS_ARCHIVE_ASSET=1
  Header always set Cache-Control "public, max-age=31536000, immutable" env=IS_ARCHIVE_ASSET

  # all active-asset cache-control rules below are gated with
  # env=!IS_ARCHIVE_ASSET so they do not collide with the archive rule when
  # FilesMatch also matches a base name inside an archive path.

  # html Â· never cache
  #
  # html is the live editorial surface. gandi's fronting varnish has previously
  # served stale html under weaker directives, so active documents use no-store.
  # assets may cache; pages must not.
  <FilesMatch "\.html$">
    Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0" env=!IS_ARCHIVE_ASSET
    Header set Pragma "no-cache" env=!IS_ARCHIVE_ASSET
    Header set Expires "0" env=!IS_ARCHIVE_ASSET
  </FilesMatch>

  # active css/js/sw Â· always revalidate
  #
  # clean filenames mean content may change between editions.
  <FilesMatch "^(styles|app|cite|sw)\.(css|js)$">
    Header set Cache-Control "no-cache, must-revalidate" env=!IS_ARCHIVE_ASSET
  </FilesMatch>
  <FilesMatch "^(print|cite)\.css$">
    Header set Cache-Control "public, max-age=86400, must-revalidate" env=!IS_ARCHIVE_ASSET
  </FilesMatch>

  # versioned content-addressed assets · immutable for one year
  #
  # urls carrying ?v=<edition>.<sha8> are content-addressed: if the
  # bytes change the build emits a new hash and the url becomes a
  # fresh cache key. browsers can treat them as truly immutable and
  # skip revalidation, which lighthouse rewards under "use efficient
  # cache lifetimes". the bare (unversioned) urls keep their short
  # ttl above so an emergency override still propagates.
  #
  # the is_versioned_asset env flag is set by the public exposure
  # allow-list gate at the top of this file (mod_rewrite runs before
  # mod_headers). moving the env-set rule here would put it after the
  # gate's [l] terminators and prevent it from firing.
  <FilesMatch "\.(css|js|woff2?)$">
    Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VERSIONED_ASSET
  </FilesMatch>

  # executable asset types
  #
  # nosniff is enforced globally, so javascript and css must be served with
  # explicit mime types. without this, browsers correctly refuse to execute
  # same-origin scripts that arrive as text/plain or application/octet-stream.
  #
  # this protects the policy while avoiding host-level mime-map drift.
  <FilesMatch "\.(js|mjs)$">
    Header set Content-Type "application/javascript; charset=utf-8" env=!IS_ARCHIVE_ASSET
  </FilesMatch>
  <FilesMatch "\.css$">
    Header set Content-Type "text/css; charset=utf-8" env=!IS_ARCHIVE_ASSET
  </FilesMatch>

  # active fonts
  #
  # clean filenames revalidate so a font replacement lands. frozen archive
  # fonts stay immutable via the IS_ARCHIVE_ASSET gate.
  <FilesMatch "\.woff2$">
    Header set Cache-Control "public, max-age=86400, must-revalidate" env=!IS_ARCHIVE_ASSET
    Header set Content-Type "font/woff2"
  </FilesMatch>

  # active icons Â· short cache, revalidate
  <FilesMatch "^(favicon\.svg|favicon\.ico|apple-touch-icon\.png|icon-(192|512)\.png)$">
    Header set Cache-Control "public, max-age=86400, must-revalidate" env=!IS_ARCHIVE_ASSET
  </FilesMatch>

  # active jpgs Â· short cache, revalidate
  #
  # active filenames are stable but their bytes can change between editions,
  # so they must not be immutable.
  <FilesMatch "^(trent-power|trent-power-og)\.jpg$">
    Header set Cache-Control "public, max-age=86400, must-revalidate" env=!IS_ARCHIVE_ASSET
  </FilesMatch>

  # identity and metadata files
  #
  # these files form the machine-readable surface of the publication.
  # short caching keeps crawlers efficient while allowing identity and release
  # metadata to update intentionally.
  <FilesMatch "^sitemap\.xml$">
    Header set Cache-Control "public, max-age=3600"
    Header set Content-Type "application/xml; charset=utf-8"
  </FilesMatch>
  <FilesMatch "^robots\.txt$">
    Header set Cache-Control "public, max-age=3600"
    Header set Content-Type "text/plain; charset=utf-8"
  </FilesMatch>
  <FilesMatch "^security\.txt$">
    Header set Cache-Control "public, max-age=86400"
    Header set Content-Type "text/plain; charset=utf-8"
  </FilesMatch>
  <FilesMatch "^(humans|pgp|ai-usage|assertion|statement|attribution)\.txt$">
    Header set Cache-Control "public, max-age=3600"
    Header set Content-Type "text/plain; charset=utf-8"
  </FilesMatch>
  <FilesMatch "^pgp-key\.asc$">
    Header set Cache-Control "public, max-age=3600"
    Header set Content-Type "application/pgp-keys"
  </FilesMatch>
  <FilesMatch "^attribution\.sig$">
    Header set Cache-Control "public, max-age=3600"
    Header set Content-Type "application/pgp-signature"
  </FilesMatch>
  <FilesMatch "^attestations\.json$">
    Header set Cache-Control "public, max-age=3600"
    Header set Content-Type "application/json; charset=utf-8"
  </FilesMatch>
  <FilesMatch "^manifest\.webmanifest$">
    Header set Cache-Control "public, max-age=3600"
    Header set Content-Type "application/manifest+json; charset=utf-8"
  </FilesMatch>
  <FilesMatch "^llms\.txt$">
    Header set Cache-Control "no-cache, must-revalidate"
    Header set Content-Type "text/plain; charset=utf-8"
  </FilesMatch>
  <FilesMatch "^person\.json$">
    Header set Cache-Control "no-cache, must-revalidate"
    Header set Content-Type "application/ld+json; charset=utf-8"
  </FilesMatch>
  <FilesMatch "^site-metadata\.json$">
    Header set Cache-Control "no-cache, must-revalidate"
    Header set Content-Type "application/json; charset=utf-8"
  </FilesMatch>
  <FilesMatch "^integrity\.json$">
    Header set Cache-Control "public, max-age=300, must-revalidate"
    Header set Content-Type "application/json; charset=utf-8"
  </FilesMatch>
  <FilesMatch "^integrity\.json\.sig$">
    Header set Cache-Control "public, max-age=300, must-revalidate"
    Header set Content-Type "application/pgp-signature"
  </FilesMatch>

  # svg assets
  #
  # avtive svgs revalidate so localised diagrams and editorial graphics update
  # on edition bumps. frozen archive svgs inherit the immutable archive rule.
  <FilesMatch "\.svg$">
    Header set Content-Type "image/svg+xml; charset=utf-8"
    Header set Cache-Control "public, max-age=86400, must-revalidate" env=!IS_ARCHIVE_ASSET
  </FilesMatch>

  # source transparency surface
  #
  # /source/*.txt files are plain-text mirrors of public files.
  # /source/source-manifest.json is the machine index.
  #
  # they revalidate so mirrors track the active site without a hard cache pin.
  SetEnvIf Request_URI "^/source/" IS_SOURCE_MIRROR=1
  <FilesMatch "\.txt$">
    Header set Content-Type "text/plain; charset=utf-8" env=IS_SOURCE_MIRROR
    Header set Cache-Control "no-cache, must-revalidate" env=IS_SOURCE_MIRROR
  </FilesMatch>
  <FilesMatch "^source-manifest\.json$">
    Header set Content-Type "application/json; charset=utf-8"
    Header set Cache-Control "no-cache, must-revalidate"
  </FilesMatch>
</IfModule>

# ----------------------------------------------------------
# exception: source reader
# ----------------------------------------------------------
# /source/view/ relaxes connect-src to self so the reader can fetch
# /source/*.txt files on demand.
#
# this is intentional: the reader is a transparency surface and the mirrored
# text files are already public. trusted-types adds tp-source-view for innerhtml
# of the tokenised code block. all other directives remain strict.
#
# note: LocationMatch is server-config-only and not valid in .htaccess.
# SetEnvIf plus conditional header directives provide the same route-specific
# effect here.
<IfModule mod_headers.c>
  SetEnvIf Request_URI "^/source/view/" IS_SOURCE_VIEW=1
  Header always unset Content-Security-Policy env=IS_SOURCE_VIEW
  Header always set Content-Security-Policy "default-src 'none'; upgrade-insecure-requests; script-src 'self' 'sha256-fSBAwBjansn0JBzMIQdkp+F/c9KhbZoA20+yB/oLT0E=' 'sha256-ong18574DRSzuyO+zjuDNWecbI/I+ojY9Bvoi6zBtvw=' 'sha256-HrAeEk/58uT8a2LzPpipjzb6wWC8IUnsWeDQjZSTNtY=' 'sha256-3dleBgMyQvchfeJrEMqLXKsfGpCGKxEg6sa2/DCjI9w='; script-src-attr 'none'; style-src 'self'; style-src-attr 'none'; font-src 'self'; img-src 'self'; manifest-src 'self'; worker-src 'self'; connect-src 'self'; frame-src 'none'; child-src 'none'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'none'; require-trusted-types-for 'script'; trusted-types tp-i18n tp-source-view" env=IS_SOURCE_VIEW
</IfModule>

# ----------------------------------------------------------
# exception: service worker and versioned enhancement assets
# ----------------------------------------------------------
<IfModule mod_headers.c>
  # service worker
  #
  # never immutable, must revalidate. csp is narrowed specifically for sw.js
  # so cache.addAll() can fetch same-origin assets. pages still receive the
  # strict global csp with connect-src none.
  #
  # explicit Content-Type and Service-Worker-Allowed are required because
  # nosniff is set globally. without an explicit JavaScript mime type, browsers
  # may refuse to parse sw.js as a worker script.
  <FilesMatch "^sw\.js$">
    Header always set Content-Type "application/javascript; charset=utf-8"
    Header always set Service-Worker-Allowed "/"
    Header set Cache-Control "no-cache, no-store, must-revalidate"
    Header always unset Content-Security-Policy
    Header always set Content-Security-Policy "default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'none'; base-uri 'none'"
  </FilesMatch>

  # source reader scripts
  #
  # short-cache and revalidate on each edition. these files are unversioned
  # because the reader keys them by query parameter.
  <FilesMatch "^(source-view|source-view-manifest)\.js$">
    Header set Content-Type "application/javascript; charset=utf-8"
    Header set Cache-Control "no-cache, must-revalidate"
  </FilesMatch>

  # verification data
  #
  # versioned files are immutable for one year. the unversioned alias revalidates
  # briefly so legacy bookmarks and offline fallbacks stay fresh.
  <FilesMatch "^verification-data\.[0-9]{4}-[0-9]{2}-[0-9]{2}\.[a-f0-9]+\.js$">
    Header set Content-Type "application/javascript; charset=utf-8"
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>
  <FilesMatch "^verification-data\.js$">
    Header set Content-Type "application/javascript; charset=utf-8"
    Header set Cache-Control "public, max-age=300, must-revalidate"
  </FilesMatch>

  # deferred enhancement bundle and full-font stylesheet
  #
  # same versioned-alias pattern as verification data. /app.js dynamically loads
  # the versioned /app-enhance.<edition>.<sha>.js after first paint; that file
  # then loads /fonts-full.<edition>.<sha>.css. unversioned aliases stay on disk
  # for offline and fallback use.
  <FilesMatch "^app-enhance\.[0-9]{4}-[0-9]{2}-[0-9]{2}\.[a-f0-9]+\.js$">
    Header set Content-Type "application/javascript; charset=utf-8"
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>
  <FilesMatch "^app-enhance\.js$">
    Header set Content-Type "application/javascript; charset=utf-8"
    Header set Cache-Control "no-cache, must-revalidate"
  </FilesMatch>
  <FilesMatch "^fonts-full\.[0-9]{4}-[0-9]{2}-[0-9]{2}\.[a-f0-9]+\.css$">
    Header set Content-Type "text/css; charset=utf-8"
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>
  <FilesMatch "^fonts-full\.css$">
    Header set Content-Type "text/css; charset=utf-8"
    Header set Cache-Control "no-cache, must-revalidate"
  </FilesMatch>
</IfModule>

# ----------------------------------------------------------
# error documents
# ----------------------------------------------------------
ErrorDocument 403 /403.html
ErrorDocument 404 /404.html
ErrorDocument 500 /500.html

# ----------------------------------------------------------
# directory and server disclosure controls
# ----------------------------------------------------------
Options -Indexes

# suppress the apache version banner from generated error pages.
# ServerTokens prod would also help but is server-config-only and would 500 the
# site if placed here, so it is intentionally omitted.
ServerSignature Off

# ----------------------------------------------------------
# allowed methods
# ----------------------------------------------------------
# static site: only get and head are legitimate. deny everything else at the
# apache level. apache may answer with 403 or 405 depending on build; either is
# acceptable. the invariant is: non-GET/HEAD methods must not return 200.
<LimitExcept GET HEAD>
  Require all denied
</LimitExcept>

# ----------------------------------------------------------
# validators
# ----------------------------------------------------------
# Last-Modified reflects filesystem mtime by default. etag derived from mtime
# and size keeps cache validation consistent without exposing inode details.
FileETag MTime Size

# ----------------------------------------------------------
# compression
# ----------------------------------------------------------
# brotli is preferred when available; gzip remains the fallback.
<IfModule mod_brotli.c>
  AddOutputFilterByType BROTLI_COMPRESS text/html text/css text/javascript application/javascript application/json application/ld+json application/jrd+json application/manifest+json image/svg+xml text/xml application/xml text/plain
</IfModule>
<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript application/json application/ld+json application/jrd+json application/manifest+json image/svg+xml text/xml application/xml text/plain
</IfModule>

# ----------------------------------------------------------
# private artefact denylist
# ----------------------------------------------------------
# defence-in-depth rules for files that should never become public, even if a
# local build, manual upload, or deployment script makes a mistake.

# dotfiles
#
# FilesMatch matches filenames, not directories. .well-known files are served by
# explicit public rules and do not start with a dot as filenames, so no exception
# is needed here.
<FilesMatch "^\.">
  Require all denied
</FilesMatch>

# operational and source file extensions
#
# archive extensions such as zip, tar, gz, and 7z are not denied here because
# signed snapshots are deliberately published under /integrity/releases/<edition>/.
# stray archives elsewhere are caught by the explicit upload allowlist in the
# build pipeline, not by these header rules.
<FilesMatch "\.(md|py|sh|bak|old|orig|tmp|log|sql|sqlite|db|env|ini|ya?ml|map|psd|ai|fig|sketch)$">
  Require all denied
</FilesMatch>

# explicit hidden config and package-file denial
#
# some of these are already caught by extension rules, but name-based rules are
# easier to audit and survive a future extension allowlist refactor. composer
# and package files are included even though this is a static site, protecting
# against accidental upload of a php or node project.
<Files ".user.ini">
  Require all denied
</Files>
<Files ".env">
  Require all denied
</Files>
<FilesMatch "^(\.user\.ini|\.env(\.|$)|\.htpasswd|composer\.(json|lock)|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml)$">
  Require all denied
</FilesMatch>

# private-name patterns
#
# filenames suggesting private or transactional content are denied. public
# artefacts such as pgp-key.asc and integrity.json.sig do not match these
# patterns.
<FilesMatch "(?i)(invoice|licen[cs]e|order|credential|password|secret)">
  Require all denied
</FilesMatch>

# ----------------------------------------------------------
# accidental-upload quarantine
# ----------------------------------------------------------
# sensitive directories are forbidden if accidentally uploaded. RedirectMatch 403
# returns forbidden without creating a redirect loop and is safe with varnish.
RedirectMatch 403 ^/docs/
RedirectMatch 403 ^/var/
RedirectMatch 403 ^/_archives/
RedirectMatch 403 ^/_licences/
RedirectMatch 403 ^/_private/
RedirectMatch 403 ^/private/
RedirectMatch 403 ^/node_modules/
RedirectMatch 403 ^/src/
RedirectMatch 403 ^/tests?/
RedirectMatch 403 ^/backups?/
RedirectMatch 403 ^/htdocs/htdocs/

# hidden vcs, editor, build, and template paths
RedirectMatch 403 ^/\.git(/|$)
RedirectMatch 403 ^/\.github(/|$)
RedirectMatch 403 ^/\.vscode(/|$)
RedirectMatch 403 ^/\.idea(/|$)
RedirectMatch 403 ^/tools(/|$)
RedirectMatch 403 ^/templates(/|$)