Skip to content

Phase 6 Implementation Plan — Multi-environment Shopee Toggle

Status: closed out 2026-05-15 — all 5 PRs + 1 hotfix merged + validated

See Phase 6 Kickoff Plan for design decisions and Phase 6 Findings for the closeout validation.

PR-1 — Schema + migration + boot-seeder (#75, merged)

Foundation. Additive only — no caller uses any of this yet.

Schema

Table Change
shopee_environment_credentials (new) One row per env. Holds partner_id, shop_id, region, callback URLs, default location/shipping option. partner_key + push_partner_key stored as AES-256-GCM ciphertext. environment enum is unique.
shopee_active_environment (new) Singleton settings row. environment enum, default sandbox.
shopee_auth_token Add environment column (default sandbox); replace unique (shop_id) with composite (environment, shop_id).
shopee_raw_event Add environment column (default sandbox); replace unique (shopee_event_id) with composite (environment, shopee_event_id).
shopee_order_sync Add environment column (default sandbox); replace unique (ordersn) with composite (environment, ordersn).
connector_exception Add nullable environment text column; backfill pre-existing Shopee rows to 'sandbox'.

Migration files:

  • apps/server/src/modules/connectors/shopee/migrations/Migration20260514120000.ts
  • apps/server/src/modules/connectors/shared/migrations/Migration20260514120100.ts

Encryption helper

apps/server/src/modules/connectors/shopee/crypto.ts:

  • AES-256-GCM with HKDF-SHA256(JWT_SECRET, salt, info) → 32-byte key
  • 12-byte random IV per encryption
  • On-disk format: v1:base64(iv || authTag || ciphertext)
  • Versioned prefix lets us rotate algorithms later without a migration
  • fingerprintSecret(plaintext) returns first4…last4·lenN for UI display

Test coverage: 12 unit tests in apps/server/src/modules/connectors/shopee/__tests__/crypto.unit.spec.ts — roundtrip, tamper rejection, prefix validation, JWT-secret guard, fingerprint masking.

Boot-seeder

ShopeeConnectorService.ensureEnvironmentInitialized():

  1. Creates the shopee_active_environment singleton with environment=sandbox if missing
  2. Creates the shopee_environment_credentials sandbox row from existing SHOPEE_* env vars (encrypting the two key vars) if missing

Idempotent — race-tolerant via duplicate-key error catch. Called lazily by getShopeeSdk(), getCredentialsForEnvironment(), and setActiveEnvironment().

Test coverage: 5 unit tests in apps/server/src/modules/connectors/shopee/__tests__/environment-init.unit.spec.ts — cold seed, no-op on subsequent calls, dup-key race, error propagation, null-cred fallback.

After PR-1, SHOPEE_* env vars become advisory: they seed the DB row on first boot, but subsequent reads are from DB.

PR-2 — Env-aware SDK factory + active-env service (#77, merged)

Outbound SDK calls now route through DB credentials instead of process.env.

Service methods added to ShopeeConnectorService

Method Behavior
getActiveEnvironment() Returns singleton row's env. Defaults to sandbox (defensive).
setActiveEnvironment(env) Atomic singleton update + SDK cache invalidate. No-op if already at target.
getCredentialsForEnvironment(env) Returns decrypted creds + non-secret config. Throws if no row exists.

getShopeeSdk(service, env?) signature

  • Optional env arg. When omitted, defaults to the active env — preserves the 6 pre-Phase-6 single-arg callers (shopee-oauth-bootstrap.ts, the three daily jobs, the two OAuth route handlers) without modification
  • Per-env SDK cache (Map<ShopeeEnvironment, SDKInstance>); cache reset on env switch
  • Reads from getCredentialsForEnvironment; throws "not fully configured" if partner_id / partner_key / shop_id missing

PostgresTokenStorage changes

Constructor takes optional environment (default sandbox). Token rows are tagged with env on write; reads filter by env. Sandbox + live tokens coexist with no risk of one stomping the other.

Test coverage: 7 new unit tests in apps/server/src/modules/connectors/shopee/__tests__/active-environment.unit.spec.ts.

PR-3 — Path-routed env-aware endpoints (#78, merged)

URL Behavior
POST /connectors/shopee/webhook (legacy) Aliases to env=sandbox. Existing Partner Center registrations keep working.
POST /connectors/shopee/webhook/:env (new) Env-aware. HMAC verify against env's push_partner_key. Inactive env → 200 OK + log + drop.
GET /connectors/shopee/oauth/callback (legacy) Aliases to env=sandbox.
GET /connectors/shopee/oauth/callback/:env (new) Env-aware token exchange + dashboard redirect with ?env= and ?status=.
GET /admin/dashboard/connectors/shopee?env= (modified) Returns env-specific config + token state + active_environment for UI banner. Defaults to active env.
GET /admin/dashboard/connectors/shopee/oauth/start?env= (modified) Returns env-specific authorization URL + environment field.

The webhook handler lives in apps/server/src/modules/connectors/shopee/webhook/handler.ts (shared between legacy + new routes); the OAuth callback handler lives in apps/server/src/modules/connectors/shopee/oauth/callback-handler.ts.

PR-4 — Tabbed UI + active-env switcher + PATCH/active-env endpoints (#79, merged)

Server additions

URL Behavior
PATCH /admin/dashboard/connectors/shopee?env= Partial credential update. Secrets encrypted server-side. Blank secret = "no change" (never clears, never echoes).
POST /admin/dashboard/connectors/shopee/active-environment Body { environment }. Pre-flight 409 if target env's creds are incomplete (prevents activating a broken env).

Dashboard /connectors/shopee

  • Two-tab layout (Sandbox / Live), URL state in ?tab=
  • Active-env banner: "Currently signed in to "
  • Per-tab editable credentials form (credentials-form.tsx, client component, useActionState for inline result)
  • Per-tab Connect/Reconnect OAuth button (disabled until creds complete)
  • "Switch to " button on the inactive tab (also gated on completeness)

Server actions

  • startShopeeOAuth(env) — env explicit; PR-#73's single-arg signature replaced
  • saveShopeeCredentialsuseActionState with form data; revalidates /connectors/shopee
  • switchActiveEnvironment(env) — capability check + redirect-on-error

Shared-types additions

  • ShopeeCredentialsUpdate (PATCH request shape)
  • SetActiveEnvironmentRequest (active-env POST request shape)
  • ShopeeEnvironment exported type
  • ShopeeConnectorDetail + OAuthStartResponse extended additively for environment + active_environment fields

PR-5 — Docs + runbook updates (ExzenTCG-Homelab#39, merged)

  • The kickoff + implementation plans
  • tcg-platform/index.md updated with Phase 6 row
  • homelab/05_service_deployments/tcg-staging.md updated with Phase 6 multi-environment Shopee section (new URLs + Partner Center setup steps for live)
  • mkdocs.yml nav extended

Hotfix — empty live row at boot (#80, merged)

Caught during the first staging deploy: PR-4's dashboard fetches both env tabs in parallel; GET ?env=live 500s because the boot-seeder only seeded the sandbox row. The fix: seeder also creates an empty live row (all credential fields null, region='GLOBAL') so the dashboard's Live tab can render the empty cred form. SDK init still throws "not fully configured" if anyone tries to use the empty live creds — no functional bypass.

Deferred to PR-6 / Phase 7

  • Env badges + filter dropdowns on /orders and /exceptions table pages
  • Home-page stat cards: default to active-env-only, "All envs" toggle in corner
  • Two-Medusa-instance topology (CT 106 = tcg-prod) — fold dashboard's env-switcher into navbar
  • JWT_SECRET rotation tooling
  • Multi-shop per environment

Anti-patterns (do not re-introduce)

  • Don't read process.env.SHOPEE_* at runtime in new code. After PR-1+PR-2, env vars are advisory; the DB row is canonical. The single exception is ensureEnvironmentInitialized which reads them on first-ever boot to seed the sandbox row. New callers should go through getCredentialsForEnvironment(env).
  • Don't store plaintext secrets in the DB. Use encryptSecret(value, process.env.JWT_SECRET). The pair (partner_key_encrypted, push_partner_key_encrypted) are the only secret columns and they're versioned (v1: prefix) for future rotation.
  • Don't add a UI affordance that mixes sandbox + live data without env labels. Operators must always know which env they're looking at. PR-4 surfaces this via the tab + active-env banner; future PRs adding list pages must surface env badges.
  • Don't introduce a new connector that uses single-env env-var config. The Phase 6 schema is connector-specific (Shopee), but the pattern (env-keyed credentials table + active-env singleton) is the model for future Lazada / Telegram connectors. If you ship a new connector before generalizing the table, leave a # TODO: migrate to env-keyed credentials at Phase N marker.