Skip to content

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).
  • game is never defaulted anywhere (CLAUDE.md ยง5.5): missing/unsupported โ†’ 400, never a fallback. On this public surface specifically, an invalid game must be caught by an explicit getSupportedGames().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.source already includes 'storefront', .status already includes 'offer_sent', and .customer.email/.name/.claimTokenHash are 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.js mounts apiRoutes (which applies dualModeAuth internally via router.use(dualModeAuth) at server/routes/api.js:1594) at /api. The new public router is a separate Express Router mounted at /api/public/buylist and registered before app.use('/api', apiRoutes) โ€” verified by reading server/index.js:130-142. Because Express matches app.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 reach apiRoutes/dualModeAuth at 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 reading firebase.json. server/index.js's allowedOrigins CORS list already includes https://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 via selectedCardLineSchema in server/schemas/buylistOrder.js) โ€” never rawText. 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 through CardSearchPicker'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}) and computeOrderTotals(lines, buylistConfig) directly (both already exported, server/services/buylistQuoteService.js:509-521) โ€” same functions the authenticated POST /api/buylist/orders selector path already uses. Do not write new match/price logic for the public path.
  • Claim tokens use crypto, not bcryptjs. server/utils/crypto.js already has this exact pattern for other secrets (generateNonce: crypto.randomBytes(16).toString('hex'); getEncryptionKeyBuffer: crypto.createHash('sha256')...digest()) โ€” extend it with generateClaimToken()/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.timingSafeEqual on the two hash buffers, not ===, so response timing can't leak a partial hash match to a scripted guesser.
  • Store.buylistConfig[gameId].enabled and .instantQuote.web already exist (server/services/buylistConfigService.js's BASE_BUYLIST_DEFAULTS, from Phase 1) โ€” reuse assertBuylistEnabled verbatim for the enabled gate; check .instantQuote.web directly for the instant-quote gate. Do not add new config fields.
  • GET /api/catalog/cards and GET /api/catalog/sets are already public-safe โ€” both run on optionalDualModeAuth (server/routes/api.js:906, :704), registered before the dualModeAuth cutoff, and optionalDualModeAuth always calls next() regardless of whether auth succeeded (verified by reading server/middleware/dualModeAuth.js:296-320). CardSearchPicker.jsx therefore 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.js axios instance on the portal. Its request interceptor assumes a merchant session โ€” it calls window.shopify.idToken() or reads a JWT from localStorage, and its response interceptor redirects to /login on a 401 (verified by reading client/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 give CardSearchPicker an injectable apiClient prop (default: the existing shared api, so the merchant NewBuyInTab.jsx usage is unaffected) so the portal can pass publicApi.
  • Portal routes bypass the merchant shell entirely. client/src/App.jsx's AppRouter() always wraps routes in <StoreProvider><RadixShell>... except for a special-cased /login branch (client/src/App.jsx:180-186). Add a second special case for /portal/* the same way โ€” StoreProvider assumes a merchant Store tied to the logged-in session, and RadixShell renders the merchant nav; neither belongs on a customer-facing page.
  • No supertest dependency exists in this repo (verified: not in package.json, no usages anywhere) and server/routes/api.js itself has no co-located test file โ€” the established convention for that file is service/schema/model-level unit tests carrying the coverage. Because publicBuylist.js is 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 mock req/res objects plus vi.mock('../services/publicBuylistService') โ€” mirroring the mock-req/res style already used in server/routes/webhooks.test.js and server/middleware/dualModeAuth.test.js.
  • Existing merchant walk-in flow must not change behavior. source: 'merchant' orders continue to go pending โ†’ accepted/declined directly via the existing POST /api/buylist/orders/:id/accept|decline handlers (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 for source: 'merchant' orders (they never reach 'offer_sent', so nothing changes for them).
  • Coverage โ‰ฅ70% on all four metrics for every modified file; npm run lint clean (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.js is 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 -5

Known 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.js

Expected: 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.js

Expected: 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 from buylistOrder.js), and from the new file: publicShopParamSchema, publicShopOrderParamSchema, publicQuoteSchema, publicOrderCreateSchema, publicOrderTokenQuerySchema. Task 8 (publicBuylist.js routes) consumes all five.

  • [ ] Step 1: Export selectedCardLineSchema from buylistOrder.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.js

Expected: 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.js

Expected: 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 by publicShopParamSchema upstream, per Task 2).

  • Produces: req.publicStore (a Mongoose Store document) on success, or a 404/500 JSON response. Task 8 (routes) applies this middleware to POST .../quote and POST .../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.js

Expected: 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.js

Expected: 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.js

Expected: 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.js

Expected: 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.js

Expected: 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.js

Expected: 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}>. order is the sanitized shape from toPublicOrder() (no claimTokenHash, _id renamed to id). 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.js

Expected: 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.js

Expected: 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}> where action is '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.js

Expected: 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.js

Expected: 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.js

Expected: 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.js

Expected: 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-worker

In another terminal:

bash
curl -s http://localhost:3000/api/public/buylist/nonexistent-shop.myshopify.com

Expected: {"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 existing POST /api/buylist/orders/:id/accept|decline status guard.

  • Consistent with existing precedent: server/routes/api.js has no co-located test file, and the pre-existing accept/decline handlers 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-offer route

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 dev

Using 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). CardSearchPicker gains an optional apiClient prop (default: the existing shared api).

  • [ ] 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 apiClient prop

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.jsx

Expected: FAIL โ€” apiClient prop is ignored, customClient.get never called.

  • [ ] Step 4: Add the apiClient prop

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.jsx

Expected: 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), CardSearchPicker with apiClient={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/buylist by 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.jsx

Expected: 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.jsx

Expected: 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/:id by 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.jsx

Expected: 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.jsx

Expected: 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/buylist and /portal/:shop/buylist/orders/:id routes, rendered outside StoreProvider/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.jsx

Expected: 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.jsx

Expected: 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.jsx

Expected: 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 &amp; 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 &amp; 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.jsx

Expected: 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/.creditTotal are 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 โ€” publicBuylistLimiter is in-memory, matching authLimiter/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).