Skip to content

Commit 3761710

Browse files
fix(storage)!: a record write claims or copies only a file its caller may read (#25951)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DM7SePpQnQZ7NUSKUCvUrek)_ Fixes #25692 Clause-②: no (narrowing) Metadata-protocol: none ## What changes ADR-0104 D3 (wave 2) gives each `sys_file` at most one owning field slot. A record write that references a file id either claims an unclaimed file for the writing slot, or, when another slot owns the file, copies its bytes into a fresh `sys_file` the writing slot owns (copy-on-claim). Neither step asked whether the write's caller may read the file. The claim path now asks the download door's own verdict, under the write's caller context, and claims or copies only on an allow. - **One judgement, two callers.** The door's verdict body moves out of `buildFileReadAuthorizer` into `judgeFileRead`: the uploader, a reader of the record whose field owns the file (`readableFieldOwners`, honouring `fileAccessDelegate`), a member of the organization for a file no record owns (#25693), a reader of any `sys_attachment` parent for an attachments-scope file. The download door calls it with no change in behaviour. The claim path calls it through `buildFileReferenceReadAuthorizer`, which also admits a `public_read` file and asks nothing on a kernel with no `auth` service, where the door binds no gate either. One file has one access rule, decided in one place. - **The quiet answer, after #25682.** A file the caller may not read is answered exactly as the organization-scoped lookup answers a file it cannot see: the id is kept as written, and nothing is claimed or copied. This follows the precedent #25682 set for the cross-organization half of the same lifecycle. There is no new error code or status, and the write cannot tell "may not read" from "does not exist". - **Ordering.** Constraint verdicts no longer run ahead of the read check. - **A write's own copy.** A copy the write itself minted is claimed by that same write without a second question. The mint is remembered per write (keyed on the write's dispatch scope). When that record is missing, the write fails safe: the copy stays unclaimed and is never claimed for the wrong caller. - **Outage.** An authorization-store outage (`AuthzStoreUnavailableError`) fails the write with its declared envelope rather than reading as either answer. Any other throw from the judgement is a deny. - **Unchanged.** System writes and context-less programmatic writes carry no caller to ask, and reference files as before. `callerContext` (attachment-access hooks) is exported so the lifecycle reads a write's caller the same way, with operation-private keys dropped. The system-context page (`content/docs/permissions/system-context.mdx`) gains row 47f for the claim path's system-write exemption, which `check-system-context-census` requires. ## BREAKING, shipped as `patch` The changeset carries Before / Now / Fix and the ADR-0087 marker `not-required (no-migration-prescription)`: no spec key, stored row or exported declaration moves. A write that referenced a file its caller may not read now keeps the id unowned. To fix, reference the file as a caller who may read it, or upload it again. A server-side process that copies files between records on purpose runs as a system write. ## Tests - `file-reference-lifecycle.test.ts`: 15 new cases against a stand-in verdict. They cover refusal on insert and update for owned files (no copy) and unclaimed files (no claim, a tombstone not revived), per-id judging of a multiple-value field, the constraint ordering with its control, the verdict being asked with the caller context (operation-private keys dropped), outage propagation (the record is not written), and system, context-less and unbound writes unchanged. Controls show an admitted caller still copies and claims. - `file-reference-read-verdict.integration.test.ts` (new): 11 cases on a real ObjectQL and SqlDriver with `StorageServicePlugin` booted. For each file, the download door's answer to the writer and the claim path's outcome for the same writer agree. A refused file gets 403 and is kept; a served file gets 200 and is copied or claimed. The cases span the field-owned, delegate, attachments, uploader, `public_read` and unclaimed arms, plus outage and other-fault pins and a system write. - Package suite: 65 files, 1179 tests pass. `tsc -p tsconfig.test.json` compiles both files. ## Ablations Every negative pin was put back to the refused behaviour and shown red, then restored. Each leg ran through `scripts/ablation-replace.mjs`: the anchor hit 1 → 0, the blob changed, the restored blob equals HEAD, and `git diff HEAD` was empty. | Leg | Mutation | Red | | --- | --- | --- | | A | the lookup ignores the verdict | 15: every unit refusal, ordering, caller-context and outage pin, and every integration refusal and fault pin. Every integration allow control stays green. | | B | the lifecycle swallows a verdict throw | 2: the unit and integration outage pins | | C | the plugin's binding admits everyone | 5: the integration refusal and fault pins | | D | the plugin's binding stops re-raising an outage | 1: the integration outage envelope pin | | E | a write's own copy is asked again | 1: the unit control "claimed without a second question" | | F | a system write is asked | 1: the unit system-write pin | ## Gates All on head `4f3c21e5`. The service-storage closure was built first, then the whole repo (`pnpm build`, 119/119 tasks). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 102 gates. All 102 exited 0, and `--ran` reconciles them: 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm lint` (repo-wide `eslint . --no-inline-config`): exit 0. - `check:commit-card-trailers --range origin/main..HEAD`: all 6 commits carry the model-free trailer pair. - Dogfood battery (`pnpm --filter @objectstack/dogfood test`, no shard, no path, under the verify lock): 295 of 299 files passed and 3 were skipped; 2522 of 2541 tests passed and 18 were skipped. The one failure was `packaged-flow-write-door-parity.dogfood.test.ts`, "a flow no package ships is still created, updated and removed through the same door", which hit its 5 s timeout while the 102 gates ran on the same 4 cores. Re-run alone under the lock: 5/5 passed. The test touches no file field. ## Acceptance notes - #25694 (queued after this card, same file) should be re-read against this head before work on it starts. - The ADR-0104 D3 text is amended in a separate docs PR, so this code PR carries no Tier H surface. --- _Generated by [Claude Code](https://claude.ai/code/session_016DTRymn12to8YSGMcTzS7h)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d5f4c2a commit 3761710

7 files changed

Lines changed: 1025 additions & 82 deletions

File tree

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 claims or copies only a file its caller may read
6+
7+
The file-reference claim path (ADR-0104 D3: a file field holds a `sys_file` id, at most one record's field owns a file, and writing an id another record owns copies the bytes into a fresh file the writing record owns) now asks, before it copies or claims a referenced file, whether the write's caller may read that file. The question is the download door's own verdict, asked under the write's caller context: the uploader, a reader of the one record whose field owns the file (its object's `fileAccessDelegate` where one is declared), a member of the organization for a file no record owns, a reader of a parent record for an attachments-scope file, anyone for a `public_read` file. The door and the claim path now call one judgement, so one file has one access rule.
8+
9+
A file the caller may not read is answered as the organization-scoped lookup answers a file it cannot see: the value is kept as written, nothing is claimed or copied, and constraint verdicts no longer run ahead of the read check. A copy this write made is claimed by the same write without a second question. System writes and context-less programmatic calls have no caller to ask and reference files as before; so does a kernel with no `auth` service, where the download door binds no gate. An authorization-store outage fails the write with its declared envelope instead of reading as either answer.
10+
11+
Clause-②: no (narrowing) — a record write reaches fewer files: a file its caller may not read is no longer copied or claimed for it. Nothing that was refused becomes accepted.
12+
13+
Metadata-protocol: none (a write path's file bookkeeping; nothing a metadata author writes changes)
14+
15+
<!-- 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 files the file-reference claim path copies or claims for a record write (those its caller may read). The remedy is a change of who writes the reference (a caller who may read the file), 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 write that referenced a file its caller may not read, shipped as `patch` because the metadata protocol does not move.
18+
19+
- **Before.** The claim path copied or claimed a referenced file without asking whether the write's caller may read it.
20+
- **Now.** Both happen only when the caller may read the file under the download door's rule. Otherwise the record keeps the id as written and owns nothing: the file's readers are unchanged, and the written record's readers gain no access to it through that record.
21+
- **Fix.** Reference a file as a caller who may read it (its uploader, or a reader of the record that owns it), or upload the file again. A server-side process that copies files between records on purpose runs as a system write.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ One of the largest consumers of the flag;
178178
| 47c | The inbound-mail IMAP sync: `syncMailbox` and `syncAll` admit the system context ALONE | service-mailbox | Get: the sync job reads every mailbox row (its organization, address and IMAP cursor), writes the cursor back, and hands each fetched message to `ingest` under the system context. Lose: nothing a user held — every other caller is refused (`PERMISSION_DENIED`), because a sync stores a mailbox's mail and moves its cursor | `packages/services/service-mailbox/src/sync/mailbox-sync-service.ts#syncMailbox`, `#syncAll` |
179179
| 47d | The print service REFUSES the system context: `printRecordToFile` admits a human user acting for themselves alone | service-print | Get: nothing — a system caller is refused (`PERMISSION_DENIED`) before any page is read, session minted or browser started. Lose: nothing a user held — the PDF is rendered as the requesting user and never as the system, so a record is archived only by someone who could read it | `packages/services/service-print/src/print-service.ts#refuseUnlessHumanCaller` |
180180
| 47e | The caller-scoped attach verbs REFUSE the system context: `attachFileAs` and `assertMayAttach` admit a signed-in user alone | service-storage | Get: nothing — a system caller is refused (`PERMISSION_DENIED`); the system attaches through `attachFile`, whose rows name no user. Lose: nothing a user held — the caller-scoped verbs insert the attachment under the caller's context so every insert gate judges that user and both rows name them | `packages/services/service-storage/src/file-attachment-service.ts#requireUserContext` |
181+
| 47f | The file-reference claim path asks no read verdict of a system write: a record write claims or copies a referenced file only as its caller may read it (the download door's verdict), and a system write carries no caller to ask | service-storage | Get: a system write claims or copies any file the organization-scoped lookup reaches, as before #25692; a deliberate server-side copy between records runs this way. Lose: nothing a user held — a user write is judged by the download door's verdict, and a context-less programmatic write is not asked either | `packages/services/service-storage/src/file-reference-lifecycle.ts#referencingCallerOf` |
181182

182183
### 5. Actions, metadata plane, provenance, the organization wall
183184

‎packages/services/service-storage/src/attachment-access-hooks.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,10 @@ function asIdList(id: unknown): Array<string | number> | null {
268268
* context resolved for the object of the CURRENT operation, which here is
269269
* `sys_attachment`. Every gate in this module asks about the PARENT record's
270270
* object, never about `sys_attachment`, so carrying any of them across is one
271-
* object's widening applied to another object's question.
271+
* object's widening applied to another object's question. [#25692] The
272+
* file-reference lifecycle reads a record write's caller through the same
273+
* {@link callerContext}, for the same reason: it asks about a FILE's owning
274+
* record, never about the object being written.
272275
*
273276
* [#7145] The general rule — which keys those are, why they are dropped by
274277
* PREFIX rather than by a name list, and why the copy is load-bearing in both
@@ -318,7 +321,7 @@ function asIdList(id: unknown): Array<string | number> | null {
318321
* object's grant. Where that judgement is not served, record sharing is asked
319322
* with no depth, so its owner-match stays at its narrowest (`own`) — the safe
320323
* direction. */
321-
function callerContext(ctx: any): ExecutionContext {
324+
export function callerContext(ctx: any): ExecutionContext {
322325
const exec = ctx?.input?.options?.context;
323326
if (exec && typeof exec === 'object') {
324327
return withoutOperationPrivateKeys(exec as Record<string, unknown>);

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

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,3 +1070,290 @@ describe('File Reference Ownership (ADR-0104 D3 wave 2)', () => {
10701070
expect(engine.calls.filter((c) => c.op === 'update' && c.object === 'sys_file')).toHaveLength(0);
10711071
});
10721072
});
1073+
1074+
/**
1075+
* [#25692] A write references only a file its caller may read.
1076+
*
1077+
* The hooks ask the read verdict the storage plugin binds (the download
1078+
* door's own judgement) of the WRITE's caller, before copy-on-claim and
1079+
* before the claim. Here the verdict is a stand-in that admits a fixed set of
1080+
* file ids; `file-reference-read-verdict.integration.test.ts` pins the real
1081+
* binding against the door. Each refusal pin below is ablated in the PR that
1082+
* introduced it.
1083+
*/
1084+
describe('a write references only a file its caller may read', () => {
1085+
const CALLER = { userId: 'u_writer', tenantId: 'org_1' };
1086+
const session = { userId: CALLER.userId, organizationId: CALLER.tenantId };
1087+
1088+
/** A verdict that admits only `readable`, recording every question asked. */
1089+
function verdict(readable: string[]) {
1090+
const asked: Array<{ fileId: unknown; context: any }> = [];
1091+
const authorizeRead = vi.fn(async (f: Record<string, unknown>, context: any) => {
1092+
asked.push({ fileId: f.id, context });
1093+
return readable.includes(String(f.id));
1094+
});
1095+
return { authorizeRead, asked };
1096+
}
1097+
1098+
function installWith(engine: Engine, authorizeRead: any, storage: any = fakeStorage()) {
1099+
installFileReferenceHooks(engine, () => storage, silentLogger(), authorizeRead);
1100+
return { storage };
1101+
}
1102+
1103+
/** {@link driveInsert}, as a signed-in caller's write carries it: a hook
1104+
* session plus the caller's context on the operation options. */
1105+
async function insertAs(
1106+
engine: Engine,
1107+
object: string,
1108+
data: Record<string, unknown>,
1109+
id: string,
1110+
caller: { session?: any; context?: any } = { session, context: CALLER },
1111+
) {
1112+
const ctx: any = {
1113+
object,
1114+
event: 'beforeInsert',
1115+
input: { data, options: caller.context ? { context: caller.context } : {} },
1116+
session: caller.session,
1117+
dispatch: recordDispatch(),
1118+
};
1119+
await engine.trigger('beforeInsert', ctx);
1120+
const row: Record<string, unknown> & { id: string } = { ...(ctx.input.data as Record<string, unknown>), id };
1121+
(engine.tables[object] ??= []).push(row);
1122+
ctx.event = 'afterInsert';
1123+
ctx.result = row;
1124+
await engine.trigger('afterInsert', ctx);
1125+
return row;
1126+
}
1127+
1128+
async function updateAs(engine: Engine, object: string, id: string, data: Record<string, unknown>) {
1129+
const ctx: any = {
1130+
object,
1131+
event: 'beforeUpdate',
1132+
input: { id, data, options: { context: CALLER } },
1133+
session,
1134+
dispatch: recordDispatch(),
1135+
};
1136+
await engine.trigger('beforeUpdate', ctx);
1137+
const row = (engine.tables[object] ?? []).find((r) => String(r.id) === String(id));
1138+
if (row) Object.assign(row, ctx.input.data);
1139+
ctx.event = 'afterUpdate';
1140+
ctx.result = row;
1141+
await engine.trigger('afterUpdate', ctx);
1142+
return row;
1143+
}
1144+
1145+
const ownedByP1 = () => file({ ref_object: 'product', ref_id: 'p1', ref_field: 'image' });
1146+
const sysFileWrites = (engine: Engine) =>
1147+
engine.calls.filter((c) => (c.op === 'update' || c.op === 'insert') && c.object === 'sys_file');
1148+
1149+
describe('a file another record owns', () => {
1150+
it('is not copied for a caller the verdict refuses: the value is kept as written', async () => {
1151+
const engine = fakeEngine({ files: [ownedByP1()], records: { product: [{ id: 'p1', image: 'file_a' }] } });
1152+
const { authorizeRead } = verdict([]);
1153+
const { storage } = installWith(engine, authorizeRead);
1154+
1155+
const row = await insertAs(engine, 'product', { image: 'file_a' }, 'p2');
1156+
1157+
expect(row.image).toBe('file_a');
1158+
expect(storage.download).not.toHaveBeenCalled();
1159+
expect(storage.upload).not.toHaveBeenCalled();
1160+
expect(engine.tables.sys_file).toHaveLength(1);
1161+
expect(engine.tables.sys_file[0]).toMatchObject({ id: 'file_a', ref_object: 'product', ref_id: 'p1', ref_field: 'image' });
1162+
expect(sysFileWrites(engine)).toEqual([]);
1163+
});
1164+
1165+
it('is not copied by an update for a caller the verdict refuses', async () => {
1166+
const engine = fakeEngine({
1167+
files: [ownedByP1()],
1168+
records: { product: [{ id: 'p1', image: 'file_a' }, { id: 'p2', image: null }] },
1169+
});
1170+
const { authorizeRead } = verdict([]);
1171+
const { storage } = installWith(engine, authorizeRead);
1172+
1173+
const row = await updateAs(engine, 'product', 'p2', { image: 'file_a' });
1174+
1175+
expect(row?.image).toBe('file_a');
1176+
expect(storage.download).not.toHaveBeenCalled();
1177+
expect(engine.tables.sys_file).toHaveLength(1);
1178+
expect(engine.tables.sys_file[0]).toMatchObject({ ref_id: 'p1' });
1179+
});
1180+
1181+
it('CONTROL — is copied for a caller the verdict admits, exactly as before; the copy is claimed without a second question', async () => {
1182+
const engine = fakeEngine({ files: [ownedByP1()], records: { product: [{ id: 'p1', image: 'file_a' }] } });
1183+
const { authorizeRead, asked } = verdict(['file_a']);
1184+
const { storage } = installWith(engine, authorizeRead);
1185+
1186+
const row = await insertAs(engine, 'product', { image: 'file_a' }, 'p2');
1187+
1188+
expect(row.image).not.toBe('file_a');
1189+
expect(storage.download).toHaveBeenCalledWith('user/file_a.png');
1190+
const copy = engine.tables.sys_file.find((f) => f.id === row.image)!;
1191+
expect(copy).toMatchObject({ ref_object: 'product', ref_id: 'p2', ref_field: 'image' });
1192+
expect(engine.tables.sys_file[0]).toMatchObject({ id: 'file_a', ref_id: 'p1' });
1193+
// The verdict was asked about the SOURCE only — the stand-in admits
1194+
// nothing else, so a claim that re-asked about the copy would have
1195+
// left it unclaimed.
1196+
expect(asked.map((q) => q.fileId)).toEqual(['file_a']);
1197+
});
1198+
});
1199+
1200+
describe('a file no record owns', () => {
1201+
it('is not claimed for a caller the verdict refuses', async () => {
1202+
const engine = fakeEngine({ files: [file()] });
1203+
const { authorizeRead } = verdict([]);
1204+
installWith(engine, authorizeRead);
1205+
1206+
const row = await insertAs(engine, 'product', { image: 'file_a' }, 'p1');
1207+
1208+
expect(row.image).toBe('file_a');
1209+
expect(engine.tables.sys_file[0].ref_id ?? null).toBeNull();
1210+
expect(sysFileWrites(engine)).toEqual([]);
1211+
});
1212+
1213+
it('is not claimed by an update for a caller the verdict refuses', async () => {
1214+
const engine = fakeEngine({ files: [file()], records: { product: [{ id: 'p1', image: null }] } });
1215+
const { authorizeRead } = verdict([]);
1216+
installWith(engine, authorizeRead);
1217+
1218+
await updateAs(engine, 'product', 'p1', { image: 'file_a' });
1219+
1220+
expect(engine.tables.sys_file[0].ref_id ?? null).toBeNull();
1221+
expect(sysFileWrites(engine)).toEqual([]);
1222+
});
1223+
1224+
it('a tombstoned file is not brought back for a caller the verdict refuses', async () => {
1225+
const engine = fakeEngine({ files: [file({ status: 'deleted', deleted_at: '2026-01-01T00:00:00.000Z' })] });
1226+
const { authorizeRead } = verdict([]);
1227+
installWith(engine, authorizeRead);
1228+
1229+
await insertAs(engine, 'product', { image: 'file_a' }, 'p1');
1230+
1231+
expect(engine.tables.sys_file[0]).toMatchObject({ status: 'deleted', deleted_at: '2026-01-01T00:00:00.000Z' });
1232+
expect(engine.tables.sys_file[0].ref_id ?? null).toBeNull();
1233+
});
1234+
1235+
it('CONTROL — is claimed for a caller the verdict admits', async () => {
1236+
const engine = fakeEngine({ files: [file()] });
1237+
const { authorizeRead } = verdict(['file_a']);
1238+
installWith(engine, authorizeRead);
1239+
1240+
await insertAs(engine, 'product', { image: 'file_a' }, 'p1');
1241+
1242+
expect(engine.tables.sys_file[0]).toMatchObject({ ref_object: 'product', ref_id: 'p1', ref_field: 'image' });
1243+
});
1244+
1245+
it('each id of a multiple:true field is judged on its own', async () => {
1246+
const engine = fakeEngine({ files: [file(), file({ id: 'file_b', key: 'user/file_b.png' })] });
1247+
const { authorizeRead } = verdict(['file_b']);
1248+
installWith(engine, authorizeRead);
1249+
1250+
const row = await insertAs(engine, 'product', { gallery: ['file_a', 'file_b'] }, 'p1');
1251+
1252+
expect(row.gallery).toEqual(['file_a', 'file_b']);
1253+
expect(engine.tables.sys_file.map((f) => [f.id, f.ref_id ?? null])).toEqual([
1254+
['file_a', null],
1255+
['file_b', 'p1'],
1256+
]);
1257+
});
1258+
});
1259+
1260+
it('constraint verdicts do not run ahead of the read check', async () => {
1261+
const engine = fakeEngine({
1262+
files: [file({ mime_type: 'application/pdf', size: 10_000_000 })],
1263+
registry: {
1264+
sys_file: REGISTRY.sys_file,
1265+
product: { name: 'product', fields: { id: { type: 'text' }, image: { type: 'image', accept: ['image/*'], maxSize: 1000 } } },
1266+
},
1267+
});
1268+
const { authorizeRead } = verdict([]);
1269+
installWith(engine, authorizeRead);
1270+
1271+
const row = await insertAs(engine, 'product', { image: 'file_a' }, 'p1');
1272+
1273+
expect(row.image).toBe('file_a');
1274+
expect(engine.tables.sys_file[0].ref_id ?? null).toBeNull();
1275+
});
1276+
1277+
it('CONTROL — a file the caller may read is still judged against its field\'s declaration', async () => {
1278+
const engine = fakeEngine({
1279+
files: [file({ mime_type: 'application/pdf', size: 10 })],
1280+
registry: {
1281+
sys_file: REGISTRY.sys_file,
1282+
product: { name: 'product', fields: { id: { type: 'text' }, image: { type: 'image', accept: ['image/*'] } } },
1283+
},
1284+
});
1285+
const { authorizeRead } = verdict(['file_a']);
1286+
installWith(engine, authorizeRead);
1287+
1288+
const err = await insertAs(engine, 'product', { image: 'file_a' }, 'p1').then(() => null, (e) => e);
1289+
1290+
expect(err).toBeInstanceOf(FileConstraintError);
1291+
expect({ code: err.code, status: err.status }).toEqual({ code: 'ERR_FILE_CONSTRAINT', status: 400 });
1292+
});
1293+
1294+
it('asks the verdict of the write\'s caller context, taken from the operation options', async () => {
1295+
const engine = fakeEngine({ files: [ownedByP1()], records: { product: [{ id: 'p1', image: 'file_a' }] } });
1296+
const { authorizeRead, asked } = verdict([]);
1297+
installWith(engine, authorizeRead);
1298+
const context = { ...CALLER, permissions: ['member_default'], __operationPrivate: 'dropped' };
1299+
1300+
await insertAs(engine, 'product', { image: 'file_a' }, 'p2', { session, context });
1301+
1302+
expect(asked.length).toBeGreaterThan(0);
1303+
for (const q of asked) {
1304+
expect(q.fileId).toBe('file_a');
1305+
expect(q.context).toMatchObject({ userId: 'u_writer', tenantId: 'org_1', permissions: ['member_default'] });
1306+
expect(q.context).not.toHaveProperty('__operationPrivate');
1307+
}
1308+
});
1309+
1310+
it('an authorization-store outage fails the write rather than reading as either answer', async () => {
1311+
const engine = fakeEngine({ files: [ownedByP1()], records: { product: [{ id: 'p1', image: 'file_a' }] } });
1312+
const outage = Object.assign(new Error('authorization store unavailable'), { code: 'SERVICE_UNAVAILABLE', status: 503 });
1313+
const authorizeRead = vi.fn(async () => { throw outage; });
1314+
const { storage } = installWith(engine, authorizeRead);
1315+
1316+
const err = await insertAs(engine, 'product', { image: 'file_a' }, 'p2').then(() => null, (e) => e);
1317+
1318+
expect(err).toBe(outage);
1319+
expect(storage.download).not.toHaveBeenCalled();
1320+
// The before hook threw, so the driver never wrote the record.
1321+
expect((engine.tables.product ?? []).map((r) => r.id)).toEqual(['p1']);
1322+
});
1323+
1324+
describe('writes with no caller to ask reference files as before', () => {
1325+
it('a system write is not asked, and copies as before', async () => {
1326+
const engine = fakeEngine({ files: [ownedByP1()], records: { product: [{ id: 'p1', image: 'file_a' }] } });
1327+
const { authorizeRead } = verdict([]);
1328+
installWith(engine, authorizeRead);
1329+
1330+
const row = await insertAs(engine, 'product', { image: 'file_a' }, 'p2', {
1331+
session: { ...session, isSystem: true },
1332+
context: { ...CALLER, isSystem: true },
1333+
});
1334+
1335+
expect(authorizeRead).not.toHaveBeenCalled();
1336+
expect(row.image).not.toBe('file_a');
1337+
});
1338+
1339+
it('a context-less programmatic write is not asked, and claims as before', async () => {
1340+
const engine = fakeEngine({ files: [file()] });
1341+
const { authorizeRead } = verdict([]);
1342+
installWith(engine, authorizeRead);
1343+
1344+
await insertAs(engine, 'product', { image: 'file_a' }, 'p1', {});
1345+
1346+
expect(authorizeRead).not.toHaveBeenCalled();
1347+
expect(engine.tables.sys_file[0]).toMatchObject({ ref_id: 'p1' });
1348+
});
1349+
1350+
it('no verdict bound (a bare kernel): nothing is asked, and files are claimed as before', async () => {
1351+
const engine = fakeEngine({ files: [file()] });
1352+
install(engine);
1353+
1354+
await insertAs(engine, 'product', { image: 'file_a' }, 'p1');
1355+
1356+
expect(engine.tables.sys_file[0]).toMatchObject({ ref_id: 'p1' });
1357+
});
1358+
});
1359+
});

0 commit comments

Comments
 (0)