Scout — Full Product Context → feature documentation

Commerce — Storefront, Earned-Not-Bought Gate, and In-App Handoff

Scout sells physical embroidered patches and a hat through a headless Shopify store built as a separate Next.js project (store/).

Screen recordings

Short spans cut from real sessions on a real device. Silent, no narration, no editing beyond the trim.

Badge unlocked 1.4s · recorded 2026-09-09
Store 4.9s · recorded 2026-09-09
Your patches 4s · recorded 2026-09-09
Cart 3.4s · recorded 2026-09-09

Full sessions

The complete, unedited recordings the clips above were cut from — every tap, including the dead ends. These are the raw captures, reframed for the web and otherwise untouched.

Full recording · 09-09 12:27 26.2s · recorded 2026-09-09 · unedited

Summary

Scout sells physical embroidered patches and a hat through a headless Shopify store built as a separate Next.js project (store/). Two purchase surfaces exist: a public web shopfront and an in-app WebView the mobile app opens. The wall that hid the whole public shopfront is now open for APPAREL: /, /shop, /shop/[category], /products/[handle] and /cart are reachable (docs/features/merch-storefront.md). Patch and pack commerce — /collections, /store-exclusives, /my-patches and the old shelves — stays walled. The wall does not say the store is unfinished: it tells the visitor the store is in the app for now and hands them the App Store / Play download links, because the in-app /app tree is shipped and selling (docs/features/mobile-store.md). Most of the catalog is gated by an "earned, not bought" rule: a patch can only be bought once the signed-in shopper has unlocked it in the mobile app, enforced independently on both the storefront and (when a separate feature flag is on) inside the app itself. A second, completely different monetization mechanism — RevenueCat in-app purchases for unlocking a Collection's content — is fully built (backend verification, mobile SDK, purchase sheet, webview UI) but has no reachable trigger anywhere in the app today; it is dead code by an explicit in-repo admission, not a design choice this doc is inferring.

Can a member of the public buy anything right now, and through which URL? Yes, and it is no longer just one page. The apparel shop — /shop, its six category pages and /products/<handle> — is open and populated (docs/features/merch-storefront.md). The hero product is the Trail Hat — Saint Louis Zoo at /products/saint-louis-zoo-trail-hat, $30 marked down to $20, in three colourways (Tan, White, Black); as of 2026-09-16 Shopify holds Tan 8, White 2, Black 0. Patch and pack commerce (/collections, /store-exclusives, /my-patches, …) still renders the coming-soon wall.

/launch was DELETED on 2026-09-16 and now 404s. It was the ad-landing drop page — its own scarcity bar, direct-to-checkout claim button and no site chrome. It went when the desktop store opened, deliberately without a redirect, so every ad, Facebook-group post, end card and shared OG card still pointing at it is broken until repointed (see marketing/BRAND.md). Its one irreplaceable job, firing Meta's ViewContent, moved to /products/[handle] via ProductViewContent — and now fires on every product page rather than one. The in-app store at /app (opened only from inside the mobile app, and only for a signed-in user) is separately reachable and sells the shopper's own collected patches plus swag/promo items.

Does any in-app purchase or subscription exist? No. react-native-purchases is installed and initializes on every app launch, and a full backend verification pipeline (backend/src/purchases/, backend/src/revenuecat/) exists and is wired to a real Collection.productId field that most collections in the DB actually carry — but the one UI action that would open the purchase sheet (tapping a locked collection's pin on the map) was removed when the app dropped SQLite, and nothing replaced it. The trigger function (setSelectedLockedCollection) is called with null everywhere in map.tsx (map.tsx:1374-1392 mounts the sheet off a view-model field nothing sets) and never with a real collection. No subscription product or subscription UI exists anywhere in the codebase.

Status (shipped / beta-badged / flagged off)

Surface State Flag / gate Shipped default
Public web shopfront (/, /products, /collections, /cart, /store-exclusives, /my-patches, /account, /featured, /about, …) Walled behind /coming-soon STORE_COMING_SOON env var, exact string 'true' (store/proxy.ts:61, store/lib/coming-soon.ts:77-83) Confirmed ON in production by live fetch of shop.scout-patches.com/ on 2026-08-31 (X-Robots-Tag: noindex, nofollow). The wall page's title is "The shop is in the app — Scout" as of 2026-09-01 (store/app/coming-soon/page.tsx:9) — a deploy is what makes that live
/launch (Trail Hat drop) DELETED 2026-09-16 — route, components, lib/launch.ts and LAUNCH_* env all removed; no redirect, it 404s N/A Gone
In-app store WebView (/app tree, opened via scout://store-webview) Live, requires sign-in Also in OPEN_PREFIXES; app-side AppStorePage redirects signed-out visitors to /auth/sign-in (store/app/app/page.tsx:64-68) On
Mobile "Purchase Patch" button (patch action sheet + celebration screen) Live in production buy_patch feature flag, defaultEnabled: false in both registries (mobile/src/config/feature-flags.ts:39-43, backend/src/admin/feature-flag-definitions.ts:32-38) — but the production DB row overrides that default to true ON in production (verified live on 2026-09-13 via GET https://scout-patches.com/api/feature-flags). The code default is still false, so a client that cannot reach GET /api/feature-flags hides the button
Storefront "Hats" catalog category Built, hardcoded off getHatsVisible() unconditionally returns false (store/lib/hats-visible.ts:22-24), independent of the storefront_hats_enabled DB flag Off (only path to a hat sale is the single After-Dark hat SKU, sold as an ordinary product page)
Hat Studio combo builder / blank-hat picker Removed, not present in the current tree N/A — deleted in commit 55cf2123 ("remove the hat builder, combo cart, and STL Zoo hat") N/A
RevenueCat in-app purchase (Collection.productId unlock) Built end-to-end, unreachable No flag — the UI trigger was deleted; map.tsx:388-402 explicitly documents this as "dead code left by the SQLite removal, tracked for a follow-up"; the sheet is now reachable only in the Screen mocks gallery, scout://dev-screen-mock/map?state=purchase Effectively off (no code path sets it on)
storefront_curated_mvp ("Curated catalog (MVP)" — only 30 hand-picked patches shown) Shipped Feature flag, defaultEnabled: true (backend/src/admin/feature-flag-definitions.ts:53-60) On
hat_studio_combo (bundle cart line for hat+patch) Built, off Feature flag, defaultEnabled: false (backend/src/admin/feature-flag-definitions.ts:61-67) Off — and the UI it would gate (Hat Studio) is deleted, so this flag currently has nothing to turn on
Customer reviews (Judge.me, rendered natively) Live on product and collection pages, the catalog homepage, and as a star row on shelf/grid product cards (GalleryPiece's rating prop, fed once per page by getRatingsByProduct) JUDGEME_PRIVATE_TOKEN + JUDGEME_SHOP_DOMAIN; unset either and reviews silently disappear On since 2026-09-01. RatingSummary + ReviewsSection are mounted in PatchDetail.tsx and CollectionDetail.tsx, which the in-app /app/products/[handle] page reuses verbatim, so the app tree inherits both
Review form (/review/<handle>) Live, unlisted — open through the coming-soon wall, noindex, nothing links to it JUDGEME_SHOP_DOMAIN only; the create endpoint needs no auth On since 2026-09-11. The only path to a review that reaches a product page: Judge.me's own requests degrade to a "Store review fallback" for our out-of-store catalogue. Reviews publish unverified and earn the verified badge if the reviewer confirms the email they ordered with

User-facing surfaces

How it works (end-to-end mechanism)

Catalog and checkout

Products live in Shopify; the storefront reads them via the Shopify Storefront API only (never the Admin API for reads). Scout's own Postgres (via the backend NestJS API) stores which patches exist, which Collections they belong to, and each patch/collection's Shopify productHandle. A product page cross-references the two: Shopify for price/inventory/images, the backend for whether the item requires an in-app unlock.

Checkout is Shopify's own hosted checkout, reached via cartCreate (store/lib/shopify/cart-actions.ts:52-104). The Next.js app never handles payment; it builds a cart with merchandiseId/quantity lines, attaches scout_session_id and (if signed in) scout_user_id as cart attributes, and redirects to the returned checkoutUrl — which resolves to Shopify's primary domain, checkout.scout-patches.com (backend/src/scripts/lib/shopify-publications.ts:7-9). Confirmed live: that domain currently serves Shopify's default, unconfigured Liquid theme (a generic "Welcome to our store" page with placeholder $19.99 products) — consistent with the catalog being deliberately unpublished from it (see Configuration below).

Order/webhook pipeline

Shopify calls POST /webhooks/shopify/:topic on orders/paid, products/update, refunds/create. Every call is HMAC-verified (x-shopify-hmac-sha256 against the raw body) and de-duplicated by x-shopify-webhook-id before any handler runs (backend/src/shopify-webhooks/shopify-webhooks.controller.ts:32-87). The orders/paid handler reads the scout_session_id/scout_user_id cart attributes back out of note_attributes, converts prices to cents, and upserts a StoreOrder row keyed by Shopify's GraphQL order id (backend/src/shopify-webhooks/handlers/orders-paid.handler.ts:39-70).

Earned-not-bought — mechanism 1: the per-product buy gate

Every patch is either requiresUnlock: true or false. The backend derives this per patch: a patch requires unlocking unless it belongs to at least one Collection with publicPurchase: true (backend/src/store-collections/store-collections.service.ts:109-114). A patch in zero collections also requires unlocking (empty-array .some() is false, negated to true).

On the client, PatchBuyArea (store/components/store/PatchBuyArea.tsx:21-57) calls isBuyable({ requiresUnlock, productHandle }, ownedHandles) (store/lib/store/ownership.ts:11-16): buyable if the item doesn't require unlock, OR the signed-in shopper's owned-handle set contains it. Ownership comes from OwnedHandlesProvider, which fetches GET /api/owned-handles (a thin Next.js route that forwards to the backend's GET /api/store/my-patches, JWT-authed) on mount, on every route change, and on a scout:auth-changed event (store/components/store/OwnedHandlesProvider.tsx:51-88, store/app/api/owned-handles/route.ts). Not buyable → LockedCallout renders an "Earned, not bought" message instead of a buy button (store/components/store/LockedCallout.tsx).

Earned-not-bought — mechanism 2: three-state section visibility

Independent of the per-product gate, the whole catalog's scope depends on who's looking. storeUserState({ signedIn, hasCollected }) returns one of 'guest' | 'member' | 'collector' (store/lib/store/access.ts:13-19): guest (signed out), member (signed in, owns 0 patches), collector (signed in, owns ≥1 patch). CatalogPage uses this to decide whether to render the buyable-only catalog (public-purchase patches only — used for guest/member, because ~90% of the real catalog is locked and an unscoped listing "reads as a store that won't sell anything", store/components/store/CatalogPage.tsx:204-232) or the full field guide (collectors, or anyone who explicitly opts in with ?show=all). Two collector-only pages — /my-patches and /collections — are meant to be gated the same way via canSeeCollectorSections() (store/lib/store/access.ts:22-23), though at the time of this doc that predicate has no call site outside its own test — it is defined but not yet wired to a page guard.

Earned-not-bought — mechanism 3: the completed-set purchase gate

Every sellable collection also has its own Shopify product (product_type: 'Patch Collection') — a scout can buy the whole set as one line rather than patch by patch. That product is gated exactly like an individual patch: canBuySet({ patchCount, ownedMemberHandles, publicPurchase }) (backend/src/store-collections/set-purchase-gate.ts:12-21) allows the buy only once ownedMemberHandles >= patchCount, with a publicPurchase (store-exclusive) collection exempted outright since nobody earns those. The predicate is implemented twice, byte-for-byte identically — backend (set-purchase-gate.ts) and storefront (store/lib/store/set-gate.ts) — because, per the per-patch gate's own posture above, neither side may trust the other.

The denominator is the sellable members, not Collection.patchCount. evaluateSetCompletion (store/lib/store/set-gate.ts:44-72) deliberately measures against memberHandles.length — the members that actually have a Shopify product to buy — never the collection's raw member count. Some members have no artwork or no Shopify listing of their own; gating on the raw count would make those collections permanently uncompletable. Measured against production: Texas Hill Country has 11 members but only 4 are sellable, so "complete" there means owning those 4, not all 11. This is a real, deliberate narrowing of "earned, not bought" to "you own every member we can actually sell you" — not "you own the whole collection."

The gate renders in CollectionBuyArea (store/components/store/CollectionBuyArea.tsx:47-77, mounted from CollectionDetail.tsx:76-84, reused verbatim by the in-app /app/products/[handle]//app/collections/[slug] trees): a locked "Earned, not bought" callout naming how many sellable patches remain, or a live AddToCartButton once the set is complete. The "Your sets" shelf (store/components/store/SetShelf.tsx, via store/lib/store/set-shelf-buy-state.ts:24-30) uses the same predicate to decide whether a shelf card offers "Order the set" or shows a locked remaining-count.

A field called canBuyPublicly is also computed and typed on the backend's ProductContext (store-collections.service.ts:71,413-417) — canBuySet evaluated with zero known ownership, i.e. the most an unauthenticated, publicly-cached endpoint can honestly say on its own. It is read nowhere — no storefront consumer branches on it (verified: grep -rn canBuyPublicly backend store returns only declarations — the backend DTO, the frontend ProductContext mirror at store/lib/store-catalog.ts:36 — its own computation, and backend spec assertions; zero read sites under store/). It is not live enforcement; a signed-in shopper's real completion is re-evaluated entirely client-side against useOwnedHandles().

Which mechanisms fail open, and which fail closed

The coming-soon wall

store/proxy.ts (Next.js middleware) checks every request before anything else. If STORE_COMING_SOON === 'true' and the path isn't in an explicit allowlist (OPEN_PREFIXES = ['/app', '/auth', '/api', '/gear', '/display', '/field-records', '/review'], plus static asset requests — store/lib/coming-soon.ts) and no valid ?preview=<token> cookie/param is present, it rewrites (not redirects) the request to /coming-soon and sets X-Robots-Tag: noindex, nofollow (store/proxy.ts:60-71, store/lib/coming-soon.ts). Rewrite-not-redirect means the URL a shopper or an ad landed on stays in the address bar, so the same link works unchanged the moment the wall comes down. ?preview=<STORE_PREVIEW_TOKEN> grants a 30-day bypass cookie for internal browsing (store/proxy.ts:54-58, 128-140).

Collecting reviews: the store-review fallback, and the way around it

Judge.me schedules a review request off Shopify's Fulfilled trigger, on by default since install, 14 days after fulfillment. For our catalogue every one of those requests is marked "Store review fallback" in Judge.me's Review requests dashboard — verified live 2026-09-11 for orders #1003, #1004 and #1005. The cause is the same in_store: false that 404s Judge.me's widgets: our products are not published to Shopify's Online Store channel, so Judge.me will not ask a buyer about a product, only about the shop.

That matters because a shop-level review never reaches a product page. getProductReviews filters by Judge.me's product id and summarizeByProduct skips any review without a product_external_id (store/lib/judgeme-shape.ts), so a store review appears only in the catalog homepage's getStoreReviews list — never on a product page, never in a shelf star row.

POST /api/v1/reviews has no such restriction. It takes the bare Shopify product id, and Judge.me's own spec says "if blank, the review is considered as a shop-level review" — so supplying it is the whole trick. /review/<handle> (store/app/(standalone)/review/[handle]/page.tsx) resolves the handle through the existing getProductDetail, hands the product's gid to ReviewForm, and the form posts to /api/review. lib/judgeme-submit.ts validates, drops honeypot hits with a 202 that looks like success, rate-limits, and sends the create with id set. Submitted reviews auto-publish (autopublish: true on the account), so they are live on the product page immediately.

The page sits in the (standalone) route group rather than (store), and that is load-bearing rather than tidiness. (store)'s Header and Footer are the shopfront's, so while the coming-soon wall is up they advertise routes the wall hides: Collections and Store Exclusives on the nav, and almost every footer link — All patches, Route 66, NFL Stadiums, About, How to iron on, Track an order — answer "The shop is in the app". A buyer who is handed this link by name got a review form wrapped in dead ends. Nested layouts COMPOSE in the App Router, so no layout under (store)/review could have dropped that chrome; leaving the group is the only mechanism, which is the same reasoning /coming-soon records for sitting outside it as a top-level route.

app/(standalone)/layout.tsx renders a bar with the Scout wordmark and nothing else, and no footer. The wordmark is deliberately not a link: / is the first route the wall hides, so linking it would make the one remaining piece of chrome another dead end. The route group's parentheses keep the URL identical, so every review link already sent still resolves. store/lib/standalone-routes.test.ts fails the build if the page moves back under (store), if the layout grows a Header or Footer, or if the wordmark gains an href.

The honeypot that ate real reviews (fixed 2026-09-14)

judgeme-submit.ts drops any submission whose hidden field is filled and answers 202, which the route maps to the same {ok:true} a success gets — deliberately, so a bot learns nothing. The cost of that design is that a false positive is indistinguishable from success: the customer sees "Thank you — that means a lot", and the review is discarded.

The field was name="website". Password managers (1Password, LastPass, Dashlane, Bitwarden) autofill by field NAME and do not honour hidden or aria-hidden, and website sits squarely in their heuristics. At least two real reviews were lost this way, with no server-side record — the drop was silent in both directions, which is why nothing could be found afterwards.

Three changes, because the first alone would not have been noticed:

  1. The name is now HONEYPOT_FIELD (ref_token), exported from judgeme-submit.ts and imported by ReviewForm so 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.
  2. The input carries the documented per-manager opt-outs: data-1p-ignore, data-lpignore, data-bwignore, data-form-type="other".
  3. 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.

  1. 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.
  2. 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 to next — 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).
  3. 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 checks sec-fetch-site to 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) via setAuthCookies.
  4. 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).
  5. Every mobile → store link is prefixed into the /app tree by storeHref(), which is idempotent and boundary-safe (/apparel is not treated as inside /app just because it starts with the same four characters) (mobile/src/config/store.ts:126-138).

Data model (Prisma, backend/prisma/schema.prisma)

API surface

All under the backend NestJS app unless noted.

Method & path Auth Purpose
GET /api/store/patches, GET /api/store/patches/public none Catalog patch listings (store-collections/store-patches.controller.ts)
GET /api/store/catalog/:handle none (cached 5 min) Per-product requiresUnlock + collection context (store-catalog.controller.ts)
GET /api/store/collections, GET /api/store/collections/:handle none Collection listing/detail
GET /api/store/inventory-visibility none Which handles are visible under limited_inventory_mode
GET /api/store/my-patches JWT The signed-in shopper's owned product handles — the ownership source of truth for both gate mechanisms (store-my-patches.controller.ts:15-18)
GET /api/store/orders, GET /api/store/orders/:id JWT A shopper's own order history
POST /api/store/analytics rate-limited (ThrottlerGuard), no auth Storefront funnel event ingestion
`GET /api/admin/store/analytics/funnel products searches`
GET/POST/PATCH/DELETE /api/admin/store-finances/* AdminGuard Revenue/expense/manual-sale admin CRUD + Shopify sync
GET /api/store/hat-favorites none List saved Hat Studio combos
POST/DELETE /api/store/hat-favorites StorefrontTokenGuard Mutate Hat Studio combos (internal-tool token, not a user session)
GET /api/store/feature-flags none Public read of store feature flags
PUT /api/admin/store/feature-flags/:key AdminGuard Toggle a store flag
POST /webhooks/shopify/:topic Shopify HMAC signature orders/paid, products/update, refunds/create
POST /purchases/verify JWT Verify a RevenueCat receipt, write UserPurchase (currently unreachable from any UI)
GET /purchases JWT List a user's UserPurchase rows
POST /purchases/migrate JWT Move guest-device RevenueCat purchases onto a newly signed-in account

Next.js BFF routes (store/app/api/**), all internal to the storefront itself: POST /api/auth/native (see handoff above), GET /api/auth/me, GET /api/owned-handles, GET /api/dev/* (dev-only), GET /api/analytics/*.

Key files

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

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