OctoWiki

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-mintedDELETE …/inventory always 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.

MethodPathHandlerPurpose
GET/g2a/oauth/tokenG2ATokenHandlerissue bearer JWT
GET/g2a/healthcheckG2AHealthcheckHandler204, no DB — G2A polls this
POST/g2a/notificationsG2ANotificationsHandlerrecord marketplace events (audit only)
POST/g2a/reservationG2ACreateReservationHandlerreserve intent (+30m TTL)
PUT/g2a/reservation/:idG2ARenewReservationHandlerrenew TTL
DELETE/g2a/reservation/:idG2AReleaseReservationHandlerrelease (idempotent 204)
POST/g2a/orderG2ACreateOrderHandlerconfirm → mint codes
GET/g2a/order/:id/inventoryG2AGetInventoryHandlerpull codes (:id is the reservation id)
DELETE/g2a/order/:id/inventoryG2AReturnInventoryHandleralways 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_secret as query params (per G2A contract). Looks up creds by public g2a_client_id; an unknown client_id returns the same 401 invalid_client as 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 shared JWT_SECRET.
  • Validation (G2AAuthMiddleware): stateless — signature + expiry + Subject=="g2a", no per-request DB lookup (keeps healthcheck cheap). Parses bearer case-insensitively (G2A sends lowercase). Sets client_id + TenantIDKeythe token is the tenant anchor.
  • Subject separation: a g2a token is rejected by /api/v1 (which requires Subject:"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_size is the fixed 1_000_000 constant — no stock is actually held, so there's no row/stock locking (nothing finite to lock).
  • Expiry two ways: the G2AReservationExpiryTask sweeper (@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 on status=RESERVED, so ordered/expired transitions are mutually safe — a renew-vs-sweep race resolves via rows-affected==0 → 410.

Order + code delivery

G2ACreateOrderHandler:

  1. Idempotency pre-check: same g2a_order_id + same reservation → return same codes (200); bound to a different reservation → 409.
  2. Reservation state/expiry checks (409/410).
  3. Mint per item: require Denomination.Valid (else 400), build refCode = G2A_<g2a_order_id>_<reservation_item_id>, call CreateVoucherOrderForJobidempotent on ClientReference (pre-check + unique index), so retries never double-mint.
  4. Sync vs async: all units have codes → DELIVERED, 200 with inline codes; else → PENDING, 202 with empty stock (G2A re-pulls later).
  5. Persist g2a_orders + g2a_order_items (each a fresh inventory_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.

TableKey columnsNotes
g2a_credentialsclient_id UNIQUE, g2a_client_id UNIQUE, client_secret_hash, webhook_secret (unused), is_enabledone row/client, bcrypt only
g2a_product_mappingsUNIQUE(client_id, g2a_product_id), octopus_product_id, denomination, currency, is_active5-min read-through cache w/ negative caching; mirrors Shopify mappings
g2a_reservationsid VARCHAR(36) PK, status, expires_atpartial sweep index
g2a_reservation_itemsoctopus_product_id, quantity, unit_price, auction_base_price
g2a_ordersUNIQUE(client_id, g2a_order_id), reservation_id, octopus_order_id, statusunique key = DB-level idempotency
g2a_order_itemsoctopus_order_item_id, inventory_uuid UNIQUE, kind, statuskind always text in v1
g2a_notificationsnotification_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:

FileCoverage
test/clientapi/g2a_orchestration_test.goFull 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.gounit: validation, stock, formatG2AValue, notification mapping
g2a_fuzz_test.gofuzz: value formatter + JSON DTO decode robustness
middleware/g2a_auth_test.go, utils/g2a_jwt_test.goauth table + cross-use rejection, expiry, tampering
repo internal tests + test/load/g2a/g2a_load.jsrepo + 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

On this page