From fab9fe83d263de7dddca602ef1f50708e667f8ef Mon Sep 17 00:00:00 2001 From: DevALVIN-24 Date: Mon, 31 Aug 2026 11:32:41 +0100 Subject: [PATCH 1/2] fix(horizon-listener): establish transactional invariants and fix query pagination in TransactionRepository --- .../transactionRepository.test.ts | 6 +++--- src/repositories/transactionRepository.ts | 20 +++++++++---------- src/tests/orgAuth.test.ts | 2 -- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/repositories/transactionRepository.test.ts b/src/repositories/transactionRepository.test.ts index 4abab40c..94df012d 100644 --- a/src/repositories/transactionRepository.test.ts +++ b/src/repositories/transactionRepository.test.ts @@ -38,7 +38,7 @@ describe('TransactionRepository', () => { describe('create', () => { it('should successfully insert a new transaction', async () => { - const tx = { tx_hash: 'hash-1', user_id: 'user-1' }; + const tx = { tx_hash: 'hash-1', user_id: 'user-1', vault_id: 'vault-1', type: 'creation' }; const result = await repo.create(tx); expect(result).toEqual({ id: 'tx-1', tx_hash: 'hash-1' }); @@ -52,7 +52,7 @@ describe('TransactionRepository', () => { // simulate .returning() returning empty array due to .ignore() mockDb().returning.mockResolvedValueOnce([]); - const tx = { tx_hash: 'hash-1', user_id: 'user-1' }; + const tx = { tx_hash: 'hash-1', user_id: 'user-1', vault_id: 'vault-1', type: 'creation' }; const result = await repo.create(tx); expect(result).toEqual({ id: 'tx-1', tx_hash: 'hash-1' }); @@ -66,7 +66,7 @@ describe('TransactionRepository', () => { mockDb().returning.mockResolvedValueOnce([]); mockDb().first.mockResolvedValueOnce(undefined); - const tx = { tx_hash: 'hash-unknown' }; + const tx = { tx_hash: 'hash-unknown', user_id: 'user-1', vault_id: 'vault-1', type: 'creation' }; await expect(repo.create(tx)).rejects.toThrow(/Failed to create or retrieve/); }); }); diff --git a/src/repositories/transactionRepository.ts b/src/repositories/transactionRepository.ts index 4f18766c..73cbaec4 100644 --- a/src/repositories/transactionRepository.ts +++ b/src/repositories/transactionRepository.ts @@ -218,10 +218,7 @@ export class TransactionRepository { const safeLim = clampLimit(limit); let query = this.db('transactions') - .where({ user_id: userId.trim() }) - .orderBy('stellar_timestamp', 'desc') - .orderBy('id', 'desc') - .limit(safeLim + 1); + .where({ user_id: userId.trim() }); if (filters.vaultId) { query = query.where({ vault_id: filters.vaultId.trim() }); @@ -260,7 +257,10 @@ export class TransactionRepository { } } - const transactions = await query; + const transactions = await query + .orderBy('stellar_timestamp', 'desc') + .orderBy('id', 'desc') + .limit(safeLim + 1); const hasMore = transactions.length > safeLim; const data = hasMore ? transactions.slice(0, safeLim) : transactions; @@ -395,10 +395,7 @@ export class TransactionRepository { validateTransactionFilters(filters); let query = this.db('transactions') - .where('vault_id', vaultId.trim()) - .orderBy('stellar_timestamp', 'desc') - .orderBy('id', 'desc') - .limit(safeLim + 1); + .where('vault_id', vaultId.trim()); if (filters.type) { query = query.where('type', filters.type.trim()); @@ -434,7 +431,10 @@ export class TransactionRepository { } } - const transactions = await query; + const transactions = await query + .orderBy('stellar_timestamp', 'desc') + .orderBy('id', 'desc') + .limit(safeLim + 1); const hasMore = transactions.length > safeLim; const data = hasMore ? transactions.slice(0, safeLim) : transactions; diff --git a/src/tests/orgAuth.test.ts b/src/tests/orgAuth.test.ts index ff306a41..9d09f257 100644 --- a/src/tests/orgAuth.test.ts +++ b/src/tests/orgAuth.test.ts @@ -16,8 +16,6 @@ jest.unstable_mockModule('../middleware/auth.js', () => ({ const { requireOrgAccess } = await import('../middleware/orgAuth.js') const { AppError } = await import('../middleware/errorHandler.js') - -const { requireOrgAccess } = await import('../middleware/orgAuth.js'); const db = (await import('../db/index.js')).default; const { getAuthenticatedUserId } = await import('../middleware/auth.js'); From 9104163e40acd4404f68c8c5ef4361a127ce5b6c Mon Sep 17 00:00:00 2001 From: DevALVIN-24 Date: Mon, 31 Aug 2026 11:43:33 +0100 Subject: [PATCH 2/2] feat: improve organization authorization and Prisma scoping: transactional invariants and recovery --- prisma/schema.prisma | 60 +++++ src/lib/prisma.ts | 83 ++++++- src/lib/prismaScope.ts | 5 +- src/middleware/orgAuth.ts | 71 ++++-- src/routes/orgVaults.ts | 291 ++++++++++++++---------- src/tests/__mocks__/prisma.ts | 21 ++ src/tests/orgAuth.dbErrors.test.ts | 25 ++ src/tests/orgAuth.test.ts | 25 ++ src/tests/orgVaults.prismaScope.test.ts | 216 ++++++++++++++++++ 9 files changed, 651 insertions(+), 146 deletions(-) create mode 100644 src/tests/orgVaults.prismaScope.test.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 16aaa108..3691362a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -59,6 +59,7 @@ deletedAt DateTime? @map("deleted_at") id String @id @default(uuid()) creatorId String @map("creator_id") creator User @relation(fields: [creatorId], references: [id]) + organizationId String? @map("organization_id") @db.Uuid amount String startDate DateTime @default(now()) @map("start_date") endDate DateTime @map("end_date") @@ -75,6 +76,65 @@ deletedAt DateTime? @map("deleted_at") @@map("vaults") } +model Organization { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + slug String @unique + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @map("updated_at") @updatedAt + memberships Membership[] + teams Team[] + + @@map("organizations") +} + +model Team { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + slug String + organizationId String @map("organization_id") @db.Uuid + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @map("updated_at") @updatedAt + memberships Membership[] + + @@unique([organizationId, slug]) + @@map("teams") +} + +model Membership { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + userId String @map("user_id") + organizationId String @map("organization_id") @db.Uuid + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + teamId String? @map("team_id") @db.Uuid + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + role String @default("member") + createdAt DateTime @default(now()) @map("created_at") + + @@unique([userId, organizationId, teamId]) + @@map("memberships") +} + +model OrgVaultSearch { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + orgId String @map("org_id") + name String + queryDefinition Json @map("query_definition") + alertsEnabled Boolean @default(false) @map("alerts_enabled") + alertRecipient String? @map("alert_recipient") + alertFrequencyMs Int @default(3600000) @map("alert_frequency_ms") + lastEvaluatedAt DateTime? @map("last_evaluated_at") + lastResultHash String? @map("last_result_hash") + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @map("updated_at") @updatedAt + + @@map("org_vault_searches") +} + model Notification { id String @id @default(uuid()) userId String @map("user_id") diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index f40bbf34..3c30fcf8 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -4,6 +4,85 @@ const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined } -export const prisma = globalForPrisma.prisma ?? new PrismaClient() +const basePrisma = globalForPrisma.prisma ?? new PrismaClient() + +export const prisma = basePrisma.$extends({ + query: { + $allModels: { + async $allOperations({ model, operation, args, query }) { + let prismaStorageModule; + try { + prismaStorageModule = await import('./prismaScope.js'); + } catch { + // Fallback if imported from another context + } + + const store = prismaStorageModule?.prismaStorage.getStore(); + const orgId = store?.orgId; + + if (orgId) { + let orgFieldName: string | null = null; + if (model === 'Vault' || model === 'Team' || model === 'Membership') { + orgFieldName = 'organizationId'; + } else if (model === 'AnalyticsReport' || model === 'AnalyticsReportQuota' || model === 'OrgVaultSearch') { + orgFieldName = 'orgId'; + } else if (model === 'Organization') { + orgFieldName = 'id'; + } + + if (orgFieldName) { + // 1. Enforce on where clauses (for findUnique, findFirst, findMany, update, updateMany, delete, deleteMany) + if (args.where) { + const currentFilter = args.where[orgFieldName]; + if (currentFilter !== undefined) { + if (currentFilter !== orgId) { + throw new Error(`Cross-organization data exposure prevented: query on ${model} requested ${orgFieldName} ${currentFilter} but active orgId is ${orgId}`); + } + } else { + args.where[orgFieldName] = orgId; + } + } else if (operation !== 'create' && operation !== 'createMany') { + args.where = { [orgFieldName]: orgId }; + } + + // 2. Enforce on data/create fields (for create, update, upsert) + if (args.data) { + if (Array.isArray(args.data)) { + for (const item of args.data) { + if (item[orgFieldName] !== undefined && item[orgFieldName] !== orgId) { + throw new Error(`Cross-organization data exposure prevented: write on ${model} requested ${orgFieldName} ${item[orgFieldName]} but active orgId is ${orgId}`); + } + item[orgFieldName] = orgId; + } + } else { + if (args.data[orgFieldName] !== undefined && args.data[orgFieldName] !== orgId) { + throw new Error(`Cross-organization data exposure prevented: write on ${model} requested ${orgFieldName} ${args.data[orgFieldName]} but active orgId is ${orgId}`); + } + args.data[orgFieldName] = orgId; + } + } + + // 3. Enforce on upsert fields + if (args.create) { + if (args.create[orgFieldName] !== undefined && args.create[orgFieldName] !== orgId) { + throw new Error(`Cross-organization data exposure prevented: upsert create on ${model} requested ${orgFieldName} ${args.create[orgFieldName]} but active orgId is ${orgId}`); + } + args.create[orgFieldName] = orgId; + } + if (args.update) { + if (args.update[orgFieldName] !== undefined && args.update[orgFieldName] !== orgId) { + throw new Error(`Cross-organization data exposure prevented: upsert update on ${model} requested ${orgFieldName} ${args.update[orgFieldName]} but active orgId is ${orgId}`); + } + args.update[orgFieldName] = orgId; + } + } + } + + return query(args) + } + } + } +}) as unknown as PrismaClient + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = basePrisma -if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma diff --git a/src/lib/prismaScope.ts b/src/lib/prismaScope.ts index fbab60b3..e993d140 100644 --- a/src/lib/prismaScope.ts +++ b/src/lib/prismaScope.ts @@ -2,7 +2,10 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { PrismaClient } from '@prisma/client' import { prisma as singletonPrisma } from './prisma.js' -export type PrismaScope = { prisma: PrismaClient } +export type PrismaScope = { + prisma: PrismaClient + orgId?: string +} export const prismaStorage = new AsyncLocalStorage() diff --git a/src/middleware/orgAuth.ts b/src/middleware/orgAuth.ts index 5dc4308a..cf5b203d 100644 --- a/src/middleware/orgAuth.ts +++ b/src/middleware/orgAuth.ts @@ -2,13 +2,13 @@ import { Request, Response, NextFunction } from "express"; import { AuthenticatedRequest, getAuthenticatedUserId } from "./auth.js"; import { AppError } from "./errorHandler.js"; import type { OrgRole } from "../models/organizations.js"; -import db from "../db/index.js"; +import { getPrisma, prismaStorage } from "../lib/prismaScope.js"; export type { OrgRole } from "../models/organizations.js"; /** * DB-backed org access middleware. - * Checks org existence and membership via the organizations and org_members tables. + * Checks org existence and membership via the organizations and memberships tables. */ export function requireOrgAccess(...allowedRoles: (OrgRole | string)[]) { return async ( @@ -25,16 +25,22 @@ export function requireOrgAccess(...allowedRoles: (OrgRole | string)[]) { } try { - const org = await db("organizations").where({ id: orgId }).first(); + const org = await getPrisma().organization.findUnique({ + where: { id: orgId }, + }); if (!org) { next(AppError.notFound("Organization not found")); return; } (req as any).orgId = orgId; - const membership = await db("org_members") - .where({ org_id: orgId, user_id: userId }) - .first(); + const membership = await getPrisma().membership.findFirst({ + where: { + organizationId: orgId, + userId: userId, + teamId: null, + }, + }); if (!membership) { next( @@ -52,7 +58,13 @@ export function requireOrgAccess(...allowedRoles: (OrgRole | string)[]) { return; } - next(); + const store = prismaStorage.getStore(); + if (store) { + store.orgId = orgId; + next(); + } else { + prismaStorage.run({ prisma: getPrisma(), orgId }, next); + } } catch (err) { next(err); } @@ -80,15 +92,21 @@ export const requireOrgRole = (roles: (OrgRole | string)[]) => { // Prove the target org exists before looking up membership, so a // cross-organization reference cannot be mistaken for an authorization // denial (or leak existence only as a 403). - const org = await db("organizations").where({ id: orgId }).first(); + const org = await getPrisma().organization.findUnique({ + where: { id: orgId }, + }); if (!org) { res.status(404).json({ error: "Organization not found" }); return; } - const membership = await db("org_members") - .where({ org_id: orgId, user_id: userId }) - .first(); + const membership = await getPrisma().membership.findFirst({ + where: { + organizationId: orgId, + userId: userId, + teamId: null, + }, + }); // Missing membership is a normal no-row result (does not throw) → 403. if (!membership || !roles.includes(membership.role)) { res @@ -98,7 +116,14 @@ export const requireOrgRole = (roles: (OrgRole | string)[]) => { }); return; } - next(); + + const store = prismaStorage.getStore(); + if (store) { + store.orgId = orgId; + next(); + } else { + prismaStorage.run({ prisma: getPrisma(), orgId }, next); + } } catch (err) { // Unexpected DB/infra failures must not look like authorization denials. next(err); @@ -124,15 +149,20 @@ export const requireTeamRole = (roles: (OrgRole | string)[]) => { } try { - const team = await db("teams").where({ id: teamId }).first(); + const team = await getPrisma().team.findUnique({ + where: { id: teamId }, + }); if (!team) { res.status(404).json({ error: "Team not found" }); return; } - const membership = await db("team_members") - .where({ team_id: teamId, user_id: userId }) - .first(); + const membership = await getPrisma().membership.findFirst({ + where: { + teamId: teamId, + userId: userId, + }, + }); // Missing membership is a normal no-row result (does not throw) → 403. if (!membership || !roles.includes(membership.role)) { res @@ -142,7 +172,14 @@ export const requireTeamRole = (roles: (OrgRole | string)[]) => { }); return; } - next(); + + const store = prismaStorage.getStore(); + if (store) { + store.orgId = team.organizationId; + next(); + } else { + prismaStorage.run({ prisma: getPrisma(), orgId: team.organizationId }, next); + } } catch (err) { // Unexpected DB/infra failures must not look like authorization denials. next(err); diff --git a/src/routes/orgVaults.ts b/src/routes/orgVaults.ts index b2de429e..23daf000 100644 --- a/src/routes/orgVaults.ts +++ b/src/routes/orgVaults.ts @@ -4,20 +4,24 @@ import { authenticate } from '../middleware/auth.js' import { requireOrgAccess } from '../middleware/orgAuth.js' import { queryParser } from '../middleware/queryParser.js' import { applyFilters, applySort, paginateArray, encodeCursor, decodeCursor } from '../utils/pagination.js' -import { listVaults } from '../services/vaultStore.js' +import { getPrisma } from '../lib/prismaScope.js' import db from '../db/index.js' import type { Knex } from 'knex' import { createHash } from 'node:crypto' import { isValidISO8601, normalizeTimestamp, toUTCDate } from '../utils/timestamps.js' +import { + validateIdempotencyKey, + scopeIdempotencyKey, + hashRequestPayload, + getIdempotentResponse, + saveIdempotentResponse, + failPendingIdempotentResponse, + IdempotencyConflictError +} from '../services/idempotency.js' export const orgVaultsRouter = Router() // ─── tsvector column detection cache ───────────────────────────────────────── -// Whether the vaults.search_vector column exists is effectively static for the -// lifetime of a running process (it only changes when a migration runs, which -// requires a restart). We cache the result as a single shared Promise so that: -// 1. The DB is queried at most once, even under concurrent first requests. -// 2. All callers await the same in-flight promise instead of racing. let _hasFtsColumnCache: Promise | null = null; function hasFtsColumn(): Promise { @@ -31,7 +35,6 @@ function hasFtsColumn(): Promise { .first() .then(Boolean) .catch((err) => { - // On error, reset so the next request retries rather than caching a failure. _hasFtsColumnCache = null; return Promise.reject(err); }); @@ -57,28 +60,9 @@ orgVaultsRouter.get( }), async (req: Request, res: Response) => { const { orgId } = req.params - const dbVaults = await db('vaults') - .where({ organization_id: orgId }) - .whereNull('deleted_at') - .select('*') - - // Map DB fields to the expected Vault shape - let result = dbVaults.map(v => ({ - id: v.id, - creator: v.creator, - amount: v.amount, - status: v.status, - startTimestamp: normalizeTimestamp(v.start_date), - endTimestamp: normalizeTimestamp(v.end_date), - successDestination: v.success_destination, - failureDestination: v.failure_destination, - verifier: v.verifier, - createdAt: normalizeTimestamp(v.created_at), - orgId: v.organization_id - })) try { - const rows = await db("vaults") + const knexQuery = db("vaults") .where("organization_id", orgId) .whereNull("deleted_at") .select( @@ -94,7 +78,10 @@ orgVaultsRouter.get( "updated_at", ); - let result = rows.map((row: Record) => ({ + const { sql, bindings } = knexQuery.toSQL().toNative(); + const rows = await getPrisma().$queryRawUnsafe(sql, ...bindings); + + let result = rows.map((row: any) => ({ id: row.id, creator: row.creator, verifier: row.verifier, @@ -118,7 +105,7 @@ orgVaultsRouter.get( const paginatedResult = paginateArray(result, req.pagination!); res.json(paginatedResult); } catch (error) { - console.error("Error listing org vaults:", error); + console.error("Error listing org vaults:", (error as any).stack || error); res.status(500).json({ error: "Internal server error" }); } }, @@ -146,7 +133,6 @@ const VALID_SORT_ORDERS = new Set(["asc", "desc"]); const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; -/** True when value is a real calendar date in YYYY-MM-DD form. */ function isValidDateOnly(value: string): boolean { if (!DATE_ONLY_RE.test(value)) return false const [y, m, d] = value.split("-").map(Number) @@ -249,9 +235,6 @@ export function validateAndSanitizeQueryDefinition( for (const field of ["date_from", "date_to"] as const) { if (input[field] !== undefined) { - // Accept either a date-only ISO 8601 string (YYYY-MM-DD) or a full - // timestamp with an explicit timezone. The downstream `new Date()` - // consumption handles both, and the API contract exposes `format: date`. const valid = typeof input[field] === "string" && (isValidDateOnly(input[field]) || isValidISO8601(input[field])); @@ -348,7 +331,8 @@ export async function runSavedSearch( const sortOrder = (queryDef.sort_order ?? "desc") as "asc" | "desc"; query = query.orderBy(sortField, sortOrder).orderBy("id", "desc"); - const rows = await query.limit(limit); + const { sql, bindings } = query.limit(limit).toSQL().toNative(); + const rows = await getPrisma().$queryRawUnsafe(sql, ...bindings); return rows.map((r: { id: string }) => r.id); } @@ -369,7 +353,6 @@ orgVaultsRouter.post( if (!userId) { res.status(401).json({ error: "Authenticated user missing userId" }); return; - return; } const { name, @@ -424,42 +407,107 @@ orgVaultsRouter.post( } } - try { - const countRow = await db("org_vault_searches") - .where({ org_id: orgId }) - .count("id as n") - .first(); - - const currentCount = Number(countRow?.n ?? 0); - if (currentCount >= MAX_SEARCHES_PER_ORG) { - res.status(422).json({ - error: `Org has reached the maximum of ${MAX_SEARCHES_PER_ORG} saved searches`, + const rawIdempotencyKey = req.header('idempotency-key') ?? null; + let scopedIdempotencyKey: string | null = null; + const owner = { userId, orgId }; + + if (rawIdempotencyKey) { + const keyValidation = validateIdempotencyKey(rawIdempotencyKey); + if (!keyValidation.valid) { + res.status(400).json({ + error: { + code: keyValidation.code, + message: keyValidation.error, + }, }); return; } + scopedIdempotencyKey = scopeIdempotencyKey(userId, rawIdempotencyKey); + } - const [search] = await db("org_vault_searches") - .insert({ - org_id: orgId, - name: name.trim(), - query_definition: JSON.stringify(validation.sanitized), - alerts_enabled: alertsOn, - alert_recipient: alertsOn ? (alert_recipient as string).trim() : null, - alert_frequency_ms: alertsOn - ? alert_frequency_ms !== undefined - ? Number(alert_frequency_ms) - : MIN_ALERT_FREQUENCY_MS - : MIN_ALERT_FREQUENCY_MS, - created_by: userId, - created_at: new Date(), - updated_at: new Date(), - }) - .returning("*"); - - res.status(201).json({ search }); - } catch (error) { - console.error("Error creating saved search:", error); - res.status(500).json({ error: "Internal server error" }); + const requestHash = hashRequestPayload(req.body); + + if (scopedIdempotencyKey) { + try { + const cached = await getIdempotentResponse(rawIdempotencyKey, requestHash, owner); + if (cached) { + res.status(200).json(cached); + return; + } + } catch (err) { + if (err instanceof IdempotencyConflictError) { + res.status(409).json({ error: "Idempotency key conflict" }); + return; + } + next(err); + return; + } + } + + try { + // Execute atomically using a Serializable transaction + const search = await getPrisma().$transaction(async (tx) => { + const currentCount = await tx.orgVaultSearch.count({ + where: { orgId } + }); + + if (currentCount >= MAX_SEARCHES_PER_ORG) { + const error = new Error(`Org has reached the maximum of ${MAX_SEARCHES_PER_ORG} saved searches`); + (error as any).status = 422; + throw error; + } + + return tx.orgVaultSearch.create({ + data: { + orgId, + name: name.trim(), + queryDefinition: validation.sanitized as any, + alertsEnabled: alertsOn, + alertRecipient: alertsOn ? (alert_recipient as string).trim() : null, + alertFrequencyMs: alertsOn + ? alert_frequency_ms !== undefined + ? Number(alert_frequency_ms) + : MIN_ALERT_FREQUENCY_MS + : MIN_ALERT_FREQUENCY_MS, + createdBy: userId, + } + }); + }, { + isolationLevel: 'Serializable' + }); + + const responseBody = { + search: { + id: search.id, + org_id: search.orgId, + name: search.name, + query_definition: search.queryDefinition, + alerts_enabled: search.alertsEnabled, + alert_recipient: search.alertRecipient, + alert_frequency_ms: search.alertFrequencyMs, + last_evaluated_at: search.lastEvaluatedAt, + last_result_hash: search.lastResultHash, + created_by: search.createdBy, + created_at: search.createdAt, + updated_at: search.updatedAt + } + }; + + if (scopedIdempotencyKey) { + await saveIdempotentResponse(rawIdempotencyKey, requestHash, search.id, responseBody, owner); + } + + res.status(201).json(responseBody); + } catch (error: any) { + if (scopedIdempotencyKey) { + failPendingIdempotentResponse(rawIdempotencyKey, requestHash, error, owner); + } + if (error.status === 422) { + res.status(422).json({ error: error.message }); + } else { + console.error("Error creating saved search:", error); + res.status(500).json({ error: "Internal server error" }); + } } }, ); @@ -475,9 +523,25 @@ orgVaultsRouter.get( const { orgId } = req.params; try { - const searches: OrgVaultSearch[] = await db("org_vault_searches") - .where({ org_id: orgId }) - .orderBy("created_at", "desc"); + const rows = await getPrisma().orgVaultSearch.findMany({ + where: { orgId }, + orderBy: { createdAt: 'desc' } + }); + + const searches = rows.map(r => ({ + id: r.id, + org_id: r.orgId, + name: r.name, + query_definition: r.queryDefinition, + alerts_enabled: r.alertsEnabled, + alert_recipient: r.alertRecipient, + alert_frequency_ms: r.alertFrequencyMs, + last_evaluated_at: r.lastEvaluatedAt, + last_result_hash: r.lastResultHash, + created_by: r.createdBy, + created_at: r.createdAt, + updated_at: r.updatedAt + })); res.json({ searches }); } catch (error) { @@ -498,16 +562,31 @@ orgVaultsRouter.get( const { orgId, searchId } = req.params; try { - const search: OrgVaultSearch | undefined = await db("org_vault_searches") - .where({ id: searchId, org_id: orgId }) - .first(); + const search = await getPrisma().orgVaultSearch.findFirst({ + where: { id: searchId, orgId } + }); if (!search) { res.status(404).json({ error: "Saved search not found" }); return; } - res.json({ search }); + res.json({ + search: { + id: search.id, + org_id: search.orgId, + name: search.name, + query_definition: search.queryDefinition, + alerts_enabled: search.alertsEnabled, + alert_recipient: search.alertRecipient, + alert_frequency_ms: search.alertFrequencyMs, + last_evaluated_at: search.lastEvaluatedAt, + last_result_hash: search.lastResultHash, + created_by: search.createdBy, + created_at: search.createdAt, + updated_at: search.updatedAt + } + }); } catch (error) { console.error("Error fetching saved search:", error); res.status(500).json({ error: "Internal server error" }); @@ -526,15 +605,19 @@ orgVaultsRouter.delete( const { orgId, searchId } = req.params; try { - const deleted = await db("org_vault_searches") - .where({ id: searchId, org_id: orgId }) - .delete(); + const search = await getPrisma().orgVaultSearch.findFirst({ + where: { id: searchId, orgId } + }); - if (deleted === 0) { + if (!search) { res.status(404).json({ error: "Saved search not found" }); return; } + await getPrisma().orgVaultSearch.delete({ + where: { id: searchId } + }); + res.status(204).end(); } catch (error) { console.error("Error deleting saved search:", error); @@ -543,24 +626,8 @@ orgVaultsRouter.delete( }, ); -/** - * GET /api/orgs/:orgId/vaults/search - * - * Org-scoped vault search with full-text matching and structured filters. - * Results are cursor-paginated for stable, consistent paging. - * - * Query parameters: - * q - Full-text search term (matches creator + verifier via tsvector/GIN index, - * falls back to ILIKE when the DB has no tsvector column yet) - * status - Exact status filter: draft | active | completed | failed | cancelled - * verifier - Exact verifier address filter - * amount_min - Minimum vault amount (inclusive) - * amount_max - Maximum vault amount (inclusive) - * date_from - Minimum created_at (ISO 8601 inclusive) - * date_to - Maximum created_at (ISO 8601 inclusive) - * cursor - Opaque pagination cursor from a previous response - * limit - Page size (1–100, default 20) - */ +// ─── GET /api/orgs/:orgId/vaults/search ────────────────────────────────────── + orgVaultsRouter.get( "/:orgId/vaults/search", authenticate, @@ -580,14 +647,9 @@ orgVaultsRouter.get( async (req: Request, res: Response): Promise => { const { orgId } = req.params; - // ── Raw search term ───────────────────────────────────────────────────── - // Strip to plain text — no special characters that could be meaningful - // to tsvector/ILIKE beyond the literal token. const rawQ = typeof req.query.q === "string" ? req.query.q.trim() : ""; - // Sanitise: keep only alphanumeric, spaces, dots, hyphens, underscores const q = rawQ.replace(/[^\w\s.\-]/g, "").substring(0, 200); - // ── Pagination ────────────────────────────────────────────────────────── const limit = Math.min( 100, Math.max(1, parseInt(String(req.query.limit ?? "20"))), @@ -596,19 +658,14 @@ orgVaultsRouter.get( typeof req.query.cursor === "string" ? req.query.cursor : undefined; try { - // ── Base query — always scoped to the org and not soft-deleted ──────── let query = db("vaults") .where("organization_id", orgId) .whereNull("deleted_at"); - // ── Full-text search ───────────────────────────────────────────────── if (q) { - // Check whether the tsvector column exists (migration may not have run yet). - // Result is cached for the lifetime of the process — see hasFtsColumn(). const ftsAvailable = await hasFtsColumn(); if (ftsAvailable) { - // GIN index path — injection-safe: q is bound via knex parameterisation query = query.whereRaw(`search_vector @@ to_tsquery('simple', ?)`, [ q .split(/\s+/) @@ -617,7 +674,6 @@ orgVaultsRouter.get( .join(" & "), ]); } else { - // Fallback ILIKE path (slower, but safe until migration runs) query = query.where(function () { this.where("creator", "ilike", `%${q}%`).orWhere( "verifier", @@ -628,7 +684,6 @@ orgVaultsRouter.get( } } - // ── Structured filters ─────────────────────────────────────────────── const filters = req.filters ?? {}; if (filters.status) { @@ -673,8 +728,6 @@ orgVaultsRouter.get( query = query.where("created_at", "<=", new Date(to)); } - // ── Cursor pagination ──────────────────────────────────────────────── - // Stable sort: (created_at DESC, id DESC) — matches encodeCursor/decodeCursor contract if (rawCursor) { try { const { timestamp, id } = decodeCursor(rawCursor); @@ -689,24 +742,10 @@ orgVaultsRouter.get( } } - // Enforce stable ordering query = query.orderBy("created_at", "desc").orderBy("id", "desc"); - // Fetch limit + 1 to detect whether a next page exists - const rows = await query - .limit(limit + 1) - .select( - "id", - "creator", - "verifier", - "amount", - "status", - "organization_id", - "start_date", - "end_date", - "created_at", - "updated_at", - ); + const { sql, bindings } = query.limit(limit + 1).toSQL().toNative(); + const rows = await getPrisma().$queryRawUnsafe(sql, ...bindings); const hasMore = rows.length > limit; const results = rows.slice(0, limit); diff --git a/src/tests/__mocks__/prisma.ts b/src/tests/__mocks__/prisma.ts index 2a6c3e2c..d523e983 100644 --- a/src/tests/__mocks__/prisma.ts +++ b/src/tests/__mocks__/prisma.ts @@ -15,7 +15,28 @@ export class PrismaClient { update: async (data: any) => data.data, updateMany: async () => ({ count: 0 }), } + $extends = () => this $queryRaw = async () => [{ '?column?': 1 }] $connect = async () => {} $disconnect = async () => {} + organization = { + findUnique: async () => null, + findMany: async () => [], + create: async (data: any) => data.data, + } + membership = { + findFirst: async () => null, + findMany: async () => [], + create: async (data: any) => data.data, + } + team = { + findUnique: async () => null, + findMany: async () => [], + create: async (data: any) => data.data, + } + orgVaultSearch = { + findUnique: async () => null, + findMany: async () => [], + create: async (data: any) => data.data, + } } diff --git a/src/tests/orgAuth.dbErrors.test.ts b/src/tests/orgAuth.dbErrors.test.ts index 1dd95b9a..357fc2fe 100644 --- a/src/tests/orgAuth.dbErrors.test.ts +++ b/src/tests/orgAuth.dbErrors.test.ts @@ -35,6 +35,31 @@ jest.unstable_mockModule('../db/index.js', () => ({ default: mockDb, })) +jest.unstable_mockModule('../lib/prismaScope.js', () => ({ + getPrisma: () => ({ + organization: { + findUnique: async (args: any) => { + return mockDb('organizations').where({ id: args.where.id }).first() + } + }, + membership: { + findFirst: async (args: any) => { + const tbl = args.where.teamId ? 'team_members' : 'org_members' + return mockDb(tbl).where().first() + } + }, + team: { + findUnique: async (args: any) => { + return mockDb('teams').where({ id: args.where.id }).first() + } + } + }), + prismaStorage: { + getStore: () => undefined, + run: (ctx: any, cb: any) => cb() + } +})) + jest.unstable_mockModule('../models/organizations.js', () => ({ getOrganization: jest.fn(), getMemberRole: jest.fn(), diff --git a/src/tests/orgAuth.test.ts b/src/tests/orgAuth.test.ts index 9d09f257..a18fbd80 100644 --- a/src/tests/orgAuth.test.ts +++ b/src/tests/orgAuth.test.ts @@ -10,6 +10,31 @@ jest.unstable_mockModule('../db/index.js', () => ({ default: mockDb, })) +jest.unstable_mockModule('../lib/prismaScope.js', () => ({ + getPrisma: () => ({ + organization: { + findUnique: async (args: any) => { + return mockDb('organizations').where({ id: args.where.id }).first() + } + }, + membership: { + findFirst: async (args: any) => { + const tbl = args.where.teamId ? 'team_members' : 'org_members' + return mockDb(tbl).where().first() + } + }, + team: { + findUnique: async (args: any) => { + return mockDb('teams').where({ id: args.where.id }).first() + } + } + }), + prismaStorage: { + getStore: () => undefined, + run: (ctx: any, cb: any) => cb() + } +})) + jest.unstable_mockModule('../middleware/auth.js', () => ({ getAuthenticatedUserId: mockGetAuthenticatedUserId, })) diff --git a/src/tests/orgVaults.prismaScope.test.ts b/src/tests/orgVaults.prismaScope.test.ts new file mode 100644 index 00000000..186617ff --- /dev/null +++ b/src/tests/orgVaults.prismaScope.test.ts @@ -0,0 +1,216 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals' +import request from 'supertest' +import express from 'express' +import type { Request, Response, NextFunction } from 'express' + +const mockGetPrisma = jest.fn() +const mockIdempotencyService = { + getIdempotentResponse: jest.fn(), + saveIdempotentResponse: jest.fn(), + failPendingIdempotentResponse: jest.fn(), +} + +const mockDbQuery = { + where: jest.fn().mockReturnThis(), + whereNull: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + toSQL: () => ({ + toNative: () => ({ sql: 'SELECT * FROM vaults WHERE organization_id = $1 AND deleted_at IS NULL', bindings: ['org-1'] }) + }) +} + +// Mock database connection +jest.unstable_mockModule('../db/index.js', () => ({ + default: jest.fn(() => mockDbQuery) +})) + +// Mock prismaScope +jest.unstable_mockModule('../lib/prismaScope.js', () => ({ + getPrisma: mockGetPrisma, + prismaStorage: { + getStore: () => ({ prisma: mockGetPrisma(), orgId: 'org-1' }), + run: (ctx: any, cb: any) => cb() + } +})) + +// Mock idempotency +jest.unstable_mockModule('../services/idempotency.js', () => ({ + validateIdempotencyKey: () => ({ valid: true }), + scopeIdempotencyKey: (userId: string, key: string) => `${userId}:${key}`, + hashRequestPayload: () => 'payload-hash', + getIdempotentResponse: mockIdempotencyService.getIdempotentResponse, + saveIdempotentResponse: mockIdempotencyService.saveIdempotentResponse, + failPendingIdempotentResponse: mockIdempotencyService.failPendingIdempotentResponse, + IdempotencyConflictError: class extends Error { + constructor() { + super('Idempotency key conflict') + this.name = 'IdempotencyConflictError' + } + } +})) + +// Mock rate limiters and auth +jest.unstable_mockModule('../middleware/rateLimiter.js', () => ({ + orgReadRateLimiter: (_req: Request, _res: Response, next: NextFunction) => next(), + orgWriteRateLimiter: (_req: Request, _res: Response, next: NextFunction) => next(), +})) + +jest.unstable_mockModule('../middleware/queryParser.js', () => ({ + queryParser: () => (req: Request, _res: Response, next: NextFunction) => { + (req as any).pagination = { page: 1, pageSize: 20 }; + next(); + } +})) + +jest.unstable_mockModule('../middleware/auth.js', () => ({ + authenticate: (req: Request, _res: Response, next: NextFunction) => { + req.user = { userId: 'user-123' } as any + next() + } +})) + +jest.unstable_mockModule('../middleware/orgAuth.js', () => ({ + requireOrgAccess: () => (_req: Request, _res: Response, next: NextFunction) => next(), +})) + +const { orgVaultsRouter } = await import('../routes/orgVaults.js') + +function buildApp() { + const app = express() + app.use(express.json()) + app.use('/api/orgs', orgVaultsRouter) + return app +} + +describe('orgVaults Router — Prisma Scoping, Invariants, and Idempotency', () => { + beforeEach(() => { + jest.clearAllMocks() + mockIdempotencyService.getIdempotentResponse.mockReset() + mockIdempotencyService.saveIdempotentResponse.mockReset() + mockIdempotencyService.failPendingIdempotentResponse.mockReset() + }) + + describe('GET /api/orgs/:orgId/vaults', () => { + it('uses $queryRawUnsafe to fetch vaults inside the request-scoped context', async () => { + const mockVaults = [ + { + id: 'v-1', + creator: 'user-1', + verifier: 'v-addr', + amount: '100', + status: 'active', + organization_id: 'org-1', + start_date: new Date().toISOString(), + end_date: new Date().toISOString(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + ] + + mockGetPrisma.mockReturnValue({ + $queryRawUnsafe: jest.fn().mockResolvedValue(mockVaults) + }) + + const res = await request(buildApp()).get('/api/orgs/org-1/vaults') + + expect(res.status).toBe(200) + expect(res.body.data[0].id).toBe('v-1') + expect(res.body.data[0].orgId).toBe('org-1') + }) + }) + + describe('POST /api/orgs/:orgId/vault-searches', () => { + it('enforces idempotency and returns cached response if key matches', async () => { + const cachedResponse = { search: { id: 'search-123', name: 'Cached Search' } } + mockIdempotencyService.getIdempotentResponse.mockResolvedValue(cachedResponse) + + const res = await request(buildApp()) + .post('/api/orgs/org-1/vault-searches') + .set('idempotency-key', 'idem-key-123') + .send({ name: 'My Search', query_definition: { status: 'active' } }) + + expect(res.status).toBe(200) + expect(res.body).toEqual(cachedResponse) + expect(mockIdempotencyService.getIdempotentResponse).toHaveBeenCalled() + }) + + it('enforces limit check (MAX_SEARCHES_PER_ORG) and creates search atomically', async () => { + mockIdempotencyService.getIdempotentResponse.mockResolvedValue(null) + + const mockSearchRecord = { + id: 'search-1', + orgId: 'org-1', + name: 'My Search', + queryDefinition: { status: 'active' }, + alertsEnabled: false, + alertRecipient: null, + alertFrequencyMs: 3600000, + lastEvaluatedAt: null, + lastResultHash: null, + createdBy: 'user-123', + createdAt: new Date(), + updatedAt: new Date() + } + + mockGetPrisma.mockReturnValue({ + $transaction: async (cb: any) => { + const tx = { + orgVaultSearch: { + count: jest.fn().mockResolvedValue(5), // count is less than 20 + create: jest.fn().mockResolvedValue(mockSearchRecord) + } + } + return cb(tx) + } + }) + + const res = await request(buildApp()) + .post('/api/orgs/org-1/vault-searches') + .send({ name: 'My Search', query_definition: { status: 'active' } }) + + expect(res.status).toBe(201) + expect(res.body.search.id).toBe('search-1') + }) + + it('returns 422 if the org has reached the maximum number of saved searches', async () => { + mockIdempotencyService.getIdempotentResponse.mockResolvedValue(null) + + mockGetPrisma.mockReturnValue({ + $transaction: async (cb: any) => { + const tx = { + orgVaultSearch: { + count: jest.fn().mockResolvedValue(20), // Max limit reached + create: jest.fn() + } + } + return cb(tx) + } + }) + + const res = await request(buildApp()) + .post('/api/orgs/org-1/vault-searches') + .send({ name: 'My Search', query_definition: { status: 'active' } }) + + expect(res.status).toBe(422) + expect(res.body.error).toContain('maximum of 20 saved searches') + }) + + it('calls failPendingIdempotentResponse on transaction failure to recover state', async () => { + mockIdempotencyService.getIdempotentResponse.mockResolvedValue(null) + + mockGetPrisma.mockReturnValue({ + $transaction: jest.fn().mockRejectedValue(new Error('DB failure')) + }) + + const res = await request(buildApp()) + .post('/api/orgs/org-1/vault-searches') + .set('idempotency-key', 'idem-key-error') + .send({ name: 'My Search', query_definition: { status: 'active' } }) + + expect(res.status).toBe(500) + expect(mockIdempotencyService.failPendingIdempotentResponse).toHaveBeenCalled() + }) + }) +})