White-Label Tenants¶
The platform is delivered as tenant instances (spec 072): each tenant (customer)
runs its own branded instance with its own configuration and — for tenants that need
asset isolation — its own contract deployments. The FairWins instance is the
default tenant (tenants/fairwins/manifest.json).
Full design: specs/072-white-label-tenants/ (spec, plan, research D1–D11, data-model,
quickstart, tasks).
The tenant manifest is the single source of truth¶
tenants/<tenant-id>/manifest.json describes one tenant: identity (name, domains, brand
assets, theme tokens), settings (features, chains, membership tier pricing, fee bps),
and contract set (shared | dedicated). Rules:
- Never hardcode a tenant identity value the manifest defines. Member-visible
names, logos, URLs, PWA metadata, share text, and support/legal links resolve through
frontend/src/config/tenant.js. - No secrets. Manifests are public config, baked into client bundles.
npm run tenants:validateenforces structure, cross-manifest domain uniqueness, fee caps, cohort separation, and dedicated-mode record presence. CI gates on it (tenant-manifestsjob in.github/workflows/test.yml).
Frontend¶
- Selection is build-time:
VITE_TENANT_IDpicks the manifest (defaultfairwins). One origin = one tenant; there is no runtime tenant switching, so a built instance physically contains only its own tenant's identity. An unknown id throws at module init — absence never falls back to another tenant. frontend/src/config/tenant.jsexposesgetActiveTenant(),tenantBrand(),tenantLinks(),isFeatureEnabled(id),tenantChainIds(cohort),tenantThemeClass(),tenantContractMode().- Theming rides the existing
platform-*seam: the default tenant's tokens stay static infrontend/src/theme.css; non-default tenants get an equivalent.platform-<id>rule set generated from their manifest and injected byfrontend/src/theme/tenantTheme.js.ThemeContextappliesplatform-<activeTenantId>and persiststhemePlatformfor the pre-hydration script. - Feature gating: a feature absent from
settings.featuresmust be absent from nav/routes — never present-but-broken. - Assistant providers (spec 104). Two feature ids and one optional settings block govern the
member assistant:
assistant— the assistant itself and the Tools ▸ Assistant tab that hosts its preferences and the API access console. Members still opt in individually.assistant-byok— offers the GutterToken (your credits) provider: the member pastes their own GutterTokensk-…key (stored on their device only, wallet-scoped, never backed up) and their browser callsapi.guttertokens.comdirectly. The tenant is not in that request path, sees no message content and charges nothing for it. Requiresassistant(the validator refusesassistant-byokwithout it — it is a radio inside the Assistant card, not a surface). A tenant that wants the assistant on its membership-funded rail only simply leaves this out.fairwinsenables both.settings.assistant.guttertokenReferralCode— optional string,^[A-Za-z0-9_-]{1,64}$, delivered ashttps://app.guttertokens.com/signup?ref=<code>(it prefills GutterToken's referral field) from the Get a key link on the Assistant card. Absent means the plain link; present, the card discloses in words that the tenant receives GutterToken credit when a member funds an account through it. The validator refuses a code on a tenant that does not enableassistant-byok(dead config that would still read as a live referral arrangement in the FinOps catalogue, entryreferral-guttertoken). It is an identity value like any other manifest field: public, never a credential, and never hardcoded in a shipped path — resolve it throughgetActiveTenant().settings.assistant. No code is registered forfairwinsyet, so its manifest carries nosettings.assistantblock.
Contract isolation (dedicated tenants)¶
- Isolation for value-bearing state is on-chain, by separate proxy instances of the audited implementations — never by a frontend/gateway filter alone. v1 ships zero Solidity changes.
- Deploys reuse the deterministic CREATE2 flow: setting
TENANT_ID=<id>on the existing deploy scripts prefixes salts (tenant:<id>:…) and writes records todeployments/tenants/<id>/<network>-chain<chainId>-v2.json(same schema as the shared records).TENANT_IDunset — orfairwins— is the shared estate, byte-identical to before. - A tenant id is immutable once deployed (salts derive from it).
- A dedicated tenant resolves ONLY its own contract set; a contract absent from the
set on a chain reads as not-deployed for that tenant there. No silent fallback to
the shared estate, ever. The set is generated:
node scripts/utils/sync-frontend-contracts.js --tenant <id> --network <n> --chainId <c>merges the tenant's record intofrontend/src/config/tenants/<id>.contracts.json, which the tenant-branding plugin injects viavirtual:tenant;getContractAddress/getContractAddressForChainconsult it before (instead of) the shared maps wheneverisDedicatedTenant(). A live dedicated tenant with no generated set fails the build. - Per-tenant gateway: run a relay-gateway instance against the tenant's deployment records — its startup allowlist (spec 036 FR-025) then is the tenant scoping.
Lifecycle¶
draft → live ⇄ suspended → retired, changed by PR (git history is the audit trail).
Suspension never traps value: contracts don't know about suspension; members keep the
documented direct-claim path. Shared-mode ("branding-only") tenants have cosmetic
isolation only — this must be disclosed to the tenant, and graduation to dedicated
contracts preserves claimability of positions opened on the shared estate.
Native channels (spec 102)¶
A tenant gets iOS/Android apps by adding the OPTIONAL native block to its
manifest (tenants/<id>/manifest.json; schema in tenants/manifest.schema.json):
"native": {
"ios": { "appId": "app.<tenant>.member" }, // bundle identifier
"android": { "appId": "app.<tenant>.member" }, // application id
"displayName": "<Home-screen name>",
"iconSource": "icons/native/"
}
Rules, all gated: appIds are reverse-DNS and globally unique ACROSS tenants
(the same tenant may reuse one id on both platforms); an absent block means
NO native channel — a native build for that tenant fails naming it, never
borrowing another tenant's identity; the associated domain (passkey RP +
universal links) is the tenant's identity.domains[0], synced into both
shells by scripts/native/sync-native-config.js --tenant <id>.
Onboarding beyond the manifest is the OPERATOR ceremony — store records under
the tenant's appIds, association files generated per tenant
(scripts/native/generate-association-files.js --tenant <id> …) and served
from the TENANT's origin, and its own support-floor document. See
docs/runbooks/native-release-operations.md and
docs/developer-guide/native-channels.md.