Editions
The open-source build, the Enterprise Edition, and Bouée Cloud.
Bouée is open-core, as PostHog is. The code builds two ways:
| Build | What it contains | License | How it is built |
|---|---|---|---|
| FOSS | everything outside ee/ | AGPL-3.0 (LICENSE) | the default: nothing to set |
| EE (Enterprise Edition) | FOSS plus ee/ | ee/ is commercial (ee/LICENSE) | BOUEE_EDITION=ee |
Where Bouée runs is a separate question from how it is built:
- Self-hosted: someone's own server, running the FOSS build. Our alpha is one. A paying customer runs the EE build with a license key instead; see Licensed self-hosted.
- Bouée Cloud: our hosted service, where customers sign up, pick a subdomain and create an account. It runs the EE build, with cloud settings that switch on what only a shared service needs; those settings arrive with sign-up. Customers pay through Stripe, monthly or annually, so billing decides what a Cloud workspace has; nobody on Cloud gets a license key.
Self-hosters keep everything one installation needs: brand subdomains under the installation's own domain, custom domains, Instance settings and multiple workspaces. Only a shared service needs the rest, so it goes in ee/:
- tenants: customers who onboard themselves as workspaces, invites, and ownership transfer;
- several base domains (bouee.so and bouee.io);
- the instance's own sign-in sender, separate from every tenant's brand;
- installation-wide inbound webhook routing.
So far only the boundary, the build switch and the extension points exist. The Enterprise Edition's features themselves are still to be built; ee/README.md holds its plans.
How core reaches ee/
Core imports one module for everything the editions differ in: @/edition (src/edition/index.ts). It exports edition. In the FOSS edition every capability is null, and a null capability means core does what it does today.
src/edition/
index.ts the one module core imports: `edition` and the capability types
types.ts Edition and the capability interfaces
foss.ts the open-source edition, every capability null
selected.ts the swap point: re-exports the open-source edition
ee/
tsconfig.json the tsconfig an EE build compiles with
edition.ts the Enterprise Editionindex.ts imports @/edition/selected, always by that alias and never as ./selected. In a FOSS build, core's @/* path resolves it to src/edition/selected.ts. An EE build compiles with ee/tsconfig.json, whose paths map that one specifier to ee/edition.ts. Nothing else changes between the editions.
The build switch
next.config.ts reads BOUEE_EDITION whenever Next loads it, for build, dev, typegen and start:
- Unset, empty or
foss:typescript.tsconfigPathistsconfig.json. ee:typescript.tsconfigPathisee/tsconfig.json. Ifee/is missing, the build stops withBOUEE_EDITION=ee, but this checkout has no ee/ ….- Any other value: the build stops rather than guess. That includes
cloudandself-hosted, which name deployments, not builds.
Next uses typescript.tsconfigPath both for type checking and for path resolution in webpack and in Turbopack, so tsc and both bundlers see the same swap. A webpack build logs Using tsconfig file: ee/tsconfig.json.
This uses tsconfig paths rather than a bundler alias for three reasons, each checked in a scratch build:
- Webpack ignores a
resolve.aliasfor a@/…specifier. It applies tsconfigpathsfirst, whereas Turbopack'sresolveAliasdoes override them. - Vitest cannot resolve a bare specifier such as
@bouee/ee. Vitest does not read tsconfig paths, so every suite that imports the edition would fail.@/edition/selectedgoes through Vitest's@alias to the open-source file instead, so core's suites always run against the FOSS edition. - Webpack's persistent cache ignores which tsconfig is in use. An EE build that ran after a FOSS one got the FOSS edition back from the cache. So
next.config.tsgives EE builds their own webpack cache (server-production-eeand so on). Turbopack's cache follows the tsconfig by itself.
The webpack hook needs the turbopack: {} key beside it: without that key, a default next build (Turbopack) exits and says it found a webpack config.
Commands
| FOSS | EE | |
|---|---|---|
| Type check | pnpm typecheck | pnpm typecheck:ee (tsc -p ee) |
| Lint | pnpm lint | pnpm lint |
| Tests | pnpm test (always the FOSS edition) | pnpm test:ee (ee/'s suites, run as the EE build) |
| Build | pnpm build | BOUEE_EDITION=ee pnpm build |
| Image | docker build . | docker build --build-arg BOUEE_EDITION=ee . |
Core's tsconfig.json excludes ee/, so the FOSS type check is the same whether or not ee/ is there. ee/tsconfig.json checks core and ee/ together.
The boundary
-
ESLint (
eslint.config.mjs).- Outside
ee/, no import may name it (../ee/…,@bouee/ee/…), and type-only imports count: the open-source distribution has nothing to check them against. - Under
src/, onlysrc/edition/may import@/edition/*.
- Outside
-
src/lib/__tests__/edition-boundary.test.ts. It also sees the import forms ESLint cannot see,import()andrequire, and it resolves every specifier to a path. It checks that:- no core file reaches
ee/; - nothing outside
src/edition/imports anything but@/edition; @/edition/selectedis imported exactly once, byindex.ts, through the alias;- the edition module imports nothing else from core at runtime, so
ee/can build on core without a cycle; - no client component can reach the edition or
ee/.
The same file tests the switch itself: which tsconfig each value selects, that each of those tsconfigs resolves
@/edition/selectedwhere it should, the per-edition webpack cache, and the Dockerfile's removal ofee/. The checks that needee/are skipped when it is absent. - no core file reaches
-
Server-only.
ee/edition.tsimportsserver-only, so an EE build fails as soon as a client component reaches it.- Core's own modules cannot use
server-only: Vitest cannot resolve it without adding the package. The client-reach check above guards core instead, asclient-boundary.test.tsdoes for the rest of core.
Extension points
These are declared in src/edition/types.ts, and each is null in the FOSS edition. They are a first cut: reshape them when the first implementation needs it.
| Capability | Interface | FOSS (null) | Where it plugs in |
|---|---|---|---|
baseDomains | BaseDomains.list(configured) | Only PlatformSettings.hostedMailBaseDomain | The brand host rules (src/lib/brand-hosts.ts and its callers), hosted addresses |
tenants | TenantOnboarding.createTenant, .invite, .transferOwnership | Staff create workspaces from Instance settings | A sign-up route in ee/, workspace membership |
signInSender | SignInSender.send(message) | platformIdentity() and the installation's mail connection | sendVerificationOTP in src/lib/auth.ts |
inboundRouting | InboundRouting.workspaceFor(recipients) | Each workspace has /api/inbound/<workspace id>; /api/inbound works only while there is a single workspace | src/app/api/inbound/route.ts |
A hook reads the capability and falls through to today's code:
import { edition } from "@/edition";
if (edition.signInSender) {
void edition.signInSender.send({ to: email, ...otpEmail({ otp }) }).catch((err) => console.error("[auth] OTP send failed", err));
return;
}
// …the platformIdentity() path, unchangedee/edition.ts fills a capability by spreading foss and replacing that one field. ee/ may import anything in core, but core never imports ee/.
Distribution
This follows PostHog's process:
ee/stays in this repository, under its own commercial license. Its source is readable; using it needs that license.- The open-source image never carries it. A Docker build without
BOUEE_EDITION=eedeletesee/beforepnpm build, and the runner stage only receives the standalone output. - A FOSS mirror can be published automatically: this repository with
ee/removed. Core never importsee/, so removing it leaves a complete open-source build. Nothing publishes the mirror yet.
Licensed self-hosted
A paying self-hosted customer runs the EE image (docker build --build-arg BOUEE_EDITION=ee .) with a license key from Bouée. Other self-hosters run the FOSS image, and Bouée Cloud runs the EE build with its cloud settings and needs no key.
- The key names the customer, plan, features, seats and expiry, and is signed with Ed25519. Licenses are sold by the year, so each key runs to the end of the term paid for, and a renewal is a new key.
- The check is offline. The app verifies the key against the public keys its image was built with (
ee/licensing/public-keys.ts). It makes no network call, so air-gapped installations work. - Expiry is gentle. A key is "expiring soon" for its last 30 days. After it expires, 14 days of grace keep the features on; then they switch off. No data is ever deleted.
- Keys are issued by the Bouée team with
pnpm license:keygen,license:signandlicense:inspect, fromee/tools/.
The key format, the tools, key rotation and publishing the EE image are in ee/README.md in the app repository.