Skip to content

fix: the HTTP example contract validates its inputs - #76

Merged
btravers merged 10 commits into
mainfrom
fix/validate-http-contract
Aug 20, 2026
Merged

fix: the HTTP example contract validates its inputs#76
btravers merged 10 commits into
mainfrom
fix/validate-http-contract

Conversation

@btravers

Copy link
Copy Markdown
Contributor

Closes #74.

The problem

oRPC's type<T>() performs no runtime validation. Its own doc:

Create a schema for things can be trust without validation.

Its runtime validate returns the value unchanged. examples/order-api-contract
declared every input, output and error payload with it, so every procedure
accepted whatever a client sent — { id: null, quantity: "abc" } reached the
use case typed string / number.

It was also the outlier. examples/order-amqp-contract,
examples/order-temporal-contract and examples/order-domain were already on
zod with real object schemas, and both worker starters carry it. The one
transport whose input arrives from a browser was the one validating nothing.

What changed

The schemas are the source; the types are inferred from them.

const orderView = z.object({ id: z.string(), quantity: z.number() });
export type OrderView = z.infer<typeof orderView>;

rather than a hand-written type the schema had to mirror. One definition, so the
checked shape and the compiled shape cannot drift — which is what
order-temporal-contract already does.

All 10 sites move: inputs, outputs and error data. tenanted.extend({ id })
where a shape composes.

Three decisions

BAD_REQUEST stays undeclared. oRPC throws
ORPCError("BAD_REQUEST", { message: "Input validation failed", data: { issues } })
before the handler runs, and it is not in any procedure's error map — so a client
gets it on the defect channel, the same treatment an undeclared UNAUTHORIZED
gets. Declaring it would be identical noise on every procedure, and this repo's
contracts declare only errors the domain produces. With zod in the contract a
typed client cannot construct a malformed input at all; the 400 guards untyped
and hostile callers, who do not read error maps.

type<T>() keeps no home in this contract. Once the schemas are the source,
outputs and error payloads come from the same objects the types are inferred
from, so there is nothing left to declare trusted-without-validation. Output
validation also catches a handler returning the wrong shape.

The framework's position does not move. No schema library in
@btravstack/*, any Standard Schema accepted, the application picks one. This is
about what the examples teach, and two of three already taught it.

Two specs, both mutation-checked

  • A malformed input is refused, BAD_REQUEST on the defect channel.
  • The handler is never entered.

Both were re-run with the contract reverted to type<>() and both fail there, so
they pin validation rather than restating it.

The second one is worth a note. It first asserted that nothing was stored — and
that passes either way, because the domain rejects "abc" too. It measured
the domain's invariant while claiming to prove the use case never ran. It now
reads the recording sink and asserts which lines were written: the controller and
interactor lines absent, the request-scope line still present because the unit
does open.

Documentation

52 type<>() occurrences across 9 pages, all converted, none kept. Six
mentions of the form survive, all prose naming it to say why the sample avoids
it. Leaving the tutorials on the unvalidated form while the example validates
would have been fresh drift, and a reader's first contact is where it does most
damage.

Every touched sample was compiled in a scratch file inside the workspace whose
dependencies it needs and then deleted, per the repo's rule.
split-a-router-into-controllers was compiled end to end — fragments through
both controllers, both slice modules, the keyed root and the lifted single-slice
root — so the conversion is proved to flow rather than merely parse.

Not fixed here — #75

Six documentation pages still describe examples/order-api before it had
authentication: no marker on contract.orders, no src/auth.ts, and
.execute(input.id, input.quantity) where the shipped controller serves
context.principal.tenantId. That drift came in with #73, not with this change.
It is filed as #75 rather than folded in, because docs/examples/order-api.md
documents the example file by file and is missing a whole file — a section
rewrite, not an edit, and unrelated to input validation.

Gate

format --check · lint · typecheck 31/31 · knip · test 29/29 (Docker) ·
build 10/10 · docs build — all green.

type<T>() is oRPC's escape hatch for values trusted without validation — its
runtime validate returns the input unchanged — so every procedure accepted
whatever a client sent and { quantity: "abc" } reached the use case typed
number. The zod schemas are the source now and the TS types are inferred from
them, matching order-temporal-contract and order-amqp-contract.

Two specs, both mutation-checked against the contract reverted to type<>():
one that a malformed input is refused, one that the handler is never entered.
The second reads the recording sink rather than the stored row, because the
domain refuses "abc" too — a stored-row assertion passes either way.
Copilot AI lite review requested due to automatic review settings August 20, 2026 00:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Updates the HTTP example’s oRPC contract to use real runtime-validated schemas (zod) instead of type<T>(), aligning the HTTP example with the Temporal/AMQP examples and preventing malformed inputs from reaching handlers typed as trusted shapes.

Changes:

  • Replace type<T>() usage in examples/order-api-contract with zod schemas and inferred types.
  • Add API-level specs proving malformed inputs are rejected as BAD_REQUEST before handler dispatch.
  • Update tutorial/how-to/reference/example docs to stop teaching type<T>() for unvalidated boundaries and to show schema-first contracts.

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
pnpm-lock.yaml Adds zod to the example contract’s lockfile resolution.
examples/order-api/src/api.spec.ts Adds two specs that pin input validation behavior and pre-dispatch refusal.
examples/order-api-contract/src/contract.ts Converts the HTTP example contract to zod schemas + inferred types.
examples/order-api-contract/package.json Adds zod dependency for the contract package.
docs/tutorial/second-runtime.md Adjusts tutorial prose about why schemas matter (Temporal replay).
docs/tutorial/getting-started.md Updates install instructions and contract sample to schema-first + validation note.
docs/reference/contract.md Updates reference samples to use zod schemas instead of type<T>().
docs/index.md Updates homepage sample to schema-first contracts and adds supporting schema constants.
docs/how-to/split-a-router-into-controllers.md Updates how-to samples and prose to schema-first contracts + inferred types.
docs/how-to/serve-orpc-over-http.md Updates how-to samples and adds rationale for schema-first contracts.
docs/how-to/protect-a-procedure.md Updates protected-procedure samples to schema-first contract definitions.
docs/examples/order-application.md Updates example narrative/code snippet to schema-first contract definitions.
docs/examples/order-api.md Updates example narrative/code snippet to schema-first contract definitions.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread docs/examples/order-api.md
Comment thread examples/order-api-contract/src/contract.ts
Comment thread docs/examples/order-application.md
Converting to zod, I typed the customers NOT_FOUND payload with orderRef —
documented as 'which order it was about'. The original used an anonymous shape
precisely to avoid that, and the exported type would have lied to a client
about which entity it names. It has customerRef now, same shape, its own name.

The two examples/ pages also gained the imports their fences reference: those
pages are fragment-style throughout, so oc was already dangling before this
change, but a reader meeting z.object with no idea where z comes from is worse
than a fence that carries two import lines. Both fences compiled.
@btravers
btravers merged commit cc31ed1 into main Aug 20, 2026
13 checks passed
@btravers
btravers deleted the fix/validate-http-contract branch August 20, 2026 07:46
btravers added a commit that referenced this pull request Aug 20, 2026
Three from review, one of them a repeat: split-a-router's customers fragment
typed its NOT_FOUND payload with orderRef — the same defect fixed in the real
contract in #76, propagated into a sample the sweep touched but did not
question. It has its own customerRef now, with the comment saying why.

serve-orpc-over-http still said 'Two gates hold at compile time' after this
branch marked its contract, which adds a third. And the example page's
authenticator fence referenced HttpAuthenticator, Unauthenticated, ErrAsync
and OkAsync without importing any of them.

Both changed fences extracted and compiled in scratch files, then deleted.
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.

The HTTP example contract validates nothing: type<>() is not a schema

2 participants