- 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>
17 KiB
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:
- Rate limiter mis-keyed:
GIS_API_TENANT_RATE_LIMITSenv JSON uses friendly slugs ({"architools":500,"planhub":120,...}). Bucket lookup onclaims.tenant= the 40-char client_id → miss → falls to_default(100 rpm). architools would silently run at 5× lower throughput than configured. - Audit log unreadable:
gis_meta."ApiAudit".tenantstores 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, afteraudToTenant(payload.aud), look up in the map. - Unit test that adds a case to
tests/auth/oidc.test.ts(or newtenant-map.test.ts). .env.exampleentry. Infisical/gis-apiadds 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=6AUTHENTIK_CLIENT_SECRET— from provider pk=6AUTHENTIK_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.acNEXT_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 theenrichment_scopeclaim is in the issued access_token (per the scope mapping attached to provider pk=6). - Session callback stores
access_tokenon the session object (not the JWT — different things; Authentik issues the JWT we forward). - Remove any HS256 minting code (
gis-api-token.tsand similar — per 002 corrections).
Verification before moving to Faza C:
architoolscan mint an Authentik authorization_code token end-to-end (login flow works).- Decoded token includes
enrichment_scope,is_beletage_group,org_idsclaims. curl -H "Authorization: Bearer <token>" https://api.gis.ac/api/v1/mereturns 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 parallelGIS_API_JWKS_URLSJSON map keyed by issuer. The latter is simpler + avoids a discovery call at first-token-time. - Cache one
createRemoteJWKSetper issuer at module scope. - Pre-parse
issfrom 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:
/geoportalin architools renders tiles fromgis.ac(no satra requests).- Click a parcel →
gisApi.parcela.get(id)returns full enrichment (Beletage staff scope=full). - Click parcel with no enrichment →
gisApi.parcel.tech()triggers orchestrator live-fetch → enriched response returns. - Place a CF order →
gisApi.enrichment.cf.create()→ row appears ingis_meta."CfExtract"withuserId = <Arhitecti user sub>. - 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. - Rate limit: 600 rapid calls (architools is configured for 500 rpm) → ~500 succeed, rest 429 with
X-RateLimit-Reset≤ 60. - Zero references to
architools_postgres.GisFeatureremain in code. - Type-check + lint + build clean.
- Portainer redeploy.
- 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
/architoolspopulated:AUTHENTIK_CLIENT_ID,AUTHENTIK_CLIENT_SECRET,AUTHENTIK_ISSUER,AUTHENTIK_JWKS_URL,GIS_API_URL,NEXT_PUBLIC_MARTIN_URL,NEXT_PUBLIC_PMTILES_URL. - Infisical
/gis-apipopulated: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.mdof~/.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.tsin 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-apion shop, image atfc459e0, healthcheckhttps://api.gis.ac/api/healthz