Files
ArchiTools/docs/plans/audit-2026-05-19.md
T
Claude VM 9847b4a070 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>
2026-05-19 08:31:45 +03:00

17 KiB

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 --noEmitexits 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 exports 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_USERonly 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_URLbuild-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-283useEffect 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.