docs/plans/5.6-store-channel-context-and-key-binding.md
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-23
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.
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.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.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, 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 is seeded alongside. No channel-bound key is seeded: the default unbound key serves every channel via X-Spree-Channel, so binding stays a purely opt-in hardening (decision 2026-07-23; the original plan seeded a bound Storefront (Wholesale) key, since removed).GET /api/v3/store/channelSpree::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):
{
"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).
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.channel_id accepted on key create (immutable after creation, like scopes — rebind by minting a new key), exposed on the admin key serializer.customers/meCustomerSerializer gains many :customer_groups (id, name) via a minimal CustomerGroupSerializer. Collection is store-scoped (groups belong to a store).
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 — seeds only the unbound default key (see Key Decisions: no bound key is seeded).client.channel.get() on @spree/sdk (the channel config option + setChannel() already exist). Generated StoreChannel type via typelizer.
spree init)A project scaffolded with storefront + sample data gets the reference storefront's wholesale portal enabled with zero manual env editing:
SPREE_WHOLESALE_CHANNEL=wholesale into apps/storefront/.env.local — that's the whole setup: the portal runs on the storefront's regular SPREE_PUBLISHABLE_KEY, with the channel header selecting the channel (no bound key involved; SPREE_WHOLESALE_PUBLISHABLE_KEY stays an unset opt-in hardening). Gated on the sample-data choice — the channel is seeded regardless, but only sample data ships the "Wholesale" customer group + trade price list that make the portal walkable. Deliberately no pre-approved demo buyer: users register on the portal and approve the account by hand (add it to the "Wholesale" customer group in the admin) — the approval flow is part of the demo.spree init, or the deferred path through spree dev) points at the portal in its summary when the storefront opted in and sample data was loaded; it does not mint or write any wholesale-specific key.spree:cli:ensure_api_key now only returns unbound keys (channel_id: nil) — handing the storefront a channel-bound key would pin every request to that channel.Additive only. channel_id is nullable; unbound keys behave exactly as before. No backfill.
preferred_storefront_access / preferred_guest_checkout in serializers — always the resolved_* methods.docs/plans/6.0-channels-catalogs-b2b.md — channel model, gating design, "Gated Storefront Access (5.6)" sectionspree/storefront feat/wholesale-portal branch