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

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

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

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.

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

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):

  1. SignInForm loads Google Identity Services (accounts.google.com/gsi/client) only when NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID is 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).
  2. The form POSTs { idToken } to the shop's own /auth/oauth/google (SignInForm.tsx:125-132). The route refuses a cross-site caller first: isSameOriginRequest wants sec-fetch-site: same-origin|same-site, or failing that an Origin whose host matches (session-core.ts:50-58); a miss is 403 (route.ts:6-8).
  3. 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 guest scout_at, sends it as Authorization: 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 is AuthController.oauth (backend/src/auth/auth.controller.ts:41-49): providers google | apple only, 5 calls per minute, OptionalJwtAuthGuard so the bearer is read when present; engine.oauthSignIn verifies the ID token and returns a Scout access + refresh token pair.
  4. A backend failure is a flat 401 Google sign-in failed to the browser (route.ts:19); success sets scout_at/scout_rt via setAuthCookies and answers { ok: true } (:20-23). The form then emits scout:auth-changed (emitAuthChanged, store/lib/auth/auth-events.ts:13-17, which makes OwnedHandlesProvider refetch ownership at once) and router.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)

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

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