diff --git a/app/backend/package-lock.json b/app/backend/package-lock.json index 6d97c9d4..ae52505b 100644 --- a/app/backend/package-lock.json +++ b/app/backend/package-lock.json @@ -42,7 +42,8 @@ "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "twilio": "^6.0.2" + "twilio": "^6.0.2", + "validator": "^13.15.35" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -9067,6 +9068,8 @@ }, "node_modules/prisma": { "version": "6.19.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", + "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -10701,7 +10704,9 @@ } }, "node_modules/validator": { - "version": "13.15.26", + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", "license": "MIT", "engines": { "node": ">= 0.10" diff --git a/app/backend/package.json b/app/backend/package.json index f09a435a..59e4946e 100644 --- a/app/backend/package.json +++ b/app/backend/package.json @@ -116,7 +116,9 @@ "moduleNameMapper": { "^src/(.*)$": "/src/$1" }, - "modulePaths": [""] + "modulePaths": [ + "" + ] }, "prisma": { "seed": "ts-node prisma/seed.ts" diff --git a/app/backend/prisma.config.ts b/app/backend/prisma.config.ts index b2ad2efe..0c6f6ee7 100644 --- a/app/backend/prisma.config.ts +++ b/app/backend/prisma.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'prisma/config'; +import 'dotenv/config'; export default defineConfig({ schema: 'prisma/schema.prisma', diff --git a/app/backend/prisma/migrations/20260530000000_add_upload_sessions/migration.sql b/app/backend/prisma/migrations/20260530000000_add_upload_sessions/migration.sql deleted file mode 100644 index a478bb7b..00000000 --- a/app/backend/prisma/migrations/20260530000000_add_upload_sessions/migration.sql +++ /dev/null @@ -1,31 +0,0 @@ -CREATE TABLE "UploadSession" ( - "id" TEXT NOT NULL PRIMARY KEY, - "ownerId" TEXT NOT NULL, - "orgId" TEXT, - "fileName" TEXT NOT NULL, - "mimeType" TEXT NOT NULL, - "totalSize" INTEGER NOT NULL, - "chunkSize" INTEGER NOT NULL, - "totalChunks" INTEGER NOT NULL, - "status" TEXT NOT NULL DEFAULT 'active', - "expiresAt" DATETIME NOT NULL, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL -); - -CREATE TABLE "UploadChunk" ( - "id" TEXT NOT NULL PRIMARY KEY, - "sessionId" TEXT NOT NULL, - "index" INTEGER NOT NULL, - "size" INTEGER NOT NULL, - "checksum" TEXT NOT NULL, - "filePath" TEXT NOT NULL, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "UploadChunk_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "UploadSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - -CREATE UNIQUE INDEX "UploadChunk_sessionId_index_key" ON "UploadChunk"("sessionId", "index"); -CREATE INDEX "UploadSession_ownerId_idx" ON "UploadSession"("ownerId"); -CREATE INDEX "UploadSession_status_idx" ON "UploadSession"("status"); -CREATE INDEX "UploadSession_expiresAt_idx" ON "UploadSession"("expiresAt"); -CREATE INDEX "UploadChunk_sessionId_idx" ON "UploadChunk"("sessionId"); diff --git a/app/backend/prisma/migrations/20260603000000_add_deployment_metadata/migration.sql b/app/backend/prisma/migrations/20260603000000_add_deployment_metadata/migration.sql deleted file mode 100644 index a6bbe46f..00000000 --- a/app/backend/prisma/migrations/20260603000000_add_deployment_metadata/migration.sql +++ /dev/null @@ -1,27 +0,0 @@ --- CreateTable -CREATE TABLE "DeploymentMetadata" ( - "id" TEXT NOT NULL PRIMARY KEY, - "contractName" TEXT NOT NULL, - "network" TEXT NOT NULL, - "contractId" TEXT NOT NULL, - "wasmHash" TEXT NOT NULL, - "deployedAt" DATETIME NOT NULL, - "commitSha" TEXT, - "deployer" TEXT, - "transactionHash" TEXT, - "metadata" JSON, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL -); - --- CreateIndex -CREATE INDEX "DeploymentMetadata_network_idx" ON "DeploymentMetadata"("network"); - --- CreateIndex -CREATE INDEX "DeploymentMetadata_contractId_idx" ON "DeploymentMetadata"("contractId"); - --- CreateIndex -CREATE INDEX "DeploymentMetadata_deployedAt_idx" ON "DeploymentMetadata"("deployedAt"); - --- CreateIndex -CREATE UNIQUE INDEX "DeploymentMetadata_network_contractName_key" ON "DeploymentMetadata"("network", "contractName"); diff --git a/app/backend/prisma/migrations/20260603192423_add_deployment_metadata/migration.sql b/app/backend/prisma/migrations/20260603192423_add_deployment_metadata/migration.sql deleted file mode 100644 index a8570362..00000000 --- a/app/backend/prisma/migrations/20260603192423_add_deployment_metadata/migration.sql +++ /dev/null @@ -1,32 +0,0 @@ -/* - Warnings: - - - You are about to alter the column `metadata` on the `DeploymentMetadata` table. The data in that column could be lost. The data in that column will be cast from `Unsupported("json")` to `Json`. - -*/ --- RedefineTables -PRAGMA defer_foreign_keys=ON; -PRAGMA foreign_keys=OFF; -CREATE TABLE "new_DeploymentMetadata" ( - "id" TEXT NOT NULL PRIMARY KEY, - "contractName" TEXT NOT NULL, - "network" TEXT NOT NULL, - "contractId" TEXT NOT NULL, - "wasmHash" TEXT NOT NULL, - "deployedAt" DATETIME NOT NULL, - "commitSha" TEXT, - "deployer" TEXT, - "transactionHash" TEXT, - "metadata" JSONB, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL -); -INSERT INTO "new_DeploymentMetadata" ("commitSha", "contractId", "contractName", "createdAt", "deployedAt", "deployer", "id", "metadata", "network", "transactionHash", "updatedAt", "wasmHash") SELECT "commitSha", "contractId", "contractName", "createdAt", "deployedAt", "deployer", "id", "metadata", "network", "transactionHash", "updatedAt", "wasmHash" FROM "DeploymentMetadata"; -DROP TABLE "DeploymentMetadata"; -ALTER TABLE "new_DeploymentMetadata" RENAME TO "DeploymentMetadata"; -CREATE INDEX "DeploymentMetadata_network_idx" ON "DeploymentMetadata"("network"); -CREATE INDEX "DeploymentMetadata_contractId_idx" ON "DeploymentMetadata"("contractId"); -CREATE INDEX "DeploymentMetadata_deployedAt_idx" ON "DeploymentMetadata"("deployedAt"); -CREATE UNIQUE INDEX "DeploymentMetadata_network_contractName_key" ON "DeploymentMetadata"("network", "contractName"); -PRAGMA foreign_keys=ON; -PRAGMA defer_foreign_keys=OFF; diff --git a/app/backend/prisma/migrations/20260715010000_role_id_to_cuid/migration.sql b/app/backend/prisma/migrations/20260715010000_role_id_to_cuid/migration.sql deleted file mode 100644 index 47e54153..00000000 --- a/app/backend/prisma/migrations/20260715010000_role_id_to_cuid/migration.sql +++ /dev/null @@ -1,46 +0,0 @@ --- Migration: 20260715010000_role_id_to_cuid --- --- Converts Role.id from INTEGER autoincrement to TEXT (cuid). --- No other model references Role.id as a foreign key, so no cascade --- changes are needed. --- --- Existing rows are preserved; their old integer IDs are stored in the --- AuditLog as metadata so audit history is not lost. --- --- Step 1: record pre-migration Role rows in AuditLog for auditability. -INSERT INTO "AuditLog" ("id", "actorId", "entity", "entityId", "action", "timestamp", "metadata") -SELECT - lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' - || substr(lower(hex(randomblob(2))),2) || '-' - || substr('89ab', abs(random()) % 4 + 1, 1) - || substr(lower(hex(randomblob(2))),2) || '-' - || lower(hex(randomblob(6))), -- synthetic cuid-like id for the log row - 'system', - 'Role', - CAST("id" AS TEXT), - 'id_type_migration', - CURRENT_TIMESTAMP, - json_object('oldIntId', "id", 'name', "name", 'migration', '20260715010000_role_id_to_cuid') -FROM "Role"; - --- Step 2: recreate Role with TEXT primary key. -PRAGMA foreign_keys=OFF; - -CREATE TABLE "Role_new" ( - "id" TEXT NOT NULL PRIMARY KEY, - "name" TEXT NOT NULL -); - --- Copy existing rows, converting the integer id to its text representation. --- This is deterministic and preserves referential transparency for any --- application code that stored a Role id as a string (e.g. in JSON metadata). -INSERT INTO "Role_new" ("id", "name") -SELECT CAST("id" AS TEXT), "name" -FROM "Role"; - -DROP TABLE "Role"; -ALTER TABLE "Role_new" RENAME TO "Role"; - -CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name"); - -PRAGMA foreign_keys=ON; diff --git a/app/backend/prisma/migrations/20260716000000_audit_log_partitioning/migration.sql b/app/backend/prisma/migrations/20260716000000_audit_log_partitioning/migration.sql deleted file mode 100644 index 60d52ffe..00000000 --- a/app/backend/prisma/migrations/20260716000000_audit_log_partitioning/migration.sql +++ /dev/null @@ -1,141 +0,0 @@ --- Drop existing AuditLog table if it exists -DROP TABLE IF EXISTS "AuditLog" CASCADE; - --- Create partitioned table by range -CREATE TABLE "AuditLogPartitioned" ( - "id" TEXT NOT NULL, - "actorId" TEXT NOT NULL, - "entity" TEXT NOT NULL, - "entityId" TEXT NOT NULL, - "action" TEXT NOT NULL, - "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "metadata" JSONB, - "deletedAt" TIMESTAMP(3), - - CONSTRAINT "AuditLogPartitioned_pkey" PRIMARY KEY ("id", "timestamp") -) PARTITION BY RANGE ("timestamp"); - --- Pre-create partitions for past, current, and future months --- Since we are in 2026, let's pre-create partitions for 2025, 2026, 2027, 2028, and a default fallback. - --- 2025 Partitions (Annual / Semi-annual / Monthly as needed; let's create monthly) -CREATE TABLE "AuditLog_y2025m01" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2025-01-01 00:00:00') TO ('2025-02-01 00:00:00'); -CREATE TABLE "AuditLog_y2025m02" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2025-02-01 00:00:00') TO ('2025-03-01 00:00:00'); -CREATE TABLE "AuditLog_y2025m03" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2025-03-01 00:00:00') TO ('2025-04-01 00:00:00'); -CREATE TABLE "AuditLog_y2025m04" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2025-04-01 00:00:00') TO ('2025-05-01 00:00:00'); -CREATE TABLE "AuditLog_y2025m05" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2025-05-01 00:00:00') TO ('2025-06-01 00:00:00'); -CREATE TABLE "AuditLog_y2025m06" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2025-06-01 00:00:00') TO ('2025-07-01 00:00:00'); -CREATE TABLE "AuditLog_y2025m07" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2025-07-01 00:00:00') TO ('2025-08-01 00:00:00'); -CREATE TABLE "AuditLog_y2025m08" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2025-08-01 00:00:00') TO ('2025-09-01 00:00:00'); -CREATE TABLE "AuditLog_y2025m09" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2025-09-01 00:00:00') TO ('2025-10-01 00:00:00'); -CREATE TABLE "AuditLog_y2025m10" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2025-10-01 00:00:00') TO ('2025-11-01 00:00:00'); -CREATE TABLE "AuditLog_y2025m11" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2025-11-01 00:00:00') TO ('2025-12-01 00:00:00'); -CREATE TABLE "AuditLog_y2025m12" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2025-12-01 00:00:00') TO ('2026-01-01 00:00:00'); - --- 2026 Partitions -CREATE TABLE "AuditLog_y2026m01" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2026-01-01 00:00:00') TO ('2026-02-01 00:00:00'); -CREATE TABLE "AuditLog_y2026m02" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2026-02-01 00:00:00') TO ('2026-03-01 00:00:00'); -CREATE TABLE "AuditLog_y2026m03" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2026-03-01 00:00:00') TO ('2026-04-01 00:00:00'); -CREATE TABLE "AuditLog_y2026m04" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2026-04-01 00:00:00') TO ('2026-05-01 00:00:00'); -CREATE TABLE "AuditLog_y2026m05" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2026-05-01 00:00:00') TO ('2026-06-01 00:00:00'); -CREATE TABLE "AuditLog_y2026m06" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2026-06-01 00:00:00') TO ('2026-07-01 00:00:00'); -CREATE TABLE "AuditLog_y2026m07" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2026-07-01 00:00:00') TO ('2026-08-01 00:00:00'); -CREATE TABLE "AuditLog_y2026m08" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2026-08-01 00:00:00') TO ('2026-09-01 00:00:00'); -CREATE TABLE "AuditLog_y2026m09" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2026-09-01 00:00:00') TO ('2026-10-01 00:00:00'); -CREATE TABLE "AuditLog_y2026m10" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2026-10-01 00:00:00') TO ('2026-11-01 00:00:00'); -CREATE TABLE "AuditLog_y2026m11" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2026-11-01 00:00:00') TO ('2026-12-01 00:00:00'); -CREATE TABLE "AuditLog_y2026m12" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2026-12-01 00:00:00') TO ('2027-01-01 00:00:00'); - --- 2027 Partitions -CREATE TABLE "AuditLog_y2027m01" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2027-01-01 00:00:00') TO ('2027-02-01 00:00:00'); -CREATE TABLE "AuditLog_y2027m02" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2027-02-01 00:00:00') TO ('2027-03-01 00:00:00'); -CREATE TABLE "AuditLog_y2027m03" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2027-03-01 00:00:00') TO ('2027-04-01 00:00:00'); -CREATE TABLE "AuditLog_y2027m04" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2027-04-01 00:00:00') TO ('2027-05-01 00:00:00'); -CREATE TABLE "AuditLog_y2027m05" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2027-05-01 00:00:00') TO ('2027-06-01 00:00:00'); -CREATE TABLE "AuditLog_y2027m06" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2027-06-01 00:00:00') TO ('2027-07-01 00:00:00'); -CREATE TABLE "AuditLog_y2027m07" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2027-07-01 00:00:00') TO ('2027-08-01 00:00:00'); -CREATE TABLE "AuditLog_y2027m08" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2027-08-01 00:00:00') TO ('2027-09-01 00:00:00'); -CREATE TABLE "AuditLog_y2027m09" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2027-09-01 00:00:00') TO ('2027-10-01 00:00:00'); -CREATE TABLE "AuditLog_y2027m10" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2027-10-01 00:00:00') TO ('2027-11-01 00:00:00'); -CREATE TABLE "AuditLog_y2027m11" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2027-11-01 00:00:00') TO ('2027-12-01 00:00:00'); -CREATE TABLE "AuditLog_y2027m12" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2027-12-01 00:00:00') TO ('2028-01-01 00:00:00'); - --- 2028 Partitions -CREATE TABLE "AuditLog_y2028m01" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2028-01-01 00:00:00') TO ('2028-02-01 00:00:00'); -CREATE TABLE "AuditLog_y2028m02" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2028-02-01 00:00:00') TO ('2028-03-01 00:00:00'); -CREATE TABLE "AuditLog_y2028m03" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2028-03-01 00:00:00') TO ('2028-04-01 00:00:00'); -CREATE TABLE "AuditLog_y2028m04" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2028-04-01 00:00:00') TO ('2028-05-01 00:00:00'); -CREATE TABLE "AuditLog_y2028m05" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2028-05-01 00:00:00') TO ('2028-06-01 00:00:00'); -CREATE TABLE "AuditLog_y2028m06" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2028-06-01 00:00:00') TO ('2028-07-01 00:00:00'); -CREATE TABLE "AuditLog_y2028m07" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2028-07-01 00:00:00') TO ('2028-08-01 00:00:00'); -CREATE TABLE "AuditLog_y2028m08" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2028-08-01 00:00:00') TO ('2028-09-01 00:00:00'); -CREATE TABLE "AuditLog_y2028m09" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2028-09-01 00:00:00') TO ('2028-10-01 00:00:00'); -CREATE TABLE "AuditLog_y2028m10" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2028-10-01 00:00:00') TO ('2028-11-01 00:00:00'); -CREATE TABLE "AuditLog_y2028m11" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2028-11-01 00:00:00') TO ('2028-12-01 00:00:00'); -CREATE TABLE "AuditLog_y2028m12" PARTITION OF "AuditLogPartitioned" FOR VALUES FROM ('2028-12-01 00:00:00') TO ('2029-01-01 00:00:00'); - --- Default partition fallback -CREATE TABLE "AuditLog_default" PARTITION OF "AuditLogPartitioned" DEFAULT; - --- Create indexes on partitioned table (they propagate to partitions) -CREATE INDEX "AuditLogPartitioned_entity_entityId_idx" ON "AuditLogPartitioned"("entity", "entityId"); -CREATE INDEX "AuditLogPartitioned_timestamp_idx" ON "AuditLogPartitioned"("timestamp"); -CREATE INDEX "AuditLogPartitioned_deletedAt_idx" ON "AuditLogPartitioned"("deletedAt"); - --- Create view presentation matching original AuditLog table definition -CREATE VIEW "AuditLog" AS -SELECT "id", "actorId", "entity", "entityId", "action", "timestamp", "metadata", "deletedAt" -FROM "AuditLogPartitioned"; - --- INSTEAD OF INSERT trigger function to route inserts to partitioned table -CREATE OR REPLACE FUNCTION audit_log_insert_trigger() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO "AuditLogPartitioned" ("id", "actorId", "entity", "entityId", "action", "timestamp", "metadata", "deletedAt") - VALUES (NEW."id", NEW."actorId", NEW."entity", NEW."entityId", NEW."action", NEW."timestamp", NEW."metadata", NEW."deletedAt") - RETURNING * INTO NEW; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER audit_log_insert_trigger_tg -INSTEAD OF INSERT ON "AuditLog" -FOR EACH ROW -EXECUTE FUNCTION audit_log_insert_trigger(); - --- INSTEAD OF UPDATE trigger function to route updates to partitioned table -CREATE OR REPLACE FUNCTION audit_log_update_trigger() -RETURNS TRIGGER AS $$ -BEGIN - UPDATE "AuditLogPartitioned" - SET "actorId" = NEW."actorId", - "entity" = NEW."entity", - "entityId" = NEW."entityId", - "action" = NEW."action", - "timestamp" = NEW."timestamp", - "metadata" = NEW."metadata", - "deletedAt" = NEW."deletedAt" - WHERE "id" = OLD."id" AND "timestamp" = OLD."timestamp"; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER audit_log_update_trigger_tg -INSTEAD OF UPDATE ON "AuditLog" -FOR EACH ROW -EXECUTE FUNCTION audit_log_update_trigger(); - --- INSTEAD OF DELETE trigger function to route deletes to partitioned table -CREATE OR REPLACE FUNCTION audit_log_delete_trigger() -RETURNS TRIGGER AS $$ -BEGIN - DELETE FROM "AuditLogPartitioned" - WHERE "id" = OLD."id" AND "timestamp" = OLD."timestamp"; - RETURN OLD; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER audit_log_delete_trigger_tg -INSTEAD OF DELETE ON "AuditLog" -FOR EACH ROW -EXECUTE FUNCTION audit_log_delete_trigger(); diff --git a/app/backend/prisma/migrations/20260529224610_baseline/migration.sql b/app/backend/prisma/migrations/20260719182034_init_sqlite/migration.sql similarity index 88% rename from app/backend/prisma/migrations/20260529224610_baseline/migration.sql rename to app/backend/prisma/migrations/20260719182034_init_sqlite/migration.sql index f861b703..01a13ff8 100644 --- a/app/backend/prisma/migrations/20260529224610_baseline/migration.sql +++ b/app/backend/prisma/migrations/20260719182034_init_sqlite/migration.sql @@ -202,15 +202,14 @@ CREATE TABLE "Campaign" ( -- CreateTable CREATE TABLE "Role" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "id" TEXT NOT NULL PRIMARY KEY, "name" TEXT NOT NULL ); -- CreateTable CREATE TABLE "ApiKey" ( "id" TEXT NOT NULL PRIMARY KEY, - "key" TEXT, - "keyHash" TEXT, + "keyHash" TEXT NOT NULL DEFAULT '', "keyPreview" TEXT, "role" TEXT NOT NULL, "ngoId" TEXT, @@ -261,6 +260,34 @@ CREATE TABLE "EvidenceQueueItem" ( CONSTRAINT "EvidenceQueueItem_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization" ("id") ON DELETE SET NULL ON UPDATE CASCADE ); +-- CreateTable +CREATE TABLE "UploadSession" ( + "id" TEXT NOT NULL PRIMARY KEY, + "ownerId" TEXT NOT NULL, + "orgId" TEXT, + "fileName" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "totalSize" INTEGER NOT NULL, + "chunkSize" INTEGER NOT NULL, + "totalChunks" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "UploadChunk" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "index" INTEGER NOT NULL, + "size" INTEGER NOT NULL, + "checksum" TEXT NOT NULL, + "filePath" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "UploadChunk_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "UploadSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + -- CreateTable CREATE TABLE "NotificationOutbox" ( "id" TEXT NOT NULL PRIMARY KEY, @@ -373,6 +400,22 @@ CREATE TABLE "EntityLink" ( CONSTRAINT "EntityLink_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "RegistryProject" ("id") ON DELETE SET NULL ON UPDATE CASCADE ); +-- CreateTable +CREATE TABLE "DeploymentMetadata" ( + "id" TEXT NOT NULL PRIMARY KEY, + "contractName" TEXT NOT NULL, + "network" TEXT NOT NULL, + "contractId" TEXT NOT NULL, + "wasmHash" TEXT NOT NULL, + "deployedAt" DATETIME NOT NULL, + "commitSha" TEXT, + "deployer" TEXT, + "transactionHash" TEXT, + "metadata" JSONB, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + -- CreateIndex CREATE INDEX "AidPackage_campaignId_idx" ON "AidPackage"("campaignId"); @@ -442,6 +485,9 @@ CREATE INDEX "SessionSubmission_stepId_idx" ON "SessionSubmission"("stepId"); -- CreateIndex CREATE INDEX "SessionSubmission_deletedAt_idx" ON "SessionSubmission"("deletedAt"); +-- CreateIndex +CREATE INDEX "SessionSubmission_sessionId_deletedAt_idx" ON "SessionSubmission"("sessionId", "deletedAt"); + -- CreateIndex CREATE INDEX "VerificationRequest_deletedAt_idx" ON "VerificationRequest"("deletedAt"); @@ -454,12 +500,18 @@ CREATE INDEX "VerificationRequest_status_idx" ON "VerificationRequest"("status") -- CreateIndex CREATE INDEX "VerificationRequest_reviewedAt_idx" ON "VerificationRequest"("reviewedAt"); +-- CreateIndex +CREATE INDEX "VerificationRequest_reviewedBy_idx" ON "VerificationRequest"("reviewedBy"); + -- CreateIndex CREATE INDEX "Claim_status_idx" ON "Claim"("status"); -- CreateIndex CREATE INDEX "Claim_campaignId_idx" ON "Claim"("campaignId"); +-- CreateIndex +CREATE INDEX "Claim_campaignId_status_idx" ON "Claim"("campaignId", "status"); + -- CreateIndex CREATE INDEX "Claim_createdAt_idx" ON "Claim"("createdAt"); @@ -526,9 +578,6 @@ CREATE INDEX "Campaign_deletedAt_idx" ON "Campaign"("deletedAt"); -- CreateIndex CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name"); --- CreateIndex -CREATE UNIQUE INDEX "ApiKey_key_key" ON "ApiKey"("key"); - -- CreateIndex CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash"); @@ -571,6 +620,21 @@ CREATE INDEX "EvidenceQueueItem_fingerprint_idx" ON "EvidenceQueueItem"("fingerp -- CreateIndex CREATE INDEX "EvidenceQueueItem_nearDuplicateOf_idx" ON "EvidenceQueueItem"("nearDuplicateOf"); +-- CreateIndex +CREATE INDEX "UploadSession_ownerId_idx" ON "UploadSession"("ownerId"); + +-- CreateIndex +CREATE INDEX "UploadSession_status_idx" ON "UploadSession"("status"); + +-- CreateIndex +CREATE INDEX "UploadSession_expiresAt_idx" ON "UploadSession"("expiresAt"); + +-- CreateIndex +CREATE INDEX "UploadChunk_sessionId_idx" ON "UploadChunk"("sessionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UploadChunk_sessionId_index_key" ON "UploadChunk"("sessionId", "index"); + -- CreateIndex CREATE INDEX "NotificationOutbox_status_idx" ON "NotificationOutbox"("status"); @@ -660,3 +724,15 @@ CREATE INDEX "EntityLink_confidenceScore_idx" ON "EntityLink"("confidenceScore") -- CreateIndex CREATE INDEX "EntityLink_isActive_idx" ON "EntityLink"("isActive"); + +-- CreateIndex +CREATE INDEX "DeploymentMetadata_network_idx" ON "DeploymentMetadata"("network"); + +-- CreateIndex +CREATE INDEX "DeploymentMetadata_contractId_idx" ON "DeploymentMetadata"("contractId"); + +-- CreateIndex +CREATE INDEX "DeploymentMetadata_deployedAt_idx" ON "DeploymentMetadata"("deployedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentMetadata_network_contractName_key" ON "DeploymentMetadata"("network", "contractName"); diff --git a/app/backend/prisma/schema.prisma b/app/backend/prisma/schema.prisma index 50e2fd94..cc62e144 100644 --- a/app/backend/prisma/schema.prisma +++ b/app/backend/prisma/schema.prisma @@ -3,8 +3,8 @@ generator client { } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "sqlite" + url = env("DATABASE_URL") } enum CampaignStatus { @@ -388,8 +388,7 @@ model ApiKey { id String @id @default(cuid()) // `key` is a legacy plaintext column. New keys should use `keyHash` and // `keyPreview` and never store raw secrets. - key String? @unique - keyHash String? @unique + keyHash String @unique @default("") keyPreview String? role AppRole ngoId String? diff --git a/app/backend/scripts/hash_existing_api_keys.js b/app/backend/scripts/hash_existing_api_keys.js new file mode 100644 index 00000000..de0c122d --- /dev/null +++ b/app/backend/scripts/hash_existing_api_keys.js @@ -0,0 +1,24 @@ +const { PrismaClient } = require('@prisma/client'); +const { createHash } = require('node:crypto'); + +(async () => { + const prisma = new PrismaClient(); + + const keys = await prisma.apiKey.findMany({ + where: { key: { not: null } }, + select: { id: true, key: true }, + }); + + for (const { id, key } of keys) { + if (!key) continue; + const hash = createHash('sha256').update(key).digest('hex'); + const preview = `${key.slice(0, 4)}…${key.slice(-4)}`; + await prisma.apiKey.update({ + where: { id }, + data: { keyHash: hash, keyPreview: preview }, + }); + console.log(`Migrated API key ${id}`); + } + + await prisma.$disconnect(); +})(); diff --git a/app/backend/scripts/hash_existing_api_keys.ts b/app/backend/scripts/hash_existing_api_keys.ts new file mode 100644 index 00000000..ab3a943b --- /dev/null +++ b/app/backend/scripts/hash_existing_api_keys.ts @@ -0,0 +1,29 @@ +import { PrismaClient } from '@prisma/client'; +import { createHash } from 'node:crypto'; + +async function main() { + const prisma = new PrismaClient(); + + const keys = await prisma.apiKey.findMany({ + where: { key: { not: null } }, + select: { id: true, key: true }, + }); + + for (const { id, key } of keys) { + if (!key) continue; + const hash = createHash('sha256').update(key).digest('hex'); + const preview = `${key.slice(0, 4)}…${key.slice(-4)}`; + await prisma.apiKey.update({ + where: { id }, + data: { keyHash: hash, keyPreview: preview }, + }); + console.log(`Migrated API key ${id}`); + } + + await prisma.$disconnect(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/app/backend/src/analytics/analytics.service.ts b/app/backend/src/analytics/analytics.service.ts index 651a8b30..3796fb27 100644 --- a/app/backend/src/analytics/analytics.service.ts +++ b/app/backend/src/analytics/analytics.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { createHash } from 'crypto'; import { PrismaService } from '../prisma/prisma.service'; +import { PrismaReadReplicaService } from '../prisma/prisma-read-replica.service'; import { ClaimStatus } from '@prisma/client'; import { GlobalStatsDto, @@ -97,6 +98,7 @@ export class AnalyticsService { constructor( private readonly prisma: PrismaService, + private readonly prismaRead: PrismaReadReplicaService, private readonly redis: RedisService, private readonly privacyService: PrivacyService, private readonly metrics: MetricsService, @@ -214,9 +216,7 @@ export class AnalyticsService { const { from, to, region, token } = query; const { startDate, endDate } = this.resolveDateRange(from, to); - // Fetch all disbursed claims within the time window, including their - // parent campaign so we can read metadata. - const claims = await this.prisma.claim.findMany({ + const claims = await this.prismaRead.invoke('claim.findMany', { where: { status: ClaimStatus.disbursed, createdAt: { gte: startDate, lte: endDate }, @@ -237,7 +237,7 @@ export class AnalyticsService { }); // Count active campaigns (optionally filtered by region / token). - const activeCampaigns = await this.prisma.campaign.count({ + const activeCampaigns = await this.prismaRead.invoke('campaign.count', { where: { status: 'active', ...(region || token ? this.buildMetadataFilter(region, token) : {}), diff --git a/app/backend/src/common/guards/api-key.guard.ts b/app/backend/src/common/guards/api-key.guard.ts index d8709958..8ec5415b 100644 --- a/app/backend/src/common/guards/api-key.guard.ts +++ b/app/backend/src/common/guards/api-key.guard.ts @@ -43,11 +43,10 @@ export class ApiKeyGuard implements CanActivate { const apiKeyHash = createHash('sha256').update(apiKey).digest('hex'); - // Primary path: look up the key in the database (hashed preferred; legacy plaintext supported) const record = await this.prisma.apiKey.findFirst({ where: { revokedAt: null, - OR: [{ keyHash: apiKeyHash }, { key: apiKey }], + keyHash: apiKeyHash, }, }); diff --git a/app/backend/src/main.ts b/app/backend/src/main.ts index d1f07694..e7ff9017 100644 --- a/app/backend/src/main.ts +++ b/app/backend/src/main.ts @@ -18,7 +18,7 @@ import { } from './common/security/security.module'; import { RedisService } from '@liaoliaots/nestjs-redis'; -async function bootstrap() { +export async function bootstrap() { // Load environment variables loadEnv(); @@ -34,6 +34,14 @@ async function bootstrap() { app.enableShutdownHooks(); const configService = app.get(ConfigService); +// Validate CORS_ORIGINS in production +const nodeEnv = configService.get('NODE_ENV', 'development'); +if (nodeEnv === 'production') { + const corsOrigins = configService.get('CORS_ORIGINS'); + if (!corsOrigins) { + throw new Error('CORS_ORIGINS must be set in production'); + } +} // Security middleware (order matters) app.use(createHelmetMiddleware(configService)); diff --git a/app/backend/src/prisma/prisma-read-replica.service.ts b/app/backend/src/prisma/prisma-read-replica.service.ts new file mode 100644 index 00000000..f5e7992d --- /dev/null +++ b/app/backend/src/prisma/prisma-read-replica.service.ts @@ -0,0 +1,60 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { PrismaService } from './prisma.service'; + +/** + * Service that routes read‑only Prisma operations to a read‑replica datasource. + * It delegates write operations to the primary {@link PrismaService}. + * Read‑only operations (find*, count*, aggregate*) are executed against a + * PrismaClient instantiated with `DATABASE_URL_READ`. + */ +@Injectable() +export class PrismaReadReplicaService { + private readonly logger = new Logger(PrismaReadReplicaService.name); + private readonly readClient: PrismaClient; + + constructor(private readonly primary: PrismaService) { + const readUrl = process.env.DATABASE_URL_READ; + if (!readUrl) { + this.logger.warn('DATABASE_URL_READ is not set; read replica will fall back to primary.'); + } + this.readClient = new PrismaClient({ + datasources: { db: { url: readUrl || process.env.DATABASE_URL } }, + }); + } + + async onModuleInit(): Promise { + try { + await this.readClient.$connect(); + this.logger.debug('Read‑replica Prisma client connected'); + } catch (err) { + this.logger.error('Failed to connect read‑replica Prisma client', err as Error); + } + } + + async onModuleDestroy(): Promise { + try { + await this.readClient.$disconnect(); + this.logger.debug('Read‑replica Prisma client disconnected'); + } catch (err) { + this.logger.error('Failed to disconnect read‑replica Prisma client', err as Error); + } + } + + /** Determine the appropriate client for a given method name. */ + private getTarget(methodName: string): any { + const readPrefixes = ['find', 'count', 'aggregate']; + const isRead = readPrefixes.some((p) => methodName.startsWith(p)); + return isRead ? this.readClient : this.primary; + } + + /** Generic proxy to call Prisma methods on the correct client. */ + public async invoke(method: T, ...args: any[]): Promise { + const target = this.getTarget(method as string); + const fn = (target as any)[method]; + if (typeof fn !== 'function') { + throw new Error(`Prisma method ${method} does not exist`); + } + return fn.apply(target, args); + } +} diff --git a/app/backend/src/prisma/prisma.module.ts b/app/backend/src/prisma/prisma.module.ts index 7207426f..4b8e38f2 100644 --- a/app/backend/src/prisma/prisma.module.ts +++ b/app/backend/src/prisma/prisma.module.ts @@ -1,9 +1,10 @@ import { Global, Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; +import { PrismaReadReplicaService } from './prisma-read-replica.service'; @Global() @Module({ - providers: [PrismaService], - exports: [PrismaService], + providers: [PrismaService, PrismaReadReplicaService], + exports: [PrismaService, PrismaReadReplicaService], }) export class PrismaModule {} diff --git a/app/backend/test/security.e2e-spec.ts b/app/backend/test/security.e2e-spec.ts index 129bb504..313fad1f 100644 --- a/app/backend/test/security.e2e-spec.ts +++ b/app/backend/test/security.e2e-spec.ts @@ -4,6 +4,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import request from 'supertest'; import { AppModule } from '../src/app.module'; +import { bootstrap } from '../src/main'; import { buildCorsOptions, createCorsOriginValidator, @@ -302,5 +303,23 @@ describe('Security (e2e)', () => { expect(response.status).toBe(200); expect(response.text).toContain('Swagger UI'); }); + describe('Bootstrap validation', () => { + const originalEnv = { NODE_ENV: process.env.NODE_ENV, CORS_ORIGINS: process.env.CORS_ORIGINS }; + afterAll(() => { + process.env.NODE_ENV = originalEnv.NODE_ENV; + if (originalEnv.CORS_ORIGINS !== undefined) { + process.env.CORS_ORIGINS = originalEnv.CORS_ORIGINS; + } else { + delete process.env.CORS_ORIGINS; + } + }); + + it('should abort if CORS_ORIGINS missing in production', async () => { + process.env.NODE_ENV = 'production'; + delete process.env.CORS_ORIGINS; + await expect(bootstrap()).rejects.toThrow(/CORS_ORIGINS/); + }); }); + +}); });