Back to Spree

Store Channel Context, Channel-Bound Publishable Keys & Wholesale Demo Seeds

docs/plans/5.6-store-channel-context-and-key-binding.md

5.6.06.1 KB
Original Source

Store Channel Context, Channel-Bound Publishable Keys & Wholesale Demo Seeds

Status: In Progress Target: Spree 5.6 Depends on: 6.0-channels-catalogs-b2b.md (Phase 1 channels + Gated Storefront Access — both shipped) Author: Damian Legawiec + Claude Last updated: 2026-07-19

Summary

The 5.5/5.6 channel work gave the v3 Store API server-enforced gated storefront access (storefront_access: public / prices_hidden / login_required), but a storefront still cannot read its own channel context — there is no Store API endpoint exposing the current channel's identity or resolved posture, and publishable keys carry no channel binding, so channel identity is entirely client-asserted via X-Spree-Channel. This plan closes both gaps and seeds a gated Wholesale channel by default, so the reference storefront can ship a blended DTC + login-gated B2B wholesale portal on one backend — something none of Shopify (dedicated store + third-party lock apps), Medusa (separate B2B storefront app), Vendure (one channel per deployment), or Saleor (no native gating) demo today.

Key Decisions (do not deviate without discussion)

  • GET /api/v3/store/channel (singular) exposes the current channel context only. No channels list endpoint in the Store API — enumeration would leak the existence of wholesale/POS channels to any holder of the public key, and a storefront has no legitimate need for it.
  • The endpoint opts out of the login gate (allow_guest_storefront_access!) and exposes resolved values (storefront_access, guest_checkout) — a gated storefront must be able to read "this channel requires login" pre-auth to render a sign-in wall instead of an error page. Raw (nil-able) preference values are an Admin API concern; the Store API serves only what a client should act on.
  • Publishable keys get an optional single-channel binding (channel_id, nullable). nil keeps today's behavior (header → store default). A bound key server-assigns Spree::Current.channel; a conflicting X-Spree-Channel header is a 422 (channel_mismatch), a matching one is allowed. Single binding, not Medusa-style many-to-many — one key per storefront surface is the use case, and it matches the original intent in 6.0-channels-catalogs-b2b.md ("each Channel can have its own PublishableApiKey"). Secret (admin) keys never bind — admin reads span channels.
  • customer_groups (id + name) are exposed on the Store customer serializer. The API's login_required gate is authenticated-vs-guest by design; group membership is the storefront's signal for "approved wholesale buyer" vs merely "logged in". Pricing/catalog correctness never depends on the storefront honoring it — price lists resolve server-side.
  • Wholesale is seeded as a default channel on every store: code wholesale, storefront_access: login_required, guest_checkout: false, inactive-by-default is not an option — gating already makes it invisible to guests. A matching "Wholesale" customer group and a channel-bound publishable key are seeded alongside.

Design Details

GET /api/v3/store/channel

Spree::Api::V3::Store::ChannelController#show renders current_channel (already resolved by ChannelResolution: bound key → X-Spree-Channel → store default). New Spree::Api::V3::ChannelSerializer (store variant):

json
{
  "id": "ch_…",
  "name": "Wholesale",
  "code": "wholesale",
  "default": false,
  "storefront_access": "login_required",
  "guest_checkout": false
}

storefront_access / guest_checkout are the resolved values (resolved_storefront_access / resolved_guest_checkout). No timestamps (Store API convention).

Channel-bound publishable keys

  • Migration: add_column :spree_api_keys, :channel_id + index (no FK constraint per conventions).
  • Spree::ApiKey belongs_to :channel, optional: true + validations: only publishable keys may bind; the channel must belong to the key's store.
  • ChannelResolution resolution order becomes: key binding → header → store default. When the key is bound and a header names a different channel → 422 with error code channel_mismatch.
  • Admin API: channel_id accepted on key create (immutable after creation, like scopes — rebind by minting a new key), exposed on the admin key serializer.

Customer groups on customers/me

CustomerSerializer gains many :customer_groups (id, name) via a minimal CustomerGroupSerializer. Collection is store-scoped (groups belong to a store).

Seeds & sample data

  • Spree::Seeds::Channels — for each store, ensure the default channel exists (already handled by Channel promotion callback) and create the wholesale channel with the gated posture.
  • Spree::Seeds::CustomerGroups — "Wholesale" group per store.
  • Spree::Seeds::ApiKeys — extend to also mint a Storefront (Wholesale) publishable key bound to the wholesale channel.
  • Sample data: publish the sample catalog to the wholesale channel (subset + a few wholesale-only products), so a fresh install demos a differentiated gated catalog.

SDK

client.channel.get() on @spree/sdk (the channel config option + setChannel() already exist). Generated StoreChannel type via typelizer.

Migration Path

Additive only. channel_id is nullable; unbound keys behave exactly as before. No backfill.

Constraints on Current Work

  • New Store API surface must keep the no-enumeration rule: never expose other channels' existence/config through Store endpoints.
  • Don't read preferred_storefront_access / preferred_guest_checkout in serializers — always the resolved_* methods.

Open Questions

  • Whether the wholesale sample-data catalog should carry a seeded PriceList for the Wholesale group (nice for the pricing demo; adds seed weight).

References

  • docs/plans/6.0-channels-catalogs-b2b.md — channel model, gating design, "Gated Storefront Access (5.6)" section
  • Competitor positioning research (Shopify blended-vs-dedicated, Medusa key↔sales-channel scoping, Vendure channels, Saleor channels): 2026-07-19 session
  • Reference storefront wholesale portal: spree/storefront feat/wholesale-portal branch