Skip to content
Open
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
9 changes: 9 additions & 0 deletions backend/env.example.txt
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,12 @@ VERIFY_CONCURRENCY_RETRY_AFTER_SECONDS=5
# Require cryptographic signatures for cancel (production always requires).
REQUIRE_SIGNATURES=false


# Signed seller webhooks. Run db:migrate before enabling PostgreSQL delivery.
# MVP endpoints/outbox are volatile; use PostgreSQL for durable delivery.
# Generate a stable server-only encryption key with: openssl rand -hex 32
# Share the key across replicas; never expose it with NEXT_PUBLIC.
WEBHOOKS_ENABLED=false
WEBHOOK_ENCRYPTION_KEY=
WEBHOOK_MAX_ATTEMPTS=8
WEBHOOK_DISABLE_AFTER_FAILURES=10
9 changes: 9 additions & 0 deletions backend/env.mvp.example
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,12 @@ REQUIRE_SIGNATURES=false
# Redis connection for verify-cache (optional). Rate limiters use in-memory.
# Falls back to in-memory cache if not configured.
REDIS_URL=

# Signed seller webhooks. Run db:migrate before enabling PostgreSQL delivery.
# MVP endpoints/outbox are volatile; use PostgreSQL for durable delivery.
# Generate a stable server-only encryption key with: openssl rand -hex 32
# Share the key across replicas; never expose it with NEXT_PUBLIC.
WEBHOOKS_ENABLED=false
WEBHOOK_ENCRYPTION_KEY=
WEBHOOK_MAX_ATTEMPTS=8
WEBHOOK_DISABLE_AFTER_FAILURES=10
1 change: 1 addition & 0 deletions backend/src/observability/log-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const FAILURE_OPERATIONS = [
'stellar.streamPayment', 'stellar.payments', 'stellar.submit',
'cache.connect', 'cache.get', 'cache.set', 'cache.clear', 'cache.verify',
'proof.invariants', 'proof.json', 'proof.pdf', 'proof.handoff',
'webhook.worker', 'webhook.manage',
] as const;

type FailureOperation = typeof FAILURE_OPERATIONS[number];
Expand Down
32 changes: 18 additions & 14 deletions backend/src/routes/invoice.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,22 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa
.set(invoiceId, txHash, httpStatus, body)
.catch(() => { emitOperationalFailure('cache.set'); });

// A rejected verification is acknowledged/cached only after its audit/outbox
// transaction commits. Otherwise the caller can retry instead of losing an event.
const recordRejection = async (res: Response, id: string, txHash: string, code: string): Promise<boolean> => {
try {
await storage.logPaymentEvent(id,
code === 'AMOUNT_TOO_LOW' || code === 'AMOUNT_MISMATCH' ? 'PARTIAL_PAYMENT' : 'PAYMENT_REJECTED',
{ code, txHash, source: 'manual-verify' });
return true;
} catch {
emitOperationalFailure('invoice.events');
res.status(503).json({ success: false, code: 'PAYMENT_EVENT_UNAVAILABLE',
error: 'The payment event could not be recorded. Please retry verification.' });
return false;
}
};

const frontendUrl = () =>
options.frontendUrl || process.env.FRONTEND_URL || 'http://localhost:3000';

Expand Down Expand Up @@ -689,21 +705,8 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa
durationMs: Date.now() - startedAt,
});
const body = verificationFailureBody(verification.code, verification.error);
if (!await recordRejection(res, id, hashCheck.value, verification.code)) return;
await cacheResult(id, hashCheck.value, 400, body);
// Issue #515: a rejected verify lands on the seller's audit feed with
// the same taxonomy the monitor uses, so "still PENDING" answers
// itself without the payer having to say so.
await storage.logPaymentEvent(
id,
verification.code === 'AMOUNT_TOO_LOW' || verification.code === 'AMOUNT_MISMATCH'
? 'PARTIAL_PAYMENT'
: 'PAYMENT_REJECTED',
{
code: verification.code,
txHash: hashCheck.value,
source: 'manual-verify',
}
).catch(() => undefined);
return sendVerificationFailure(res, 400, verification.code, verification.error);
}

Expand Down Expand Up @@ -774,6 +777,7 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa
durationMs: Date.now() - startedAt,
});
const body = verificationFailureBody(error.code, messageForCode(error.code));
if (!await recordRejection(res, id, hashCheck.value, error.code)) return;
await cacheResult(id, hashCheck.value, 409, body);
res.status(409).json(body);
return;
Expand Down
2 changes: 2 additions & 0 deletions backend/src/routes/invoice.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '../middleware/rate-limit';
import { createInvoiceCeilingMiddleware } from '../middleware/invoice-ceiling';
import { createVerifyCacheMiddleware, verificationCache } from '../middleware/verify-cache';
import { createWebhookRouter } from './webhook.routes';

export interface InvoiceRouterOptions extends InvoiceHandlerOptions {
enableRateLimiting?: boolean;
Expand Down Expand Up @@ -48,6 +49,7 @@ export function createInvoiceRouter(options: InvoiceRouterOptions): Router {
const handlers = createInvoiceHandlers(options);
const router = Router();
router.use(requestCorrelationMiddleware);
if (options.storage.webhooks) router.use(createWebhookRouter(options.storage.webhooks));

const enableRateLimiting =
options.enableRateLimiting ??
Expand Down
256 changes: 256 additions & 0 deletions backend/src/routes/webhook.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import { randomUUID } from 'node:crypto';
import { Router, type Request, type Response, type RequestHandler } from 'express';
import {
WEBHOOK_EVENT_TYPES,
webhookProofMessage,
type WebhookAction,
type WebhookEventType,
} from '../../../shared/webhooks';
import { createRateLimiter, getClientIp } from '../middleware/rate-limit';
import { emitOperationalFailure } from '../observability/log-events';
import {
WebhookStoreError,
publicWebhookDelivery,
publicWebhookEndpoint,
type WebhookEndpoint,
type WebhookStorage,
} from '../storage/webhook-storage';
import { newWebhookSecret, webhookEncryptionKey } from '../services/webhook-crypto';
import {
validateWebhookUrl,
WebhookTransportError,
type WebhookResolver,
} from '../services/webhook-transport';
import { verifySellerSignature } from '../utils/signature-verification';

export interface WebhookRouterOptions {
encryptionKey?: Buffer;
enabled?: boolean;
clock?: () => Date;
resolve?: WebhookResolver;
}

const UUID = /^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/;
const eventTypes: ReadonlySet<string> = new Set(WEBHOOK_EVENT_TYPES);

function fail(res: Response, status: number, code: string, message: string): void {
res.status(status).json({ success: false, code, error: message });
}

export function createWebhookRouter(
storage: WebhookStorage,
options: WebhookRouterOptions = {}
): Router {
const router = Router();
const clock = options.clock ?? (() => new Date());
const enabled = options.enabled ?? process.env.WEBHOOKS_ENABLED === 'true';
const encryptionKey = options.encryptionKey ?? (enabled ? webhookEncryptionKey() : undefined);
if (encryptionKey && encryptionKey.length !== 32) {
throw new Error('Webhook encryption requires a 32-byte key');
}

router.use('/webhooks', (_req: Request, res: Response, next) => {
res.setHeader('Cache-Control', 'no-store');
next();
}, createRateLimiter({
windowMs: 60_000,
max: 60,
keyGenerator: (req) => 'manage_webhooks:' + getClientIp(req),
code: 'WEBHOOK_RATE_LIMIT_EXCEEDED',
message: 'Too many webhook management requests. Please retry later.',
}));

function handler(action: WebhookAction): RequestHandler {
return async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? req.body as Record<string, unknown>
: {};
const endpointId = typeof req.params.id === 'string' ? req.params.id : undefined;
if (
action !== 'list' &&
action !== 'register' &&
(!endpointId || !UUID.test(endpointId))
) {
fail(res, 400, 'INVALID_ENDPOINT_ID', 'Provide a valid webhook endpoint ID.');
return;
}

const { sellerPublicKey, signature, timestamp, nonce } = body;
if (
typeof sellerPublicKey !== 'string' ||
typeof signature !== 'string' ||
timestamp === undefined ||
typeof nonce !== 'string'
) {
fail(res, 401, 'WEBHOOK_PROOF_REQUIRED', 'A signed seller wallet proof is required.');
return;
}

const now = clock();
if (
sellerPublicKey.length !== 56 ||
signature.length === 0 ||
signature.length > 256 ||
typeof timestamp !== 'number' ||
!Number.isSafeInteger(timestamp) ||
!UUID.test(nonce) ||
now.getTime() - timestamp * 1000 >= 300_000 ||
timestamp * 1000 - now.getTime() > 30_000
) {
fail(res, 401, 'INVALID_WEBHOOK_PROOF', 'The wallet proof is invalid or has expired.');
return;
}

let url: string | undefined;
let events: WebhookEventType[] | undefined;
if (action === 'register') {
if (
typeof body.url !== 'string' ||
body.url.length === 0 ||
body.url.length > 2048 ||
!Array.isArray(body.events) ||
body.events.length < 1 ||
body.events.length > WEBHOOK_EVENT_TYPES.length ||
!body.events.every((event): event is WebhookEventType =>
typeof event === 'string' && eventTypes.has(event)) ||
new Set(body.events).size !== body.events.length
) {
fail(res, 400, 'INVALID_WEBHOOK', 'Provide an HTTPS URL and one or more distinct supported events.');
return;
}
url = body.url;
events = body.events;
}

const message = webhookProofMessage({
sellerPublicKey,
action,
endpointId,
url,
events,
timestamp,
nonce,
});
if (!verifySellerSignature(sellerPublicKey, signature, [message])) {
fail(res, 401, 'INVALID_WEBHOOK_PROOF', 'The wallet proof is invalid or has expired.');
return;
}

if (action !== 'list' && action !== 'remove' && (!enabled || !encryptionKey)) {
fail(res, 503, 'WEBHOOKS_DISABLED', 'Webhook delivery is not enabled on this server.');
return;
}
const consumed = await storage.consumeProof(
sellerPublicKey, nonce, new Date((timestamp + 300) * 1000), now
);
if (!consumed) {
fail(res, 409, 'WEBHOOK_PROOF_REPLAYED', 'This wallet proof has already been used.');
return;
}

if (action === 'list') {
const [endpoints, deliveries] = await Promise.all([
storage.listEndpoints(sellerPublicKey),
storage.listDeliveries(sellerPublicKey),
]);
res.json({
success: true,
data: {
endpoints: endpoints.map(publicWebhookEndpoint),
deliveries: deliveries.map(publicWebhookDelivery),
},
});
return;
}

if (action === 'register') {
const validated = await validateWebhookUrl(url!, options.resolve);
const { secret, ...secrets } = newWebhookSecret(encryptionKey!);
const endpoint: WebhookEndpoint = {
id: randomUUID(),
sellerPublicKey,
url: validated.url.href,
events: events!,
enabled: true,
failureCount: 0,
createdAt: now.toISOString(),
...secrets,
};
await storage.register(endpoint);
res.status(201).json({
success: true,
data: { endpoint: publicWebhookEndpoint(endpoint), secret },
});
return;
}

if (action === 'remove') {
if (!await storage.remove(sellerPublicKey, endpointId!, now)) {
fail(res, 404, 'ENDPOINT_NOT_FOUND', 'Webhook endpoint not found.');
return;
}
res.json({ success: true, data: { id: endpointId, removed: true } });
return;
}

if (action === 'rotate') {
const { secret, ...secrets } = newWebhookSecret(encryptionKey!);
const endpoint = await storage.rotate(
sellerPublicKey, endpointId!, secrets, now, 86_400_000
);
if (!endpoint) {
fail(res, 404, 'ENDPOINT_NOT_FOUND', 'Webhook endpoint not found.');
return;
}
res.json({
success: true,
data: {
endpoint: publicWebhookEndpoint(endpoint),
secret,
previousSecretExpiresAt: endpoint.previousSecretExpiresAt,
},
});
return;
}

const eventId = randomUUID();
if (!await storage.enqueueTest(sellerPublicKey, endpointId!, eventId, now)) {
fail(res, 404, 'ENDPOINT_NOT_FOUND', 'Webhook endpoint not found.');
return;
}
res.status(202).json({ success: true, data: { eventId } });
} catch (error) {
if (error instanceof WebhookStoreError) {
switch (error.code) {
case 'ENDPOINT_LIMIT':
fail(res, 400, error.code, 'The webhook endpoint limit has been reached.');
return;
case 'ENDPOINT_NOT_FOUND':
fail(res, 404, error.code, 'Webhook endpoint not found.');
return;
case 'ROTATION_IN_PROGRESS':
fail(res, 409, error.code, 'The previous signing secret is still in its rotation window.');
return;
case 'TEST_RATE_LIMIT':
fail(res, 429, error.code, 'Wait before sending another test event.');
return;
}
}
if (error instanceof WebhookTransportError) {
fail(res, 400, 'INVALID_WEBHOOK', 'The endpoint must resolve to a public HTTPS address without credentials or a fragment.');
return;
}
emitOperationalFailure('webhook.manage');
fail(res, 503, 'WEBHOOK_UNAVAILABLE', 'Webhook management is temporarily unavailable.');
}
};
}

router.post('/webhooks', handler('register'));
router.post('/webhooks/list', handler('list'));
router.post('/webhooks/:id/remove', handler('remove'));
router.post('/webhooks/:id/rotate', handler('rotate'));
router.post('/webhooks/:id/test', handler('test'));
return router;
}
Loading
Loading