# 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

# ----------------------------------------------------------
# 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.
  #
  # clipboard-write is allowed for same-origin citation and verification copy
  # actions. clipboard-read remains denied.
  Header always set Permissions-Policy "accelerometer=(), autoplay=(), camera=(), clipboard-read=(), clipboard-write=(self), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), local-fonts=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), serial=(), sync-xhr=(), usb=(), web-share=(), window-management=(), 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-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 'none'; 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>

  # 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-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(/|$)