Skip to content

Commit 33f9791

Browse files
fix(service-storage): a file field's accept/maxSize refusal answers 400 ERR_FILE_CONSTRAINT (#21751)
Fixes #21730 Clause-②: no ## What changed `FileConstraintError` (`packages/services/service-storage/src/file-reference-lifecycle.ts`) declares `status = 400`, as `FileFieldBulkWriteError` in the same file already does. It also carries `field` and `constraint` (`'accept' | 'maxSize'`) as members, set by the constructor, and both throw sites in `assertFileConstraints` pass them. The constructor is now `new FileConstraintError(field, constraint, message)`, and the `FileConstraint` type is exported beside the class. The message text is byte-identical. A file field's `accept` / `maxSize` refusal now answers `400 ERR_FILE_CONSTRAINT` with a sentence naming the field and the constraint. Before, it answered the sanitised `500 INTERNAL_ERROR`. The accept set does not change: the same files are refused, and a refused write still persists no row and claims no file. ## Measured at the `/api/v1/data` door Real stack (`bootStack` + `StorageServicePlugin`), object `ffc_doc` with `doc: { type: 'file', accept: ['.pdf'], maxSize: 10 }`, `sys_file` rows seeded under a system context. | write | before (`ff29410e`) | after (`415514d0`) | |---|---|---| | POST, `text/plain` file | `500 {"error":"Internal server error","code":"INTERNAL_ERROR"}` | `400 {"error":"File type 'text/plain' is not permitted by the accept list declared for 'doc' (.pdf)","code":"ERR_FILE_CONSTRAINT","object":"ffc_doc"}` | | POST, 5005-byte PDF | `500`, same body | `400 {"error":"File exceeds the maximum size declared for 'doc' (5005 bytes > 10 bytes)","code":"ERR_FILE_CONSTRAINT","object":"ffc_doc"}` | | PATCH onto the 5005-byte PDF | `500`, same body | `400`, same body as the POST | | POST, 6-byte PDF (control) | `201` | `201` | Read-back on both sides: neither refused POST wrote a row, and no refused write claimed its `sys_file` (`ref_object` / `ref_id` / `ref_field` stay null). ## The PM's mechanism hypotheses, measured - **H1 holds.** `ERR_FILE_CONSTRAINT` is registered at `packages/spec/src/api/error-code-ledger.zod.ts:506`. No ledger edit. - **H2 holds.** The 400 body carries exactly the keys `error`, `code` and `object`. `field` and `constraint` are declared on the error, but the declared-status passthrough (`packages/types/src/data-error-classification.ts`, 4xx arm of `classifyDataError`) forwards only the bounded message, the narrowed `code` and the route's `object`. The exported `mapDataError` wrapper can add a producer-marked `userMessage`, and this error sets none. So on the wire, the field and the constraint are named by the message only. Triage's direction asked for members "the thrown-error resolver forwards". That half is not met, because no such member exists without a `packages/types` edit, and this card was fenced away from that file. See Acceptance notes. - **H3 holds.** Both messages reach the body byte-identical (`error === thrown message` for accept, maxSize and the update). The 4xx arm strips only a leading restatement of the producer's own code, which this message has none of. It then applies the 500-character truncation bound. No leak heuristic runs on this arm. - **H4 holds.** Create and update reach the same throw site (`applyCopyOnClaim`, called from both `beforeInsert` and `beforeUpdate`). Both are pinned, at the unit seam and at the door. ## Tests - `packages/services/service-storage/src/file-reference-lifecycle.test.ts`: the accept and maxSize create refusals and a new update refusal each assert `{ code: 'ERR_FILE_CONSTRAINT', status: 400, field: 'image', constraint }`, that the message names the field, and that no row was written or file claimed. 51/51 pass. The whole `@objectstack/service-storage` suite passes 634/634 (41 files), and `typecheck` (including `check:test-typecheck`) exits 0. Both at `9251e230`. - `packages/qa/dogfood/test/file-field-constraint-refusal.dogfood.test.ts` (new): the door-level pin from the table above, in 5 cases. Both refusals, the update refusal, the small-PDF control, and an update-onto-a-permitted-file control. Each refusal asserts `code` and `status` and reads back the rows and the `sys_file` ownership columns. 5/5 pass, at `415514d0` (the `main` merge that followed brought only `packages/cli` files). The dogfood `tsc --noEmit` exits 0 and its program lists this file. - **Ablation** (committed fix first, so restore points at HEAD). I planted `readonly ablation21730 = true;` in place of `readonly status = 400;` on `FileConstraintError`, using `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `632a803a` → `487a00f9`). Then I rebuilt `service-storage`. `ablation-dist-preflight` proved the marker present in 2 built files. Results: - Unit: **3 failed / 48 passed**. The three refusal cases failed with `expected Error: File exceeds the maximum size decl… to match object { code: 'ERR_FILE_CONSTRAINT', …(3) }`. - Dogfood: **3 failed / 2 passed**. The three refusals failed with `{"error":"Internal server error","code":"INTERNAL_ERROR"}: expected 500 to be 400`. Both controls stayed green. - Restore: blob after restore `632a803a` equals HEAD, `git diff HEAD` is empty, the whole-tree `git status --porcelain` is empty, and the rebuild's preflight in `--absent` mode shows the marker absent from all 6 built files. The unit (51/51) and dogfood (5/5) re-runs passed. - The ablation exercised the status assertions. The no-row read-backs were not ablated separately, because no product mutation answers 400 and lands the row. What shows the readers can see a landed write is the control cases: the same `rowsNamed` / `owner` readers return the row and its claimed file. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `9251e230` derived 70 commands. I ran every one, recording each exit code. `--ran` reports: `70 derived famil(ies) accounted for — 68 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`. - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET, because 33 packages outside this diff's closure have no `dist/`. Supplementary reading: `require()` of `service-storage/dist/index.cjs` and `import()` of `dist/index.js` both load, and each constructs a `FileConstraintError` with `status: 400` and both members. - NOT MEASURED: `pnpm check:i18n`, reason: PREREQUISITE NOT MET (unbuilt config imports). This diff touches no translation bundle or label. - `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3, because its pinned fixture commit was absent from this shallow clone. It exited 0 after `git fetch --depth=1` of that commit. ## Acceptance notes - **QA checklist clause, routed to the next run, not edited here.** `docs/qa/platform-checklist/areas/attachments-storage.json`, item `attachments-storage.field-accept-maxsize-server-enforced`, carries a knownGap and clause 2. Both predict the sanitised 500 and say a measured 4xx carrying `ERR_FILE_CONSTRAINT` means "revise this clause with the measurement". The `FOLLOW-UPS.md` row D13 describes the same 500. The item's own clause routes that revision to the next QA run. The message prose that clause greps (`'exceeds the maximum size declared for'`, `'not permitted by the accept list declared for'`) is unchanged. - **The field and the constraint are on the wire through the message only.** That is what the door forwards today (H2). Forwarding `field` / `constraint` as structured body members would take a `packages/types` change (an `ERR_FILE_CONSTRAINT` arm in `structuredCodeAnswer`, or a generic declared-4xx detail channel). That file belongs to another lane, so the seat routes it. Nothing measured here reads such a member. - **Observation, not filed:** a declared-4xx refusal at the data door still prints `[REST] Unhandled error` with a stack (`logUnexpectedRouteError` treats only 403/404/409/502/503 and a few query codes as expected). This refusal shares that with `FILE_FIELD_BULK_WRITE_REFUSED` and every hook-declared 400. It is log noise only, and the wire is unaffected. Carrier: none. - **Observation, not filed:** `content/docs/data-modeling/field-types.mdx` (Media Types) says file constraints "are configured at the storage-provider layer, not on the field". `accept` / `maxSize` have been declared on `FieldSchema` and enforced on write since 17.0. This was already false before this change, which does not touch it. Carrier: none. - The changeset is `patch`. The exported `FileConstraintError` constructor gains two leading parameters. The only constructor in the repository is the module's own throw sites. ## Patch round 1 _Appended by the seat (`domain:services#1`, `session_011K3zqE8Pv1Evw5hc8tZCnN`) from the dev's patch-round report `5981408643`, after seat review `5981327964`._ - **`content/docs/data-modeling/field-types.mdx`, "Media Types" paragraph** (`f18345a4`). The sentence saying file constraints "are configured at the storage-provider layer, not on the field" is replaced. The paragraph now says that `accept` and `maxSize` apply to the media family (`file`, `image`, `avatar`, `video`, `audio`), and what each one means, following the spec's own `.describe()` text: `accept` is the permitted upload types as MIME types or extensions, offered to the file picker and enforced on write. `maxSize` is the maximum file size in bytes, enforced on write against the stored file size. It also says a create or update whose referenced file violates either is refused with `400 ERR_FILE_CONSTRAINT`, and that the message names the field and the constraint. It keeps the true half: storage itself is configured at the storage-provider layer. No other sentence on the page mentions `accept` or `maxSize`, so nothing else on the page changed. - **`main` merged** at `aa33b2e3`. It brought 5 commits, and none of them touches this PR's files. - **Gates** at `aa33b2e3`. `dispatch-gates --commands` derives 95 commands. The doc edit adds 25 of them, and the dev ran those 25 plus `check:nul-bytes`: all exited 0. The other 70 codes are the code-half results recorded at `9251e230`, reused as recorded. `--ran` reports: 95 derived, 93 run, 2 NOT-MEASURED (`check:dual-build-cjs-loads` and `check:i18n`, each exit 3 with its prerequisite not met, unchanged from the code half), 0 UNRUN. - **Acceptance notes revision:** the `field-types.mdx` observation above is resolved by this round. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3eb38ae commit 33f9791

6 files changed

Lines changed: 277 additions & 12 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
A file field's declared `accept` / `maxSize` refusal now answers `400 ERR_FILE_CONSTRAINT` with a sentence naming the field and the constraint, instead of `500 INTERNAL_ERROR` with the sentence withheld.
6+
7+
Clause-②: no
8+
9+
- **`FileConstraintError` declares `status = 400`**, as `FileFieldBulkWriteError` in the same module already did. The data API's declared-status passthrough now answers the refusal on create and on update, e.g. `400 {"error":"File exceeds the maximum size declared for 'doc' (5005 bytes > 10 bytes)","code":"ERR_FILE_CONSTRAINT","object":"…"}`. Before, the error declared a registered `code` but no status, so it fell through to the sanitised `500 INTERNAL_ERROR`, and the field and the reason reached only the server log.
10+
- **It carries `field` and `constraint` (`'accept' | 'maxSize'`) as members**, for in-process callers. The constructor is now `new FileConstraintError(field, constraint, message)`. The new `FileConstraint` type is exported beside it. On the HTTP wire the message names both, and its wording is unchanged.
11+
- The accept set is unchanged: the same files are refused, and a refused write still persists no row and claims no file. `ERR_FILE_CONSTRAINT` was already in the error-code ledger.

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ Self-referential hierarchy (e.g., categories, org chart).
362362

363363
## Media Types
364364

365-
Media fields store a reference to an uploaded file. They take the universal field properties (`multiple` is honored for `file` and `image`); there is no per-field file-attachment config object in the field schema — file constraints and storage are configured at the storage-provider layer, not on the field.
365+
Media fields store a reference to an uploaded file. They take the universal field properties (`multiple` is honored for `file` and `image`). Two more field properties apply to the media family (`file`, `image`, `avatar`, `video` and `audio`) and constrain the referenced file: `accept`, the permitted upload types as MIME types or extensions (e.g. `["image/*", ".pdf"]`), offered to the file picker and enforced on write; and `maxSize`, the maximum permitted file size in bytes, enforced on write against the stored file size, not just checked in the browser. A create or update whose referenced file violates either is refused with `400 ERR_FILE_CONSTRAINT`, and the error message names the field and the constraint. Storage itself is configured at the storage-provider layer, not on the field.
366366

367367
### `image`
368368
Image file with optional validation.
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// A file field's declared `accept` / `maxSize` refusal, end to end through the
4+
// real stack: storage service's copy-on-claim before-hook
5+
// (`assertFileConstraints`, `FileConstraintError`) → ObjectQL triggerHooks →
6+
// REST `mapDataError` → HTTP body.
7+
//
8+
// ── What this pins ──────────────────────────────────────────────────────────
9+
//
10+
// The refusal is a verdict about the CALLER's own file — wrong type, too big —
11+
// so it answers `400 ERR_FILE_CONSTRAINT` with a sentence naming the field and
12+
// the constraint. Before `FileConstraintError` declared its `status`, the
13+
// declared-status passthrough never fired and the same refusal left through the
14+
// unclassified-fault terminal as `500 INTERNAL_ERROR` with the sentence
15+
// withheld: enforcement held, the wire contract did not.
16+
//
17+
// ── Why each assertion is load-bearing ──────────────────────────────────────
18+
//
19+
// `code` AND `status` (ADR-0112). A bare status cannot tell this verdict
20+
// apart from any other 400 the data door gives.
21+
//
22+
// The READ-BACK, not the response. A write that answers 400 and lands anyway
23+
// satisfies a response-only assertion perfectly, so every refusal re-reads
24+
// the object's rows and the `sys_file` ownership columns under a system
25+
// context.
26+
//
27+
// The SMALL-PDF CONTROL, on the same field. Without it the refusal cases pass
28+
// just as well when every file write is refused for an unrelated reason.
29+
//
30+
// The sentence is asserted by its NAMED SUBJECTS only — the field and the
31+
// constraint — never its full wording: the field and the constraint reach the
32+
// wire through the message alone (the declared-status passthrough forwards
33+
// `status`, the registered `code` and the bounded message, and no other member
34+
// of the thrown error).
35+
36+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
37+
import { mkdtempSync } from 'node:fs';
38+
import { join } from 'node:path';
39+
import { tmpdir } from 'node:os';
40+
import { bootStack, type VerifyStack } from '@objectstack/verify';
41+
import { defineStack } from '@objectstack/spec';
42+
import { ObjectSchema, Field, RAW_FILE_VALUES_CONTEXT_KEY } from '@objectstack/spec/data';
43+
import { StorageServicePlugin } from '@objectstack/service-storage';
44+
45+
const SYS = { isSystem: true } as const;
46+
const OBJECT = 'ffc_doc';
47+
48+
const FfcDoc = ObjectSchema.create({
49+
name: OBJECT,
50+
label: 'Constrained Document',
51+
fields: {
52+
name: Field.text({ label: 'Name' }),
53+
doc: Field.file({ label: 'Document', accept: ['.pdf'], maxSize: 10 }),
54+
},
55+
});
56+
57+
const ffcStack = defineStack({
58+
manifest: {
59+
id: 'com.dogfood.file-field-constraint-refusal',
60+
namespace: 'ffc',
61+
version: '0.0.0',
62+
type: 'app',
63+
name: 'File Field Constraint Refusal Fixture',
64+
description: 'A file field declaring accept and maxSize, written through the data API.',
65+
},
66+
objects: [FfcDoc],
67+
});
68+
69+
/** The three `sys_file` rows the cases reference — one per verdict. */
70+
const FILES = {
71+
wrongType: { id: 'ffc_file_txt', name: 'notes.txt', mime_type: 'text/plain', size: 6 },
72+
oversize: { id: 'ffc_file_big', name: 'big.pdf', mime_type: 'application/pdf', size: 5005 },
73+
smallPdf: { id: 'ffc_file_ok', name: 'small.pdf', mime_type: 'application/pdf', size: 6 },
74+
smallPdf2: { id: 'ffc_file_ok2', name: 'small2.pdf', mime_type: 'application/pdf', size: 6 },
75+
} as const;
76+
77+
describe('a file field\'s accept / maxSize refusal answers 400 ERR_FILE_CONSTRAINT', () => {
78+
let stack: VerifyStack;
79+
let token: string;
80+
let ql: any;
81+
82+
beforeAll(async () => {
83+
const rootDir = mkdtempSync(join(tmpdir(), 'ffc-pin-'));
84+
stack = await bootStack(ffcStack, {
85+
extraPlugins: [
86+
new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }),
87+
],
88+
});
89+
token = await stack.signIn();
90+
ql = await stack.kernel.getServiceAsync('objectql');
91+
92+
for (const f of Object.values(FILES)) {
93+
await ql.insert(
94+
'sys_file',
95+
{ ...f, key: `user/${f.id}`, status: 'committed', scope: 'user' },
96+
{ context: SYS },
97+
);
98+
}
99+
}, 60_000);
100+
101+
afterAll(async () => {
102+
await stack?.stop();
103+
});
104+
105+
const write = async (method: 'POST' | 'PATCH', path: string, data: Record<string, unknown>) => {
106+
const res = await stack.apiAs(token, method, path, data);
107+
return { status: res.status, body: (await res.json()) as any };
108+
};
109+
110+
/** The rows named `name`, read above every access layer. */
111+
const rowsNamed = async (name: string) =>
112+
ql.find(OBJECT, { where: { name }, context: { ...SYS, [RAW_FILE_VALUES_CONTEXT_KEY]: true } });
113+
114+
/** The `sys_file` ownership columns, read above every access layer. */
115+
const owner = async (id: string) => {
116+
const row = await ql.findOne('sys_file', { where: { id }, context: SYS });
117+
return { ref_object: row?.ref_object ?? null, ref_id: row?.ref_id ?? null, ref_field: row?.ref_field ?? null };
118+
};
119+
const UNOWNED = { ref_object: null, ref_id: null, ref_field: null };
120+
121+
let controlId: string;
122+
123+
it('a file outside the accept list answers 400 ERR_FILE_CONSTRAINT naming the field and the accept list, and writes no row', async () => {
124+
const r = await write('POST', `/data/${OBJECT}`, { name: 'wrong type', doc: FILES.wrongType.id });
125+
126+
expect(r.status, JSON.stringify(r.body)).toBe(400);
127+
expect(r.body.code).toBe('ERR_FILE_CONSTRAINT');
128+
expect(r.body.error).toContain("'doc'");
129+
expect(r.body.error).toContain('accept list');
130+
expect(r.body.object).toBe(OBJECT);
131+
132+
expect(await rowsNamed('wrong type')).toHaveLength(0);
133+
expect(await owner(FILES.wrongType.id)).toEqual(UNOWNED);
134+
});
135+
136+
it('a file over maxSize answers 400 ERR_FILE_CONSTRAINT naming the field and the maximum size, and writes no row', async () => {
137+
const r = await write('POST', `/data/${OBJECT}`, { name: 'oversize', doc: FILES.oversize.id });
138+
139+
expect(r.status, JSON.stringify(r.body)).toBe(400);
140+
expect(r.body.code).toBe('ERR_FILE_CONSTRAINT');
141+
expect(r.body.error).toContain("'doc'");
142+
expect(r.body.error).toContain('maximum size');
143+
expect(r.body.object).toBe(OBJECT);
144+
145+
expect(await rowsNamed('oversize')).toHaveLength(0);
146+
expect(await owner(FILES.oversize.id)).toEqual(UNOWNED);
147+
});
148+
149+
it('control: a 6-byte PDF satisfies both declarations, answers 201, and is claimed by the row', async () => {
150+
const r = await write('POST', `/data/${OBJECT}`, { name: 'control', doc: FILES.smallPdf.id });
151+
152+
expect(r.status, JSON.stringify(r.body)).toBe(201);
153+
controlId = r.body.record?.id ?? r.body.id;
154+
expect(controlId).toBeTruthy();
155+
156+
const stored = await rowsNamed('control');
157+
expect(stored).toHaveLength(1);
158+
expect(stored[0].doc).toBe(FILES.smallPdf.id);
159+
expect(await owner(FILES.smallPdf.id)).toEqual({ ref_object: OBJECT, ref_id: controlId, ref_field: 'doc' });
160+
});
161+
162+
it('an UPDATE onto an oversize file is refused the same way and leaves the record and both files as they were', async () => {
163+
const r = await write('PATCH', `/data/${OBJECT}/${controlId}`, { doc: FILES.oversize.id });
164+
165+
expect(r.status, JSON.stringify(r.body)).toBe(400);
166+
expect(r.body.code).toBe('ERR_FILE_CONSTRAINT');
167+
expect(r.body.error).toContain("'doc'");
168+
expect(r.body.error).toContain('maximum size');
169+
170+
const stored = await rowsNamed('control');
171+
expect(stored).toHaveLength(1);
172+
expect(stored[0].doc).toBe(FILES.smallPdf.id);
173+
expect(await owner(FILES.smallPdf.id)).toEqual({ ref_object: OBJECT, ref_id: controlId, ref_field: 'doc' });
174+
expect(await owner(FILES.oversize.id)).toEqual(UNOWNED);
175+
});
176+
177+
it('control: an UPDATE onto a permitted file still lands', async () => {
178+
const r = await write('PATCH', `/data/${OBJECT}/${controlId}`, { doc: FILES.smallPdf2.id });
179+
180+
expect(r.status, JSON.stringify(r.body)).toBe(200);
181+
const stored = await rowsNamed('control');
182+
expect(stored[0].doc).toBe(FILES.smallPdf2.id);
183+
});
184+
});

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

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -924,30 +924,75 @@ describe('File Reference Ownership (ADR-0104 D3 wave 2)', () => {
924924
},
925925
});
926926

927-
it('rejects a file larger than the declared maxSize', async () => {
927+
/** The refusal a caller is owed: the ADR-0112 `code` plus the 4xx
928+
* `status` the REST passthrough answers with, and the two named subjects
929+
* as members. Without `status` the same throw leaves the data door as a
930+
* sanitised `500 INTERNAL_ERROR` with the sentence withheld. */
931+
const refusal = (constraint: 'accept' | 'maxSize') => ({
932+
code: 'ERR_FILE_CONSTRAINT',
933+
status: 400,
934+
field: 'image',
935+
constraint,
936+
});
937+
938+
it('rejects a file larger than the declared maxSize (ERR_FILE_CONSTRAINT / 400, naming field and constraint)', async () => {
928939
const engine = fakeEngine({
929940
files: [file({ size: 5000 })],
930941
registry: constrained(),
931942
});
932943
install(engine);
933944

934-
await expect(driveInsert(engine, 'product', { image: 'file_a' }, 'p1')).rejects.toThrow(
935-
FileConstraintError,
936-
);
937-
// The write failed, so nothing was claimed.
945+
const err = await driveInsert(engine, 'product', { image: 'file_a' }, 'p1')
946+
.then(() => null)
947+
.catch((e) => e);
948+
949+
expect(err).toBeInstanceOf(FileConstraintError);
950+
expect(err).toMatchObject(refusal('maxSize'));
951+
expect(err.message).toContain("'image'");
952+
// The write failed before the driver: no row, and nothing was claimed.
953+
expect(engine.tables.product ?? []).toHaveLength(0);
938954
expect(engine.tables.sys_file[0].ref_id).toBeUndefined();
939955
});
940956

941-
it('rejects a file whose MIME type is outside the declared accept list', async () => {
957+
it('rejects a file whose MIME type is outside the declared accept list (ERR_FILE_CONSTRAINT / 400, naming field and constraint)', async () => {
942958
const engine = fakeEngine({
943959
files: [file({ mime_type: 'application/pdf', name: 'a.pdf', size: 10 })],
944960
registry: constrained(),
945961
});
946962
install(engine);
947963

948-
await expect(driveInsert(engine, 'product', { image: 'file_a' }, 'p1')).rejects.toThrow(
949-
/not permitted by the accept list/,
950-
);
964+
const err = await driveInsert(engine, 'product', { image: 'file_a' }, 'p1')
965+
.then(() => null)
966+
.catch((e) => e);
967+
968+
expect(err).toBeInstanceOf(FileConstraintError);
969+
expect(err).toMatchObject(refusal('accept'));
970+
expect(err.message).toMatch(/not permitted by the accept list/);
971+
expect(err.message).toContain("'image'");
972+
expect(engine.tables.product ?? []).toHaveLength(0);
973+
expect(engine.tables.sys_file[0].ref_id).toBeUndefined();
974+
});
975+
976+
it('refuses an UPDATE onto an oversize file with the same envelope, leaving the record and both files as they were', async () => {
977+
const engine = fakeEngine({
978+
files: [
979+
file({ id: 'file_old', size: 10, ref_object: 'product', ref_id: 'p1', ref_field: 'image' }),
980+
file({ id: 'file_big', size: 5000 }),
981+
],
982+
records: { product: [{ id: 'p1', image: 'file_old' }] },
983+
registry: constrained(),
984+
});
985+
install(engine);
986+
987+
const err = await driveUpdate(engine, 'product', 'p1', { image: 'file_big' })
988+
.then(() => null)
989+
.catch((e) => e);
990+
991+
expect(err).toBeInstanceOf(FileConstraintError);
992+
expect(err).toMatchObject(refusal('maxSize'));
993+
expect(engine.tables.product[0].image).toBe('file_old');
994+
expect(engine.tables.sys_file.find((f) => f.id === 'file_old')).toMatchObject({ ref_id: 'p1' });
995+
expect(engine.tables.sys_file.find((f) => f.id === 'file_big')!.ref_id).toBeUndefined();
951996
});
952997

953998
it('accepts a file that satisfies both declarations', async () => {

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,15 +221,36 @@ export class FileReferenceCopyError extends Error {
221221
}
222222
}
223223

224+
/** The media-field declaration a {@link FileConstraintError} names as violated. */
225+
export type FileConstraint = 'accept' | 'maxSize';
226+
224227
/**
225228
* Raised when a referenced file violates its field's declared `accept` /
226229
* `maxSize`. Fails the write: a stored value that breaks its own field's
227230
* declaration is the "declared but not enforced" state ADR-0104 removes.
231+
*
232+
* ADR-0112 envelope, the same shape as {@link FileFieldBulkWriteError} below:
233+
* a registered `code` plus a 4xx `status`, so the REST layer's declared-status
234+
* passthrough answers `400 ERR_FILE_CONSTRAINT` with this message instead of
235+
* the bare error falling through to the sanitised `500 INTERNAL_ERROR` with
236+
* the sentence withheld. `400` because the verdict is about the file the
237+
* CALLER chose — wrong type, too large — and the remedy is the caller's: pick
238+
* a file the field's declaration permits.
239+
*
240+
* `field` and `constraint` carry the two named subjects as members for
241+
* in-process callers. On the HTTP wire the passthrough forwards `status`, the
242+
* registered `code` and the bounded message, so there the message is what
243+
* names both — keep it naming the field and the constraint.
228244
*/
229245
export class FileConstraintError extends Error {
230246
readonly code = 'ERR_FILE_CONSTRAINT';
231-
constructor(message: string) {
247+
readonly status = 400;
248+
readonly field: string;
249+
readonly constraint: FileConstraint;
250+
constructor(field: string, constraint: FileConstraint, message: string) {
232251
super(message);
252+
this.field = field;
253+
this.constraint = constraint;
233254
}
234255
}
235256

@@ -328,6 +349,8 @@ function assertFileConstraints(
328349
if (typeof maxSize === 'number' && maxSize > 0 && typeof file.size === 'number') {
329350
if (file.size > maxSize) {
330351
throw new FileConstraintError(
352+
field,
353+
'maxSize',
331354
`File exceeds the maximum size declared for '${field}' ` +
332355
`(${file.size} bytes > ${maxSize} bytes)`,
333356
);
@@ -351,6 +374,8 @@ function assertFileConstraints(
351374

352375
if (!testable.some((e) => matchesAcceptEntry(e, mime, name))) {
353376
throw new FileConstraintError(
377+
field,
378+
'accept',
354379
`File type '${mime || name}' is not permitted by the accept list declared for ` +
355380
`'${field}' (${accept.join(', ')})`,
356381
);

‎packages/services/service-storage/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export {
6565
FileReferenceCopyError,
6666
FileConstraintError,
6767
} from './file-reference-lifecycle.js';
68-
export type { FileReferenceEngine, FileReferenceLogger } from './file-reference-lifecycle.js';
68+
export type { FileConstraint, FileReferenceEngine, FileReferenceLogger } from './file-reference-lifecycle.js';
6969
export {
7070
verifyFileReferences,
7171
formatFileReferenceReport,

0 commit comments

Comments
 (0)