From 553d9e47593049f1b38e9b137cebb20045550863 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sat, 4 Jul 2026 22:49:06 +0200 Subject: [PATCH 01/12] build: force GPM index refresh before install; add remote-diag - remote-install-plugins now runs 'gpm index -f' before install so a fresh server doesn't rely on the stale package index bundled in the grav-admin zip (which can miss recently-published plugins). - Add read-only remote-diag target: Grav version, installed plugin versions, GPM view of the api plugin, api override keys, and log tail. Surfaced the prod rc.10 vs 2.0.4 mismatch that blocked the api plugin. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU --- Makefile | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 745c8c8..16f6df2 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \ remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \ remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \ - remote-content-status remote-clean remote-maintenance-on remote-maintenance-off + remote-content-status remote-clean remote-diag remote-maintenance-on remote-maintenance-off ENVS := test prod guard-env: @@ -153,7 +153,7 @@ remote-fetch-content: guard-env $(SSH) "git -C $(WEBROOT)/user fetch origin main && git -C $(WEBROOT)/user sparse-checkout disable && git -C $(WEBROOT)/user reset --hard origin/main" remote-install-plugins: guard-env - $(SSH) "cd $(WEBROOT) && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y" + $(SSH) "cd $(WEBROOT) && php bin/gpm index -f && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y" remote-update-plugins: guard-env $(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache" @@ -173,6 +173,15 @@ remote-content-status: guard-env remote-clean: guard-env $(SSH) "cd $(WEBROOT) && php bin/grav clearcache" +# Read-only health check: plugin install state, versions, key config, log tail. +remote-diag: guard-env + $(SSH) "cd $(WEBROOT) && \ + echo '=== Grav version ==='; php bin/grav --version 2>/dev/null; \ + echo '=== installed plugin versions ==='; for p in login admin2 flex-objects form api; do printf '%s: ' \"\$$p\"; grep -m1 '^version:' user/plugins/\$$p/blueprints.yaml 2>/dev/null || echo '(NOT installed)'; done; \ + echo '=== what does GPM say about api? ==='; php bin/gpm info api 2>&1 | head -12; \ + echo '=== api override (enabled/route/session) ==='; grep -nE '^enabled:|^route:|session_enabled:' user/config/plugins/api.yaml 2>&1; \ + echo '=== grav.log tail ==='; tail -8 logs/grav.log 2>/dev/null" + remote-maintenance-on: guard-env $(SSH) "bash -s on $(WEBROOT)" < scripts/server-maintenance.sh From 41e61fc14861964e1c97f9d1855d5bac59b02e60 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sat, 4 Jul 2026 22:54:59 +0200 Subject: [PATCH 02/12] build: per-environment Grav config override (prod Twig prod-mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prod needs twig.cache:true / debug:false / auto_reload:false, but those values break local dev, so they must not live in the committed system.yaml. Add a per-environment override deployed only to the server via Grav's environment://config (user/env//config/system.yaml): - deploy/env/prod/system.yaml — version-controlled source of truth. - make remote-apply-env-prod — writes it to the server + clears cache; resolves the host in-recipe (WEB_HOST || REMOTE_HOST) to avoid the recursive-make empty-export trap. - remote-diag now shows the deployed override + whether twig cache is populating, so prod-mode can be verified not assumed. - CLAUDE.md §1 rewritten: never flip committed system.yaml; use the override. Backlog updated (twig prod-mode + /post login-gate done; note stale .env.prod GRAV_VERSION and pending git-sync). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU --- CLAUDE.md | 34 ++++++++++++++++++++++++++++------ Makefile | 21 ++++++++++++++++++++- deploy/env/prod/system.yaml | 20 ++++++++++++++++++++ docs/working/backlog.md | 7 ++++--- 4 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 deploy/env/prod/system.yaml diff --git a/CLAUDE.md b/CLAUDE.md index bd59064..46430cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,15 +171,37 @@ Active settings in `user/config/system.yaml`: With these settings, Grav rebuilds templates on every request. This is intentionally slower but means you never need to flush cache after editing a `.html.twig` file. -### Production mode (not yet configured) +### Production mode (per-environment override) -Before going live, change in `user/config/system.yaml`: +Production needs different Twig settings than dev, but **never change the +committed `user/config/system.yaml`** — `twig.cache: false` (and `debug`/ +`auto_reload: true`) are the *intended dev values*, and committing prod values +there breaks local development for everyone. -| Setting | Prod value | Why | -|---|---|---| -| `twig.cache` | `true` | Templates compiled once and reused; safe because theme files don't change at runtime | +Instead, prod values are a **per-environment override** deployed to the server +only, via Grav's per-environment config (`environment://config`, keyed on the +request hostname): -**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in the trip page feed at `/trips/italy-2026-demo` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled. +| Setting | Dev (committed) | Prod (override) | Why prod differs | +|---|---|---|---| +| `twig.cache` | `false` | `true` | Compile templates once and reuse | +| `twig.debug` | `true` | `false` | No debug functions in prod | +| `twig.auto_reload` | `true` | `false` | Don't stat templates every request | + +- **Source of truth:** `deploy/env/prod/system.yaml` (version-controlled). +- **Deploy:** `make remote-apply-env-prod` — writes it to + `/user/env//config/system.yaml` and clears cache. It + deep-merges over the committed `system.yaml`. +- **Not synced by content:** `user/env/` is outside the content repo's tracked + folders, so `content-push` / git-sync / `remote-fetch-content` do **not** + restore it. **Re-run `make remote-apply-env-prod` after any fresh install.** +- The hostname segment defaults to `REMOTE_HOST`; override with `WEB_HOST` in + `.env.` if Grav sees a different host than the SSH host. + +**Pre-launch smoke test required:** with the prod override applied, submit one +post via `/post` and confirm the entry appears in the trip page feed +immediately. This verifies the cache-on-save plugin (BUG-001 fix) works +correctly with caching enabled. ### What the cache-on-save plugin handles diff --git a/Makefile b/Makefile index 16f6df2..ba2524d 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,10 @@ REMOTE_PORT ?= 22 SSH := ssh -p $(REMOTE_PORT) $(REMOTE_USER)@$(REMOTE_HOST) WEBROOT ?= $(REMOTE_HOME)/public_html SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config +# Hostname Grav uses to pick its per-environment config (user/env//). +# Defaults to the SSH host; override in .env. only if the web hostname +# Grav sees differs from the SSH host (e.g. an addon domain on a shared box). +WEB_HOST ?= $(REMOTE_HOST) # ── Environment guard + generated per-env remote targets ────────────────────── # Every remote-* target below gains `-test` / `-prod` variants, e.g. @@ -22,7 +26,8 @@ SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \ remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \ remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \ - remote-content-status remote-clean remote-diag remote-maintenance-on remote-maintenance-off + remote-content-status remote-clean remote-diag remote-apply-env \ + remote-maintenance-on remote-maintenance-off ENVS := test prod guard-env: @@ -173,6 +178,18 @@ remote-content-status: guard-env remote-clean: guard-env $(SSH) "cd $(WEBROOT) && php bin/grav clearcache" +# Deploy per-environment Grav config overrides to the server's +# user/env//config/ tree (deep-merged over the committed config). +# Source of truth: deploy/env/$(ENV)/system.yaml (version-controlled). This +# tree is outside the content repo, so it is NOT restored by content sync — +# re-run after any fresh install. +remote-apply-env: guard-env + @test -f deploy/env/$(ENV)/system.yaml || { echo "ERROR: missing deploy/env/$(ENV)/system.yaml"; exit 1; } + @host="$${WEB_HOST:-$(REMOTE_HOST)}"; \ + test -n "$$host" || { echo "ERROR: WEB_HOST/REMOTE_HOST unresolved"; exit 1; }; \ + $(SSH) "mkdir -p $(WEBROOT)/user/env/$$host/config && cat > $(WEBROOT)/user/env/$$host/config/system.yaml && cd $(WEBROOT) && php bin/grav clearcache" < deploy/env/$(ENV)/system.yaml; \ + echo "Applied deploy/env/$(ENV)/system.yaml -> $(WEBROOT)/user/env/$$host/config/system.yaml" + # Read-only health check: plugin install state, versions, key config, log tail. remote-diag: guard-env $(SSH) "cd $(WEBROOT) && \ @@ -180,6 +197,8 @@ remote-diag: guard-env echo '=== installed plugin versions ==='; for p in login admin2 flex-objects form api; do printf '%s: ' \"\$$p\"; grep -m1 '^version:' user/plugins/\$$p/blueprints.yaml 2>/dev/null || echo '(NOT installed)'; done; \ echo '=== what does GPM say about api? ==='; php bin/gpm info api 2>&1 | head -12; \ echo '=== api override (enabled/route/session) ==='; grep -nE '^enabled:|^route:|session_enabled:' user/config/plugins/api.yaml 2>&1; \ + echo '=== per-env override present? ==='; for f in user/env/*/config/system.yaml; do echo \"\$$f:\"; cat \"\$$f\" 2>/dev/null | grep -E 'cache:|debug:|auto_reload:'; done; \ + echo '=== twig cache populating? (non-empty => cache on) ==='; ls cache/twig/ 2>/dev/null | head -1 || echo '(empty)'; \ echo '=== grav.log tail ==='; tail -8 logs/grav.log 2>/dev/null" remote-maintenance-on: guard-env diff --git a/deploy/env/prod/system.yaml b/deploy/env/prod/system.yaml new file mode 100644 index 0000000..4244eb6 --- /dev/null +++ b/deploy/env/prod/system.yaml @@ -0,0 +1,20 @@ +# Production-only Grav config overrides. +# +# Deep-merged OVER the committed user/config/system.yaml via Grav's +# per-environment config mechanism: on the server this file is deployed to +# /user/env//config/system.yaml +# and Grav's `environment://config` stream (keyed on the request hostname) +# layers it on top of `user://config`. +# +# These values are deliberately NOT in the committed system.yaml because they +# would break local development (see CLAUDE.md §1 — dev keeps twig.cache:false +# so theme edits take effect immediately). Prod is the only place they apply. +# +# Deploy with: make remote-apply-env-prod +# The user/env/ tree is outside the content repo's tracked folders, so it is +# NOT restored by content-push / git-sync / remote-fetch-content — re-run the +# target above after any fresh install. +twig: + cache: true + debug: false + auto_reload: false diff --git a/docs/working/backlog.md b/docs/working/backlog.md index dbd82b1..788c0c4 100644 --- a/docs/working/backlog.md +++ b/docs/working/backlog.md @@ -6,11 +6,12 @@ Ideas and improvements not yet planned or scheduled. ## Production — remaining items -- [ ] Set `twig.cache: true` in `user/config/system.yaml` on the server (do not commit — breaks local dev) +- [x] Prod Twig prod-mode (`cache: true`, `debug/auto_reload: false`) — applied as a per-environment override via `make remote-apply-env-prod` (source: `deploy/env/prod/system.yaml`); committed `system.yaml` stays dev - [ ] Smoke test: submit one post via `/post`, confirm entry appears in dailies immediately (verifies cache-on-save with twig cache on) -- [ ] Confirm `/post` requires login — unauthenticated visitors must not be able to post +- [x] Confirm `/post` requires login — verified on prod (returns the login gate to unauthenticated visitors) - [ ] Register at carto.com and review terms for production traffic -- [ ] Japan & Korea 2026 trip page: set `date_start`, add `cover_image`, upload GPX route file(s) +- [ ] Update `GRAV_VERSION` in `.env.prod` to `2.0.4` (was stale `2.0.0-rc.10`; fixed on the running server via self-upgrade, but a future fresh install would repeat the RC) +- [ ] git-sync on prod: install, add encrypted token, apply `folders:` fix, enable after first content round-trip --- From 0e597c5329839b64c2b9ff88dfc944762b7c31c2 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sat, 4 Jul 2026 23:01:36 +0200 Subject: [PATCH 03/12] chore: bump user/ pin to API plugin config fix (b8a7bed) Pins the superproject to the tracked API plugin config (deployed to prod to restore Admin2 login). Already published to content repo main. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU --- user | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user b/user index 2e32a85..b8a7bed 160000 --- a/user +++ b/user @@ -1 +1 @@ -Subproject commit 2e32a8559c03fbc9223df7275672cefb465cbe12 +Subproject commit b8a7bedcdae6411b23c3429b4b1be753e61ca6e1 From 4aeff3975632c459d2f13c301789ce8f4c2bf0e3 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sat, 4 Jul 2026 23:12:27 +0200 Subject: [PATCH 04/12] fix(prod): stop garbage page from double Content-Encoding header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On this non-FastCGI host Grav's shutdown early-connection-close can't fastcgi_finish_request(), so it emits 'Content-Encoding: identity' to ask the webserver not to compress — but Apache mod_deflate compresses anyway and adds 'Content-Encoding: gzip'. Two conflicting headers => browsers render raw gzip bytes (garbage page). Surfaced when prod flipped to debug:false today (activated Grav's full output path). Fix in the prod env override: debugger.shutdown.close_connection:false skips the identity-header path so mod_deflate compresses cleanly (single header). Verified: one 'content-encoding: gzip', page renders correctly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU --- deploy/env/prod/system.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/deploy/env/prod/system.yaml b/deploy/env/prod/system.yaml index 4244eb6..a859bf9 100644 --- a/deploy/env/prod/system.yaml +++ b/deploy/env/prod/system.yaml @@ -18,3 +18,21 @@ twig: cache: true debug: false auto_reload: false + +# Compression / connection handling. +# +# This host is not FastCGI (no fastcgi_finish_request()), so Grav's shutdown +# "early connection close" falls back to emitting `Content-Encoding: identity` +# to ask the webserver not to compress. But Apache's mod_deflate compresses +# anyway and adds `Content-Encoding: gzip`, giving TWO conflicting headers — +# the browser can't decode the body and renders raw gzip bytes (a garbage +# page). Note: allow_webserver_gzip:true takes the SAME identity branch, so it +# does not help. The real fix is to disable the early-close path, so Grav never +# emits the bogus header and mod_deflate compresses cleanly (single header). +debugger: + shutdown: + close_connection: false +# Let the webserver own gzip; Grav does not compress or double-label. +cache: + gzip: false + allow_webserver_gzip: false From cc40c23ea89cea2e9c1dffe897cf2b0a363a891c Mon Sep 17 00:00:00 2001 From: Mischa Date: Sat, 4 Jul 2026 23:18:51 +0200 Subject: [PATCH 05/12] build: add remote-gpm-install (single out-of-plugins.txt package) For installing remote-only plugins like git-sync that are deliberately excluded from plugins.txt. Usage: make remote-gpm-install-prod PKG=git-sync Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU --- Makefile | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ba2524d..a703d86 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \ remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \ remote-content-status remote-clean remote-diag remote-apply-env \ - remote-maintenance-on remote-maintenance-off + remote-gpm-install remote-maintenance-on remote-maintenance-off ENVS := test prod guard-env: @@ -178,6 +178,13 @@ remote-content-status: guard-env remote-clean: guard-env $(SSH) "cd $(WEBROOT) && php bin/grav clearcache" +# Install a single GPM package on the server (e.g. git-sync, which is +# intentionally NOT in plugins.txt — it is remote-only). +# Usage: make remote-gpm-install-prod PKG=git-sync +remote-gpm-install: guard-env + @test -n "$(PKG)" || { echo "ERROR: set PKG="; exit 1; } + $(SSH) "cd $(WEBROOT) && php bin/gpm index -f && php bin/gpm install $(PKG) -y && php bin/grav clearcache" + # Deploy per-environment Grav config overrides to the server's # user/env//config/ tree (deep-merged over the committed config). # Source of truth: deploy/env/$(ENV)/system.yaml (version-controlled). This From d61de6f3f7f21be1d954a122184cc4c111cf0486 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sat, 4 Jul 2026 23:35:11 +0200 Subject: [PATCH 06/12] build: make git-sync toggle + diag env-path aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grav Admin saves plugin config into the active environment's config tree (user/env//config/plugins/) when an env override dir exists — so git-sync.yaml landed there, not in user/config/plugins/. Update the toggle script to take a WEBROOT and search both locations (env path first), and update remote-git-sync-disable/enable to pass WEBROOT. remote-diag now surfaces git-sync config (secrets redacted) from either location. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU --- Makefile | 5 +++-- scripts/git-sync-toggle.sh | 21 +++++++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index a703d86..43af199 100644 --- a/Makefile +++ b/Makefile @@ -167,10 +167,10 @@ remote-upgrade-grav: guard-env $(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache" remote-git-sync-disable: guard-env - $(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' false" < scripts/git-sync-toggle.sh + $(SSH) "bash -s -- '$(WEBROOT)' false" < scripts/git-sync-toggle.sh remote-git-sync-enable: guard-env - $(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' true" < scripts/git-sync-toggle.sh + $(SSH) "bash -s -- '$(WEBROOT)' true" < scripts/git-sync-toggle.sh remote-content-status: guard-env $(SSH) "cd $(WEBROOT)/user && git status --short && echo '--- config diff ---' && git diff -- config/" @@ -206,6 +206,7 @@ remote-diag: guard-env echo '=== api override (enabled/route/session) ==='; grep -nE '^enabled:|^route:|session_enabled:' user/config/plugins/api.yaml 2>&1; \ echo '=== per-env override present? ==='; for f in user/env/*/config/system.yaml; do echo \"\$$f:\"; cat \"\$$f\" 2>/dev/null | grep -E 'cache:|debug:|auto_reload:'; done; \ echo '=== twig cache populating? (non-empty => cache on) ==='; ls cache/twig/ 2>/dev/null | head -1 || echo '(empty)'; \ + echo '=== git-sync config (secrets redacted) ==='; grep -vaiE 'password|token|secret' user/config/plugins/git-sync.yaml user/env/*/config/plugins/git-sync.yaml 2>/dev/null; \ echo '=== grav.log tail ==='; tail -8 logs/grav.log 2>/dev/null" remote-maintenance-on: guard-env diff --git a/scripts/git-sync-toggle.sh b/scripts/git-sync-toggle.sh index 3f0268c..f1b8189 100755 --- a/scripts/git-sync-toggle.sh +++ b/scripts/git-sync-toggle.sh @@ -1,13 +1,22 @@ #!/bin/bash set -e -FILE="$1" +# Enable/disable the git-sync plugin by flipping `enabled:` in its config. +# +# git-sync.yaml may live in the per-environment config tree +# (user/env//config/plugins/) when an env override dir exists — Grav's +# Admin saves config there when an environment is active — otherwise in the +# standard user/config/plugins/. Search both, env path first. +WEBROOT="$1" STATE="$2" -: "${FILE:?usage: git-sync-toggle.sh }" -: "${STATE:?usage: git-sync-toggle.sh }" +: "${WEBROOT:?usage: git-sync-toggle.sh }" +: "${STATE:?usage: git-sync-toggle.sh }" -if [ ! -f "$FILE" ]; then - echo "ERROR: $FILE not found — is git-sync installed on this server?" >&2 +FILE=$(ls "$WEBROOT"/user/env/*/config/plugins/git-sync.yaml \ + "$WEBROOT"/user/config/plugins/git-sync.yaml 2>/dev/null | head -1) + +if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then + echo "ERROR: git-sync.yaml not found under $WEBROOT — is git-sync installed/configured?" >&2 exit 1 fi @@ -17,4 +26,4 @@ else printf 'enabled: %s\n' "$STATE" | cat - "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE" fi -echo "git-sync now: $(grep -E '^enabled:' "$FILE")" +echo "git-sync now: $(grep -E '^enabled:' "$FILE") ($FILE)" From 425c7b8e201e4f5428c45b2e98461e96dd422d4d Mon Sep 17 00:00:00 2001 From: Mischa Date: Sat, 4 Jul 2026 23:35:11 +0200 Subject: [PATCH 07/12] chore: bump user/ pin to versions.yaml untrack (4aa9ae9) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU --- user | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user b/user index b8a7bed..4aa9ae9 160000 --- a/user +++ b/user @@ -1 +1 @@ -Subproject commit b8a7bedcdae6411b23c3429b4b1be753e61ca6e1 +Subproject commit 4aa9ae9b23d4032f016e6f84d179b632aac29662 From dcf9c1345549c22c8916924490ee78d13b870290 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sat, 4 Jul 2026 23:40:11 +0200 Subject: [PATCH 08/12] docs: document Grav env-tree config save behavior + git-sync env path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once user/env// exists, Grav Admin saves ALL config (system + plugin) into that env tree, not user/config/ — so prod's git-sync.yaml lives at the env path, and Admin-on-server config edits are server-only (not committed, not synced). Documented the footgun in CLAUDE.md §1 (session-loaded) and rewrote git-sync-notes.md: env-path location, v3.4.4 folders format, gitignore table (incl. versions.yaml + security-private.php TODO), and fresh-server enable procedure. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU --- CLAUDE.md | 18 +++++++++ docs/working/git-sync-notes.md | 69 +++++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 46430cd..53a1c9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,6 +198,24 @@ request hostname): - The hostname segment defaults to `REMOTE_HOST`; override with `WEB_HOST` in `.env.` if Grav sees a different host than the SSH host. +> **⚠️ Once `user/env//` exists, Grav's Admin saves ALL config there.** +> Creating the env override dir has a site-wide side effect: Grav's Admin panel +> writes **every** config change (system *and* plugin) into the active +> environment's config tree — e.g. editing a plugin on prod saves to +> `user/env/intotheeast.com/config/plugins/.yaml`, **not** +> `user/config/plugins/.yaml`. Consequences you must remember: +> - Config edited via **Admin on the server is server-only**: `user/env/` is +> outside the content repo's tracked folders, so it is **not committed** and +> **not synced by git-sync** (which syncs only `pages`/`config`/`themes`). +> Good for secrets — `git-sync.yaml` (token) safely lives at the env path — +> but it means prod Admin config edits silently do **not** reach Gitea/local. +> - When reading/writing server config, check **both** `user/config/...` and +> `user/env//config/...` (env wins). Server tooling must search the env +> path first — see `scripts/git-sync-toggle.sh` and `make remote-diag`. +> - Repo-authored config (`user/config/...` via `make content-push`) still +> applies everywhere; the env tree only holds per-host overrides + Admin-on- +> server edits. Full details: `docs/working/git-sync-notes.md`. + **Pre-launch smoke test required:** with the prod override applied, submit one post via `/post` and confirm the entry appears in the trip page feed immediately. This verifies the cache-on-save plugin (BUG-001 fix) works diff --git a/docs/working/git-sync-notes.md b/docs/working/git-sync-notes.md index 9819723..b1c5045 100644 --- a/docs/working/git-sync-notes.md +++ b/docs/working/git-sync-notes.md @@ -1,17 +1,41 @@ # Git Sync Plugin — Setup Notes -## Folders YAML bug +## ⚠️ Config lives in the ENVIRONMENT tree, not `user/config/` (IMPORTANT) -The plugin UI always saves the folders field as a single comma-string: +Prod has a per-environment override directory `user/env//config/` +(created for Twig prod-mode — see CLAUDE.md §1). **A crucial Grav side effect: +once that env dir exists, the Admin panel saves ALL config changes — system and +plugin — into the active environment's config tree**, not `user/config/`. -```yaml -folders: - - 'pages,config,themes' +So on prod, `git-sync.yaml` (configured via Admin) lives at: + +``` +user/env/intotheeast.com/config/plugins/git-sync.yaml ← here (env tree) +user/config/plugins/git-sync.yaml ← NOT here ``` -But the plugin code iterates the array expecting separate items. This causes `git status pages,config,themes` to be passed as a single path, so git sees nothing to commit and sync silently does nothing. +Why this matters: -**Fix:** Edit `user/config/plugins/git-sync.yaml` directly: +- **Server-only, not synced, not committed.** `user/env/` is outside the + content repo's tracked folders (`pages`/`config`/`accounts`/`themes`) and is + not one of git-sync's synced folders (`pages`/`config`/`themes`). So config + saved via Admin *on the server* never reaches Gitea or local. This is ideal + for the git-sync token (it stays server-only) but means **prod Admin config + edits silently diverge** — author durable config in the repo, not prod Admin. +- **Look in both places.** When inspecting/toggling server config, check + `user/config/plugins/.yaml` **and** + `user/env//config/plugins/.yaml` (env wins). +- **Tooling is env-path-aware.** `scripts/git-sync-toggle.sh` takes a `WEBROOT` + and searches `user/env/*/config/plugins/git-sync.yaml` first, then + `user/config/plugins/git-sync.yaml`. `make remote-git-sync-disable/enable-` + and `make remote-diag-` use it. + +## Folders format + +Older plugin versions' UI saved the `folders` field as a single comma-string +(`- 'pages,config,themes'`), which the plugin iterated as one path, so sync +silently did nothing. **git-sync v3.4.4 (installed on prod 2026-07-04) saves it +correctly** as separate list items: ```yaml folders: @@ -20,8 +44,33 @@ folders: - themes ``` -Never use the Admin UI to change folders — it will rewrite the broken format. +If you see the comma-string form on an older version, fix it by editing +`git-sync.yaml` directly (at whichever path it lives — see above); do not +re-save folders via the Admin UI on the buggy version. -## Files to gitignore +## Per-install / secret files — must be gitignored (never synced) -`user/config/plugins/git-sync.yaml` contains an encrypted token and is server-specific. `user/config/security.yaml` contains Grav nonces/salts, also server-specific. Both are in `.gitignore` and must never be committed. +git-sync syncs the `config/` folder, so any per-install or secret file tracked +there would get pushed to Gitea and pollute every environment. Keep these out +of the content repo (all in `user/.gitignore`): + +| File | Why | +|---|---| +| `config/plugins/git-sync.yaml` | encrypted token; server-specific (also lives at env path on prod) | +| `config/plugins/api-private.php` | API JWT secret | +| `config/security.yaml` | Grav nonces/salts (legacy location) | +| `config/versions.yaml` | per-install Grav schema-migration state — differs per env (dev 2.0.4, prod 2.0.7); Grav regenerates it. Untracked 2026-07-04. | +| `config/security-private.php` | **TODO:** committed salt secret; should be gitignored like `api-private.php` (deferred — untracking resets server sessions) | + +## git-sync config summary (prod, 2026-07-04) + +- `repository: https://git.gorinskat.nl/m038/intotheeast-com-content.git`, + `branch: main`, HTTPS + token auth (SSH is Tailscale-only). +- `sync.direction: both`, `on_save/on_delete/on_media: true` → prod Admin edits + and `/post` push to Gitea; content-repo pushes pull to prod **via webhook** + (`/_git-sync`). The webhook is configured in Gitea repo settings (same secret + as the test instance). +- **Before enabling on a fresh server**, reset the synced folders clean + (`make remote-fetch-content-`) so no install-time drift (e.g. a stale + `versions.yaml`) gets pushed on the first sync. Toggle with + `make remote-git-sync-disable/enable-`. From 3ad055d4a8084431da7f199cdcf0a848622bbf38 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sat, 4 Jul 2026 23:53:11 +0200 Subject: [PATCH 09/12] docs: capture Grav double Content-Encoding garbage-page fix (ce-compound) Non-FastCGI host + Apache mod_deflate: Grav's shutdown early-close emits Content-Encoding: identity while mod_deflate adds gzip -> two conflicting headers -> browsers render raw gzip bytes. Fix: debugger.shutdown.close_ connection:false in the prod env override. Documents the dead-end (cache.gzip/allow_webserver_gzip take the same code path) and the browser-style curl + screenshot verification. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU --- ...av-double-content-encoding-garbage-page.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md diff --git a/docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md b/docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md new file mode 100644 index 0000000..975d623 --- /dev/null +++ b/docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md @@ -0,0 +1,122 @@ +--- +title: "Grav garbage page from two conflicting Content-Encoding headers (identity + gzip)" +date: 2026-07-04 +category: docs/solutions/integration-issues +module: "grav / production deploy" +problem_type: integration_issue +component: tooling +severity: high +symptoms: + - "Homepage and every dynamic Grav page render as full-screen binary/mojibake garbage in the browser" + - "curl without Accept-Encoding returns clean HTML, so the page looks fine from a naive curl" + - "curl -H \"Accept-Encoding: gzip\" (a browser-style request) returns raw gzip bytes" + - "Response carries two conflicting headers: content-encoding: identity AND content-encoding: gzip" + - "Only appeared after switching prod to production Twig mode (twig.debug: false)" +root_cause: config_error +resolution_type: config_change +related_components: + - "Apache mod_deflate" + - "DirectAdmin shared host" + - "Grav shutdown handler (system/src/Grav/Common/Grav.php)" + - "deploy/env/prod/system.yaml" + - "make remote-apply-env-prod" +tags: + - grav + - content-encoding + - gzip + - mod-deflate + - fastcgi-finish-request + - apache + - production-deploy + - twig-debug +--- + +# Grav garbage page from two conflicting Content-Encoding headers (identity + gzip) + +## Problem + +Grav renders every dynamic page as binary garbage in the browser because it emits two conflicting `Content-Encoding` headers. The response body is valid gzip, but because the server advertises both `content-encoding: identity` and `content-encoding: gzip`, the browser cannot decide how (or whether) to inflate it, and paints the raw compressed bytes to screen. Static assets are unaffected — only Grav's own dynamically generated pages are broken. The problem surfaced only after switching the site to production Twig mode (`twig.debug: false`). + +## Symptoms + +- The homepage and all dynamic Grav pages show a full screen of binary/mojibake characters in the browser (completely unreadable). The surrounding HTML shell and static assets are fine. +- A naive `curl https://site/` (with **no** `Accept-Encoding` header) returns clean, correct HTML — so a quick curl sanity check looks perfectly healthy and completely hides the bug. +- A browser-style request exposes it. `curl -H "Accept-Encoding: gzip" -D - -o /dev/null https://site/` shows **two** `Content-Encoding` response headers: + ``` + content-encoding: identity + content-encoding: gzip + ``` + The body is valid gzip and `gunzip`s to the correct HTML. +- A static asset served by the webserver alone (e.g. a CSS/JS file) shows a **single** clean `content-encoding: gzip` under the same request — confirming the webserver's gzip is fine and the duplication is Grav-originated. +- The bug only appeared after switching the site to production Twig mode (`twig.debug: false`), which activates Grav's full shutdown/output path. + +## What Didn't Work + +- **First fix attempt: `cache.gzip: false` + `allow_webserver_gzip: true`.** This was the key dead end. It had **no effect** — the duplicated headers were unchanged. Reading Grav's source explained why: the branch that emits the bogus header fires on `if ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip'))`. Setting `allow_webserver_gzip: true` satisfies the **same** `||` condition, so Grav still takes the identical `header('Content-Encoding: identity')` code path. The two knobs that *look* like they control this are both on the wrong side of the problem. +- **Verifying with a naive `curl` (no `Accept-Encoding: gzip`).** This hid the problem entirely, because the server only compresses when the client advertises gzip support. Any healthcheck that omits `Accept-Encoding: gzip` reports a false "all clear." Reproduce it the way a browser does — send `Accept-Encoding: gzip`, or take a real headless-browser (Playwright) screenshot. + +## Solution + +Root the fix in Grav's shutdown handler, `system/src/Grav/Common/Grav.php` (~lines 615–631): + +```php +if ($config->get('system.debugger.shutdown.close_connection', true)) { + $success = function_exists('fastcgi_finish_request') ? @fastcgi_finish_request() : false; + if (!$success) { + if (!ini_get('zlib.output_compression')) { + if ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip')) { + header('Content-Encoding: identity'); // <-- the bogus header + } elseif (function_exists('apache_setenv')) { + @apache_setenv('no-gzip', '1'); + } else { + header('Content-Encoding: none'); + } + header('Content-Length: ' . ob_get_length()); + } + header('Connection: close'); + ob_end_flush(); + } +} +``` + +The entire problematic block is gated by `system.debugger.shutdown.close_connection` (default `true`). Disable it so the whole branch is skipped and Grav never touches `Content-Encoding` at all. + +In this project it is applied as a **per-environment (prod-only) override** so local dev is untouched — `deploy/env/prod/system.yaml`, deployed via `make remote-apply-env-prod`: + +```yaml +debugger: + shutdown: + close_connection: false +``` + +Verify — the response must show **exactly one** `content-encoding`: + +```bash +curl -s -D - -o /dev/null -H "Accept-Encoding: gzip" https://site/ | grep -i content-encoding +# content-encoding: gzip +``` + +Then take a screenshot of the rendered page to confirm it displays correctly. A `curl`-without-gzip check is **not** sufficient proof — it would have passed even while the bug was live. + +## Why This Works + +`debugger.shutdown.close_connection` (default `true`) makes Grav flush the full response and close the connection to the browser **early**, so slow shutdown tasks (logging, debugger teardown) don't keep the visitor waiting. On a FastCGI/PHP-FPM host, Grav does this cleanly via `fastcgi_finish_request()` and never manipulates headers — which is why the bug is invisible on most stacks. + +On a **non-FastCGI** host (LiteSpeed, suPHP, plain CGI), `fastcgi_finish_request()` does not exist, so `$success` is `false` and Grav falls back to closing the connection *manually*. To do that it must set an explicit `Content-Length`, and to keep that length honest it tries to tell the webserver "do not compress this body" — which, on the `cache.gzip`/`allow_webserver_gzip` branch, it expresses as `header('Content-Encoding: identity')`. + +But `identity` is not a real content transformation and is **not** a recognized "suppress compression" signal to Apache `mod_deflate`. `mod_deflate` ignores it, compresses the body anyway, and appends its **own** `Content-Encoding: gzip`. The response now carries two contradictory `Content-Encoding` headers (`identity` and `gzip`). Browsers cannot reconcile the contradiction, fail to inflate the gzip stream, and render the raw compressed bytes — the "binary garbage" screen. + +Setting `close_connection: false` means Grav never enters the manual connection-close path, never emits `Content-Encoding: identity`, and leaves the webserver as the **sole** authority on compression. The webserver then sends a single, correct `Content-Encoding: gzip`, and the browser inflates and renders normally. + +## Prevention + +- On **non-FastCGI PHP hosts with server-side gzip** (Apache `mod_deflate`, LiteSpeed), set `debugger.shutdown.close_connection: false` for that environment. Deliver it as a **per-environment override**, never by editing the committed `system.yaml` (which would silently change dev behavior too). +- **Reproduce compression bugs the way a browser sees them.** Always test with `curl -H "Accept-Encoding: gzip" -D -` and/or a headless-browser screenshot. A plain `curl` negotiates no compression and silently masks encoding bugs. +- **Health check:** dynamic pages must return **exactly one** `Content-Encoding` header. Two of them (`identity` + `gzip`) is the unambiguous signature of this bug. Add this assertion to any smoke test. +- **Know the trigger.** This can stay completely hidden in development mode and only appear once a site is switched to production mode (`twig.debug: false`), which activates Grav's full shutdown/output path. Re-run the browser-style compression check as part of any production cutover. + +## Related + +- **Grav per-environment override mechanism** — `deploy/env/prod/system.yaml` applied via `make remote-apply-env-prod`, described in `CLAUDE.md` §1 ("Production mode — per-environment override"). The pattern that lets a prod-only setting like `debugger.shutdown.close_connection: false` ship without mutating the committed dev `system.yaml`. +- **`docs/working/git-sync-notes.md`** — documents the `user/env//config/` override tree (where this fix physically lives on the server) and the caveat that config saved via Admin on the server stays server-only. +- **`docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md`** — a sibling gotcha from the same 2026-07-04 Grav 2.0.4 production cutover (account permission provisioning); different root cause, same deploy. From 66438836de15ce1c7ea37c9cb4c4c3af9802c888 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sat, 4 Jul 2026 23:59:44 +0200 Subject: [PATCH 10/12] docs: capture stale-GRAV_VERSION api-plugin-install failure (ce-compound) Fresh prod install ran Grav rc.10 (stale .env.prod GRAV_VERSION), so GPM would not serve the api plugin (needs >=2.0.4); admin2 (auth via /api/v1) then 404'd login silently. Documents the dead ends (deploying api config, gpm index -f, same-channel assumption) and the fix (self-upgrade core + reinstall + bump .env.prod). Adds reciprocal 'same 2026-07-04 cutover' cross-links across the three sibling deploy gotchas. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU --- ...av-double-content-encoding-garbage-page.md | 1 + ...-grav-version-blocks-api-plugin-install.md | 94 +++++++++++++++++++ ...new-user-grants-api-not-admin-on-admin2.md | 1 + 3 files changed, 96 insertions(+) create mode 100644 docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md diff --git a/docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md b/docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md index 975d623..e7db895 100644 --- a/docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md +++ b/docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md @@ -119,4 +119,5 @@ Setting `close_connection: false` means Grav never enters the manual connection- - **Grav per-environment override mechanism** — `deploy/env/prod/system.yaml` applied via `make remote-apply-env-prod`, described in `CLAUDE.md` §1 ("Production mode — per-environment override"). The pattern that lets a prod-only setting like `debugger.shutdown.close_connection: false` ship without mutating the committed dev `system.yaml`. - **`docs/working/git-sync-notes.md`** — documents the `user/env//config/` override tree (where this fix physically lives on the server) and the caveat that config saved via Admin on the server stays server-only. +- **`docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`** — sibling from the same cutover: Admin2 login failed because a stale `GRAV_VERSION` installed an rc core and GPM wouldn't serve the `api` plugin. Different root cause, same deploy. - **`docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md`** — a sibling gotcha from the same 2026-07-04 Grav 2.0.4 production cutover (account permission provisioning); different root cause, same deploy. diff --git a/docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md b/docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md new file mode 100644 index 0000000..b58c11c --- /dev/null +++ b/docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md @@ -0,0 +1,94 @@ +--- +title: "Admin2 login fails silently because a stale GRAV_VERSION installs an rc core below the api plugin's version floor" +date: 2026-07-04 +category: docs/solutions/integration-issues +module: "grav / production deploy / plugin install" +problem_type: integration_issue +component: authentication +severity: high +symptoms: + - "Admin2 login at /admin silently fails: button disables then re-enables, no visible error, nothing written to grav.log" + - "admin2 SPA background login POST to /api/... returns 404" + - "/api/v1/pages returns 404 on prod but 401 locally (api plugin route not registered)" + - "user/plugins/api directory does not exist on prod (plugin never installed)" + - "gpm install api reports 'These packages were not found on Grav: api' even after gpm index -f" +root_cause: config_error +resolution_type: environment_setup +related_components: + - "gpm" + - "admin2 plugin" + - "api plugin" + - "scripts/server-install.sh" + - "Makefile remote targets" + - ".env.prod" +tags: + - grav + - gpm + - admin2 + - api-plugin + - plugin-dependency + - version-compatibility + - production-deploy + - env-config +--- + +# Admin2 login fails silently because a stale GRAV_VERSION installs an rc core below the api plugin's version floor + +## Problem + +On a fresh Grav production install, Admin2 login fails silently because the `api` plugin — which Admin2 authenticates through — never installed. GPM refused to serve it: a stale `GRAV_VERSION` in `.env.prod` had installed Grav `2.0.0-rc.10`, and the `api` plugin requires Grav core `>=2.0.4`. GPM filters offered packages by the installed core version, so on an rc.10 core the `api` plugin was excluded from results entirely and reported as "not found." Admin2 was present (and depends on `api`), but its login POST hit an `/api/...` route that was never registered, so authentication silently 404'd before it ever reached Grav's auth layer. + +## Symptoms + +- Admin login at `/admin` silently fails: the login button disables briefly, re-enables, and shows no error. **Nothing appears in `logs/grav.log`** — a wrong password *would* log a failed-attempt warning, so its absence means auth was never reached. +- The Admin2 SPA's background login request (to an `/api/...` endpoint) returns **HTTP 404** with `content-type: application/json`. +- `GET /api/v1/pages` returns **404** on prod, but **401 Unauthorized** on the working local install — i.e. the api route isn't registered on prod at all. +- `ls user/plugins/api` on the server: **No such file or directory** — the plugin was never installed, even though `admin2` (which depends on it) was. +- `php bin/gpm install ... api -y` → `"These packages were not found on Grav: api"`, even after `php bin/gpm index -f`. + +## What Didn't Work + +- **Committing/deploying the api plugin config** (`enabled` / `route` / `session_enabled`, moved from the untracked `user/plugins/api/api.yaml` into the tracked `user/config/plugins/api.yaml`). This was a real, necessary fix for a *different* latent problem, but it did not fix login: you cannot configure a plugin that isn't installed. Still 404. +- **Forcing a GPM index refresh** (`php bin/gpm index -f`). No effect. "Package not found" here is not a stale-index problem — GPM filters the packages it offers by the installed Grav **core** version, and rc.10 is below the api plugin's `>=2.0.4` requirement, so `api` is excluded from results entirely. +- **Assuming "same channel = same availability."** Local (Grav 2.0.4, `stable` channel) found `api` via `gpm info api`; prod (also `stable`) reported it "not found." The channel was identical — the difference was the Grav **core** version, which silently filtered the plugin out. + +## Solution + +The real cause is that prod was running the wrong Grav core. `scripts/server-install.sh` downloads `grav-admin-v${GRAV_VERSION}.zip`, and `.env.prod` still carried the stale pre-upgrade `GRAV_VERSION=2.0.0-rc.10`. + +1. Upgrade the Grav core in place to stable (rc.10 → 2.0.7): + ```bash + make remote-upgrade-grav-prod # php bin/gpm self-upgrade -y && php bin/grav cache + ``` +2. Install the plugins now that a compatible core is present (the api plugin resolves): + ```bash + make remote-install-plugins-prod # php bin/gpm index -f && php bin/gpm install -y + # => "Preparing to install API [v1.0.8] ... Success!" + ``` +3. Clear cache, then verify the api route is live and login works: + ```bash + make remote-clean-prod + curl -s -o /dev/null -w '%{http_code}\n' https://site/api/v1/pages + # 401 (was 404) => plugin installed + routed + ``` +4. **Prevent recurrence:** update `.env.prod` to `GRAV_VERSION=2.0.4` so a future *fresh* install doesn't reinstall rc.10 (self-upgrade fixed the running server, not the env file). Keep the api plugin's functional config in the tracked `user/config/plugins/api.yaml` so it deploys on a clean clone. + +## Why This Works + +GPM (Grav Package Manager) only offers a plugin version whose declared Grav requirement is satisfied by the **installed core**. The `api` plugin requires Grav `>=2.0.4`; on a `2.0.0-rc.10` core there is no compatible version, so GPM reports the package as "not found" rather than a version conflict. Admin2 declares `api` as a hard dependency and performs all authentication over the api plugin's `/api/v1` JWT endpoints, so with `api` absent the login POST hits a route that doesn't exist (404) and never reaches Grav's auth layer — hence the silent failure with no `grav.log` entry. Upgrading the core to a stable `>=2.0.4` build makes GPM offer `api` again; installing it registers `/api/v1`, and Admin2's login flow succeeds. + +## Prevention + +- **Keep `.env.` `GRAV_VERSION` current.** It is the version a *fresh* `make remote-install-` bakes in; a stale value silently installs an old core. After any core upgrade, bump the env file too — self-upgrade only moves the running server. Note this is a *third* version-authority surface alongside `user/config/system.yaml` `gpm.releases` (channel) and `plugins.txt` — they must stay in sync. +- **When GPM says "package not found" for a package you know is on your channel, check the target's Grav core version first** (`php bin/grav --version` on the server, or `make remote-diag-`). GPM filters by core compatibility; "not found" often means "no version compatible with your core," not "missing from the index." `gpm index -f` will not help. +- **Don't trust a top-level install "Success" to mean dependencies installed.** A fresh install can leave a plugin's declared dependency unsatisfied (here `admin2` installed but its `api` dependency didn't). Verify with `ls user/plugins/`. +- **Know the Admin2 ⇄ api coupling.** Admin2 authenticates via the api plugin's `/api/v1` endpoints; a missing or unrouted api plugin makes admin login fail *silently* (login POST 404s, nothing logged). A quick `curl /api/v1/pages` expecting `401` (not `404`) is a good post-deploy smoke check. + +## Related + +This is one of three independent gotchas from the same **2026-07-04 Grav 2.0.4 production cutover**: + +- `docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md` — sibling: garbage-rendered pages from a double `Content-Encoding` header on a non-FastCGI host. Different root cause (HTTP compression), same deploy. +- `docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md` — sibling: an authenticated account is denied an admin-gated page because `login new-user` auto-detect granted `api.*` but not `admin.*`. Different root cause (permission provisioning), same admin2/api area. +- `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the upgrade plan whose Global Constraints spell out the GPM version floors (`grav >=2.0.4`, `api >=1.0.6`) that cause the "package not found" on an rc core. +- **api plugin config** in the tracked `user/config/plugins/api.yaml` (`enabled` / `route` / `session_enabled`) — must ship on a clean clone; necessary but not sufficient (the plugin must be installed first). See CLAUDE.md §0 "Plugin management". diff --git a/docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md b/docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md index 890a6e0..e20104a 100644 --- a/docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md +++ b/docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md @@ -78,5 +78,6 @@ This site runs **Admin2 only** (the classic `admin` plugin is disabled), so auto ## Related Issues - `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the self-contained test-account infrastructure shipped alongside the Grav 2.0.4 upgrade. +- Sibling gotchas from the same 2026-07-04 Grav 2.0.4 production cutover (all surface around admin2/api but with distinct root causes): `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` (stale `GRAV_VERSION` → rc core → GPM won't serve the `api` plugin → login 404s) and `docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md` (double `Content-Encoding` header → garbage page). - `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` — accounts live in the `user/` repo; the `testrunner` account is gitignored so it never reaches production. - GPX manager auth model (`access.admin.login: true` frontmatter + Login plugin) — see the project's GPX manager notes. From 39d42119b2c759621ba4f40ae3c2aded738b0169 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 5 Jul 2026 00:05:11 +0200 Subject: [PATCH 11/12] docs: capture Grav plugin-config-must-be-tracked convention (ce-compound) Knowledge-track: functional Grav plugin config must live in the tracked user/config/plugins/.yaml override, not the gitignored (and gpm-update-wiped) user/plugins//.yaml. Secrets stay in gitignored *-private.php. Root of the api plugin being unconfigured on the fresh prod install. Cross-linked with the api/rc.10 install-failure doc. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU --- ...-plugin-config-must-be-tracked-override.md | 85 +++++++++++++++++++ ...-grav-version-blocks-api-plugin-install.md | 2 +- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md diff --git a/docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md b/docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md new file mode 100644 index 0000000..36df1f7 --- /dev/null +++ b/docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md @@ -0,0 +1,85 @@ +--- +title: "Grav plugin config must live in the tracked user/config/plugins/ override, not the plugin folder" +date: 2026-07-04 +category: docs/solutions/conventions +module: "grav / plugin configuration" +problem_type: convention +component: tooling +severity: high +applies_when: + - "Editing functional config for any GPM-managed Grav plugin" + - "user/plugins/ is gitignored and only pages/config/accounts/themes are tracked" + - "Preparing a fresh install or production cutover" + - "A plugin behaves correctly locally but ships with only default config on deploy" +related_components: + - "grav" + - "gpm" + - "api plugin" + - "content repo" + - "deployment" +tags: + - grav + - plugin-config + - gpm + - config-override + - gitignore + - deployment + - api-plugin +--- + +# Grav plugin config must live in the tracked user/config/plugins/ override, not the plugin folder + +## Context + +Grav resolves a plugin's config by deep-merging two layers: the plugin's own shipped file `user/plugins//.yaml` (installed by GPM, part of the package) and the tracked override `user/config/plugins/.yaml` (which wins). In this project the content repo tracks only `pages/`, `config/`, `accounts/`, `themes/`; `user/plugins/` and `user/data/` are gitignored (GPM manages plugin *code*). So any functional config a developer edits into a plugin's own `user/plugins//.yaml` is invisible to version control. + +It was this gap that left the `api` plugin unconfigured on the fresh prod install. Its `enabled`/`route`/`session_enabled`/cors/rate_limit config existed only in the untracked plugin folder locally, while the committed `user/config/plugins/api.yaml` held only a runtime `popularity.salt`. The local machine worked because the plugin folder had been hand-edited; every fresh environment got only the plugin's shipped defaults. + +## Guidance + +Put **functional** plugin configuration in the TRACKED override `user/config/plugins/.yaml`. Grav deep-merges it over the plugin's shipped defaults, so it need only carry the keys that must differ (or the full config, for clarity). Keep **secrets and per-install generated values** OUT of the tracked file — JWT secrets, salts, encrypted tokens belong in gitignored `*-private.php` companion files (e.g. `api-private.php`, `security-private.php`) or should be regenerated per-install. + +Never rely on edits to the plugin's own `user/plugins//.yaml`: it is gitignored (won't deploy) and is overwritten on the next `php bin/gpm update`. + +Concrete before/after, using the `api` plugin: + +```yaml +# WRONG: user/plugins/api/api.yaml (gitignored, GPM-managed, wiped on update) +enabled: true +route: /api +auth: + session_enabled: true +``` + +```yaml +# RIGHT: user/config/plugins/api.yaml (tracked, deploys, survives gpm update) +enabled: true +route: /api +version_prefix: v1 +auth: + session_enabled: true + # JWT secret intentionally NOT here — it lives in the gitignored api-private.php +``` + +## Why This Matters + +Reproducible deploys: a fresh clone or `make remote-install-` must produce a working site from the repo alone. Config stranded in the gitignored plugin folder silently yields a plugin with only its shipped defaults on every new environment — which, for a plugin whose behavior depends on non-default config, means it's misconfigured or effectively off. On prod the `api` plugin's route/auth simply didn't work. + +The failure is silent and per-environment: it works on the developer's machine (where the plugin folder was hand-edited) and breaks everywhere else. `gpm update` compounds it by wiping the folder edit even locally, so the "working" state is not just unshared — it is also unstable on the one machine that had it. + +## When to Apply + +- Any time you configure a Grav plugin whose non-default settings must work on a server (prod/test) or survive a plugin update. +- Especially for plugins whose function depends on config: `api` (route/auth/cors), `admin2`, `flex-objects`, form/media settings, etc. +- When auditing a fresh-install failure: check whether the "working" local config actually lives in a tracked path (`git ls-files user/config/plugins/.yaml`) or was stranded in `user/plugins//`. + +## Examples + +- **api plugin (this project):** the functional config was moved from the untracked `user/plugins/api/api.yaml` into the tracked `user/config/plugins/api.yaml`, then `make content-push` + `make remote-fetch-content-` deployed it. The JWT secret stayed in the gitignored `api-private.php`. +- **Quick audit command:** `git -C user ls-files config/plugins/` shows exactly which plugin configs are tracked/deployable; anything you rely on that isn't listed is a latent fresh-install failure. + +## Related + +- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — the config gap documented here was the *other* latent problem surfaced in that same investigation: the `api` plugin also had to be *installed* first before any config could take effect. The install gap (GPM version floor) and this config-tracking gap compounded each other on the fresh prod environment. +- `docs/working/git-sync-notes.md` — the related third config location: on prod, Grav Admin saves config into the per-environment tree `user/env//config/`, which is *also* untracked. Same "config that doesn't reach the repo" family. +- **CLAUDE.md §0 (plugin-management model):** only `pages/`, `config/`, `accounts/`, `themes/` are tracked in the `user/` repo; `plugins/` and `data/` are gitignored and GPM-managed. That tracking boundary is exactly why functional config must live under `config/plugins/`, not in the plugin's own folder. diff --git a/docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md b/docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md index b58c11c..231bd18 100644 --- a/docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md +++ b/docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md @@ -91,4 +91,4 @@ This is one of three independent gotchas from the same **2026-07-04 Grav 2.0.4 p - `docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md` — sibling: garbage-rendered pages from a double `Content-Encoding` header on a non-FastCGI host. Different root cause (HTTP compression), same deploy. - `docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md` — sibling: an authenticated account is denied an admin-gated page because `login new-user` auto-detect granted `api.*` but not `admin.*`. Different root cause (permission provisioning), same admin2/api area. - `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the upgrade plan whose Global Constraints spell out the GPM version floors (`grav >=2.0.4`, `api >=1.0.6`) that cause the "package not found" on an rc core. -- **api plugin config** in the tracked `user/config/plugins/api.yaml` (`enabled` / `route` / `session_enabled`) — must ship on a clean clone; necessary but not sufficient (the plugin must be installed first). See CLAUDE.md §0 "Plugin management". +- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — the *other* latent problem from this same investigation: the api plugin's functional config (`enabled` / `route` / `session_enabled`) must live in the tracked `user/config/plugins/api.yaml` to deploy at all. Necessary but not sufficient here (the plugin must be installed first), but a durable convention in its own right. From e0e2e1e7b5c7c0b0a4d2ae631974ff3134691fc5 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sun, 5 Jul 2026 00:16:33 +0200 Subject: [PATCH 12/12] docs: mark Grav 2.0.4 plan Phase 3 (prod) executed + outcome Prod was actually deployed (LIVE at intotheeast.com, Grav 2.0.7); the plan previously said Phase 3 was documentation-only/not-executed. Record the three deploy gotchas (docs/solutions/) and remaining follow-ups. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU --- .../plans/2026-07-04-grav-2.0.4-upgrade.md | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md b/docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md index 7ff3e3a..b60ae85 100644 --- a/docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md +++ b/docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Status:** ✅ Complete (2026-07-04) — Phase 1 (local) and Phase 2 (remote test env) both validated and shipped; git-sync re-enabled on test. Phase 3 (prod) is documentation-only per design. See "Known issue" below re: Form 9.1.10 filepond. +**Status:** ✅ Complete — Phase 1 (local), Phase 2 (test env), and **Phase 3 (production) all executed and shipped**. Phase 3 was run for real on 2026-07-05 (see the Phase 3 section for the execution outcome and the three `docs/solutions/` gotchas it produced). git-sync re-enabled on test and set up on prod. See "Known issue" below re: Form 9.1.10 filepond. **Goal:** Upgrade Grav core `2.0.0-rc.10` → `2.0.4` stable and promote `admin2`/`api`/`flex-objects` to GPM management, validated on local then the remote test env (prod is documented-only). @@ -539,11 +539,32 @@ Content, config, and accounts are in git, so no data restore is required — but --- -## Phase 3 — Production (fresh install, NOT executed) +## Phase 3 — Production (fresh install) — EXECUTED 2026-07-05 -Production is empty, so this is a **fresh install**, not an upgrade — and it is **documentation only**. Do not run it as part of this plan. +> **Execution outcome (2026-07-05):** the fresh prod install was run for real +> (`make remote-install-prod`) and the site is live at `https://intotheeast.com`. +> The runbook below was followed, but three non-obvious gotchas surfaced — each +> now has its own learning in `docs/solutions/`: +> - **Stale `.env.prod GRAV_VERSION`** installed Grav rc.10, so GPM wouldn't +> serve the `api` plugin (needs ≥2.0.4) → Admin2 login 404'd silently. Fixed +> via `make remote-upgrade-grav-prod` (→ 2.0.7) + reinstall. See +> `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`. +> **TODO: bump `.env.prod GRAV_VERSION` to `2.0.4`** so a future fresh install +> doesn't repeat the RC. +> - **Double `Content-Encoding` header** (non-FastCGI host + mod_deflate) +> rendered a garbage page once prod switched to `twig.debug: false`. Fixed via +> `debugger.shutdown.close_connection: false` in the prod env override. See +> `docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md`. +> - **Plugin config stranded in the untracked `user/plugins/`** doesn't deploy. +> See `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md`. +> +> Twig prod-mode is applied as a per-environment override (`deploy/env/prod/system.yaml` +> via `make remote-apply-env-prod`); git-sync is installed, configured, and enabled +> (see `docs/working/git-sync-notes.md`). Remaining minor follow-ups: gitignore +> `config/security-private.php` (committed salt); optional `popularity.salt` strip. -When prod is provisioned: +The original runbook (production was empty, so this was a **fresh install**, not +an upgrade): 1. **Provision creds:** copy the REMOTE section of `.env.example` into `.env.prod` with production values (never commit it). Run `make remote-env-setup-prod`. 2. **Fresh install at 2.0.4:** `make remote-install-prod` with `GRAV_VERSION=2.0.4` in `.env.prod`. `scripts/server-install.sh` installs core, then all of `plugins.txt` — `admin2`/`api`/`flex-objects` now install purely via `php bin/gpm install` (no zip-stash; that special-casing was removed in Task 4). The `gpm.releases: stable` channel arrives with the `user/` content clone.