Bid Request Auditor
OpenRTB 2.6 validator
Live
- TypeScript
- Node.js
- Zero dependencies
- Vanilla JS
- node:test
A validator for OpenRTB 2.6 bid requests. Paste a request, drop a
.json file on the page, or load one of five samples, and
it reports spec violations, invalid AdCOM 1.0 enum values, internal
contradictions, and privacy signals contradicted by the identifiers the
request actually carries — a COPPA or DNT flag asserted while
device IDs, user IDs and ten-metre geo are still being sent. It runs
21 checks across four categories, each citing the spec
section it came from and graded by severity. The same rules run three
ways: a CLI that exits non-zero on errors so it drops into CI, a
browser page, and a Claude Code skill.
Stack
TypeScript, run directly on Node using native type stripping: no
compiler, no bundler, no package.json, zero runtime
dependencies. The page is plain HTML, CSS and vanilla JavaScript; its
one script is generated from src/ by a small Node script
using node:module's stripTypeScriptTypes, so
the browser runs the real rules rather than a copy of them. Tests are
node --test.
What I decided, and why
-
Deterministic rules, no LLM at runtime. Results are
reproducible, need no network and cost nothing per run. I used AI
heavily to build it and not at all to run it — “the spec
says X” has to trace to a line of code and a section number,
not to a model's judgment.
-
ERROR only where a spec table literally says
“required.” “Recommended” caps at
WARNING. Unknown fields and unrecognised enum values cap at INFO,
because §2.6 requires implementers to tolerate them. The three
privacy contradictions are the one deliberate exception, promoted to
ERROR.
-
One engine, two front doors. The CLI and the page
import the same modules, so no rule is duplicated, and a test fails
if the generated browser bundle drifts from the source.
-
No backend. The audit runs entirely in the browser,
so a bid request pasted into the page never leaves the analyst's
machine.
-
Cut a check the spec supports. §3.2.19 says
city should use UN/LOCODE, but effectively nobody sends it that way.
Flagging “Tel Aviv” would have been inventing a problem,
so it's out.
- Python
- Cloudflare Workers
- FastAPI
- Cloudflare D1
- Workers KV
- Stripe
- Gemini 2.5 Flash
A surfer signs in with Google, uploads a video of themselves in the
water, and enters height, weight and skill level. Gemini 2.5 Flash
confirms the clip actually shows surfing, assesses technique, and
returns a recommended board volume in litres and length in feet and
inches. To keep recommendations grounded in real expertise, the app
pulls an experienced coach's previous sizing decisions out of the
database and feeds them into the prompt as few-shot examples, so the
model mirrors a human coach's logic rather than sizing from scratch.
Clips that aren't surfing are rejected and the user's bundle is
refunded. It runs on Cloudflare Python Workers with D1, and the three
bundles are bought through Stripe Checkout, in test mode. Built and
tested with the guidance of an Olympic surfing coach.
Stack
Python on Cloudflare Workers — FastAPI and Jinja2 on Pyodide,
server-rendered, no frontend framework. Cloudflare D1 for the dataset,
reached through prepared statements rather than an ORM; Workers KV for
the uploaded clips, behind storage.py; hand-rolled Google
OIDC and HMAC-signed cookie sessions; Gemini 2.5 Flash over REST,
because the google-genai SDK doesn't run under Pyodide;
Resend's HTTPS API for result emails, because a Worker can't open an
SMTP socket; Stripe Checkout for the bundles. Queued analyses run from
a job table in D1 that a Cron Trigger sweeps once a minute — a
Worker can't keep a thread alive past its response.
What I decided, and why
-
Swapped the model out when it couldn't do the job.
I prototyped a 3D linear regression over height, weight and a
computer-vision-derived skill score. It couldn't capture technique,
so I moved to a multimodal model prompted with few-shot examples from
the coach's past sizing decisions.
-
Grounded the prompt in real decisions. The few-shot
examples come out of the database at request time, not from a fixed
prompt, so the model's output tracks the coach's actual record.
-
Kept a human in the loop. An admin dashboard
supports manual review, sizing overrides, inventory and user chat, so
the model's answer is reviewable rather than final. On the coach
tier the model's answer is stored as a draft that an admin has to
sign off before the surfer sees it.
-
Moved it off Flask onto Python Workers and D1. Two
native Cloudflare services: D1 for the dataset, Workers for the
routes. The platform forced most of what followed — the Worker
filesystem is read-only and isn't shared between isolates, so the
SQLite file and the
uploads/ directory had nowhere to
live; a thread can't outlive the response, so the background
analysis had to become a job somewhere; and neither Authlib nor
google-genai runs under Pyodide.
-
Video went to Workers KV, not R2, and that cost
something. R2 is the right store for video and was the
first implementation, but turning it on puts a payment card on the
Cloudflare account even inside the free tier. KV is a config and
cache store: 25 MiB per value, 1 GB free, no range
requests, and eventually consistent between locations. That last one
is the sharp edge — a read returning nothing can mean "not
here yet", so the store raises
VideoMissing and the
sweeper retries, instead of concluding the clip wasn't surfing and
refunding a session that was fine. What KV gives back is
expirationTtl, which R2 has no equivalent for: every
clip carries an expiry, so storage plateaus instead of growing out of
the free tier. storage.py is the only file that knows
where bytes live, so moving to R2, Supabase or Cloudinary is a
rewrite of one file.
-
D1 with prepared statements, no ORM. D1 speaks SQL
over a binding and there's no SQLAlchemy dialect for it, so the
models became
db.py. It reads better than a concession:
D1 bills per row read, so it's worth seeing the exact SQL a page
runs, and the awkward operations collapse into one statement.
Spending a bundle guards itself in the WHERE clause
rather than reading a balance and writing it back, so two
overlapping requests can't both spend the same one.
-
Made the payment replay-safe. The Buy buttons were
href="#". They now open Stripe Checkout, so card details
never reach the Worker, and the bundle is granted by the webhook
rather than the success redirect — a redirect is just a URL
anyone can visit, and the browser may never arrive at it. The webhook
is verified by recomputing Stripe's HMAC over the raw body with a
constant-time compare and rejecting signatures older than five
minutes. The delivery is then claimed with
INSERT OR IGNORE, so the check and the claim are one
statement: Stripe delivers at least once, and without that, one
payment becomes five bundles.
-
Hand-rolled the session layer, and hardened it.
Flask's
session and Authlib don't exist here, so
sessions are an HMAC-signed cookie, CSRF is a token compared in
constant time, and Google sign-in is the authorization-code flow
written out. The CSRF gate is pure ASGI rather than an HTTP
middleware, because reading the token out of the body there drains
the receive channel and the route then sees an empty form. HttpOnly
and SameSite=Lax as before, Secure now on where the
Flask app hardcoded it off, X-Frame-Options: DENY,
X-Content-Type-Options: nosniff,
Referrer-Policy: strict-origin-when-cross-origin, and
uploads capped at 20 MB, under Workers KV's hard 25 MiB
per-value ceiling — down from the Flask app's 500 MB.
/video_serve/<id> also had no access control at
all; it's now owner-or-admin.