Skip to content

Building Extensions

Extensions add capabilities — payment gateways, tax providers, shipping carriers — without modifying the core engine. They are installed as Python packages and discovered automatically at startup via entry points.

Extensions catalog in admin

Filter by strategy category to see what's wired up for a domain — tax, for example:

Extensions filtered by tax strategy

Extension Structure

ext_authorize_net/
├── __init__.py
├── extension.py    # ExtensionProtocol implementation
├── strategy.py     # Strategy implementations
├── resolvers.py    # GraphQL resolvers (saved cards, admin transactions)
└── pyproject.toml  # Package metadata + entry point

ext_elastic_email follows the same layout (__init__.py, extension.py, strategy.py): it registers an ExtensionManifest with config_schema for the Elastic Email API and provides ElasticEmailStrategy, an EmailDeliveryStrategy implementation (vectis.modules.notification.strategies). Wire it where notification delivery is resolved (e.g. admin email settings), using channel extension config for api_key, default_from_email, default_from_name, and api_url.

How Extensions Contribute (Decided #222–#231 + #233)

Vectis enforces a strict separation between core and extensions. Core modules must never import ext_*; an AST guard at backend/scripts/check_no_core_extension_imports.py fails any commit that violates this rule (Decided #233).

To make that rule work, extensions contribute through three distinct mechanisms — pick the right one for the contribution:

1. ExtensionManifest declarations — metadata + admin UI

Inside on_activate, call registry.register_manifest(ExtensionManifest(...)). The manifest fields below are declarative — the core engine reads them later (over GraphQL, in the admin layout, in the schedule runner) without ever importing your extension by name.

Manifest field What it contributes
category One of payment, shipping, tax, compliance, communication, authentication, marketing (others used in practice — fulfillment, ai, support — pass through but aren't in the validated tuple yet)
strategies Names of strategies this extension registers via strategy_resolver (used by admin filters)
depends_on / conflicts_with Other extension names that must (or must not) be active. Missing deps → activation skipped with a warning
config_schema JSON schema for the generic Configure modal in the admin
permissions Permission codenames introduced by the extension
display_name, icon_light, icon_dark Admin presentation
admin_nav_items Sidebar entries — {href, label, section, icon_name, permission?} (Decided #223). section matches a sidebar group; icon_name is mapped explicitly in +layout.svelte
admin_quick_actions Cmd-K palette entries — {href, label, icon_name, permission?} (Decided #226)
admin_page_tabs Tabs grafted onto an existing admin page — {page, label, href, icon_name?, permission?} (Decided #229)
admin_settings_pages Deep-link target for the Configure button on /settings/extensions
admin_tabs Tabs on entity-detail pages
admin_pages, admin_widgets, admin_list_columns, admin_action_bar_items, admin_bulk_actions, admin_form_fields, admin_filters Other admin slot contributions
custom_field_definitions Custom fields per entity type (Decided #96). On registration these flow through custom_field_registry automatically

EXTENSION_CATEGORIES lives in vectis/core/extension.py and is the validated tuple — passing a category not in the tuple is currently silently accepted (no validator), but new categories should be added there before use.

2. Methods on the extension class — functional contributions

Some contributions need actual Python objects (workflow classes, activity callables, GraphQL resolver classes, SQLAlchemy models). For these, declare a method on your extension class that returns the contribution. The registry iterates these lazily from the loaded extensions.

Method Returns Consumed by
def workflows(self) -> list[type] Temporal @workflow.defn classes worker.py at startup via registry.iter_workflows() (Decided #222)
def activities(self) -> list Temporal @activity.defn callables worker.py via registry.iter_activities()
def schedules(self) -> list[dict] {"id", "workflow", "activity", "interval", "note"} dicts schedules.py via registry.iter_schedules()
def search_indexes(self) -> list[dict] {"key", "label", "search_fn"} where search_fn is async (query, limit) -> list[dict] (Decided #227) Cmd-K via registry.iter_search_indexes()
def models(self) -> list[str] Module paths like "vectis.extensions.ext_foo.models" (Decided #230) _import_all_models in app.py / worker.py via ExtensionRegistry.scan_model_modules()
def graphql_queries(self) -> list[type] Strawberry Query mixin classes (Decided #231) core/graphql.py at import time via ExtensionRegistry.scan_graphql_queries()
def graphql_mutations(self) -> list[type] Strawberry Mutation mixin classes core/graphql.py via ExtensionRegistry.scan_graphql_mutations()

Imports inside these methods are lazy on purpose — loading extension.py itself must not drag the Temporal SDK into the GraphQL API container. Always import workflow / activity / model modules inside the method body, not at module top.

scan_* are sync classmethods used before the async registry has finished discovery (model loading, GraphQL schema build); iter_* are instance methods used after discover_and_load() has activated everything.

3. Global singletons — strategies and event handlers

A few contributions go through a global singleton rather than the registry or manifest:

  • Strategiesstrategy_resolver.register(StrategyABC, impl, name=..., extension_name=...) (defined in vectis/core/strategy.py). Last registration for a (type, name) pair wins; pass extension_name=self.name so channel-scoped filtering can drop the strategy when the channel disables your extension.
  • Event handlersevent_bus.subscribe(event_type, handler) for in-process events; @on_event(topic) decorator for Redpanda consumers.

A typical on_activate does manifest registration first, then strategy registration:

class MyExtension:
    name = "my_gateway"
    version = "0.1.0"
    description = "My payment gateway"

    async def on_activate(self, registry: ExtensionRegistry) -> None:
        registry.register_manifest(ExtensionManifest(
            name=self.name,
            version=self.version,
            display_name="My Gateway",
            description=self.description,
            category="payment",
            strategies=["my_gateway"],
            permissions=["payment.my_gateway.manage"],
            config_schema={
                "api_key": {"type": "string", "required": True, "secret": True},
            },
        ))
        strategy_resolver.register(
            PaymentProcessStrategy,
            MyGatewayStrategy(),
            name="my_gateway",
            extension_name=self.name,
        )

Admin Routes via admin_pages/ (Decided #224)

Beyond admin slots, an extension can ship full SvelteKit routes. Drop them into the extension directory:

backend/vectis/extensions/ext_my_gateway/
└── admin_pages/
    ├── +page.svelte          → /extensions/my_gateway
    ├── +page.server.ts
    └── settings/
        ├── +page.svelte      → /extensions/my_gateway/settings
        └── +page.server.ts

Run make sync-admin-extensions (or python backend/scripts/sync_admin_extensions.py) — it mirrors admin_pages/ into admin/src/routes/extensions/<name>/. The mirror destination is gitignored; production deploys must run the sync before docker build admin.

Deep-link from the admin's Configure button by setting admin_settings_pages on the manifest.

Lifecycle & Install State (Decided #148)

ExtensionRegistry._activate consults InstallStateService (defined in vectis/core/extension_lifecycle.py) before invoking on_activate. State is persisted in the extension_install_state table:

Column Purpose
extension_name Extension's name attribute
version Version at install time
installed_at Timestamp of the on_install call
uninstalled_at Timestamp of mark_uninstalled (null while installed)

InstallStateService.previous_version(name) returns the latest LIVE installed version (rows with uninstalled_at set are ignored). Behaviour:

  • First boot with the extension's entry point present → previous_version returns Noneon_install fires → mark_installed writes a row → on_activate fires.
  • Subsequent boots same version → only on_activate fires.
  • Version bumpon_upgrade(registry, prev_version) fires before on_activate; mark_installed updates the row.
  • Explicit uninstall via registry.uninstall(name)pre_uninstall_check() collects blockers (anything truthy blocks unless force=True); on pass, on_uninstall fires, mark_uninstalled stamps the row, registry drops the in-memory entry.

The lifecycle hooks (each optional except on_activate):

Hook When What to do
async on_install(self, registry) First-ever activation of (name, version) per the ledger One-time setup: seed default settings, create webhook subscriptions, reserve channel-extension entries
async on_activate(self, registry) Every process boot Register manifest, register strategies, attach event handlers, register custom fields
async on_upgrade(self, registry, prev_version) Activation when persisted version differs from current Run extension-owned migrations, update settings shape
async on_deactivate(self, registry) Deactivation via admin UI Tear down handlers, deregister strategies
async on_uninstall(self, registry) Uninstall after pre_uninstall_check() passes Drop extension-owned tables, clear cached data, revoke API keys
async pre_uninstall_check(self) Before on_uninstall (Decided #150) Return a list of blockers (or an UninstallBlockers object) — block uninstall when extension owns in-flight state (e.g., unsettled refunds)

Worker Strategy-Registration Gate (Decided #233)

On boot the Temporal worker iterates every payment method enabled in the DB and verifies a registered strategy exists for it. In strict mode (default) the worker exits non-zero if any enabled method has no registered strategy — catches the cluster-state bug where you remove an extension package but forget to disable its payment methods.

Override with the env var VECTIS_WORKER_STRICT_STRATEGY_CHECK=false (also accepts 0, no, empty string — case insensitive). Non-strict mode logs the issues as warnings and proceeds. The check lives in vectis/core/extension_readiness.py.

End-to-End Walkthrough: a New Payment Gateway

Here's the full sequence for landing a brand-new payment-gateway extension ext_my_gateway. Everything lives under backend/vectis/extensions/ext_my_gateway/:

1. File layout

backend/vectis/extensions/ext_my_gateway/
├── __init__.py
├── extension.py         # extension class + on_activate
├── strategy.py          # PaymentProcessStrategy implementation
├── resolvers.py         # optional: extension-owned GraphQL
├── models.py            # optional: extension-owned tables
├── workflows.py         # optional: Temporal workflows
├── activities.py        # optional: Temporal activities
└── admin_pages/         # optional: SvelteKit routes (Decided #224)
    └── +page.svelte

2. extension.py — the entry point

from __future__ import annotations

import logging
from vectis.core.extension import ExtensionManifest, ExtensionRegistry
from vectis.core.strategy import strategy_resolver
from vectis.modules.payment.strategies import PaymentProcessStrategy

logger = logging.getLogger(__name__)


class MyGatewayExtension:
    name = "my_gateway"
    version = "0.1.0"
    description = "MyGateway payment gateway with tokenized profiles"

    # Decided #230: extension-owned tables. Lazy so loading the
    # extension module doesn't trigger model imports outside the
    # SQLAlchemy bootstrap path.
    def models(self) -> list[str]:
        return ["vectis.extensions.ext_my_gateway.models"]

    # Decided #231: GraphQL contributions.
    def graphql_queries(self) -> list[type]:
        from vectis.extensions.ext_my_gateway.resolvers import MyGatewayQuery
        return [MyGatewayQuery]

    def graphql_mutations(self) -> list[type]:
        from vectis.extensions.ext_my_gateway.resolvers import MyGatewayMutation
        return [MyGatewayMutation]

    # Decided #222: Temporal contributions.
    def workflows(self) -> list[type]:
        from vectis.extensions.ext_my_gateway.workflows import MyGatewayRefundWorkflow
        return [MyGatewayRefundWorkflow]

    def activities(self) -> list:
        from vectis.extensions.ext_my_gateway.activities import my_gateway_refund
        return [my_gateway_refund]

    async def on_install(self, registry: ExtensionRegistry) -> None:
        # One-time: seed default settings rows, register webhook subscription
        ...

    async def on_activate(self, registry: ExtensionRegistry) -> None:
        registry.register_manifest(
            ExtensionManifest(
                name=self.name,
                version=self.version,
                description=self.description,
                display_name="My Gateway",
                category="payment",
                strategies=["my_gateway"],
                permissions=["payment.my_gateway.manage"],
                config_schema={
                    "api_key": {"type": "string", "required": True, "secret": True},
                    "merchant_id": {"type": "string", "required": True},
                    "sandbox": {"type": "boolean", "default": True},
                },
                admin_nav_items=[{
                    "href": "/extensions/my_gateway",
                    "label": "My Gateway",
                    "section": "Payments",
                    "icon_name": "CreditCard",
                    "permission": "payment.my_gateway.manage",
                }],
                admin_settings_pages=[{
                    "path": "/extensions/my_gateway/settings",
                    "label": "Settings",
                }],
            )
        )
        strategy_resolver.register(
            PaymentProcessStrategy,
            MyGatewayStrategy(),
            name="my_gateway",
            extension_name=self.name,
        )
        logger.info("MyGateway extension activated")


extension = MyGatewayExtension

3. Entry pointbackend/pyproject.toml

[project.entry-points."vectis.extensions"]
ext_my_gateway = "vectis.extensions.ext_my_gateway.extension:extension"

Reinstall the backend package (pip install -e ".[dev]") and restart the API + worker.

4. Migrations — run Alembic against the new models

alembic revision --autogenerate -m "add my_gateway tables"
alembic upgrade head

The models() method ensures _import_all_models loads your models before autogenerate runs.

5. Admin routes — if you ship admin_pages/:

make sync-admin-extensions

The script mirrors ext_my_gateway/admin_pages/ into admin/src/routes/extensions/my_gateway/. Restart the admin dev server.

6. Enable per-channel

session.add(ChannelExtension(
    channel_id=1,
    extension_name="my_gateway",
    enabled=True,
    config={"api_key": "...", "merchant_id": "...", "sandbox": True},
))

Or use the admin Settings → Extensions page once the manifest is registered.

7. Verify — visit /settings/extensions in admin (extension should appear), check the worker startup log for "X core + 1 extension workflows", and trigger a test transaction.

Implementing ExtensionProtocol

The on_activate hook registers strategies and metadata at startup:

from vectis.core.extension import ExtensionRegistry, ExtensionManifest
from vectis.core.strategy import strategy_resolver

class AuthorizeNetExtension:
    name = "authorize_net"
    version = "1.0.0"
    description = "Authorize.Net payment gateway"

    async def on_activate(self, registry: ExtensionRegistry) -> None:
        from ext_authorize_net.strategy import AuthorizeNetPayment
        strategy_resolver.register(
            PaymentProcessStrategy, AuthorizeNetPayment(),
            name="authorize_net", extension_name=self.name,
        )
        registry.register_manifest(ExtensionManifest(
            name=self.name, version=self.version,
            description=self.description,
            category="payment",
            strategies=["authorize_net"],
            permissions=["payment.configure_authorize_net"],
            config_schema={
                "api_login_id": {"type": "string", "required": True},
                "transaction_key": {"type": "string", "required": True, "secret": True},
                "sandbox": {"type": "boolean", "default": True},
                "capture_mode": {"type": "string", "enum": ["authorize", "capture"], "default": "authorize"},
                "allowed_card_brands": {"type": "array", "default": ["visa", "mastercard", "amex", "discover"]},
                "supported_currencies": {"type": "array", "default": ["USD"]},
                "require_cvv": {"type": "boolean", "default": True},
                "require_billing_address": {"type": "boolean", "default": True},
            },
        ))

Discovery (Entry Points + Package Scan)

The registry discovers extensions two ways:

1. Entry point in backend/pyproject.toml — the canonical mechanism:

[project.entry-points."vectis.extensions"]
ext_authorize_net = "vectis.extensions.ext_authorize_net.extension:extension"
ext_excise_engine = "vectis.extensions.ext_excise_engine.extension:extension"

Conventions to follow exactly:

  • Key uses the directory name with the ext_ prefix (e.g. ext_authorize_net), not the value of the extension's name attribute.
  • Value points at the module attribute extension — the class itself, exported with extension = MyExtension at the bottom of extension.py. The registry calls it (which is allowed for classes — ext() returns an instance) and checks isinstance(ext, ExtensionProtocol).
  • Add the entry point line and reinstall the backend package (pip install -e ".[dev]") so the metadata refreshes; restart the API and worker after.

2. Package scan fallback — for Docker volume-mount dev workflows where the entry-point metadata is stale, the registry scans backend/vectis/extensions/ for ext_* directories with an extension.py and loads any that aren't already registered. The module attribute must still be extension (the class). This means:

  • During local development you can drop a new extension into vectis/extensions/ext_my_thing/ and it'll load on next restart without editing pyproject.toml.
  • For production deploys, add the entry point so the pip-built image carries the extension deterministically.

The name attribute on the extension class is a free-form identifier used for in-memory lookup, manifest keys, GraphQL responses, and admin UI. The codebase has several conventions in use — "authorize_net", "ext-excise-engine", "aftership", "jai_chat" — the registry tolerates all variants (it deduplicates against the directory name when scanning).

Strategy Implementation

Payment gateway strategies implement the PaymentProcessStrategy ABC:

from decimal import Decimal
from vectis.modules.payment.strategies import PaymentProcessStrategy, PaymentResult

class AuthorizeNetCIMStrategy(PaymentProcessStrategy):
    @property
    def gateway_name(self) -> str:
        return "authorize_net"

    async def authorize(self, amount: Decimal, currency: str, payment_data: dict) -> PaymentResult:
        # Auth-only: holds funds without settling
        ...

    async def capture(self, transaction_id: str, amount: Decimal) -> PaymentResult:
        # Settle a previously authorized transaction
        ...

    async def charge(self, amount: Decimal, currency: str, payment_data: dict) -> PaymentResult:
        # Auth + capture in a single call
        ...

    async def refund(self, transaction_id: str, amount: Decimal) -> PaymentResult:
        # Return funds for a captured transaction
        ...

    async def void(self, transaction_id: str) -> PaymentResult:
        # Cancel an authorization before settlement
        ...

    async def approve_held_transaction(self, transaction_id: str) -> PaymentResult:
        # Release a transaction held by fraud filters (FDS)
        ...

Fraud Filter Handling

The Authorize.net strategy detects responseCode=="4" (held for review by FDS filters) and returns a PaymentResult with held_for_review=True. The PaymentService stores the transaction as status="held_for_review" and the order enters the HeldForReview state.

The approve_held_transaction method calls Authorize.net's updateHeldTransactionRequest API to release the hold. This is exposed as the adminReleaseFraudHold GraphQL mutation.

Email delivery (ext_ses)

The ext_ses extension declares an ExtensionManifest with config_schema for Amazon SES (aws_region, aws_access_key_id, aws_secret_access_key, default_from_email, default_from_name, optional configuration_set). The SesEmailStrategy class in vectis.extensions.ext_ses.strategy implements EmailDeliveryStrategy using aiobotocore and SendEmail. Install the optional extra pip install vectis[ses] (or add aiobotocore to your environment).

Notification SMS (Twilio, SNS)

The core module defines SmsDeliveryStrategy and SmsMessage in vectis.modules.notification.sms_strategies.

  • ext_twilio declares an ExtensionManifest with config_schema for account_sid, auth_token, from_number, and optional messaging_service_sid. Instantiate TwilioSmsStrategy from extension config when wiring SMS delivery (manifest only; no global resolver registration).

  • ext_sns declares a manifest for aws_region, aws_access_key_id, aws_secret_access_key, optional sender_id, and sms_type (Transactional or Promotional, default Transactional). On activate it registers SnsStrategy with strategy_resolver as SmsDeliveryStrategy / name sns. Install pip install vectis[sns] (or vectis[ses] — same aiobotocore dependency).

Admin SSO (ext_keycloak)

The ext_keycloak extension registers an ExtensionManifest with config_schema for server_url, realm, client_id, client_secret, admin_only (default true), and role_mapping (object: Keycloak realm role name → Vectis role slug). Use vectis.extensions.ext_keycloak.service.KeycloakService with ChannelExtension.config or explicit constructor arguments: get_authorize_url(redirect_uri) loads {server_url}/realms/{realm}/.well-known/openid-configuration and returns url + state; handle_callback(code, redirect_uri, session) exchanges the code, reads realm_access.roles from the id_token, maps roles, and creates or updates a staff User, OAuthAccount (provider="keycloak"), and global UserRole rows for mapped slugs. Wire routes/BFF to this service where admin OIDC login is enabled.

OnTrac Shipping (ext_ontrac)

The ext_ontrac extension registers a ShippingCalculatorStrategy for OnTrac Ground service. It calls the OnTrac ServicesAndCharges v3 JSON API for live rate quotes and falls back to weight-based static rates when credentials are absent or the API is unavailable.

Config

Key Type Default Description
wsid string (required) OnTrac Web Services ID
wskey string (required, secret) OnTrac Web Services Key

Services

Only Ground (GRND) is supported. The service code map is structured for future expansion but currently returns a single rate.

AfterShip Tracking (ext_aftership)

Multi-carrier shipment tracking and delivery notifications powered by AfterShip. Supports 1,200+ carriers with automatic tracking registration, webhook-based status updates, and scheduled polling fallback.

AfterShip does not register a ShippingCalculatorStrategy — it is a tracking-only extension that complements rate-quote providers like ShipStation, GoShippo, UPS, or OnTrac.

Config

Key Type Default Description
api_key string (required, secret) AfterShip API key
webhook_secret string (optional, secret) HMAC secret for webhook signature verification
auto_register boolean true Auto-register tracking numbers on fulfillment creation
tracking_poll_enabled boolean false Poll AfterShip for updates (fallback for webhooks)
poll_interval_minutes integer 30 Poll interval in minutes (minimum 15)
custom_domain string (optional) Custom domain for branded tracking pages
notify_customer_on_update boolean false Send Vectis notifications on status changes

Temporal Workflows

  • AfterShipRegisterTrackingWorkflow — on-demand, registers a tracking number with AfterShip when a fulfillment is created.
  • AfterShipTrackingPollWorkflow — scheduled (every 30 min), polls AfterShip for tracking status updates across all active trackings.

Files

  • vectis/backend/vectis/extensions/ext_aftership/extension.py
  • vectis/backend/vectis/extensions/ext_aftership/client.py
  • vectis/backend/vectis/extensions/ext_aftership/activities.py
  • vectis/backend/vectis/extensions/ext_aftership/workflows.py

ShipStation (ext_shipstation)

Multi-carrier rate quotes via ShipStation v2 API, plus order sync and tracking poll via v1 API.

Strategy: _ShipStationProxyStrategy — resolves credentials per-call from ShippingProvider.config where carrier_code = 'shipstation'.

Configuration (ShippingProvider.config JSONB):

Key Type Default Description
api_key string (required, secret) ShipStation v2 API key
origin_postal_code string Ship-from postal code
origin_country string "US" Ship-from country
auto_sync_orders boolean false Push orders on placement
tracking_poll_enabled boolean false Poll for tracking updates
sync_trigger string "order_placed" order_placed, awaiting_fulfillment, or manual
v1_api_key string (secret) Legacy v1 API key (order sync)
v1_api_secret string (secret) Legacy v1 API secret

GoShippo (ext_goshippo)

Multi-carrier rate quotes, labels, and tracking via the Shippo API.

Strategy: _ShippoProxyStrategy — resolves credentials per-call from ShippingProvider.config where carrier_code = 'goshippo'.

Configuration (ShippingProvider.config JSONB):

Key Type Default Description
api_token string (required, secret) Shippo API token
origin_postal_code string Ship-from postal code
origin_country string "US" Ship-from country
origin_state string Ship-from state
origin_city string Ship-from city

Shipping Extension Config Pattern

All shipping carrier extensions store their settings in ShippingProvider.config (JSONB), not in ChannelExtension.config or the extension manifest's config_schema. The strategy reads config from the provider where carrier_code matches, with env var fallback for development. The full convention lives in vectis/docs/conventions.md in the application repo.


Channel-Scoped Activation

Extensions are toggled per-channel via ChannelExtension. The config JSONB column stores per-channel settings:

from vectis.core.extension import ChannelExtension

channel_ext = ChannelExtension(
    channel_id=1, extension_name="authorize_net", enabled=True,
    config={"api_login_id": "xxx", "transaction_key": "yyy", "sandbox": True},
)
session.add(channel_ext)

ExtensionManifest Reference

See vectis/core/extension.py for the dataclass definition; the full set of fields:

Field Purpose
name, version, description Identity — mirrors the ExtensionProtocol attributes
display_name, icon_light, icon_dark Admin presentation
category Functional category: one of payment, shipping, tax, compliance, communication, authentication, marketing in the validated tuple. Other strings (fulfillment, ai, support) are accepted but not yet in EXTENSION_CATEGORIES
strategies Strategy names this extension registers via strategy_resolver
depends_on / conflicts_with Dependency and exclusivity declarations
config_schema JSON schema for the generic Configure modal
permissions Permission codenames introduced
admin_nav_items Sidebar entries {href, label, section, icon_name, permission?} (Decided #223)
admin_quick_actions Cmd-K palette entries {href, label, icon_name, permission?} (Decided #226)
admin_page_tabs Tabs grafted onto an existing admin page {page, label, href, icon_name?, permission?} (Decided #229)
admin_settings_pages Deep-link target for the Configure button on /settings/extensions
admin_tabs Tabs on entity-detail pages
admin_pages, admin_widgets Slot contributions on dashboards and entity pages
admin_list_columns, admin_filters Extra columns / filters in admin list views
admin_action_bar_items, admin_bulk_actions Extra buttons on entity-list action bars
admin_form_fields Extra fields in entity edit forms
custom_field_definitions Custom fields per entity type (Decided #96) — auto-registered with custom_field_registry

Testing

Tests can drive the registry directly without booting the FastAPI app:

import pytest
from vectis.core.extension import ExtensionRegistry
from vectis.extensions.ext_my_gateway.extension import MyGatewayExtension

@pytest.mark.asyncio
async def test_my_gateway_registers_manifest():
    registry = ExtensionRegistry()
    await registry._activate(MyGatewayExtension())
    manifest = registry.get_manifest("my_gateway")
    assert manifest is not None
    assert manifest.category == "payment"
    assert "my_gateway" in manifest.strategies

For workflow / activity / schedule contributions, call iter_workflows(), iter_activities(), iter_schedules() on the registry after activation:

@pytest.mark.asyncio
async def test_my_gateway_workflows():
    registry = ExtensionRegistry()
    await registry._activate(MyGatewayExtension())
    workflows = registry.iter_workflows()
    assert MyRefundWorkflow in workflows

End-to-end tests (the package scan finds your extension) use the live test fixtures and let discover_and_load() activate everything.

Common Failure Modes

  • Worker doesn't pick up my workflow — verify workflows() returns the class itself, not an instance. Check the worker startup log for the "X core + Y extension" count.
  • Schedule didn't fire — run make recreate-schedules (or restart the worker; schedules are created/updated on each create_or_update_schedules() call).
  • Nav item didn't appear — confirm the extension is in the GraphQL installedExtensions response and the iconName is in +layout.svelte's resolveExtensionIcon map (the map is explicit on purpose, for deterministic Vite tree-shaking).
  • Admin route didn't appear — run make sync-admin-extensions; confirm admin/src/routes/extensions/<name>/+page.svelte exists; restart the admin dev server.
  • Extension activates twice — usually means both the entry point and the package scan found it under different name variants. The registry deduplicates and logs a warning; check the activation log line.
  • on_install re-fires on every restart — the session factory isn't reaching the registry. The ExtensionRegistry(session_factory=...) constructor must be passed get_session_factory() from the app lifespan; tests that omit it fall back to in-memory tracking only.

AgeChecker.Net Extension (ext_agechecker)

Age verification powered by AgeChecker.Net. Registers an AgeVerificationStrategy under the name "agechecker".

Strategy ABC

Defined in vectis/modules/auth/strategies.py:

  • AgeVerificationStrategy.verify_age(customer_data) — seamless server-side check
  • AgeVerificationStrategy.validate_token(token) — validates popup SDK tokens
  • AgeVerificationStrategy.get_popup_config(channel_config) — returns storefront config

GraphQL

Operation Name Description
Query ageCheckerConfig Returns popup config (null when disabled)
Mutation verifyAge Seamless verification with customer data
Mutation validateAgeToken Validates AgeChecker.Net popup token

Config Schema

Key Type Default Description
merchant_id string (required) AgeChecker.Net merchant ID
api_key string (required, secret) API key for server calls
sandbox boolean true Use sandbox environment
mode enum "both" seamless, popup, or both
trigger enum "checkout" store_wide, checkout, or disabled
minimum_age integer 21 Minimum age for verification
require_dob_on_checkout boolean false Show DOB field on checkout

Storefront Integration

  • Checkout gate (trigger: "checkout"): verification step after payment, before order placement. Seamless API called first; popup shown if photo ID needed.
  • Store-wide gate (trigger: "store_wide"): full-screen overlay in root layout gates all content until the visitor verifies their age.

Files

  • vectis/backend/vectis/extensions/ext_agechecker/extension.py
  • vectis/backend/vectis/extensions/ext_agechecker/strategy.py
  • vectis/backend/vectis/extensions/ext_agechecker/resolvers.py
  • vectis/storefront/src/lib/components/AgeCheckerPopup.svelte

Fraud Scoring Extensions

Four fraud scoring extensions implement FraudScoringStrategy. Only one can be active at a time (resolved via strategy_resolver.resolve("fraud_scoring")).

ext_ipqs (IPQualityScore)

Pre-payment IP reputation and email validation. Calls two REST endpoints per checkout: Proxy/VPN Detection and Email Verification.

Key Type Default Description
api_key string (required, secret) IPQS API key
strictness integer 1 0=lenient, 1=moderate, 2=strict
review_threshold integer 75 Score (0-100) to hold for review
block_threshold integer 90 Score (0-100) to block checkout

ext_maxmind (MaxMind minFraud)

Full-featured minFraud v2.0 integration with three API tiers, comprehensive request enrichment, and the Report Transaction feedback loop.

Scoring: Pre-payment (score_checkout), post-payment (score_post_payment), and login (score_login) scoring. Each uses the correct minFraud event.type (purchase vs account_login) so MaxMind's ML models apply the right behavioral baseline.

Request enrichment: Sends all available checkout context to minFraud — device (IP, user agent, session, Accept-Language), email (MD5-hashed), billing/shipping addresses (full name, address, region, phone), credit card (BIN, last 4, AVS, CVV, 3-D Secure), payment (method, processor, authorization outcome), order (amount, currency, discount code), shopping cart (item IDs, categories, prices, quantities), and account ID.

Response parsing (Insights / Factors tiers): Extracts IP risk, anonymous IP flags, email intelligence (disposable, free, first seen), device intelligence (device ID, confidence, last seen), billing/shipping address intelligence (distance to IP, in-IP-country), credit card issuer match, risk score reasons (Factors), disposition (custom rules), and API warnings.

Report Transaction: Implements report_decision() to send chargeback, false-positive, and suspected-fraud feedback to MaxMind via the /minfraud/v2.0/transactions/report endpoint. MaxMind reports that this feedback loop improves scoring accuracy by 10–50%.

Key Type Default Description
account_id string (required) MaxMind account ID
license_key string (required, secret) License key
tier enum "score" score, insights, or factors
review_threshold integer 50 risk_score (0-99) for review
block_threshold integer 80 risk_score (0-99) for block

Files:

  • vectis/backend/vectis/extensions/ext_maxmind/extension.py
  • vectis/backend/vectis/extensions/ext_maxmind/strategy.py

ext_signifyd (Signifyd)

Post-authorization chargeback guarantee. score_post_payment is the primary method — login and checkout return allow by default.

Key Type Default Description
api_key string (required, secret) Signifyd API key
team_id string optional Signifyd team ID
review_threshold integer 500 Score (0-1000) below which to review
block_threshold integer 250 Score (0-1000) below which to block

ext_riskified (Riskified)

Post-authorization chargeback guarantee with HMAC-SHA256 authentication.

Key Type Default Description
shop_domain string (required) Shop domain registered with Riskified
auth_token string (required, secret) HMAC authentication token
sandbox boolean true Use sandbox environment

Storage Extensions

Storage extensions implement FileStorageStrategy (defined in vectis/core/storage.py) and are registered via strategy_resolver.register(FileStorageStrategy, ...).

ext_s3 (Amazon S3)

Amazon S3 cloud object storage using the shared S3Service (SigV4 + httpx, no boto3).

Key Type Default Description
s3_region string (required) AWS region
s3_access_key string (required, secret) Access Key ID
s3_secret_key string (required, secret) Secret Access Key
s3_bucket string (required) Bucket name
s3_public_url string Optional CloudFront CDN URL

ext_minio (MinIO)

Self-hosted S3-compatible storage, also uses S3Service.

ext_digitalocean_spaces (DigitalOcean Spaces)

S3-compatible storage on DigitalOcean with optional CDN.

ext_dropbox (Dropbox)

File storage backed by a Dropbox account via the HTTP API v2 with OAuth2 refresh-token auth. No SDK dependency — uses httpx directly.

Key Type Default Description
app_key string (required) Dropbox App Key
app_secret string (required, secret) Dropbox App Secret
refresh_token string (required, secret) OAuth2 Refresh Token
access_token string Short-lived access token (auto-refreshed)
base_folder string /vectis-uploads Root folder path in Dropbox

Setup: Create a Dropbox App at https://www.dropbox.com/developers/apps, generate a refresh token via the OAuth2 flow, then configure the extension in Settings > Extensions.

ext_priority1 (Priority1 LTL Freight)

Live LTL freight rate quotes from Priority1, a freight broker returning competing carrier quotes identified by SCAC code. Includes built-in pallet calculation with two modes (simple weight-based and per-product dimensions), NMFC freight class support via product traits, and carrier filtering.

Strategy: Priority1ShippingStrategy — registered as ShippingCalculatorStrategy with name="priority1".

API: Priority1 v2 — POST /v2/ltl/quotes/rates with X-API-KEY header. Responses are cached in Redis for 10 minutes.

Configuration (ShippingProvider.config JSONB):

Note

All shipping extension settings live on ShippingProvider.config, not on ChannelExtension.config or the extension manifest. Edited from Settings > Shipping > Providers & Methods > [Provider] > Edit.

Key Type Default Description
api_key string (required, secret) Priority1 API key
environment string "dev" dev or live
api_timeout integer 30 API timeout in seconds
default_freight_class string "70" NMFC freight class fallback
pallet_mode string "simple" simple or per_product_dims
pallet_length number 48 Pallet length in inches
pallet_width number 40 Pallet width in inches
max_pallet_weight number 2500 Max weight per pallet (lbs)
pallet_tare_weight number 45 Empty pallet weight (lbs)
pallet_min_weight number 0 Min weight sent to API (0=none)
pallet_min_height number 0 Min height sent to API (0=default)
max_pallet_height number 94 Max stack height in inches
min_weight number 0 Min cart weight to offer LTL (0=always)
max_rates integer 0 Max carrier rates to show (0=all)
transit_days_padding integer 0 Extra days added to display
allowed_carriers string "" Comma-separated SCAC codes (blank=all)
fallback_enabled boolean false Show fallback rate on API failure
fallback_rate number 0 Fallback rate amount ($)
fallback_label string "LTL Freight Shipping" Fallback rate label

Carrier Discovery: The admin provides discoverPriority1Carriers and testPriority1Connection GraphQL mutations. The discover mutation runs test quotes across 7 diverse US routes to collect all carrier SCAC codes, merged with the KNOWN_LTL_CARRIERS dict. The admin UI renders a checkbox picker for selecting allowed carriers.

Per-product freight class: Assign NMFC freight classes to individual products using the "LTL Freight Class" trait (seeded on extension activation). Products without the trait use the default_freight_class from config.

Pallet calculation modes:

  • Simple: Aggregates all items onto pallets by weight. Uses cubic volume to calculate realistic stack height on the configured pallet footprint. Splits to a new pallet when weight or height limits are exceeded.
  • Per-product dimensions: Same as simple, but also forces a new pallet when an individual item exceeds the pallet footprint dimensions.

Fee Extensions

ext_package_protection (Package Protection)

Checkout fee for shipment protection with a claims workflow for damaged or missing items. Supports all five built-in calculation types: flat rate, percentage, flat + percentage, per weight, and per box.

Strategy: PackageProtectionCalculator — registered as FeeCalculationStrategy with name="package_protection".

Claim Reasons (seeded by default):

Reason Slug Customer Message
Damaged damaged Apology + prompt to submit
Missing - All Quantity missing-all Video-verified packing notice
Missing - Partial Quantity missing-partial Video-verified packing notice

Configuration (ChannelExtension.config):

Key Type Default Description
auto_attach boolean true Auto-add fee to eligible carts
default_calculation_type string "flat" One of: flat, percent, flat_plus_percent, per_weight, per_box
default_flat_amount string "4.99" Default flat fee amount
claim_header_message string see code Message shown at top of claim form
claim_footer_message string see code Message shown at bottom of claim form
reason_messages object see code Per-reason override messages keyed by slug

Claim Resolution Workflow:

  1. Admin clicks "Approve" on a submitted claim
  2. System issues store credit for the claimed item value
  3. System creates a replacement order from the claimed line items
  4. Store credit is applied to the replacement order
  5. Claim status transitions to complete

Support Chat (ext_jai_chat)

J'AI Chat was promoted from a core module to an extension. The extension contributes:

  • ChatProvider strategy (OpenAI, Anthropic, Azure) with register_strategy
  • Admin page tab for chat configuration via register_admin_page_tab
  • GraphQL fragments: jaiChatSession, sendJaiMessage, plus the staff-side moderation queries
  • A Temporal schedule jai-session-cleanup (every 24h) for session GC

Configuration is per-channel (ChannelExtension.config):

Key Type Default Description
provider enum openai openai, anthropic, or azure_openai
model string gpt-4o-mini Model identifier
api_key string (required, secret) Provider API key
system_prompt string (built-in) System instructions for the assistant
allow_anonymous boolean false Allow guest sessions
escalation_threshold integer 3 Failed-answer turns before staff escalation

The support_chat core module remains as a thin shim that delegates to the resolved strategy; if no ext_jai_chat (or substitute) is installed, the admin "Support Chat" page renders a stub and the storefront chat widget is hidden.

Excise Tax (ext_excise_engine)

Replaces the deprecated ext_exciseiq (Decided #225, decommissioned 2026-04-23). Self-registers a TaxStrategy with stage 200 so it runs after sales tax (100). Slot integration:

  • Admin nav item: Settings → Tax → Excise
  • Admin Cmd-K quick actions: "Validate Excise Rules", "Show Last Excise Calc"
  • A page tab on the Order detail surface to inspect resolved excise per-line
  • Reads from product.traits (alcohol_by_volume, nicotine_strength, …) and the new compliance.state_restrictions JSONB to choose the right excise rule

The legacy ext_exciseiq extension and its seed data have been removed from the default installation. Operators upgrading from a previous version see a deprecation banner once and a one-click migration that copies their TaxJar / IQ config into ext_excise_engine.

TaxJar (ext_taxjar)

Replaces the pre-2026 stub. The extension now implements full TaxJar:

  • CalculationTaxJar.taxForOrder for cart and order subtotals, with line-level expected_tax
  • Filing — daily Temporal TaxJarFilingWorkflow aggregates settled orders into a TaxJar filing payload
  • Order syncTaxJarOrderSyncStrategy records and refunds orders via TaxJar's transaction API

Strategy stage is 100 (sales-tax tier); ext_excise_engine runs at stage 200 against the order subtotal returned by TaxJar.

Webhook Secret Rotation (Decided #155, OQ #28)

Payment / shipping / webhook secrets are stored Fernet-encrypted in the settings table with rotation support:

  • SECRETS_MASTER_KEY (current) — used for new writes and reads
  • SECRETS_MASTER_KEY_ROTATING_FROM (optional, list) — additional keys tried during read; lets you rotate without downtime

For inbound webhooks (Authorize.Net, NMI, Stripe), each gateway extension declares a multi-secret rotation window. The dispatcher tries each registered secret in priority order and accepts the first that verifies the signature. Old secrets are pruned after a configurable retention window (default 14 days).

The admin "Webhook Secrets" page exposes the rotation lifecycle: generate new, mark current as previous, schedule old for retirement.