Skip to content

fix: validate POST /admin/keys body with zod (#506) - #595

Merged
Miracle656 merged 2 commits into
Divinity-Labs:mainfrom
CHKM001:fix/admin-key-validation-506
Oct 25, 2026
Merged

Miracle656 merged 2 commits into
Divinity-Labs:mainfrom
CHKM001:fix/admin-key-validation-506

Conversation

@CHKM001

@CHKM001 CHKM001 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Validate the POST /admin/keys body with zod (#506)

Problem

POST /admin/keys validated label and nothing else. Every other field went
to the database exactly as sent:

if (!label || typeof label !== 'string' || label.trim().length === 0) {
  return reply.status(400).send({ error: 'label is required' })
}
...
...(ratePerMin !== undefined ? { ratePerMin } : {}),
...(ratePerDay !== undefined ? { ratePerDay } : {}),
  • Numeric fields were stored verbatim. ratePerMin, ratePerDay,
    monthlyQuotaCents and dailyQuotaCents accepted a negative, a zero, a
    fraction, 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.
  • overagePolicy was unchecked. The TypeScript union
    ('block' | 'charge_402' | 'allow_overage') is erased at runtime, so any
    string was stored. src/x402/metering.ts falls back to 'block' for
    anything 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.

  • Rates and quota cents are positive integers in [1, 2_147_483_647]. The
    upper bound is the Postgres Int column ceiling (32-bit signed): a larger
    value could not be stored without overflow anyway. Zero, negatives and
    fractions are rejected.
  • overagePolicy is a z.enum of exactly the three values metering
    understands; anything else is rejected instead of defaulting to block.
  • ratePerDay must be ≥ ratePerMin when both are supplied — a per-day cap
    below the per-minute cap can never bind and is always a mistake.
  • Errors name the offending field by going through the shared
    formatValidationError (src/validation.ts), so a caller sees
    Invalid <field>: <reason> with the VALIDATION_ERROR code, identical in
    shape to every other route's rejection.
  • label (non-empty after trim) and expiresAt (a valid future date)
    validation is preserved, now expressed in the same schema.

Changes

  • src/api/admin.ts — add CreateKeySchema (zod) and validate req.body
    with safeParse; on failure return formatValidationError(...) with a 400.
    A quotaField(name) helper builds the bounded positive-integer rule for each
    numeric field; a superRefine enforces the future-date and
    ratePerDay ≥ ratePerMin cross-field rules. The create call now reads from
    parsed.data (label already trimmed).
  • src/__tests__/auth.test.ts — 8 new tests under the admin endpoints
    suite (below).

Acceptance criteria

  • Every numeric field is validated as a positive integer within bounds —
    rejects a negative rate, rejects a fractional quota, rejects a zero
    rate
    , rejects an out-of-range value above the Int column ceiling.
  • overagePolicy outside the known set is rejected, not silently defaulted —
    rejects an unknown overagePolicy instead of silently defaulting to block.
  • Contradictory rate combinations are rejected —
    rejects a per-day rate below the per-minute rate.
  • Errors name the offending field — every rejection asserts the field name
    appears in body.error, via formatValidationError.
  • Tests cover a negative, a fractional, an out-of-range and an unknown
    policy — plus a zero rate, a contradictory rate pair, and a blank
    (whitespace-only) label.

Each rejection also asserts the database was never touched
(mockCreate not called).

Testing

  • npx tsc --noEmit — clean (after prisma generate).
  • npx vitest run src/__tests__/auth.test.ts — 30 passed (8 new).
  • Full suite: 924 passed, 1 skipped (pre-existing).

Notes / trade-offs

  • The ratePerDay ≥ ratePerMin check only fires when both values are in the
    request. If only one is sent, the other keeps its schema default
    (ratePerMin 60, ratePerDay 10000), which never contradicts; duplicating
    those defaults in the route to cross-check a single-field request would just
    risk drift from prisma/schema.prisma.
  • The [1, 2_147_483_647] ceiling is the column's physical limit, not a policy
    limit. If a product-level cap (e.g. a maximum sensible rate) is wanted later,
    tighten MAX_INT32 in quotaField — no other change needed.
  • The CreateKeyBody interface is retained as the Fastify route generic; the
    zod schema is the runtime source of truth.

Closes #506

@drips-wave

drips-wave Bot commented Oct 21, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

Miracle656 added a commit that referenced this pull request Oct 22, 2026
  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 Miracle656 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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)))
  : undefined

and 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>
@CHKM001
CHKM001 requested a review from Miracle656 October 23, 2026 21:43
@CHKM001
CHKM001 force-pushed the fix/admin-key-validation-506 branch from b2e11eb to f58ebcf Compare October 24, 2026 09:06
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>
@Miracle656
Miracle656 merged commit 58474a9 into Divinity-Labs:main Oct 25, 2026
3 checks passed
@Miracle656

Copy link
Copy Markdown
Collaborator

Merged as 58474a9 — this closes #506, and the finding from the previous round is properly addressed.

The scopes handling is the reason I asked for changes, and your fix goes further than patching it — the comment explains the trap so it cannot come back:

Scopes are optional, but an unknown-key strip would silently mint a key with no scopes at all — so they must be in the schema.

That is exactly the failure. A zod object strips what it does not declare, so adding validation would have quietly removed scopes from the body and returned 201 for a key that can read nothing. A successful-looking response that hands back a useless credential is worse than the unvalidated version it replaced, because nothing anywhere reports an error.

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 undefined is preserved distinctly from [], so "not supplied" and "explicitly empty" stay different facts.

The rest is solid: expiresAt rejected when unparseable or already in the past (a key that expires before it is used is a configuration error worth catching at creation), overagePolicy against the enum rather than a free string, and errors that name the field.

135 test files / 1,626 tests pass; tsc --noEmit clean.

Miracle656 added a commit that referenced this pull request Oct 25, 2026
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
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.

Key quotas are stored exactly as sent

2 participants