G2A Import Channel
The inbound G2A marketplace channel — Octopus is the server. OAuth2 token issuance, the reserve → order → pull-codes lifecycle, mint-on-demand GH stock, per-tenant scoping, and its strong test coverage.
G2A is an inbound sales channel where Octopus is the HTTP server and OAuth2 token issuer — the mirror image of the vendor-adapter pattern (there Octopus is the client). The G2A marketplace calls in to reserve stock, confirm orders, and pull voucher codes. Spec of record: docs/g2a/api.yml (the full G2A Merchant/Import contract) + docs/g2a/RUNBOOK.md.
The architectural keystone: mint-on-demand
G2A sells Octopus Cards (Grasshopper / "GH"), which are minted on demand, not drawn from a finite pool. Everything follows from that:
- Reservations hold no codes — pure intent + a 30-min TTL.
- Stock is always reported as a constant
1_000_000(g2aUnlimitedInventory). - Codes are minted at order-confirm time through the canonical voucher pipeline (
CreateVoucherOrderForJob). - Delivered codes cannot be un-minted →
DELETE …/inventoryalways returns 400.
This is the healthiest of the three inbound integrations — well-tested, correctly multi-tenant, few smells.
The inbound API contract
Routes under app.Group("g2a") (routes.go:62); the token endpoint is public, everything else sits behind G2AAuthMiddleware(). The error envelope is G2A's {code, message} shape (g2aError()), distinct from the app-wide handler.
| Method | Path | Handler | Purpose |
|---|---|---|---|
| GET | /g2a/oauth/token | G2ATokenHandler | issue bearer JWT |
| GET | /g2a/healthcheck | G2AHealthcheckHandler | 204, no DB — G2A polls this |
| POST | /g2a/notifications | G2ANotificationsHandler | record marketplace events (audit only) |
| POST | /g2a/reservation | G2ACreateReservationHandler | reserve intent (+30m TTL) |
| PUT | /g2a/reservation/:id | G2ARenewReservationHandler | renew TTL |
| DELETE | /g2a/reservation/:id | G2AReleaseReservationHandler | release (idempotent 204) |
| POST | /g2a/order | G2ACreateOrderHandler | confirm → mint codes |
| GET | /g2a/order/:id/inventory | G2AGetInventoryHandler | pull codes (:id is the reservation id) |
| DELETE | /g2a/order/:id/inventory | G2AReturnInventoryHandler | always 400 not_returnable |
healthcheck failures cause G2A to deactivate the listings, so it must stay cheap and DB-free. notifications is recording-only (today just auction_deactivated) — no side effects.
The lifecycle
Requests/responses are bare JSON arrays (reservation items in, stock out) — matching the G2A contract.
Auth — OAuth2 client-credentials
- Token issuance (
G2ATokenHandler):grant_type/client_id/client_secretas query params (per G2A contract). Looks up creds by publicg2a_client_id; an unknown client_id returns the same 401invalid_clientas a bad secret (anti-enumeration). bcrypt compare; disabled channel → 403 after secret validation. Mints{access_token, token_type:"bearer", expires_in}. - Token (
utils/jwt.go:45): HS256,Subject:"g2a", 15-min TTL, signed with the sharedJWT_SECRET. - Validation (
G2AAuthMiddleware): stateless — signature + expiry +Subject=="g2a", no per-request DB lookup (keeps healthcheck cheap). Parsesbearercase-insensitively (G2A sends lowercase). Setsclient_id+TenantIDKey— the token is the tenant anchor. - Subject separation: a
g2atoken is rejected by/api/v1(which requiresSubject:"access") and vice-versa — tested (CrossUseRejected). See Auth & Access Control.
Disabling the channel only stops new token issuance — already-issued tokens stay valid up to 15 min (documented, accepted). And like every JWT surface, the G2A middleware falls back to the hardcoded "your-super-secret-key" if JWT_SECRET is unset (a prod-misconfig risk — see the auth findings).
Credential provisioning — two paths, both generate g2a_client_id = "g2a_"+random(24) + a random(48) secret (bcrypt-hashed, plaintext returned once), default is_enabled=false (operator must explicitly enable):
- Admin (
admin_g2a.go) — operator generates for any client via:id. - Client self-service (
client_g2a.go) — cookie-scoped, client manages its own creds (no URL:id). Clients cannot manage product mappings — admin-only.
Reservation model
Table g2a_reservations + g2a_reservation_items, statuses RESERVED|EXPIRED|ORDERED|RELEASED.
- id is a server-supplied UUID that also serves as the external
order_id. - TTL = 30 min;
inventory_sizeis the fixed1_000_000constant — no stock is actually held, so there's no row/stock locking (nothing finite to lock). - Expiry two ways: the
G2AReservationExpiryTasksweeper (@every 5m,UPDATE …EXPIRED WHERE status=RESERVED AND expires_at<now, backed by a partial index) and a lazy order-time check (410). Renew and sweeper both gate onstatus=RESERVED, so ordered/expired transitions are mutually safe — a renew-vs-sweep race resolves via rows-affected==0 → 410.
Order + code delivery
G2ACreateOrderHandler:
- Idempotency pre-check: same
g2a_order_id+ same reservation → return same codes (200); bound to a different reservation → 409. - Reservation state/expiry checks (409/410).
- Mint per item: require
Denomination.Valid(else 400), buildrefCode = G2A_<g2a_order_id>_<reservation_item_id>, callCreateVoucherOrderForJob— idempotent onClientReference(pre-check + unique index), so retries never double-mint. - Sync vs async: all units have codes →
DELIVERED, 200 with inline codes; else →PENDING, 202 with empty stock (G2A re-pulls later). - Persist
g2a_orders+g2a_order_items(each a freshinventory_uuid,kind=text) + reservation→ORDERED, all in one tx.
Code reveal (buildG2AInventoryGroups): loads the linked Octopus order_items, skips code-less units, decrypts code/PIN via utils.DecryptVoucherData, renders with formatG2AValue (PIN present → {"Card": code, "PIN": pin}; else bare code). Secrets are never duplicated into G2A tables — code/PIN live encrypted only on the Octopus order_items.
Concurrency rough edge (safe, not clean)
The idempotency pre-check and InsertG2AOrder are not in the same transaction as the mint. Under concurrent duplicate confirms, the losing racer relies on the ClientReference dup error and gets a 500 (not a clean 200/409) — it must retry. There is no double-mint (verified by Order_ConcurrentSameOrderID_NoDoubleMint), so this is a rough edge, not a data-integrity bug.
DELETE …/inventory always returns 400 — minted codes can't be clawed back; refunds are manual reconciliation / cost-absorbed.
Data model
Migrations 20260611000001..05. Models in database/models/g2a.go.
| Table | Key columns | Notes |
|---|---|---|
g2a_credentials | client_id UNIQUE, g2a_client_id UNIQUE, client_secret_hash, webhook_secret (unused), is_enabled | one row/client, bcrypt only |
g2a_product_mappings | UNIQUE(client_id, g2a_product_id), octopus_product_id, denomination, currency, is_active | 5-min read-through cache w/ negative caching; mirrors Shopify mappings |
g2a_reservations | id VARCHAR(36) PK, status, expires_at | partial sweep index |
g2a_reservation_items | octopus_product_id, quantity, unit_price, auction_base_price | |
g2a_orders | UNIQUE(client_id, g2a_order_id), reservation_id, octopus_order_id, status | unique key = DB-level idempotency |
g2a_order_items | octopus_order_item_id, inventory_uuid UNIQUE, kind, status | kind always text in v1 |
g2a_notifications | notification_type, auction_id, offer_id, … | audit/alerting only |
Multi-tenancy
Every inbound call is scoped by client_id from the JWT's ClientID claim (set at token issuance from the matched credentials row). The middleware puts it in c.Locals("client_id") + TenantIDKey; every handler threads it into every repo call; every query filters WHERE client_id = $. The token is the tenant — no header, no per-request lookup. This is the pattern Shopify's write path should follow (contrast the Shopify client_id=0 bug).
Test coverage
Notably strong — the reference example for inbound-channel testing:
| File | Coverage |
|---|---|
test/clientapi/g2a_orchestration_test.go | Full E2E: token, bearer-required, healthcheck, notifications, reservation lifecycle + validation, order happy/idempotent/409, expired-410, concurrent-no-double-mint, GH-mint-failure-no-leak |
g2a_test.go | unit: validation, stock, formatG2AValue, notification mapping |
g2a_fuzz_test.go | fuzz: value formatter + JSON DTO decode robustness |
middleware/g2a_auth_test.go, utils/g2a_jwt_test.go | auth table + cross-use rejection, expiry, tampering |
repo internal tests + test/load/g2a/g2a_load.js | repo + load |
Gaps: the async (202) branch has no dedicated unit test beyond the mint-failure chaos test; the concurrency loser-500 behavior is untested/unspecified.
Spec-vs-impl gaps & dead code
Spec features not implemented in v1 (present in api.yml, no handlers): /inapp/validate, /inapp/top-up, file/account inventory kinds (kind file/account exist in the model but only text is ever emitted), per-item inventory sub-routes.
Price-mismatch smell
G2A sends unit_price and auction_base_price; both are stored but never used in billing — billing uses the mapping's denomination, not what G2A actually charged the buyer. Worth confirming this is intended before scaling.
Dead / reserved: g2a_credentials.webhook_secret (never read/written), G2AReservation.G2AOrderID (link lives on g2a_orders.reservation_id instead), G2AOrderStatusFailed / G2AOrderItemStatusReturned constants (never assigned).
Smells: CreateVoucherOrderForJob builds a throwaway fiber.New() per order item (a per-item Fiber app under load — the same fake-context hack Shopify uses), and its doc comment still says "the Shopify ingest path" (now stale — G2A uses it too). buildG2AStock reports 1_000_000 unconditionally — fine only while GH mints on demand.
Vendor-topology leak check: clean
No vendor identity reaches G2A — codes are minted GH stock, kind is always text, and only decrypted code/PIN values are returned. See no-vendor-in-customer-facing.
Key files
- Docs:
docs/g2a/{api.yml,RUNBOOK.md,IMPLEMENTATION_PLAN.md} - Handlers:
http/handler/{g2a,g2a_order,g2a_reservation,admin_g2a,client_g2a}.go - Auth:
middleware/g2a_auth.go,utils/jwt.go - Model/repo/migrations:
database/models/g2a.go,database/repo/g2a_*.go,database/migration/20260611* - Jobs:
jobs/g2a_reservation_expiry_job.go,scheduler/g2a_reservation_expiry_task.go - Tests:
test/clientapi/g2a_orchestration_test.go
Shopify Integration
The inbound Shopify sales channel — per-tenant order polling, SKU→product mapping, voucher provisioning, fulfilment and cancellation on the 2026-04 GraphQL API. Includes the critical client_id=0 ingest bug.
Frontend Apps
Every frontend surface — the claim worker, client portal, marketing site, docs, this wiki, the mock API — plus the server-rendered admin UI.