docs(19.5-05): draft at the Task 6 checkpoint (bm view test, review, release table)

This commit is contained in:
Jakub Zych
2026-10-21 14:48:18 +02:00
parent 8c7794efa2
commit b57d2e109d
2 changed files with 100 additions and 36 deletions

View File

@@ -2,7 +2,7 @@
phase: 19.5-social-login-in-sm-user-plugin
plan: 05
subsystem: auth
tags: [sociallogin, sm-user-plugin, email-proof, pre-account-takeover, postgres, gormigrate, gate]
tags: [sociallogin, sm-user-plugin, email-proof, pre-account-takeover, postgres, gormigrate, gate, vitest, bm]
status: draft
requires:
- phase: 19.5-04
@@ -11,25 +11,29 @@ provides:
- "users.email_verified_at (migration 202610210001_add_users_email_verified_at), written only by VerifyActivationCode and ResetPassword, cleared on an email change"
- "sociallogin ownerVerifiedEmail reads EmailVerifiedAt (D-26)"
- "TestOwnerVerifiedEmail (8 subtests), TestEmailVerifiedAt, TestAdminEmailVerifiedAt, TestEmailVerifiedAtMigration"
- "gate: USER_ADDED_MIGRATIONS, refused_migration_changes, migration_writes_rows, DOCKER_USER_UPDATES_TESTS"
affects: [19.5-05 Task 4 (bm view test), 19.5-05 Task 5 (security review, validation map), 19.5-05 Task 7 (release)]
- "bm src/views/OAuthCallbackView.test.ts (D-28): the real view mounted with vue's createRenderer, no new npm dependency"
- "gate: USER_ADDED_MIGRATIONS, refused_migration_changes, migration_writes_rows, DOCKER_USER_UPDATES_TESTS, detect_vitest REQUIRE_FILES"
- "19.5-SECURITY-REVIEW.md T-19.5-39..46 and CR-02 open; 19.5-VALIDATION.md rows 19.5-05-01..07, D-26..D-28"
affects: [19.5-05 Task 6 (release go-ahead), 19.5-05 Task 7 (release or hold)]
actuals:
tokens: 18621 # chars/4 over the Task 1-3 diffs (sm-user-plugin, sm-sociallogin-plugin, gate script)
tasks: 3 # of 7; hand-off after Task 3
commits: 2 # summercms.go only (7170c5b, 30e0cd9); rev-list from the ledger counts 4 because two 19.8-03 commits of another session landed in between
tokens: 30829 # chars/4: Tasks 1-3 diffs 18621 + Tasks 4-5 diffs 12208 (bm view test, gate change, security review and validation map)
tasks: 5 # of 7; at the Task 6 checkpoint
commits: 5 # summercms.go commits of this plan before this draft's commit (7170c5b, 30e0cd9, c2c8c6a, 7e43873, 8c7794e); rev-list from the ledger counts 7 because two foreign 19.8-03 commits landed in between
plan_head_before: 33460607db95276e8180487a8ece7e4f0070b1e7
plan_head_after: 30e0cd9
plan_head_after: 8c7794efa250e6b5f03fcae1ce2c075f779e94f9
tech-stack:
added: []
patterns:
- "Proof of address as a nullable timestamp column, stamped in the same write as the mailed-code action that proves it"
- "Gate migration allow-list: git diff --name-status filtered through refused_migration_changes, plus a comment-blind SQL write scan"
- "SFC view test without a DOM package: vue createRenderer over an in-memory host, the template compiled for the client with the script's binding metadata, createMemoryHistory router"
key-files:
created:
- ../fonoteka.go/plugins/golem15/user/updates/202610210001_add_users_email_verified_at.go
- ../fonoteka.go/plugins/golem15/user/updates/email_verified_at_test.go
- ../fonoteka.go/plugins/golem15/user/email_verified_test.go
- ../sm-sociallogin-plugin/owner_verified_test.go
- ../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts
modified:
- ../fonoteka.go/plugins/golem15/user/models/user.go
- ../fonoteka.go/plugins/golem15/user/classes/codes.go
@@ -43,31 +47,34 @@ key-files:
- ../sm-sociallogin-plugin/testdata/PHP_METHOD_MAP.md
- ../sm-sociallogin-plugin/README.md
- scripts/check-phase19.5.sh
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md
- .planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md
- .planning/phases/19.5-social-login-in-sm-user-plugin/deferred-items.md
key-decisions:
- "Proof writers are exactly VerifyActivationCode (activate, activate-by-code, invitation link) and ResetPassword's password write; RegisterUser, admin create, ActivateUsers, ChangePassword and every sociallogin path write nothing"
- "An email change through /update or the admin form clears email_verified_at; a case-only change (strings.EqualFold) keeps it"
- "Harness e.user is a proven account so plan 02-04 link cases keep testing the link path; e.unprovenUser models accounts without proof"
requirements-completed: [] # D-26, D-27, D-28, SC-3 are marked by the final context (Task 7), not this hand-off
duration: 29min
- "The bm view test compiles the view's own template for the client at test time (vitest's node environment gives the SFC only ssrRender) and provides an empty SSR context; vite.config.ts and the dependencies stay unchanged"
requirements-completed: [] # D-26, D-27, D-28, SC-3 are marked by the final context (Task 7), not this draft
duration: 68min
completed: 2026-10-21
---
# Phase 19.5 Plan 05: Proof of Address for Social Sign-in Linking Summary
Status: draft (hand-off after Task 3)
Status: draft (at Task 6 checkpoint)
**users.email_verified_at (stamped only by the mailed activation code and the completed reset-code password reset, cleared on an email change) is now the owner-side condition for auto-linking a verified provider, so an auto-mode `/register` account goes pending instead of handing its sign-in to a squatter**
**users.email_verified_at (stamped only by the mailed activation code and the completed reset-code password reset, cleared on an email change) is now the owner-side condition for auto-linking a verified provider, so an auto-mode `/register` account goes pending instead of handing its sign-in to a squatter; the bm OAuth callback view's code handling is pinned by a vitest view test without a new dependency**
This draft covers Tasks 1-3 of 7. A fresh agent runs Tasks 4 (bm view test) and 5 (security review, validation map, `--all`, release table) and stops at the Task 6 checkpoint; Task 7 finalizes this file.
This draft covers Tasks 1-5 of 7. The Task 6 go-ahead (release or hold) is pending; Task 7 finalizes this file.
## Performance
- **Duration:** 29 min (this context)
- **Duration:** 29 min (Tasks 1-3) + 39 min (Tasks 4-5, about 25 of them spent on two gate runs that timed out on blocked loopback; see Issues)
- **Started:** 2026-10-21T11:37:14Z
- **Hand-off:** 2026-10-21T12:06:29Z
- **Tasks:** 3 of 7
- **Files modified:** 17 across three repositories (plus deferred-items.md)
- **Second context:** 2026-10-21T12:08Z to 12:47Z
- **Tasks:** 5 of 7
- **Files modified:** 20 across four repositories (plus deferred-items.md)
## Accomplishments
@@ -75,7 +82,9 @@ This draft covers Tasks 1-3 of 7. A fresh agent runs Tasks 4 (bm view test) and
- `classes.VerifyActivationCode` stamps the proof in the same `UpdateColumns` write that activates; `controllers.ResetPassword` stamps it in the `Updates` that stores the new password.
- `controllers.Update` and `usersAdminController.FormBeforeUpdate` clear it when the email changes (case-only change keeps it). `storedEmail` is an unexported helper now shared with `guardCredentials`.
- sociallogin `ownerVerifiedEmail(u)` is `u != nil && u.EmailVerifiedAt != nil`; `classes/flow.go` no longer reads `HasSelfSetPassword`.
- Gate: the userplugin stage accepts exactly the one added migration and refuses any other added, modified, renamed or deleted non-test file under `updates/`, and a migration that writes or drops data; the new tests are required by name.
- bm `src/views/OAuthCallbackView.test.ts`: the real view mounted with `createRenderer` from `vue` on an in-memory host and a `createMemoryHistory` router; 5 cases (route replaced before the single redeem, no second redeem, `?error=` in the alert, pending registration replaced to `oauth-register`, expired code explained). `frontend/package.json` and `package-lock.json` unchanged (last commit touching them `fe967c4` before and after).
- Gate: the userplugin stage accepts exactly the one added migration and refuses any other change under `updates/` and a migration that writes or drops data; the new Go tests are required by name; `detect_vitest` takes `REQUIRE_FILES` and the bm stage requires both `src/lib/oauth.test.ts` and `src/views/OAuthCallbackView.test.ts`.
- 19.5-SECURITY-REVIEW.md: T-19.5-38 describes the proof as `email_verified_at`, T-19.5-39..T-19.5-46 added, T-19.5-26 closed by the view test, CR-02 under "Open findings" (open, no user decision). 19.5-VALIDATION.md: rows 19.5-05-01..07, D-26, D-27, D-28, the view check moved from manual to automated.
## Commits per repository
@@ -95,16 +104,25 @@ This draft covers Tasks 1-3 of 7. A fresh agent runs Tasks 4 (bm view test) and
| 3 | `962190b` | test: owner proof table for verified sign-ins |
| 3 | `c4ebf05` | docs: proof of address in the matching table and the PHP map |
**summercms.go** (gate script; base 3346060):
**sm-bm-app** (base 99af8c2, nothing pushed):
| Task | Commit | Message |
|------|--------|---------|
| 4 | `5fd0cc3` | test(frontend): OAuth callback view drops the code before one redeem |
**summercms.go** (base 3346060, never pushed by this plan):
| Task | Commit | Message |
|------|--------|---------|
| 1 | `7170c5b` | test(19.5): gate the email_verified_at migration and TestOwnerVerifiedEmail |
| 2 | `30e0cd9` | test(19.5): gate the user-plugin proof tests |
| hand-off | `c2c8c6a` | docs(19.5-05): hand-off draft after Task 3 (email proof in user and sociallogin) |
| 4 | `7e43873` | test(19.5): gate the bm callback view test |
| 5 | `8c7794e` | docs(19.5): proof of address in the security review and validation map |
Two foreign commits of another session (`2057643`, `4895b25`, both `fix(19.8-03)`) landed between them on summercms.go master; they are not this plan's.
**sm-bm-app, sm-demo-app, fonoteka.go:** no commit in this context. fonoteka.go's worktree shows `plugins/golem15/user` modified (the checkout is now at 35cf166); it is not staged until Task 7.
**sm-demo-app, fonoteka.go:** no commit yet. fonoteka.go's worktree shows `plugins/golem15/user` modified (the checkout is at 35cf166); it is not staged until Task 7.
## Tests added (all pass)
@@ -113,44 +131,88 @@ Two foreign commits of another session (`2057643`, `4895b25`, both `fix(19.8-03)
- **sm-user-plugin** `TestEmailVerifiedAt` (email_verified_test.go): `register_auto_no_proof`, `register_admin_mode_no_proof`, `register_ignores_submitted_proof`, `activate_by_code_stamps`, `activate_stamps`, `activate_wrong_code_no_proof`, `reset_password_stamps`, `reset_wrong_code_no_proof`, `change_password_no_proof`, `update_email_change_clears`, `update_same_email_keeps`, `update_ignores_submitted_proof`.
- **sm-user-plugin** `TestAdminEmailVerifiedAt`: `admin_create_no_proof`, `admin_activate_action_no_proof`, `admin_invite_link_stamps`, `admin_email_change_clears`, `admin_save_keeps_proof`, `admin_json_hides_proof`.
- **sm-user-plugin** `TestEmailVerifiedAtMigration` (updates/email_verified_at_test.go): `column_shape`, `no_backfill`, `existing_column_kept`, `rollback_keeps_column`, `history_once`. `TestCreateOrganisationsMigration` expects the new ID last and rolls back 12 steps.
- **sm-bm-app** vitest `src/views/OAuthCallbackView.test.ts`, describe `OAuthCallbackView`: `replaces the route before the single redeem`, `does not redeem a code twice`, `shows the error message`, `routes a pending registration`, `explains an expired code`.
- **summercms.go** gate self-test: vitest cases on both required files, `missing-view-file` (exit 5), `single-file-fallback` (REQUIRE_FILE, exit 5).
RED evidence: with the old `ownerVerifiedEmail` (self-set password), `TestOwnerVerifiedEmail/register_auto_then_google_pending` failed (the redirect carried a `code`, i.e. the auto-mode account was linked). With the Task 2 controllers reverted to HEAD, exactly `TestEmailVerifiedAt/update_email_change_clears` and `TestAdminEmailVerifiedAt/admin_email_change_clears` failed.
RED evidence:
- With the old `ownerVerifiedEmail` (self-set password), `TestOwnerVerifiedEmail/register_auto_then_google_pending` failed (the redirect carried a `code`, i.e. the auto-mode account was linked).
- With the Task 2 controllers reverted to HEAD, exactly `TestEmailVerifiedAt/update_email_change_clears` and `TestAdminEmailVerifiedAt/admin_email_change_clears` failed.
- View mutations, each reverted: removing the `router.replace({ path })` before the redeem fails 3 cases (`replaces the route…`, `does not redeem…`, `shows the error…`); dropping the `redeemed` check fails `does not redeem a code twice`; `push` instead of `replace` for the pending route fails `routes a pending registration`.
## Gate stages (scripts/check-phase19.5.sh) at hand-off
## Gate stages (scripts/check-phase19.5.sh)
Final run, 14:42-14:46 local: `PHASE195_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.5.sh --all`, exit 0.
| Stage | State | Notes |
|-------|-------|-------|
| --self-test | PASS | new migration-check cases (allowed `A`, refused `M` 00_base.go, other `A`, `D`, `R100`; planted row update refused, word in a `//` comment accepted) |
| --userplugin | PASS | "13 declarations added; 1 allowed migration(s) added, column only" |
| --sociallogin | PASS | includes TestPHPMethodMap on the edited map |
| --fonoteka | PASS | go vet and go test -short of the workspace at the changed user plugin |
| --docker | PASS | cooler 1, user root 12, user updates 2, sociallogin 29 top-level tests; run with `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1` (see Issues) |
| --go, --demo, --bm, --docs | NOT RUN | not touched by Tasks 1-3; Task 5 runs `PHASE195_REQUIRE_DOCKER=1 --all` |
| --self-test | PASS | detectors, migration-check cases, vitest `REQUIRE_FILES` cases incl. `missing-view-file`, docker NOT RUN rule |
| --go | PASS | summercms.go vet and short suite; cooler 4 named tests |
| --userplugin | PASS | 2 named tests; "13 declarations added; 1 allowed migration(s) added, column only" |
| --sociallogin | PASS | 9 named Docker-free tests incl. TestPHPMethodMap; deps and goth importer checks |
| --demo | PASS | build, vet, 3 named tests |
| --bm | PASS | build, vet, 1 + 2 named tests; vitest 216 passed (216) with both required files; typecheck |
| --fonoteka | PASS | vet and short suite of the workspace at the changed user plugin |
| --docs | PASS | TestDocsTree, docs:build --check, decision note, no consumer names |
| --docker | PASS | cooler 1, user root 12, user updates 2, sociallogin 29 top-level tests |
## Decisions Made
Followed the plan's decisions. Within them:
- `storedEmail` was extracted from `guardCredentials` so `FormBeforeUpdate` compares the stored and submitted email without a second copy of the query; `guardCredentials`' behaviour is unchanged.
- `TestOwnerVerifiedEmail` boots its own environment with `gothFakeRegistry` (helper `ownerEnv`) so the Google and Facebook wrappers run on fake APIs, as `TestPHPSocialAuthGate` does.
- `mountAt(url, router)` builds a fresh router and app per mount (vue-router resets its state when its last app unmounts), so the second-mount case pushes the same callback URL again on a new router while the view module's `redeemed` set carries over.
## Deviations from Plan
None in production code or tests - Tasks 1-3 executed as written.
**1. [Rule 3 - Blocking] The bm view test compiles the view's template at test time**
- **Found during:** Task 4
- **Issue:** Under vitest's `environment: 'node'`, @vitejs/plugin-vue compiles SFCs in SSR mode: the imported component has `setup` and `ssrRender` but no `render`, so `createRenderer` alone mounts nothing. The SSR module's setup also calls `useSSRContext()` and fails without one. A client-compiled template without binding metadata cannot read `<script setup>` bindings (they are not exposed on the render context).
- **Fix:** In the test file only: `parse` and `compileScript` from `vue/compiler-sfc` (part of the `vue` package) read the view's own source (`?raw`), `compile` from `vue` builds the client render function with `bindingMetadata` and `prefixIdentifiers`, and the app provides an empty SSR context (`ssrContextKey`). The `<script setup>` that runs is the real module, `redeemed` set included. No dependency added, `vite.config.ts` unchanged, so the plan's "stop at a checkpoint" branch did not apply.
- **Files modified:** `../sm-bm-app/frontend/src/views/OAuthCallbackView.test.ts`
- **Commit:** `5fd0cc3`
Environment-only adjustments (no repository change):
- Docker-backed runs used `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1` (see Issues Encountered).
- Docker-backed runs used `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1` and ran outside the sandbox.
- `change_password_no_proof` sets `has_self_set_password` before calling change-password, as `TestChangePassword` does (`insertUser` creates the row with the flag false, which change-password answers with an opaque 500).
## Issues Encountered
- **Docker host IPv6 loopback.** Published container ports answer on 127.0.0.1 but time out on `[::1]`; pgx resolves `localhost` to `[::1]` first, so every testcontainers suite reported "postgres unavailable" (inside and outside the sandbox). Setting `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1` fixed it; Task 5's `--all` needs the same. Logged in deferred-items.md.
- **Pre-existing:** sm-user-plugin `updates/postgres_test.go` `testShort` scans `os.Args`, so `go test -short` still starts Postgres for the updates package. Out of scope, logged in deferred-items.md.
- **Docker loopback blocked by the NordLayer VPN.** The VPN's nftables table drops loopback traffic to published container ports, so pgx timed out against the testcontainers Postgres (`127.0.0.1:<port>: failed to receive message: timeout`). The first two `--all` runs of this context failed at `--userplugin`: `sm-user-plugin/classes` hit `go test`'s 10-minute timeout (`TestPermissionSetScan` starts Postgres even under `-short`) and `updates` failed after 2 minutes. The user restored loopback with `sudo nft insert rule inet nordlayer input iifname "lo" accept` and `... output oifname "lo" accept`. These rules disappear when the VPN reconnects, and Task 7's gate runs need them again. The third run passed in 3.5 minutes.
- **Pre-existing:** sm-user-plugin `updates/postgres_test.go` `testShort` scans `os.Args`, and `classes.TestPermissionSetScan` has no `-short` skip, so `go test -short` still starts Postgres for those packages. Out of scope, both logged in deferred-items.md, as is the VPN loopback rule.
## Hand-off for Tasks 4 and 5
## Release table
- Task 4 starts from sm-bm-app (no commit yet) and the gate's `detect_vitest`/`run_bm`.
- Task 5 records T-19.5-39..46, CR-02 open, VALIDATION rows 19.5-05-01..07, runs `PHASE195_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.5.sh --all`, and adds the `## Release table` here.
- No tag exists on either plugin's master; nothing was pushed.
Fetched `origin` in every repository during Task 5 (about 12:25Z). `git log --oneline origin/master..master`:
| Repository | Unpushed commits | This plan's? |
|------------|------------------|--------------|
| sm-user-plugin | `35cf166`, `a99cb46`, `add1f7a` | all this plan's |
| sm-sociallogin-plugin | `c4ebf05`, `962190b`, `3bb5650` | all this plan's |
| sm-bm-app | `5fd0cc3` | this plan's (Task 4) |
| sm-demo-app | `b325460` feat(demo): roadmap deck describes the new console | **foreign** (another session, 13:27 today; touches only `plugins/demo/content/ROADMAP.md` and `plugins/demo/roadmap/`) |
| fonoteka.go | 31 commits (`f4d5728` … `ca746fc`: 14.1, 14.2, 12.1, parity and earlier bump commits) | all foreign to this plan; fonoteka.go is never pushed by this plan |
| summercms.go | 49 commits before this draft's commit; this plan's: `7170c5b`, `30e0cd9`, `c2c8c6a`, `7e43873`, `8c7794e` | the rest foreign (19.8 and earlier 19.5 planning); summercms.go is never pushed by this plan |
Proposed tags (each `git ls-remote --tags origin <tag>` printed nothing; no tag points at either plugin's master):
| Repository | Tag | Points at | Free? |
|------------|-----|-----------|-------|
| sm-user-plugin | `v0.1.5` | `35cf166` | yes (also `v0.1.6` free) |
| sm-sociallogin-plugin | `v0.1.2` | the Task 7 commit `chore: require sm-user-plugin v0.1.5` on top of `c4ebf05` | yes (also `v0.1.3` free) |
Current pins: sm-demo-app and sm-bm-app `plugins/golem15/user` c43ba87 (v0.1.4), `plugins/golem15/sociallogin` d7ee28d (v0.1.1); sm-demo-app go.mod requires sm-user-plugin v0.1.4 and sm-sociallogin-plugin v0.1.1; sm-bm-app go.mod requires sm-user-plugin v0.1.4 and sm-sociallogin-plugin v0.0.0 (replace to the submodule; only the user line names a version); fonoteka.go gitlink c43ba87.
Pointer-bump commits Task 7 would make (on "release"):
1. sm-sociallogin-plugin: `chore: require sm-user-plugin v0.1.5` (go.mod, go.sum), then tag v0.1.2.
2. sm-demo-app: `chore: bump sm-user-plugin to v0.1.5` (gitlink, go.mod, go.sum), then `chore: bump sm-sociallogin-plugin to v0.1.2` (gitlink, go.mod, go.sum); `--demo` and `scripts/check-pins.sh` green after each; push. **The push would include the foreign `b325460` unless the reply excludes it.**
3. sm-bm-app: the same two commits (only the versioned require line changes); `--bm` green after each; push including `5fd0cc3`.
4. fonoteka.go: `chore: bump sm-user-plugin to v0.1.5` (only `plugins/golem15/user`), local only.
## Hand-off for Task 6 and Task 7
- Task 6 records the user's reply verbatim under `## Task 6 reply` here.
- Before Task 7's gate runs, check that loopback to Docker works (the NordLayer `lo` accept rules); a quick probe: `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 go test -short -count=1 -timeout 120s -run '^TestPermissionSetScan$' ./plugins/golem15/user/classes` in fonoteka.go finishes in about 15 s when it works.
- Nothing is pushed or tagged.
## User Setup Required
@@ -162,4 +224,4 @@ None.
## Self-Check: PASSED
All eight commits and the four created files exist; the draft status line is present once.
All twelve commits listed (U 3, S 3, B 1, F 5) and the five created files exist; the draft status line and the Release table heading are present once; no tag points at either plugin master and v0.1.2 is absent on the remote.

View File

@@ -10,3 +10,5 @@ Out-of-scope discoveries logged by executors; not fixed in this phase.
- **sm-user-plugin `updates/postgres_test.go` `testShort` scans `os.Args` for `-test.short`.** `go test` passes `-test.short=true`, so `go test -short ./plugins/golem15/user/...` still starts the Postgres container for the `updates` package (the pitfall CLAUDE.md names). Pre-existing, not caused by plan 05; the fix is `flag.Parse()` and `testing.Short()` in its `TestMain`.
- **Docker host: published container ports do not answer on `[::1]`.** On this host testcontainers resolves `localhost`, pgx dials `[::1]` first and times out, and every Docker-backed suite fails with `postgres unavailable`. Plan 05 ran the Docker stages with `TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1`; Task 5's `--all` needs the same until the host's IPv6 loopback forwarding is fixed.
- **sm-user-plugin `classes.TestPermissionSetScan` starts Postgres under `-short`.** It calls `throttleDB` without a `testing.Short()` skip, so the gate's `--userplugin` short run needs a working Docker host; when loopback to the container is blocked the package hangs until `go test`'s 10-minute timeout. Pre-existing, not caused by plan 05.
- **NordLayer VPN drops loopback traffic to container ports.** With the VPN connected, testcontainers Postgres times out on `127.0.0.1` too. The user's workaround is `sudo nft insert rule inet nordlayer input iifname "lo" accept` and `sudo nft insert rule inet nordlayer output oifname "lo" accept`; both rules disappear when the VPN reconnects.