diff --git a/.changeset/22466-refused-attach-tombstone.md b/.changeset/22466-refused-attach-tombstone.md new file mode 100644 index 00000000000..66ad9a2fe5e --- /dev/null +++ b/.changeset/22466-refused-attach-tombstone.md @@ -0,0 +1,17 @@ +--- +'@objectstack/service-storage': patch +--- + +fix(service-storage): an attach the attachment gate refuses no longer leaves the uploaded file stored forever — the caller's own never-attached file is tombstoned and reclaimed by the sweep + +Clause-②: no + +Attaching a file is two writes: the upload commits a `sys_file` (scope `attachments`), and then a `sys_attachment` insert attaches it to a record. When the attachment gate refused that insert with `403 ATTACHMENT_PARENT_ACCESS`, the file stayed `committed` with no attachment pointing at it. Nothing ever reclaimed it: files were tombstoned only when they lost their last attachment, and this one never had one. + +- **Now:** when the attachment gate refuses an attach, the file is tombstoned (`status: 'deleted'`, `deleted_at` set), the same way a file is tombstoned when its last attachment is removed. This covers every way the gate refuses: sharing denies edit on the parent, the parent's master record denies it (`controlled_by_parent`), or the parent cannot be read. From there the file follows the existing path. The lifecycle sweep reclaims the row and its bytes 30 days after `deleted_at`. At sweep time it checks again that nothing holds the file. +- **Only the caller's own unheld upload:** the file must be an `attachments`-scope, `committed` file. The refused caller must be its uploader (`sys_file.owner_id`). It must have no attachment and no field owner (`ref_*`). A refused attach that names someone else's file, a file another record still holds, or a field file changes nothing. +- **It survives a rollback:** the tombstone is written after the refusal, outside the refused write's transaction. If the attach ran inside a caller's own transaction, an `atomic` batch for example, rolling that transaction back does not undo the tombstone. +- **Unchanged:** the refusal itself (status, code, message), which does not depend on the file and says nothing about it; an admitted attach; and a retry. Retrying the same `file_id` within the 30 days is admitted or refused like any attach. An admitted retry attaches the file and brings it back to `committed`, as re-attaching a detached file always has. +- **Not covered:** an attach refused before the attachment gate runs still leaves the uploaded file `committed`. That is an attach with no `sys_attachment` create grant (`403 PERMISSION_DENIED`), or one to a parent with `enable.files` off (`403 FILES_DISABLED`). This is tracked in #22547. + +What changes for you: nothing to do. When the attachment gate refuses an attach, the uploaded file no longer uses storage indefinitely. diff --git a/packages/services/service-storage/src/attachment-access-hooks.ts b/packages/services/service-storage/src/attachment-access-hooks.ts index c078a57acb1..4f39b0c69bd 100644 --- a/packages/services/service-storage/src/attachment-access-hooks.ts +++ b/packages/services/service-storage/src/attachment-access-hooks.ts @@ -6,10 +6,11 @@ import type { ISecurityService, ISharingService } from '@objectstack/spec/contra import type { ExecutionContext } from '@objectstack/spec/kernel'; import { renderOperationMessage, type ValidationMessageTranslator } from '@objectstack/spec/system'; -import type { - AttachmentLifecycleEngine, - AttachmentLifecycleLogger, - AttachmentReadMiddlewareCtx, +import { + createRefusedAttachTombstoner, + type AttachmentLifecycleEngine, + type AttachmentLifecycleLogger, + type AttachmentReadMiddlewareCtx, } from './attachment-lifecycle.js'; /** @@ -26,7 +27,9 @@ import type { * "Parent EDIT" below; Salesforce parity, #2970 item 3 — v1 asked read * visibility, which is now only the degraded mode). Fail-closed 403 * `ATTACHMENT_PARENT_ACCESS`. `uploaded_by` is server-stamped from the - * session — a client-supplied value never wins. + * session — a client-supplied value never wins. A refusal also hands the + * refused `file_id` to the lifecycle, which tombstones the caller's own + * never-attached upload (#22466, `createRefusedAttachTombstoner`). * - beforeUpdate (commit da891e0ef): the caller must be the uploader OR hold edit on * the parent record — the delete rule, applied to the verb that could * otherwise rewrite the other two gates away: an ungated update let any @@ -371,6 +374,15 @@ export function installAttachmentAccessHooks( messageTranslator?: () => ValidationMessageTranslator | undefined, getSecurity?: () => AttachmentSecurityLike | null | undefined, ): void { + /** + * [#22466] What a refused attach does about its file. Created HERE, at + * installation — outside every engine operation — because the tombstone it + * schedules runs in a snapshot of this context: outside the refused write's + * unit of work, so a caller's transaction rolling back cannot take the + * tombstone with it. See {@link createRefusedAttachTombstoner}. + */ + const tombstoneRefusedAttach = createRefusedAttachTombstoner(engine, logger); + /** * May the caller EDIT the parent record `(object, recordId)`? The one * question the attach rule, the row rule's parent-editor limb and the @@ -573,6 +585,14 @@ export function installAttachmentAccessHooks( // its master, as its own update is. const allowed = await mayEditParent(ctx, parentObject, parentId, callerContext(ctx), 'attach'); if (!allowed) { + // [#22466] Every refusal leg of the attach rule arrives HERE — sharing + // `deny` or a non-verdict, the master-detail check's `deny` or + // `unresolvable`, the degraded read probe's miss — so this one call + // covers them all and no leg can drift. A REJECTION from either check + // (an outage) never reaches this line: it is no verdict, and keeps + // its own status. The tombstone is scheduled, never awaited, and is + // conditional on the file being the caller's own unheld upload. + tombstoneRefusedAttach(data.file_id, ctx.session.userId); forbid( 'ATTACHMENT_PARENT_ACCESS', `Cannot attach to ${parentObject}/${parentId}: the parent record does not exist or you cannot edit it`, diff --git a/packages/services/service-storage/src/attachment-lifecycle.ts b/packages/services/service-storage/src/attachment-lifecycle.ts index 44cdd52eb37..10c5ee24113 100644 --- a/packages/services/service-storage/src/attachment-lifecycle.ts +++ b/packages/services/service-storage/src/attachment-lifecycle.ts @@ -1,6 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { AsyncLocalStorage } from 'node:async_hooks'; import type { IStorageService } from '@objectstack/spec/contracts'; +import type { FileRecord } from './metadata-store.js'; +import { isFileUploader } from './upload-ownership.js'; /** * sys_file orphan lifecycle (#2755, ADR-0057). @@ -15,8 +18,10 @@ import type { IStorageService } from '@objectstack/spec/contracts'; * LAST join row referencing an attachments-scope file goes away, the * `sys_file` row is marked `status='deleted'` + `deleted_at=now`. A join * row goes away two ways, and both are covered: it is DELETED, or an - * UPDATE re-points its `file_id` at some other file (#10171). - * Re-attaching before the grace window expires un-tombstones it. + * UPDATE re-points its `file_id` at some other file (#10171). A file that + * never GAINED a join row because its attach was refused takes the same + * tombstone, from the attach gate ({@link createRefusedAttachTombstoner}, + * #22466). Re-attaching before the grace window expires un-tombstones it. * 2. The `lifecycle` declaration on `sys_file` (system-file.object.ts): * the platform LifecycleService reaps tombstones `30d` after * `deleted_at`, and never-completed `pending` uploads after `7d`. @@ -87,6 +92,32 @@ export interface AttachmentLifecycleLogger { const PACKAGE_ID = 'com.objectstack.service.storage'; const SYSTEM_CTX = { isSystem: true } as const; +/** + * May this module tombstone this `sys_file` row at all? Only a live + * (`committed`) file of the `attachments` scope — the one answer every trigger + * asks, whatever made it look orphaned. Field-file scopes have their own seam + * (see the module header), and a row that is `pending` or already `deleted` + * has nothing to move. + */ +function isLiveAttachmentsFile(file: Record | null | undefined): file is Record { + return !!file && file.scope === 'attachments' && file.status === 'committed'; +} + +/** + * THE tombstone — `status='deleted'` + `deleted_at=now`, written under system + * context. One shape for every trigger, because the declared lifecycle reads + * exactly these two columns: `ttl { field: 'deleted_at' }` nominates the row + * and the reap guard below confirms it on `status === 'deleted'`. A second + * spelling of it would be a second thing for the sweep to miss. + */ +async function writeTombstone(engine: Pick, fileId: string): Promise { + await engine.update( + 'sys_file', + { id: fileId, status: 'deleted', deleted_at: new Date().toISOString() }, + { context: { ...SYSTEM_CTX } }, + ); +} + /** * Tombstone every id in `fileIds` that no longer has a join row — the orphan * rule, in ONE place because two write verbs now ask it (`afterDelete`, and @@ -110,12 +141,8 @@ async function tombstoneOrphanedFiles( }); if (remaining?.length) continue; const file = await engine.findOne('sys_file', { where: { id: fileId }, context: { ...SYSTEM_CTX } }); - if (!file || file.scope !== 'attachments' || file.status !== 'committed') continue; - await engine.update( - 'sys_file', - { id: fileId, status: 'deleted', deleted_at: new Date().toISOString() }, - { context: { ...SYSTEM_CTX } }, - ); + if (!isLiveAttachmentsFile(file)) continue; + await writeTombstone(engine, fileId); logger.debug?.(`[storage] attachment lifecycle: tombstoned orphan sys_file ${fileId}`); } catch (err) { logger.warn( @@ -413,6 +440,140 @@ export async function findHeldFiles( return held; } +/** + * Why a refused attach did nothing about its file, or that it tombstoned it — + * the one debug line each scheduled run ends on, whichever way it went. + */ +type RefusedAttachOutcome = + | 'tombstoned' + | 'kept: no such file' + | 'kept: not a committed attachments-scope file' + | 'kept: the refused caller is not its uploader' + | `kept: still held (${Exclude})`; + +/** + * [#22466] A REFUSED attach tombstones the caller's own never-attached file. + * + * The console's upload is two writes: the presigned upload commits a + * `sys_file` (scope `attachments`, `owner_id` = the uploader), and only then + * does a `sys_attachment` insert attach it. When the attach gate refuses that + * insert, the file is left `committed` with zero join rows, and nothing above + * ever reaches it: the hooks tombstone only a file that LOSES its last join + * row, and the declared lifecycle nominates only `deleted_at` (ttl) or + * `pending` (retention) rows. Measured on `main` before this change, on every + * refusal leg of the gate: refused 403, file `committed`, `deleted_at` null, + * zero join rows — kept forever. + * + * So the gate hands the refused `file_id` here, and the file takes the SAME + * tombstone a last-join-row removal writes ({@link writeTombstone}). From + * there it follows the existing path and nothing new: `deleted_at` → the 30d + * `ttl` → {@link createSysFileReapGuard}, which re-checks {@link + * findFileHolder} at sweep time. A retry that attaches the same `file_id` + * inside the window is admitted or refused exactly as any attach is; an + * admitted one is revived by the `afterInsert` leg above, as a re-attach of a + * detached file always was. + * + * Tombstoned only when ALL of these hold, read AFTER the refusal: + * - the file is `attachments`-scope and `committed` ({@link isLiveAttachmentsFile}); + * - nothing holds it — {@link findFileHolder}, the ONE definition: zero join + * rows AND no `ref_*` owner, so a file in field-file lineage is never + * tombstoned by this path, whatever its scope says; + * - the refused caller is its uploader — {@link isFileUploader}, the upload + * ownership rule, which reads `sys_file.owner_id` (the uploader the upload + * doors stamp; the file row carries no `uploaded_by`). A caller who names + * someone else's file id in a refused attach moves nothing. + * + * ## Outside the refused write's unit of work — and why that needs a snapshot + * + * Measured on a real engine over sqlite: `engine.insert` opens NO transaction + * of its own (the refusal throws from `beforeInsert` with none open), and the + * generic `/data` create door calls it without one (`createData` in the + * protocol), so on that door a system write made while the refusal unwinds + * lands and survives. But a caller may wrap the attach in a transaction of its + * own — an `atomic` batch, an explicit `transaction()` — and every engine call + * issued inside it, hook and middleware writes included, joins that + * transaction through the engine's ambient store (ADR-0034) and is rolled back + * with it: measured, the same write did not survive. A tombstone written from + * the gate in-line would therefore vanish exactly when the refusal aborts the + * caller's unit of work. + * + * The run is therefore DETACHED from the refusal's async context: it starts + * on a later turn of the event loop, inside a snapshot of the context this + * tombstoner was CREATED in — the hook installation, outside every engine + * operation — where the engine's ambient transaction store holds nothing. It + * reads and writes on its own connection, after the refusal has left the + * operation, and outlives a rollback. Two properties make detaching safe: + * - it is never awaited by the refused write. Awaiting an out-of-transaction + * query from inside an open transaction is the single-connection deadlock + * ADR-0034 was written about; a detached one simply queues for the + * connection until the transaction lets go of it; + * - the tombstone is reconcilable by design. If something attaches the file + * between the refusal and this run, the sweep's `findFileHolder` re-check + * un-tombstones it instead of reaping it, and the download and hydration + * paths already ask the same question of a tombstone (#10246, c3c72a4bc). + * + * Best-effort, like every leg here: a failure is logged and never reaches the + * refused caller, whose refusal is byte-identical whether this ran or not. + * Nothing it reads is returned to the caller, and the refusal does not wait on + * it, so the refusal discloses nothing about the file — not even through its + * timing. + * + * ⚠️ Create it where the gate is INSTALLED (outside any engine operation). + * Created inside a transaction, the snapshot would capture that transaction + * and every later run would ride a closed handle. + */ +export function createRefusedAttachTombstoner( + engine: Pick, + logger: AttachmentLifecycleLogger, +): (fileId: unknown, callerUserId: string | undefined) => void { + const outsideAnyOperation = AsyncLocalStorage.snapshot(); + return (fileId, callerUserId) => { + // No file named, or no caller to be its uploader: nothing could pass the + // conditions below, so nothing is scheduled. + if (!((typeof fileId === 'string' && fileId !== '') || typeof fileId === 'number')) return; + if (typeof callerUserId !== 'string' || callerUserId === '') return; + const id = String(fileId); + outsideAnyOperation(() => { + setImmediate(() => { + void tombstoneRefusedAttachFile(engine, logger, id, callerUserId); + }); + }); + }; +} + +async function tombstoneRefusedAttachFile( + engine: Pick, + logger: AttachmentLifecycleLogger, + fileId: string, + callerUserId: string, +): Promise { + let outcome: RefusedAttachOutcome; + try { + const file = await engine.findOne('sys_file', { where: { id: fileId }, context: { ...SYSTEM_CTX } }); + if (!file) { + outcome = 'kept: no such file'; + } else if (!isLiveAttachmentsFile(file)) { + outcome = 'kept: not a committed attachments-scope file'; + } else if (!isFileUploader(callerUserId, file as Pick)) { + outcome = 'kept: the refused caller is not its uploader'; + } else { + const holder = await findFileHolder(engine, fileId, file); + if (holder) { + outcome = `kept: still held (${holder})`; + } else { + await writeTombstone(engine, fileId); + outcome = 'tombstoned'; + } + } + } catch (err) { + logger.warn( + `[storage] attachment lifecycle: failed to tombstone sys_file ${fileId} after a refused attach (${(err as Error)?.message ?? err})`, + ); + return; + } + logger.debug?.(`[storage] attachment lifecycle: refused attach of sys_file ${fileId} — ${outcome}`); +} + /** * The `sys_file` reap guard ({@link LifecycleReapGuard} shape from * `@objectstack/objectql`, duck-typed here to avoid the dependency). diff --git a/packages/services/service-storage/src/attachment-refused-attach-tombstone.test.ts b/packages/services/service-storage/src/attachment-refused-attach-tombstone.test.ts new file mode 100644 index 00000000000..730fe108007 --- /dev/null +++ b/packages/services/service-storage/src/attachment-refused-attach-tombstone.test.ts @@ -0,0 +1,395 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22466] A refused attach tombstones the caller's own never-attached file. + * + * The defect, measured on `main` before the change on every refusal leg of the + * attach gate: the presigned upload has committed an `attachments`-scope + * `sys_file`, the `sys_attachment` insert that would attach it is refused + * (`403 ATTACHMENT_PARENT_ACCESS`), and the file is left `committed`, with a + * NULL `deleted_at` and zero join rows. The hooks tombstone only a file that + * LOSES its last join row and the declared lifecycle nominates only + * `deleted_at` / `pending` rows, so nothing ever reclaimed it. + * + * What these pin, through a REAL `ObjectQL` engine over sqlite `:memory:` (the + * package's ruled test backend; a real driver with real transactions on a + * single-connection pool, which is the property the transaction pins turn on): + * + * - one refusal per leg of the attach rule — sharing `deny`, a sharing + * non-verdict, the master-detail check's `deny` and `unresolvable` (the leg + * the `controlled_by_parent` fix added), and the degraded read probe's miss + * — each leaves the file tombstoned, and the platform sweep reclaims it; + * - the tombstone is written outside the refused write's unit of work: the + * generic insert opens no transaction, and inside a caller's transaction + * that rolls back, the tombstone survives the rollback; + * - the controls: an admitted attach leaves the file `committed`; a file in + * field-file lineage (`ref_*`) is never tombstoned by this path; nor is a + * file the refused caller did not upload, a file still held by another join + * row, or a file that is not a committed attachments-scope file; + * - a client retry of the same `file_id` inside the 30-day window gets a + * clear answer: admitted ⇒ attached and revived; refused ⇒ the same refusal, + * and the file stays tombstoned; + * - the refusal itself is unchanged and names nothing about the file. + * + * The tombstone runs DETACHED from the refusal (see + * `createRefusedAttachTombstoner`), so every pin waits for the one debug line + * each run ends on — the run's own verdict, tombstoned or kept and why — + * rather than for a timer. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectQL, LifecycleService } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SystemFile } from './objects/system-file.object.js'; +import { createSysFileReapGuard, installAttachmentLifecycleHooks } from './attachment-lifecycle.js'; +import { + installAttachmentAccessHooks, + type AttachmentSecurityLike, + type AttachmentSharingLike, +} from './attachment-access-hooks.js'; + +const DAY_MS = 86_400_000; +const SYS = { context: { isSystem: true } } as const; +const UPLOADER = 'usr_uploader'; +const SOMEONE_ELSE = 'usr_someone_else'; + +type Verdict = Awaited>; +type MasterOutcome = Awaited>>; + +function recordingLogger() { + const logger = { + info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn(), + trace: vi.fn(), fatal: vi.fn(), + child() { return logger; }, + }; + return logger; +} + +let engine: ObjectQL | undefined; + +afterEach(async () => { + // Closes the `:memory:` database with the engine that owns it. + try { await engine?.destroy(); } catch { /* already torn down */ } + engine = undefined; +}); + +/** + * A real engine with the storage lifecycle hooks and the attach gate installed + * exactly as `StorageServicePlugin` installs them. `sharing === null` is the + * degraded mode (no sharing service at all). `gate.verdict` / `gate.master` + * are read per check, so a test can change the answer between two attaches — + * a grant that changes between a refusal and the client's retry. + */ +async function boot(opts: { verdict?: Verdict; master?: MasterOutcome; sharing?: null } = {}) { + const gate = { verdict: opts.verdict ?? ('deny' as Verdict), master: opts.master }; + const logger = recordingLogger(); + const ql = new ObjectQL({ logger: recordingLogger() } as any); + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }) as any; + const beginTransaction = vi.spyOn(driver, 'beginTransaction'); + ql.registerDriver(driver, true); + await ql.init(); + engine = ql; + + // The REAL sys_file schema, so the sweep below reads its DECLARED lifecycle + // (`ttl` on `deleted_at`, `retention` on `pending`) — not a copy of it. + ql.registry.registerObject(SystemFile as any, 'test-22466'); + ql.registry.registerObject({ + name: 'sys_attachment', + fields: { + file_id: { type: 'text' }, parent_object: { type: 'text' }, parent_id: { type: 'text' }, + uploaded_by: { type: 'text' }, + }, + } as any, 'test-22466'); + ql.registry.registerObject({ name: 'att_parent', fields: { name: { type: 'text' } } } as any, 'test-22466'); + await ql.syncSchemas(); + + installAttachmentLifecycleHooks(ql as any, logger); + const sharing: AttachmentSharingLike | null = + opts.sharing === null ? null : { checkEdit: async () => gate.verdict }; + const security: AttachmentSecurityLike = { + checkControlledByParentWrite: async () => gate.master ?? { outcome: 'not_applicable' }, + }; + installAttachmentAccessHooks(ql as any, () => sharing, logger, undefined, () => security); + + await ql.insert('att_parent', { id: 'p1', name: 'parent' }, SYS); + beginTransaction.mockClear(); + return { ql, gate, logger, beginTransaction }; +} + +/** A `sys_file` row as the presigned upload leaves it: committed, attachments-scope, uploader stamped. */ +async function seedFile(ql: ObjectQL, id: string, extra: Record = {}) { + await ql.insert('sys_file', { + id, key: `attachments/${id}.bin`, name: `${id}.txt`, scope: 'attachments', + status: 'committed', owner_id: UPLOADER, ...extra, + }, SYS); +} + +const file = (ql: ObjectQL, id: string) => ql.findOne('sys_file', { where: { id }, ...SYS }) as Promise; +const joinRows = (ql: ObjectQL, id: string) => ql.find('sys_attachment', { where: { file_id: id }, ...SYS }); + +/** The generic insert the `/data` create door issues for the console's attach. */ +const attach = (ql: ObjectQL, fileId: string, userId = UPLOADER, parentId = 'p1') => + ql.insert( + 'sys_attachment', + { parent_object: 'att_parent', parent_id: parentId, file_id: fileId }, + { context: { userId } }, + ); + +const rejection = (p: Promise) => p.then(() => { throw new Error('expected a refusal'); }, (e: any) => e); + +/** + * The verdict the detached run ends on for `fileId` — the debug line it + * writes whichever way it went. Waiting on it is waiting on the run itself. + * A run that FAILED writes a warn instead and no verdict; the wait then ends + * (inside vitest's own test timeout) naming the warn, so a red here says why. + */ +async function runVerdict(logger: ReturnType, fileId: string): Promise { + const marker = `refused attach of sys_file ${fileId} — `; + return vi.waitFor( + () => { + const line = logger.debug.mock.calls.map((c) => String(c[0])).find((m) => m.includes(marker)); + if (!line) { + const warns = logger.warn.mock.calls.map((c) => String(c[0])); + throw new Error(`no run verdict for sys_file ${fileId}; warn lines: ${JSON.stringify(warns)}`); + } + return line.slice(line.indexOf(marker) + marker.length); + }, + { timeout: 3_000, interval: 5 }, + ); +} + +const REFUSAL = { code: 'ATTACHMENT_PARENT_ACCESS', status: 403, object: 'att_parent' }; + +describe('a refused attach tombstones the uploader\'s never-attached file — one refusal per leg', () => { + const LEGS: Array<{ leg: string; opts: Parameters[0]; parentId?: string }> = [ + { leg: 'sharing deny', opts: { verdict: 'deny' } }, + { leg: 'sharing non-verdict (fail closed)', opts: { verdict: 'not-a-verdict' as unknown as Verdict } }, + { + leg: 'master-detail check deny (controlled_by_parent)', + opts: { verdict: 'abstain', master: { outcome: 'deny', leg: 'record_sharing' } as MasterOutcome }, + }, + { + leg: 'master-detail check unresolvable (controlled_by_parent)', + opts: { verdict: 'abstain', master: { outcome: 'unresolvable', reason: 'record_not_found' } as MasterOutcome }, + }, + // No sharing service: the gate asks parent READ visibility, and a parent + // the caller cannot read (here: none by that id) is refused. + { leg: 'degraded mode — no sharing service, parent not readable', opts: { sharing: null }, parentId: 'p_missing' }, + ]; + + for (const { leg, opts, parentId } of LEGS) { + it(`${leg}: refused 403, then the file is tombstoned with no join row`, async () => { + const { ql, logger } = await boot(opts); + await seedFile(ql, 'f1'); + + const err = await rejection(attach(ql, 'f1', UPLOADER, parentId)); + expect(err).toMatchObject(REFUSAL); + + expect(await runVerdict(logger, 'f1')).toBe('tombstoned'); + const row = await file(ql, 'f1'); + expect(row).toMatchObject({ id: 'f1', status: 'deleted' }); + expect(row.deleted_at, 'deleted_at is what the declared ttl reads').toBeTruthy(); + expect(await joinRows(ql, 'f1')).toHaveLength(0); + }); + } + + it('the platform sweep reclaims it: past the 30d ttl the reap guard deletes the bytes and the row', async () => { + const { ql, logger, gate } = await boot({ verdict: 'deny' }); + await seedFile(ql, 'f_refused'); + await seedFile(ql, 'f_attached'); // control: attached, committed — must survive the sweep + + await rejection(attach(ql, 'f_refused')); + expect(await runVerdict(logger, 'f_refused')).toBe('tombstoned'); + gate.verdict = 'allow'; + await attach(ql, 'f_attached'); + + const storage = { delete: vi.fn(async () => {}) }; + const lifecycle = new LifecycleService({ + getEngine: () => ql as any, + logger: { info: () => {}, warn: () => {}, debug: () => {} }, + now: () => Date.now() + 31 * DAY_MS, + referenceAudit: { enabled: false }, + }); + lifecycle.registerReapGuard('sys_file', createSysFileReapGuard(ql as any, () => storage as any, logger)); + const report = await lifecycle.sweep(); + + expect(report.errors, JSON.stringify(report.errors)).toEqual([]); + expect(await file(ql, 'f_refused'), 'the row is reaped').toBeNull(); + expect(storage.delete).toHaveBeenCalledTimes(1); + expect(storage.delete).toHaveBeenCalledWith('attachments/f_refused.bin'); + expect(await file(ql, 'f_attached')).toMatchObject({ status: 'committed' }); + }); +}); + +describe('the tombstone is written outside the refused write\'s unit of work', () => { + it('the generic insert opens no transaction: the refusal throws in `beforeInsert` with none open, and the tombstone lands', async () => { + const { ql, logger, beginTransaction } = await boot({ verdict: 'deny' }); + await seedFile(ql, 'f1'); + + await rejection(attach(ql, 'f1')); + + expect(beginTransaction, 'no transaction around the generic insert').not.toHaveBeenCalled(); + expect(await runVerdict(logger, 'f1')).toBe('tombstoned'); + expect(await file(ql, 'f1')).toMatchObject({ status: 'deleted' }); + }); + + it('inside a CALLER\'s transaction that the refusal rolls back, the tombstone survives the rollback', async () => { + const { ql, logger, beginTransaction } = await boot({ verdict: 'deny' }); + await seedFile(ql, 'f1'); + + const err = await rejection( + ql.transaction(async (trx: any) => { + // A write the caller made in the same unit of work — the control that + // the rollback is real. + await ql.insert('att_parent', { id: 'p_in_tx', name: 'rolled back' }, { context: { ...trx, isSystem: true } }); + return ql.insert( + 'sys_attachment', + { parent_object: 'att_parent', parent_id: 'p1', file_id: 'f1' }, + { context: { ...trx, userId: UPLOADER } }, + ); + }), + ); + expect(err).toMatchObject(REFUSAL); + expect(beginTransaction, 'the caller really opened a transaction').toHaveBeenCalledTimes(1); + + expect(await runVerdict(logger, 'f1')).toBe('tombstoned'); + expect(await ql.findOne('att_parent', { where: { id: 'p_in_tx' }, ...SYS }), 'the caller\'s unit of work rolled back').toBeNull(); + expect(await file(ql, 'f1'), 'the tombstone was not in it').toMatchObject({ status: 'deleted' }); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); + +describe('controls — what this path never tombstones', () => { + it('an ADMITTED attach leaves the file committed and attached', async () => { + const { ql, logger } = await boot({ verdict: 'allow' }); + await seedFile(ql, 'f1'); + + await expect(attach(ql, 'f1')).resolves.toBeDefined(); + // Two event-loop turns: past the turn a scheduled run would start on. + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + expect(await file(ql, 'f1')).toMatchObject({ status: 'committed' }); + expect((await file(ql, 'f1')).deleted_at ?? null).toBeNull(); + expect(await joinRows(ql, 'f1')).toHaveLength(1); + expect(logger.debug.mock.calls.map((c) => String(c[0])).filter((m) => m.includes('refused attach'))).toEqual([]); + }); + + it('a file referenced through `ref_*` (field-file lineage) is never tombstoned by this path', async () => { + const { ql, logger } = await boot({ verdict: 'deny' }); + await seedFile(ql, 'f_ref', { ref_object: 'contract', ref_id: 'c1', ref_field: 'signed_pdf' }); + + await rejection(attach(ql, 'f_ref')); + + expect(await runVerdict(logger, 'f_ref')).toBe('kept: still held (field-owner)'); + expect(await file(ql, 'f_ref')).toMatchObject({ status: 'committed' }); + }); + + it('a field-file scope is never tombstoned by this path', async () => { + const { ql, logger } = await boot({ verdict: 'deny' }); + await seedFile(ql, 'f_field', { scope: 'user' }); + + await rejection(attach(ql, 'f_field')); + + expect(await runVerdict(logger, 'f_field')).toBe('kept: not a committed attachments-scope file'); + expect(await file(ql, 'f_field')).toMatchObject({ status: 'committed' }); + }); + + it('a refused caller who is NOT the file\'s uploader moves nothing', async () => { + const { ql, logger } = await boot({ verdict: 'deny' }); + await seedFile(ql, 'f1'); + + await rejection(attach(ql, 'f1', SOMEONE_ELSE)); + + expect(await runVerdict(logger, 'f1')).toBe('kept: the refused caller is not its uploader'); + expect(await file(ql, 'f1')).toMatchObject({ status: 'committed' }); + }); + + it('a file still attached elsewhere is not tombstoned when another attach of it is refused', async () => { + const { ql, logger } = await boot({ verdict: 'deny' }); + await seedFile(ql, 'f1'); + await ql.insert('sys_attachment', { parent_object: 'att_parent', parent_id: 'p1', file_id: 'f1' }, SYS); + + await rejection(attach(ql, 'f1')); + + expect(await runVerdict(logger, 'f1')).toBe('kept: still held (attachment)'); + expect(await file(ql, 'f1')).toMatchObject({ status: 'committed' }); + }); + + it('a pending upload is left to its own retention, and an unknown file id writes nothing', async () => { + const { ql, logger } = await boot({ verdict: 'deny' }); + await seedFile(ql, 'f_pending', { status: 'pending' }); + + await rejection(attach(ql, 'f_pending')); + await rejection(attach(ql, 'f_nowhere')); + + expect(await runVerdict(logger, 'f_pending')).toBe('kept: not a committed attachments-scope file'); + expect(await runVerdict(logger, 'f_nowhere')).toBe('kept: no such file'); + expect(await file(ql, 'f_pending')).toMatchObject({ status: 'pending' }); + expect(await file(ql, 'f_nowhere')).toBeNull(); + }); +}); + +describe('a client retry of the same file_id inside the 30-day window gets a clear answer', () => { + it('admitted on retry: attached, and the tombstone is revived — the re-attach revival, unchanged', async () => { + const { ql, logger, gate } = await boot({ verdict: 'deny' }); + await seedFile(ql, 'f1'); + await rejection(attach(ql, 'f1')); + expect(await runVerdict(logger, 'f1')).toBe('tombstoned'); + + gate.verdict = 'allow'; // the grant changed between the refusal and the retry + await expect(attach(ql, 'f1')).resolves.toBeDefined(); + + const row = await file(ql, 'f1'); + expect(row).toMatchObject({ status: 'committed' }); + expect(row.deleted_at ?? null).toBeNull(); + expect(await joinRows(ql, 'f1')).toHaveLength(1); + }); + + it('refused on retry: the same refusal, and the file stays tombstoned — the first tombstone untouched', async () => { + const { ql, logger } = await boot({ verdict: 'deny' }); + await seedFile(ql, 'f1'); + const first = await rejection(attach(ql, 'f1')); + expect(await runVerdict(logger, 'f1')).toBe('tombstoned'); + const { deleted_at: firstTombstone } = await file(ql, 'f1'); + + logger.debug.mockClear(); + const second = await rejection(attach(ql, 'f1')); + + expect({ code: second.code, status: second.status, message: second.message, object: second.object }).toEqual({ + code: first.code, status: first.status, message: first.message, object: first.object, + }); + expect(await runVerdict(logger, 'f1')).toBe('kept: not a committed attachments-scope file'); + expect(await file(ql, 'f1')).toMatchObject({ status: 'deleted', deleted_at: firstTombstone }); + }); +}); + +describe('the refusal itself is unchanged, and names nothing about the file', () => { + it('the same envelope for the caller\'s own file, another user\'s file and an unknown id', async () => { + const { ql, logger } = await boot({ verdict: 'deny' }); + await seedFile(ql, 'f_own'); + await seedFile(ql, 'f_theirs', { owner_id: SOMEONE_ELSE }); + + const shape = (e: any) => ({ + name: e.name, code: e.code, status: e.status, message: e.message, object: e.object, + keys: Object.keys(e).sort(), + }); + const own = shape(await rejection(attach(ql, 'f_own'))); + const theirs = shape(await rejection(attach(ql, 'f_theirs'))); + const unknown = shape(await rejection(attach(ql, 'f_unknown'))); + + expect(theirs).toEqual(own); + expect(unknown).toEqual(own); + for (const id of ['f_own', 'f_theirs', 'f_unknown']) { + expect(JSON.stringify(own)).not.toContain(id); + } + // Only the caller's own file moved. + expect(await runVerdict(logger, 'f_own')).toBe('tombstoned'); + expect(await runVerdict(logger, 'f_theirs')).toBe('kept: the refused caller is not its uploader'); + expect(await file(ql, 'f_theirs')).toMatchObject({ status: 'committed' }); + }); +});