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

258 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `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 `{"<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: `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 <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):
```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 = <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`