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

5.3 KiB

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.