Skip to content

refactor(backend): make request validation schema-driven - #478

Open
solaawojobi00-bit wants to merge 2 commits into
Stellar-Ecosystem:mainfrom
solaawojobi00-bit:claude/schema-driven-request-validation-95wg3v
Open

refactor(backend): make request validation schema-driven#478
solaawojobi00-bit wants to merge 2 commits into
Stellar-Ecosystem:mainfrom
solaawojobi00-bit:claude/schema-driven-request-validation-95wg3v

Conversation

@solaawojobi00-bit

@solaawojobi00-bit solaawojobi00-bit commented Jul 29, 2026

Copy link
Copy Markdown

Validation was spread across addressValidator.js and inline checks in every route handler, so the same rule was written several different ways and there was no single place to see what a route accepts.

Every route now declares its request as a zod schema in src/schemas/. One middleware (src/middleware/validate.js) enforces those schemas before the handler runs; handlers read coerced values from req.valid and do no checking of their own. addressValidator.js is gone, folded into schemas/common.js.

Validation failures share one response shape. error and code keep the values the API already returned, so clients see no break, and a new details array lists every problem found rather than only the first.

The OpenAPI 3.1 document is generated from the same schemas via zod's toJSONSchema, served at GET /api/openapi.json and emitted by npm run openapi. It cannot drift from what the server enforces.

Rules that were inconsistent between endpoints are now uniform, which changes some behaviour:

  • :id params are strict positive integers everywhere. Previously /services/:id used parseInt, so "7abc" was read as 7, while /services/:id/deactivate rejected it.
  • Non-numeric query params are rejected instead of silently defaulting. ?lat=abc was a 400 only by accident before; ?lat=0 became 40.7128 because the old code used parseFloat(x) || default.
  • POST /agents/:address/payment validates its body before the idempotency replay lookup rather than after.
  • amountUsdc must parse as a number; it used to reach BigInt(NaN) and surface as a 500 instead of a 400.

parseActivityPagination is removed: the activity and payment-history schemas own those rules now, and keeping it would restore the duplication this change exists to remove. Its cases moved to src/schemas/common.test.js.

src/schemas/routes.test.js walks the Express routers and fails if a route is registered without a declared schema, if a declared schema has no route, or if a route accepting input is missing the validation middleware.

Closes #346

Claude-Session: https://claude.ai/code/session_01Gy7MYRindtXZLNU4fE4kpQ

Summary by CodeRabbit

  • New Features

    • Added centralized request validation with consistent HTTP 400 error responses and field-level details.
    • Added OpenAPI specification generation and made the specification available at /api/openapi.json.
    • Added validation for agent, registry, demo, service, pagination, address, and payment inputs.
  • Documentation

    • Updated the README with validation, error formats, OpenAPI usage, and testing guidance.
  • Tests

    • Added coverage for validation behavior, schema rules, route coverage, and OpenAPI output.

Validation was spread across addressValidator.js and inline checks in every
route handler, so the same rule was written several different ways and there
was no single place to see what a route accepts.

Every route now declares its request as a zod schema in src/schemas/. One
middleware (src/middleware/validate.js) enforces those schemas before the
handler runs; handlers read coerced values from req.valid and do no checking
of their own. addressValidator.js is gone, folded into schemas/common.js.

Validation failures share one response shape. `error` and `code` keep the
values the API already returned, so clients see no break, and a new `details`
array lists every problem found rather than only the first.

The OpenAPI 3.1 document is generated from the same schemas via zod's
toJSONSchema, served at GET /api/openapi.json and emitted by `npm run openapi`.
It cannot drift from what the server enforces.

Rules that were inconsistent between endpoints are now uniform, which changes
some behaviour:

- `:id` params are strict positive integers everywhere. Previously
  /services/:id used parseInt, so "7abc" was read as 7, while
  /services/:id/deactivate rejected it.
- Non-numeric query params are rejected instead of silently defaulting.
  ?lat=abc was a 400 only by accident before; ?lat=0 became 40.7128 because
  the old code used `parseFloat(x) || default`.
- POST /agents/:address/payment validates its body before the idempotency
  replay lookup rather than after.
- amountUsdc must parse as a number; it used to reach BigInt(NaN) and surface
  as a 500 instead of a 400.

parseActivityPagination is removed: the activity and payment-history schemas
own those rules now, and keeping it would restore the duplication this change
exists to remove. Its cases moved to src/schemas/common.test.js.

src/schemas/routes.test.js walks the Express routers and fails if a route is
registered without a declared schema, if a declared schema has no route, or if
a route accepting input is missing the validation middleware.

Closes Stellar-Ecosystem#346

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gy7MYRindtXZLNU4fE4kpQ
@drips-wave

drips-wave Bot commented Jul 29, 2026

Copy link
Copy Markdown

@solaawojobi00-bit Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The backend adds Zod-based request schemas, centralized validation middleware, standardized 400 responses, route integrations, schema coverage tests, and OpenAPI generation exposed through the API and a CLI command.

Changes

Request validation and OpenAPI

Layer / File(s) Summary
Shared and route schema contracts
backend/src/schemas/*, backend/src/schemas/*.test.js
Shared Zod validators and route descriptors define normalized parameters, bodies, headers, pagination, and metadata for agent, registry, service, and demo routes.
Validation middleware and route migration
backend/src/middleware/validate.js, backend/src/routes/agents.js, backend/src/routes/registry.js, backend/src/routes/services.js, backend/src/routes/demo.js, backend/src/lib/activityFeed.js, backend/test/*, backend/src/routes/*.test.js
Routes use validate(...) and consume parsed values from req.valid. Inline validation and activity pagination parsing are removed. Middleware and route wiring tests cover the new behavior.
OpenAPI generation and publication
backend/src/lib/openapi.js, backend/src/routes/openapi.js, backend/scripts/generate-openapi.js, backend/src/index.js, backend/package.json, backend/src/lib/openapi.test.js, README.md
OpenAPI documents are generated from route schemas, served at /api/openapi.json, available through the openapi script, and documented with validation behavior and coverage tests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Express
  participant openapiRouter
  participant buildOpenApiDocument
  participant schemas
  Express->>openapiRouter: dispatch GET /api/openapi.json
  openapiRouter->>buildOpenApiDocument: build document at startup
  buildOpenApiDocument->>schemas: read route descriptors and Zod schemas
  schemas-->>buildOpenApiDocument: route metadata and schemas
  buildOpenApiDocument-->>openapiRouter: OpenAPI document
  openapiRouter-->>Client: JSON specification
Loading

Possibly related PRs

Suggested reviewers: ritik4ever

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: schema-driven backend request validation.
Linked Issues check ✅ Passed The changes define route schemas, centralize validation, standardize errors, generate OpenAPI from schemas, and remove inline validation.
Out of Scope Changes check ✅ Passed The documented changes and tests support the linked issue objectives without introducing unrelated code changes.
Docstring Coverage ✅ Passed Docstring coverage is 85.19% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@solaawojobi00-bit

Copy link
Copy Markdown
Author

Please review and Merge

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
backend/src/schemas/services.js (1)

10-14: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

demoRunId has no length limit.

It's optional but otherwise unconstrained, and is echoed into the activity feed per its own description. Consider adding a .max(...) to bound it, consistent with other length-constrained fields in this schema set (e.g. requiredString).

♻️ Suggested bound
 const demoRunId = z
   .string()
+  .max(100, { error: "`demoRunId` is too long" })
   .optional()
   .describe("Opaque ID echoed into the activity feed");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/schemas/services.js` around lines 10 - 14, Update the demoRunId
schema definition to enforce a maximum string length, using the same established
bound as other constrained fields such as requiredString. Preserve its optional
behavior and activity-feed description.
backend/src/schemas/registry.js (2)

120-124: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

endpoint only checks the prefix, not URL validity.

startsWith("https://") doesn't guarantee a parseable URL — "https://" alone, or a string with embedded whitespace/control characters, would pass. Consider layering z.url() (or a new URL() refinement) on top of the prefix check so malformed endpoints are rejected here instead of failing later when the service is invoked.

♻️ Suggested tightening
 endpoint: z
   .string({ error: "`endpoint` must start with https://" })
   .trim()
   .startsWith("https://", { error: "`endpoint` must start with https://" })
+  .refine((v) => { try { new URL(v); return true; } catch { return false; } }, {
+    error: "`endpoint` must be a valid URL",
+  })
   .describe("Public HTTPS URL the service is served from"),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/schemas/registry.js` around lines 120 - 124, Update the endpoint
schema to validate both the required https:// prefix and full URL parseability,
using z.url() or an equivalent URL refinement after trimming. Ensure bare or
malformed URLs, including embedded whitespace/control characters, are rejected
while preserving the existing public HTTPS URL description and error behavior.

50-52: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

page has no upper bound, unlike other paginated fields.

limit/offset elsewhere in this PR (e.g. getActivity in services.js) are clamped via max. page here is unbounded, so an arbitrarily large value flows straight into listServices({ page, pageSize: PAGE_SIZE }).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/schemas/registry.js` around lines 50 - 52, Update the page query
parameter definition in the registry schema to enforce an upper bound using the
same max-clamping pattern as other paginated fields. Preserve the existing
zero-based default and description while ensuring large page values cannot flow
unbounded into listServices.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/src/schemas/common.js`:
- Around line 123-131: Update stroopsField to validate string values as
well-formed positive integer amounts before they reach BigInt conversion,
rejecting non-numeric strings and negative values while preserving the existing
positive-number validation and error message. Mirror the validation approach
used by positiveIntegerField rather than only checking string length.

---

Nitpick comments:
In `@backend/src/schemas/registry.js`:
- Around line 120-124: Update the endpoint schema to validate both the required
https:// prefix and full URL parseability, using z.url() or an equivalent URL
refinement after trimming. Ensure bare or malformed URLs, including embedded
whitespace/control characters, are rejected while preserving the existing public
HTTPS URL description and error behavior.
- Around line 50-52: Update the page query parameter definition in the registry
schema to enforce an upper bound using the same max-clamping pattern as other
paginated fields. Preserve the existing zero-based default and description while
ensuring large page values cannot flow unbounded into listServices.

In `@backend/src/schemas/services.js`:
- Around line 10-14: Update the demoRunId schema definition to enforce a maximum
string length, using the same established bound as other constrained fields such
as requiredString. Preserve its optional behavior and activity-feed description.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 32477e57-6525-4ab8-937e-48e95e74b4f5

📥 Commits

Reviewing files that changed from the base of the PR and between a3fd824 and 7851cce.

⛔ Files ignored due to path filters (1)
  • backend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (27)
  • README.md
  • backend/package.json
  • backend/scripts/generate-openapi.js
  • backend/src/index.js
  • backend/src/lib/activityFeed.js
  • backend/src/lib/openapi.js
  • backend/src/lib/openapi.test.js
  • backend/src/middleware/addressValidator.js
  • backend/src/middleware/validate.js
  • backend/src/middleware/validate.test.js
  • backend/src/routes/agents.js
  • backend/src/routes/agents.test.js
  • backend/src/routes/demo.js
  • backend/src/routes/openapi.js
  • backend/src/routes/registry.js
  • backend/src/routes/services.js
  • backend/src/routes/services.test.js
  • backend/src/schemas/agents.js
  • backend/src/schemas/common.js
  • backend/src/schemas/common.test.js
  • backend/src/schemas/demo.js
  • backend/src/schemas/index.js
  • backend/src/schemas/registry.js
  • backend/src/schemas/routes.test.js
  • backend/src/schemas/services.js
  • backend/test/activity.test.js
  • backend/test/agents.test.js
💤 Files with no reviewable changes (3)
  • backend/src/middleware/addressValidator.js
  • backend/src/routes/services.test.js
  • backend/src/routes/agents.test.js

Comment on lines +123 to +131
export function stroopsField(field) {
const message = `\`${field}\` is required (string or number)`;
return z
.union([z.number(), z.string()], { error: message })
.refine((value) => (typeof value === "number" ? value > 0 : value.length > 0), {
error: message,
})
.describe("Amount in stroops (1 USDC = 10,000,000 stroops)");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

stroopsField doesn't actually validate the string case.

.refine((value) => (typeof value === "number" ? value > 0 : value.length > 0), ...) only checks that a string is non-empty — not that it's a well-formed, non-negative integer. "abc" or "-100" both pass this schema. maxPerTxStroops/maxPerDayStroops flow from here straight into contract.js's BigInt() conversion (per the comment on line 121), so:

  • a non-numeric string throws a SyntaxError at BigInt(), producing an unhandled-style 500 instead of the clean 400 the rest of this file is designed to guarantee (see the usdcAmountField docstring on the exact same failure mode, lines 96-99).
  • a negative numeric string like "-100" parses fine and would set a negative spending limit on-chain.
🛡️ Proposed fix (mirrors `positiveIntegerField`)
 export function stroopsField(field) {
   const message = `\`${field}\` is required (string or number)`;
   return z
     .union([z.number(), z.string()], { error: message })
-    .refine((value) => (typeof value === "number" ? value > 0 : value.length > 0), {
-      error: message,
-    })
+    .transform((value, ctx) => {
+      const wellFormed =
+        typeof value === "number" ? Number.isSafeInteger(value) && value > 0
+          : POSITIVE_INT_REGEX.test(value);
+      if (!wellFormed) {
+        ctx.addIssue({ code: "custom", message });
+        return z.NEVER;
+      }
+      return value; // keep original number/string — contract.js owns the BigInt conversion
+    })
     .describe("Amount in stroops (1 USDC = 10,000,000 stroops)");
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function stroopsField(field) {
const message = `\`${field}\` is required (string or number)`;
return z
.union([z.number(), z.string()], { error: message })
.refine((value) => (typeof value === "number" ? value > 0 : value.length > 0), {
error: message,
})
.describe("Amount in stroops (1 USDC = 10,000,000 stroops)");
}
export function stroopsField(field) {
const message = `\`${field}\` is required (string or number)`;
return z
.union([z.number(), z.string()], { error: message })
.transform((value, ctx) => {
const wellFormed =
typeof value === "number" ? Number.isSafeInteger(value) && value > 0
: POSITIVE_INT_REGEX.test(value);
if (!wellFormed) {
ctx.addIssue({ code: "custom", message });
return z.NEVER;
}
return value; // keep original number/string — contract.js owns the BigInt conversion
})
.describe("Amount in stroops (1 USDC = 10,000,000 stroops)");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/schemas/common.js` around lines 123 - 131, Update stroopsField to
validate string values as well-formed positive integer amounts before they reach
BigInt conversion, rejecting non-numeric strings and negative values while
preserving the existing positive-number validation and error message. Mirror the
validation approach used by positiveIntegerField rather than only checking
string length.

@ritik4ever

Copy link
Copy Markdown
Collaborator

Hi @solaawojobi00-bit,

This PR could not be merged because it has merge conflicts with the target branch.

Please resolve the merge conflicts, push the updated changes, and the PR can be reviewed and merged.

Thank you!

Resolved conflicts across 6 files where upstream's hand-rolled request
parsing collided with this branch's schema-driven validation (Stellar-Ecosystem#478):

- backend/package.json: kept both branches' additions (openapi/bench
  scripts, zod/pino-pretty deps)
- backend/package-lock.json: regenerated via npm install
- backend/src/routes/agents.js: kept schema-validated req.valid.* over
  upstream's manual parseInt/parseFloat parsing
- backend/src/routes/registry.js: kept schema validation for
  GET /services, restored the annotateTtlWarning helper (missing on
  this branch, needed after adopting upstream's offset/limit
  contract.js pagination interface), dropped now-redundant hand-rolled
  helpers
- backend/src/schemas/registry.js: switched listServices to
  offset/limit to match the current contract.js interface; raised
  name/description max length to 64/256 to match a business-rule
  change made independently upstream
- backend/src/routes/services.js: kept schema validation for
  GET /search, restored upstream's query sanitization (control-char
  stripping, 256-char cap) and the usdcToStroops import upstream's
  merged code still needs

Also fixes two tests left asserting pre-refactor behavior, surfaced by
the merge:

- backend/src/lib/openapi.test.js: expected name maxLength 50, now 64
- backend/test/demo.test.js: asserted the old single-string error
  message; now checks code and the per-field details array the
  validate() middleware actually returns
@solaawojobi00-bit

Copy link
Copy Markdown
Author

Hi @ritik4ever, the merge conflicts are resolved and pushed (f02e082) — this branch now shows as cleanly mergeable against main. Ready for review whenever you get a chance. Thanks!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
README.md (1)

5-5: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the readiness claim with Project Status and Roadmap.

Line 5 calls Lodestar a “production-grade open source project”. Line 13 states that it is an early-stage demo and is not production-grade. Replace the opening claim with “demo-ready”, or update the maturity section if production readiness is intended.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 5, Update the opening README description around the
Lodestar project summary to replace the conflicting “production-grade” readiness
claim with “demo-ready,” while preserving the rest of the feature description
and keeping it consistent with the Project Status and Roadmap maturity
statement.
backend/src/routes/demo.js (1)

84-89: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Keep the disconnect listener active for the full cancellable operation.

Register the listener before getService() awaits, and remove it in finally. Also pass the abort signal into waitForActivityTxHash() and cancel its sleep loop, because the route currently exits fetchWithTx() at header receipt and then continues with response.json() and polling after a client close.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/routes/demo.js` around lines 84 - 89, Update the route’s
request-disconnect handling around getService(), fetchWithTx(), response.json(),
and waitForActivityTxHash(): register the close listener before any await, pass
the same abort signal to waitForActivityTxHash() and make its sleep loop
cancellable, then remove the listener in a finally block so cancellation remains
active through the entire operation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@backend/src/routes/demo.js`:
- Around line 84-89: Update the route’s request-disconnect handling around
getService(), fetchWithTx(), response.json(), and waitForActivityTxHash():
register the close listener before any await, pass the same abort signal to
waitForActivityTxHash() and make its sleep loop cancellable, then remove the
listener in a finally block so cancellation remains active through the entire
operation.

In `@README.md`:
- Line 5: Update the opening README description around the Lodestar project
summary to replace the conflicting “production-grade” readiness claim with
“demo-ready,” while preserving the rest of the feature description and keeping
it consistent with the Project Status and Roadmap maturity statement.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9718a1a2-979e-462a-a323-b73f20692d02

📥 Commits

Reviewing files that changed from the base of the PR and between 7851cce and f02e082.

⛔ Files ignored due to path filters (1)
  • backend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (12)
  • README.md
  • backend/package.json
  • backend/src/index.js
  • backend/src/lib/openapi.test.js
  • backend/src/routes/agents.js
  • backend/src/routes/demo.js
  • backend/src/routes/registry.js
  • backend/src/routes/services.js
  • backend/src/routes/services.test.js
  • backend/src/schemas/registry.js
  • backend/test/activity.test.js
  • backend/test/demo.test.js
🚧 Files skipped from review as they are similar to previous changes (8)
  • backend/src/index.js
  • backend/package.json
  • backend/test/activity.test.js
  • backend/src/routes/services.test.js
  • backend/src/lib/openapi.test.js
  • backend/src/schemas/registry.js
  • backend/src/routes/services.js
  • backend/src/routes/agents.js

@solaawojobi00-bit

Copy link
Copy Markdown
Author

Also flagging: while resolving conflicts, I found two pre-existing issues in main unrelated to this PR: rate-limit-redis and pino-http are imported in src/middleware/rateLimiter.js and requestContext.js but never declared in package.json, so both test files fail to load. Left both untouched since they predate this PR. Happy to open a separate issue if useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backend: request validation is hand-rolled rather than schema-driven

3 participants