From 008016b947abd2874da1eef1a7ee8d684eae6db2 Mon Sep 17 00:00:00 2001 From: Femi <114834028+Toyosi5566@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:32:36 +0100 Subject: [PATCH 1/2] security: ignore local environment files .env is read by dotenv.config() at boot and holds ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY and DEPLOYER_SECRET_KEY. None of the .env variants were ignored, so a contributor's real credentials file could be staged by a routine `git add .` with nothing to warn anyone. Ignore the .env variants and keep the committed example file trackable. No .env file is tracked at HEAD and no .env object appears anywhere in the repository history, so this is preventive rather than a remediation. --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index bea4619..ac0c2db 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,12 @@ coverage/ *.lcov coverage-final.json clover.xml + +# Local environment files. .env is loaded by dotenv at boot and holds live +# secrets (ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY, +# DEPLOYER_SECRET_KEY). The committed example file is the only environment +# file that belongs in git. +.env +.env.local +.env.*.local +!.env.example From 55f9e7ee3ada23f15404a798d4035073829491fa Mon Sep 17 00:00:00 2001 From: samuelelijah585 Date: Sat, 17 Oct 2026 18:12:19 +0100 Subject: [PATCH 2/2] fix: fix(db): replace the remaining free-form status strings with (#249) --- .../migration.sql | 96 +++++++++++++++++++ prisma/schema.prisma | 37 ++++++- src/__tests__/payout.service.test.ts | 8 +- src/services/group-exit.service.ts | 3 +- src/services/payout.service.ts | 8 +- src/utils/audit-logger.ts | 5 +- 6 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 prisma/migrations/20260901000000_convert_status_strings_to_enums/migration.sql diff --git a/prisma/migrations/20260901000000_convert_status_strings_to_enums/migration.sql b/prisma/migrations/20260901000000_convert_status_strings_to_enums/migration.sql new file mode 100644 index 0000000..ec43081 --- /dev/null +++ b/prisma/migrations/20260901000000_convert_status_strings_to_enums/migration.sql @@ -0,0 +1,96 @@ +-- Convert remaining free-form status strings to enums +-- This migration is designed to fail loudly if any existing row +-- contains a value outside the documented set, rather than coercing it. + +-- ------------------------------------------------------------------------------ +-- ContributionStatus: PENDING, COMPLETED, FAILED +-- ------------------------------------------------------------------------------ +CREATE TYPE "ContributionStatus" AS ENUM ('PENDING', 'COMPLETED', 'FAILED'); + +-- Fail loudly if any existing row has an undocumented value. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "Contribution" + WHERE status NOT IN ('PENDING', 'COMPLETED', 'FAILED') + ) THEN + RAISE EXCEPTION 'Contribution.status contains values outside the ContributionStatus enum'; + END IF; +END $$; + +ALTER TABLE "Contribution" + ALTER COLUMN status TYPE "ContributionStatus" + USING status::"ContributionStatus", + ALTER COLUMN status SET DEFAULT 'PENDING'; + +-- ------------------------------------------------------------------------------ +-- PayoutStatus: COMPLETED +-- ------------------------------------------------------------------------------ +CREATE TYPE "PayoutStatus" AS ENUM ('COMPLETED'); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "Payout" + WHERE status NOT IN ('COMPLETED') + ) THEN + RAISE EXCEPTION 'Payout.status contains values outside the PayoutStatus enum'; + END IF; +END $$; + +ALTER TABLE "Payout" + ALTER COLUMN status TYPE "PayoutStatus" + USING status::"PayoutStatus"; + +-- ------------------------------------------------------------------------------ +-- PayoutOrderLogReason: REORDER, SKIP_DEFAULT +-- ------------------------------------------------------------------------------ +CREATE TYPE "PayoutOrderLogReason" AS ENUM ('REORDER', 'SKIP_DEFAULT'); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "PayoutOrderLog" + WHERE reason NOT IN ('REORDER', 'SKIP_DEFAULT') + ) THEN + RAISE EXCEPTION 'PayoutOrderLog.reason contains values outside the PayoutOrderLogReason enum'; + END IF; +END $$; + +ALTER TABLE "PayoutOrderLog" + ALTER COLUMN reason TYPE "PayoutOrderLogReason" + USING reason::"PayoutOrderLogReason"; + +-- ----------------------------------------------------------------------------- +-- MemberExitLogType: LEAVE, KICK +-- ------------------------------------------------------------------------------ +CREATE TYPE "MemberExitLogType" AS ENUM ('LEAVE', 'KICK'); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "MemberExitLog" + WHERE type NOT IN ('LEAVE', 'KICK') + ) THEN + RAISE EXCEPTION 'MemberExitLog.type contains values outside the MemberExitLogType enum'; + END IF; +END $$; + +ALTER TABLE "MemberExitLog" + ALTER COLUMN type TYPE "MemberExitLogType" + USING type::"MemberExitLogType"; + +-- ------------------------------------------------------------------------------ +-- SecretAccessLogOperation: SEND, CONTRIBUTE, TRUSTLINE, KEY_ROTATION +-- ------------------------------------------------------------------------------ +CREATE TYPE "SecretAccessLogOperation" AS ENUM ('SEND', 'CONTRIBUTE', 'TRUSTLINE', 'KEY_ROTATION'); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "SecretAccessLog" + WHERE operation NOT IN ('SEND', 'CONTRIBUTE', 'TRUSTLINE', 'KEY_ROTATION') + ) THEN + RAISE EXCEPTION 'SecretAccessLog.operation contains values outside the SecretAccessLogOperation enum'; + END IF; +END $$;} \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca46c2b..96a75b7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,6 +35,33 @@ enum DeploymentStatus { FAILED } +enum ContributionStatus { + PENDING + COMPLETED + FAILED +} + +enum PayoutStatus { + COMPLETED +} + +enum PayoutOrderReason { + REORDER + SKIP_DEFAULT +} + +enum SecretAccessOperation { + SEND + CONTRIBUTE + TRUSTLINE + KEY_ROTATION +} + +enum MemberExitType { + LEAVE + KICK +} + model SavingsGroup { id String @id @default(uuid()) name String @@ -89,7 +116,7 @@ model Contribution { groupId String amount Decimal transactionHash String? - status String @default("PENDING") // PENDING, COMPLETED, FAILED + status ContributionStatus @default(PENDING) createdAt DateTime @default(now()) @@index([userId]) @@ -104,7 +131,7 @@ model Payout { amount Decimal transactionHash String cycleNumber Int - status String @default("COMPLETED") + status PayoutStatus @default(COMPLETED) createdAt DateTime @default(now()) @@index([groupId]) @@ -118,7 +145,7 @@ model PayoutOrderLog { changedBy String previousOrder Json? newOrder Json - reason String @default("REORDER") // e.g., "REORDER", "SKIP_DEFAULT" + reason PayoutOrderReason @default(REORDER) createdAt DateTime @default(now()) @@index([groupId]) @@ -146,7 +173,7 @@ model PaymentRequest { model SecretAccessLog { id String @id @default(uuid()) userId String - operation String // "SEND" | "CONTRIBUTE" | "TRUSTLINE" | "KEY_ROTATION" + operation SecretAccessOperation success Boolean errorMessage String? createdAt DateTime @default(now()) @@ -187,7 +214,7 @@ model MemberExitLog { /// userId of who triggered the exit — the member themselves for LEAVE, the /// creator for KICK. initiatedBy String - type String // "LEAVE" | "KICK" + type MemberExitType totalContributed Decimal totalReceived Decimal /// totalContributed - totalReceived. Positive is refunded to the member; diff --git a/src/__tests__/payout.service.test.ts b/src/__tests__/payout.service.test.ts index 14467f0..8397e25 100644 --- a/src/__tests__/payout.service.test.ts +++ b/src/__tests__/payout.service.test.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, PayoutOrderLogReason } from '@prisma/client'; import { PayoutService, PayoutOrderLockedError, @@ -28,7 +28,7 @@ jest.mock('@prisma/client', () => { }, $transaction: jest.fn((ops: any[]) => Promise.resolve(ops)), }; - return { PrismaClient: jest.fn(() => mPrismaClient), Prisma: { Decimal } }; + return { PrismaClient: jest.fn(() => mPrismaClient), Prisma: { Decimal }, PayoutOrderLogReason: { REORDER: 'REORDER', SKIP_DEFAULT: 'SKIP_DEFAULT' } }; }); jest.mock('../queue/contribution-scheduler.queue', () => ({ @@ -153,7 +153,7 @@ describe('PayoutService', () => { changedBy: 'u1', previousOrder: ['u1', 'u2', 'u3'], // default join order since payoutOrder was null newOrder: ['u3', 'u1', 'u2'], - reason: 'REORDER', + reason: PayoutOrderLogReason.REORDER, }, }); }); @@ -387,7 +387,7 @@ describe('PayoutService', () => { data: { payoutOrder: ['u1', 'u3', 'u2'] }, }); expect(prisma.payoutOrderLog.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ reason: 'SKIP_DEFAULT', changedBy: 'u1' }), + data: expect.objectContaining({ reason: PayoutOrderLogReason.SKIP_DEFAULT, changedBy: 'u1' }), }); }); diff --git a/src/services/group-exit.service.ts b/src/services/group-exit.service.ts index e756805..70d8950 100644 --- a/src/services/group-exit.service.ts +++ b/src/services/group-exit.service.ts @@ -1,6 +1,7 @@ import * as StellarSdk from '@stellar/stellar-sdk'; import { prisma } from '../lib/prisma'; import { PayoutService } from './payout.service'; +import { MemberExitType } from '@prisma/client'; import { SorobanService } from './soroban.service'; import { WhatsAppService } from './whatsapp.service'; import { removeGroupCycle } from '../queue/contribution-scheduler.queue'; @@ -166,7 +167,7 @@ export class GroupExitService { groupId: group.id, userId: member.userId, initiatedBy, - type, + type: type as MemberExitType, totalContributed: String(totalContributed), totalReceived: String(totalReceived), netOwed: String(netOwed), diff --git a/src/services/payout.service.ts b/src/services/payout.service.ts index 7392b32..7d6c15a 100644 --- a/src/services/payout.service.ts +++ b/src/services/payout.service.ts @@ -1,4 +1,4 @@ -import { PrismaClient, Prisma } from '@prisma/client'; +import { PrismaClient, Prisma, PayoutOrderLogReason, PayoutStatus, ContributionStatus } from '@prisma/client'; import { WhatsAppService } from './whatsapp.service'; import { SorobanService } from './soroban.service'; import { config } from '../config/env'; @@ -100,7 +100,7 @@ export class PayoutService { changedBy: requesterUserId, previousOrder, newOrder: order, - reason: 'REORDER', + reason: PayoutOrderLogReason.REORDER, }, }), ]); @@ -230,7 +230,7 @@ export class PayoutService { changedBy: requesterUserId, previousOrder: currentOrder, newOrder, - reason: 'SKIP_DEFAULT', + reason: PayoutOrderLogReason.SKIP_DEFAULT, }, }), ]); @@ -345,7 +345,7 @@ export class PayoutService { amount, transactionHash: hash, cycleNumber, - status: 'COMPLETED', + status: PayoutStatus.COMPLETED, }, }); diff --git a/src/utils/audit-logger.ts b/src/utils/audit-logger.ts index 6097537..68199eb 100644 --- a/src/utils/audit-logger.ts +++ b/src/utils/audit-logger.ts @@ -1,6 +1,7 @@ import { prisma } from '../lib/prisma'; +import type { SecretAccessOperation } from '@prisma/client'; -export type SecretOperation = 'SEND' | 'CONTRIBUTE' | 'TRUSTLINE' | 'KEY_ROTATION'; +export type SecretOperation = SecretAccessOperation; export async function logSecretAccess( userId: string, @@ -20,4 +21,4 @@ export async function logSecretAccess( } catch (e) { console.error('Audit log failed:', e); } -} \ No newline at end of file +}