Files
ArchiTools/docs/plans/003-architools-cutover-execution-2026-05-17.md
T
Claude VM 28c870fb12 harden(epay): cart-hygiene invariant uses confirmed cart count + add service architecture plan
- cartCount tracks actual cart rows (decrement only on confirmed delete) so a
  failed cleanup delete can't trigger a false dirty-cart abort.
- docs/plans/006: the multi-tenant CF-service architecture (DB-backed
  fulfiller, account pool, catalog dedup, per-tenant credential model,
  reversible flag flip) — the executable next phase. The Phase-F flag flip is
  gated on the orchestrator fulfiller existing (Plan 003 Faza F was wrong).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:06:06 +03:00

17 KiB
Raw Blame History

Plan 003 — ArchiTools cutover execution (post-PR1+PR2)

Date: 2026-05-17 Author: gis-api session (Claude, /home/orchestrator/Code/gis-api) Supersedes Faza AH of: 002-architools-thin-client-review.md Starting brief for: next ArchiTools session


TL;DR — what changed since 002 was written

PR1 (Authentik OIDC dual-support, ad1825a deployed 22:25) and PR2 (5 proxy endpoints + scope filter + CORS + rate limit + audit + RLS smoke test, fc459e0 deployed 22:55) are both live on api.gis.ac. Migration 008-api-audit.sql applied to gis DB. RLS leak from 2026-05-17 patched; relrowsecurity enforced via CI smoke test.

Skip Faza A entirely — its full content is done.

One pre-cutover gis-api blocker discovered (PR2.1 below) — must ship before architools' first request hits the rate limiter or audit log starts collecting unreadable 40-char tenant strings.

Everything else in 002 (Faza BG) is executable against the deployed api.gis.ac. Faza H (Planhub) deferred — separate PR after architools is stable.


⚠️ PR2.1 — Tenant-slug translation (gis-api blocker)

Status: NOT YET SHIPPED. ~30 min of work in gis-api repo.

Problem: PR2 lifts audclaims.tenant verbatim. For Authentik authorization_code AND client_credentials flows, aud is the OAuth client_id (40-char opaque string like V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMi), not the friendly slug architools. Two real consequences:

  1. Rate limiter mis-keyed: GIS_API_TENANT_RATE_LIMITS env JSON uses friendly slugs ({"architools":500,"planhub":120,...}). Bucket lookup on claims.tenant = the 40-char client_id → miss → falls to _default (100 rpm). architools would silently run at 5× lower throughput than configured.
  2. Audit log unreadable: gis_meta."ApiAudit".tenant stores the 40-char string. Ops queries (SELECT … WHERE tenant = 'architools') return zero rows. Future billing routing has the same problem.

Fix shape (small, surgical):

  • New env: GIS_API_TENANT_MAP — JSON {"<client_id>":"<slug>", ...}. Missing entry → fall back to verbatim aud. Missing env → no translation (current behavior).
  • In src/lib/auth.ts, after audToTenant(payload.aud), look up in the map.
  • Unit test that adds a case to tests/auth/oidc.test.ts (or new tenant-map.test.ts).
  • .env.example entry. Infisical /gis-api adds the secret.

Who ships this: gis-api session (next firing or this one if Marius approves). Architools cutover is gated on this being deployed first.

Required values: ArchiTools client_id (confirmed in user message: V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMiarchitools). Planhub + eterra-live client_ids: TBD as those apps onboard.


Faza B — ArchiTools secrets + Authentik client (½ day)

Aspect Detail
Owner ArchiTools session + Marius (Infisical)
Repo ArchiTools (NextAuth config) + Infisical (secrets) + Authentik (no changes — provider pk=6 already exists with gis-api enrichment scope mapping attached, per reference-authentik-provisioned-state memory)
gis-api involvement Zero. Just confirm: architools provider pk=6 in Authentik issues tokens with iss = https://auth.beletage.ro/application/o/architools/, aud = V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMi. NOTE: gis-api's current OIDC path validates against GIS_API_AUTHENTIK_ISSUER = https://auth.beletage.ro/application/o/gis-api/ — architools tokens will NOT match this issuer and will fall through to HS256 path → 401. See "Multi-issuer JWT support" below — this is a second gis-api blocker that PR2.1 (or PR2.2) must address.

Infisical adds (/architools folder, prod env):

  • AUTHENTIK_CLIENT_ID — value from Authentik provider pk=6
  • AUTHENTIK_CLIENT_SECRET — from provider pk=6
  • AUTHENTIK_ISSUER = https://auth.beletage.ro/application/o/architools/
  • AUTHENTIK_JWKS_URL = https://auth.beletage.ro/application/o/architools/jwks/
  • GIS_API_URL = https://api.gis.ac
  • NEXT_PUBLIC_MARTIN_URL = https://tiles.gis.ac (verify infrastructure live first)
  • NEXT_PUBLIC_PMTILES_URL = https://pmtiles.gis.ac/overview.pmtiles (same)

ArchiTools NextAuth changes:

  • Add Authentik OIDC provider. Request scope: "openid profile email enrichment" so the enrichment_scope claim is in the issued access_token (per the scope mapping attached to provider pk=6).
  • Session callback stores access_token on the session object (not the JWT — different things; Authentik issues the JWT we forward).
  • Remove any HS256 minting code (gis-api-token.ts and similar — per 002 corrections).

Verification before moving to Faza C:

  • architools can mint an Authentik authorization_code token end-to-end (login flow works).
  • Decoded token includes enrichment_scope, is_beletage_group, org_ids claims.
  • curl -H "Authorization: Bearer <token>" https://api.gis.ac/api/v1/me returns 200 with those claims. This curl will FAIL today until multi-issuer support ships — see PR2.2 below.

⚠️ PR2.2 — Multi-issuer JWT support (second gis-api blocker)

Status: NOT YET SHIPPED.

Problem: PR1's src/lib/auth.ts validates iss === GIS_API_AUTHENTIK_ISSUER (the gis-api provider's issuer). architools tokens carry iss = https://auth.beletage.ro/application/o/architools/ — different issuer, different JWKS endpoint. Current code falls through to HS256 → 401.

Fix shape:

  • Multi-issuer config: env GIS_API_ACCEPTED_ISSUERS = JSON array ["https://auth.beletage.ro/application/o/gis-api/","https://auth.beletage.ro/application/o/architools/", …].
  • For each accepted issuer, derive its JWKS URL via the well-known discovery path ({iss}/.well-known/openid-configuration) at boot, or accept a parallel GIS_API_JWKS_URLS JSON map keyed by issuer. The latter is simpler + avoids a discovery call at first-token-time.
  • Cache one createRemoteJWKSet per issuer at module scope.
  • Pre-parse iss from the token (already done) and route to the matching JWKS getter.

Bundle PR2.1 + PR2.2 as one gis-api PR ("PR3" in the cutover flow): they touch the same file (auth.ts), share a config layout (per-issuer + per-tenant maps), and architools needs both before it can call.

Who ships: gis-api session, ~1 hour total.

Required values (need to be in Infisical /gis-api):

  • GIS_API_ACCEPTED_ISSUERS = ["https://auth.beletage.ro/application/o/gis-api/","https://auth.beletage.ro/application/o/architools/"] (add planhub+eterra-live later)
  • GIS_API_JWKS_URLS = {"https://auth.beletage.ro/application/o/gis-api/":"https://auth.beletage.ro/application/o/gis-api/jwks/","https://auth.beletage.ro/application/o/architools/":"https://auth.beletage.ro/application/o/architools/jwks/"}
  • GIS_API_TENANT_MAP = {"V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMi":"architools"} (PR2.1)

Faza C — Rip-out (1 day, feature-flagged)

Aspect Detail
Owner ArchiTools session
Repo ArchiTools only
gis-api involvement None

NEXT_PUBLIC_USE_GIS_AC=1 flag controls cutover. Plan-as-written stands.

One verification gis-api can pre-confirm: CORS allowlist on api.gis.ac includes architools.beletage.ro (deployed in PR2 src/lib/hono-app.ts). Browser preflight from architools origin → 204 with Access-Control-Allow-Origin echo (smoke-tested 22:55).

Do not drop Prisma tables (GisFeature, GisUat, GisSyncRun, GisSyncRule, CfExtract) in this PR — keep as dead columns 2-3 days. Drop in follow-up PR after prod validation.


Faza D — Thin client (1-2 days)

Aspect Detail
Owner ArchiTools session
Repo ArchiTools only
gis-api involvement None — all endpoints exist as specified

Endpoint contract confirmation (each verified against PR2 deploy):

ArchiTools method gis-api endpoint Auth Scope
gisApi.me() GET /api/v1/me Bearer any
gisApi.parcela.get(id) GET /api/v1/parcela/:id Bearer >= basic; none → 403; basic redacts PROPRIETARI / PROPRIETARI_VECHI / NR_CF / DOC
gisApi.search(q, limit) GET /api/v1/search?q=&limit= Bearer any
gisApi.parcel.tech(body) POST /api/v1/parcel/tech Bearer >= basic
gisApi.parcel.unitsFetch(body) POST /api/v1/parcel/units/fetch Bearer >= basic
gisApi.parcel.immApps(body) POST /api/v1/parcel/imm-apps Bearer >= basic
gisApi.building.tech(body) POST /api/v1/building/tech Bearer >= basic
gisApi.building.condoOwners(body) POST /api/v1/building/condo-owners Bearer >= basic
gisApi.enrichment.cf.list({...}) GET /api/v1/enrichment/cf?… Bearer RLS-filtered
gisApi.enrichment.cf.get(id) GET /api/v1/enrichment/cf/:id Bearer RLS-filtered
gisApi.enrichment.cf.create(body) POST /api/v1/enrichment/cf Bearer RLS-owned write
gisApi.enrichment.cf.patch(id, body) PATCH /api/v1/enrichment/cf/:id Bearer RLS-owned write
gisApi.enrichment.cf.uploadPdf(id, buf) POST /api/v1/enrichment/cf/:id/pdf Bearer + Content-Type: application/pdf RLS-owned write
gisApi.enrichment.cf.getPdf(id) GET /api/v1/enrichment/cf/:id/pdf Bearer RLS-filtered
gisApi.enrichment.catalog(nrCadastral) GET /api/v1/enrichment/catalog/:nrCadastral Bearer >= basic

Proxy request payload shape (parcel/tech, parcel/units/fetch, building/tech, building/condo-owners):

{ siruta: string /* /^\d{3,7}$/ */, cadastralRef: string /* 1..200 */, force?: boolean }

For parcel/imm-apps add layerId?: "TERENURI_ACTIVE" | "CLADIRI_ACTIVE".

Do NOT pass correlationId from the client — gis-api rewrites it server-side. Anything you send is silently overwritten.

Response shape: orchestrator response forwarded verbatim. Per reference-orchestrator-endpoints memory: {status: "ok", data: {...}} on success or {error: "...", code} on failure.

Rate limit headers on every response: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (seconds). On exhaustion: 429 { error: "rate_limited", retryAfterSec }.

No correlationId echoed in proxy response today (see project-audit-correlation-echo memory). If architools wants to correlate, generate a client-side request ID and log it alongside; gis-api's gis_meta."ApiAudit" row has the server-side traceId in format ${tenant_slug}:${sub.slice(0,8)}:${reqId} indexed by (tenant, ts DESC).


Faza E — Geoportal rewrite (2 days)

Aspect Detail
Owner ArchiTools session
Repo ArchiTools only
Prerequisite to verify before starting tiles.gis.ac (Martin) and pmtiles.gis.ac/overview.pmtiles are live. NOT a gis-api concern — infrastructure on shop. The 002 brief assumed they exist; confirm with infra team before ripping out the satra Martin source.
gis-api involvement Confirm via curl curl -I https://tiles.gis.ac/ + curl -I https://pmtiles.gis.ac/overview.pmtiles. If either 404s, that's an infrastructure blocker for Faza E (not gis-api).

map-viewer.tsx, search-bar.tsx, feature-info-panel.tsx, basemap-switcher.tsx rewrites stand as-written in 002.

For feature-info-panel.tsx calling gisApi.parcel.tech() on missing enrichment: that path requires enrichment_scope >= basic. Beletage staff (Arhitecti LDAP group) get enrichment_scope=full from the scope mapping → no friction. Anonymous / non-Arhitecti users get none → 403 → panel must handle gracefully (probably hide enrichment section + show "log in for details").

boundary-check / cf-status / export / pad / piz: defer scoping per 002. If architools keeps any, evaluate whether a thin proxy to api.gis.ac is enough OR if a new endpoint is needed. New endpoints = new gis-api PR — out of scope for this cutover.


Faza F — ePay / CF ordering (½ day)

Aspect Detail
Owner ArchiTools session
Repo ArchiTools only
gis-api involvement None — endpoints already exist

UI flow: "Comandă extras CF" button calls gisApi.enrichment.cf.create({nrCadastral, type: "epay", …}). Status + download list via enrichment.cf.list + enrichment.cf.getPdf(id).

Tenant claim for billing routing: post-PR2.1, claims.tenant === "architools" (human-readable). The CfExtract row will be RLS-owned by the user's sub, but ops can attribute billing via the audit log + tenant join. Future billing PR will pick this up.

Delete src/app/api/ancpi/* entirely.


Faza G — Test E2E + deploy (½ day)

Aspect Detail
Owner ArchiTools session
Repo ArchiTools only
gis-api involvement Read-only: watch container logs + gis_meta."ApiAudit" for first architools traffic

Test plan:

  1. /geoportal in architools renders tiles from gis.ac (no satra requests).
  2. Click a parcel → gisApi.parcela.get(id) returns full enrichment (Beletage staff scope=full).
  3. Click parcel with no enrichment → gisApi.parcel.tech() triggers orchestrator live-fetch → enriched response returns.
  4. Place a CF order → gisApi.enrichment.cf.create() → row appears in gis_meta."CfExtract" with userId = <Arhitecti user sub>.
  5. Audit log: SELECT tenant, endpoint, statusCode, ts FROM gis_meta."ApiAudit" WHERE tenant='architools' ORDER BY ts DESC LIMIT 20; shows architools traffic with human-readable tenant.
  6. Rate limit: 600 rapid calls (architools is configured for 500 rpm) → ~500 succeed, rest 429 with X-RateLimit-Reset ≤ 60.
  7. Zero references to architools_postgres.GisFeature remain in code.
  8. Type-check + lint + build clean.
  9. Portainer redeploy.
  10. 24h grace period: keep old stack (Martin satra, deprecated parcel-sync code) until validation. Follow-up PR drops dead Prisma tables + stops martin satra container.

Prerequisites checklist (in execution order)

Mark these done before opening the architools session:

  • gis-api PR2.1 (tenant translation) + PR2.2 (multi-issuer JWT) shipped + deployed on api.gis.ac. Verified with: mint an architools-issuer token via client_credentials → call https://api.gis.ac/api/v1/me → 200 + claims.tenant === "architools".
  • Infisical /architools populated: AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET, AUTHENTIK_ISSUER, AUTHENTIK_JWKS_URL, GIS_API_URL, NEXT_PUBLIC_MARTIN_URL, NEXT_PUBLIC_PMTILES_URL.
  • Infisical /gis-api populated: GIS_API_ACCEPTED_ISSUERS, GIS_API_JWKS_URLS, GIS_API_TENANT_MAP (PR2.1/PR2.2 inputs).
  • tiles.gis.ac + pmtiles.gis.ac live (curl 200 / non-404). Infra confirms.
  • architools-session Claude has read this plan + MEMORY.md of ~/.claude/projects/-home-orchestrator-Code-gis-api/memory/.

Items resolved from 002's "Open questions"

002 question Resolution
1. Authentik OIDC client provisioning DONE. architools provider pk=6, scope mapping 41b23bc3-bdd8-4a61-b975-6e0eff56df72 attached. See reference-authentik-provisioned-state.
2. Audit table schema exists? DONE. gis_meta."ApiAudit" created by busc-infra 0693457 (008-api-audit.sql). Mirrored in prisma/schema.prisma (model ApiAudit).
3. Rate-limit defaults DONE. GIS_API_TENANT_RATE_LIMITS JSON env, defaults to 100 rpm. Deployed seed: {"architools":500,"planhub":120,"eterra-live":300,"_default":100}. ⚠️ but mis-keyed until PR2.1 ships.
4. archi. schema writes (Canvas, Job, etc.)* DEFERRED. Out of scope for this cutover. Later sprint.

Items obsolete in 002

  • Faza A entire content — done.
  • "PR3 (gis-api): Per-tenant rate limit + audit migration" — done as part of actual PR2 (rolled together).
  • "scope-mapper.ts in architools" correction — already noted; reaffirm: architools must NOT compute scope client-side.
  • "GIS_API_JWT_SECRET (shared) in Infisical" correction — still valid; do not pull this into architools NextAuth.

What this gis-api session will produce next (if Marius approves)

PR2.1 + PR2.2 (bundled into one gis-api commit): src/lib/auth.ts multi-issuer + tenant-map, .env.example adds, unit tests. ~5080 LOC, ~1 hour including typecheck+build+push. Migration of architools onto api.gis.ac is gated on this landing.

After that, this gis-api session is done — architools cutover is owned by the architools session.


Reference

  • gis-api memory: ~/.claude/projects/-home-orchestrator-Code-gis-api/memory/MEMORY.md
  • gis-api recent commits: b34c58e (PR1) → ad1825a (PR1.1) → a43673a..fc459e0 (PR2 = 4 commits)
  • busc-infra: 0693457 (migration 008)
  • Authentik provider pks: gis-api pk=8, architools pk=6; scope mapping pk=41b23bc3-bdd8-4a61-b975-6e0eff56df72
  • Live container: gis-api on shop, image at fc459e0, healthcheck https://api.gis.ac/api/healthz