Files
pangolin/docs/enterprise-private-code.md
2026-11-09 16:23:48 -05:00

111 lines
5.3 KiB
Markdown

# Enterprise / private code
Pangolin builds three ways, chosen by `server/build.ts` (generated by
`npm run set:oss|enterprise|saas`):
- `oss`: open source (AGPLv3). `server/private/` is **not** part of this build.
- `enterprise`: self-hosted commercial build. Includes `server/private/`.
- `saas`: Pangolin Cloud. Includes `server/private/`.
Check the build at runtime with `import { build } from "@server/build"`
(`build === "oss"`, `"enterprise"`, `"saas"`). The UI imports the same constant.
## The `server/private/` directory
- Everything under `server/private/` is proprietary and only compiled into the
enterprise and saas builds.
- Every file there must start with the Fossorial Commercial License header.
`license_header_checker.py` enforces this. Copy the header from any existing
private file.
- Its layout mirrors `server/`: `private/routers/<entity>/`, `private/lib/`,
`private/middlewares/`, `private/auth/`, `private/license/`.
- The private router files (`server/private/routers/external.ts`,
`internal.ts`, `integration.ts`, `hybrid.ts`) import the public routers, add the
private routes, and re-export them. The servers load them through
`#dynamic/routers/external` and so on.
## Import rules
| You are in… | To import a private file | To import a public file |
| --- | --- | --- |
| `server/private/**` | `#private/<path>` (**never** `#dynamic`) | `@server/<path>` |
| anywhere else (`server/**`, `src/**`) | **never** use `#private`. Use `#dynamic/<path>` | `@server/<path>` |
`esbuild.mjs` enforces both rules: `privateImportGuardPlugin` flags
`#private` imports outside `server/private`, and `dynamicImportGuardPlugin` flags
`#dynamic` imports **inside** `server/private` (private code already knows it
wants the private file, so it uses `#private`).
### How `#dynamic` works
`#dynamic/<path>` resolves at build time:
- `oss` → `server/<path>`
- `enterprise` / `saas` → `server/private/<path>`
(In `tsconfig.oss.json` it maps to `./server/*`. In
`tsconfig.enterprise.json` / `tsconfig.saas.json` it maps to
`./server/private/*`.)
So any `#dynamic` import needs **two files at the same relative path with the
same exports**:
1. `server/private/<path>.ts`: the real enterprise implementation (with the
license header). It can import other private code via `#private/...`.
2. `server/<path>.ts`: the OSS version. Usually a stub that no-ops or returns a
safe default. Sometimes a simpler working version.
Examples:
| `#dynamic` import | OSS file | Private file | OSS behaviour |
| --- | --- | --- | --- |
| `#dynamic/middlewares` (`logActionAudit`) | `server/middlewares/logActionAudit.ts` | `server/private/middlewares/logActionAudit.ts` | Middleware just calls `next()` |
| `#dynamic/lib/isLicencedOrSubscribed` | `server/lib/isLicencedOrSubscribed.ts` | `server/private/lib/isLicencedOrSubscribed.ts` | Always `false` |
| `#dynamic/lib/blueprints/MaintenanceSchema` | `server/lib/blueprints/MaintenanceSchema.ts` | `server/private/lib/blueprints/MaintenanceSchema.ts` | Empty `z.object({})` |
| `#dynamic/lib/cache`, `lib/lock`, `lib/rateLimitStore` | in-memory / local | Redis-backed | Single-node implementations |
| `#dynamic/lib/traefik` | `server/lib/traefik/` | `server/private/lib/traefik/` | Different config generator |
Rules of thumb:
- Keep the exported names and signatures identical, or the build that uses the
other file breaks. Type-check under both builds: `npm run set:oss && npx tsc
--noEmit`, then `npm run set:enterprise && npx tsc --noEmit`.
- If `index.ts` re-exports (e.g. `server/middlewares/index.ts`), make sure the
private `index.ts` re-exports the same names.
- Don't scatter `if (build === "oss")` checks through public code that would
need private logic. Put the difference behind a `#dynamic` module.
- A private-only route doesn't need a stub. Register it in the
`server/private/routers/*.ts` files and it simply doesn't exist on OSS.
## Private config
- Public config (`config/config.yml`) is validated by
`server/lib/readConfigFile.ts` and read through
`config.getRawConfig()` from `@server/lib/config`.
- **Private config** (`config/privateConfig.yml`) is validated by
`privateConfigSchema` in `server/private/lib/readConfigFile.ts` and read through
`privateConfig.getRawPrivateConfig()` from `#private/lib/config`.
- Put enterprise-only config keys in the private schema, not the public one.
If the UI needs a value, `PrivateConfig`'s constructor copies it to
`process.env`, and `src/lib/pullEnv.ts` reads it from there.
## Private database tables
Tables used only by enterprise features go in
`server/db/pg/schema/privateSchema.ts` **and**
`server/db/sqlite/schema/privateSchema.ts`. Both are re-exported from
`@server/db`, so imports are the same as for public tables. The parity rules in
`docs/database.md` apply.
## UI side
- Enterprise-only pages live under the `src/app/[orgId]/settings/(private)/`
route group.
- Gate enterprise UI with `env.flags.disableEnterpriseFeatures` (from
`useEnvContext()` / `pullEnv()`) and tier checks with
`usePaidStatus().isPaidUser(tierMatrix[TierFeature.X])`. Show
`<PaidFeaturesAlert tiers={tierMatrix.x} />` above paywalled controls. See
`src/app/[orgId]/settings/general/page.tsx` for the pattern.
- Server-side tier enforcement uses `isLicensedOrSubscribed(orgId, tiers)` from
`#dynamic/lib/isLicencedOrSubscribed`.