# Plan 003 — ArchiTools cutover execution (post-PR1+PR2) **Date:** 2026-05-17 **Author:** gis-api session (Claude, `/home/orchestrator/Code/gis-api`) **Supersedes Faza A–H 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 B–G) 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 `aud` → `claims.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 `{"":"", ...}`. 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: `V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMi` → `architools`). 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 " 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): ```ts { 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 = `. 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. ~50–80 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`