docs(plans): session handoff + audit + Faza H runbook

End of 2026-05-19 cutover-debug session. Saves the full state +
2 outstanding bugs + Faza G/H plan into docs/plans/ so the next
session can resume without re-investigating.

- prompt-handoff-2026-05-19.md: short prompt for the next session
  to amend + resume.
- audit-2026-05-19.md: auditor-agent output (~30 findings).
- 004-faza-h-runbook.md: pg_dump + REVOKE + DROP runbook with
  prereqs (eterra.live shares DB, unidentified writer, CfExtract
  schema drift).

Memory entries also written:
- feedback/authentik-token-endpoint-shared
- project/architools-cutover-state-2026-05-19

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-19 08:31:45 +03:00
parent 162c8ed257
commit 9847b4a070
3 changed files with 747 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
# ArchiTools → api.gis.ac cutover — audit 2026-05-19
**Scope:** every commit on `main` from `6b3d56e` (pre-cutover baseline) through `68355ef` (HEAD).
**Reviewer:** Claude Opus 4.7 — read-only audit; no code changes.
**Pilot user:** `m.tarau@beletage.ro` (gated via `GIS_AC_PILOT_USERS`).
Commits in scope:
```
68355ef fix(geoportal-v2): UAT click deep-links to eterra.live + revert debug
1c6efb9 debug(auth): expose session.debug={hasRefreshToken, expiresIn}
3829401 feat(ops): VersionWatcher — toast prompt when a new deploy is live
64bccdb feat(ops): /api/version endpoint with git SHA + build time
6054d08 fix(faza-e): refresh dedup, fetch timeout, error surfacing
47ca366 fix(auth): Authentik access_token refresh flow
e0610b0 fix(geoportal-v2): handle PMTiles features without uuid id
7a22b11 debug(gis-search): log session presence + access token presence
99a673d feat(geoportal): Faza E v2 thin client (PMTiles + gis.ac)
fc2bdfb feat(gis-api): Faza D thin client lib (src/lib/gis-api-client.ts)
977db6d feat(cutover): Faza C feature-flag infra for api.gis.ac
403b6b3 feat(auth): Faza B NextAuth Authentik scope=enrichment + forward access_token
54b78c2 feat(deploy): Faza A Infisical runtime migration
```
Files changed (19): `Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`, `src/middleware.ts`, `src/app/providers.tsx`, `src/core/auth/auth-options.ts`, `src/core/feature-flags/use-gis-ac.ts`, `src/core/version/version-watcher.tsx`, `src/lib/gis-api-client.ts`, `src/app/api/version/route.ts`, `src/app/api/gis/{search,parcela/[id],parcel/tech}/route.ts`, `src/modules/geoportal/components/geoportal-module.tsx`, `src/modules/geoportal/v2/*.tsx` (5 files).
---
## 1. Production safety (flag=0 path)
- `[ship]` Legacy `parcel-sync` / `eterra` / `ancpi` source trees are **untouched** by this session's commits. `git diff 6b3d56e..HEAD --name-only -- src/modules/parcel-sync/ src/modules/eterra/ src/modules/ancpi/ src/app/api/ancpi/ src/app/api/eterra/` returns empty. Non-pilot users see exactly the same code path as before.
- `[ship]` `src/config/{modules,navigation,flags,companies,external-tools,nas-paths}.ts` and `src/core/feature-flags/index.ts` are **unchanged**. No regression risk in module registration or sidebar/feature-flag wiring.
- `[ship]` `geoportal-module.tsx` is the **only** legacy module file touched: it imports `useSession`, reads `session.useGisAc`, branches to `GeoportalV2` only when true. The legacy renderer (`GeoportalV1Legacy`) is the verbatim previous body. For flag=0 users the render output is identical to baseline.
- `[ship]` `middleware.ts` adds `api/version` to the exclusion regex. No other matcher change. The version route is intentionally public (no auth), as is correct for a build-ID endpoint.
- `[ship]` `auth-options.ts` change is backwards-compatible: callbacks still mint the same `role` / `company` claims; new fields (`accessToken`, `error`, `useGisAc`) are additive on the session object. Users without `account.access_token` (rare, but possible on cached sessions) skip the refresh branch via the `token.accessToken && token.refreshToken` guard — no hot-loop risk.
- `[cosmetic]` `VersionWatcher` toast component is global (mounted in `providers.tsx`). It fires for **everyone**, not just pilot. For non-pilot users this is fine — they get a "new build available" nudge after each redeploy, which is desirable.
## 2. Secret hygiene
- `[ship]` No hard-coded secrets in any committed file. `AUTHENTIK_CLIENT_ID/SECRET/ISSUER`, `GIS_API_URL`, `NEXTAUTH_SECRET`, etc. are all `process.env.X` with safe `||` fallbacks (`""` for secrets, public URLs for non-secrets).
- `[ship]` `docker-entrypoint.sh` uses `--data` with `$INFISICAL_CLIENT_ID`/`$INFISICAL_CLIENT_SECRET` only in a JSON body sent to Infisical over HTTPS; nothing echoed. The `[infisical] exported env -` line only reports presence (`${VAR:+set}`), never values. Good.
- `[ship]` Hard-coded **URLs** present and acceptable:
- `https://api.gis.ac` (gis-api-client.ts:18, env-overridable)
- `https://pmtiles.gis.ac/overview.pmtiles` (map-viewer.tsx:39, env-overridable via `NEXT_PUBLIC_PMTILES_URL`)
- `https://eterra.live/harta?...` (geoportal-v2.tsx:39, feature-info-panel.tsx:157) — intentional cross-app deep link, fine.
- `https://tools.beletage.ro` fallback (middleware.ts) — pre-existing, not introduced this session.
- `[cosmetic]` `src/app/api/version/route.ts` exposes `useGisAcDefault` (boolean) and `pilotUsers` (**count only**) — no email leakage. Safe.
- `[ship]` `console.log("[auth] refresh OK expires_in=%d", body.expires_in)` logs only the integer (seconds-until-expiry), never the token itself. The `console.warn` paths log `res.status` + `body.error` (Authentik error code like `invalid_grant`), no token bodies. Safe.
## 3. TypeScript correctness
- `[ship]` `NODE_ENV=development npx tsc --noEmit`**exits 0, zero errors, zero warnings**.
## 4. Dead / orphan code
- `[future]` `gisApi.parcel.unitsFetch`, `gisApi.parcel.immApps`, `gisApi.building.tech`, `gisApi.building.condoOwners`, `gisApi.enrichment.cf.{list,get,create,patch,uploadPdf,getPdf}`, `gisApi.enrichment.catalog`, `gisApi.me` — declared in `src/lib/gis-api-client.ts`, **no callers** under `src/`. These are Faza F surface (CF ordering + parcel detail tabs). Acceptable to land ahead of consumers since they share helper infrastructure. Bundle cost: lib is server-side only and tree-shaken in production builds.
- `[future]` `GisApiCallOpts.accessToken` override path: every public method accepts `opts.accessToken` but no caller passes it. Intended for future background jobs/service accounts. Keep.
- `[cosmetic]` `objectId` field on `ClickedFeatureLite` is computed from `p.object_id` in `map-viewer.tsx:248-254`, set on the panel feature, but **never read** by `feature-info-panel.tsx`. Minor — costs nothing but should be removed if no consumer materializes by Faza F.
- `[future]` `(session as any).error` is set when refresh fails; **no client component** currently reads `session.error` to force re-login. The map-viewer fetches will silently 401 and the user will see "Eroare: gis_api_401" in the panel without auto-redirect. Add a session-error watcher in Faza F polish.
## 5. Faza E gaps vs plan 003
Plan 003 §Faza E specified five rewrites: `map-viewer.tsx`, `search-bar.tsx`, `feature-info-panel.tsx`, `basemap-switcher.tsx`, plus the v2 wrapper. All five exist under `src/modules/geoportal/v2/`. Gaps vs spec:
- `[future]` **UAT bounds / flyTo:** Plan §Faza E implicitly assumed `gisApi.search` would return UAT bounds for client `fitBounds`. Today gis-api returns `{siruta, name, county}` only. Workaround in `geoportal-v2.tsx:34-41`: click on UAT opens `eterra.live/harta?siruta=…` in a new tab. Acceptable as documented; tracked as "add `GET /api/v1/uat/:siruta/bounds` to gis-api". Severity = future, not blocker.
- `[future]` **Layer panel removed entirely** from v2. v1 had `LayerPanel` + `getDefaultVisibility` controlling per-layer visibility (UATs/cladiri/terenuri toggles). v2 hard-codes all four layers on. Pilot user may complain about not being able to toggle building outlines. Minor UX regression vs v1.
- `[future]` **Selection toolbar removed entirely** from v2. v1 had `SelectionToolbar` + `selectionMode` (`off`/`point`/`rectangle`/`polygon`) for multi-select export workflows. v2 has no multi-select. Plan §Faza E was silent on this; reasonable to defer until usage is observed.
- `[future]` **`SetupBanner` removed.** Was a no-op in v1 once setup completed, so no regression. Skip.
- `[future]` **`boundary-check` / `cf-status` / `export` / `pad` / `piz` buttons** in the panel. Plan §Faza E: "defer scoping per 002." v2's `feature-info-panel.tsx` has only three actions: `Citește din ANCPI` (parcel/tech), `Export GeoPackage` (eterra.live deep-link), `Comandă CF` (disabled — "Va fi disponibil la Faza F"). Aligned with plan; not a gap.
- `[blocker for global rollout, ship for pilot]` **Map starts at Romania-wide zoom 6 with no auto-fit on click.** When user clicks an UAT in search, behavior = open eterra.live in new tab (not flyTo). Pilot probably aware; broader rollout needs the bounds API or a fallback `fitBounds` from the parcel polygon's bbox.
- `[cosmetic]` "gis.ac · v2" badge bottom-right (`geoportal-v2.tsx:83-85`) — intentional, fine. Remove on global rollout.
## 6. Infisical drift (satra `.env` vs `/architools` prod)
Cross-checked `ssh satra "grep ^[A-Z] /opt/architools/.env"` (41 keys) vs Infisical `/architools` prod (39 keys).
**Drift category A — both places (31 keys, app-secret duplication):**
`ADDRESSBOOK_API_KEY`, `ANCPI_*` (5), `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`, `AUTHENTIK_ISSUER`, `BREVO_API_KEY`, `DATABASE_URL`, `DWG2DXF_URL`, `ENCRYPTION_SECRET`, `ETERRA_PASSWORD`, `ETERRA_USERNAME`, `MANICTIME_TAGS_PATH`, `MINIO_*` (6), `NEXTAUTH_SECRET`, `NEXTAUTH_URL`, `NOTIFICATION_*` (3), `PORTAL_ONLY_USERS`, `STIRLING_PDF_*` (2).
- `[ship]` Behaviour is correct because the new `docker-compose.yml` no longer references these keys in the `environment:` block (only `INFISICAL_CLIENT_ID/SECRET`, `NODE_ENV`, `PORT`, `HOSTNAME` remain). `docker-entrypoint.sh` fetches them from Infisical at boot and `export`s them. The .env duplicates are **dead bytes** for the running container — never read by compose.
- `[future]` Drift is still a maintenance hazard. If a future operator rotates a secret in .env and forgets Infisical, nothing breaks (compose ignores the .env value), but reading `/opt/architools/.env` to "find the current value" gives a false answer. **Recommendation:** prune `.env` to bootstrap-only keys (`INFISICAL_CLIENT_ID`, `INFISICAL_CLIENT_SECRET`, `NODE_ENV`, and the build-time `NEXT_PUBLIC_MARTIN_URL` / `NEXT_PUBLIC_PMTILES_URL`) after the cutover stabilizes.
**Drift category B — only in `.env` (10 keys):**
- `INFISICAL_CLIENT_ID`, `INFISICAL_CLIENT_SECRET` — bootstrap, **must stay** in .env.
- `NODE_ENV` — compose reads `${NODE_ENV:-production}`, can stay or be removed (default kicks in).
- `DB_HOST`, `DB_NAME`, `DB_PASS`, `DB_PORT`, `DB_USER`**only used by the `tippecanoe` service** (compose line 84-88), which has `profiles: ["tools"]` and is started ad-hoc. Belongs in .env. Acceptable.
- `NEXT_PUBLIC_MARTIN_URL`, `NEXT_PUBLIC_PMTILES_URL`**build-time args** (Dockerfile lines 26-30) baked into the static client bundle. They must be in .env at `docker compose build` time. Cannot move to Infisical without entrypoint-side build (not viable for `NEXT_PUBLIC_*`). Acceptable.
**Drift category C — only in Infisical (8 keys):**
- `AI_PROVIDER`, `AI_MAX_TOKENS` — referenced in compose pre-refactor; no longer in compose `environment:` block but still fetched by entrypoint and present in Infisical. AI module reads them via `process.env.AI_*`. OK.
- `AUTHENTIK_JWKS_URL` — set in Infisical but **not consumed** by current code. NextAuth `AuthentikProvider` uses `issuer` to derive JWKS via discovery. Dead key (until/unless gis-api side validates with explicit JWKS URL — that's a gis-api concern, not architools). Cosmetic.
- `AUTHENTIK_SCOPES` = `"openid email profile enrichment offline_access"` — verified present; this is how `offline_access` is actually getting into the auth flow (overrides the code default `"openid email profile enrichment"`). Critical for refresh_token issuance from Authentik. **The fact that code default doesn't include `offline_access` is an audit finding** — if someone deletes `AUTHENTIK_SCOPES` from Infisical, refresh stops working silently. See §7 finding.
- `GIS_AC_PILOT_USERS` — Faza C pilot list. Live and used.
- `GIS_API_URL` — Faza D thin client target. Live.
- `N8N_WEBHOOK_URL` — PMTiles rebuild webhook (existing functionality, was on satra .env before). Compose no longer references it; entrypoint pulls from Infisical and `parcel-sync` reads from `process.env`. Working.
- `USE_GIS_AC` — Faza C cutover global toggle. Currently expected to be `""` (only pilot user enabled).
## 7. Diagnostic logs left in code
All emitted only on real errors (`.warn` / `.error`) except the one `.log`:
- `[cosmetic]` `console.log("[auth] refresh OK expires_in=%d", ...)` at `auth-options.ts:69` — fires every ~5min for every active session. Will be high-volume in container logs. Suggest demote to `console.debug` or gate behind `process.env.AUTH_DEBUG === "1"`. Not a blocker — values are integers, not tokens.
- `[ship]` `console.warn("[auth] refresh failed: …")` / `console.warn("[auth] refresh error: …")` — fires on failure only. Keep as-is, this is real ops signal.
- `[ship]` `console.error("[gis-search]", "[gis-parcela]", "[gis-parcel-tech]" "internal error: …")` — error-path only, no token leakage (message truncated to 200 chars via `msg.slice(0, 200)`). Keep.
- `[ship]` `console.warn("[v2-click] tile props missing siruta/cadastral_ref:", p)` — fires only when PMTiles emits an unexpected feature shape. Useful diag, low volume. Keep.
- `[ship]` The earlier `7a22b11` debug log in `gis-search/route.ts` and `1c6efb9` `session.debug` exposure were both reverted in `6054d08` and `68355ef` respectively. Verified — neither leaks into HEAD.
**Code default scope omits `offline_access`.** Plan 003 §Faza B specified `scope: "openid profile email enrichment"`. The code reads `process.env.AUTHENTIK_SCOPES || "openid email profile enrichment"`. The production-required `offline_access` (for refresh_token issuance) is only present because the Infisical value overrides the default. `[future]` Tighten the default to include `offline_access` so a missing Infisical key doesn't break refresh.
## 8. UX gotchas the pilot user might hit
- `[ship]` **Search-dropdown UAT click opens new tab to eterra.live** (`geoportal-v2.tsx:34-41`). Intentional, documented in code comment. Pilot has been told. Track: add `GET /api/v1/uat/:siruta/bounds` to gis-api so v2 can `fitBounds` in-place.
- `[ship]` **Pilot users (only) see V2; everyone else sees V1.** Confirmed in `geoportal-module.tsx:30-40`: `useSession()``Boolean(session.useGisAc)` → render V2 or V1Legacy. Server-side `useGisAcFlag` reads `USE_GIS_AC` env (currently unset = `""`) and `GIS_AC_PILOT_USERS` (only the pilot email). Non-pilot users land on V1 with byte-identical render output to pre-cutover.
- `[future]` **Search results dropdown shows UATs and parcele but does not snap the map.** UAT click → external tab (above). Parcel click → opens info panel immediately via `setClicked({…})` with `siruta: ""` (empty, line 47), so the **"Citește din ANCPI" button will fail with `missing_siruta_or_cad`** because `feature.siruta` is empty for search-originated features. The map-click path passes siruta correctly; the search-click path doesn't. **Severity: blocker for pilot productivity** if they expect to find parcels by cadastral search then enrich them. Test plan should cover this case.
- `[cosmetic]` "gis.ac · v2" badge in bottom-right corner. Aesthetic. Will be removed at global rollout.
- `[cosmetic]` "Comandă CF" button in v2 panel is disabled with tooltip "Va fi disponibil la Faza F". Pilot may try to click, see disabled state. Acceptable.
- `[future]` **No re-login UX** when `session.error === "RefreshAccessTokenError"`. The session keeps a stale token, every gis-api call returns 401, panel shows raw error code. Adding a session-error watcher in `providers.tsx` (or extending `VersionWatcher`) would close the loop.
- `[future]` **Map basemap switch tears down and rebuilds the whole MapLibre instance** (`map-viewer.tsx:198-283``useEffect` depends on `basemap`). PMTiles source has to re-fetch metadata each time. On slow networks this is noticeable. v1 had the same issue; not a regression.
- `[future]` **PMTiles overview tile coverage.** v2 uses a single `overview.pmtiles` from `pmtiles.gis.ac`. If that file's contents don't include UATs the pilot needs (e.g. a UAT not yet in the central DB), the map is silently empty for that area. v1 was backed by satra Martin and had different sync state. Worth a sanity check before broad rollout.
---
## Verdict
**Ready to enable for the pilot user broadly: YES (with one caveat).**
The flag=0 path is byte-identical to pre-cutover; legacy code is untouched. TypeScript clean. Secret hygiene clean. No console leaks. The only **pilot-blocking** finding is **§8 third bullet** — clicking a parcel in the search dropdown sets `siruta: ""`, which breaks the "Citește din ANCPI" button. That should be patched before relying on cadastral-search workflows. Either:
(a) `gis-api`'s `/search` response carries `siruta` per feature (preferred), or
(b) `handleFeatureSelect` in `geoportal-v2.tsx` does a follow-up `parcela.get(f.id)` to hydrate `siruta` before showing the panel.
**Ready for global rollout (flip `USE_GIS_AC=1`): NO.** Three blockers/gaps need to land first:
1. The siruta-empty-from-search bug above.
2. UAT-bounds endpoint in gis-api so search → flyTo works in-place (no eterra.live tab).
3. Faza F shipped (CF ordering parity) — currently the v2 panel's "Comandă CF" button is disabled. The work is half-done in the working tree (`src/app/api/cf/*`, `cf-api-base.ts`, modified `epay-tab.tsx`) but **not committed** to main.
**Operational improvements (no rollout impact):**
- Demote `[auth] refresh OK` to debug-level.
- Add `offline_access` to the code-default `AUTHENTIK_SCOPES`.
- Prune duplicate keys from `/opt/architools/.env` after a few weeks of stable Infisical bootstrap.
- Wire `session.error === "RefreshAccessTokenError"` to a re-login UX.
- Remove unused `objectId` field on `ClickedFeatureLite` if nothing materializes by Faza F.