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
48 changes: 48 additions & 0 deletions .changeset/host-app-resolver-shared.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/types": minor
"@objectstack/verify": minor
"@objectstack/cli": patch
---

fix(verify): resolve the enterprise organizations package from the HOST APP (#4700)

`bootStack(app, { multiTenant: true })` — and therefore `objectstack verify
--multi-tenant` — could never load `@objectstack/organizations`. Node ESM
resolves a bare `import()` against the **importer's own realpath**, which for
`packages/verify` is inside the framework workspace, while the enterprise
package is cloud-private and only ever lives in the verified app's
`node_modules`. Every real host app fell into the catch and was told to
"Install/link it in this workspace" — about a package it had already installed.
Same defect class as cloud#1013, which fixed `objectstack serve`; #4699 fixed
that one call site and this issue tracked the two the sweep left behind.

**New: `@objectstack/types/node`.** The host-app resolver (`createHostRequire` /
`createHostImporter`) moved out of `packages/cli/src/utils/import-from-host.ts`
— where `@objectstack/verify` and the dogfood suite could not import it without
inverting the dependency direction — into a **node-only subpath export** of
`@objectstack/types`. One behaviour, one source; the CLI now consumes it and its
private copy is deleted.

It is a subpath and **not** the root export because `@objectstack/types` is a
dependency of `@objectstack/hono` ("edge-compatible REST API server for
Cloudflare Workers, Deno, Bun, and Node") and of the plugin layer a `LiteKernel`
boots on Workers. The root entry reaches zero `node:` builtins, and a Workers
bundle breaks on `node:module` even when nothing calls it. `tsup` emits the two
entries as separate self-contained bundles (`splitting: false`), and a test
walks the root's import graph and fails on the first reachable `node:`
specifier, so the isolation is enforced rather than merely intended. Same
arrangement `@objectstack/metadata` already ships for its `./node` subpath.

**New: `BootOptions.hostRoot`** (optional, defaults to `process.cwd()`) names
the app whose `node_modules` supplies those optional packages — for a harness
booting an app that is not the working directory.

**The dogfood multi-org gates had never run.** Two suites probed availability
with the same bare `import()` and so were **constant-false** — not "false
because absent" but false by construction, in every environment including the
cloud CI whose comment claimed it ran them. The #1994 cross-tenant RLS proof and
the attachments cross-tenant isolation block had therefore never executed while
the suite reported green (Prime Directive #10, test-suite edition). They now
resolve like the runtime does, and `OS_TEST_MULTI_ORG_ENABLED=1` declares that a
run is expected to ship the package — turning a silent skip into a loud failure,
so a run can no longer pass by quietly not running the gates it exists for.
4 changes: 3 additions & 1 deletion packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level
import { BootLogCapture, isVerboseBootLevel } from '../utils/boot-log-capture.js';
import { graftAuthoredRuntimeMembers, isAppPluginLike } from '../utils/graft-runtime-hooks.js';
import { redactConnectionUrl, describeDriverConnection } from '../utils/connection-display.js';
import { createHostRequire, createHostImporter } from '../utils/import-from-host.js';
// Shared with @objectstack/verify and the dogfood multi-org probes (#4700) —
// node-only, hence the `/node` subpath rather than the edge-safe root export.
import { createHostRequire, createHostImporter } from '@objectstack/types/node';
import {
printHeader,
printKV,
Expand Down
80 changes: 0 additions & 80 deletions packages/cli/src/utils/import-from-host.ts

This file was deleted.

3 changes: 2 additions & 1 deletion packages/qa/dogfood/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@
"@objectstack/example-showcase": "workspace:*",
"@objectstack/mcp": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/plugin-audit": "workspace:*",
"@objectstack/plugin-auth": "workspace:*",
"@objectstack/plugin-email": "workspace:*",
"@objectstack/plugin-security": "workspace:*",
"@objectstack/plugin-sharing": "workspace:*",
"@objectstack/plugin-webhooks": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/service-analytics": "workspace:*",
"@objectstack/service-messaging": "workspace:*",
"@objectstack/service-storage": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"@objectstack/verify": "workspace:*"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { bootStack, type VerifyStack } from '@objectstack/verify';
import { StorageServicePlugin } from '@objectstack/service-storage';
import { AuditPlugin } from '@objectstack/plugin-audit';
import { attachmentsFixtureStack, attachmentsFixtureSecurity } from './fixtures/attachments-fixture.js';
import { organizationsAvailable, warnIfUnavailable } from './enterprise-organizations.js';

const SYS = { isSystem: true } as const;
const DAY_MS = 86_400_000;
Expand Down Expand Up @@ -525,13 +526,11 @@ describe('attachments permission matrix (#2755)', () => {
});

// ── (g) tenant isolation — enterprise multi-org boot ─────────────────────
const organizationsAvailable = await import(/* webpackIgnore: true */ '@objectstack/organizations')
.then(() => true)
.catch(() => false);
if (!organizationsAvailable) {
// eslint-disable-next-line no-console
console.warn('[dogfood] @objectstack/organizations (enterprise) not installed — skipping the attachments multi-tenant block');
}
// #4700: this probe was a bare `import()` resolved against this file's realpath
// inside the framework workspace, so it was constant-false and block (g) had
// never executed. Shared host-app resolution + a declarative switch now decide
// it; see `enterprise-organizations.ts`.
warnIfUnavailable('attachments multi-tenant block');

describe.skipIf(!organizationsAvailable)('attachments cross-tenant isolation (g)', () => {
let stack: VerifyStack;
Expand Down
102 changes: 102 additions & 0 deletions packages/qa/dogfood/test/enterprise-organizations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #4700 — proof that the multi-org availability probe is no longer constant.
*
* The old probe answered "unavailable" in every environment because it resolved
* a cloud-private package against the framework workspace. Nothing detected that
* — a `describe.skipIf` that always skips leaves no trace beyond a warning line,
* and the suite stays green. The only way to know a capability probe works is to
* make it say BOTH things, on demand.
*
* So these cases build real host roots on disk (real `node_modules`, a real
* stand-in package, nothing mocked) and pin all three verdicts: available,
* unavailable, and declared-but-missing.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { probeOrganizations, MULTI_ORG_ENV, ORGANIZATIONS_PKG } from './enterprise-organizations.js';

let hostWithPkg: string;
let hostWithoutPkg: string;

function writeHost(prefix: string, withPkg: boolean): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({
name: 'dogfood-host-fixture',
private: true,
type: 'module',
...(withPkg ? { dependencies: { [ORGANIZATIONS_PKG]: '*' } } : {}),
}),
'utf8',
);
if (withPkg) {
const pkgDir = join(dir, 'node_modules', ...ORGANIZATIONS_PKG.split('/'));
mkdirSync(pkgDir, { recursive: true });
writeFileSync(
join(pkgDir, 'package.json'),
JSON.stringify({
name: ORGANIZATIONS_PKG,
version: '0.0.0-fixture',
type: 'module',
main: 'index.js',
}),
'utf8',
);
writeFileSync(
join(pkgDir, 'index.js'),
'export class OrganizationsPlugin { name = "com.objectstack.organizations"; }\n',
'utf8',
);
}
return dir;
}

beforeAll(() => {
hostWithPkg = writeHost('os-dogfood-org-ok-', true);
hostWithoutPkg = writeHost('os-dogfood-org-missing-', false);
});

afterAll(() => {
for (const dir of [hostWithPkg, hostWithoutPkg]) {
if (dir) rmSync(dir, { recursive: true, force: true });
}
});

describe('enterprise multi-org probe (#4700)', () => {
it('reports AVAILABLE when the package is installed in the host app', async () => {
// The verdict the old probe could never reach, no matter what any app or CI
// had installed. This is what makes `describe.skipIf(!organizationsAvailable)`
// a real gate rather than an unconditional skip.
const probe = await probeOrganizations(hostWithPkg, false);
expect(probe.available).toBe(true);
expect(probe.reason).toBeUndefined();
});

it('reports UNAVAILABLE, with an actionable reason, when the app lacks it', async () => {
const probe = await probeOrganizations(hostWithoutPkg, false);
expect(probe.available).toBe(false);
// The reason has to name the switch, or the skip stays folklore.
expect(probe.reason).toContain(MULTI_ORG_ENV);
expect(probe.reason).toContain(hostWithoutPkg);
});

it('THROWS when the run declares the package but it is missing', async () => {
// The half that converts "silently green over gates that never ran" into a
// failure a CI operator cannot miss (Prime Directive #10 / "absence must be
// loud"). Without this, a cloud run that lost the package would look exactly
// like a cloud run that has it.
await expect(probeOrganizations(hostWithoutPkg, true)).rejects.toThrow(
new RegExp(`${MULTI_ORG_ENV}=1 declares`),
);
});

it('does not throw when the run declares the package AND it is there', async () => {
await expect(probeOrganizations(hostWithPkg, true)).resolves.toEqual({ available: true });
});
});
Loading
Loading