feat(faza-c.2): gate legacy GisFeature writes under USE_GIS_AC
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) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
|||||||
isFresh,
|
isFresh,
|
||||||
} from "@/modules/parcel-sync/services/enrich-service";
|
} from "@/modules/parcel-sync/services/enrich-service";
|
||||||
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||||
|
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -38,6 +39,8 @@ type UatRefreshResult = {
|
|||||||
* ?includeEnrichment=true — re-enrich UATs with partial enrichment
|
* ?includeEnrichment=true — re-enrich UATs with partial enrichment
|
||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/auto-refresh");
|
||||||
|
if (gate) return gate;
|
||||||
// ── Auth ──
|
// ── Auth ──
|
||||||
const secret = process.env.NOTIFICATION_CRON_SECRET;
|
const secret = process.env.NOTIFICATION_CRON_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from "@/modules/parcel-sync/services/session-store";
|
} from "@/modules/parcel-sync/services/session-store";
|
||||||
import { syncNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync";
|
import { syncNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync";
|
||||||
import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson";
|
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 runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -87,6 +88,8 @@ const csvEscape = (val: unknown) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/export-bundle");
|
||||||
|
if (gate) return gate;
|
||||||
let jobId: string | undefined;
|
let jobId: string | undefined;
|
||||||
let message: string | undefined;
|
let message: string | undefined;
|
||||||
let phase = "Inițializare";
|
let phase = "Inițializare";
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
unregisterJob,
|
unregisterJob,
|
||||||
} from "@/modules/parcel-sync/services/session-store";
|
} from "@/modules/parcel-sync/services/session-store";
|
||||||
import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson";
|
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 runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -68,6 +69,8 @@ const scheduleClear = (jobId?: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/export-layer-gpkg");
|
||||||
|
if (gate) return gate;
|
||||||
let jobId: string | undefined;
|
let jobId: string | undefined;
|
||||||
let message: string | undefined;
|
let message: string | undefined;
|
||||||
let phase = "Inițializare";
|
let phase = "Inițializare";
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
|||||||
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
|
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
|
||||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||||
|
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -25,6 +26,8 @@ export const dynamic = "force-dynamic";
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/refresh-all");
|
||||||
|
if (gate) return gate;
|
||||||
const username = process.env.ETERRA_USERNAME ?? "";
|
const username = process.env.ETERRA_USERNAME ?? "";
|
||||||
const password = process.env.ETERRA_PASSWORD ?? "";
|
const password = process.env.ETERRA_PASSWORD ?? "";
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-heal
|
|||||||
import { createAppNotification } from "@/core/notifications/app-notifications";
|
import { createAppNotification } from "@/core/notifications/app-notifications";
|
||||||
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||||
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
||||||
|
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -32,6 +33,8 @@ const g = globalThis as {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/sync-all-counties");
|
||||||
|
if (gate) return gate;
|
||||||
const session = getSessionCredentials();
|
const session = getSessionCredentials();
|
||||||
const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim();
|
const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim();
|
||||||
const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim();
|
const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "@/modules/parcel-sync/services/enrich-service";
|
} from "@/modules/parcel-sync/services/enrich-service";
|
||||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
import { syncNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync";
|
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 runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -44,6 +45,8 @@ type Body = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/sync-background");
|
||||||
|
if (gate) return gate;
|
||||||
try {
|
try {
|
||||||
const body = (await req.json()) as Body;
|
const body = (await req.json()) as Body;
|
||||||
const session = getSessionCredentials();
|
const session = getSessionCredentials();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-heal
|
|||||||
import { createAppNotification } from "@/core/notifications/app-notifications";
|
import { createAppNotification } from "@/core/notifications/app-notifications";
|
||||||
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||||
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
||||||
|
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -30,6 +31,8 @@ export const dynamic = "force-dynamic";
|
|||||||
const g = globalThis as { __countySyncRunning?: string; __allCountiesSyncRunning?: boolean };
|
const g = globalThis as { __countySyncRunning?: string; __allCountiesSyncRunning?: boolean };
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/sync-county");
|
||||||
|
if (gate) return gate;
|
||||||
let body: { county?: string };
|
let body: { county?: string };
|
||||||
try {
|
try {
|
||||||
body = (await req.json()) as { county?: string };
|
body = (await req.json()) as { county?: string };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { prisma } from "@/core/storage/prisma";
|
import { prisma } from "@/core/storage/prisma";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -27,6 +28,8 @@ export async function PATCH(
|
|||||||
req: Request,
|
req: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/sync-rules/[id]");
|
||||||
|
if (gate) return gate;
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -83,6 +86,8 @@ export async function DELETE(
|
|||||||
_req: Request,
|
_req: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/sync-rules/[id]");
|
||||||
|
if (gate) return gate;
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
import { prisma } from "@/core/storage/prisma";
|
import { prisma } from "@/core/storage/prisma";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -37,6 +38,8 @@ type BulkBody = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/sync-rules/bulk");
|
||||||
|
if (gate) return gate;
|
||||||
try {
|
try {
|
||||||
const body = (await req.json()) as BulkBody;
|
const body = (await req.json()) as BulkBody;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { prisma } from "@/core/storage/prisma";
|
import { prisma } from "@/core/storage/prisma";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -84,6 +85,8 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/sync-rules");
|
||||||
|
if (gate) return gate;
|
||||||
try {
|
try {
|
||||||
const body = (await req.json()) as {
|
const body = (await req.json()) as {
|
||||||
siruta?: string;
|
siruta?: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||||
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||||
|
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -17,6 +18,8 @@ type Body = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/sync");
|
||||||
|
if (gate) return gate;
|
||||||
try {
|
try {
|
||||||
const body = (await req.json()) as Body;
|
const body = (await req.json()) as Body;
|
||||||
const session = getSessionCredentials();
|
const session = getSessionCredentials();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { readFile } from "fs/promises";
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||||
|
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -212,6 +213,8 @@ export async function GET() {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/uats");
|
||||||
|
if (gate) return gate;
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// Read uat.json from public/ directory
|
// Read uat.json from public/ directory
|
||||||
@@ -288,6 +291,8 @@ export async function POST() {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export async function PATCH() {
|
export async function PATCH() {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/eterra/uats");
|
||||||
|
if (gate) return gate;
|
||||||
try {
|
try {
|
||||||
// 1. Get eTerra credentials from session
|
// 1. Get eTerra credentials from session
|
||||||
const session = getSessionCredentials();
|
const session = getSessionCredentials();
|
||||||
|
|||||||
@@ -9,11 +9,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/core/storage/prisma";
|
import { prisma } from "@/core/storage/prisma";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const gate = await gateLegacyGisWrite("/api/geoportal/enrich");
|
||||||
|
if (gate) return gate;
|
||||||
try {
|
try {
|
||||||
const body = (await req.json()) as { featureId?: string; siruta?: string; objectId?: number };
|
const body = (await req.json()) as { featureId?: string; siruta?: string; objectId?: number };
|
||||||
|
|
||||||
|
|||||||
@@ -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<NextResponse | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user