Skip to content

Commit 7e4af20

Browse files
fix(storage)!: a record write naming a file its caller may not read is refused at the field (#26237)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XSuaEom6oxsAY7KX6uf4FcT)_ Fixes #26043 Clause-②: no (narrowing) Metadata-protocol: none ## What this changes A record write that names a file its caller may not read used to save, keeping the id as written (#25951's "quiet answer"). The field then held a file it did not own, and every reader got an unusable value. That is the state ADR-0104's `verify-references` counts as blocking. Now the write is refused at the field. - **The refusal** (`file-reference-lifecycle.ts`). Copy-on-claim judges every file a write names before it copies any, through the one lookup both halves of the claim path already share (`findReferenceableFile`). A file the caller may not read, or an id the acting organization cannot see, refuses its field. The write fails before the driver runs with one `400 VALIDATION_FAILED` whose `fields[]` has one ADR-0114 `invalid_value` entry per refused field. The entry names the field and its label only, never the file or the record that holds it. The two cases answer with the same body, so the write door cannot tell "may not read" from "does not exist in this organization". - **One envelope, one recognition.** The refusal is built by the same helper as #26036's anonymous-write refusal, which is the form door's envelope (#25682). The form-submission door writes through the engine, so a submission naming such a file gets this answer too. No second copy of the judgement exists. - **Order.** The read check runs first. A field that would pass is never copied when another field is refused, and constraint verdicts (`accept` / `maxSize`) never answer ahead of the read check. - **Unchanged.** System writes and context-less programmatic writes keep their old behaviour, including keeping an id that names no file. A kernel with no read verdict bound asks nothing. An authorization-store outage still fails the write with its declared envelope. PR #26144's rule (a released file is judged by its former record) and PR #26036's rule (an anonymous write carries no file) are untouched. - **Signed-link lifetime** (second commit, `storage-routes.ts`). A file governed by a record's rule now gets the record-governed link lifetime at the download door. The door and the read verdict read the same recognition (`releasingRecordOf` in `metadata-store.ts`). Who gets a link does not change. - **A stale dogfood fixture** (third commit). The field-zoo HTTP round-trip wrote media ids that name no file, and so relied on the quiet answer. The first full dogfood run on this branch turned it red. It now uploads one real file per media field and asserts the read form carries that id. The matrix keeps its stored-shape stand-ins for the value-shape test. - **ADR-0104.** The 2026-10-26 addendum's "quiet answer" item becomes "the field refusal", with an amendment line in the status block. It changes D3's text, so this PR is Tier H and stays draft for the maintainer. **Envelope spelling.** The order and claim wrote `400 VALIDATION_ERROR`. They also say to match the #25682 form door and to carry an `invalid_value` entry. That door answers `400 VALIDATION_FAILED` with `fields[]`, and in this codebase `VALIDATION_ERROR` is the envelope with no `fields[]` (ADR-0104's 2026-10-19 note on D2). So this PR uses `VALIDATION_FAILED`, the form door's envelope, and says so here. ## Declarations - `Clause-②: no (narrowing)`, measured on this diff: a record write naming a file its caller may not read, or an id no file of the organization has, used to save and is now refused. Nothing that was refused becomes accepted. - `Metadata-protocol: none`: nothing a metadata author writes changes. - One `patch` changeset on `@objectstack/service-storage`, `fix(storage)!:`, with the BREAKING section and the ADR-0087 disposition `not-required (no-migration-prescription)`. ## Tests HEAD `c075544b6`. - **Red first** on base `3993793dc` (the new pins, same files): 8 failed, 64 passed. That is the 7 refusal pins in `file-reference-read-verdict.integration.test.ts` and the 1 link-lifetime pin in `storage-routes.test.ts`. The controls (a system write still copies, a context-less write still keeps an unknown id) were green on base. - `file-reference-read-verdict.integration.test.ts` (real `ObjectQL` + sqlite, `StorageServicePlugin` booted as shipped). It pins the whole refusal body with `toEqual` for a file owned by a record the writer cannot read, an attachments-scope file, a delegate refusal, an id that names no file, an update (the record keeps what it held), a multi-field write (nothing copied for the passing field), and a judgement fault. Each pin also checks that no record and no `sys_file` row was written. - `file-reference-lifecycle.test.ts`: the #25692 unit pins move from "kept" to "refused". New pins: an unknown id gets the same body as an unreadable file, and every refused field is named in one answer. - `file-reference-release-read-rule.integration.test.ts` (#26046): a write by a member refused the record is now `refused` instead of `kept`, under both collection settings. - **Ablation** through `scripts/ablation-replace.mjs`. Disabling the refusal throw turns 27 tests red across the three files (blob `a9069ab6eba1` to `37aa77f91e7a`). Reverting the lifetime line turns the link-lifetime pin red (blob `95d899032bca` to `95bfe351ee89`). Both were restored, and the blob equals HEAD with `git diff HEAD` empty. - `pnpm --filter @objectstack/service-storage test`: 66 files, 1226 tests passed. `typecheck` (including `check:test-typecheck`) passed. eslint `--no-inline-config` on `packages/services/service-storage/src`: clean. - Full dogfood battery at HEAD: `pnpm --filter @objectstack/dogfood test` under the verify lock, VERDICT command-exit 0, Test Files 296 passed | 3 skipped (299), Tests 2523 passed | 18 skipped (2541). The first run, one commit earlier, was red in one file (`field-zoo-roundtrip.dogfood.test.ts`, the stale fixture above). After the fix that file and `field-zoo-value-shape.test.ts` pass 91/91, and the rerun at HEAD is the one quoted here. ## Gates Local subset at HEAD `c075544b6`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 85 families from this diff. All 85 ran with exit 0, each code captured before any pipe, and `--ran` reconciles 85 run, 0 NOT MEASURED. One commit earlier, `check-comment-mask-corpus` hit a 300 s local timeout on the loaded box and `check-plugin-teardown-shape --self-test` answered exit 3 (shallow clone). Both were 0 on rerun, the latter after fetching its control commit, and both are 0 in the HEAD run. `pnpm --filter @objectstack/service-storage test` and `typecheck` ran one commit earlier, at `66398e79`. The last commit touches only two dogfood test files. Every other family is CI's, and this PR's check-runs are the record. ## Acceptance notes - **A record that already holds such an id.** An update that sends back an id its caller may not read, or one that names no file, is refused like any other write. This includes the unchanged value of a record that got it under the quiet answer. The answer names the field, so the remedy is to clear it or name a readable file. Rows written under the quiet answer are the ones `verify-references` already reports. - The second card the maintainer named in 「先改措辞,再批,另开两张卡」 is not addressed here. ## 维护者速读(草稿) **改了什么**:记录写入时,如果文件字段里填的是调用者无权读取的文件(或本组织里根本不存在的文件 id),现在直接在该字段上拒绝:返回 `400 VALIDATION_FAILED`,只点名字段,不暴露文件或其所属记录。以前(#25951)是"静默保留 id、保存成功"。另一个提交让受记录规则约束的文件,下载签名链接用记录级的短有效期。ADR-0104 2026-10-26 补记里"quiet answer"一条改写为"field refusal"。 **为什么改**:静默保留会让用户看到"保存成功但文件打不开",而这正是 ADR-0104 第 5 步 `verify-references` 判为阻断的状态。同类平台的做法也是直接拒绝挂接。这是你「先改措辞,再批,另开两张卡」里的第一张卡。 **风险与代价(含回滚)**:这是收窄:以前能保存的这类写入现在被拒。系统写入和无上下文的程序写入不变,鉴权存储故障时仍按原信封失败。已经按旧规则存下悬空 id 的记录,再次带着该值更新时会被拒,需清空或换成可读文件。回滚即 revert 本 PR。 **席位意见**: **你要做的**:审阅 ADR 那一条与修复,批准后合并(Tier H,保持 draft 等你)。 --- _Generated by [Claude Code](https://claude.ai/code/session_01EGkwyoUjm7LjHY7uBXFSgv)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01EGkwyoUjm7LjHY7uBXFSgv --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e761a2b commit 7e4af20

12 files changed

Lines changed: 427 additions & 106 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(storage)!: a record write naming a file its caller may not read is refused at the field
6+
7+
A record write that names a file its caller may not read is now refused at that field, instead of saving the id as written. The answer is one `400 VALIDATION_FAILED` whose `fields[]` names each such field with the ADR-0114 code `invalid_value`, the same envelope the public form door gives an anonymous submission that carries a file. It names the field only. An id that names no file of the acting organization gets the same answer, so the write cannot tell the two apart. Nothing is claimed, copied or written. System writes and writes with no caller context reference files as before, and an authorization-store outage still fails the write with its declared envelope.
8+
9+
A file governed by a record's rule also gets the record-governed link lifetime at the download door.
10+
11+
Clause-②: no (narrowing) — a record write that names a file its caller may not read, or an id no file of the organization has, was saved and is now refused. Nothing that was refused becomes accepted.
12+
13+
Metadata-protocol: none (a write-time refusal on file fields; nothing a metadata author writes changes)
14+
15+
<!-- adr-0087: not-required (no-migration-prescription) No metadata author's surface moves: no spec key or authorable spelling is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which record writes the file-reference lifecycle accepts: a write naming a file its caller may not read is refused at the field. The remedy is naming a file the caller may read, or leaving the field empty, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
16+
17+
**BREAKING** for a caller that writes a file id it may not read, shipped as `patch` because the metadata protocol does not move.
18+
19+
- **Before.** The write saved, and the field kept an id its readers could not use.
20+
- **Now.** The write is refused with `400 VALIDATION_FAILED`, naming the field with `invalid_value`.
21+
- **Fix.** Name a file the caller may read (upload it, or choose one they can open), or leave the field empty.

‎docs/adr/0104-field-runtime-value-shape-contract.md‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
kept as written and never claimed or copied, the answer #25682 gave across
2929
organizations. This qualifies the 2026-07-27 addendum's worst-case row for
3030
exclusive ownership. See the 2026-10-26 addendum.
31+
· **Amended** (2026-10-26, #26043) — that kept id is replaced by a
32+
refusal at the field: a write naming a file its caller may not read, or an
33+
id the acting organization cannot see, fails with one `400
34+
VALIDATION_FAILED` naming each such field with the ADR-0114 code
35+
`invalid_value`, and nothing is written. See "The field refusal" in the
36+
2026-10-26 addendum.
3137
- **Date**: 2026-07-22
3238
- **Issue**: design follow-up generalizing #3405 / #3406 (inline lookup param
3339
silently stripped); relates #3407 (silently dropped writes), #1878 / #1891
@@ -1464,17 +1470,20 @@ exists in the acting organization. One file had two access rules.
14641470
collected (#26046); a member of the organization for a file no record has
14651471
owned; a reader of any parent for an attachments-scope file; anyone for a
14661472
`public_read` file.
1467-
- **The quiet answer.** At the write door, a file the writer may not read is
1468-
answered as one the organization-scoped lookup cannot see: the id is kept
1469-
as written, nothing is claimed or copied, and constraint verdicts do not run
1470-
ahead of the read check. #25682 gave this answer across organizations; this
1471-
addendum states it within one. Unless the record already owned the file,
1472-
the kept id gives no reader anything through it: a reader refused
1473-
`sys_file` read gets the refused marker (`metadataRefused`), and the
1474-
download door judges the file by its own rule, never by this record. A slot
1475-
holding a file it does not own is a state that step 5's
1476-
`verify-references` classifies as blocking. #26043 tracks replacing this
1477-
answer with a field-level refusal.
1473+
- **The field refusal.** *(Amended 2026-10-26, #26043. It replaces the quiet
1474+
answer this item first stated, which kept the id as written and so left a
1475+
slot holding a file it does not own, the state step 5's
1476+
`verify-references` classifies as blocking.)* At the write door, a write
1477+
naming a file its caller may not read is refused at the field, before the
1478+
driver runs: one `400 VALIDATION_FAILED` whose `fields[]` names each such
1479+
field with the ADR-0114 code `invalid_value`, the envelope the form door
1480+
gives an anonymous submission that carries a file (#25682). Nothing is
1481+
claimed, copied or written, and constraint verdicts do not run ahead of the
1482+
read check. An id the organization-scoped lookup cannot see gets the same
1483+
answer, and the answer names the field only, never the file or the record
1484+
that holds it, so the write door cannot tell "may not read" from "does not
1485+
exist in this organization". A write with no caller (below) keeps such an
1486+
id as written, as before.
14781487
- **A write's own copy** is claimed by the same write without being asked
14791488
again.
14801489
- **Outage.** An authorization-store outage fails the write with its declared

‎packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2020
import { SECRET_MASK } from '@objectstack/objectql';
21+
import { FILE_REFERENCE_TYPES } from '@objectstack/spec/data';
2122
import { type VerifyStack } from '@objectstack/verify';
2223
import { bootShowcase } from './showcase-boot.js';
2324

@@ -27,6 +28,8 @@ describe('dogfood: field-type capability matrix round-trips over HTTP (#2004)',
2728
let record: Record<string, unknown>;
2829
/** Resolved reference ids, keyed by field — the assertions compare to these. */
2930
const referenceIds: Record<string, string> = {};
31+
/** Uploaded `sys_file` ids, keyed by media field — the assertions compare to these. */
32+
const fileIds: Record<string, string> = {};
3033

3134
beforeAll(async () => {
3235
stack = await bootShowcase();
@@ -54,13 +57,38 @@ describe('dogfood: field-type capability matrix round-trips over HTTP (#2004)',
5457
referenceIds[target.field] = id as string;
5558
}
5659

60+
// [#26043] Upload a REAL file for each media field. A write naming an id
61+
// that is no file of the organization is now refused at the field, as one
62+
// naming a file the writer may not read is, so the matrix's stand-in ids
63+
// would be refused. What this file proves for the media family is
64+
// unchanged: the stored id round-trips as the same file.
65+
for (const c of MATRIX) {
66+
if (!FILE_REFERENCE_TYPES.has(c.type) || !('write' in c.check) || c.check.write === undefined) continue;
67+
const bytes = `zoo ${c.field}`;
68+
const presign = await stack.apiAs(token, 'POST', '/storage/upload/presigned', {
69+
filename: `${c.field}.txt`, mimeType: 'text/plain', size: bytes.length,
70+
});
71+
expect(presign.status, `presign for ${c.field}`).toBe(200);
72+
const { data } = (await presign.json()) as { data: { uploadUrl: string; fileId: string; headers?: Record<string, string> } };
73+
const put = await stack.raw(String(data.uploadUrl).replace(/^https?:\/\/[^/]+/, ''), {
74+
method: 'PUT', headers: data.headers ?? { 'content-type': 'text/plain' }, body: bytes,
75+
});
76+
expect(put.status, `upload for ${c.field}`).toBeLessThan(300);
77+
const complete = await stack.apiAs(token, 'POST', '/storage/upload/complete', { fileId: data.fileId });
78+
expect(complete.status, `complete for ${c.field}`).toBe(200);
79+
fileIds[c.field] = String(data.fileId);
80+
}
81+
5782
// Build the create body from every entry that carries a `write` value
5883
// (+ required name). `present`/`computed` server-owned fields are skipped.
59-
// A reference placeholder resolves to the id seeded above.
84+
// A reference placeholder resolves to the id seeded above, and a media
85+
// field to the file uploaded above.
6086
const body: Record<string, unknown> = { name: 'zoo-roundtrip' };
6187
for (const c of MATRIX) {
6288
if (!('write' in c.check) || c.check.write === undefined) continue;
63-
body[c.field] = c.field in referenceIds ? referenceIds[c.field] : c.check.write;
89+
body[c.field] = c.field in referenceIds
90+
? referenceIds[c.field]
91+
: c.field in fileIds ? fileIds[c.field] : c.check.write;
6492
}
6593

6694
const created = await stack.apiAs(token, 'POST', '/data/showcase_field_zoo', body);
@@ -88,6 +116,13 @@ describe('dogfood: field-type capability matrix round-trips over HTTP (#2004)',
88116
const actual = record[c.field];
89117
switch (c.check.kind) {
90118
case 'equal':
119+
// A media field reads back in its expanded form, the file object
120+
// the stored id resolves to (ADR-0104 D3 wave 2), so its `id` is
121+
// the stored id.
122+
if (c.field in fileIds) {
123+
expect(actual).toMatchObject({ id: fileIds[c.field] });
124+
break;
125+
}
91126
expect(actual).toEqual(
92127
c.field in referenceIds ? referenceIds[c.field] : c.check.write,
93128
);

‎packages/qa/dogfood/test/field-zoo.matrix.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,10 @@ export const MATRIX: FieldCase[] = [
138138
{ field: 'f_record', type: 'record', check: { kind: 'equal', write: { home: '+1', work: '+2' } } },
139139
// media — the STORED form is an opaque sys_file id (ADR-0104 D3 wave 2); the
140140
// inline `{url, …}` blob is the expanded READ form, derived by the resolver
141-
// rather than written. These ids match no sys_file row here, which is the
142-
// point: the round-trip must return the stored id verbatim when there is
143-
// nothing to expand it into.
141+
// rather than written. The `write` values are stand-ins in the stored shape
142+
// (the value-shape test parses them): [#26043] a write naming an id that is
143+
// no file of the organization is refused, so the HTTP suite uploads a real
144+
// file for each and asserts the read form carries its id.
144145
{ field: 'f_video', type: 'video', check: { kind: 'equal', write: 'file_zoo_video' } },
145146
{ field: 'f_audio', type: 'audio', check: { kind: 'equal', write: 'file_zoo_audio' } },
146147
{ field: 'f_composite', type: 'composite', check: { kind: 'equal', write: { label: 'x', n: 1 } } },

‎packages/services/service-storage/src/file-reference-lifecycle.test.ts‎

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,9 @@ describe('File Reference Ownership (ADR-0104 D3 wave 2)', () => {
10811081
* file ids; `file-reference-read-verdict.integration.test.ts` pins the real
10821082
* binding against the door. Each refusal pin below is ablated in the PR that
10831083
* introduced it.
1084+
*
1085+
* [#26043] A file the verdict refuses refuses the write at the field, before
1086+
* the driver runs; an id that names no file gets the same answer.
10841087
*/
10851088
describe('a write references only a file its caller may read', () => {
10861089
const CALLER = { userId: 'u_writer', tenantId: 'org_1' };
@@ -1146,34 +1149,45 @@ describe('a write references only a file its caller may read', () => {
11461149
const ownedByP1 = () => file({ ref_object: 'product', ref_id: 'p1', ref_field: 'image' });
11471150
const sysFileWrites = (engine: Engine) =>
11481151
engine.calls.filter((c) => (c.op === 'update' || c.op === 'insert') && c.object === 'sys_file');
1152+
const refusedBy = (p: Promise<unknown>) => p.then(() => null, (e) => e);
1153+
1154+
/** [#26043] The field refusal, as the HTTP boundary answers it: one `invalid_value` entry per refused field. */
1155+
const expectFieldRefused = (err: unknown, fields: string[]) => {
1156+
const answered = resolveThrownHttpError(err);
1157+
expect({ status: answered.status, code: answered.code }).toEqual({ status: 400, code: 'VALIDATION_FAILED' });
1158+
expect((answered.details?.fields as any[]).map((f) => [f.field, f.code]))
1159+
.toEqual(fields.map((f) => [f, 'invalid_value']));
1160+
};
11491161

11501162
describe('a file another record owns', () => {
1151-
it('is not copied for a caller the verdict refuses: the value is kept as written', async () => {
1163+
it('is refused at the field for a caller the verdict refuses, and not copied', async () => {
11521164
const engine = fakeEngine({ files: [ownedByP1()], records: { product: [{ id: 'p1', image: 'file_a' }] } });
11531165
const { authorizeRead } = verdict([]);
11541166
const { storage } = installWith(engine, authorizeRead);
11551167

1156-
const row = await insertAs(engine, 'product', { image: 'file_a' }, 'p2');
1168+
const err = await refusedBy(insertAs(engine, 'product', { image: 'file_a' }, 'p2'));
11571169

1158-
expect(row.image).toBe('file_a');
1170+
expectFieldRefused(err, ['image']);
1171+
expect((engine.tables.product ?? []).map((r) => r.id)).toEqual(['p1']);
11591172
expect(storage.download).not.toHaveBeenCalled();
11601173
expect(storage.upload).not.toHaveBeenCalled();
11611174
expect(engine.tables.sys_file).toHaveLength(1);
11621175
expect(engine.tables.sys_file[0]).toMatchObject({ id: 'file_a', ref_object: 'product', ref_id: 'p1', ref_field: 'image' });
11631176
expect(sysFileWrites(engine)).toEqual([]);
11641177
});
11651178

1166-
it('is not copied by an update for a caller the verdict refuses', async () => {
1179+
it('is refused at the field by an update for a caller the verdict refuses, and not copied', async () => {
11671180
const engine = fakeEngine({
11681181
files: [ownedByP1()],
11691182
records: { product: [{ id: 'p1', image: 'file_a' }, { id: 'p2', image: null }] },
11701183
});
11711184
const { authorizeRead } = verdict([]);
11721185
const { storage } = installWith(engine, authorizeRead);
11731186

1174-
const row = await updateAs(engine, 'product', 'p2', { image: 'file_a' });
1187+
const err = await refusedBy(updateAs(engine, 'product', 'p2', { image: 'file_a' }));
11751188

1176-
expect(row?.image).toBe('file_a');
1189+
expectFieldRefused(err, ['image']);
1190+
expect(engine.tables.product.find((r) => r.id === 'p2')?.image).toBeNull();
11771191
expect(storage.download).not.toHaveBeenCalled();
11781192
expect(engine.tables.sys_file).toHaveLength(1);
11791193
expect(engine.tables.sys_file[0]).toMatchObject({ ref_id: 'p1' });
@@ -1199,25 +1213,28 @@ describe('a write references only a file its caller may read', () => {
11991213
});
12001214

12011215
describe('a file no record owns', () => {
1202-
it('is not claimed for a caller the verdict refuses', async () => {
1216+
it('is refused at the field for a caller the verdict refuses, and not claimed', async () => {
12031217
const engine = fakeEngine({ files: [file()] });
12041218
const { authorizeRead } = verdict([]);
12051219
installWith(engine, authorizeRead);
12061220

1207-
const row = await insertAs(engine, 'product', { image: 'file_a' }, 'p1');
1221+
const err = await refusedBy(insertAs(engine, 'product', { image: 'file_a' }, 'p1'));
12081222

1209-
expect(row.image).toBe('file_a');
1223+
expectFieldRefused(err, ['image']);
1224+
expect(engine.tables.product ?? []).toEqual([]);
12101225
expect(engine.tables.sys_file[0].ref_id ?? null).toBeNull();
12111226
expect(sysFileWrites(engine)).toEqual([]);
12121227
});
12131228

1214-
it('is not claimed by an update for a caller the verdict refuses', async () => {
1229+
it('is refused at the field by an update for a caller the verdict refuses, and not claimed', async () => {
12151230
const engine = fakeEngine({ files: [file()], records: { product: [{ id: 'p1', image: null }] } });
12161231
const { authorizeRead } = verdict([]);
12171232
installWith(engine, authorizeRead);
12181233

1219-
await updateAs(engine, 'product', 'p1', { image: 'file_a' });
1234+
const err = await refusedBy(updateAs(engine, 'product', 'p1', { image: 'file_a' }));
12201235

1236+
expectFieldRefused(err, ['image']);
1237+
expect(engine.tables.product[0].image).toBeNull();
12211238
expect(engine.tables.sys_file[0].ref_id ?? null).toBeNull();
12221239
expect(sysFileWrites(engine)).toEqual([]);
12231240
});
@@ -1227,7 +1244,7 @@ describe('a write references only a file its caller may read', () => {
12271244
const { authorizeRead } = verdict([]);
12281245
installWith(engine, authorizeRead);
12291246

1230-
await insertAs(engine, 'product', { image: 'file_a' }, 'p1');
1247+
expectFieldRefused(await refusedBy(insertAs(engine, 'product', { image: 'file_a' }, 'p1')), ['image']);
12311248

12321249
expect(engine.tables.sys_file[0]).toMatchObject({ status: 'deleted', deleted_at: '2026-01-01T00:00:00.000Z' });
12331250
expect(engine.tables.sys_file[0].ref_id ?? null).toBeNull();
@@ -1243,21 +1260,55 @@ describe('a write references only a file its caller may read', () => {
12431260
expect(engine.tables.sys_file[0]).toMatchObject({ ref_object: 'product', ref_id: 'p1', ref_field: 'image' });
12441261
});
12451262

1246-
it('each id of a multiple:true field is judged on its own', async () => {
1263+
it('one id of a multiple:true field the caller may not read refuses the field, and none of its ids is taken', async () => {
12471264
const engine = fakeEngine({ files: [file(), file({ id: 'file_b', key: 'user/file_b.png' })] });
12481265
const { authorizeRead } = verdict(['file_b']);
12491266
installWith(engine, authorizeRead);
12501267

1251-
const row = await insertAs(engine, 'product', { gallery: ['file_a', 'file_b'] }, 'p1');
1268+
expectFieldRefused(await refusedBy(insertAs(engine, 'product', { gallery: ['file_b', 'file_a'] }, 'p1')), ['gallery']);
12521269

1253-
expect(row.gallery).toEqual(['file_a', 'file_b']);
12541270
expect(engine.tables.sys_file.map((f) => [f.id, f.ref_id ?? null])).toEqual([
12551271
['file_a', null],
1256-
['file_b', 'p1'],
1272+
['file_b', null],
12571273
]);
12581274
});
12591275
});
12601276

1277+
it('[#26043] an id that names no file gets the answer a file the caller may not read gets', async () => {
1278+
const engine = fakeEngine({ files: [file()] });
1279+
const { authorizeRead } = verdict([]);
1280+
installWith(engine, authorizeRead);
1281+
1282+
const unreadable = await refusedBy(insertAs(engine, 'product', { image: 'file_a' }, 'p1'));
1283+
const nowhere = await refusedBy(insertAs(engine, 'product', { image: 'file_nowhere' }, 'p2'));
1284+
1285+
expectFieldRefused(nowhere, ['image']);
1286+
expect(resolveThrownHttpError(nowhere)).toEqual(resolveThrownHttpError(unreadable));
1287+
expect(engine.tables.product ?? []).toEqual([]);
1288+
});
1289+
1290+
it('[#26043] every refused field is named, in one answer', async () => {
1291+
const engine = fakeEngine({ files: [file()] });
1292+
const { authorizeRead } = verdict([]);
1293+
installWith(engine, authorizeRead);
1294+
1295+
const err = await refusedBy(insertAs(engine, 'product', { image: 'file_a', gallery: ['file_nowhere'] }, 'p1'));
1296+
1297+
expectFieldRefused(err, ['image', 'gallery']);
1298+
});
1299+
1300+
it('[#26043] a refused field fails the write before another field\'s file is copied', async () => {
1301+
const engine = fakeEngine({ files: [ownedByP1()], records: { product: [{ id: 'p1', image: 'file_a' }] } });
1302+
const { authorizeRead } = verdict(['file_a']);
1303+
const { storage } = installWith(engine, authorizeRead);
1304+
1305+
const err = await refusedBy(insertAs(engine, 'product', { image: 'file_a', gallery: ['file_nowhere'] }, 'p2'));
1306+
1307+
expectFieldRefused(err, ['gallery']);
1308+
expect(storage.download).not.toHaveBeenCalled();
1309+
expect(engine.tables.sys_file).toHaveLength(1);
1310+
});
1311+
12611312
it('constraint verdicts do not run ahead of the read check', async () => {
12621313
const engine = fakeEngine({
12631314
files: [file({ mime_type: 'application/pdf', size: 10_000_000 })],
@@ -1269,9 +1320,10 @@ describe('a write references only a file its caller may read', () => {
12691320
const { authorizeRead } = verdict([]);
12701321
installWith(engine, authorizeRead);
12711322

1272-
const row = await insertAs(engine, 'product', { image: 'file_a' }, 'p1');
1323+
const err = await refusedBy(insertAs(engine, 'product', { image: 'file_a' }, 'p1'));
12731324

1274-
expect(row.image).toBe('file_a');
1325+
// The read refusal, not the constraint's `ERR_FILE_CONSTRAINT`.
1326+
expectFieldRefused(err, ['image']);
12751327
expect(engine.tables.sys_file[0].ref_id ?? null).toBeNull();
12761328
});
12771329

@@ -1298,7 +1350,7 @@ describe('a write references only a file its caller may read', () => {
12981350
installWith(engine, authorizeRead);
12991351
const context = { ...CALLER, permissions: ['member_default'], __operationPrivate: 'dropped' };
13001352

1301-
await insertAs(engine, 'product', { image: 'file_a' }, 'p2', { session, context });
1353+
await refusedBy(insertAs(engine, 'product', { image: 'file_a' }, 'p2', { session, context }));
13021354

13031355
expect(asked.length).toBeGreaterThan(0);
13041356
for (const q of asked) {

0 commit comments

Comments
 (0)