Skip to content

Commit 273ead3

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21761-lock-address-selection
2 parents 7fe4958 + e27a7c0 commit 273ead3

10 files changed

Lines changed: 583 additions & 43 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.

‎.claude/skills/pm-dispatch/references/execution-duties.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@
6565
- 条款②入队闸门只读认领评论且只读行首键:句中提及与另发评论皆读不到,卡即不可读。
6666
- 谓词保持严格、⛔ 不放宽;session ID 不可省,`mode:subagent` 下 dev 与 PM 同 ID,身份看分支。
6767
- `Clause-②: yes | no` 恰这两种拼写,恒英文机器判据;判据:本卡放宽接受集或扩大公开面吗。
68+
- 公开面 = 包构建后的入口声明所达,⛔ 不只数入口的再导出清单。
69+
- 所达 = 再导出名,加经其 props、参数与返回类型可达的每个类型。
70+
- 语言包键已发布:包导出语言包及其派生类型(`TranslationKeys = typeof en`)即发布每个键。
71+
- `Clause-②: yes` 至少 `minor`:AGENTS.md Post-Task Checklist 第 3 条,本行在认领处复述。
72+
- 席位读不定(席位不做构建)⇒ 认领写明,dev 在报告里于构建声明闭包上实测,实测定案。
6873
- 档位引 `dispatch-gates --tier --repo 仓`⛔ 不凭记忆;天花板拼常量名并同行引据,⛔ 不抄模型 id
6974
- 末行 Serial constraints cleared 是落在评论里的读数,同包在飞单不点名等于没查。
7075
- 本卡 pin 断言兄弟卡在改的行为 ⇒ 派发令注明,并在用例内预登记翻转触发词,⛔ 不修绿。
@@ -131,9 +136,6 @@
131136
- 触 `skills/**` 的卡,派发词必带 PM 定的净增行数预算(按 feature 大小定,小功能给小数)。
132137
- dev 装不下预算 ⇒ 停手回报,⛔ 不自行扩写、不自行抬预算。
133138
- 对外发布的技能包是平台最大的对外价值,评估恒整包整体。
134-
- 文件面写两句:预期落点;生产者在别包时修生产者侧并报落点,⛔ 不在消费者侧打补丁。
135-
- same-day churn 行:当天合并 ⇒ 先核对当前 main;在飞重叠每轮求交,相交即发四句警告。
136-
- 被在飞重叠完全覆盖就停下回报,⛔ 不硬造 diff。
137139
- 翻转公开语义的裁决随卡带全仓 pin 清扫,两句缺一不可,原文见 runbook。
138140
- 拒收用例 ⇒ `code`+`status` 最低断言;过滤/谓词语义 ⇒ 编译面清单逐面申报,⛔ 静默略过。
139141
- 资源与后端:S 级机械 + M ⇒ `mode:subagent`;S 级但不机械(判断面在设计不在门禁)按 M 待遇。
@@ -170,8 +172,6 @@
170172

171173
- 你是记录在案的复核人:对 GitHub 核验,⛔ 不对报告的自述核验。
172174
- 逐项判据展开在 `references/review-checklist.md`,每份报告对着它过。
173-
- PR 形态与范围:draft、目标 `main`、`Fixes`/`Part of` 首行判据,翻 ready 前亲核。
174-
- `Part of` 卡 MERGED 时点收口;changed files 范围与 changeset/`skip-changeset` 分流;测试证据。
175175
- 改到已有 `.changeset/*.md` 的 PR ⛔ 不打 `skip-changeset`;确认取同 head 达档 PASS 或维护者原话。
176176
- 报告在草稿 PR 时点到达,CI 收敛读数只属于复核侧:gate `in_progress` 是诚实读数。
177177
- 绿色输出≠ 该绿证明了被测风险:拒收断言、全绿方向与时序、pin 翻转、边界后收益。
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)