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-submit",
"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.