Bouée
Editions

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:

BuildWhat it containsLicenseHow it is built
FOSSeverything 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 Edition

index.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.tsconfigPath is tsconfig.json.
  • ee: typescript.tsconfigPath is ee/tsconfig.json. If ee/ is missing, the build stops with BOUEE_EDITION=ee, but this checkout has no ee/ ….
  • Any other value: the build stops rather than guess. That includes cloud and self-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.alias for a @/… specifier. It applies tsconfig paths first, whereas Turbopack's resolveAlias does 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/selected goes 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.ts gives EE builds their own webpack cache (server-production-ee and 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

FOSSEE
Type checkpnpm typecheckpnpm typecheck:ee (tsc -p ee)
Lintpnpm lintpnpm lint
Testspnpm test (always the FOSS edition)pnpm test:ee (ee/'s suites, run as the EE build)
Buildpnpm buildBOUEE_EDITION=ee pnpm build
Imagedocker 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/, only src/edition/ may import @/edition/*.
  • src/lib/__tests__/edition-boundary.test.ts. It also sees the import forms ESLint cannot see, import() and require, 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/selected is imported exactly once, by index.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/selected where it should, the per-edition webpack cache, and the Dockerfile's removal of ee/. The checks that need ee/ are skipped when it is absent.

  • Server-only.

    • ee/edition.ts imports server-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, as client-boundary.test.ts does 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.

CapabilityInterfaceFOSS (null)Where it plugs in
baseDomainsBaseDomains.list(configured)Only PlatformSettings.hostedMailBaseDomainThe brand host rules (src/lib/brand-hosts.ts and its callers), hosted addresses
tenantsTenantOnboarding.createTenant, .invite, .transferOwnershipStaff create workspaces from Instance settingsA sign-up route in ee/, workspace membership
signInSenderSignInSender.send(message)platformIdentity() and the installation's mail connectionsendVerificationOTP in src/lib/auth.ts
inboundRoutingInboundRouting.workspaceFor(recipients)Each workspace has /api/inbound/<workspace id>; /api/inbound works only while there is a single workspacesrc/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, unchanged

ee/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=ee deletes ee/ before pnpm build, and the runner stage only receives the standalone output.
  • A FOSS mirror can be published automatically: this repository with ee/ removed. Core never imports ee/, 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:sign and license:inspect, from ee/tools/.

The key format, the tools, key rotation and publishing the EE image are in ee/README.md in the app repository.

On this page