diff --git a/.agents/skills/address-feedback/SKILL.md b/.agents/skills/address-feedback/SKILL.md index 92cc633df..efefa868c 100644 --- a/.agents/skills/address-feedback/SKILL.md +++ b/.agents/skills/address-feedback/SKILL.md @@ -55,7 +55,7 @@ Run what the change touches: - `bun typecheck`. - Affected package/app test suites (commands in each subdirectory's CLAUDE.md). - `bun run build:shared` when `packages/shared` changed, then re-run dependent suites. -- `bun run wire-contract:check` when shared endpoint types or the SDK surface changed; +- `bun run wire-contract:check` when shared endpoint types, the SDK surface, or API routes changed; if the change is intentional, `bun run wire-contract:update` and commit the snapshot diff with an explicit note on backward compatibility. diff --git a/.agents/skills/vortex-cleanup/SKILL.md b/.agents/skills/vortex-cleanup/SKILL.md new file mode 100644 index 000000000..39595c271 --- /dev/null +++ b/.agents/skills/vortex-cleanup/SKILL.md @@ -0,0 +1,83 @@ +--- +name: vortex-cleanup +description: Rules and procedure for over-engineering cleanups in this repository, such as ponytail audits and reviews (/ponytail:ponytail-audit, /ponytail:ponytail-review), dead-code and unused-dependency removal, and simplifying refactors. Use before auditing or applying any cleanup, so the run respects the Compatibility Contract and the scope decisions already made. +--- + +# Vortex cleanup rules + +Cleanup runs here follow the root `CLAUDE.md` **Compatibility Contract** first. The ponytail +plugin decides *what* looks over-engineered; this skill decides what may actually change +and how to prove nothing broke. When the two disagree, the contract wins. + +## Scope decisions (do not propose these again without new information) + +Excluded from cleanups by Marcel on 2026-10-01: + +- Duplicate ABI files (`apps/api/src/contracts`, `apps/frontend/src/contracts`, + `packages/shared/src/contracts`) and swaps to viem's `erc20Abi`. +- Storybook (`apps/frontend/.storybook`, `*.stories.tsx`), and any component a story + imports. +- The retired BRL↔AssetHub flows, the phases only they use, and their substrate branches. +- Generic `packages/shared` shrinking: endpoint placeholders, twin helpers, dead DTO types. +- The rebalancer's BlindPay shadow quotes, and gold demo mode. +- Hand-rolled sleeps and IP-range checks (stdlib/`node:net` swaps). + +Kept by the Compatibility Contract: mounted endpoints that nothing in this repo calls +(`/v1/siwe`, `/v1/storage`, `GET /v1/prices`), because external partners may call them. + +Deferred because the swap changes behaviour; each needs an explicit decision and a +migration plan, not a cleanup commit: + +- `body-parser` → `express.json()`: body-parser 2 leaves `req.body` undefined when nothing + was parsed. +- `joi` → zod: the email validation regexes differ. +- `method-override`: live, it honours `X-HTTP-Method-Override`. +- `dotenv`: the api also loads `../.env`; Bun only auto-loads from the working directory. +- ethers/siwe → viem: signed-transaction parsing and the SIWE nonce format. +- The swc build step: the Render start command depends on it. +- SDK ESLint → Biome: it enforces `.js` import extensions in the published ESM. +- node-forge → `node:crypto` for BRLA signing: node:crypto rejects PEM spellings forge + accepts, and the production key format is unverified. +- react-toastify → sonner, removing `input-otp`: UX changes. + +## Procedure + +1. **Base and isolation.** Work from `origin/staging` in a worktree. Re-check every finding + against the latest `staging` before integrating: staging can start using something you + deleted as dead. +2. **Audit.** Partition by workspace. Every delegated agent gets the Compatibility Contract + and this skill's scope table in its prompt: built-in Explore/Plan subagents do not load + `CLAUDE.md`. Finder-style passes can use the cheap tier (Claude Code: `sonnet`; Codex: + GPT-5.6-Luna); agents that edit code use the strong tier (Claude Code: session model; + Codex: GPT-5.6-Sol). +3. **Prove "dead" before deleting.** Zero references across `apps/`, `packages/`, + `contracts/`, `scripts/`, CI workflows, and package.json scripts. Include barrels, + string-keyed and dynamic use, lazy imports, TanStack file routes, dynamic i18n keys, + tsconfig `types`, side-effect imports, and stories. Usage only by tests means the + test usage goes too, never that the code is live. +4. **Dependencies.** Check imports, config files, CLI use in scripts, and peer requirements. + The frontend bundles `packages/shared` from source through its browser export, so + anything shared imports must stay resolvable from the frontend. The `bun.lock` diff may + only remove packages or move hoisting, never shift a consumer's resolved version. +5. **Refactors.** Characterization tests against the old code come first and stay. Keep + message strings, state keys, attempt classes, and log semantics that recovery or + operators rely on. If the result isn't smaller, or equivalence is uncertain, skip it + and say why. Skipping is fine; a silent behaviour change is not. +6. **Gates.** Before handing off: + - `bun run typecheck`, `bun run verify`, `bun run wire-contract:check`. + - The affected test suites. API tests need their own database: create + `vortex_test_` on `localhost:54329` and run with `TEST_DB_NAME`. The dashboard + uses `bun run test`, since plain `bun test` picks up its Playwright specs. + - `bun run build` for every affected app. + - Any change to the wire-contract snapshot needs a compatibility note in the PR. +7. **Commits and PR.** One conventional commit per concern, with tests in the same + commit, grouped by workspace. The PR targets `staging` and lists what was kept or + skipped and why. + +## Running parallel workstreams + +- Install with Bun's global cache (`bun install --frozen-lockfile`). Per-worktree caches + from `bun bootstrap:worktree` filled the disk when seven agents ran at once. +- Give each workstream exclusive ownership of the `package.json` files it edits. When + integrating by cherry-pick, resolve `bun.lock` conflicts by keeping the integration + side and re-running `bun install`. diff --git a/.claude/skills/vortex-cleanup b/.claude/skills/vortex-cleanup new file mode 120000 index 000000000..57ebae8b3 --- /dev/null +++ b/.claude/skills/vortex-cleanup @@ -0,0 +1 @@ +../../.agents/skills/vortex-cleanup \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 4d4b618d4..ca60f3107 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,6 +157,33 @@ Before finalizing, make a simplification pass. Remove speculative flexibility, d state, unnecessary branches, single-use helpers, and indirection that do not protect a demonstrated requirement. Keep the regression test that proves the leaner fix is safe. +## Compatibility Contract + +Every change, and especially every refactor or cleanup (including ponytail audits), must +keep existing integrators working. Simplification never justifies breaking them. + +- **HTTP API**: every route mounted from `apps/api/src/config/express.ts` keeps its path, + method, request validation, status codes, error codes/messages, and response bodies. + Never remove a mounted endpoint because nothing in this repo calls it; partners might. +- **SDK**: the exports of `packages/sdk/src/index.ts`, its emitted types, its runtime + behavior, and the HTTP requests it sends stay unchanged. Relaxing a requirement (for + example dropping an unused peer dependency) is fine. +- **`@vortexfi/shared`** is published to npm: never remove or change anything the SDK + source or its emitted types reference. Other removed exports need a version bump on the + next publish. +- **Persisted formats** stay readable: ramp state and its metadata, rebalancer state files, + browser storage keys, and webhook payloads. +- **Refactors** need tests that prove equivalence. Where existing tests don't, write + characterization tests against the old code first; if equivalence can't be shown, skip + the refactor. No exceptions in fund-moving code (`apps/api/src/api/services/phases/`, + `apps/rebalancer`). +- A **cleanup** must remove net code. Moving lines into a new abstraction is not cleanup. + +`bun run wire-contract:check` (CI) snapshots the shared endpoint types, the SDK surface, +and every mounted `METHOD /path`; a snapshot diff is a compatibility review, not a +formality. Cleanup and ponytail runs also follow +[`.agents/skills/vortex-cleanup/SKILL.md`](.agents/skills/vortex-cleanup/SKILL.md). + ## Testing These apply to every agent working in this repo: diff --git a/MAP.md b/MAP.md index cf40f66cb..762b98f78 100644 --- a/MAP.md +++ b/MAP.md @@ -45,6 +45,6 @@ The full placement and lifecycle policy is in [`docs/README.md`](docs/README.md) |---|---| | `scripts` | Repository coverage and maintenance tooling. | | `supabase` | Supabase configuration, migrations, snippets, and email templates. | -| `.agents/skills` | Purpose-built, repository-specific agent workflows (currently Vortex integration and Sentry guidance). | +| `.agents/skills` | Purpose-built, repository-specific agent workflows (review, shipping, PR feedback, cleanup rules, Vortex integration, and Sentry guidance). | | `.claude` | Shared Claude Code settings and worktree configuration. | | `.clinerules` | Pointer from Cline to the canonical `CLAUDE.md` and documentation policy. | diff --git a/docs/api/README.md b/docs/api/README.md index 0786030cb..383d9efd3 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -5,7 +5,7 @@ This directory is the repository source of truth for the partner-facing Vortex A ## Structure - `openapi/vortex.openapi.json` is the OpenAPI reference used for the Apidog endpoint catalog. -- `wire-contract.snapshot.md` is the generated snapshot of the typed partner-facing surface (shared endpoint types + public SDK API). CI fails when it is stale; regenerate with `bun run wire-contract:update` and review the diff for backward compatibility. +- `wire-contract.snapshot.md` is the generated snapshot of the partner-facing surface (shared endpoint types, public SDK API, and every mounted `METHOD /path` of the API). CI fails when it is stale; regenerate with `bun run wire-contract:update` and review the diff for backward compatibility. - `pages/*.md` contains the pure Markdown guide pages that sit around the endpoint reference. - `apidog/page-manifest.json` records the intended page order, source files, current Apidog project ID, and endpoint grouping decisions. - `scripts/*.ts` contains the local export, validation, and type-generation helpers for this docs source. diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index d57a4ef2e..a9437d98f 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -3,11 +3,12 @@ Generated by `bun run wire-contract:update` — do not edit by hand. This file is a canonical, structurally expanded rendering of the typed partner-facing -surface: the shared endpoint request/response types and the public SDK API. CI runs -`bun run wire-contract:check` and fails when this snapshot is stale, so every change -to what integrators consume appears as an explicit, reviewable diff in this file. -A diff here means: check backward compatibility for live integrations, and keep -`docs/api/openapi/vortex.openapi.json` and the SDK error mappings in sync. +surface: the shared endpoint request/response types, the public SDK API, and the +mounted HTTP routes. CI runs `bun run wire-contract:check` and fails when this +snapshot is stale, so every change to what integrators consume appears as an +explicit, reviewable diff in this file. A diff here means: check backward +compatibility for live integrations, and keep `docs/api/openapi/vortex.openapi.json` +and the SDK error mappings in sync. ## packages/shared — partner wire contract (`src/endpoints`) @@ -8626,3 +8627,238 @@ parseAPIError: (response: unknown, fallbackStatus?: number) => { readonly status: number; } ``` + +## apps/api — mounted HTTP routes (`apps/api/src/config/express.ts`) + +```text +GET /v1/admin-console/accounts +GET /v1/admin-console/accounts/:profileId +GET /v1/admin-console/impersonation +POST /v1/admin-console/impersonation +DELETE /v1/admin-console/impersonation/:sessionId +GET /v1/admin/api-client-events +GET /v1/admin/managed-profile-managers/:profileId +PUT /v1/admin/managed-profile-managers/:profileId +POST /v1/admin/managed-profile-managers/:profileId/managed-profiles +POST /v1/admin/managed-profiles +POST /v1/admin/monerium-b2b/accounts +PATCH /v1/admin/monerium-b2b/accounts/:accountId/status +POST /v1/admin/partner-pricing-configs +DELETE /v1/admin/partner-pricing-configs/:configId +GET /v1/admin/partners/:partnerName/api-credentials +POST /v1/admin/partners/:partnerName/api-credentials +DELETE /v1/admin/partners/:partnerName/api-credentials/:credentialId +GET /v1/admin/profile-partner-assignments +POST /v1/admin/profile-partner-assignments +DELETE /v1/admin/profile-partner-assignments/:assignmentId +POST /v1/admin/profile-roles +DELETE /v1/admin/profile-roles/:userIdOrEmail/:role +GET /v1/alfredpay/alfredpayStatus +POST /v1/alfredpay/createBusinessCustomer +POST /v1/alfredpay/createIndividualCustomer +GET /v1/alfredpay/fiatAccounts +POST /v1/alfredpay/fiatAccounts +DELETE /v1/alfredpay/fiatAccounts/:fiatAccountId +GET /v1/alfredpay/findKybCustomerAndBusiness +GET /v1/alfredpay/getKybRedirectLink +GET /v1/alfredpay/getKycRedirectLink +GET /v1/alfredpay/getKycStatus +POST /v1/alfredpay/kycRedirectFinished +POST /v1/alfredpay/kycRedirectOpened +POST /v1/alfredpay/retryKyc +POST /v1/alfredpay/sendKybSubmission +POST /v1/alfredpay/sendKycSubmission +POST /v1/alfredpay/submitKybFile +POST /v1/alfredpay/submitKybInformation +POST /v1/alfredpay/submitKybRelatedPersonFile +POST /v1/alfredpay/submitKycFile +POST /v1/alfredpay/submitKycInformation +GET /v1/api-credentials +POST /v1/api-credentials +DELETE /v1/api-credentials/:credentialId +GET /v1/ar/alfredpayStatus +POST /v1/ar/createBusinessCustomer +POST /v1/ar/createIndividualCustomer +GET /v1/ar/fiatAccounts +POST /v1/ar/fiatAccounts +DELETE /v1/ar/fiatAccounts/:fiatAccountId +GET /v1/ar/findKybCustomerAndBusiness +GET /v1/ar/getKybRedirectLink +GET /v1/ar/getKycRedirectLink +GET /v1/ar/getKycStatus +POST /v1/ar/kycRedirectFinished +POST /v1/ar/kycRedirectOpened +POST /v1/ar/retryKyc +POST /v1/ar/sendKybSubmission +POST /v1/ar/sendKycSubmission +POST /v1/ar/submitKybFile +POST /v1/ar/submitKybInformation +POST /v1/ar/submitKybRelatedPersonFile +POST /v1/ar/submitKycFile +POST /v1/ar/submitKycInformation +GET /v1/auth/check-email +POST /v1/auth/refresh +POST /v1/auth/request-otp +POST /v1/auth/verify +POST /v1/auth/verify-otp +POST /v1/brl/createSubaccount +GET /v1/brl/getKycStatus +GET /v1/brl/getSelfieLivenessUrl +POST /v1/brl/getUploadUrls +GET /v1/brl/getUser +GET /v1/brl/getUserRemainingLimit +GET /v1/brl/kyb/attempt-status +POST /v1/brl/kyb/documents +GET /v1/brl/kyb/documents/:documentId +POST /v1/brl/kyb/new-level-1/api +POST /v1/brl/kyb/new-level-1/web-sdk +POST /v1/brl/kyb/ubos +POST /v1/brl/kyc/import-token +POST /v1/brl/kyc/record-attempt +POST /v1/brl/newKyc +GET /v1/brl/validatePixKey +POST /v1/brla/createSubaccount +GET /v1/brla/getKycStatus +GET /v1/brla/getSelfieLivenessUrl +POST /v1/brla/getUploadUrls +GET /v1/brla/getUser +GET /v1/brla/getUserRemainingLimit +GET /v1/brla/kyb/attempt-status +POST /v1/brla/kyb/documents +GET /v1/brla/kyb/documents/:documentId +POST /v1/brla/kyb/new-level-1/api +POST /v1/brla/kyb/new-level-1/web-sdk +POST /v1/brla/kyb/ubos +POST /v1/brla/kyc/import-token +POST /v1/brla/kyc/record-attempt +POST /v1/brla/newKyc +GET /v1/brla/validatePixKey +GET /v1/co/alfredpayStatus +POST /v1/co/createBusinessCustomer +POST /v1/co/createIndividualCustomer +GET /v1/co/fiatAccounts +POST /v1/co/fiatAccounts +DELETE /v1/co/fiatAccounts/:fiatAccountId +GET /v1/co/findKybCustomerAndBusiness +GET /v1/co/getKybRedirectLink +GET /v1/co/getKycRedirectLink +GET /v1/co/getKycStatus +POST /v1/co/kycRedirectFinished +POST /v1/co/kycRedirectOpened +POST /v1/co/retryKyc +POST /v1/co/sendKybSubmission +POST /v1/co/sendKycSubmission +POST /v1/co/submitKybFile +POST /v1/co/submitKybInformation +POST /v1/co/submitKybRelatedPersonFile +POST /v1/co/submitKycFile +POST /v1/co/submitKycInformation +POST /v1/contact/submit +GET /v1/domestic/alfredpayStatus +POST /v1/domestic/createBusinessCustomer +POST /v1/domestic/createIndividualCustomer +GET /v1/domestic/fiatAccounts +POST /v1/domestic/fiatAccounts +DELETE /v1/domestic/fiatAccounts/:fiatAccountId +GET /v1/domestic/findKybCustomerAndBusiness +GET /v1/domestic/getKybRedirectLink +GET /v1/domestic/getKycRedirectLink +GET /v1/domestic/getKycStatus +POST /v1/domestic/kycRedirectFinished +POST /v1/domestic/kycRedirectOpened +POST /v1/domestic/retryKyc +POST /v1/domestic/sendKybSubmission +POST /v1/domestic/sendKycSubmission +POST /v1/domestic/submitKybFile +POST /v1/domestic/submitKybInformation +POST /v1/domestic/submitKybRelatedPersonFile +POST /v1/domestic/submitKycFile +POST /v1/domestic/submitKycInformation +POST /v1/email/create +GET /v1/ip +POST /v1/limits +GET /v1/maintenance/schedules +PATCH /v1/maintenance/schedules/:id/active +GET /v1/maintenance/status +GET /v1/managed-profiles +POST /v1/managed-profiles +DELETE /v1/managed-profiles/:profileId +GET /v1/managed-profiles/:profileId +GET /v1/managed-profiles/:profileId/api-credentials +POST /v1/managed-profiles/:profileId/api-credentials +DELETE /v1/managed-profiles/:profileId/api-credentials/:credentialId +GET /v1/metrics/volumes +GET /v1/monerium-b2b/account +GET /v1/monerium-b2b/deposits +POST /v1/monerium-b2b/webhook +POST /v1/monerium/iban/move +POST /v1/monerium/oauth/complete +POST /v1/monerium/oauth/start +GET /v1/monerium/status +POST /v1/monerium/wallet +GET /v1/mx/alfredpayStatus +POST /v1/mx/createBusinessCustomer +POST /v1/mx/createIndividualCustomer +GET /v1/mx/fiatAccounts +POST /v1/mx/fiatAccounts +DELETE /v1/mx/fiatAccounts/:fiatAccountId +GET /v1/mx/findKybCustomerAndBusiness +GET /v1/mx/getKybRedirectLink +GET /v1/mx/getKycRedirectLink +GET /v1/mx/getKycStatus +POST /v1/mx/kycRedirectFinished +POST /v1/mx/kycRedirectOpened +POST /v1/mx/retryKyc +POST /v1/mx/sendKybSubmission +POST /v1/mx/sendKycSubmission +POST /v1/mx/submitKybFile +POST /v1/mx/submitKybInformation +POST /v1/mx/submitKybRelatedPersonFile +POST /v1/mx/submitKycFile +POST /v1/mx/submitKycInformation +GET /v1/mykobo/profiles +POST /v1/mykobo/profiles +GET /v1/notifications +POST /v1/notifications/:id/read +GET /v1/notifications/preferences +PUT /v1/notifications/preferences +POST /v1/notifications/read-all +PUT /v1/onboarding/active-entity +GET /v1/onboarding/requirements +GET /v1/onboarding/status +GET /v1/prices +GET /v1/prices/all +GET /v1/public-key +POST /v1/quotes +GET /v1/quotes/:id +POST /v1/quotes/best +GET /v1/ramp-info +GET /v1/ramp/:id +GET /v1/ramp/:id/errors +GET /v1/ramp/history +GET /v1/ramp/history/:walletAddress +POST /v1/ramp/register +POST /v1/ramp/start +POST /v1/ramp/update +POST /v1/rating/create +GET /v1/recipients +PATCH /v1/recipients/:id +GET /v1/recipients/:id/eligibility +PATCH /v1/recipients/invitations/:id +POST /v1/recipients/invite +GET /v1/recipients/invite/:token +POST /v1/recipients/invite/:token/accept +POST /v1/session/create +GET /v1/siwe/check +POST /v1/siwe/create +POST /v1/siwe/validate +GET /v1/status +POST /v1/storage/create +GET /v1/supported-countries +GET /v1/supported-cryptocurrencies +GET /v1/supported-fiat-currencies +GET /v1/supported-payment-methods +POST /v1/webhook +DELETE /v1/webhook/:id +POST /v1/webhooks/avenia +``` diff --git a/scripts/wire-contract/fixtures/routes/app.ts b/scripts/wire-contract/fixtures/routes/app.ts new file mode 100644 index 000000000..f09031345 --- /dev/null +++ b/scripts/wire-contract/fixtures/routes/app.ts @@ -0,0 +1,11 @@ +import express from "express"; +import itemRoutes from "./items.route"; +import routes from "./v1"; + +const app = express(); + +app.get("env"); +app.use(["/legacy", "/old"], itemRoutes); +app.use("/v1", routes); + +export default app; diff --git a/scripts/wire-contract/fixtures/routes/dynamic.route.ts b/scripts/wire-contract/fixtures/routes/dynamic.route.ts new file mode 100644 index 000000000..5eb63d903 --- /dev/null +++ b/scripts/wire-contract/fixtures/routes/dynamic.route.ts @@ -0,0 +1,8 @@ +import { Router } from "express"; + +const router = Router(); +const ITEMS_PATH = "/items"; + +router.get(ITEMS_PATH, () => undefined); + +export default router; diff --git a/scripts/wire-contract/fixtures/routes/items.route.ts b/scripts/wire-contract/fixtures/routes/items.route.ts new file mode 100644 index 000000000..f158ce515 --- /dev/null +++ b/scripts/wire-contract/fixtures/routes/items.route.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; + +const router = Router({ mergeParams: true }); +const handler = () => undefined; + +router.route("/").get(handler).post(handler); +router.get("/:id", handler); +router.delete("/:id", handler); + +export default router; diff --git a/scripts/wire-contract/fixtures/routes/v1/index.ts b/scripts/wire-contract/fixtures/routes/v1/index.ts new file mode 100644 index 000000000..b8d14527d --- /dev/null +++ b/scripts/wire-contract/fixtures/routes/v1/index.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; +import itemRoutes from "../items.route"; + +const router = Router(); + +router.get("/status", (_request, response) => response.send("ok")); +router.use(["/items", "/things"], itemRoutes); + +export default router; diff --git a/scripts/wire-contract/generate-report.test.ts b/scripts/wire-contract/generate-report.test.ts index fcfc6a481..0b7746b0e 100644 --- a/scripts/wire-contract/generate-report.test.ts +++ b/scripts/wire-contract/generate-report.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { buildEntryReport } from "./generate-report"; +import { buildEntryReport, buildRouteReport } from "./generate-report"; const FIXTURE_TSCONFIG = "scripts/wire-contract/fixtures/tsconfig.json"; const FIXTURE_ENTRY = "scripts/wire-contract/fixtures/fixture-surface.ts"; @@ -115,3 +115,35 @@ describe("wire-contract surface serializer", () => { expect(report).not.toContain("import("); }); }); + +describe("wire-contract route table", () => { + test("follows app/router mounts, path aliases and route() chains, ignoring the settings getter", () => { + expect(buildRouteReport("scripts/wire-contract/fixtures/routes/app.ts")).toBe( + [ + "GET /legacy", + "POST /legacy", + "DELETE /legacy/:id", + "GET /legacy/:id", + "GET /old", + "POST /old", + "DELETE /old/:id", + "GET /old/:id", + "GET /v1/items", + "POST /v1/items", + "DELETE /v1/items/:id", + "GET /v1/items/:id", + "GET /v1/status", + "GET /v1/things", + "POST /v1/things", + "DELETE /v1/things/:id", + "GET /v1/things/:id" + ].join("\n") + ); + }); + + test("fails loudly on a route path it cannot read statically", () => { + expect(() => buildRouteReport("scripts/wire-contract/fixtures/routes/dynamic.route.ts")).toThrow( + "Unsupported route declaration at scripts/wire-contract/fixtures/routes/dynamic.route.ts:6" + ); + }); +}); diff --git a/scripts/wire-contract/generate-report.ts b/scripts/wire-contract/generate-report.ts index 0f8134406..191d8f813 100644 --- a/scripts/wire-contract/generate-report.ts +++ b/scripts/wire-contract/generate-report.ts @@ -1,8 +1,8 @@ /** * Wire-contract surface report generator. * - * Renders the typed partner-facing surface — the shared endpoint request/response types - * and the public SDK API — into a canonical, structurally expanded snapshot at + * Renders the partner-facing surface — the shared endpoint request/response types, the + * public SDK API, and the mounted API routes — into a canonical snapshot at * docs/api/wire-contract.snapshot.md. Types declared inside this repository are expanded * to their structural shape, so a change to a transitively referenced type (an enum * value, a union member, a nested field) surfaces in the snapshot even when no endpoint @@ -16,7 +16,7 @@ * `bun run build:shared` before regenerating if shared changed. */ import { readFileSync, writeFileSync } from "node:fs"; -import { dirname, isAbsolute, resolve } from "node:path"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; import ts from "typescript"; const REPO_ROOT = resolve(import.meta.dir, "../.."); @@ -354,6 +354,103 @@ export function buildEntryReport(tsconfigPath: string, entryPath: string): strin return sections.join("\n\n"); } +const ROUTE_ROOT = "apps/api/src/config/express.ts"; +const ROUTE_RECEIVERS = new Set(["app", "router"]); +const HTTP_METHODS = new Set(["all", "delete", "get", "patch", "post", "put"]); + +function literalPaths(node: ts.Expression | undefined): string[] { + if (!node) return []; + if (ts.isStringLiteralLike(node)) return [node.text]; + if (ts.isArrayLiteralExpression(node) && node.elements.every(ts.isStringLiteralLike)) { + return node.elements.map(element => (element as ts.StringLiteralLike).text); + } + return []; +} + +function joinRoutePath(prefix: string, path: string): string { + const joined = `${prefix}/${path}`.replace(/\/+/g, "/").replace(/(.)\/$/, "$1"); + return joined === "" ? "/" : joined; +} + +function resolveRouteModule(fromFile: string, specifier: string): string { + const base = resolve(dirname(fromFile), specifier); + for (const candidate of [`${base}.ts`, resolve(base, "index.ts"), base]) { + if (ts.sys.fileExists(candidate)) return candidate; + } + throw new Error(`Cannot resolve ${specifier} from ${fromFile}`); +} + +// Leftmost identifier of a call/property chain, e.g. `router` in router.route("/x").get(h). +function chainRoot(node: ts.Expression): ts.Identifier | undefined { + if (ts.isIdentifier(node)) return node; + if (ts.isPropertyAccessExpression(node)) return chainRoot(node.expression); + if (ts.isCallExpression(node)) return chainRoot(node.expression); + return undefined; +} + +// Path of the router.route("/x") call at the bottom of a .get(h).post(h) chain. +function routeChainPaths(receiver: ts.Expression): string[] { + let current = receiver; + while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) { + if (current.expression.name.text === "route") return literalPaths(current.arguments[0]); + current = current.expression.expression; + } + return []; +} + +function collectRoutes(file: string, prefix: string, routes: Set): void { + const source = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true); + const routerImports = new Map(); + for (const statement of source.statements) { + if ( + ts.isImportDeclaration(statement) && + statement.importClause?.name && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text.startsWith(".") + ) { + routerImports.set(statement.importClause.name.text, resolveRouteModule(file, statement.moduleSpecifier.text)); + } + } + + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { + const receiver = node.expression.expression; + const method = node.expression.name.text; + const root = chainRoot(receiver); + if (root && ROUTE_RECEIVERS.has(root.text)) { + const location = `${relative(REPO_ROOT, file)}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}`; + if (method === "use") { + const paths = literalPaths(node.arguments[0]); + for (const argument of node.arguments) { + const target = ts.isIdentifier(argument) ? routerImports.get(argument.text) : undefined; + if (!target) continue; + for (const path of paths.length > 0 ? paths : [""]) collectRoutes(target, joinRoutePath(prefix, path), routes); + } + } else if (HTTP_METHODS.has(method) && (node.arguments.length > 1 || !ts.isIdentifier(receiver))) { + // A one-argument app.get(name) is Express's settings getter, not a route. + const paths = ts.isIdentifier(receiver) ? literalPaths(node.arguments[0]) : routeChainPaths(receiver); + if (paths.length === 0) throw new Error(`Unsupported route declaration at ${location}: use a string literal path`); + for (const path of paths) routes.add(`${method.toUpperCase()} ${joinRoutePath(prefix, path)}`); + } + } + } + ts.forEachChild(node, visit); + }; + visit(source); +} + +/** + * Lists every mounted `METHOD /path`, following `app.use`/`router.use` mounts statically from + * the Express app. Conditionally mounted routers are listed too. Route modules must declare + * paths as string literals on a variable named `router` (or `app`); anything else throws. + */ +export function buildRouteReport(rootFile: string = ROUTE_ROOT): string { + const routes = new Set(); + collectRoutes(isAbsolute(rootFile) ? rootFile : resolve(REPO_ROOT, rootFile), "", routes); + const byPath = (line: string) => `${line.slice(line.indexOf(" ") + 1)} ${line.slice(0, line.indexOf(" "))}`; + return [...routes].sort((a, b) => (byPath(a) < byPath(b) ? -1 : byPath(a) > byPath(b) ? 1 : 0)).join("\n"); +} + export function buildReport(): string { const parts: string[] = [ "# Wire-contract snapshot", @@ -361,17 +458,19 @@ export function buildReport(): string { "Generated by `bun run wire-contract:update` — do not edit by hand.", "", "This file is a canonical, structurally expanded rendering of the typed partner-facing", - "surface: the shared endpoint request/response types and the public SDK API. CI runs", - "`bun run wire-contract:check` and fails when this snapshot is stale, so every change", - "to what integrators consume appears as an explicit, reviewable diff in this file.", - "A diff here means: check backward compatibility for live integrations, and keep", - "`docs/api/openapi/vortex.openapi.json` and the SDK error mappings in sync.", + "surface: the shared endpoint request/response types, the public SDK API, and the", + "mounted HTTP routes. CI runs `bun run wire-contract:check` and fails when this", + "snapshot is stale, so every change to what integrators consume appears as an", + "explicit, reviewable diff in this file. A diff here means: check backward", + "compatibility for live integrations, and keep `docs/api/openapi/vortex.openapi.json`", + "and the SDK error mappings in sync.", "" ]; for (const entry of ENTRIES) { parts.push(`## ${entry.heading}`, "", "```text", buildEntryReport(entry.tsconfig, entry.entry), "```", ""); } + parts.push(`## apps/api — mounted HTTP routes (\`${ROUTE_ROOT}\`)`, "", "```text", buildRouteReport(), "```", ""); return `${parts.join("\n").trimEnd()}\n`; }