Appearance
Smart Buylist Phase 3 Implementation Plan โ Public API + Hosted Portal โ
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: Let an end customer build their own sell list and submit a buy order through a public, unauthenticated, store-scoped hosted portal โ no merchant login required โ then check their order's status and accept or decline the merchant's reviewed offer via a claim-token-gated link, with zero changes to today's in-person/walk-in merchant flow.
Architecture: A new Express router (server/routes/publicBuylist.js) mounted at /api/public/buylist, registered in server/index.js before apiRoutes so it never passes through dualModeAuth โ it's a separate router, not a route inside the authenticated one. Requests are store-scoped by a :shop URL param (resolved via a new resolvePublicBuylistShop middleware that looks up Store directly), never by session-based req.store. All pricing reuses Phase 2/selector-entry's buylistQuoteService.priceSelectedLines/computeOrderTotals verbatim โ no parallel pricing logic. The public surface accepts only pre-identified lines[] (cardUuid + finish + condition + quantity), never free-text paste โ that path stays merchant-only. A new thin publicBuylistService.js orchestrates store/config gating, order creation with a one-time claim token (hashed at rest), and claim-token-gated lookups/actions. Client-side, a new client/src/pages/portal/ reuses the existing CardSearchPicker component (already public-endpoint-safe) via a new injectable-apiClient prop, and mounts as a route branch in App.jsx that bypasses the merchant StoreProvider/RadixShell/ProtectedRoute entirely, mirroring the existing /login special case.
Tech Stack: Node 24 / Express / Mongoose (CommonJS), Zod, Vitest (ESM tests), React 18 + retroui + Tailwind 4, React Router v6, express-rate-limit (already a dependency).
Spec: docs/superpowers/specs/2026-07-14-smart-buylist-customer-intake-design.md (Phase 3 of 7 โ "Public API + hosted portal page (customer web surface live)"). Phase 1 (docs/superpowers/plans/2026-07-14-smart-buylist-phase-1.md, merged PR #359) built BuylistOrder, Store.buylistConfig, buylistConfigService. Phase 2 (docs/superpowers/plans/2026-07-16-smart-buylist-phase-2.md, merged PR #362) built buylistQuoteService, the merchant Buy Orders/New Buy-In/Review UI. The selector-entry plan (docs/superpowers/plans/2026-07-17-smart-buylist-selector-entry.md, merged PR #366) added CardSearchPicker, findCardByUuid, priceIdentifiedLine, priceSelectedLines. All three are on main as of 2026-07-18.
Global Constraints โ
- Server code is CommonJS (
require); ALL test files are ESM (import). gameis never defaulted anywhere (CLAUDE.md ยง5.5): missing/unsupported โ 400, never a fallback. On this public surface specifically, an invalidgamemust be caught by an explicitgetSupportedGames().includes(game)check in the service layer and returned as{error, status:400}โ do not let it throw (an authenticated merchant route can afford to 500 on a value the client UI would never send; a fully public endpoint will receive garbage from bots/scanners and must not 500 on it).- No new Mongoose schema fields.
BuylistOrder.sourcealready includes'storefront',.statusalready includes'offer_sent', and.customer.email/.name/.claimTokenHashare already declared (server/models/BuylistOrder.js, from Phase 1 โ verified by reading the file directly). This phase wires up existing-but-unused scaffolding (CLAUDE.md ยง5.9's "Dead Scaffold" pattern, named for exactly this shape) rather than adding fields, so no new round-trip test is required (ยง5.2 only applies to new persisted fields). - Mount point avoids auth by construction, not by a bypass flag.
server/index.jsmountsapiRoutes(which appliesdualModeAuthinternally viarouter.use(dualModeAuth)atserver/routes/api.js:1594) at/api. The new public router is a separate Express Router mounted at/api/public/buylistand registered beforeapp.use('/api', apiRoutes)โ verified by readingserver/index.js:130-142. Because Express matchesapp.use()registrations in order and the more specific path is registered first, requests to/api/public/buylist/*are fully handled by the public router and never reachapiRoutes/dualModeAuthat all. - No Firebase Hosting or CORS changes needed.
firebase.json's existing rewrite ({"source": "/api/**", "run": {"serviceId": "lgs-ledger-api", ...}}) already proxies any/api/*path โ including the new/api/public/buylist/*โ to Cloud Run; verified by readingfirebase.json.server/index.js'sallowedOriginsCORS list already includeshttps://app.lgsforge.com(the client's real domain); the portal is new routes inside the existing client app, not a new origin, so no CORS entry is added. This deliberately defers the spec's "own subdomain" idea (buylist.lgsforge.com) โ the spec's own PR-sequence text says "hosted portal ...buylist.lgsforge.com/{shop}or equivalent path," and true on-merchant-domain hosting is explicitly the next phase (Theme App Extension + App Proxy, phase 5) โ building subdomain infra now would be throwaway work. - Public order creation is selector-only. The request body accepts
lines[](the exact{cardUuid, finish, condition, quantity}shape Phase 2/selector-entry already validates viaselectedCardLineSchemainserver/schemas/buylistOrder.js) โ neverrawText. The free-text regex parser (parseCardList, already patched for ReDoS in Phase 2 but still more attack surface) stays behind merchant auth; the anonymous public surface only accepts input that arrived throughCardSearchPicker's catalog-lookup flow, which cannot reference a card that doesn't exist in the global catalog. - Reuse the exact pricing pipeline, don't duplicate it. Call
buylistQuoteService.priceSelectedLines({store, game, lines})andcomputeOrderTotals(lines, buylistConfig)directly (both already exported,server/services/buylistQuoteService.js:509-521) โ same functions the authenticatedPOST /api/buylist/ordersselector path already uses. Do not write new match/price logic for the public path. - Claim tokens use
crypto, notbcryptjs.server/utils/crypto.jsalready has this exact pattern for other secrets (generateNonce:crypto.randomBytes(16).toString('hex');getEncryptionKeyBuffer:crypto.createHash('sha256')...digest()) โ extend it withgenerateClaimToken()/hashClaimToken()using the same primitives.bcryptjs(server/models/User.js) is for low-entropy human passwords, where slow hashing defeats brute force; a 256-bit random claim token has no brute-forceable entropy to defend, so a fast SHA-256 hash is the correct (and simpler) tool โ do not import bcrypt here. - Claim-token comparison is timing-safe. Use
crypto.timingSafeEqualon the two hash buffers, not===, so response timing can't leak a partial hash match to a scripted guesser. Store.buylistConfig[gameId].enabledand.instantQuote.webalready exist (server/services/buylistConfigService.js'sBASE_BUYLIST_DEFAULTS, from Phase 1) โ reuseassertBuylistEnabledverbatim for the enabled gate; check.instantQuote.webdirectly for the instant-quote gate. Do not add new config fields.GET /api/catalog/cardsandGET /api/catalog/setsare already public-safe โ both run onoptionalDualModeAuth(server/routes/api.js:906,:704), registered before thedualModeAuthcutoff, andoptionalDualModeAuthalways callsnext()regardless of whether auth succeeded (verified by readingserver/middleware/dualModeAuth.js:296-320).CardSearchPicker.jsxtherefore needs no server-side change to work on the portal โ only a client-side injectable API client (see below).- Do not reuse the shared
client/src/utils/api.jsaxios instance on the portal. Its request interceptor assumes a merchant session โ it callswindow.shopify.idToken()or reads a JWT fromlocalStorage, and its response interceptor redirects to/loginon a 401 (verified by readingclient/src/utils/api.js:78-180). On an anonymous customer page this is both wrong (no merchant session exists) and actively buggy (any unrelated 401 would yank the customer to the merchant login screen). Use a new minimal instance (client/src/utils/publicApi.js, no interceptors) instead, and giveCardSearchPickeran injectableapiClientprop (default: the existing sharedapi, so the merchantNewBuyInTab.jsxusage is unaffected) so the portal can passpublicApi. - Portal routes bypass the merchant shell entirely.
client/src/App.jsx'sAppRouter()always wraps routes in<StoreProvider><RadixShell>...except for a special-cased/loginbranch (client/src/App.jsx:180-186). Add a second special case for/portal/*the same way โStoreProviderassumes a merchantStoretied to the logged-in session, andRadixShellrenders the merchant nav; neither belongs on a customer-facing page. - No
supertestdependency exists in this repo (verified: not inpackage.json, no usages anywhere) andserver/routes/api.jsitself has no co-located test file โ the established convention for that file is service/schema/model-level unit tests carrying the coverage. BecausepublicBuylist.jsis a new, small, self-contained file (unlike the api.js monolith), give it real per-handler coverage anyway by exporting each handler function individually (alongside the default router export) and unit-testing them with mockreq/resobjects plusvi.mock('../services/publicBuylistService')โ mirroring the mock-req/res style already used inserver/routes/webhooks.test.jsandserver/middleware/dualModeAuth.test.js. - Existing merchant walk-in flow must not change behavior.
source: 'merchant'orders continue to gopending โ accepted/declineddirectly via the existingPOST /api/buylist/orders/:id/accept|declinehandlers (server/routes/api.js:2119-2162) โ this plan only widens their status guard to additionally accept'offer_sent'(so a merchant can manually finalize a storefront order without waiting on the customer), which is a strict superset of today's behavior forsource: 'merchant'orders (they never reach'offer_sent', so nothing changes for them). - Coverage โฅ70% on all four metrics for every modified file;
npm run lintclean (zero new warnings); Husky pre-commit runs ESLint โ never--no-verify. - Commit messages: one imperative sentence, merchant-visible outcome, sentence case, no trailing period.
- Game parity (ยง5.1):
publicBuylistService.jsis plugin-driven by construction โgetSupportedGames()/getPlugin()/getGameBuylistConfig()/priceSelectedLines()all already resolve mtg, pokemon, and riftbound uniformly with no per-game branching, reused verbatim from Phases 1/2/selector-entry. State this in the PR description, naming all three games.
Task 0: Preflight โ
Files: none (environment only)
- [ ] Step 1: Branch from up-to-date main (ยง5.10)
Phases 1, 2, and selector-entry are all merged to main as of 2026-07-18 (git log origin/main --oneline | grep -i buylist shows PRs #359, #362, #366 all merged). Branch directly from origin/main, not a phase branch:
bash
git fetch origin
git checkout -b claude/buylist-phase-3-public-portal origin/main- [ ] Step 2: Install deps and verify the suites run green before touching anything
bash
npm run install:all
npm test 2>&1 | tail -5 # expect: all passing
npm run test:client 2>&1 | tail -5Known gotcha: in a fresh worktree npm run test:client can fail on a missing @testing-library/dom (undeclared peer dep). If so: cd client && npm install --no-save @testing-library/dom and re-run.
Task 1: Claim token crypto utilities โ
Files:
- Modify:
server/utils/crypto.js - Modify:
server/utils/crypto.test.js
Interfaces:
Produces:
generateClaimToken(): string(64-char hex, 32 random bytes),hashClaimToken(token: string): string(64-char hex SHA-256 digest). Task 6/7 (publicBuylistService.js) consume both.[ ] Step 1: Write the failing tests
Add to server/utils/crypto.test.js (inside the existing describe('Crypto Utils - Native Format', ...) block's beforeEach, extend the destructured imports to also grab the two new functions):
javascript
beforeEach(async () => {
// Clear module cache to get fresh imports
vi.resetModules();
const cryptoModule = await import('./crypto.js');
encryptToken = cryptoModule.encryptToken;
decryptToken = cryptoModule.decryptToken;
generateClaimToken = cryptoModule.generateClaimToken;
hashClaimToken = cryptoModule.hashClaimToken;
});(Add let generateClaimToken, hashClaimToken; next to the existing let encryptToken, decryptToken; declaration at the top of the describe block.)
Then add a new top-level describe block at the end of the file:
javascript
describe('generateClaimToken / hashClaimToken', () => {
let generateClaimToken, hashClaimToken;
beforeEach(async () => {
vi.resetModules();
const cryptoModule = await import('./crypto.js');
generateClaimToken = cryptoModule.generateClaimToken;
hashClaimToken = cryptoModule.hashClaimToken;
});
it('generates a 64-character hex token', () => {
const token = generateClaimToken();
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
it('generates a different token on each call', () => {
const a = generateClaimToken();
const b = generateClaimToken();
expect(a).not.toBe(b);
});
it('hashes a token to a 64-character hex digest', () => {
const hash = hashClaimToken('some-token-value');
expect(hash).toMatch(/^[0-9a-f]{64}$/);
});
it('is deterministic โ the same token always hashes the same way', () => {
const token = generateClaimToken();
expect(hashClaimToken(token)).toBe(hashClaimToken(token));
});
it('produces different hashes for different tokens', () => {
const a = generateClaimToken();
const b = generateClaimToken();
expect(hashClaimToken(a)).not.toBe(hashClaimToken(b));
});
});- [ ] Step 2: Run the tests to verify they fail
bash
cd server && npx vitest run utils/crypto.test.jsExpected: FAIL โ generateClaimToken/hashClaimToken are undefined.
- [ ] Step 3: Implement the functions
Add to server/utils/crypto.js, near generateNonce (which already uses the identical crypto.randomBytes(...).toString('hex') pattern):
javascript
/**
* Generate a high-entropy claim token for a public buylist order. Returned
* to the customer once at order-creation time; only its hash is persisted
* (BuylistOrder.claimTokenHash) so a DB read alone can't impersonate a
* customer's claim link.
*/
function generateClaimToken() {
return crypto.randomBytes(32).toString('hex');
}
/**
* Hash a claim token for storage/comparison. Plain SHA-256 (not bcrypt,
* unlike User password hashing) is correct here: the token is already a
* 256-bit random value with no brute-forceable low entropy to slow-hash
* against, so a fast deterministic hash is both simpler and sufficient.
*/
function hashClaimToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}Add both to module.exports at the bottom of the file (alongside encryptToken, decryptToken, generateNonce, etc.).
- [ ] Step 4: Run the tests to verify they pass
bash
cd server && npx vitest run utils/crypto.test.jsExpected: PASS, all cases including the new ones.
- [ ] Step 5: Commit
bash
git add server/utils/crypto.js server/utils/crypto.test.js
git commit -m "Add claim token generation and hashing for public buylist orders"Task 2: Public Zod schemas โ
Files:
- Modify:
server/schemas/buylistOrder.js - Create:
server/schemas/publicBuylist.js - Modify:
server/schemas/schemas.test.js
Interfaces:
Consumes:
shopDomainSchema(server/schemas/shop.js),objectIdSchema(server/schemas/params.js).Produces:
selectedCardLineSchema(now exported frombuylistOrder.js), and from the new file:publicShopParamSchema,publicShopOrderParamSchema,publicQuoteSchema,publicOrderCreateSchema,publicOrderTokenQuerySchema. Task 8 (publicBuylist.jsroutes) consumes all five.[ ] Step 1: Export
selectedCardLineSchemafrombuylistOrder.js
In server/schemas/buylistOrder.js, change the final line:
javascript
module.exports = { createBuylistOrderSchema, updateBuylistOrderLinesSchema };to:
javascript
module.exports = { createBuylistOrderSchema, updateBuylistOrderLinesSchema, selectedCardLineSchema };- [ ] Step 2: Write the failing tests for the new public schemas
Add to server/schemas/schemas.test.js (this file already imports schemas by destructuring from ../index or the individual files, per the existing objectIdParamSchema tests around line 512-535 โ follow that exact import style: add publicShopParamSchema, publicShopOrderParamSchema, publicQuoteSchema, publicOrderCreateSchema, publicOrderTokenQuerySchema to the top-of-file import list, sourced from ../schemas/publicBuylist):
javascript
describe('publicShopParamSchema', () => {
it('accepts a valid shop domain', () => {
expect(publicShopParamSchema.safeParse({ shop: 'test-store.myshopify.com' }).success).toBe(true);
});
it('rejects a non-myshopify domain', () => {
expect(publicShopParamSchema.safeParse({ shop: 'evil.com' }).success).toBe(false);
});
});
describe('publicShopOrderParamSchema', () => {
it('accepts a valid shop and ObjectId pair', () => {
expect(publicShopOrderParamSchema.safeParse({
shop: 'test-store.myshopify.com',
id: '507f1f77bcf86cd799439011'
}).success).toBe(true);
});
it('rejects an invalid ObjectId', () => {
expect(publicShopOrderParamSchema.safeParse({
shop: 'test-store.myshopify.com',
id: 'not-an-id'
}).success).toBe(false);
});
});
describe('publicQuoteSchema', () => {
const validLine = { cardUuid: 'card-1', finish: 'normal', condition: 'nm', quantity: 1 };
it('accepts a valid quote request', () => {
expect(publicQuoteSchema.safeParse({ game: 'mtg', lines: [validLine] }).success).toBe(true);
});
it('rejects an empty lines array', () => {
expect(publicQuoteSchema.safeParse({ game: 'mtg', lines: [] }).success).toBe(false);
});
it('rejects more than 200 lines', () => {
const lines = Array.from({ length: 201 }, () => validLine);
expect(publicQuoteSchema.safeParse({ game: 'mtg', lines }).success).toBe(false);
});
it('rejects a rawText field โ the public surface is selector-only', () => {
expect(publicQuoteSchema.safeParse({ game: 'mtg', lines: [validLine], rawText: '1 Bolt' }).success).toBe(false);
});
it('rejects a missing game', () => {
expect(publicQuoteSchema.safeParse({ lines: [validLine] }).success).toBe(false);
});
});
describe('publicOrderCreateSchema', () => {
const validLine = { cardUuid: 'card-1', finish: 'normal', condition: 'nm', quantity: 1 };
it('accepts a valid order-create request', () => {
expect(publicOrderCreateSchema.safeParse({
game: 'mtg',
lines: [validLine],
customer: { email: 'customer@example.com', name: 'Sam' }
}).success).toBe(true);
});
it('accepts a customer with no name', () => {
expect(publicOrderCreateSchema.safeParse({
game: 'mtg',
lines: [validLine],
customer: { email: 'customer@example.com' }
}).success).toBe(true);
});
it('rejects an invalid customer email', () => {
expect(publicOrderCreateSchema.safeParse({
game: 'mtg',
lines: [validLine],
customer: { email: 'not-an-email' }
}).success).toBe(false);
});
it('rejects a missing customer', () => {
expect(publicOrderCreateSchema.safeParse({ game: 'mtg', lines: [validLine] }).success).toBe(false);
});
});
describe('publicOrderTokenQuerySchema', () => {
it('accepts a well-formed claim token', () => {
expect(publicOrderTokenQuerySchema.safeParse({ token: 'a'.repeat(64) }).success).toBe(true);
});
it('rejects a missing token', () => {
expect(publicOrderTokenQuerySchema.safeParse({}).success).toBe(false);
});
it('rejects a too-short token', () => {
expect(publicOrderTokenQuerySchema.safeParse({ token: 'short' }).success).toBe(false);
});
});- [ ] Step 3: Run the tests to verify they fail
bash
cd server && npx vitest run schemas/schemas.test.jsExpected: FAIL โ server/schemas/publicBuylist.js doesn't exist yet.
- [ ] Step 4: Create
server/schemas/publicBuylist.js
javascript
/**
* Public (unauthenticated, store-scoped) buylist schemas.
*
* Validates the customer-facing portal's requests: GET .../:shop,
* POST .../:shop/quote, POST .../:shop/orders, and the claim-token-gated
* GET/accept/decline order endpoints. Deliberately selector-only (lines[],
* never rawText) โ see Global Constraints.
*/
'use strict';
const { z } = require('zod');
const { shopDomainSchema } = require('./shop');
const { objectIdSchema } = require('./params');
const { selectedCardLineSchema } = require('./buylistOrder');
const publicShopParamSchema = z.object({ shop: shopDomainSchema });
const publicShopOrderParamSchema = z.object({
shop: shopDomainSchema,
id: objectIdSchema
});
// Tighter cap than the merchant path's 500 (createBuylistOrderSchema) โ
// defense in depth on a fully anonymous, rate-limited-but-public endpoint.
const publicLinesSchema = z.array(selectedCardLineSchema).min(1, 'lines must not be empty').max(200, 'too many lines');
const publicQuoteSchema = z.object({
game: z.string().min(1, 'game is required'),
lines: publicLinesSchema
}).strict();
const publicOrderCreateSchema = z.object({
game: z.string().min(1, 'game is required'),
lines: publicLinesSchema,
customer: z.object({
email: z.string().email('customer.email must be a valid email'),
name: z.string().optional()
})
}).strict();
const publicOrderTokenQuerySchema = z.object({
token: z.string().min(32, 'token is required')
});
module.exports = {
publicShopParamSchema,
publicShopOrderParamSchema,
publicQuoteSchema,
publicOrderCreateSchema,
publicOrderTokenQuerySchema
};- [ ] Step 5: Run the tests to verify they pass
bash
cd server && npx vitest run schemas/schemas.test.jsExpected: PASS, all new cases plus every pre-existing case in the file.
- [ ] Step 6: Commit
bash
git add server/schemas/buylistOrder.js server/schemas/publicBuylist.js server/schemas/schemas.test.js
git commit -m "Add validation schemas for the public buylist portal API"Task 3: resolvePublicBuylistShop middleware โ
Files:
- Create:
server/middleware/resolvePublicBuylistShop.js - Create:
server/middleware/resolvePublicBuylistShop.test.js
Interfaces:
Consumes:
req.params.shop(already validated as a real Shopify domain bypublicShopParamSchemaupstream, per Task 2).Produces:
req.publicStore(a MongooseStoredocument) on success, or a404/500JSON response. Task 8 (routes) applies this middleware toPOST .../quoteandPOST .../orders.[ ] Step 1: Write the failing tests
javascript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
const { default: resolvePublicBuylistShop, _setDeps, _resetDeps } = await import('./resolvePublicBuylistShop.js');
describe('resolvePublicBuylistShop', () => {
let req, res, next, mockStore, mockLogger;
beforeEach(() => {
mockStore = { findOne: vi.fn() };
mockLogger = { error: vi.fn() };
_setDeps({ Store: mockStore, logger: mockLogger });
req = { params: { shop: 'test-store.myshopify.com' } };
res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() };
next = vi.fn();
});
afterEach(() => {
_resetDeps();
});
it('attaches req.publicStore and calls next() when an active store is found', async () => {
const storeDoc = { shop: 'test-store.myshopify.com', isActive: true };
mockStore.findOne.mockResolvedValue(storeDoc);
await resolvePublicBuylistShop(req, res, next);
expect(mockStore.findOne).toHaveBeenCalledWith({ shop: 'test-store.myshopify.com', isActive: true });
expect(req.publicStore).toBe(storeDoc);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('returns 404 when no active store matches (not found, or inactive)', async () => {
mockStore.findOne.mockResolvedValue(null);
await resolvePublicBuylistShop(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Store not found' });
expect(next).not.toHaveBeenCalled();
});
it('returns 500 and logs when the store lookup throws', async () => {
mockStore.findOne.mockRejectedValue(new Error('connection lost'));
await resolvePublicBuylistShop(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(mockLogger.error).toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
});- [ ] Step 2: Run the test to verify it fails
bash
cd server && npx vitest run middleware/resolvePublicBuylistShop.test.jsExpected: FAIL โ module doesn't exist.
- [ ] Step 3: Implement the middleware
javascript
/**
* Resolves the store for a public (unauthenticated) buylist portal request
* from the :shop URL param โ never from req.store, which only dualModeAuth
* populates. A single 404 covers "no such store" and "store inactive" so an
* anonymous caller can't distinguish the two (no existence leak).
*/
'use strict';
const Store = require('../models/Store');
const logger = require('../utils/logger');
let _deps = { Store, logger };
function _setDeps(overrides) { _deps = { ..._deps, ...overrides }; }
function _resetDeps() { _deps = { Store, logger }; }
async function resolvePublicBuylistShop(req, res, next) {
try {
const store = await _deps.Store.findOne({ shop: req.params.shop, isActive: true });
if (!store) {
return res.status(404).json({ error: 'Store not found' });
}
req.publicStore = store;
next();
} catch (error) {
_deps.logger.error('Failed to resolve public buylist store', { error: error.message, shop: req.params.shop });
res.status(500).json({ error: 'Failed to resolve store' });
}
}
module.exports = resolvePublicBuylistShop;
module.exports._setDeps = _setDeps;
module.exports._resetDeps = _resetDeps;- [ ] Step 4: Run the test to verify it passes
bash
cd server && npx vitest run middleware/resolvePublicBuylistShop.test.jsExpected: PASS.
- [ ] Step 5: Commit
bash
git add server/middleware/resolvePublicBuylistShop.js server/middleware/resolvePublicBuylistShop.test.js
git commit -m "Add store resolution middleware for the public buylist portal"Task 4: Public rate limiter โ
Files:
- Modify:
server/middleware/rateLimiter.js - Modify:
server/middleware/rateLimiter.test.js
Interfaces:
Produces:
publicBuylistLimiter(Express middleware). Task 8 (server/index.js) mounts it ahead of the public router.[ ] Step 1: Write the failing tests
Extend server/middleware/rateLimiter.test.js's existing structure (it already imports authLimiter/apiLimiter the same way โ mirror exactly):
javascript
let authLimiter, apiLimiter, publicBuylistLimiter;
const rateLimiterModule = require('./rateLimiter.js');
authLimiter = rateLimiterModule.authLimiter;
apiLimiter = rateLimiterModule.apiLimiter;
publicBuylistLimiter = rateLimiterModule.publicBuylistLimiter;
describe('publicBuylistLimiter', () => {
it('should be defined and be a function', () => {
expect(publicBuylistLimiter).toBeDefined();
expect(typeof publicBuylistLimiter).toBe('function');
});
it('should be a rate limiter middleware', () => {
expect(publicBuylistLimiter.length).toBeGreaterThanOrEqual(2);
});
});Also update the existing 'should only export authLimiter and apiLimiter' test (it will now fail on a strict count) to account for the new export โ change its assertion to expect all three names present.
- [ ] Step 2: Run the tests to verify they fail
bash
cd server && npx vitest run middleware/rateLimiter.test.jsExpected: FAIL โ publicBuylistLimiter undefined.
- [ ] Step 3: Implement
Add to server/middleware/rateLimiter.js, after apiLimiter:
javascript
/**
* Rate limiter for the public (unauthenticated) buylist portal.
* Tighter than apiLimiter (100/min) since this surface has no session to
* hold accountable โ table-stakes abuse mitigation per the design spec's
* "cheapest first" list. In-memory store, same limitation authLimiter/
* apiLimiter already accept (single Cloud Run instance's view only) โ a
* distributed store is an explicit non-goal here (YAGNI).
*/
const publicBuylistLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 30, // 30 requests per window per IP
message: { error: 'Too many requests, please try again later' },
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false,
skipFailedRequests: false
});
module.exports = { authLimiter, apiLimiter, publicBuylistLimiter };(This replaces the old module.exports = { authLimiter, apiLimiter }; line.)
- [ ] Step 4: Run the tests to verify they pass
bash
cd server && npx vitest run middleware/rateLimiter.test.jsExpected: PASS.
- [ ] Step 5: Commit
bash
git add server/middleware/rateLimiter.js server/middleware/rateLimiter.test.js
git commit -m "Add a rate limiter for the public buylist portal"Task 5: publicBuylistService โ store summary + quote โ
Files:
- Create:
server/services/publicBuylistService.js - Create:
server/services/publicBuylistService.test.js
Interfaces:
Consumes:
Store.findOne(model),getSupportedGames/getPlugin(server/plugins),getGameBuylistConfig/assertBuylistEnabled(server/services/buylistConfigService.js),priceSelectedLines/computeOrderTotals(server/services/buylistQuoteService.js).Produces:
getPublicStoreSummary(shop): Promise<{shop, games: [{id,name}]}|null>,quotePublicLines(store, game, lines): Promise<{lines, totals}|{instantQuoteDisabled:true}|{error,status}>. Task 6/7 extend this same file; Task 8 (routes) consumes all of it.[ ] Step 1: Write the failing tests
javascript
import { describe, it, expect, vi, afterEach } from 'vitest';
import { getPublicStoreSummary, quotePublicLines, _setDeps, _resetDeps } from './publicBuylistService.js';
function makeDeps(overrides = {}) {
return {
Store: { findOne: vi.fn() },
getSupportedGames: vi.fn().mockReturnValue(['mtg', 'pokemon', 'riftbound']),
getPlugin: vi.fn((id) => ({ gameId: id, displayName: id.toUpperCase() })),
getGameBuylistConfig: vi.fn(),
assertBuylistEnabled: vi.fn().mockReturnValue(null),
priceSelectedLines: vi.fn(),
computeOrderTotals: vi.fn(),
generateClaimToken: vi.fn().mockReturnValue('a'.repeat(64)),
hashClaimToken: vi.fn().mockReturnValue('b'.repeat(64)),
BuylistOrder: vi.fn(),
logger: { info: vi.fn(), error: vi.fn() },
...overrides
};
}
afterEach(() => {
_resetDeps();
});
describe('getPublicStoreSummary', () => {
it('returns null when the store is not found or inactive', async () => {
const deps = makeDeps();
deps.Store.findOne.mockResolvedValue(null);
_setDeps(deps);
const result = await getPublicStoreSummary('missing.myshopify.com');
expect(result).toBeNull();
expect(deps.Store.findOne).toHaveBeenCalledWith({ shop: 'missing.myshopify.com', isActive: true });
});
it('returns only games where buylist is enabled for the store', async () => {
const deps = makeDeps();
deps.Store.findOne.mockResolvedValue({ shop: 'test.myshopify.com' });
deps.getGameBuylistConfig.mockImplementation((_store, gameId) => ({ enabled: gameId === 'mtg' }));
_setDeps(deps);
const result = await getPublicStoreSummary('test.myshopify.com');
expect(result).toEqual({ shop: 'test.myshopify.com', games: [{ id: 'mtg', name: 'MTG' }] });
});
it('returns an empty games array when no game has buylist enabled', async () => {
const deps = makeDeps();
deps.Store.findOne.mockResolvedValue({ shop: 'test.myshopify.com' });
deps.getGameBuylistConfig.mockReturnValue({ enabled: false });
_setDeps(deps);
const result = await getPublicStoreSummary('test.myshopify.com');
expect(result).toEqual({ shop: 'test.myshopify.com', games: [] });
});
});
describe('quotePublicLines', () => {
const store = { shop: 'test.myshopify.com' };
const lines = [{ cardUuid: 'card-1', finish: 'normal', condition: 'nm', quantity: 1 }];
it('returns a 400-shaped error for an unsupported game', async () => {
const deps = makeDeps();
_setDeps(deps);
const result = await quotePublicLines(store, 'yugioh', lines);
expect(result).toEqual({ error: 'Unsupported game', status: 400 });
expect(deps.getGameBuylistConfig).not.toHaveBeenCalled();
});
it('returns the disabled-error shape when buylist is disabled for the game', async () => {
const deps = makeDeps();
deps.getGameBuylistConfig.mockReturnValue({ enabled: false, instantQuote: { web: true } });
deps.assertBuylistEnabled.mockReturnValue('Buylist is not enabled for this game');
_setDeps(deps);
const result = await quotePublicLines(store, 'mtg', lines);
expect(result).toEqual({ error: 'Buylist is not enabled for this game', status: 400 });
});
it('returns instantQuoteDisabled when the store has turned off web instant quotes', async () => {
const deps = makeDeps();
deps.getGameBuylistConfig.mockReturnValue({ enabled: true, instantQuote: { web: false } });
_setDeps(deps);
const result = await quotePublicLines(store, 'mtg', lines);
expect(result).toEqual({ instantQuoteDisabled: true });
expect(deps.priceSelectedLines).not.toHaveBeenCalled();
});
it('prices and totals the lines when enabled and instant quotes are on', async () => {
const deps = makeDeps();
const buylistConfig = { enabled: true, instantQuote: { web: true } };
deps.getGameBuylistConfig.mockReturnValue(buylistConfig);
const pricedLines = [{ ...lines[0], matchStatus: 'matched', offerPrice: 5 }];
deps.priceSelectedLines.mockResolvedValue({ lines: pricedLines });
const totals = { cashTotal: 5, creditTotal: 6.25, creditBonus: 0.25, marketTotal: 10 };
deps.computeOrderTotals.mockReturnValue(totals);
_setDeps(deps);
const result = await quotePublicLines(store, 'mtg', lines);
expect(deps.priceSelectedLines).toHaveBeenCalledWith({ store, game: 'mtg', lines });
expect(deps.computeOrderTotals).toHaveBeenCalledWith(pricedLines, buylistConfig);
expect(result).toEqual({ lines: pricedLines, totals });
});
});- [ ] Step 2: Run the tests to verify they fail
bash
cd server && npx vitest run services/publicBuylistService.test.jsExpected: FAIL โ module doesn't exist.
- [ ] Step 3: Implement
javascript
/**
* Orchestrates the public (unauthenticated, store-scoped) buylist portal:
* store/game gating, quoting, order creation, and claim-token-gated
* lookups/actions. Reuses buylistQuoteService's pricing pipeline verbatim
* (server/services/buylistQuoteService.js) -- no parallel pricing logic.
*/
'use strict';
const crypto = require('crypto');
const BuylistOrder = require('../models/BuylistOrder');
const Store = require('../models/Store');
const { getPlugin, getSupportedGames } = require('../plugins');
const { getGameBuylistConfig, assertBuylistEnabled } = require('./buylistConfigService');
const { priceSelectedLines, computeOrderTotals } = require('./buylistQuoteService');
const { generateClaimToken, hashClaimToken } = require('../utils/crypto');
const logger = require('../utils/logger');
let _deps = null;
function _setDeps(deps) { _deps = deps; }
function _resetDeps() { _deps = null; }
function getDeps() {
if (_deps) return _deps;
return {
BuylistOrder, Store, getPlugin, getSupportedGames,
getGameBuylistConfig, assertBuylistEnabled,
priceSelectedLines, computeOrderTotals,
generateClaimToken, hashClaimToken, logger
};
}
async function getPublicStoreSummary(shop, deps = getDeps()) {
const store = await deps.Store.findOne({ shop, isActive: true });
if (!store) return null;
const games = deps.getSupportedGames()
.filter((gameId) => deps.getGameBuylistConfig(store, gameId).enabled)
.map((gameId) => {
const plugin = deps.getPlugin(gameId);
return { id: plugin.gameId, name: plugin.displayName };
});
return { shop: store.shop, games };
}
async function quotePublicLines(store, game, lines, deps = getDeps()) {
if (!deps.getSupportedGames().includes(game)) {
return { error: 'Unsupported game', status: 400 };
}
const buylistConfig = deps.getGameBuylistConfig(store, game);
const disabledError = deps.assertBuylistEnabled(buylistConfig);
if (disabledError) return { error: disabledError, status: 400 };
if (!buylistConfig.instantQuote.web) {
return { instantQuoteDisabled: true };
}
const quote = await deps.priceSelectedLines({ store, game, lines });
const totals = deps.computeOrderTotals(quote.lines, buylistConfig);
return { lines: quote.lines, totals };
}
module.exports = {
getPublicStoreSummary,
quotePublicLines,
_setDeps,
_resetDeps
};- [ ] Step 4: Run the tests to verify they pass
bash
cd server && npx vitest run services/publicBuylistService.test.jsExpected: PASS.
- [ ] Step 5: Commit
bash
git add server/services/publicBuylistService.js server/services/publicBuylistService.test.js
git commit -m "Add public buylist store summary and quote endpoints' service logic"Task 6: publicBuylistService โ create order with claim token โ
Files:
- Modify:
server/services/publicBuylistService.js - Modify:
server/services/publicBuylistService.test.js
Interfaces:
Produces:
createPublicOrder(store, game, lines, customer): Promise<{order, claimToken}|{error,status}>.orderis the sanitized shape fromtoPublicOrder()(noclaimTokenHash,_idrenamed toid). Task 8 (routes) and Task 11 (portal submit flow) consume this.[ ] Step 1: Write the failing tests
Add to server/services/publicBuylistService.test.js:
javascript
import { createPublicOrder } from './publicBuylistService.js';
describe('createPublicOrder', () => {
const store = { shop: 'test.myshopify.com' };
const lines = [{ cardUuid: 'card-1', finish: 'normal', condition: 'nm', quantity: 1 }];
const customer = { email: 'buyer@example.com', name: 'Sam' };
it('returns a 400-shaped error for an unsupported game', async () => {
const deps = makeDeps();
_setDeps(deps);
const result = await createPublicOrder(store, 'yugioh', lines, customer);
expect(result).toEqual({ error: 'Unsupported game', status: 400 });
});
it('returns the disabled-error shape when buylist is disabled for the game', async () => {
const deps = makeDeps();
deps.getGameBuylistConfig.mockReturnValue({ enabled: false });
deps.assertBuylistEnabled.mockReturnValue('Buylist is not enabled for this game');
_setDeps(deps);
const result = await createPublicOrder(store, 'mtg', lines, customer);
expect(result).toEqual({ error: 'Buylist is not enabled for this game', status: 400 });
});
it('prices the lines, saves a storefront-sourced pending order, and returns a one-time claim token', async () => {
const deps = makeDeps();
const buylistConfig = { enabled: true, storeCreditBonus: 0.25 };
deps.getGameBuylistConfig.mockReturnValue(buylistConfig);
const pricedLines = [{ ...lines[0], matchStatus: 'matched', offerPrice: 5, basisPrice: 10, included: true }];
deps.priceSelectedLines.mockResolvedValue({ lines: pricedLines });
const totals = { cashTotal: 5, creditTotal: 6.25, creditBonus: 0.25, marketTotal: 10 };
deps.computeOrderTotals.mockReturnValue(totals);
deps.generateClaimToken.mockReturnValue('raw-token-value');
deps.hashClaimToken.mockReturnValue('hashed-token-value');
const savedDoc = {
_id: { toString: () => 'order-id-1' },
game: 'mtg',
status: 'pending',
customer,
lines: pricedLines,
payout: { method: 'store_credit', cashTotal: 5, creditTotal: 6.25, creditBonus: 0.25, marketTotal: 10 },
createdAt: new Date('2026-07-18T00:00:00Z'),
save: vi.fn().mockResolvedValue(undefined)
};
deps.BuylistOrder = vi.fn().mockImplementation((doc) => Object.assign(savedDoc, doc, { save: savedDoc.save }));
_setDeps(deps);
const result = await createPublicOrder(store, 'mtg', lines, customer);
expect(deps.BuylistOrder).toHaveBeenCalledWith(expect.objectContaining({
shop: 'test.myshopify.com',
game: 'mtg',
source: 'storefront',
status: 'pending',
customer,
claimTokenHash: 'hashed-token-value',
lines: pricedLines
}));
expect(savedDoc.save).toHaveBeenCalled();
expect(result.claimToken).toBe('raw-token-value');
expect(result.order.id).toBe('order-id-1');
expect(result.order.claimTokenHash).toBeUndefined();
});
});- [ ] Step 2: Run the tests to verify they fail
bash
cd server && npx vitest run services/publicBuylistService.test.jsExpected: FAIL โ createPublicOrder not exported.
- [ ] Step 3: Implement
Add to server/services/publicBuylistService.js, before module.exports:
javascript
function toPublicOrder(order) {
return {
id: order._id.toString(),
game: order.game,
status: order.status,
customer: { email: order.customer.email, name: order.customer.name },
lines: order.lines,
payout: order.payout,
createdAt: order.createdAt
};
}
async function createPublicOrder(store, game, lines, customer, deps = getDeps()) {
if (!deps.getSupportedGames().includes(game)) {
return { error: 'Unsupported game', status: 400 };
}
const buylistConfig = deps.getGameBuylistConfig(store, game);
const disabledError = deps.assertBuylistEnabled(buylistConfig);
if (disabledError) return { error: disabledError, status: 400 };
const quote = await deps.priceSelectedLines({ store, game, lines });
const totals = deps.computeOrderTotals(quote.lines, buylistConfig);
const claimToken = deps.generateClaimToken();
const order = new deps.BuylistOrder({
shop: store.shop,
game,
source: 'storefront',
status: 'pending',
customer,
claimTokenHash: deps.hashClaimToken(claimToken),
lines: quote.lines,
payout: {
method: 'store_credit',
cashTotal: totals.cashTotal,
creditTotal: totals.creditTotal,
creditBonus: totals.creditBonus,
marketTotal: totals.marketTotal
}
});
await order.save();
deps.logger.info('Created public buylist order', { shop: store.shop, game, orderId: order._id, source: 'storefront' });
return { order: toPublicOrder(order), claimToken };
}Update module.exports to add createPublicOrder.
- [ ] Step 4: Run the tests to verify they pass
bash
cd server && npx vitest run services/publicBuylistService.test.jsExpected: PASS.
- [ ] Step 5: Commit
bash
git add server/services/publicBuylistService.js server/services/publicBuylistService.test.js
git commit -m "Let customers submit buy orders through the public buylist portal"Task 7: publicBuylistService โ claim-token lookup + accept/decline โ
Files:
- Modify:
server/services/publicBuylistService.js - Modify:
server/services/publicBuylistService.test.js
Interfaces:
Produces:
getPublicOrderByClaim(shop, orderId, token): Promise<{order}|{error,status}>,actOnPublicOrder(shop, orderId, token, action): Promise<{order}|{error,status}>whereactionis'accept'|'decline'. Task 8 (routes) and Task 12 (portal status page) consume both.[ ] Step 1: Write the failing tests
Add to server/services/publicBuylistService.test.js:
javascript
import { getPublicOrderByClaim, actOnPublicOrder } from './publicBuylistService.js';
function makeOrderDoc(overrides = {}) {
return {
_id: { toString: () => 'order-id-1' },
shop: 'test.myshopify.com',
game: 'mtg',
status: 'offer_sent',
customer: { email: 'buyer@example.com', name: 'Sam' },
claimTokenHash: 'hashed-token-value',
lines: [],
payout: {},
createdAt: new Date('2026-07-18T00:00:00Z'),
save: vi.fn().mockResolvedValue(undefined),
...overrides
};
}
describe('getPublicOrderByClaim', () => {
it('returns 404 when no order matches shop + id', async () => {
const deps = makeDeps();
deps.BuylistOrder.findOne = vi.fn().mockResolvedValue(null);
deps.hashClaimToken.mockReturnValue('hashed-token-value');
_setDeps(deps);
const result = await getPublicOrderByClaim('test.myshopify.com', 'order-id-1', 'raw-token');
expect(result).toEqual({ error: 'Order not found', status: 404 });
});
it('returns 404 when the claim token does not match', async () => {
const deps = makeDeps();
deps.BuylistOrder.findOne = vi.fn().mockResolvedValue(makeOrderDoc());
deps.hashClaimToken.mockReturnValue('a-completely-different-64-char-hash-value-000000000000000000');
_setDeps(deps);
const result = await getPublicOrderByClaim('test.myshopify.com', 'order-id-1', 'wrong-token');
expect(result).toEqual({ error: 'Order not found', status: 404 });
});
it('returns the sanitized order when the claim token matches', async () => {
const deps = makeDeps();
const doc = makeOrderDoc();
deps.BuylistOrder.findOne = vi.fn().mockResolvedValue(doc);
deps.hashClaimToken.mockReturnValue('hashed-token-value');
_setDeps(deps);
const result = await getPublicOrderByClaim('test.myshopify.com', 'order-id-1', 'raw-token');
expect(result.order.id).toBe('order-id-1');
expect(result.order.claimTokenHash).toBeUndefined();
});
});
describe('actOnPublicOrder', () => {
it('returns 404 when the claim token does not match', async () => {
const deps = makeDeps();
deps.BuylistOrder.findOne = vi.fn().mockResolvedValue(makeOrderDoc());
deps.hashClaimToken.mockReturnValue('a-completely-different-64-char-hash-value-000000000000000000');
_setDeps(deps);
const result = await actOnPublicOrder('test.myshopify.com', 'order-id-1', 'wrong-token', 'accept');
expect(result).toEqual({ error: 'Order not found', status: 404 });
});
it('returns 409 when the order is not in offer_sent status', async () => {
const deps = makeDeps();
const doc = makeOrderDoc({ status: 'pending' });
deps.BuylistOrder.findOne = vi.fn().mockResolvedValue(doc);
deps.hashClaimToken.mockReturnValue('hashed-token-value');
_setDeps(deps);
const result = await actOnPublicOrder('test.myshopify.com', 'order-id-1', 'raw-token', 'accept');
expect(result).toEqual({ error: 'Cannot accept an order with status pending', status: 409 });
expect(doc.save).not.toHaveBeenCalled();
});
it('accepts an offer_sent order', async () => {
const deps = makeDeps();
const doc = makeOrderDoc({ status: 'offer_sent' });
deps.BuylistOrder.findOne = vi.fn().mockResolvedValue(doc);
deps.hashClaimToken.mockReturnValue('hashed-token-value');
_setDeps(deps);
const result = await actOnPublicOrder('test.myshopify.com', 'order-id-1', 'raw-token', 'accept');
expect(doc.status).toBe('accepted');
expect(doc.save).toHaveBeenCalled();
expect(result.order.status).toBe('accepted');
});
it('declines an offer_sent order', async () => {
const deps = makeDeps();
const doc = makeOrderDoc({ status: 'offer_sent' });
deps.BuylistOrder.findOne = vi.fn().mockResolvedValue(doc);
deps.hashClaimToken.mockReturnValue('hashed-token-value');
_setDeps(deps);
const result = await actOnPublicOrder('test.myshopify.com', 'order-id-1', 'raw-token', 'decline');
expect(doc.status).toBe('declined');
expect(result.order.status).toBe('declined');
});
});Note: makeDeps()'s default BuylistOrder (vi.fn()) needs .findOne attached per-test as shown above โ this is consistent with createPublicOrder's tests using BuylistOrder as a constructor and these new tests using BuylistOrder.findOne as a static method, matching the real Mongoose model's dual nature.
- [ ] Step 2: Run the tests to verify they fail
bash
cd server && npx vitest run services/publicBuylistService.test.jsExpected: FAIL โ functions not exported.
- [ ] Step 3: Implement
Add to server/services/publicBuylistService.js, before module.exports:
javascript
async function findOrderByClaim(shop, orderId, token, deps) {
const order = await deps.BuylistOrder.findOne({ _id: orderId, shop });
if (!order || !order.claimTokenHash) return null;
const providedHash = Buffer.from(deps.hashClaimToken(token));
const storedHash = Buffer.from(order.claimTokenHash);
// Timing-safe compare: a naive === would let response timing leak a
// partial hash match to a scripted guesser probing this public endpoint.
if (providedHash.length !== storedHash.length || !crypto.timingSafeEqual(providedHash, storedHash)) {
return null;
}
return order;
}
async function getPublicOrderByClaim(shop, orderId, token, deps = getDeps()) {
const order = await findOrderByClaim(shop, orderId, token, deps);
if (!order) return { error: 'Order not found', status: 404 };
return { order: toPublicOrder(order) };
}
async function actOnPublicOrder(shop, orderId, token, action, deps = getDeps()) {
const order = await findOrderByClaim(shop, orderId, token, deps);
if (!order) return { error: 'Order not found', status: 404 };
if (order.status !== 'offer_sent') {
return { error: `Cannot ${action} an order with status ${order.status}`, status: 409 };
}
order.status = action === 'accept' ? 'accepted' : 'declined';
await order.save();
deps.logger.info(`Customer ${action}ed buylist order`, { shop, orderId: order._id });
return { order: toPublicOrder(order) };
}Update module.exports to add getPublicOrderByClaim and actOnPublicOrder.
- [ ] Step 4: Run the tests to verify they pass
bash
cd server && npx vitest run services/publicBuylistService.test.jsExpected: PASS โ full file, all tasks 5-7 tests green.
- [ ] Step 5: Commit
bash
git add server/services/publicBuylistService.js server/services/publicBuylistService.test.js
git commit -m "Let customers view and respond to their buylist offer via a claim link"Task 8: publicBuylist routes + mount โ
Files:
- Create:
server/routes/publicBuylist.js - Create:
server/routes/publicBuylist.test.js - Modify:
server/index.js
Interfaces:
Consumes: everything from Tasks 2-7.
Produces: the live public API surface at
/api/public/buylist/*.[ ] Step 1: Write the failing tests
javascript
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../services/publicBuylistService', () => ({
getPublicStoreSummary: vi.fn(),
quotePublicLines: vi.fn(),
createPublicOrder: vi.fn(),
getPublicOrderByClaim: vi.fn(),
actOnPublicOrder: vi.fn()
}));
const routesModule = await import('./publicBuylist.js');
const serviceModule = await import('../services/publicBuylistService.js');
function makeRes() {
return { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() };
}
describe('publicBuylist route handlers', () => {
let res;
beforeEach(() => {
vi.clearAllMocks();
res = makeRes();
});
describe('handleGetSummary', () => {
it('returns the store summary', async () => {
serviceModule.getPublicStoreSummary.mockResolvedValue({ shop: 'test.myshopify.com', games: [] });
const req = { params: { shop: 'test.myshopify.com' } };
await routesModule.handleGetSummary(req, res);
expect(res.json).toHaveBeenCalledWith({ shop: 'test.myshopify.com', games: [] });
});
it('returns 404 when the store is not found', async () => {
serviceModule.getPublicStoreSummary.mockResolvedValue(null);
const req = { params: { shop: 'missing.myshopify.com' } };
await routesModule.handleGetSummary(req, res);
expect(res.status).toHaveBeenCalledWith(404);
});
});
describe('handleQuote', () => {
it('returns the quote result', async () => {
serviceModule.quotePublicLines.mockResolvedValue({ lines: [], totals: {} });
const req = { params: { shop: 'test.myshopify.com' }, body: { game: 'mtg', lines: [] }, publicStore: { shop: 'test.myshopify.com' } };
await routesModule.handleQuote(req, res);
expect(serviceModule.quotePublicLines).toHaveBeenCalledWith(req.publicStore, 'mtg', []);
expect(res.json).toHaveBeenCalledWith({ lines: [], totals: {} });
});
it('maps a service error to its status code', async () => {
serviceModule.quotePublicLines.mockResolvedValue({ error: 'Buylist is not enabled for this game', status: 400 });
const req = { params: { shop: 'test.myshopify.com' }, body: { game: 'mtg', lines: [] }, publicStore: {} };
await routesModule.handleQuote(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Buylist is not enabled for this game' });
});
});
describe('handleCreateOrder', () => {
it('returns 201 with the created order and claim token', async () => {
serviceModule.createPublicOrder.mockResolvedValue({ order: { id: 'order-1' }, claimToken: 'raw-token' });
const req = {
params: { shop: 'test.myshopify.com' },
body: { game: 'mtg', lines: [], customer: { email: 'buyer@example.com' } },
publicStore: { shop: 'test.myshopify.com' }
};
await routesModule.handleCreateOrder(req, res);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({ order: { id: 'order-1' }, claimToken: 'raw-token' });
});
});
describe('handleGetOrder', () => {
it('returns the order for a valid claim token', async () => {
serviceModule.getPublicOrderByClaim.mockResolvedValue({ order: { id: 'order-1' } });
const req = { params: { shop: 'test.myshopify.com', id: 'order-1' }, query: { token: 'a'.repeat(64) } };
await routesModule.handleGetOrder(req, res);
expect(res.json).toHaveBeenCalledWith({ order: { id: 'order-1' } });
});
it('returns 404 for an invalid claim token', async () => {
serviceModule.getPublicOrderByClaim.mockResolvedValue({ error: 'Order not found', status: 404 });
const req = { params: { shop: 'test.myshopify.com', id: 'order-1' }, query: { token: 'a'.repeat(64) } };
await routesModule.handleGetOrder(req, res);
expect(res.status).toHaveBeenCalledWith(404);
});
});
describe('handleAccept / handleDecline', () => {
it('handleAccept calls actOnPublicOrder with action "accept"', async () => {
serviceModule.actOnPublicOrder.mockResolvedValue({ order: { id: 'order-1', status: 'accepted' } });
const req = { params: { shop: 'test.myshopify.com', id: 'order-1' }, query: { token: 'a'.repeat(64) } };
await routesModule.handleAccept(req, res);
expect(serviceModule.actOnPublicOrder).toHaveBeenCalledWith('test.myshopify.com', 'order-1', 'a'.repeat(64), 'accept');
});
it('handleDecline calls actOnPublicOrder with action "decline"', async () => {
serviceModule.actOnPublicOrder.mockResolvedValue({ order: { id: 'order-1', status: 'declined' } });
const req = { params: { shop: 'test.myshopify.com', id: 'order-1' }, query: { token: 'a'.repeat(64) } };
await routesModule.handleDecline(req, res);
expect(serviceModule.actOnPublicOrder).toHaveBeenCalledWith('test.myshopify.com', 'order-1', 'a'.repeat(64), 'decline');
});
});
});- [ ] Step 2: Run the tests to verify they fail
bash
cd server && npx vitest run routes/publicBuylist.test.jsExpected: FAIL โ module doesn't exist.
- [ ] Step 3: Implement the router
javascript
/**
* Public (unauthenticated, store-scoped) buylist portal API. Mounted at
* /api/public/buylist in server/index.js *before* apiRoutes (which applies
* dualModeAuth internally) -- this router never passes through auth. See
* "Mount point avoids auth by construction" in the phase-3 plan.
*/
'use strict';
const express = require('express');
const router = express.Router();
const { validate } = require('../middleware/validate');
const resolvePublicBuylistShop = require('../middleware/resolvePublicBuylistShop');
const {
publicShopParamSchema,
publicShopOrderParamSchema,
publicQuoteSchema,
publicOrderCreateSchema,
publicOrderTokenQuerySchema
} = require('../schemas/publicBuylist');
const logger = require('../utils/logger');
async function handleGetSummary(req, res) {
try {
const { getPublicStoreSummary } = require('../services/publicBuylistService');
const summary = await getPublicStoreSummary(req.params.shop);
if (!summary) return res.status(404).json({ error: 'Store not found' });
res.json(summary);
} catch (error) {
logger.error('Failed to fetch public buylist store summary', { error: error.message, shop: req.params.shop });
res.status(500).json({ error: 'Failed to fetch store' });
}
}
async function handleQuote(req, res) {
try {
const { quotePublicLines } = require('../services/publicBuylistService');
const result = await quotePublicLines(req.publicStore, req.body.game, req.body.lines);
if (result.error) return res.status(result.status).json({ error: result.error });
res.json(result);
} catch (error) {
logger.error('Failed to generate public buylist quote', { error: error.message, shop: req.params.shop });
res.status(500).json({ error: 'Failed to generate quote' });
}
}
async function handleCreateOrder(req, res) {
try {
const { createPublicOrder } = require('../services/publicBuylistService');
const result = await createPublicOrder(req.publicStore, req.body.game, req.body.lines, req.body.customer);
if (result.error) return res.status(result.status).json({ error: result.error });
res.status(201).json(result);
} catch (error) {
logger.error('Failed to create public buylist order', { error: error.message, shop: req.params.shop });
res.status(500).json({ error: 'Failed to create order' });
}
}
async function handleGetOrder(req, res) {
try {
const { getPublicOrderByClaim } = require('../services/publicBuylistService');
const result = await getPublicOrderByClaim(req.params.shop, req.params.id, req.query.token);
if (result.error) return res.status(result.status).json({ error: result.error });
res.json(result);
} catch (error) {
logger.error('Failed to fetch public buylist order', { error: error.message, orderId: req.params.id });
res.status(500).json({ error: 'Failed to fetch order' });
}
}
function makeOrderActionHandler(action) {
return async function handleOrderAction(req, res) {
try {
const { actOnPublicOrder } = require('../services/publicBuylistService');
const result = await actOnPublicOrder(req.params.shop, req.params.id, req.query.token, action);
if (result.error) return res.status(result.status).json({ error: result.error });
res.json(result);
} catch (error) {
logger.error(`Failed to ${action} public buylist order`, { error: error.message, orderId: req.params.id });
res.status(500).json({ error: `Failed to ${action} order` });
}
};
}
const handleAccept = makeOrderActionHandler('accept');
const handleDecline = makeOrderActionHandler('decline');
router.get('/:shop', validate(publicShopParamSchema, { source: 'params' }), handleGetSummary);
router.post('/:shop/quote', validate(publicShopParamSchema, { source: 'params' }), resolvePublicBuylistShop, validate(publicQuoteSchema), handleQuote);
router.post('/:shop/orders', validate(publicShopParamSchema, { source: 'params' }), resolvePublicBuylistShop, validate(publicOrderCreateSchema), handleCreateOrder);
router.get('/:shop/orders/:id', validate(publicShopOrderParamSchema, { source: 'params' }), validate(publicOrderTokenQuerySchema, { source: 'query' }), handleGetOrder);
router.post('/:shop/orders/:id/accept', validate(publicShopOrderParamSchema, { source: 'params' }), validate(publicOrderTokenQuerySchema, { source: 'query' }), handleAccept);
router.post('/:shop/orders/:id/decline', validate(publicShopOrderParamSchema, { source: 'params' }), validate(publicOrderTokenQuerySchema, { source: 'query' }), handleDecline);
module.exports = router;
module.exports.handleGetSummary = handleGetSummary;
module.exports.handleQuote = handleQuote;
module.exports.handleCreateOrder = handleCreateOrder;
module.exports.handleGetOrder = handleGetOrder;
module.exports.handleAccept = handleAccept;
module.exports.handleDecline = handleDecline;Note: GET /:shop/orders/:id, .../accept, .../decline deliberately skip resolvePublicBuylistShop โ they look up the order by {_id, shop} directly inside findOrderByClaim (Task 7), so resolving the full Store document first would be an unnecessary extra DB round trip.
- [ ] Step 4: Run the tests to verify they pass
bash
cd server && npx vitest run routes/publicBuylist.test.jsExpected: PASS.
- [ ] Step 5: Mount the router in
server/index.js
Add near the other direct route requires (after const adminRoutes = require('./routes/admin');):
javascript
const publicBuylistRoutes = require('./routes/publicBuylist');
const { publicBuylistLimiter } = require('./middleware/rateLimiter');Then, in the "API Routes" section, add the new mount before app.use('/api', apiRoutes):
javascript
// API Routes
app.use('/auth', authRoutes); // Shopify embedded OAuth
app.use('/auth/google', googleAuthRoutes); // Google OAuth
app.use('/auth/shopify', shopifyConnectRoutes); // Standalone Shopify connect
app.use('/user', userAuthRoutes); // User signup/login (no shop required)
// Public buylist portal โ mounted BEFORE apiRoutes so it never passes
// through apiRoutes' internal dualModeAuth (router.use(dualModeAuth) in
// server/routes/api.js). See phase-3 plan's Global Constraints.
app.use('/api/public/buylist', publicBuylistLimiter, publicBuylistRoutes);
app.use('/api', apiRoutes); // Shop-specific API (requires shop param)
app.use('/admin', adminRoutes); // Admin routes (Bull-Board, stats) - requires isAdmin- [ ] Step 6: Manually verify the mount order (no automated test covers cross-file route precedence)
bash
npm run dev:no-workerIn another terminal:
bash
curl -s http://localhost:3000/api/public/buylist/nonexistent-shop.myshopify.comExpected: {"error":"Store not found"} with HTTP 404 โ not a 401 (which would mean the request incorrectly reached dualModeAuth).
- [ ] Step 7: Commit
bash
git add server/routes/publicBuylist.js server/routes/publicBuylist.test.js server/index.js
git commit -m "Add the public buylist portal API and mount it ahead of authenticated routes"Task 9: Merchant send-offer endpoint + widen accept/decline โ
Files:
- Modify:
server/routes/api.js
Interfaces:
Produces:
POST /api/buylist/orders/:id/send-offer(merchant-authenticated). Widens the existingPOST /api/buylist/orders/:id/accept|declinestatus guard.Consistent with existing precedent:
server/routes/api.jshas no co-located test file, and the pre-existingaccept/declinehandlers this task modifies have none either โ this task follows that established (if imperfect) convention rather than introducing route-level testing infra for one file mid-plan.[ ] Step 1: Add the
send-offerroute
In server/routes/api.js, immediately before the existing POST /api/buylist/orders/:id/accept route (server/routes/api.js:2122), add:
javascript
/**
* POST /api/buylist/orders/:id/send-offer
* Merchant has reviewed a customer-submitted (source !== 'merchant') order
* and is ready for the customer to see + confirm it on their claim-link
* status page. Only a 'pending' order can move to 'offer_sent'.
*/
router.post('/buylist/orders/:id/send-offer', async (req, res) => {
try {
const BuylistOrder = require('../models/BuylistOrder');
const order = await BuylistOrder.findOne({ _id: req.params.id, shop: req.store.shop });
if (!order) {
return res.status(404).json({ error: 'Buy order not found' });
}
if (order.status !== 'pending') {
return res.status(409).json({ error: `Cannot send an offer for an order with status ${order.status}` });
}
order.status = 'offer_sent';
await order.save();
logger.info('Sent buylist offer to customer', { shop: req.store.shop, orderId: order._id });
res.json({ order });
} catch (error) {
logger.error('Failed to send buylist offer', { error: error.message, orderId: req.params.id });
res.status(500).json({ error: 'Failed to send offer' });
}
});- [ ] Step 2: Widen the accept/decline status guard
In the existing accept handler (server/routes/api.js), change:
javascript
if (order.status !== 'pending') {
return res.status(409).json({ error: `Cannot accept an order with status ${order.status}` });
}to:
javascript
// pending: today's walk-in flow (source: 'merchant'). offer_sent: a
// storefront order the merchant is finalizing manually (e.g. the
// customer confirmed by phone instead of the portal) -- see phase-3 plan.
if (!['pending', 'offer_sent'].includes(order.status)) {
return res.status(409).json({ error: `Cannot accept an order with status ${order.status}` });
}And in the decline handler, change:
javascript
if (order.status !== 'pending') {
return res.status(409).json({ error: `Cannot decline an order with status ${order.status}` });
}to:
javascript
if (!['pending', 'offer_sent'].includes(order.status)) {
return res.status(409).json({ error: `Cannot decline an order with status ${order.status}` });
}- [ ] Step 3: Manually verify with the dev server
bash
npm run devUsing the existing merchant Buylist UI (or curl with a valid session), create a source: 'merchant' order via the dashboard as before โ confirm Accept/Decline still work identically (they never reach offer_sent, so this is a no-op verification of the existing path). This is covered further end-to-end in Task 14.
- [ ] Step 4: Commit
bash
git add server/routes/api.js
git commit -m "Let merchants send a buylist offer to a customer for confirmation"Task 10: Client publicApi + CardSearchPicker injectable client โ
Files:
- Create:
client/src/utils/publicApi.js - Modify:
client/src/pages/buylist/CardSearchPicker.jsx - Modify:
client/src/pages/buylist/CardSearchPicker.test.jsx
Interfaces:
Produces:
publicApi(default export, a bare axios instance).CardSearchPickergains an optionalapiClientprop (default: the existing sharedapi).[ ] Step 1: Create the minimal public API client
javascript
import axios from 'axios';
// Minimal client for the unauthenticated customer-facing buylist portal.
// Deliberately does NOT reuse the shared `api` instance from './api.js' --
// that instance's request interceptor assumes a merchant session (App
// Bridge session token or standalone JWT) and its response interceptor
// redirects to /login on a 401, which would incorrectly bounce an
// anonymous customer off the portal. See phase-3 plan's Global Constraints.
const publicApi = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' }
});
export default publicApi;- [ ] Step 2: Write the failing test for the injectable
apiClientprop
Add to client/src/pages/buylist/CardSearchPicker.test.jsx:
javascript
it('uses a custom apiClient when provided, instead of the shared api instance', async () => {
const customClient = { get: vi.fn().mockResolvedValue({ data: { cards: [
{ uuid: 'uuid-9', name: 'Llanowar Elves', number: '226', setCode: 'FDN', setName: 'Foundations', rarity: 'common', finishes: ['Nonfoil'] }
] } }) };
const onSelect = vi.fn();
render(<CardSearchPicker game="mtg" onSelect={onSelect} apiClient={customClient} />);
fireEvent.change(screen.getByLabelText(/search cards/i), { target: { value: 'Llanowar' } });
vi.advanceTimersByTime(300);
await waitFor(() => expect(customClient.get).toHaveBeenCalledWith('/catalog/cards', { params: { search: 'Llanowar', game: 'mtg', limit: 8 } }));
expect(api.get).not.toHaveBeenCalled();
expect(await screen.findByText('Llanowar Elves')).toBeInTheDocument();
});- [ ] Step 3: Run the test to verify it fails
bash
cd client && npx vitest run src/pages/buylist/CardSearchPicker.test.jsxExpected: FAIL โ apiClient prop is ignored, customClient.get never called.
- [ ] Step 4: Add the
apiClientprop
In client/src/pages/buylist/CardSearchPicker.jsx, change the function signature:
javascript
export default function CardSearchPicker({ game, onSelect }) {to:
javascript
export default function CardSearchPicker({ game, onSelect, apiClient = api }) {Then replace both call sites โ const { data } = await api.get('/catalog/cards', ...) and const { data } = await api.get('/catalog/sets', ...) โ with apiClient.get(...) instead of api.get(...). Add apiClient to both useEffect dependency arrays ([query, game, selectedSets] โ [query, game, selectedSets, apiClient]; [setFilterQuery, game] โ [setFilterQuery, game, apiClient]).
- [ ] Step 5: Run the tests to verify they pass
bash
cd client && npx vitest run src/pages/buylist/CardSearchPicker.test.jsxExpected: PASS โ the new test plus every pre-existing test (which don't pass apiClient, so they exercise the = api default unchanged).
- [ ] Step 6: Commit
bash
git add client/src/utils/publicApi.js client/src/pages/buylist/CardSearchPicker.jsx client/src/pages/buylist/CardSearchPicker.test.jsx
git commit -m "Make CardSearchPicker reusable on the unauthenticated buylist portal"Task 11: Client BuylistPortalPage โ build and submit an order โ
Files:
- Create:
client/src/pages/portal/BuylistPortalPage.jsx - Create:
client/src/pages/portal/BuylistPortalPage.test.jsx
Interfaces:
Consumes:
publicApi(Task 10),CardSearchPickerwithapiClient={publicApi}(Task 10),getDefaultFinish/hasMultipleFinishes(client/src/hooks/useCardSyncAction.js, already exported per selector-entry plan).Produces: the portal's card-building + submit UI, mounted at
/portal/:shop/buylistby Task 13.[ ] Step 1: Write the failing test
javascript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import BuylistPortalPage from './BuylistPortalPage';
vi.mock('../../utils/publicApi', () => ({ default: { get: vi.fn(), post: vi.fn() } }));
import publicApi from '../../utils/publicApi';
const storeSummary = { shop: 'test-store.myshopify.com', games: [{ id: 'mtg', name: 'Magic: The Gathering' }] };
function renderAt(shop) {
return render(
<MemoryRouter initialEntries={[`/portal/${shop}/buylist`]}>
<Routes>
<Route path="/portal/:shop/buylist" element={<BuylistPortalPage />} />
</Routes>
</MemoryRouter>
);
}
beforeEach(() => {
vi.clearAllMocks();
publicApi.get.mockImplementation((url) => {
if (url === '/public/buylist/test-store.myshopify.com') return Promise.resolve({ data: storeSummary });
if (url === '/catalog/cards') return Promise.resolve({ data: { cards: [
{ uuid: 'uuid-1', name: 'Ragavan, Nimble Pilferer', number: '138', setCode: 'MH2', setName: 'Modern Horizons 2', rarity: 'mythic', finishes: ['Nonfoil'] }
] } });
return Promise.resolve({ data: {} });
});
});
describe('BuylistPortalPage', () => {
it('loads the store summary and shows the enabled game as a tab', async () => {
renderAt('test-store.myshopify.com');
expect(publicApi.get).toHaveBeenCalledWith('/public/buylist/test-store.myshopify.com');
expect(await screen.findByText('Magic: The Gathering')).toBeInTheDocument();
});
it('shows a not-available message when the store has no games enabled', async () => {
publicApi.get.mockResolvedValueOnce({ data: { shop: 'test-store.myshopify.com', games: [] } });
renderAt('test-store.myshopify.com');
expect(await screen.findByText(/not currently accepting/i)).toBeInTheDocument();
});
it('adds a searched card to the running list', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
renderAt('test-store.myshopify.com');
await screen.findByText('Magic: The Gathering');
fireEvent.change(screen.getByLabelText(/search cards/i), { target: { value: 'Ragavan' } });
vi.advanceTimersByTime(300);
fireEvent.click(await screen.findByText('Ragavan, Nimble Pilferer'));
expect(screen.getByText('Ragavan, Nimble Pilferer')).toBeInTheDocument();
vi.useRealTimers();
});
it('submits the order and shows the one-time claim link', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
publicApi.post.mockResolvedValue({ data: { order: { id: 'order-1' }, claimToken: 'raw-token-value' } });
renderAt('test-store.myshopify.com');
await screen.findByText('Magic: The Gathering');
fireEvent.change(screen.getByLabelText(/search cards/i), { target: { value: 'Ragavan' } });
vi.advanceTimersByTime(300);
fireEvent.click(await screen.findByText('Ragavan, Nimble Pilferer'));
fireEvent.change(screen.getByLabelText(/your email/i), { target: { value: 'buyer@example.com' } });
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => expect(publicApi.post).toHaveBeenCalledWith(
'/public/buylist/test-store.myshopify.com/orders',
{ game: 'mtg', customer: { email: 'buyer@example.com' }, lines: [{ cardUuid: 'uuid-1', finish: 'Nonfoil', condition: 'nm', quantity: 1 }] }
));
expect(await screen.findByText(/order-1/)).toBeInTheDocument();
vi.useRealTimers();
});
});- [ ] Step 2: Run the test to verify it fails
bash
cd client && npx vitest run src/pages/portal/BuylistPortalPage.test.jsxExpected: FAIL โ module doesn't exist.
- [ ] Step 3: Implement
javascript
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import publicApi from '../../utils/publicApi';
import { Alert, Button, Input, Text } from '../../components/retroui';
import CardSearchPicker from '../buylist/CardSearchPicker';
import { getDefaultFinish, hasMultipleFinishes } from '../../hooks/useCardSyncAction';
// Matches CardSyncControls.jsx's unexported CONDITIONS โ redeclared here
// (same precedent as NewBuyInTab.jsx), since that file doesn't export it.
const CONDITIONS = [
{ value: 'nm', label: 'NM' },
{ value: 'lp', label: 'LP' },
{ value: 'mp', label: 'MP' },
{ value: 'hp', label: 'HP' },
{ value: 'damaged', label: 'DMG' }
];
export default function BuylistPortalPage() {
const { shop } = useParams();
const [summary, setSummary] = useState(null);
const [summaryError, setSummaryError] = useState('');
const [game, setGame] = useState('');
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [selectedCards, setSelectedCards] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState(null); // { orderId, claimToken }
useEffect(() => {
publicApi.get(`/public/buylist/${shop}`)
.then(({ data }) => {
setSummary(data);
if (data.games.length > 0) setGame(data.games[0].id);
})
.catch(() => setSummaryError('Failed to load this shop\'s buylist.'));
}, [shop]);
const addCard = (card) => {
setSelectedCards((prev) => {
if (prev.some((c) => c.uuid === card.uuid)) return prev;
return [...prev, {
uuid: card.uuid,
name: card.name,
setCode: card.setCode,
number: card.number,
finishes: card.finishes || [],
finish: getDefaultFinish(card),
condition: 'nm',
quantity: 1
}];
});
};
const removeCard = (uuid) => {
setSelectedCards((prev) => prev.filter((c) => c.uuid !== uuid));
};
const updateCard = (uuid, field, value) => {
setSelectedCards((prev) => prev.map((c) => (c.uuid === uuid ? { ...c, [field]: value } : c)));
};
const submit = async () => {
setSubmitting(true);
setError('');
try {
const customer = name ? { email, name } : { email };
const body = {
game,
customer,
lines: selectedCards.map((c) => ({
cardUuid: c.uuid,
finish: c.finish,
condition: c.condition,
quantity: Number(c.quantity)
}))
};
const { data } = await publicApi.post(`/public/buylist/${shop}/orders`, body);
setResult({ orderId: data.order.id, claimToken: data.claimToken });
} catch (err) {
setError(err.response?.data?.error || 'Failed to submit your list');
} finally {
setSubmitting(false);
}
};
if (summaryError) return <div className="p-6 max-w-2xl mx-auto"><Alert status="error">{summaryError}</Alert></div>;
if (!summary) return <div className="p-6 max-w-2xl mx-auto"><Text>Loadingโฆ</Text></div>;
if (summary.games.length === 0) {
return <div className="p-6 max-w-2xl mx-auto"><Text>This shop is not currently accepting buy orders online.</Text></div>;
}
if (result) {
const statusUrl = `${window.location.origin}/portal/${shop}/buylist/orders/${result.orderId}?token=${result.claimToken}`;
return (
<div className="p-6 max-w-2xl mx-auto space-y-4">
<Text as="h1">Thanks โ your list is in!</Text>
<p>The shop will review your list and send you an offer. Save this link to check your order's status and respond to the offer โ it's the only way to access your order, and it won't be shown again:</p>
<Input aria-label="Your order status link" readOnly value={statusUrl} onFocus={(e) => e.target.select()} />
</div>
);
}
return (
<div className="p-6 max-w-2xl mx-auto space-y-4">
<Text as="h1">Sell your cards</Text>
{error && <Alert status="error">{error}</Alert>}
{summary.games.length > 1 && (
<div className="flex gap-2">
{summary.games.map((g) => (
<Button key={g.id} variant={g.id === game ? 'default' : 'outline'} size="sm" onClick={() => setGame(g.id)}>
{g.name}
</Button>
))}
</div>
)}
<div>
<label htmlFor="portal-email" className="font-semibold text-sm block mb-1">Your email</label>
<Input id="portal-email" aria-label="Your email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div>
<label htmlFor="portal-name" className="font-semibold text-sm block mb-1">Your name (optional)</label>
<Input id="portal-name" aria-label="Your name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<CardSearchPicker game={game} onSelect={addCard} apiClient={publicApi} />
{selectedCards.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-secondary text-secondary-foreground border-b-2 border-black">
<tr>
<th className="px-4 py-2 text-left">Card</th>
<th className="px-4 py-2 text-left">Set</th>
<th className="px-4 py-2 text-left">Finish</th>
<th className="px-4 py-2 text-left">Condition</th>
<th className="px-4 py-2 text-left">Qty</th>
<th className="px-4 py-2 text-left"></th>
</tr>
</thead>
<tbody>
{selectedCards.map((c) => (
<tr key={c.uuid} className="border-b border-black/20">
<td className="px-4 py-2">{c.name}</td>
<td className="px-4 py-2 text-muted-foreground">{c.setCode} #{c.number}</td>
<td className="px-4 py-2">
{hasMultipleFinishes({ finishes: c.finishes }) ? (
<select
aria-label={`Finish for ${c.name}`}
className="border-2 border-black rounded px-2 py-1 text-xs bg-card text-foreground"
value={c.finish}
onChange={(e) => updateCard(c.uuid, 'finish', e.target.value)}
>
{c.finishes.map((f) => <option key={f} value={f}>{f}</option>)}
</select>
) : (
<span className="text-muted-foreground text-xs">{c.finishes[0] || 'โ'}</span>
)}
</td>
<td className="px-4 py-2">
<select
aria-label={`Condition for ${c.name}`}
className="border-2 border-black rounded px-2 py-1 text-xs bg-card text-foreground"
value={c.condition}
onChange={(e) => updateCard(c.uuid, 'condition', e.target.value)}
>
{CONDITIONS.map((cond) => <option key={cond.value} value={cond.value}>{cond.label}</option>)}
</select>
</td>
<td className="px-4 py-2">
<input
type="number"
min={1}
aria-label={`Quantity for ${c.name}`}
className="w-16 border-2 border-black rounded px-2 py-1 text-xs bg-card text-foreground"
value={c.quantity}
onChange={(e) => updateCard(c.uuid, 'quantity', e.target.value)}
/>
</td>
<td className="px-4 py-2">
<button type="button" aria-label={`Remove ${c.name}`} className="text-xs underline" onClick={() => removeCard(c.uuid)}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Button onClick={submit} disabled={submitting || !email || selectedCards.length === 0}>
{submitting ? 'Submittingโฆ' : 'Submit List'}
</Button>
</div>
);
}- [ ] Step 4: Run the tests to verify they pass
bash
cd client && npx vitest run src/pages/portal/BuylistPortalPage.test.jsxExpected: PASS.
- [ ] Step 5: Commit
bash
git add client/src/pages/portal/BuylistPortalPage.jsx client/src/pages/portal/BuylistPortalPage.test.jsx
git commit -m "Add the public buylist portal page for customers to build and submit a sell list"Task 12: Client BuylistPortalStatusPage โ view and respond to an offer โ
Files:
- Create:
client/src/pages/portal/BuylistPortalStatusPage.jsx - Create:
client/src/pages/portal/BuylistPortalStatusPage.test.jsx
Interfaces:
Consumes:
publicApi(Task 10).Produces: the claim-link status page, mounted at
/portal/:shop/buylist/orders/:idby Task 13.[ ] Step 1: Write the failing test
javascript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import BuylistPortalStatusPage from './BuylistPortalStatusPage';
vi.mock('../../utils/publicApi', () => ({ default: { get: vi.fn(), post: vi.fn() } }));
import publicApi from '../../utils/publicApi';
const offerSentOrder = {
id: 'order-1',
game: 'mtg',
status: 'offer_sent',
customer: { email: 'buyer@example.com' },
lines: [{ cardName: 'Lightning Bolt', setCode: 'CLB', collectorNumber: '187', quantity: 1, offerPrice: 0.80, matchStatus: 'matched', included: true }],
payout: { method: 'store_credit', cashTotal: 0.80, creditTotal: 1.00, creditBonus: 0.25 }
};
function renderAt(shop, id, token) {
return render(
<MemoryRouter initialEntries={[`/portal/${shop}/buylist/orders/${id}?token=${token}`]}>
<Routes>
<Route path="/portal/:shop/buylist/orders/:id" element={<BuylistPortalStatusPage />} />
</Routes>
</MemoryRouter>
);
}
beforeEach(() => {
vi.clearAllMocks();
publicApi.get.mockResolvedValue({ data: { order: offerSentOrder } });
});
describe('BuylistPortalStatusPage', () => {
it('loads and displays the order using the token from the URL', async () => {
renderAt('test-store.myshopify.com', 'order-1', 'raw-token-value');
expect(publicApi.get).toHaveBeenCalledWith('/public/buylist/test-store.myshopify.com/orders/order-1', { params: { token: 'raw-token-value' } });
expect(await screen.findByText('Lightning Bolt')).toBeInTheDocument();
});
it('shows accept/decline buttons only when the offer has been sent', async () => {
renderAt('test-store.myshopify.com', 'order-1', 'raw-token-value');
await screen.findByText('Lightning Bolt');
expect(screen.getByRole('button', { name: /accept/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /decline/i })).toBeInTheDocument();
});
it('hides accept/decline when the order is still pending review', async () => {
publicApi.get.mockResolvedValue({ data: { order: { ...offerSentOrder, status: 'pending' } } });
renderAt('test-store.myshopify.com', 'order-1', 'raw-token-value');
await screen.findByText('Lightning Bolt');
expect(screen.queryByRole('button', { name: /accept/i })).not.toBeInTheDocument();
});
it('accepts the offer', async () => {
publicApi.post.mockResolvedValue({ data: { order: { ...offerSentOrder, status: 'accepted' } } });
renderAt('test-store.myshopify.com', 'order-1', 'raw-token-value');
await screen.findByText('Lightning Bolt');
fireEvent.click(screen.getByRole('button', { name: /accept/i }));
await waitFor(() => expect(publicApi.post).toHaveBeenCalledWith(
'/public/buylist/test-store.myshopify.com/orders/order-1/accept',
null,
{ params: { token: 'raw-token-value' } }
));
expect(await screen.findByText(/accepted/i)).toBeInTheDocument();
});
it('declines the offer', async () => {
publicApi.post.mockResolvedValue({ data: { order: { ...offerSentOrder, status: 'declined' } } });
renderAt('test-store.myshopify.com', 'order-1', 'raw-token-value');
await screen.findByText('Lightning Bolt');
fireEvent.click(screen.getByRole('button', { name: /decline/i }));
await waitFor(() => expect(publicApi.post).toHaveBeenCalledWith(
'/public/buylist/test-store.myshopify.com/orders/order-1/decline',
null,
{ params: { token: 'raw-token-value' } }
));
expect(await screen.findByText(/declined/i)).toBeInTheDocument();
});
});- [ ] Step 2: Run the test to verify it fails
bash
cd client && npx vitest run src/pages/portal/BuylistPortalStatusPage.test.jsxExpected: FAIL โ module doesn't exist.
- [ ] Step 3: Implement
javascript
import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import publicApi from '../../utils/publicApi';
import { Alert, Badge, Button, Text } from '../../components/retroui';
// Mirrors BuylistOrder.status in server/models/BuylistOrder.js.
const STATUS_LABEL = {
pending: 'Pending review',
offer_sent: 'Offer sent',
accepted: 'Accepted',
declined: 'Declined',
paid: 'Paid'
};
export default function BuylistPortalStatusPage() {
const { shop, id } = useParams();
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const [order, setOrder] = useState(null);
const [error, setError] = useState('');
const [acting, setActing] = useState(false);
useEffect(() => {
publicApi.get(`/public/buylist/${shop}/orders/${id}`, { params: { token } })
.then(({ data }) => setOrder(data.order))
.catch((err) => setError(err.response?.data?.error || 'Failed to load your order'));
}, [shop, id, token]);
const act = async (action) => {
setActing(true);
setError('');
try {
const { data } = await publicApi.post(`/public/buylist/${shop}/orders/${id}/${action}`, null, { params: { token } });
setOrder(data.order);
} catch (err) {
setError(err.response?.data?.error || `Failed to ${action} the offer`);
} finally {
setActing(false);
}
};
if (error && !order) return <div className="p-6 max-w-2xl mx-auto"><Alert status="error">{error}</Alert></div>;
if (!order) return <div className="p-6 max-w-2xl mx-auto"><Text>Loading your orderโฆ</Text></div>;
const isOfferSent = order.status === 'offer_sent';
return (
<div className="p-6 max-w-2xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<Text as="h1">Your Buy Order</Text>
<Badge>{STATUS_LABEL[order.status] || order.status}</Badge>
</div>
{error && <Alert status="error">{error}</Alert>}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-secondary text-secondary-foreground border-b-2 border-black">
<tr>
<th className="px-4 py-2 text-left">Card</th>
<th className="px-4 py-2 text-left">Qty</th>
<th className="px-4 py-2 text-left">Offer</th>
</tr>
</thead>
<tbody>
{order.lines.map((line, i) => (
<tr key={i} className="border-b border-black/20">
<td className="px-4 py-2">{line.cardName || line.rawLine}</td>
<td className="px-4 py-2">{line.quantity}</td>
<td className="px-4 py-2">{typeof line.offerPrice === 'number' ? line.offerPrice.toFixed(2) : 'โ'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="border-t-2 border-black pt-3 font-semibold space-y-1">
<div>Cash total: {order.payout.cashTotal?.toFixed(2)}</div>
<div>Store credit total: {order.payout.creditTotal?.toFixed(2)}</div>
</div>
{isOfferSent && (
<div className="flex gap-2">
<Button onClick={() => act('accept')} disabled={acting}>Accept Offer</Button>
<Button variant="outline" onClick={() => act('decline')} disabled={acting}>Decline Offer</Button>
</div>
)}
</div>
);
}- [ ] Step 4: Run the tests to verify they pass
bash
cd client && npx vitest run src/pages/portal/BuylistPortalStatusPage.test.jsxExpected: PASS.
- [ ] Step 5: Commit
bash
git add client/src/pages/portal/BuylistPortalStatusPage.jsx client/src/pages/portal/BuylistPortalStatusPage.test.jsx
git commit -m "Let customers view and respond to their buylist offer from a claim link"Task 13: Wire portal routes into App.jsx โ
Files:
- Modify:
client/src/App.jsx - Modify:
client/src/App.test.jsx
Interfaces:
Consumes:
BuylistPortalPage,BuylistPortalStatusPage(Tasks 11-12).Produces: live
/portal/:shop/buylistand/portal/:shop/buylist/orders/:idroutes, rendered outsideStoreProvider/RadixShell/ProtectedRoute.[ ] Step 1: Write the failing test
Add to client/src/App.test.jsx, alongside the existing mocks (add these two new vi.mock calls near the top with the others):
javascript
vi.mock('./pages/portal/BuylistPortalPage', () => ({ default: () => <div>buylist-portal-page</div> }));
vi.mock('./pages/portal/BuylistPortalStatusPage', () => ({ default: () => <div>buylist-portal-status-page</div> }));Then add a new describe block:
javascript
describe('App routing โ public portal', () => {
it('/portal/:shop/buylist renders the portal page outside the merchant shell', async () => {
await renderAppAt('/portal/test-store.myshopify.com/buylist');
expect(await screen.findByText('buylist-portal-page')).toBeInTheDocument();
});
it('/portal/:shop/buylist/orders/:id renders the portal status page', async () => {
await renderAppAt('/portal/test-store.myshopify.com/buylist/orders/order-1?token=abc');
expect(await screen.findByText('buylist-portal-status-page')).toBeInTheDocument();
});
});- [ ] Step 2: Run the test to verify it fails
bash
cd client && npx vitest run src/App.test.jsxExpected: FAIL โ /portal/* falls through to the existing embedded/standalone catch-all instead of rendering the portal pages.
- [ ] Step 3: Add the imports and the portal route branch
In client/src/App.jsx, add the imports near the other page imports:
javascript
import BuylistPortalPage from './pages/portal/BuylistPortalPage';
import BuylistPortalStatusPage from './pages/portal/BuylistPortalStatusPage';In AppRouter(), add a /portal/ special case alongside the existing /login one (both live inside the same function, before the StoreProvider/RadixShell return):
javascript
// Auth page - no shell wrapper needed
if (location.pathname === '/login' && !isEmbedded) {
if (isAuthenticated()) {
return <Navigate to="/dashboard" replace />;
}
return <Auth onAuthenticated={handleAuthenticated} />;
}
// Public customer-facing buylist portal โ no merchant session exists here,
// so this bypasses StoreProvider (assumes a logged-in Store) and
// RadixShell (merchant nav) entirely, same reasoning as the /login case.
if (location.pathname.startsWith('/portal/')) {
return (
<Routes>
<Route path="/portal/:shop/buylist" element={<BuylistPortalPage />} />
<Route path="/portal/:shop/buylist/orders/:id" element={<BuylistPortalStatusPage />} />
</Routes>
);
}- [ ] Step 4: Run the tests to verify they pass
bash
cd client && npx vitest run src/App.test.jsxExpected: PASS โ new tests plus every pre-existing routing test (the /portal/ branch is checked before the embedded/standalone logic, but only matches paths starting with /portal/, so it can't shadow any existing route).
- [ ] Step 5: Commit
bash
git add client/src/App.jsx client/src/App.test.jsx
git commit -m "Mount the public buylist portal routes outside the merchant app shell"Task 14: Merchant BuylistReviewPage โ "Send Offer" action โ
Files:
- Modify:
client/src/pages/buylist/BuylistReviewPage.jsx - Modify:
client/src/pages/buylist/BuylistReviewPage.test.jsx
Interfaces:
Consumes:
POST /api/buylist/orders/:id/send-offer(Task 9).Produces: source-aware action buttons โ walk-in (
source: 'merchant') orders keep today's single accept/decline step; storefront orders get a distinct "Send Offer to Customer" step before accept/decline become merchant-facing manual-override actions.[ ] Step 1: Update the test fixture and write the failing tests
In client/src/pages/buylist/BuylistReviewPage.test.jsx, add source: 'merchant' to the existing baseOrder fixture (required โ BuylistOrder.source is a required field on the real model, and the new conditional rendering branches on it):
javascript
const baseOrder = {
_id: 'order-1',
game: 'mtg',
status: 'pending',
source: 'merchant',
customer: { email: 'jamie@example.com' },
lines: [(leave the rest of baseOrder unchanged). Then add new test cases:
javascript
describe('BuylistReviewPage โ storefront orders', () => {
const storefrontPendingOrder = { ...baseOrder, source: 'storefront', status: 'pending' };
const offerSentOrder = { ...baseOrder, source: 'storefront', status: 'offer_sent' };
it('shows "Send Offer to Customer" instead of "Accept" for a pending storefront order', async () => {
api.get.mockResolvedValue({ data: { order: storefrontPendingOrder } });
renderAt('order-1');
await screen.findByText('Lightning Bolt');
expect(screen.getByRole('button', { name: /send offer to customer/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^accept/i })).not.toBeInTheDocument();
});
it('sends the offer', async () => {
api.get.mockResolvedValue({ data: { order: storefrontPendingOrder } });
api.post.mockResolvedValue({ data: { order: { ...storefrontPendingOrder, status: 'offer_sent' } } });
renderAt('order-1');
await screen.findByText('Lightning Bolt');
fireEvent.click(screen.getByRole('button', { name: /send offer to customer/i }));
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/buylist/orders/order-1/send-offer'));
expect(await screen.findByText(/offer sent/i)).toBeInTheDocument();
});
it('shows a waiting message and manual accept/decline once the offer has been sent', async () => {
api.get.mockResolvedValue({ data: { order: offerSentOrder } });
renderAt('order-1');
await screen.findByText('Lightning Bolt');
expect(screen.getByText(/waiting on the customer/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^accept order/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /decline order/i })).toBeInTheDocument();
});
});- [ ] Step 2: Run the tests to verify they fail
bash
cd client && npx vitest run src/pages/buylist/BuylistReviewPage.test.jsxExpected: FAIL โ no source-aware branching exists yet.
- [ ] Step 3: Implement the source-aware action buttons
In client/src/pages/buylist/BuylistReviewPage.jsx, replace:
javascript
const isPending = order.status === 'pending';with:
javascript
const isPending = order.status === 'pending';
const isOfferSent = order.status === 'offer_sent';And replace the existing action-buttons block:
javascript
{isPending && (
<div className="flex gap-2">
<Button onClick={() => act('accept')} disabled={acting}>Accept & Send Offer</Button>
{/* Button's cva variants are default|secondary|outline|link|ghost (components/retroui/Button.jsx) โ
there is no variant="destructive". `outline` is the established de-emphasized/non-primary
choice used elsewhere in buylist (see BuylistOrdersTab's game-tab buttons). */}
<Button variant="outline" onClick={() => act('decline')} disabled={acting}>Decline Order</Button>
</div>
)}with:
javascript
{isPending && order.source === 'merchant' && (
<div className="flex gap-2">
<Button onClick={() => act('accept')} disabled={acting}>Accept & Send Offer</Button>
{/* Button's cva variants are default|secondary|outline|link|ghost (components/retroui/Button.jsx) โ
there is no variant="destructive". `outline` is the established de-emphasized/non-primary
choice used elsewhere in buylist (see BuylistOrdersTab's game-tab buttons). */}
<Button variant="outline" onClick={() => act('decline')} disabled={acting}>Decline Order</Button>
</div>
)}
{isPending && order.source !== 'merchant' && (
<div className="flex gap-2">
<Button onClick={() => act('send-offer')} disabled={acting}>Send Offer to Customer</Button>
<Button variant="outline" onClick={() => act('decline')} disabled={acting}>Decline Order</Button>
</div>
)}
{isOfferSent && (
<div className="space-y-2">
<p className="text-muted-foreground text-sm">Offer sent โ waiting on the customer to respond. You can also finalize manually below.</p>
<div className="flex gap-2">
<Button onClick={() => act('accept')} disabled={acting}>Accept Order</Button>
<Button variant="outline" onClick={() => act('decline')} disabled={acting}>Decline Order</Button>
</div>
</div>
)}- [ ] Step 4: Run the tests to verify they pass
bash
cd client && npx vitest run src/pages/buylist/BuylistReviewPage.test.jsxExpected: PASS โ new tests plus every pre-existing test (which now runs against baseOrder.source === 'merchant', matching the first new branch, so "accepts the order"/"declines the order" behave exactly as before).
- [ ] Step 5: Commit
bash
git add client/src/pages/buylist/BuylistReviewPage.jsx client/src/pages/buylist/BuylistReviewPage.test.jsx
git commit -m "Show merchants a Send Offer step before a storefront buy order can be accepted"Deferred to later phases (explicitly out of scope here) โ
Matching the design spec's own PR sequence and the 2026-07-18 roadmap update โ do not build these in this plan:
- Kiosk mode / kiosk device tokens (spec phase 4) โ the in-store iPad flow and its device-token auth strategy.
- Theme App Extension + on-domain App Proxy hosting (spec phase 5) โ the
buylist.lgsforge.com/on-merchant-domain subdomain idea deferred by this plan's mount-point decision belongs here, not as a fast-follow to this PR. - Gift card payout issuance (spec phase 6) โ
payout.method/.cashTotal/.creditTotalare computed and shown, but no Shopify gift card is ever actually issued yet (true of the existing merchant flow too โ this plan doesn't regress or advance that). - Customer email notifications ("your offer is ready", spec phase 6) โ a customer must revisit their saved claim link manually; there is no trigger to email them when a merchant calls
send-offer. State this explicitly as a known phase-3 limitation, not an oversight. - ManaBox / TCGPlayer app list import (spec phase 7, unscheduled) โ unrelated to this phase; blocked on collecting real sample exports per the 2026-07-18 spec note.
- Cloudflare Turnstile / CAPTCHA โ the spec's "cheapest first" abuse-mitigation list starts with rate limiting (built here); Turnstile needs a third-party site-key/account, which is a procurement step, not a code change.
- Daily per-IP quote-line caps โ a second abuse-mitigation layer the spec lists after rate limiting; deferred as a fast-follow once real portal traffic patterns are known (YAGNI against a threat model that hasn't materialized yet).
- Distributed/Redis-backed rate limiting โ
publicBuylistLimiteris in-memory, matchingauthLimiter/apiLimiter's existing accepted limitation (single Cloud Run instance's view). Worth revisiting only if Cloud Run is scaled to multiple concurrent instances and abuse becomes a real problem.
Self-Review โ
Spec coverage: Phase 3's spec bullet ("Public API + hosted portal page โ customer web surface live") is covered end-to-end: store summary (Task 5), instant quote respecting instantQuote.web (Task 5), order submission with a one-time claim token (Task 6), claim-token-gated status viewing (Task 7/12), and the customer's own accept/decline of a merchant-reviewed offer (Task 7/12) โ the full loop the spec's "customer's status page (offer sent โ accept/decline)" line describes, including making the previously-dead offer_sent status real (Task 9/14). Explicitly-scoped-out items are listed above with citations to which later phase owns them.
Placeholder scan: No TBD/TODO/"add appropriate error handling" phrases; every step has complete, runnable code; no step says "similar to Task N" without repeating the actual code.
Type consistency: quotePublicLines(store, game, lines) / createPublicOrder(store, game, lines, customer) / getPublicOrderByClaim(shop, orderId, token) / actOnPublicOrder(shop, orderId, token, action) signatures are used identically across their defining tasks (5-7), the route handlers that call them (Task 8), and the tests. The sanitized toPublicOrder() shape ({id, game, status, customer, lines, payout, createdAt}, no claimTokenHash) is used consistently by every consumer (Tasks 6, 7, 8, 11, 12).
