Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/single-source-multi-org-flag.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
});

Expand Down
7 changes: 2 additions & 5 deletions packages/cli/src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 2 additions & 3 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/driver-sql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"knex": "^3.2.10",
"nanoid": "^5.1.15"
},
Expand Down
8 changes: 5 additions & 3 deletions packages/plugins/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
16 changes: 3 additions & 13 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/plugins/plugin-dev/src/dev-plugin.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime/src/app-plugin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions packages/types/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.