From b957de77b98a2970493ee05c964f8463ef82df56 Mon Sep 17 00:00:00 2001 From: Claude VM Date: Tue, 19 May 2026 11:00:16 +0300 Subject: [PATCH] feat(faza-c.2): gate legacy GisFeature writes under USE_GIS_AC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds gateLegacyGisWrite() helper that returns 410 when the caller is on the api.gis.ac path (global USE_GIS_AC=1 or per-user GIS_AC_PILOT_USERS). Wired into 13 routes covering every entry point that touches Gis* tables on architools_postgres — directly or via parcel-sync services. Why: yesterday 4 GisFeature rows were updated on architools_postgres even though the scheduler is officially disabled. Root cause: pilot user opened the legacy /geoportal UI in a stale tab and clicked parcels; POST /api/geoportal/enrich wrote directly to the local DB. Without a write gate, Faza H (pg_dump + REVOKE + DROP) is unsafe — any stale tab in any user's browser can still trip writes between freeze and DROP. Gated routes (writes only — reads stay open for rollback ergonomics): - /api/geoportal/enrich (POST) — the writer of the 4 rows - /api/eterra/sync-rules (POST), /api/eterra/sync-rules/[id] (PATCH+DELETE) - /api/eterra/sync-rules/bulk (POST) - /api/eterra/uats (POST+PATCH) - /api/eterra/sync (POST), /api/eterra/sync-county (POST) - /api/eterra/sync-background (POST), /api/eterra/sync-all-counties (POST) - /api/eterra/auto-refresh (POST), /api/eterra/refresh-all (POST) - /api/eterra/export-layer-gpkg (POST), /api/eterra/export-bundle (POST) (last two trigger syncLayer write-first-then-export) Read-only routes intentionally NOT gated: sync-status, no-geom-scan (scanNoGeometryParcels is read-only), export-local, db-summary, counties, search, features (GET), stats, uat-dashboard, sync-rules (GET), sync-rules/scheduler. Operations: after redeploy, flip USE_GIS_AC=1 in Infisical /architools prod env and restart container. Then monitor docker logs for ~30 min: grep "deprecated" + "/api/geoportal/enrich|/api/eterra/sync*" lines indicate stale-tab clients that need a refresh. pg_stat_user_tables write count on GisFeature should hit 0 within one hour. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/eterra/auto-refresh/route.ts | 3 ++ src/app/api/eterra/export-bundle/route.ts | 3 ++ src/app/api/eterra/export-layer-gpkg/route.ts | 3 ++ src/app/api/eterra/refresh-all/route.ts | 3 ++ src/app/api/eterra/sync-all-counties/route.ts | 3 ++ src/app/api/eterra/sync-background/route.ts | 3 ++ src/app/api/eterra/sync-county/route.ts | 3 ++ src/app/api/eterra/sync-rules/[id]/route.ts | 5 ++ src/app/api/eterra/sync-rules/bulk/route.ts | 3 ++ src/app/api/eterra/sync-rules/route.ts | 3 ++ src/app/api/eterra/sync/route.ts | 3 ++ src/app/api/eterra/uats/route.ts | 5 ++ src/app/api/geoportal/enrich/route.ts | 3 ++ src/core/feature-flags/gis-write-gate.ts | 48 +++++++++++++++++++ 14 files changed, 91 insertions(+) create mode 100644 src/core/feature-flags/gis-write-gate.ts diff --git a/src/app/api/eterra/auto-refresh/route.ts b/src/app/api/eterra/auto-refresh/route.ts index 001ae34..5183f6d 100644 --- a/src/app/api/eterra/auto-refresh/route.ts +++ b/src/app/api/eterra/auto-refresh/route.ts @@ -6,6 +6,7 @@ import { isFresh, } from "@/modules/parcel-sync/services/enrich-service"; import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health"; +import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -38,6 +39,8 @@ type UatRefreshResult = { * ?includeEnrichment=true — re-enrich UATs with partial enrichment */ export async function POST(request: Request) { + const gate = await gateLegacyGisWrite("/api/eterra/auto-refresh"); + if (gate) return gate; // ── Auth ── const secret = process.env.NOTIFICATION_CRON_SECRET; if (!secret) { diff --git a/src/app/api/eterra/export-bundle/route.ts b/src/app/api/eterra/export-bundle/route.ts index a024ab5..9a50e48 100644 --- a/src/app/api/eterra/export-bundle/route.ts +++ b/src/app/api/eterra/export-bundle/route.ts @@ -33,6 +33,7 @@ import { } from "@/modules/parcel-sync/services/session-store"; import { syncNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync"; import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson"; +import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -87,6 +88,8 @@ const csvEscape = (val: unknown) => { }; export async function POST(req: Request) { + const gate = await gateLegacyGisWrite("/api/eterra/export-bundle"); + if (gate) return gate; let jobId: string | undefined; let message: string | undefined; let phase = "Inițializare"; diff --git a/src/app/api/eterra/export-layer-gpkg/route.ts b/src/app/api/eterra/export-layer-gpkg/route.ts index 7b2a0df..4caec6a 100644 --- a/src/app/api/eterra/export-layer-gpkg/route.ts +++ b/src/app/api/eterra/export-layer-gpkg/route.ts @@ -28,6 +28,7 @@ import { unregisterJob, } from "@/modules/parcel-sync/services/session-store"; import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson"; +import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -68,6 +69,8 @@ const scheduleClear = (jobId?: string) => { }; export async function POST(req: Request) { + const gate = await gateLegacyGisWrite("/api/eterra/export-layer-gpkg"); + if (gate) return gate; let jobId: string | undefined; let message: string | undefined; let phase = "Inițializare"; diff --git a/src/app/api/eterra/refresh-all/route.ts b/src/app/api/eterra/refresh-all/route.ts index 836af69..0aafffd 100644 --- a/src/app/api/eterra/refresh-all/route.ts +++ b/src/app/api/eterra/refresh-all/route.ts @@ -18,6 +18,7 @@ import { syncLayer } from "@/modules/parcel-sync/services/sync-service"; import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service"; import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health"; +import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -25,6 +26,8 @@ export const dynamic = "force-dynamic"; const prisma = new PrismaClient(); export async function POST() { + const gate = await gateLegacyGisWrite("/api/eterra/refresh-all"); + if (gate) return gate; const username = process.env.ETERRA_USERNAME ?? ""; const password = process.env.ETERRA_PASSWORD ?? ""; if (!username || !password) { diff --git a/src/app/api/eterra/sync-all-counties/route.ts b/src/app/api/eterra/sync-all-counties/route.ts index 62f8465..0e8bcd3 100644 --- a/src/app/api/eterra/sync-all-counties/route.ts +++ b/src/app/api/eterra/sync-all-counties/route.ts @@ -21,6 +21,7 @@ import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-heal import { createAppNotification } from "@/core/notifications/app-notifications"; import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook"; +import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -32,6 +33,8 @@ const g = globalThis as { }; export async function POST() { + const gate = await gateLegacyGisWrite("/api/eterra/sync-all-counties"); + if (gate) return gate; const session = getSessionCredentials(); const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim(); const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim(); diff --git a/src/app/api/eterra/sync-background/route.ts b/src/app/api/eterra/sync-background/route.ts index abfa26c..a1e1b65 100644 --- a/src/app/api/eterra/sync-background/route.ts +++ b/src/app/api/eterra/sync-background/route.ts @@ -30,6 +30,7 @@ import { } from "@/modules/parcel-sync/services/enrich-service"; import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; import { syncNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync"; +import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -44,6 +45,8 @@ type Body = { }; export async function POST(req: Request) { + const gate = await gateLegacyGisWrite("/api/eterra/sync-background"); + if (gate) return gate; try { const body = (await req.json()) as Body; const session = getSessionCredentials(); diff --git a/src/app/api/eterra/sync-county/route.ts b/src/app/api/eterra/sync-county/route.ts index 18cb2d9..4f7bcf6 100644 --- a/src/app/api/eterra/sync-county/route.ts +++ b/src/app/api/eterra/sync-county/route.ts @@ -22,6 +22,7 @@ import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-heal import { createAppNotification } from "@/core/notifications/app-notifications"; import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook"; +import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -30,6 +31,8 @@ export const dynamic = "force-dynamic"; const g = globalThis as { __countySyncRunning?: string; __allCountiesSyncRunning?: boolean }; export async function POST(req: Request) { + const gate = await gateLegacyGisWrite("/api/eterra/sync-county"); + if (gate) return gate; let body: { county?: string }; try { body = (await req.json()) as { county?: string }; diff --git a/src/app/api/eterra/sync-rules/[id]/route.ts b/src/app/api/eterra/sync-rules/[id]/route.ts index 02080d8..2076423 100644 --- a/src/app/api/eterra/sync-rules/[id]/route.ts +++ b/src/app/api/eterra/sync-rules/[id]/route.ts @@ -5,6 +5,7 @@ import { prisma } from "@/core/storage/prisma"; import { NextResponse } from "next/server"; +import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -27,6 +28,8 @@ export async function PATCH( req: Request, { params }: { params: Promise<{ id: string }> }, ) { + const gate = await gateLegacyGisWrite("/api/eterra/sync-rules/[id]"); + if (gate) return gate; const { id } = await params; try { @@ -83,6 +86,8 @@ export async function DELETE( _req: Request, { params }: { params: Promise<{ id: string }> }, ) { + const gate = await gateLegacyGisWrite("/api/eterra/sync-rules/[id]"); + if (gate) return gate; const { id } = await params; try { diff --git a/src/app/api/eterra/sync-rules/bulk/route.ts b/src/app/api/eterra/sync-rules/bulk/route.ts index a1f5386..03f6f57 100644 --- a/src/app/api/eterra/sync-rules/bulk/route.ts +++ b/src/app/api/eterra/sync-rules/bulk/route.ts @@ -9,6 +9,7 @@ import { prisma } from "@/core/storage/prisma"; import { NextResponse } from "next/server"; +import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -37,6 +38,8 @@ type BulkBody = { }; export async function POST(req: Request) { + const gate = await gateLegacyGisWrite("/api/eterra/sync-rules/bulk"); + if (gate) return gate; try { const body = (await req.json()) as BulkBody; diff --git a/src/app/api/eterra/sync-rules/route.ts b/src/app/api/eterra/sync-rules/route.ts index ff76d32..db3ca77 100644 --- a/src/app/api/eterra/sync-rules/route.ts +++ b/src/app/api/eterra/sync-rules/route.ts @@ -5,6 +5,7 @@ import { prisma } from "@/core/storage/prisma"; import { NextResponse } from "next/server"; +import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -84,6 +85,8 @@ export async function GET() { } export async function POST(req: Request) { + const gate = await gateLegacyGisWrite("/api/eterra/sync-rules"); + if (gate) return gate; try { const body = (await req.json()) as { siruta?: string; diff --git a/src/app/api/eterra/sync/route.ts b/src/app/api/eterra/sync/route.ts index f7623aa..98dc650 100644 --- a/src/app/api/eterra/sync/route.ts +++ b/src/app/api/eterra/sync/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { syncLayer } from "@/modules/parcel-sync/services/sync-service"; import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; +import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -17,6 +18,8 @@ type Body = { }; export async function POST(req: Request) { + const gate = await gateLegacyGisWrite("/api/eterra/sync"); + if (gate) return gate; try { const body = (await req.json()) as Body; const session = getSessionCredentials(); diff --git a/src/app/api/eterra/uats/route.ts b/src/app/api/eterra/uats/route.ts index 3664663..a7541b0 100644 --- a/src/app/api/eterra/uats/route.ts +++ b/src/app/api/eterra/uats/route.ts @@ -4,6 +4,7 @@ import { readFile } from "fs/promises"; import { join } from "path"; import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; +import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -212,6 +213,8 @@ export async function GET() { /* ------------------------------------------------------------------ */ export async function POST() { + const gate = await gateLegacyGisWrite("/api/eterra/uats"); + if (gate) return gate; try { // Read uat.json from public/ directory @@ -288,6 +291,8 @@ export async function POST() { /* ------------------------------------------------------------------ */ export async function PATCH() { + const gate = await gateLegacyGisWrite("/api/eterra/uats"); + if (gate) return gate; try { // 1. Get eTerra credentials from session const session = getSessionCredentials(); diff --git a/src/app/api/geoportal/enrich/route.ts b/src/app/api/geoportal/enrich/route.ts index e3ae1cd..200c48a 100644 --- a/src/app/api/geoportal/enrich/route.ts +++ b/src/app/api/geoportal/enrich/route.ts @@ -9,11 +9,14 @@ import { NextResponse } from "next/server"; import { prisma } from "@/core/storage/prisma"; import { headers } from "next/headers"; +import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(req: Request) { + const gate = await gateLegacyGisWrite("/api/geoportal/enrich"); + if (gate) return gate; try { const body = (await req.json()) as { featureId?: string; siruta?: string; objectId?: number }; diff --git a/src/core/feature-flags/gis-write-gate.ts b/src/core/feature-flags/gis-write-gate.ts new file mode 100644 index 0000000..e7d5fda --- /dev/null +++ b/src/core/feature-flags/gis-write-gate.ts @@ -0,0 +1,48 @@ +// Faza C.2 — hard-disable legacy writes to architools_postgres Gis* tables +// when the api.gis.ac path is enabled for the caller (per-user pilot or global). +// +// Called at the top of every route handler that triggers a write through +// `prisma.gisFeature.*`, `prisma.gisUat.*`, `prisma.gisSyncRun.*`, +// `prisma.gisSyncRule.*` — directly or via parcel-sync services. Read-only +// handlers (sync-status, export-local, *_GET) are intentionally NOT gated: +// freezing reads breaks rollback ergonomics with no benefit, since Faza H +// only needs writes to stop. +// +// Returns a NextResponse(410) when the legacy path is blocked. Returns null +// when the caller may continue. Background/cron callers (no session) hit the +// `USE_GIS_AC === "1"` fast-path; user-initiated requests fall to the +// pilot-list check. + +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/core/auth/auth-options"; +import { useGisAcFlag } from "./use-gis-ac"; + +export async function gateLegacyGisWrite( + endpoint: string, +): Promise { + if (process.env.USE_GIS_AC === "1") { + return NextResponse.json( + { + error: "deprecated", + endpoint, + message: + "Local GisFeature writes are disabled. Use the api.gis.ac equivalent.", + }, + { status: 410 }, + ); + } + const session = await getServerSession(authOptions).catch(() => null); + if (useGisAcFlag(session?.user?.email)) { + return NextResponse.json( + { + error: "deprecated", + endpoint, + message: + "This user is on the api.gis.ac path; refresh the UI to use the new flow.", + }, + { status: 410 }, + ); + } + return null; +}