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>
This commit is contained in:
Claude VM
2026-06-05 00:06:06 +03:00
parent f49fdb1da0
commit 28c870fb12
6 changed files with 1703 additions and 11 deletions
@@ -0,0 +1,257 @@
# 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`