gis-api session shipped PR3 (gis-api 09f1ab8 + gis-sync-orchestrator
0371d81): new POST /api/v1/parcel/enrich does the full eTerra
round-trip (searchImmovableByIdentifier → fetchDocumentationData
→ fetchImmovableParcelDetails) and merges NR_CF / ADRESA / PROPRIETARI
+ 20-plus fields into gis_core.GisFeature.enrichment with a 30-day
cache. Verified on 266888 + 328607 → 27 keys with full PII.
Wired in three places:
1. src/lib/gis-api-client.ts — gisApi.parcel.enrich({siruta,
cadastralRef, force?}) thin wrapper.
2. src/app/api/gis/parcel/enrich/route.ts — architots-side proxy,
matches the parcel/tech pattern (auth check → forward → bubble up
GisApiError status codes).
3. src/modules/geoportal/v2/feature-info-panel.tsx — refreshFromAncpi
now POSTs to /api/gis/parcel/enrich instead of /api/gis/parcel/tech.
After the orchestrator returns, the panel re-fetches the canonical
record via parcela.get (when uuid known) or parcela.find (when
not), so it sees exactly what gis_core stores rather than the
orchestrator response shape.
The existing auto-trigger (fires when detail has no NR_CF/ADRESA/
PROPRIETARI) now actually fills those fields. Subsequent clicks on the
same parcel hit gis-api's 30-day cache (5ms vs 1-2s live fetch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Parcel 328607 in Cluj-Napoca (and many others) is in gis_core with
only 10 enrichment keys, all tech-level (PARCEL_HAS_LANDBOOK,
PARCEL_IS_CONDOMINIUM, etc.) — no NR_CF, no ADRESA, no PROPRIETARI.
The panel renders correctly but with nothing of substance shown.
User had to manually click "Citește din ANCPI" to backfill.
Now auto-fires the fetch when:
- Panel mounts a fresh detail (or after parcel switch)
- AND enrichment lacks ALL three of NR_CF / ADRESA / PROPRIETARI
- AND we haven't already auto-fetched for this parcel this tab
session (sessionStorage dedupe keyed by uuid OR siruta+cad+layerId)
Visible feedback while it runs: a quiet "Se preiau date suplimentare
din ANCPI…" strip below the loading area. The user can keep reading
whatever is already on screen.
Side fixes:
- refreshFromAncpi → useCallback (stable deps) so it can sit in the
auto-trigger useEffect's dep array without infinite loops.
- Refresh path now uses /api/gis/parcela/find as the fallback when no
uuid is available, matching the initial-load logic. The old
orchestrator-shape projection still exists as a last-resort fallback
but is rarely hit.
Note: orchestrator's parcel/tech only re-populates PARCEL_* tech
fields. Truly enriching rich PII (NR_CF/ADRESA/PROPRIETARI) needs the
"deep enrich" orchestrator path which the gis-api proxy contract
doesn't expose yet — separate gis-api task. So parcels that only ever
got tech-level enrichment will stay at tech-level even after this
auto-fetch. The visible improvement is: parcels that DO have rich
data load it in the first second instead of needing a manual click.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marius's feedback: "datele arată foarte ciudat" — the flat dl renderer
showed enrichment keys in whatever order gis-api returned them, with
raw key names (PARCEL_POSTAL_NO instead of "Nr. poștal") and no visual
hierarchy. With 20+ keys the important things (NR_CF, ADRESA,
PROPRIETARI) ended up below the fold under PARCEL_* tech metadata.
Restructure to mirror eterra.live's parcel-info layout:
1. HERO BLOCK at the top of the data area
- "Nr. Carte Funciară" big mono number (NR_CF)
- NR_CF_VECHI shown below when different
- Adresă with pin icon and proper text wrapping
2. NAMED SECTIONS (rendered in fixed order, only when populated)
- Proprietari — splits comma/semicolon-separated names into a list
- Cadastru — NR_CAD, NR_TOPO, PARCEL_TOPO_NO
- Suprafețe — SUPRAFATA_R, SUPRAFATA_2D, PARCEL_LEGAL_AREA, SUPRAFATA
(values parsed + " m²" suffix)
- Înscriere — SOLICITANT, TIP_INSCRIERE, DATA_CERERE, DOC
- (CF/Adresă removed from the list because they're in the hero)
3. CARACTERISTICI CHIPS (existing) — Intravilan / Categorie / nr corpuri /
status stay at the top. CARACTERISTICI_KEYS excludes them from the
sections below so nothing is shown twice.
4. DETALII TEHNICE (collapsed by default) — anything not in a named
section: PARCEL_HAS_LANDBOOK, PARCEL_IS_CONDOMINIUM,
PARCEL_TECH_ENRICHED_AT, etc. Renders with friendly labels (Da/Nu
for flags, dd/mm/yyyy hh:mm for dates) instead of "1" / ISO strings.
5. Value renderers per key type:
- formatAreaValue("456" | "456.06" | "456 mp") → "456 m²"
- formatFlag(0/1, da/nu) → "Da" / "Nu"
- formatDate(ISO) → "19.05.2026, 04:40" (Romanian locale)
- parseOwners("MATHBOUT MOHAMED MAHER, DR.") → list items
6. enrichedAt moved to a single small line at the bottom of the data
area instead of a per-section caption.
LABEL map covers all 25 enrichment keys observed in DB. Anything new
falls back to raw key name (visible — easy to spot and add).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the "Re-loghează-te" button + scope-mismatch warning prose.
On 403 from /api/gis/parcela/find the panel now:
1. Checks sessionStorage flag — false on first 403 of the tab
2. Sets the flag, fires signIn("authentik", { callbackUrl: current
URL }) silently. For an SSO'd user this is a sub-second Authentik
redirect cycle that mints a fresh access_token with the right
scope claims, lands the user back on the same panel, and the
re-mount fetches successfully — no visible message, no prompt.
3. If another 403 happens after the retry (i.e., Authentik genuinely
can't grant the scope — config issue, not a stale-token issue),
falls through to a discreet "Datele detaliate nu pot fi încărcate
momentan." note. No call-to-action, no jargon.
4. On any successful 200 fetch, clears the sessionStorage flag so a
future 403 in the same tab can re-trigger the silent retry.
Per Marius: "vreau doar să meargă, safe și fix" — no auth-flow
chrome shown to the user. The recovery is part of the system's
correctness contract, not a feature for the user to manage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The other-session's gis-api investigation found that gis-api is
working correctly — full/basic/none scopes all behave per spec.
The bug was in our /api/gis/parcela/find proxy: when EVERY candidate
returned 403 from gis-api (because the caller's JWT carried no
enrichment_scope claim), the proxy swallowed the 403s and returned
silent 404. The panel then rendered the "not in central DB" empty
state instead of prompting re-login.
This was the case for Marius today — his pre-refresh-fix session
held a token without the enrichment claim. After the auth self-heal
fix (commit 8ff67d1) the next gis-api call would have re-authed
correctly, but the panel never gave him that signal because find
hid the 403.
Fix in two places:
1. /api/gis/parcela/find:
- Count 403s seen during candidate iteration
- If forbiddenCount > 0 && forbiddenCount === candidates.length,
return 403 { error: "scope_insufficient", ... } with a log line
[gis-parcela-find] all_candidates_forbidden siruta=X cad=Y N
- Otherwise log [gis-parcela-find] no_match (so we never go silent)
2. feature-info-panel: when fetch returns 403, the existing
"forbidden" UI was a passive warning. Now it shows an actionable
"Re-loghează-te" button that fires signIn("authentik", {
callbackUrl: current }) — same path SessionErrorWatcher uses for
RefreshAccessTokenError.
Reference: gis-api session report 2026-05-19 (Marius forwarded
analysis); the gis-api repo is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three-layer fix for the "session keeps dying with invalid_grant" pain:
1. Authentik provider config (separate change via API):
access_token_validity bumped 5min → 60min so refreshes are 12x less
frequent. Refresh-token rotation collisions only happen during the
refresh, so a longer access_token TTL means far fewer windows.
2. jwt callback (auth-options.ts): when Authentik responds 400
invalid_grant on refresh, the stored refresh_token is permanently
dead — Authentik rotated it on a previous successful refresh and the
old value can't be reused. Clear it (and the access_token) from the
JWT so subsequent session checks see a clean RefreshAccessTokenError
instead of looping into the same 400 every 5 minutes.
3. SessionErrorWatcher (new client component, mounted in providers
tree): listens for session.error === "RefreshAccessTokenError" and
calls signIn("authentik") with the current URL as callback. The
cleared JWT cookie means Authentik runs a full OIDC flow, mints fresh
tokens, and the user lands back where they were. No manual logout.
Net effect: refresh storms become invisible — at worst there's a single
redirect to Authentik (silent if the user is still SSO'd) instead of a
broken session that 401s every API call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
f9bf2ca4 has 25 enrichment keys in gis_core.GisFeature but
parcela.get returns only 10 — all PII (NR_CF, ADRESA, PROPRIETARI)
redacted. Symptom of enrichment_scope=basic. Plan 003 §Faza B says
Arhitecti LDAP group should get full. Need to verify the mapping.
Calls gisApi.me() and returns the claims. Logs them server-side
(truncated to 500 chars). Marius hits the URL once, we see what
enrichment_scope his JWT actually carries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous fix searched by cadastralRef and picked the first
layerId-matching result. But cadastral refs collide across UATs:
"354686" exists in multiple counties. The Cluj-Napoca f9bf2ca4-...
parcel with full enrichment got passed over for a same-cad parcel
in another UAT that has no enrichment → panel rendered header +
"Caracteristici" with empty Intravilan, no "Date eTerra" section.
New server-side /api/gis/parcela/find?siruta&cad&layerId proxy:
- gisApi.search(cad) → filter by layerId → up to ~20 candidates
- For each candidate, parcela.get and check stored siruta
- Return the siruta-matching detail
- Fallback: first readable candidate (so the panel still has data
even if siruta mismatch — better than empty)
Panel useEffect simplified: fast path = parcela.get by uuid when the
tile has one, slow path = parcela/find when not. 404 from find sets
the "not in central DB yet" empty state (user can hit Citește din
ANCPI to trigger orchestrator live-fetch).
Diagnostic logs: [gis-parcela-find] siruta=… cad=… layerId=…
candidates=N + per-hit "has_enrich=true keys=N" so we can tell from
container logs whether the right parcel resolved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of B1 (panel showed "Apasă din ANCPI" even with full enrichment
in DB): PMTiles overview tiles don't carry the GisFeature uuid, only
siruta/cadastral_ref/object_id. The panel's useEffect bailed out at
`!feature.id` and never fetched. So the data was there, the UI just
refused to ask for it.
Fix: when the click feature has no uuid, the panel now calls
`/api/gis/search?q=<cadref>`, filters by layerId match, and uses the
returned id to do `parcela.get(id)`. One extra round trip (~50ms with
the trigram-idx fix from 2026-05-18). For features arriving from the
search dropdown the uuid is already known — that path is unchanged.
Panel redesign — same data shape as eterra.live, ArchiTools styling
(shadcn instead of HeroUI), single-file:
- Header: cadref + layer + area + status chip + close
- Caracteristici: intravilan + categorie folosință + nr corpuri (chips)
- Date eTerra: all enrichment fields, PII passes through gis-api scope
redaction (scope=basic → PROPRIETARI/NR_CF/DOC already null)
- Apartamente (condominium): for CLADIRI_ACTIVE clicks, fetches
/api/gis/building/condo-owners and renders units with owners + cf + area
- Localizare: click lat/lng + Google Maps link + SIRUTA echo
Two new proxy routes (thin wrappers over gis-api):
- POST /api/gis/parcel/units-fetch
- POST /api/gis/building/condo-owners
Basic-panel mode for restricted users (per Marius: "for users I don't
want to give full access to"):
- New env BASIC_PANEL_USERS (csv emails) → session.basicPanel flag
- Optional PANEL_BASIC_GLOBAL=1 to force-basic everyone
- When true, panel renders only header + cadref + suprafață + a
restriction notice; all sections + condo fetch are skipped
- Defaults to off; pilot user Marius gets full panel as before
map-viewer now forwards lngLat on click so the Localizare section has
coordinates without a second lookup.
Type-check clean. Production build (NODE_ENV=production npx next build)
passes. The dev-mode prerender error on / page is pre-existing (Next 16
useContext-null on client component during static export, unrelated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds gateLegacyGisWrite() helper that returns 410 when the caller is on
the api.gis.ac path (global USE_GIS_AC=1 or per-user GIS_AC_PILOT_USERS).
Wired into 13 routes covering every entry point that touches Gis*
tables on architools_postgres — directly or via parcel-sync services.
Why: yesterday 4 GisFeature rows were updated on architools_postgres
even though the scheduler is officially disabled. Root cause: pilot
user opened the legacy /geoportal UI in a stale tab and clicked
parcels; POST /api/geoportal/enrich wrote directly to the local DB.
Without a write gate, Faza H (pg_dump + REVOKE + DROP) is unsafe —
any stale tab in any user's browser can still trip writes between
freeze and DROP.
Gated routes (writes only — reads stay open for rollback ergonomics):
- /api/geoportal/enrich (POST) — the writer of the 4 rows
- /api/eterra/sync-rules (POST), /api/eterra/sync-rules/[id] (PATCH+DELETE)
- /api/eterra/sync-rules/bulk (POST)
- /api/eterra/uats (POST+PATCH)
- /api/eterra/sync (POST), /api/eterra/sync-county (POST)
- /api/eterra/sync-background (POST), /api/eterra/sync-all-counties (POST)
- /api/eterra/auto-refresh (POST), /api/eterra/refresh-all (POST)
- /api/eterra/export-layer-gpkg (POST), /api/eterra/export-bundle (POST)
(last two trigger syncLayer write-first-then-export)
Read-only routes intentionally NOT gated: sync-status, no-geom-scan
(scanNoGeometryParcels is read-only), export-local, db-summary,
counties, search, features (GET), stats, uat-dashboard, sync-rules
(GET), sync-rules/scheduler.
Operations: after redeploy, flip USE_GIS_AC=1 in Infisical /architools
prod env and restart container. Then monitor docker logs for ~30 min:
grep "deprecated" + "/api/geoportal/enrich|/api/eterra/sync*" lines
indicate stale-tab clients that need a refresh. pg_stat_user_tables
write count on GisFeature should hit 0 within one hour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
THE bug behind every "data nu raman" / invalid_token incident this
session: refresh POSTed to `{issuer}/token/` = /application/o/architools/token/
which returns HTTP 405 + empty body. JSON.parse on the empty body
threw "Unexpected end of JSON input" → catch fired → token marked
RefreshAccessTokenError → 60s cooldown later, retry hit the same
broken URL → loop.
OIDC discovery at {issuer}/.well-known/openid-configuration confirms:
"token_endpoint": "https://auth.beletage.ro/application/o/token/"
This is the SHARED endpoint, not per-provider. Hard-fix the URL by
constructing it from the issuer's origin.
Marius's currently-stuck session will auto-recover on next request
(cooldown expires, refresh fires against the corrected URL,
refresh_token still valid 30d).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User pushback on the pool-based CF flow: he wants his own ePay account
(per-user creds, visible credit balance, decrement per order) — not the
shared orchestrator pool which hides cost attribution.
V2 panel "Comandă CF" now opens /parcel-sync?tab=epay&cad=<ref> in a
new tab where the legacy UI handles ordering with credits. The
/api/cf/* gis-api routes stay (used elsewhere + future SaaS consumers)
but the V2 button doesn't hit them.
Plus diagnostic on /api/gis/parcela showing enrichment presence + key
count to debug "data nu raman" — should reveal whether Marius's session
is getting full enrichment back from gis-api.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous logic set token.error=RefreshAccessTokenError and never
retried — once a refresh failed (likely a race during the early
parallel-storm period), Marius's JWT cookie carried that error
forever. New jwt calls all saw "blocked" → kept using the stale
accessToken → api.gis.ac returned invalid_token on every call.
Fix: store errorAt timestamp alongside the error flag. Block refresh
attempts for 60s after a failure (avoids hot-loop on persistent
Authentik issues), then unblock and retry. On the next failure, the
60s cooldown re-arms.
For Marius's currently-stuck session: as soon as this deploys, his
next jwt callback will pass the cooldown check (errorAt is hours ago)
and trigger a fresh refresh. If Authentik is happy with his
refresh_token, the error flag is cleared and he's back to normal —
no relogin needed.
Logs now show "blocked=true/false" alongside secLeft for visibility.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was a disabled placeholder ("Va fi disponibil la Faza F"). Now
POSTs to /api/cf/order with nrCadastral + siruta + gisFeatureId.
Forwards 409 catalog_hit as "Extras CF valid deja există".
Spinner during request, result text shown below the toolbar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
epay-order-button.tsx was disabling the "Comanda CF" button on
!epayStatus.connected even for gis.ac pilot users. The new path
dispatches ePay calls through the orchestrator's shared account
pool — no per-user ePay session or credit balance applies.
Legacy path keeps the original gates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit-flagged blocker: search-dropdown feature select → setClicked
with siruta:"" because gis-api /search response doesn't include
siruta per feature. If user clicks "Citește din ANCPI" before the
panel's auto-fetch of parcela.get completes, refresh fails with
missing_siruta_or_cad.
Fix: refresh handler now awaits parcela.get inline when siruta is
empty + feature.id is present, then hydrates detail before posting
to /api/gis/parcel/tech.
Proper followup: extend gis-api /search response with siruta per
feature so the race goes away entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan 003 Faza F. Pilot users (session.useGisAc=true) get their CF
extract flow routed through api.gis.ac (RLS-filtered, RLS-owned
writes); everyone else keeps the legacy /api/ancpi/* path
unchanged. Feature-flag preserves rollback.
New routes (5):
- POST /api/cf/order → gisApi.enrichment.cf.create. Forwards
409 catalog_hit verbatim.
- GET /api/cf/orders → gisApi.enrichment.cf.list (limit, offset, status).
- GET /api/cf/[id] → gisApi.enrichment.cf.get.
- PATCH /api/cf/[id] → gisApi.enrichment.cf.patch.
- GET /api/cf/[id]/pdf → streams gisApi.enrichment.cf.getPdf
through to browser. Filename from documentName via cf.get; falls
back to cf-<id>.pdf.
- GET /api/cf/catalog → gisApi.enrichment.catalog.
All use getAuthSession() → 401 on no session, forward GisApiError
status+code+body, fallback {error:"internal_error", hint} at 500.
runtime=nodejs, dynamic=force-dynamic.
Helper module `cf-api-base.ts`:
- cfApiBase(useGisAc) → "/api/cf" | "/api/ancpi"
- adaptCfRow(row) → maps gisApi.CfExtractRow into the UI shape
expected by epay-tab.tsx (CfExtractRecord). Fields not in gis-api
(siruta, judetName, uatName, errorMessage, etc.) default to
empty/zero — filter-by-judet/uat on the pilot path is reduced
until gis-api enriches the response.
- fetchCfOrdersList, fetchCfHasCompletedForCadastral, placeCfOrder,
cfDownloadUrl — used by components.
UI changes:
- epay-tab.tsx: reads session.useGisAc; list fetch, reorder, single
+ bulk download routed via helpers. UI shape unchanged.
- epay-order-button.tsx: existence check uses catalog endpoint on
gis-ac path; order placement uses placeCfOrder which treats 409
catalog_hit as a soft success ("Extras CF valid").
Known gaps (followups):
- /api/ancpi/session still serves ePay session/credits — no gis-api
equivalent today. epay-connect.tsx untouched.
- ZIP bulk download has no gis-api analog; "Descarcă tot" falls back
to N tabs on gis-ac path.
- src/app/api/geoportal/cf-status returns hardcoded /api/ancpi/download
URL — separate followup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Logs session + token + duration on every search. Will revert after
cause identified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UAT click previously console.logged only. gis-api search response
doesn't include bbox/centroid, so ArchiTools can't fitBounds locally.
Reuse the deep-link pattern (already used by Export GeoPackage) →
open eterra.live/harta?siruta=X in a new tab. eterra.live has its own
/api/geoportal/uat-bounds + flyTo wired.
Future: add GET /api/v1/uat/:siruta/bounds to gis-api so ArchiTools
can fitBounds inline without leaving the page.
Also reverts the session.debug diagnostic (Marius confirmed
hasRefreshToken=true + expiresIn=293 after attaching offline_access
scope mapping to Authentik provider pk=6 — root cause fixed,
diagnostic no longer needed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Temporary — verify whether Marius's JWT has a refresh_token. Will
revert once cause is identified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Client-side polling component mounted in providers.tsx. At mount,
captures the initial commit from /api/version. Every 60s, re-checks.
If commit differs from the captured one → renders a dismissible toast
in the bottom-right offering a hard reload.
Useful because Next.js bundles cache per commit hash → after a deploy
users would otherwise keep running the old client until they manually
refresh. Now they get a discoverable nudge.
Banner UX:
- "Versiune nouă disponibilă: <shortSha> · apasă pentru reîncărcare"
- [Reîncarcă] button (window.location.reload)
- [X] dismiss for current page life
- Tailwind animate-in fade slide-from-bottom
Polling interval 60s is fine for our deploy frequency; cheap (one
GET per minute, ~150 bytes). Cache-busted with cache: "no-store".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a public, no-auth endpoint at /api/version that returns:
{ commit, commitShort, buildTime, nodeEnv, cutover, nextVersion }
Build-time injection via GIT_COMMIT + BUILD_TIME ARG/ENV propagated
from compose build.args through Dockerfile builder + runner stages.
Excluded from middleware auth gating.
Deploy command (run on satra after git pull):
GIT_COMMIT=$(git rev-parse HEAD) \
BUILD_TIME=$(date -u +%FT%TZ) \
docker compose build architools
Without these env vars, falls back to "unknown" so the build never
fails; only the endpoint shows reduced info.
Useful for: confirming what's actually deployed after CI, cross-app
deploy correlation (api.gis.ac, eterra.live, orchestrator), uptime
monitors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 fixes for the symptoms Marius hit on Faza E pilot — search returning
"Eroare la căutare" after sessions got stale, even after relogin:
1. Refresh deduplication
Authentik rotates refresh_tokens — exchange-once. Parallel map +
search + parcela.get all hit jwt callback concurrently, each fires
its own refresh, the first wins, the rest get invalid_grant and
poison the JWT with token.error=RefreshAccessTokenError → user
appears logged out for no good reason. Cache the inflight refresh
promise in-memory keyed by refresh_token so concurrent callers
share one Authentik exchange.
2. Fetch timeout in gis-api-client
AbortSignal.timeout(30s) on every api.gis.ac call. Without it, a
slow upstream (ANCPI scrape, orchestrator hiccup) hangs the route
for the full Next.js default → Marius saw 10s gaps with no
feedback. Throws GisApiError(504, upstream_timeout) instead.
3. Better error surfacing
/api/gis/* routes return { error, hint: <first 200 chars> } on
non-GisApiError throws instead of a bare "internal_error". Easier
to triage from browser DevTools without paging through container
logs.
4. Remove diagnostic [gis-search] logs
Diagnostic served its purpose (identified the stale-token cause
pre-refresh-fix). Now noise; keep only [auth] refresh success/fail
+ per-route internal_error.
Also adds AbortSignal.timeout(8s) on the Authentik refresh fetch
itself to keep the jwt callback bounded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Authentik provider pk=6 issues access tokens with 5-minute TTL but
NextAuth's JWT cookie lives 30 days. Without refresh, every api.gis.ac
call after the first 5 minutes returned 401 invalid_token — the exact
failure Marius hit on first Faza E pilot test.
Implementation:
- jwt callback captures account.refresh_token + account.expires_at on
first sign-in alongside access_token.
- Before each jwt issuance, if access_token is within 30s of expiry
and a refresh_token exists, POST to {issuer}/token/ with
grant_type=refresh_token + client_id + client_secret. Update token
with the new access_token + expiry + (rotated) refresh_token.
- On failure, set token.error="RefreshAccessTokenError" and stop
trying (avoid hot-loop). Surfaced via session.error so client UI
can prompt re-login.
AUTHENTIK_SCOPES updated in Infisical to include `offline_access` so
Authentik issues a refresh_token on first sign-in (standard OAuth2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PMTiles overview tiles may carry only object_id + siruta + cadastral_ref
(not the gis_core.GisFeature uuid). Old click handler required `id` and
silently dropped clicks. Now:
- map-viewer click handler: extract objectId AND id; require only
siruta+cadastral_ref to dispatch. Logs the props when fields are
missing for further diagnosis.
- feature-info-panel: skip auto-fetch when feature.id is empty; show
basic info from tile properties + nudge user to "Citește din ANCPI"
to populate enrichment.
- Refresh button: project orchestrator response straight into the
panel when no gis-uuid available (no second parcela.get roundtrip).
Falls back to detail.siruta when click came from search (which only
returns id + cadastralRef, no siruta).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Temporary diagnostic for Faza E debugging — Marius reports search
returning "Eroare la căutare" after relogin. Need to confirm whether
session.accessToken is reaching the route.
Will revert/clean up once cause identified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server-side wrapper around api.gis.ac. Auto-extracts Authentik
access_token from NextAuth session via getServerSession. Full surface
covering 15 endpoints + RateLimit / GisApiError types:
- me, parcela.get, search
- parcel.{tech, unitsFetch, immApps}
- building.{tech, condoOwners}
- enrichment.cf.{list, get, create, patch, uploadPdf, getPdf}
- enrichment.catalog
Streaming PDF endpoint (getPdf) returns raw Response so caller can
forward to browser without buffering. uploadPdf accepts ArrayBuffer/
Uint8Array/Blob with optional X-Document-Name header.
Rate-limit headers (X-RateLimit-{Limit,Remaining,Reset}) parsed and
attached to GisApiError on 429. No correlationId in requests — gis-api
overwrites server-side (per project_audit_correlation_echo memory).
GIS_API_URL env (Infisical /architools) defaults to https://api.gis.ac.
Dormant until Faza E geoportal + Faza F ePay backend swap import it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server-side helper useGisAcFlag(email) → boolean, gated by:
- USE_GIS_AC=1 (global rollout switch), OR
- GIS_AC_PILOT_USERS=a@x,b@y (per-email staged rollout)
Both defaults are off (USE_GIS_AC=0, pilot list empty) in Infisical
/architools — this PR is dormant; no call sites consume the flag yet.
Future Faza D/E call sites in src/lib/gis-api-client.ts and
src/modules/geoportal/* will branch on it.
Exposed on session.useGisAc so client components can branch identically
to server routes without a separate API roundtrip. Re-evaluated per
request → flag flip via Infisical + container restart, no rebuild.
Per-user override (PILOT_USERS) is the rollout vehicle:
1. Deploy with flag=0 (default) → nothing changes
2. Set GIS_AC_PILOT_USERS=marius@... → Marius sees new code path
3. Watch 24-48h → set USE_GIS_AC=1 → global cutover
4. Rollback = unset USE_GIS_AC
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Authentik OIDC provider now requests `openid email profile enrichment`
(from AUTHENTIK_SCOPES env, Infisical-fetched at boot). The enrichment
scope triggers Authentik scope mapping pk=41b23bc3-bdd8-4a61-b975-
6e0eff56df72 which emits enrichment_scope + is_beletage_group claims
based on LDAP group membership (Arhitecti/Administrators/Domain Admins
→ scope=full + is_beletage_group=true).
jwt callback captures account.access_token on first sign-in; session
callback exposes it as session.accessToken so api.gis.ac calls can
forward it. Used by Faza D thin client (src/lib/gis-api-client.ts,
pending) to authenticate against api.gis.ac.
Without scope=enrichment, every architools user falls through to
scope=none on api.gis.ac → 403 on every parcel/enrichment read.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- docker-compose.yml: replace 43 hardcoded env values with ${VAR} references.
Operators must provide /opt/architools/.env (chmod 600, gitignored) with the
matching keys. Removes the historical leak surface where every edit risked
echoing secrets.
- email-service.ts: drop nodemailer SMTP transport; use Brevo REST API
(POST https://api.brevo.com/v3/smtp/email) with BREVO_API_KEY header.
Brevo SMTP relay credentials have been deleted upstream.
- package.json: remove nodemailer + @types/nodemailer.
NOTE: legacy hardcoded credentials present in git history must still be
rotated separately (DB password, Authentik client secret, ENCRYPTION_SECRET,
ANCPI password, etc.).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prevents nightly delta sync (Mon-Fri 01-05) and weekend deep sync
(Fri-Sun 23-04) from writing to GisFeature/GisUat while the schema
is being reworked. Re-enable by uncommenting the import in
src/instrumentation.ts once the new DB layout is stable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pmtiles-webhook returns 409 when a rebuild is already in progress.
Previously this was treated as a failure, showing 'Webhook PMTiles
indisponibil' error to the user. Now 409 is handled as a valid state
with appropriate messaging.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add pmtiles-webhook.ts shared helper for triggering PMTiles rebuild
- sync-county: trigger rebuild when new features synced, pass jobId to
syncLayer for sub-progress, update % after UAT completion (not before)
- sync-all-counties: same progress fix + rebuild trigger at end
- geoportal monitor: use shared helper instead of raw fetch
- weekend-deep-sync + auto-refresh: consolidate webhook code via helper
- docker-compose: default N8N_WEBHOOK_URL to pmtiles-webhook on satra:9876
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both session-store.ts and eterra-client.ts used globalThis.__eterraSessionStore
but for completely different purposes (EterraSession vs Map<string, SessionEntry>).
The Map from eterra-client made getSessionStatus() report connected: true on
server start (Map is truthy), while getSessionCredentials() returned undefined
username/password — causing "Credentiale lipsa" on sync attempts despite the
UI showing a green "Conectat" dot.
Renamed eterra-client's global keys to __eterraClientCache and
__eterraClientCleanupTimer to eliminate the collision.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The top export buttons required all primary layers to be "fresh" (<7 days)
before using the DB download path. When stale, they fell through to live
eTerra sync which requires credentials — blocking users who just want to
download existing data. Now any UAT with data in DB uses the local export
path directly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 1 of unified sync scheduler:
- New Prisma model GisSyncRule: per-UAT or per-county sync frequency
rules with priority, time windows, step selection (T/C/N/E)
- CRUD API: /api/eterra/sync-rules (list, create, update, delete, bulk)
- Global default frequency via KeyValueStore
- /sync-management page with 3 tabs:
- Reguli: table with filters, add dialog (UAT search + county select)
- Status: stats cards, frequency distribution, coverage overview
- Judete: quick county-level frequency assignment
- Monitor page: link to sync management from eTerra actions section
Rule resolution: UAT-specific > county default > global default.
Scheduler engine (Phase 2) will read these rules to automate syncs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- /api/eterra/stats: lightweight polling endpoint (8 parallel Prisma queries, 30s poll)
- /api/eterra/sync-all-counties: iterates all counties in DB sequentially,
syncs TERENURI + CLADIRI + INTRAVILAN + enrichment (magic mode) per UAT
- Monitor page: live stat cards (UATs, parcels, buildings, DB size),
Sync All Romania button with progress tracking at county+UAT level
- Concurrency guard: blocks county sync while all-Romania sync runs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The eTerra connect/disconnect UI and session status were missing from the
deployed monitor page. Also, since ETERRA env vars are empty in the
container, the connect flow now accepts username/password from the UI.
This unblocks county sync which requires an active eTerra session.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same pattern as sync-background: session credentials from eTerra login
take priority, env vars are fallback only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- GET /api/eterra/counties — distinct county list from GisUat
- POST /api/eterra/sync-county — background sync all UATs in a county
(TERENURI + CLADIRI + INTRAVILAN), magic mode for enriched UATs,
concurrency guard, creates notification on completion
- In-app notification service (KeyValueStore, CRUD, unread count)
- GET/PATCH /api/notifications/app — list and mark-read endpoints
- NotificationBell component in header with popover + polling
- Monitor page: county select dropdown + SyncTestButton with customBody
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Changed bg-background/95 to bg-background (opaque) and added
text-foreground to ensure input text is visible on dark theme.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>