Grasshopper — Claim Worker
The customer-facing voucher/topup/eSIM claim worker end-to-end — Hono+D1 on Cloudflare Workers, the verify→submit→poll reveal flow with sequence diagrams, AES-256-GCM encryption, HMAC nonces & poll tokens, rate limiting, circuit breaker & idempotent recovery, telemetry, deployment, and the full security risk register.
Grasshopper is the public claim front-end at claim.octopuscards.io — a single Cloudflare Worker (Hono 4 + hono/jsx, backed by D1 SQLite via Drizzle) where a customer redeems an Octopus voucher card. It takes a code + 6-digit PIN, drives fulfilment through the Octopus Go backend, and reveals the result: a gift-card success, a mobile/gaming recharge status, or an eSIM QR code. It is deliberately thin and security-hardened — it never stores plaintext codes/PINs (only SHA-256 hashes), encrypts PII at rest (AES-256-GCM), and delegates all money movement to Octopus.
This page is the system overview. Two companion deep-dives go further: Grasshopper UI (the JSX/HTMX rendering layer, fragments, card-flip, QR, dynamic fields) and Grasshopper API (the exact per-endpoint request/response contract + how Octopus provisions vouchers and catalog into D1). For where it sits among the other frontends and its DNS/route/KV IDs, see Frontends → grasshopper and Cloudflare. For GH as a voucher vendor inside Octopus (a separate concept — vendor.code='GH' owns all products), see Vendor Adapters → Grasshopper and Vendor Architecture.
Two things named 'Grasshopper' — don't conflate them
- This worker — the Cloudflare claim site the customer uses (
frontend/grasshopper/). - The GH vendor — inside the Go backend, a pseudo-vendor (
vendor.code='GH') that owns the entireproducts/vendor_productscatalog. Octopus calls itself to mint GH voucher codes.
They share a name and a lineage but are different systems. This page is about (1), which consumes Octopus's /api/v1 as a client — much like any external integrator.
Architecture at a glance
Entry: src/index.tsx:10 builds the Hono app; src/index.tsx:91 exports instrumentHandler(app) (OTel wrapper). Fetch handler only — there is no scheduled/cron handler, no Queue, no Durable Object (verified absent in wrangler.jsonc).
Request pipeline (middleware order)
Outermost first, from src/index.tsx:
- Telemetry wrapper —
instrumentHandler(app)(index.tsx:91). Lazy: only instruments whenOTEL_ENABLED==='true'(decided per request,telemetry.ts:46), else zero overhead. Auto-injectstraceparentinto all outboundfetch(). - Debug logger —
app.use('*')(index.tsx:17);configureDlog(c.env)per request; early-returns unlessDEBUG_LOG==='true'. - Security headers —
app.use('*')(index.tsx:57); runsnext()then stamps CSP +nosniff+X-Frame-Options: DENY+Referrer-Policyon every response. - Route dispatch —
/api(:77),/status(:79),/(:86).
No global error/404/CORS/rate-limit middleware
There is no app.onError and no app.notFound — an unhandled exception yields a bare Hono 500 (the branded ErrorPage.tsx exists but is never imported — dead code). CORS is set only inside contact.ts. Rate limiting is applied per-handler inline (rateLimit(c.env, '<BINDING>', key)), not as middleware. So a thrown error anywhere outside /api/contact carries no CORS headers and no branded page.
Routes
Registration: index.tsx:77 mounts api at /api; api/index.ts:10 mounts the four sub-routers. Admin routes require the X-Auth-Token header (Admin auth); claim routes are public but gated by Turnstile + nonce + rate limits.
Public / claim flow (HTMX HTML fragments)
| Method | Path | Auth gate | Returns | Src |
|---|---|---|---|---|
| GET | / | — | HTML landing page (server-fetches Trustpilot score, injects Turnstile key) | index.tsx:86 |
| GET | /status | IP rate-limit | JSON {healthy,time} / 429 | index.tsx:79 |
| POST | /api/claim/verify-code | Turnstile + IP + code-hash RL | HTML: plan details + PIN form (mints nonce), or in-flight poll UI, or delivered-eSIM view | claim.ts:178 |
| POST | /api/claim/submit-direct | HMAC nonce + IP + code-hash RL | HTML reveal: success / processing-poll / failed | claim.ts:673 |
| GET | /api/claim/status/:token | poll_token (constant-time) + IP RL | HTML fragment (HTMX) or JSON — polls Octopus, flips card on terminal | claim.ts:1093 |
| POST | /api/claim/notify | poll_token lookup + IP RL | JSON {success} — stores encrypted notify email | claim.ts:1724 |
There is no GET /claim route — the whole claim UI lives on / and is driven by HTMX posting to these endpoints (LandingPage.tsx:393).
Admin / server-to-server (Octopus → Grasshopper), all authenticateAdmin
| Method | Path | Purpose | Src |
|---|---|---|---|
| POST · GET · PUT · PATCH · DELETE | /api/vouchers… | CRUD + code/:code lookup + code/:code/redeem (creates from pre-hashed code/pin) | vouchers.ts:23,68,89,124,158 |
| POST · GET · PUT · PATCH · DELETE | /api/voucher-types… | catalog CRUD + /filters + /sync (worker self-pulls Octopus catalog; the primary path is a Go-side push — see provisioning) | voucher-types.ts:73…301 |
| GET · PATCH | /api/contact (list/update) | read/triage contact submissions | contact.ts:114,127 |
Contact (public write)
POST /api/contact (contact.ts:26) — public, Turnstile mandatory, IP rate-limited 3/60s, length-capped fields, CORS allow-list + OPTIONS preflight (contact.ts:98).
Data model (D1)
Three tables (src/db/schema.ts); factory getDb(env) (db/client.ts:19). No indexes and no unique constraints are defined anywhere — the only relational constraint is one FK (vouchers.voucher_type_id → voucher_types.id). All lookups by code, poll_token, topup_client_ref are unindexed table scans — a scaling hot-spot.
vouchers — issued instances + redemption/fulfilment state
| Column | Stored as | Notes |
|---|---|---|
code | SHA-256 hash | never plaintext (schema.ts:52) |
pin_hash | SHA-256 hash | 6-digit PIN (:79) |
voucher_type_id | FK | the only FK in the schema (:49) |
is_redeemed, redeemed_at, expires_at | plain | redemption guard + ISO timestamps |
topup_order_id, topup_status, topup_client_ref | plain | Octopus order id, status mirror, idempotency key gh-{id}-{ts} |
topup_voucher_code_enc | AES-256-GCM | plaintext code retained submit→terminal for idempotent recovery; nulled on terminal (:86) |
input_data_json | AES-256-GCM | user form inputs — encrypted but lacks _enc suffix (:88) |
notification_email | AES-256-GCM | + notification_email_synced bool — encrypted, no _enc suffix (:95) |
poll_token, poll_token_expires_at | plain (server-side) | random UUID capability handle, ~1h TTL (:91) |
full_name, mobile_*, display_until, octopus_order_id | plain | legacy/unused in the claim flow — vestiges of an older gift-card design |
Encryption-suffix inconsistency
Only topup_voucher_code_enc carries the _enc convention. input_data_json and notification_email are also encrypted (encrypt() at claim.ts:822/868/1759) but look like plaintext when scanning the schema. eSIM secrets (ICCID, activation code) are never stored — fetched live from Octopus on each poll.
voucher_types — catalog/product-type metadata
Denormalized catalog row: name, denomination, currency, topup_type (GAMING|MOBILE|PAYOUT|ESIM), octopus_topup_product_id, octopus_esim_product_id, input_fields_json, country/country_code, par_value+unit, dialing_prefix, faqs_json, benefits, validity_days, is_active. Populated from the Octopus catalog — primarily by an admin-triggered push from the Go side, with the worker's own /sync as an alternate self-pull (provisioning).
eSIM id-space divergence
octopus_esim_product_id (schema.ts:23) is only ever used as an admin listing filter (voucher-types.ts:173) — and /sync never even populates it. Actual eSIM order creation reads octopus_topup_product_id (claim.ts:817 gate, :902 create). The "separate id spaces" comment is aspirational; the fulfilment path ignores the eSIM column.
contact_submissions
name, company, email, subject, message, status (default new), ip_address, created_at. Plaintext, length-capped in the handler.
The claim → reveal → poll flow
The heart of Grasshopper. A customer never leaves /; HTMX swaps fragments. Three phases: verify-code (unlock + PIN form), submit-direct (reserve + create order at Octopus), poll (reconcile + reveal). Failure terminals release the voucher so the code is reusable.
Phase 1 + 2 — verify then submit
Key points: the atomic reserve (UPDATE … WHERE is_redeemed=false, claim.ts:866) is the double-claim guard. Order-create passes the plaintext code as redeem_voucher_code (octopus-client.ts:510/660) — Octopus skips the wallet debit and claims the originating voucher item (prepaid semantics). Plain gift cards (no octopus_topup_product_id) short-circuit at claim.ts:817 with a pure D1 update and no external call.
Phase 3 — poll, reconcile, reveal
GET /api/claim/status/:token, driven by HTMX hx-trigger="load delay:{n}s" with exponential backoff min(5·2ⁿ, 120)s (claim.ts:1243), poll counter clamped to 20. No hard timeout other than the poll-token's 1-hour TTL, after which the endpoint clears the token and HX-Refreshes.
A GET can create a real order
The ambiguous-path recovery recreates an order inside a GET (claim.ts:1192/1209) when Octopus definitively answers notFound. Safety rests entirely on Octopus honoring the reused client_reference for dedup. Transient errors never trigger recreate. Still — a side-effecting GET is exposed to browser prefetch/retry; if the backend ever fails to dedupe, HTMX GET retries could duplicate orders.
The eSIM reveal (claim.ts:1361) fetches ICCID + activation code live and renders a QR (via qrcode-generator) with install steps; if already installed it shows ICCID only. A returning customer who re-verifies a delivered eSIM gets the same reveal directly from verify-code (claim.ts:308).
Octopus integration
OctopusClient (src/utils/octopus-client.ts:229). Base URL OCTOPUS_API_URL; no static API key — it does a username/password login and caches the JWT.
Two-tier JWT cache
| Tier | Store | Notes |
|---|---|---|
| L1 | module-scope _cachedToken (octopus-client.ts:15) | survives across requests in a warm isolate |
| L2 | KV octopus_token, key octopus_jwt (:12) | shared across isolates — prevents a login stampede on cold starts |
Both decode the JWT exp and refuse any token with < 5 min left (TOKEN_SAFETY_MARGIN=300); an undecodable exp is rejected outright to avoid 401 storms. KV TTL is set to exp − now − margin. Auth order: L1 → L2 (promote to L1) → POST /auth/login. On a 401, authedFetch refreshes once — invalidate both tiers, re-auth, retry; if that also 401s it re-materializes the original 401 so callers see a definite 4xx.
Endpoints called
| Method | Path | Purpose |
|---|---|---|
| POST | /auth/login | obtain JWT |
| POST | /api/v1/topups/orders | create recharge order |
| POST | /api/v1/esim/orders | create eSIM order |
| GET | /api/v1/topups|esim/orders?client_reference=… | reconciliation lookup |
| GET | /api/v1/topups|esim/orders/{id} | poll status (eSIM adds activation code/iccid) |
| PATCH | /api/v1/topups/orders/{id}/notification-email | attach async-notify email |
| GET | /api/v1/topups/products… (+/{id}, /{id}/variants) | catalog sync |
Encryption & hashing
src/utils/crypto.ts — AES-256-GCM via WebCrypto. Fresh 12-byte random IV per call (no reuse); stored as base64(iv ‖ ciphertext+GCMtag) — GCM auth tag gives integrity. Symmetric: same ENCRYPTION_KEY encrypts and decrypts.
src/utils/hash.ts — plain unsalted SHA-256, hex. Used for code and pin_hash. PIN comparison is constant-time, but the underlying hash is unsuitable for a 10⁶ PIN keyspace if the DB ever leaks.
Crypto risks
deriveKeyis not a KDF (crypto.ts:9): itpadEnd(32,'0').slice(0,32)— pads short keys with ASCII0, truncates to 32 chars, no salt/PBKDF2/HKDF. The configuredopenssl rand -base64 32keys are 44 chars, so the last ~12 chars are silently ignored (still ~192-bit, but surprising).- No key rotation / versioning — no key id stored with ciphertext. Rotating
ENCRYPTION_KEYorphans all existing D1 ciphertext (decrypt throws). - Unsalted SHA-256 for low-entropy secrets (6-digit PINs, voucher codes) — brute-forceable on a DB leak; mitigation is entirely rate-limiting.
Session security — nonce & poll token
Grasshopper is cookie-less (no Set-Cookie anywhere). Two token mechanisms, both compared with constantTimeEqual (claim-session.ts:28):
- Claim nonce (
claim-nonce.ts) — stateless HMAC-SHA-256, payload{c: sha256(code), e: expiry}, 10-min TTL, signed withCLAIM_NONCE_SECRET. Proves the caller cleared the Turnstile-gatedverify-codebefore hittingsubmit-direct, and is bound to the code hash so it can't be replayed for another code. Fail-closed: missing secret → submit-direct refuses all requests. Not single-use — replayable within its 10-min window; the real brute-force guard is the code/IP rate limiters. - Poll token (
generatePollToken,claim-session.ts:57) —crypto.randomUUID(), 1-hour TTL, stored plaintext server-side. Unguessable capability handle for/status/:token(no sequential id in the URL — see no-sequential-ids rule). Travels in the URL, soReferrer-Policy: strict-origin-when-cross-originmitigates cross-origin leakage.
Dead session code
createClaimSession/verifyClaimSession (claim-session.ts:42-88) — an HMAC-signed 30-min "skip re-entering code+pin" token — is never called anywhere, and no secret is provisioned for it. Remove or document as unused.
Rate limiting
Backed by Cloudflare native RateLimit bindings (binding.limit({key})), not KV/DO. Applied inline per-handler.
| Binding | Limit | Key | Protects |
|---|---|---|---|
CLAIM_RATE_LIMITER | 5 / 60s | code hash | per-code brute-force |
CLAIM_IP_RATE_LIMITER | 20 / 60s | IP | verify/submit/status/notify |
CONTACT_RATE_LIMITER | 3 / 60s | IP | contact form |
STATUS_RATE_LIMITER | 5 / 60s | IP | /status |
ADMIN_RATE_LIMITER | 10 / 60s | — | declared but never called |
Rate-limit gaps
ADMIN_RATE_LIMITERis dead — bound inwrangler.jsoncwith a comment claiming "brute-force protection," butadmin-auth.ts:6explicitly does no rate limiting. Admin-token auth has zero throttling.- Fail-open on missing binding (
rate-limit.ts:39) — a deploy that drops a binding silently disables that limiter. RATE_LIMIT_FAIL_OPEN=trueships in.dev.vars.example— a copy-paste-to-prod hazard (it forgives thrown limiter errors). Must stay unset in prod.- The memory note about
OTP_GENERATION/OTP_VERIFICATIONkeys is stale — no such keys exist; keys are code-hash and IP.
Admin auth
src/middleware/admin-auth.ts — custom X-Auth-Token header, constantTimeEqual against ADMIN_TOKEN. Not bearer/basic/JWT. Missing token or unset ADMIN_TOKEN → 401 (fails closed). Justified as server-to-server (Octopus→Grasshopper), "already throttled on the caller side" — but see the no-rate-limit gap above.
Resilience — circuit breaker, retries, idempotency
Circuit breaker (src/utils/circuit-breaker.ts) — module-scope, isolate-local (no KV; round-trip latency would defeat it). Wraps every Octopus call incl. /auth/login.
| State | Behaviour |
|---|---|
| closed | allow all; opens after 5 consecutive failures |
| open | fail fast (CircuitOpenError) for 30s cooldown, then → half-open |
| half-open | single probe; success closes+resets, any failure re-opens |
Failure classifier: 5xx = failure, 4xx = success (response.status < 500) — a bad request isn't a sick upstream. When open, order-create surfaces as the ambiguous "Processing…" poll UI (no fallback). Gap: per-isolate, so each of Cloudflare's many isolates must independently rack up 5 failures — no global fail-fast (documented as intentional).
Timeouts via AbortSignal.timeout: login 10s, order-create 15s, status/lookup/patch 15s, catalog 10–15s. Trustpilot has no timeout.
Retries: none at the HTTP layer (one attempt per call — deliberate, to avoid duplicate orders). The only auto-retry is the single 401→refresh. Retry-like behaviour lives in the poll loop (transient lookup failures hold PENDING and retry next poll; email sync retries each poll via synced=false).
Idempotency: client_reference = gh-{voucherId}-{now} is passed on create and reused on reconcile/recreate; Octopus dedupes server-side. Recreate happens only on a definitive notFound, never on ambiguous transport errors.
Telemetry
src/utils/telemetry.ts via @microlabs/otel-cf-workers. OTLP/HTTP traces to OTEL_EXPORTER_OTLP_ENDPOINT (prod/sandbox: https://otel.octopuscards.io/v1/traces), service grasshopper. Two auth headers: signoz-access-token (OTEL_EXPORTER_OTLP_TOKEN, optional) and X-Octopus-Telemetry-Auth (OTEL_INGEST_TOKEN, matched by a Cloudflare rule fronting the collector). Accepts incoming W3C traceparent and auto-injects it outbound → distributed traces continue Grasshopper → Octopus, sharing the same SigNoz backend as the Go services (see Metrics & Tracing). Separately, dlog is a DEBUG_LOG-gated structured JSON console logger (masks code hashes/poll tokens).
Deployment & configuration
Deployed Worker name grasshopper; workers_dev:false (custom route only); nodejs_compat. Static assets served from ./public.
| Prod | Sandbox | |
|---|---|---|
| Route | claim.octopuscards.io/* | sandbox-claim.octopuscards.io/* |
| Config | top-level wrangler.jsonc | env.sandbox block |
| D1 | grasshopper (d99ec4a1-…) | grasshopper_sandbox (d0f8b860-…) |
| KV | octopus_token | octopus_token_sandbox |
| RL namespaces | 1001–1005 | 2001–2005 |
| Octopus | prod backend (secret) | sandbox-api.octopuscards.io |
CI/CD — GHA on push to master under frontend/grasshopper/**:
deploy-grasshopper.yml:bun install→wrangler d1 migrations apply grasshopper --remote→wrangler deploy.deploy-grasshopper-sandbox.yml: same with--env sandbox; addsworkflow_dispatch+ cancel-in-progress concurrency.
Deploy divergence
The bun run deploy npm script uses wrangler deploy --minify, but CI runs plain wrangler deploy (no --minify). Prod bundles are therefore un-minified unless deployed manually. Migrations are always applied before the worker ships. Use bun, never npm.
D1 migrations — 21 files 0000→0020 in drizzle/migrations/. Workflow: edit src/db/schema.ts → bun run db:generate (drizzle-kit) → bun run migrate:local to test → merge; CI applies --remote. Several later migrations are hand-authored with synthetic journal timestamps. Never run migrations yourself — the user applies them (migrate:local/migrate:remote/db:reset).
Reset scripts: db:reset (local wipe), db:reset:remote → scripts/reset-remote.sh (destructive to prod — deletes+recreates the prod D1; its sed replaces the first database_id in wrangler.jsonc, order-dependent and unguarded), db:reset:sandbox → scripts/reset-sandbox.sh (hard-scoped to sandbox, interactive confirm). Seed (drizzle/seed.sql) is a no-op — catalog is repopulated by the Octopus→Grasshopper /sync.
Env / binding reference
Source of truth: src/types.ts (Env). See Env Reference for the Go side.
| Name | Kind | Purpose |
|---|---|---|
DB | binding (D1) | primary SQLite database |
octopus_token | binding (KV, optional) | cross-isolate JWT cache; falls back to per-isolate |
CLAIM_/CLAIM_IP_/CONTACT_/STATUS_/ADMIN_RATE_LIMITER | binding (RateLimit) | limiters (ADMIN_ unused) |
TURNSTILE_SITE_KEY | var | public widget key |
OTEL_ENABLED, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_INGEST_TOKEN | var | telemetry (ingest token is committed — a real credential) |
TURNSTILE_SECRET_KEY | secret | siteverify secret |
OCTOPUS_API_URL / OCTOPUS_CLIENT_USERNAME / OCTOPUS_CLIENT_PASSWORD | secret | backend base URL + login creds |
ADMIN_TOKEN | secret | admin route bearer |
ENCRYPTION_KEY | secret | AES-256-GCM key (worker crashes on first encrypt() if unset) |
CLAIM_NONCE_SECRET | secret | HMAC nonce (submit-direct fails closed if unset) |
OTEL_EXPORTER_OTLP_TOKEN | secret (optional) | SigNoz access token |
DEBUG_LOG / RATE_LIMIT_FAIL_OPEN | flag | structured logs / dev-only limiter bypass |
PRIVATE | stale | in .dev.vars.example only; unused — dead config |
Testing & bench
Tests run inside workerd via @cloudflare/vitest-pool-workers with real D1/KV/RateLimit bindings; migrations applied per-suite via TEST_MIGRATIONS. Layers: L1 pure utils, L2 app.request(), L3 SELF.fetch + real D1 + fetchMock Octopus, L4 e2e (real Octopus + mocky-balboa, gated GRASSHOPPER_E2E=1). Plus boundary, chaos, fuzz (fast-check), smoke. bench/ is off-CI autocannon load against hot routes (needs the full local stack). See Testing.
Security risk register
Consolidated for triage; the biggest ones also feed Known Issues.
| # | Risk | Where |
|---|---|---|
| 1 | Committed sandbox secrets in git — ENCRYPTION_KEY, ADMIN_TOKEN, CLAIM_NONCE_SECRET, TURNSTILE_SECRET_KEY, demo Octopus creds, plus prod OTEL_INGEST_TOKEN (real, in vars) | wrangler.jsonc:27,157-181 |
| 2 | deriveKey truncates/pads the key, no KDF/salt | crypto.ts:9 |
| 3 | No encryption key rotation — rotating orphans all ciphertext | crypto.ts |
| 4 | Unsalted SHA-256 for 6-digit PINs & codes | hash.ts, schema.ts:79 |
| 5 | Admin auth has no rate limiting (ADMIN_RATE_LIMITER dead) | admin-auth.ts:6 |
| 6 | Rate limiter fails open on missing binding; RATE_LIMIT_FAIL_OPEN=true in example | rate-limit.ts:39 |
| 7 | Claim nonce is not single-use — replayable within 10-min TTL | claim-nonce.ts |
| 8 | CSP allows 'unsafe-inline' scripts while HTML is built from template strings → XSS is the main surface (hand-rolled esc()/safeRegexTest() guards) | index.tsx:61, claim.ts:24 |
| 9 | No HSTS header at the worker layer | index.tsx:56 |
| 10 | Side-effecting GET can create orders on the poll path (idempotency rests on Octopus dedup) | claim.ts:1192 |
| 11 | No indexes on vouchers hot lookups (code, poll_token, topup_client_ref) | schema.ts |
| 12 | Dead code — ErrorPage.tsx, createClaimSession, octopus_esim_product_id fulfilment, isPending=true const | various |
Key files
- Entry/config:
src/index.tsx,src/types.ts,wrangler.jsonc,package.json,.dev.vars.example - API:
src/api/{claim,vouchers,voucher-types,contact,index}.ts - Data:
src/db/{schema,client}.ts,drizzle/migrations/0000…0020 - Security/resilience:
src/utils/{crypto,hash,claim-session,claim-nonce,rate-limit,circuit-breaker,octopus-client,telemetry}.ts,src/middleware/admin-auth.ts - CI:
.github/workflows/deploy-grasshopper{,-sandbox}.yml; scriptsscripts/reset-{remote,sandbox}.sh
Client Portal — Settings
The client portal's six settings tabs — Account Info (read-only), Security (2FA/passkeys), API Keys (username/password credentials shown once), Webhooks (per-event, HTTPS-enforced, HMAC signing + rotate/test), IP Whitelist (empty = allow-all, no client-side CIDR validation), and Integrations (G2A self-service OAuth issuing; Shopify is NOT implemented — flag + dead upsell only).
Grasshopper — UI & HTMX
How the claim UI is actually rendered and driven — the single JSX landing page, the template-literal HTML fragments, the HTMX 2.0 swap/OOB machinery, the full fragment-state catalog, the CSS card-flip, data-URI QR rendering, inline JS, the dynamic input-field system, and the hand-rolled XSS escaping with its gaps.