Skip to content

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

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):

  1. Install state — only extensions loaded in the runtime registry contribute.
  2. 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).
  3. when predicate — 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's storefront/ package-data, mounted only when it appears in the build-time $plugins registry (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-slot import() map. StorefrontSlot invokes a loader only when a server contribution fills that slot.
  • PAYMENT_PLUGINS / paymentPluginForGateway(gateway) — a lazy per-gateway import() 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 in PLUGIN_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:

  1. The descriptor's csp domains have been admin-approved (enforced upstream by the composed CSP), and
  2. The visitor's consent for the descriptor's consent category is granted (essential always loads; analytics/marketing require window.__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 anonymous storefrontCspGrants query (backend resolves the channel from the forwarded Host, same as storefrontContributions); 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 excludes default-src, base-uri, object-src, frame-ancestors, and any 'unsafe-*' token.
  • Domain validator (isGrantableDomain / backend _is_valid_domain, kept in lockstep): must be https://, no path/query/fragment, no userinfo (user:pass@… rejected), and no host wildcard (https://*, https://*.evil.com rejected). One exact origin per approval.
  • Each grant carries a trust_tier — today first_party / third_party (an admin click is the only ceiling). The certified / signed tiers 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 (resolveHostTYPESENSE_URL / MEILISEARCH_URL, or the fixed {appId}-dsn.algolia.net template). There is no host field on the public input, so neither a ChannelExtension config nor the browser can point an adapter at an attacker-controlled host.
  • Compliance-scoped fallback: when getSearchAdapter(engine) returns undefined (no engine configured), the storefront falls back to the backend products(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_search is subtracted on search only: search queries drop exclude_from_search products; listing/browse pages keep page-visible exclude_from_search products.

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_states filtering is enforced on the external-index path but not in the compliance-scoped products() 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 / signed trust tiers — not yet shipped (they arrive with signed/certified extension distribution). Today the only ceiling is an admin approval click at first_party / third_party.