From 56519c986873b4504971f3e9bc0013b00cce357a Mon Sep 17 00:00:00 2001 From: Glorious21 Date: Thu, 15 Oct 2026 20:38:53 +0100 Subject: [PATCH 1/3] fix(openapi): document GET /fees, and make the coverage test deterministic and fast (W082) The coverage suite was red on main for one route: GET /fees shipped without a spec entry. It is now documented, with its optional ?amount= query and the per-asset fee shape. The suite looked flaky, but the route walk is deterministic; the environment was not. On a Windows checkout core.autocrlf rewrote openapi.json to CRLF, so the byte comparison failed until someone ran `npm run docs:openapi` (which writes LF) and then failed again after the next checkout. .gitattributes pins the spec to LF, and the test compares content with line endings normalised. Speed: tsconfig.jest.json now sets isolatedModules, so ts-jest transpiles instead of type-checking the whole app graph a second time. CI already type-checks src/ and the tests in `npm run typecheck`. A cold run of this suite drops from 23s to 13s; warm, from ~9s to ~2s. Closes #239 Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 7 +++ openapi.json | 87 +++++++++++++++++++++++++++ src/__tests__/openapiCoverage.test.ts | 32 +++++++++- src/openapi/build.ts | 18 ++++++ src/openapi/schemas.ts | 36 +++++++++++ tsconfig.jest.json | 7 ++- 6 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..7ca7c45bf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# The OpenAPI spec is generated with LF line endings and compared byte-for-byte +# against the generator in src/__tests__/openapiCoverage.test.ts. Without this, +# a Windows checkout (core.autocrlf=true) turned every line into CRLF and the +# comparison failed until someone happened to regenerate the file — so the same +# commit passed or failed depending on whose machine it ran on. +openapi.json text eol=lf +docs/openapi.json text eol=lf diff --git a/openapi.json b/openapi.json index f807dbbbb..055b139c4 100644 --- a/openapi.json +++ b/openapi.json @@ -4676,6 +4676,93 @@ } } }, + "/fees": { + "get": { + "summary": "Veil platform fee", + "description": "What the wallet should charge on a send, and where to pay it, per asset. The fee is collected on-chain in the same transaction as the send; this only tells the client the amount, so it can change without shipping an app. Unauthenticated and served with `Cache-Control: no-store`, because a stale fee is one the user was shown but did not agree to. `enabled: false` means no fee is configured today.", + "parameters": [ + { + "schema": { + "type": "string", + "nullable": true, + "description": "The amount being sent, in whole units of the asset. When given and a percentage fee is configured, each fee is computed for it; without it each fee is the floor. Optional because shipped clients predate it.", + "example": "25" + }, + "required": false, + "description": "The amount being sent, in whole units of the asset. When given and a percentage fee is configured, each fee is computed for it; without it each fee is the floor. Optional because shipped clients predate it.", + "name": "amount", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "False when no fee is configured, so a client can tell a deliberate waiver from a backend too old to know about fees." + }, + "fees": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "amount": { + "type": "string", + "description": "What to charge, in whole units of the fee asset, as a decimal string.", + "example": "0.25" + }, + "assetCode": { + "type": "string", + "example": "USDC" + }, + "assetIssuer": { + "type": "string", + "nullable": true, + "description": "Null for native XLM." + }, + "destination": { + "type": "string", + "description": "The classic account the fee is paid to." + }, + "percent": { + "type": "string", + "description": "Percent of the sent amount, when the fee scales. Absent when it is flat." + }, + "min": { + "type": "string", + "description": "Floor; present only alongside percent." + }, + "max": { + "type": "string", + "description": "Cap; present only alongside percent." + } + }, + "required": [ + "amount", + "assetCode", + "assetIssuer", + "destination" + ] + }, + "description": "Keyed by asset code. Always charged in the asset being sent." + } + }, + "required": [ + "enabled", + "fees" + ] + } + } + } + } + } + } + }, "/transfers.csv": { "get": { "summary": "Export all transfers as CSV", diff --git a/src/__tests__/openapiCoverage.test.ts b/src/__tests__/openapiCoverage.test.ts index c88bdbf3c..da1084053 100644 --- a/src/__tests__/openapiCoverage.test.ts +++ b/src/__tests__/openapiCoverage.test.ts @@ -14,6 +14,22 @@ import { buildOpenApiDocument } from "../openapi/build"; * walking the app — `GET /accounts/:address/balance` and the provider * callback `POST /webhooks/linq`. * + * It went red again (W082) when `GET /fees` shipped without a spec entry. It + * also appeared to flap, but the route walk is deterministic — ten runs in a + * row agree. What changed between runs was the environment, not the route table: + * - `openapi.json` on a Windows checkout had CRLF line endings, so the + * byte comparison below failed until someone ran `npm run docs:openapi` + * (which writes LF), then failed again after the next checkout; + * - before that, it read the gitignored `docs/openapi.json`, which existed + * only on machines that had run the generator. + * Both are fixed; see the last test and `.gitattributes`. + * + * Speed: nearly all of this suite's time is loading the app (the Stellar SDK, + * Apollo, Prisma), not the check — walking the routes and building the spec is + * ~100ms. ts-jest used to type-check that whole import graph on every cold + * run, which CI already does in `npm run typecheck`; `tsconfig.jest.json` now + * sets `isolatedModules` so tests are transpiled, not checked twice. + * * How the app's route table is collected: express stacks every `app.use` and * `app.get` as a Layer. A Layer with a `route` is a method route; a Layer * whose `handle` has its own `stack` is a mounted router. Express 4 keeps the @@ -151,6 +167,11 @@ describe("OpenAPI route coverage (W085)", () => { expect(keys).toContain("get /summary/:address"); expect(keys).toContain("get /accounts/:address/balance"); expect(keys).toContain("get /transfers.csv"); + expect(keys).toContain("get /fees"); + }); + + it("documents /fees", () => { + expect(spec.paths["/fees"]?.get).toBeDefined(); }); it("documents /tokens", () => { @@ -192,7 +213,16 @@ describe("OpenAPI route coverage (W085)", () => { // every clean clone, CI included, before this test could assert anything. // Nothing is lost: the same loop in build.ts writes both from one // `document`, so the docs copy cannot drift independently of this one. - const committed = readFileSync(path.resolve(process.cwd(), "openapi.json"), "utf8"); + // + // Line endings are normalised: they are not part of the spec, and a + // Windows checkout with core.autocrlf rewrote all of them, so this failed + // or passed on the same commit depending on whether the file had been + // regenerated locally since checkout. `.gitattributes` pins the file to LF + // as well; this keeps the assertion about content either way. + const committed = readFileSync(path.resolve(process.cwd(), "openapi.json"), "utf8").replace( + /\r\n/g, + "\n", + ); const generated = JSON.stringify(spec, null, 2) + "\n"; expect(committed).toBe(generated); diff --git a/src/openapi/build.ts b/src/openapi/build.ts index 7486c938c..f9b6bea3b 100644 --- a/src/openapi/build.ts +++ b/src/openapi/build.ts @@ -52,6 +52,8 @@ import { offrampErrorResponseSchema, ngnCustomerStatusQuerySchema, ngnCustomerStatusResponseSchema, + feesQuerySchema, + feesResponseSchema, } from "./schemas"; const registry = new OpenAPIRegistry(); @@ -397,6 +399,22 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", + path: "/fees", + summary: "Veil platform fee", + description: + "What the wallet should charge on a send, and where to pay it, per asset. The fee " + + "is collected on-chain in the same transaction as the send; this only tells the " + + "client the amount, so it can change without shipping an app. Unauthenticated and " + + "served with `Cache-Control: no-store`, because a stale fee is one the user was " + + "shown but did not agree to. `enabled: false` means no fee is configured today.", + request: { query: feesQuerySchema }, + responses: { + 200: { description: "OK", content: { "application/json": { schema: feesResponseSchema } } }, + }, +}); + const transferExportResponses = { 200: { description: "File download of the full matching transfer set (no pagination).", diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 6e7bdc178..070f72224 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -755,3 +755,39 @@ export const ngnCustomerStatusResponseSchema = z.object({ status: z.string().optional(), }); + +// ─── GET /fees ────────────────────────────────────────────────────────────── + +export const feesQuerySchema = z.object({ + amount: optionalQueryString( + "The amount being sent, in whole units of the asset. When given and a percentage " + + "fee is configured, each fee is computed for it; without it each fee is the " + + "floor. Optional because shipped clients predate it.", + ).openapi({ example: "25" }), +}); + +const platformFeeSchema = z.object({ + amount: z.string().openapi({ + description: "What to charge, in whole units of the fee asset, as a decimal string.", + example: "0.25", + }), + assetCode: z.string().openapi({ example: "USDC" }), + assetIssuer: z.string().nullable().openapi({ description: "Null for native XLM." }), + destination: z.string().openapi({ description: "The classic account the fee is paid to." }), + percent: z.string().optional().openapi({ + description: "Percent of the sent amount, when the fee scales. Absent when it is flat.", + }), + min: z.string().optional().openapi({ description: "Floor; present only alongside percent." }), + max: z.string().optional().openapi({ description: "Cap; present only alongside percent." }), +}); + +export const feesResponseSchema = z.object({ + enabled: z.boolean().openapi({ + description: + "False when no fee is configured, so a client can tell a deliberate waiver " + + "from a backend too old to know about fees.", + }), + fees: z.record(z.string(), platformFeeSchema).openapi({ + description: "Keyed by asset code. Always charged in the asset being sent.", + }), +}); diff --git a/tsconfig.jest.json b/tsconfig.jest.json index da824f960..7561a1dcf 100644 --- a/tsconfig.jest.json +++ b/tsconfig.jest.json @@ -3,6 +3,11 @@ "compilerOptions": { "module": "commonjs", "moduleResolution": "node", - "noEmit": false + "noEmit": false, + // Transpile only. Type errors in src/ and the tests are caught by + // `npm run typecheck` (tsconfig.test.json), which CI runs before `npm test`; + // ts-jest checking the same graph again roughly doubled a cold run of any + // suite that imports the whole app (openapiCoverage: 23s → 13s). + "isolatedModules": true } } From efadd0d1413f1998354ce8f148b53688b72b6332 Mon Sep 17 00:00:00 2001 From: Glorious21 Date: Thu, 15 Oct 2026 20:39:04 +0100 Subject: [PATCH 2/3] test(openapi): add an undocumented route to prove the coverage suite catches it (W082, reverted next) Co-Authored-By: Claude Opus 5.5 --- src/api.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/api.ts b/src/api.ts index e1ff05683..52f173f86 100644 --- a/src/api.ts +++ b/src/api.ts @@ -312,6 +312,12 @@ export function createApp(): express.Application { * Returns 200 as long as the process is alive. * Used by orchestrators to decide whether to restart the container. */ + // W082 proof: an undocumented route. This commit exists to show the coverage + // suite fails on it, and is reverted by the next one. + app.get("/w082-undocumented-probe", (_req: Request, res: Response) => { + res.json({ ok: true }); + }); + app.get("/healthz", (_req: Request, res: Response) => { res.json({ ok: true, uptime: process.uptime() }); }); From 4a130f5d09a8f8d7f9ca2611c1d5580ff8f06d67 Mon Sep 17 00:00:00 2001 From: Glorious21 Date: Thu, 15 Oct 2026 20:39:34 +0100 Subject: [PATCH 3/3] Revert "test(openapi): add an undocumented route to prove the coverage suite catches it" The probe did its job: with it in place the coverage suite failed with `+ "get /w082-undocumented-probe"`. Removed again. Co-Authored-By: Claude Opus 5.5 --- src/api.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/api.ts b/src/api.ts index 52f173f86..e1ff05683 100644 --- a/src/api.ts +++ b/src/api.ts @@ -312,12 +312,6 @@ export function createApp(): express.Application { * Returns 200 as long as the process is alive. * Used by orchestrators to decide whether to restart the container. */ - // W082 proof: an undocumented route. This commit exists to show the coverage - // suite fails on it, and is reverted by the next one. - app.get("/w082-undocumented-probe", (_req: Request, res: Response) => { - res.json({ ok: true }); - }); - app.get("/healthz", (_req: Request, res: Response) => { res.json({ ok: true, uptime: process.uptime() }); });