diff --git a/packages/server/package.json b/packages/server/package.json index bc74e1f..469be4a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@relayauth/server", - "version": "0.2.23", + "version": "0.2.24", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/server/src/__tests__/create-identity.test.ts b/packages/server/src/__tests__/create-identity.test.ts index 295da1d..e877c7e 100644 --- a/packages/server/src/__tests__/create-identity.test.ts +++ b/packages/server/src/__tests__/create-identity.test.ts @@ -3,6 +3,7 @@ import crypto from "node:crypto"; import test from "node:test"; import type { AgentIdentity, CreateIdentityInput, RelayAuthTokenClaims } from "@relayauth/types"; import { FixedWindowRateLimiter, RateLimitExceededError } from "../lib/rate-limit.js"; +import { StorageCapacityExhaustedError } from "../lib/storage-retry.js"; import type { IdentityBudget, StoredIdentity } from "../storage/identity-types.js"; import { assertJsonResponse, @@ -398,3 +399,37 @@ test("the global error handler preserves rate-limit errors as 429 responses", as assert.equal(response.headers.get("Retry-After"), "60"); assert.equal(response.headers.get("RateLimit-Remaining"), "0"); }); + +test("POST /v1/identities returns 503 with capacity envelope when storage.identities.create throws StorageCapacityExhaustedError", async () => { + // Verifies that StorageCapacityExhaustedError thrown from storage.identities.create() + // propagates correctly: the route catch block re-throws it (not swallowed as 500), + // and app.onError handles it via storageCapacityResponse → 503 with Retry-After. + // This is the server-side assertion that the 503 route mapping fix is correct. + const storage = createTestStorage(); + storage.identities.create = async () => { + throw new StorageCapacityExhaustedError("post-auth mint capacity exhausted (status 429)"); + }; + const app = createTestApp({}, { storage }); + + const response = await app.request( + createTestRequest( + "POST", + "/v1/identities", + { name: "capacity-probe", sponsorId: "user_sponsor_1" }, + { Authorization: `Bearer ${createAuthToken()}` }, + ), + undefined, + app.bindings, + ); + + const body = await assertJsonResponse<{ + code?: string; + retryable?: boolean; + retryAfterMs?: number; + requestId?: string; + }>(response, 503); + assert.equal(body.code, "storage_capacity_exhausted", "capacity envelope code"); + assert.equal(body.retryable, true, "capacity envelope must be retryable"); + assert.ok(response.headers.get("Retry-After"), "Retry-After header must be present"); + assert.equal(response.headers.get("Cache-Control"), "no-store", "Cache-Control must be no-store"); +}); diff --git a/packages/server/src/routes/identities.ts b/packages/server/src/routes/identities.ts index 624afee..9282a2f 100644 --- a/packages/server/src/routes/identities.ts +++ b/packages/server/src/routes/identities.ts @@ -11,6 +11,7 @@ import type { AppEnv } from "../env.js"; import { authenticateAndAuthorizeFromContext, authenticateBearerOrApiKey, authorizeClaims, decodeBase64UrlJson } from "../lib/auth.js"; import { emitObserverEvent, now as observerNow } from "../lib/events.js"; import { + isStorageCapacityExhausted, isTransientStorageOverload, isStorageOverloadedError, StorageOverloadedError, @@ -502,6 +503,10 @@ identities.post("/", async (c) => { }); return c.json(createdIdentity, 201); } catch (error) { + if (isStorageCapacityExhausted(error)) { + throw error; + } + if (isStorageOverloadedError(error)) { return storageOverloadResponse(c, error); }