Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ jobs:
sleep 2
done

- name: Sync Prisma schema to test database
run: npx prisma db push --skip-generate --accept-data-loss
- name: Apply Prisma migrations to test database
run: npx prisma migrate deploy
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test

Expand Down
43 changes: 43 additions & 0 deletions docs/WEBHOOKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Webhook System Documentation

## Overview
PropChain supports transactional and event-driven webhooks for asynchronous notifications. Webhooks are delivered via HTTP POST requests with cryptographically verified payloads.

## Delivery & Retry Semantics (#1256)

### Retry Backoff Schedule
When an endpoint responds with a non-2xx status or a network timeout/error occurs, the webhook delivery is automatically scheduled for retry using a deterministic backoff schedule:

| Attempt | Failure Sequence | Backoff Delay | Next Retry Timestamp |
|---------|------------------|---------------|----------------------|
| 1 | Initial failure | 1 second | `now + 1,000ms` |
| 2 | 1st retry fails | 5 seconds | `now + 5,000ms` |
| 3 | 2nd retry fails | 15 seconds | `now + 15,000ms` |
| 4 | 3rd retry fails | 60 seconds | `now + 60,000ms` |
| 5 | 4th retry fails | 300 seconds | `now + 300,000ms` |

After 5 failed attempts (`MAX_ATTEMPTS = 5`), the delivery log is marked as `FAILED` and no further retries will be attempted.

### Idempotency & Deduplication
To protect receivers against duplicate side-effects (e.g., duplicated notifications or duplicate email triggers during network hiccups), each webhook delivery includes deduplication headers:

- `X-Webhook-Idempotency-Key`: A unique idempotency identifier. For retries of the same event/delivery, this header remains identical.
- `X-Webhook-Delivery-Id`: Unique identifier of the delivery log entry in PropChain.
- `X-Webhook-Event-Id`: Unique event identifier.
- `X-Webhook-Event`: The event name (e.g. `PROPERTY_CREATED`, `TRANSACTION_COMPLETED`).
- `X-Webhook-Signature`: HMAC-SHA256 signature computed over the raw request body using the webhook secret.

Receivers should check the `X-Webhook-Idempotency-Key` and avoid reprocessing already acknowledged events.

## Secret Rotation (#1255)

If a webhook secret is exposed or needs regular rotation:
- Call `POST /webhooks/:id/rotate-secret` (authenticated as the webhook owner).
- The previous secret is immediately invalidated in the database.
- A new 64-character hex secret is generated and returned once in the response.
- All subsequent deliveries and retries will sign payloads using the new secret.
- The secret rotation event is logged in the system audit trail (`ActivityLog`).
- Deliveries verified using the retired secret will be rejected by receiver signature verification.

## Data Retention & Pruning
Webhook delivery records (`WebhookDeliveryLog`) are retained for 30 days. A scheduled cleanup worker runs daily at midnight (`00:00 UTC`) to prune delivery log entries older than the retention threshold.
39 changes: 0 additions & 39 deletions prisma/migrations/001_add_export_jobs.sql

This file was deleted.

This file was deleted.

58 changes: 0 additions & 58 deletions prisma/migrations/20260422000000_add_auth_security_foundation.sql

This file was deleted.

28 changes: 0 additions & 28 deletions prisma/migrations/20260422170000_add_session_management.sql

This file was deleted.

3 changes: 0 additions & 3 deletions prisma/migrations/20260422180000_add_trust_score.sql

This file was deleted.

This file was deleted.

16 changes: 0 additions & 16 deletions prisma/migrations/20260424000000_add_document_features.sql

This file was deleted.

9 changes: 6 additions & 3 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -457,9 +457,12 @@ model Property {
lotSize Decimal? @map("lot_size")
yearBuilt Int? @map("year_built")
status PropertyStatus @default(DRAFT)
deleted Boolean @default(false)
deletedAt DateTime? @map("deleted_at")
deletedById String? @map("deleted_by_id")
deleted Boolean @default(false) @map("is_deleted")
deletedAt DateTime? @map("deleted_at")
deletedById String? @map("deleted_by_id")
metaTitle String? @map("meta_title")
metaDescription String? @map("meta_description")
metaKeywords String[] @default([]) @map("meta_keywords")
ownerId String @map("owner_id")
neighborhoodId String? @map("neighborhood_id")
latitude Float?
Expand Down
90 changes: 90 additions & 0 deletions src/database/prisma.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { scrubQuery } from './prisma.service';

describe('PrismaService - Query Scrubbing (#1252)', () => {
describe('scrubQuery unit tests', () => {
it('returns empty string when input is empty or null', () => {
expect(scrubQuery('')).toBe('');
expect(scrubQuery(null as any)).toBe('');
});

it('redacts $n parameter placeholders', () => {
const sql = 'SELECT * FROM "User" WHERE id = $1 AND role = $2 AND tenant_id = $10';
const scrubbed = scrubQuery(sql);
expect(scrubbed).toBe('SELECT * FROM "User" WHERE id = ? AND role = ? AND tenant_id = ?');
expect(scrubbed).not.toMatch(/\$\d+/);
});

it('redacts single-quoted literal strings', () => {
const sql = "SELECT * FROM properties WHERE title = 'Luxury Villa' AND location = 'Downtown'";
const scrubbed = scrubQuery(sql);
expect(scrubbed).toBe('SELECT * FROM properties WHERE title = ? AND location = ?');
expect(scrubbed).not.toContain('Luxury Villa');
expect(scrubbed).not.toContain('Downtown');
});

it('redacts email patterns (quoted and unquoted)', () => {
const fixtures = [
"SELECT * FROM users WHERE email = 'alice.smith@propchain.io'",
'SELECT * FROM users WHERE email = bob-admin@sub.domain.org',
"UPDATE users SET email = 'john.doe+filter@gmail.com' WHERE id = 1",
];

const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;

for (const fixture of fixtures) {
const scrubbed = scrubQuery(fixture);
expect(scrubbed).not.toMatch(emailRegex);
}
});

it('redacts IPv4 and IPv6 addresses (quoted and unquoted)', () => {
const fixtures = [
"SELECT * FROM login_history WHERE ip_address = '192.168.1.100'",
'SELECT * FROM login_history WHERE ip_address = 10.0.0.1',
"SELECT * FROM audit_logs WHERE ip = '172.16.254.1'",
"SELECT * FROM sessions WHERE ip = '2001:0db8:85a3:0000:0000:8a2e:0370:7334'",
];

const ipv4Regex = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;

for (const fixture of fixtures) {
const scrubbed = scrubQuery(fixture);
expect(scrubbed).not.toMatch(ipv4Regex);
}
});

it('redacts phone number patterns (quoted and unquoted)', () => {
const fixtures = [
"SELECT * FROM users WHERE phone = '+1-555-123-4567'",
'SELECT * FROM users WHERE phone = +15551234567',
"SELECT * FROM agents WHERE phone = '(555) 234-5678'",
"SELECT * FROM contacts WHERE phone = '555-867-5309'",
"SELECT * FROM users WHERE mobile = '+44 20 7946 0958'",
];

const phoneRegex = /(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{3,4}[-.\s]?\d{3,4}\b/;

for (const fixture of fixtures) {
const scrubbed = scrubQuery(fixture);
expect(scrubbed).not.toMatch(phoneRegex);
}
});

it('redacts multiple PII types embedded in a complex query', () => {
const complexQuery =
"SELECT u.id, u.email, u.phone FROM users u WHERE u.email = 'customer@example.com' " +
"AND u.phone = '+1-555-987-6543' AND u.last_login_ip = '10.20.30.40' AND u.status = $1";

const scrubbed = scrubQuery(complexQuery);

expect(scrubbed).not.toContain('customer@example.com');
expect(scrubbed).not.toContain('+1-555-987-6543');
expect(scrubbed).not.toContain('10.20.30.40');
expect(scrubbed).not.toContain('$1');
expect(scrubbed).toBe(
'SELECT u.id, u.email, u.phone FROM users u WHERE u.email = ? ' +
'AND u.phone = ? AND u.last_login_ip = ? AND u.status = ?',
);
});
});
});
Loading