diff --git a/README.md b/README.md index 88739f3..fec87d6 100644 --- a/README.md +++ b/README.md @@ -10,24 +10,24 @@ npm (or yarn/pnpm) Setup Bash -# Clone the repo (or use your fork) +Clone the repo (or use your fork) git clone cd anchornet-backend -# Install dependencies +Install dependencies npm install -# Run in development +Run in development npm run dev Server runs at http://localhost:3001 by default. Set PORT to override. Scripts -Command Description -npm run dev Start dev server with hot reload -npm run build Compile TypeScript to dist/ -npm start Run production build -npm test Run tests (Jest) -npm run lint Run ESLint +Command Description +npm run dev Start dev server with hot reload +npm run build Compile TypeScript to dist/ +npm start Run production build +npm test Run tests (Jest) +npm run lint Run ESLint API Service GET /health – health check @@ -67,7 +67,15 @@ POST /api/v1/anchors – register an anchor { id, name? } (409 if it exists) POST /api/v1/anchors/bulk – register a batch of anchors atomically { anchors: [{ id, name? }, ...] }; validates and checks every entry (against both the existing registry and duplicates within the batch) before storing -any of them, so one bad entry never leaves a partial batch registered +any of them, so one bad entry never leaves a partial batch registered. +Supports ?dryRun=true for a read-only preflight check: the batch runs +through the identical validation and returns the same success/error +outcome (201 with the would-be-registered anchors, or the same 400/409), +but nothing is persisted. Successful responses include a dryRun boolean +so callers can confirm whether the batch was committed. The flag is +strictly parsed — only "true" or "false" (any casing) is accepted, and +any other value is a 400, so a typo can never silently perform a real +registration GET /api/v1/anchors – list anchors; supports ?status=active or ?status=inactive (400 for any other value), a free-text ?q= search over id/name (case-insensitive substring match), ?sort=id|name|registeredAt @@ -128,10 +136,10 @@ Initial request: Send a POST request to register an anchor with a unique Idempot Bash -curl -i -X POST http://localhost:3001/api/v1/anchors \ - -H "Content-Type: application/json" \ - -H "Idempotency-Key: register-anchor-xyz" \ - -d '{"id": "anchor-xyz", "name": "Anchor XYZ"}' +curl -i -X POST http://localhost:3001/api/v1/anchors +-H "Content-Type: application/json" +-H "Idempotency-Key: register-anchor-xyz" +-d '{"id": "anchor-xyz", "name": "Anchor XYZ"}' Response: http @@ -141,19 +149,19 @@ Content-Type: application/json; charset=utf-8 x-request-id: df743737-896c-4e4f-8dae-1c08a95302cd { - "id": "anchor-xyz", - "name": "Anchor XYZ", - "registeredAt": "2026-07-22T14:17:57.537Z", - "active": true +"id": "anchor-xyz", +"name": "Anchor XYZ", +"registeredAt": "2026-07-22T14:17:57.537Z", +"active": true } Subsequent replay: Send the exact same request again using the same Idempotency-Key. The server returns the cached 201 response immediately, bypassing the normal handler and avoiding a 409 (which would normally happen for duplicate anchor registration): Bash -curl -i -X POST http://localhost:3001/api/v1/anchors \ - -H "Content-Type: application/json" \ - -H "Idempotency-Key: register-anchor-xyz" \ - -d '{"id": "anchor-xyz", "name": "Anchor XYZ"}' +curl -i -X POST http://localhost:3001/api/v1/anchors +-H "Content-Type: application/json" +-H "Idempotency-Key: register-anchor-xyz" +-d '{"id": "anchor-xyz", "name": "Anchor XYZ"}' Response (Cached): http @@ -163,19 +171,19 @@ Content-Type: application/json; charset=utf-8 x-request-id: 4a123f52-1623-429b-ba67-3d0d0d5c2eb0 { - "id": "anchor-xyz", - "name": "Anchor XYZ", - "registeredAt": "2026-07-22T14:17:57.537Z", - "active": true +"id": "anchor-xyz", +"name": "Anchor XYZ", +"registeredAt": "2026-07-22T14:17:57.537Z", +"active": true } Mismatched body (Known Gap): If you reuse the same Idempotency-Key but change the request payload (e.g., modifying the name field), the server will still return the cached 201 response corresponding to the first payload. Detecting mismatched request bodies (which would ideally return a 422 error) is currently a known gap in this system. Bash -curl -i -X POST http://localhost:3001/api/v1/anchors \ - -H "Content-Type: application/json" \ - -H "Idempotency-Key: register-anchor-xyz" \ - -d '{"id": "anchor-xyz", "name": "Anchor XYZ Modified Name"}' +curl -i -X POST http://localhost:3001/api/v1/anchors +-H "Content-Type: application/json" +-H "Idempotency-Key: register-anchor-xyz" +-d '{"id": "anchor-xyz", "name": "Anchor XYZ Modified Name"}' Response (Replayed from the original cached version): http @@ -185,10 +193,10 @@ Content-Type: application/json; charset=utf-8 x-request-id: 184c8357-3fc3-4e2f-a87c-19042ab804fe { - "id": "anchor-xyz", - "name": "Anchor XYZ", - "registeredAt": "2026-07-22T14:17:57.537Z", - "active": true +"id": "anchor-xyz", +"name": "Anchor XYZ", +"registeredAt": "2026-07-22T14:17:57.537Z", +"active": true } The process shuts down gracefully on SIGTERM/SIGINT: it stops accepting new connections, closes the HTTP server, marks /health/ready unready, and @@ -204,27 +212,27 @@ operators can pause writes without taking the whole API down. Configuration The application is configured using environment variables. Every environment variable read by config.ts is listed below with its default and valid range/format: -Variable Default Valid Range / Format Description -PORT 3001 Positive integer (typically 1 - 65535) HTTP port the server binds to. Non-numeric values fall back to default. -FEE_BPS 10 Integer between 0 and 10000 (inclusive) Protocol fee in basis points applied to settlements and quotes. The process throws an error and fails to start if configured outside this range. -API_KEY (Unset) Any non-empty string If set, mutating requests (POST/PUT/PATCH/DELETE) must send an matching x-api-key header. Whitespace-only values are treated as unset. -CORS_ORIGIN (Unset) Comma-separated list of origin URLs Allowed CORS origins. Whitespace around entries is trimmed; empty entries are ignored. If unset, every origin is permitted. -BODY_LIMIT 100kb Express bytes-compatible string (e.g., "500kb", "2mb") Maximum accepted JSON request body size. Default is applied if value is blank. -MAINTENANCE_MODE false "1", "true" (case-insensitive) to enable When enabled, mutating requests are rejected with a 503 Service Unavailable error, while read requests continue to function normally. -NODE_ENV development Any environment name string (e.g., "development", "production", "test") Specifies the runtime environment name. -METRICS_SNAPSHOT_INTERVAL_MS (Unset) Positive integer Optional interval in milliseconds to automatically take metrics snapshots. -IDEMPOTENCY_TTL_MS 86400000 (24h) Positive integer Milliseconds that a cached response remains eligible for idempotency replay. -RATE_LIMIT_MAX 30 Positive integer Maximum mutating requests allowed per client within the rolling rate-limiting window. -RATE_LIMIT_WINDOW_MS 60000 (1 min) Positive integer Length of the rolling rate-limiting window, in milliseconds. +Variable Default Valid Range / Format Description +PORT 3001 Positive integer (typically 1 - 65535) HTTP port the server binds to. Non-numeric values fall back to default. +FEE_BPS 10 Integer between 0 and 10000 (inclusive) Protocol fee in basis points applied to settlements and quotes. The process throws an error and fails to start if configured outside this range. +API_KEY (Unset) Any non-empty string If set, mutating requests (POST/PUT/PATCH/DELETE) must send an matching x-api-key header. Whitespace-only values are treated as unset. +CORS_ORIGIN (Unset) Comma-separated list of origin URLs Allowed CORS origins. Whitespace around entries is trimmed; empty entries are ignored. If unset, every origin is permitted. +BODY_LIMIT 100kb Express bytes-compatible string (e.g., "500kb", "2mb") Maximum accepted JSON request body size. Default is applied if value is blank. +MAINTENANCE_MODE false "1", "true" (case-insensitive) to enable When enabled, mutating requests are rejected with a 503 Service Unavailable error, while read requests continue to function normally. +NODE_ENV development Any environment name string (e.g., "development", "production", "test") Specifies the runtime environment name. +METRICS_SNAPSHOT_INTERVAL_MS (Unset) Positive integer Optional interval in milliseconds to automatically take metrics snapshots. +IDEMPOTENCY_TTL_MS 86400000 (24h) Positive integer Milliseconds that a cached response remains eligible for idempotency replay. +RATE_LIMIT_MAX 30 Positive integer Maximum mutating requests allowed per client within the rolling rate-limiting window. +RATE_LIMIT_WINDOW_MS 60000 (1 min) Positive integer Length of the rolling rate-limiting window, in milliseconds. Architecture text -routes/ HTTP layer (thin controllers) -services/ business rules (liquidity, quotes, anchors, settlements) -repositories/ in-memory stores (swappable for an indexer) -middleware/ request id, logging, API-key auth, rate limiting, error handling -models/ domain types -config.ts env-based configuration +routes/ HTTP layer (thin controllers) +services/ business rules (liquidity, quotes, anchors, settlements) +repositories/ in-memory stores (swappable for an indexer) +middleware/ request id, logging, API-key auth, rate limiting, error handling +models/ domain types +config.ts env-based configuration Contributing Fork the repo and create a branch from main. Install deps: npm install. Run tests: npm test; lint: npm run lint. diff --git a/src/openapi.test.ts b/src/openapi.test.ts index e2dbcad..6f1e13e 100644 --- a/src/openapi.test.ts +++ b/src/openapi.test.ts @@ -35,4 +35,17 @@ describe("openapi spec", () => { res.body.paths["/api/v1/liquidity/{anchor}/{asset}"].delete, ).toBeDefined(); }); + + it("documents the dryRun preflight parameter on POST /api/v1/anchors/bulk", () => { + const spec = buildOpenApiSpec() as { + paths: Record< + string, + { post: { parameters?: string[]; description?: string } } + >; + }; + const operation = spec.paths["/api/v1/anchors/bulk"].post; + + expect(operation.parameters).toEqual(expect.arrayContaining(["dryRun"])); + expect(operation.description).toContain("dryRun=true"); + }); }); diff --git a/src/openapi.ts b/src/openapi.ts index 436c469..6df1f57 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -114,7 +114,18 @@ export function buildOpenApiSpec(): Record { post: { summary: "Reactivate a previously deactivated anchor" }, }, "/api/v1/anchors/bulk": { - post: { summary: "Register a batch of anchors atomically" }, + post: { + summary: "Register a batch of anchors atomically", + description: + "Validates every entry (against both the existing registry and " + + "duplicate ids within the batch) before storing any of them. " + + "Pass ?dryRun=true to run that identical validation as a " + + "read-only preflight check: the response reports the same " + + "success/error outcome and the would-be-registered anchors, but " + + 'nothing is persisted. `dryRun` accepts only "true" or ' + + '"false"; any other value is a 400.', + parameters: ["dryRun"], + }, }, "/api/v1/anchors/{id}/settlements": { get: { diff --git a/src/routes/anchors.test.ts b/src/routes/anchors.test.ts index fcf6f6b..50d038a 100644 --- a/src/routes/anchors.test.ts +++ b/src/routes/anchors.test.ts @@ -50,9 +50,7 @@ describe("anchor routes", () => { await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); await request(app).delete("/api/v1/anchors/anchorA"); - const res = await request(app).post( - "/api/v1/anchors/anchorA/reactivate", - ); + const res = await request(app).post("/api/v1/anchors/anchorA/reactivate"); expect(res.status).toBe(200); expect(res.body.active).toBe(true); }); @@ -68,9 +66,9 @@ describe("anchor routes", () => { expect(auditRes.status).toBe(200); const entries = auditRes.body.entries; - + const deactivateEntry = entries.find( - (e: any) => e.method === "DELETE" && e.path === "/api/v1/anchors/anchorA" + (e: any) => e.method === "DELETE" && e.path === "/api/v1/anchors/anchorA", ); expect(deactivateEntry).toBeDefined(); expect(deactivateEntry).toMatchObject({ @@ -82,7 +80,8 @@ describe("anchor routes", () => { expect(deactivateEntry).toHaveProperty("timestamp"); const reactivateEntry = entries.find( - (e: any) => e.method === "POST" && e.path === "/api/v1/anchors/anchorA/reactivate" + (e: any) => + e.method === "POST" && e.path === "/api/v1/anchors/anchorA/reactivate", ); expect(reactivateEntry).toBeDefined(); expect(reactivateEntry).toMatchObject({ @@ -173,9 +172,7 @@ describe("anchor routes", () => { "anchorA", ]); - const inactive = await request(app).get( - "/api/v1/anchors?status=inactive", - ); + const inactive = await request(app).get("/api/v1/anchors?status=inactive"); expect(inactive.body.anchors.map((a: { id: string }) => a.id)).toEqual([ "anchorB", ]); @@ -194,9 +191,7 @@ describe("anchor routes", () => { await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); await request(app).post("/api/v1/anchors").send({ id: "anchorB" }); - const res = await request(app).get( - "/api/v1/anchors?sort=id&order=desc", - ); + const res = await request(app).get("/api/v1/anchors?sort=id&order=desc"); expect(res.status).toBe(200); expect(res.body.anchors.map((a: { id: string }) => a.id)).toEqual([ "anchorB", @@ -250,6 +245,16 @@ describe("anchor routes", () => { expect(res.body.error.code).toBe("BAD_REQUEST"); }); + it("flags dryRun: false on a normal bulk registration", async () => { + const app = createApp(); + const res = await request(app) + .post("/api/v1/anchors/bulk") + .send({ anchors: [{ id: "anchorA" }] }); + + expect(res.status).toBe(201); + expect(res.body.dryRun).toBe(false); + }); + it("searches the anchor list via ?q=", async () => { const app = createApp(); await request(app) @@ -306,7 +311,11 @@ describe("GET /api/v1/anchors/:id/settlements", () => { expect(res.status).toBe(200); expect(res.body.settlements).toHaveLength(3); - expect(res.body.settlements.every((s: { anchor: string }) => s.anchor === "anchorA")).toBe(true); + expect( + res.body.settlements.every( + (s: { anchor: string }) => s.anchor === "anchorA", + ), + ).toBe(true); }); it("returns 404 for an unknown anchor id", async () => { @@ -357,8 +366,12 @@ describe("GET /api/v1/anchors/:id/settlements", () => { const app = createApp(); await setupAnchorWithSettlements(app); - const nested = await request(app).get("/api/v1/anchors/anchorA/settlements"); - const filtered = await request(app).get("/api/v1/settlements?anchor=anchorA"); + const nested = await request(app).get( + "/api/v1/anchors/anchorA/settlements", + ); + const filtered = await request(app).get( + "/api/v1/settlements?anchor=anchorA", + ); expect(nested.status).toBe(200); expect(filtered.status).toBe(200); @@ -375,7 +388,9 @@ describe("GET /api/v1/anchors/:id/settlements", () => { ); expect(res.status).toBe(200); - const amounts = res.body.settlements.map((s: { amount: number }) => s.amount); + const amounts = res.body.settlements.map( + (s: { amount: number }) => s.amount, + ); expect(amounts).toEqual([100, 200, 300]); }); @@ -388,7 +403,9 @@ describe("GET /api/v1/anchors/:id/settlements", () => { ); expect(res.status).toBe(200); - const amounts = res.body.settlements.map((s: { amount: number }) => s.amount); + const amounts = res.body.settlements.map( + (s: { amount: number }) => s.amount, + ); expect(amounts).toEqual([300, 200, 100]); }); @@ -456,7 +473,197 @@ describe("GET /api/v1/anchors/:id/settlements", () => { expect(res.status).toBe(200); expect(res.headers["content-type"]).toMatch(/text\/csv/); - expect(res.text).toMatch(/^id,anchor,asset,amount,fee,status,createdAt,cancelReason\n/); + expect(res.text).toMatch( + /^id,anchor,asset,amount,fee,status,createdAt,cancelReason\n/, + ); expect(res.text).toContain("anchorA"); }); }); + +describe("POST /api/v1/anchors/bulk?dryRun=true", () => { + it("validates the batch and registers nothing", async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/v1/anchors/bulk?dryRun=true") + .send({ anchors: [{ id: "anchorA" }, { id: "anchorB", name: "B" }] }); + + expect(res.status).toBe(201); + expect(res.body.dryRun).toBe(true); + expect(res.body.anchors.map((a: { id: string }) => a.id)).toEqual([ + "anchorA", + "anchorB", + ]); + expect(res.body.anchors[1].name).toBe("B"); + + const list = await request(app).get("/api/v1/anchors"); + expect(list.body.anchors).toHaveLength(0); + }); + + it("leaves the repository unchanged, verified before and after", async () => { + const app = createApp(); + await request(app).post("/api/v1/anchors").send({ id: "existing" }); + + const before = await request(app).get("/api/v1/anchors"); + + await request(app) + .post("/api/v1/anchors/bulk?dryRun=true") + .send({ anchors: [{ id: "anchorA" }, { id: "anchorB" }] }); + + const after = await request(app).get("/api/v1/anchors"); + expect(after.body.anchors).toEqual(before.body.anchors); + expect(after.body.anchors).toHaveLength(1); + }); + + it("returns the same 409 as a real call for an id already registered", async () => { + const app = createApp(); + await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); + + const dry = await request(app) + .post("/api/v1/anchors/bulk?dryRun=true") + .send({ anchors: [{ id: "anchorB" }, { id: "anchorA" }] }); + const real = await request(app) + .post("/api/v1/anchors/bulk") + .send({ anchors: [{ id: "anchorB" }, { id: "anchorA" }] }); + + expect(dry.status).toBe(409); + expect(dry.status).toBe(real.status); + expect(dry.body).toEqual(real.body); + + const list = await request(app).get("/api/v1/anchors"); + expect(list.body.anchors).toHaveLength(1); + }); + + it("returns the same 409 as a real call for a duplicate id within the batch", async () => { + const app = createApp(); + + const dry = await request(app) + .post("/api/v1/anchors/bulk?dryRun=true") + .send({ anchors: [{ id: "anchorA" }, { id: "anchorA" }] }); + const real = await request(app) + .post("/api/v1/anchors/bulk") + .send({ anchors: [{ id: "anchorA" }, { id: "anchorA" }] }); + + expect(dry.status).toBe(409); + expect(dry.body).toEqual(real.body); + + const list = await request(app).get("/api/v1/anchors"); + expect(list.body.anchors).toHaveLength(0); + }); + + it("returns 400 for a missing/empty anchors array in dry-run mode", async () => { + const app = createApp(); + + const missing = await request(app) + .post("/api/v1/anchors/bulk?dryRun=true") + .send({}); + const empty = await request(app) + .post("/api/v1/anchors/bulk?dryRun=true") + .send({ anchors: [] }); + + expect(missing.status).toBe(400); + expect(missing.body.error.code).toBe("BAD_REQUEST"); + expect(empty.status).toBe(400); + }); + + it("returns 400 for a blank entry id in dry-run mode", async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/v1/anchors/bulk?dryRun=true") + .send({ anchors: [{ id: "anchorA" }, { id: " " }] }); + + expect(res.status).toBe(400); + expect(res.body.error.message).toContain("anchors[1].id"); + + const list = await request(app).get("/api/v1/anchors"); + expect(list.body.anchors).toHaveLength(0); + }); + + it("performs a real registration for ?dryRun=false", async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/v1/anchors/bulk?dryRun=false") + .send({ anchors: [{ id: "anchorA" }] }); + + expect(res.status).toBe(201); + expect(res.body.dryRun).toBe(false); + + const list = await request(app).get("/api/v1/anchors"); + expect(list.body.anchors).toHaveLength(1); + }); + + it("accepts mixed casing and surrounding whitespace for the flag", async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/v1/anchors/bulk?dryRun=%20TRUE%20") + .send({ anchors: [{ id: "anchorA" }] }); + + expect(res.status).toBe(201); + expect(res.body.dryRun).toBe(true); + + const list = await request(app).get("/api/v1/anchors"); + expect(list.body.anchors).toHaveLength(0); + }); + + it("rejects an unrecognized dryRun value with 400 instead of registering", async () => { + const app = createApp(); + + for (const value of ["yes", "1", "ture"]) { + const res = await request(app) + .post(`/api/v1/anchors/bulk?dryRun=${value}`) + .send({ anchors: [{ id: "anchorA" }] }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("BAD_REQUEST"); + expect(res.body.error.message).toContain("dryRun"); + } + + const list = await request(app).get("/api/v1/anchors"); + expect(list.body.anchors).toHaveLength(0); + }); + + it("rejects a repeated dryRun query param with 400", async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/v1/anchors/bulk?dryRun=true&dryRun=true") + .send({ anchors: [{ id: "anchorA" }] }); + + expect(res.status).toBe(400); + + const list = await request(app).get("/api/v1/anchors"); + expect(list.body.anchors).toHaveLength(0); + }); + + it("treats a bare ?dryRun (no value) as invalid rather than a real write", async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/v1/anchors/bulk?dryRun") + .send({ anchors: [{ id: "anchorA" }] }); + + expect(res.status).toBe(400); + + const list = await request(app).get("/api/v1/anchors"); + expect(list.body.anchors).toHaveLength(0); + }); + + it("lets a dry run be followed by a real commit of the same batch", async () => { + const app = createApp(); + const body = { anchors: [{ id: "anchorA" }, { id: "anchorB" }] }; + + const dry = await request(app) + .post("/api/v1/anchors/bulk?dryRun=true") + .send(body); + expect(dry.status).toBe(201); + + const real = await request(app).post("/api/v1/anchors/bulk").send(body); + expect(real.status).toBe(201); + + const list = await request(app).get("/api/v1/anchors"); + expect(list.body.anchors).toHaveLength(2); + }); +}); diff --git a/src/routes/anchors.ts b/src/routes/anchors.ts index a6ba573..e380be9 100644 --- a/src/routes/anchors.ts +++ b/src/routes/anchors.ts @@ -8,11 +8,18 @@ import { SettlementService } from "../services/settlementService"; import { applySort } from "../utils/sorting"; import { paginate } from "../utils/pagination"; import { toCsv } from "../utils/csv"; +import { optionalBooleanFlag } from "../utils/validation"; const SORTABLE_FIELDS = ["id", "name", "registeredAt"]; const CSV_COLUMNS = ["id", "name", "registeredAt", "active"]; -const SETTLEMENT_SORTABLE_FIELDS = ["id", "amount", "fee", "status", "createdAt"]; +const SETTLEMENT_SORTABLE_FIELDS = [ + "id", + "amount", + "fee", + "status", + "createdAt", +]; const SETTLEMENT_CSV_COLUMNS = [ "id", "anchor", @@ -24,7 +31,10 @@ const SETTLEMENT_CSV_COLUMNS = [ "cancelReason", ]; -export function anchorRouter(service: AnchorService, settlements?: SettlementService): Router { +export function anchorRouter( + service: AnchorService, + settlements?: SettlementService, +): Router { const router = Router(); // Register a new anchor. @@ -34,9 +44,17 @@ export function anchorRouter(service: AnchorService, settlements?: SettlementSer }); // Register a batch of anchors atomically. + // + // With ?dryRun=true the batch runs through the identical validation but + // nothing is persisted — a preflight check for onboarding UIs. The response + // shape and status match a real call, plus a `dryRun` flag so the caller can + // confirm no registration happened. `dryRun` is strictly parsed: only + // "true"/"false" are accepted, so a typo can never silently perform a real + // registration. router.post("/bulk", (req: Request, res: Response) => { - const anchors = service.registerBulk((req.body ?? {}).anchors); - res.status(201).json({ anchors }); + const dryRun = optionalBooleanFlag(req.query.dryRun, "dryRun"); + const anchors = service.registerBulk((req.body ?? {}).anchors, dryRun); + res.status(201).json({ anchors, dryRun }); }); // List anchors, optionally filtered via ?status=active|inactive and/or a @@ -85,7 +103,14 @@ export function anchorRouter(service: AnchorService, settlements?: SettlementSer service.get(req.params.id); if (!settlements) { - res.status(501).json({ error: { code: "NOT_IMPLEMENTED", message: "settlements service unavailable" } }); + res + .status(501) + .json({ + error: { + code: "NOT_IMPLEMENTED", + message: "settlements service unavailable", + }, + }); return; } @@ -104,7 +129,10 @@ export function anchorRouter(service: AnchorService, settlements?: SettlementSer page: req.query.page, pageSize: req.query.pageSize, }); - res.json({ settlements: page.items, pagination: { ...page, items: undefined } }); + res.json({ + settlements: page.items, + pagination: { ...page, items: undefined }, + }); }); return router; diff --git a/src/services/anchorService.test.ts b/src/services/anchorService.test.ts index e9ed8ea..e6323f8 100644 --- a/src/services/anchorService.test.ts +++ b/src/services/anchorService.test.ts @@ -247,3 +247,192 @@ describe("AnchorService", () => { expect(service.list()).toEqual([]); }); }); + +describe("AnchorService.registerBulk dry run", () => { + function makeRepoAndService(): { + repo: AnchorRepository; + service: AnchorService; + } { + const repo = new AnchorRepository(); + return { repo, service: new AnchorService(repo) }; + } + + it("returns the would-be-registered anchors without persisting them", () => { + const { repo, service } = makeRepoAndService(); + + const result = service.registerBulk( + [{ id: "anchorA" }, { id: "anchorB", name: "Anchor B" }], + true, + ); + + expect(result.map((a) => a.id)).toEqual(["anchorA", "anchorB"]); + expect(result[0].name).toBe("anchorA"); // name defaults to id + expect(result[1].name).toBe("Anchor B"); + expect(result.every((a) => a.active === true)).toBe(true); + expect(result.every((a) => typeof a.registeredAt === "string")).toBe(true); + + // Nothing was persisted. + expect(repo.count()).toBe(0); + expect(repo.all()).toEqual([]); + expect(repo.has("anchorA")).toBe(false); + }); + + it("leaves the repository provably unchanged (count/all before and after)", () => { + const { repo, service } = makeRepoAndService(); + service.register({ id: "existing" }); + + const countBefore = repo.count(); + const allBefore = JSON.stringify(repo.all()); + + service.registerBulk([{ id: "anchorA" }, { id: "anchorB" }], true); + + expect(repo.count()).toBe(countBefore); + expect(JSON.stringify(repo.all())).toBe(allBefore); + }); + + it("never calls repo.upsert during a dry run", () => { + const { repo, service } = makeRepoAndService(); + const upsert = jest.spyOn(repo, "upsert"); + + service.registerBulk([{ id: "anchorA" }, { id: "anchorB" }], true); + + expect(upsert).not.toHaveBeenCalled(); + }); + + it("persists when dryRun is false or omitted", () => { + const { repo, service } = makeRepoAndService(); + + service.registerBulk([{ id: "anchorA" }], false); + service.registerBulk([{ id: "anchorB" }]); + + expect(repo.count()).toBe(2); + expect(repo.all().map((a) => a.id)).toEqual(["anchorA", "anchorB"]); + }); + + it("reports the same outcome as a real call for a valid batch", () => { + const dry = makeRepoAndService(); + const real = makeRepoAndService(); + const batch = [{ id: "anchorA" }, { id: "anchorB", name: "B" }]; + + const dryResult = dry.service.registerBulk(batch, true); + const realResult = real.service.registerBulk(batch, false); + + const strip = (anchors: { registeredAt?: string }[]) => + anchors.map((a) => ({ ...a, registeredAt: undefined })); + + expect(strip(dryResult)).toEqual(strip(realResult)); + expect(dry.repo.count()).toBe(0); + expect(real.repo.count()).toBe(2); + }); + + it("rejects a non-array batch identically in dry-run mode", () => { + const { repo, service } = makeRepoAndService(); + + expect(() => service.registerBulk({ id: "anchorA" }, true)).toThrow( + ApiError, + ); + expect(repo.count()).toBe(0); + }); + + it("rejects an empty batch identically in dry-run mode", () => { + const { service } = makeRepoAndService(); + + expect(() => service.registerBulk([], true)).toThrow(ApiError); + }); + + it("rejects a null/undefined batch entry identically in both modes", () => { + const dry = makeRepoAndService(); + const real = makeRepoAndService(); + + const dryError = captureError(() => + dry.service.registerBulk([null, undefined], true), + ); + const realError = captureError(() => + real.service.registerBulk([null, undefined], false), + ); + + expect(dryError?.status).toBe(400); + expect(dryError?.message).toBe(realError?.message); + expect(dry.repo.count()).toBe(0); + expect(real.repo.count()).toBe(0); + }); + + it("rejects an invalid entry id identically in dry-run mode", () => { + const { repo, service } = makeRepoAndService(); + + expect(() => service.registerBulk([{ id: "" }], true)).toThrow( + /"anchors\[0\]\.id" must be a non-empty string/, + ); + expect(repo.count()).toBe(0); + }); + + it("rejects an invalid entry name identically in dry-run mode", () => { + const { repo, service } = makeRepoAndService(); + + expect(() => + service.registerBulk([{ id: "anchorA", name: 42 }], true), + ).toThrow(/"anchors\[0\]\.name" must be a non-empty string/); + expect(repo.count()).toBe(0); + }); + + it("rejects a duplicate id within the batch identically in dry-run mode", () => { + const dry = makeRepoAndService(); + const real = makeRepoAndService(); + const batch = [{ id: "anchorA" }, { id: "anchorA" }]; + + const dryError = captureError(() => dry.service.registerBulk(batch, true)); + const realError = captureError(() => + real.service.registerBulk(batch, false), + ); + + expect(dryError).toBeInstanceOf(ApiError); + expect(dryError?.status).toBe(realError?.status); + expect(dryError?.code).toBe(realError?.code); + expect(dryError?.message).toBe(realError?.message); + expect(dry.repo.count()).toBe(0); + expect(real.repo.count()).toBe(0); + }); + + it("rejects an id that conflicts with the registry identically in dry-run mode", () => { + const dry = makeRepoAndService(); + const real = makeRepoAndService(); + dry.service.register({ id: "anchorA" }); + real.service.register({ id: "anchorA" }); + const batch = [{ id: "anchorB" }, { id: "anchorA" }]; + + const dryError = captureError(() => dry.service.registerBulk(batch, true)); + const realError = captureError(() => + real.service.registerBulk(batch, false), + ); + + expect(dryError?.status).toBe(409); + expect(dryError?.status).toBe(realError?.status); + expect(dryError?.message).toBe(realError?.message); + + // Neither mode registered the valid entry that preceded the conflict. + expect(dry.repo.all().map((a) => a.id)).toEqual(["anchorA"]); + expect(real.repo.all().map((a) => a.id)).toEqual(["anchorA"]); + }); + + it("does not consume ids, so the same batch can be dry-run repeatedly then committed", () => { + const { repo, service } = makeRepoAndService(); + const batch = [{ id: "anchorA" }, { id: "anchorB" }]; + + service.registerBulk(batch, true); + service.registerBulk(batch, true); + const committed = service.registerBulk(batch, false); + + expect(committed.map((a) => a.id)).toEqual(["anchorA", "anchorB"]); + expect(repo.count()).toBe(2); + }); +}); + +/** Runs `fn` and returns the ApiError it threw, or `undefined`. */ +function captureError(fn: () => unknown): ApiError | undefined { + try { + fn(); + } catch (error) { + return error as ApiError; + } + return undefined; +} diff --git a/src/services/anchorService.ts b/src/services/anchorService.ts index bfca8c4..969c5f8 100644 --- a/src/services/anchorService.ts +++ b/src/services/anchorService.ts @@ -128,18 +128,25 @@ export class AnchorService { } /** - * Registers a batch of anchors atomically: every entry is validated (and - * checked against both the existing registry and duplicate ids within the - * same batch) before any of them are stored, so a single bad entry never - * leaves a partial batch registered. + * Validation phase of {@link registerBulk}: parses and checks every entry of + * the batch without touching the repository. + * + * Rejects a non-array/empty batch (400), entries whose `id`/`name` are not + * non-empty strings (400), ids duplicated within the batch (409), and ids + * that already exist in the registry (409). Returns the normalized + * `{ id, name }` pairs in batch order. + * + * Kept side-effect free on purpose so `dryRun` can reuse it verbatim: the + * preflight check and the real call run the exact same rules, and can never + * drift apart. */ - registerBulk(input: unknown): Anchor[] { + private validateBulk(input: unknown): { id: string; name: string }[] { if (!Array.isArray(input) || input.length === 0) { throw ApiError.badRequest('"anchors" must be a non-empty array'); } const seen = new Set(); - const parsed = input.map((entry, index) => { + return input.map((entry, index) => { const record = (entry ?? {}) as { id?: unknown; name?: unknown }; const id = requireString(record.id, `anchors[${index}].id`); const name = @@ -148,7 +155,9 @@ export class AnchorService { : requireString(record.name, `anchors[${index}].name`); if (seen.has(id)) { - throw ApiError.conflict(`anchor "${id}" appears more than once in the batch`); + throw ApiError.conflict( + `anchor "${id}" appears more than once in the batch`, + ); } seen.add(id); @@ -158,6 +167,34 @@ export class AnchorService { return { id, name }; }); + } + + /** + * Registers a batch of anchors atomically: every entry is validated (and + * checked against both the existing registry and duplicate ids within the + * same batch) before any of them are stored, so a single bad entry never + * leaves a partial batch registered. + * + * When `dryRun` is `true` the batch is validated exactly as it would be for a + * real call — identical errors, identical error order — but the persist phase + * is skipped entirely: `repo.upsert` is never invoked and the registry is left + * untouched. The returned anchors are the records that *would* have been + * created, so an onboarding UI can preflight a batch and show inline errors + * before committing. + */ + registerBulk(input: unknown, dryRun = false): Anchor[] { + const parsed = this.validateBulk(input); + const registeredAt = new Date().toISOString(); + + if (dryRun) { + // Preflight only: return the would-be-registered records, persist nothing. + return parsed.map(({ id, name }) => ({ + id, + name, + registeredAt, + active: true, + })); + } return parsed.map(({ id, name }) => this.repo.upsert({ diff --git a/src/utils/validation.test.ts b/src/utils/validation.test.ts index 16ed2ac..0c81a78 100644 --- a/src/utils/validation.test.ts +++ b/src/utils/validation.test.ts @@ -1,5 +1,6 @@ import { normalizeAsset, + optionalBooleanFlag, requirePositiveInteger, requirePositiveNumber, requireString, @@ -42,8 +43,12 @@ describe("requirePositiveNumber", () => { }); it("accepts MAX_SAFE_INTEGER and values beyond it", () => { - expect(requirePositiveNumber(Number.MAX_SAFE_INTEGER, "amount")).toBe(Number.MAX_SAFE_INTEGER); - expect(requirePositiveNumber(Number.MAX_SAFE_INTEGER + 1, "amount")).toBe(Number.MAX_SAFE_INTEGER + 1); + expect(requirePositiveNumber(Number.MAX_SAFE_INTEGER, "amount")).toBe( + Number.MAX_SAFE_INTEGER, + ); + expect(requirePositiveNumber(Number.MAX_SAFE_INTEGER + 1, "amount")).toBe( + Number.MAX_SAFE_INTEGER + 1, + ); }); it("rejects zero", () => { @@ -86,15 +91,27 @@ describe("requirePositiveInteger", () => { }); it("accepts Number.MAX_SAFE_INTEGER", () => { - expect(requirePositiveInteger(Number.MAX_SAFE_INTEGER, "id")).toBe(Number.MAX_SAFE_INTEGER); - expect(requirePositiveInteger(String(Number.MAX_SAFE_INTEGER), "id")).toBe(Number.MAX_SAFE_INTEGER); + expect(requirePositiveInteger(Number.MAX_SAFE_INTEGER, "id")).toBe( + Number.MAX_SAFE_INTEGER, + ); + expect(requirePositiveInteger(String(Number.MAX_SAFE_INTEGER), "id")).toBe( + Number.MAX_SAFE_INTEGER, + ); }); it("rejects values above Number.MAX_SAFE_INTEGER", () => { - expect(() => requirePositiveInteger(Number.MAX_SAFE_INTEGER + 1, "id")).toThrow(ApiError); - expect(() => requirePositiveInteger(Number.MAX_SAFE_INTEGER + 2, "id")).toThrow(ApiError); - expect(() => requirePositiveInteger(String(Number.MAX_SAFE_INTEGER + 1), "id")).toThrow(ApiError); - expect(() => requirePositiveInteger(String(Number.MAX_SAFE_INTEGER + 2), "id")).toThrow(ApiError); + expect(() => + requirePositiveInteger(Number.MAX_SAFE_INTEGER + 1, "id"), + ).toThrow(ApiError); + expect(() => + requirePositiveInteger(Number.MAX_SAFE_INTEGER + 2, "id"), + ).toThrow(ApiError); + expect(() => + requirePositiveInteger(String(Number.MAX_SAFE_INTEGER + 1), "id"), + ).toThrow(ApiError); + expect(() => + requirePositiveInteger(String(Number.MAX_SAFE_INTEGER + 2), "id"), + ).toThrow(ApiError); }); it("rejects zero", () => { @@ -171,3 +188,40 @@ describe("normalizeAsset", () => { expect(() => normalizeAsset("THISISWAYTOOLONGASSETCODE")).toThrow(ApiError); }); }); + +describe("optionalBooleanFlag", () => { + it("defaults to false when the value is absent", () => { + expect(optionalBooleanFlag(undefined, "dryRun")).toBe(false); + }); + + it("passes through real booleans", () => { + expect(optionalBooleanFlag(true, "dryRun")).toBe(true); + expect(optionalBooleanFlag(false, "dryRun")).toBe(false); + }); + + it('accepts "true" and "false" strings in any casing, trimmed', () => { + expect(optionalBooleanFlag("true", "dryRun")).toBe(true); + expect(optionalBooleanFlag(" TRUE ", "dryRun")).toBe(true); + expect(optionalBooleanFlag("False", "dryRun")).toBe(false); + }); + + it("rejects truthy-looking values instead of coercing them", () => { + for (const value of ["yes", "1", "on", "ture", ""]) { + expect(() => optionalBooleanFlag(value, "dryRun")).toThrow(ApiError); + } + }); + + it("rejects non-string, non-boolean values such as a repeated query param", () => { + expect(() => optionalBooleanFlag(["true", "true"], "dryRun")).toThrow( + ApiError, + ); + expect(() => optionalBooleanFlag(1, "dryRun")).toThrow(ApiError); + expect(() => optionalBooleanFlag(null, "dryRun")).toThrow(ApiError); + }); + + it("names the offending field and the accepted values in the message", () => { + expect(() => optionalBooleanFlag("nope", "dryRun")).toThrow( + '"dryRun" must be "true" or "false"', + ); + }); +}); diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 65d953f..42a5a7f 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -14,10 +14,16 @@ export function requireString(value: unknown, field: string): string { } /** Ensures `value` is a non-empty string up to a maximum length. */ -export function requireStringMaxLength(value: unknown, field: string, maxLength: number): string { +export function requireStringMaxLength( + value: unknown, + field: string, + maxLength: number, +): string { const str = requireString(value, field); if (str.length > maxLength) { - throw ApiError.badRequest(`"${field}" must be at most ${maxLength} characters`); + throw ApiError.badRequest( + `"${field}" must be at most ${maxLength} characters`, + ); } return str; } @@ -45,6 +51,29 @@ export function requirePositiveInteger(value: unknown, field: string): number { return parsed; } +/** + * Parses an optional boolean flag, typically sourced from a query param where + * the value arrives as a string (`?dryRun=true`). + * + * Parsing is deliberately strict: an absent value defaults to `false`, and the + * only accepted values are `true`/`false` (as a real boolean, or as a string in + * any casing/with surrounding whitespace). Anything else — `"yes"`, `"1"`, a + * typo such as `"ture"`, or a repeated query param that Express turns into an + * array — is a 400 rather than being silently coerced. For a flag like + * `dryRun`, silently treating a typo as "not set" would perform a real, + * persisting write when the caller explicitly asked for a preflight check. + */ +export function optionalBooleanFlag(value: unknown, field: string): boolean { + if (value === undefined) return false; + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true") return true; + if (normalized === "false") return false; + } + throw ApiError.badRequest(`"${field}" must be "true" or "false"`); +} + /** Normalizes an asset code to upper case (e.g. "usdc" -> "USDC"). */ export function normalizeAsset(value: unknown): string { const asset = requireString(value, "asset").toUpperCase();