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.tsapps/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)returnsfirst4…last4·lenNfor 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():
- Creates the
shopee_active_environmentsingleton withenvironment=sandboxif missing - Creates the
shopee_environment_credentialssandbox row from existingSHOPEE_*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
envarg. 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" ifpartner_id/partner_key/shop_idmissing
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,useActionStatefor 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 replacedsaveShopeeCredentials—useActionStatewith form data; revalidates/connectors/shopeeswitchActiveEnvironment(env)— capability check + redirect-on-error
Shared-types additions¶
ShopeeCredentialsUpdate(PATCH request shape)SetActiveEnvironmentRequest(active-env POST request shape)ShopeeEnvironmentexported typeShopeeConnectorDetail+OAuthStartResponseextended additively forenvironment+active_environmentfields
PR-5 — Docs + runbook updates (ExzenTCG-Homelab#39, merged)¶
- The kickoff + implementation plans
tcg-platform/index.mdupdated with Phase 6 rowhomelab/05_service_deployments/tcg-staging.mdupdated with Phase 6 multi-environment Shopee section (new URLs + Partner Center setup steps for live)mkdocs.ymlnav 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
/ordersand/exceptionstable 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_SECRETrotation 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 isensureEnvironmentInitializedwhich reads them on first-ever boot to seed the sandbox row. New callers should go throughgetCredentialsForEnvironment(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 Nmarker.