diff --git a/.changeset/seed-env-enforced.md b/.changeset/seed-env-enforced.md new file mode 100644 index 0000000000..6c40ea115c --- /dev/null +++ b/.changeset/seed-env-enforced.md @@ -0,0 +1,40 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(seed): enforce `Seed.env` — environment-scoped datasets no longer seed everywhere + +`Seed.env` was authorable, defaulted and type-checked, but inert. `SeedLoaderService` +filtered on the **loader config's** `env`, and none of the six call sites that build a +`SeedLoaderRequest` (app boot, per-org replay, hot reload, package apply, draft publish, +marketplace install) ever passed one — so `config.env` was always `undefined`, the filter +short-circuited, and `dataset.env` was never read. A dataset marked `env: ['dev']` seeded +into production exactly as if it were marked `['prod']`, which is the dangerous direction: +the rows most likely to carry that marking are demo users, fake customers and seeded +credentials. + +The loader now resolves the environment itself, at the one funnel every seeding path goes +through: + +- **Source is `NODE_ENV`** — the environment source this repo already uses everywhere + (`os start` defaults it to `production`, `os dev` / `serve --dev` set `development`, + vitest sets `test`). No new environment variable and no new authorable key. `production` + / `development` / `test` and the seed-enum spellings `prod` / `dev` are accepted, + case-insensitively. +- **An explicit `config.env` still wins**, so a host can seed "as" another environment. +- **A dataset that declares no `env`** (the schema default `['prod','dev','test']`) seeds + in every environment, exactly as before — no existing deployment loses rows. +- **When the environment cannot be determined** (NODE_ENV unset, or a value like + `staging`), the loader stays permissive and seeds everything — but logs a **warning** + naming each environment-scoped dataset, the accepted `NODE_ENV` values and the + `config.env` escape hatch. Fail-open is deliberate: fail-closed would also drop an + `env: ['prod']` dataset on a production host that merely forgot to export `NODE_ENV`, + a silent data-loss regression worse than the over-seeding it prevents. +- **Skipped datasets are always named** in an `info` log, so "my demo rows are missing" is + one log line to answer rather than a mystery. + +The resolved environment is also what seed CEL expressions now bind `env` to, so a seed's +`env` and the loader's filter can no longer disagree. + +No API or schema change: `Seed.env` and `SeedLoaderConfig.env` are unchanged, and no +package export was added. diff --git a/packages/metadata-protocol/src/seed-loader-env-scope.test.ts b/packages/metadata-protocol/src/seed-loader-env-scope.test.ts new file mode 100644 index 0000000000..5856c7a2dd --- /dev/null +++ b/packages/metadata-protocol/src/seed-loader-env-scope.test.ts @@ -0,0 +1,277 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SeedLoaderService } from './seed-loader.js'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; + +/** + * `Seed.env` is ENFORCED, not merely authorable (framework#4704). + * + * The key was authorable, defaulted and type-checked for releases while being + * completely inert: `filterByEnv` gated on the LOADER CONFIG's env, and none of + * the six call sites that build a `SeedLoaderRequest` (app boot, per-org + * replay, hot reload, package apply, draft publish, marketplace install) ever + * passed one — so `config.env` was always `undefined`, the filter + * short-circuited, and `dataset.env` was never read. A dataset marked + * `env: ['dev']` seeded into production exactly as if it were marked + * `['prod']` — and the rows most likely to carry that marking are demo users, + * fake customers and seeded credentials. + * + * The loader now resolves the environment itself, from NODE_ENV — the repo's + * one established environment source (`os start` defaults it to `production`, + * `os dev` sets `development`, vitest sets `test`). + * + * These tests pin the three outcomes SEPARATELY, because a fix that only + * proved the first would be indistinguishable from one that dropped every + * dataset, or from one that dropped none: + * 1. scoped dataset + matching environment → seeded + * 2. scoped dataset + non-matching environment → NOT seeded + * 3. unscoped dataset (schema default) → seeded everywhere + * 4. environment indeterminate → seeded, but LOUDLY + */ + +function createLogger() { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; +} + +function createEngine() { + const store: Record = {}; + let idCounter = 0; + + const engine = { + find: vi.fn(async (objectName: string, query?: any) => { + let records = store[objectName] || []; + if (query?.where) { + records = records.filter((r) => + Object.entries(query.where).every(([k, v]) => r[k] === v), + ); + } + if (typeof query?.limit === 'number') records = records.slice(0, query.limit); + return records; + }), + findOne: vi.fn(async () => null), + insert: vi.fn(async (objectName: string, data: any) => { + if (!store[objectName]) store[objectName] = []; + if (Array.isArray(data)) { + const records = data.map((d) => ({ id: `gen-${++idCounter}`, ...d })); + store[objectName].push(...records); + return records; + } + const record = { id: `gen-${++idCounter}`, ...data }; + store[objectName].push(record); + return record; + }), + update: vi.fn(async (_o: string, data: any) => data), + delete: vi.fn(async () => ({ deleted: 1 })), + count: vi.fn(async (objectName: string) => (store[objectName] || []).length), + aggregate: vi.fn(async () => []), + } as unknown as IDataEngine; + + return { engine, store }; +} + +function createMetadata(): IMetadataService { + const objects: Record = { + account: { name: 'account', fields: { name: { type: 'text' } } }, + demo_user: { name: 'demo_user', fields: { name: { type: 'text' } } }, + }; + return { + getObject: vi.fn(async (name: string) => objects[name]), + listObjects: vi.fn(async () => Object.values(objects)), + register: vi.fn(async () => {}), + get: vi.fn(async () => undefined), + list: vi.fn(async () => []), + unregister: vi.fn(async () => {}), + exists: vi.fn(async () => false), + listNames: vi.fn(async () => []), + } as unknown as IMetadataService; +} + +const BASE_CONFIG = { + dryRun: false, + haltOnError: false, + multiPass: true, + defaultMode: 'upsert', + batchSize: 1000, + transaction: false, +} as any; + +/** A production-safe dataset (schema default: every environment). */ +const ACCOUNT_DATASET = { + object: 'account', + externalId: 'name', + mode: 'upsert', + env: ['prod', 'dev', 'test'], + records: [{ name: 'Acme Corporation' }], +}; + +/** The dangerous kind: demo rows the author scoped to development only. */ +const DEMO_DATASET = { + object: 'demo_user', + externalId: 'name', + mode: 'upsert', + env: ['dev'], + records: [{ name: 'Demo Person' }], +}; + +async function seedUnder(nodeEnv: string | undefined, seeds: any[], configOverrides: any = {}) { + if (nodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = nodeEnv; + + const { engine, store } = createEngine(); + const logger = createLogger(); + const result = await new SeedLoaderService(engine, createMetadata(), logger).load({ + seeds, + config: { ...BASE_CONFIG, ...configOverrides }, + } as any); + + return { result, store, logger }; +} + +const seededObjects = (store: Record) => Object.keys(store).sort(); + +describe('Seed.env is enforced against the runtime environment (#4704)', () => { + let savedNodeEnv: string | undefined; + + beforeEach(() => { + savedNodeEnv = process.env.NODE_ENV; + }); + + afterEach(() => { + if (savedNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = savedNodeEnv; + }); + + // ── 1. The regression itself ──────────────────────────────────────────── + + it('does NOT seed an env:[dev] dataset under NODE_ENV=production', async () => { + const { result, store } = await seedUnder('production', [ACCOUNT_DATASET, DEMO_DATASET]); + + expect(seededObjects(store)).toEqual(['account']); + expect(store.demo_user).toBeUndefined(); + expect(result.summary.objectsProcessed).toBe(1); + expect(result.summary.totalInserted).toBe(1); + }); + + it('DOES seed the same env:[dev] dataset under NODE_ENV=development', async () => { + const { result, store } = await seedUnder('development', [ACCOUNT_DATASET, DEMO_DATASET]); + + expect(seededObjects(store)).toEqual(['account', 'demo_user']); + expect(store.demo_user).toHaveLength(1); + expect(result.summary.objectsProcessed).toBe(2); + expect(result.summary.totalInserted).toBe(2); + }); + + // The two above must DIFFER — a fix that dropped everything, or nothing, + // would satisfy one of them alone. + it('produces a different result in production than in development', async () => { + const prod = await seedUnder('production', [ACCOUNT_DATASET, DEMO_DATASET]); + const dev = await seedUnder('development', [ACCOUNT_DATASET, DEMO_DATASET]); + + expect(seededObjects(prod.store)).not.toEqual(seededObjects(dev.store)); + expect(prod.result.summary.totalInserted).toBeLessThan(dev.result.summary.totalInserted); + }); + + // ── 2. Unscoped datasets are untouched (no behaviour change) ──────────── + + it('seeds a dataset carrying the schema default in EVERY environment', async () => { + for (const nodeEnv of ['production', 'development', 'test']) { + const { store } = await seedUnder(nodeEnv, [ACCOUNT_DATASET]); + expect(store.account, `account should seed under NODE_ENV=${nodeEnv}`).toHaveLength(1); + } + }); + + it('treats a dataset with no env key at all as unrestricted (schema default)', async () => { + const noEnv = { object: 'account', externalId: 'name', mode: 'upsert', records: [{ name: 'Acme Corporation' }] }; + const { store } = await seedUnder('production', [noEnv]); + + expect(store.account).toHaveLength(1); + }); + + it('honours a multi-environment scope that includes the running environment', async () => { + const prodAndTest = { ...DEMO_DATASET, env: ['prod', 'test'] }; + const underProd = await seedUnder('production', [prodAndTest]); + const underDev = await seedUnder('development', [prodAndTest]); + + expect(underProd.store.demo_user).toHaveLength(1); + expect(underDev.store.demo_user).toBeUndefined(); + }); + + // ── 3. Indeterminate environment: permissive, but loud ────────────────── + + it('seeds everything BUT warns loudly when NODE_ENV is unset', async () => { + const { store, logger } = await seedUnder(undefined, [ACCOUNT_DATASET, DEMO_DATASET]); + + // Permissive: pre-fix behaviour preserved, no deployment silently loses rows. + expect(seededObjects(store)).toEqual(['account', 'demo_user']); + + // Loud: names the datasets AND the remedy. + const warning = logger.warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes('Cannot determine the runtime environment')); + expect(warning).toBeDefined(); + expect(warning).toContain('demo_user'); + expect(warning).toContain('NODE_ENV'); + expect(warning).toContain('config.env'); + // The unscoped dataset is not the operator's problem — don't name it. + expect(warning).not.toContain('account (env:'); + }); + + it('warns the same way for a NODE_ENV that names no seed environment', async () => { + const { store, logger } = await seedUnder('staging', [DEMO_DATASET]); + + expect(store.demo_user).toHaveLength(1); + expect( + logger.warn.mock.calls.some((c) => String(c[0]).includes('Cannot determine the runtime environment')), + ).toBe(true); + }); + + it('stays SILENT when the environment is indeterminate but nothing is scoped', async () => { + const { store, logger } = await seedUnder(undefined, [ACCOUNT_DATASET]); + + expect(store.account).toHaveLength(1); + expect( + logger.warn.mock.calls.some((c) => String(c[0]).includes('Cannot determine the runtime environment')), + ).toBe(false); + }); + + // The three indeterminate/determinate outcomes must be mutually distinguishable. + it('distinguishes filtered, permissive-and-warned, and clean loads', async () => { + const filtered = await seedUnder('production', [ACCOUNT_DATASET, DEMO_DATASET]); + const permissive = await seedUnder(undefined, [ACCOUNT_DATASET, DEMO_DATASET]); + const clean = await seedUnder('production', [ACCOUNT_DATASET]); + + const warned = (l: any) => + l.warn.mock.calls.some((c: any[]) => String(c[0]).includes('Cannot determine the runtime environment')); + + expect([seededObjects(filtered.store), warned(filtered.logger)]).toEqual([['account'], false]); + expect([seededObjects(permissive.store), warned(permissive.logger)]).toEqual([['account', 'demo_user'], true]); + expect([seededObjects(clean.store), warned(clean.logger)]).toEqual([['account'], false]); + }); + + // ── 4. Explicit config.env still wins ─────────────────────────────────── + + it('lets an explicit config.env override NODE_ENV', async () => { + // Host says "seed as production" while running under a dev NODE_ENV. + const { store } = await seedUnder('development', [ACCOUNT_DATASET, DEMO_DATASET], { env: 'prod' }); + + expect(seededObjects(store)).toEqual(['account']); + }); + + it('accepts the seed-enum spellings of NODE_ENV', async () => { + const { store } = await seedUnder('prod', [ACCOUNT_DATASET, DEMO_DATASET]); + expect(seededObjects(store)).toEqual(['account']); + + const dev = await seedUnder('DEV', [ACCOUNT_DATASET, DEMO_DATASET]); + expect(seededObjects(dev.store)).toEqual(['account', 'demo_user']); + }); + + // ── 5. Skipping is reported, never mysterious ─────────────────────────── + + it('names every skipped dataset so missing demo rows are one log line to explain', async () => { + const { logger } = await seedUnder('production', [ACCOUNT_DATASET, DEMO_DATASET]); + + const info = logger.info.mock.calls.map((c) => String(c[0])).find((m) => m.includes('skipped')); + expect(info).toBeDefined(); + expect(info).toContain('demo_user'); + expect(info).toContain("'prod'"); + }); +}); diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index f738e29e59..2197962741 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -27,6 +27,79 @@ interface Logger { /** Default field used for externalId matching on target objects */ const DEFAULT_EXTERNAL_ID_FIELD = 'name'; +/** The environments a seed dataset can be scoped to — mirrors `SeedSchema.env`. */ +type SeedEnv = 'prod' | 'dev' | 'test'; + +/** Every environment a dataset can declare — i.e. `SeedSchema.env`'s default. */ +const ALL_SEED_ENVS: readonly SeedEnv[] = ['prod', 'dev', 'test']; + +/** + * `NODE_ENV` spellings accepted for each seed environment. + * + * `NODE_ENV` is this repo's ONE established environment source — `os start` + * defaults it to `production`, `os dev` / `serve --dev` set `development`, + * vitest sets `test`, and every other environment-sensitive behaviour here + * (auto-DDL, the api-registry production guard, the sqlite step-down, the + * hot-reload seeder) already branches on it. Seeds reuse it rather than + * minting an `OS_SEED_ENV`, which would only trade one declared-but-unset key + * for another. + * + * The seed-enum spellings (`prod`/`dev`) are accepted alongside Node's + * canonical ones so an operator who read the `Seed.env` docs and exported + * `NODE_ENV=prod` gets what they meant instead of an indeterminate answer. + * This is normalization of an OPERATOR-supplied variable at a third-party + * boundary (Prime Directive #9 lists `NODE_ENV` as exactly that), not + * consumer-side tolerance of our own metadata contract. + */ +const NODE_ENV_TO_SEED_ENV: Readonly> = { + production: 'prod', + prod: 'prod', + development: 'dev', + dev: 'dev', + test: 'test', +}; + +/** + * Resolve the environment `Seed.env` is gated on from `NODE_ENV`. + * + * Returns `undefined` when `NODE_ENV` is unset or names no seed environment + * (`staging`, `qa`, …) — i.e. the host never said where it is running. What + * that means for scoped datasets is decided in {@link SeedLoaderService.load}. + */ +function resolveSeedEnvFromNodeEnv(): SeedEnv | undefined { + const raw = (globalThis as { process?: { env?: Record } }) + .process?.env?.NODE_ENV; + if (typeof raw !== 'string') return undefined; + return NODE_ENV_TO_SEED_ENV[raw.trim().toLowerCase()]; +} + +/** + * Does this dataset apply to `env`? + * + * A dataset carrying no `env` at all is unrestricted — which is precisely what + * `SeedSchema.env`'s default (`['prod','dev','test']`) parses to. Every + * production call site parses its request through `SeedLoaderRequestSchema` + * first, so this only covers an in-process caller handing the loader an + * unparsed literal: it gets the schema's own answer rather than a second + * dialect of it. + */ +function datasetAllowsEnv(dataset: Seed, env: SeedEnv): boolean { + const declared = dataset.env as string[] | undefined; + if (!Array.isArray(declared)) return true; + return declared.includes(env); +} + +/** + * True when a dataset NARROWED its scope below the schema default — the only + * datasets for which a resolvable environment changes anything, and therefore + * the only ones worth warning about when it cannot be resolved. + */ +function isEnvScopedDataset(dataset: Seed): boolean { + const declared = dataset.env as string[] | undefined; + if (!Array.isArray(declared)) return false; + return ALL_SEED_ENVS.some(e => !declared.includes(e)); +} + /** * SeedLoaderService — Runtime implementation of ISeedLoaderService * @@ -73,7 +146,16 @@ export class SeedLoaderService implements ISeedLoaderService { async load(request: SeedLoaderRequest): Promise { const startTime = Date.now(); - const config = request.config; + // Pin the environment `Seed.env` is gated on BEFORE anything reads config. + // Resolving it here — the one funnel every seeding path goes through — is + // deliberate. `env` stayed authorable, defaulted and type-checked while + // being completely inert purely because none of the six call sites that + // build a SeedLoaderRequest (app boot, per-org replay, hot reload, package + // apply, draft publish, marketplace install) ever passed it, so + // `filterByEnv` short-circuited on `undefined` and `dataset.env` was never + // read at all. Gating at those call sites instead would leave call site + // seven free to re-open the same hole (framework#4704). + const config = this.resolveEnvConfig(request.config, request.seeds); const allErrors: ReferenceResolutionError[] = []; const allResults: SeedLoadResult[] = []; @@ -1352,9 +1434,71 @@ export class SeedLoaderService implements ISeedLoaderService { // Internal: Helpers // ========================================================================== - private filterByEnv(datasets: Seed[], env?: string): Seed[] { + /** + * Decide the environment this load filters on, and say so when it cannot. + * + * Precedence: an explicit `config.env` from the host always wins (it is the + * documented escape hatch and the only way to seed "as" another + * environment), then `NODE_ENV`. + * + * When neither answers, the load stays PERMISSIVE — every dataset is seeded, + * exactly as before this fix — but says so loudly if, and only if, a dataset + * actually narrowed its scope. Fail-open is the deliberate choice here: + * fail-closed would also drop a `env: ['prod']` dataset on a production host + * that merely forgot to export `NODE_ENV`, which is a silent data-loss + * regression strictly worse than the over-seeding it prevents. The + * indeterminate window is narrow by construction — both first-party boot + * paths pin `NODE_ENV` — so this is the embedded-host case, and it is now + * signposted rather than silent. + */ + private resolveEnvConfig(config: SeedLoaderConfig, seeds: Seed[]): SeedLoaderConfig { + if (config.env) return config; + + const resolved = resolveSeedEnvFromNodeEnv(); + if (resolved) return { ...config, env: resolved }; + + const scoped = seeds.filter(isEnvScopedDataset); + if (scoped.length > 0) { + const named = scoped + .map(d => `${d.object} (env: ${(d.env as string[]).join(', ')})`) + .join('; '); + this.logger.warn( + `[SeedLoader] Cannot determine the runtime environment — NODE_ENV is unset or names no seed ` + + `environment, so ${scoped.length} environment-scoped dataset(s) were seeded EVERYWHERE ` + + `instead of only where they are declared: ${named}. Set NODE_ENV ` + + `(production | development | test) on the host, or pass an explicit \`config.env\`, to make ` + + `\`Seed.env\` take effect.`, + { scoped: scoped.map(d => d.object) }, + ); + } + return config; + } + + /** + * Drop datasets that do not apply to the resolved environment. + * + * Skipping is the declared, intended outcome (that is what `env: ['dev']` + * asks for), so it logs at `info` rather than crying wolf on every + * production boot — but it always NAMES what it dropped, so "my demo rows + * are missing" is one log line to answer instead of a mystery. + */ + private filterByEnv(datasets: Seed[], env?: SeedEnv): Seed[] { if (!env) return datasets; - return datasets.filter(d => (d.env as string[]).includes(env)); + + const kept: Seed[] = []; + const skipped: Seed[] = []; + for (const dataset of datasets) { + (datasetAllowsEnv(dataset, env) ? kept : skipped).push(dataset); + } + + if (skipped.length > 0) { + this.logger.info( + `[SeedLoader] Environment '${env}': skipped ${skipped.length} dataset(s) scoped to other ` + + `environments: ${skipped.map(d => `${d.object} (env: ${(d.env as string[]).join(', ')})`).join('; ')}`, + { env, skipped: skipped.map(d => d.object) }, + ); + } + return kept; } private orderDatasets(datasets: Seed[], insertOrder: string[]): Seed[] {