Skip to content

Marketplace Connections (Marketplace Sync PR 1) Implementation Plan โ€‹

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Merchants on Growth+ tiers can connect their CardTrader seller account (paste API token), pick which games to sync there, and disconnect โ€” the foundation PR of the Marketplace Sync arc (spec: docs/superpowers/specs/2026-07-30-marketplace-sync-design.md).

Architecture: New server/marketplaces/ adapter registry mirroring server/plugins/ (game plugins), a Store.marketplaceConnections encrypted sub-doc mirroring notificationConfig (Slack), routes gated by the previously-unwired requireFeature('marketplaceSync'), and a Settings card mirroring NotificationSettings.jsx. No listing push yet โ€” that is PR 2; per ยง5.9 this PR adds only interface members it consumes.

Tech Stack: Node/Express (CommonJS), Mongoose, Zod, Vitest (tests are ESM), React 18 + retroui, axios client api.

Global Constraints โ€‹

  • Server code is CommonJS (require); test files are ESM (import), route-handler tests use createRequire (pattern: server/routes/buylist.settle.test.js).
  • All CardTrader HTTP calls go through server/marketplaces/cardtrader/client.js โ€” nothing else may call api.cardtrader.com (rule-6 analog). Never through shopifyAPI.js.
  • Secrets encrypted at rest with encryptToken/decryptToken from server/utils/crypto.js; encrypted fields use the Enc suffix (precedent: Store.clientSecretEnc). Plaintext tokens are never returned by any endpoint โ€” masked reads only (pattern: server/routes/notifications.js:29-39).
  • Every new sub-doc field is declared field-by-field in the Mongoose schema AND covered by a round-trip test (ยง5.2; pattern: server/models/Store.branding.test.js).
  • No identity defaults: no || 'mtg', no defaulted game/marketplace params (ยง5.5).
  • Game vocabulary is exactly ['mtg', 'pokemon', 'riftbound'] (inline Zod enum precedent: server/schemas/shop.js:85).
  • New endpoints: Zod schema in server/schemas/ + barrel export + validate() middleware on the route (ยง7).
  • Coverage โ‰ฅ70% on all four metrics for every touched file โ€” read the per-file table yourself; the Vitest 4 threshold gate is silently dead (ยง7).
  • Husky pre-commit runs ESLint; never --no-verify.
  • Commits: one imperative sentence, merchant-visible outcome, sentence case, no trailing period.

ยง5.1 Game-parity statement (for the PR description) โ€‹

mtg / pokemon / riftbound: all three are supported CardTrader games and appear identically in CardTraderAdapter.supportedGames(); no per-game plugin, model, or importer is touched in this PR. Manapool (MTG-only) is deliberately absent until its adapter PR.


Task 1: Store.marketplaceConnections sub-document โ€‹

Files:

  • Modify: server/models/Store.js (insert after the notificationConfig block, which ends at line 331)
  • Test: server/models/Store.marketplaceConnections.test.js (create)

Interfaces:

  • Produces: store.marketplaceConnections.cardtrader with fields enabled:Boolean, accessTokenEnc:String|null, sharedSecretEnc:String|null, connectedAt:Date|null, enabledGames:[String], lastPushAt:Date|null, lastPushStatus:String|null. Tasks 6 (routes) read/write these exact names.

  • [ ] Step 1: Write the failing round-trip test

js
// server/models/Store.marketplaceConnections.test.js
import { describe, it, expect } from 'vitest';
import Store from './Store.js';

const cardtraderDoc = {
  enabled: true,
  accessTokenEnc: 'aa11:bb22:cc33',
  sharedSecretEnc: 'dd44:ee55:ff66',
  connectedAt: new Date('2026-07-31T00:00:00Z'),
  enabledGames: ['mtg', 'pokemon', 'riftbound'],
  lastPushAt: new Date('2026-07-31T01:00:00Z'),
  lastPushStatus: 'success'
};

describe('Store.marketplaceConnections sub-document', () => {
  it('round-trips every declared cardtrader field (fails if a schema line is dropped)', () => {
    const store = new Store({
      shop: 'test.myshopify.com',
      accessToken: 'x',
      marketplaceConnections: { cardtrader: cardtraderDoc }
    });
    const ct = store.marketplaceConnections.cardtrader;
    expect(ct.enabled).toBe(true);
    expect(ct.accessTokenEnc).toBe('aa11:bb22:cc33');
    expect(ct.sharedSecretEnc).toBe('dd44:ee55:ff66');
    expect(ct.connectedAt).toEqual(cardtraderDoc.connectedAt);
    expect(ct.enabledGames.slice()).toEqual(['mtg', 'pokemon', 'riftbound']);
    expect(ct.lastPushAt).toEqual(cardtraderDoc.lastPushAt);
    expect(ct.lastPushStatus).toBe('success');
    expect(store.validateSync()?.errors?.marketplaceConnections).toBeUndefined();
  });

  it('is optional โ€” a store with no connections is valid and defaults are safe', () => {
    const store = new Store({ shop: 'x.myshopify.com', accessToken: 'x' });
    const ct = store.marketplaceConnections.cardtrader;
    expect(ct.enabled).toBe(false);
    expect(ct.accessTokenEnc).toBeNull();
    expect(ct.enabledGames.slice()).toEqual([]);
    expect(store.validateSync()).toBeUndefined();
  });

  it('rejects an unknown game id in enabledGames', () => {
    const store = new Store({
      shop: 'x.myshopify.com',
      accessToken: 'x',
      marketplaceConnections: { cardtrader: { enabledGames: ['yugioh'] } }
    });
    expect(store.validateSync()).toBeDefined();
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npx vitest run server/models/Store.marketplaceConnections.test.js Expected: FAIL โ€” enabled is undefined (strict mode dropped the undeclared sub-doc).

  • [ ] Step 3: Add the schema block to Store.js (immediately after the notificationConfig closing }, at line 331)
js
    // Third-party marketplace connections (Marketplace Sync, issue #316).
    // Keyed by marketplace id; every field declared explicitly โ€” strict mode
    // silently drops undeclared sub-doc fields on write (ยง5.2). Secrets use
    // the Enc suffix and utils/crypto (precedent: clientSecretEnc).
    marketplaceConnections: {
        cardtrader: {
            enabled: {
                type: Boolean,
                default: false
            },
            // Encrypted CardTrader bearer token (seller-generated JWT)
            accessTokenEnc: {
                type: String,
                default: null
            },
            // Encrypted webhook HMAC secret, captured from GET /info at connect
            sharedSecretEnc: {
                type: String,
                default: null
            },
            connectedAt: {
                type: Date,
                default: null
            },
            // Games the merchant syncs to this marketplace. Intersected with
            // enabledCatalogs at push time (PR 2) โ€” not duplicated here.
            enabledGames: {
                type: [String],
                default: [],
                validate: {
                    validator: (games) => games.every((g) => ['mtg', 'pokemon', 'riftbound'].includes(g)),
                    message: 'enabledGames contains an unsupported game id'
                }
            },
            lastPushAt: {
                type: Date,
                default: null
            },
            lastPushStatus: {
                type: String,
                default: null
            }
        }
    },
  • [ ] Step 4: Run to verify it passes

Run: npx vitest run server/models/Store.marketplaceConnections.test.js Expected: 3 passed. Also run npx vitest run server/models/ โ€” no other model test regresses.

  • [ ] Step 5: Commit
bash
git add server/models/Store.js server/models/Store.marketplaceConnections.test.js
git commit -m "Add CardTrader connection storage to the store model"

Task 2: Zod schemas for the connection endpoints โ€‹

Files:

  • Create: server/schemas/marketplaceConnections.js
  • Modify: server/schemas/index.js (add barrel line after // Notifications group, line 56)
  • Test: server/schemas/marketplaceConnections.test.js (create)

Interfaces:

  • Produces: MARKETPLACE_IDS (array, currently ['cardtrader']), marketplaceConnectionParamsSchema ({ marketplace }), marketplaceConnectionUpdateSchema ({ enabled?, accessToken?|null, enabledGames? }). Task 6 consumes all three via the schema barrel.

  • [ ] Step 1: Write the failing test

js
// server/schemas/marketplaceConnections.test.js
import { describe, it, expect } from 'vitest';
import {
  MARKETPLACE_IDS,
  marketplaceConnectionParamsSchema,
  marketplaceConnectionUpdateSchema,
} from './marketplaceConnections.js';

describe('marketplaceConnectionParamsSchema', () => {
  it('accepts a known marketplace id', () => {
    expect(marketplaceConnectionParamsSchema.parse({ marketplace: 'cardtrader' }))
      .toEqual({ marketplace: 'cardtrader' });
  });
  it('rejects unknown marketplaces โ€” no fallback (ยง5.5)', () => {
    expect(marketplaceConnectionParamsSchema.safeParse({ marketplace: 'tcgplayer' }).success).toBe(false);
    expect(marketplaceConnectionParamsSchema.safeParse({}).success).toBe(false);
  });
});

describe('marketplaceConnectionUpdateSchema', () => {
  it('accepts a token connect payload', () => {
    const body = { accessToken: 'a'.repeat(64), enabledGames: ['mtg', 'riftbound'] };
    expect(marketplaceConnectionUpdateSchema.parse(body)).toEqual(body);
  });
  it('accepts null accessToken (disconnect)', () => {
    expect(marketplaceConnectionUpdateSchema.parse({ accessToken: null })).toEqual({ accessToken: null });
  });
  it('rejects a short token and unknown games', () => {
    expect(marketplaceConnectionUpdateSchema.safeParse({ accessToken: 'short' }).success).toBe(false);
    expect(marketplaceConnectionUpdateSchema.safeParse({ enabledGames: ['yugioh'] }).success).toBe(false);
  });
  it('MARKETPLACE_IDS currently lists exactly cardtrader', () => {
    expect(MARKETPLACE_IDS).toEqual(['cardtrader']);
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npx vitest run server/schemas/marketplaceConnections.test.js Expected: FAIL โ€” module not found.

  • [ ] Step 3: Implement the schema module
js
// server/schemas/marketplaceConnections.js
/**
 * Marketplace connection schemas (Marketplace Sync, issue #316).
 *
 * Validates GET/PUT /api/marketplace-connections. `marketplace` is an enum
 * with NO default โ€” an absent or unknown marketplace is a 400, never a guess
 * (ยง5.5). Grow MARKETPLACE_IDS as adapter PRs land (manapool is PR 5).
 */
'use strict';

const { z } = require('zod');

const MARKETPLACE_IDS = ['cardtrader'];

const marketplaceConnectionParamsSchema = z.object({
    marketplace: z.enum(MARKETPLACE_IDS),
});

/**
 * PUT /api/marketplace-connections/:marketplace body.
 * accessToken: null disconnects; a string is validated live against the
 * marketplace before being stored (route does the live check, not Zod).
 * Length bounds only โ€” token format is the marketplace's business (ยง5.4:
 * don't invent a format we haven't verified).
 */
const marketplaceConnectionUpdateSchema = z.object({
    enabled: z.boolean().optional(),
    accessToken: z.string().min(20).max(4096).nullable().optional(),
    enabledGames: z.array(z.enum(['mtg', 'pokemon', 'riftbound'])).max(3).optional(),
});

module.exports = {
    MARKETPLACE_IDS,
    marketplaceConnectionParamsSchema,
    marketplaceConnectionUpdateSchema,
};

Add to server/schemas/index.js (after the // Notifications entry):

js
    // Marketplace connections
    ...require('./marketplaceConnections'),
  • [ ] Step 4: Run to verify it passes

Run: npx vitest run server/schemas/ Expected: new file passes; existing schemas.test.js still green.

  • [ ] Step 5: Commit
bash
git add server/schemas/marketplaceConnections.js server/schemas/marketplaceConnections.test.js server/schemas/index.js
git commit -m "Validate marketplace connection requests with Zod"

Task 3: Adapter base class and registry โ€‹

Files:

  • Create: server/marketplaces/BaseMarketplaceAdapter.js
  • Create: server/marketplaces/index.js
  • Test: server/marketplaces/index.test.js (create)

Interfaces:

  • Produces: BaseMarketplaceAdapter (abstract: id, displayName, supportedGames(), validateConnection(creds)), registry functions getAdapter(id), getAllAdapters(), getSupportedMarketplaces(). Task 5 subclasses the base; Task 6 consumes the registry.

  • Note: the SYNC INTERFACE members from the spec (mapVariant, pushListings, delist, fetchOrders, verifyWebhook, parseOrderEvent, rateLimiterConfig) are deliberately not declared here โ€” nothing consumes them until PR 2, and unwired scaffolding is the ยง5.9 failure mode. PR 2 adds them alongside their first consumer.

  • [ ] Step 1: Write the failing test

js
// server/marketplaces/index.test.js
import { describe, it, expect } from 'vitest';
import { createRequire } from 'module';

const require_ = createRequire(import.meta.url);
const BaseMarketplaceAdapter = require_('./BaseMarketplaceAdapter.js');
const { getAdapter, getAllAdapters, getSupportedMarketplaces } = require_('./index.js');

describe('BaseMarketplaceAdapter', () => {
  it('cannot be instantiated directly', () => {
    expect(() => new BaseMarketplaceAdapter()).toThrow(/abstract/i);
  });
  it('subclass must implement the interface', async () => {
    class Empty extends BaseMarketplaceAdapter {}
    const a = new Empty();
    expect(() => a.id).toThrow(/implement/i);
    expect(() => a.supportedGames()).toThrow(/implement/i);
    await expect(a.validateConnection({})).rejects.toThrow(/implement/i);
  });
});

describe('marketplace registry', () => {
  it('resolves the cardtrader adapter', () => {
    const adapter = getAdapter('cardtrader');
    expect(adapter.id).toBe('cardtrader');
    expect(adapter).toBeInstanceOf(BaseMarketplaceAdapter);
  });
  it('throws on unknown marketplace โ€” no fallback (ยง5.5)', () => {
    expect(() => getAdapter('tcgplayer')).toThrow(/Unknown marketplace/);
    expect(() => getAdapter(undefined)).toThrow(/Unknown marketplace/);
  });
  it('lists registered marketplaces', () => {
    expect(getSupportedMarketplaces()).toEqual(['cardtrader']);
    expect(Object.keys(getAllAdapters())).toEqual(['cardtrader']);
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npx vitest run server/marketplaces/index.test.js Expected: FAIL โ€” modules not found.

  • [ ] Step 3: Implement base + registry (registry mirrors server/plugins/index.js)
js
// server/marketplaces/BaseMarketplaceAdapter.js
/**
 * Base Marketplace Adapter (Marketplace Sync, issue #316)
 *
 * The marketplace analog of plugins/BaseGamePlugin: route/queue code is
 * adapter-driven โ€” no `if (marketplace === '...')` outside adapter dirs.
 *
 * PR 1 declares only what PR 1 consumes (connection validation). The SYNC
 * INTERFACE (mapVariant, pushListings, delist, fetchOrders, verifyWebhook,
 * parseOrderEvent, rateLimiterConfig) lands in PR 2 with its first consumer
 * (ยง5.9 โ€” no dead scaffolding). Spec: docs/superpowers/specs/2026-07-30-
 * marketplace-sync-design.md.
 */
'use strict';

class BaseMarketplaceAdapter {
    constructor() {
        if (new.target === BaseMarketplaceAdapter) {
            throw new Error('BaseMarketplaceAdapter is abstract โ€” instantiate a subclass');
        }
    }

    /** @returns {string} stable marketplace id, e.g. 'cardtrader' */
    get id() {
        throw new Error(`${this.constructor.name} must implement get id()`);
    }

    /** @returns {string} merchant-facing name, e.g. 'CardTrader' */
    get displayName() {
        throw new Error(`${this.constructor.name} must implement get displayName()`);
    }

    /** @returns {string[]} subset of ['mtg','pokemon','riftbound'] this marketplace sells */
    supportedGames() {
        throw new Error(`${this.constructor.name} must implement supportedGames()`);
    }

    /**
     * Verify credentials against the live marketplace API.
     * @param {{accessToken: string}} creds โ€” plaintext, never persisted here
     * @returns {Promise<{ok: true, accountName: string|null, sharedSecret: string|null}>}
     * @throws {Error} adapter-specific auth error when credentials are rejected
     */
    async validateConnection(creds) { // eslint-disable-line no-unused-vars
        throw new Error(`${this.constructor.name} must implement validateConnection()`);
    }
}

module.exports = BaseMarketplaceAdapter;
js
// server/marketplaces/index.js
/**
 * Marketplace Adapter Registry
 * Central registry for all marketplace adapters (mirror of plugins/index.js).
 */
'use strict';

const CardTraderAdapter = require('./cardtrader');

const adapters = {
    cardtrader: new CardTraderAdapter(),
};

/**
 * @param {string} marketplaceId
 * @returns {BaseMarketplaceAdapter}
 * @throws {Error} if adapter not found โ€” never falls back (ยง5.5)
 */
function getAdapter(marketplaceId) {
    const adapter = adapters[marketplaceId];
    if (!adapter) {
        throw new Error(`Unknown marketplace: ${marketplaceId}. Available: ${Object.keys(adapters).join(', ')}`);
    }
    return adapter;
}

function getAllAdapters() {
    return adapters;
}

function getSupportedMarketplaces() {
    return Object.keys(adapters);
}

module.exports = {
    getAdapter,
    getAllAdapters,
    getSupportedMarketplaces,
};

Note: this file requires ./cardtrader, which does not exist until Task 5. To keep Task 3 independently green, create the minimal server/marketplaces/cardtrader/index.js stub in this task:

js
// server/marketplaces/cardtrader/index.js  (completed in Task 5)
'use strict';

const BaseMarketplaceAdapter = require('../BaseMarketplaceAdapter');

class CardTraderAdapter extends BaseMarketplaceAdapter {
    get id() { return 'cardtrader'; }
    get displayName() { return 'CardTrader'; }
    supportedGames() { return ['mtg', 'pokemon', 'riftbound']; }
}

module.exports = CardTraderAdapter;
  • [ ] Step 4: Run to verify it passes

Run: npx vitest run server/marketplaces/ Expected: all pass (the validateConnection assertion passes because the stub inherits the base's throwing implementation).

  • [ ] Step 5: Commit
bash
git add server/marketplaces/
git commit -m "Add marketplace adapter registry with CardTrader registered"

Task 4: CardTrader HTTP client โ€‹

Files:

  • Create: server/marketplaces/cardtrader/client.js
  • Test: server/marketplaces/cardtrader/client.test.js (create)

Interfaces:

  • Produces: getInfo(accessToken) โ†’ parsed JSON of GET https://api.cardtrader.com/api/v2/info; throws CardTraderAuthError on 401/403, Error otherwise. Exports CardTraderAuthError, _setDeps, _resetDeps. Task 5 consumes getInfo; Task 6's error mapping consumes CardTraderAuthError.

  • This file is the ONLY place allowed to call api.cardtrader.com (Global Constraints). PR 2 extends it with the rate limiter + bulk endpoints.

  • [ ] Step 1: Write the failing test

js
// server/marketplaces/cardtrader/client.test.js
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createRequire } from 'module';

const require_ = createRequire(import.meta.url);
const client = require_('./client.js');
const { getInfo, CardTraderAuthError } = client;

afterEach(() => client._resetDeps());

function mockFetch(status, body) {
  return vi.fn().mockResolvedValue({
    ok: status >= 200 && status < 300,
    status,
    json: async () => body,
  });
}

describe('cardtrader client getInfo', () => {
  it('sends the bearer token to /info and returns the parsed body', async () => {
    const fetchMock = mockFetch(200, { id: 42, name: 'lgs-forge-app', shared_secret: 'sekrit' });
    client._setDeps({ fetch: fetchMock });

    const info = await getInfo('jwt-token-abc');

    expect(fetchMock).toHaveBeenCalledWith(
      'https://api.cardtrader.com/api/v2/info',
      expect.objectContaining({
        headers: expect.objectContaining({ Authorization: 'Bearer jwt-token-abc' }),
      })
    );
    expect(info.shared_secret).toBe('sekrit');
  });

  it('throws CardTraderAuthError on 401', async () => {
    client._setDeps({ fetch: mockFetch(401, { error_code: 'unauthorized' }) });
    await expect(getInfo('bad')).rejects.toBeInstanceOf(CardTraderAuthError);
  });

  it('throws a plain error on 500', async () => {
    client._setDeps({ fetch: mockFetch(500, {}) });
    const err = await getInfo('tok').catch((e) => e);
    expect(err).toBeInstanceOf(Error);
    expect(err).not.toBeInstanceOf(CardTraderAuthError);
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npx vitest run server/marketplaces/cardtrader/client.test.js Expected: FAIL โ€” module not found.

  • [ ] Step 3: Implement the client
js
// server/marketplaces/cardtrader/client.js
/**
 * CardTrader API client โ€” the ONLY module allowed to call api.cardtrader.com
 * (rule-6 analog; see plan Global Constraints). PR 2 adds the rate-limited
 * bulk endpoints here; PR 1 needs only GET /info (connection validation).
 *
 * API reference: https://www.cardtrader.com/en/docs/api/full/reference
 * Auth: per-seller long-lived bearer JWT, generated by the merchant at
 * Settings -> API Access. Rate budget (PR 2 concern): 200 req / 10 s.
 */
'use strict';

const CARDTRADER_API_BASE = 'https://api.cardtrader.com/api/v2';
const REQUEST_TIMEOUT_MS = 15000;

class CardTraderAuthError extends Error {
    constructor(message = 'CardTrader rejected the API token') {
        super(message);
        this.name = 'CardTraderAuthError';
    }
}

// Overridable deps for testing (follows the _setDeps pattern used elsewhere).
let _deps = null;
function _setDeps(deps) { _deps = deps; }
function _resetDeps() { _deps = null; }
function _fetch() { return (_deps && _deps.fetch) || fetch; }

/**
 * GET /info โ€” validates the token and returns the app object
 * ({id, name, shared_secret, ...}). Field names verified against the live
 * API during implementation โ€” do not rename without re-checking (ยง5.4).
 */
async function getInfo(accessToken) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
    try {
        const response = await _fetch()(`${CARDTRADER_API_BASE}/info`, {
            method: 'GET',
            headers: {
                Authorization: `Bearer ${accessToken}`,
                Accept: 'application/json',
            },
            signal: controller.signal,
        });
        if (response.status === 401 || response.status === 403) {
            throw new CardTraderAuthError();
        }
        if (!response.ok) {
            throw new Error(`CardTrader /info failed with status ${response.status}`);
        }
        return await response.json();
    } finally {
        clearTimeout(timer);
    }
}

module.exports = {
    getInfo,
    CardTraderAuthError,
    CARDTRADER_API_BASE,
    _setDeps,
    _resetDeps,
};
  • [ ] Step 4: Run to verify it passes

Run: npx vitest run server/marketplaces/cardtrader/client.test.js Expected: 3 passed.

  • [ ] Step 5: Commit
bash
git add server/marketplaces/cardtrader/client.js server/marketplaces/cardtrader/client.test.js
git commit -m "Add CardTrader API client with token validation call"

Task 5: CardTrader adapter validateConnection โ€‹

Files:

  • Modify: server/marketplaces/cardtrader/index.js (the Task 3 stub)
  • Test: server/marketplaces/cardtrader/index.test.js (create)

Interfaces:

  • Consumes: client.getInfo(accessToken), client.CardTraderAuthError (Task 4).

  • Produces: adapter.validateConnection({accessToken}) โ†’ {ok: true, accountName: string|null, sharedSecret: string|null}; rethrows CardTraderAuthError untouched (Task 6 maps it to a 422).

  • [ ] Step 1: Write the failing test

js
// server/marketplaces/cardtrader/index.test.js
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createRequire } from 'module';

const require_ = createRequire(import.meta.url);
const CardTraderAdapter = require_('./index.js');
const client = require_('./client.js');

afterEach(() => vi.restoreAllMocks());

describe('CardTraderAdapter', () => {
  const adapter = new CardTraderAdapter();

  it('declares identity and all three games', () => {
    expect(adapter.id).toBe('cardtrader');
    expect(adapter.displayName).toBe('CardTrader');
    expect(adapter.supportedGames()).toEqual(['mtg', 'pokemon', 'riftbound']);
  });

  it('validateConnection returns account name and shared secret from /info', async () => {
    vi.spyOn(client, 'getInfo').mockResolvedValue({ id: 42, name: 'my-app', shared_secret: 'sekrit' });
    const result = await adapter.validateConnection({ accessToken: 'jwt' });
    expect(client.getInfo).toHaveBeenCalledWith('jwt');
    expect(result).toEqual({ ok: true, accountName: 'my-app', sharedSecret: 'sekrit' });
  });

  it('tolerates /info responses missing optional fields', async () => {
    vi.spyOn(client, 'getInfo').mockResolvedValue({ id: 42 });
    const result = await adapter.validateConnection({ accessToken: 'jwt' });
    expect(result).toEqual({ ok: true, accountName: null, sharedSecret: null });
  });

  it('propagates auth errors from the client', async () => {
    vi.spyOn(client, 'getInfo').mockRejectedValue(new client.CardTraderAuthError());
    await expect(adapter.validateConnection({ accessToken: 'bad' }))
      .rejects.toBeInstanceOf(client.CardTraderAuthError);
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npx vitest run server/marketplaces/cardtrader/index.test.js Expected: FAIL โ€” validateConnection throws "must implement".

  • [ ] Step 3: Complete the adapter
js
// server/marketplaces/cardtrader/index.js
/**
 * CardTrader marketplace adapter (Marketplace Sync, issue #316).
 * PR 1: connection validation only. PR 2 adds the sync interface
 * (mapVariant/pushListings/... โ€” see the spec).
 */
'use strict';

const BaseMarketplaceAdapter = require('../BaseMarketplaceAdapter');
const client = require('./client');

class CardTraderAdapter extends BaseMarketplaceAdapter {
    get id() { return 'cardtrader'; }

    get displayName() { return 'CardTrader'; }

    // CardTrader sells all three of our games (verified against GET /games
    // and cardtrader.com/en/riftbound, 2026-07-30 research).
    supportedGames() { return ['mtg', 'pokemon', 'riftbound']; }

    async validateConnection({ accessToken }) {
        const info = await client.getInfo(accessToken);
        return {
            ok: true,
            accountName: info.name || null,
            // Webhook HMAC key โ€” persisted encrypted at connect so the PR 3
            // webhook receiver can verify order events.
            sharedSecret: info.shared_secret || null,
        };
    }
}

module.exports = CardTraderAdapter;
  • [ ] Step 4: Run to verify it passes

Run: npx vitest run server/marketplaces/ Expected: all marketplace tests pass (registry test from Task 3 included).

  • [ ] Step 5: Commit
bash
git add server/marketplaces/cardtrader/index.js server/marketplaces/cardtrader/index.test.js
git commit -m "Validate CardTrader tokens against the live API on connect"

Task 6: Connection routes, gated by requireFeature('marketplaceSync') โ€‹

Files:

  • Create: server/routes/marketplaces.js
  • Modify: server/routes/api.js (mount next to the notifications router โ€” grep require('./notifications') and add the line below it)
  • Test: server/routes/marketplaces.test.js (create)

Interfaces:

  • Consumes: registry getAdapter/getAllAdapters (Task 3), CardTraderAuthError (Task 4), schemas (Task 2), encryptToken/decryptToken, requireFeature, validate.

  • Produces: GET /api/marketplace-connections โ†’ {connections: [{marketplace, displayName, supportedGames, enabled, connected, tokenMasked, enabledGames, connectedAt, lastPushAt, lastPushStatus}]}; PUT /api/marketplace-connections/:marketplace โ†’ {success: true} | 422 {error: 'invalid_credentials'} | 422 {error: 'unsupported_games', games} | 502 {error: 'marketplace_unreachable'}. Both 403 {error: 'upgrade_required', requiredFeature: 'marketplaceSync', currentTier} below Growth (that shape comes from requireFeature.js:35-39). Task 7's client consumes these exact shapes.

  • Named handlers are exported for direct testing (pattern: server/routes/buylist.js / buylist.settle.test.js).

  • [ ] Step 1: Write the failing handler tests

js
// server/routes/marketplaces.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createRequire } from 'module';

const require_ = createRequire(import.meta.url);
const { handleGetConnections, handleUpdateConnection } = require_('./marketplaces.js');
const Store = require_('../models/Store.js');
const { encryptToken } = require_('../utils/crypto.js');
const client = require_('../marketplaces/cardtrader/client.js');
const cardtraderModule = require_('../marketplaces/cardtrader/index.js');

function makeRes() {
  return { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() };
}

function makeReq(overrides = {}) {
  return {
    params: { marketplace: 'cardtrader' },
    body: {},
    store: { shop: 'test.myshopify.com', marketplaceConnections: {} },
    ...overrides,
  };
}

describe('handleGetConnections', () => {
  it('lists cardtrader as disconnected for a fresh store', async () => {
    const res = makeRes();
    await handleGetConnections(makeReq(), res);
    const payload = res.json.mock.calls[0][0];
    expect(payload.connections).toHaveLength(1);
    expect(payload.connections[0]).toMatchObject({
      marketplace: 'cardtrader',
      displayName: 'CardTrader',
      supportedGames: ['mtg', 'pokemon', 'riftbound'],
      connected: false,
      tokenMasked: null,
      enabled: false,
      enabledGames: [],
    });
  });

  it('masks the stored token and never returns plaintext', async () => {
    const token = 'jwt-token-value-1234567890';
    const req = makeReq({
      store: {
        shop: 'test.myshopify.com',
        marketplaceConnections: {
          cardtrader: { enabled: true, accessTokenEnc: encryptToken(token), enabledGames: ['mtg'] },
        },
      },
    });
    const res = makeRes();
    await handleGetConnections(req, res);
    const conn = res.json.mock.calls[0][0].connections[0];
    expect(conn.connected).toBe(true);
    expect(conn.tokenMasked).toBe('...' + token.slice(-6));
    expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain(token);
  });
});

describe('handleUpdateConnection', () => {
  beforeEach(() => {
    vi.spyOn(Store, 'findOneAndUpdate').mockResolvedValue({});
  });
  afterEach(() => vi.restoreAllMocks());

  it('validates the token live, then stores it encrypted with the shared secret', async () => {
    vi.spyOn(client, 'getInfo').mockResolvedValue({ name: 'app', shared_secret: 'sekrit' });
    const res = makeRes();
    await handleUpdateConnection(makeReq({ body: { accessToken: 'a'.repeat(30) } }), res);

    expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true }));
    const setArg = Store.findOneAndUpdate.mock.calls[0][1].$set;
    expect(setArg['marketplaceConnections.cardtrader.accessTokenEnc']).toMatch(/^[0-9a-f]+:[0-9a-f]+:/);
    expect(setArg['marketplaceConnections.cardtrader.accessTokenEnc']).not.toContain('a'.repeat(30));
    expect(setArg['marketplaceConnections.cardtrader.sharedSecretEnc']).toBeDefined();
    expect(setArg['marketplaceConnections.cardtrader.connectedAt']).toBeInstanceOf(Date);
  });

  it('maps a rejected token to 422 invalid_credentials and stores nothing', async () => {
    vi.spyOn(client, 'getInfo').mockRejectedValue(new client.CardTraderAuthError());
    const res = makeRes();
    await handleUpdateConnection(makeReq({ body: { accessToken: 'a'.repeat(30) } }), res);
    expect(res.status).toHaveBeenCalledWith(422);
    expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_credentials' }));
    expect(Store.findOneAndUpdate).not.toHaveBeenCalled();
  });

  it('maps a network failure to 502 marketplace_unreachable', async () => {
    vi.spyOn(client, 'getInfo').mockRejectedValue(new Error('fetch failed'));
    const res = makeRes();
    await handleUpdateConnection(makeReq({ body: { accessToken: 'a'.repeat(30) } }), res);
    expect(res.status).toHaveBeenCalledWith(502);
    expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'marketplace_unreachable' }));
  });

  it('null token disconnects: clears creds and disables', async () => {
    const res = makeRes();
    await handleUpdateConnection(makeReq({ body: { accessToken: null } }), res);
    const setArg = Store.findOneAndUpdate.mock.calls[0][1].$set;
    expect(setArg['marketplaceConnections.cardtrader.accessTokenEnc']).toBeNull();
    expect(setArg['marketplaceConnections.cardtrader.sharedSecretEnc']).toBeNull();
    expect(setArg['marketplaceConnections.cardtrader.connectedAt']).toBeNull();
    expect(setArg['marketplaceConnections.cardtrader.enabled']).toBe(false);
  });

  it('rejects enabledGames the marketplace does not support', async () => {
    const adapter = new cardtraderModule();
    vi.spyOn(adapter, 'supportedGames');
    const res = makeRes();
    await handleUpdateConnection(makeReq({ body: { enabledGames: ['mtg'] }, params: { marketplace: 'cardtrader' } }), res);
    expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true }));
    // (CardTrader supports all three games, so an unsupported-game rejection
    // can't be exercised through the real adapter โ€” assert the guard directly:)
    const { assertGamesSupported } = require_('./marketplaces.js');
    expect(() => assertGamesSupported(['mtg', 'yugioh'], ['mtg', 'pokemon', 'riftbound']))
      .toThrow(/unsupported/i);
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npx vitest run server/routes/marketplaces.test.js Expected: FAIL โ€” module not found.

  • [ ] Step 3: Implement the route module
js
// server/routes/marketplaces.js
/**
 * Marketplace Connection Routes (Marketplace Sync, issue #316)
 *
 * Mounted inside routes/api.js after the dualModeAuth + resolveTier boundary.
 * Every route is additionally gated by requireFeature('marketplaceSync') โ€”
 * the first consumer of that middleware (Growth tier and up, see
 * config/pricingPlans.js). Tokens are stored encrypted and read back masked
 * (pattern: routes/notifications.js).
 */
'use strict';

const express = require('express');
const router = express.Router();

const Store = require('../models/Store');
const { validate } = require('../middleware/validate');
const requireFeature = require('../middleware/requireFeature');
const { encryptToken, decryptToken } = require('../utils/crypto');
const {
    marketplaceConnectionParamsSchema,
    marketplaceConnectionUpdateSchema,
} = require('../schemas');
const { getAdapter, getAllAdapters } = require('../marketplaces');
const { CardTraderAuthError } = require('../marketplaces/cardtrader/client');
const logger = require('../utils/logger');

class UnsupportedGamesError extends Error {
    constructor(games) {
        super(`unsupported games: ${games.join(', ')}`);
        this.games = games;
    }
}

function assertGamesSupported(enabledGames, supportedGames) {
    const bad = enabledGames.filter((g) => !supportedGames.includes(g));
    if (bad.length > 0) {
        throw new UnsupportedGamesError(bad);
    }
}

async function handleGetConnections(req, res) {
    try {
        const stored = req.store.marketplaceConnections || {};
        const connections = Object.values(getAllAdapters()).map((adapter) => {
            const conn = stored[adapter.id] || {};
            let tokenMasked = null;
            if (conn.accessTokenEnc) {
                try {
                    tokenMasked = '...' + decryptToken(conn.accessTokenEnc).slice(-6);
                } catch {
                    // Corrupt stored value โ€” surface as connected-but-unmaskable
                    tokenMasked = null;
                }
            }
            return {
                marketplace: adapter.id,
                displayName: adapter.displayName,
                supportedGames: adapter.supportedGames(),
                enabled: conn.enabled || false,
                connected: !!conn.accessTokenEnc,
                tokenMasked,
                enabledGames: conn.enabledGames ? Array.from(conn.enabledGames) : [],
                connectedAt: conn.connectedAt || null,
                lastPushAt: conn.lastPushAt || null,
                lastPushStatus: conn.lastPushStatus || null,
            };
        });
        res.json({ connections });
    } catch (error) {
        logger.error('Failed to fetch marketplace connections', { error: error.message, shop: req.store?.shop });
        res.status(500).json({ error: 'Failed to fetch marketplace connections' });
    }
}

async function handleUpdateConnection(req, res) {
    const { marketplace } = req.params; // validated enum โ€” unknown ids never reach here
    try {
        const adapter = getAdapter(marketplace);
        const updates = req.body;
        const prefix = `marketplaceConnections.${marketplace}`;
        const updateObj = {};

        if (updates.enabledGames !== undefined) {
            assertGamesSupported(updates.enabledGames, adapter.supportedGames());
            updateObj[`${prefix}.enabledGames`] = updates.enabledGames;
        }

        if (updates.accessToken !== undefined) {
            if (updates.accessToken === null) {
                updateObj[`${prefix}.accessTokenEnc`] = null;
                updateObj[`${prefix}.sharedSecretEnc`] = null;
                updateObj[`${prefix}.connectedAt`] = null;
                updateObj[`${prefix}.enabled`] = false;
            } else {
                const result = await adapter.validateConnection({ accessToken: updates.accessToken });
                updateObj[`${prefix}.accessTokenEnc`] = encryptToken(updates.accessToken);
                updateObj[`${prefix}.sharedSecretEnc`] = result.sharedSecret
                    ? encryptToken(result.sharedSecret)
                    : null;
                updateObj[`${prefix}.connectedAt`] = new Date();
            }
        }

        if (updates.enabled !== undefined) {
            updateObj[`${prefix}.enabled`] = updates.enabled;
        }

        updateObj.updatedAt = new Date();

        await Store.findOneAndUpdate({ shop: req.store.shop }, { $set: updateObj }, { new: true });
        res.json({ success: true });
    } catch (error) {
        if (error instanceof UnsupportedGamesError) {
            return res.status(422).json({ error: 'unsupported_games', games: error.games, marketplace });
        }
        if (error instanceof CardTraderAuthError) {
            return res.status(422).json({ error: 'invalid_credentials', marketplace });
        }
        logger.error('Failed to update marketplace connection', { error: error.message, marketplace, shop: req.store?.shop });
        res.status(502).json({ error: 'marketplace_unreachable', marketplace });
    }
}

router.get(
    '/marketplace-connections',
    requireFeature('marketplaceSync'),
    handleGetConnections
);

router.put(
    '/marketplace-connections/:marketplace',
    requireFeature('marketplaceSync'),
    validate(marketplaceConnectionParamsSchema, { source: 'params' }),
    validate(marketplaceConnectionUpdateSchema),
    handleUpdateConnection
);

module.exports = router;
module.exports.handleGetConnections = handleGetConnections;
module.exports.handleUpdateConnection = handleUpdateConnection;
module.exports.assertGamesSupported = assertGamesSupported;

Mount in server/routes/api.js, directly below the notifications mount:

js
router.use(require('./marketplaces'));
  • [ ] Step 4: Run to verify it passes

Run: npx vitest run server/routes/marketplaces.test.js then the full suite npm test Expected: new tests pass; no regressions. Note: the 422-auth-error test proves the DB write is skipped when validation fails; the disconnect test proves creds are actually cleared.

  • [ ] Step 5: Commit
bash
git add server/routes/marketplaces.js server/routes/marketplaces.test.js server/routes/api.js
git commit -m "Let Growth-tier merchants connect and disconnect CardTrader from the API"

Task 7: Settings UI card โ€‹

Files:

  • Create: client/src/components/MarketplaceSettings.jsx
  • Modify: client/src/pages/settings/GeneralSettings.jsx (add import + one JSX line โ€” the file is a stack of self-contained cards)
  • Test: client/src/components/MarketplaceSettings.test.jsx (create)

Interfaces:

  • Consumes: GET /api/marketplace-connections and PUT /api/marketplace-connections/:marketplace (Task 6 shapes, including the 403 upgrade_required body from requireFeature), default axios instance from client/src/utils/api.js (api.get/api.put, paths relative to /api).

  • Style/pattern source: client/src/components/NotificationSettings.jsx โ€” retroui barrel imports, useStore() for activeShop, reload on shop change, secret input shown only when not connected, payload omits the secret unless newly typed, explicit Save, transient success state. Match it; deviations below are only where marketplace semantics differ.

  • Reminder: fresh worktrees have no client/node_modules โ€” run npm --prefix client install before client tests.

  • [ ] Step 1: Write the failing test

jsx
// client/src/components/MarketplaceSettings.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import MarketplaceSettings from './MarketplaceSettings';
import api from '../utils/api';

vi.mock('../utils/api', () => ({
  default: { get: vi.fn(), put: vi.fn() },
}));
vi.mock('../context/StoreContext', () => ({
  useStore: () => ({ activeShop: 'test.myshopify.com' }),
}));

const disconnectedPayload = {
  data: {
    connections: [{
      marketplace: 'cardtrader', displayName: 'CardTrader',
      supportedGames: ['mtg', 'pokemon', 'riftbound'],
      enabled: false, connected: false, tokenMasked: null,
      enabledGames: [], connectedAt: null, lastPushAt: null, lastPushStatus: null,
    }],
  },
};

beforeEach(() => vi.clearAllMocks());

describe('MarketplaceSettings', () => {
  it('renders the connect form when CardTrader is not connected', async () => {
    api.get.mockResolvedValue(disconnectedPayload);
    render(<MarketplaceSettings />);
    await waitFor(() => expect(screen.getByText('CardTrader')).toBeInTheDocument());
    expect(screen.getByLabelText(/api token/i)).toBeInTheDocument();
  });

  it('shows the masked token and no token input when connected', async () => {
    api.get.mockResolvedValue({
      data: {
        connections: [{
          ...disconnectedPayload.data.connections[0],
          connected: true, enabled: true, tokenMasked: '...abc123', enabledGames: ['mtg'],
        }],
      },
    });
    render(<MarketplaceSettings />);
    await waitFor(() => expect(screen.getByText(/\.\.\.abc123/)).toBeInTheDocument());
    expect(screen.queryByLabelText(/api token/i)).not.toBeInTheDocument();
    expect(screen.getByRole('button', { name: /disconnect/i })).toBeInTheDocument();
  });

  it('shows the upgrade prompt on a 403 upgrade_required', async () => {
    api.get.mockRejectedValue({
      response: { status: 403, data: { error: 'upgrade_required', requiredFeature: 'marketplaceSync', currentTier: 'starter' } },
    });
    render(<MarketplaceSettings />);
    await waitFor(() => expect(screen.getByText(/growth/i)).toBeInTheDocument());
    expect(screen.queryByLabelText(/api token/i)).not.toBeInTheDocument();
  });
});

Adjust the StoreContext mock path to wherever NotificationSettings.jsx imports useStore from (check its import line and mirror it exactly).

  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- MarketplaceSettings Expected: FAIL โ€” component not found.

  • [ ] Step 3: Implement the component

Build MarketplaceSettings.jsx by cloning the structure of NotificationSettings.jsx and adapting. Core requirements (exact JSX left to match the retroui idiom in the cloned file):

jsx
// client/src/components/MarketplaceSettings.jsx โ€” structure
// State: connections[], loading, upgradeRequired, tokenInput (per marketplace),
//        saving, success, error
// Load: useEffect on activeShop โ†’ api.get('/marketplace-connections')
//   - catch: err.response?.status === 403 && err.response.data?.error ===
//     'upgrade_required' โ†’ setUpgradeRequired(true)  // server rule: requireFeature('marketplaceSync')
// Render, one card section per connection:
//   - upgradeRequired โ†’ card: "Marketplace Sync is available on the Growth
//     plan and up." + link to /settings/billing. No inputs.
//   - !connected โ†’ labeled input "API token" (type password) + help text
//     "Generate a token in CardTrader โ†’ Settings โ†’ API Access" + Connect
//     button โ†’ api.put(`/marketplace-connections/cardtrader`, { accessToken: tokenInput })
//       - 422 invalid_credentials โ†’ inline error "CardTrader rejected this token"
//       - 502 marketplace_unreachable โ†’ inline error "Could not reach CardTrader โ€” try again"
//   - connected โ†’ masked token (tokenMasked), connectedAt date, per-game
//     checkboxes for supportedGames (checked = enabledGames includes game;
//     save โ†’ api.put with { enabledGames }), enabled toggle ({ enabled }),
//     Disconnect button โ†’ api.put with { accessToken: null } after
//     window.confirm('Disconnect CardTrader? Sync will stop.')
// After every successful PUT: re-fetch the list (api.get) โ€” the server is
// the source of truth for masked/connected state.
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client and npm run build Expected: new tests pass, no client test regressions, build exits 0.

  • [ ] Step 5: Wire into GeneralSettings and commit

In client/src/pages/settings/GeneralSettings.jsx add import MarketplaceSettings from '../../components/MarketplaceSettings'; and render <MarketplaceSettings /> in the card stack (after <NotificationSettings />).

bash
git add client/src/components/MarketplaceSettings.jsx client/src/components/MarketplaceSettings.test.jsx client/src/pages/settings/GeneralSettings.jsx
git commit -m "Add CardTrader connection card to store settings"

Task 8: Verification pass and PR โ€‹

Files: none new โ€” this is the ยง7 quality-bar sweep.

  • [ ] Step 1: Full server + client suites

Run: npm test โ†’ exit 0. Run: npm run test:client โ†’ exit 0.

  • [ ] Step 2: Coverage โ€” read the per-file table

Run: npm run test:coverage Expected: every file created/modified in this plan (Store.js, marketplaceConnections.js schema, marketplaces/**, routes/marketplaces.js, MarketplaceSettings.jsx) reports โ‰ฅ70% on all four metrics in the table โ€” the gate itself is inoperative under Vitest 4, so eyeball it.

  • [ ] Step 3: Lint + build

Run: npm run lint โ†’ exit 0, zero new warnings. Run: npm run build โ†’ exit 0.

  • [ ] Step 4: Greps from the quality bars
bash
grep -rn "myshopify.com" server/marketplaces/ server/routes/marketplaces.js   # expect: no matches
grep -rn "cardtrader.com" server/ --include="*.js" | grep -v "marketplaces/cardtrader/client" | grep -v test   # expect: no matches (client.js is the only caller)
grep -rn "|| 'mtg'\|?? 'mtg'" server/marketplaces/ server/routes/marketplaces.js server/schemas/marketplaceConnections.js   # expect: no matches
  • [ ] Step 5: Manual smoke test (requires dev env + a real CardTrader token)

docker compose up -d, npm run dev, open Settings โ†’ General. Verify: bogus token โ†’ "CardTrader rejected this token"; real token (Brent's seller account โ€” no CardTrader sandbox exists) โ†’ connected state with masked token; disconnect clears it. Verify a free-tier store (tierOverride or a non-Growth dev store) sees the upgrade prompt.

  • [ ] Step 6: Open the PR
bash
git push -u origin claude/issue-316-planning-843a71
gh pr create --title "Let merchants connect their CardTrader account" --body "$(cat <<'EOF'
PR 1 of the Marketplace Sync arc (#316). Spec: docs/superpowers/specs/2026-07-30-marketplace-sync-design.md.

Adds the marketplace adapter registry, encrypted per-store CardTrader credentials, Growth-tier-gated connection endpoints (first consumer of requireFeature), and the Settings card. No listing push yet โ€” that is PR 2.

Game parity (ยง5.1): mtg/pokemon/riftbound all supported by CardTrader and declared identically in supportedGames(); no per-game plugin, model, or importer touched. Manapool deliberately absent until its adapter PR.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"

Follow-on plans (not in this document) โ€‹

  • PR 2 โ€” Listing push: marketplace_listings collection, marketplace-sync queue/processor + worker registration, MarketplaceRateLimiter, CardTrader bulk endpoints + expansion-id mapping, identity mapping per game, scheduler. Plan written after PR 1 merges.
  • PR 3 โ€” Marketplace orders โ†’ Shopify decrement: webhook receiver (HMAC via the sharedSecretEnc captured here), polling backstop, marketplace_orders idempotency, CardTrader Zero hub_pending handling.
  • PR 4 โ€” Shopify orders โ†’ marketplace decrement: extend orders/paid processing (capture variant ids/quantities, fix first: 50 pagination, decouple from the billing-subscription early-return).