Skip to content

Commit a835858

Browse files
test(runtime): pin the /data door's 409 RESOURCE_CONFLICT for a sharing_rule row created or renamed under a declared rule's name (#25289)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuACdPWFJTLDcTrByFCHLzqb)_ Fixes #25253 Clause-②: no Metadata-protocol: none ## What Adds one end-to-end suite, `packages/runtime/src/sharing-rule-declared-name.data-door.pin.test.ts`. It boots the composition `bootStack` mirrors (ObjectQL, sqlite-wasm in memory, Hono, the app, platform objects, auth, security, sharing, REST, dispatcher). The application declares two sharing rules through its own `sharingRules`. A signed-in administrator then drives the generic `/data` door over HTTP: - `POST /api/v1/data/sys_sharing_rule` under a declared rule's name answers `409` with `code: RESOURCE_CONFLICT`, and no row lands. - `PATCH /api/v1/data/sys_sharing_rule/:id` renaming an authored row onto a declared rule's name answers `409` with `code: RESOURCE_CONFLICT`, and the row keeps its name. - **Control:** the same `POST` under a name no declaration holds answers 2xx and the row lands. So the 409 comes from the guard and not from the rig. - **Precondition:** the sharing-rule service the guard asks (`declaredRuleNamed`) reads both declared rules, and neither has a row. This is test-only. No production file changed and there is no behaviour change. Today the door does answer 409 `RESOURCE_CONFLICT` on both paths, so `content/docs/permissions/sharing-rules.mdx` (regime C) is accurate. **Wire shape, measured rather than assumed.** The `/data` door answers its flat envelope: `{ error: "SENTENCE", code: "RESOURCE_CONFLICT", object: "sys_sharing_rule" }`. `code` sits at the top level (`mapDataError`, `@objectstack/types`), and the pin reads it there. It does not use a fallback that would also accept a nested `error.code`. The first run asserted the nested spelling and went red on exactly that. **Why each refusal has its own declared rule.** `sys_sharing_rule`'s unique constraint on the name also answers `409`, as `UNIQUE_VIOLATION`. My first draft used one declared name for both cases. Under ablation the create landed, and the rename then answered `409 UNIQUE_VIOLATION`, so a status-only pin would have read that 409 as the guard's. Now each case targets its own declared rule and stays independent under ablation. `code` is asserted beside `status`. ## Lane: runtime, not plugin-sharing (deviation from the claim's file surface) The claim's file surface was `packages/plugins/plugin-sharing/src/**` test files, based on the assumption that `business-unit-parent-delete-restrict.test.ts` is an HTTP rig. I measured it, and it is not. It drives `engine.delete` on a real ObjectQL + SqlDriver, and `/api/v1/data/...` appears only in a comment. plugin-sharing has no `@objectstack/rest`, `@objectstack/plugin-hono-server` or `@objectstack/runtime` dependency. `@objectstack/runtime` depends on plugin-sharing, so adding runtime as a dev dependency would create a workspace cycle. An HTTP pin in plugin-sharing would therefore need new dependencies and a hand-built door. The card names "plugin-sharing or runtime" as the lane, and runtime already carries every piece: its full-composition pins (`unresolved-object-name.actions-door.pin.test.ts`, `hook-handler-package-scope.pin.test.ts`) use the same boot. So the suite lives there. It adds no new testkit or helper file. ## Evidence (head a949814) - `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/sharing-rule-declared-name.data-door.pin.test.ts` gave Test Files 1 passed (1), Tests 4 passed (4). - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` gave Test Files 436 passed (436), Tests 6889 passed | 19 skipped. The lock reported VERDICT command-exit 0. - `pnpm --filter @objectstack/runtime typecheck` exited 0. The pin is in the `tsconfig.test.json` program (`--listFilesOnly` count 1) and carries no ledger entry, so it compiles with zero errors. - **Ablation, one round, guard disabled.** The runtime suite resolves `@objectstack/plugin-sharing` through `dist/`, so each leg rebuilt it. - `node scripts/ablation-replace.mjs` replaced the `bindDeclaredRuleNameGuard` throw (`if (declared) throw new DeclaredSharingRuleNameConflictError(...)`) with a marker assignment. On disk: anchor x1 to x0, blob 26f18e1 to d5199123b3f6. - `pnpm --filter @objectstack/plugin-sharing build` ran, then `ablation-dist-preflight.mjs @objectstack/plugin-sharing __ABLATION_25253` reported the marker present in 2 built files. - The pin then went **2 failed | 2 passed**. The create case read `expected 201 to be 409` and the rename case read `expected 200 to be 409`. The precondition and the control stayed green, as predicted. - Restore: blob back to the HEAD blob 26f18e1, and `git diff HEAD` was empty. I rebuilt, and `--absent` reported the marker absent from all 6 built files, with the tree clean against HEAD. The pin went back to 4 passed (4). ## Changeset None. This PR carries `skip-changeset` because it publishes nothing. `packages/runtime`'s `files[]` is `dist`, `README.md` and `CHANGELOG.md`. After a runtime build, the pin's own fixture name `pin_amber_projects_declared` has 0 hits there, and the positive control `createDispatcherPlugin` has 4 hits in `dist/`. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 53 families for this diff. I ran each one under the verify lock and recorded its exit code. `--ran` reports: 53 accounted for, 52 run (all exit 0), 1 NOT MEASURED. The NOT MEASURED one is `pnpm check:dual-build-cjs-loads`, which exited 3 (PREREQUISITE NOT MET) because it reads every package's `dist/` and this checkout built only runtime's closure. This diff changes no built output. CI runs it. ## Acceptance notes - **File wall.** One run of this file reads `Duration 15.6–16.8 s`, of which `import` is about 14 s and `tests` about 2 s. That is over the 10 s file line, and the cost is the full-composition boot, the same one the sibling runtime pins pay. I declared no budget line: none of the three ruled classes fits. This note is for the shard-timings refresh, which reports and never reds. - **Envelope dialects.** The sharing-rule route (`POST /sharing/rules`) answers this refusal in the nested envelope (`error.code`), while the `/data` door answers it flat (`code`). This is not a defect for this card. It is the known two-dialect split, recorded here so nobody asserts the wrong spelling on the wrong door. --- _Generated by [Claude Code](https://claude.ai/code/session_016g7nYP9wdNx1GDa5XDr7Uy)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016g7nYP9wdNx1GDa5XDr7Uy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 49185cd commit a835858

1 file changed

Lines changed: 221 additions & 0 deletions

File tree

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D6 regime C] The generic `/data` door refuses a `sys_sharing_rule`
5+
* row that takes a declared rule's name, over HTTP, in the ADR-0112 envelope.
6+
*
7+
* A declared sharing rule (a stack's `sharingRules`) is read-only and clonable:
8+
* it is customised by a clone under ANOTHER name plus the declared rule
9+
* switched off in the activation ledger. So no organization-authored row may
10+
* take a declared rule's name — not a new row, and not an existing row renamed
11+
* onto it. `content/docs/permissions/sharing-rules.mdx` publishes the answer:
12+
* `409 RESOURCE_CONFLICT`, for Setup's New and edit forms and for scripts and
13+
* imports through the data API.
14+
*
15+
* The refusal is plugin-sharing's `bindDeclaredRuleNameGuard`
16+
* (`DeclaredSharingRuleNameConflictError`), a `beforeInsert` / `beforeUpdate`
17+
* hook on `sys_sharing_rule`. Its unit pins drive the hook over a fake engine
18+
* and the `POST /sharing/rules` door's pin builds the thrown shape by hand;
19+
* neither reaches the door those forms and scripts actually write through.
20+
* This file does: a booted composition, an administrator signed in, and
21+
* `POST` / `PATCH {basePath}/data/sys_sharing_rule` over the HTTP app, so what
22+
* is asserted is the wire answer a client receives — `status` and `code`.
23+
* The `/data` door's declared-4xx answer is its flat envelope: `code` is the
24+
* machine token at the top level, beside the human `error` sentence
25+
* (`mapDataError`, `@objectstack/types`) — read there, not through a fallback
26+
* that would also accept the nested `error.code` other families answer.
27+
*
28+
* Composition: the plugin set, in order, `@objectstack/verify`'s `bootStack`
29+
* uses (it mirrors `objectstack dev` / `serve`), as the #21516 and #21454 pins
30+
* assemble it, with the production `autoDefaultOrganization` default so the
31+
* administrator's rows have an organization to belong to. The declared rules
32+
* come from the application's own `sharingRules`, as a real stack declares
33+
* them. The boot is paid in `beforeAll`, never inside a case.
34+
*
35+
* The CONTROL is the same create under a name no declaration holds: it lands,
36+
* so the 409 is the guard's answer about the name and not the rig refusing
37+
* every write to this object.
38+
*
39+
* Each refusal targets its OWN declared rule, and `code` is asserted beside
40+
* `status`, because this object's unique constraint on the name also answers
41+
* 409 (`UNIQUE_VIOLATION`): with one shared name, a create that wrongly landed
42+
* would turn the rename's answer into that other 409, and a status-only pin
43+
* would read it as the guard's.
44+
*/
45+
46+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
47+
import { ObjectKernel } from '@objectstack/core';
48+
import { ObjectQLPlugin } from '@objectstack/objectql';
49+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
50+
import { createRestApiPlugin } from '@objectstack/rest';
51+
import { AuthPlugin } from '@objectstack/plugin-auth';
52+
import { SecurityPlugin, appSecurityPluginOptions } from '@objectstack/plugin-security';
53+
import { SharingServicePlugin } from '@objectstack/plugin-sharing';
54+
import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin';
55+
import { HttpStatusErrorCodeMap } from '@objectstack/spec/api';
56+
import { AppPlugin } from './app-plugin.js';
57+
import { DefaultDatasourcePlugin } from './default-datasource-plugin.js';
58+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
59+
60+
const BOOT_TIMEOUT = 180_000;
61+
const ORIGIN = 'http://localhost:3000';
62+
const API = '/api/v1';
63+
const ADMIN = { email: 'admin@objectos.ai', password: 'admin123' };
64+
const RULES = '/data/sys_sharing_rule';
65+
66+
/** The declared rule the create targets — a name no authored row may take. */
67+
const DECLARED_CREATE = 'pin_red_projects_declared';
68+
/** The declared rule the rename targets. */
69+
const DECLARED_RENAME = 'pin_amber_projects_declared';
70+
/** A name no declaration holds: the control's, and the row the rename starts from. */
71+
const OWN = 'pin_red_projects_ours';
72+
73+
const declaredRule = (name: string, status: string) => ({
74+
type: 'criteria',
75+
name,
76+
label: `${status} projects (declared)`,
77+
object: 'pin_project',
78+
condition: `record.status == '${status}'`,
79+
accessLevel: 'read',
80+
sharedWith: { type: 'user', value: 'usr_declared_recipient' },
81+
active: true,
82+
});
83+
84+
const PIN_APP: any = {
85+
manifest: { id: 'com.pin.declaredrulename', name: 'Declared rule name pins', version: '1.0.0' },
86+
objects: [
87+
{
88+
name: 'pin_project',
89+
label: 'Pin project',
90+
fields: {
91+
title: { type: 'text', label: 'Title' },
92+
status: { type: 'text', label: 'Status' },
93+
},
94+
},
95+
],
96+
sharingRules: [declaredRule(DECLARED_CREATE, 'red'), declaredRule(DECLARED_RENAME, 'amber')],
97+
};
98+
99+
/** A complete authored row, as Setup's New form posts it. */
100+
const authoredRow = (name: string) => ({
101+
name,
102+
label: 'Red projects (ours)',
103+
object_name: 'pin_project',
104+
criteria_json: JSON.stringify({ status: 'red' }),
105+
recipient_type: 'user',
106+
recipient_id: 'usr_own_recipient',
107+
access_level: 'read',
108+
});
109+
110+
let kernel: any;
111+
let httpServer: any;
112+
let app: any;
113+
let engine: any;
114+
let adminToken: string;
115+
let prevNodeEnv: string | undefined;
116+
117+
const req = (path: string, init?: RequestInit) => app.request(`${ORIGIN}${API}${path}`, init);
118+
const as = (token: string, method: string, path: string, payload?: unknown) =>
119+
req(path, {
120+
method,
121+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
122+
...(payload !== undefined ? { body: JSON.stringify(payload) } : {}),
123+
});
124+
125+
async function readJson(res: Response): Promise<any> {
126+
const text = await res.text();
127+
try {
128+
return JSON.parse(text);
129+
} catch {
130+
return text;
131+
}
132+
}
133+
134+
/** The authored rows under `name`, read past every door. */
135+
const rowsNamed = async (name: string): Promise<any[]> =>
136+
engine.find('sys_sharing_rule', { where: { name }, context: { isSystem: true } });
137+
138+
async function signIn(who: { email: string; password: string }): Promise<string> {
139+
const res = await req('/auth/sign-in/email', {
140+
method: 'POST',
141+
headers: { 'Content-Type': 'application/json' },
142+
body: JSON.stringify(who),
143+
});
144+
if (!res.ok) throw new Error(`pin signIn failed: ${res.status} ${await res.text()}`);
145+
return ((await res.json()) as any).token;
146+
}
147+
148+
beforeAll(async () => {
149+
prevNodeEnv = process.env.NODE_ENV;
150+
process.env.NODE_ENV = 'development'; // the dev-admin seed, as `objectstack dev` / bootStack arm it
151+
152+
kernel = new ObjectKernel();
153+
await kernel.use(new ObjectQLPlugin());
154+
await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } }));
155+
await kernel.use(new HonoServerPlugin({ port: 0 }));
156+
await kernel.use(new AppPlugin(PIN_APP));
157+
await kernel.use(new PlatformObjectsPlugin());
158+
await kernel.use(new AuthPlugin({ secret: 'declared-rule-name-data-door-secret' }));
159+
await kernel.use(new SecurityPlugin(appSecurityPluginOptions(PIN_APP)));
160+
await kernel.use(new SharingServicePlugin());
161+
await kernel.use(createRestApiPlugin({}));
162+
await kernel.use(createDispatcherPlugin({}));
163+
await kernel.bootstrap();
164+
165+
httpServer = await kernel.getServiceAsync('http-server');
166+
app = httpServer.getRawApp();
167+
engine = await kernel.getServiceAsync('objectql');
168+
adminToken = await signIn(ADMIN);
169+
}, BOOT_TIMEOUT);
170+
171+
afterAll(async () => {
172+
try { await httpServer?.close?.(); } catch { /* best-effort */ }
173+
try { await kernel?.shutdown?.(); } catch { /* best-effort */ }
174+
if (prevNodeEnv === undefined) delete process.env.NODE_ENV;
175+
else process.env.NODE_ENV = prevNodeEnv;
176+
}, 60_000);
177+
178+
describe('[ADR-0131 D6 regime C] precondition and control', () => {
179+
it('the application\'s rules are declared, as the sharing-rule service the guard asks reads them', async () => {
180+
const rules: any = await kernel.getServiceAsync('sharingRules');
181+
for (const name of [DECLARED_CREATE, DECLARED_RENAME]) {
182+
expect((await rules.declaredRuleNamed(name))?.name).toBe(name);
183+
// A declared rule has no row: nothing below can collide with one.
184+
expect(await rowsNamed(name)).toEqual([]);
185+
}
186+
expect(await rules.declaredRuleNamed(OWN)).toBeNull();
187+
});
188+
189+
it('CONTROL: a create under a name no declaration holds lands through the same door', async () => {
190+
const res = await as(adminToken, 'POST', RULES, authoredRow(OWN));
191+
const payload = await readJson(res);
192+
expect(res.status, JSON.stringify(payload)).toBeGreaterThanOrEqual(200);
193+
expect(res.status, JSON.stringify(payload)).toBeLessThan(300);
194+
expect(await rowsNamed(OWN)).toHaveLength(1);
195+
});
196+
});
197+
198+
describe('[ADR-0131 D6 regime C] POST / PATCH /data/sys_sharing_rule onto a declared rule\'s name', () => {
199+
it('a create taking the declared name answers 409 RESOURCE_CONFLICT, and no row lands', async () => {
200+
const res = await as(adminToken, 'POST', RULES, authoredRow(DECLARED_CREATE));
201+
const payload = await readJson(res);
202+
expect(res.status, JSON.stringify(payload)).toBe(409);
203+
expect(payload?.code, JSON.stringify(payload)).toBe('RESOURCE_CONFLICT');
204+
// The catalogue's own code for the status, not a restated pair.
205+
expect(HttpStatusErrorCodeMap[409]).toBe(payload?.code);
206+
expect(await rowsNamed(DECLARED_CREATE)).toEqual([]);
207+
});
208+
209+
it('a rename onto the declared name answers 409 RESOURCE_CONFLICT, and the row keeps its name', async () => {
210+
const [own] = await rowsNamed(OWN);
211+
expect(own?.id, 'the control row the rename starts from').toBeTruthy();
212+
213+
const res = await as(adminToken, 'PATCH', `${RULES}/${own.id}`, { name: DECLARED_RENAME });
214+
const payload = await readJson(res);
215+
expect(res.status, JSON.stringify(payload)).toBe(409);
216+
expect(payload?.code, JSON.stringify(payload)).toBe('RESOURCE_CONFLICT');
217+
expect(HttpStatusErrorCodeMap[409]).toBe(payload?.code);
218+
expect(await rowsNamed(DECLARED_RENAME)).toEqual([]);
219+
expect((await rowsNamed(OWN)).map((r) => r.id)).toEqual([own.id]);
220+
});
221+
});

0 commit comments

Comments
 (0)