Skip to content

Commit e09f1ac

Browse files
fix(cloud-connection): refuse install-local sample data for a session with no active organization (ADR-0123 D2/D4) (#21780)
Fixes #21774 Clause-②: no Under an organization wall, a session with no active organization is ADR-0123 D1's declared state. The install-local sample-data doors now answer it the way ADR-0123 D2 / D4 require, instead of skipping quietly. ## What changed - `resolveActiveOrgId` (`packages/cloud-connection/src/marketplace-install-local-plugin.ts`): the "first membership" fallback is **deleted**, not repaired. It read `sys_organization_member`, an object nothing defines. - **Reseed and purge** answer `403 PERMISSION_DENIED` (standard catalog, no new code) with one sentence built in one place (`noActiveOrganizationRefusal`). The sentence says the session has no active organization and gives the remedy. - **Install** still registers the env-wide package. Its `seeded` block is `{ mode: "refused", reason }`, with the same sentence in `reason` (was `{ mode: "skipped", reason: "multi-tenant-no-active-org" }`). - `RESEED_SKIPPED` stays for reseed's other declines: `no-datasets`, `objectql-or-metadata-missing`, `seed-error: …`. - `storage-service-plugin.ts` (comment only, declared cross-lane): the clause citing the deleted fallback now cites ADR-0123 D1. The comment's own rule is unchanged. - Ratchets moved down by one each for this file (`scripts/slot-lookup-baseline.json` 15 to 14, `scripts/query-options-erasure-baseline.json` 2 to 1). The deleted fallback carried those sites. ## Measured before the change (`origin/main` `ebfe658c72`, real walled boot, posture-only) Tenancy `isolated`, `isolationActive: true`; session `activeOrganizationId` null; 1 `sys_member` row for the admin. - install: `200`, `seeded {mode: "skipped", reason: "multi-tenant-no-active-org"}` - reseed: `400 RESEED_SKIPPED` "Reseed did not run: multi-tenant-no-active-org" - purge: `400 RESEED_SKIPPED` "Purge did not run: multi-tenant-no-active-org. …" - the fallback's read: `Object 'sys_organization_member' not found` (thrown, swallowed) ## Pins - Unit: `marketplace-install-local-no-active-organization.test.ts` (new). It covers walled with no active org and two memberships served by the store, walled with an active org, and `single` (a spy proves `resolveActiveOrgId` is never called). The purge unit pin moves to 403. - Door pin on a real walled boot: `packages/qa/dogfood/test/install-local-no-active-organization.dogfood.test.ts` (new). In one session: no-org install/reseed/purge, then the same session after `set-active` to org B, as the control. 7/7 green. - Reverse verification (A5), committed fix at `a1a5513852`, through `scripts/ablation-replace.mjs`, two literal anchors, nested WRAP: - anchor 1, the seed refusal line, replaced by the old `mode: 'skipped'` line: blob `5f88579b38e0` to `24f914c847b4` - anchor 2, the purge 403, replaced by the old `RESEED_SKIPPED` / 400: blob to `505004807245` - On disk: removed lines 0/0, injected lines 1/1. - Unit: 4 red (the 3 no-org pins and the purge no-org pin; received `skipped` and `400`), 33 green. - Door: 3 red (no-org install/reseed/purge), 4 green (preconditions, nothing seeded, org-B control). - Restore: three legs prove blob == HEAD `5f88579b38e0` and an empty `git diff HEAD`. ## Verification (head `0590b46d38`) - `pnpm --filter @objectstack/cloud-connection test`: 38 files, 466 tests passed. - typecheck green for cloud-connection, dogfood and service-storage; `--listFiles` includes both new test files. - full `pnpm lint`: exit 0. - `dispatch-gates --commands` derived 79 commands; 78 ran green on the final head. `check-empty-changeset` is red **on purpose**; see below. ## Pending release note corrected (the empty-changeset gate stays red by design) `.changeset/21728-install-local-purge.md` is pending, from #21773; the last version PR `617f25f8a4` predates that merge. It said a no-active-organization purge is answered `400 RESEED_SKIPPED`. This PR makes that sentence false, and both changesets ship in one release, so its clause now reads `403 PERMISSION_DENIED`, naming the missing active organization. Nothing else in that note changed. The gate's own text classes this as a deliberate correction that a person confirms on the PR. **Please confirm or reject this edit.** ## Acceptance notes - The console (objectui at the pinned `.objectui-sha`, `marketplaceApi.ts` `postLocalSampleAction`) shows `error.message` for any non-ok status and does not branch on the code or status. The 400 to 403 move is shown to the user as the new sentence. The `os package install` CLI does not read `seeded`. - `resolveActiveOrgId` reads only the better-auth session. An API-key caller (no session cookie) on a walled boot is refused with the session wording. Before, it was skipped. This was not measured further; carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e83c9f6 commit e09f1ac

9 files changed

Lines changed: 578 additions & 38 deletions

‎.changeset/21728-install-local-purge.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ Clause-②: no
88

99
- **What was wrong.** The purge looked up a bare `driver` service, a name no kernel registers (drivers register as `driver.<name>`), so it refused everywhere. Behind that it matched seed records by `id`, which seed records rarely carry: the CRM example's 28 records key by `name`, `email` and `subject`. It also deleted through the driver, past every engine hook.
1010
- **What it does now.** It deletes through the ObjectQL engine, so lifecycle hooks and the audit trail run, under the posture the seed was written with (record-change automation suppressed). Rows are matched by each dataset's `externalId`, the key the install and the reseed upsert by. A row whose key no seed record declares is never touched. Children are deleted before parents, in the reverse of the seed loader's own dependency order.
11-
- **Scope.** Under an organization wall the purge removes only the seed rows of the caller's active organization, the scope the install and the reseed seed into. A session with no active organization is answered `400 RESEED_SKIPPED` (`multi-tenant-no-active-org`), the way reseed answers it. Without a wall the deployment is one tenant, and the match is table-wide, as the install's own match is.
11+
- **Scope.** Under an organization wall the purge removes only the seed rows of the caller's active organization, the scope the install and the reseed seed into. A session with no active organization is refused with `403 PERMISSION_DENIED` and a message naming the missing active organization, the way reseed refuses it. Without a wall the deployment is one tenant, and the match is table-wide, as the install's own match is.
1212
- **The response keeps its shape**, `{ manifestId, deleted, skipped, errors, withSampleData }`. `skipped` counts seed records no row carries (already deleted). `errors` counts records that could not be purged, each with its reason in the server log: a delete the engine refused (for example, a user's row still requires the seed row as its parent), a key that more than one row carries, or a seed record with no key value.
1313
- A runtime with no data engine or no metadata service still answers `500 DRIVER_UNAVAILABLE`, now naming what is missing.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/cloud-connection": patch
3+
---
4+
5+
Under an organization wall, the install-local sample-data doors now refuse a session with no active organization, and the refusal names what is missing (ADR-0123 D2 / D4). Before, they skipped quietly.
6+
7+
Clause-②: no
8+
9+
- **Reseed and purge.** `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data` and `…/purge-sample-data` answer `403 PERMISSION_DENIED`, with a message saying the session has no active organization and that one must be joined or selected. Before, the reseed answered `400 RESEED_SKIPPED` (`multi-tenant-no-active-org`); the purge, which starts deleting in this same release, refuses the same way from the start. Reseed's other declines are unchanged and still answer `400 RESEED_SKIPPED`: a package with no seed datasets, a runtime with no data engine or metadata service, and a seed run that threw.
10+
- **Install.** `POST /api/v1/marketplace/install-local` still installs the package, which is environment-wide. Its `seeded` block now reads `{ mode: "refused", reason: "…" }`, where `reason` names the missing active organization and says to select one and then reseed. Before, it read `{ mode: "skipped", reason: "multi-tenant-no-active-org" }`.
11+
- **No organization is guessed.** The active-organization read no longer falls back to the user's first membership. ADR-0123 D1 makes "authenticated, with no active organization" a declared state. A guess would write into an organization the caller never chose. The fallback read an object no package defines, so it never resolved anything.
12+
- **Unchanged.** A session with an active organization seeds, reseeds and purges in that organization, as before. Without a wall (`single` posture), no organization is read, and the three doors act table-wide.
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0123 D1 / D2 / D4] A walled session with NO active organization, at the
5+
* three install-local doors that seed or purge sample data.
6+
*
7+
* ## What was measured before this file
8+
*
9+
* On a posture-only (walled) boot, a signed-in admin whose session carried no
10+
* `activeOrganizationId`, though their user held a `sys_member` row:
11+
*
12+
* POST …/install-local -> 200, seeded {mode: "skipped", reason: "multi-tenant-no-active-org"}
13+
* POST …/install-local/:id/reseed-sample-data -> 400 RESEED_SKIPPED "Reseed did not run: multi-tenant-no-active-org"
14+
* POST …/install-local/:id/purge-sample-data -> 400 RESEED_SKIPPED
15+
*
16+
* and the resolver's "first membership" fallback read an object no package
17+
* defines, so it threw and was swallowed. ADR-0123 already rules on this state:
18+
* D1 makes it legal and named, so no subsystem guesses an organization for it;
19+
* D2 refuses a tenant-scoped write loudly, with the catalog's
20+
* `PERMISSION_DENIED` / 403; D4 says the refusal names the missing active
21+
* organization.
22+
*
23+
* ## What this file pins
24+
*
25+
* 1. walled, no active organization, the user a member of TWO organizations:
26+
* the install registers the package and reports `seeded {mode: "refused"}`
27+
* with the sentence in `reason`; the reseed and the purge answer the 403
28+
* envelope; no seed run starts and no organization is guessed;
29+
* 2. walled, an active organization: all three doors act in it, as before;
30+
* 3. unwalled (`single`): the active-organization read is never consulted.
31+
*
32+
* The membership rows are served by the store on purpose: a resolver that
33+
* guessed from them (under either object name the platform has spelled) would
34+
* seed into one of the two organizations, and case 1 would go red.
35+
*/
36+
37+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
38+
import { mkdtempSync, rmSync } from 'node:fs';
39+
import { join } from 'node:path';
40+
import { tmpdir } from 'node:os';
41+
42+
let loadCalls: any[] = [];
43+
44+
vi.mock('@objectstack/runtime', () => ({
45+
SeedLoaderService: class {
46+
async load(request: any) {
47+
loadCalls.push(request);
48+
return { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
49+
}
50+
async buildDependencyGraph(objects: string[]) {
51+
return { insertOrder: objects, nodes: objects.map((object) => ({ object, dependsOn: [], references: [] })) };
52+
}
53+
},
54+
recordSeedOutcome: vi.fn(),
55+
}));
56+
// Only the seed-request parse is stubbed; every other export stays the real one,
57+
// because `@objectstack/core` reads `@objectstack/spec/data` values at module load.
58+
vi.mock('@objectstack/spec/data', async (importOriginal) => ({
59+
...(await importOriginal<typeof import('@objectstack/spec/data')>()),
60+
SeedLoaderRequestSchema: { parse: (x: any) => x },
61+
}));
62+
63+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
64+
import { INSTALLER_USER_ID, withInstallerGrants } from './install-local-principal.fixtures.js';
65+
import { LocalManifestSource } from './local-manifest-source.js';
66+
67+
type Handler = (c: any) => Promise<any>;
68+
69+
const BASE = '/api/v1/marketplace/install-local';
70+
const MANIFEST = {
71+
id: 'app.test.noorg',
72+
version: '1.0.0',
73+
objects: [{ name: 'noorg_x', fields: { name: { type: 'text' } } }],
74+
data: [{ object: 'noorg_x', externalId: 'name', records: [{ name: 'a' }, { name: 'b' }] }],
75+
};
76+
77+
/** The installer is a member of two organizations; the store says so under both spellings. */
78+
const MEMBERSHIPS = [
79+
{ id: 'mem_a', organization_id: 'org_a', user_id: INSTALLER_USER_ID, role: 'owner' },
80+
{ id: 'mem_b', organization_id: 'org_b', user_id: INSTALLER_USER_ID, role: 'owner' },
81+
];
82+
83+
function makeC(body: unknown, manifestId?: string) {
84+
return {
85+
req: {
86+
url: `http://localhost:3000${BASE}`,
87+
raw: new Request('http://localhost:3000/x'),
88+
json: async () => body,
89+
param: (k: string) => (k === 'manifestId' ? manifestId : undefined),
90+
header: () => undefined,
91+
},
92+
json: vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })),
93+
};
94+
}
95+
96+
let dir: string;
97+
beforeEach(() => {
98+
dir = mkdtempSync(join(tmpdir(), 'mil-noorg-'));
99+
loadCalls = [];
100+
});
101+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); });
102+
103+
/**
104+
* Boot the real plugin. `posture` is the `tenancy` service's posture in force;
105+
* `activeOrg` what the session's `activeOrganizationId` says (absent: none).
106+
*/
107+
async function boot(opts: { posture: 'single' | 'isolated'; activeOrg?: string }) {
108+
const routes = new Map<string, Handler>();
109+
const rawApp = {
110+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
111+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
112+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
113+
};
114+
const reads: Array<{ object: string; query: any }> = [];
115+
const granted = withInstallerGrants({
116+
syncSchemas: async () => undefined,
117+
find: async (object: string, query?: any) => { reads.push({ object, query }); return []; },
118+
});
119+
const objectql = {
120+
...granted,
121+
find: async (object: string, query?: any) => {
122+
if (object === 'sys_member' || object === 'sys_organization_member') {
123+
reads.push({ object, query });
124+
return MEMBERSHIPS.map((m) => ({ ...m }));
125+
}
126+
return granted.find(object, query);
127+
},
128+
};
129+
const register = vi.fn();
130+
const services: Record<string, unknown> = {
131+
manifest: { register },
132+
auth: {
133+
api: {
134+
getSession: async () => ({
135+
user: { id: INSTALLER_USER_ID },
136+
session: opts.activeOrg ? { activeOrganizationId: opts.activeOrg } : {},
137+
}),
138+
},
139+
},
140+
objectql,
141+
metadata: {},
142+
tenancy: { posture: opts.posture },
143+
};
144+
const hooks = new Map<string, any>();
145+
const ctx = {
146+
hook: (e: string, h: any) => hooks.set(e, h),
147+
getService: (name: string) => {
148+
if (name === 'http-server') return { getRawApp: () => rawApp };
149+
const svc = services[name];
150+
if (svc === undefined) throw new Error(`no ${name}`);
151+
return svc;
152+
},
153+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
154+
};
155+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
156+
const resolveActiveOrgId = vi.spyOn(plugin as any, 'resolveActiveOrgId');
157+
await plugin.start(ctx as any);
158+
await hooks.get('kernel:ready')?.();
159+
return {
160+
register,
161+
reads,
162+
resolveActiveOrgId,
163+
install: () => routes.get(`POST ${BASE}`)!(makeC({ manifest: MANIFEST })),
164+
reseed: () => routes.get(`POST ${BASE}/:manifestId/reseed-sample-data`)!(makeC({}, MANIFEST.id)),
165+
purge: () => routes.get(`POST ${BASE}/:manifestId/purge-sample-data`)!(makeC({}, MANIFEST.id)),
166+
};
167+
}
168+
169+
const ledgerEntry = () => new LocalManifestSource(dir).read(MANIFEST.id).entry;
170+
171+
describe('walled, no active organization — refused at every sample-data door, and no organization is guessed', () => {
172+
it('install: the package registers, and `seeded` reports the refusal instead of seeding', async () => {
173+
const m = await boot({ posture: 'isolated' });
174+
const res = await m.install();
175+
176+
expect(res.status).toBe(200);
177+
expect(res.payload.success).toBe(true);
178+
expect(m.register).toHaveBeenCalled();
179+
expect(ledgerEntry()?.manifestId).toBe(MANIFEST.id);
180+
expect(Object.keys(res.payload.data.seeded).sort()).toEqual(['mode', 'reason']);
181+
expect(res.payload.data.seeded.mode).toBe('refused');
182+
expect(res.payload.data.seeded.reason).toContain('this session has no active organization');
183+
expect(loadCalls).toHaveLength(0);
184+
expect(ledgerEntry()?.withSampleData).not.toBe(true);
185+
});
186+
187+
it('reseed: 403 PERMISSION_DENIED naming the missing active organization, and no seed run', async () => {
188+
const m = await boot({ posture: 'isolated' });
189+
await m.install();
190+
const res = await m.reseed();
191+
192+
expect(res.status).toBe(403);
193+
expect(res.payload.success).toBe(false);
194+
expect(res.payload.error.code).toBe('PERMISSION_DENIED');
195+
expect(res.payload.error.message).toContain('this session has no active organization');
196+
expect(loadCalls).toHaveLength(0);
197+
expect(ledgerEntry()?.withSampleData).not.toBe(true);
198+
});
199+
200+
it('purge: 403 PERMISSION_DENIED naming the missing active organization, and no seed object is read', async () => {
201+
const m = await boot({ posture: 'isolated' });
202+
await m.install();
203+
const res = await m.purge();
204+
205+
expect(res.status).toBe(403);
206+
expect(res.payload.success).toBe(false);
207+
expect(res.payload.error.code).toBe('PERMISSION_DENIED');
208+
expect(res.payload.error.message).toContain('this session has no active organization');
209+
expect(m.reads.filter((r) => r.object === 'noorg_x')).toEqual([]);
210+
expect(ledgerEntry()?.sampleDataPurged).toBeUndefined();
211+
});
212+
});
213+
214+
describe('walled, an active organization — all three doors act in it, as before', () => {
215+
it('install seeds inline into the active organization', async () => {
216+
const m = await boot({ posture: 'isolated', activeOrg: 'org_b' });
217+
const res = await m.install();
218+
219+
expect(res.status).toBe(200);
220+
expect(res.payload.data.seeded).toMatchObject({ mode: 'inline', inserted: 2 });
221+
expect(loadCalls).toHaveLength(1);
222+
expect(loadCalls[0].config.organizationId).toBe('org_b');
223+
});
224+
225+
it('reseed seeds into the active organization', async () => {
226+
const m = await boot({ posture: 'isolated', activeOrg: 'org_b' });
227+
await m.install();
228+
const res = await m.reseed();
229+
230+
expect(res.status).toBe(200);
231+
expect(res.payload.data).toMatchObject({ inserted: 2, withSampleData: true });
232+
expect(loadCalls).toHaveLength(2);
233+
expect(loadCalls[1].config.organizationId).toBe('org_b');
234+
});
235+
236+
it('purge reads the seed object in the active organization only', async () => {
237+
const m = await boot({ posture: 'isolated', activeOrg: 'org_b' });
238+
await m.install();
239+
const res = await m.purge();
240+
241+
expect(res.status).toBe(200);
242+
expect(res.payload.success).toBe(true);
243+
const seedReads = m.reads.filter((r) => r.object === 'noorg_x');
244+
expect(seedReads).toHaveLength(1);
245+
expect(seedReads[0].query.where).toEqual({ organization_id: 'org_b' });
246+
});
247+
});
248+
249+
describe('unwalled (`single`) — the active-organization read is never consulted', () => {
250+
it('install, reseed and purge act table-wide with no organization, and never ask for one', async () => {
251+
const m = await boot({ posture: 'single' });
252+
253+
const install = await m.install();
254+
expect(install.status).toBe(200);
255+
expect(install.payload.data.seeded).toMatchObject({ mode: 'inline', inserted: 2 });
256+
const reseed = await m.reseed();
257+
expect(reseed.status).toBe(200);
258+
const purge = await m.purge();
259+
expect(purge.status).toBe(200);
260+
261+
expect(loadCalls).toHaveLength(2);
262+
for (const call of loadCalls) expect(call.config.organizationId).toBeUndefined();
263+
const seedReads = m.reads.filter((r) => r.object === 'noorg_x');
264+
expect(seedReads).toHaveLength(1);
265+
expect(seedReads[0].query.where).toBeUndefined();
266+
expect(m.resolveActiveOrgId).not.toHaveBeenCalled();
267+
});
268+
});

0 commit comments

Comments
 (0)