// Standalone Node 22+ example. Place behind your public HTTPS reverse proxy. // Production handlers must persist event IDs and effects before acknowledging. import http, { type IncomingMessage, type ServerResponse } from 'node:http' import { createHmac, timingSafeEqual } from 'node:crypto' import { pathToFileURL } from 'node:url' export function verifyNopaseSignature(rawBody: string, timestamp: string, signature: string, secret: string, now = Date.now()): boolean { if (!/^\d{10}$/.test(timestamp) || Math.abs(now / 1000 - Number(timestamp)) > 300 || !/^sha256=[a-f0-9]{64}$/.test(signature)) return false const expected = 'sha256=' + createHmac('sha256', secret).update(timestamp + '.' + rawBody).digest('hex') return timingSafeEqual(Buffer.from(expected), Buffer.from(signature)) } export function createNopaseReceiver(secret: string, onEvent: (event: Record, context: { rawBody: string; headers: IncomingMessage['headers'] }) => Promise) { if (secret.length < 32) throw new Error('Configure a webhook signing secret with at least 32 characters.') const seen = new Map() let processing = Promise.resolve() return async (request: IncomingMessage, response: ServerResponse) => { if (request.method !== 'POST' || request.url !== '/nopase') { request.resume(); response.writeHead(404); response.end(); return } const timeout = setTimeout(() => request.destroy(), 10000) try { const chunks: Buffer[] = []; let length = 0 for await (const chunk of request) { const buffer = Buffer.from(chunk); length += buffer.length if (length > 1_048_576) { response.writeHead(413); response.end(); return } chunks.push(buffer) } const rawBody = Buffer.concat(chunks).toString('utf8') if (!verifyNopaseSignature(rawBody, String(request.headers['x-nopase-timestamp'] ?? ''), String(request.headers['x-nopase-signature-v2'] ?? ''), secret)) { response.writeHead(401); response.end(); return } let event: Record try { event = JSON.parse(rawBody) } catch { response.writeHead(400); response.end(); return } if (!event || typeof event !== 'object' || typeof event.event_id !== 'string' || event.event_id.length > 200 || typeof event.event_type !== 'string' || event.source !== 'nopase') { response.writeHead(422); response.end(); return } // Serialize processing so two simultaneous copies cannot both apply effects. const operation = processing.then(async () => { if (seen.has(String(event.event_id))) return await onEvent(event, { rawBody, headers: request.headers }) seen.set(String(event.event_id), Date.now()) // Bounded in-memory demo cache. Use your durable store in production. if (seen.size > 10000) seen.delete(seen.keys().next().value!) }) processing = operation.catch(() => {}) await operation response.writeHead(202); response.end() } catch { if (!response.headersSent) response.writeHead(503); response.end() } finally { clearTimeout(timeout) } } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const secret = process.env.NOPASE_WEBHOOK_SECRET ?? '' const receiver = createNopaseReceiver(secret, async event => { console.info('Accepted Nopase event', { eventId: event.event_id, eventType: event.event_type }) // Replace this callback with durable ingestion into your own destination. }) const port = Number(process.env.NOPASE_WEBHOOK_RECEIVER_PORT ?? 8787) if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid receiver port.') const server = http.createServer(receiver) server.listen(port, '127.0.0.1', () => { const address = server.address() if (address && typeof address === 'object') console.info(`Receiver on http://127.0.0.1:${address.port}/nopase; expose through your public HTTPS reverse proxy.`) }) }