# =============================================================================
# COMET — application-root .htaccess
# =============================================================================
# Vite copies client/public/ verbatim into client/dist/, so this file lands at
# the root of the extracted install. It must work at ANY depth, on ANY origin,
# with ZERO editing — that is the whole packaging promise.
#
# Every block below cites a behaviour MEASURED on the target class of host
# (Apache 2.4 + cPanel EA4, mod_security active). Nothing here is speculative.
# Do not "tidy" a block without re-measuring it.
#
# NOT here, deliberately:
#   * RewriteBase       — a hardcoded base is exactly what breaks depth
#                         independence. MEASURED: a *relative* RewriteRule
#                         substitution resolves correctly at depths 1-8,
#                         including nested under a parent .htaccess that
#                         carries its own rewrite rules. No RewriteBase needed.
#   * AddHandler application/x-httpd-ea-phpNN
#                       — MEASURED: an AddHandler naming an ABSENT php version
#                         makes Apache serve raw PHP SOURCE with a 200, leaking
#                         whatever the file contains. The handler upgrade is a
#                         probe-then-commit action owned by api/handler-upgrade.php,
#                         which re-fetches a marker and rolls this file back if
#                         PHP did not actually execute. It is never a shipped
#                         default.
#   * ErrorDocument     — a relative ErrorDocument path is depth-dependent and an
#                         absolute one is not knowable at build time. The public
#                         404 is produced by the front controller instead.
# =============================================================================


# -----------------------------------------------------------------------------
# 1. Directory listing
# -----------------------------------------------------------------------------
# MEASURED: mod_autoindex is ON by default on this host. Without this line a
# request for any directory without an index file returns a browsable listing of
# the entire install — api/, package/, media/, the lot. This is not hardening
# theatre; it is the difference between a private install and a public one.
Options -Indexes


# -----------------------------------------------------------------------------
# 2. Directory index
# -----------------------------------------------------------------------------
# index.php first: when PHP works, the public site is server-rendered from the
# published snapshot (crawlable, works with JS off). index.html second: the
# static admin SPA shell, and the graceful-degradation path on a host with no
# working PHP. Apache picks whichever actually exists, so both the Stage-1
# static-only install and the finished install index correctly.
DirectoryIndex index.php index.html


# -----------------------------------------------------------------------------
# 3. MIME types
# -----------------------------------------------------------------------------
# MEASURED: .avif and .map are served with an EMPTY Content-Type by default on
# this host. An empty type on a sourcemap is merely untidy; an empty type on an
# image is a broken image, and an empty/wrong type on an ES module is a hard
# load failure in every browser. These lines are corrective, not decorative.
AddType text/javascript              .js
AddType text/javascript              .mjs
AddType text/css                     .css
AddType application/json             .json
AddType application/json             .map
AddType application/wasm             .wasm
AddType image/avif                   .avif
AddType image/webp                   .webp
AddType image/svg+xml                .svg
AddType image/x-icon                 .ico
AddType font/woff2                   .woff2
AddType font/woff                    .woff
AddType application/manifest+json    .webmanifest


# -----------------------------------------------------------------------------
# 4. Character set
# -----------------------------------------------------------------------------
# Site content is UTF-8 end to end (module -> PHP snapshot -> HTML).
AddDefaultCharset utf-8


# -----------------------------------------------------------------------------
# 5. Compression
# -----------------------------------------------------------------------------
# MEASURED: mod_deflate is active and .htaccess-controllable on this host.
# Only text-shaped types are listed. woff2, avif, webp, png, jpeg, mp4 and the
# wasm module are ALREADY compressed — re-deflating them burns CPU for ~0%.
<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html
  AddOutputFilterByType DEFLATE text/plain
  AddOutputFilterByType DEFLATE text/css
  AddOutputFilterByType DEFLATE text/xml
  AddOutputFilterByType DEFLATE text/javascript
  AddOutputFilterByType DEFLATE application/javascript
  AddOutputFilterByType DEFLATE application/json
  AddOutputFilterByType DEFLATE application/xml
  AddOutputFilterByType DEFLATE application/rss+xml
  AddOutputFilterByType DEFLATE application/manifest+json
  AddOutputFilterByType DEFLATE image/svg+xml
</IfModule>


# -----------------------------------------------------------------------------
# 6. Caching and response headers
# -----------------------------------------------------------------------------
# MEASURED: mod_headers is active and .htaccess-controllable on this host.
<IfModule mod_headers.c>

  # 6a. Hashed build assets -> immutable, one year.
  # Vite emits content-hashed filenames (index-DkT3xQ1a.js, index-a91Bc2Vd.css,
  # inter-latin-9Kd0zQpX.woff2). The hash IS the cache key: the filename changes
  # whenever the bytes change, so the response can never go stale and revalidation
  # is pure waste. Matched on the "-<hash>." shape rather than on a directory,
  # because .htaccess cannot open a <Directory> block.
  <FilesMatch "-[A-Za-z0-9_-]{8,}\.(?i:js|mjs|css|woff2?|ttf|png|jpe?g|gif|webp|avif|svg|ico)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>

  # 6b. The entry documents -> never cached.
  # index.html and index.php name the current hashed assets. If a proxy or
  # browser serves a stale entry document it will request build assets that were
  # deleted by the last re-unzip, and the app dies with a blank page. This is the
  # single most important caching line in the file.
  <FilesMatch "^(index\.html|index\.php|sitemap\.xml|robots\.txt)$">
    Header set Cache-Control "no-cache, no-store, must-revalidate"
  </FilesMatch>

  # 6b-ii. The runtime config -> never cached, on ANY status.
  # `Header always set`, not `Header set`: before the wizard runs, this URL is a
  # 404 (produced by rule 8b), and a proxy that caches that 404 would leave the
  # install reporting "unconfigured" after setup had already succeeded. `set`
  # alone only touches 2xx/3xx responses, so the 404 would go out uncontrolled.
  # The client also sends `cache: "no-store"`, but only one of those two is under
  # Comet's control on a shared host — belt and braces.
  <FilesMatch "^comet\.config\.json$">
    Header always set Cache-Control "no-cache, no-store, must-revalidate"
  </FilesMatch>

  # 6c. Never let a browser guess a content type.
  # Paired with block 3: with correct AddType lines plus nosniff, a MIME mistake
  # fails loudly and specifically instead of silently rendering something wrong.
  # It is also what makes the asset guard in block 8 behave predictably.
  Header always set X-Content-Type-Options "nosniff"

  # 6d. Do not leak the install URL — which is also the admin URL — to third
  # parties via the Referer header on outbound links and media.
  Header always set Referrer-Policy "strict-origin-when-cross-origin"

</IfModule>


# -----------------------------------------------------------------------------
# 7. Files that must never be served
# -----------------------------------------------------------------------------
# Belt-and-braces beside the 0600 file mode used for on-disk secrets. MEASURED:
# a 0644 JSON config in the web root was fetched over HTTPS and leaked its
# contents, while the same file at 0600 returned 403 (Apache serves static files
# as a different user than the FCGI PHP process). Mode is the primary control;
# this block is the second one.
#
# WHAT IS DENIED, AND WHY EACH ONE
#   *.token   — api/data/comet-secrets.token is the SpacetimeDB bearer token.
#               It is written 0600, but a host that ignores the mode (or a
#               future token file written 0644 by mistake) must still not serve
#               it. This deny is correct and measured-necessary.
#   .env*     — same argument for anything env-shaped an operator drops in.
#   VERSION   — not a secret, only a build fingerprint; nothing in Comet fetches
#               it over HTTP (scripts/build-zip.sh reads it from the filesystem),
#               so denying it costs nothing and gives a scanner one less answer.
#
# WHAT IS DELIBERATELY *NOT* DENIED
#   comet.config.json — it holds exactly two things the client needs before it
#     can do anything at all: the database NAME and the SpacetimeDB host URI.
#     Both are public by construction — the `site_public` table is world-readable
#     to anonymous callers from any origin (architecture §1.3, MEASURED), and the
#     browser must learn the name somehow before it can connect, so the name
#     travels to every visitor regardless. It carries NO token: secrets live in
#     api/data/*.token at 0600.
#     Denying it here was a REAL, MEASURED bug: `GET comet.config.json` returned
#     403 on every Apache install, src/lib/config.ts read that as "absent", and
#     the app routed to #/setup forever — including on installs with a perfectly
#     valid config on disk. The client now also distinguishes 403 from 404 and
#     falls back to `api/config.php?action=read`, but that fallback is the safety
#     net for hosts with a hostile parent .htaccess; it is not licence to break
#     the static path here on purpose.
#   comet.config.json.example — documentation, ships readable.
<FilesMatch "^(\.env.*|.*\.token|VERSION)$">
  Require all denied
</FilesMatch>


# -----------------------------------------------------------------------------
# 8. Rewrites
# -----------------------------------------------------------------------------
<IfModule mod_rewrite.c>
RewriteEngine On

  # 8a. Compute the install's URL prefix WITHOUT a RewriteBase.
  # In per-directory context mod_rewrite hands the rule a path with the install
  # prefix already stripped. Matching the full REQUEST_URI against that stripped
  # path recovers the prefix into %1 (e.g. "/comet/comet-1/"). This is used ONLY
  # as an existence test in 8c; every substitution below stays relative.
  RewriteCond %{REQUEST_URI}::$1 ^(.*?/)(.*)::\2$
  RewriteRule ^(.*)$ - [E=COMET_BASE:%1]

  # 8b. Missing static assets and missing endpoints must 404, not fall through.
  # MEASURED: without this rule a request for a missing /assets/foo.js is caught
  # by the SPA fallback and answered with the index HTML and a 200. The browser
  # then reports an opaque module/MIME error with no hint that the real problem
  # is a missing file — the worst possible failure to debug in the field. This
  # rule turns that into a plain, honest 404. It MUST come before 8c and 8d.
  #
  # `php` is in the list for the same reason, and MEASURED after 8d became a
  # redirect: on an install with no api/ directory, `GET api/config.php` was
  # redirected to the install root and answered 200 + index.html, so the client's
  # config fallback saw "an endpoint that returned HTML" instead of "no endpoint".
  # A .php file that is not on disk is not a public route — the front controller
  # (8c) is reached without an extension — so 404 is the only honest answer.
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule \.(?i:js|mjs|css|map|json|php|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|eot|mp4|webm|wasm)$ - [R=404,L]

  # 8c. Public site: hand unmatched paths to the PHP front controller.
  # THIS IS THE SEAM FOR STAGE 10. When index.php lands beside this file, every
  # deep path stops being "a stray" and becomes a real public URL: /about,
  # /blog/post-1, and a real 404 with a real 404 status, all server-rendered
  # from the published snapshot. Nothing below has to change for that to happen
  # — 8c fires first and carries [L], so 8d never sees those requests once the
  # front controller exists. 8d is the no-PHP branch, not a competing route.
  #
  # The substitution "index.php" is RELATIVE — MEASURED to resolve correctly at
  # depths 1-8 with no RewriteBase. The -f test is guarded by the prefix from 8a
  # so that on an install with no index.php (Stage-1 static build, or a host
  # where the front controller was removed) this rule simply does not fire and
  # 8d takes over. Degrading to the client-rendered SPA is safe; rewriting to a
  # file that does not exist is a 500.
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteCond %{DOCUMENT_ROOT}%{ENV:COMET_BASE}index.php -f
  RewriteRule . index.php [L]

  # 8d. No-PHP fallback: send strays to the install ROOT, do not fake them.
  #
  # MEASURED, and the reason this is a redirect rather than a rewrite: serving
  # index.html at .../admin/pages/3 returned 200 with a document byte-identical
  # to the root one — including its RELATIVE `./assets/index-<hash>.js`, which
  # the browser then resolved against the request directory as
  # .../admin/pages/assets/index-<hash>.js and got a 404 from rule 8b. The
  # result was a permanently blank page: a 200, no HTML error, and a module load
  # failure in the console. Serving a document that cannot fetch its own scripts
  # is strictly worse than not serving it.
  #
  # Redirecting to the install root fixes it completely: the browser re-requests
  # the root, `./assets/...` resolves correctly, the app boots, and HashRouter
  # takes over. Nothing is lost by dropping the path, because with `base: './'`
  # + HashRouter a deep PATH is never an app route (architecture §2.1) — every
  # real route lives after the `#`, which the server never sees.
  #
  # 302, never 301: on a Stage-10 install these same paths become real public
  # URLs served by index.php (8c). A permanent redirect cached in browsers and
  # proxies would keep bouncing them to the root long after the front controller
  # arrived, and nobody would be able to clear it.
  #
  # The `^/.*/$` guard: %{ENV:COMET_BASE} is recovered in 8a by string-matching
  # the per-directory path against REQUEST_URI. That match is the one part of
  # this file that can silently produce NOTHING — an empty prefix would turn the
  # line below into a redirect with no target, which is a 500. The guard sends
  # that case to 8d-ii instead. (MEASURED: it did not trigger even for a
  # percent-escaped path — `GET /a%20b/c` still redirected correctly to the
  # install root — so this is a belt for a brace that held.)
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteCond %{ENV:COMET_BASE} ^/.*/$
  RewriteRule . %{ENV:COMET_BASE} [R=302,L]

  # 8d-ii. Prefix unknown (percent-escaped path): serve the shell rather than
  # 404. This document has the same relative-asset problem described above if
  # the escaped path was deep, but it is the last resort on a branch ordinary
  # requests never reach, and a bookmarked root-level odd name still works.
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . index.html [L]

</IfModule>
