fix: the HTTP example contract validates its inputs - #76
Merged
Conversation
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.
There was a problem hiding this comment.
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 inexamples/order-api-contractwith zod schemas and inferred types. - Add API-level specs proving malformed inputs are rejected as
BAD_REQUESTbefore 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.
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #74.
The problem
oRPC's
type<T>()performs no runtime validation. Its own doc:Its runtime
validatereturns the value unchanged.examples/order-api-contractdeclared every input, output and error payload with it, so every procedure
accepted whatever a client sent —
{ id: null, quantity: "abc" }reached theuse case typed
string/number.It was also the outlier.
examples/order-amqp-contract,examples/order-temporal-contractandexamples/order-domainwere already onzod 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.
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-contractalready does.All 10 sites move: inputs, outputs and error
data.tenanted.extend({ id })where a shape composes.
Three decisions
BAD_REQUESTstays undeclared. oRPC throwsORPCError("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
UNAUTHORIZEDgets. 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 isabout what the examples teach, and two of three already taught it.
Two specs, both mutation-checked
BAD_REQUESTon the defect channel.Both were re-run with the contract reverted to
type<>()and both fail there, sothey 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 measuredthe 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. Sixmentions 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-controllerswas compiled end to end — fragments throughboth 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-apibefore it hadauthentication: no marker on
contract.orders, nosrc/auth.ts, and.execute(input.id, input.quantity)where the shipped controller servescontext.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.mddocuments 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·typecheck31/31 ·knip·test29/29 (Docker) ·build10/10 · docs build — all green.