Merge remote-tracking branch 'origin/main' into feat/journal-post-form

# Conflicts:
#	user
This commit is contained in:
2026-07-05 00:43:14 +02:00
12 changed files with 531 additions and 34 deletions
+46 -6
View File
@@ -171,15 +171,55 @@ 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
`<webroot>/user/env/<hostname>/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.<env>` if Grav sees a different host than the SSH host.
> **⚠️ Once `user/env/<hostname>/` 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/<name>.yaml`, **not**
> `user/config/plugins/<name>.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/<host>/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
correctly with caching enabled.
### What the cache-on-save plugin handles
+40 -4
View File
@@ -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/<host>/).
# Defaults to the SSH host; override in .env.<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-maintenance-on remote-maintenance-off
remote-content-status remote-clean remote-diag remote-apply-env \
remote-gpm-install remote-maintenance-on remote-maintenance-off
ENVS := test prod
guard-env:
@@ -153,7 +158,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"
@@ -162,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/"
@@ -173,6 +178,37 @@ 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=<plugin-slug>"; 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/<WEB_HOST>/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) && \
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 '=== 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
$(SSH) "bash -s on $(WEBROOT)" < scripts/server-maintenance.sh
+38
View File
@@ -0,0 +1,38 @@
# 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
# <webroot>/user/env/<hostname>/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
# 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
@@ -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/<name>/<name>.yaml` (installed by GPM, part of the package) and the tracked override `user/config/plugins/<name>.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/<name>/<name>.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/<name>.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/<name>/<name>.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-<env>` 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/<name>.yaml`) or was stranded in `user/plugins/<name>/`.
## 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-<env>` 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/<host>/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.
@@ -0,0 +1,123 @@
---
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 615631):
```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/<hostname>/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.
@@ -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 <plugins.txt> -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.<env>` `GRAV_VERSION` current.** It is the version a *fresh* `make remote-install-<env>` 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-<env>`). 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/<dependency>`.
- **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.
- `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.
@@ -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.
+4 -3
View File
@@ -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
---
+59 -10
View File
@@ -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/<hostname>/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/<name>.yaml` **and**
`user/env/<host>/config/plugins/<name>.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-<env>`
and `make remote-diag-<env>` 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-<env>`) 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-<env>`.
@@ -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.
+15 -6
View File
@@ -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/<host>/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 <git-sync.yaml path> <true|false>}"
: "${STATE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
: "${WEBROOT:?usage: git-sync-toggle.sh <webroot> <true|false>}"
: "${STATE:?usage: git-sync-toggle.sh <webroot> <true|false>}"
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)"
+1 -1
Submodule user updated: fff5358ce2...16a570ca3b