Summary
Scout sells physical embroidered patches and a hat through a headless Shopify
store built as a separate Next.js project (store/). Two purchase surfaces
exist: a public web shopfront and an in-app WebView the mobile app opens.
The wall that hid the whole public shopfront is now open for APPAREL: /,
/shop, /shop/[category], /products/[handle] and /cart are reachable
(docs/features/merch-storefront.md).
Patch and pack commerce — /collections, /store-exclusives, /my-patches
and the old shelves — stays walled. The wall does not say the store is unfinished: it tells the
visitor the store is in the app for now and hands them the App Store / Play
download links, because the in-app /app tree is shipped and selling
(docs/features/mobile-store.md). Most of the catalog is gated by an "earned, not
bought" rule: a patch can only be bought once the signed-in shopper has
unlocked it in the mobile app, enforced independently on both the storefront
and (when a separate feature flag is on) inside the app itself. A second,
completely different monetization mechanism — RevenueCat in-app purchases for
unlocking a Collection's content — is fully built (backend verification,
mobile SDK, purchase sheet, webview UI) but has no reachable trigger
anywhere in the app today; it is dead code by an explicit in-repo admission,
not a design choice this doc is inferring.
Can a member of the public buy anything right now, and through which URL?
Yes, and it is no longer just one page. The apparel shop — /shop, its six
category pages and /products/<handle> — is open and populated
(docs/features/merch-storefront.md). The hero product is the Trail Hat —
Saint Louis Zoo at /products/saint-louis-zoo-trail-hat, $30 marked down to
$20, in three colourways (Tan, White, Black); as of 2026-09-16 Shopify holds
Tan 8, White 2, Black 0. Patch and pack commerce (/collections,
/store-exclusives, /my-patches, …) still renders the coming-soon wall.
/launch was DELETED on 2026-09-16 and now 404s. It was the ad-landing
drop page — its own scarcity bar, direct-to-checkout claim button and no site
chrome. It went when the desktop store opened, deliberately without a redirect,
so every ad, Facebook-group post, end card and shared OG card still pointing at
it is broken until repointed (see marketing/BRAND.md). Its one irreplaceable
job, firing Meta's ViewContent, moved to /products/[handle] via
ProductViewContent — and now fires on every product page rather than one. The in-app store at /app (opened only from inside the
mobile app, and only for a signed-in user) is separately reachable and sells
the shopper's own collected patches plus swag/promo items.
Does any in-app purchase or subscription exist? No. react-native-purchases
is installed and initializes on every app launch, and a full backend
verification pipeline (backend/src/purchases/, backend/src/revenuecat/)
exists and is wired to a real Collection.productId field that most
collections in the DB actually carry — but the one UI action that would open
the purchase sheet (tapping a locked collection's pin on the map) was
removed when the app dropped SQLite, and nothing replaced it. The trigger
function (setSelectedLockedCollection) is called with null everywhere in
map.tsx (map.tsx:1374-1392 mounts the sheet off a view-model field nothing
sets) and never with a real collection. No subscription product or
subscription UI exists anywhere in the codebase.
Status (shipped / beta-badged / flagged off)
| Surface | State | Flag / gate | Shipped default |
|---|---|---|---|
Public web shopfront (/, /products, /collections, /cart, /store-exclusives, /my-patches, /account, /featured, /about, …) |
Walled behind /coming-soon |
STORE_COMING_SOON env var, exact string 'true' (store/proxy.ts:61, store/lib/coming-soon.ts:77-83) |
Confirmed ON in production by live fetch of shop.scout-patches.com/ on 2026-08-31 (X-Robots-Tag: noindex, nofollow). The wall page's title is "The shop is in the app — Scout" as of 2026-09-01 (store/app/coming-soon/page.tsx:9) — a deploy is what makes that live |
/launch (Trail Hat drop) |
DELETED 2026-09-16 — route, components, lib/launch.ts and LAUNCH_* env all removed; no redirect, it 404s |
N/A | Gone |
In-app store WebView (/app tree, opened via scout://store-webview) |
Live, requires sign-in | Also in OPEN_PREFIXES; app-side AppStorePage redirects signed-out visitors to /auth/sign-in (store/app/app/page.tsx:64-68) |
On |
| Mobile "Purchase Patch" button (patch action sheet + celebration screen) | Live in production | buy_patch feature flag, defaultEnabled: false in both registries (mobile/src/config/feature-flags.ts:39-43, backend/src/admin/feature-flag-definitions.ts:32-38) — but the production DB row overrides that default to true |
ON in production (verified live on 2026-09-13 via GET https://scout-patches.com/api/feature-flags). The code default is still false, so a client that cannot reach GET /api/feature-flags hides the button |
| Storefront "Hats" catalog category | Built, hardcoded off | getHatsVisible() unconditionally returns false (store/lib/hats-visible.ts:22-24), independent of the storefront_hats_enabled DB flag |
Off (only path to a hat sale is the single After-Dark hat SKU, sold as an ordinary product page) |
| Hat Studio combo builder / blank-hat picker | Removed, not present in the current tree | N/A — deleted in commit 55cf2123 ("remove the hat builder, combo cart, and STL Zoo hat") |
N/A |
RevenueCat in-app purchase (Collection.productId unlock) |
Built end-to-end, unreachable | No flag — the UI trigger was deleted; map.tsx:388-402 explicitly documents this as "dead code left by the SQLite removal, tracked for a follow-up"; the sheet is now reachable only in the Screen mocks gallery, scout://dev-screen-mock/map?state=purchase |
Effectively off (no code path sets it on) |
storefront_curated_mvp ("Curated catalog (MVP)" — only 30 hand-picked patches shown) |
Shipped | Feature flag, defaultEnabled: true (backend/src/admin/feature-flag-definitions.ts:53-60) |
On |
hat_studio_combo (bundle cart line for hat+patch) |
Built, off | Feature flag, defaultEnabled: false (backend/src/admin/feature-flag-definitions.ts:61-67) |
Off — and the UI it would gate (Hat Studio) is deleted, so this flag currently has nothing to turn on |
| Customer reviews (Judge.me, rendered natively) | Live on product and collection pages, the catalog homepage, and as a star row on shelf/grid product cards (GalleryPiece's rating prop, fed once per page by getRatingsByProduct) |
JUDGEME_PRIVATE_TOKEN + JUDGEME_SHOP_DOMAIN; unset either and reviews silently disappear |
On since 2026-09-01. RatingSummary + ReviewsSection are mounted in PatchDetail.tsx and CollectionDetail.tsx, which the in-app /app/products/[handle] page reuses verbatim, so the app tree inherits both |
Review form (/review/<handle>) |
Live, unlisted — open through the coming-soon wall, noindex, nothing links to it |
JUDGEME_SHOP_DOMAIN only; the create endpoint needs no auth |
On since 2026-09-11. The only path to a review that reaches a product page: Judge.me's own requests degrade to a "Store review fallback" for our out-of-store catalogue. Reviews publish unverified and earn the verified badge if the reviewer confirms the email they ordered with |
User-facing surfaces
- Public shopfront (
store/app/(store)/…): home/catalog,/products/[handle],/collections,/collections/[slug],/store-exclusives,/cart,/my-patches,/account,/featured,/about,/how-to-iron,/shipping-returns. All walled (see below) whileSTORE_COMING_SOON=true. /launch— gone. The colour picker, angle strip and full-screen viewer it pioneered all live on/products/[handle]now; the scarcity bar and the direct-to-checkout claim button were dropped rather than ported./packs,/packs/[slug]: both now redirect (store/app/packs/page.tsx→/store-exclusives;store/app/packs/[slug]/page.tsx→/collections/<slug>). Packs are no longer a distinct concept — a "pack" is now just apublicPurchasecollection viewed at/collections/<handle>./open-patches: redirects to/featured(store/app/(store)/open-patches/page.tsx:5).- In-app store (
store/app/app/…, opened only inside the mobile WebView):/app(home — "Your Store"),/app/products/[handle],/app/collections/[slug]. Requires a signed-in session; redirects to/auth/sign-in?next=/appotherwise. - Mobile entry points into the WebView (all resolve to the deep link
scout://store-webview, screen filemobile/app/store-webview.tsx):- Drawer → Store row (
mobile/src/components/navigation/drawerSections.ts:301) - Patch action sheet's "Purchase Patch" button, gated by
buy_patch(mobile/src/components/patches-v2/PatchActionSheet.tsx:61-69, 97-100) - The unlock-celebration screen's purchase CTA, same gate
(
mobile/app/celebration.tsx:250-254decides it,:275-289navigates,:735-753is the CTA branch — the screen is now split intoCelebrationScreenViewModelImpland a pureCelebrationScreenLayout, andpurchasableis a view-model field) - Every one of these routes to the in-app store home (
/app), never a specific product page — the just-collected/just-tapped patch simply sorts first in the "Your patches" grid (collectedAt descinstore-my-patches.service.ts:22).
- Drawer → Store row (
/auth/*stays open too — required so a signed-out hit on the open/apptree can still complete sign-in (store/lib/coming-soon.ts:24-31).
How it works (end-to-end mechanism)
Catalog and checkout
Products live in Shopify; the storefront reads them via the Shopify
Storefront API only (never the Admin API for reads). Scout's own
Postgres (via the backend NestJS API) stores which patches exist, which
Collections they belong to, and each patch/collection's Shopify
productHandle. A product page cross-references the two: Shopify for
price/inventory/images, the backend for whether the item requires an
in-app unlock.
Checkout is Shopify's own hosted checkout, reached via cartCreate
(store/lib/shopify/cart-actions.ts:52-104). The Next.js app never handles
payment; it builds a cart with merchandiseId/quantity lines, attaches
scout_session_id and (if signed in) scout_user_id as cart attributes, and
redirects to the returned checkoutUrl — which resolves to Shopify's
primary domain, checkout.scout-patches.com
(backend/src/scripts/lib/shopify-publications.ts:7-9). Confirmed live: that
domain currently serves Shopify's default, unconfigured Liquid theme (a
generic "Welcome to our store" page with placeholder $19.99 products) —
consistent with the catalog being deliberately unpublished from it (see
Configuration below).
Order/webhook pipeline
Shopify calls POST /webhooks/shopify/:topic on orders/paid,
products/update, refunds/create. Every call is HMAC-verified
(x-shopify-hmac-sha256 against the raw body) and de-duplicated by
x-shopify-webhook-id before any handler runs
(backend/src/shopify-webhooks/shopify-webhooks.controller.ts:32-87). The
orders/paid handler reads the scout_session_id/scout_user_id cart
attributes back out of note_attributes, converts prices to cents, and
upserts a StoreOrder row keyed by Shopify's GraphQL order id
(backend/src/shopify-webhooks/handlers/orders-paid.handler.ts:39-70).
Earned-not-bought — mechanism 1: the per-product buy gate
Every patch is either requiresUnlock: true or false. The backend derives
this per patch: a patch requires unlocking unless it belongs to at least
one Collection with publicPurchase: true
(backend/src/store-collections/store-collections.service.ts:109-114). A
patch in zero collections also requires unlocking (empty-array .some() is
false, negated to true).
On the client, PatchBuyArea (store/components/store/PatchBuyArea.tsx:21-57)
calls isBuyable({ requiresUnlock, productHandle }, ownedHandles)
(store/lib/store/ownership.ts:11-16): buyable if the item doesn't require
unlock, OR the signed-in shopper's owned-handle set contains it. Ownership
comes from OwnedHandlesProvider, which fetches GET /api/owned-handles
(a thin Next.js route that forwards to the backend's
GET /api/store/my-patches, JWT-authed) on mount, on every route change, and
on a scout:auth-changed event
(store/components/store/OwnedHandlesProvider.tsx:51-88,
store/app/api/owned-handles/route.ts). Not buyable → LockedCallout
renders an "Earned, not bought" message instead of
a buy button (store/components/store/LockedCallout.tsx).
Earned-not-bought — mechanism 2: three-state section visibility
Independent of the per-product gate, the whole catalog's scope depends on
who's looking. storeUserState({ signedIn, hasCollected }) returns one of
'guest' | 'member' | 'collector' (store/lib/store/access.ts:13-19):
guest (signed out), member (signed in, owns 0 patches), collector (signed
in, owns ≥1 patch). CatalogPage uses this to decide whether to render the
buyable-only catalog (public-purchase patches only — used for
guest/member, because ~90% of the real catalog is locked and an unscoped
listing "reads as a store that won't sell anything",
store/components/store/CatalogPage.tsx:204-232) or the full field guide
(collectors, or anyone who explicitly opts in with ?show=all). Two
collector-only pages — /my-patches and /collections — are meant to be
gated the same way via canSeeCollectorSections()
(store/lib/store/access.ts:22-23), though at the time of this doc that
predicate has no call site outside its own test — it is defined but not yet
wired to a page guard.
Earned-not-bought — mechanism 3: the completed-set purchase gate
Every sellable collection also has its own Shopify product (product_type: 'Patch Collection') — a scout can buy the whole set as one line rather than
patch by patch. That product is gated exactly like an individual patch:
canBuySet({ patchCount, ownedMemberHandles, publicPurchase })
(backend/src/store-collections/set-purchase-gate.ts:12-21) allows the buy
only once ownedMemberHandles >= patchCount, with a publicPurchase
(store-exclusive) collection exempted outright since nobody earns those. The
predicate is implemented twice, byte-for-byte identically — backend
(set-purchase-gate.ts) and storefront (store/lib/store/set-gate.ts) —
because, per the per-patch gate's own posture above, neither side may trust
the other.
The denominator is the sellable members, not Collection.patchCount.
evaluateSetCompletion (store/lib/store/set-gate.ts:44-72) deliberately
measures against memberHandles.length — the members that actually have a
Shopify product to buy — never the collection's raw member count. Some
members have no artwork or no Shopify listing of their own; gating on the
raw count would make those collections permanently uncompletable. Measured
against production: Texas Hill Country has 11 members but only 4 are
sellable, so "complete" there means owning those 4, not all 11. This is a
real, deliberate narrowing of "earned, not bought" to "you own every member
we can actually sell you" — not "you own the whole collection."
The gate renders in CollectionBuyArea (store/components/store/CollectionBuyArea.tsx:47-77,
mounted from CollectionDetail.tsx:76-84, reused verbatim by the in-app
/app/products/[handle]//app/collections/[slug] trees): a locked
"Earned, not bought" callout naming how many sellable patches remain, or a
live AddToCartButton once the set is complete. The "Your sets" shelf
(store/components/store/SetShelf.tsx, via store/lib/store/set-shelf-buy-state.ts:24-30)
uses the same predicate to decide whether a shelf card offers "Order the
set" or shows a locked remaining-count.
A field called canBuyPublicly is also computed and typed on the backend's
ProductContext (store-collections.service.ts:71,413-417) — canBuySet
evaluated with zero known ownership, i.e. the most an unauthenticated,
publicly-cached endpoint can honestly say on its own. It is read nowhere
— no storefront consumer branches on it (verified: grep -rn canBuyPublicly backend store returns only declarations — the backend DTO,
the frontend ProductContext mirror at store/lib/store-catalog.ts:36 — its
own computation, and backend spec assertions; zero read sites under store/).
It is not live enforcement; a signed-in shopper's real
completion is re-evaluated entirely client-side against useOwnedHandles().
Which mechanisms fail open, and which fail closed
- The per-product gate now fails CLOSED — this changed in 2026-09 and the
old "defaults to buyable" behaviour is gone. If the backend's per-product
context call fails, times out, returns a shape we cannot read, or answers
unknown,patchProductGate(store/lib/store/collection-page-guard.ts) reportsrequiresUnlock: trueand the patch shows its locked state. All three patch product pages —/products/[handle],/app/products/[handle]and every product page — read that one function. It costs availability during a backend outage (a genuinely store-exclusive patch reads as locked), butisBuyablestill passes a shopper who owns the patch, so what an outage withholds is exactly the sale we cannot verify is allowed. - If the "has this shopper collected anything" check hits a backend blip,
the shopper is treated as a collector (assume-access) rather than
bounced to the smaller buyable-only catalog
(
store/lib/store/user-state.server.ts:14-17, 28, 34, 41;store/lib/store/collected.ts:21-23, which explicitly calls this "the safe direction — a collector briefly sees a smaller store, rather than a guest hitting a wall of locked products"). - The
/auth/nativesilent-login bridge also fails open: a missing or invalid token still redirects to the target page, just signed out (store/app/(store)/auth/native/page.tsx:15-16). - Mechanism 3 (the set gate) is the opposite: it fails closed.
CollectionBuyAreatreatshandles === null(signed-out or still loading) as owning nothing, so a non-publicPurchaseset stays locked until ownership actually resolves (CollectionBuyArea.tsx:44-51) — a transient blip here hides a legitimate buy button rather than exposing an unearned one, the inverse trade-off from mechanisms 1 and 2 above.
The coming-soon wall
store/proxy.ts (Next.js middleware) checks every request before anything
else. If STORE_COMING_SOON === 'true' and the path isn't in an explicit
allowlist (OPEN_PREFIXES = ['/app', '/auth', '/api', '/gear', '/display', '/field-records', '/review'], plus static asset requests —
store/lib/coming-soon.ts) and no valid ?preview=<token> cookie/param is
present, it rewrites (not redirects) the request to /coming-soon and
sets X-Robots-Tag: noindex, nofollow (store/proxy.ts:60-71,
store/lib/coming-soon.ts). Rewrite-not-redirect means the URL a shopper or
an ad landed on stays in the address bar, so the same link works unchanged
the moment the wall comes down. ?preview=<STORE_PREVIEW_TOKEN> grants a
30-day bypass cookie for internal browsing (store/proxy.ts:54-58, 128-140).
Collecting reviews: the store-review fallback, and the way around it
Judge.me schedules a review request off Shopify's Fulfilled trigger, on by
default since install, 14 days after fulfillment. For our catalogue every one of
those requests is marked "Store review fallback" in Judge.me's Review
requests dashboard — verified live 2026-09-11 for orders #1003, #1004 and #1005.
The cause is the same in_store: false that 404s Judge.me's widgets: our
products are not published to Shopify's Online Store channel, so Judge.me will
not ask a buyer about a product, only about the shop.
That matters because a shop-level review never reaches a product page.
getProductReviews filters by Judge.me's product id and summarizeByProduct
skips any review without a product_external_id (store/lib/judgeme-shape.ts),
so a store review appears only in the catalog homepage's getStoreReviews
list — never on a product page, never in a shelf star row.
POST /api/v1/reviews has no such restriction. It takes the bare Shopify
product id, and Judge.me's own spec says "if blank, the review is considered as
a shop-level review" — so supplying it is the whole trick. /review/<handle>
(store/app/(standalone)/review/[handle]/page.tsx) resolves the handle through
the existing getProductDetail, hands the product's gid to ReviewForm, and
the form posts to /api/review. lib/judgeme-submit.ts validates, drops honeypot
hits with a 202 that looks like success, rate-limits, and sends the create with
id set. Submitted reviews auto-publish (autopublish: true on the account),
so they are live on the product page immediately.
The page sits in the (standalone) route group rather than (store), and that
is load-bearing rather than tidiness. (store)'s Header and Footer are the
shopfront's, so while the coming-soon wall is up they advertise routes the wall
hides: Collections and Store Exclusives on the nav, and almost every footer
link — All patches, Route 66, NFL Stadiums, About, How to iron on, Track an
order — answer "The shop is in the app". A buyer who is handed this link by name
got a review form wrapped in dead ends. Nested layouts COMPOSE in the App
Router, so no layout under (store)/review could have dropped that chrome;
leaving the group is the only mechanism, which is the same reasoning
/coming-soon records for sitting outside it as a top-level route.
app/(standalone)/layout.tsx renders a bar with the Scout wordmark and nothing
else, and no footer. The wordmark is deliberately not a link: / is the
first route the wall hides, so linking it would make the one remaining piece of
chrome another dead end. The route group's parentheses keep the URL identical,
so every review link already sent still resolves. store/lib/standalone-routes.test.ts
fails the build if the page moves back under (store), if the layout grows a
Header or Footer, or if the wordmark gains an href.
The honeypot that ate real reviews (fixed 2026-09-14)
judgeme-submit.ts drops any submission whose hidden field is filled and
answers 202, which the route maps to the same {ok:true} a success gets —
deliberately, so a bot learns nothing. The cost of that design is that a false
positive is indistinguishable from success: the customer sees "Thank you —
that means a lot", and the review is discarded.
The field was name="website". Password managers (1Password, LastPass,
Dashlane, Bitwarden) autofill by field NAME and do not honour hidden or
aria-hidden, and website sits squarely in their heuristics. At least two
real reviews were lost this way, with no server-side record — the drop was
silent in both directions, which is why nothing could be found afterwards.
Three changes, because the first alone would not have been noticed:
- The name is now
HONEYPOT_FIELD(ref_token), exported fromjudgeme-submit.tsand imported byReviewFormso the two cannot drift — a renamed input with the server reading the old key is a trap that never fires, and the reverse drops everything. - The input carries the documented per-manager opt-outs:
data-1p-ignore,data-lpignore,data-bwignore,data-form-type="other". - A trip is now
console.warned with the reviewer's email, so a legitimate reviewer who still trips it can be found and asked again instead of lost.
judgeme-submit.test.ts guards the name against a list of autofill-magnet
field names, asserts the form and server share the constant, asserts the
opt-out attributes are present, and — the paired opposite — that a submission
leaving the field empty, or omitting it entirely, still posts.
Re-publishing the catalogue to the Online Store channel would also fix the
fallback and is not an option: resolvePublicationIds() throws if the set
contains a browsable publication, prod carries no SHOPIFY_PUBLICATION_IDS at
all, and the whole arrangement is deliberate (see "Does not use the Shopify
Online Store or Shop channel to sell anything").
In-app WebView + secure session handoff
The mobile app never embeds store credentials directly; it hands the WebView a fresh pair of its own tokens and lets the store's own bridge adopt them.
buildStorePathUrl(path, session)(mobile/src/config/store.ts:151-170) builds the target URL. With a session, it goes to${storeUrl}/auth/native#accessToken=…&refreshToken=…&next=<target>— the fragment, never the query string, so the tokens never reach a server access log (mobile/src/config/store.ts:143-149). Without a session, it links straight to the (public) target path.store/app/(store)/auth/native/page.tsx(client component) reads the fragment,POSTs{accessToken, refreshToken}to/api/auth/native, then wipes the fragment from browser history (window.history.replaceState) before navigating tonext— so a screenshot, a shared link, or the WebView's own history never retains the tokens (store/app/(store)/auth/native/page.tsx:39-43).POST /api/auth/native(store/app/api/auth/native/route.ts) is the one BFF endpoint that trusts a client-supplied token rather than one it minted itself, so it re-verifies the access token against the auth engine's JWKS before adopting it; if the access token is stale it falls back to the refresh token via a server-to-server call (store/app/api/auth/native/route.ts:39-59). It also checkssec-fetch-siteto reject a cross-site POST that would fixate a session (lines 25-28). On success it sets first-party httpOnly session cookies (scout_at/scout_rt) viasetAuthCookies.- Fails open: a missing/invalid token pair still finishes the redirect
— the shopper just lands signed out rather than stuck on a spinner
(
store/app/(store)/auth/native/page.tsx:15-16; backend route returns 401 rather than throwing). - Every mobile → store link is prefixed into the
/apptree bystoreHref(), which is idempotent and boundary-safe (/apparelis not treated as inside/appjust because it starts with the same four characters) (mobile/src/config/store.ts:126-138).
Data model (Prisma, backend/prisma/schema.prisma)
Collection(line 36) — a themed group of patches. Fields relevant here:productId(RevenueCat/App-Store SKU — the unused IAP path),productHandle(Shopify handle — used when the collection itself is sold as a "collection product" page),priceUsd,storeVisible,adminOnly,publicPurchase(true = "Store Exclusive", buyable without earning it),hatStyle, relations topatches(PatchCollection) andpurchases(UserPurchase, the RevenueCat unlock records).PatchCollection(line 247) — join table, patch ↔ collection membership;deriveRequiresUnlockwalks this to decide gating.Patch— carries its ownproductHandle(line ~55 onward; the field that both/api/store/my-patchesand the catalog gate key off).UserPurchase(line 903) —{userId, collectionId, productId, purchasedAt}, unique on(userId, collectionId). Written only byPurchasesService.verifyPurchase/migratePurchasesafter a successful RevenueCat verification. This is the RevenueCat/IAP table, unrelated to Shopify orders.StoreOrder(line 1154) — one row per Shopify order (id= Shopify's GraphQL order id),totalCents,refundedCents,feeCents(processing fees),lineItemsJson, fullshopifyPayload, optionaluserId/sessionIdattribution. Net revenue =totalCents - refundedCents - feeCents.StoreExpense(line 1182) /StoreManualSale(line 1199) — admin-entered expenses and off-Shopify sales, both feeding the finances dashboard alongsideStoreOrder.WebhookDelivery(line ~1195) — dedup ledger keyed by Shopify's delivery id.StoreFeatureFlag(line 1109) — DB-backed store-specific flags (separate table from the mobile/adminFeatureFlagused forbuy_patchetc.).StoreAnalyticsEvent(line 1133) — storefront funnel events (page views, add-to-cart, search, checkout) keyed by session/user.HatFavorite(line 1225) — saved patch+hat-color combos for the internal Hat Studio design tool, explicitly not user-scoped ("/hatshas no auth and is an internal design tool" — schema comment above the model).WaitlistEntry—{productHandle, email}, unique pair. ORPHANED as of 2026-09-17: the waitlist feature was removed (both the sold-out restock form and the app-launch list). Nothing reads or writes this table any more; it andWaitlistSignupare retained only so the rows stay recoverable, and are scheduled to be dropped in a later migration. Data exported tomarketing/exports/2026-09-17-*.csvbefore removal.AppConfig(line 1121) — generic key/value store; the landing-config row it holds (appStoreUrl,playStoreUrl, shop URL) is whatstore/lib/app-links.tsreads instead of hardcoding store-related links.
API surface
All under the backend NestJS app unless noted.
| Method & path | Auth | Purpose |
|---|---|---|
GET /api/store/patches, GET /api/store/patches/public |
none | Catalog patch listings (store-collections/store-patches.controller.ts) |
GET /api/store/catalog/:handle |
none (cached 5 min) | Per-product requiresUnlock + collection context (store-catalog.controller.ts) |
GET /api/store/collections, GET /api/store/collections/:handle |
none | Collection listing/detail |
GET /api/store/inventory-visibility |
none | Which handles are visible under limited_inventory_mode |
GET /api/store/my-patches |
JWT | The signed-in shopper's owned product handles — the ownership source of truth for both gate mechanisms (store-my-patches.controller.ts:15-18) |
GET /api/store/orders, GET /api/store/orders/:id |
JWT | A shopper's own order history |
POST /api/store/analytics |
rate-limited (ThrottlerGuard), no auth |
Storefront funnel event ingestion |
| `GET /api/admin/store/analytics/funnel | products | searches` |
GET/POST/PATCH/DELETE /api/admin/store-finances/* |
AdminGuard | Revenue/expense/manual-sale admin CRUD + Shopify sync |
GET /api/store/hat-favorites |
none | List saved Hat Studio combos |
POST/DELETE /api/store/hat-favorites |
StorefrontTokenGuard | Mutate Hat Studio combos (internal-tool token, not a user session) |
GET /api/store/feature-flags |
none | Public read of store feature flags |
PUT /api/admin/store/feature-flags/:key |
AdminGuard | Toggle a store flag |
POST /webhooks/shopify/:topic |
Shopify HMAC signature | orders/paid, products/update, refunds/create |
POST /purchases/verify |
JWT | Verify a RevenueCat receipt, write UserPurchase (currently unreachable from any UI) |
GET /purchases |
JWT | List a user's UserPurchase rows |
POST /purchases/migrate |
JWT | Move guest-device RevenueCat purchases onto a newly signed-in account |
Next.js BFF routes (store/app/api/**), all internal to the storefront
itself: POST /api/auth/native (see handoff above), GET /api/auth/me,
GET /api/owned-handles, GET /api/dev/* (dev-only), GET /api/analytics/*.
Key files
store/proxy.ts— coming-soon wall + auth-cookie refresh + discount-code capture, runs before every request.store/lib/coming-soon.ts— pure allowlist logic for the wall (isOpenPath,isComingSoonEnabled,grantsPreview).store/app/(store)/review/[handle]/page.tsx— the per-product review page. Resolves the handle withgetProductDetailand 404s on an unknown one; setsrobots: noindex, nofollowbecause the link is handed to one buyer.store/components/store/reviews/ReviewForm.tsx— the client form. TakesproductIdas a prop so the browser never picks which product it is reviewing; carries the hiddenwebsitehoneypot.store/app/api/review/route.ts— the submit endpoint. A route handler rather than a server action deliberately: server actions all arrive at one Next endpoint, which defeats per-route IP throttling. Reads the caller fromx-nf-client-connection-ip(Netlify's edge sets it;x-forwarded-foris a spoofable dev fallback).store/lib/judgeme-submit.ts— validation, honeypot, rate limit and the Judge.me create call. Framework-free so the node test runner (lib/**only) can cover it. NoteproductExternalIdis deliberately stricter thanshopifyNumericId: aProductVariantgid must be rejected, not posted as though it were a product.store/lib/rate-limit.ts— in-memory sliding window. Per-instance on Netlify, so it is a floor and not a guarantee; see known limits.store/app/coming-soon/page.tsx— the wall page itself; outside the(store)route group so it carries no site header/footer. Its copy routes the visitor to the in-app store (App Store / Play buttons) plus the one open an open product page, never to a walled path.— deleted 2026-09-16. Was the one open, buyable page; fully env-driven (store/app/launch/page.tsx,store/lib/launch.tsgetLaunchConfig()).store/lib/store/ownership.ts,store/lib/store/access.ts— the two gate predicates (isBuyable,storeUserState/canSeeCollectorSections).store/components/store/PatchBuyArea.tsx,LockedCallout.tsx,OwnedHandlesProvider.tsx— per-product gate UI + client ownership state.store/components/store/CatalogPage.tsx— three-state section-visibility scoping of the catalog.store/lib/store/collected.ts,store/lib/store/user-state.server.ts— server-side fail-open reads of ownership/collector status.store/app/(store)/auth/native/page.tsx,store/app/api/auth/native/route.ts— the silent-login bridge (fragment read/wipe, token re-verification, cookie exchange).mobile/src/config/store.ts—buildStorePathUrl,storeHref, store-URL resolution for the WebView.mobile/app/store-webview.tsx— the WebView screen itself.mobile/src/domain/purchaseGate.ts—shouldOfferPurchase, the single source of truth for thebuy_patch-gated Purchase button, shared by the action sheet and celebration screen.mobile/src/components/patches-v2/PatchActionSheet.tsx,mobile/app/celebration.tsx— the two call sites of that gate.backend/src/store-collections/store-collections.service.ts—deriveRequiresUnlock, the backend source of truth for the per-product gate.backend/src/scripts/lib/shopify-publications.ts,backend/src/scripts/unpublish-shopify-channel.ts— headless-only enforcement (assertHeadlessOnly) and the one-off script that stripped the catalog from the Online Store/Shop publications.backend/src/shopify-webhooks/shopify-webhooks.controller.ts+handlers/orders-paid.handler.ts— HMAC-verified, dedup'd webhook intake.backend/src/purchases/purchases.service.ts,backend/src/revenuecat/revenuecat.service.ts,mobile/src/providers/PurchaseProvider.tsx,mobile/src/components/purchases/PurchaseSheet.tsx— the fully-built, currently-unreachable RevenueCat IAP pipeline.mobile/app/(drawer)/map.tsx:388-402, 1374-1392— the comment and code proving the IAP trigger is dead.MapScreenViewModel.purchaseCollection/.welcomeCollection(map.tsx:337-351) are the two fields nothing sets.mobile/src/dev/mocks/map.tsx— thepurchaseandwelcomestates, the only way left to look at the sheet. They show it quoting the hardcoded $0.99 against a real collection whoseproductIdis now a Shopify id.store/lib/hats-visible.ts— hats catalog category hardcoded off; the only hat sold today is the single After-Dark SKU on its own product page.store/lib/collection-pricing.ts— the "$5/patch in a set" arithmetic (collectionBundlePrice) that both the UI display and, mirrored intobackend/src/scripts/lib/bundle-pricing.ts, the Shopify repricing script render from. No cart/checkout discount logic reads it directly — it prices a real product (the collection's own), not a cart-level discount.backend/src/store-collections/set-purchase-gate.ts,store/lib/store/set-gate.ts—canBuySet/evaluateSetCompletion, the completed-set purchase gate, implemented identically on both sides.store/lib/store/collection-page-guard.ts—collectionPageDecision,collectionProductGateandpatchProductGate: the one place every product page turns aProductContextinto what it renders and whether it may offer a buy affordance.patchProductGateis read by all three patch pages (/products/[handle],/app/products/[handle]) and fails closed onrequiresUnlock. Both gates also accept a nullish context:getProductContextends in an uncheckedas ProductContextcast, so a backend body of literalnullreaches a page despite the type — it is treated asunavailable.collectionProductGate: the one place both collection product pages (store/app/(store)/products/[handle]/page.tsxandstore/app/app/products/[handle]/page.tsx) turn aProductContextinto a render/refuse decision and apublicPurchaseflag. Refuses onunknown, renders degraded onunavailable, and fails CLOSED onpublicPurchasefor every non-collectioncontext — see the fifth door in "Edge cases".backend/src/scripts/sync-collection-bundle-pricing.ts,backend/src/scripts/lib/bundle-pricing-plan.ts,backend/src/scripts/lib/bundle-pricing.ts— reprices every sellable collection's Shopify product topatchCount × $5; defaults to--dry-run. The whole decision (classification, the sellable filter, the diff, the drift buckets, the--maxceiling) isbuildBundlePricingPlan, a pure tested function; the script is glue around it.backend/src/scripts/audit-collection-catalog.ts,backend/src/scripts/prune-collection-products.ts,backend/src/scripts/lib/prune-plan.ts,backend/src/scripts/lib/catalog-audit.ts— read-only catalog classification (delete/price/keep-orphan/protected/skip-pack) and the (unrun) script that deletes admin-only collection products.buildPrunePlanselects and runs both guards;assertSelectionIsDeletablere-derives the delete precondition fromproductType+ the DB row rather than from the classification pass, so it is a real second opinion.backend/src/scripts/lib/catalog-audit.ts'sCOLLECTION_SELECT— the one Prisma select all four catalog scripts read collections with. It countspatch_collectionsrows (_count.patches) and deliberately does NOT selectcollections.patch_count.backend/src/scripts/lib/script-args.ts— the shared--apply/--dry-run/--maxparser. Fails closed: unknown tokens are fatal, and the prune's--maxis bounded above by a hard ceiling of 100.backend/src/scripts/lib/create-collection-products.ts,backend/src/scripts/create-collection-products.ts— creates the four missing collection products (Great River Road, Overseas Highway, Texas Hill Country, Natchez Trace Parkway); not yet run.createCollectionProductowns the ordering: create as DRAFT → write the DB pointer → set the price → flip to ACTIVE.
Configuration and flags
STORE_COMING_SOON(store env, exact'true'required) — the wall. Confirmed on in production by live check.STORE_PREVIEW_TOKEN— bypass secret for?preview=; unset means nobody bypasses (grantsPreviewreturnsfalseon an empty secret,store/lib/coming-soon.ts:88-91).— all removed with the drop page on 2026-09-16. Deleted fromLAUNCH_VARIANT_ID,LAUNCH_CAP,LAUNCH_CAPS,LAUNCH_PRODUCT_HANDLEstore/.env; still set in Netlify, where they are now inert and should be cleaned up. The notes below are kept only as the record of what they meant.LAUNCH_VARIANT_IDpins the page to one variant. With a multi-colour drop it should be UNSET, sopickLaunchVariantopens on the first colourway in stock instead of a pinned one that may have sold out. A pinned id that is not on the product is ignored, never fatal.LAUNCH_CAPSis the per-colourway run size —"Tan:8,White:2,Black:0". Shopify knows current stock but never how many were MADE, which is the denominator of "3 of 8 claimed"; one sharedLAUNCH_CAPacross three colours would make a 2-unit colour read "6 of 8 claimed" on day one. A colourwayLAUNCH_CAPSdoes not name falls back toLAUNCH_CAP. An explicit0is honoured as a real cap, not treated as unset, and suppresses the bar entirely rather than drawing "0 of 0".
buy_patch— mobile feature flag,defaultEnabled: falsein both the mobile and backend registries, but enabled in production: the DB row wins over the registry default (verified live on 2026-09-13 viaGET https://scout-patches.com/api/feature-flags). Gates the "Purchase Patch" button on the patch action sheet and the celebration screen (both viashouldOfferPurchase). Note what it does and does not do — it routes to the store WebView (scout://store-webview), it does not add an in-app purchase. The hardcoded-off default still matters as the fallback a client uses when it cannot reachGET /api/feature-flags.storefront_curated_mvp— default on; storefront shows only ~30 hand-curated patches (limited_inventory_modeinCatalogPage.tsx).hat_studio_combo— default off; the UI it gated (combo cart line) no longer exists in the codebase.storefront_hats_enabled— exists as a DB-backed flag withdefaultEnabled: falseand an "admin always sees hats" carve-out described in its own description, but the actual code path that renders the Hats catalog category (getHatsVisible()) ignores it and hardcodesfalse(store/lib/hats-visible.ts:22-24) — this flag currently has no effect on that specific surface. Flipping it alone would not surface hats even if it were wired up, because every Trail Hat product carries Shopify'sinternaltag and every catalog query excludes-tag:internal.- Shopify publication scoping —
SHOPIFY_PUBLICATION_IDS(env) is asserted at startup by every publish/sync script to never include the Online Store or Shop publication GIDs (assertHeadlessOnly,backend/src/scripts/lib/shopify-publications.ts:89-102), so the catalog cannot accidentally leak a browsable, ungated product page again. ADMIN_EMAILS(store env, comma-separated) — mirrors the backend's admin allowlist; bypasses some storefront gates for QA (e.g. seeing hats even while the category is off).- RevenueCat API keys are hardcoded in
mobile/src/services/revenuecat.ts(not env vars): a real iOS production key (appl_…), and an Android key left as the literal placeholder string'goog_PRODUCTION_KEY_HERE'— Android in-app purchasing was never actually configured. Comment in that file: "TODO: ONLY SUPPORTING IOS AT LAUNCH." JUDGEME_PRIVATE_TOKEN(store env, server-only) — Judge.me API credential. Deliberately notNEXT_PUBLIC_*: Next inlines those into the client bundle, which would publish a private API token to every visitor. Judge.me's public token answers403on/reviews, so there is no client-side path at all. Stored as a Netlify secret.JUDGEME_SHOP_DOMAIN—mjhd81-vs.myshopify.com. Unset either var andlib/judgeme.tslogs once and returns an empty summary: product pages render with no rating row and no reviews section, and nothing errors (verified by booting the store with the token removed).- The former
NEXT_PUBLIC_SENJA_*andNEXT_PUBLIC_GOOGLE_REVIEW_URLvars are gone — unset in Netlify and removed from.env.example.
Edge cases and known limits
- The review form's rate limit is a floor, not a guarantee. The limiter is
in-memory (
store/lib/rate-limit.ts), so on Netlify each serverless instance keeps its own counts and a caller spread across cold starts gets more than three per hour. It stops an opportunistic bot that finds a public endpoint, which is the actual threat for a store doing single-digit orders a month; it is not a defence against a determined attacker. Shared storage is the upgrade path if that ever changes. - A spoofed
x-forwarded-forcosts a bot only its own bucket. Netlify'sx-nf-client-connection-ipis preferred and cannot be forged by the client; the header fallback exists for local dev. - Submitted reviews auto-publish.
autopublishistrueon the Judge.me account, so anything that clears validation is live on the product page immediately, with no moderation step. The honeypot and rate limit are the only gates. Turning auto-publish off is a global setting and would also hold reviews that arrive from Judge.me's own request emails. - Web reviews are NOT restricted on the account — verified end to end
2026-09-11. A real submission through the form created review
1329793214withproduct_external_id: 15842687090848(the Compass Cap) andsource: "web", and it rendered on that product's page as "5.0 · 1 review". This is the proof that the write path escapes the store-review fallback: the same catalogue Judge.me refuses to request a product review for accepts a product-attached one throughPOST /reviews. (Worth knowing because the endpoint fails silently if a store does disable web reviews — a 200 with nothing created — so a 200 alone never proves a review exists. Check/reviews/count.) - A created review takes a few seconds to become queryable by product.
Judge.me's spec says it creates "in background", and that is literal: the
review appeared in
/reviewsimmediately but the product page still rendered empty on the next request, then showed it on a reload. Nothing in our code is wrong when a just-submitted review is briefly absent — andgetProductReviewscaches forREVALIDATE_SECONDS(300) on top of that. - Judge.me emails the reviewer a confirmation, and the admin a notification.
Submitting through the form triggers a "Please confirm your review" email to
the reviewer (the badge hinges on that click) and a "left a N star review"
email to
admin_email. Neither is something our code sends or can suppress from this side — the admin one is switched off under Settings → Review Request → Admin Notifications. Worth knowing before a campaign: every submission puts two emails in flight. - The reviewer name we send is overridden by an existing reviewer record.
The test posted
name: "Scout Test"with a known email and Judge.me published it as "Alan Sax" — it matches on email and reuses that reviewer's stored name. So the displayed name comes from Judge.me's reviewer record, not from our form, for anyone who has reviewed or been imported before. - A backend outage during catalog-context fetch used to make a locked patch
look buyable; it no longer does. That fail-open default (
requiresUnlockdefaulting tofalsefor every non-patchcontext) was the patch-shaped twin of the fifth door below, and it was duplicated across all three patch product pages. They now sharepatchProductGate, which fails closed. Checkout still has no independent server-side re-check ofrequiresUnlockbefore completing an order — the gate remains UI-only, so this narrows the window rather than closing the class. canSeeCollectorSections()(the three-state gate's collector-only rule for/my-patchesand/collections) exists and is tested, but has no call site outside its own test at the time of this doc — those pages may not currently be enforcing collector-only visibility server-side.- The "buy the whole set" price is a real Shopify product price now, not
just display math — but production has not been repriced yet. Every
sellable collection's own product (
product_type: 'Patch Collection') can be repriced topatchCount × $5bybackend/src/scripts/sync-collection-bundle-pricing.ts, which readsPATCH_PRICE_USD/COLLECTION_PATCH_PRICE_USDmirrored fromstore/lib/collection-pricing.ts(drift between the two is caught by a test that reads the storefront file as text,backend/src/scripts/lib/bundle-pricing.spec.ts). APPLIED to production on 2026-09-02: 197 products repriced, 0 failed. A second run reports 0 updates, confirming idempotency. Before that run, production held every collection product at a flat $8.00 regardless of set size. The set size is counted, not read fromcollections.patch_count. That column is a cache refreshed only bysyncPatchCount, called from two admin member-mutation endpoints (backend/src/admin/api/collections-api.controller.ts); seed scripts, content publish and direct SQL all leave it behind. Eight store-visible collections were stale on it — Muffler Men (stored 14, actual 12), O'Fallon Illinois (12/10), The Western Theater (15/14), Aliens & Cryptids (12/11), Haunted Grounds (8/7), The Southern Campaign (8/7), Colonial Foundations (8/7), New England & New York (13/12) — every one stale HIGH, so pricing off the column would have overcharged those eight sets by $5-$10 each, ~$50 total. Both the repricer andcreate-collection-products.tsnow price off_count.patches(COLLECTION_SELECT). Until someone runs it with--apply, the set-detail page's "Full set value" callout (CollectionSetOffer.tsx) shows the correct $5/patch math while the product it is describing still checks out at $8 flat. - Adding patches to cart one at a time never gets a bundle discount, by
design, even after the bundle product is repriced. The $5/patch price
lives entirely on the collection's own product/variant — there is no
cart-level discount code or line-item override. A shopper who adds every
member patch individually still pays each patch's real $8 Shopify variant
price; only buying the collection's own bundle product (gated by
canBuySet, see "How it works" above) gets the $5/patch price. - Two residual gaps, by decision, not oversight. (1) The 14
publicPurchase"Store Exclusive" pack products are explicitly excluded from repricing —sync-collection-bundle-pricing.ts's sellable filter isstore_visible AND NOT admin_only AND NOT public_purchase— and stay at a flat $8 regardless of size. (2)Four sellable collections have no Shopify product— resolved 2026-09-02. Great River Road, Overseas Highway, Texas Hill Country and Natchez Trace Parkway (allRoad Tripscampaign members, 11 patches each) were created at $55 bybackend/src/scripts/create-collection-products.ts. Natchez Trace needed the handlenatchez-trace-parkway-1: the patch product of that name already holds the bare handle, exactly asblue-ridge-parkwaydoes — which is why the Blue Ridge collection product isblue-ridge-parkway-1. The sellable count is now 201, all priced. - The generator that caused the $8 mispricing is itself still unfixed.
backend/src/scripts/seed-shopify.ts:32creates a new collection's Shopify product at a flatSTORE_DEFAULT_PRICE_CENTS ?? '800'regardless of set size.sync-collection-bundle-pricing.tscorrects today's catalog; it does not stop a newly-seeded collection from being mispriced the same way tomorrow. Re-running the pricing sync after every new collection is seeded is a manual step, not automatic. - The in-app store used to leak admin-only collections as buyable sets —
now closed, five separate doors.
StoreCollectionsService.getCollectionsonly returnsadminOnlyas a field for callers to decide on (store-collections.service.ts:195-238, filters onlystoreVisible: trueat:203); before this work, four different consumers failed to check it: the in-app shelf filter, the in-app id fallback,getCollectionDetail's handle/id lookup, andgetProductContext. Verified live before the fix:shop.scout-patches.com/app/products/national-parksserved a 91-patch admin-only collection at $8 with a working Add to Cart, unauthenticated. All four are now fixed: the shelf/id-fallback rule lives instore/lib/store/app-earnable-collections.ts;getProductContextitself now refuses at the source (store-collections.service.ts:384-393). On the storefront,store/lib/store-catalog.ts'sProductContexttype splits a genuine "not this app's to show" backend answer (kind: 'unknown') from a fetch failure/timeout/unset-config (kind: 'unavailable') — collapsing the two would 404 a real pack page on a transient blip, an availability regression introduced while fixing a security one./app/products/national-parksnow 404s; confirmed in a browser. The fifth door was the backend fix's own side effect, and stayed open longest. OncegetProductContextstarted filtering, an admin-only collection came back askind: 'unknown'rather than askind: 'collection', publicPurchase: false— and both product pages derived the set gate with a ternary that defaulted the other branch totrue(publicPurchase={context.kind === 'collection' ? context.publicPurchase : true}).canBuySetshort-circuitstrueon that flag (store/lib/store/set-gate.ts), so the very collections the filter had just started refusing were the ones whose earned-not-bought gate it switched off. The in-app page happened to survive it becausecollectionPageDecision404s first; the public/products/[handle]page never got that guard, so it rendered an unauthenticated $8 Add-to-cart for the 91-patchnational-parksset. The same default also let akind: 'unavailable'(backend unreachable, or the 2.5sAbortControllertimeout instore-catalog.ts) produce buyable HTML — and the public page isexport const revalidate = 300, so one slow backend window served it from cache for five minutes after recovery. Both pages now share one wiring function,collectionProductGate(store/lib/store/collection-page-guard.ts), which refuses onunknownand fails CLOSED onpublicPurchasefor every non-collectioncontext.unavailablestill renders degraded rather than refusing — only the buy affordance is withheld — so a transient blip does not 404 real pack pages. AutoPurchase(?autocart=1on a product page, auto-adds to cart and opens the drawer) exists in the storefront but the current mobile "Purchase Patch" flow no longer generates that URL — it always routes to the in-app store home, not a specific product page — so this mechanism is effectively orphaned from the mobile side today (still reachable if something else links a product page with that query param).CollectionLanding.tsx(a full pack-landing page component, with its own hat-grid resolution) has no importer anywhere understore/app/— it is dead code left over from before/packs/[slug]became a redirect.GET /api/owned-handlesreturns[]with a 200, not 401, whenBACKEND_API_URLis unset — so a misconfigured deployment would read as "signed in, owns nothing" rather than surfacing the misconfiguration.- Guest (not-signed-in) RevenueCat purchases would be stored only in local
Zustand state and migrated server-side on sign-in; the migration path
explicitly never wholesale-clears guest records on partial failure
(see the long comment in
PurchaseProvider.tsx:284-334) — but since no UI can start a purchase, this logic is currently unexercised by real users. - The coming-soon wall is a rewrite at the Next.js middleware layer, not an
auth check — a determined visitor can still reach any walled Server Action
or API route directly if they know its shape; only the page-level
navigation is walled. (
/apiitself is inOPEN_PREFIXES.)
What this feature does NOT do
-
Does not run any in-app purchase or subscription today. The RevenueCat pipeline is real, production-configured code (iOS key present, backend verification live,
UserPurchasemodel,PurchaseSheetUI) but has no reachable entry point in the current app. No StoreKit/Play Billing subscription of any kind exists in this codebase. -
Does not sell patches or packs on the public web. What the public web sells is apparel: the
shop-tagged merch catalogue (docs/features/merch-storefront.md). The full patch catalog, Store Exclusives and collections stay walled behind coming-soon. -
The colour picker does not create per-colour URLs. There is one page per product handle; the selected colourway is client state, not a query param or a route, so a link to "the white one" cannot be shared and an ad cannot deep-link a colour.
-
Nothing redirects
/launch. Every link to it 404s, by decision rather than oversight — see the Summary. -
There is no scarcity counter anywhere any more. It died with the drop page; no product page shows "N of M claimed".
-
Does not apply a bundle discount at the cart/checkout level, ever. The $5/patch price lives only on the collection's own product; buying member patches individually always costs $8 each, with or without the repricing script having run. There is no discount code and no cart-line override.
-
Has not yet repriced production.
sync-collection-bundle-pricing.tsexists, is tested, and its dry run is clean. All three catalog scripts (create, reprice, prune) WERE run against production Shopify with--applyon 2026-09-02: 4 products created, 197 repriced, 20 deleted, 0 failures. A re-audit afterwards reports 0 remaining deletions and 201 correctly priced sellable collections. The capability is no longer merely built — the data has changed. See "Edge cases" above. -
The 4 newly created collection products are not published to any sales channel. Great River Road, Overseas Highway, Texas Hill Country and Natchez Trace Parkway now exist (created 2026-09-02, $55 each), but
create-collection-products.tsdoes not publish what it creates to any Shopify publication — a new product is invisible to the headless storefront until someone publishes it to "Scout Headless" (npm run script:publish-shopify, or by hand). Deliberate: an unpublished product cannot be bought by accident. Both halves are now done as of 2026-09-02: all four were published to Scout Headless — and only to Scout Headless, verified viaresourcePublicationsV2, so the headless-only posture is intact. The publish script gained an--only <ids>flag for this; its default is blanket (every patch and collection, ~2,400 rows), which is the right tool after a bulk import and the wrong one for four products. -
The storefront's displayed set size and struck-through total read
Collection.patchCount, and on production that column is trustworthy. It is a derived cache ofpatch_collections, and it is deliberately EXCLUDED from the publish payload (content-entities.ts) — carrying a derived value would make it "a second, staler source". InsteadrecomputePatchCountsrewrites it globally on the receiving side inside every publish apply (publish-apply.service.ts), so production self-heals and the displayed size agrees with what the repriced Shopify product charges. Verified 2026-09-02 against the live API for the eight collections that had drifted locally: all eight matched membership exactly.The side that drifts is the LOCAL authoring database, which has no such moment —
syncPatchCountis a private method oncollections-api.controller.tsreachable only from two admin member-mutation endpoints, so seed scripts, direct SQL and adb:pull-prod-plus-edits all leave it behind. 16 rows were stale locally on 2026-09-02, repaired bysrc/scripts/sync-local-patch-counts.ts, which runs the same statement the publish apply runs (imported, not copied). The catalog scripts are unaffected either way: they count live membership precisely because this column cannot be trusted on the authoring side.A correction to an earlier revision of this doc, which claimed eight collections quote a total above what checkout charges. That was measured in
scout_devand reported as production behaviour without checking production. It was never true of prod. -
The prune's manifest is a record, not a restore button. It captures each doomed product's description, SEO, media URLs and alt text, status, tags, options, metafields, publication membership and every variant's price/SKU/barcode/inventory policy — enough to hand-rebuild a recognisably identical product, and the run aborts rather than delete anything it could not first snapshot. It does not capture, and nothing restores, Shopify's product and variant ids (a rebuild gets new ones), per-location inventory quantities, order history, or the image bytes — only CDN URLs, which are served from the product being deleted.
-
Does not price the 14 store-exclusive (
publicPurchase) packs by size. They are deliberately out of scope for the repricing script and remain a flat $8 regardless of patch count — "behind the wall" (walled/unlisted) is the only mitigation for that residual mispricing, by explicit decision. -
Does not let anyone buy a patch they haven't earned, by design — the per-product gate and the catalog-scope gate are two independent mechanisms. The per-product gate fails CLOSED as of 2026-09 (
patchProductGate); the catalog-scope gate still fails open (a backend blip treats the shopper as a collector). Neither is an airtight guarantee, because nothing re-checks either gate server-side at checkout — see the last bullet in this section. -
Does not sell hats generally. Only one hat SKU (After Dark, one variant per National Park) sells today, through its own ordinary product page; the Hats catalog category, the Hat Studio combo builder, and per-park blank-hat customization are all off or removed.
-
Does not verify
requiresUnlock— orcanBuySet— server-side at checkout. Both gates live entirely in the Next.js UI layer reading backend data; Shopify's cart/checkout has no concept of "earned," for a single patch or for a completed set.canBuyPubliclyis computed and typed on the backend but read by nothing — it is not a live enforcement path, just an unused field. -
Does not use the Shopify Online Store or Shop channel to sell anything. Those channels stay installed only because the Online Store's primary domain is what serves checkout; nothing is published to their browsable storefront.
-
Does not render Judge.me's widgets, and cannot. Every Judge.me widget installs into the Shopify Liquid theme, which this headless storefront never renders; the "Platform-independent Review Widget" built for non-Shopify pages is a paid (Awesome, $15/mo) feature and is disabled on our plan. We read the REST API and render the reviews ourselves instead.
-
Does not have a review form a shopper can browse to. The form at
/review/<handle>is per-product and unlisted: nothing links to it, it isnoindex, nofollow, and there is no page at bare/review(that URL passes the coming-soon wall and then 404s). It is a link you hand a buyer, not a storefront surface, and there is deliberately no product picker — every link names the product it is about. -
Does not show the verified badge until the reviewer confirms their email. A form review is created as
verified: "not-yet"and publishes immediately without the badge. Judge.me then emails the reviewer a "Please confirm your review" link, and — verbatim from its admin notification — "we will attach the 'verified buyer' badge to this web review once (s)he confirms her/his email, because we found an order from [them] in your order history." So the badge is reachable through this form, but only for a reviewer who uses the email they ordered with and clicks the confirmation. No order match or no confirmation means it stays unverified forever, andisVerifiedBuyerallowlists exactlybuyer, so an unconfirmed review renders plain. -
Does not show a store-wide rating anywhere. The catalog homepage lists recent reviews but carries no aggregate score, and the catalog/hero rating rows were removed: an average across every product describes none of them. Ratings appear only on the product they are about.
-
Does not mark imported reviews as verified. Judge.me refuses to verify imports, and
isVerifiedBuyerallowlists exactly one state (buyer) so any unknown/future state falls to unverified. The one review in the system today is an import and correctly carries no badge. -
Does not store reviews in Scout's database. Review text, ratings and reviewer identity live only in the Judge.me account; nothing in Prisma models a review, and the mobile app cannot read or moderate them.
-
Judge.me marks our products
in_store: falsebecause they are not published to Shopify's Online Store sales channel. That 404s Judge.me's own widget endpoints for them but does not affect the data API, which is another reason the widget path was not viable.
Tests that cover it
store/lib/coming-soon.test.ts— wall allowlist/prefix-boundary logic.store/lib/store/variant-media.test.ts— the per-colour photo convention.store/lib/coming-soon.test.tsandstore/lib/sitemap-entries.test.tsboth now assert the NEGATIVE:/launchis not open and no/launchURL is ever submitted to Google.store/lib/store/ownership.test.ts—isBuyablegate predicate.store/lib/store/collection-page-guard.test.ts—collectionPageDecision(render/degraded/refuse perProductContext.kind) andcollectionProductGatewired the way the pages wire it, straight throughevaluateSetCompletion: a backend-refused (unknown) collection is neither rendered nor buyable even for a shopper who owns its members; anunavailablecontext renders degraded but is NOT buyable; a realpublicPurchasepack stays buyable with nothing owned; an earn-only set is buyable only once every sellable member is owned. The last three are the fifth door's regression tests. ThenpatchProductGateagainst the same call-site wiring, straight throughisBuyable: a refused (unknown), an unaskable (unavailable), a collection-shaped and a nullish context are each not freely buyable, while a realkind: 'patch'context passesrequiresUnlockthrough in BOTH directions — an earn-first patch stays locked but its owner can still buy it, and a store-exclusive patch stays buyable by anyone.store/lib/store/access.test.ts—storeUserState/canSeeCollectorSectionsthree-state logic (guest/member/collector), including the "hasCollected is meaningless when signed out" case.store/lib/auth/session.test.ts— session cookie handling.store/lib/app-links.test.ts— app-links config mapping.store/lib/judgeme-submit.test.ts— the review-create path. The load-bearing case asserts the outbound payload carriesidas the numeric Shopify id: drop that field and Judge.me silently records a shop-level review, which is the exact failure the page exists to avoid (verified by removing the field and watching only that test fail). Plus honeypot drop-without-send, aProductVariantgid rejected rather than posted as a product, rating bounds and type, whitespace-only body, malformed email, the fourth submission blocked, a rejected payload NOT spending the rate limit, Judge.me errors and throws mapping to 502, unset shop domain failing closed, and the private token never being sent.store/lib/rate-limit.test.ts— sliding-window limiter: per-key isolation, the window sliding rather than resetting in fixed buckets (which would allow a double burst at a boundary), and blocked attempts not extending their own block.store/lib/judgeme-shape.test.ts— Judge.me payload shaping: the verified-buyer allowlist (an unknown state must never earn a purchase badge), dropping unpublished/hidden/spam reviews, out-of-range ratings, malformed envelopes, average rounding, and Shopify-GID → numeric-id extraction. Built around the real payload captured from the live account.backend/src/store-collections/__tests__/store-collections.service.requires-unlock.spec.tsandstore-collections.service.spec.ts—deriveRequiresUnlockand catalog/context service behavior.backend/src/store-my-patches/store-my-patches.service.spec.ts— the ownership source-of-truth endpoint.backend/src/shopify-webhooks/shopify-webhooks.service.spec.tsandhandlers/{orders-paid,products-update,refunds-create}.handler.spec.ts— HMAC verification, dedup, and each webhook handler.backend/src/revenuecat/revenuecat.service.spec.ts— RevenueCat verification service (backend half of the unreachable IAP pipeline).mobile/src/domain/__tests__/purchaseGate.test.ts—shouldOfferPurchase(all four gate conditions: collected, has handle, flag on, and their negations).mobile/screen-tests/store-webview.test.tsx— WebView URL construction (seeded path, dev-vs-prod origin, default-to-store-home fallback).mobile/src/utils/__tests__/celebrationGate.test.ts— where the unlock-celebration (which carries the Purchase CTA) may or may not pop.
No test was found that exercises store/app/api/auth/native/route.ts's
JWKS re-verification path, the sec-fetch-site same-origin guard, or the
fragment-wipe behavior in auth/native/page.tsx directly — this doc could
not confirm regression coverage for the token-handoff security properties
beyond reading the implementation.
Open questions
- Whether
STORE_COMING_SOONin Netlify is expected to stay on or is mid-rollout — this doc verified the current live value (on) but could not read Netlify's env configuration or any rollout plan/ticket. - Whether
REVENUECAT_API_KEYis configured on the deployed backend. Not set in this checkout'sbackend/.env(which doesn't exist locally); without it,RevenueCatService.verifyPurchasetrusts the client unconditionally (backend/src/revenuecat/revenuecat.service.ts:56-65) — moot today only because nothing can reach the purchase flow, but relevant if the map trigger is ever restored. - Whether the "collect all N patches to unlock set pricing" UX is intended to eventually apply a real Shopify discount, or is meant to stay purely informational — couldn't find a ticket or comment stating the intended end state.
- Whether
canSeeCollectorSections()is meant to be wired to/my-patchesand/collectionsand just hasn't been yet, or was deliberately left unused after the wall made those pages unreachable to the public anyway. - The actual live Shopify price of a standalone patch — code assumes $8
(
store/lib/collection-pricing.ts:11) but this wasn't verified against live Shopify data (out of scope: no Shopify API calls were made). - Whether Android in-app purchases are planned — the placeholder
'goog_PRODUCTION_KEY_HERE'and the "iOS at launch" comment suggest Android was deferred, not abandoned, but no roadmap doc was found either way.