@axeptio/provisioning
v4.0.0
Published
Axeptio provisioning client with state management and batch operations
Readme
@axeptio/provisioning Axeptio provisioning library
TypeScript toolkit for automated provisioning of Axeptio entities. It provides a typed HTTP client, composable provisioners, checkpointed execution, and a CLI — optimized for both humans and AI agents.
Why this package
- ✅ Type-safe client with retries and rate limiting
- ✅ Composable provisioners for org → projects → configurations → users
- ✅ Checkpointed execution with automatic resume and rollback
- ✅ Smart merge: fresh data + existing state on every run
- ✅ Parallel batches with pause/resume
- ✅ Built-in callbacks (publish, scan, logs)
- ✅ CLI and programmatic APIs
- ✅ AI-friendly: small, copy-pasteable recipes and clear method names
Table of Contents
- Authentication (Frontegg M2M client-credentials)
- Reading & Querying
- Quick Start (Fetcher API)
- Chain Navigation
- Filters & Pagination
- Public vs Authenticated Endpoints
.fetch()vs.stream()- Analytics
- Provisioning
- Quick Start (Provisioner API)
- Post-Provisioning Callbacks
- Entity Recipes (copy-paste)
- Organizations & Projects
- Projects Groups
- Cookies Configuration
- Project Design & Stylesheet
- Terms (ContractsV2) Configuration
- Vendors (Company & Solution)
- Media & Assets
- Billing (Customer, VAT, Subscription)
- Users, Invitations, Access
- User Management & Access Control
- CLI Usage
- Configuration
- Supported Entity Types
- Error Handling & Recovery
- State Management
- Rate Limiting
- Environment Variables
- API Reference (Configs)
- Examples
Installation
npm install @axeptio/provisioningAuthentication
Two auth modes: Frontegg M2M client-credentials, or a caller-supplied bearer. The pre-Frontegg auth system (username/password /auth/local/signin, browser-driven provider login, pre-minted user tokens) has been removed server-side and from this client; passing username, password, or token throws at construction with a migration hint.
M2M (Frontegg client-credentials)
Pass clientId + secret (the machine user's M2M pair, e.g. for [email protected]) and the client authenticates against Frontegg SSO via the client-credentials exchange: it POSTs the pair to https://login.axept.io/identity/resources/auth/v1/api-token, receives a short-lived access JWT (expiresIn seconds, 24h in practice), and attaches it as the bearer. Protected services (e.g. the automator / shake-api routes behind requireAuth()) no longer accept the legacy ?access_key= shared secret.
import { createClient } from '@axeptio/provisioning';
const client = createClient({
clientId: process.env.FRONTEGG_M2M_CLIENT_ID!, // secret — from your secret store
secret: process.env.FRONTEGG_M2M_SECRET!, // secret — from your secret store
baseURL: process.env.AXEPTIO_API_URL!,
// Optional knobs (defaults shown):
// fronteggBaseUrl: 'https://login.axept.io',
// fronteggTokenPath: '/identity/resources/auth/v1/api-token',
});
// client.getAuthMode() returns 'm2m'. The first request mints the token lazily;
// call client.authenticate() to mint eagerly at startup instead.Behavior in M2M mode:
- Tokens are minted lazily, cached in-memory, and reused across calls; concurrent mints collapse into a single in-flight request (single-flight).
- The cache expires ~60s before the token's real expiry, so long-running processes re-mint transparently across the 24h TTL.
- A
401from the API invalidates the cached token and re-mints once before the error is surfaced (handles rotation / early expiry). If the retry also 401s, anAuthenticationErroris thrown. - Construction fails fast when
clientIdorsecretis missing or empty — don't defer the failure to the first call. - The
secret, theaccessToken, and therefreshTokenare never logged.
Pitfalls (all observed in practice):
- The mint host is your Frontegg environment host (
https://login.axept.iofor prod, the default) — notapi.frontegg.com. - The mint path is
/identity/resources/auth/v1/api-token(v1, unauthenticated, camelCase response). The…/v1/user/api-tokenvariant requires an existing bearer; v2 returns snake_case. - Tokens are environment-scoped: a production-minted token is rejected (401) by a staging service and vice-versa. Match
fronteggBaseUrlto the environmentbaseURLpoints at. - Store
clientId/secretin a secret store (e.g. SSMSecureString), never plaintext env/IaC vars — and beware trailing newlines when writing them (a newline-terminated secret 401s the mint).
Caller bearer (pass-through)
For services that act on behalf of a caller who already holds a Frontegg access token (e.g. the MCP server's HTTP transport), pass the token itself and the client attaches it verbatim — the service holds no credentials of its own:
const client = createClient({
bearer: callerAccessToken, // the `accessToken` a caller minted themselves
baseURL: process.env.AXEPTIO_API_URL!,
});
// client.getAuthMode() returns 'bearer'. The client is authenticated from
// construction; authenticate() is a no-op.Behavior in bearer mode: no mint, no cache, no refresh. The token is used as-is for the lifetime of the client instance; when it expires or is revoked, requests surface AuthenticationError (the internal 401 retry re-sends the same token) and the caller must supply a fresh token via a new client. bearer is exclusive with clientId/secret — passing both throws. The bearer is a secret and is never logged.
The underlying token provider is also exported standalone, if you need a Frontegg bearer for something other than this client:
import { createFronteggTokenProvider } from '@axeptio/provisioning';
const provider = createFronteggTokenProvider({
clientId: process.env.FRONTEGG_M2M_CLIENT_ID!,
secret: process.env.FRONTEGG_M2M_SECRET!,
});
const jwt = await provider.getToken(); // mint + cache + single-flight
// On a 401 from the protected service: provider.invalidate(); retry once.Refusing legacy or missing shapes. Passing any removed pre-Frontegg field (username, password, token) or only { baseURL } throws at construction. TypeScript rejects the removed fields at compile time (never-typed); the constructor also validates at runtime.
Reading & Querying
@axeptio/provisioning ships a typed, chainable fetcher API for reading Axeptio entities. Start with createFetcher(client) and navigate through the entity hierarchy. Every chain ends in one of two terminals: .fetch() (eager, auto-paginated) or .stream() (lazy async iterator).
Quick Start (Fetcher API)
import { createClient, createFetcher } from '@axeptio/provisioning';
const client = createClient({
clientId: process.env.FRONTEGG_M2M_CLIENT_ID!,
secret: process.env.FRONTEGG_M2M_SECRET!,
baseURL: process.env.AXEPTIO_API_URL!,
environment: 'staging',
});
const fetcher = createFetcher(client);
// List organizations (first page auto-paginated, default perPage 100)
const { data, __meta } = await fetcher.organizations().fetch();
console.log(`${data.length} organizations, total ${__meta.totalCount}`);Chain Navigation
One worked example per major entity family:
Organizations and Projects — authenticated, parent → child:
// All projects the caller can see, across every organization (flat /vault/projects).
// No org id required — use this when you don't know or don't care about the owning org.
const all = await fetcher.projects().fetch();
// Narrow to a single org without going through the chain.
const orgProjects = await fetcher.projects({ organizationId: 'org_xxx' }).fetch();
// Or use the parent chain — identical payload, different ergonomics.
const projects = await fetcher
.organizations({ id: 'org_xxx' })
.projects()
.fetch();Configurations — CookieConfigurationFetcher, TCFConfigurationFetcher, DPOConfigurationFetcher, SubsConfigurationFetcher, CustomVendorFetcher all hang off a project via data.projectId:
const cookies = await fetcher
.organizations({ id: 'org_xxx' })
.projects({ id: 'proj_yyy' })
.cookieConfigurations()
.fetch();
// tcfConfigurations() / dpoConfigurations() / subsConfigurations() / customVendors()
// follow the exact same chain shape.Terms (VersionedDocs) — a TermsConfiguration contract has both revisions (TermsRevisionFetcher) and versions (TermsVersionFetcher) as direct children:
// A contract's revisions
const revisions = await fetcher
.organizations({ id: 'org_xxx' })
.projects({ id: 'proj_yyy' })
.termsConfigurations({ id: 'terms_zzz' })
.revisions()
.fetch();
// A contract's versions
const versions = await fetcher
.organizations({ id: 'org_xxx' })
.projects({ id: 'proj_yyy' })
.termsConfigurations({ id: 'terms_zzz' })
.versions()
.fetch();Vendors — public, unauthenticated endpoints:
const companies = await fetcher.vendorCompanies().fetch();
const solutions = await fetcher
.vendorCompanies({ id: 'company_xxx' })
.vendorSolutions()
.fetch();
// Top-level solution search
const matches = await fetcher
.vendorSolutions({ searchTerm: 'analytics', language: 'fr' })
.fetch();Users — parent-scoped with dynamic endpoint (org-scoped OR project-scoped):
// Org-scoped users (→ /vault/organizations/:id/users)
const orgUsers = await fetcher
.organizations({ id: 'org_xxx' })
.users()
.fetch();
// Project-scoped users (→ /vault/projects/:id/users, same UserFetcher class)
const projectUsers = await fetcher
.organizations({ id: 'org_xxx' })
.projects({ id: 'proj_yyy' })
.users()
.fetch();There is no top-level .users() — users are always parent-scoped.
Filters & Pagination
Every fetcher accepts a filter. The base shape is { id?: string, page?: number, perPage?: number }. Specialized filters add fields (ProjectFilter.organizationId, VendorSolutionFilter.companyId / searchTerm / language / sort). Scoping usually comes from the parent chain, not the filter.
// Cap the aggregate record count across auto-pagination
const page = await fetcher
.organizations()
.fetch({ limit: 250 });Adjust the default page size at factory time:
const fetcher = createFetcher(client, { defaultPerPage: 50 });Public vs Authenticated Endpoints
Most fetchers require authentication via createClient({ username, password }) and route through requestWithMeta(). The exceptions are vendor data:
VendorCompanyFetcherhits/vendors/companies(public)VendorSolutionFetcherhits/vendors/solutions/search(public)
These call publicRequestWithMeta() internally, so they work without valid credentials. Every fetcher exposes its auth mode via isPublicEndpoint().
.fetch() vs .stream()
.fetch() returns everything eagerly as a single FetchResult<T>. .stream() returns an async iterator yielding pages lazily — use it for large datasets like vendors (hundreds of records):
for await (const page of fetcher.vendorCompanies().stream()) {
for (const company of page.data) {
// process each record as it arrives
}
}.stream() works on every fetcher — root and child. Use it the same way on a parent-scoped chain:
for await (const page of fetcher.vendorCompanies({ id: 'company_xxx' }).vendorSolutions().stream()) {
for (const solution of page.data) {
// process each solution as it arrives
}
}Fan-out over parents
When you call .stream() on a child fetcher without a resolved parent id, the library fetches all parents first, then streams each parent's children sequentially. Each yielded page carries __meta.parentId so you can group results:
const groupByOrg = new Map<string, User[]>();
for await (const page of fetcher.organizations().users().stream({ limit: 500 })) {
const parentId = page.__meta.parentId!;
const bucket = groupByOrg.get(parentId) ?? [];
bucket.push(...page.data);
groupByOrg.set(parentId, bucket);
}The opening parent fetch is O(parents) — narrow with a parent filter (or resolve the parent id yourself) when the parent population is large.
Analytics
projects({ id }).analytics(filter) is a project-scoped query builder (not a list endpoint — it never appears in getChildren()). It fronts two different backends, and which one you get depends on the method you call.
The chain must resolve a project id (projects({ id: '...' })) — every stats endpoint is project-scoped, and the fetcher throws if it is missing. Every method except configMetadata() also needs a date range: either { startDate, endDate } on the filter, or .period(...) / { period } (allTime, lastYear, lastQuarter, lastMonth, lastWeek). Neither present throws.
caas-api stats — the back-office charts, under baseURL:
const kpis = await fetcher
.organizations({ id: 'org_xxx' })
.projects({ id: '657c621b5e1a3b05ccf432d2' })
.analytics({ period: 'lastMonth' })
.globalStats();
// .line(['pageview', 'consentRate']) — one row per date
// .byDimension('device') — one row per dimension value
// .bounceRateByDuration()
// .configMetadata(['cfg_1']) — needs no date rangeThe full AnalyticsFilter accepted by these methods:
| Field | Meaning |
|---|---|
| startDate / endDate | YYYY-MM-DD, exclusive with period. Validated as parseable and correctly ordered, not for the exact shape — consentStats() is the one terminal that enforces YYYY-MM-DD strictly, because the headless endpoint 400s on anything else |
| period | rolling window, resolved to a range client-side |
| filters | [{ dimension, values }] where dimension is device, os, browser, config or host |
| source | event source, server defaults to sdk-web |
| aggregateBy | e.g. 'day'; the server enforces a plan allow-list |
KPI fields on globalStats().results[0] are all optional — the server strips KPIs for callers whose plan does not unlock them. Null-check before reading.
Headless CMP consent stats — the BI-shaped read behind Looker Studio / Power BI / Tableau, on the headless host:
const client = createClient({
clientId: process.env.FRONTEGG_M2M_CLIENT_ID!,
secret: process.env.FRONTEGG_M2M_SECRET!,
baseURL: process.env.AXEPTIO_API_URL!,
headlessBaseURL: process.env.AXEPTIO_HEADLESS_API_URL!, // https://headless-api.axeptio.tech
});
const stats = await createFetcher(client)
.organizations({ id: 'org_xxx' })
.projects({ id: '657c621b5e1a3b05ccf432d2' })
.analytics({ startDate: '2026-08-01', endDate: '2026-08-30' })
.consentStats({ groupBy: ['date', 'country'] });
stats.meta; // { filters: { from, to }, groupBy, rows, timeZone }
stats.summary; // totals over the whole period
stats.data; // one row per groupBy combinationconsentStats() issues GET {headlessBaseURL}/stats. It authorizes per request off the Frontegg bearer the client already holds, so it needs no headless project token — but it does need headlessBaseURL, which has no default (the host differs per environment and a production bearer is rejected by a staging service). Calling it without one throws naming AXEPTIO_HEADLESS_API_URL.
| | caas-api methods | consentStats() |
|---|---|---|
| Host | baseURL | headlessBaseURL |
| Call | POST /stats/{projectId} | GET /stats?projectId&from&to&groupBy |
| Shape | { dimensions, results[] } | { meta, summary, data[] } |
| Dimensions | device, os, browser, config, host | date, country, device, configVersion |
groupBy defaults to ['date']. Pass any combination of the four dimensions; each data row then carries the dimension keys it was grouped on alongside the metrics.
summary and every data row share one metric shape (ConsentStatsMetrics):
| Counts (integers) | Ratios (0..1, not percentages) |
|---|---|
| pageview, visitor, couldInteract, widgetDisplay, interaction, consent, reject, partial, bounce, quickBounce, suspectedBots | interactionRate, consentRate, optInRate, bounceRate, quickBounceRate |
Every field is optional, same reasoning as the caas-api KPIs. Both summary and each row also carry a responses array — the consent / partial / reject counts pivoted into exactly three labelled rows (Consent, Partial, Reject, always in that order, zeros when empty) so a BI tool has a dimension to chart a pie against. It is derived from those fields and adds no new data; the labels are a stable contract that surfaces in customer-facing reports.
Two limits are enforced client-side, before the request goes out, so they fail with a message naming the problem rather than as an opaque 400: groupBy must contain only the four allowed dimensions, and the window must be 365 days or narrower.
⚠️ That second limit rules out two of the rolling periods, not one. allTime is obviously too wide, but so is lastYear — it resolves to 366 days inclusive, one over the cap. lastQuarter (122 days) is the widest period that fits; for a true one-year window pass an explicit { startDate, endDate } 365 days apart. The caas-api terminals have no such cap and accept every period.
One API behavior is deliberately not exposed: GET /stats also accepts no date bounds and defaults to the last 30 days. consentStats() still requires a range or a period, so it behaves like every other method on this fetcher rather than being the one that silently picks a window.
On errors, 403 covers both "not allowed to read this project" and "no such project" — the endpoint does not distinguish them, deliberately, so it cannot be used to probe which project ids exist. The endpoint also has its own 429 and surfaces upstream stats-backend failures as 502 / 503 / 504; all of them arrive as the client's usual typed errors.
Provisioning
@axeptio/provisioning also provides a write-capable provisioner tree for creating Axeptio entities in bulk, with checkpointed execution and automatic resume. The sections below cover the provisioner API and entity recipes.
Quick Start (Provisioner API)
import {
createClient,
createExecutor,
OrganizationProvisioner,
ProjectProvisioner,
CookieConfigurationProvisioner,
TermsConfigurationProvisioner,
ProvisioningCallbacks,
} from '@axeptio/provisioning';
const client = createClient({
clientId: process.env.FRONTEGG_M2M_CLIENT_ID!,
secret: process.env.FRONTEGG_M2M_SECRET!,
baseURL: process.env.AXEPTIO_API_URL!,
environment: 'staging',
});
const executor = createExecutor(client, 'my-migration-2024', './provisioning-state', true); // true = dry run
const org = new OrganizationProvisioner('org', {
companyName: 'Example Corp',
email: '[email protected]',
country: 'FR', line1: '123 Rue de la Paix', city: 'Paris', postalCode: '75001',
isProfessional: 'YES',
});
const project = new ProjectProvisioner('project', {
name: 'Example Website', websiteURL: 'https://example.com', locales: ['en', 'fr'],
});
const cookies = new CookieConfigurationProvisioner('cookies', {
projectId: '', language: 'en', country: 'FR',
steps: [{ layout: 'welcome', title: 'We use cookies' }],
});
const terms = new TermsConfigurationProvisioner('terms', {
projectId: '',
config: { language: 'en', title: 'Terms of Service', name: 'tos', mandatory_download: false },
content: {
sections: [
{ uid: 'intro', name: 'introduction', title: 'Introduction', blocks: [
{ type: 'title', content: 'Introduction' },
{ type: 'richText', content: 'Welcome to our service.' },
] }]
},
});
project
.addConfiguration(cookies)
.addConfiguration(terms)
.onSuccess(ProvisioningCallbacks.publishProject(['cookies']))
.onSuccess(ProvisioningCallbacks.startScan({ maxPages: 10 }));
org.addProject(project);
executor.addOrganization(org);
// environment is 'staging' or 'production' - dry run is controlled by executor creation
await executor.execute({ environment: 'staging', organizationBatchSize: 1, projectBatchSize: 2 });Post-Provisioning Callbacks
Add callbacks to execute actions after successful or failed provisioning:
import { ProvisioningCallbacks } from '@axeptio/provisioning';
// Single callback
project.onSuccess(ProvisioningCallbacks.publishProject(['cookies', 'tcf']));
// Multiple callbacks
project
.onSuccess(ProvisioningCallbacks.publishProject())
.onSuccess(ProvisioningCallbacks.startScan({
maxTabs: 3,
maxPages: 10,
testCMP: true,
languages: ['en', 'fr']
}))
.onFailure(ProvisioningCallbacks.logFailure('Project creation failed'));
// Custom callbacks
project.onSuccess(async (result: Project, context) => {
console.log(`Project created: ${result.name} (${result._id})`);
await sendNotificationEmail(result.name);
await updateExternalSystem(result._id);
});
// Chain multiple callbacks
project.onSuccess(ProvisioningCallbacks.chain(
ProvisioningCallbacks.publishProject(['cookies']),
ProvisioningCallbacks.logSuccess('Project published'),
customNotificationCallback
));Built-in Callbacks
| Callback | Description | Parameters |
|----------|-------------|------------|
| publishProject(services?) | Publish project configurations | services: string[] - Default: ['cookies', 'tcf'] |
| startScan(config) | Launch automator scan job | maxTabs, maxPages, testCMP, languages |
| logSuccess(message?) | Log successful completion | message: string - Optional custom message |
| logFailure(message?) | Log failure details | message: string - Optional custom message |
| chain(...callbacks) | Execute multiple callbacks in sequence | callbacks: ProvisioningCallback[] |
Note: Callbacks are preserved during state recovery. When resuming from a saved state, callbacks will still execute for newly completed nodes, but not for previously completed ones.
Projects Groups
Projects groups are organizational folders used in the backoffice to organize projects. They execute after projects are created so their projectIds array can be populated.
// Create projects first
const project1 = new ProjectProvisioner('proj-1', projectData1);
const project2 = new ProjectProvisioner('proj-2', projectData2);
// Create a projects group and add projects to it
const projectsGroup = new ProjectsGroupProvisioner('group-1', {
name: 'Website Projects',
organizationId: 'org-123' // Optional - will be set from context
})
.addProject(project1) // Projects will be referenced by ID
.addProject(project2)
.onSuccess(ProvisioningCallbacks.logSuccess('Projects group created'));
// Add to executor
org.addProject(project1);
org.addProject(project2);
executor.addOrganization(org);
executor.addProjectsGroup(projectsGroup); // Executed after projectsExecution Order: Projects Groups are executed after Projects to ensure project IDs are available for the projectIds array.
Entity Recipes (copy-paste)
Small, focused snippets to reduce context load. Replace IDs/emails as needed.
Organizations & Projects
const org = new OrganizationProvisioner('org', { companyName: 'ACME', email: '[email protected]', country: 'FR', line1: '1 Rue', city: 'Paris', postalCode: '75001', isProfessional: 'YES' });
const project = new ProjectProvisioner('proj', { name: 'Website', websiteURL: 'https://acme.com' });
org.addProject(project);
executor.addOrganization(org);Projects Groups
const group = new ProjectsGroupProvisioner('group', { name: 'Web Properties' }).addProject(project);
executor.addProjectsGroup(group);Cookies Configuration
const cookies = new CookieConfigurationProvisioner('cookies', {
projectId: '', language: 'en', country: 'FR',
steps: [{ layout: 'welcome', title: 'We use cookies' }],
});
project.addConfiguration(cookies);Auto-categorization mode (bulk-friendly)
Optionally let the provisioner build steps from a list of vendor solution IDs. Vendors are assigned to their most frequent category, cookie-step templates are used to shape steps, and an optional ConsentWall can be added as a special step.
const autoCookies = new CookieConfigurationProvisioner('cookies-auto', {
projectId: '',
language: 'en',
autoCategorize: {
vendorIds: ['64a...','64b...'],
language: 'en',
withConsentWall: true,
},
});
project.addConfiguration(autoCookies);Notes:
- Reorders 'info' to index 1 when present, 'other' to last.
- Supports
specialStepsfor the ConsentWall. - Uses
/vendors/categories,/vendors/solutions/{id}/{language}, and/templates/cookie-stependpoints.
Extending Cookie Configuration Validation
Cookie configurations support two extensibility hooks for migration-specific validation logic:
1. Language Resolver Hook
Override how the configuration language is determined for validation:
cookieConfig.setConfigLanguageResolver((provisioner) => {
// Example: Use a custom property for original language
return (provisioner as any).originalLanguage || provisioner.state.data.language;
});Use case: When you need to validate against a different language value than the normalized API language (e.g., distinguishing between 'nl' and 'nlinf' in Dutch migrations).
2. Vendor Validator Hook
Inject custom vendor validation logic:
cookieConfig.setVendorValidator(
(vendorProvisioner, configLanguage, context) => {
// Custom validation logic
const vendorLang = vendorProvisioner.someProperty;
if (vendorLang !== configLanguage) {
throw new Error(`Validation failed in ${context}: vendor=${vendorLang}, config=${configLanguage}`);
}
},
(vendorId: string) => {
// Vendor lookup function - returns provisioner object for vendor ID
return project.getChildren().find(child => child.getId() === vendorId);
}
);Use case: When vendor IDs need domain-specific validation beyond MongoDB ObjectID format checks (e.g., language matching, category restrictions).
Integration:
Both hooks run during the validation phase, before any API calls are made. They work together:
const cookiesConfig = new CookieConfigurationProvisioner('cookies-en', {
projectId: 'proj-123',
language: 'en',
autoCategorize: { vendorIds: [...], language: 'en' }
});
// Set language resolver first
cookiesConfig.setConfigLanguageResolver((provisioner) => {
// Return migration-specific language
return (provisioner as any).originalLanguage || provisioner.state.data.language;
});
// Then set vendor validator
cookiesConfig.setVendorValidator(
(vendorProvisioner, configLanguage, context) => {
// Validator receives language from resolver
validateLanguageMatch(vendorProvisioner, configLanguage, context);
},
(vendorId) => project.getChildren().find(child => child.getId() === vendorId)
);
project.addConfiguration(cookiesConfig);Both methods return this for method chaining.
Project Design & Stylesheet
Provision project theming with a simple style guide. We expose types and a helper to mirror WidgetGenerator behavior without pulling its internals.
Types:
StyleGuideInput— { lightColor, darkColor, themeColor, isDarkMode?, isMonochrome?, font? } (hex colors without '#')ProjectStylesheet— shape compatible withProjectInput(colors,fonts,widgetStyle,overlayStyle,isCustomStyle)
Helper:
integrateStyleGuide(style: StyleGuideInput): ProjectStylesheet
Example:
import { ProjectProvisioner, integrateStyleGuide, type StyleGuideInput } from '@axeptio/provisioning';
const sg: StyleGuideInput = {
lightColor: 'F5BD55',
darkColor: '000000',
themeColor: 'f6c434',
isDarkMode: false,
isMonochrome: false,
font: 'Lato',
};
const stylesheet = integrateStyleGuide(sg);
const project = new ProjectProvisioner('project-1', {
name: 'Website',
websiteURL: 'https://example.com',
...stylesheet,
});Notes:
ProjectInput.colors,widgetStyle, andoverlayStylesupport extended fields (e.g.,toggle_on,consent_button_*,borderRadius,position.side).isCustomStyle: trueis set by the helper to indicate custom design.
Terms (ContractsV2) Configuration
const terms = new TermsConfigurationProvisioner('terms', {
projectId: '',
config: { language: 'en', title: 'Terms of Service', name: 'tos' },
content: { sections: [{ uid: 'intro', name: 'introduction', blocks: [ { type: 'title', content: 'Introduction' }, { type: 'richText', content: 'Welcome.' } ] }] }
});
project.addConfiguration(terms);Vendors (Company & Solution)
const company = new VendorCompanyProvisioner('vendor-co', { name: 'Acme Analytics', domain: 'acme-analytics.example' });
const solution = new VendorSolutionProvisioner('vendor-sol', {
name: 'acme-analytics',
title: { __lang: { en: 'Acme Analytics' } },
website: { __lang: { en: 'https://acme.example' } },
shortDescription: { __lang: { en: 'Analytics' } },
categoryIds: [],
} as any);
executor.addVendorCompany(company);
executor.addVendorSolution(solution);Media & Assets
import { readFileSync } from 'fs';
// One step: upload a file to the asset store and register it as project media
const media = await client.uploadMedia(projectId, {
content: readFileSync('./logo.png'),
filename: 'logo.png',
contentType: 'image/png',
});
console.log(media.data?.url); // public Imgix URL
// Or drive the two API calls yourself (POST /assets → POST /vault/media)
const asset = await client.uploadAsset({
content: readFileSync('./logo.png'),
filename: 'logo.png',
contentType: 'image/png',
});
await client.createMedia({ projectId, ...asset });
// Remove a media entry (the uploaded asset URL is unaffected)
await client.deleteMedia(media._id);Billing (Customer, VAT, Subscription)
// 1) Customer
const customer = await client.createCustomer({
email: '[email protected]', name: 'Example Corp',
tax_exempt: 'none', preferred_locales: ['fr-FR','en-US'],
address: { line1: '123 Rue', city: 'Paris', country: 'FR', postal_code: '75001' },
metadata: { isProfessional: 'YES', vatNumber: 'FR123...', contactName: 'Jane' },
expand: ['tax_ids','invoice_settings.default_payment_method','sources'],
});
// 2) VAT
await client.createCustomerTaxId(customer.id, { type: 'eu_vat', value: 'FR123...' });
// 3) Subscription
project.setSubscription(new SubscriptionProvisioner('sub', {
customer: customer.id,
items: [{ price: 'price_agency_monthly', quantity: 1 }],
payment_behavior: 'default_incomplete', currency: 'eur',
expand: ['latest_invoice','discounts','items.price'],
metadata: { organizationId: '...', userId: '...' },
}));Users, Invitations, Access
const user = new UserProvisioner('user', { email: '[email protected]', password: 'Secret123!', displayName: 'New User', data: { preferredLanguage: 'en', acceptTerms: true }});
user.addInvitation(new InvitationProvisioner('inv', { email: '[email protected]', data: { collection: 'organizations', id: 'orgId' }}));
executor.addUser(user);
executor.addGroupAssignment(new GroupManagerProvisioner('assign', { userId: '', projectId: 'projectId', action: 'add' }));User Management & Access Control
Provision users and manage their access to organizations and projects:
import {
UserProvisioner,
InvitationProvisioner,
GroupManagerProvisioner
} from '@axeptio/provisioning';
// Create user with invitations
const user = new UserProvisioner('user-1', {
email: '[email protected]',
password: 'SecurePassword123',
displayName: 'New User',
data: {
preferredLanguage: 'en',
acceptTerms: true
}
})
.onSuccess(ProvisioningCallbacks.logSuccess('User created successfully'))
.onSuccess(ProvisioningCallbacks.addUserToOrganization('org-id'));
// Add invitation to organization
const invitation = new InvitationProvisioner('invite-1', {
email: '[email protected]',
data: {
collection: 'organizations',
id: 'org-id-123',
templateVars: {
organization: { companyName: 'Example Corp' }
}
}
});
user.addInvitation(invitation);
// Manage group assignments
const groupAssignment = new GroupManagerProvisioner('group-1', {
userId: 'user-id-from-context',
projectId: 'project-id-123',
action: 'add'
});
executor.addUser(user);
executor.addGroupAssignment(groupAssignment);User Management Callbacks
| Callback | Description | Usage |
|----------|-------------|-------|
| addUserToOrganization(orgId) | Add user to organization | After user creation |
| addUserToProject(projectId) | Add user to project | After user creation |
| logSuccess(message?) | Log successful completion | After successful user creation |
CLI Usage
# Set Frontegg M2M credentials (from your secret store)
export FRONTEGG_M2M_CLIENT_ID="..."
export FRONTEGG_M2M_SECRET="..."
# Run provisioning
axeptio-provision run my-migration config.json
# Check status
axeptio-provision status
# Resume after interruption (automatic - just re-run with config)
axeptio-provision run my-migration ./config.json
# Automatically merges fresh build with existing state
# View checkpoints
axeptio-provision checkpoints my-migration
# Rollback created entities
axeptio-provision rollback my-migration
# Clean up state
axeptio-provision clean my-migrationConfiguration
Create a config.json file for execution settings:
{
"environment": "staging",
"organizationBatchSize": 3,
"projectBatchSize": 5,
"configurationBatchSize": 10,
"continueOnError": false,
"maxRetries": 3,
"retryDelay": 1000
}Note: The environment field specifies the target environment ("staging" or "production"). Dry-run behavior is controlled separately when creating the executor:
// Dry run mode
const executor = createExecutor(client, 'state-id', './provisioning-state', true);
// Live mode
const executor = createExecutor(client, 'state-id', './provisioning-state', false);Supported Entity Types
- Organizations - Company entities that own projects
- Projects - Individual compliance projects
- Projects Groups - Organizational folders for projects (backoffice convenience)
- Users - User accounts with authentication and access control
- Invitations - User invitations to organizations/projects
- Group Assignments - User access management for organizations/projects
- Cookie Configurations - Cookie consent widgets
- TCF Configurations - IAB TCF compliance setups
- DPO Configurations - Data Protection Officer contacts
- Terms Configurations - Legal terms and conditions (ContractsV2)
- Subs Configurations - Data processing subscriptions (formerly Processing)
- Subscriptions - Billing subscriptions (Stripe)
- Media & Assets - Project media uploaded to the Imgix-backed asset store
Error Handling & Recovery
The package automatically handles:
- Network failures with exponential backoff
- Rate limiting with intelligent queuing
- Partial failures with detailed error reporting
- State corruption with checkpoint recovery
Authentication errors
Every 401/403 surfaces as AuthenticationError (non-retryable). Key off err.code to branch:
| Condition | err.code |
| -------------------------------------------- | ---------------------------------- |
| Frontegg mint failed (bad clientId/secret) | ErrorCode.AUTHENTICATION_FAILED |
| Request 401'd even after a one-shot re-mint | ErrorCode.AUTHENTICATION_FAILED |
import { AuthenticationError, ErrorCode } from '@axeptio/provisioning';
try {
await fetcher.organizations().fetch();
} catch (err) {
if (err instanceof AuthenticationError && err.code === ErrorCode.TOKEN_EXPIRED) {
// Prompt the user to mint a fresh token; reconstruct the client.
}
throw err;
}Resuming After Interruption (Automatic)
// Resume is automatic! Just build fresh and use setFreshTree()
// Orchestrator automatically merges with existing state
const built = await buildProvisioners(jobConfig);
const executor = new ResumableProvisioningExecutor(client, stateManager);
await executor.setFreshTree(built); // Merges fresh with state!
await executor.execute(config);
// OLD WAY (deprecated - only for rollback/status):
// const executor = await ResumableProvisioningExecutor.fromState(
// 'my-migration-2024',
'./provisioning-state',
client,
false // isDryRun: false for live mode, true for dry run
);
await executor.execute(config);Rollback Created Entities
// Rollback all created entities in reverse order
await executor.rollback();State Management
State is automatically persisted to disk:
./provisioning-state/
├── my-migration-2024.json # Main state file
└── checkpoints/
└── my-migration-2024/
├── 2024-01-15T10-30-00-execution-start.json
├── 2024-01-15T10-45-00-execution-complete.json
└── 2024-01-15T10-46-00-execution-failed.jsonCheckpoint Strategy
Checkpoints are milestone-based, not per-entity, to minimize file system overhead while maintaining recovery capability:
When Checkpoints Are Created:
- ✅
execution-start- At the beginning of execution (for crash recovery) - ✅
execution-complete- When execution succeeds (marks completion) - ✅
execution-failed- When execution fails (preserves partial progress) - ✅
execution-paused- When execution is manually paused - ✅
rollback-complete- When rollback finishes
State Updates:
- State is updated after every provisioner executes (via
updateStateAfterExecution()) - This ensures progress is always persisted without creating excessive checkpoint files
- Resume operations use the main state file, which always contains the latest progress
Why Not Per-Entity Checkpoints?
- A migration with 100 entities would create 100+ checkpoint files per run
- State updates already happen after each entity, so progress is never lost
- Milestone checkpoints provide sufficient recovery points for debugging
- Reduces filesystem I/O and improves performance
Recovery Scenarios:
- Crash during execution: Use main state file (most recent) or
execution-startcheckpoint - Failed execution: Use
execution-failedcheckpoint to inspect partial progress - Manual pause: Use
execution-pausedcheckpoint to resume later - Debugging: Compare checkpoints to see state at different execution phases
Rate Limiting
The client automatically handles Axeptio's rate limits:
- Authentication: 60 requests per 5 minutes
- Scan endpoints: 15 requests per minute
- General API: 50 requests per minute (configurable)
Environment Variables
Env vars apply to M2M mode only (bearer mode takes a caller-provided token directly in code — no env vars), plus the API URL and environment.
# Frontegg M2M (machine user)
# Both are SECRETS — inject from a secret store (e.g. SSM SecureString at
# /<service>/<env>/frontegg-m2m-client-id and /<service>/<env>/frontegg-m2m-secret),
# never plaintext env/IaC vars. Beware trailing newlines when writing them.
FRONTEGG_M2M_CLIENT_ID=...
FRONTEGG_M2M_SECRET=...
# Non-secret knobs (defaults shown; must match the environment you call —
# tokens are environment-scoped)
FRONTEGG_M2M_BASE_URL=https://login.axept.io
FRONTEGG_M2M_TOKEN_PATH=/identity/resources/auth/v1/api-token
# Common
AXEPTIO_API_URL=https://api-staging.axept.io/v1
AXEPTIO_ENVIRONMENT=staging # or production
# Headless CMP API — a different host from AXEPTIO_API_URL, needed only by the
# headless-backed reads (analytics().consentStats()). Must point at the same
# environment as AXEPTIO_API_URL: Frontegg tokens are environment-scoped.
AXEPTIO_HEADLESS_API_URL=https://headless-api.axeptio.techThe CLI requires FRONTEGG_M2M_CLIENT_ID + FRONTEGG_M2M_SECRET and authenticates as the machine user; the legacy --admin-username/--admin-password options have been removed.
CLI Commands
| Command | Description |
|---------|-------------|
| run <state-id> <config-file> | Execute provisioning (auto-resumes from existing state) |
| status | Show all provisioning states |
| checkpoints <state-id> | List available checkpoints |
| rollback <state-id> | Delete all created entities |
Note: The resume command has been removed. The run command now automatically resumes from existing state if present, merging fresh data with saved progress.
| clean <state-id> | Remove state files |
API Reference
Client Configuration
ClientConfig is a discriminated union: pick one of the two auth shapes.
interface ClientConfigBase {
baseURL?: string; // Required via env or explicit value
headlessBaseURL?: string; // Headless CMP host; required only by headless-backed reads
environment?: 'staging' | 'production';
maxRetries?: number;
retryDelay?: number;
rateLimitRpm?: number;
mockStripe?: boolean;
mockShake?: boolean;
mockPublish?: boolean;
}
// Frontegg M2M client credentials — the server mints/refreshes its own token
interface M2MClientConfig extends ClientConfigBase {
clientId: string; // Frontegg machine-user clientId (secret)
secret: string; // Frontegg machine-user secret (secret)
fronteggBaseUrl?: string; // default https://login.axept.io
fronteggTokenPath?: string; // default /identity/resources/auth/v1/api-token
}
// Caller-supplied access token — attached verbatim, no mint/refresh
interface BearerClientConfig extends ClientConfigBase {
bearer: string; // caller-minted Frontegg accessToken (secret)
}
type ClientConfig = M2MClientConfig | BearerClientConfig;See Authentication above for the full behavior (mint, cache, 401 re-mint).
Extended Client Methods
New helper endpoints exposed by the client for bulk cookie configuration generation:
Templates & Steps
getCookieStepTemplates()— list available cookie-step templatesgetCookieStepTemplateByName(name)— fetch a specific cookie-step templategetCustomCookieTemplate({ language, country?, subdivision?, steps })— generate a cookie template from step names
Vendors & Categories
getVendorCategories(params?)getVendorSolutionLocalized(id, language)
Project Admin Actions
lockProject(projectId)/unlockProject(projectId)unpublishProject(projectId)duplicateProject(projectId, { name?, websiteURL? })
Media & Assets
uploadAsset({ content, filename, contentType? })— upload a file to the asset store (POST /assets)createMedia({ projectId, url, ... })— register an uploaded asset as project media (POST /vault/media)uploadMedia(projectId, { content, filename, contentType? })— upload + register in one calldeleteMedia(id)— delete a project media entry
Style and Template Helpers
Cookies
buildCookieConfiguration(client, { projectId, language, stepNames?, stepsOverride?, published?, googleConsentMode? })positionStepByNameAndIndex(steps, name, index)/concatenateOtherSteps(steps)
Design
integrateStyleGuide(style: StyleGuideInput): ProjectStylesheet
Execution Configuration
interface ExecutionConfig {
environment: 'staging' | 'production';
organizationBatchSize?: number;
projectBatchSize?: number;
configurationBatchSize?: number;
continueOnError?: boolean;
maxRetries?: number;
retryDelay?: number;
}Note: The environment field specifies the target environment ('staging' or 'production'). Dry-run behavior is controlled separately via the --dry flag or isDryRun parameter when creating the executor.
Examples
See the examples/ directory for complete usage:
basic-usage.ts— Org, Project, Cookies, Terms, optional Subscriptionorganizations-and-projects.ts— Multiple projects + projects groupcookies-config.ts— Cookie configuration with steps/vendorsterms-config.ts— ContractsV2 Terms with config/content modelvendors.ts— Vendor company and solution creationbilling.ts— Stripe customer + VAT + subscriptionusers-and-access.ts— Users, invitations, and project accessmedia-upload.ts— Upload a file and register it as project mediaconfig.json— Execution configuration
License
© Axeptio 2025 - Proprietary
