Memory Appendix
G2A Import Integration
G2A Import API integration — Octopus as SERVER/sales-channel for G2A marketplace (inbound API), confirmed scope
Source memory file:
project_g2a_import_integration.md· Category: Project / investigation This is a verbatim dump of Claude's persistent memory for the Octopus project. Rendered inside a code block so nothing is altered.
---
name: project_g2a_import_integration
description: "G2A Import API integration — Octopus as SERVER/sales-channel for G2A marketplace (inbound API), confirmed scope"
metadata:
node_type: memory
type: project
originSessionId: 92cf4c96-e302-4aec-be94-535ba5e8fba7
---
New initiative (confirmed 2026-06-06): integrate the **G2A Import API** so Octopus Cards are sold on the G2A marketplace. Spec at [docs/g2a/api.yml](../../go/src/github.com/vnay92/octopus/docs/g2a/api.yml) (title "Merchant's API to manage stock").
**This is a new pattern, distinct from both the vendor adapters and Shopify:**
- Other vendors (DT One/SEAGM/Runa/etc.): Octopus is the CLIENT, calls vendor to BUY codes.
- Shopify: Octopus is the CLIENT, POLLS Shopify for orders, then fulfills (sales channel, pull).
- **G2A Import: Octopus is the SERVER. G2A is the marketplace/client and CALLS INTO Octopus** to reserve stock, create orders, and pull codes. Sales channel, but PUSH/inbound. Octopus must expose an authenticated API + issue OAuth tokens. Closest prior art = OCTO self-vendor / `/api/v1` auth, NOT the vendor factory. Does NOT implement `VoucherVendor`.
**Confirmed decisions:**
1. Direction = **Import API** (sell our stock on G2A; Octopus = server). NOT the Export/dropship direction (where Octopus would buy G2A's catalog).
2. **Multi-tenant like Shopify** — per-client credentials + product mappings (mirror `shopify_product_mappings`, client-table credentials).
3. Stock sold = **Octopus Cards** (the GH/Grasshopper-sourced gift-card products; Grasshopper owns all `products`/`vendor_products`).
4. v1 scope = **`kind: text` only**; defer `file`, `account`, in-app top-up.
**Confirmed design decisions (2026-06-06):**
- Octopus Cards (GH) carry PIN + expiry (`GrasshopperVendor.CreateOrder` mints `xxxx-xxx-xxx` code + 6-digit PIN + 1yr expiry on demand). → G2A `value` format = `"Card": "<code>"; "PIN": "<pin>"`, NOT bare form.
- GH stock is **mint-on-demand**, not a finite pool (unlike Valkey ZREM pool allocation in `services/inventory.go`). So reservations hold NO codes — pure intent + TTL.
- **Sellable cap = effectively unlimited** — no cap check; report a large constant `inventory_size`.
- **Order = sync 200 default** — mint via GrasshopperVendor.CreateOrder, return codes inline; `202` only defensive fallback.
- **`DELETE /order/{id}/inventory` (return to stock) = return 400 error** — minted GH codes can't be un-minted. Op implication: G2A-side refund does NOT claw back delivered codes (reconciliation/cost-eaten, runbook note).
- **Outbound auction/price/stock sync to G2A is IN SCOPE** — second direction where Octopus is the CLIENT calling G2A (create auction, push price+stock, deactivate). Familiar vendor-adapter shape → mock in mocky-balboa with /_chaos. NEED the outbound spec (Export API / seller auction API) — the downloaded api.yml is inbound Import only.
**Status (2026-06-11): Phase 0 BUILT + verified** (build/vet/lint/test green). Plan doc at `docs/g2a/IMPLEMENTATION_PLAN.md`. Done: 5 migrations (`20260611000001..5`), `database/models/g2a.go` (all structs + status enums), repos `database/repo/g2a_credentials.go` + `g2a_product_mapping.go` (cached, mirrors shopify) with sqlmock tests in `g2a_repo_internal_test.go` (incl. cache hit/negative-cache via `mapCache`), admin handlers `http/handler/admin_g2a.go` (credential generate/rotate show-once, enable toggle, mappings list/create/toggle — uses EXPLICIT tx, NOT shopify's nil-tx which would panic on real PGClient), routes in `http/routes/admin.go`, page `views/admin/g2a_integration.jet` + link from `client_detail.jet`. Credentials in dedicated `g2a_credentials` table (NOT on clients model). Reservation/order repos deferred to their consuming phases (no speculative CRUD).
**Phase 1 (auth) BUILT + verified:** `utils/jwt.go` `GenerateG2AToken`/`ValidateG2AToken` (Subject="g2a", 15m TTL, auto-rejected by /api/v1 which requires Subject "access"); stateless `middleware/g2a_auth.go` (no per-request DB → cheap healthcheck; disable latency bounded by TTL; returns G2A {code,message} envelope directly, NOT errors.UnauthorizedError — note: `CustomErrorHandler` only maps `*web.AppError`, but `errors.*` are `*entity.AppError`, so entity errors fall to 500 — latent codebase bug, avoided here); `http/handler/g2a.go` `G2ATokenHandler` (GET /g2a/oauth/token, client_credentials via query params per spec, bcrypt-checks secret, 401 invalid_client / 403 disabled) + `G2AHealthcheckHandler` (204); mounted `app.Group("g2a")` in `http/routes/routes.go` (public token, `g2aAuth` group for the rest). Tests: `utils/g2a_jwt_test.go` (mint/validate/expired/cross-use/tampered) + `middleware/g2a_auth_test.go` (HTTP-level, no DB).
**Phase 2 (liveness+notifications) BUILT + verified:** healthcheck landed in P1. `database/repo/g2a_notifications.go` (Insert + List newest-first) + `g2a_notifications_internal_test.go`; `G2ANotificationsHandler` (POST /g2a/notifications, accepts JSON array, `g2aNotificationToModel` pure mapper, explicit tx, 204; records only — NO lifecycle side-effects per notification-scope rule) + `g2a_test.go` mapping test; route added; admin "Recent G2A Events" table on `views/admin/g2a_integration.jet` via `AdminG2AIntegrationPage` (`g2aNotificationDTOs` flattens sql.Null* for Jet). Jet range form is `{{range _, n := xs}}` (single var = index).
**Phase 3 (reservation lifecycle) BUILT + verified:** `database/repo/g2a_reservations.go` (InsertReservation[id=uuid, RETURNING created/updated]+InsertReservationItem, GetByID, GetItems, RenewG2AReservation[only status=RESERVED, returns rows-affected], SetG2AReservationStatus, ExpireG2AReservations[bulk RESERVED→EXPIRED where expires_at<now]) + `g2a_reservations_internal_test.go`. Handlers in `http/handler/g2a_reservation.go`: `POST /g2a/reservation` (validates each product_id maps to active octopus product via GetG2AProductMappingByG2AID, generates uuid, TTL=30m, inserts resv+items in tx, returns {reservation_id, stock:[{product_id, inventory_size=1_000_000}]} — `g2aUnlimitedInventory` const since GH mint-on-demand), `PUT reservation/:id` (renew: RESERVED→extend TTL; EXPIRED→410; ORDERED/RELEASED→400; nil→404), `DELETE reservation/:id` (release: ORDERED→400, non-RESERVED→204 idempotent, RESERVED→RELEASED+204). Pure helpers `validateG2AReservationItems`/`buildG2AStock`/`nullStringIfSet` unit-tested in `g2a_test.go`. Routes added. Expiry sweeper: `jobs/g2a_reservation_expiry_job.go` + `scheduler/g2a_reservation_expiry_task.go` (`@every 5m`, group "g2a", embeds *BaseTask) registered in `main.go` vouchers-enabled block. All tests pass incl `-race`.
G2A inbound surface now: `GET oauth/token` (public); bearer-authed `GET healthcheck`, `POST notifications`, `POST reservation`, `PUT/DELETE reservation/:id`.
**Phase 4 (order + materialization — the core) BUILT + verified:** `database/repo/g2a_orders.go` (InsertG2AOrder, GetG2AOrderByG2AOrderID[idempotency], GetG2AOrderByReservationID[=order_id lookup], InsertG2AOrderItem, GetG2AOrderItemsByOrderRefID) + `g2a_orders_internal_test.go`. **Mint path = `h.CreateVoucherOrderForJob(ctx, clientID, *entity.CreateVoucherOrderRequest{ProductID, Denomination, Quantity, ClientReference, Email:nil})`** — the canonical Shopify-style voucher pipeline: idempotent on ClientReference (`G2A_<g2a_order_id>_<reservation_item_id>`), Email:nil → no customer email, WalletID:nil → auto-select+debit client wallet, returns plaintext Vouchers; codes also persisted encrypted on octopus order_items. Denomination comes from `g2a_product_mappings.denomination` (re-fetched at order time; null → 400). Handlers in `http/handler/g2a_order.go`: `POST /g2a/order` (idempotent retry→200 same codes; same g2a_order_id diff reservation→409; reservation ORDERED→409; EXPIRED/RELEASED/time-past→410; nil→404; mints per reservation item, persists g2a_order+items+marks reservation ORDERED in one tx; **order_id returned = reservation_id**; allReady→200 inline else→202). `GET order/:order_id/inventory` (looks up by reservation_id, `buildG2AInventoryGroups` loads each g2a_order_item→octopus order_item via GetOrderItemByID, decrypts code+pin via `utils.DecryptVoucherData`, `formatG2AValue`→`"Card":"<code>";"PIN":"<pin>"`; returns Inventory array directly, NOT wrapped). `DELETE order/:order_id/inventory`→400 (can't un-mint). Pure `formatG2AValue` unit-tested. Routes added. All green incl lint.
CAVEAT (Phase 6/7 hardening): double-mint protection currently relies on ClientReference idempotency + ORDERED status guard, NOT a row lock — true serialization (SELECT FOR UPDATE on reservation) for concurrent POST /order is deferred to the orchestration concurrency suite.
G2A inbound surface COMPLETE: `GET oauth/token`(public); bearer: `GET healthcheck`, `POST notifications`, `POST/PUT/DELETE reservation`, `POST order`, `GET/DELETE order/:id/inventory`. The channel can now fulfill a real G2A sale end-to-end.
**Phase 5 (wiring/observability/safety) BUILT + verified:** 4 G2A business metrics in `metrics/instruments.go` (`octopus.business.g2a.{reservations_created[client_id], orders_created[client_id,status], codes_delivered[client_id,product_id], notifications_received[client_id,type]}`) recorded in the reservation/order/notification handlers via `if inst := appMetrics.OTel; inst != nil { inst.X.Add(ctx,n,metric.WithAttributes(...)) }`. Vendor-topology hygiene grep over G2A-facing strings = CLEAN (no vendor/supplier/brand vocabulary leaks to G2A). Runbook at `docs/g2a/RUNBOOK.md` (onboarding, TTL/idempotency/wallet/no-clawback behaviours, metrics, troubleshooting table, SELECT-only investigation tables). All green incl lint.
**Phase 6 (orchestration buyer-simulator) BUILT (compiles `-tags=orchestration`, vet exit 0; NOT run here — needs `migrate up`+`seed all`+real DB).** `test/clientapi/g2a_orchestration_test.go` drives inbound `/g2a/*` directly (G2A=client, NOT a mocky outbound vendor). Key harness facts: reuses `ForConfigGroup(t,"full_featured",fn)` + `ctx.Server.{Get,Post,Put,Delete}(path, testhelpers.WithAuth(token), WithBody/WithQuery)` from `test/clientapi/setup_test.go`+`test/testhelpers/http.go`; G2A bearer minted directly via `utils.NewAuthService([]byte(FetchEnv("JWT_SECRET","your-super-secret-key"))).GenerateG2AToken(clientID)` (matches middleware); creds/mapping seeded via my repos (idempotent, fresh g2a_product_id/g2a_order_id = `time.Now().UnixNano()` avoids cleanup). **GH mint needs outbound — `GrasshopperVendor.CreateOrder` REQUIRES 2xx from `{host}/api/vouchers` (mocky has NO GH route), so suite spins an in-process `httptest` GH stub returning `{"id":N}` and binds GH vendor host via `testhelpers.SetVendorAttribute(t,repo,vendorID,"host",stubURL)`.** Order/concurrency subtests `t.Skip` if no GH product (GetVendorByCode("GH")+GetVendorProductsbyFilters). Subtests: auth(token endpoint 200/401/403, protected→401, api-token cross-use→401), healthcheck 204, notifications(204+recorded+malformed 400), reservation(create/renew/release/idempotent-release/validation/unmapped/404), order(200|202 + Card/PIN format + idempotent retry same order_id + GET inventory idempotent + DELETE 400 + 409 conflict + 410 expired), concurrency(6× racing POST /order same g2a_order_id → exactly 1 g2a_order + 1 minted item = no double-mint). Run: `go test -tags=orchestration ./test/clientapi/ -run TestG2A_Orchestration -v`.
**Phase 7 (fuzz/chaos/load) BUILT + verified.** Fuzz: `http/handler/g2a_fuzz_test.go` (package handler) — `FuzzG2AValueFormatter` (invariants: no-PIN→bare code, with-PIN→Card/PIN envelope carrying both), `FuzzG2ANotificationDecode`, `FuzzG2AReservationDecode` (raw bytes→json.Unmarshal→pure fns, no panic). RAN here: seed corpus green + 6s `-fuzz` each (~136k execs, no crashes). Chaos: added `Order_GHMintFailure_NoCodeLeak` subtest to orchestration suite (refactored `g2aGHProductStub(status,body)` + `g2aGHProduct` wrapper; GH stub returns 500 → assert response never 200-with-codes AND no pullable inventory — robust to hard-fail-500 vs async-202). Load: `test/load/g2a/g2a_load.js` k6 (MODE=healthcheck default read-only / MODE=journey mints+debits → staging only; ramping VUs, p95 thresholds per endpoint tag). All compiles (`-tags=orchestration` vet exit 0), fuzz green, normal build clean. k6 not installed locally (user runs).
**Client-portal self-service credentials (2026-06-30) BUILT + verified.** Clients generate/rotate their OWN G2A creds from the portal (not just admin). Backend: `http/handler/client_g2a.go` (on main `*Handler`, session-scoped via `c.Locals("client_id").(uint64)`, uses `errors.*`+`entity.SuccessResponse` NOT admin's raw fiber.Map; reuses g2a_credentials repos + `h.AuthService.HashPassword`; secret shown once) — `ClientGetG2ACredentialsHandler` (GET status, empty-state when nil), `ClientGenerateG2ACredentialsHandler` (POST generate-or-rotate), `ClientSetG2AEnabledHandler` (POST {enabled}). Routes in `http/routes/client.go` under `clientAPI` (cookie auth, NO CSRF): `GET/POST /client/api/g2a/credentials`, `POST /client/api/g2a/enabled`. Frontend (Next.js `frontend/client/`, App Router, MUI v7, axios `baseURL=${NEXT_PUBLIC_API_URL}/client` withCredentials, envelope `{success,data}`→extractData): `src/types/g2a.ts`, `g2aApi{getStatus,generate,setEnabled}` in `src/services/api/client.ts`, `src/components/sections/settings/integrations/IntegrationsTabPanel.tsx` (mirrors api-keys one-time-secret + webhooks toggle: SettingsSection + Switch + rotate-confirm Dialog + one-time secret Dialog w/ copy), registered as Settings tab id:6 "Integrations" in `src/data/settings/settings-tabs.tsx` (deep-link `?tab=integrations`). tsc 0 errors, backend build/vet/lint clean. Build FE: `bun install` + `bunx tsc --noEmit` / `bun run build` from `frontend/client/`.
**INITIATIVE COMPLETE for inbound (Phases 0-7 + client self-service).** G2A inbound Import API: fully built, unit+fuzz tested locally, DB-backed orchestration+chaos suite authored (user runs via `go test -tags=orchestration ./test/clientapi/ -run TestG2A_Orchestration`), load script authored, runbook at `docs/g2a/RUNBOOK.md`, plan at `docs/g2a/IMPLEMENTATION_PLAN.md`. ONLY Phase 6b (outbound auction/price/stock sync to G2A + mocky-balboa `/g2a/*` mock) remains — BLOCKED on G2A Export/seller API spec (drop into `docs/g2a/`).
**Test plan:** Go G2A buyer-simulator in `test/clientapi/g2a_orchestration_test.go` (build tag `orchestration`, mirror Runa 28-subtest ref) drives inbound endpoints; mocky-balboa `/g2a/*` mocks the OUTBOUND seller API + chaos. Full matrix: unit (sqlmock/faultDB), e2e, validation, fuzz (request decoders + value formatter), chaos (GH mint faults), concurrency (`-race`, racing reserve/order/pull/delete), load (k6 on healthcheck + reserve→order→pull). Phases: 0 schema/creds/mapping → 1 OAuth issuer+bearer mw → 2 healthcheck+notifications → 3 reservation+expiry sweeper → 4 order+materialization+idempotency → 5 wiring/metrics/safety → 6 mocky+harness → 6b outbound auction adapter → 7 test matrix.
**API surface Octopus must expose (G2A calls these; OAuth2 client_credentials bearer):**
- `GET /oauth/token` (Octopus issues short-lived tokens), `GET /healthcheck` (204; uptime gates listings)
- `POST /reservation` (hold stock, TTL, returns reservation_id+counts), `PUT/DELETE /reservation/{id}` (renew/release)
- `POST /order` {reservation_id, g2a_order_id} → 200 codes inline or 202 async; 409 dup, 410 expired
- `GET /order/{id}/inventory` (pull codes; items {id,value,kind text|file|account}); `/inventory/{id}` (file base64), `/inventory/{id}/account`
- `DELETE /order/{id}/inventory?id[]=` (return/restock = refund path)
- `POST /notifications` (G2A→Octopus events, currently only `auction_deactivated`)
- `POST /inapp/validate` + `/inapp/top-up` (direct account top-up; = direct_topup capability, out of v1 scope)
**Hard parts:** reservations must hold real inventory with TTL/auto-release tied to Octopus real-time inventory allocation + `inventory-pump`; atomic reserve→order (no double-alloc); idempotent code re-pull (same codes, never re-mint); cheap OAuth minting; strict `value` formats (gift card `XXXX-YYYY-ZZZZ` or `"Card":..;"PIN":..`).
**Auction/offer creation is NOT in this API** — listing products + setting prices (`auction_base_price` EUR) is done via G2A seller panel or the separate Export API. Import API is runtime-only.
Relates to [[feedback_no_vendor_in_customer_facing]] (G2A here is a sales channel/buyer of our stock, an inversion), [[project_octopus_self_vendor]] (prior art for exposing an authed Octopus API).Client Portal Deploy
Client portal (frontend/client/, Next.js) is deployed to Cloudflare Pages, not via the systemd deploy scripts in deploy/
Neocurrency Spec Drift
NeoCurrency production wire shapes contradict the OpenAPI spec in several places — the spec is unreliable. Prefer json.RawMessage for fields where prod/spec dis