Appearance
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 usecreateRequire(pattern:server/routes/buylist.settle.test.js). - All CardTrader HTTP calls go through
server/marketplaces/cardtrader/client.jsโ nothing else may callapi.cardtrader.com(rule-6 analog). Never throughshopifyAPI.js. - Secrets encrypted at rest with
encryptToken/decryptTokenfromserver/utils/crypto.js; encrypted fields use theEncsuffix (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 defaultedgame/marketplaceparams (ยง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 thenotificationConfigblock, which ends at line 331) - Test:
server/models/Store.marketplaceConnections.test.js(create)
Interfaces:
Produces:
store.marketplaceConnections.cardtraderwith fieldsenabled: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 thenotificationConfigclosing},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// Notificationsgroup, 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 functionsgetAdapter(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 ofGET https://api.cardtrader.com/api/v2/info; throwsCardTraderAuthErroron 401/403,Errorotherwise. ExportsCardTraderAuthError,_setDeps,_resetDeps. Task 5 consumesgetInfo; Task 6's error mapping consumesCardTraderAuthError.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}; rethrowsCardTraderAuthErroruntouched (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 โ greprequire('./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 fromrequireFeature.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-connectionsandPUT /api/marketplace-connections/:marketplace(Task 6 shapes, including the 403upgrade_requiredbody fromrequireFeature), default axios instance fromclient/src/utils/api.js(api.get/api.put, paths relative to/api).Style/pattern source:
client/src/components/NotificationSettings.jsxโ retroui barrel imports,useStore()foractiveShop, 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โ runnpm --prefix client installbefore 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_listingscollection,marketplace-syncqueue/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
sharedSecretEnccaptured here), polling backstop,marketplace_ordersidempotency, CardTrader Zerohub_pendinghandling. - PR 4 โ Shopify orders โ marketplace decrement: extend
orders/paidprocessing (capture variant ids/quantities, fixfirst: 50pagination, decouple from the billing-subscription early-return).
