From 12faa30b08a07e627b134cd5a865ff3ceffe56a4 Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Tue, 3 Nov 2026 21:43:37 +0100 Subject: [PATCH 1/2] chore: pin @fastify/cors 11.3.1 for the dashboard's CORS --- api/bun.lock | 5 +++++ api/package.json | 1 + 2 files changed, 6 insertions(+) diff --git a/api/bun.lock b/api/bun.lock index fc510f1..b4ab848 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -4,6 +4,7 @@ "workspaces": { "": { "dependencies": { + "@fastify/cors": "11.3.1", "dbmate": "2.37.0", "fastify": "5.12.7", "ipaddr.js": "2.5.0", @@ -33,6 +34,8 @@ "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.7", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^4.0.0" } }, "sha512-VP0yIfwlc1ohj733keYDqyHm1VWeP9VQxYomunhr90jkC4aNNibbkrRiaNgPgqj1ORxUenILR4qzA/ppYBNAtQ=="], + "@fastify/cors": ["@fastify/cors@11.3.1", "", { "dependencies": { "fastify-plugin": "^6.0.0", "toad-cache": "^3.7.0" } }, "sha512-gZXNzd6FLHzOIfvoK4+Az3Wko2V6Y0MIsLxpyo4AEYl7a9q1JBPwPaxLea4nkr++8FCiJiPpYYo6E02uZW0dqw=="], + "@fastify/deepmerge": ["@fastify/deepmerge@3.2.4", "", {}, "sha512-kc8GQq5gX9RRhw0rov3DMrN8wS8yRlxJRdDzsYwwdBV/wazuVgc/1K7CKL90tlvu3RlHan6Y0R0aE3nJtBz2eQ=="], "@fastify/error": ["@fastify/error@4.2.1", "", {}, "sha512-MYBumuIwP+5MIpXSyKm6/o1Eigcoij186lWUssX3LH5U8KWqU0ktZhzC4CErIX9EJeQeRGI+zJZQWakBlFbUcg=="], @@ -81,6 +84,8 @@ "fastify": ["fastify@5.12.7", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^7.0.0", "find-my-way": "^9.6.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.1.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw=="], + "fastify-plugin": ["fastify-plugin@6.0.1", "", {}, "sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA=="], + "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="], "find-my-way": ["find-my-way@9.10.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q=="], diff --git a/api/package.json b/api/package.json index f049d56..7647254 100644 --- a/api/package.json +++ b/api/package.json @@ -14,6 +14,7 @@ "db:down": "bash scripts/test-db.sh down" }, "dependencies": { + "@fastify/cors": "11.3.1", "dbmate": "2.37.0", "fastify": "5.12.7", "ipaddr.js": "2.5.0", From 334970d1a809b3c1e4e029f795da6bb56b2ebfc6 Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Tue, 3 Nov 2026 22:07:16 +0100 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20events=20with=20idempotency=20and?= =?UTF-8?q?=20fan-out,=20delivery=20reads,=20and=20CORS=20(steps=203.4?= =?UTF-8?q?=E2=80=933.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /v1/events: the event and one delivery per matching live endpoint in one transaction; the same Idempotency-Key returns the first answer (ON CONFLICT DO NOTHING, then a separate SELECT) with Idempotent-Replayed: true. NUL and lone surrogates in the payload are 422. - GET /v1/deliveries: keyset pages (limit + 1, cursor = last row sent), filters by status and endpoint, every join also on owner_id. - GET /v1/deliveries/:id: event, endpoint (also when deleted) and the numbered attempt log; malformed, missing and foreign ids are the same 404. - CORS for the dashboard origins, registered before the auth hook. --- api/src/app.ts | 16 +++- api/src/cors.test.ts | 52 +++++++++++ api/src/repositories/deliveries.ts | 70 +++++++++++++++ api/src/repositories/events.ts | 36 ++++++++ api/src/routes/deliveries.ts | 24 +++++ api/src/routes/events.ts | 20 +++++ api/src/routes/v1.ts | 4 + api/src/schemas/deliveries.ts | 12 +++ api/src/schemas/events.ts | 10 +++ api/src/services/deliveries.ts | 37 ++++++++ api/src/services/events.ts | 23 +++++ .../integration/deliveries-detail.test.ts | 76 ++++++++++++++++ api/test/integration/deliveries-fixture.ts | 28 ++++++ .../integration/deliveries-isolation.test.ts | 46 ++++++++++ api/test/integration/deliveries.test.ts | 45 ++++++++++ .../integration/events-concurrency.test.ts | 48 ++++++++++ api/test/integration/events-cors.test.ts | 16 ++++ api/test/integration/events-fixture.ts | 41 +++++++++ .../integration/events-validation.test.ts | 58 +++++++++++++ api/test/integration/events.test.ts | 87 +++++++++++++++++++ docs/api.md | 10 ++- 21 files changed, 755 insertions(+), 4 deletions(-) create mode 100644 api/src/cors.test.ts create mode 100644 api/src/repositories/deliveries.ts create mode 100644 api/src/repositories/events.ts create mode 100644 api/src/routes/deliveries.ts create mode 100644 api/src/routes/events.ts create mode 100644 api/src/schemas/deliveries.ts create mode 100644 api/src/schemas/events.ts create mode 100644 api/src/services/deliveries.ts create mode 100644 api/src/services/events.ts create mode 100644 api/test/integration/deliveries-detail.test.ts create mode 100644 api/test/integration/deliveries-fixture.ts create mode 100644 api/test/integration/deliveries-isolation.test.ts create mode 100644 api/test/integration/deliveries.test.ts create mode 100644 api/test/integration/events-concurrency.test.ts create mode 100644 api/test/integration/events-cors.test.ts create mode 100644 api/test/integration/events-fixture.ts create mode 100644 api/test/integration/events-validation.test.ts create mode 100644 api/test/integration/events.test.ts diff --git a/api/src/app.ts b/api/src/app.ts index 7b5396a..867210d 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -2,6 +2,7 @@ // It never listens on a port itself. server.ts does that, so tests can call // buildApp() and send fake requests with app.inject(). import Fastify, { LogController, type FastifyInstance } from "fastify"; +import cors from "@fastify/cors"; import { realLookup, type Lookup } from "./ssrf.ts"; import type { Sql } from "./db.ts"; import { v1Routes } from "./routes/v1.ts"; @@ -9,6 +10,8 @@ import type { Config } from "./config.ts"; import { errorHandler, notFoundHandler } from "./error-handler.ts"; import { healthRoutes } from "./routes/health.ts"; +const DASHBOARD_ORIGINS = ["https://cdn.myrelay.tech", "http://localhost:4173"]; + export function buildApp( config: Config, deps: { sql: Sql; lookup?: Lookup }, @@ -41,7 +44,18 @@ export function buildApp( app.setErrorHandler(errorHandler); app.setNotFoundHandler(notFoundHandler); - // Step 2: only the v1 plugin inherits the authentication hook. + // Step 2: preflight runs at the root before the authenticated v1 routes. + app.register(cors, { + origin: DASHBOARD_ORIGINS, + methods: ["GET", "POST", "DELETE"], + allowedHeaders: ["authorization", "content-type", "idempotency-key"], + exposedHeaders: ["x-request-id", "retry-after", "idempotent-replayed"], + maxAge: 600, + credentials: false, + // Otherwise OPTIONS without preflight headers gets the plugin's plain-text 400. + strictPreflight: false, + }); + // Step 3: only the v1 plugin inherits the authentication hook. app.register(healthRoutes, { config }); app.register(v1Routes, { prefix: "/v1", sql: deps.sql, lookup: deps.lookup ?? realLookup, allowOrigins: config.SSRF_ALLOW_ORIGINS, diff --git a/api/src/cors.test.ts b/api/src/cors.test.ts new file mode 100644 index 0000000..1839fa3 --- /dev/null +++ b/api/src/cors.test.ts @@ -0,0 +1,52 @@ +// Checks browser preflights at the root without needing a database connection. +import { afterAll, expect, test } from "bun:test"; +import { buildApp } from "./app.ts"; +import { loadConfig } from "./config.ts"; +import { createDb } from "./db.ts"; + +const config = loadConfig({ DATABASE_URL: "postgres://relay:x@127.0.0.1:1/relay" }); +const sql = createDb(config); +const app = buildApp(config, { sql }, { logger: false }); +afterAll(async () => { await app.close(); await sql.end({ timeout: 5 }); }); +const origin = "https://cdn.myrelay.tech"; +// Step 1: a browser's unauthenticated preflight finishes before the v1 auth hook. +for (const allowed of [origin, "http://localhost:4173"]) { + test(`preflight permits ${allowed} and the retry-key header`, async () => { + const res = await app.inject({ method: "OPTIONS", url: "/v1/events", headers: { + origin: allowed, "access-control-request-method": "POST", + "access-control-request-headers": "authorization,content-type,idempotency-key", + } }); + expect(res.statusCode).toBe(204); + expect(res.headers["access-control-allow-origin"]).toBe(allowed); + expect(res.headers["access-control-allow-headers"]).toContain("idempotency-key"); + expect(res.headers["access-control-max-age"]).toBe("600"); + expect(res.headers["access-control-allow-credentials"]).toBeUndefined(); + }); +} +test("foreign preflight has no allow-origin header", async () => { + const res = await app.inject({ method: "OPTIONS", url: "/v1/events", headers: { + origin: "https://evil.example", "access-control-request-method": "POST", + } }); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); +}); +test("OPTIONS without Origin is 204 without an allow-origin header", async () => { + const res = await app.inject({ method: "OPTIONS", url: "/v1/events" }); + expect(res.statusCode).toBe(204); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); +}); +// Step 2: browser code can read even an authentication failure. +test("401 includes the permitted origin and exposed response headers", async () => { + const res = await app.inject({ url: "/v1/me", headers: { origin } }); + expect(res.statusCode).toBe(401); + expect(res.headers["access-control-allow-origin"]).toBe(origin); + for (const header of ["x-request-id", "retry-after", "idempotent-replayed"]) { + expect(res.headers["access-control-expose-headers"]).toContain(header); + } +}); +test("health probes still answer without authentication", async () => { + for (const url of ["/health/live", "/health/ready"]) { + const res = await app.inject({ url }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ status: "ok" }); + } +}); diff --git a/api/src/repositories/deliveries.ts b/api/src/repositories/deliveries.ts new file mode 100644 index 0000000..cb41ca4 --- /dev/null +++ b/api/src/repositories/deliveries.ts @@ -0,0 +1,70 @@ +// Fans events out and reads deliveries and attempts within their owner's boundary. +import type postgres from "postgres"; +import type { Sql } from "../db.ts"; + +export type DeliveryRow = { + id: string; status: "pending" | "delivered" | "dead"; attempts: number; + next_attempt_at: Date | null; last_status_code: number | null; last_error: string | null; + delivered_at: Date | null; created_at: Date; + event_id: string; event_type: string; endpoint_id: string; endpoint_name: string; +}; +export type DetailRow = DeliveryRow & { + payload: Record; event_created_at: Date; endpoint_url: string; +}; +export type AttemptRow = { + id: string; number: number; started_at: Date; duration_ms: number; + status_code: number | null; error: string | null; response_excerpt: string | null; +}; +export type DeliveryFilters = { + status?: DeliveryRow["status"]; endpointId?: string; cursor?: string; limit: number; +}; + +// Step 1: column defaults create pending deliveries with no attempts. +export async function fanOut(tx: postgres.TransactionSql, ownerId: string, eventId: string, type: string) { + const rows = await tx` + INSERT INTO deliveries (owner_id, event_id, endpoint_id) + SELECT ${ownerId}, ${eventId}, e.id FROM endpoints e + WHERE e.owner_id = ${ownerId} AND e.deleted_at IS NULL + AND (${type} = ANY(e.event_types) OR '*' = ANY(e.event_types))`; + return rows.count; +} + +// Step 2: the column is never NULL; during a lease it holds the lease's expiry. +// The API shows it only for pending deliveries. Both read queries share this rule. +const nextAttempt = (db: Sql) => db`CASE WHEN d.status = 'pending' THEN d.next_attempt_at END AS next_attempt_at`; +const columns = (db: Sql) => db` + d.id, d.status, d.attempts, ${nextAttempt(db)}, d.last_status_code, d.last_error, + d.delivered_at, d.created_at, ev.id AS event_id, ev.type AS event_type, + e.id AS endpoint_id, e.name AS endpoint_name`; + +export async function listDeliveries(db: Sql, ownerId: string, filters: DeliveryFilters): Promise { + const { status, endpointId, cursor, limit } = filters; + return db` + SELECT ${columns(db)} FROM deliveries d + JOIN events ev ON ev.id = d.event_id AND ev.owner_id = d.owner_id + JOIN endpoints e ON e.id = d.endpoint_id AND e.owner_id = d.owner_id + WHERE d.owner_id = ${ownerId} + ${status ? db`AND d.status = ${status}` : db``} + ${endpointId ? db`AND d.endpoint_id = ${endpointId}` : db``} + ${cursor ? db`AND d.id < ${cursor}` : db``} + ORDER BY d.id DESC LIMIT ${limit + 1}`; +} + +// Step 3: deleted endpoints stay available in historical delivery details. +export async function getDelivery(db: Sql, ownerId: string, id: string): Promise { + const [row] = await db` + SELECT ${columns(db)}, ev.payload, ev.created_at AS event_created_at, e.url AS endpoint_url + FROM deliveries d + JOIN events ev ON ev.id = d.event_id AND ev.owner_id = d.owner_id + JOIN endpoints e ON e.id = d.endpoint_id AND e.owner_id = d.owner_id + WHERE d.owner_id = ${ownerId} AND d.id = ${id}`; + return row ?? null; +} + +export async function listAttempts(db: Sql, ownerId: string, deliveryId: string): Promise { + return db` + SELECT a.id, row_number() OVER (ORDER BY a.started_at, a.id)::int AS number, + a.started_at, a.duration_ms, a.status_code, a.error, a.response_excerpt + FROM attempts a JOIN deliveries d ON d.id = a.delivery_id AND d.owner_id = ${ownerId} + WHERE a.delivery_id = ${deliveryId} ORDER BY a.started_at, a.id`; +} diff --git a/api/src/repositories/events.ts b/api/src/repositories/events.ts new file mode 100644 index 0000000..626edab --- /dev/null +++ b/api/src/repositories/events.ts @@ -0,0 +1,36 @@ +// Stores events and retrieves the original answer for an owner's retry key. +import type postgres from "postgres"; +import { AppError } from "../errors.ts"; +import type { EventInput } from "../schemas/events.ts"; + +type Tx = postgres.TransactionSql; +type EventRow = { id: string; type: string; created_at: Date }; + +// Step 1: the unique constraint chooses the winner, including concurrent requests. +export async function insertEvent(tx: Tx, ownerId: string, input: EventInput, key?: string): Promise { + try { + const [row] = await tx` + INSERT INTO events (owner_id, type, payload, idempotency_key) + VALUES (${ownerId}, ${input.type}, ${tx.json(input.payload as postgres.JSONValue)}, ${key ?? null}) + ON CONFLICT (owner_id, idempotency_key) DO NOTHING RETURNING id, type, created_at`; + return row ?? null; + } catch (error) { + const code = (error as { code?: unknown } | null)?.code; + if (code === "22P05" || code === "22P02") { + throw new AppError(422, "validation_failed", "Request validation failed", [ + { path: "payload", message: "Payload contains characters that can't be stored" }, + ]); + } + throw error; + } +} + +// Step 2: a separate statement sees the winning transaction under READ COMMITTED. +export async function findEventByKey(tx: Tx, ownerId: string, key: string) { + const [row] = await tx<(EventRow & { deliveries: number })[]>` + SELECT e.id, e.type, e.created_at, count(d.id)::int AS deliveries + FROM events e LEFT JOIN deliveries d ON d.event_id = e.id AND d.owner_id = e.owner_id + WHERE e.owner_id = ${ownerId} AND e.idempotency_key = ${key} + GROUP BY e.id, e.type, e.created_at`; + return row ?? null; +} diff --git a/api/src/routes/deliveries.ts b/api/src/routes/deliveries.ts new file mode 100644 index 0000000..49d6d62 --- /dev/null +++ b/api/src/routes/deliveries.ts @@ -0,0 +1,24 @@ +// Lists deliveries and reads one delivery after checking filters and ids. +import type { FastifyPluginAsync } from "fastify"; +import type { Sql } from "../db.ts"; +import { notFound, unauthorized } from "../errors.ts"; +import { DeliveryId, DeliveryListQuery } from "../schemas/deliveries.ts"; +import { getDelivery, listDeliveries } from "../services/deliveries.ts"; + +export const deliveryRoutes: FastifyPluginAsync<{ sql: Sql }> = async (app, { sql }) => { + // Step 1: query input uses public names; repository filters use local camelCase. + app.get("/deliveries", async (request) => { + if (!request.owner) throw unauthorized(); + const query = DeliveryListQuery.parse(request.query); + return listDeliveries(sql, request.owner.id, { + status: query.status, endpointId: query.endpoint_id, cursor: query.cursor, limit: query.limit, + }); + }); + // Step 2: malformed ids have the same 404 as missing and foreign ids. + app.get<{ Params: { id: string } }>("/deliveries/:id", async (request) => { + if (!request.owner) throw unauthorized(); + const id = DeliveryId.safeParse(request.params.id); + if (!id.success) throw notFound("Delivery"); + return getDelivery(sql, request.owner.id, id.data); + }); +}; diff --git a/api/src/routes/events.ts b/api/src/routes/events.ts new file mode 100644 index 0000000..5d51f2b --- /dev/null +++ b/api/src/routes/events.ts @@ -0,0 +1,20 @@ +// Accepts validated event requests and marks answers returned from a retry key. +import type { FastifyPluginAsync } from "fastify"; +import { z } from "zod"; +import type { Sql } from "../db.ts"; +import { unauthorized } from "../errors.ts"; +import { EventCreate, IdempotencyKey } from "../schemas/events.ts"; +import { createEvent } from "../services/events.ts"; + +// Step 1: parse the header in an object so validation names its exact public path. +const Headers = z.object({ "idempotency-key": IdempotencyKey }); +export const eventRoutes: FastifyPluginAsync<{ sql: Sql }> = async (app, { sql }) => { + app.post("/events", async (request, reply) => { + if (!request.owner) throw unauthorized(); + const input = EventCreate.parse(request.body); + const headers = Headers.parse({ "idempotency-key": request.headers["idempotency-key"] }); + const { event, deliveries, replayed } = await createEvent(sql, request.owner.id, input, headers["idempotency-key"]); + if (replayed) reply.header("idempotent-replayed", "true"); + return reply.code(202).send({ id: event.id, type: event.type, created_at: event.created_at.toISOString(), deliveries }); + }); +}; diff --git a/api/src/routes/v1.ts b/api/src/routes/v1.ts index bca414b..c490dc8 100644 --- a/api/src/routes/v1.ts +++ b/api/src/routes/v1.ts @@ -5,10 +5,14 @@ import type { Sql } from "../db.ts"; import type { Lookup } from "../ssrf.ts"; import { endpointRoutes } from "./endpoints.ts"; import { meRoutes } from "./me.ts"; +import { eventRoutes } from "./events.ts"; +import { deliveryRoutes } from "./deliveries.ts"; // Step 1: child routes inherit this hook through Fastify encapsulation. export const v1Routes: FastifyPluginAsync<{ sql: Sql; lookup: Lookup; allowOrigins: string[] }> = async (app, { sql, lookup, allowOrigins }) => { app.addHook("onRequest", authenticate(sql)); app.register(meRoutes); app.register(endpointRoutes, { sql, lookup, allowOrigins }); + app.register(eventRoutes, { sql }); + app.register(deliveryRoutes, { sql }); }; diff --git a/api/src/schemas/deliveries.ts b/api/src/schemas/deliveries.ts new file mode 100644 index 0000000..5436cb0 --- /dev/null +++ b/api/src/schemas/deliveries.ts @@ -0,0 +1,12 @@ +// Checks delivery filters and cursor input without silently accepting numeric formats. +import { z } from "zod"; + +// Step 1: only digit text becomes a page size, with a bounded default. +export const DeliveryListQuery = z.object({ + status: z.enum(["pending", "delivered", "dead"]).optional(), + endpoint_id: z.uuid().optional(), + limit: z.string().regex(/^[0-9]{1,3}$/).transform(Number) + .pipe(z.number().int().min(1).max(100)).default(50), + cursor: z.uuid().optional(), +}).strict(); +export const DeliveryId = z.uuid(); diff --git a/api/src/schemas/events.ts b/api/src/schemas/events.ts new file mode 100644 index 0000000..9e2c4d7 --- /dev/null +++ b/api/src/schemas/events.ts @@ -0,0 +1,10 @@ +// Checks event envelopes and optional retry keys before they reach the service. +import { z } from "zod"; + +// Step 1: payloads must be objects; unknown envelope fields are rejected. +export const EventCreate = z.object({ + type: z.string().regex(/^[a-z0-9][a-z0-9_.-]{0,99}$/), + payload: z.record(z.string(), z.unknown()), +}).strict(); +export const IdempotencyKey = z.string().min(1).max(255).regex(/^[^\p{Cc}]+$/u).optional(); +export type EventInput = z.infer; diff --git a/api/src/services/deliveries.ts b/api/src/services/deliveries.ts new file mode 100644 index 0000000..61846b0 --- /dev/null +++ b/api/src/services/deliveries.ts @@ -0,0 +1,37 @@ +// Maps database rows to public delivery pages and delivery history. +import type { Sql } from "../db.ts"; +import { notFound } from "../errors.ts"; +import * as repository from "../repositories/deliveries.ts"; + +// Step 1: explicitly map public fields, including nullable values and ISO timestamps. +function delivery(row: repository.DeliveryRow) { + return { + id: row.id, status: row.status, attempts: row.attempts, + next_attempt_at: row.next_attempt_at?.toISOString() ?? null, + last_status_code: row.last_status_code, last_error: row.last_error, + delivered_at: row.delivered_at?.toISOString() ?? null, created_at: row.created_at.toISOString(), + event: { id: row.event_id, type: row.event_type }, + endpoint: { id: row.endpoint_id, name: row.endpoint_name }, + }; +} +export async function listDeliveries(sql: Sql, ownerId: string, filters: repository.DeliveryFilters) { + const rows = await repository.listDeliveries(sql, ownerId, filters); + const data = rows.slice(0, filters.limit).map(delivery); + return { data, next_cursor: rows.length > filters.limit ? data.at(-1)!.id : null }; +} + +// Step 2: a missing or foreign delivery has one answer; history never reveals secret fields. +export async function getDelivery(sql: Sql, ownerId: string, id: string) { + const row = await repository.getDelivery(sql, ownerId, id); + if (!row) throw notFound("Delivery"); + const attempts = await repository.listAttempts(sql, ownerId, id); + return { + ...delivery(row), + event: { id: row.event_id, type: row.event_type, payload: row.payload, created_at: row.event_created_at.toISOString() }, + endpoint: { id: row.endpoint_id, name: row.endpoint_name, url: row.endpoint_url }, + attempt_log: attempts.map((a) => ({ + id: a.id, number: a.number, started_at: a.started_at.toISOString(), duration_ms: a.duration_ms, + status_code: a.status_code, error: a.error, response_excerpt: a.response_excerpt, + })), + }; +} diff --git a/api/src/services/events.ts b/api/src/services/events.ts new file mode 100644 index 0000000..ae00524 --- /dev/null +++ b/api/src/services/events.ts @@ -0,0 +1,23 @@ +// Saves an event and its deliveries atomically, or returns the original retry answer. +import type { Sql } from "../db.ts"; +import { fanOut as repositoryFanOut } from "../repositories/deliveries.ts"; +import { findEventByKey, insertEvent } from "../repositories/events.ts"; +import type { EventInput } from "../schemas/events.ts"; + +// Step 1: an injected fan-out lets tests prove a failure rolls back the whole transaction. +export async function createEvent( + sql: Sql, ownerId: string, input: EventInput, key?: string, + { fanOut = repositoryFanOut }: { fanOut?: typeof repositoryFanOut } = {}, +) { + return sql.begin(async (tx) => { + const event = await insertEvent(tx, ownerId, input, key); + if (event) { + const deliveries = await fanOut(tx, ownerId, event.id, event.type); + return { event, deliveries, replayed: false }; + } + // Step 2: the key wins, even with a different body; comparing bodies needs a new contract error code. + const original = key === undefined ? null : await findEventByKey(tx, ownerId, key); + if (!original) throw new Error("Conflicting event could not be found"); + return { event: original, deliveries: original.deliveries, replayed: true }; + }); +} diff --git a/api/test/integration/deliveries-detail.test.ts b/api/test/integration/deliveries-detail.test.ts new file mode 100644 index 0000000..67fbfbb --- /dev/null +++ b/api/test/integration/deliveries-detail.test.ts @@ -0,0 +1,76 @@ +// Checks public delivery fields, deleted endpoint history and numbered attempt logs. +import { beforeAll, expect, test } from "bun:test"; +import { deliveriesFixture } from "./deliveries-fixture.ts"; + +const f = deliveriesFixture(); +let a: Awaited>; +beforeAll(async () => { a = await f.seed(); }); +// Step 1: compare the contract example field by field, with this run's isolated ids. +test("list row has exactly the example's keys, types and values", async () => { + const body = (await f.get(a.key)).json(); + const row = body.data[0]; + expect(row).toEqual({ + id: f.id(5), status: "pending", attempts: 3, + next_attempt_at: "2026-10-25T14:09:30.000Z", last_status_code: 500, + last_error: null, delivered_at: null, created_at: "2026-10-25T14:03:20.000Z", + event: { id: expect.any(String), type: "invoice.paid" }, + endpoint: { id: a.endpoint, name: "Accounting webhook" }, + }); + expect(typeof row.attempts).toBe("number"); + expect(typeof row.last_status_code).toBe("number"); +}); +test("pending shows its stored time; dead and delivered hide it in list and detail", async () => { + const rows = (await f.get(a.key)).json().data; + for (const row of rows) { + const expected = row.status === "pending" ? "2026-10-25T14:09:30.000Z" : null; + expect(row.next_attempt_at).toBe(expected); + const res = await f.get(a.key, `/v1/deliveries/${row.id}`); + expect(res.statusCode).toBe(200); + expect(res.json().next_attempt_at).toBe(expected); + } +}); + +// Step 2: chronological numbering breaks ties by id, independent of insertion order. +test("detail keeps payload, deleted endpoint and the full oldest-first attempt history", async () => { + await f.sql`UPDATE endpoints SET deleted_at = now() WHERE owner_id = ${a.owner.id} AND id = ${a.endpoint}`; + for (const [order, time, status, error, excerpt] of [ + [12, "2026-10-25T14:04:00Z", 500, null, "receiver failed"], + [11, "2026-10-25T14:03:00Z", null, "timeout", null], + [13, "2026-10-25T14:04:00Z", 200, null, "ok"], + ] as const) { + await f.sql`INSERT INTO attempts (id, delivery_id, started_at, duration_ms, status_code, error, response_excerpt) + VALUES (${f.id(order)}, ${f.id(1)}, ${time}, 123, ${status}, ${error}, ${excerpt})`; + } + const res = await f.get(a.key, `/v1/deliveries/${f.id(1)}`); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + id: f.id(1), status: "dead", attempts: 3, next_attempt_at: null, + last_status_code: 500, last_error: null, delivered_at: null, created_at: "2026-10-25T14:03:20.000Z", + event: { id: a.eventId, type: "invoice.paid", payload: { invoice: { total: 42 } }, created_at: "2026-10-25T14:03:19.000Z" }, + endpoint: { id: a.endpoint, name: "Accounting webhook", url: "https://example.com/hook" }, + attempt_log: [ + { id: f.id(11), number: 1, started_at: "2026-10-25T14:03:00.000Z", duration_ms: 123, + status_code: null, error: "timeout", response_excerpt: null }, + { id: f.id(12), number: 2, started_at: "2026-10-25T14:04:00.000Z", duration_ms: 123, + status_code: 500, error: null, response_excerpt: "receiver failed" }, + { id: f.id(13), number: 3, started_at: "2026-10-25T14:04:00.000Z", duration_ms: 123, + status_code: 200, error: null, response_excerpt: "ok" }, + ], + }); +}); +test("non-null completion fields remain present in list and detail", async () => { + await f.sql`UPDATE deliveries SET delivered_at = '2026-10-25T14:05:00Z', last_status_code = 204 + WHERE owner_id = ${a.owner.id} AND id = ${f.id(3)}`; + await f.sql`UPDATE deliveries SET last_status_code = NULL, last_error = 'endpoint_deleted' + WHERE owner_id = ${a.owner.id} AND id = ${f.id(2)}`; + const rows = (await f.get(a.key)).json().data; + for (const id of [f.id(2), f.id(3)]) { + const row = rows.find((r: { id: string }) => r.id === id); + const detail = (await f.get(a.key, `/v1/deliveries/${id}`)).json(); + expect(detail.delivered_at).toBe(row.delivered_at); + expect(detail.last_error).toBe(row.last_error); + expect(detail.last_status_code).toBe(row.last_status_code); + } + expect(rows.find((r: { id: string }) => r.id === f.id(3)).delivered_at).toBe("2026-10-25T14:05:00.000Z"); + expect(rows.find((r: { id: string }) => r.id === f.id(2)).last_error).toBe("endpoint_deleted"); +}); diff --git a/api/test/integration/deliveries-fixture.ts b/api/test/integration/deliveries-fixture.ts new file mode 100644 index 0000000..5a4042a --- /dev/null +++ b/api/test/integration/deliveries-fixture.ts @@ -0,0 +1,28 @@ +// Seeds ordered delivery histories without depending on connection timing. +import { randomBytes } from "node:crypto"; +import { eventsFixture } from "./events-fixture.ts"; + +// Step 1: order lives in the UUID prefix; a random tail avoids crashed-run collisions. +export function deliveriesFixture() { + const f = eventsFixture(); + const tail = randomBytes(6).toString("hex"); + const id = (order: number) => `019000${order.toString(16).padStart(2, "0")}-0000-7000-8000-${tail}`; + async function seed() { + const a = await f.owner(); + const endpoint = await f.endpoint(a.owner.id), otherEndpoint = await f.endpoint(a.owner.id); + const [event] = await f.sql`INSERT INTO events (owner_id, type, payload, created_at) + VALUES (${a.owner.id}, 'invoice.paid', ${f.sql.json({ invoice: { total: 42 } })}, '2026-10-25T14:03:19Z') RETURNING id`; + // Step 2: one event per endpoint pair; separate events let five deliveries share endpoints. + for (let i = 1; i <= 5; i++) { + const [ev] = i === 1 ? [event] : await f.sql` + INSERT INTO events (owner_id, type, payload) VALUES (${a.owner.id}, 'invoice.paid', '{}') RETURNING id`; + await f.sql`INSERT INTO deliveries + (id, owner_id, event_id, endpoint_id, status, attempts, next_attempt_at, last_status_code, created_at) + VALUES (${id(i)}, ${a.owner.id}, ${ev!.id}, ${i % 2 ? endpoint : otherEndpoint}, + ${i <= 2 ? "dead" : i === 3 ? "delivered" : "pending"}, 3, + '2026-10-25T14:09:30Z', 500, '2026-10-25T14:03:20Z')`; + } + return { ...a, endpoint, otherEndpoint, eventId: event!.id as string }; + } + return { ...f, id, seed }; +} diff --git a/api/test/integration/deliveries-isolation.test.ts b/api/test/integration/deliveries-isolation.test.ts new file mode 100644 index 0000000..abdb000 --- /dev/null +++ b/api/test/integration/deliveries-isolation.test.ts @@ -0,0 +1,46 @@ +// Checks that foreign ids and deliberately inconsistent joins cannot reveal another owner. +import { expect, test } from "bun:test"; +import { eventsFixture } from "./events-fixture.ts"; + +const f = eventsFixture(); +// Step 1: normalize only the per-request id when comparing otherwise identical 404 bodies. +const publicError = (res: { json(): any; headers: Record }) => { + const body = res.json(); + expect(body.error.request_id).toBe(res.headers["x-request-id"]); + return { ...body, error: { ...body.error, request_id: "request-specific" } }; +}; +test("B cannot list, filter for or read A's deliveries; malformed ids have the same 404", async () => { + const a = await f.owner(), b = await f.owner(); + const endpoint = await f.endpoint(a.owner.id); + await f.post(a.key); + const [row] = await f.sql`SELECT id FROM deliveries WHERE owner_id = ${a.owner.id}`; + expect((await f.get(b.key)).json()).toEqual({ data: [], next_cursor: null }); + expect((await f.get(b.key, `/v1/deliveries?endpoint_id=${endpoint}`)).json()) + .toEqual({ data: [], next_cursor: null }); + const foreign = await f.get(b.key, `/v1/deliveries/${row!.id}`); + const missing = await f.get(b.key, `/v1/deliveries/${crypto.randomUUID()}`); + const malformed = await f.get(b.key, "/v1/deliveries/not-a-uuid"); + for (const res of [foreign, missing, malformed]) expect(res.statusCode).toBe(404); + expect(publicError(foreign)).toEqual(publicError(missing)); + expect(publicError(malformed)).toEqual(publicError(missing)); + expect(missing.json().error.code).toBe("not_found"); + expect(missing.json().error.message).toBe("Delivery not found"); +}); +// Step 2: single-column foreign keys permit inconsistent ownership, so joins must enforce it. +test("foreign event and foreign endpoint joins are hidden in both list and detail", async () => { + const a = await f.owner(), b = await f.owner(); + const aEndpoint = await f.endpoint(a.owner.id), bEndpoint = await f.endpoint(b.owner.id); + const aEvent = (await f.post(a.key)).json().id, bEvent = (await f.post(b.key)).json().id; + const planted: string[] = []; + for (const [event, endpoint] of [[aEvent, bEndpoint], [bEvent, aEndpoint]]) { + const [row] = await f.sql`INSERT INTO deliveries (owner_id, event_id, endpoint_id) + VALUES (${b.owner.id}, ${event}, ${endpoint}) RETURNING id`; + planted.push(row!.id); + } + const list = await f.get(b.key); + expect(list.json().data).toHaveLength(1); + for (const id of planted) { + expect(list.json().data.some((d: { id: string }) => d.id === id)).toBe(false); + expect((await f.get(b.key, `/v1/deliveries/${id}`)).statusCode).toBe(404); + } +}); diff --git a/api/test/integration/deliveries.test.ts b/api/test/integration/deliveries.test.ts new file mode 100644 index 0000000..2ff6066 --- /dev/null +++ b/api/test/integration/deliveries.test.ts @@ -0,0 +1,45 @@ +// Checks keyset pages, combined filters and strict query validation through HTTP. +import { beforeAll, expect, test } from "bun:test"; +import { deliveriesFixture } from "./deliveries-fixture.ts"; + +const f = deliveriesFixture(); +let a: Awaited>; +beforeAll(async () => { a = await f.seed(); }); +// Step 1: the cursor is the last row sent, never the extra row fetched. +test("five rows page newest first as 2, 2, 1 with no gaps or duplicates", async () => { + const ids: string[] = []; + let cursor: string | null = null; + for (const expected of [[f.id(5), f.id(4)], [f.id(3), f.id(2)], [f.id(1)]]) { + const res = await f.get(a.key, `/v1/deliveries?limit=2${cursor ? `&cursor=${cursor}` : ""}`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data.map((d: { id: string }) => d.id)).toEqual(expected); + cursor = body.next_cursor; + expect(cursor).toBe(expected.length === 2 ? expected.at(-1)! : null); + ids.push(...body.data.map((d: { id: string }) => d.id)); + } + expect(new Set(ids).size).toBe(5); + const defaults = await f.get(a.key); + expect(defaults.json().data).toHaveLength(5); + expect(defaults.json().next_cursor).toBeNull(); +}); +test("status, endpoint and both filters select only the requested rows", async () => { + for (const [query, ids] of [ + ["status=dead", [f.id(2), f.id(1)]], + [`endpoint_id=${a.endpoint}`, [f.id(5), f.id(3), f.id(1)]], + [`status=dead&endpoint_id=${a.endpoint}`, [f.id(1)]], + ] as [string, string[]][]) { + const res = await f.get(a.key, `/v1/deliveries?${query}`); + expect(res.statusCode).toBe(200); + expect(res.json().data.map((d: { id: string }) => d.id)).toEqual(ids); + } +}); +// Step 2: text that Number() alone would accept must fail before SQL. +for (const query of ["limit=0", "limit=101", "limit=abc", "limit=", "limit=1e2", "limit=%205", + "status=lost", "cursor=x", "unknown=1", "endpoint_id=x"]) { + test(`invalid query ${query} gives validation_failed`, async () => { + const res = await f.get(a.key, `/v1/deliveries?${query}`); + expect(res.statusCode).toBe(422); + expect(res.json().error.code).toBe("validation_failed"); + }); +} diff --git a/api/test/integration/events-concurrency.test.ts b/api/test/integration/events-concurrency.test.ts new file mode 100644 index 0000000..19244d7 --- /dev/null +++ b/api/test/integration/events-concurrency.test.ts @@ -0,0 +1,48 @@ +// Holds event inserts at a real lock to prove concurrent retry keys save one event. +import { afterAll, expect, test } from "bun:test"; +import { eventsFixture } from "./events-fixture.ts"; +import { testDb } from "./helpers.ts"; + +// Step 1: requests, the lock holder and the observer cannot exhaust each other's pools. +const f = eventsFixture(10); +const blocker = testDb({ poolMax: 1 }), observer = testDb({ poolMax: 1 }); +afterAll(async () => { await blocker.end({ timeout: 5 }); await observer.end({ timeout: 5 }); }); +test("five locked requests with one key have one creator and four replays", async () => { + const a = await f.owner(); + await f.endpoint(a.owner.id); + const ready = Promise.withResolvers(), release = Promise.withResolvers(); + const holding = blocker.begin(async (tx) => { + await tx`LOCK TABLE events IN SHARE MODE`; + ready.resolve(); + await release.promise; + }); + holding.catch((error) => ready.reject(error)); + await ready.promise; + const responses = Promise.all(Array.from({ length: 5 }, () => f.post(a.key, undefined, "race"))); + try { + // Step 2: observe all five INSERTs waiting, rather than guessing with a fixed sleep. + const deadline = performance.now() + 5_000; + while (true) { + const [row] = await observer`SELECT count(*)::int AS count FROM pg_stat_activity + WHERE datname = current_database() AND application_name = 'relay-api' + AND wait_event_type = 'Lock' AND query LIKE '%INSERT INTO events%' + AND query LIKE '%ON CONFLICT%'`; + if (row!.count >= 5) break; + if (performance.now() >= deadline) throw new Error("Five event inserts did not reach their locks"); + await Bun.sleep(10); + } + } finally { + release.resolve(); + await holding; + await responses; + } + const answers = await responses; + expect(answers.map((r) => r.statusCode)).toEqual([202, 202, 202, 202, 202]); + for (const res of answers) expect(res.json()).toEqual(answers[0]!.json()); + expect(answers.filter((r) => r.headers["idempotent-replayed"] === undefined)).toHaveLength(1); + expect(answers.filter((r) => r.headers["idempotent-replayed"] === "true")).toHaveLength(4); + const [events] = await f.sql`SELECT count(*)::int AS count FROM events WHERE owner_id = ${a.owner.id}`; + const [deliveries] = await f.sql`SELECT count(*)::int AS count FROM deliveries WHERE owner_id = ${a.owner.id}`; + expect(events!.count).toBe(1); + expect(deliveries!.count).toBe(1); +}, 15_000); diff --git a/api/test/integration/events-cors.test.ts b/api/test/integration/events-cors.test.ts new file mode 100644 index 0000000..27d77ed --- /dev/null +++ b/api/test/integration/events-cors.test.ts @@ -0,0 +1,16 @@ +// Checks that authenticated dashboard requests can read the API response and request id. +import { expect, test } from "bun:test"; +import { eventsFixture } from "./events-fixture.ts"; + +const f = eventsFixture(); +// Step 1: use a real key so CORS is checked together with successful authentication. +test("GET me from the dashboard returns identity and exposed request id", async () => { + const a = await f.owner(); + const res = await f.app.inject({ url: "/v1/me", headers: { + origin: "https://cdn.myrelay.tech", authorization: `Bearer ${a.key}`, + } }); + expect(res.statusCode).toBe(200); + expect(res.json().owner.id).toBe(a.owner.id); + expect(res.headers["access-control-allow-origin"]).toBe("https://cdn.myrelay.tech"); + expect(res.headers["access-control-expose-headers"]).toContain("x-request-id"); +}); diff --git a/api/test/integration/events-fixture.ts b/api/test/integration/events-fixture.ts new file mode 100644 index 0000000..1e8d663 --- /dev/null +++ b/api/test/integration/events-fixture.ts @@ -0,0 +1,41 @@ +// Gives event and delivery tests isolated owners, local HTTP injection and SQL setup. +import { afterAll, beforeAll } from "bun:test"; +import { buildApp } from "../../src/app.ts"; +import { loadConfig } from "../../src/config.ts"; +import { createOwnerWithKey } from "../../src/services/owners.ts"; +import { DATABASE_URL, testDb } from "./helpers.ts"; + +// Step 1: each calling file owns its database rows and removes them even after failures. +export function eventsFixture(poolMax = 5) { + const sql = testDb({ poolMax }); + const owners: Awaited>[] = []; + const app = buildApp(loadConfig({ DATABASE_URL }), { sql }, { logger: false }); + beforeAll(() => app.ready()); + afterAll(async () => { + try { + await app.close(); + for (const a of owners) await sql`DELETE FROM owners WHERE id = ${a.owner.id}`; + } finally { await sql.end({ timeout: 5 }); } + }); + async function owner() { + const a = await createOwnerWithKey(sql, `events-${crypto.randomUUID()}`); + owners.push(a); + return a; + } + async function endpoint(ownerId: string, types = ["*"], deleted = false) { + const [row] = await sql` + INSERT INTO endpoints (owner_id, name, url, signing_secret, event_types, deleted_at) + VALUES (${ownerId}, 'Accounting webhook', 'https://example.com/hook', 'test-only', + ${sql.array(types)}, ${deleted ? new Date() : null}) RETURNING id`; + return row!.id as string; + } + // Step 2: no outbound HTTP or DNS; requests run inside Fastify. + function post(key: string, payload: unknown = { type: "invoice.paid", payload: {} }, retryKey?: string) { + return app.inject({ method: "POST", url: "/v1/events", payload: payload as object, + headers: { authorization: `Bearer ${key}`, ...(retryKey === undefined ? {} : { "idempotency-key": retryKey }) } }); + } + function get(key: string, url = "/v1/deliveries") { + return app.inject({ url, headers: { authorization: `Bearer ${key}` } }); + } + return { sql, app, owner, endpoint, post, get }; +} diff --git a/api/test/integration/events-validation.test.ts b/api/test/integration/events-validation.test.ts new file mode 100644 index 0000000..8966011 --- /dev/null +++ b/api/test/integration/events-validation.test.ts @@ -0,0 +1,58 @@ +// Checks invalid event envelopes, retry headers, JSON characters and HTTP limits. +import { beforeAll, expect, test } from "bun:test"; +import { eventsFixture } from "./events-fixture.ts"; + +const f = eventsFixture(); +let key: string; +beforeAll(async () => { key = (await f.owner()).key; }); +// Step 1: each validation failure identifies its public input field. +const cases: [string, unknown, string][] = [ + ["uppercase type", { type: "Invoice.Paid", payload: {} }, "type"], + ["empty type", { type: "", payload: {} }, "type"], + ["long type", { type: "a".repeat(101), payload: {} }, "type"], + ["array payload", { type: "test", payload: [] }, "payload"], + ["null payload", { type: "test", payload: null }, "payload"], + ["string payload", { type: "test", payload: "x" }, "payload"], + ["missing payload", { type: "test" }, "payload"], + ["unknown envelope field", { type: "test", payload: {}, extra: true }, ""], + ["NUL value", { type: "test", payload: { value: "\u0000" } }, "payload"], + ["NUL key", { type: "test", payload: { "\u0000": "x" } }, "payload"], + ["nested NUL", { type: "test", payload: { nested: [{ value: "\u0000" }] } }, "payload"], + ["high surrogate", { type: "test", payload: { value: "\ud800" } }, "payload"], + ["low surrogate", { type: "test", payload: { value: "\udc00" } }, "payload"], +]; +for (const [name, body, path] of cases) { + test(name + " gives 422 with the field path", async () => { + const res = await f.post(key, body); + expect(res.statusCode).toBe(422); + expect(res.json().error.code).toBe("validation_failed"); + expect(res.json().error.details.some((d: { path: string }) => d.path === path)).toBe(true); + }); +} +for (const retryKey of ["a".repeat(256), "bad\tkey", ""]) { + test(`invalid retry key ${JSON.stringify(retryKey)} identifies idempotency-key`, async () => { + const res = await f.post(key, undefined, retryKey); + expect(res.statusCode).toBe(422); + expect(res.json().error.code).toBe("validation_failed"); + expect(res.json().error.details[0].path).toBe("idempotency-key"); + }); +} +// Step 2: ordinary Unicode and boundary lengths remain valid. +test("emoji round-trips and a 255-character key is accepted", async () => { + const a = await f.owner(); + const res = await f.post(a.key, { type: "test", payload: { value: "😀" } }, "a".repeat(255)); + expect(res.statusCode).toBe(202); + const [row] = await f.sql`SELECT payload FROM events WHERE owner_id = ${a.owner.id} AND id = ${res.json().id}`; + expect(row!.payload).toEqual({ value: "😀" }); +}); +test("event route enforces body size, JSON media type and authentication", async () => { + const large = await f.post(key, { type: "test", payload: { value: "x".repeat(256 * 1024) } }); + expect(large.statusCode).toBe(413); + expect(large.json().error.code).toBe("payload_too_large"); + const text = await f.app.inject({ method: "POST", url: "/v1/events", payload: "hello", + headers: { authorization: `Bearer ${key}`, "content-type": "text/plain" } }); + expect(text.statusCode).toBe(415); + expect(text.json().error.code).toBe("unsupported_media_type"); + const noKey = await f.app.inject({ method: "POST", url: "/v1/events", payload: { type: "test", payload: {} } }); + expect(noKey.statusCode).toBe(401); +}); diff --git a/api/test/integration/events.test.ts b/api/test/integration/events.test.ts new file mode 100644 index 0000000..9d0d8c2 --- /dev/null +++ b/api/test/integration/events.test.ts @@ -0,0 +1,87 @@ +// Checks atomic event creation, endpoint matching and retry-key behavior through HTTP. +import { expect, test } from "bun:test"; +import { createEvent } from "../../src/services/events.ts"; +import { eventsFixture } from "./events-fixture.ts"; + +const f = eventsFixture(); +// Step 1: only this owner's live matching endpoints receive pending deliveries. +test("202 saves nested JSON and exactly the two live matching deliveries", async () => { + const a = await f.owner(), b = await f.owner(); + const wildcard = await f.endpoint(a.owner.id), exact = await f.endpoint(a.owner.id, ["invoice.paid"]); + await f.endpoint(a.owner.id, ["order.created"]); + await f.endpoint(a.owner.id, ["*"], true); + await f.endpoint(b.owner.id); + const payload = { nested: { items: [1, { paid: true }], emoji: "😀" } }; + const res = await f.post(a.key, { type: "invoice.paid", payload }); + expect(res.statusCode).toBe(202); + const body = res.json(); + expect(Object.keys(body).sort()).toEqual(["created_at", "deliveries", "id", "type"]); + expect(typeof body.deliveries).toBe("number"); + expect(body.deliveries).toBe(2); + expect(new Date(body.created_at).toISOString()).toBe(body.created_at); + const events = await f.sql`SELECT id, payload FROM events WHERE owner_id = ${a.owner.id}`; + expect(events).toHaveLength(1); + expect(events[0]!.payload).toEqual(payload); + const rows = await f.sql`SELECT endpoint_id, status, attempts FROM deliveries WHERE owner_id = ${a.owner.id}`; + expect(rows.map((r) => r.endpoint_id).sort()).toEqual([wildcard, exact].sort()); + for (const row of rows) { expect(row.status).toBe("pending"); expect(row.attempts).toBe(0); } +}); +test("no matching endpoint still saves the event with zero deliveries", async () => { + const a = await f.owner(); + await f.endpoint(a.owner.id, ["order.created"]); + expect((await f.post(a.key)).json().deliveries).toBe(0); + const [row] = await f.sql`SELECT count(*)::int AS count FROM events WHERE owner_id = ${a.owner.id}`; + expect(row!.count).toBe(1); +}); + +// Step 2: keys are owner-scoped; omitting a key always creates a new event. +test("a repeated key returns the same answer and only the second has a replay header", async () => { + const a = await f.owner(), b = await f.owner(); + const first = await f.post(a.key, undefined, "same"); + const second = await f.post(a.key, undefined, "same"); + expect(first.statusCode).toBe(202); + expect(second.statusCode).toBe(202); + expect(second.json()).toEqual(first.json()); + expect(first.headers["idempotent-replayed"]).toBeUndefined(); + expect(second.headers["idempotent-replayed"]).toBe("true"); + const other = await f.post(b.key, undefined, "same"); + expect(other.json().id).not.toBe(first.json().id); + const [row] = await f.sql`SELECT count(*)::int AS count FROM events WHERE owner_id = ${a.owner.id}`; + expect(row!.count).toBe(1); + const noKey1 = await f.post(a.key), noKey2 = await f.post(a.key); + expect(noKey1.json().id).not.toBe(noKey2.json().id); +}); +test("a lost answer and a changed body keep the original count without new fan-out", async () => { + const a = await f.owner(); + await f.endpoint(a.owner.id); + const first = await f.post(a.key, undefined, "lost"); + const lateEndpoint = await f.endpoint(a.owner.id); + const retry = await f.post(a.key, undefined, "lost"); + const changed = await f.post(a.key, { type: "order.created", payload: { changed: true } }, "lost"); + expect(first.json().deliveries).toBe(1); + expect(retry.json()).toEqual(first.json()); + expect(changed.json()).toEqual(first.json()); + expect(changed.headers["idempotent-replayed"]).toBe("true"); + const [events] = await f.sql`SELECT count(*)::int AS count FROM events WHERE owner_id = ${a.owner.id}`; + const [late] = await f.sql`SELECT count(*)::int AS count FROM deliveries + WHERE owner_id = ${a.owner.id} AND endpoint_id = ${lateEndpoint}`; + expect(events!.count).toBe(1); + expect(late!.count).toBe(0); +}); + +// Step 3: even an error after inserting deliveries rolls all writes back. +test("failed fan-out propagates its error and saves neither event nor deliveries", async () => { + const a = await f.owner(); + const endpoint = await f.endpoint(a.owner.id); + const failure = new Error("fan-out failed"); + await expect(createEvent(f.sql, a.owner.id, { type: "invoice.paid", payload: {} }, "rollback", { + fanOut: async (tx, ownerId, eventId) => { + await tx`INSERT INTO deliveries (owner_id, event_id, endpoint_id) VALUES (${ownerId}, ${eventId}, ${endpoint})`; + throw failure; + }, + })).rejects.toBe(failure); + for (const table of ["events", "deliveries"]) { + const [row] = await f.sql`SELECT count(*)::int AS count FROM ${f.sql(table)} WHERE owner_id = ${a.owner.id}`; + expect(row!.count).toBe(0); + } +}); diff --git a/docs/api.md b/docs/api.md index 53e2624..1dcc190 100644 --- a/docs/api.md +++ b/docs/api.md @@ -53,7 +53,7 @@ A 422 also has `details`, one entry per problem: Lists that can grow use a **cursor**: `?limit=50&cursor=` → `{ "data": [...], "next_cursor": "" | null }`. Pass `next_cursor` as `cursor` to get the next page. `null` means there are no more pages. -Newest first. `limit` is 1–100, default 50. +Newest first. `limit` is a whole number written in digits, 1–100, default 50. ## Types @@ -194,7 +194,7 @@ Deleting it again answers 404. Errors: 401, 404. ### `POST /v1/events` -Header (optional): `Idempotency-Key: `. +Header (optional): `Idempotency-Key: <1–255 characters, no control characters>`. Body: @@ -206,6 +206,10 @@ Body: 202 → `EventAccepted`. The event and one delivery per matching endpoint are saved in one transaction; a matching endpoint lists this exact type or `"*"`. Sending the same `Idempotency-Key` again returns the first answer again, with the header `Idempotent-Replayed: true`, and creates nothing new. +The key wins: the same key with a different body returns the first answer and saves nothing new. + +Send an `Idempotency-Key`. After a 503, a network error or a 500, retry with the same key: +you get the original answer, or the event is created now. Without a key, a retry may create a second event. Errors: 401, 413, 415, 422. ### `GET /v1/deliveries` @@ -216,7 +220,7 @@ Query: `status` (optional, a `DeliveryStatus`), `endpoint_id` (optional), `limit ### `GET /v1/deliveries/:id` -200 → `DeliveryDetail`. Errors: 401, 404. +200 → `DeliveryDetail`. A malformed delivery id also answers 404. Errors: 401, 404. ### `POST /v1/deliveries/:id/replay`