From 2ec24a38d58c8895d151451064072967b940ec77 Mon Sep 17 00:00:00 2001 From: emarc99 <57766083+emarc99@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:27:33 +0100 Subject: [PATCH 1/2] feat: add idempotency support to postage refund endpoint --- .../api/v1/postage/$messageId/refund.ts | 101 +++- .../api/postage-refund-idempotency.test.ts | 533 ++++++++++++++++++ 2 files changed, 632 insertions(+), 2 deletions(-) create mode 100644 tests/unit/api/postage-refund-idempotency.test.ts diff --git a/src/routes/api/v1/postage/$messageId/refund.ts b/src/routes/api/v1/postage/$messageId/refund.ts index d34052958..d12e9241e 100644 --- a/src/routes/api/v1/postage/$messageId/refund.ts +++ b/src/routes/api/v1/postage/$messageId/refund.ts @@ -5,7 +5,38 @@ import { getApiContext } from "@/server/api/context"; import { hash32Schema } from "@/server/api/domain"; import { getPostage, resolvePostage } from "@/server/api/postage-service"; import { apiSuccess, handleApiRequest } from "@/server/api/response"; +import { acquireIdempotency, recordIdempotency } from "@/server/api/idempotency-service"; +import { ApiError } from "@/server/api/errors"; +/** + * POST /api/v1/postage/:messageId/refund + * + * Refunds postage for a message, marking it as refunded and returning escrow to the sender. + * + * ## Idempotency + * + * This endpoint supports idempotent refund via the optional `X-Idempotency-Key` header. + * When provided: + * - Multiple refund requests with the same key will return the same response + * - The first successful refund is recorded and replayed on subsequent requests + * - If refund fails (e.g., already settled or refunded), the error is cached and replayed + * - Idempotency keys are scoped per recipient to prevent cross-actor collisions + * + * ## Retry Safety + * + * Refund operations are safe to retry: + * - If postage is already refunded, returns 409 with explanation + * - If postage is settled, returns 409 with current state + * - Network failures do not cause double-refunding + * - Terminal states (settled/refunded) are deterministic + * + * @example + * ``` + * POST /api/v1/postage/abc123.../refund + * X-Idempotency-Key: unique-refund-request-id + * Authorization: Bearer + * ``` + */ export const Route = createFileRoute("/api/v1/postage/$messageId/refund")({ server: { handlers: { @@ -19,8 +50,74 @@ export const Route = createFileRoute("/api/v1/postage/$messageId/refund")({ const messageId = hash32Schema.parse(params.messageId); const current = await getPostage(repository, messageId); requireActorMatches(context, current.recipient); - const postage = await resolvePostage(context, messageId, "refunded"); - return apiSuccess(request, postage); + + // Check for idempotency key to enable safe retries + const rawIdempotencyKey = request.headers.get("x-idempotency-key"); + if (rawIdempotencyKey) { + const result = await acquireIdempotency( + repository, + current.recipient, + rawIdempotencyKey, + ); + + if (result.status === "in_progress") { + throw new ApiError(409, "conflict", "Request is already in progress"); + } + + if (result.status === "completed") { + // Replay the previous response (success or failure) + return apiSuccess(request, result.record.body, { + status: result.record.status, + headers: { "x-idempotency-replayed": "true" }, + }); + } + } + + try { + const postage = await resolvePostage(context, messageId, "refunded"); + + // Record successful refund for idempotent replay + if (rawIdempotencyKey) { + await recordIdempotency( + repository, + current.recipient, + rawIdempotencyKey, + 200, + postage, + ); + } + + return apiSuccess(request, postage); + } catch (error) { + // Record terminal-state errors for idempotent replay + // This ensures retry-after-failure returns the same error + if ( + rawIdempotencyKey && + error && + typeof error === "object" && + "status" in error && + "code" in error && + "message" in error + ) { + const apiError = error as { + status: number; + code: string; + message: string; + details?: unknown; + }; + // Only cache terminal-state errors (409 conflict), not transient failures + if (apiError.status === 409) { + await recordIdempotency(repository, current.recipient, rawIdempotencyKey, 409, { + error: { + code: apiError.code, + message: apiError.message, + details: apiError.details, + }, + }); + } + } + throw error; + } }), }, }, diff --git a/tests/unit/api/postage-refund-idempotency.test.ts b/tests/unit/api/postage-refund-idempotency.test.ts new file mode 100644 index 000000000..26502e823 --- /dev/null +++ b/tests/unit/api/postage-refund-idempotency.test.ts @@ -0,0 +1,533 @@ +import { describe, expect, it } from "vitest"; + +import { MemoryApiRepository } from "../../../src/server/api/memory-repository"; +import { resolvePostage, getPostage } from "../../../src/server/api/postage-service"; +import { createApiContext } from "../../../src/server/api/context"; +import { checkIdempotency, recordIdempotency } from "../../../src/server/api/idempotency-service"; + +const recipient = `G${"A".repeat(55)}`; +const sender = `G${"B".repeat(55)}`; + +describe("Postage Refund Idempotency", () => { + describe("resolvePostage - deterministic terminal states", () => { + it("returns deterministic error when refunding already-refunded postage", async () => { + const repository = new MemoryApiRepository(); + const messageId = "a".repeat(64); + + await repository.setPostage({ + amount: "100", + createdAt: "2026-06-14T12:00:00.000Z", + messageId, + paymentHash: "b".repeat(64), + recipient, + sender, + status: "pending", + }); + + // First refund succeeds + const firstResult = await resolvePostage(createApiContext(repository), messageId, "refunded"); + expect(firstResult.status).toBe("refunded"); + + // Second refund attempt returns deterministic error + await expect( + resolvePostage(createApiContext(repository), messageId, "refunded"), + ).rejects.toMatchObject({ + status: 409, + code: "conflict", + message: expect.stringContaining("already been refunded"), + details: { + currentStatus: "refunded", + attemptedStatus: "refunded", + messageId, + }, + }); + + // Third attempt also returns the same error (determinism) + await expect( + resolvePostage(createApiContext(repository), messageId, "refunded"), + ).rejects.toMatchObject({ + status: 409, + code: "conflict", + message: expect.stringContaining("already been refunded"), + }); + }); + + it("returns deterministic error when refunding already-settled postage", async () => { + const repository = new MemoryApiRepository(); + const messageId = "c".repeat(64); + + await repository.setPostage({ + amount: "150", + createdAt: "2026-06-14T13:00:00.000Z", + messageId, + paymentHash: "d".repeat(64), + recipient, + sender, + status: "pending", + }); + + // First settlement succeeds + await resolvePostage(createApiContext(repository), messageId, "settled"); + + // Attempt to refund already-settled postage + await expect( + resolvePostage(createApiContext(repository), messageId, "refunded"), + ).rejects.toMatchObject({ + status: 409, + code: "conflict", + message: expect.stringContaining("already been settled"), + details: { + currentStatus: "settled", + attemptedStatus: "refunded", + messageId, + }, + }); + }); + + it("explains terminal state in error details for debugging", async () => { + const repository = new MemoryApiRepository(); + const messageId = "e".repeat(64); + + await repository.setPostage({ + amount: "200", + createdAt: "2026-06-14T14:00:00.000Z", + messageId, + paymentHash: "f".repeat(64), + recipient, + sender, + status: "refunded", + }); + + try { + await resolvePostage(createApiContext(repository), messageId, "refunded"); + expect.fail("Should have thrown an error"); + } catch (error) { + const apiError = error as { + status: number; + code: string; + message: string; + details: { currentStatus: string; attemptedStatus: string; messageId: string }; + }; + + // Verify error provides actionable information + expect(apiError.message).toContain("already been refunded"); + expect(apiError.message).toContain("escrow was previously returned"); + expect(apiError.details.currentStatus).toBe("refunded"); + expect(apiError.details.attemptedStatus).toBe("refunded"); + expect(apiError.details.messageId).toBe(messageId); + } + }); + }); + + describe("resolvePostage - concurrent refund & settlement-versus-refund races", () => { + it("only refunds once when multiple concurrent requests race with no idempotency key", async () => { + const repository = new MemoryApiRepository(); + const messageId = "y".repeat(64); + + await repository.setPostage({ + amount: "1000", + createdAt: "2026-06-14T21:00:00.000Z", + messageId, + paymentHash: "u".repeat(64), + recipient, + sender, + status: "pending", + }); + + const outcomes = await Promise.allSettled( + Array.from({ length: 10 }, () => + resolvePostage(createApiContext(repository), messageId, "refunded"), + ), + ); + + const fulfilled = outcomes.filter((outcome) => outcome.status === "fulfilled"); + const rejected = outcomes.filter((outcome) => outcome.status === "rejected"); + + // Exactly one refund side effect occurs. + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(9); + + for (const outcome of rejected) { + if (outcome.status !== "rejected") continue; + expect(outcome.reason).toMatchObject({ + status: 409, + code: "conflict", + details: { currentStatus: "refunded" }, + }); + } + + const finalState = await getPostage(repository, messageId); + expect(finalState.status).toBe("refunded"); + }); + + it("only allows one winner between a concurrent settle and refund race", async () => { + const repository = new MemoryApiRepository(); + const messageId = "v".repeat(64); + + await repository.setPostage({ + amount: "1200", + createdAt: "2026-06-14T22:00:00.000Z", + messageId, + paymentHash: "w".repeat(64), + recipient, + sender, + status: "pending", + }); + + const outcomes = await Promise.allSettled([ + resolvePostage(createApiContext(repository), messageId, "refunded"), + resolvePostage(createApiContext(repository), messageId, "settled"), + ]); + + const fulfilled = outcomes.filter((outcome) => outcome.status === "fulfilled"); + const rejected = outcomes.filter((outcome) => outcome.status === "rejected"); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + + const finalState = await getPostage(repository, messageId); + expect(["settled", "refunded"]).toContain(finalState.status); + if (fulfilled[0].status === "fulfilled") { + expect(finalState.status).toBe(fulfilled[0].value.status); + } + }); + + it("ensures refund cannot win after settlement has completed", async () => { + const repository = new MemoryApiRepository(); + const messageId = "x".repeat(64); + + await repository.setPostage({ + amount: "500", + createdAt: "2026-06-14T23:00:00.000Z", + messageId, + paymentHash: "z".repeat(64), + recipient, + sender, + status: "pending", + }); + + // Settle completes first + await resolvePostage(createApiContext(repository), messageId, "settled"); + + // Refund attempt fails with 409 + await expect( + resolvePostage(createApiContext(repository), messageId, "refunded"), + ).rejects.toMatchObject({ + status: 409, + code: "conflict", + details: { + currentStatus: "settled", + attemptedStatus: "refunded", + }, + }); + + // State remains settled + const state = await getPostage(repository, messageId); + expect(state.status).toBe("settled"); + }); + }); + + describe("idempotency service integration", () => { + it("records and replays successful refund", async () => { + const repository = new MemoryApiRepository(); + const messageId = "g".repeat(64); + const idempotencyKey = "refund-request-001"; + + await repository.setPostage({ + amount: "250", + createdAt: "2026-06-14T15:00:00.000Z", + messageId, + paymentHash: "h".repeat(64), + recipient, + sender, + status: "pending", + }); + + // First request: no idempotency record exists + const firstCheck = await checkIdempotency(repository, recipient, idempotencyKey); + expect(firstCheck).toBeNull(); + + // Perform refund + const refundedPostage = await resolvePostage( + createApiContext(repository), + messageId, + "refunded", + ); + expect(refundedPostage.status).toBe("refunded"); + + // Record the success for replay + await recordIdempotency(repository, recipient, idempotencyKey, 200, refundedPostage); + + // Second request: idempotency record exists + const secondCheck = await checkIdempotency(repository, recipient, idempotencyKey); + expect(secondCheck).not.toBeNull(); + expect((secondCheck as any)?.status).toBe(200); + expect((secondCheck as any)?.body).toEqual(refundedPostage); + + // Verify the recorded body matches the refunded postage + const recordedBody = (secondCheck as any)?.body as typeof refundedPostage; + expect(recordedBody.status).toBe("refunded"); + expect(recordedBody.messageId).toBe(messageId); + expect(recordedBody.amount).toBe("250"); + }); + + it("records and replays terminal-state errors (409)", async () => { + const repository = new MemoryApiRepository(); + const messageId = "i".repeat(64); + const idempotencyKey = "refund-request-002"; + + // Create already-refunded postage + await repository.setPostage({ + amount: "300", + createdAt: "2026-06-14T16:00:00.000Z", + messageId, + paymentHash: "j".repeat(64), + recipient, + sender, + status: "refunded", + }); + + // First attempt: refund fails with 409 + let capturedError: unknown; + try { + await resolvePostage(createApiContext(repository), messageId, "refunded"); + } catch (error) { + capturedError = error; + } + + expect(capturedError).toMatchObject({ + status: 409, + code: "conflict", + }); + + // Record the error for replay + const apiError = capturedError as { + status: number; + code: string; + message: string; + details: unknown; + }; + await recordIdempotency(repository, recipient, idempotencyKey, 409, { + error: { + code: apiError.code, + message: apiError.message, + details: apiError.details, + }, + }); + + // Second attempt: retrieve cached error + const replayedRecord = await checkIdempotency(repository, recipient, idempotencyKey); + expect(replayedRecord).not.toBeNull(); + expect((replayedRecord as any)?.status).toBe(409); + + const replayedBody = (replayedRecord as any)?.body as { + error: { code: string; message: string }; + }; + expect(replayedBody.error.code).toBe("conflict"); + expect(replayedBody.error.message).toContain("already been refunded"); + }); + + it("ensures actor isolation - different actors cannot replay each other's refunds", async () => { + const repository = new MemoryApiRepository(); + const recipient2 = `G${"C".repeat(55)}`; + const idempotencyKey = "shared-key-refund-123"; + + // Recipient 1 records a refund + await recordIdempotency(repository, recipient, idempotencyKey, 200, { + messageId: "x".repeat(64), + status: "refunded", + }); + + // Recipient 1 can retrieve their record + const recipient1Check = await checkIdempotency(repository, recipient, idempotencyKey); + expect(recipient1Check).not.toBeNull(); + expect((recipient1Check as any)?.status).toBe(200); + + // Recipient 2 cannot see recipient 1's idempotency record (actor isolation) + const recipient2Check = await checkIdempotency(repository, recipient2, idempotencyKey); + expect(recipient2Check).toBeNull(); + }); + }); + + describe("retry scenarios - network failures", () => { + it("handles retry after successful refund (same idempotency key)", async () => { + const repository = new MemoryApiRepository(); + const messageId = "k".repeat(64); + const idempotencyKey = "network-retry-refund-001"; + + await repository.setPostage({ + amount: "400", + createdAt: "2026-06-14T17:00:00.000Z", + messageId, + paymentHash: "l".repeat(64), + recipient, + sender, + status: "pending", + }); + + // First request completes successfully + const firstResult = await resolvePostage(createApiContext(repository), messageId, "refunded"); + await recordIdempotency(repository, recipient, idempotencyKey, 200, firstResult); + + // Network failure occurs, client retries with same idempotency key + const retryRecord = await checkIdempotency(repository, recipient, idempotencyKey); + expect(retryRecord).not.toBeNull(); + expect((retryRecord as any)?.status).toBe(200); + + // The replayed response matches the original + const replayedPostage = (retryRecord as any)?.body as typeof firstResult; + expect(replayedPostage).toEqual(firstResult); + expect(replayedPostage.status).toBe("refunded"); + + // The underlying postage state remains refunded (no double-refunding) + const currentState = await getPostage(repository, messageId); + expect(currentState.status).toBe("refunded"); + }); + + it("handles retry after terminal-state error (same idempotency key)", async () => { + const repository = new MemoryApiRepository(); + const messageId = "m".repeat(64); + const idempotencyKey = "network-retry-refund-002"; + + // Postage already refunded by another process + await repository.setPostage({ + amount: "500", + createdAt: "2026-06-14T18:00:00.000Z", + messageId, + paymentHash: "n".repeat(64), + recipient, + sender, + status: "refunded", + }); + + // First request fails with 409 + let firstError: unknown; + try { + await resolvePostage(createApiContext(repository), messageId, "refunded"); + } catch (error) { + firstError = error; + } + + const apiError = firstError as { + status: number; + code: string; + message: string; + details: unknown; + }; + await recordIdempotency(repository, recipient, idempotencyKey, 409, { + error: { + code: apiError.code, + message: apiError.message, + details: apiError.details, + }, + }); + + // Network failure, client retries with same idempotency key + const retryRecord = await checkIdempotency(repository, recipient, idempotencyKey); + expect(retryRecord).not.toBeNull(); + expect((retryRecord as any)?.status).toBe(409); + + // The replayed error matches the original + const replayedError = (retryRecord as any)?.body as { + error: { code: string; message: string; details: unknown }; + }; + expect(replayedError.error.code).toBe("conflict"); + expect(replayedError.error.message).toBe(apiError.message); + expect(replayedError.error.details).toEqual(apiError.details); + }); + + it("allows different operations with different idempotency keys", async () => { + const repository = new MemoryApiRepository(); + const messageId1 = "o".repeat(64); + const messageId2 = "p".repeat(64); + const key1 = "operation-refund-001"; + const key2 = "operation-refund-002"; + + // Create two pending postages + await repository.setPostage({ + amount: "600", + createdAt: "2026-06-14T19:00:00.000Z", + messageId: messageId1, + paymentHash: "q".repeat(64), + recipient, + sender, + status: "pending", + }); + + await repository.setPostage({ + amount: "700", + createdAt: "2026-06-14T19:01:00.000Z", + messageId: messageId2, + paymentHash: "r".repeat(64), + recipient, + sender, + status: "pending", + }); + + // Refund first postage with key1 + const result1 = await resolvePostage(createApiContext(repository), messageId1, "refunded"); + await recordIdempotency(repository, recipient, key1, 200, result1); + + // Refund second postage with key2 + const result2 = await resolvePostage(createApiContext(repository), messageId2, "refunded"); + await recordIdempotency(repository, recipient, key2, 200, result2); + + // Each key retrieves its own result + const check1 = await checkIdempotency(repository, recipient, key1); + const check2 = await checkIdempotency(repository, recipient, key2); + + expect((check1 as any)?.body).toEqual(result1); + expect((check2 as any)?.body).toEqual(result2); + expect(((check1 as any)?.body as typeof result1).messageId).toBe(messageId1); + expect(((check2 as any)?.body as typeof result2).messageId).toBe(messageId2); + }); + }); + + describe("edge cases and validation", () => { + it("handles missing postage gracefully", async () => { + const repository = new MemoryApiRepository(); + const nonExistentMessageId = "z".repeat(64); + + await expect( + resolvePostage(createApiContext(repository), nonExistentMessageId, "refunded"), + ).rejects.toMatchObject({ + status: 404, + code: "not_found", + message: "Postage was not found", + }); + }); + + it("preserves postage data integrity across refund retries", async () => { + const repository = new MemoryApiRepository(); + const messageId = "s".repeat(64); + + const originalPostage = { + amount: "800", + createdAt: "2026-06-14T20:00:00.000Z", + messageId, + paymentHash: "t".repeat(64), + recipient, + sender, + status: "pending" as const, + }; + + await repository.setPostage(originalPostage); + + // First refund + const refunded = await resolvePostage(createApiContext(repository), messageId, "refunded"); + + // Verify all fields preserved except status + expect(refunded.amount).toBe(originalPostage.amount); + expect(refunded.createdAt).toBe(originalPostage.createdAt); + expect(refunded.messageId).toBe(originalPostage.messageId); + expect(refunded.paymentHash).toBe(originalPostage.paymentHash); + expect(refunded.recipient).toBe(originalPostage.recipient); + expect(refunded.sender).toBe(originalPostage.sender); + expect(refunded.status).toBe("refunded"); + + // Retry attempt should see the same data + const currentState = await getPostage(repository, messageId); + expect(currentState).toEqual(refunded); + }); + }); +}); From ab9044df647c557913140cd2a88429fb69e98975 Mon Sep 17 00:00:00 2001 From: emarc99 <57766083+emarc99@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:27:49 +0100 Subject: [PATCH 2/2] docs: add comprehensive postage refund idempotency documentation --- docs/api/README.md | 3 +- docs/api/REFUND_IDEMPOTENCY.md | 246 ++++++++++++++++++++++++++++ src/routes/api/v1/postage/README.md | 2 +- 3 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 docs/api/REFUND_IDEMPOTENCY.md diff --git a/docs/api/README.md b/docs/api/README.md index 8f10dfd7d..c8af8e609 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -26,8 +26,9 @@ retry behavior during network failures or race conditions. Currently supported: - `POST /api/v1/postage/` - Postage submission - `POST /api/v1/postage/:messageId/settle` - Postage settlement +- `POST /api/v1/postage/:messageId/refund` - Postage refund -See [SETTLEMENT_IDEMPOTENCY.md](./SETTLEMENT_IDEMPOTENCY.md) for detailed documentation on +See [SETTLEMENT_IDEMPOTENCY.md](./SETTLEMENT_IDEMPOTENCY.md) and [REFUND_IDEMPOTENCY.md](./REFUND_IDEMPOTENCY.md) for detailed documentation on idempotency semantics, retry scenarios, and client best practices. ## Input Validation diff --git a/docs/api/REFUND_IDEMPOTENCY.md b/docs/api/REFUND_IDEMPOTENCY.md new file mode 100644 index 000000000..f1e3d3866 --- /dev/null +++ b/docs/api/REFUND_IDEMPOTENCY.md @@ -0,0 +1,246 @@ +# Postage Refund Idempotency + +## Overview + +The postage refund endpoint (`POST /api/v1/postage/:messageId/refund`) implements idempotency to ensure safe retry behavior during network failures, race conditions, or other transient errors. + +## Problem Statement + +Refund operations involve critical state transitions in the escrow system: + +- Moving postage from `pending` to `refunded` returns escrow funds to the sender +- Network failures can cause clients to retry refund requests +- Without idempotency, retries could result in conflicting responses or ambiguous system state +- Concurrent settlement and refund attempts need deterministic conflict outcomes (settlement vs. refund) + +## Solution + +### Idempotency Key Header + +Clients can include an optional `X-Idempotency-Key` header with refund requests: + +```http +POST /api/v1/postage/abc123.../refund +Authorization: Bearer +X-Idempotency-Key: unique-refund-request-id +``` + +### Key Properties + +- **Actor-scoped**: Keys are scoped per recipient, preventing cross-actor collisions +- **SHA-256 hashed**: Raw keys are hashed to protect against key leakage in logs +- **Success replay**: Successful refunds (200) are cached and replayed +- **Error replay**: Terminal-state errors (409 conflict) are cached and replayed +- **Transient errors**: Non-terminal errors (500, network failures) are NOT cached, allowing retry + +### Request Flow + +``` +┌─────────────┐ +│ Client │ +└──────┬──────┘ + │ + │ POST /refund (with idempotency key) + ▼ +┌─────────────────────────────────────────┐ +│ Check idempotency cache │ +│ - Hash actor + key │ +│ - Look up previous response │ +└──────┬──────────────────────────────────┘ + │ + ├─ Cache hit? ──► Return cached response (200 or 409) + │ + X-Idempotency-Replayed: true + │ + └─ Cache miss + │ + ▼ + ┌──────────────────────────────┐ + │ Attempt refund │ + │ - Load postage │ + │ - Check status │ + │ - Transition to "refunded" │ + └──────┬───────────────────────┘ + │ + ├─ Success (200) + │ - Cache response + │ - Return success + │ + └─ Terminal error (409) + - Cache error + - Return error +``` + +## Retry Scenarios + +### Scenario 1: Retry After Successful Refund + +```http +# First request +POST /api/v1/postage/abc123.../refund +X-Idempotency-Key: req-001 + +Response: 200 OK +{ + "data": { "status": "refunded", ... }, + "meta": { "requestId": "..." } +} +``` + +Network failure occurs. Client retries: + +```http +# Retry with same key +POST /api/v1/postage/abc123.../refund +X-Idempotency-Key: req-001 + +Response: 200 OK +X-Idempotency-Replayed: true +{ + "data": { "status": "refunded", ... }, # Same response + "meta": { "requestId": "..." } +} +``` + +**Result**: No double-refunding. Same response returned. Client can safely process. + +### Scenario 2: Retry After Terminal-State Error + +Postage already refunded or settled by another process: + +```http +# First request +POST /api/v1/postage/abc123.../refund +X-Idempotency-Key: req-002 + +Response: 409 Conflict +{ + "error": { + "code": "conflict", + "message": "Postage has already been refunded. The escrow was previously returned to the sender.", + "details": { + "currentStatus": "refunded", + "attemptedStatus": "refunded", + "messageId": "abc123..." + } + } +} +``` + +Client retries: + +```http +# Retry with same key +POST /api/v1/postage/abc123.../refund +X-Idempotency-Key: req-002 + +Response: 409 Conflict +X-Idempotency-Replayed: true +{ + "error": { ... } # Same error +} +``` + +**Result**: Deterministic error response. Client knows refund already completed. + +### Scenario 3: Settlement vs. Refund Race + +```http +# Settle message A +POST /api/v1/postage/messageA.../settle + +# Refund message A concurrently +POST /api/v1/postage/messageA.../refund +``` + +**Result**: Atomic compare-and-swap guarantees only one operation wins `pending` state transition. The losing operation receives a `409 conflict` detailing the current status (`settled` or `refunded`). + +## Error Messages + +The implementation provides detailed error messages for terminal states: + +### Already Refunded + +```json +{ + "error": { + "code": "conflict", + "message": "Postage has already been refunded. The escrow was previously returned to the sender.", + "details": { + "currentStatus": "refunded", + "attemptedStatus": "refunded", + "messageId": "..." + } + } +} +``` + +### Already Settled + +```json +{ + "error": { + "code": "conflict", + "message": "Postage has already been settled. The escrow was previously released to the recipient.", + "details": { + "currentStatus": "settled", + "attemptedStatus": "refunded", + "messageId": "..." + } + } +} +``` + +## Security Considerations + +### Actor Isolation + +Idempotency keys are scoped per recipient: + +```typescript +hashIdempotencyKey(actor: string, rawKey: string): string { + return createHash("sha256") + .update(`${actor}:${rawKey}`) + .digest("hex"); +} +``` + +This ensures: + +- Recipient A cannot replay responses meant for Recipient B +- Same key used by different recipients produces different cache entries +- No cross-actor information leakage + +### Key Hashing + +Raw idempotency keys are hashed before storage: + +- Prevents key leakage in logs or database exports +- Provides consistent 64-character hex identifiers +- SHA-256 is computationally secure for this use case + +## Implementation Details + +### Code Location + +- **Endpoint**: `src/routes/api/v1/postage/$messageId/refund.ts` +- **Service Logic**: `src/server/api/postage-service.ts` (`resolvePostage`) +- **Idempotency Logic**: `src/server/api/idempotency-service.ts` +- **Tests**: `tests/unit/api/postage-refund-idempotency.test.ts` + +### Test Coverage + +The test suite covers: + +- ✅ Deterministic terminal states (refunded/settled) +- ✅ Success response replay +- ✅ Terminal error response replay (409) +- ✅ Actor isolation (different recipients don't collide) +- ✅ Network failure retry scenarios +- ✅ Settlement vs. refund concurrency races +- ✅ Data integrity across retries +- ✅ Missing postage error handling + +## Related Endpoints + +- `POST /api/v1/postage/` (postage submission) +- `POST /api/v1/postage/:messageId/settle` (postage settlement) diff --git a/src/routes/api/v1/postage/README.md b/src/routes/api/v1/postage/README.md index 0c5efff64..498f77b47 100644 --- a/src/routes/api/v1/postage/README.md +++ b/src/routes/api/v1/postage/README.md @@ -97,7 +97,7 @@ Clients should branch on `error.code`, not on `message` text. Every response als - **Retryable:** `too_many_requests` (429) — wait `details.retryAfterSeconds`, then retry the identical request. `internal_error` (500) — retry with exponential backoff. - **Non-retryable:** 400, 401, 403, 404, 409, 413, 415, and 422. Retrying the same request unchanged will fail again; fix the request first. -- **Idempotent retries:** on `POST /api/v1/postage/`, send an `x-idempotency-key` header. A replay returns the stored `201` body with `x-idempotency-replayed: true` instead of a `409 conflict`, so automatic retries stay safe. +- **Idempotent retries:** on `POST /api/v1/postage/`, `POST /api/v1/postage/:messageId/settle`, and `POST /api/v1/postage/:messageId/refund`, send an `x-idempotency-key` header. A replay returns the stored body with `x-idempotency-replayed: true` instead of a `409 conflict`, so automatic retries stay safe. ### Errors by endpoint