Skip to content

Commit 844142d

Browse files
committed
test(spec,plugin-sharing): pin the guest recipient's refusal at both doors and its census; regenerate translations
Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 050e8ed commit 844142d

9 files changed

Lines changed: 344 additions & 12 deletions
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0138 D8] `sys_record_share`'s `guest` recipient is retired at the ROW
5+
* door — on a REAL engine over a REAL driver, with the real `SysRecordShare`.
6+
*
7+
* The grant door refuses `'guest'` with its prescription
8+
* (`sharing-service.test.ts`, "[ADR-0138 D8]"). What it cannot prove is the
9+
* door every other producer of a row crosses: a system-context engine write —
10+
* a seed, a plugin, a migration — which `grant()` never sees. Before the
11+
* retirement the `recipient_type` select offered `guest`, so such a write
12+
* PERSISTED a row that no gate reads (the gates match `recipient_type: 'user'`
13+
* only): a share that looked granted and enforced nothing (ADR-0078). Now the
14+
* select's option arm refuses it, naming the values a row may carry.
15+
*
16+
* The control row (`user`) is the same write with one value changed, so a red
17+
* on the guest case cannot come from the harness.
18+
*/
19+
20+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
21+
import { ObjectQL } from '@objectstack/objectql';
22+
import { SqlDriver } from '@objectstack/driver-sql';
23+
24+
import { SysRecordShare } from './objects/sys-record-share.object.js';
25+
26+
const SYS = { context: { isSystem: true } } as never;
27+
28+
/** The four values a row may carry, in the select's declared order. */
29+
const ROW_RECIPIENTS = ['user', 'group', 'position', 'unit_and_subordinates'];
30+
31+
const row = (id: string, recipientType: string) => ({
32+
id,
33+
object_name: 'account',
34+
record_id: 'acc_1',
35+
recipient_type: recipientType,
36+
recipient_id: 'usr_target',
37+
access_level: 'read',
38+
source: 'manual',
39+
});
40+
41+
let engine: ObjectQL;
42+
43+
beforeAll(async () => {
44+
engine = new ObjectQL();
45+
engine.registerDriver(
46+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never,
47+
true,
48+
);
49+
await engine.init();
50+
engine.registerApp({
51+
id: 'com.objectstack.qa.record-share-guest-recipient-retired',
52+
name: 'Record share: the retired guest recipient',
53+
version: '1.0.0',
54+
type: 'plugin',
55+
scope: 'system',
56+
objects: [SysRecordShare],
57+
} as never);
58+
await engine.syncSchemas();
59+
});
60+
61+
afterAll(async () => {
62+
try { await engine?.destroy(); } catch { /* noop */ }
63+
});
64+
65+
describe('[ADR-0138 D8] the row door refuses a guest recipient_type', () => {
66+
it('the declared select offers exactly the four row recipients, guest not among them', () => {
67+
const options = (SysRecordShare.fields.recipient_type as { options?: unknown[] }).options ?? [];
68+
const values = options.map((o) => (typeof o === 'object' && o !== null ? (o as { value: unknown }).value : o));
69+
expect(values).toEqual(ROW_RECIPIENTS);
70+
});
71+
72+
it('a system-context insert carrying recipient_type guest is refused on that field, and nothing lands', async () => {
73+
const refusal = await engine.insert('sys_record_share', [row('shr_guest', 'guest')], SYS)
74+
.then(() => null, (e: unknown) => e as { code?: unknown; fields?: unknown[] });
75+
expect(refusal, 'the guest row was admitted').not.toBeNull();
76+
expect(refusal!.code).toBe('VALIDATION_FAILED');
77+
const fieldErrors = (refusal!.fields ?? []) as Array<{ field?: string; code?: string; options?: string[] }>;
78+
expect(fieldErrors).toHaveLength(1);
79+
expect(fieldErrors[0]).toMatchObject({ field: 'recipient_type', code: 'invalid_option' });
80+
expect(fieldErrors[0]!.options).toEqual(ROW_RECIPIENTS);
81+
const stored = await engine.findOne('sys_record_share', { where: { id: 'shr_guest' }, context: { isSystem: true } } as never);
82+
expect(stored ?? null).toBeNull();
83+
});
84+
85+
it('control: the same insert with recipient_type user lands', async () => {
86+
await engine.insert('sys_record_share', [row('shr_user', 'user')], SYS);
87+
const stored = await engine.findOne('sys_record_share', { where: { id: 'shr_user' }, context: { isSystem: true } } as never) as { recipient_type?: string } | null;
88+
expect(stored?.recipient_type).toBe('user');
89+
});
90+
});

‎packages/plugins/plugin-sharing/src/sharing-service.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1046,7 +1046,7 @@ describe('[ADR-0111 D7] no inert grants', () => {
10461046
it('refuses non-user recipient types instead of persisting rows no gate reads', async () => {
10471047
// Every non-`user` member of RecordShareRecipientType — no `as any`
10481048
// needed since #4539 aligned the contract type to the storage select.
1049-
for (const recipientType of ['group', 'position', 'unit_and_subordinates', 'guest'] as const) {
1049+
for (const recipientType of ['group', 'position', 'unit_and_subordinates'] as const) {
10501050
await expect(
10511051
svc.grant(
10521052
{ object: 'account', recordId: 'a1', recipientId: 'g1', recipientType },
@@ -1057,6 +1057,26 @@ describe('[ADR-0111 D7] no inert grants', () => {
10571057
expect(engine._tables.sys_record_share ?? []).toHaveLength(0);
10581058
});
10591059

1060+
it("[ADR-0138 D8] refuses the retired 'guest' recipient with its prescription, never the sharing-rule route", async () => {
1061+
// `guest` left RecordShareRecipientType, so only an untyped caller (a REST
1062+
// body) can still send it. The grant's one error channel is the message
1063+
// prefix (`rest-server.ts`'s `respondSharingError` maps it to a 400); the
1064+
// pin asserts that prefix, the prescription, and that nothing was written.
1065+
// A sharing rule cannot deliver a guest either, so the generic sentence
1066+
// ("delivered via sharing rules") would misdirect the caller.
1067+
const refusal = await svc.grant(
1068+
{ object: 'account', recordId: 'a1', recipientId: 'anyone', recipientType: 'guest' as never },
1069+
{ userId: 'alice' },
1070+
).then(() => null, (e: unknown) => e);
1071+
expect(refusal).toBeInstanceOf(Error);
1072+
const message = (refusal as Error).message;
1073+
expect(message.startsWith("VALIDATION_FAILED: recipientType 'guest' was removed (ADR-0138 D8)")).toBe(true);
1074+
expect(message).toMatch(/guest access flows through the guest position and share links \(ADR-0090 D11\)/);
1075+
expect(message).toMatch(/Expose the record through a public form or a share link instead\.$/);
1076+
expect(message).not.toMatch(/sharing rules/);
1077+
expect(engine._tables.sys_record_share ?? []).toHaveLength(0);
1078+
});
1079+
10601080
it('refuses a grant on a public object (no gate would ever consult it)', async () => {
10611081
await expect(
10621082
svc.grant({ object: 'whiteboard', recordId: 'w1', recipientId: 'bob' }, { userId: 'alice' }),

‎packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
3838
user: "user",
3939
group: "group",
4040
position: "position",
41-
unit_and_subordinates: "unit_and_subordinates",
42-
guest: "guest"
41+
unit_and_subordinates: "unit_and_subordinates"
4342
}
4443
},
4544
recipient_id: {

‎packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
3838
user: "Usuario",
3939
group: "Grupo",
4040
position: "Puesto",
41-
unit_and_subordinates: "Unidad de negocio y subordinados",
42-
guest: "Invitado"
41+
unit_and_subordinates: "Unidad de negocio y subordinados"
4342
}
4443
},
4544
recipient_id: {

‎packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
3838
user: "ユーザー",
3939
group: "グループ",
4040
position: "ポジション",
41-
unit_and_subordinates: "ビジネスユニットと下位階層",
42-
guest: "ゲスト"
41+
unit_and_subordinates: "ビジネスユニットと下位階層"
4342
}
4443
},
4544
recipient_id: {

‎packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
3838
user: "用户",
3939
group: "组",
4040
position: "岗位",
41-
unit_and_subordinates: "业务单元及下级",
42-
guest: "访客"
41+
unit_and_subordinates: "业务单元及下级"
4342
}
4443
},
4544
recipient_id: {
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `sys_record_share`'s `guest` recipient RETIRED (ADR-0138 D8) — the census.
5+
*
6+
* The record's row: "remove, on its own card"; the pin it names: "a census
7+
* showing no stored row carries it". The value was declared in two places —
8+
* the `recipient_type` select on `SysRecordShare` (`@objectstack/plugin-sharing`)
9+
* and the contract union `RecordShareRecipientType` here — and honoured by
10+
* none: `grant()` refused it and no gate reads such a row, because guest access
11+
* flows through the guest position and share links (ADR-0090 D11).
12+
*
13+
* Three legs, one walk:
14+
* 1. PRODUCERS — no source inside the declared radius writes
15+
* `recipient_type` / `recipientType` as `guest` (a seed, a fixture, an
16+
* example, a template, a test row, a REST body, a migration). The key is
17+
* shared with `sys_sharing_rule`, whose vocabulary retired the same value
18+
* earlier (`sharing-rule-recipient-reconcile`), so a hit on either row
19+
* kind is a resurrection of a dead value, never a live one.
20+
* 2. THE SELECT — the `sys_record_share` object's `recipient_type` options,
21+
* read from its declaration, do not offer `guest`.
22+
* 3. THE UNION — `RecordShareRecipientType`'s literal members do not name it
23+
* (the compile-time half is `sharing-service.test.ts`'s `@ts-expect-error`).
24+
*
25+
* Each leg carries a planted positive control (the matcher finds the value
26+
* where it is written) and a lit control on the real tree (the matcher finds
27+
* the LIVE values — `user` producers, the `user` option, the `user` member) so
28+
* a zero is a measurement, not a matcher that reads nothing.
29+
*
30+
* The bound, stated: a value assembled at runtime (`recipient_type: kind`, a
31+
* spread, a computed key) is invisible to a text walk; `docs/**`, `.claude/**`,
32+
* `.github/**` and the repo-root files are outside the radius, which is the
33+
* one declared for `@objectstack/spec` in `scripts/cross-package-test-inputs.mjs`.
34+
*/
35+
36+
import fs from 'node:fs';
37+
import path from 'node:path';
38+
import { fileURLToPath } from 'node:url';
39+
40+
import { describe, expect, it } from 'vitest';
41+
42+
const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
43+
const REPO_ROOT = path.resolve(SPEC_ROOT, '../..');
44+
const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/');
45+
46+
/** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */
47+
const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts'];
48+
const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']);
49+
/** Under `examples/` the non-code extensions everywhere, and `.ts` under each app's `src/` only — what is declared. */
50+
const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']);
51+
const EXAMPLES_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/;
52+
const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']);
53+
/** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */
54+
const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/;
55+
56+
/**
57+
* Structural exclusions, each with its reason. ⛔ NOT an allowlist file: every
58+
* entry's JOB is to spell the retired value.
59+
*/
60+
const EXCLUDED = new Set([
61+
// This census plants the value in its positive controls.
62+
THIS_FILE,
63+
// The grant door's refusal pin sends `recipientType: 'guest'` on purpose.
64+
'packages/plugins/plugin-sharing/src/sharing-service.test.ts',
65+
]);
66+
const EXCLUDED_PREFIXES = [
67+
// Release-owned prose records the removal; never edited by a code PR.
68+
'content/docs/releases/',
69+
];
70+
71+
/** Leg 1: a key spelling of the recipient type, then `:` (or a lone `=`), then `guest` QUOTED. */
72+
const PRODUCER_CODE = /(?:^|[^\w$])['"]?recipient_?[tT]ype['"]?\s*(?::|=(?![=>]))\s*(['"`])guest\1/g;
73+
/** Leg 1, YAML: a mapping line whose key is the recipient type and whose value is `guest`, quoted or not. */
74+
const PRODUCER_YAML = /^\s*(?:-\s+)?recipient_?[tT]ype\s*:\s*(['"]?)guest\1\s*(?:#.*)?$/gm;
75+
76+
const lineOf = (text: string, index: number): number => text.slice(0, index).split('\n').length;
77+
78+
const producersIn = (ext: string, text: string, live = 'guest'): number[] => {
79+
if (!text.includes(live)) return [];
80+
const out: number[] = [];
81+
const code = new RegExp(PRODUCER_CODE.source.replace('guest', live), 'g');
82+
for (let m = code.exec(text); m; m = code.exec(text)) out.push(lineOf(text, m.index));
83+
if (ext === '.yaml' || ext === '.yml' || ext === '.md' || ext === '.mdx') {
84+
const yaml = new RegExp(PRODUCER_YAML.source.replace('guest', live), 'gm');
85+
for (let m = yaml.exec(text); m; m = yaml.exec(text)) out.push(lineOf(text, m.index));
86+
}
87+
return [...new Set(out)].sort((a, b) => a - b);
88+
};
89+
90+
/** The string literals inside the bracketed span that starts at `open`, comments stripped. */
91+
const literalsInBrackets = (text: string, open: number): string[] => {
92+
let depth = 0;
93+
let end = -1;
94+
for (let i = open; i < text.length; i += 1) {
95+
if (text[i] === '[') depth += 1;
96+
else if (text[i] === ']') { depth -= 1; if (depth === 0) { end = i; break; } }
97+
}
98+
if (end < 0) return [];
99+
const body = text.slice(open + 1, end).replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
100+
return [...body.matchAll(/(['"`])([^'"`]*)\1/g)].map((m) => m[2]!);
101+
};
102+
103+
/** Leg 2: the options of the `recipient_type` select in a file that declares `sys_record_share`, or `null`. */
104+
const recordShareSelectOptions = (text: string): string[] | null => {
105+
if (!/name:\s*['"]sys_record_share['"]/.test(text)) return null;
106+
const at = text.search(/recipient_type\s*:\s*Field\.select\(/);
107+
if (at < 0) return null;
108+
const open = text.indexOf('[', at);
109+
return open < 0 ? null : literalsInBrackets(text, open);
110+
};
111+
112+
/** Leg 3: the literal members of `RecordShareRecipientType`, or `null` when the file does not declare it. */
113+
const unionMembers = (text: string): string[] | null => {
114+
const m = /export\s+type\s+RecordShareRecipientType\s*=([^;]*);/.exec(text);
115+
if (!m) return null;
116+
return [...m[1]!.replace(/\/\/[^\n]*/g, '').matchAll(/'([^']*)'/g)].map((x) => x[1]!);
117+
};
118+
119+
describe('record-share guest recipient census — the matchers can see the value (planted positive controls)', () => {
120+
it('leg 1 finds a producer in each spelling the walk reads, and ignores the neighbours', () => {
121+
expect(producersIn('.ts', "insert('sys_record_share', [{ id: 's', recipient_type: 'guest', recipient_id: 'x' }])")).toEqual([1]);
122+
expect(producersIn('.ts', "svc.grant({ object: 'a', recordId: 'r',\n recipientType: \"guest\" })")).toEqual([2]);
123+
expect(producersIn('.ts', "const input = {};\ninput.recipientType = 'guest';")).toEqual([2]);
124+
expect(producersIn('.json', '{ "records": [ { "recipient_type": "guest" } ] }')).toEqual([1]);
125+
expect(producersIn('.yaml', 'records:\n - recipient_type: guest\n recipient_id: x\n')).toEqual([2]);
126+
expect(producersIn('.md', "Seed:\n\n```ts\n({ recipient_type: 'guest' });\n```\n")).toEqual([4]);
127+
// Neighbours that must NOT match: a comparison (a reader), another key,
128+
// the anchor vocabulary, a different value, and prose naming the word.
129+
expect(producersIn('.ts', "if (row.recipient_type === 'guest') return;")).toEqual([]);
130+
expect(producersIn('.ts', "({ anchor: 'guest', principalKind: 'guest', type: 'guest' })")).toEqual([]);
131+
expect(producersIn('.ts', "({ recipient_type: 'guest_portal' })")).toEqual([]);
132+
expect(producersIn('.md', 'The guest recipient type was removed.')).toEqual([]);
133+
});
134+
135+
it('leg 2 reads the options of the record-share select, and finds a planted guest option', () => {
136+
const planted = "ObjectSchema.create({ name: 'sys_record_share', fields: {\n"
137+
+ " recipient_type: Field.select(\n // a comment naming 'nothing'\n ['user', 'guest'],\n { label: 'x' },\n ),\n} })";
138+
expect(recordShareSelectOptions(planted)).toEqual(['user', 'guest']);
139+
// Another object's `recipient_type` select is not this object's.
140+
expect(recordShareSelectOptions("ObjectSchema.create({ name: 'sys_sharing_rule', fields: { recipient_type: Field.select(['user', 'guest']) } })")).toBeNull();
141+
});
142+
143+
it('leg 3 reads the union members, and finds a planted guest member', () => {
144+
expect(unionMembers("export type RecordShareRecipientType =\n | 'user'\n | 'guest';")).toEqual(['user', 'guest']);
145+
expect(unionMembers("export type SharingRuleRecipientType = 'user' | 'guest';")).toBeNull();
146+
});
147+
});
148+
149+
describe('record-share guest recipient census — nothing inside the declared radius carries it', () => {
150+
it('no producer writes it, the select does not offer it, the union does not name it', () => {
151+
const producers: string[] = [];
152+
const liveProducers: string[] = [];
153+
const selects: { rel: string; options: string[] }[] = [];
154+
const unions: { rel: string; members: string[] }[] = [];
155+
let visited = 0;
156+
let recordShareFiles = 0;
157+
158+
const walk = (dir: string) => {
159+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
160+
const full = path.join(dir, entry.name);
161+
const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/');
162+
if (entry.isDirectory()) {
163+
if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
164+
walk(full);
165+
continue;
166+
}
167+
if (!entry.isFile()) continue;
168+
const ext = path.extname(entry.name);
169+
const declared = rel.startsWith('examples/')
170+
? EXAMPLES_EXT.has(ext) || EXAMPLES_SRC_TS.test(rel)
171+
: SCANNED_EXT.has(ext);
172+
if (!declared) continue;
173+
if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal
174+
if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue;
175+
if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue;
176+
let text: string;
177+
try {
178+
text = fs.readFileSync(full, 'utf-8');
179+
} catch (err) {
180+
// Tolerate ONLY a path that vanished mid-walk (a concurrent build).
181+
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') continue;
182+
throw err;
183+
}
184+
visited += 1;
185+
if (text.includes('sys_record_share')) recordShareFiles += 1;
186+
for (const line of producersIn(ext, text)) producers.push(`${rel}:${line}`);
187+
for (const line of producersIn(ext, text, 'user')) liveProducers.push(`${rel}:${line}`);
188+
if (ext === '.ts') {
189+
const options = recordShareSelectOptions(text);
190+
if (options) selects.push({ rel, options });
191+
const members = unionMembers(text);
192+
if (members) unions.push({ rel, members });
193+
}
194+
}
195+
};
196+
for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root));
197+
198+
// Lit controls: the walk covered the tree, it read the files that hold
199+
// record shares, and the producer matcher finds the LIVE value at scale.
200+
expect(visited).toBeGreaterThan(1000);
201+
expect(recordShareFiles).toBeGreaterThan(20);
202+
expect(liveProducers.length).toBeGreaterThan(20);
203+
// Leg 1.
204+
expect(producers, 'a recipient_type / recipientType written as guest is a share no gate honours (ADR-0138 D8)').toEqual([]);
205+
// Leg 2: exactly one declaration, which the matcher really read (`user` is there).
206+
expect(selects.map((s) => s.rel)).toEqual(['packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts']);
207+
expect(selects[0]!.options).toContain('user');
208+
expect(selects[0]!.options).not.toContain('guest');
209+
// Leg 3: exactly one declaration, likewise lit.
210+
expect(unions.map((u) => u.rel)).toEqual(['packages/spec/src/contracts/sharing-service.ts']);
211+
expect(unions[0]!.members).toContain('user');
212+
expect(unions[0]!.members).not.toContain('guest');
213+
});
214+
});

0 commit comments

Comments
 (0)