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-19
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 and a channel-bound publishable key are seeded alongside.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 — extend to also mint a Storefront (Wholesale) publishable key bound to the wholesale channel.client.channel.get() on @spree/sdk (the channel config option + setChannel() already exist). Generated StoreChannel type via typelizer.
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