OctoWiki

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

  1. This worker — the Cloudflare claim site the customer uses (frontend/grasshopper/).
  2. The GH vendor — inside the Go backend, a pseudo-vendor (vendor.code='GH') that owns the entire products/vendor_products catalog. 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:

  1. Telemetry wrapperinstrumentHandler(app) (index.tsx:91). Lazy: only instruments when OTEL_ENABLED==='true' (decided per request, telemetry.ts:46), else zero overhead. Auto-injects traceparent into all outbound fetch().
  2. Debug loggerapp.use('*') (index.tsx:17); configureDlog(c.env) per request; early-returns unless DEBUG_LOG==='true'.
  3. Security headersapp.use('*') (index.tsx:57); runs next() then stamps CSP + nosniff + X-Frame-Options: DENY + Referrer-Policy on every response.
  4. 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)

MethodPathAuth gateReturnsSrc
GET/HTML landing page (server-fetches Trustpilot score, injects Turnstile key)index.tsx:86
GET/statusIP rate-limitJSON {healthy,time} / 429index.tsx:79
POST/api/claim/verify-codeTurnstile + IP + code-hash RLHTML: plan details + PIN form (mints nonce), or in-flight poll UI, or delivered-eSIM viewclaim.ts:178
POST/api/claim/submit-directHMAC nonce + IP + code-hash RLHTML reveal: success / processing-poll / failedclaim.ts:673
GET/api/claim/status/:tokenpoll_token (constant-time) + IP RLHTML fragment (HTMX) or JSON — polls Octopus, flips card on terminalclaim.ts:1093
POST/api/claim/notifypoll_token lookup + IP RLJSON {success} — stores encrypted notify emailclaim.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

MethodPathPurposeSrc
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 submissionscontact.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

ColumnStored asNotes
codeSHA-256 hashnever plaintext (schema.ts:52)
pin_hashSHA-256 hash6-digit PIN (:79)
voucher_type_idFKthe only FK in the schema (:49)
is_redeemed, redeemed_at, expires_atplainredemption guard + ISO timestamps
topup_order_id, topup_status, topup_client_refplainOctopus order id, status mirror, idempotency key gh-{id}-{ts}
topup_voucher_code_encAES-256-GCMplaintext code retained submit→terminal for idempotent recovery; nulled on terminal (:86)
input_data_jsonAES-256-GCMuser form inputs — encrypted but lacks _enc suffix (:88)
notification_emailAES-256-GCM+ notification_email_synced bool — encrypted, no _enc suffix (:95)
poll_token, poll_token_expires_atplain (server-side)random UUID capability handle, ~1h TTL (:91)
full_name, mobile_*, display_until, octopus_order_idplainlegacy/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

TierStoreNotes
L1module-scope _cachedToken (octopus-client.ts:15)survives across requests in a warm isolate
L2KV 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

MethodPathPurpose
POST/auth/loginobtain JWT
POST/api/v1/topups/orderscreate recharge order
POST/api/v1/esim/orderscreate 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-emailattach 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

  • deriveKey is not a KDF (crypto.ts:9): it padEnd(32,'0').slice(0,32) — pads short keys with ASCII 0, truncates to 32 chars, no salt/PBKDF2/HKDF. The configured openssl rand -base64 32 keys 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_KEY orphans 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 with CLAIM_NONCE_SECRET. Proves the caller cleared the Turnstile-gated verify-code before hitting submit-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, so Referrer-Policy: strict-origin-when-cross-origin mitigates 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.

BindingLimitKeyProtects
CLAIM_RATE_LIMITER5 / 60scode hashper-code brute-force
CLAIM_IP_RATE_LIMITER20 / 60sIPverify/submit/status/notify
CONTACT_RATE_LIMITER3 / 60sIPcontact form
STATUS_RATE_LIMITER5 / 60sIP/status
ADMIN_RATE_LIMITER10 / 60sdeclared but never called

Rate-limit gaps

  • ADMIN_RATE_LIMITER is dead — bound in wrangler.jsonc with a comment claiming "brute-force protection," but admin-auth.ts:6 explicitly 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=true ships 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_VERIFICATION keys 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.

StateBehaviour
closedallow all; opens after 5 consecutive failures
openfail fast (CircuitOpenError) for 30s cooldown, then → half-open
half-opensingle 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.

ProdSandbox
Routeclaim.octopuscards.io/*sandbox-claim.octopuscards.io/*
Configtop-level wrangler.jsoncenv.sandbox block
D1grasshopper (d99ec4a1-…)grasshopper_sandbox (d0f8b860-…)
KVoctopus_tokenoctopus_token_sandbox
RL namespaces1001–10052001–2005
Octopusprod backend (secret)sandbox-api.octopuscards.io

CI/CD — GHA on push to master under frontend/grasshopper/**:

  • deploy-grasshopper.yml: bun installwrangler d1 migrations apply grasshopper --remotewrangler deploy.
  • deploy-grasshopper-sandbox.yml: same with --env sandbox; adds workflow_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 00000020 in drizzle/migrations/. Workflow: edit src/db/schema.tsbun 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:remotescripts/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:sandboxscripts/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.

NameKindPurpose
DBbinding (D1)primary SQLite database
octopus_tokenbinding (KV, optional)cross-isolate JWT cache; falls back to per-isolate
CLAIM_/CLAIM_IP_/CONTACT_/STATUS_/ADMIN_RATE_LIMITERbinding (RateLimit)limiters (ADMIN_ unused)
TURNSTILE_SITE_KEYvarpublic widget key
OTEL_ENABLED, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_INGEST_TOKENvartelemetry (ingest token is committed — a real credential)
TURNSTILE_SECRET_KEYsecretsiteverify secret
OCTOPUS_API_URL / OCTOPUS_CLIENT_USERNAME / OCTOPUS_CLIENT_PASSWORDsecretbackend base URL + login creds
ADMIN_TOKENsecretadmin route bearer
ENCRYPTION_KEYsecretAES-256-GCM key (worker crashes on first encrypt() if unset)
CLAIM_NONCE_SECRETsecretHMAC nonce (submit-direct fails closed if unset)
OTEL_EXPORTER_OTLP_TOKENsecret (optional)SigNoz access token
DEBUG_LOG / RATE_LIMIT_FAIL_OPENflagstructured logs / dev-only limiter bypass
PRIVATEstalein .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.

#RiskWhere
1Committed sandbox secrets in gitENCRYPTION_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
2deriveKey truncates/pads the key, no KDF/saltcrypto.ts:9
3No encryption key rotation — rotating orphans all ciphertextcrypto.ts
4Unsalted SHA-256 for 6-digit PINs & codeshash.ts, schema.ts:79
5Admin auth has no rate limiting (ADMIN_RATE_LIMITER dead)admin-auth.ts:6
6Rate limiter fails open on missing binding; RATE_LIMIT_FAIL_OPEN=true in examplerate-limit.ts:39
7Claim nonce is not single-use — replayable within 10-min TTLclaim-nonce.ts
8CSP 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
9No HSTS header at the worker layerindex.tsx:56
10Side-effecting GET can create orders on the poll path (idempotency rests on Octopus dedup)claim.ts:1192
11No indexes on vouchers hot lookups (code, poll_token, topup_client_ref)schema.ts
12Dead codeErrorPage.tsx, createClaimSession, octopus_esim_product_id fulfilment, isPending=true constvarious

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; scripts scripts/reset-{remote,sandbox}.sh

On this page