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.

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

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:
- Strategies —
strategy_resolver.register(StrategyABC, impl, name=..., extension_name=...)(defined invectis/core/strategy.py). Last registration for a(type, name)pair wins; passextension_name=self.nameso channel-scoped filtering can drop the strategy when the channel disables your extension. - Event handlers —
event_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_versionreturnsNone→on_installfires →mark_installedwrites a row →on_activatefires. - Subsequent boots same version → only
on_activatefires. - Version bump →
on_upgrade(registry, prev_version)fires beforeon_activate;mark_installedupdates the row. - Explicit uninstall via
registry.uninstall(name)→pre_uninstall_check()collects blockers (anything truthy blocks unlessforce=True); on pass,on_uninstallfires,mark_uninstalledstamps 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 point — backend/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
The models() method ensures _import_all_models loads your models before autogenerate runs.
5. Admin routes — if you ship admin_pages/:
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'snameattribute. - Value points at the module attribute
extension— the class itself, exported withextension = MyExtensionat the bottom ofextension.py. The registry calls it (which is allowed for classes —ext()returns an instance) and checksisinstance(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 editingpyproject.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
ExtensionManifestwithconfig_schemaforaccount_sid,auth_token,from_number, and optionalmessaging_service_sid. InstantiateTwilioSmsStrategyfrom 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, optionalsender_id, andsms_type(TransactionalorPromotional, defaultTransactional). On activate it registersSnsStrategywithstrategy_resolverasSmsDeliveryStrategy/ namesns. Installpip install vectis[sns](orvectis[ses]— sameaiobotocoredependency).
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.pyvectis/backend/vectis/extensions/ext_aftership/client.pyvectis/backend/vectis/extensions/ext_aftership/activities.pyvectis/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 eachcreate_or_update_schedules()call). - Nav item didn't appear — confirm the extension is in the GraphQL
installedExtensionsresponse and theiconNameis in+layout.svelte'sresolveExtensionIconmap (the map is explicit on purpose, for deterministic Vite tree-shaking). - Admin route didn't appear — run
make sync-admin-extensions; confirmadmin/src/routes/extensions/<name>/+page.svelteexists; restart the admin dev server. - Extension activates twice — usually means both the entry point and the package scan found it under different
namevariants. The registry deduplicates and logs a warning; check the activation log line. on_installre-fires on every restart — the session factory isn't reaching the registry. TheExtensionRegistry(session_factory=...)constructor must be passedget_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 checkAgeVerificationStrategy.validate_token(token)— validates popup SDK tokensAgeVerificationStrategy.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.pyvectis/backend/vectis/extensions/ext_agechecker/strategy.pyvectis/backend/vectis/extensions/ext_agechecker/resolvers.pyvectis/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.pyvectis/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:
- Admin clicks "Approve" on a submitted claim
- System issues store credit for the claimed item value
- System creates a replacement order from the claimed line items
- Store credit is applied to the replacement order
- 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:
ChatProviderstrategy (OpenAI, Anthropic, Azure) withregister_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 newcompliance.state_restrictionsJSONB 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:
- Calculation —
TaxJar.taxForOrderfor cart and order subtotals, with line-levelexpected_tax - Filing — daily Temporal
TaxJarFilingWorkflowaggregates settled orders into a TaxJar filing payload - Order sync —
TaxJarOrderSyncStrategyrecords 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 readsSECRETS_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.