diff --git a/.changeset/single-source-multi-org-flag.md b/.changeset/single-source-multi-org-flag.md new file mode 100644 index 0000000000..211750ebab --- /dev/null +++ b/.changeset/single-source-multi-org-flag.md @@ -0,0 +1,34 @@ +--- +"@objectstack/types": patch +--- + +refactor: single-source the multi-org (`OS_MULTI_ORG_ENABLED`) flag resolution + +"Is this deployment multi-org?" was resolved in 10 places across 8 packages +with three subtly different inline expressions: + +- the canonical `String(readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', + 'OS_MULTI_TENANT') ?? 'false').toLowerCase() !== 'false'` (objectql registry, + plugin-dev, runtime app-plugin, cli serve/verify, cloud-connection), +- a redundant `env.OS_MULTI_ORG_ENABLED !== undefined ? … : …` variant in + plugin-auth (auth-manager `/auth/config` features + `beforeCreateOrganization` + guard), +- and a bare `process.env.OS_MULTI_ORG_ENABLED ?? process.env.OS_MULTI_TENANT` + read in the SQL driver's `isMultiTenantMode()` — which skipped the + `OS_MULTI_TENANT` deprecation warning every other site emits. + +Because the SQL driver computed the mode independently of the auth/security +layer, the driver's tenant-audit gate and the rest of the system could in +principle disagree about whether tenant isolation is active. + +Introduces `resolveMultiOrgEnabled()` in `@objectstack/types` (next to +`readEnvWithDeprecation`, the natural leaf dependency) as the single source of +truth, and routes all 10 sites through it. `@objectstack/driver-sql` gains a +direct `@objectstack/types` dependency (previously it read `process.env` +directly). + +Behaviour is unchanged everywhere except the SQL driver, which now also emits +the one-shot `OS_MULTI_TENANT`-is-deprecated warning — consistent with every +other site. This mirrors the `resolveAuthzContext` single-source pattern in +`@objectstack/core`. Follow-up (not in this change): a lint gate forbidding new +inline reads of these env vars outside the helper. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index a988b48b63..bd976682ed 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -8,7 +8,7 @@ import chalk from 'chalk'; import { bundleRequire } from 'bundle-require'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; -import { readEnvWithDeprecation } from '@objectstack/types'; +import { readEnvWithDeprecation, resolveMultiOrgEnabled } from '@objectstack/types'; import { resolveObjectStackHome } from '@objectstack/runtime'; import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js'; import { @@ -1263,7 +1263,7 @@ export default class Serve extends Command { // the `org-scoping` service at start() time and conditionally // strips the wildcard `tenant_isolation` RLS when this plugin // is absent — so registration order matters. - const multiTenant = String(readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') ?? 'false').toLowerCase() !== 'false'; + const multiTenant = resolveMultiOrgEnabled(); if (multiTenant) { try { const orgScopingPkg = '@objectstack/plugin-org-scoping'; @@ -2066,7 +2066,7 @@ export default class Serve extends Command { consolePath: loadedPlugins.includes('ConsoleUI') ? CONSOLE_PATH : undefined, driverLabel: resolvedDriverLabel, databaseUrl: redactDbUrl(resolvedDatabaseUrl), - multiTenant: String(readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') ?? 'false').toLowerCase() !== 'false', + multiTenant: resolveMultiOrgEnabled(), seededAdmin, }); diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index c81c5929ca..141a269d12 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -2,7 +2,7 @@ import { Command, Flags } from '@oclif/core'; import chalk from 'chalk'; -import { readEnvWithDeprecation } from '@objectstack/types'; +import { resolveMultiOrgEnabled } from '@objectstack/types'; import { bootStack, runCrudVerification, @@ -53,10 +53,7 @@ export default class Verify extends Command { const { config, absolutePath } = await loadConfig(flags.app); - const multiTenant = - flags['multi-tenant'] || - String(readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') ?? 'false').toLowerCase() !== - 'false'; + const multiTenant = flags['multi-tenant'] || resolveMultiOrgEnabled(); // Data fidelity runs on its own pristine stack. let crud: VerifyReport; diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 4c460ac58e..bf53f95085 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -42,7 +42,7 @@ */ import type { Plugin, PluginContext } from '@objectstack/core'; -import { readEnvWithDeprecation } from '@objectstack/types'; +import { resolveMultiOrgEnabled } from '@objectstack/types'; import { resolveCloudUrl } from './cloud-url.js'; import { resolveMarketplacePublicBaseUrl } from './marketplace-public-url.js'; import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; @@ -789,7 +789,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // writes tenant-scoped rows the same way AppPlugin's // single-tenant branch + SecurityPlugin's per-org replay do. if (opts.seedNow && datasets.length > 0) { - const multiTenant = String(readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') ?? 'false').toLowerCase() !== 'false'; + const multiTenant = resolveMultiOrgEnabled(); try { const ql: any = ctx.getService('objectql'); let metadata: any; diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 0c3fc5af84..ecf0124d78 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ServiceObject, ObjectSchema, ObjectOwnership } from '@objectstack/spec/data'; -import { readEnvWithDeprecation } from '@objectstack/types'; +import { resolveMultiOrgEnabled } from '@objectstack/types'; import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema } from '@objectstack/spec/kernel'; import { AppSchema } from '@objectstack/spec/ui'; import { applyProtection } from '@objectstack/spec/shared'; @@ -420,8 +420,7 @@ export class SchemaRegistry { this.multiTenant = options.multiTenant; } else { // Mirror the SecurityPlugin / CLI banner default (env-driven, off by default). - this.multiTenant = - String(readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') ?? 'false').toLowerCase() !== 'false'; + this.multiTenant = resolveMultiOrgEnabled(); } // ADR-0048 — default to a loud error on cross-package collision; allow an diff --git a/packages/plugins/driver-sql/package.json b/packages/plugins/driver-sql/package.json index 0231250f95..de1a175550 100644 --- a/packages/plugins/driver-sql/package.json +++ b/packages/plugins/driver-sql/package.json @@ -20,6 +20,7 @@ "dependencies": { "@objectstack/core": "workspace:*", "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*", "knex": "^3.2.10", "nanoid": "^5.1.15" }, diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 87746185f5..a1e97989a4 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -12,6 +12,7 @@ import { parseAutonumberFormat, renderAutonumber, missingFieldValues, type Auton import type { IDataDriver } from '@objectstack/spec/contracts'; import { StorageNameMapping } from '@objectstack/spec/system'; import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared'; +import { resolveMultiOrgEnabled } from '@objectstack/types'; import { diffManagedTable, driftKey, @@ -2229,9 +2230,10 @@ export class SqlDriver implements IDataDriver { private _multiTenantMode?: boolean; protected isMultiTenantMode(): boolean { if (this._multiTenantMode === undefined) { - const raw = - process.env.OS_MULTI_ORG_ENABLED ?? process.env.OS_MULTI_TENANT ?? 'false'; - this._multiTenantMode = String(raw).toLowerCase() !== 'false'; + // Single source of truth (shared with auth/registry/CLI) — previously + // this read `process.env` inline and so never emitted the + // `OS_MULTI_TENANT` deprecation warning the other sites do. + this._multiTenantMode = resolveMultiOrgEnabled(); } return this._multiTenantMode; } diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index e3f2658b11..63f2754ca8 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -12,7 +12,7 @@ import type { } from '@objectstack/spec/system'; import type { IDataEngine } from '@objectstack/core'; import type { IEmailService } from '@objectstack/spec/contracts'; -import { readEnvWithDeprecation } from '@objectstack/types'; +import { readEnvWithDeprecation, resolveMultiOrgEnabled } from '@objectstack/types'; import { mapMembershipRole, BUILTIN_ROLE_PLATFORM_ADMIN } from '@objectstack/spec'; import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js'; import { @@ -827,13 +827,7 @@ export class AuthManager { // 2. else `OS_MULTI_TENANT` (multi-tenant deployments are always // multi-org), default `'false'` → single-org / per-env runtime. beforeCreateOrganization: async () => { - const env = (globalThis as any)?.process?.env ?? {}; - const explicit = env.OS_MULTI_ORG_ENABLED; - const legacy = explicit === undefined - ? readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') - : explicit; - const flag = String(legacy ?? 'false').toLowerCase(); - if (flag === 'false') { + if (!resolveMultiOrgEnabled()) { const { APIError } = await import('better-auth/api'); throw new APIError('FORBIDDEN', { message: @@ -1505,11 +1499,7 @@ export class AuthManager { // Resolution order: explicit `OS_MULTI_ORG_ENABLED` wins, else fall // back to legacy `OS_MULTI_TENANT` (multi-tenant deployments are always // multi-org); default `'false'` → single-org / per-env runtime. - const multiOrgEnv = (globalThis as any)?.process?.env ?? {}; - const multiOrgRaw = multiOrgEnv.OS_MULTI_ORG_ENABLED !== undefined - ? multiOrgEnv.OS_MULTI_ORG_ENABLED - : (readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') ?? 'false'); - const multiOrgEnabled = String(multiOrgRaw).toLowerCase() !== 'false'; + const multiOrgEnabled = resolveMultiOrgEnabled(); // Legal links shown beneath the login / register cards. Defaults to // the public ObjectStack pages so vanilla deployments don't link to diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index f33e25528e..ea93e70bad 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Plugin, PluginContext, createMemoryCache, createMemoryQueue, createMemoryJob, createMemoryI18n } from '@objectstack/core'; -import { readEnvWithDeprecation } from '@objectstack/types'; +import { resolveMultiOrgEnabled } from '@objectstack/types'; /** * All 17 core kernel service names as defined in CoreServiceName. @@ -584,14 +584,14 @@ export class DevPlugin implements Plugin { // because SecurityPlugin.start() probes the `org-scoping` service and // caches the result for the lifetime of the plugin. if (enabled('security')) { - const multiTenant = String(readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') ?? 'false').toLowerCase() !== 'false'; + const multiTenant = resolveMultiOrgEnabled(); if (multiTenant) { try { const { OrgScopingPlugin } = await import('@objectstack/plugin-org-scoping') as any; this.childPlugins.push(new OrgScopingPlugin()); ctx.logger.info(' ✔ Org-scoping plugin enabled (multi-tenant: organization_id auto-stamp, per-org seed)'); } catch { - ctx.logger.warn(' ✘ OS_MULTI_TENANT=true but @objectstack/plugin-org-scoping not installed'); + ctx.logger.warn(' ✘ OS_MULTI_ORG_ENABLED=true but @objectstack/plugin-org-scoping not installed'); } } try { diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index e65acebce8..71894f42fc 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Plugin, PluginContext } from '@objectstack/core'; -import { readEnvWithDeprecation } from '@objectstack/types'; +import { resolveMultiOrgEnabled } from '@objectstack/types'; import { SeedLoaderService } from './seed-loader.js'; import { loadDisabledPackageIds } from './package-state-store.js'; import type { IMetadataService, II18nService } from '@objectstack/spec/contracts'; @@ -662,7 +662,7 @@ export class AppPlugin implements Plugin { // step. So we skip it. Single-tenant deployments keep the // legacy behaviour: seed immediately at boot so there's // always demo data without needing an org insert. - const multiTenant = String(readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') ?? 'false').toLowerCase() !== 'false'; + const multiTenant = resolveMultiOrgEnabled(); if (multiTenant) { ctx.logger.info('[Seeder] multi-tenant mode — skipping inline seed; per-org replay will run on sys_organization insert'); } else { diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index 28820fdfc5..aa4784747a 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -79,6 +79,30 @@ export function readEnvWithDeprecation( return undefined; } +/** + * Resolve whether the deployment runs in multi-org (a.k.a. multi-tenant) mode. + * + * Single source of truth for the `OS_MULTI_ORG_ENABLED` flag. Resolution: the + * canonical `OS_MULTI_ORG_ENABLED` wins; else the deprecated `OS_MULTI_TENANT` + * (which fires the one-shot rename warning via {@link readEnvWithDeprecation}); + * else `false`. Any value other than a case-insensitive `'false'` enables it. + * + * Every site that needs to know "is this multi-org?" — the SQL driver's + * tenant-audit gate, the auth manager's `/auth/config` feature flag and + * org-create guard, the CLI / dev / runtime org-scoping plugin wiring — MUST + * call this instead of re-reading the env, so the driver, the security layer, + * and the UI can never disagree about the mode. Previously each site inlined + * its own `String(... ?? 'false').toLowerCase() !== 'false'` (and the SQL + * driver read `process.env` directly, skipping the deprecation warning). + * + * Reads `process.env` live on each call; memoise at the call site if the + * result must be stable for the process lifetime. + */ +export function resolveMultiOrgEnabled(): boolean { + const raw = readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT'); + return String(raw ?? 'false').toLowerCase() !== 'false'; +} + /** * Internal: clear the dedupe set. Test-only; exposed so suite-wide * deprecation warnings don't bleed between tests. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba930fa33b..03da441dc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1158,6 +1158,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../../spec + '@objectstack/types': + specifier: workspace:* + version: link:../../types knex: specifier: ^3.2.10 version: 3.2.10(better-sqlite3@12.11.1)(mysql2@3.22.4(@types/node@26.0.0))(pg@8.21.0)(sqlite3@5.1.7)(tedious@18.6.2(@azure/core-client@1.10.2))