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 coming-soon wall that once hid the whole public shopfront is DOWN
(STORE_COMING_SOON is not 'true' in production — the code's own header
says so, store/lib/coming-soon.ts:7-8, and on 2026-10-29 /patches, /about
and /drops all served their real pages with no X-Robots-Tag): /, /shop,
/shop/<slug>, /products/[handle], /patches, /patches/c/<handle>,
/drops and /cart are reachable (docs/features/merch-storefront.md).
The old patch and pack commerce routes — /collections, /store-exclusives,
/my-patches, /featured, /packs, /open-patches — are not walled, they
are GONE: deleted in be767d53 (2026-09-17, "delete the routes /shop
superseded") with no redirect, so each one 404s today (verified live
2026-10-29). The field guide the public can browse is /patches →
/patches/c/<handle> (store/app/(store)/patches/), and 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. The retired patch-commerce URLs (/collections,
/store-exclusives, /my-patches, /packs, /open-patches, …) 404; the
public way to a patch is /patches/c/<handle>, where the earn-to-buy gate
applies per patch (a store-exclusive pack's patches are buyable by anyone, an
earned patch only by a signed-in owner).
/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? Nothing is sold. The one subscription in the tree is Patch Club — $6.99/month, pick one patch you have unlocked each month, billed through Shopify Subscriptions on the web (never through the app stores). It is built end-to-end and merged, but behind patch_club, which reads false in production (2026-10-29), with its Shopify product still a draft and PATCH_CLUB_PRODUCT_ID unset, so nobody can buy it and nothing in the app or the store shows it. Its own doc is internal until launch; do not describe it as live. RevenueCat subscription plumbing for a paid tier that is not live: since 2026-10-25 the backend can mirror a user's subscription state from the RevenueCat API into a subscriptions table (RevenueCatService.getSubscriber, backend/src/revenuecat/revenuecat.service.ts:180; the Subscription model, backend/prisma/schema.prisma:897) for a paid tier that is not live; no subscription product is on sale and no purchase or subscription UI ships. Collection unlock IAP: 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 is sold and
no subscription UI exists in the app; only the server-side subscription
plumbing described above exists.
Status (shipped / beta-badged / flagged off)
| Surface | State | Flag / gate | Shipped default |
|---|---|---|---|
Public web shopfront (/, /shop, /shop/<slug>, /products/[handle], /patches, /patches/c/<handle>, /drops, /search, /cart, /account, /about, …) |
Open — the coming-soon wall is down | STORE_COMING_SOON env var, exact string 'true' (store/proxy.ts:60-71, store/lib/coming-soon.ts:105-107). The code's own header records the wall as "currently DOWN" (store/lib/coming-soon.ts:7-8) |
Wall OFF in production, verified 2026-10-29: /patches, /about and /drops answer 200 with their real titles and no X-Robots-Tag (it read ON on 2026-08-31). The OPEN_PREFIXES allowlist only matters if the wall goes back up |
/collections, /store-exclusives, /my-patches, /featured, /packs, /packs/[slug], /open-patches (the old patch and pack commerce) |
DELETED 2026-09-17 (be767d53) — no route file, no redirect: store/next.config.ts's redirects() is only the Shopify-shaped guess table (store/lib/store/legacy-page-routes.ts: /pages/about, /policies/*, /contact, …) and store/proxy.ts only rewrites to the wall |
N/A | Each one 404s live (verified 2026-10-29). Any old ad, post or OG card pointing at one is broken |
/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), the patch detail Shop tab and the Buy button | Paused — flag off in production | buy_patch feature flag, defaultEnabled: false in both registries (mobile/src/config/feature-flags.ts:105, backend/src/admin/feature-flag-definitions.ts:120); the production DB row overrides the default |
Off in production: it reads false via GET https://scout-patches.com/api/feature-flags (verified 2026-11-06), so builds from v1.1.117 open the "would you buy this?" questionnaire instead. History: true 2026-09-13, false 2026-10-11, true 2026-10-15 and 2026-10-29 — re-check the live flag before saying either way. The celebration screen's purchase button was removed on 2026-10-11 and sells nothing. With the flag on, the action sheet and the Shop tab open the per-patch page /app/patch/<handle>; the Buy button opens /app/products/<handle>?buynow=1 — see patch-shop.md |
| 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 the 30 hand-curated patches") |
Registry entry with no consumer | App-flag registry row, defaultEnabled: true (backend/src/admin/feature-flag-definitions.ts:136-143). Nothing in store/ or backend/src reads the key storefront_curated_mvp (grep, 2026-10-29). The store reads a different key, limited_inventory_mode, from the separate store_feature_flags table via GET /api/store/feature-flags (backend/src/store-feature-flags/store-feature-flags.service.ts:47-51, store/lib/feature-flags.ts:39-42), and that key only decides whether sitemap.xml carries product URLs and how robots.txt is written (store/app/sitemap.ts:102, store/app/robots.ts:10) — it never narrows what the catalog shows |
Off in production: storefront_curated_mvp reads false on /api/feature-flags and limited_inventory_mode reads false on /api/store/feature-flags (both 2026-10-29). Either way the shopper sees the full catalog: every shop-tagged product on /shop (312 live, 2026-10-29) and every synced collection on /patches. No "curated 30" code path exists today |
Promo drops (promo Shopify tag + /drops) |
Live | No feature flag — the Shopify promo tag IS the switch. Tag a product and anyone can buy it; untag it and the earned-not-bought gate closes again, with no deploy |
On. Two products carry it today (verified 2026-09-21): saint-louis-zoo-trail-hat and the saint-louis-zoo patch. /drops is open — the wall is down (first row) |
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 |
| Store-wide review fallback on a product page | Live — a product with zero reviews of its own renders recent reviews of other Scout products, headed with "No reviews for this product yet" and attributed per review with a link to the product each is about | None; driven by getStoreReviews(), which the catalog homepage already used |
On since 2026-09-21. PatchDetail.tsx picks the fallback when reviews.count === 0; ReviewsSection's attributeProducts prop adds the per-review "on <product>" line and suppresses the score row, so a borrowed review never prints an average beside another product's name |
Review form (/review/<handle>) |
Live, unlisted — noindex, nothing links to it (it sat in the wall's allowlist while the wall was up) |
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 |
| Review-request email the day after delivery (backend job) | Built 2026-10-27; sends only once REVIEW_REQUESTS_START is set on the server |
REVIEW_REQUESTS_START (a YYYY-MM-DD day) + JUDGEME_PRIVATE_TOKEN + JUDGEME_SHOP_DOMAIN in the backend env; positive and fail-closed, unset any one and the job reads nothing and sends nothing |
Off until the start day is set. Daily 10:00 CT (backend/src/review-requests/); emails Alan's own Marketplace review ask with the /review/<handle> link for each product bought |
User-facing surfaces
- Public shopfront (
store/app/(store)/…):/(merch shelves plus a/patchesteaser,store/app/(store)/page.tsx),/shopand/shop/[...slug](the tag-driven apparel catalog),/products/[handle],/patches(every synced collection grouped by campaign,store/app/(store)/patches/page.tsx) and/patches/c/[handle](one collection's patches with the earn-to-buy gate resolved per viewer,force-dynamic),/drops,/search,/cart,/account,/account/orders,/about,/how-to-iron,/shipping-returns,/privacy,/terms. All open — the wall is down (see Status). /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],/open-patches,/collections,/store-exclusives,/my-patches,/featured: DELETED inbe767d53(2026-09-17). There is no route file and no redirect —store/next.config.ts'sredirects()is only the Shopify-shaped guess table (store/lib/store/legacy-page-routes.ts:/pages/about,/policies/*,/contact, …) andstore/proxy.tsonly rewrites to the wall — so every one of them 404s (verified live 2026-10-29). An earlier revision of this doc described/packs→/store-exclusives,/packs/<slug>→/collections/<slug>and/open-patches→/featuredredirects; those files no longer exist and the behaviour was never re-created. A "pack" is just apublicPurchasecollection, reachable at/patches/c/<handle>./drops(store/app/(store)/drops/page.tsx): the promotional runs — everything carrying the Shopifypromotag, which is the one exception to earned-not-bought. ReusesgetPromos()andSwagShelf, the same pair the in-app promo shelf uses, so the two cannot drift. The header link is CONDITIONAL on the shelf being non-empty (store/app/(store)/layout.tsx), so untagging the last promo product retires the link without a deploy.- In-app store (
store/app/app/…, opened only inside the mobile WebView):/app(home — "Your Store"),/app/products/[handle],/app/collections/[slug],/app/patch/[handle],/app/rewards. The layout (store/app/app/layout.tsx) does no auth. Only two pages redirect a signed-out visitor to/auth/sign-in?next=…:/apphome (store/app/app/page.tsx:55,58) and/app/rewards(store/app/app/rewards/page.tsx:64,67). The product, collection and patch pages are open to anyone; earn-to-buy gating happens per product, not by redirect. - 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:260) - Patch action sheet's "Purchase Patch" button:
buy_patchpicks the store or (off) the paused store's questionnaire, see below (mobile/src/components/patches-v2/PatchActionSheet.tsx:64-70,mobile/src/scout/patch/PatchActionSheet.tsx:85-91) — opens the per-patch page/app/patch/<handle>(patchShopPath,mobile/src/config/store.ts:148) - The patch detail screen's Shop tab, same gate (
shouldOfferPurchase: collected +productHandle+ patch art (patchUrl); since 2026-11-03 the flag no longer hides it, it picks the destination), same destination (mobile/src/screens/PatchModalScreen.tsx:190-204) - The unlock-celebration screen had a purchase CTA on this gate; removed
2026-10-11 (stale-doc fix: this line described
celebration.tsxcode that no longer exists). The celebration sells nothing. - The two
buy_patchentry points go to/app/patch/<handle>, the patch and everything tagged to it (patch-shop.md). The drawer's Store row still opens the store home (/app), where the just-collected patch sorts first in the "Your patches" grid (collectedAt descinstore-my-patches.service.ts:22). - With
buy_patchoff, the store is paused, and none of these entry points opens the WebView (production today; in store builds since v1.1.117). The Buy button, Shop tab, Purchase Patch row, drawer Store row and Home's completion band all stay, but open the "would you buy this?" questionnaire instead:patchPurchaseRoute/storeHomeHref,mobile/src/config/store.ts. Full detail instore-interest.md. Before that change the flag hid the patch buttons, and the Store row was not flagged at all.
- 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).
Buy now (?buynow=1): one tap to checkout, for a collected patch or a hat
The one path that skips the cart. BUY_NOW_PARAM = 'buynow', on only for
exactly buynow=1 (store/lib/store/buy-now.ts:21-34). Two product pages honour it:
- The in-app patch page
/app/products/<handle>?buynow=1(store side shipped 2026-10-25,56330834). The page readssearchParamson the server (store/app/app/products/[handle]/page.tsx:34-35) andPatchBuyAreaswaps Add to cart forBuyNowButton— "Purchase for $X" — only when the patch is owned and in stock (store/components/store/PatchBuyArea.tsx:74-75). The app's Buy button in the patch detail hero opens exactly this URL (patchBuyNowPath,b3a44f35; in app 1.1.113). Detail:patch-shop.md. - The public merch page
/products/<handle>?buynow=1for ashop-tagged hat (2026-10-27,5706da7c), so a paid-social ad can land a stranger one tap from checkout. That page is ISR-cached, soMerchBuyAreareads the param in the browser (isBuyNowSearchbehinduseSyncExternalStore,falseuntil hydration) rather than fromsearchParams, which would have made every product page dynamic (store/components/store/MerchBuyArea.tsx:38-64). A single-variant hat rendersBuyNowButtonwhereApparelBuyAreawould be (:120-135); a multi-colour hat renders it under the swatches once a variant is selected (:177-182). The earn-to-buy gate wins: apatch:-tagged hat the viewer has not earned showsLockedCalloutwhatever the URL says (:104-122,:164-165), and a real Size choice is never guessed (:126,:166-176).
The tap itself is shared: buyNowOnce latches so a double tap cannot build two
Shopify carts, fires trackInitiateCheckout, calls the same cartCreate with
one line, and only once the checkout exists empties the Scout cart and
navigates (store/lib/store/buy-now.ts:46-81,
store/components/store/BuyNowButton.tsx). Tests: store/lib/store/buy-now.test.ts,
store/lib/store/buy-now-render.test.ts (both pages' render branches).
Order/webhook pipeline
Shopify calls POST /webhooks/shopify/:topic on orders/paid,
products/update, refunds/create, and (since Patch Club, 2026-11) orders/cancelled
and orders/fulfilled. 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).
Patch Club branch of orders/paid. When a payload line is the Patch Club
product (PATCH_CLUB_PRODUCT_ID, unset = never; backend/src/patch-club/club-product.ts:27-32),
the order is stored with StoreOrder.isClub = true, ClubSyncService.ingestPaidOrder opens
one pick credit from the Admin API's own read of the order (a throw fails the webhook so
Shopify retries; orders-paid.handler.ts:105-112), analytics records one club_payment
event instead of purchase + product_purchase (:155-157), the Pushover heading reads
"Patch Club renewal" (:178), and rewards redemption is skipped (:186). orders/cancelled
voids an unpicked credit, orders/fulfilled marks a picked one shipped, and refunds/create
also voids the credit when it covers the membership line. Detail lives in the internal Patch Club doc until launch.
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).
A promo tag overrides all of that. isPromo(tags)
(store/lib/store/promo.ts) is precedence rule 0 in both gate resolvers
(tagBuyGate, productBuyGate in store/lib/store/patch-hat.ts): a product
carrying the plain Shopify tag promo is buyable by anyone, whatever the
backend said and whatever patch: tag it carries. It has to outrank patch:
because a promo item carries patch: by definition — that tag is what gated
it — and it is checked in both resolvers because a promo patch is locked
by Collection.publicPurchase rather than by any tag, so tagBuyGate alone
would never see it. The match is exact, case-insensitive and trimmed, never a
prefix: promotional, promos and promo:10 do not unlock anything.
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,
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).
Patch hats are gated too, by a Shopify tag
A patch hat is a blank cap carrying the same embroidered patch the Scout app hands out for showing up somewhere, so the same rule applies: a hat you could simply buy would be a $30 way around the only thing the app is for.
The hat is a separate Shopify product with its own handle, and ownership is
keyed by patch handle, so the two have to be linked. The link is a tag,
patch:<patch-product-handle>, read by
store/lib/store/patch-hat.ts (linkedPatchHandle, tagBuyGate,
productBuyGate — tagBuyGate was named patchHatGate until promo made it
answer for any product, not only hats) and written by
backend/src/scripts/lib/patch-hat-plan.ts (patchHatTag). GateableItem
grew an optional unlockHandle for it, so one predicate still answers for
both product shapes (store/lib/store/ownership.ts).
-
The tag's presence IS the gate. No
patch:tag, no gate — which is what keeps tees, beanies, stickers and every store-exclusive hat on open sale, unchanged. -
Which patches are gated is not decided by the tooling. The script reads
Collection.publicPurchase, the same field mechanism 1 uses. All fourteenpublicPurchasecollections havecampaignId = null: they are the store-exclusive sets (Safari, Dinosaurs, National Parks — After Dark) that are bought outright and never earned, so they never appear in a campaign run and their hats stay ungated. -
It applies on every surface, from one place.
toSwagGalleryItemreads the tag, so every shelf that renders aGalleryPiecegets it;MerchBuyAreakeeps the colour swatches visible and swaps Add-to-cart forLockedCallout;AutoPurchaserefuses the?autocart=1deep link;isIndexableProductdrops the page from Google and the sitemap, because a stranger arriving cold cannot buy it andProductschema claimingInStockon such a page is the Merchant Center violation the patch pages already avoid. -
unlockHandledistinguishes unset from null. Unset means "this product unlocks itself" and falls back toproductHandle(every patch).nullmeans "gated, nothing opens it" and is never buyable — what a malformedpatch:tag produces. Falling back for the null case would almost work, since no hat handle is ever in an owned set, and "almost" is not a gate. -
A
promotag releases one hat from the gate.tagBuyGatechecksisPromofirst, so a promotional run is sold to everyone while every other hat stays earned. Being ungated, a promo hat also becomes sitemap-eligible through the sameisIndexableProductcall — which is correct for something being advertised.
Status: the gate is live across the patch hats. Measured against the
production Storefront API on 2026-09-21: 250 products carry the patch hat
tag, and 249 of them carry a patch: tag and are gated — including the
zoo Trail Hats, which an earlier revision of this doc described as ungated.
That is no longer true and was corrected here. The single exception is
saint-louis-zoo-trail-hat, which is gated and promo, so it sells openly
by deliberate choice rather than by omission.
Route changes stop re-asking once the route has answered 401. A signed-out
visitor is answered from the session cookie alone — the route never reaches
the backend — so re-asking on each navigation is a question already answered,
and a guest browsing four pages put four 401s in a tester's network log.
Measured in a browser after the change: one 401 across five guest pages,
and a still-refetching 200 on every navigation while signed in, so an expiring
session is noticed. An auth event is never suppressed, and a failed request
is not recorded as an answer, so one network blip cannot mute ownership for the
rest of the visit. The rules are pure and tested in
store/lib/store/owned-handles-refresh.ts.
Two structural facts make that safe, both verified in a browser rather than
inferred. The provider is per-layout-tree: app/(store)/layout.tsx and
app/app/layout.tsx each mount their own OwnedHandlesProvider, so the mobile
handoff (/auth/native under (store) → next=/app under app/) always
arrives at a freshly mounted provider that knows nothing and therefore always
asks. Within a tree the provider survives navigation with its refs intact,
which is where suppression actually applies. Consequently a response must still
be honoured when it lands after a navigation — the counter that orders
responses is monotonic for the component's whole life, and only a real unmount
stops the UI being updated.
Earned-not-bought — mechanism 2: scope as the shopper sees it (and the dead three-state code)
Independent of the per-product gate, the public listings make the buyable
subset findable. On /shop and /shop/[...slug] that is ShelfBrowser's
toolbar (store/components/store/shop/ShelfBrowser.tsx): a count, a sort and
ScopeToggleView, an in-memory "buyable only" switch that filters the grid to
the products isBuyable says the viewer can order right now — of 312
shop-tagged products, 252 carry a patch: gate (live Storefront read,
2026-10-29), so without it a stranger scans three hundred cards reading
buttons. It is browser state, not URL state (the shelves are ISR and read no
searchParams), so a filtered shelf has no shareable URL. On
/patches/c/[handle] the split is server-side: getCollectionDetail returns
buyablePatches and lockedPatches, and the page reads the signed-in
viewer's owned handles before it renders
(store/app/(store)/patches/c/[handle]/page.tsx,
store/lib/collection-detail-split.ts).
The older three-state design is still in the tree but nothing mounts it:
storeUserState({ signedIn, hasCollected }) → 'guest' | 'member' | 'collector' and canSeeCollectorSections() (store/lib/store/access.ts:13-23)
have no caller outside their own test; CatalogListing.tsx (the component that
used them, with its catalog | owned modes) has no importer under store/app;
and the URL-writing ScopeToggle (?show=all) is likewise unmounted — only
its view half, ScopeToggleView, is used. CatalogPage.tsx, which earlier
revisions of this doc cited for the guest/member/collector branch, no longer
exists. The pages the collector-only rule was meant to guard (/my-patches,
/collections) were deleted with the rest of the patch commerce routes
(be767d53), which is why the predicate was never wired.
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
Down in production — at least since be767d53 (2026-09-17) wrote
"currently DOWN" into the code's own header (store/lib/coming-soon.ts:5-8),
and confirmed live on 2026-10-29 (see Status). The mechanism is kept because
the allowlist is what decides what stays reachable if it ever goes back up.
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 = ['/shop', '/products', '/cart', '/account', '/app', '/auth', '/api', '/review'], plus / exactly and static asset
requests — store/lib/coming-soon.ts:48-96) 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.
Confirmed again 2026-09-20 from the API. Of nine reviews on the account,
exactly one carries source: new-rre-flow — Judge.me's request email — and
its product_external_id is 0. The request flow is configured and working;
it simply produces shop-level reviews, as this section describes. The account
is otherwise healthy: dmarc_ok now reads true, where it was false on
2026-09-11. Five older debugging reviews are published: false and marked
spam, so three are public.
The badge a real order earns has two states, not one. isVerifiedBuyer
allowed only buyer — what Judge.me's own request email produces. A review
left through /review/<handle> becomes verified-purchase once the
reviewer confirms and their email matches an order history, and that was
being rejected: the store's one genuinely verified purchase rendered with no
badge. Both states are now allowed, still as an allowlist;
unconfirmed-buyer (Judge.me suspects an order, the reviewer never
confirmed) and not-yet (every import) stay out.
Two display rules keep a thin review set from reading as manufactured, both from the 2026-09-19 panel, where four of twenty-one guessed the one visible review was planted and two pointed at how it was built rather than what it said:
- A review's title is suppressed when the body already contains it
(
showsReviewTitle). Judge.me stores the two separately and Scout's gifted review repeats its title as a sentence, so the page printed the same words twice. - A trailing
[Disclosure: …]is lifted out of the body and rendered on its own line under a divider (splitDisclosure). Judge.me has no field for one, so it ends up typed into the review text — and a disclosure buried in the customer's own sentence stops reading as candour and starts reading as assembly. Only a trailing block whose first word is "Disclosure" is lifted; brackets a reviewer used mid-sentence are left alone.
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 with the wall up they would 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 review-request email, the day after delivery (backend, built 2026-10-27)
Judge.me's own request goes 14 days after fulfillment and, for our out-of-store catalogue, asks for a shop review that never reaches a product page (above). So the backend sends its own ask, at the moment a buyer still has the product in hand.
- When.
ReviewRequestSchedulerruns daily at 10:00 America/Chicago. An order is ready once Shopify reports it fully fulfilled and every shipment that was not cancelled carries adeliveredAt(Shopify Shipping labels and carrier tracking fill this in; every recent order has one). The delivery must fall on an earlier Central calendar day, so a hat delivered Tuesday is asked about Wednesday morning. Deliveries older thanMAX_AGE_DAYS(14) are dropped: the ask is meant while the product is fresh, and a job that was off for a month must not flush a backlog. - Who, and the one rule that matters: one ask per buyer per product, ever.
The key is (lowercased email, Shopify product id). A returning buyer is still
asked about a product they have not been asked about; buying the same product
again never produces a second ask; a product they already reviewed on Judge.me
(any review with their email and that product, published or not) is never asked
about. Several eligible orders from one buyer become one email. Cancelled,
refunded and voided orders, and orders with no email, are skipped. Rules:
review-request.rules.ts(planReviewRequests). - Nothing before the start day.
REVIEW_REQUESTS_STARTis the first Central day whose deliveries are asked about. Buyers Alan already asked by hand (in Marketplace chat) were delivered earlier, so they are never asked again. - The words are Alan's, from the message he sends Marketplace buyers: "Hope
you like the hat!" / "If you could, I would really appreciate leaving a review on
my website! It would help me out a ton to help my shop grow 🙏" / the link,
signed "Alan, Scout Patches". "the hat" becomes "the patch" or "the shirt" from
the Shopify product type, else "your <title>"; an email about several products
says "your order" and lists each title with its own link. Subject "How's the
hat?". From and reply-to
alan@scout-patches.com(REVIEW_REQUEST_FROM), so a reply reaches Alan. Builder:review-request.email.ts. - Never twice, even across a crash. Each (buyer, product) is claimed in
review_request(INSERT … ON CONFLICT DO NOTHING, primary key(email, product_id)) before the send; only products this run claimed go in the email, so two overlapping runs cannot both send. Resend accepted →status = 'sent'with its message id. Resend refused → the claim is deleted and tomorrow's run retries. A crash between the send and the update leaves'sending', which still blocks a repeat: a missed ask is preferred to a double one. - Fail closed on Judge.me. If the Judge.me review list cannot be read the run stops before sending anything, because it cannot tell who already reviewed.
- Manual run:
cd backend && npm run script:review-requestsis a dry run (prints who would be emailed today, emails masked, writes nothing);-- --senddoes exactly what the 10:00 job does. Verified 2026-10-27 as a dry run against live Shopify and Judge.me: a buyer who had already reviewed their hat was skipped, and the other recent deliveries were planned.
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).
Web sign-in on the shop: the BFF, and the Google OAuth route
Outside the WebView a shopper signs in at /auth/sign-in
(store/app/(store)/auth/sign-in/SignInForm.tsx): email + password, a magic
link (/auth/magic-link, /auth/magic-link/verify), or Google. The
storefront is a BFF — the browser never holds a Scout token. The Next route
handlers mint and hold the session as two first-party httpOnly cookies,
scout_at / scout_rt (store/lib/auth/session-core.ts:9-10; setAuthCookies
:101-106, SameSite=Lax, path=/, no domain so host-only on
shop.scout-patches.com), and store/proxy.ts:76-100 refreshes an expired
scout_at from scout_rt on the way in so server components render signed in.
Google, specifically (store/app/(store)/auth/oauth/google/route.ts):
SignInFormloads Google Identity Services (accounts.google.com/gsi/client) only whenNEXT_PUBLIC_GOOGLE_WEB_CLIENT_IDis set — unset, the button is simply absent (SignInForm.tsx:53,:159-166) — renders Google's own button, and the credential callback hands back an ID token (SignInForm.tsx:143-150).- The form
POSTs{ idToken }to the shop's own/auth/oauth/google(SignInForm.tsx:125-132). The route refuses a cross-site caller first:isSameOriginRequestwantssec-fetch-site: same-origin|same-site, or failing that anOriginwhose host matches (session-core.ts:50-58); a miss is 403 (route.ts:6-8). - It forwards the token server-to-server to the backend's
POST /api/auth/oauth/google(callBackend,store/lib/auth/backend.ts) and — the anon-merge — if the browser already holds a guestscout_at, sends it asAuthorization: Bearer …so the backend folds that guest session into the Google account instead of starting a second one (route.ts:11-18). The backend handler isAuthController.oauth(backend/src/auth/auth.controller.ts:41-49): providersgoogle|appleonly, 5 calls per minute,OptionalJwtAuthGuardso the bearer is read when present;engine.oauthSignInverifies the ID token and returns a Scout access + refresh token pair. - A backend failure is a flat 401
Google sign-in failedto the browser (route.ts:19); success setsscout_at/scout_rtviasetAuthCookiesand answers{ ok: true }(:20-23). The form then emitsscout:auth-changed(emitAuthChanged,store/lib/auth/auth-events.ts:13-17, which makesOwnedHandlesProviderrefetch ownership at once) androuter.push(next)(SignInForm.tsx:138-140).
What it does not do: there is no OAuth redirect dance and no code
exchange — it is the GIS ID-token flow, one POST; the shop has no Sign in with
Apple; and the shop's BFF does not sign the visitor's IP (no SCOUT_BFF_SECRET
anywhere under store/lib, 2026-10-29). Relation to the planner's "shop BFF
sign-in copied": trips-web/lib/auth/* is a byte-identical copy of
store/lib/auth/* — same cookies, same JWKS verification, same same-origin
check, same safeRelativeNext — kept honest by a drift test; the planner's
copy adds Sign in with Apple and the signed x-scout-client-ip header,
neither of which the shop has. See road-trip-planner
§ Sign-in. Because both apps set host-only cookies on their own hosts
(shop.scout-patches.com vs scout-patches.com), a shop session and a planner
session are separate sign-ins to the same account.
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, andisClub(true for a Patch Club membership payment, set by the webhook and re-asserted by the Shopify Sync). Net revenue =totalCents - refundedCents - feeCents. The finances summary reports Patch Club separately asclubRevenueCents(net of refunds, a subset ofrevenueCents) andclubOrdersfor the period (backend/src/store-finances/store-finances.service.ts:116-121,:181-182); the admin Store Finances page shows a "Patch Club" revenue row and the "Shopify" row excludes it (backend/admin-ui/src/pages/StoreFinancesPage.tsx:222-226).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 keyed by session/user. Event types:pageview,search,add_to_cart,remove_from_cart,checkout_start,product_view,purchase,product_purchase,club_payment(a paid Patch Club order, written instead of the two purchase events), and (since 2026-10-19)image_swipe,filter_applied,sort_changed,page_engagement. The storefront may write every type exceptpurchase(one row per order, carrying the order total),product_purchase(one row per line item, carrying that line's subtotal) andclub_payment(one row per Patch Club order, carrying the order total); those three are written solely by theorders/paidwebhook, and the public DTO rejects them. Since 2026-10-19 each row also carriescountry,region,device_type,browser,os,in_appandprivacy_signal(see below).- Shop search is tracked (fixed 2026-10-19). The only live search box
(
HeaderSearch) posts a plain form to/search, and the tracking component lived in a listing nothing rendered, so onesearchevent ever existed (2026-08-01).SearchTracker(store/components/analytics/SearchTracker.tsx, mounted instore/app/(store)/search/page.tsx) now firessearchonce per distinct query per page load viatrackSearch(store/lib/analytics/search.ts): lower-cased, trimmed, 2+ characters, withresultCount. The query is the one piece of free text kept. Also tracked now:image_swipe(gallery/thumb),filter_appliedandsort_changed(ShelfBrowser), andpage_engagement(visible seconds + max scroll, once per page). - Enrichment. Since 2026-10-19 storefront events can carry the shopper's
country and region (the IP is used once, then discarded) and device, browser
and OS (the raw user agent is discarded). Obvious bots are left out only once
the shop is connected to our server as a trusted source; until then, nothing is
filtered or enriched.
Purchase rows from the
orders/paidwebhook are never enriched or filtered. Internal doc: web-analytics. - In-app marker. The app opens the shop in a WebView at
/auth/nativethen/app/*. A row isin_app = truewhen its path is in that tree or its session's landing page was (backend/src/store-analytics/in-app.ts);store_attribution.in_appmarks the visitor. Existing rows were backfilled by the same rule. In-app product pages now fireproduct_view, so in-app browsing is in the funnel. Internal shop reporting is described in the internal analytics hub doc. - Shared visitor id.
scout_vid(one year,Domain=.scout-patches.comon Scout hosts) carries the same value as the host-onlyscout_session_id, so the website and shop agree on a visitor;scout_session_idis kept becausecart-actions.tsreads it to stitch orders. An existing session id wins, so no shopper is re-identified. - Meta Pixel opt-out. The shop pixel (
MetaPixel.tsx) loads only whenscout_privacy_optoutis not1, Global Privacy Control is not set, and the visit is not in the Scout app (/appand/auth/nativepaths, remembered for the browser session;store/lib/analytics/privacy.ts). The opt-out lives athttps://scout-patches.com/privacy/choices(footer link "Your Privacy Choices"). First-party analytics still runs and records the signal; thescout_touchsource tag still rides the Shopify cart. The shop's noscript pixel beacon was removed (it cannot honour the opt-out). The opt-out covers the pixel on our own pages, not Shopify's checkout pages. - How a source reaches us — two paths, because one is not reliable.
The storefront reports the touch on the first analytics event of an arrival.
That path is best-effort:
sendBeaconreports only that the browser queued the request, never that it landed, so a dropped or rejected one used to discard the source for the entire visit with nothing to retry it. Order #1011 (2026-09-22) arrived with no recorded source exactly that way, while sixteen Facebook sessions sat recorded beside it. Now:- the touch is held in
sessionStorageand re-sent on every event until the server actually acknowledges it (the event carrying it goes byfetch, which returns a status, notsendBeacon, which does not); - a compact copy — source, medium, campaign — is written to a
scout_touchcookie, whichcreateCheckoutSessionputs into the Shopify cart's attributes asscout_source/scout_medium/scout_campaign. Those come back on theorders/paidwebhook, so an order's source rides the ORDER and cannot be lost in transit. This is the path that attributes a sale when every analytics request failed. Over-long values are truncated, not rejected: the batch is validated as one unit, so a single over-long UTM used to 400 the whole request and kill the pageview alongside the attribution (measured: a 600-characterfbclidreturned 400 and wrote nothing).
- the touch is held in
StoreAttribution— one row per analytics session, recording where that visitor came from. Holds afirst_*and alast_*copy of ten fields (referrer, referrer host, landing page, the five UTM parameters,fbclid,gclid). First-touch is written once and never changed; last-touch is overwritten by any later arrival that carries a real source. A bare direct visit may seed first-touch but never overwrites last-touch, so returning traffic cannot erase the campaign that originally found someone.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; a kind: 'patch' body also carries the patch's name, city, state for the per-patch page heading (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, plus the per-session attribution payload. Money events (purchase, product_purchase, club_payment) are rejected here |
| `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 (with a Patch Club branch), products/update, refunds/create, orders/cancelled, orders/fulfilled (the last two only act on Patch Club orders) |
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. Aliases only a $RCAnonymousID: id (purchases.service.ts:7-15, since 2026-10-25); any other id is ignored |
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/(standalone)/review/[handle]/page.tsx— the per-product review page (moved out of(store)so it carries no site header/footer). 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/promo.ts—PROMO_TAG+isPromo, the promo predicate and the shelf query, sharing one spelling of the tag so the shelf can never list a product the gate still locks.store/lib/store/gallery-gate.ts—cardGate, the shelf card's gate+badge merge. Separate fromstore-patches.tsbecause that file importsserver-only, which is not innode_modulesand cannot be loaded by the plain node test runner.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/shop/ShelfBrowser.tsx,store/components/store/ScopeToggle.tsx(ScopeToggleView) — the "buyable only" switch on/shop.CatalogPage.tsxis gone;CatalogListing.tsxand the URL-writingScopeToggleare unmounted (mechanism 2).store/app/(store)/patches/page.tsx,store/app/(store)/patches/c/[handle]/page.tsx,store/lib/store/patches-index.ts,store/lib/collection-detail-split.ts— the public field guide: every collection by campaign, then one collection's buyable/locked split.store/lib/store/buy-now.ts,store/components/store/BuyNowButton.tsx,store/components/store/MerchBuyArea.tsx—?buynow=1on the in-app patch page and the public hat page (§ Buy now).store/app/(store)/auth/oauth/google/route.ts,store/app/(store)/auth/sign-in/SignInForm.tsx,store/lib/auth/session-core.ts— the shop's Google sign-in and thescout_at/scout_rtcookie session (§ Web sign-in).store/lib/store/legacy-page-routes.ts— the ONLY redirect table (next.config.tsredirects()): Shopify-shaped guesses such as/pages/aboutand/policies/refund-policy. Nothing in it answers for the deleted patch-commerce routes.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 sheets and the patch detail Shop tab (the celebration screen no longer uses it).mobile/src/components/patches-v2/PatchActionSheet.tsx,mobile/src/scout/patch/PatchActionSheet.tsx,mobile/src/screens/PatchModalScreen.tsx— the 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. Off in production (live check 2026-10-29:/patches,/aboutand/dropsrender their real pages;store/lib/coming-soon.ts:7-8records the wall as down). It read on 2026-08-31.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 (mobile/src/config/feature-flags.ts:105,backend/src/admin/feature-flag-definitions.ts:120), and off in production: it readsfalseviaGET https://scout-patches.com/api/feature-flags(verified 2026-11-06). History:true2026-09-13,false2026-10-11,true2026-10-15 and 2026-10-29 — re-check the live flag before saying either way. Gates the "Purchase Patch" button on the patch action sheets, the patch detail Shop tab and the Buy button (all viashouldOfferPurchase). The celebration screen no longer has a purchase button (removed 2026-10-11). 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— app-flag registry entry, default on, livefalse(2026-10-29), and nothing reads it: no code understore/orbackend/srcconsumes the key. The store's ownlimited_inventory_mode(separatestore_feature_flagstable, served byGET /api/store/feature-flags, alsofalselive) is the key the storefront reads, and today it only shapessitemap.xml/robots.txt(store/app/sitemap.ts:102,store/app/robots.ts:10). Neither flag narrows the catalog; the "30 hand-curated patches" behaviour the description promises has no code path (CatalogPage.tsxis gone).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).- Backend, review-request email (
backend/src/review-requests/review-request.service.ts,reviewRequestConfig):REVIEW_REQUESTS_START(YYYY-MM-DD; unset or not a date = off), plusJUDGEME_PRIVATE_TOKENandJUDGEME_SHOP_DOMAIN(the same values as the store's, set in the backend's own.env; without them the job is off). OptionalREVIEW_REQUEST_FROM(defaultAlan Sax <alan@scout-patches.com>) andREVIEW_REQUEST_BASE_URL(defaulthttps://shop.scout-patches.com). Sending also needsRESEND_API_KEYand the Shopify app credentials the backend already has; the Shopify app needsread_orders, which it has. - 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 routes to the per-patch page/app/patch/<handle>(patchShopPath), not a product page with?autocart=1— 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(the old pack-landing component) is gone with the/packs/[slug]route. The dead code left in its place is the three-state scope design:storeUserState/canSeeCollectorSections(store/lib/store/access.ts) andCatalogListing.tsxhave no importer understore/app/(mechanism 2).- The retired patch-commerce URLs 404 rather than redirect.
/packs,/packs/<slug>,/open-patches,/collections,/store-exclusives,/my-patchesand/featuredwere deleted inbe767d53with no entry inlegacy-page-routes.tsand nothing inproxy.ts, so a link saved before 2026-09-17 (an ad, a Facebook-group post, an OG card) lands on a 404 — verified live 2026-10-29. Adding a row toLEGACYinstore/lib/store/legacy-page-routes.tsis the fix if any of them still gets traffic. 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 (down today, see Status) 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
-
A bare direct visit never overwrites the cart cookie. Only an attributable arrival writes
scout_touch, mirroring the server's last-touch rule — so someone who returns by typing the address in cannot erase the campaign that originally found them. -
In-app shop visits were counted as web before 2026-10-19. The old "(direct)" bucket mixes them in; the backfill marks them
in_app. -
Attribution cannot see anything before 2026-09-22, and cannot see Shopify's own checkout pages.
store_attributionis written from the storefront, so every session that predates it has no recorded source and is grouped under(unattributed)— not underdirect, which is a real answer meaning "arrived with no referrer and no campaign". Shopify'scustomerJourneySummaryis not a substitute: it only records moments on the checkout domain, because its tracking rides on theme JavaScript that a headless Next.js storefront never loads. -
Attribution is per browser session, not per person. It keys on the
scout_session_idcookie, so the same human on a phone and a laptop is two visitors with two independent first-touches, and clearing cookies starts a new one. There is no cross-device identity stitching, and the "visitors" column is a count of sessions — it is not a unique-people count. -
The dashboard's headline Revenue is order revenue, and the per-product Revenue column is not. The headline sums
purchaseevents (one per order, the Shopify order total, inclusive of shipping and tax). The per-product column sumsproduct_purchaseline subtotals, which exclude shipping and tax and are capped at the top 25 products. The two are deliberately different measures and will not add up to each other. -
Ad-platform click IDs are stored, not reconciled.
fbclidandgclidare captured intostore_attribution, but nothing joins them back to Meta's or Google's reporting, so Scout's own numbers and the ad platforms' numbers are independent counts that will disagree. -
The review provenance line names Judge.me, and only Judge.me.
ReviewsSectioncloses with "Reviews collected by Judge.me, an independent review platform" on every instance — product page, collection page, catalog homepage and the store-wide fallback. Scout has no Trustpilot, Yotpo or Okendo integration; every review on the site comes fromhttps://judge.me/api/v1(lib/judgeme.ts). Naming any other collector there would be a false provenance claim and someone else's trademark. The line carries Judge.me's real lockup (store/public/judgeme-reviews.png, their own asset, trimmed and resized — never an approximated wordmark), sat on a cream chip. That chip is load-bearing: the lockup's navy half (#0E415F, the ".me" and "REVIEWS") measures 1.81:1 against--bg-0and is effectively invisible, versus 9.44:1 on the chip. The alternative was recolouring a mark we cite for credibility, which is worse. The teal reads 2.26:1 on the chip and is fine — it is 2.59:1 on Judge.me's own white, so that is their design, and a logo is exempt from WCAG 1.4.11. The line also does not claim Scout cannot edit or remove reviews: a merchant can unpublish one in Judge.me, andmapReviewfilters onpublished/hidden, so that claim would be false. -
The store-wide review fallback never becomes the product's rating. It renders review cards only. The star row under the product title (
RatingSummary), the star row on shelf/grid cards (GalleryPiece'srating), andaggregateRatingin the product JSON-LD all stay bound to that product's owngetProductReviewscount, which is usually zero — so they stay absent.product-jsonld.tsomitsaggregateRatingentirely rather than emitting zeroes, and the fallback does not touch that input. This is the line the removed Senja Wall-of-Love crossed: it was "a single global wall with no product scoping, so one hat review claimed a rating on every patch page" (ReviewsSection.tsx). Feeding store reviews into any of the three surfaces above re-creates that bug and, for the JSON-LD, is a Google structured-data policy violation. -
Judge.me shop reviews are dropped everywhere. Judge.me lets a customer review the shop rather than a product and files it against a pseudo-product with
product_external_id: 0("Judge.me Shop Reviews").mapReviewreturns null for those, so they reach neither the homepage block, nor the fallback, norgetRatingsByProduct(which would otherwise key a rating under product id"0"). One such review existed and was published on 2026-09-21. -
The patch-hat gate is client-side, exactly like the patch gate it reuses. It decides what the product page and the shelf render; it is not re-checked when the Shopify order is placed. This is the same limitation the per-patch gate has — see "Does not verify
requiresUnlock… server-side at checkout" below. Reusing that gate neither widens nor closes it. -
The
patch:tag is a SNAPSHOT, not a live link. It records the patch's gate at the moment the hat was created or last re-run. A patch that later moves into apublicPurchasecollection keeps a gated hat untilcreate-patch-hats.tsis re-run for its campaign. The alternative was a backend round trip per hat to render one shelf, and — worse — a shelf and a product page that can cache different answers about the same hat. -
A
promotag on a hat does NOT open the patch it carries. They are separate Shopify products and each needs its own tag. Today both thesaint-louis-zoo-trail-hatand thesaint-louis-zoopatch are tagged, so both sell — but that is two decisions, not one, and untagging the hat leaves the patch on sale. -
/dropsis NOT the only place a promo product appears, and/featuredis NOT one of them. A promo product still sits on whatever category shelves itsshop:tags put it on. It does not appear on/featured, which is filtered server-side onCollection.publicPurchase(getPublicPurchasePatches) and cannot see Shopify tags at all. -
The promo badge carries NO stock number, and selling out does NOT re-lock. Every store surface is ISR at
revalidate = 300, so a live "2 left" on a ten-unit run would be stale exactly when it mattered. When the run is gone the card reads "Sold out" — the gate stays open until the tag is pulled. -
The promo gate is NOT server-enforced, for the same reason the rest of the gate is not. It loosens the gate rather than tightening it, so it adds no exposure that the client-side gate did not already have.
-
There is no patch hat for most patches. 279 products carry the
patch hattag (live Storefront read, 2026-10-29; all 279 also carry apatch:gate), and a park can have both a beanie and a trucker line, so fewer than 279 distinct patches have a hat. Against the live catalog of 3,553 places that leaves well over 3,200 patches with no hat product at all. -
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 is sold, and the app has no subscription purchase screen. The backend can record subscription state from RevenueCat for a paid tier that is not live, but nothing a user can do today creates any. -
Does not sell an un-earned patch on the public web. The public web sells apparel (the
shop-tagged merch catalogue,docs/features/merch-storefront.md), and on/patches/c/<handle>and/products/<handle>it sells a patch only under the same earn-to-buy rule as the app: apublicPurchase(store-exclusive) pack's patches to anyone, an earned patch to a signed-in owner, everything else as a locked "Earned, not bought" callout. There is no wall any more (see Status) — the gate is per patch, not per route. -
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. -
The review-request email does not reach Facebook Marketplace or Etsy buyers. It reads Shopify orders only, and Marketplace gives no buyer email. Marketplace buyers are asked by hand in Messenger, and nothing records those asks: the job cannot see them, which is why
REVIEW_REQUESTS_STARTexists. -
It does not send for orders Shopify cannot see delivered. No tracking, no
deliveredAt, no email. A hand-delivered or local-pickup Shopify order is never asked about. -
It does not turn off Judge.me's own request emails. Those are a Judge.me admin setting (Settings → Request scheduling). Left on, a buyer gets our ask the day after delivery and Judge.me's shop-review ask 14 days after fulfillment.
-
It does not resend, remind or follow up. One email per buyer per product, and a buyer who ignores it is never asked again.
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/promo.test.ts—isPromoexact-match behaviour, including the falsifying cases (promotional,promos,promo:10,promo-hatmust all fail to unlock) and a drift guard tying the predicate to the shelf query.store/lib/store/patch-hat.test.ts— precedence rule 0, each promo case paired with its opposite: promo opens the Saint Louis Zoo hat, and the SAME tag list withpromoremoved is still locked. Those promo assertions were confirmed FAILING against the unfixed gate before rule 0 was written.store/lib/store/gallery-gate.test.ts—cardGate: a promo tag overrides the backend'srequiresUnlock, and apromotionaltag overrides nothing.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. The predicates are currently unmounted (mechanism 2), so this pins dead code.store/lib/store/buy-now.test.ts,store/lib/store/buy-now-render.test.ts— the?buynow=1param, the double-tap latch, and the render branches on both the in-app patch page and the public hat page (locked, sold out, size unchosen, single-variant).store/lib/store/legacy-page-routes.test.ts,store/lib/coming-soon.test.ts,store/lib/standalone-routes.test.ts— the guess-redirect table, and that/collections,/packs,/open-patches… are NOT open paths (they are not routes at all).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/review-requests/review-request.rules.spec.ts— who is asked: the earlier-Central-day rule (a delivery at 23:30 CDT is "yesterday" the next morning), the start day, the 14-day freshness cap, one ask per buyer per product (a returning buyer IS asked about a new product), case-insensitive emails, a product already reviewed, merging one buyer's orders, and the skipped order states. Removing the asked/reviewed check fails 4 of them.backend/src/review-requests/review-request.email.spec.ts— Alan's text word for word with the right product's link, the product-kind wording, one link per product in a multi-product email, HTML escaping.backend/src/review-requests/review-request.sources.spec.ts— Shopify order mapping (delivered only when every live shipment arrived; a cancelled shipment does not hold the order back), pagination, and Judge.me keys (shop-level reviews skipped; an HTTP error throws).backend/src/review-requests/review-request.db.spec.ts(DB tier) — a dry run writes nothing; a second run sends nothing; a later order of the same product is not asked about while a new product is; a failed send releases its claim and the next run retries; a crashed run's'sending'row blocks a repeat; a Judge.me failure sends nothing; off without config. Letting the claim through on conflict fails 3; skipping the release fails 1.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 five gate conditions: collected, has handle, has patch art, 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 no longer carries a 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 Judge.me's own request emails should be switched off now that the backend sends the day-after-delivery ask. Until they are, a buyer can receive both.
-
Whether
STORE_COMING_SOONis retired or held in reserve. It is off in production (2026-10-29) and the code calls the wall "currently DOWN", but the allowlist is still maintained; no ticket says which. -
Whether the deleted patch-commerce URLs still receive traffic (
/packs,/open-patches,/collections, …, all 404 sincebe767d53). Netlify's 404 log was not read; if they do,legacy-page-routes.tsis where a redirect goes. -
Whether
5706da7c(hat buy-now) is on the deployed storefront. The commit is ondevelop/mainand the store deploys from Netlify; the live bundle was not inspected for it. -
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:75-84) — 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— resolved 2026-10-29: both pages were deleted (canSeeCollectorSections()is meant to be wired to/my-patchesand/collectionsbe767d53), so the predicate has nothing to guard. It,storeUserStateandCatalogListing.tsxare dead code to delete or revive, not a wiring gap. -
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.