chore(hardening): maintenance jobs, defense-in-depth, redis-backed public rate limit
- maintenance worker now expires GDPR export bundles (db row + MinIO object) on the gdpr_exports.expires_at boundary, plus 90-day retention sweep on ai_usage_ledger; both jobs scheduled daily. - portId scoping added to listClientRelationships and listClientExports (defense-in-depth — parent-resource gates already prevent cross-tenant reads, but service layer should enforce on its own). - SELECT FOR UPDATE on parent client/company row inside add/update address transactions to serialize concurrent isPrimary toggles. - public /interests + /residential-inquiries endpoints swap their in-memory ipHits maps for the redis sliding-window limiter via the new rateLimiters.publicForm config (5/hr/IP), so the cap survives restarts and is shared across worker processes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,8 +45,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
filters.push(eq(clients.source, source));
|
||||
}
|
||||
if (nationality) {
|
||||
// Filter accepts an ISO-3166-1 alpha-2 code; legacy free-text matching is
|
||||
// gone after the i18n column drop.
|
||||
// Filter accepts an ISO-3166-1 alpha-2 code.
|
||||
filters.push(eq(clients.nationalityIso, nationality.toUpperCase()));
|
||||
}
|
||||
if (tagIds && tagIds.length > 0) {
|
||||
@@ -516,8 +515,14 @@ export async function addClientAddress(
|
||||
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||
|
||||
// The unique partial index requires us to demote any existing primary
|
||||
// before inserting a new one, in a single transaction.
|
||||
// before inserting a new one. We grab a row lock on the client to
|
||||
// serialize concurrent primary-toggle requests against the same client —
|
||||
// without this, two simultaneous "isPrimary=true" inserts can both
|
||||
// observe "no existing primary" and one trips the unique index with a
|
||||
// 5xx instead of being safely ordered.
|
||||
const address = await withTransaction(async (tx) => {
|
||||
await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');
|
||||
|
||||
const wantsPrimary = data.isPrimary ?? false;
|
||||
if (wantsPrimary) {
|
||||
await tx
|
||||
@@ -576,6 +581,9 @@ export async function updateClientAddress(
|
||||
if (!existing) throw new NotFoundError('Address');
|
||||
|
||||
const updated = await withTransaction(async (tx) => {
|
||||
// Lock the client row to serialize primary-toggle changes — see addClientAddress.
|
||||
await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');
|
||||
|
||||
if (data.isPrimary === true && !existing.isPrimary) {
|
||||
await tx
|
||||
.update(clientAddresses)
|
||||
@@ -658,7 +666,8 @@ export async function listRelationships(clientId: string, portId: string) {
|
||||
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||
|
||||
return db.query.clientRelationships.findMany({
|
||||
where: (r, { or, eq }) => or(eq(r.clientAId, clientId), eq(r.clientBId, clientId)),
|
||||
where: (r, { and, or, eq }) =>
|
||||
and(eq(r.portId, portId), or(eq(r.clientAId, clientId), eq(r.clientBId, clientId))),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user