Architecture
The foundations, module by module
The code of the toolkit and its kits is private; this page is its public description, written from the code. For each part it says what a product no longer has to build, how the part works, what it guarantees and which parts it meets. It also shows the few lines a product writes to use it.
State: October 2026
How the parts fit
Take one request to an endpoint that works inside an organization. It carries the token of a user who signed in through the identity kit’s Keycloak. Tenancy reads which organization the request is for. A membership check from Onboarding confirms that the user belongs to it, and the lifecycle gate from Organizations turns the request away if the organization is suspended or deleted. The endpoint then asks entitlements and usage whether the organization may make the call and whether it still fits its quota. Billing works beside the request, turning the payment provider’s webhooks into the plan behind those answers.
The operator works with the same organizations through the admin API. Around all of it, the design-system kit is the core of the product’s interface, and the delivery kit takes the product from tests to a deploy with a health gate.
Kit · container image
Identity kit
A product's Keycloak is built on one pinned image and provisioned by its scripts, and no user gets a token before accepting the terms of use.
How it works
The kit is a base image, not a running service: Keycloak pinned by digest, two scripts and the login messages in Slovenian. A product adds its login theme, built in an earlier stage of its Dockerfile, and runs kc.sh build. Product defaults stay out of the kit: a short wrapper in the product holds them and runs setup in the container.
setup provisions the realm. It is a script, not an export, because an export is machine-written JSON that nobody reviews. The script states, step by step, the decisions the API depends on: public clients with PKCE (S256) for the product's app and for the operator's, the audience the API validates, realm roles flattened into a roles claim for ASP.NET, a declared organization_id attribute and the operator's realm role.
require-terms makes accepting the terms of use a default required action and gives it to the existing users who have not accepted yet. Both scripts are safe to repeat, and setup creates only what is missing, so a deploy can run both every time.
What it guarantees
- Pinned down to the build tools. Keycloak is pinned by digest, and so is the toolchain that builds the image: the Dockerfile frontend and BuildKit. The
jqthe image carries is pinned by checksum. The image has noRUNstep, and the tests build it with the release's builder. - It stops before it changes anything. A missing required variable stops
setupbefore it signs in to Keycloak, and the message names every missing one. A test drops each of the nine required names in turn and checks that no refused run changed Keycloak. - No token before the terms. A new user gets the required action. In the kit's tests, a password grant is refused before acceptance and a token is issued after it, and a decline fails the sign-in. Only the time of acceptance is stored, and Keycloak ignores a write of it through its admin REST API.
- What
setupprovisions does not change unseen. The realm provisioned from a given set of variables is committed, normalized, as a golden file, and a test compares a fresh provisioning with it byte for byte, so a change to it, a Keycloak upgrade included, reaches review as a diff.
What a product writes
The product's whole Keycloak image, after the stage that builds its theme:
# The kit's image; kc.sh build runs here because it must follow the theme JAR.
FROM <registry>/<toolkit>/keycloak:X.Y.Z@sha256:…
COPY --from=theme /app/keycloak-theme/dist_keycloak/<product>-keycloak-theme.jar \
/opt/keycloak/providers/
RUN /opt/keycloak/bin/kc.sh build --db=postgres --health-enabled=true
The wrapper that runs setup with the product's values:
CONTAINER="${KEYCLOAK_CONTAINER:-<product>-keycloak}"
export KEYCLOAK_REALM="${KEYCLOAK_REALM:-<realm>}"
export KEYCLOAK_CLIENT_ID="${KEYCLOAK_CLIENT_ID:-<realm>-spa}"
# ... the other required names, each with the product's default
flags=()
for name in KEYCLOAK_REALM KEYCLOAK_CLIENT_ID KEYCLOAK_API_AUDIENCE ADMIN_CLIENT_ID \
SPA_ORIGINS ADMIN_ORIGINS DEV_USER DEV_PASSWORD ORGANIZATION_ID; do
flags+=(-e "$name")
done
exec docker exec "${flags[@]}" "$CONTAINER" /opt/microsaas/keycloak/setup
Connects to
Tokens carry what the API reads: multi-tenancy and onboarding identify the user by its subject, and the operator's realm role is the system scope the Admin API requires. After a deploy, the health gate of the delivery kit can check that the sign-in page comes from the product's theme.
setuprequire-terms.NET packages
Multi-tenancy
A product does not resolve or check the organization itself and writes no organization filter; an endpoint marks that it needs an organization, a job opens a scope for one, and queries over its entities see only that organization's rows.
How it works
An endpoint states what it needs in its metadata: [RequiresTenant] for a call inside one organization, [AllowInactiveTenant] for one that must still answer while the organization is locked, [AllowSystemScope(…)] for an operator's call, admitted only with the role or token scope it names, and [AllowAnonymousTenant] for a call outside tenancy.
For a tenant endpoint, the middleware reads the organization from the X-Organization-Id header. A value that is not a GUID is refused; without the header, a token claim is read only when a product switches that on. With the Onboarding module registered, the middleware then checks in the database that the caller is a member. It reads the organization's lifecycle on each such request, so a suspension applies to the next call. The organization then lives in an ambient context for the rest of the request.
The data layer reads the same context. A product's entity implements IOrganizationEntity, and the product's DbContext puts the toolkit's query filter, which returns only the current organization's rows, on every such entity (shown under onboarding). A save interceptor, attached with the DbContext, fills in a new row's organization. A background job sets the context itself: ITenantScopeFactory opens a scope for one organization, for none, or a system scope that is given a reason, and restores the previous context when the scope closes.
What it guarantees
- Refusals carry a code. The middleware's own refusals are RFC 9457 problems with a machine code:
tenant_required,tenant_invalid,not_authenticated,tenant_access_denied,tenant_inactiveorsystem_scope_not_permitted. The toolkit's tests read the problem body back. - A locked organization is turned away. Suspended, pending deletion or deleted, an organization gets
423from every tenant endpoint that does not allow inactive ones. - No tenant sees nothing. With no organization in context, a query over an
IOrganizationEntityreturns no rows rather than all of them; only a system scope sees every organization. - A row stays in its organization. Inside a tenant, saving a row whose organization was changed throws. Tests in the canary product, which takes kit releases first, prove this, the filter and the stamp on insert against a real PostgreSQL database, and the isolation once more through the API's own pipeline.
- The context ends with the request. The middleware clears the context when the request ends, whether it succeeded or failed.
What a product writes
The composition, and a controller with no tenant code in it:
builder.Services.AddMicroSaasTenancy(); // the X-Organization-Id header, scopes
// Onboarding brings the membership check, Organizations the lifecycle gate.
builder.Services.AddMicroSaasOnboarding<AppDbContext>(o => o.ProductCode = "myproduct");
builder.Services.AddMicroSaasOrganizations<AppDbContext>();
builder.Services.AddDbContext<AppDbContext>((sp, o) => o.UseNpgsql(connectionString)
.AddInterceptors(sp.GetRequiredService<OrganizationSaveChangesInterceptor>()));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseMicroSaasTenancy(); // needs the user: after authentication
[ApiController, Route("api/projects"), RequiresTenant, Authorize]
public sealed class ProjectsController(AppDbContext db) : ControllerBase
{
[HttpGet] // no Where: the tenant filter returns this organization's rows only
public Task<List<Project>> List(CancellationToken ct) => db.Projects.ToListAsync(ct);
[HttpPost] // no OrganizationId: the save interceptor sets it from the tenant
public async Task Create(NewProject request, CancellationToken ct)
{
db.Projects.Add(new Project { Name = request.Name });
await db.SaveChangesAsync(ct);
}
}
What a client of a suspended organization receives:
HTTP/1.1 423 Locked
Content-Type: application/problem+json
{"title":"Locked","status":423,"detail":"Tenant is not active.","code":"tenant_inactive"}
Connects to
Onboarding & organizations provides the membership check, the lifecycle, and OrganizationDbContextBase, the base of the product's DbContext. The Admin API runs in system scope. Entitlements read the same lifecycle and give a locked organization an empty snapshot, and usage quotas refuse in the same problem shape.
Tenancy.AbstractionsTenancy.Runtime.NET packages
Onboarding & organizations
Everything between a user's first sign-in and a team working inside an organization: the first organization and its Owner, invites, roles, the lifecycle, and an audit row for each membership change.
How it works
At first sign-in the app calls one endpoint, POST /api/onboarding/ensure. A user who already belongs to an organization gets an existing one back. When it provisions, one transaction records the user and creates the organization with a unique slug, the Owner membership, the onboarding state and an audit row. The product's hooks (IOrganizationCreatedHook) run inside it before it commits; the entitlements module seeds a new organization's default grants through one of them.
Invites, members and the lifecycle are endpoints of the organizations module. An Admin or Owner creates an invite for an email address and a role; the invitee, signed in, accepts it with the invite's token, which creates the membership. Any member can list the members. An organization starts Active and can become Suspended, PendingDeletion or Deleted. Its status stays readable while it is locked, the operator suspends and restores it in system scope, and multi-tenancy answers a locked organization with 423.
What it guarantees
- A repeat does not create a second organization. Onboarding looks for an existing membership before the transaction and once more inside it. A test runs onboarding for a user who already belongs to an organization, and the result is
AlreadyProvisionedwith that organization. - Only a hash of an invite token is stored. A token is 32 random bytes; the database keeps, under a unique index, a SHA-256 hash of it with a pepper the product configures, and without a pepper no invite is created or accepted. A new invite revokes the organization's earlier live invites to the same address, and an accepted invite cannot be used again. An unknown, revoked, used or expired token gets its own error code.
- The last Owner stays. Only an
Ownerchanges or removes anOwner, and the lastOwnercan be neither demoted nor removed (LAST_OWNER_GUARD). Creating, revoking and accepting an invite, changing a role and removing a member each write an audit row.
What a product writes
The product's DbContext, on the toolkit's base class, with the toolkit's models and the tenant filter added last:
public sealed class AppDbContext(
DbContextOptions<AppDbContext> options, ITenantContextAccessor tenant)
: OrganizationDbContextBase(options, tenant)
{
public DbSet<Project> Projects => Set<Project>(); // Project : IOrganizationEntity
protected override void OnModelCreating(ModelBuilder b)
{
b.AddMicroSaasOnboardingModels(); // users, organizations, members, audit log
b.AddMicroSaasOrganizationModels(); // invites, lifecycle
b.AddOrganizationQueryFilters(this); // last: filters every IOrganizationEntity
}
}
Invites with a seat quota, and the endpoints. The hook runs after the role check, before a token exists: the product supplies the count, usage compares it with the organization's quota, and a refusal reaches the client as a 403 problem with a QUOTA_* code.
builder.Services.AddMicroSaasOrganizations<AppDbContext>(o => o.PreCreateInviteHook =
(sp, organizationId, ct) => sp.RequireStatefulQuotaAsync(
"seats.max", additional: 1, // a Quota entitlement of the plan
getCurrentUsage: (org, c) => sp.GetRequiredService<AppDbContext>()
.Set<MsMembership>().CountAsync(m => m.OrganizationId == org, c),
ct));
// ...
app.MapMicroSaasOnboardingEndpoints<AppDbContext>(); // first login, me, organizations
app.MapMicroSaasOrganizationEndpoints<AppDbContext>(); // invites, members, lifecycle
Connects to
Multi-tenancy relies on the membership check from Onboarding and the lifecycle from Organizations. The seat hook calls usage, which reads the quota from entitlements, and the Admin API works with the same organizations, members, lifecycle and audit log.
Onboarding.ApiOnboarding.EFCoreOrganizations.ApiOrganizations.EFCore.NET packages
Entitlements
Feature code asks whether an organization may use a feature and how much of something it may have, and gets one answer from the organization's grants instead of a plan name.
How it works
An entitlement is a key with a type: a flag (Boolean), a number (Quota) or a JSON value. The product declares its keys and their types in a registry, which is what the usage meter checks a key against. An organization's grants are rows: the organization, optionally one of its users, the key, the type, the value, a source and an optional start and end. A grant counts while it is in force: started, not ended and not revoked, so a trial is a grant with an end.
The resolver merges the grants in force into one snapshot for the organization, or for the organization and one user, the organization's grants first. It caches the snapshot in the process for a configurable time, 2 minutes by default. Feature code reads it through IEntitlementResolver: HasAsync for a flag, GetQuotaAsync for a number.
A new organization gets its first grants from hooks inside the onboarding transaction. The module's own hook writes default grants from configuration, with optional trial days, and a product can add its own, like the one below that starts the organization on the free plan.
What it guarantees
- Nothing is granted by default. A key without a grant in force is not in the snapshot:
HasAsyncanswers false, and both kinds of quota check in usage refuse. - Merging only widens. Where grants of the organization and of a user meet,
truewins for a flag and the highest value wins for a quota, so a user's grant can raise what the organization has but not lower it. - A locked organization's snapshot is empty. The resolver reads the lifecycle before its cache, a check that is on by default, so feature code that asks the resolver about a suspended, pending-deletion or deleted organization gets an empty snapshot on its next call, not when the cache expires. A caller can skip the check. The resolver's tests check this, both merge rules, and a user's higher quota winning over the organization's.
- One grant per key. Two partial unique indexes allow one unrevoked grant per key for the organization, and one per key for each of its users.
What a product writes
The registration with the product's keys, the hook, and the model in the DbContext:
builder.Services.AddMicroSaasEntitlements<AppDbContext>(
builder.Configuration, // optional default grants
o => o.CacheTtl = TimeSpan.FromMinutes(10), // 2 minutes by default
registry => registry
.Add("exports.enabled", MsEntitlementType.Boolean, "CSV export")
.Add("exports.monthly", MsEntitlementType.Quota, "Exports a month")
.Add("projects.max", MsEntitlementType.Quota, "Projects at a time")
.Add("seats.max", MsEntitlementType.Quota, "Members at a time"));
builder.Services.AddScoped<IOrganizationCreatedHook, AssignFreePlan>();
// In AppDbContext.OnModelCreating, before AddOrganizationQueryFilters(this):
b.AddMicroSaasEntitlementModels();
The hook asks the product's plan catalogue, shown under billing, for the free plan's grants and writes them:
public sealed class AssignFreePlan(IPlanCatalog plans, IEntitlementGrantService grants)
: IOrganizationCreatedHook // runs in onboarding's transaction, before it commits
{
public async Task OnOrganizationCreatedAsync(
Guid organizationId, Guid? createdByUserId, CancellationToken ct)
{
var free = plans.GetGrants("free", DateTime.UtcNow, periodEndUtc: null);
foreach (var grant in free)
await grants.UpsertAsync(organizationId, createdByUserId, grant, ct);
}
}
Connects to
Onboarding runs the hooks in its transaction, and the lifecycle the resolver reads is the one multi-tenancy answers with 423. Usage takes its limits from the snapshot, and billing writes grants only through the same IEntitlementGrantService the hook uses.
Entitlements.ApiEntitlements.EFCore.NET packages
Usage
A product counts what an organization uses and asks whether the next action fits its quota, for events in a period and for resources it holds.
How it works
Usage has two kinds of quota, and both take their limit from the same place: the Quota value the organization's entitlements snapshot holds for the key. Both therefore follow the same grant.
A metered quota counts events, such as exports in a month. The meter adds each event to a daily counter in UTC, for the organization or for one of its users, and the gate sums the counters over a window: the current month in UTC by default, or the last 30 days. CheckAndConsumeAsync compares what is used, plus the amount requested, with the limit, and counts the request only when it is allowed. A metered key is one the product declared as a quota in the entitlements registry.
A resource quota covers what an organization holds, such as seats or projects. Nothing is metered: the product supplies the current count from its own rows, and RequireStatefulQuotaAsync compares that count, plus what the request adds, with the limit. A product checks seats with it in the invite hook shown under onboarding.
For a plan page, each kind has a call that reports the limit and what is used without refusing: GetStatusAsync and GetStatefulQuotaStatusAsync. A product registers the meter and the gate with AddMicroSaasUsage and AddMicroSaasUsageQuotaGate, and adds the counters to its DbContext with the model builder's AddMicroSaasUsage.
What it guarantees
- Refused unless granted. Both kinds refuse a key without a grant and a limit of zero; the meter also refuses a key the product did not declare as a quota.
- Every refusal says why. The meter's decision names its reason:
entitlement_missing,entitlement_type_mismatch,quota_zeroorquota_exceeded, and the gate's tests check each one. The resource check refuses with a403problem whose code isQUOTA_ENTITLEMENT_MISSING,QUOTA_DISABLEDorQUOTA_EXCEEDED, written by multi-tenancy like its own refusals. - A refused request is not counted. The meter counts a request only after the gate allows it, and only when the caller asks it to.
- The window is explicit. Counters are kept per UTC day, and the window is one setting of the gate; its tests check where each window starts.
What a product writes
A resource quota in the endpoint that creates a project:
[HttpPost]
public async Task Create(NewProject request, CancellationToken ct)
{
// The count is the product's; at the limit, a 403 problem with code QUOTA_EXCEEDED.
await HttpContext.RequireStatefulQuotaAsync("projects.max", additional: 1,
getCurrentUsage: (o, c) => db.Projects.CountAsync(p => p.OrganizationId == o, c),
ct);
db.Projects.Add(new Project { Name = request.Name });
await db.SaveChangesAsync(ct);
}
A metered quota in a service an endpoint calls:
public sealed class Exports(IUsageQuotaGate quota)
{
public async Task<bool> TryStartAsync(Guid organizationId, CancellationToken ct)
{
var decision = await quota.CheckAndConsumeAsync(organizationId, userId: null,
"exports.monthly", amount: 1, consumeIfAllowed: true, nowUtc: null, ct);
// Counted only when allowed; a refusal's DenyReason is entitlement_missing,
// entitlement_type_mismatch, quota_zero or quota_exceeded.
return decision.Allowed;
}
}
Connects to
Limits come from entitlements, where billing also writes its grants. The seat check shown under onboarding is a resource quota, refusals reach the client through multi-tenancy, and the Admin API shows an organization's usage over the last 7 and 30 days.
Usage.AbstractionsUsage.RuntimeUsage.ApiUsage.EFCore.NET packages
Billing
The organization's plan follows its subscription at the payment provider, and billing writes grants only through the entitlements module, so feature code needs neither the provider nor its price ids.
How it works
Each payment provider is an adapter behind one interface, IBillingProvider. The module's own packages reference no adapter, and only the Stripe adapter references Stripe's library. Stripe's adapter registers with AddMicroSaasStripeBilling, Lemon Squeezy's with AddMicroSaasLemonSqueezyBilling.
A webhook arrives at POST /api/v1/webhooks/{provider}, and the adapter with that name verifies it and turns it into one provider-neutral event, with the subscription, the provider's plan id, whether the subscription gives access, and the event time. The endpoint keeps one record per delivery: one already processed is answered 200 and not applied again; any other is applied and marked processed, or keeps its error and is answered with one, so the provider retries it.
The sync skips a subscription event older than the last one it applied, records the subscription's state and chooses the plan key. It writes grants only through the grant service of entitlements, IEntitlementGrantService, so the gates of entitlements and usage see them. The product's billing logic is its plan catalogue: an IPlanCatalog turns a provider's plan id into a plan key, and a plan key into its grants.
What it guarantees
Each property below holds for both adapters, which derive what the shared code needs from their provider's payload.
- Verified before anything is stored. Stripe's adapter checks the
Stripe-Signatureheader with Stripe's library; Lemon Squeezy's compares the HMAC-SHA256 of the raw body withX-Signaturein constant time. An event that fails is refused with400before a delivery is recorded. Tests forge a Stripe signature and change a signed Lemon Squeezy body, and both events are refused. - A redelivery leaves the result as it was. The delivery id is Stripe's event id; Lemon Squeezy sends none, so its adapter derives one from the event name, the object and its
updated_at, and the same delivery sent again gets the same id. Applying an event again leads to the same plan. - A late event does not roll the plan back. The event time is the
createdof Stripe's event and theupdated_atof Lemon Squeezy's object, and a subscription event older than the last one applied is skipped. - The plan follows the subscription's status, not the price on the payload. Stripe's
active,trialingandpast_duekeep the paid plan, as do Lemon Squeezy'son_trial,active,past_dueandcancelled, until it expires; a subscription in any other status the adapter knows no longer holds it. A failed payment alone does not change the plan.
What a product writes
The registration, and the model in the DbContext:
builder.Services.AddMicroSaasBilling<AppDbContext>();
builder.Services.AddMicroSaasStripeBilling(stripeOptions); // the provider's adapter
builder.Services.AddSingleton<IPlanCatalog, PlanCatalog>(); // the product's plans
// ...
app.MapMicroSaasBillingEndpoints<AppDbContext>();
// In AppDbContext.OnModelCreating, before AddOrganizationQueryFilters(this):
b.AddMicroSaasBilling();
The plan catalogue, with a placeholder price id:
public sealed class PlanCatalog : IPlanCatalog
{
public string ResolvePlanKey(string provider, string? planExternalId) =>
planExternalId == "price_pro" ? "pro" : "free";
public IReadOnlyList<UpsertEntitlementGrantRequest> GetGrants(
string planKey, DateTime nowUtc, DateTime? periodEndUtc, Guid? userId = null) =>
[new("projects.max", MsEntitlementType.Quota, null, planKey == "pro" ? 25 : 1,
null, "subscription", nowUtc, null, userId),
new("seats.max", MsEntitlementType.Quota, null, planKey == "pro" ? 10 : 3,
null, "subscription", nowUtc, null, userId)];
}
Connects to
Entitlements resolve the grants billing writes, and the quotas of usage read them; the free-plan hook under entitlements takes a new organization's first grants from the same catalogue.
Billing.AbstractionsBilling.ApiBilling.EFCoreBilling.Providers.StripeBilling.Providers.LemonSqueezy.NET packages
Admin API
An operator finds any organization of the product, sees its members, usage and history, and changes its lifecycle with a recorded reason, without database access or a back office of the product's own.
How it works
MapMicroSaasAdminEndpoints maps every route of the API into one group, and the group carries [AllowSystemScope("admin:endpoints")]. Multi-tenancy admits a call only when the token carries admin:endpoints as a scope or a role, and then runs it in system scope, across organizations. The identity kit creates that role in the realm, for the operator.
The directory searches organizations by name or slug and filters them by lifecycle status; each row shows the lifecycle, the number of members, the onboarding state and the usage of the last 30 days. The overview of one organization adds the reason for its lifecycle status, its members, its usage over 7 and 30 days with the busiest keys, and its latest 50 audit entries. The audit log can also be searched across organizations, by organization, user, action and time.
A lifecycle change is one call with a status, a reason and an optional ticket id.
What it guarantees
- One gate in front of every route. The scope is checked before any route of the group runs, and a token without it gets a
403problem with the codesystem_scope_not_permitted. The middleware's tests check the403. - A lifecycle change carries its reason and its audit row. Without a status or a reason the call is refused with a
400problem,missing_statusormissing_reason. The new status and anADMIN_LIFECYCLE_SETaudit row, with the status, the reason and the ticket id, are saved in oneSaveChanges. - A suspension applies from the next request. Multi-tenancy reads the lifecycle on each request to a tenant endpoint that does not allow inactive organizations, so the organization's next such call gets
423, as the middleware's tests check. - Pages stay bounded. A page holds 25 rows by default and at most 200, and the directory loads the details of a page in a fixed number of queries, not one query per row.
What a product writes
The registration, and where the gate sits in the pipeline that multi-tenancy shows in full:
builder.Services.AddMicroSaasAdminApi(); // the Admin API's options
// ... after app.UseAuthentication() and app.UseAuthorization():
app.UseMicroSaasTenancy(); // the system-scope gate
app.MapMicroSaasAdminEndpoints<AppDbContext>(); // every route: "admin:endpoints"
Connects to
The Admin API works on the organizations, members, lifecycle and audit log of onboarding & organizations, and its usage totals come from the counters of usage.
Admin.ApiKit · npm package
Design-system kit
A product takes its interface core from one npm package and writes its brand in one file, and its own tests refuse a brand that breaks the kit's contract.
How it works
The kit is one npm package, published as its TypeScript and CSS source, with no build step and no dependencies of its own: React, React Router and i18next are peers pinned to exact versions. It holds the tokens, the ds- components with React wrappers, a catalogue, lint rules and test helpers.
Its stylesheet declares seven cascade layers once, in order: ds.reset, ds.tokens, ds.brand, ds.base, ds.layout, ds.components and product. A product imports the kit's CSS, then its own brand.css. The brand file sets the twelve brand roles inside @layer ds.brand, and the core gives those roles no value of its own. The product's own rules go in the last layer, product.
The product builds its ESLint and stylelint configs from the kit's factories, createEslintConfig and createStylelintConfig, and its tests call two helpers from the kit: assertBrand on the brand file and assertKitLintRules on the configs.
This site is built on the kit's layers, tokens and brand contract: it pins the package, bundles the kit's CSS before its own, and its brand.css passes assertBrand in its tests.
What it guarantees
- The brand is twelve roles, no more.
assertBrandfails when a role is missing and when the brand file sets any other--ds-*property, and it names each one. A test sets the focus ring's colour, a core role, and the brand is refused. - Contrast and state colours are measured. Ten pairs are checked against WCAG contrast ratios: nine at 4.5 : 1, among them the link, brand, action and current colours on white and text on the action colour, and the brand mark on white at 3 : 1. The four interactive colours must also lie at least ΔE2000 20 from the success, warning and danger tones by default, so an action colour cannot read as a state.
- The product's lint is tested against the kit's rules.
assertKitLintRuleslints thirteen snippets the kit forbids, such asclassNameorstyleon a screen, raw HTML, or ads-class or a colour literal in product CSS, with the product's own ESLint and stylelint configs, and fails unless each is refused as an error. - Components are checked for accessibility and pixels. In 55 component test files, axe-core checks what the component renders. The kit's CI captures every catalogue entry under two test brands at three widths and compares each with a committed baseline at zero changed pixels; a missing or extra image fails too.
What a product writes
Its brand, shortened, under a neutral prefix:
@layer ds.brand {
:root {
--acme-navy-900: #111a2a;
--acme-navy-800: #19283f;
--acme-orange-500: #f59e0b;
--ds-color-brand: var(--acme-navy-800);
--ds-color-action: var(--acme-navy-800);
--ds-color-action-hover: var(--acme-navy-900);
/* ... eight more of the twelve roles ... */
--ds-border-brand: var(--acme-orange-500); /* decorative only, never text */
}
}
The test that holds it to the contract:
import { fileURLToPath } from "node:url";
import { assertBrand } from "@<scope>/design-system/testing";
import { it } from "vitest";
it("brand.css honours the design-system brand contract", () => {
assertBrand(fileURLToPath(new URL("./brand.css", import.meta.url)));
});
Connects to
The frontend jobs of the delivery kit install the package with install scripts switched off. The kit's own version and its changelog flags, [contract], [visual] and [peer], are covered under releases and versioning.
brand.cssassertBrandassertKitLintRulesKit · GitLab CI/CD
Delivery kit
A product builds its pipeline from the kit's components at one pinned version: tests with dependency scans, a secret scan, image builds, a deploy that backs up the database and ends in a health gate, and a restore test.
How it works
The kit is seven GitLab CI/CD components and an ops image that carries the deploy scripts: dotnet-test builds and tests against PostgreSQL and scans the NuGet packages for advisories, secret-scan runs gitleaks over the commits a pipeline brings in, and frontend-gate installs without install scripts and runs npm audit; the other four are frontend-test, image-build, deploy and restore-test.
A deploy runs on the CI runner and reaches the host over SSH. .env, the host's configuration and the registry password travel on standard input, never as arguments, and the host key is pinned, never scanned. On the host, the kit's script backs up, pulls, starts the containers, waits until the named ones report healthy and runs the product's hook. The restore test runs on a schedule or by hand, never during a deploy of the same environment. The kit's own pipeline runs the deploy component end to end against a test host.
What it guarantees
- Checked before the host is touched. A missing variable, an unfilled placeholder in a health URL, a missing compose file or missing registry credentials stop the deploy before its first SSH connection, and the product's missing variables are named in one message. A deploy without a health URL is refused.
- A health gate from outside. The runner requests each health URL from outside, through DNS and the edge, until it answers with a status below 400, by default up to 24 times. Every URL is checked even after one has failed, and any failure fails the job. URLs the product lists as closed must answer 401, 403 or 404.
- A consistent backup before the pull. The dump of the running database and the row counts come from one repeatable-read snapshot while the old version keeps writing; every file gets a SHA-256 checksum, and a failed backup stops the deploy. A test against a real PostgreSQL writes rows throughout a backup and restores it with the same counts.
- A restore test that leaves the running server alone. It verifies the checksums first, restores into a throwaway container without a network, started from the running database's image, and requires every dump to restore and the restored row counts to equal the ones the backup recorded. Tests check that the running server stays untouched and that a corrupted archive is refused before anything is restored.
What a product writes
One environment's deploy, shortened:
include:
- component: $CI_SERVER_FQDN/…/deploy@delivery-vX.Y.Z
inputs:
environment: dev
product: <product>
environment_url: https://$DEV_APP_HOST
required_vars: APP_HOST API_HOST AUTH_HOST POSTGRES_PASSWORD
health_urls: https://{API_HOST}/health https://{APP_HOST}/
theme_check: "<realm> <realm>-spa <theme>"
backup_db_user: <product>
backup_db_name: <product>
needs: ["build:api", "build:keycloak"]
Its restore test, in the same list:
- component: $CI_SERVER_FQDN/…/restore-test@delivery-vX.Y.Z
inputs: { environment: dev, product: <product> }
Connects to
By default a deploy waits for the product's Keycloak, built on the identity kit, and theme_check looks for its login theme on the sign-in page. The frontend jobs install the design-system kit with the job's own token. Its versions and changelog flags are covered under releases and versioning.
deployrestore-testsecret-scan.NET packages
Releases and versioning
Each release says in its changelog what a product must do to take it, and a product takes it by moving one pinned version.
How it works
The nineteen .NET packages share one version, and a vX.Y.Z tag on the toolkit releases them. A product pins the version once, as a property in its Directory.Build.props, and each package reference reads that property. Abstractions and Tenancy.Abstractions, the contracts the modules share, depend on no other package.
Each kit has its own version and is released from main. A release is a merge request that dates the top section of the kit's changelog; after the merge, the kit's release job runs tests on that commit, publishes the kit and tags the commit.
Every changelog flags what a consumer must do: a package entry is marked [migration] when the product's DbContext needs a migration, [contract] when a response or a public signature changed, and [behaviour] when the outcome changed under the same contract. The kits add their own flags, such as [keycloak], [visual], [peer] and [pipeline].
Two factory scripts link a release to the products. Before a package release, one builds every active product that uses the packages against the release candidate. After a release of the packages, the identity kit or the delivery kit, the other opens a merge request in each active product that pins an older version.
What it guarantees
- One version for nineteen packages. The tag pipeline packs every package at the tag's version and fails unless exactly nineteen come out. Its tests run in an earlier stage, so a red suite publishes nothing.
- No release without its changelog section. The packages' pipeline refuses a version whose changelog section is missing or still marked unreleased. A kit's release job reads its version from the changelog's top heading, which must be
## X.Y.Z — YYYY-MM-DDor, to release nothing,## X.Y.Z — unreleased; anything else stops the job. - A kit's tag names what the registry serves. The release job never overwrites a version the registry already holds, and a failed lookup stops it. After publishing, it verifies the image or package the registry serves, and only then tags the commit it was built from.
- An upgrade arrives with its notes. The merge request the factory opens changes only the pin and carries in its description every changelog section between the product's version and the new one; the product's own merge request pipeline is the compatibility check.
What a product writes
The pin, once for the whole product, and a package reference that reads it:
<!-- Directory.Build.props: one version for every toolkit package -->
<PropertyGroup>
<MicroSaasToolkitVersion>X.Y.Z</MicroSaasToolkitVersion>
</PropertyGroup>
<!-- A project file: each toolkit package reads the same property -->
<PackageReference Include="MicroSaas.Toolkit.Tenancy.Runtime"
Version="$(MicroSaasToolkitVersion)" />
Connects to
Each kit is pinned to an exact version too: the image of the identity kit by version and digest in FROM, the design-system kit by its version in package.json and package-lock.json, and the components of the delivery kit by @delivery-vX.Y.Z on each include.
MicroSaas.Toolkit.AbstractionsCHANGELOG.mdHow this page stays true
Each C# example is copied from a small project compiled against the toolkit release this page describes, and this site’s tests fail if a block on the page is not in that project word for word. The other examples are copied or shortened from real files in the products and in the toolkit’s repository, or recorded from a run of that release’s code. The page does not follow every release: once a month, the factory’s review checks it against the releases since its last review and updates what they changed.
State: October 2026