Skip to content

Commit 3ff32c4

Browse files
fix(plugin-sharing): a share link whose redactFields names no column of the object is refused at mint (#29293)
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi943RpYVWrqYbqemn2F3hcjD)_ Fixes #29130 Clause-②: yes (narrowing) Metadata-protocol: none This PR widens one surface and narrows another, so it takes the `(narrowing)` arm. The widening is one new export on `@objectstack/objectql/core` (`declaredColumnSet`). The narrowing is the share-link mint: a `redactFields` entry naming no column of the object was accepted `201` on `main`, and is now refused `400 VALIDATION_FAILED` with nothing minted (measured below on base and head). The changeset carries the `**BREAKING**` banner with the FROM → TO fix and the ADR-0087 disposition `not-required (no-migration-prescription)`. Both packages stay at patch. Contract review 6576071712, M1. ## 维护者速读 - 改了什么:创建分享链接时,`redactFields` 里每个字段名都必须是该对象真实存在的列,否则 400 拒绝,不生成链接;objectql 的 `./core` 多导出一个现成函数 `declaredColumnSet`。 - 为什么改:以前写错一个字段名也照样生成链接,那个字段一点没被隐藏,落地页却显示「已隐藏」,等于把本想藏起来的数据发给了所有持链接的人。 - 风险与回滚:这是一个声明为 BREAKING 的收窄:以前能成功、但字段名写错的创建请求现在会被拒;正确的字段名、空列表、已存在的旧链接都不受影响。回滚即还原本 PR。 - 先看:`packages/plugins/plugin-sharing/src/share-link-service.ts`(`assertRedactFields` 和它在 `createLink` 里的位置,在权限检查之后)、`packages/plugins/plugin-sharing/src/share-link-redact-fields.test.ts`、`packages/objectql/src/core.ts`。 - 两分钟验证:`pnpm --filter @objectstack/plugin-sharing exec vitest run --maxWorkers=2 src/share-link-redact-fields.test.ts`,预期 11 个用例全过。 - 需要你做的:无,席位落地。 ## What changes - `ShareLinkService.createLink` checks `redactFields` before anything is written, in two steps. First the shape: the list must be an array of strings. That check runs with the mint's other request-shape 400s (`object`, `recordId`, `emailAllowlist`), before any record is read. Then the columns: each entry must name a column a read of the object serves: the object's field map plus the platform-provisioned columns (`id`, `created_at`, `updated_at`). Otherwise it answers `400 VALIDATION_FAILED`, naming every unknown entry once, and no link row is written. This is the request-shape family the mint already uses (`object is required`, `emailAllowlist is required when audience=email`); no new error code. - The column check runs after the authority check, so a caller with no authority over the record still gets its `403` and learns nothing about the object's columns. - An object with no checkable field map gets no verdict, and the list is kept as sent. That is `declaredColumnSet`'s shared rule: `undefined` means no opinion. - The REST door (`POST /api/v1/share-links`) and the dispatcher twin both call `createLink`, so both answer the refusal with the door's existing envelope. No route edit was needed. - `@objectstack/objectql/core` re-exports `declaredColumnSet` from `declared-read-columns.ts`. That module says its list is the one every read and write door reads, so the mint asks it too and keeps no copy of the column list. - Changeset `.changeset/29130-share-link-redact-fields.md` (patch, both packages). Forced test edits: none. No existing test changed. One ledger row was forced: the new suite's engine double is pinned to the engine's `update`/`delete` dispatch contract (`assertEngineUpdateDispatch` / `assertEngineDeleteDispatch`), so `check:engine-double-contract` asked for its two `pinned` rows in `scripts/engine-double-contract.pinned.json` (written with `--write`: 0 rows added or grown elsewhere, 0 lost). The same double holds the caller's `limit` and refuses a `where` combinator it does not implement, which is what `check:objectql-double-limit` and `check:where-matcher` ask of a new double. ## Tests New file `packages/plugins/plugin-sharing/src/share-link-redact-fields.test.ts`, 11 cases. The fixture schemas are shaped like the registry's, so `id` is not in the field map: - an unknown entry is refused: status, code, the entry named, no row stored; - mixed valid and unknown entries: only the unknown ones are named, deduplicated; - a non-array or non-string list is refused, no row stored; - a malformed list is refused before any record is read (the engine sees no `find`), even for a record the caller cannot see; - a valid entry is minted, and resolve withholds that field from the served record; - `id`, `created_at` and `updated_at` are accepted even when the field map omits them; - an omitted or empty list still stores `null`; - no field map: no verdict, the list is kept; - no authority: `403 FORBIDDEN` comes first and does not echo the name; - REST door: unknown entry gives `400 VALIDATION_FAILED` and stores no row; a valid entry gives `201`. ## Acceptance notes - **Stored links are not migrated.** Rows already holding a name that matches no column keep it. The resolve answer still lists that name among the hidden fields, so the landing page can still claim a name is hidden when there is nothing behind it. Filtering at resolve would mean changing the same list that drives redaction, which risks un-hiding a policy entry, so it is not in this PR. It is raised as an open question in the report. - No update or edit path exists for a link's `redactFields` (only create, list, revoke and resolve), so the mint is the only place the list enters the store. - Security-family card: the class is described here in the abstract. The tests use generic fixture names. ## Verification All readings were taken in this checkout at head `04c61e78`, base `104cd1a5` (merge base with `origin/main`). <details><summary>Premise on base (104cd1a)</summary> A throwaway test minted with one misspelt name against an opted-in object. The row stored `redact_fields: ["secrte"]`, and `resolveToken` answered `redactFields: ["secrte"]`. Exit 0 (`MINTED ["secrte"]`, `RESOLVED redactFields ["secrte"]`). The file was deleted afterwards. </details> <details><summary>Package suites</summary> - `pnpm --filter @objectstack/plugin-sharing test`: Test Files 60 passed (60), Tests 1316 passed (1316). - `pnpm --filter @objectstack/plugin-sharing typecheck`: exit 0. - `pnpm --filter @objectstack/objectql typecheck`: exit 0 (at `38ded5e5`; `objectql` is unchanged since). - Consumers (at `38ded5e5`; the round-2 diff moves only the shape check's position). Runtime share-link domain tests (7 `src/domains/share-links-*.test.ts` files plus `src/domain-handler-registry.test.ts`): 8/8 files, 268/268 tests. Dogfood share-link files (`share-link-combined-gates`, `share-links-self-list`, `showcase-client-liaison-fixtures`, `guest-grants-channel`): 4/4 files, 41/41 tests. - `ui/`: `git grep redactFields` finds the console's resolve-answer readers and shape tests. The only `ui/` test that mints (`share-dialog-locale-13460.test.tsx`) mocks the wire and sends no `redactFields`, so there was nothing to run against the service. - Lint (`eslint --no-inline-config` on the touched source/test files): exit 0. - **Full dogfood battery** (it applies under the `(narrowing)` arm): `pnpm --filter @objectstack/dogfood test` on `04c61e78`, under the lock, after the `^...` closure build. VERDICT command-exit 0; Test Files 388 passed, 3 skipped (391); Tests 3208 passed, 28 skipped (3236); no failures. </details> <details><summary>Ablation (negative pins)</summary> Put the old accept back with `scripts/ablation-replace.mjs`, replacing the `assertRedactFields(...)` call with the raw `input.redactFields ?? []`. The anchor went from 1 to 0 hits and the blob from 7db89b5 to efb2844eef05. Result: 4 failed and 6 passed (10, at `38ded5e5`, before the round-2 shape pin was added). The red cases were the three refusal pins and the REST 400 pin; the valid-mint, provisioned-column, empty-list, no-field-map and 403-ordering cases stayed green, as they should. Restore: the blob matched HEAD again (7db89b5) and `git diff HEAD` was empty. The test imports the service from `src/`, so no `dist` leg was needed. </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `04c61e78` derived 71 commands. All 71 ran on this head and every one exited 0. `--ran` with exit codes recorded: `71 derived famil(ies) accounted for — 71 run, 0 NOT-MEASURED (a DERIVED zero — all 71 recorded an exit code and none of them is 3)`. This includes `check-adr-0087-registration --base origin/main` (exit 0): `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition` — `[BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`. It also includes `check-changeset-no-major` and `check-empty-changeset`, both exit 0. An earlier run on `e57ba0d3` found three real reds, all about the new suite's engine double: `check:engine-double-contract` (update/delete not dispatch-pinned), `check:objectql-double-limit` (a `find` that ignored `limit`) and `check:where-matcher` (a matcher that would read a combinator as a field name). Each was fixed in the double, as the gate prescribes; no baseline grew. Two prerequisite exits from that run were cleared before the final run: the teardown-shape self-test's pinned fixture commit was fetched, and the seven unbuilt `dist/` trees that `check:dual-build-cjs-loads` reads were built. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmPZTPQs1sThmyLtPtR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5924e50 commit 3ff32c4

5 files changed

Lines changed: 334 additions & 2 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
fix(plugin-sharing): a share link whose `redactFields` names a field the object does not have is refused at mint, instead of being issued with a redaction that hides nothing
7+
8+
Clause-②: yes (narrowing)
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) the narrowing is a REST request refusal at the share-link mint; no metadata key, spec schema or stored row changes, so there is nothing for objectstack migrate meta to rewrite and the caller is told at the request -->
12+
13+
**BREAKING**: `POST /api/v1/share-links` (and `ShareLinkService.createLink`) now refuses a `redactFields` entry that names no column of the object with `400 VALIDATION_FAILED`, where it used to answer `201` and mint the link. FROM a request carrying a misspelt or unknown name in `redactFields` TO the same request with the name corrected to a real field of the object, or the entry dropped.
14+
15+
The share-link mint (`POST /api/v1/share-links`, `ShareLinkService.createLink`) stored the per-link `redactFields` list as sent. At resolve the list is applied as field names, so an entry that matched no field stripped nothing, while the resolve answer still listed it among the hidden fields and the landing page said it was hidden. A misspelt redaction therefore served the very field it was meant to keep back.
16+
17+
- **The mint now narrows.** Every `redactFields` entry must name a column a read of the object serves: a declared field, or a platform-provisioned column (`id`, `created_at`, `updated_at`). Anything else answers `400 VALIDATION_FAILED` naming each unknown entry, and no link is minted. A `redactFields` that is not an array of strings answers the same. A request accepted on the previous release with a misspelt entry is now refused: correct the name, or drop it.
18+
- **Unchanged.** A valid list is stored and applied exactly as before. An omitted or empty list still means no per-link redaction. A caller with no authority over the record still gets its `403` before any name is judged. An object whose schema exposes no field map is not judged, and the list is kept as sent.
19+
- **Links already minted** keep the list they were stored with; nothing is migrated.
20+
21+
`@objectstack/objectql`: the `./core` entry gains one export, `declaredColumnSet(schema)`, the column set a read serves (the field map plus the platform-provisioned columns). The mint judges `redactFields` against it, so the share-link check and the read and write doors read one list.

‎packages/objectql/src/core.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ export type { EvaluateRulesOptions } from './validation/rule-validator.js';
102102
// verify its copy still agrees, instead of the two silently drifting behind
103103
// one doc comment's word.
104104
export { materializeDeclaredFields } from './declared-fields.js';
105+
// The column set a read may serve (declared fields plus the platform-provisioned
106+
// columns), published so a consumer that judges a list of field names — the
107+
// share-link mint's `redactFields` — asks the same ONE list the read and write
108+
// doors read instead of keeping a copy of it.
109+
export { declaredColumnSet } from './declared-read-columns.js';
105110
export {
106111
InMemoryHookMetricsRecorder,
107112
noopHookMetricsRecorder,
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29130] A share link's `redactFields` entries must name columns of the
5+
* object, or nothing is minted.
6+
*
7+
* The mint stored the list as sent. At resolve the per-link list is unioned
8+
* with the object policy's and applied as names, so an entry matching no
9+
* column stripped nothing, while the merged list — the unknown entry included
10+
* — was handed to the landing page, which prints it as the set of hidden
11+
* fields. One misspelling served the field the minter meant to keep back under
12+
* a notice saying it was withheld.
13+
*
14+
* The column set an entry is judged against is the one a read SERVES
15+
* (`declaredColumnSet` from `@objectstack/objectql/core`: the registry's field
16+
* map plus the platform-provisioned columns), so the fake schemas below are
17+
* shaped like the registry's — `id` is NOT in the field map, as it is not in a
18+
* registered object's.
19+
*
20+
* ⛔ No bare `toThrow()`: a refusal is pinned on its `status` + `code` and on
21+
* the row never being written.
22+
*/
23+
24+
import { describe, it, expect, beforeEach } from 'vitest';
25+
import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
26+
import { buildShareLinkResolveAnswer } from '@objectstack/types';
27+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
28+
import { ShareLinkService } from './share-link-service.js';
29+
import { registerShareLinkRoutes } from './share-link-routes.js';
30+
31+
interface FakeRow { [k: string]: any }
32+
33+
const SCHEMAS: Record<string, any> = {
34+
sys_share_link: { name: 'sys_share_link', fields: { token: {} } },
35+
doc: {
36+
name: 'doc',
37+
publicSharing: { enabled: true, redactFields: ['notes'] },
38+
fields: { title: {}, body: {}, notes: {}, created_at: {}, owner_id: {} },
39+
},
40+
// An object that opts out of the injected audit columns: the timestamps are
41+
// still provisioned (and served), they are just not in the field map.
42+
bare: {
43+
name: 'bare',
44+
publicSharing: { enabled: true },
45+
fields: { title: {}, body: {} },
46+
},
47+
// No checkable field map: the mint gives no verdict on the names.
48+
unmapped: { name: 'unmapped', publicSharing: { enabled: true } },
49+
};
50+
51+
function makeEngine() {
52+
const tables: Record<string, FakeRow[]> = {
53+
doc: [{ id: 'd1', title: 'Plan', body: 'the withheld text', notes: 'n', created_at: '2026-01-01' }],
54+
bare: [{ id: 'r1', title: 'T', body: 's', created_at: '2026-01-01', updated_at: '2026-01-02' }],
55+
unmapped: [{ id: 'u1', title: 'T' }],
56+
sys_share_link: [],
57+
};
58+
return {
59+
tables,
60+
getSchema: (name: string) => SCHEMAS[name],
61+
async find(object: string, options?: any) {
62+
const where = options?.where ?? {};
63+
const rows = (tables[object] ?? []).filter((r) => Object.entries(where).every(([k, v]) => {
64+
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
65+
return r[k] === v;
66+
}));
67+
return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows;
68+
},
69+
async insert(object: string, data: any) { (tables[object] ??= []).push({ ...data }); return data; },
70+
async update(object: string, data: any, options?: any) {
71+
const verdict = assertEngineUpdateDispatch(data, options);
72+
const targets = verdict.kind === 'by-id' ? (tables[object] ?? []).filter((r) => r.id === verdict.id) : [];
73+
for (const r of targets) Object.assign(r, data);
74+
return verdict.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
75+
},
76+
async delete(_object: string, options?: any) {
77+
assertEngineDeleteDispatch(options);
78+
return { ok: true };
79+
},
80+
};
81+
}
82+
83+
async function refusal(promise: Promise<unknown>): Promise<{ status?: number; code?: string; message: string }> {
84+
try {
85+
await promise;
86+
} catch (err: any) {
87+
return { status: err.status, code: err.code, message: String(err.message) };
88+
}
89+
throw new Error('expected the mint to be refused, it was accepted');
90+
}
91+
92+
describe('[#29130] share-link mint — redactFields must name columns of the object', () => {
93+
let engine: ReturnType<typeof makeEngine>;
94+
let service: ShareLinkService;
95+
96+
beforeEach(() => {
97+
engine = makeEngine();
98+
service = new ShareLinkService({ engine: engine as any });
99+
});
100+
101+
it('refuses an entry naming no column: 400 VALIDATION_FAILED naming it, and nothing is minted', async () => {
102+
const err = await refusal(
103+
service.createLink({ object: 'doc', recordId: 'd1', redactFields: ['bdoy'] }, { userId: 'u1' } as any),
104+
);
105+
expect(err.status).toBe(400);
106+
expect(err.code).toBe('VALIDATION_FAILED');
107+
expect(err.message).toContain('bdoy');
108+
expect(engine.tables.sys_share_link).toHaveLength(0);
109+
});
110+
111+
it('names every unknown entry, and only those, when valid and unknown names are mixed', async () => {
112+
const err = await refusal(
113+
service.createLink(
114+
{ object: 'doc', recordId: 'd1', redactFields: ['body', 'bdoy', 'titel', 'bdoy'] },
115+
{ userId: 'u1' } as any,
116+
),
117+
);
118+
expect(err.status).toBe(400);
119+
expect(err.code).toBe('VALIDATION_FAILED');
120+
expect(err.message).toContain('bdoy, titel');
121+
expect(err.message).not.toMatch(/\bbody\b/);
122+
expect(engine.tables.sys_share_link).toHaveLength(0);
123+
});
124+
125+
it('refuses a redactFields that is not an array of names, and nothing is minted', async () => {
126+
for (const redactFields of ['body', [42], { body: true }]) {
127+
const err = await refusal(
128+
service.createLink({ object: 'doc', recordId: 'd1', redactFields: redactFields as any }, { userId: 'u1' } as any),
129+
);
130+
expect(err.status).toBe(400);
131+
expect(err.code).toBe('VALIDATION_FAILED');
132+
}
133+
expect(engine.tables.sys_share_link).toHaveLength(0);
134+
});
135+
136+
it('mints a valid name, and resolve withholds that field from the record it serves', async () => {
137+
const link = await service.createLink(
138+
{ object: 'doc', recordId: 'd1', redactFields: ['body'] },
139+
{ userId: 'u1' } as any,
140+
);
141+
expect(engine.tables.sys_share_link).toHaveLength(1);
142+
expect(engine.tables.sys_share_link[0].redact_fields).toEqual(['body']);
143+
144+
const resolved = await service.resolveToken(link.token, {});
145+
expect(resolved).not.toBeNull();
146+
expect(resolved!.redactFields.sort()).toEqual(['body', 'notes']);
147+
const answer = buildShareLinkResolveAnswer(engine.tables.doc[0], resolved!, SCHEMAS.doc) as any;
148+
expect(answer.record).not.toHaveProperty('body');
149+
expect(answer.record.title).toBe('Plan');
150+
});
151+
152+
it('accepts the platform-provisioned columns a read serves, though the field map omits them', async () => {
153+
await service.createLink({ object: 'doc', recordId: 'd1', redactFields: ['id', 'created_at', 'owner_id'] }, { userId: 'u1' } as any);
154+
await service.createLink({ object: 'bare', recordId: 'r1', redactFields: ['created_at', 'updated_at'] }, { userId: 'u1' } as any);
155+
expect(engine.tables.sys_share_link.map((r) => r.redact_fields)).toEqual([
156+
['id', 'created_at', 'owner_id'],
157+
['created_at', 'updated_at'],
158+
]);
159+
});
160+
161+
it('keeps an omitted or empty list as no per-link redaction', async () => {
162+
await service.createLink({ object: 'doc', recordId: 'd1' }, { userId: 'u1' } as any);
163+
await service.createLink({ object: 'doc', recordId: 'd1', redactFields: [] }, { userId: 'u1' } as any);
164+
expect(engine.tables.sys_share_link.map((r) => r.redact_fields)).toEqual([null, null]);
165+
});
166+
167+
it('gives no verdict when the object has no checkable field map, and keeps the entries as sent', async () => {
168+
await service.createLink({ object: 'unmapped', recordId: 'u1', redactFields: ['anything'] }, { userId: 'u1' } as any);
169+
expect(engine.tables.sys_share_link[0].redact_fields).toEqual(['anything']);
170+
});
171+
172+
it('refuses a malformed list with the request-shape checks, before any record is read', async () => {
173+
const reads: string[] = [];
174+
const find = engine.find.bind(engine);
175+
engine.find = async (object: string, options?: any) => { reads.push(object); return find(object, options); };
176+
const err = await refusal(
177+
service.createLink({ object: 'doc', recordId: 'missing', redactFields: 'body' as any }, { userId: 'u1' } as any),
178+
);
179+
expect(err.status).toBe(400);
180+
expect(err.code).toBe('VALIDATION_FAILED');
181+
expect(reads).toEqual([]);
182+
});
183+
184+
it('answers a caller with no authority over the record 403 before judging the names', async () => {
185+
const err = await refusal(
186+
service.createLink({ object: 'doc', recordId: 'missing', redactFields: ['bdoy'] }, { userId: 'u1' } as any),
187+
);
188+
expect(err.status).toBe(403);
189+
expect(err.code).toBe('FORBIDDEN');
190+
expect(err.message).not.toContain('bdoy');
191+
});
192+
});
193+
194+
describe('[#29130] share-link REST door — the mint refusal reaches the caller as its envelope', () => {
195+
function mountCreate(service: ShareLinkService, engine: ReturnType<typeof makeEngine>): RouteHandler {
196+
const routes = new Map<string, RouteHandler>();
197+
const http = {
198+
get: (p: string, h: RouteHandler) => { routes.set(`GET ${p}`, h); },
199+
post: (p: string, h: RouteHandler) => { routes.set(`POST ${p}`, h); },
200+
put: () => {},
201+
delete: () => {},
202+
patch: () => {},
203+
use: () => {},
204+
listen: async () => {},
205+
close: async () => {},
206+
};
207+
registerShareLinkRoutes(http as any, service, engine as any, { contextFromRequest: () => ({ userId: 'u1' }) as any });
208+
const handler = routes.get('POST /api/v1/share-links');
209+
if (!handler) throw new Error('fixture: no create handler');
210+
return handler;
211+
}
212+
213+
async function post(handler: RouteHandler, body: unknown) {
214+
const captured = { status: 200, body: undefined as any };
215+
const res: any = {
216+
json(data: any) { captured.body = data; },
217+
send() {},
218+
status(code: number) { captured.status = code; return res; },
219+
header() { return res; },
220+
};
221+
const url = '/api/v1/share-links';
222+
await handler({ params: {}, query: {}, body, headers: {}, method: 'POST', path: url, url } as unknown as IHttpRequest, res as IHttpResponse);
223+
return captured;
224+
}
225+
226+
it('POST with an unknown redactFields entry answers 400 VALIDATION_FAILED and stores no row', async () => {
227+
const engine = makeEngine();
228+
const handler = mountCreate(new ShareLinkService({ engine: engine as any }), engine);
229+
const res = await post(handler, { object: 'doc', recordId: 'd1', redactFields: ['bdoy'] });
230+
expect(res.status).toBe(400);
231+
expect(res.body?.success).toBe(false);
232+
expect(res.body?.error?.code).toBe('VALIDATION_FAILED');
233+
expect(engine.tables.sys_share_link).toHaveLength(0);
234+
});
235+
236+
it('POST with a valid entry still answers 201 and stores it', async () => {
237+
const engine = makeEngine();
238+
const handler = mountCreate(new ShareLinkService({ engine: engine as any }), engine);
239+
const res = await post(handler, { object: 'doc', recordId: 'd1', redactFields: ['body'] });
240+
expect(res.status).toBe(201);
241+
expect(engine.tables.sys_share_link[0].redact_fields).toEqual(['body']);
242+
});
243+
});

‎packages/plugins/plugin-sharing/src/share-link-service.ts‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { ExpressionEngine } from '@objectstack/formula';
3333
// published from the lean `./core` entry, so there is no structural reason to
3434
// keep a copy. `declared-fields.ts`'s doc comment is the canonical statement of
3535
// the rule; this seam defers to it instead of restating it.
36-
import { materializeDeclaredFields, readInternalColumn } from '@objectstack/objectql/core';
36+
import { declaredColumnSet, materializeDeclaredFields, readInternalColumn } from '@objectstack/objectql/core';
3737
// [#14935] The ONE reading of `publicSharing.enabled`, imported from the
3838
// package that DECLARES the key rather than spelled out again here. This file
3939
// exported its own copy (#14637) and `@objectstack/runtime` kept a documented
@@ -344,6 +344,41 @@ function assertEligible(
344344
}
345345
}
346346

347+
/**
348+
* [#29130] The SHAPE of the mint's `redactFields`: absent, or an array of
349+
* strings. A request-shape refusal (`400 VALIDATION_FAILED`), asked with the
350+
* mint's other request-shape checks, before any record is read. Returns the
351+
* list (`[]` when none was sent).
352+
*/
353+
function redactFieldsShape(raw: unknown): string[] {
354+
if (raw === undefined || raw === null) return [];
355+
if (!Array.isArray(raw) || raw.some((name) => typeof name !== 'string')) {
356+
throw makeError(400, 'VALIDATION_FAILED', 'redactFields must be an array of field names');
357+
}
358+
return raw;
359+
}
360+
361+
/**
362+
* [#29130] Every `redactFields` entry must name a column a read of `object`
363+
* serves (`declaredColumnSet`). Refused with the door's request-shape family
364+
* (`400 VALIDATION_FAILED`), naming every unknown entry, so nothing is minted.
365+
* A schema with no checkable field map gives no verdict.
366+
*/
367+
function assertRedactFieldColumns(names: readonly string[], schema: unknown, object: string): void {
368+
if (names.length === 0) return;
369+
const columns = declaredColumnSet(schema as { fields?: unknown } | undefined);
370+
if (!columns) return;
371+
const unknown = Array.from(new Set(names.filter((name) => !columns.has(name))));
372+
if (unknown.length > 0) {
373+
throw makeError(
374+
400,
375+
'VALIDATION_FAILED',
376+
`redactFields names ${unknown.length === 1 ? 'a field' : 'fields'} object '${object}' does not have: ` +
377+
`${unknown.join(', ')} — a redaction that matches no field hides nothing, so no link was issued`,
378+
);
379+
}
380+
}
381+
347382
/**
348383
* One line out of a CEL fault. The engine appends a source excerpt and a caret
349384
* line, which is right for a log and wrong for an API message.
@@ -565,6 +600,11 @@ export class ShareLinkService implements IShareLinkService {
565600
throw makeError(400, 'VALIDATION_FAILED', 'emailAllowlist is required when audience=email');
566601
}
567602

603+
// [#29130] The shape of `redactFields` is a request-shape check like the two
604+
// above; whether each name is a column of the object is judged after the
605+
// authority check below.
606+
const redactFields = redactFieldsShape(input.redactFields);
607+
568608
// Confirm the target record exists AND — for an HTTP caller — that the
569609
// caller holds authority over it. [Finding-2] Reading under the caller's
570610
// own context (positions/permissions/RLS) means a client can no longer
@@ -639,6 +679,19 @@ export class ShareLinkService implements IShareLinkService {
639679
: makeError(403, 'FORBIDDEN', `Not permitted to share ${input.object}/${input.recordId}`);
640680
}
641681

682+
// [#29130] Every `redactFields` entry must name a column of the object, or
683+
// nothing is minted. A name that matches no column strips nothing at
684+
// resolve, while the landing page still lists it as hidden — so one typo
685+
// served the field the minter meant to keep back, under a notice saying it
686+
// was withheld. The column set is the one a read SERVES (`declaredColumnSet`:
687+
// the field map plus the platform-provisioned columns), so a name is
688+
// accepted exactly when there is something for it to hide. Judged AFTER the
689+
// authority check, so a caller with no authority over the record learns
690+
// nothing about the object's columns from the refusal. A schema with no
691+
// checkable field map gives no verdict (the shared rule: a door that cannot
692+
// see the field map does not invent one), and the entries are kept as sent.
693+
assertRedactFieldColumns(redactFields, schema, input.object);
694+
642695
// [#7861] The declared eligibility gate. Placed AFTER the authority check
643696
// (it needs the record, and a caller with no authority over the row must
644697
// not learn anything about its contents from the refusal) and BEFORE the
@@ -667,7 +720,7 @@ export class ShareLinkService implements IShareLinkService {
667720
? input.emailAllowlist.map((e) => e.trim().toLowerCase()).filter(Boolean)
668721
: null,
669722
password_hash: passwordHash,
670-
redact_fields: input.redactFields && input.redactFields.length > 0 ? input.redactFields : null,
723+
redact_fields: redactFields.length > 0 ? redactFields : null,
671724
label: input.label ?? null,
672725
revoked_at: null,
673726
created_by: context.userId ?? null,

‎scripts/engine-double-contract.pinned.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4306,6 +4306,16 @@
43064306
"verb": "update",
43074307
"pinned": 1
43084308
},
4309+
{
4310+
"file": "packages/plugins/plugin-sharing/src/share-link-redact-fields.test.ts",
4311+
"verb": "delete",
4312+
"pinned": 1
4313+
},
4314+
{
4315+
"file": "packages/plugins/plugin-sharing/src/share-link-redact-fields.test.ts",
4316+
"verb": "update",
4317+
"pinned": 1
4318+
},
43094319
{
43104320
"file": "packages/plugins/plugin-sharing/src/sharing-rule-provenance.test.ts",
43114321
"verb": "delete",

0 commit comments

Comments
 (0)