Extending
dms-saas exposes its extension points through the separately published @antelopejs/interface-dms-saas package. Pages you add stay backend-declared trees of DMS blocks: make only the block the DMS lacks a custom component, and give it .meta({ name, description, icon }) so the role editor can show it.
Choose an integration
| You want to… | Start here | Expected result |
|---|---|---|
| Save a referral code at signup | Registration extras | Your module stores the answers before registration completes |
| Bill compute or storage usage | Invoice line items | Usage appears beside the subscription on renewal invoices |
| Show an estimated bill | Upcoming invoice preview | A priced estimate, or an explicit absent/unavailable state |
| Add a billing block | Workspace settings pages | Your block appears beside the existing billing content |
| Provision a resource after payment | Workspace lifecycle | A durable notification your module deduplicates before creating resources |
Start with the example for your task. The interface reference contains the complete types and failure contracts; the remaining sections explain how to use them in a product.
Consuming this module
A module that extends dms-saas depends on the separately published interface
package ("@antelopejs/interface-dms-saas" in its dependencies) and
imports the extension points from it. Load the dms-saas runtime separately in the
project; importing its package root boots the registration graph. The
consumer reference maps supported subpaths and
defines signatures, results, failures and lifecycle contracts.
invoiceLineItems and tenantLifecycle come off the root entry as
namespaces because each carries an internal proxy namespace of its own; db
is reachable only through its subpath, since importing it registers the SaaS
tables.
After a seed or import writes subscription records directly, await
RecomputeTenantBillingState
for each affected tenant. This refreshes the derived billing and workspace
directory state; it does not replace the runtime's Stripe or lifecycle work.
The interface package must be released before dms-saas, which depends on it
through >=<interface version> <0.<minor + 1>.0: dms-saas implements the
interface, so it caps the range below the next minor and a breaking interface
minor never reaches a dms-saas that does not implement it. During workspace
development pnpm resolves that range to the sibling package. Release the interface package first,
then
release dms-saas, and update consumers to the published interface version.
Tenant owner permissions
The dms-saas.plan-intersection permissions resolver runs at order 100. For a
member whose isTenantOwner is true in the requested tenant, it grants all
explicit permissions from that tenant's resolved plan, including inherited
permissions, regardless of assigned roles. Permissions outside the plan are
removed even when a role grants them. Other members retain only the intersection
of their incoming permissions and the resolved plan.
A plan grants exactly the ids it lists, nothing nested under them: the plan editor's permission tree adds a node's descendants and ancestors when it is ticked, so plans built from the UI list every component id, and plans seeded by code must list them too.
Tenant ownership never grants *, even if a plan contains it. An incoming *
(including the DMS platform owner's wildcard) retains the existing behavior:
the wildcard survives, and other incoming permissions are intersected with the
plan. Without a subscription, a planId, or an existing plan, the resolver
preserves incoming permissions and grants nothing new. This compatibility
fallback is not a denial of access for tenants with missing billing data.
Applications define their business permissions and can project the capabilities
that survive the plan into resource-specific actions in later resolvers. They
do not need a separate tenant-owner grant list. Subscription access gates remain
independent and unchanged. This resolver alone does not constrain
defaultGranted permissions or guards that do not consult effective permissions.
Public screens
dms-saas ships a working registration screen so a fresh project can sell from day one. It is a reference implementation, not a design: the module owns the money and the compliance of the flow, the consuming SaaS owns the conversion.
Replacing it takes two steps. Turn the bundled page off in the module config —
page registration is keyed by slug, so declaring a second page on register
races the bundled one instead of overriding it:
{
"modules": {
"dms-saas": {
"config": {
"publicScreens": { "register": false }
}
}
}
}
Then register your own page on the register slug and build it on the
useSaasRegistration() composable the Vue adapter auto-imports. It holds the
whole flow — the card policy, the Stripe setup intent and card confirmation,
the order the requirements are validated in, the provisioning call and the
landing route — and renders nothing, so a re-themed screen keeps the same
steps:
<script setup lang="ts">
const {
form, // name, email, password, hasAcceptedLegal, skipsPaymentMethod
paymentElementId,
canSkipPaymentMethod, // true under `optional`
isPaymentStepVisible, // bind with v-show so the Stripe element stays mounted
passwordStrength, // { strength, score, color } for <DmsPasswordStrength>
isRegistrationClosed, // render the invitation-only state instead of the form
errorMessage,
isSubmitting,
submit,
} = useSaasRegistration({ redirectTo: "/welcome" });
</script>
Nothing it returns throws: a failure surfaces as a translated errorMessage,
and submit() resolves to the new tenant id or null. A password outside the
DMS policy stops submit() before the card is confirmed. The workspace name is
the localised saas.register.default_workspace_name. Every screen stays
enabled by default, so an existing project needs no configuration change.
Registration extras and the provisioning hook
A SaaS that captures its own fields at signup — referral source, team size,
anything — sends them as extras on the registration payload and writes them
from a TENANT_BEING_PROVISIONED listener. dms-saas forwards the object
verbatim: it never reads it, never stores it, never returns it. The data stays
the consumer's, and so does the schema.
import type { TenantBeingProvisionedPayload } from "@antelopejs/interface-dms-saas/provisioning";
import {
Hook,
RegisterHook,
} from "@antelopejs/interface-dms/hooks";
RegisterHook(
Hook.TENANT_BEING_PROVISIONED,
async ({ tenantId, userId, extras }: TenantBeingProvisionedPayload) => {
await storeOnboardingAnswers(tenantId, userId, extras);
return undefined;
},
);
storeOnboardingAnswers is your module's persistence function. Keep the explicit undefined return: this hook's handler contract is Promise<undefined>, not Promise<void>.
The provisioning contract defines capture limits, rollback and the per-card lock. Keep the hook to bounded persistence work: a failure cancels registration. For external resource creation after payment, use the durable tenant lifecycle instead.
On the browser side, useSaasRegistration() takes the capture as an option
and sends it with the rest of the form:
<script setup lang="ts">
const answers = reactive({ referral: "", teamSize: "" });
const { form, submit } = useSaasRegistration({ extras: () => answers });
</script>
Invoice line items
RegisterInvoiceLineItemsProvider lets a module bill its own metered usage on
the workspace's subscription invoice. When Stripe opens the renewal invoice of
a billing cycle, dms-saas asks every registered provider for the lines it wants
and pushes them as Stripe invoice items while the invoice is still a draft, so
they are charged on finalization.
dms-saas stays agnostic: it never interprets a line. What a metric is, how it is measured and how it is priced belong to the provider.
import {
RegisterInvoiceLineItemsProvider,
UnregisterInvoiceLineItemsProvider,
} from "@antelopejs/interface-dms-saas/invoice-line-items";
const PROVIDER_ID = "cloud";
export async function construct(): Promise<void> {
RegisterInvoiceLineItemsProvider({
id: PROVIDER_ID,
resolve: async ({ tenantId, periodStart, periodEnd, currency }) => {
const usage = await aggregateUsage(tenantId, periodStart, periodEnd);
return [
{
key: "vcpu-minutes",
description: "vCPU minutes",
amountCents: usage.vcpuCents,
quantity: usage.vcpuMinutes,
unit: "minute",
},
{
key: "included-credit",
description: "Included usage credit",
amountCents: -usage.includedCreditCents,
},
];
},
});
}
export async function stop(): Promise<void> {
UnregisterInvoiceLineItemsProvider(PROVIDER_ID);
}
Use a unique provider ID, stable line keys and pre-aggregated usage. Follow the invoice line-item contract for paid-period boundaries, amounts, idempotency, error handling and preview calls. A resolver also prices previews, so reading usage must not consume it.
Upcoming invoice preview
GetUpcomingInvoicePreview returns what Stripe will bill the current
workspace next, tax included, so a page can show the exact VAT and total before
the invoice exists. Stripe computes every figure (Stripe Tax: country, VAT
number, reverse charge); dms-saas copies them without recomputing any.
import { GetUpcomingInvoicePreview } from "@antelopejs/interface-dms-saas/billing";
const preview = await GetUpcomingInvoicePreview({ tenantId, userId });
if (preview.status === "available") {
const { taxMinorUnits, totalMinorUnits, currency } = preview;
render({ tax: taxMinorUnits, total: totalMinorUnits, currency });
}
Take the scope from trusted authentication and handle all three states:
available, absent and unavailable. The billing reference
defines their fields, reason codes, authorization failures and monetary units.
Usage is quoted up to the time of the request with the same providers, paid coverage window and line validation as the renewal invoice, so the preview's tax covers plan and usage together. It is an estimate of a cycle still running: the renewal invoice bills the whole cycle.
Previews are cached per workspace for an hour, shared by every instance through
the database. Set upcomingInvoicePreviewCacheTtlSeconds to change that
lifetime, or to 0 to price every request. A cached preview is dropped as soon
as the workspace's subscription (plan change, status) or billing identity
(address, VAT number) changes locally, and when Stripe reports an invoice
created, finalized, paid or voided, a subscription updated or deleted, or a
customer updated. unavailable results are never cached. Display
computedAt to tell the reader how current the figures are.
The Stripe reads a page renders (this preview, the billing status, the VAT
check) give up after stripe.readTimeoutMs (4000 ms by default) and are not
retried unless stripe.readMaxNetworkRetries says so: a slow Stripe makes the
preview unavailable rather than holding the page. Writes keep the Stripe
SDK's own timeout and retries.
Workspace settings pages
A module adding a page to workspace settings takes workspaceSettingsCategory
from @antelopejs/interface-dms/page, rather than rebuilding its hierarchy.
The SaaS interface does not export this category or a category getter.
import { PageController, RegisterPage, workspaceSettingsCategory } from "@antelopejs/interface-dms/page";
@RegisterPage()
export class WorkspaceRegistriesController extends PageController("registries", {
displayName: "$cloud.workspace.registries.title",
category: workspaceSettingsCategory,
icon: "i-ph-package",
order: 10,
}) {}
Extend the existing tenant billing page through its stable identifiers instead
of importing its controller. Use an anchor such as
tenantBillingPage.components.planCard; the page extension reference
defines placement and registration lifetime.
import { CustomComponent } from "@antelopejs/interface-dms/base/custom";
import {
RegisterTenantBillingPageExtension,
tenantBillingPage,
type TenantBillingPageExtensionRegistration,
} from "@antelopejs/interface-dms-saas/pages";
let billingBlocks: TenantBillingPageExtensionRegistration | undefined;
export function construct(): void {
billingBlocks = RegisterTenantBillingPageExtension({
name: "CloudBillingBlocks",
components: [
{
key: "inProgressInvoice",
component: CustomComponent("CloudInProgressInvoice").meta({
name: "$cloud.billing.in_progress_invoice",
description: "$cloud.billing.in_progress_invoice_description",
icon: "i-ph-receipt",
}),
side: "after",
anchorKey: tenantBillingPage.components.planCard,
},
],
});
}
export function stop(): void {
billingBlocks?.unregister();
billingBlocks = undefined;
}
Platform modules can extend the SaaS platform-owner workspace detail without
importing its controller or the private saasModule instance. Use
platformSaasModule, platformWorkspaceDetailPage, and
RegisterPlatformWorkspaceDetailPageExtension from the same public pages
entry point. platformSaasModule is also a synchronous descriptor; runtime
code that requires the registered module root can await
GetPlatformSaasModule(). Platform data controllers that must hide a tenant
routing field can import HiddenStringFilter from
@antelopejs/interface-dms-saas/data-api; the underlying
SaaS data controllers remain private. Canonical database models are available
from /db, with the import and direct-write warnings.
Plan feature labels and values
A feature's displayName and tooltip are localized fields
(@Localized, falling back to en): the row stores one value per locale and
every reader gets the text in the viewer's language, taken from the
x-content-language header the DMS frontend sends. The tenant plan pages and
the plan admin pages show it as written; the features table in the platform
admin edits it one language at a time.
A module declaring features writes every locale at once through
localize("*"), never i18n keys:
const row = FeatureModel.fromPlainData({
_id: "cloud.plan.egress",
displayName: { en: "Included egress", fr: "Trafic sortant inclus" },
tooltip: { en: "Outbound traffic", fr: "Trafic sortant" },
// ...
}).localize("*");
await GetModel(FeatureModel).insert(row);
Read a row with localizeFeature(feature, locale) from
@antelopejs/interface-dms-saas/plans. A row without a value in the viewer's
locale or in en has no tooltip.
Values are formatted from the feature's valueType and unit: -1 reads as
unlimited, booleans as ✓/—, numbers are grouped in the viewer's locale. A
per <unit> unit makes the value a price in the plan's currency, and
currency units an amount of it. Known units are scaled to something a person
reads at a glance; any other unit is shown verbatim after the grouped number.
A text value follows the same convention as labels: a $<key> value is
translated in the declaring module's locales, anything else is shown as stored.
| Stored unit | Quantity reads as | per <unit> price reads as |
|---|---|---|
byte(s) | bytes, KB, MB, GB or TB (powers of 1000) | per GB |
minute(s) | minutes | per minute |
A module declaring features in its own units teaches the plan pages how they
read with registerPlanFeatureUnit, auto-imported like the other dms-saas
composables. Call it from a frontend plugin that runs on the server and in the
browser, so server-rendered and hydrated values agree. Each scale says how
many stored units it holds; a quantity takes the largest scale it fills, and a
per <unit> price reads per priceScale. Labels are full i18n keys the module
ships in its own locales, each a node holding a quantity message
({value}, pluralised on the scaled value) and a price message ({price}):
// frontend-vue/app/plugins/plan-feature-units.ts
const MINUTES_PER_HOUR = 60;
const vcpuHour = { label: "cloud.plan.units.vcpu_hour", size: MINUTES_PER_HOUR };
export default defineDmsPlugin(() => {
registerPlanFeatureUnit(["vCPU-minute", "vCPU-minutes"], {
scales: [vcpuHour],
priceScale: vcpuHour,
});
});
{
"cloud": {
"plan": {
"units": {
"vcpu_hour": {
"quantity": "{value} vCPU-hour | {value} vCPU-hours",
"price": "{price} / vCPU-hour"
}
}
}
}
}
Units match case-insensitively, and a registered unit takes precedence over a built-in one of the same name.