Storefront Contributions¶
The storefront contribution bus lets an extension add customer-facing storefront UI, payment tokenizers, external scripts, age gates, notification opt-ins, and search backends declaratively, without editing core storefront code. It is the storefront counterpart of the Admin Contributions bus and the backend authoring contract.
The organising idea is a headless contract: the root
storefrontContributions(channel) GraphQL query is the storefront
surface. A merchant who replaces the shipped SvelteKit storefront reads
the same per-channel descriptor list and renders it themselves. Every
storefront capability an extension can contribute is expressible as a
{slot, kind, order, source_extension, payload} row — core (or a headless
client) draws the descriptor; the extension ships only data. That is the
fee pattern (availableFees → core-rendered checkout rows) generalized.
Where this fits
- Backend authoring contract: Building Extensions.
- Admin-side sibling bus: Admin Contributions.
- Storefront app internals (Houdini, BFF, i18n): Storefront Developer Guide.
- Per-extension operational docs: Extensions section.
The headless contract: storefrontContributions¶
storefrontContributions(channel) returns every enabled contribution a
storefront should render for a channel. It is served after
server-side filtering (backend/vectis/core/storefront_contributions.py,
extension_resolvers.py):
- Install state — only extensions loaded in the runtime registry contribute.
- Channel enablement — a channel-disabled extension
contributes nothing. This is the deliberate divergence from the admin
bus, where channel enablement is not used as a visibility filter (an admin
must reach a disabled extension's settings to enable it). The storefront is
customer-facing, so a disabled extension must stay invisible. The resolver
filters on
load_channel_extensions(channel_id). whenpredicate — the same safe, no-eval, depth-capped AST evaluator shared verbatim with the admin bus. A predicate that turns out malformed at evaluation is skipped and logged (per-surface isolation), never an error.
There is no staff gate — callers are anonymous or customer sessions on
the shared GraphQL endpoint. It is safe because a storefront contribution
lives in a disjoint manifest field (storefront_contributions) with a
disjoint slot vocabulary, so admin-only payloads can never leak here.
Output rows carry source_extension provenance and are stably sorted by
order (default 100).
Declaring contributions¶
An extension declares storefront UI on
ExtensionManifest.storefront_contributions, a list of dicts. Each entry
mirrors the admin bus shape:
storefront_contributions=[
{
"slot": "checkout.section.before-payment",
"kind": "descriptor",
"order": 60,
"payload": {
"component": "optin",
"channelKey": "sms_marketing",
"label": "Text me order updates & offers",
"description": "Receive SMS notifications about your order and promotions.",
},
},
]
| Field | Meaning |
|---|---|
slot |
Canonical slot id (validated — see Slots). |
kind |
descriptor (core-drawn) or component (synced Svelte file). |
payload |
Kind-specific dict, opaque to core. |
order |
Sort within a slot (default 100). |
when |
Optional no-eval predicate AST (shared with the admin bus). |
Per-surface failure isolation. Contributions are validated at
manifest-registration time (build_validated_storefront_contributions). A bad
one (unknown slot, unknown kind, malformed when) is disabled and logged — it
never fails the extension's activation and the others still serve.
Slots¶
Slot ids are validated against a canonical registry
(storefront_contributions.is_valid_slot; enumerate the full set with
all_slot_ids()). Unknown slots disable the contribution (logged, never
fatal).
Static slots
| Slot | Surface |
|---|---|
gate.interstitial |
Age-gate / interstitial overlay body |
cart.line.meta |
Per-cart-line metadata region |
cart.summary.after-totals |
Below the cart order-summary totals |
order.confirmation.after-summary |
Below the order-confirmation summary card |
layout.body-end |
End of <body> (scripts, floating UI) |
account.nav |
Extra storefront account-nav entries |
Patterned slots
| Pattern | Values | Surface |
|---|---|---|
checkout.section.<pos> |
pos ∈ {before-shipping, after-shipping, before-payment, after-payment, before-submit} |
Checkout step regions |
product.detail.<pos> |
pos ∈ {after-gallery, after-description, badges} |
Product-detail regions |
Not a slot
Full pages under /ext/<name>/** are not contributions — they ship as
storefront_pages package-data synced by the extension-UI sync pipeline (same policy
as admin_pages), and never go through the bus.
Rendering models: StorefrontSlot¶
StorefrontSlot (storefront/src/lib/$plugins/StorefrontSlot.svelte) is the
empty-safe mount point. For a named slot it renders the intersection of:
- Descriptors (
kind: "descriptor") — server-described UI drawn by core (the Tier-2 fee pattern generalized). No extension code runs in the browser. - Components (
kind: "component") — a synced Svelte file from the extension'sstorefront/package-data, mounted only when it appears in the build-time$pluginsregistry (Tier-1).
<StorefrontSlot
slot="cart.summary.after-totals"
contributions={data.storefrontContributions}
context={{ cart }}
/>
Empty-safe (the LCP rule). With no contribution for a slot, the component
renders nothing — no wrapper element, no plugin chunk. A component
contribution triggers a lazy import() only when a matching build-time
entry exists (matched by (ext, file)), so a slot chunk never ships on a route
whose slots are all empty and never blocks core hydration.
The lazy $plugins registry¶
storefront/src/lib/$plugins/registry.ts is auto-generated by the sync
pipeline (make sync-extensions; do not hand-edit). It exposes:
PLUGIN_SLOTS/pluginLoadersForSlot(slot)— a lazy per-slotimport()map.StorefrontSlotinvokes a loader only when a server contribution fills that slot.PAYMENT_PLUGINS/paymentPluginForGateway(gateway)— a lazy per-gatewayimport()map for payment modules (see below). A payment component is chosen by gateway name at checkout, not by a contribution row, so it lives here rather than inPLUGIN_SLOTS.
Note
Storefront $plugins consumption is shipped — this page is the current contract.
Core-drawn descriptor kinds¶
StorefrontSlot core-renders these Tier-2 descriptor payload.component
values today:
payload.component |
Rendered by core | Payload |
|---|---|---|
notice |
A styled note (tone ∈ neutral\|success\|warning) |
{title?, message?, tone?} |
optin |
A notification opt-in checkbox (see Notification opt-ins) | {channelKey, label?, description?} |
Unknown descriptor components render nothing (forward-compatible: a newer extension can declare a kind a future core release teaches itself to draw).
Payment modules: PaymentMethodModule + paymentClientConfig¶
The storefront no longer has hardcoded per-gateway checkout branches. A gateway's
browser tokenizer now ships inside its own extension as a
PaymentMethodModule Svelte component, chosen by gateway name from
PAYMENT_PLUGINS. Each module exposes the contract via a bindable module
prop:
interface PaymentMethodModule {
tokenize(): Promise<{ token: string; /* ... */ }>;
authenticate?(): Promise<unknown>; // e.g. 3DS step-up
}
The shipped modules are ext_authorize_net (Accept.js) and ext_nmi
(Collect.js with 3DS collapsed in). Both tokenize client-side, then the
storefront calls the generic processPayment mutation. The
charge flow is a zero-diff move.
paymentClientConfig(gateway) (backend/vectis/modules/payment/resolvers.py)
delivers the public-only config a tokenizer needs — public keys, the
sandbox flag, allowed brands, and the env-correct vendor SDK URLs — so the
storefront stops deriving SDK URLs itself and stops reading secrets off the
generic gateway config blob. It returns only keys whitelisted per gateway
(_GATEWAY_PUBLIC_CONFIG_KEYS); secrets (transaction_key,
security_key) are never present. A headless merchant reads this, tokenizes
with the vendor SDK, and calls processPayment — no dependency on the shipped
Svelte components.
Live tokenization unverified
Live NMI/Authorize.Net sandbox tokenization is unverified — vendor CDNs
are network-sealed in the build/test environment and NMI_SECURITY_KEY is
unset. This is a standing verification TODO (see the
Payments usage guide), not a code gap.
External scripts + admin-approved CSP¶
The storefront moves the Content-Security-Policy boundary from a code-change to an admin-approval click, without weakening it.
Declaring a script/pixel¶
An extension declares an external script via a layout.body-end descriptor
whose payload carries {id, src, location, load, consent, csp{...}}.
ScriptInjector (storefront/src/lib/components/ScriptInjector.svelte) renders
the tag only when:
- The descriptor's
cspdomains have been admin-approved (enforced upstream by the composed CSP), and - The visitor's consent for the descriptor's
consentcategory is granted (essentialalways loads;analytics/marketingrequirewindow.__vectisConsent[category]; DNT honored via the same plumbing).
Security: ScriptInjector renders only src-referenced external tags. It
never injects inline JS (no innerHTML, eval, or inline textContent) —
inline execution stays forbidden by the CSP (script-src has no
'unsafe-inline' in prod and there is no nonce pipeline). A descriptor with only
an inline blob and no src renders nothing. Injection is client-only
(onMount), so SSR never emits an un-consented tag.
Admin-approved CSP grants (approval-for-all)¶
hooks.server.ts composes the response CSP as baseline + admin-approved
grants, additively (storefront/src/lib/server/csp.ts,
backend/vectis/core/csp_grants.py). The hardcoded, load-bearing baseline
(payment SDKs, agechecker, first-party vendors) is unchanged; the owner ratified
no auto-apply. Approved grants only ever widen a grantable directive with
one exact https origin — never remove a baseline source, never relax a policy
token, never add inline JS.
- Grants persist on
ChannelExtension.approved_grants(JSONB) and are served per-channel by the anonymousstorefrontCspGrantsquery (backend resolves the channel from the forwarded Host, same asstorefrontContributions); the storefront caches them in-process with a short TTL. - Grantable directives:
script-src,connect-src,frame-src,img-src,font-src,style-src,media-src. Deliberately excludesdefault-src,base-uri,object-src,frame-ancestors, and any'unsafe-*'token. - Domain validator (
isGrantableDomain/ backend_is_valid_domain, kept in lockstep): must behttps://, no path/query/fragment, no userinfo (user:pass@…rejected), and no host wildcard (https://*,https://*.evil.comrejected). One exact origin per approval. - Each grant carries a
trust_tier— todayfirst_party/third_party(an admin click is the only ceiling). Thecertified/signedtiers are not yet shipped (see Not yet shipped).
Age gate: gateConfig + submitGateVerification¶
A vendor-neutral storefront gate surface
(ext_agechecker/resolvers.py in the enterprise-extensions repo) is available. The storefront reads
gateConfig (not ageCheckerConfig) and submits verification through
submitGateVerification(provider, token):
type GateConfig {
required: Boolean! # gate enforced (provider trigger != disabled)
mode: String! # trigger point: store_wide | checkout
provider: String! # gate provider id (agechecker today)
providerConfig: JSON! # provider-specific data its storefront component needs
branding: JSON! # presentation hints (minimumAge, checkoutPosition, ...)
}
ext_agechecker is the first gate provider; a future age/ID/geo gate ships
as another provider with zero core change. The gate fails closed and still
blocks checkout. The legacy ageCheckerConfig query is deprecated (its three
consumers were migrated), but the gate keeps blocking checkout during the
transition.
Notification opt-ins¶
The notificationChannels query and setNotificationOptIn(channelKey,
optedIn) mutation (backend/vectis/modules/notification/resolvers.py) support opt-ins. An extension
surfaces a storefront opt-in with zero storefront code by declaring an
optin descriptor (payload: {component: "optin", channelKey, label?,
description?}) in any slot. StorefrontSlot core-renders the checkbox: one
notificationChannels fetch hydrates current state (keyed by channelKey) and
toggling POSTs setNotificationOptIn. ext_twilio and ext_omnisend are
descriptor-only — they ship no storefront code.
Search adapter registry + compliance fallback¶
A server-only
adapter registry (storefront/src/lib/server/search.ts) replaces the compile-time engine switch.
Each supported engine is a registered SearchAdapter keyed by engine name
(typesense, typesense_cloud, meili, algolia), normalized to the
Typesense-shaped { hits: [{ document }], found }.
- SSRF guard (hardened, must not weaken): an adapter resolves its upstream
host inside the adapter from server env only (
resolveHost→TYPESENSE_URL/MEILISEARCH_URL, or the fixed{appId}-dsn.algolia.nettemplate). There is nohostfield on the public input, so neither aChannelExtensionconfig nor the browser can point an adapter at an attacker-controlled host. - Compliance-scoped fallback: when
getSearchAdapter(engine)returnsundefined(no engine configured), the storefront falls back to the backendproducts(search:, respectCompliance: true)path (storefront/src/lib/server/federated-search.ts), reusing the existing product-visibility filter — restricted/hidden products never leak. Compliance is reused, not reinvented. exclude_from_searchis subtracted on search only: search queries dropexclude_from_searchproducts; listing/browse pages keep page-visibleexclude_from_searchproducts.
Not yet shipped¶
Marked here so authors don't build against absent tooling:
- Priority1 LTL de-hardcode. The Priority1 LTL freight strategy works today but is still hardcoded; the bidirectional-state refactor through the checkout flow is deferred to its own cycle with e2e coverage.
- State-level compliance in the search fallback. Per-shopper-state
restricted_states/hide_completely_statesfiltering is enforced on the external-index path but not in the compliance-scopedproducts()fallback (which has no shopper-state dimension), so a state-restricted product can surface in the fallback for a shopper in a restricted state. Global rails (is_visible=False,exclude_from_search=True) are enforced. Tracked separately. - Live NMI/Authorize.Net tokenization — unverified in the sealed build/test environment (see the payment note above).
- CSP
certified/signedtrust tiers — not yet shipped (they arrive with signed/certified extension distribution). Today the only ceiling is an admin approval click atfirst_party/third_party.