Skip to content

Commit b4199c9

Browse files
fix(storage)!: an anonymous write carries no file, at every anonymous door (#26036)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X5Y7YBfgeAvejMYwsPJZwdM)_ Fixes #25694 Clause-②: no (narrowing) Metadata-protocol: none ## What this changes Ruling C on #25572 ("an anonymous submission carries no file", landed at the public form door by #25682) now holds at every anonymous write door of ADR-0138 D2, as the maintainer chose for this card (「立卡,先实测再修」). - **The rule, in one place** (`@objectstack/service-storage`, the file-reference lifecycle). A record write whose caller has no user may not put a value in a file field (`FILE_REFERENCE_TYPES`). It is refused in the before-write hook, before the driver runs and before any file is looked up, so a refused write leaves nothing behind. - **The answer is the form door's.** `400 VALIDATION_FAILED`, one `fields[]` entry per refused field with the ADR-0114 field code `invalid_value` and the field's label, built with the same `validationFailure` and judged with the same emptiness rule (`isEmptyForRequired`). No error code is added. - **Unchanged.** An empty or absent file field is written as before, so an anonymous update may still leave a file field out or clear it under the grants a deployment binds. Signed-in callers, system writes and context-less programmatic writes are not judged. "Has no user" is the caller the #25692 read verdict already asks about, so the two rules read one caller. - **Per door class.** Classes 2, 3 and 6 are reads. Class 1 keeps its own judgement at the door (#25682). Class 4 is closed by this change. Class 5 was measured closed on `main` before this change, and the rule above holds for it as well. The measurements are with the server seat. Why the lifecycle and not each door: one guard closes the whole class, including a door added later, and it sits on the path the claim file surface names. The class-4 door itself (`@objectstack/runtime`) is not edited. ## Declarations - `Clause-②: no (narrowing)`, measured on this diff: an anonymous write that carried a file value is refused. Nothing refused becomes accepted. The claim wrote `Clause-②: no` and named this arm as expected. - `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)`. ## Three existing fixtures changed, with their subjects kept `sys-file-copy-backfill-organization-stamping.test.ts` (three cases) and `backfill-sys-file-public-scope.test.ts` drove a copy-on-claim write with a caller that carried an organization and no user. Their subject is organization stamping and scope on the copy, not the caller, and a user-less caller is now the anonymous case. Each now names a user. Nothing they assert changed. ## Tests All on head `b2f90ab2b5`. - `pnpm --filter @objectstack/service-storage test`: Test Files 65 passed (65), Tests 1197 passed (1197). `typecheck` (including `check:test-typecheck`): exit 0. - Red first: the pins were committed ahead of the fix (`cb5bb350`, `be589179`) and failed on the unchanged lifecycle; the fix is `1171d6c2`. The ablation below is the same proof on the final tree. - `file-reference-lifecycle.test.ts`, new describe, 11 cases: refusal on insert for four value shapes, refusal on update, several fields named in one envelope by label or name, and five controls (empty or absent values, an anonymous update that leaves out or clears a file field, a signed-in caller, a system and a context-less write, an object with no file field). Each refusal asserts `code` and the `fields[]` entry, and that nothing was written, no file was looked up and no bytes moved. - `file-reference-read-verdict.integration.test.ts`, new describe, 7 cases on a real `ObjectQL` over `SqlDriver` with `StorageServicePlugin` booted: insert and update refused for the guest envelope across the file arms the read verdict distinguishes, nothing written and no ownership moved, plus a guest write with no file and a signed-in control. - `eslint --no-inline-config` with the root config over `packages/services/service-storage/src`: exit 0. The repo-wide `pnpm lint` is CI's run. - Full dogfood battery (owed for the `(narrowing)` arm), `pnpm --filter @objectstack/dogfood test` under the verify lock, no shard, no path, on `b2f90ab2b5`: Test Files 295 passed, 1 failed, 3 skipped (299); Tests 2522 passed, 1 failed, 18 skipped (2541). The one failure is `authz-probe-blind-spot.test.ts`, the `packages/runtime/src/domains/i18n.ts` control count (expects 3 `handleI18nRequest`, finds 4). It is red on `main` independently of this diff: neither that test nor that file differs between this branch's base `05f7758f` and current `main` `39a78b15`, and this diff touches neither package. ## Proof Through `node scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed, restored blob equals HEAD, `git diff HEAD` empty). With the anonymous-caller test replaced by an always-pass, 12 pins redden: the 6 unit refusal pins, the 5 integration refusal pins, and the integration signed-in control, which follows from the guest pins having left the file claimed. The 5 unit controls and the guest no-file control stay green. Both suites import the lifecycle through relative `src` paths, so no `dist/` is involved. ## Gates - Derived on `b2f90ab2b5` by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; the change set is from the merge base): 71 commands, each exit code captured before any pipe. 70 exited 0 in sequence. `pnpm check:dual-build-cjs-loads` first exited 3 (seven packages outside the dogfood closure had no `dist/`, nothing measured), and after building those seven its rerun exited 0. `--ran`: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. - CI check-runs had not concluded when this PR opened; convergence is read by the seat. ## Acceptance notes - The rule lives where the file-reference hooks are installed, so it binds wherever the storage service is composed (it is on the always-on slate `serve` mounts). A kernel composed without the storage service has no platform files for an anonymous write to carry. - The class-4 refusal message is inline English beside the form door's, from the same pattern. No catalog key is added. --- _Generated by [Claude Code](https://claude.ai/code/session_01VG2Ug3VQqDPcepHcmoD9m1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c1ae4a4 commit b4199c9

6 files changed

Lines changed: 351 additions & 7 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(storage)!: an anonymous write carries no file
6+
7+
Ruling C ("an anonymous submission carries no file") now holds at every anonymous write door of ADR-0138 D2, not only at the public form door. A record write whose caller has no user, such as the guest an `authRequired: false` endpoint executes as under the grants a deployment binds to the guest, may not put a value in a file field (`file`, `image`, `avatar`, `video`, `audio`). It is refused before anything is written, with the form door's envelope: `400 VALIDATION_FAILED`, one `fields[]` entry per refused field carrying the ADR-0114 field code `invalid_value` and the field's label. An empty or absent file field is written as before. Signed-in callers, system writes and context-less programmatic writes are unchanged. No error code is added.
8+
9+
Clause-②: no (narrowing) — an anonymous write that carried a value in a file field is refused. Nothing that was refused becomes accepted.
10+
11+
Metadata-protocol: none (a write path's file rule; nothing a metadata author writes changes)
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape 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 path accepts (an anonymous caller's write carrying a file value is refused). The remedy is a change of who provides the file (a signed-in caller), 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). -->
14+
15+
**BREAKING** for an anonymous write that carried a file value, shipped as `patch` because the metadata protocol does not move.
16+
17+
- **Before.** Only the public form door refused a file value from an anonymous caller.
18+
- **Now.** Every write whose caller has no user is refused with `400 VALIDATION_FAILED` when a file field carries a value.
19+
- **Fix.** Leave file fields empty in an anonymous write. A file is provided by a signed-in caller, through the authenticated upload doors.

‎packages/services/service-storage/src/backfill-sys-file-public-scope.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,10 @@ describe('sys_file public-scope backfill (#22443 ruling B) — a real ObjectQL o
125125
* the source row's scope on the copy.
126126
*/
127127
// The claiming write acts in an organization: `product` is created by this
128-
// release, whose tenant column is NOT NULL (ADR-0131 D1).
128+
// release, whose tenant column is NOT NULL (ADR-0131 D1). [#25694] Its
129+
// caller is signed in: an anonymous write carries no file.
129130
const copyBySecondReference = (recordId: string, fileId: string) =>
130-
engine.insert('product', { id: recordId, title: recordId, image: fileId }, { context: { tenantId: 'org_a' } } as any);
131+
engine.insert('product', { id: recordId, title: recordId, image: fileId }, { context: { userId: 'u_writer', tenantId: 'org_a' } } as any);
131132

132133
it('a deployment with no public rows runs clean: it counts zero and writes nothing', async () => {
133134
await engine.insert('sys_file', { ...legacyPublicRow('f_user'), scope: 'user' }, SYSTEM as any);

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

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type FileReferenceEngine,
1010
} from './file-reference-lifecycle.js';
1111
import { assertEngineFindOnePredicate } from '@objectstack/objectql';
12+
import { resolveThrownHttpError } from '@objectstack/types';
1213

1314
const silentLogger = () => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn() });
1415

@@ -1357,3 +1358,191 @@ describe('a write references only a file its caller may read', () => {
13571358
});
13581359
});
13591360
});
1361+
1362+
describe('an anonymous write carries no file (#25694, ruling C at every anonymous write door)', () => {
1363+
/** The guest principal an `authRequired: false` endpoint's write executes as (ADR-0138 D9). */
1364+
const GUEST = { principalKind: 'guest', positions: ['guest'], isSystem: false, tenantId: 'org_1' };
1365+
const guestSession = { organizationId: 'org_1', positions: ['guest'] };
1366+
const SIGNED_IN = { userId: 'u_writer', tenantId: 'org_1' };
1367+
1368+
const LABELLED: Record<string, any> = {
1369+
sys_file: REGISTRY.sys_file,
1370+
product: {
1371+
name: 'product',
1372+
fields: {
1373+
id: { type: 'text' },
1374+
name: { type: 'text' },
1375+
image: { type: 'image', label: 'Photo' },
1376+
gallery: { type: 'image', multiple: true },
1377+
manual: { type: 'file' },
1378+
},
1379+
},
1380+
};
1381+
1382+
function installAdmitting(engine: Engine, storage: any = fakeStorage()) {
1383+
// A verdict that admits every file: the refusal must not lean on it.
1384+
const authorizeRead = vi.fn(async () => true);
1385+
installFileReferenceHooks(engine, () => storage, silentLogger(), authorizeRead);
1386+
return { storage, authorizeRead };
1387+
}
1388+
1389+
async function write(
1390+
engine: Engine,
1391+
phase: 'insert' | 'update',
1392+
data: Record<string, unknown>,
1393+
id: string,
1394+
caller: { session?: any; context?: any },
1395+
) {
1396+
const ctx: any = {
1397+
object: 'product',
1398+
event: phase === 'insert' ? 'beforeInsert' : 'beforeUpdate',
1399+
input: { ...(phase === 'update' ? { id } : {}), data, options: caller.context ? { context: caller.context } : {} },
1400+
session: caller.session,
1401+
dispatch: recordDispatch(),
1402+
};
1403+
await engine.trigger(ctx.event, ctx);
1404+
let row: Record<string, unknown> & { id: string };
1405+
if (phase === 'insert') {
1406+
row = { ...(ctx.input.data as Record<string, unknown>), id };
1407+
(engine.tables.product ??= []).push(row);
1408+
} else {
1409+
row = (engine.tables.product ?? []).find((r) => String(r.id) === id) as any;
1410+
Object.assign(row, ctx.input.data);
1411+
}
1412+
ctx.event = phase === 'insert' ? 'afterInsert' : 'afterUpdate';
1413+
ctx.result = row;
1414+
await engine.trigger(ctx.event, ctx);
1415+
return row;
1416+
}
1417+
1418+
const asGuest = { session: guestSession, context: GUEST };
1419+
const refusedBy = (p: Promise<unknown>) => p.then(() => null, (e) => e);
1420+
const sysFileWrites = (engine: Engine) =>
1421+
engine.calls.filter((c) => (c.op === 'update' || c.op === 'insert') && c.object === 'sys_file');
1422+
1423+
describe('a value in a file field is refused before anything is written', () => {
1424+
const VALUES: Array<[string, unknown]> = [
1425+
['an unclaimed file id', 'file_a'],
1426+
['a file id another record owns', 'file_b'],
1427+
['an external URL', 'https://example.test/x.png'],
1428+
['an inline file value', { url: 'https://example.test/y.png', name: 'y.png' }],
1429+
];
1430+
for (const [what, value] of VALUES) {
1431+
it(`on insert: ${what}`, async () => {
1432+
const engine = fakeEngine({
1433+
registry: LABELLED,
1434+
files: [file(), file({ id: 'file_b', key: 'user/file_b.png', ref_object: 'product', ref_id: 'p0', ref_field: 'image' })],
1435+
records: { product: [{ id: 'p0', image: 'file_b' }] },
1436+
});
1437+
const { storage, authorizeRead } = installAdmitting(engine);
1438+
1439+
const err = await refusedBy(write(engine, 'insert', { name: 'n', image: value }, 'p1', asGuest));
1440+
1441+
const answered = resolveThrownHttpError(err);
1442+
expect({ status: answered.status, code: answered.code }).toEqual({ status: 400, code: 'VALIDATION_FAILED' });
1443+
expect(err?.fields).toEqual([expect.objectContaining({ field: 'image', code: 'invalid_value', label: 'Photo' })]);
1444+
expect(err?.fields[0].message).toContain('Photo');
1445+
// Nothing was written: no row, no claim, no copy, and no file was looked up for it.
1446+
expect((engine.tables.product ?? []).map((r) => r.id)).toEqual(['p0']);
1447+
expect(sysFileWrites(engine)).toEqual([]);
1448+
expect(storage.download).not.toHaveBeenCalled();
1449+
expect(authorizeRead).not.toHaveBeenCalled();
1450+
});
1451+
}
1452+
1453+
it('on update', async () => {
1454+
const engine = fakeEngine({ registry: LABELLED, files: [file()], records: { product: [{ id: 'p1', image: null }] } });
1455+
installAdmitting(engine);
1456+
1457+
const err = await refusedBy(write(engine, 'update', { image: 'file_a' }, 'p1', asGuest));
1458+
1459+
expect(err?.code).toBe('VALIDATION_FAILED');
1460+
expect(err?.fields).toEqual([expect.objectContaining({ field: 'image', code: 'invalid_value' })]);
1461+
expect(engine.tables.product[0].image).toBeNull();
1462+
expect(engine.tables.sys_file[0].ref_id ?? null).toBeNull();
1463+
});
1464+
1465+
it('every file field carrying a value is named in one envelope, by its label or else its name', async () => {
1466+
const engine = fakeEngine({ registry: LABELLED, files: [file()] });
1467+
installAdmitting(engine);
1468+
1469+
const err = await refusedBy(
1470+
write(engine, 'insert', { image: 'file_a', gallery: ['file_a'], manual: 'https://example.test/m.pdf' }, 'p1', asGuest),
1471+
);
1472+
1473+
expect(err?.code).toBe('VALIDATION_FAILED');
1474+
expect(err?.fields.map((f: any) => [f.field, f.code, f.label])).toEqual([
1475+
['image', 'invalid_value', 'Photo'],
1476+
['gallery', 'invalid_value', 'gallery'],
1477+
['manual', 'invalid_value', 'manual'],
1478+
]);
1479+
});
1480+
});
1481+
1482+
describe('CONTROL — what the rule leaves as it was', () => {
1483+
it('an anonymous write with every file field empty or absent is written', async () => {
1484+
const engine = fakeEngine({ registry: LABELLED, files: [file()] });
1485+
installAdmitting(engine);
1486+
1487+
for (const [id, data] of [
1488+
['p1', { name: 'absent' }],
1489+
['p2', { name: 'null', image: null }],
1490+
['p3', { name: 'blank', image: '' }],
1491+
['p4', { name: 'empty set', gallery: [] }],
1492+
] as const) {
1493+
await write(engine, 'insert', { ...data }, id, asGuest);
1494+
}
1495+
1496+
expect(engine.tables.product.map((r) => r.id)).toEqual(['p1', 'p2', 'p3', 'p4']);
1497+
expect(engine.tables.sys_file[0].ref_id ?? null).toBeNull();
1498+
});
1499+
1500+
it('an anonymous update that clears a file field, or leaves it out, is written', async () => {
1501+
const engine = fakeEngine({ registry: LABELLED, records: { product: [{ id: 'p1', name: 'a', image: null }] } });
1502+
installAdmitting(engine);
1503+
1504+
await write(engine, 'update', { name: 'b' }, 'p1', asGuest);
1505+
await write(engine, 'update', { image: null }, 'p1', asGuest);
1506+
1507+
expect(engine.tables.product[0]).toMatchObject({ name: 'b', image: null });
1508+
});
1509+
1510+
it('a signed-in caller\'s write still claims the file', async () => {
1511+
const engine = fakeEngine({ registry: LABELLED, files: [file()] });
1512+
installAdmitting(engine);
1513+
1514+
await write(engine, 'insert', { image: 'file_a' }, 'p1', {
1515+
session: { userId: SIGNED_IN.userId, organizationId: 'org_1' },
1516+
context: SIGNED_IN,
1517+
});
1518+
1519+
expect(engine.tables.sys_file[0]).toMatchObject({ ref_object: 'product', ref_id: 'p1', ref_field: 'image' });
1520+
});
1521+
1522+
it('a system write and a context-less write still claim the file', async () => {
1523+
const engine = fakeEngine({ registry: LABELLED, files: [file(), file({ id: 'file_c', key: 'user/file_c.png' })] });
1524+
installAdmitting(engine);
1525+
1526+
await write(engine, 'insert', { image: 'file_a' }, 'p1', { session: { isSystem: true }, context: { isSystem: true } });
1527+
await write(engine, 'insert', { image: 'file_c' }, 'p2', {});
1528+
1529+
expect(engine.tables.sys_file.map((f) => [f.id, f.ref_id ?? null])).toEqual([
1530+
['file_a', 'p1'],
1531+
['file_c', 'p2'],
1532+
]);
1533+
});
1534+
1535+
it('an object with no file field is not judged', async () => {
1536+
const engine = fakeEngine({ files: [file()] });
1537+
installAdmitting(engine);
1538+
const ctx: any = {
1539+
object: 'tag', event: 'beforeInsert', input: { data: { label: 'file_a' }, options: { context: GUEST } },
1540+
session: guestSession, dispatch: recordDispatch(),
1541+
};
1542+
1543+
await engine.trigger('beforeInsert', ctx);
1544+
1545+
expect(engine.calls).toEqual([]);
1546+
});
1547+
});
1548+
});

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

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { randomUUID } from 'node:crypto';
44
import { FILE_REFERENCE_TYPES, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY } from '@objectstack/spec/data';
55
import type { IStorageService } from '@objectstack/spec/contracts';
66
import type { ExecutionContext } from '@objectstack/spec/kernel';
7+
import { isEmptyForRequired } from '@objectstack/core';
8+
import { validationFailure } from '@objectstack/types';
79
import { callerContext } from './attachment-access-hooks.js';
810

911
/**
@@ -51,6 +53,18 @@ import { callerContext } from './attachment-access-hooks.js';
5153
* the read check. System writes and context-less programmatic calls carry no
5254
* caller to ask, and reference files as before.
5355
*
56+
* ### An anonymous write carries no file
57+
*
58+
* [#25694] Ruling C (record 6384525042), held at every anonymous write door
59+
* rather than at the public form door alone: a write whose caller has no user
60+
* (ADR-0138 D2's anonymous write doors: the guest envelope of an
61+
* `authRequired: false` endpoint, under whatever grants a deployment binds to
62+
* the guest) may not put a value in a file field. It is refused before the driver runs, naming each
63+
* field, with the form door's envelope ({@link assertAnonymousWriteCarriesNoFile}).
64+
* The read verdict above would admit a `public_read` file to such a caller,
65+
* and keep any other id as written; neither is a file the anonymous caller
66+
* may provide. An empty or absent file field is written as before.
67+
*
5468
* ### The one write that cannot honour it, and is refused
5569
*
5670
* [#7102] A predicate (`multi: true`) update has ONE payload for N matched
@@ -277,6 +291,50 @@ async function findReferenceableFile(
277291
return (await authorizeRead(row, caller)) ? row : null;
278292
}
279293

294+
/**
295+
* [#25694] Ruling C at every anonymous write door: an anonymous write carries
296+
* no file. A file is provided by a signed-in caller, through the authenticated
297+
* upload doors; a caller with no user has none to provide, so a value in a
298+
* file field of its write is refused, whether it names a platform file (one it
299+
* may download or not) or anything else. Judged before the driver runs and
300+
* before any file is looked up, so a refused write leaves nothing behind.
301+
*
302+
* The form door's own judgement (`form-routes.ts`, #25572), held at the
303+
* engine: the same emptiness rule (`isEmptyForRequired`), and the same
304+
* envelope — one `400 VALIDATION_FAILED` naming every refused field by its
305+
* label with the closed ADR-0114 field code `invalid_value`. The form door
306+
* keeps its own judgement, which answers before anything reaches the engine.
307+
*
308+
* `caller` is {@link referencingCallerOf}'s answer, the caller the read
309+
* verdict is asked about: a system write and a context-less programmatic
310+
* write have none, and are not judged; a caller with no user is anonymous.
311+
*/
312+
function assertAnonymousWriteCarriesNoFile(
313+
engine: FileReferenceEngine,
314+
object: string,
315+
data: Record<string, unknown>,
316+
fileFields: string[],
317+
caller: ExecutionContext | null,
318+
): void {
319+
if (!caller || caller.userId) return;
320+
const refused: Array<{ field: string; code: 'invalid_value'; message: string; label: string }> = [];
321+
for (const field of fileFields) {
322+
if (!Object.prototype.hasOwnProperty.call(data, field)) continue;
323+
const def = fieldDefOf(engine, object, field);
324+
if (isEmptyForRequired(def, data[field])) continue;
325+
const declared = typeof def?.label === 'string' ? def.label.trim() : '';
326+
const label = declared.length > 0 ? declared : field;
327+
refused.push({
328+
field,
329+
code: 'invalid_value',
330+
message: `${label} cannot take a file here: an anonymous write carries no file. `
331+
+ 'Leave it empty, or sign in and provide the file from your account.',
332+
label,
333+
});
334+
}
335+
if (refused.length > 0) throw validationFailure(refused.map((f) => f.message).join('; '), refused);
336+
}
337+
280338
/** Bound on owned files released per record delete. */
281339
const RELEASE_BATCH_LIMIT = 1_000;
282340

@@ -869,9 +927,11 @@ export function installFileReferenceHooks(
869927
if (!object || !data || typeof data !== 'object') return;
870928
const fileFields = activeFileFields(engine, object);
871929
if (fileFields.length === 0) return;
930+
const caller = referencingCallerOf(ctx);
931+
assertAnonymousWriteCarriesNoFile(engine, object, data, fileFields, caller);
872932
await applyCopyOnClaim(
873933
engine, getStorage, logger, object, null, data, fileFields,
874-
actingOrganizationOf(ctx), referencingCallerOf(ctx), authorizeRead, mintedCopiesFor(ctx),
934+
actingOrganizationOf(ctx), caller, authorizeRead, mintedCopiesFor(ctx),
875935
);
876936
},
877937
{ packageId: PACKAGE_ID },
@@ -885,6 +945,10 @@ export function installFileReferenceHooks(
885945
if (!object || !data || typeof data !== 'object') return;
886946
const fileFields = activeFileFields(engine, object);
887947
if (fileFields.length === 0) return;
948+
// [#25694] Before the predicate refusal below: an anonymous write is
949+
// refused for what it carries, whichever shape the write takes.
950+
const caller = referencingCallerOf(ctx);
951+
assertAnonymousWriteCarriesNoFile(engine, object, data, fileFields, caller);
888952
// [#6966] "Is this a multi-update?" is the engine's marker, not the shape
889953
// of `input.id`. A per-row context names ITS row, so the old
890954
// `ids.length === 1` test answered "single update" on every row of a
@@ -928,7 +992,7 @@ export function installFileReferenceHooks(
928992
// analysis a later edit has to re-derive.
929993
await applyCopyOnClaim(
930994
engine, getStorage, logger, object, recordId, data, fileFields,
931-
actingOrganizationOf(ctx), referencingCallerOf(ctx), authorizeRead, mintedCopiesFor(ctx),
995+
actingOrganizationOf(ctx), caller, authorizeRead, mintedCopiesFor(ctx),
932996
);
933997
},
934998
{ packageId: PACKAGE_ID },

0 commit comments

Comments
 (0)