Repository navigation
fix: validate POST /admin/keys body with zod (#506) - #595
Conversation
|
@CHKM001 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! 🚀 |
tsc --noEmit clean vitest 106 files, 1232 passing (up from 102 / 1157) dropped-tests none Four of the eight screened in this group. The other four were held, and the reason is worth stating because it keeps recurring: each one merges cleanly and fails on its own. #590 72 type errors alone, including syntax errors in src/api/rest.ts and src/api/schemas.ts -- "Declaration or statement expected", "Expression expected". Not type mismatches; the file does not parse. #584 3 type errors alone #592 conflicts #595 conflicts Merged together before screening, that group produced 68 failing test files and syntax errors I first mistook for a bad three-way merge. They were not: #590 carries them on its own branch. Screening each PR individually before batching costs one typecheck per PR and saves an hour of bisection, which is the lesson of the day. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Miracle656
left a comment
There was a problem hiding this comment.
Moving this to zod is the right call — declarative, one error shape, and superRefine keeping the future-date check on expiresAt rather than losing it in translation. It needs a rebase before I can take it, because main grew API key scopes after you cut this branch.
scopes would be silently dropped
CreateKeySchema has no scopes field, and the word does not appear anywhere in this branch's src/api/admin.ts. Zod strips unknown keys by default, so a caller posting
{ "label": "scoped-partner", "scopes": ["read:prices", "subscribe"] }gets a 201 and a key with no scopes at all — no error, no warning. Silently minting a key with different permissions than the operator asked for is the bad direction for this particular endpoint to be wrong in.
main currently validates and normalises them:
if (scopes !== undefined) {
if (!Array.isArray(scopes) || scopes.some(s => typeof s !== 'string')) {
return reply.status(400).send({ error: 'scopes must be an array of strings' })
}
}
const normalizedScopes = scopes !== undefined
? Array.from(new Set(scopes.map(s => s.trim()).filter(Boolean)))
: undefinedand src/__tests__/auth.test.ts has 22 references to scopes, including mints a key with a scope set and lists keys with scopes visible. Those fail on this branch.
What it needs
Add scopes to the schema, which also lets zod do the normalisation the hand-rolled version does by hand:
scopes: z
.array(z.string(), { error: 'scopes must be an array of strings' })
.optional()
.transform(s => (s === undefined ? undefined : Array.from(new Set(s.map(v => v.trim()).filter(Boolean))))),Then rebase on current main — you are a long way back and src/__tests__/auth.test.ts conflicts as well, because the scope tests landed in the same region you rewrote.
Worth knowing
Two other open PRs touch admin key handling: #569 and #593 both add paginated key listing, and #574 adds an admin auth hook. Whoever lands first will move src/api/admin.ts under the others. If you rebase soon this is the smallest of the four and the easiest to get in.
The approach is right and I want it — a hand-rolled validator on an endpoint that mints credentials is exactly the thing that should be a schema.
Rates and quota cents (ratePerMin, ratePerDay, monthlyQuotaCents, dailyQuotaCents) went to the database exactly as sent: a negative, a zero, a fraction, or an out-of-range number were all accepted and only surfaced later as a key that could never call anything or was never limited. overagePolicy's TypeScript union is erased at runtime, so any string was stored and metering silently fell back to 'block'. Validate the body with zod and reject rather than coerce: - Every numeric field is a positive integer in [1, 2_147_483_647] (the Int column ceiling); zero, negatives and fractions are rejected. - overagePolicy is restricted to the three values metering understands. - ratePerDay must be >= ratePerMin — a per-day cap below the per-minute cap can never bind. - Errors go through the shared formatValidationError so they name the offending field, matching the other routes. Tests cover a negative, a fractional, a zero, an out-of-range value, an unknown policy, a contradictory rate pair and a blank label. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
b2e11eb to
f58ebcf
Compare
The /rates endpoint (added by Divinity-Labs#453) references #/components/responses/BadRequest, but that response component was never defined. openapi-typescript aborts with 'Can't resolve $ref at #/paths/~1rates/get/responses/400', so 'npm run client:gen' — the first step of CI's Typecheck & build job — fails before tsc runs, turning the check red for every open PR. Define BadRequest with the same minimal shape every other 400 in the spec uses (description: Invalid parameters) and regenerate the committed artifacts: - openapi.json via openapi:gen - clients/typescript/src/schema.d.ts via client:gen, which had gone stale (missing the whole /rates path) precisely because generation was aborting, so the 'git diff --exit-code -- clients/typescript' gate could never pass either. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Merged as The
That is exactly the failure. A zod object strips what it does not declare, so adding validation would have quietly removed Folding the trim/de-dupe/drop-empty into the field's transform is the right follow-through: .transform((s) => s === undefined
? undefined
: Array.from(new Set(s.map((v) => v.trim()).filter(Boolean))))The route used to do that by hand. Now there is one place that decides what a stored scope list looks like, rather than two that can drift — and The rest is solid: 135 test files / 1,626 tests pass; |
Rebased as asked: the pagination is folded into the existing handler rather than added as a second route, and main's selected columns survive — lastUsedAt from #587 and the quota/overage fields are all still there. `parsePageValue` rejects rather than coerces, and caps limit at MAX_KEY_PAGE_SIZE. Bounding offset at Number.MAX_SAFE_INTEGER as well is more than most listings do and is the reason a hostile offset cannot turn into a silently wrong query. The conflict with #595 was additive on both sides — main's zod schemas against this branch's pagination helpers — and is resolved as a union, keeping both. tsc clean, 1,734 tests pass. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Validate the POST /admin/keys body with zod (#506)
Problem
POST /admin/keysvalidatedlabeland nothing else. Every other field wentto the database exactly as sent:
ratePerMin,ratePerDay,monthlyQuotaCentsanddailyQuotaCentsaccepted a negative, a zero, afraction, or a number large enough to mean "no limit" — all silently. The
mistake only showed up later as a key that could not call anything or one
that was never limited.
overagePolicywas unchecked. The TypeScript union(
'block' | 'charge_402' | 'allow_overage') is erased at runtime, so anystring was stored.
src/x402/metering.tsfalls back to'block'foranything it does not recognise, so a typo became a silent policy change.
Solution
Validate the whole body with zod — the pattern the other routes already use —
and reject rather than coerce, so a bad value fails loudly at creation
instead of producing a subtly broken key.
[1, 2_147_483_647]. Theupper bound is the Postgres
Intcolumn ceiling (32-bit signed): a largervalue could not be stored without overflow anyway. Zero, negatives and
fractions are rejected.
overagePolicyis az.enumof exactly the three values meteringunderstands; anything else is rejected instead of defaulting to
block.ratePerDaymust be ≥ratePerMinwhen both are supplied — a per-day capbelow the per-minute cap can never bind and is always a mistake.
formatValidationError(src/validation.ts), so a caller seesInvalid <field>: <reason>with theVALIDATION_ERRORcode, identical inshape to every other route's rejection.
label(non-empty after trim) andexpiresAt(a valid future date)validation is preserved, now expressed in the same schema.
Changes
src/api/admin.ts— addCreateKeySchema(zod) and validatereq.bodywith
safeParse; on failure returnformatValidationError(...)with a 400.A
quotaField(name)helper builds the bounded positive-integer rule for eachnumeric field; a
superRefineenforces the future-date andratePerDay ≥ ratePerMincross-field rules. The create call now reads fromparsed.data(label already trimmed).src/__tests__/auth.test.ts— 8 new tests under theadmin endpointssuite (below).
Acceptance criteria
rejects a negative rate, rejects a fractional quota, rejects a zero
rate, rejects an out-of-range value above the Int column ceiling.
overagePolicyoutside the known set is rejected, not silently defaulted —rejects an unknown overagePolicy instead of silently defaulting to block.
rejects a per-day rate below the per-minute rate.
appears in
body.error, viaformatValidationError.policy — plus a zero rate, a contradictory rate pair, and a blank
(whitespace-only) label.
Each rejection also asserts the database was never touched
(
mockCreatenot called).Testing
npx tsc --noEmit— clean (afterprisma generate).npx vitest run src/__tests__/auth.test.ts— 30 passed (8 new).Notes / trade-offs
ratePerDay ≥ ratePerMincheck only fires when both values are in therequest. If only one is sent, the other keeps its schema default
(
ratePerMin60,ratePerDay10000), which never contradicts; duplicatingthose defaults in the route to cross-check a single-field request would just
risk drift from
prisma/schema.prisma.[1, 2_147_483_647]ceiling is the column's physical limit, not a policylimit. If a product-level cap (e.g. a maximum sensible rate) is wanted later,
tighten
MAX_INT32inquotaField— no other change needed.CreateKeyBodyinterface is retained as the Fastify route generic; thezod schema is the runtime source of truth.
Closes #506