diff --git a/.changeset/attachments-2755-followups.md b/.changeset/attachments-2755-followups.md new file mode 100644 index 0000000000..dab85f46db --- /dev/null +++ b/.changeset/attachments-2755-followups.md @@ -0,0 +1,38 @@ +--- +'@objectstack/objectql': minor +'@objectstack/service-storage': minor +'@objectstack/platform-objects': minor +'@objectstack/verify': minor +'@objectstack/rest': patch +--- + +feat(attachments): sys_file orphan lifecycle + parent-derived attachment access (#2755) + +**Orphan lifecycle (ADR-0057).** Deleting a `sys_attachment` join row used to +orphan the backing `sys_file` row and its storage bytes forever. `sys_file` +now declares a lifecycle (`ttl 30d` on a new `deleted_at` tombstone for +orphans; `retention 7d onlyWhen status=pending` for abandoned uploads), the +storage plugin's new hooks tombstone a file when its LAST join row is deleted +(attachments scope only — `Field.file`/`Field.image`/avatar scopes are never +touched) and un-tombstone on re-attach, and a new LifecycleService **reap +guard** seam (`registerReapGuard`) re-verifies zero references at sweep time +and deletes the storage bytes before confirming each row reap. A guarded +object is never blind-deleted; an erroring guard fails safe (rows retained). + +**Attachment access (ADR-0049, Salesforce parent-derived semantics).** +`sys_attachment` create now requires caller READ visibility of the parent +record (403 `ATTACHMENT_PARENT_ACCESS`) and server-stamps `uploaded_by` from +the session (client value ignored); delete requires uploader-or-parent-editor +(403 `ATTACHMENT_DELETE_DENIED`). The storage upload routes require an +authenticated session when an auth service is wired (401 `AUTH_REQUIRED`; +bare kernels stay open) and stamp `owner_id` on new files. + +**REMOVED — `sys_attachment.share_type` / `sys_attachment.visibility`.** +Both fields were modeled in v1 with zero runtime consumers (ADR-0049 +parsed-but-unenforced). There is no replacement key: attachment access is +derived from the parent record by the hooks above. Writers of these fields +should simply stop sending them (unknown-field validation will reject them); +existing DB columns are left as unmanaged leftovers, no migration needed. + +`@objectstack/verify` gains `BootOptions.extraPlugins` for booting optional +service pairs (e.g. storage + audit) in dogfood fixtures. diff --git a/docs/adr/0057-system-data-lifecycle-and-retention.md b/docs/adr/0057-system-data-lifecycle-and-retention.md index 2654fc9252..b4b587d735 100644 --- a/docs/adr/0057-system-data-lifecycle-and-retention.md +++ b/docs/adr/0057-system-data-lifecycle-and-retention.md @@ -170,6 +170,28 @@ registered ⇒ rows are retained (today's behavior), reported as `archive-pending` — a compliance ledger cannot be destroyed by declaring a lifecycle. +#### Amendment (#2755): reap guards — domain re-verification + external-resource reclaim + +Some reapable rows are pointers to state the Reaper cannot see: `sys_file` +rows reference storage bytes (disk/S3), and a tombstoned orphan may have +regained `sys_attachment` references since it was marked. For these, a plugin +may register a **reap guard** at runtime +(`lifecycle.registerReapGuard(object, guard)`): the Reaper fetches candidate +rows in batches, the guard confirms each id — performing external cleanup +(byte deletion) and sweep-time re-verification first — or vetoes it (kept, +retried next sweep). Rules: + +- A guarded object is **never blind-deleted**: an erroring guard fails safe + (rows retained), and an engine without row reads skips the object + (`reap-guard-unsupported`) rather than degrading to a blind delete. +- Guards are runtime wiring, not spec surface — detection and scheduling stay + inside the single platform sweep (no bespoke sweepers, per the alternatives + below); the guard is a domain callback, not a scheduler. +- First consumer: `sys_file` (service-storage) — attachment orphans are + tombstoned by hooks when their last `sys_attachment` reference is deleted, + reaped `30d` later by TTL with byte reclaim, with zero-reference + re-verification at sweep time; abandoned `pending` uploads reap after `7d`. + ### 3.4 Reclaim — driver space hygiene SQLite driver defaults to `auto_vacuum=INCREMENTAL` (shipped P0); the Reaper diff --git a/packages/dogfood/package.json b/packages/dogfood/package.json index 9e0884d80f..8ed3d48897 100644 --- a/packages/dogfood/package.json +++ b/packages/dogfood/package.json @@ -12,8 +12,10 @@ "@objectstack/example-crm": "workspace:*", "@objectstack/example-showcase": "workspace:*", "@objectstack/objectql": "workspace:*", + "@objectstack/plugin-audit": "workspace:*", "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-security": "workspace:*", + "@objectstack/service-storage": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/verify": "workspace:*" }, diff --git a/packages/dogfood/test/attachments-permission-matrix.dogfood.test.ts b/packages/dogfood/test/attachments-permission-matrix.dogfood.test.ts new file mode 100644 index 0000000000..a5fd57f822 --- /dev/null +++ b/packages/dogfood/test/attachments-permission-matrix.dogfood.test.ts @@ -0,0 +1,468 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Attachments v1 follow-ups (#2755) — the non-admin permission matrix the +// original E2E (#2742, seeded admin only) never exercised, plus the +// sys_file orphan-lifecycle proof, driven end-to-end through the REAL +// surfaces: better-auth sign-up members, the presigned three-step upload, +// the generic /data path, and the platform LifecycleService sweep. +// +// Matrix legend (letters reference the gap inventory in the #2755 plan): +// (a) delete-anyone's-attachment → uploader-or-parent-editor gate +// (b) attach-to-invisible-record → parent read-visibility gate +// (c) attachment LISTING does not inherit parent visibility — KNOWN GAP +// (e) anonymous uploads → session gate; anonymous downloads — KNOWN GAP +// (f) spoofable uploaded_by → server stamping +// (g) tenant isolation → multiTenant block +// +// @proof: attachments-permission-matrix + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { StorageServicePlugin } from '@objectstack/service-storage'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { attachmentsFixtureStack, attachmentsFixtureSecurity } from './fixtures/attachments-fixture.js'; + +const SYS = { isSystem: true } as const; +const DAY_MS = 86_400_000; + +/** Extract the created record id from a REST create response ({id} | {record:{id}}). */ +async function createdId(res: Response): Promise { + const j = (await res.json()) as any; + const id = j.id ?? j.record?.id ?? j.data?.id; + if (!id) throw new Error(`create response carried no id: ${JSON.stringify(j)}`); + return String(id); +} + +function bootFixture(extra: { multiTenant?: boolean } = {}) { + const rootDir = mkdtempSync(join(tmpdir(), 'att-dogfood-')); + return { + rootDir, + stack: bootStack(attachmentsFixtureStack as never, { + ...extra, + security: attachmentsFixtureSecurity(), + extraPlugins: [ + // The real `objectstack dev` pairing for the attachments surface: + // storage (sys_file/sys_attachment + routes + lifecycle hooks) and + // audit (the #2727 enable.files FILES_DISABLED gate). + // `bindToSettings:false` keeps the constructor rootDir — the settings + // live-wire would swap in a './storage' adapter and the fs asserts + // below would point at the wrong directory. + new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }), + new AuditPlugin(), + ], + }), + }; +} + +/** Drive the REAL presigned three-step upload; returns the fileId. */ +async function uploadFile(stack: VerifyStack, token: string | null, name = 'hello.txt'): Promise { + const auth = token ? { Authorization: `Bearer ${token}` } : {}; + const presignRes = await stack.api('/storage/upload/presigned', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...auth }, + body: JSON.stringify({ filename: name, mimeType: 'text/plain', size: 5, scope: 'attachments' }), + }); + expect(presignRes.status, 'presign').toBe(200); + const { data } = (await presignRes.json()) as any; + const putPath = String(data.uploadUrl).replace(/^https?:\/\/[^/]+/, ''); + const putRes = await stack.raw(putPath, { + method: 'PUT', + headers: data.headers ?? { 'content-type': 'text/plain' }, + body: 'hello', + }); + expect(putRes.status, 'raw PUT').toBeLessThan(300); + const completeRes = await stack.api('/storage/upload/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...auth }, + body: JSON.stringify({ fileId: data.fileId }), + }); + expect(completeRes.status, 'complete').toBe(200); + return data.fileId as string; +} + +describe('attachments permission matrix (#2755)', () => { + let stack: VerifyStack; + let rootDir: string; + let ql: any; + let lifecycle: any; + let adminTok: string, memberATok: string, memberBTok: string, baselineTok: string; + let adminId: string, memberAId: string, memberBId: string; + let caseAId: string; // att_case record created by memberA + let secretId: string; // att_secret record owned by admin + + const uid = async (email: string) => + (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + + const attach = (token: string, parentObject: string, parentId: string, fileId: string, extra: any = {}) => + stack.apiAs(token, 'POST', '/data/sys_attachment', { + parent_object: parentObject, + parent_id: parentId, + file_id: fileId, + file_name: 'hello.txt', + mime_type: 'text/plain', + size: 5, + ...extra, + }); + + beforeAll(async () => { + const boot = bootFixture(); + rootDir = boot.rootDir; + stack = await boot.stack; + adminTok = await stack.signIn(); + memberATok = await stack.signUp('att-member-a@verify.test'); + memberBTok = await stack.signUp('att-member-b@verify.test'); + baselineTok = await stack.signUp('att-baseline@verify.test'); + ql = await stack.kernel.getServiceAsync('objectql'); + lifecycle = await stack.kernel.getServiceAsync('lifecycle'); + adminId = await uid('admin@objectos.ai'); + memberAId = await uid('att-member-a@verify.test'); + memberBId = await uid('att-member-b@verify.test'); + + // Domain grant (see attachmentManagerSet): memberA/memberB manage + // attachments; the baseline member deliberately keeps only the + // `member_default` everyone-anchor baseline (no delete bit anywhere). + const managerSet = await ql.findOne('sys_permission_set', { where: { name: 'att_attachment_manager' }, context: SYS }); + expect(managerSet?.id, 'fixture permission set seeded').toBeTruthy(); + for (const userId of [memberAId, memberBId]) { + await ql.insert('sys_user_permission_set', { user_id: userId, permission_set_id: managerSet.id }, { context: { ...SYS } }); + } + + // Parent records: a public case owned by memberA, a private secret owned by admin. + const caseRes = await stack.apiAs(memberATok, 'POST', '/data/att_case', { name: 'public case' }); + expect(caseRes.status).toBeLessThan(300); + caseAId = await createdId(caseRes); + + const secret = await ql.insert( + 'att_secret', + { name: 'admin secret', owner_id: adminId }, + { context: { ...SYS } }, + ); + secretId = secret.id; + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + if (rootDir) await fs.rm(rootDir, { recursive: true, force: true }); + }); + + // ── (e) upload auth ────────────────────────────────────────────────── + it('(e) anonymous presigned upload → 401 AUTH_REQUIRED', async () => { + const res = await stack.api('/storage/upload/presigned', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: 'x.txt', mimeType: 'text/plain', size: 1, scope: 'attachments' }), + }); + expect(res.status).toBe(401); + expect(((await res.json()) as any).code).toBe('AUTH_REQUIRED'); + }); + + it('(e) authenticated upload succeeds and sys_file.owner_id is server-stamped', async () => { + const fileId = await uploadFile(stack, memberATok); + const file = await ql.findOne('sys_file', { where: { id: fileId }, context: SYS }); + expect(file?.status).toBe('committed'); + expect(file?.scope).toBe('attachments'); + expect(file?.owner_id).toBe(memberAId); + }); + + // ── (#2727 gate, first e2e) ────────────────────────────────────────── + it('(#2727) attaching to an object without enable.files → 403 FILES_DISABLED', async () => { + const nofilesRes = await stack.apiAs(memberATok, 'POST', '/data/att_nofiles', { name: 'plain' }); + expect(nofilesRes.status).toBeLessThan(300); + const nofilesId = await createdId(nofilesRes); + const fileId = await uploadFile(stack, memberATok); + const res = await attach(memberATok, 'att_nofiles', nofilesId, fileId); + expect(res.status).toBe(403); + expect(((await res.json()) as any).code).toBe('FILES_DISABLED'); + }); + + // ── (b) parent visibility + (f) provenance ─────────────────────────── + it('(f) member attaches to a readable record; uploaded_by is server-stamped over a spoofed value', async () => { + const fileId = await uploadFile(stack, memberATok); + const res = await attach(memberATok, 'att_case', caseAId, fileId, { uploaded_by: memberBId }); + expect(res.status).toBeLessThan(300); + const row = await ql.findOne('sys_attachment', { where: { file_id: fileId }, context: SYS }); + expect(row?.uploaded_by, 'server identity wins over the spoofed uploaded_by').toBe(memberAId); + }); + + it('(b) member cannot attach to a record they cannot read → 403 ATTACHMENT_PARENT_ACCESS; the owner can', async () => { + const fileId = await uploadFile(stack, memberATok); + const denied = await attach(memberATok, 'att_secret', secretId, fileId); + expect(denied.status).toBe(403); + expect(((await denied.json()) as any).code).toBe('ATTACHMENT_PARENT_ACCESS'); + + const adminFile = await uploadFile(stack, adminTok); + const allowed = await attach(adminTok, 'att_secret', secretId, adminFile); + expect(allowed.status).toBeLessThan(300); + }); + + // ── (a) delete gate ────────────────────────────────────────────────── + it('(a, DOGFOOD FINDING pin) the everyone baseline carries NO delete bit: an ungranted member cannot delete even their OWN attachment (403 PERMISSION_DENIED)', async () => { + // ADR-0090 D5 / #2753: `member_default` is the anchor-bound baseline and + // deliberately omits `allowDelete`. Managing attachments therefore + // requires a domain grant (attachmentManagerSet) — the RecordAttachments + // panel's delete button 403s for rank-and-file members until the app + // ships one. RBAC denies BEFORE the attachment-level gate is consulted. + const fileId = await uploadFile(stack, baselineTok); + const created = await attach(baselineTok, 'att_case', caseAId, fileId); + expect(created.status, 'baseline member CAN attach (create is baseline)').toBeLessThan(300); + const row = await ql.findOne('sys_attachment', { where: { file_id: fileId }, context: SYS }); + + const denied = await stack.apiAs(baselineTok, 'DELETE', `/data/sys_attachment/${row.id}`); + expect(denied.status).toBe(403); + expect(((await denied.json()) as any).code).toBe('PERMISSION_DENIED'); + + // cleanup so later ref-counts stay per-test + await ql.delete('sys_attachment', { where: { id: row.id }, context: { ...SYS } }); + }); + + it('(a) granted member: uploader may delete their own attachment; a non-uploader without parent edit is denied', async () => { + const row = await ql.findOne('sys_attachment', { where: { parent_object: 'att_secret', parent_id: secretId }, context: SYS }); + expect(row?.id, 'admin attachment on att_secret exists').toBeTruthy(); + + // memberB holds the delete bit, but is neither the uploader nor able to + // edit the (admin-owned, private-model) parent → 403. Depending on which + // layer fires first (member_default's owner-scoped delete RLS pre-image + // vs the attachment access hook) the code is PERMISSION_DENIED or + // ATTACHMENT_DELETE_DENIED — both are the fail-closed contract. + const denied = await stack.apiAs(memberBTok, 'DELETE', `/data/sys_attachment/${row.id}`); + expect(denied.status).toBe(403); + expect(['ATTACHMENT_DELETE_DENIED', 'PERMISSION_DENIED']).toContain(((await denied.json()) as any).code); + expect(await ql.findOne('sys_attachment', { where: { id: row.id }, context: SYS })).toBeTruthy(); + + // The uploader (admin) may delete it. + const allowed = await stack.apiAs(adminTok, 'DELETE', `/data/sys_attachment/${row.id}`); + expect(allowed.status).toBeLessThan(300); + + // A granted member deletes their OWN attachment (uploader rule). + const fileId = await uploadFile(stack, memberATok); + const created = await attach(memberATok, 'att_case', caseAId, fileId); + expect(created.status).toBeLessThan(300); + const own = await ql.findOne('sys_attachment', { where: { file_id: fileId }, context: SYS }); + const ownDelete = await stack.apiAs(memberATok, 'DELETE', `/data/sys_attachment/${own.id}`); + expect(ownDelete.status).toBeLessThan(300); + }); + + // ── (c) known gap: listing does not inherit parent visibility ──────── + it('(c, KNOWN GAP pin) a member can LIST sys_attachment rows pointing at records they cannot read', async () => { + const adminFile = await uploadFile(stack, adminTok); + const created = await attach(adminTok, 'att_secret', secretId, adminFile); + expect(created.status).toBeLessThan(300); + + // memberB cannot read the att_secret PARENT… + const parentRead = await stack.apiAs(memberBTok, 'GET', `/data/att_secret/${secretId}`); + expect([403, 404]).toContain(parentRead.status); + + // …but CAN see the join row (file name, size, parent id) through the + // generic list path. Attachment read visibility does not yet inherit + // parent-record visibility — enforce-or-remove follow-up filed with + // #2755; flip this pin to a denial assertion when inheritance lands. + const list = await stack.apiAs(memberBTok, 'GET', '/data/sys_attachment'); + expect(list.status).toBe(200); + const rows = ((await list.json()) as any).records ?? []; + const leaked = rows.some((r: any) => r.parent_object === 'att_secret' && r.parent_id === secretId); + expect(leaked, 'KNOWN GAP: join rows of invisible parents are listable').toBe(true); + }); + + // ── (e-read) known gap: anonymous downloads ────────────────────────── + it('(e-read, KNOWN GAP pin) anonymous download of a committed file id succeeds (capability URL)', async () => { + const fileId = await uploadFile(stack, memberATok); + const res = await stack.api(`/storage/files/${fileId}/url`); + // Anyone holding a fileId can mint a download URL without a session. + // Authenticated downloads / signed-link gating is a filed follow-up. + expect(res.status).toBe(200); + }); + + // ── Part 1: sys_file orphan lifecycle, end to end ───────────────────── + describe('sys_file orphan lifecycle (ADR-0057 reap guard)', () => { + it('tombstones on last-ref delete, keeps shared files alive, un-tombstones on re-attach', async () => { + const fileId = await uploadFile(stack, memberATok); + // Two join rows on the SAME file (ContentDocumentLink semantics). + const caseB = await stack.apiAs(memberATok, 'POST', '/data/att_case', { name: 'case b' }); + const caseBId = await createdId(caseB); + const a1 = await attach(memberATok, 'att_case', caseAId, fileId); + const a2 = await attach(memberATok, 'att_case', caseBId, fileId); + expect(a1.status).toBeLessThan(300); + expect(a2.status).toBeLessThan(300); + const rows = await ql.find('sys_attachment', { where: { file_id: fileId }, context: SYS }); + expect(rows).toHaveLength(2); + + // Delete ref #1 → file must stay committed (a second ref remains). + await stack.apiAs(memberATok, 'DELETE', `/data/sys_attachment/${rows[0].id}`); + let file = await ql.findOne('sys_file', { where: { id: fileId }, context: SYS }); + expect(file.status).toBe('committed'); + + // Delete ref #2 (the LAST) → tombstoned. + await stack.apiAs(memberATok, 'DELETE', `/data/sys_attachment/${rows[1].id}`); + file = await ql.findOne('sys_file', { where: { id: fileId }, context: SYS }); + expect(file.status).toBe('deleted'); + expect(file.deleted_at).toBeTruthy(); + + // Re-attach inside the grace window → back to committed. + const re = await attach(memberATok, 'att_case', caseAId, fileId); + expect(re.status).toBeLessThan(300); + file = await ql.findOne('sys_file', { where: { id: fileId }, context: SYS }); + expect(file.status).toBe('committed'); + expect(file.deleted_at ?? null).toBeNull(); + }); + + it('the sweep reaps expired tombstones AND their bytes; fresh tombstones, committed rows and NULL deleted_at survive', async () => { + // Orphan an attachments file for real (upload → attach → detach). + const fileId = await uploadFile(stack, memberATok); + const created = await attach(memberATok, 'att_case', caseAId, fileId); + expect(created.status).toBeLessThan(300); + const joinRow = await ql.findOne('sys_attachment', { where: { file_id: fileId }, context: SYS }); + await stack.apiAs(memberATok, 'DELETE', `/data/sys_attachment/${joinRow.id}`); + + const tombstoned = await ql.findOne('sys_file', { where: { id: fileId }, context: SYS }); + expect(tombstoned.status).toBe('deleted'); + const key = tombstoned.key as string; + await expect(fs.access(join(rootDir, key)), 'bytes exist before the sweep').resolves.toBeUndefined(); + + // A CONTROL file: committed, NULL deleted_at — must survive every sweep. + const controlId = await uploadFile(stack, memberATok, 'control.txt'); + const controlAttach = await attach(memberATok, 'att_case', caseAId, controlId); + expect(controlAttach.status).toBeLessThan(300); + + // Fresh tombstone (inside the 30d grace window) — must survive too. + const freshId = await uploadFile(stack, memberATok, 'fresh.txt'); + const freshAttach = await attach(memberATok, 'att_case', caseAId, freshId); + expect(freshAttach.status).toBeLessThan(300); + const freshJoin = await ql.findOne('sys_attachment', { where: { file_id: freshId }, context: SYS }); + await stack.apiAs(memberATok, 'DELETE', `/data/sys_attachment/${freshJoin.id}`); + + // Backdate ONLY the target tombstone past the 30d window (engine-level + // update so the datetime lands driver-correct). + await ql.update( + 'sys_file', + { id: fileId, deleted_at: new Date(Date.now() - 31 * DAY_MS) }, + { context: { ...SYS } }, + ); + + const report = await lifecycle.sweep(); + expect(report.errors, JSON.stringify(report.errors)).toEqual([]); + + // Row gone, bytes gone. + expect(await ql.findOne('sys_file', { where: { id: fileId }, context: SYS })).toBeNull(); + await expect(fs.access(join(rootDir, key)), 'bytes reclaimed by the guard').rejects.toThrow(); + + // Survivors. + expect((await ql.findOne('sys_file', { where: { id: controlId }, context: SYS }))?.status).toBe('committed'); + expect((await ql.findOne('sys_file', { where: { id: freshId }, context: SYS }))?.status).toBe('deleted'); + }); + + it('sweep-time re-verification: a tombstone that regained a reference behind the hooks\' back is un-tombstoned, not reaped', async () => { + const fileId = await uploadFile(stack, memberATok, 'undead.txt'); + const created = await attach(memberATok, 'att_case', caseAId, fileId); + expect(created.status).toBeLessThan(300); + const joinRow = await ql.findOne('sys_attachment', { where: { file_id: fileId }, context: SYS }); + await stack.apiAs(memberATok, 'DELETE', `/data/sys_attachment/${joinRow.id}`); + expect((await ql.findOne('sys_file', { where: { id: fileId }, context: SYS })).status).toBe('deleted'); + + // Hook-bypass re-reference: a DIRECT DRIVER insert (no engine hooks + // fire, so afterInsert cannot un-tombstone) + a backdated tombstone. + const driver = ql.getDriverForObject('sys_attachment'); + await driver.create('sys_attachment', { + id: `bypass-${fileId}`, + parent_object: 'att_case', + parent_id: caseAId, + file_id: fileId, + created_at: new Date(), + }); + await ql.update( + 'sys_file', + { id: fileId, deleted_at: new Date(Date.now() - 31 * DAY_MS) }, + { context: { ...SYS } }, + ); + + const report = await lifecycle.sweep(); + expect(report.errors, JSON.stringify(report.errors)).toEqual([]); + + const file = await ql.findOne('sys_file', { where: { id: fileId }, context: SYS }); + expect(file, 'the guard must veto, not reap').toBeTruthy(); + expect(file.status).toBe('committed'); + }); + + it('abandoned pending uploads are reaped after the 7d retention window', async () => { + // Presign but never complete → status stays 'pending'. + const presign = await stack.api('/storage/upload/presigned', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${memberATok}` }, + body: JSON.stringify({ filename: 'abandoned.txt', mimeType: 'text/plain', size: 5, scope: 'attachments' }), + }); + const { data } = (await presign.json()) as any; + // Backdate creation past the 7d pending window. + await ql.update( + 'sys_file', + { id: data.fileId, created_at: new Date(Date.now() - 8 * DAY_MS) }, + { context: { ...SYS } }, + ); + + const report = await lifecycle.sweep(); + expect(report.errors, JSON.stringify(report.errors)).toEqual([]); + expect(await ql.findOne('sys_file', { where: { id: data.fileId }, context: SYS })).toBeNull(); + }); + }); +}); + +// ── (g) tenant isolation — enterprise multi-org boot ───────────────────── +const organizationsAvailable = await import(/* webpackIgnore: true */ '@objectstack/organizations') + .then(() => true) + .catch(() => false); +if (!organizationsAvailable) { + // eslint-disable-next-line no-console + console.warn('[dogfood] @objectstack/organizations (enterprise) not installed — skipping the attachments multi-tenant block'); +} + +describe.skipIf(!organizationsAvailable)('attachments cross-tenant isolation (g)', () => { + let stack: VerifyStack; + let rootDir: string; + let ql: any; + + beforeAll(async () => { + const boot = bootFixture({ multiTenant: true }); + rootDir = boot.rootDir; + stack = await boot.stack; + ql = await stack.kernel.getServiceAsync('objectql'); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + if (rootDir) await fs.rm(rootDir, { recursive: true, force: true }); + }); + + it('a member outside the admin org cannot read or delete the admin\'s attachments through /data', async () => { + const adminTok = await stack.signIn(); + const outsiderTok = await stack.signUp('att-outsider@verify.test'); + + const caseRes = await stack.apiAs(adminTok, 'POST', '/data/att_case', { name: 'org case' }); + expect(caseRes.status).toBeLessThan(300); + const caseId = await createdId(caseRes); + const fileId = await uploadFile(stack, adminTok); + const attachRes = await stack.apiAs(adminTok, 'POST', '/data/sys_attachment', { + parent_object: 'att_case', + parent_id: caseId, + file_id: fileId, + file_name: 'hello.txt', + }); + expect(attachRes.status).toBeLessThan(300); + const row = await ql.findOne('sys_attachment', { where: { file_id: fileId }, context: SYS }); + + // Org-scoped list: the outsider must not see the admin org's join rows. + const list = await stack.apiAs(outsiderTok, 'GET', '/data/sys_attachment'); + if (list.status === 200) { + const rows = ((await list.json()) as any).records ?? []; + expect(rows.some((r: any) => r.id === row.id)).toBe(false); + } else { + expect([403, 404]).toContain(list.status); + } + + // …and must not be able to delete them. + const del = await stack.apiAs(outsiderTok, 'DELETE', `/data/sys_attachment/${row.id}`); + expect([403, 404]).toContain(del.status); + expect(await ql.findOne('sys_attachment', { where: { id: row.id }, context: SYS })).toBeTruthy(); + }); +}); diff --git a/packages/dogfood/test/fixtures/attachments-fixture.ts b/packages/dogfood/test/fixtures/attachments-fixture.ts new file mode 100644 index 0000000000..e1b55f2dc8 --- /dev/null +++ b/packages/dogfood/test/fixtures/attachments-fixture.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Attachments permission-matrix fixture (#2755). +// +// Three tiny objects spanning the enforcement axes of the generic +// Attachments surface (#2727): +// +// att_case — enable.files + public sharing model: any member can read +// (and per Salesforce "can edit parent ⇒ can manage its +// attachments", any member can detach from it). +// att_secret — enable.files + DEFAULT sharing model (omitted ⇒ private, +// ADR-0090) + an `owner_id` field: the sharing service +// read-filters rows to the owner, so a fresh member cannot +// read (→ cannot attach to) another user's record, and +// `canEdit` denies non-owners (→ cannot delete another +// user's attachments on it). +// att_nofiles — NO enable.files: the #2727 opt-in gate (FILES_DISABLED). +// +// No custom SecurityPlugin: a fresh signUp member falls back to the real +// `member_default` wildcard-CRUD set — exactly the posture the issue's +// permission matrix questions (the gates under test are the ones layered on +// TOP of that wildcard). + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; + +export const AttCase = ObjectSchema.create({ + name: 'att_case', + label: 'Attachment Case', + pluralLabel: 'Attachment Cases', + sharingModel: 'public_read_write', + enable: { files: true }, + fields: { + name: Field.text({ label: 'Name', required: true }), + }, +}); + +export const AttSecret = ObjectSchema.create({ + name: 'att_secret', + label: 'Attachment Secret', + pluralLabel: 'Attachment Secrets', + // sharingModel omitted — custom object defaults to PRIVATE (ADR-0090); + // owner_id is the sharing service's owner anchor. + enable: { files: true }, + fields: { + name: Field.text({ label: 'Name', required: true }), + owner_id: Field.text({ label: 'Owner' }), + }, +}); + +export const AttNoFiles = ObjectSchema.create({ + name: 'att_nofiles', + label: 'No Files Here', + pluralLabel: 'No Files Here', + sharingModel: 'public_read_write', + fields: { + name: Field.text({ label: 'Name', required: true }), + }, +}); + +/** + * The domain grant a real app ships when it turns the attachments panel on + * for members: `member_default` (the `everyone` anchor baseline) carries NO + * `allowDelete` (ADR-0090 D5 — delete is not a baseline right), so managing + * attachments requires an ordinary position-distributed set with the delete + * bit on `sys_attachment`. The matrix grants this to some members and pins + * the no-grant baseline with another. + */ +export const attachmentManagerSet: PermissionSet = PermissionSetSchema.parse({ + name: 'att_attachment_manager', + label: 'Attachments Fixture — attachment manager', + objects: { + sys_attachment: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + }, +}); + +/** SecurityPlugin carrying the platform defaults + the fixture's domain set. */ +export function attachmentsFixtureSecurity(): SecurityPlugin { + return new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, attachmentManagerSet], + }); +} + +export const attachmentsFixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.attachments_fixture', + namespace: 'att', + version: '0.0.0', + type: 'app', + name: 'Attachments Permission Matrix Fixture', + description: + 'Three-object app exercising the #2755 attachment permission matrix: parent visibility, uploader/editor delete, enable.files gate.', + }, + objects: [AttCase, AttSecret, AttNoFiles], +}); diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 55aed4b61a..889371d172 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -63,6 +63,7 @@ export type { LifecycleObjectLike, LifecycleSettingsLike, LifecycleGovernanceAlert, + LifecycleReapGuard, } from './lifecycle/lifecycle-service.js'; export { parseLifecycleDuration } from './lifecycle/duration.js'; export { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js'; diff --git a/packages/objectql/src/lifecycle/lifecycle-service.test.ts b/packages/objectql/src/lifecycle/lifecycle-service.test.ts index 468b5a057e..4dc8771660 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.test.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.test.ts @@ -17,10 +17,13 @@ function captureEngine( deleteImpl?: (object: string, options: any) => any; driver?: Record; datasources?: Record; + /** When set, the engine exposes `find` (guarded-reap candidate reads). */ + findImpl?: (object: string, options: any) => any; } = {}, ) { const deletes: Array<{ object: string; where: any; multi: any; context: any }> = []; - const engine = { + const finds: Array<{ object: string; where: any; limit: any; context: any }> = []; + const engine: any = { registry: { getAllObjects: () => objects }, async delete(object: string, options: any) { deletes.push({ object, where: options?.where, multi: options?.multi, context: options?.context }); @@ -33,7 +36,13 @@ function captureEngine( return ds; }, }; - return { engine, deletes }; + if (opts.findImpl) { + engine.find = async (object: string, options: any) => { + finds.push({ object, where: options?.where, limit: options?.limit, context: options?.context }); + return opts.findImpl!(object, options); + }; + } + return { engine, deletes, finds }; } function service(engine: any, extra: Partial[0]> = {}) { @@ -307,6 +316,105 @@ describe('LifecycleService.sweep — Reaper', () => { }); }); +describe('LifecycleService.sweep — reap guard', () => { + const guarded: LifecycleObjectLike[] = [ + { name: 'sys_file', lifecycle: { class: 'transient', ttl: { field: 'deleted_at', expireAfter: '30d' } } }, + ]; + + it('deletes only guard-confirmed ids, per id, after fetching candidates with the cutoff filter', async () => { + const rows = [ + { id: 'f1', deleted_at: '2020-01-01T00:00:00Z' }, + { id: 'f2', deleted_at: '2020-01-02T00:00:00Z' }, + { id: 'f3', deleted_at: '2020-01-03T00:00:00Z' }, + ]; + const { engine, deletes, finds } = captureEngine(guarded, { findImpl: () => rows }); + const svc = service(engine); + const guard = vi.fn(async () => ['f1', 'f3']); // vetoes f2 + svc.registerReapGuard('sys_file', guard); + + const report = await svc.sweep(); + + expect(finds).toHaveLength(1); + expect(finds[0].where).toEqual({ deleted_at: { $lt: isoCutoff('30d') } }); + expect(finds[0].context).toEqual({ isSystem: true, positions: [], permissions: [] }); + expect(guard).toHaveBeenCalledWith('sys_file', rows); + // Per-id deletes — the engine's delete path reads `where.id` as a scalar + // target; a `{$in}` there would be bound as an object by the driver. + expect(deletes.map((d) => d.where)).toEqual([{ id: 'f1' }, { id: 'f3' }]); + expect(report.swept).toEqual([ + { object: 'sys_file', class: 'transient', policy: 'ttl', cutoff: isoCutoff('30d'), deleted: 2 }, + ]); + expect(report.errors).toEqual([]); + }); + + it('an erroring guard fails safe: no rows deleted, error reported', async () => { + const { engine, deletes } = captureEngine(guarded, { findImpl: () => [{ id: 'f1' }] }); + const svc = service(engine); + svc.registerReapGuard('sys_file', async () => { + throw new Error('storage unreachable'); + }); + + const report = await svc.sweep(); + + expect(deletes).toHaveLength(0); + expect(report.swept).toEqual([]); + expect(report.errors).toEqual([{ object: 'sys_file', error: 'storage unreachable' }]); + }); + + it('a guard on one object never changes the blind reap of others (regression pin)', async () => { + const { engine, deletes } = captureEngine( + [ + ...guarded, + { name: 'sys_job_run', lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } } }, + ], + { findImpl: () => [] }, + ); + const svc = service(engine); + svc.registerReapGuard('sys_file', async () => []); + + const report = await svc.sweep(); + + // sys_file: no candidates → no delete. sys_job_run: classic blind reap. + expect(deletes).toHaveLength(1); + expect(deletes[0].object).toBe('sys_job_run'); + expect(deletes[0].where).toEqual({ created_at: { $lt: isoCutoff('30d') } }); + expect(report.errors).toEqual([]); + }); + + it('a guarded object on an engine without find is skipped, never blind-deleted', async () => { + const { engine, deletes } = captureEngine(guarded); // no findImpl → no engine.find + const svc = service(engine); + svc.registerReapGuard('sys_file', async () => ['f1']); + + const report = await svc.sweep(); + + expect(deletes).toHaveLength(0); + expect(report.swept).toEqual([]); + expect(report.skipped).toEqual([{ object: 'sys_file', reason: 'reap-guard-unsupported' }]); + }); + + it('drains full batches but stops the pass when a batch is not fully confirmed', async () => { + // Two "pages" of 500, then a short page. All of page 1 confirmed → loop + // continues; page 2 only partially confirmed → pass ends (vetoed rows + // would be re-fetched forever within one sweep). + const page = (n: number, prefix: string) => + Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i}`, deleted_at: '2020-01-01T00:00:00Z' })); + const pages = [page(500, 'a'), page(500, 'b')]; + let call = 0; + const { engine, deletes, finds } = captureEngine(guarded, { findImpl: () => pages[call++] ?? [] }); + const svc = service(engine); + svc.registerReapGuard('sys_file', async (_object, rows) => + rows[0].id === 'a0' ? rows.map((r: any) => r.id) : rows.slice(0, 10).map((r: any) => r.id), + ); + + const report = await svc.sweep(); + + expect(finds).toHaveLength(2); + expect(deletes).toHaveLength(510); // 500 confirmed from page 1 + 10 from page 2, per id + expect(report.swept[0].deleted).toBe(510); + }); +}); + describe('LifecycleService.sweep — Archiver (P3)', () => { const AUDIT_OBJ: LifecycleObjectLike = { name: 'sys_audit_log', diff --git a/packages/objectql/src/lifecycle/lifecycle-service.ts b/packages/objectql/src/lifecycle/lifecycle-service.ts index d79d8a6cbf..f51c045108 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.ts @@ -200,6 +200,28 @@ interface ArchiveCapableDriver { const ARCHIVE_BATCH_SIZE = 500; const ARCHIVE_MAX_BATCHES_PER_SWEEP = 20; +/** Guarded reap batching — same posture as the Archiver: bound one sweep's + * work, drain the backlog across sweeps. */ +const REAP_GUARD_BATCH_SIZE = 500; +const REAP_GUARD_MAX_BATCHES_PER_SWEEP = 20; + +/** + * Reap guard (ADR-0057 amendment): a domain callback consulted by the Reaper + * before rows of the guarded object are deleted. The guard receives the + * candidate rows and returns the ids it CONFIRMS for deletion — performing + * any external cleanup (e.g. storage-byte reclaim) for those ids before + * returning. Ids not returned are kept this sweep (vetoed — e.g. the row + * regained references since it was marked). + * + * Guards are registered at runtime (`registerReapGuard`), not declared in the + * spec: detection and scheduling stay inside the single platform sweep + * (ADR-0057 §3.3 — a guard is a domain callback, not a second sweeper). + */ +export type LifecycleReapGuard = ( + object: string, + rows: Array>, +) => Promise>; + export class LifecycleService { private readonly now: () => number; private timer: ReturnType | undefined; @@ -209,6 +231,8 @@ export class LifecycleService { private lastCounts = new Map(); /** Governance snapshot for the sweep in flight. */ private governance: GovernanceSnapshot = DEFAULT_GOVERNANCE; + /** Per-object reap guards ({@link LifecycleReapGuard}). */ + private readonly reapGuards = new Map(); constructor(private readonly opts: LifecycleServiceOptions) { this.now = opts.now ?? (() => Date.now()); @@ -241,6 +265,17 @@ export class LifecycleService { this.timer = undefined; } + /** + * Register a {@link LifecycleReapGuard} for one object. From then on the + * Reaper never blind-deletes that object's rows: candidates are fetched, + * the guard confirms (after external cleanup) or vetoes each row, and only + * confirmed ids are deleted. One guard per object (last registration wins — + * guards are platform wiring, not user surface). + */ + registerReapGuard(object: string, guard: LifecycleReapGuard): void { + this.reapGuards.set(object, guard); + } + /** * Apply every declared lifecycle policy once. Safe to call directly (the * dogfood growth gate and `db:clean`-style tooling do); re-entrant calls @@ -604,49 +639,46 @@ export class LifecycleService { // rows outside it (live workflow state) are retained regardless of age. const scope = onlyWhen ?? {}; + // A guarded object is NEVER blind-deleted: without row reads the guard + // cannot confirm, so the reap is skipped (fail-safe), not degraded. + const guard = this.reapGuards.get(object); + if (guard && typeof engine.find !== 'function') { + if (!report.skipped.some((s) => s.object === object && s.reason === 'reap-guard-unsupported')) { + report.skipped.push({ object, reason: 'reap-guard-unsupported' }); + } + return 0; + } + let total: number | undefined = 0; - const accumulate = (res: unknown) => { - const n = countDeleted(res); + const accumulate = (n: number | undefined) => { if (n === undefined) total = undefined; else if (total !== undefined) total += n; }; + const reapWhere = async (where: Record): Promise => + guard + ? this.guardedReap(engine, object, guard, where) + : countDeleted(await engine.delete(object, { where, multi: true, context: { ...SYSTEM_CTX } })); if (tenantWindows.length === 0) { - accumulate( - await engine.delete(object, { - where: { [field]: { $lt: cutoff }, ...scope }, - multi: true, - context: { ...SYSTEM_CTX }, - }), - ); + accumulate(await reapWhere({ [field]: { $lt: cutoff }, ...scope })); } else { // Tenant-level windows (P4): each overriding tenant gets its own // cutoff on its own rows… for (const t of tenantWindows) { const tMs = this.effectiveWindowMs(t[overrideKey], windowMs, `${object} (tenant ${t.tenantId})`); const tCutoff = new Date(this.now() - tMs).toISOString(); - accumulate( - await engine.delete(object, { - where: { [field]: { $lt: tCutoff }, organization_id: t.tenantId, ...scope }, - multi: true, - context: { ...SYSTEM_CTX }, - }), - ); + accumulate(await reapWhere({ [field]: { $lt: tCutoff }, organization_id: t.tenantId, ...scope })); } // …and the global pass covers everyone else, INCLUDING rows with no // organization (a bare `$nin` would silently skip NULL-org rows). accumulate( - await engine.delete(object, { - where: { - [field]: { $lt: cutoff }, - $or: [ - { organization_id: { $nin: tenantWindows.map((t) => t.tenantId) } }, - { organization_id: null }, - ], - ...scope, - }, - multi: true, - context: { ...SYSTEM_CTX }, + await reapWhere({ + [field]: { $lt: cutoff }, + $or: [ + { organization_id: { $nin: tenantWindows.map((t) => t.tenantId) } }, + { organization_id: null }, + ], + ...scope, }), ); } @@ -654,6 +686,45 @@ export class LifecycleService { report.swept.push({ object, class: lc.class, policy, cutoff, deleted: total }); return total; } + + /** + * Guarded reap: fetch candidate rows in batches, let the guard confirm + * (after performing external cleanup) or veto each, delete only confirmed + * ids. A guard error propagates to the per-object handler in `sweep()` — + * an erroring guard must never fail open into deletion. A batch that isn't + * fully confirmed ends the pass: vetoed rows still match the cutoff filter + * and would be re-fetched forever; the next sweep retries them. + */ + private async guardedReap( + engine: LifecycleEngineLike, + object: string, + guard: LifecycleReapGuard, + where: Record, + ): Promise { + let total = 0; + for (let batch = 0; batch < REAP_GUARD_MAX_BATCHES_PER_SWEEP; batch++) { + const rows = await engine.find!(object, { + where, + limit: REAP_GUARD_BATCH_SIZE, + context: { ...SYSTEM_CTX }, + }); + if (!rows?.length) break; + const confirmed = (await guard(object, rows)).filter((id) => id !== null && id !== undefined); + // Per-id deletes (NOT a `{$in}` filter): the engine's delete path reads + // `where.id` as a scalar target, and by-id deletes get referential + // cascade handling a filter-delete would bypass. + for (const id of confirmed) { + await engine.delete(object, { + where: { id }, + multi: true, + context: { ...SYSTEM_CTX }, + }); + } + total += confirmed.length; + if (confirmed.length < rows.length || rows.length < REAP_GUARD_BATCH_SIZE) break; + } + return total; + } } /** Best-effort row-count extraction from a driver's delete result. */ diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index a7d31b277d..c24594fbad 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -473,7 +473,11 @@ export const enObjects: NonNullable = { }, _views: { mine: { - label: "My Memberships" + label: "My Memberships", + emptyState: { + title: "No organizations yet", + message: "You haven't joined any organizations." + } } }, _actions: { @@ -1354,7 +1358,11 @@ export const enObjects: NonNullable = { }, _views: { recent: { - label: "Recent" + label: "Recent", + emptyState: { + title: "No events", + message: "No notification events have been emitted." + } }, by_topic: { label: "By Topic" @@ -1391,23 +1399,6 @@ export const enObjects: NonNullable = { size: { label: "Size (bytes)" }, - share_type: { - label: "Share Type", - help: "viewer | collaborator | inferred (inherited from parent record)", - options: { - viewer: "viewer", - collaborator: "collaborator", - inferred: "inferred" - } - }, - visibility: { - label: "Visibility", - options: { - internal: "internal", - all_users: "all_users", - shared_users: "shared_users" - } - }, uploaded_by: { label: "Uploaded By" }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 95abc11e68..704198f948 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -473,7 +473,11 @@ export const esESObjects: NonNullable = { }, _views: { mine: { - label: "My Memberships" + label: "My Memberships", + emptyState: { + title: "No organizations yet", + message: "You haven't joined any organizations." + } } }, _actions: { @@ -1354,7 +1358,11 @@ export const esESObjects: NonNullable = { }, _views: { recent: { - label: "Recent" + label: "Recent", + emptyState: { + title: "No events", + message: "No notification events have been emitted." + } }, by_topic: { label: "By Topic" @@ -1391,23 +1399,6 @@ export const esESObjects: NonNullable = { size: { label: "Tamaño (bytes)" }, - share_type: { - label: "Tipo de compartición", - help: "visualizador | colaborador | inferido (heredado del registro principal)", - options: { - viewer: "Visualizador", - collaborator: "Colaborador", - inferred: "Inferido" - } - }, - visibility: { - label: "Visibilidad", - options: { - internal: "Interno", - all_users: "Todos los usuarios", - shared_users: "Usuarios compartidos" - } - }, uploaded_by: { label: "Subido por" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 5ab58ee234..f2598f3117 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -473,7 +473,11 @@ export const jaJPObjects: NonNullable = { }, _views: { mine: { - label: "My Memberships" + label: "My Memberships", + emptyState: { + title: "No organizations yet", + message: "You haven't joined any organizations." + } } }, _actions: { @@ -1354,7 +1358,11 @@ export const jaJPObjects: NonNullable = { }, _views: { recent: { - label: "Recent" + label: "Recent", + emptyState: { + title: "No events", + message: "No notification events have been emitted." + } }, by_topic: { label: "By Topic" @@ -1391,23 +1399,6 @@ export const jaJPObjects: NonNullable = { size: { label: "サイズ(バイト)" }, - share_type: { - label: "共有タイプ", - help: "viewer | collaborator | inferred(親レコードから継承)", - options: { - viewer: "閲覧者", - collaborator: "共同編集者", - inferred: "推定" - } - }, - visibility: { - label: "公開範囲", - options: { - internal: "内部", - all_users: "すべてのユーザー", - shared_users: "共有ユーザー" - } - }, uploaded_by: { label: "アップロード者" }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 9ac86ede06..60dff24e01 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -473,7 +473,11 @@ export const zhCNObjects: NonNullable = { }, _views: { mine: { - label: "My Memberships" + label: "My Memberships", + emptyState: { + title: "No organizations yet", + message: "You haven't joined any organizations." + } } }, _actions: { @@ -1354,7 +1358,11 @@ export const zhCNObjects: NonNullable = { }, _views: { recent: { - label: "Recent" + label: "Recent", + emptyState: { + title: "No events", + message: "No notification events have been emitted." + } }, by_topic: { label: "By Topic" @@ -1391,23 +1399,6 @@ export const zhCNObjects: NonNullable = { size: { label: "大小(字节)" }, - share_type: { - label: "共享类型", - help: "viewer | collaborator | inferred(继承自父记录)", - options: { - viewer: "查看者", - collaborator: "协作者", - inferred: "推断" - } - }, - visibility: { - label: "可见性", - options: { - internal: "内部", - all_users: "全部用户", - shared_users: "共享用户" - } - }, uploaded_by: { label: "上传人" }, diff --git a/packages/platform-objects/src/audit/sys-attachment.object.ts b/packages/platform-objects/src/audit/sys-attachment.object.ts index 7d58137dce..bc7ddf1329 100644 --- a/packages/platform-objects/src/audit/sys-attachment.object.ts +++ b/packages/platform-objects/src/audit/sys-attachment.object.ts @@ -17,8 +17,11 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * - `parent_id` is the parent record's primary key. * - `(parent_object, parent_id)` is the natural index for the * "files on this record" lookup. - * - `share_type` follows the Salesforce convention: V = Viewer, - * C = Collaborator, I = Inferred (inherited from parent record). + * - Access is derived from the PARENT record (Salesforce semantics), + * enforced by service-storage's attachment access hooks (#2755). + * Salesforce's `ShareType`/`Visibility` dials were modeled here in v1 + * but had no runtime consumer — removed per ADR-0049 enforce-or-remove; + * reintroduce them together with their enforcement if ever needed. * * @namespace sys */ @@ -90,26 +93,6 @@ export const SysAttachment = ObjectSchema.create({ group: 'File', }), - // ── Sharing ──────────────────────────────────────────────── - share_type: Field.select( - ['viewer', 'collaborator', 'inferred'], - { - label: 'Share Type', - defaultValue: 'viewer', - description: 'viewer | collaborator | inferred (inherited from parent record)', - group: 'Sharing', - }, - ), - - visibility: Field.select( - ['internal', 'all_users', 'shared_users'], - { - label: 'Visibility', - defaultValue: 'internal', - group: 'Sharing', - }, - ), - // ── Authoring ────────────────────────────────────────────── uploaded_by: Field.lookup('sys_user', { label: 'Uploaded By', diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 5d92a21252..350522f32f 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -106,6 +106,20 @@ export function mapDataError(error: any, object?: string): { status: number; bod }, }; } + // Attachment access gates (#2755): service-storage's engine hooks reject + // sys_attachment writes fail-closed when the caller cannot see the parent + // record (create) or is neither the uploader nor a parent editor + // (delete). Same mapping rationale as the capability gates above. + if (error?.code === 'ATTACHMENT_PARENT_ACCESS' || error?.code === 'ATTACHMENT_DELETE_DENIED') { + return { + status: 403, + body: { + error: error?.message ?? 'Attachment access denied', + code: error.code, + ...(error?.object || object ? { object: error?.object ?? object } : {}), + }, + }; + } // Short-circuit: explicit security denial → 403. Match by `code` / // `name` to avoid pulling a runtime dependency on plugin-security. if ( diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 909f3583ab..ef2a48aa86 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -2110,6 +2110,36 @@ describe('mapDataError — schema/constraint envelopes', () => { expect(r.body.object).toBe('crm_lead'); }); + // #2755: attachment access gates from service-storage's engine hooks — + // same generic-data-path rationale as the capability gates above. + it('maps ATTACHMENT_PARENT_ACCESS → 403 with the parent object', () => { + const r = mapDataError( + Object.assign(new Error('Cannot attach to crm_lead/rec1: the parent record does not exist or you do not have access to it'), { + code: 'ATTACHMENT_PARENT_ACCESS', + status: 403, + object: 'crm_lead', + }), + 'sys_attachment', + ); + expect(r.status).toBe(403); + expect(r.body.code).toBe('ATTACHMENT_PARENT_ACCESS'); + expect(r.body.object).toBe('crm_lead'); + }); + + it('maps ATTACHMENT_DELETE_DENIED → 403', () => { + const r = mapDataError( + Object.assign(new Error('Cannot delete attachment a1: only the uploader or a user who can edit the parent record (crm_lead/rec1) may delete it'), { + code: 'ATTACHMENT_DELETE_DENIED', + status: 403, + object: 'crm_lead', + }), + 'sys_attachment', + ); + expect(r.status).toBe(403); + expect(r.body.code).toBe('ATTACHMENT_DELETE_DENIED'); + expect(r.body.object).toBe('crm_lead'); + }); + it('maps SQLite "has no column named" → 400 INVALID_FIELD with the field', () => { const r = mapDataError( sqliteError( diff --git a/packages/services/service-storage/src/attachment-access-hooks.test.ts b/packages/services/service-storage/src/attachment-access-hooks.test.ts new file mode 100644 index 0000000000..9ab16fa0a0 --- /dev/null +++ b/packages/services/service-storage/src/attachment-access-hooks.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { installAttachmentAccessHooks, type AttachmentSharingLike } from './attachment-access-hooks.js'; +import type { AttachmentLifecycleEngine } from './attachment-lifecycle.js'; + +const silentLogger = () => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn() }); + +/** Capture the two registered hooks so tests can drive them directly. */ +function install(opts: { + attachments?: Array>; + sharing?: AttachmentSharingLike | null; +}) { + const hooks = new Map Promise>(); + const engine: AttachmentLifecycleEngine = { + registerHook: (event, handler) => { + hooks.set(event, handler as any); + }, + find: async (_object, options: any) => { + const rows = (opts.attachments ?? []).filter((r) => + Object.entries(options?.where ?? {}).every(([k, v]) => r[k] === v), + ); + return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows; + }, + findOne: async (_object, options: any) => + (opts.attachments ?? []).find((r) => + Object.entries(options?.where ?? {}).every(([k, v]) => r[k] === v), + ) ?? null, + update: async () => ({}), + }; + installAttachmentAccessHooks(engine, () => opts.sharing, silentLogger()); + return { + beforeInsert: hooks.get('beforeInsert')!, + beforeDelete: hooks.get('beforeDelete')!, + }; +} + +/** Caller-scoped api fake: `visibleParents` is the set of records the + * caller can read, keyed `object/id`. */ +function apiFor(visibleParents: string[]) { + return { + object: (name: string) => ({ + findOne: async ({ where }: any) => + visibleParents.includes(`${name}/${where.id}`) ? { id: where.id } : null, + }), + }; +} + +const insertCtx = (data: any, opts: { userId?: string; isSystem?: boolean; visible?: string[] } = {}) => ({ + object: 'sys_attachment', + event: 'beforeInsert', + input: { data, options: { context: { userId: opts.userId, permissions: [] } } }, + session: opts.isSystem ? { isSystem: true, userId: opts.userId } : opts.userId ? { userId: opts.userId } : undefined, + api: apiFor(opts.visible ?? []), +}); + +const deleteCtx = (input: any, opts: { userId?: string; isSystem?: boolean; visible?: string[] } = {}) => ({ + object: 'sys_attachment', + event: 'beforeDelete', + input: { ...input, options: { ...(input.options ?? {}), context: { userId: opts.userId, permissions: [] } } }, + session: opts.isSystem ? { isSystem: true, userId: opts.userId } : opts.userId ? { userId: opts.userId } : undefined, + api: apiFor(opts.visible ?? []), +}); + +describe('attachment access — beforeInsert (parent visibility + provenance)', () => { + it('rejects attaching to a parent the caller cannot read (403 ATTACHMENT_PARENT_ACCESS)', async () => { + const { beforeInsert } = install({}); + const ctx = insertCtx( + { parent_object: 'att_secret', parent_id: 'r1', file_id: 'f1' }, + { userId: 'u1', visible: [] }, + ); + await expect(beforeInsert(ctx)).rejects.toMatchObject({ + code: 'ATTACHMENT_PARENT_ACCESS', + status: 403, + object: 'att_secret', + }); + }); + + it('allows attaching to a readable parent', async () => { + const { beforeInsert } = install({}); + const ctx = insertCtx( + { parent_object: 'att_case', parent_id: 'r1', file_id: 'f1' }, + { userId: 'u1', visible: ['att_case/r1'] }, + ); + await expect(beforeInsert(ctx)).resolves.toBeUndefined(); + }); + + it('server-stamps uploaded_by from the session, overwriting a spoofed value', async () => { + const { beforeInsert } = install({}); + const data = { parent_object: 'att_case', parent_id: 'r1', file_id: 'f1', uploaded_by: 'someone-else' }; + await beforeInsert(insertCtx(data, { userId: 'u1', visible: ['att_case/r1'] })); + expect(data.uploaded_by).toBe('u1'); + }); + + it('bypasses for system context and context-less calls', async () => { + const { beforeInsert } = install({}); + await expect( + beforeInsert(insertCtx({ parent_object: 'x', parent_id: 'r' }, { isSystem: true, userId: 'u1' })), + ).resolves.toBeUndefined(); + await expect(beforeInsert(insertCtx({ parent_object: 'x', parent_id: 'r' }, {}))).resolves.toBeUndefined(); + }); +}); + +describe('attachment access — beforeDelete (uploader or parent editor)', () => { + const row = { id: 'a1', file_id: 'f1', parent_object: 'att_secret', parent_id: 'r1', uploaded_by: 'uploader' }; + + it('the uploader may always delete their attachment', async () => { + const canEdit = vi.fn(async () => false); + const { beforeDelete } = install({ attachments: [row], sharing: { canEdit } }); + await expect(beforeDelete(deleteCtx({ id: 'a1' }, { userId: 'uploader' }))).resolves.toBeUndefined(); + expect(canEdit).not.toHaveBeenCalled(); + }); + + it('a non-uploader without parent edit is rejected (403 ATTACHMENT_DELETE_DENIED)', async () => { + const { beforeDelete } = install({ attachments: [row], sharing: { canEdit: async () => false } }); + await expect(beforeDelete(deleteCtx({ id: 'a1' }, { userId: 'stranger' }))).rejects.toMatchObject({ + code: 'ATTACHMENT_DELETE_DENIED', + status: 403, + }); + }); + + it('a parent editor may delete another user\'s attachment', async () => { + const canEdit = vi.fn(async (object: string, recordId: string, ctx: any) => { + expect(object).toBe('att_secret'); + expect(recordId).toBe('r1'); + expect(ctx.userId).toBe('editor'); + return true; + }); + const { beforeDelete } = install({ attachments: [row], sharing: { canEdit } }); + await expect(beforeDelete(deleteCtx({ id: 'a1' }, { userId: 'editor' }))).resolves.toBeUndefined(); + }); + + it('multi-delete requires EVERY matched row to pass', async () => { + const rows = [ + { ...row, id: 'a1', uploaded_by: 'me' }, + { ...row, id: 'a2', uploaded_by: 'someone-else', parent_object: 'att_secret', parent_id: 'r2' }, + ]; + const { beforeDelete } = install({ attachments: rows, sharing: { canEdit: async () => false } }); + await expect( + beforeDelete(deleteCtx({ options: { where: { parent_object: 'att_secret' }, multi: true } }, { userId: 'me' })), + ).rejects.toMatchObject({ code: 'ATTACHMENT_DELETE_DENIED' }); + }); + + it('degrades to parent READ visibility when the sharing service is absent', async () => { + const { beforeDelete } = install({ attachments: [row], sharing: null }); + // caller can read the parent → allowed + await expect( + beforeDelete(deleteCtx({ id: 'a1' }, { userId: 'reader', visible: ['att_secret/r1'] })), + ).resolves.toBeUndefined(); + // caller cannot read the parent → denied + await expect( + beforeDelete(deleteCtx({ id: 'a1' }, { userId: 'reader', visible: [] })), + ).rejects.toMatchObject({ code: 'ATTACHMENT_DELETE_DENIED' }); + }); + + it('bypasses for system context; a no-match delete is not blocked', async () => { + const { beforeDelete } = install({ attachments: [row], sharing: { canEdit: async () => false } }); + await expect(beforeDelete(deleteCtx({ id: 'a1' }, { isSystem: true, userId: 'x' }))).resolves.toBeUndefined(); + await expect(beforeDelete(deleteCtx({ id: 'missing' }, { userId: 'x' }))).resolves.toBeUndefined(); + }); +}); diff --git a/packages/services/service-storage/src/attachment-access-hooks.ts b/packages/services/service-storage/src/attachment-access-hooks.ts new file mode 100644 index 0000000000..d0416cee03 Binary files /dev/null and b/packages/services/service-storage/src/attachment-access-hooks.ts differ diff --git a/packages/services/service-storage/src/attachment-lifecycle.test.ts b/packages/services/service-storage/src/attachment-lifecycle.test.ts new file mode 100644 index 0000000000..13b5ba7543 --- /dev/null +++ b/packages/services/service-storage/src/attachment-lifecycle.test.ts @@ -0,0 +1,249 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { + installAttachmentLifecycleHooks, + createSysFileReapGuard, + type AttachmentLifecycleEngine, +} from './attachment-lifecycle.js'; + +const silentLogger = () => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn() }); + +/** + * In-memory fake engine: a sys_attachment table + a sys_file table, plus a + * hook registry so tests can drive the engine seams the way the real engine + * does (same HookContext object across beforeDelete → afterDelete). + */ +function fakeEngine(seed: { + attachments?: Array>; + files?: Array>; +}) { + const tables: Record>> = { + sys_attachment: [...(seed.attachments ?? [])], + sys_file: [...(seed.files ?? [])], + }; + const hooks = new Map Promise | void>>(); + const updates: Array<{ object: string; data: any }> = []; + + const matches = (row: Record, where: Record) => + Object.entries(where).every(([k, v]) => row[k] === v); + + const engine: AttachmentLifecycleEngine & { + tables: typeof tables; + updates: typeof updates; + trigger(event: string, ctx: any): Promise; + deleteRows(where: Record): void; + } = { + registerHook(event, handler, opts) { + expect(opts?.object).toBe('sys_attachment'); + const list = hooks.get(event) ?? []; + list.push(handler); + hooks.set(event, list); + }, + async find(object, options: any) { + const rows = tables[object].filter((r) => matches(r, options?.where ?? {})); + return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows; + }, + async findOne(object, options: any) { + return tables[object].find((r) => matches(r, options?.where ?? {})) ?? null; + }, + async update(object, data: any, _options) { + updates.push({ object, data }); + const row = tables[object].find((r) => r.id === data.id); + if (row) Object.assign(row, data); + return row; + }, + tables, + updates, + async trigger(event, ctx) { + for (const h of hooks.get(event) ?? []) await h(ctx); + }, + deleteRows(where) { + tables.sys_attachment = tables.sys_attachment.filter((r) => !matches(r, where)); + }, + }; + return engine; +} + +/** Drive a full engine-shaped delete: beforeDelete → row removal → afterDelete + * with ONE shared ctx object (mirrors engine.ts delete()). */ +async function driveDelete(engine: ReturnType, input: any, where: Record) { + const ctx: any = { object: 'sys_attachment', event: 'beforeDelete', input }; + await engine.trigger('beforeDelete', ctx); + engine.deleteRows(where); + ctx.event = 'afterDelete'; + await engine.trigger('afterDelete', ctx); +} + +const committedFile = (id: string, scope = 'attachments') => ({ + id, + key: `attachments/${id}.bin`, + scope, + status: 'committed', +}); + +describe('installAttachmentLifecycleHooks — tombstoning', () => { + it('tombstones the file when the LAST join row is deleted (by id)', async () => { + const engine = fakeEngine({ + attachments: [{ id: 'a1', file_id: 'f1' }], + files: [committedFile('f1')], + }); + installAttachmentLifecycleHooks(engine, silentLogger()); + + await driveDelete(engine, { id: 'a1', options: {} }, { id: 'a1' }); + + expect(engine.updates).toHaveLength(1); + expect(engine.updates[0].data).toMatchObject({ id: 'f1', status: 'deleted' }); + expect(typeof engine.updates[0].data.deleted_at).toBe('string'); + }); + + it('does NOT tombstone while another join row still references the file', async () => { + const engine = fakeEngine({ + attachments: [ + { id: 'a1', file_id: 'f1' }, + { id: 'a2', file_id: 'f1' }, // second parent, same file + ], + files: [committedFile('f1')], + }); + installAttachmentLifecycleHooks(engine, silentLogger()); + + await driveDelete(engine, { id: 'a1', options: {} }, { id: 'a1' }); + + expect(engine.updates).toHaveLength(0); + expect(engine.tables.sys_file[0].status).toBe('committed'); + }); + + it('resolves every affected file on a multi-delete (options.where)', async () => { + const engine = fakeEngine({ + attachments: [ + { id: 'a1', file_id: 'f1', parent_id: 'p1' }, + { id: 'a2', file_id: 'f2', parent_id: 'p1' }, + { id: 'a3', file_id: 'f2', parent_id: 'p2' }, // f2 keeps a ref + ], + files: [committedFile('f1'), committedFile('f2')], + }); + installAttachmentLifecycleHooks(engine, silentLogger()); + + await driveDelete( + engine, + { id: undefined, options: { where: { parent_id: 'p1' }, multi: true } }, + { parent_id: 'p1' }, + ); + + expect(engine.updates.map((u) => u.data.id)).toEqual(['f1']); + }); + + it('never tombstones non-attachments scopes (Field.file/avatar protection)', async () => { + const engine = fakeEngine({ + attachments: [{ id: 'a1', file_id: 'f1' }], + files: [committedFile('f1', 'user')], + }); + installAttachmentLifecycleHooks(engine, silentLogger()); + + await driveDelete(engine, { id: 'a1', options: {} }, { id: 'a1' }); + + expect(engine.updates).toHaveLength(0); + }); + + it('un-tombstones on re-attach (afterInsert)', async () => { + const engine = fakeEngine({ + attachments: [], + files: [{ id: 'f1', key: 'attachments/f1.bin', scope: 'attachments', status: 'deleted', deleted_at: '2026-01-01T00:00:00Z' }], + }); + installAttachmentLifecycleHooks(engine, silentLogger()); + + await engine.trigger('afterInsert', { + object: 'sys_attachment', + event: 'afterInsert', + input: { doc: { file_id: 'f1' } }, + result: { id: 'a9', file_id: 'f1' }, + }); + + expect(engine.updates).toHaveLength(1); + expect(engine.updates[0].data).toMatchObject({ id: 'f1', status: 'committed', deleted_at: null }); + }); + + it('a failing lookup never blocks the delete (best-effort)', async () => { + const engine = fakeEngine({ attachments: [], files: [] }); + engine.findOne = async () => { + throw new Error('driver exploded'); + }; + const logger = silentLogger(); + installAttachmentLifecycleHooks(engine, logger); + + await expect(driveDelete(engine, { id: 'a1', options: {} }, { id: 'a1' })).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalled(); + }); +}); + +describe('createSysFileReapGuard', () => { + const storage = () => ({ delete: vi.fn(async () => {}) }) as any; + + it('confirms zero-ref tombstones after deleting the bytes', async () => { + const engine = fakeEngine({ attachments: [], files: [] }); + const s = storage(); + const guard = createSysFileReapGuard(engine, () => s, silentLogger()); + + const confirmed = await guard('sys_file', [ + { id: 'f1', key: 'attachments/f1.bin', status: 'deleted' }, + ]); + + expect(s.delete).toHaveBeenCalledWith('attachments/f1.bin'); + expect(confirmed).toEqual(['f1']); + }); + + it('vetoes and un-tombstones a row that regained references (sweep-time re-verification)', async () => { + const engine = fakeEngine({ + attachments: [{ id: 'a1', file_id: 'f1' }], + files: [{ id: 'f1', key: 'attachments/f1.bin', scope: 'attachments', status: 'deleted', deleted_at: '2026-01-01T00:00:00Z' }], + }); + const s = storage(); + const guard = createSysFileReapGuard(engine, () => s, silentLogger()); + + const confirmed = await guard('sys_file', [ + { id: 'f1', key: 'attachments/f1.bin', status: 'deleted' }, + ]); + + expect(confirmed).toEqual([]); + expect(s.delete).not.toHaveBeenCalled(); + expect(engine.updates[0].data).toMatchObject({ id: 'f1', status: 'committed', deleted_at: null }); + }); + + it('a byte-delete failure vetoes the row (retried next sweep, bytes never leaked)', async () => { + const engine = fakeEngine({ attachments: [], files: [] }); + const s = { delete: vi.fn(async () => { throw new Error('S3 down'); }) } as any; + const logger = silentLogger(); + const guard = createSysFileReapGuard(engine, () => s, logger); + + const confirmed = await guard('sys_file', [ + { id: 'f1', key: 'attachments/f1.bin', status: 'deleted' }, + ]); + + expect(confirmed).toEqual([]); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('confirms abandoned pending uploads with best-effort byte cleanup', async () => { + const engine = fakeEngine({ attachments: [], files: [] }); + const s = storage(); + const guard = createSysFileReapGuard(engine, () => s, silentLogger()); + + const confirmed = await guard('sys_file', [ + { id: 'p1', key: 'user/p1.bin', status: 'pending' }, + ]); + + expect(s.delete).toHaveBeenCalledWith('user/p1.bin'); + expect(confirmed).toEqual(['p1']); + }); + + it('vetoes rows in any other state (fail toward retention)', async () => { + const engine = fakeEngine({ attachments: [], files: [] }); + const guard = createSysFileReapGuard(engine, storage, silentLogger()); + + const confirmed = await guard('sys_file', [ + { id: 'c1', key: 'attachments/c1.bin', status: 'committed' }, + ]); + + expect(confirmed).toEqual([]); + }); +}); diff --git a/packages/services/service-storage/src/attachment-lifecycle.ts b/packages/services/service-storage/src/attachment-lifecycle.ts new file mode 100644 index 0000000000..97bf520df5 --- /dev/null +++ b/packages/services/service-storage/src/attachment-lifecycle.ts @@ -0,0 +1,245 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { IStorageService } from '@objectstack/spec/contracts'; + +/** + * sys_file orphan lifecycle (#2755, ADR-0057). + * + * The generic Attachments surface (#2727) separates file storage + * (`sys_file`) from "where the file is attached" (`sys_attachment` join + * rows, Salesforce ContentDocumentLink pattern). Deleting an attachment + * deletes only the join row — one file can back many attachments, so no + * naive cascade. This module closes the resulting orphan leak: + * + * 1. Tombstone hooks (this file, installed on `sys_attachment`): when the + * LAST join row referencing an attachments-scope file is deleted, the + * `sys_file` row is marked `status='deleted'` + `deleted_at=now`. + * 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`. + * 3. The reap guard (this file, registered with the LifecycleService): + * re-verifies zero references at sweep time (hook races, direct-driver + * writes, future trash restore) and reclaims the storage bytes before + * confirming the row delete. Detection and scheduling stay inside the + * single platform sweep — ADR-0057 §3.3, no bespoke sweeper. + * + * Only `scope === 'attachments'` files are ever tombstoned: `Field.file` / + * `Field.image` / avatar uploads use other scopes and reference files from + * record columns the join-row count cannot see. + */ + +/** Engine surface these installers need — duck-typed like the other + * service-storage seams so tests can fake it. */ +export interface AttachmentLifecycleEngine { + registerHook( + event: string, + handler: (ctx: any) => void | Promise, + options?: { object?: string; packageId?: string }, + ): void; + find(object: string, options: Record): Promise>>; + findOne(object: string, options: Record): Promise | null>; + update(object: string, data: Record, options: Record): Promise; +} + +export interface AttachmentLifecycleLogger { + info(msg: string, meta?: unknown): void; + warn(msg: string, meta?: unknown): void; + debug?(msg: string, meta?: unknown): void; +} + +const PACKAGE_ID = 'com.objectstack.service.storage'; +const SYSTEM_CTX = { isSystem: true } as const; +/** Bound on join rows resolved per multi-delete — matches the reap-guard + * batch posture: bound one pass, converge across sweeps. */ +const MULTI_DELETE_RESOLVE_LIMIT = 1_000; + +/** Key under which beforeDelete stashes file ids for afterDelete (the engine + * passes the SAME HookContext object to both events). */ +const STASH_KEY = '__attachmentFileIds'; + +function asIdList(id: unknown): Array | null { + if (typeof id === 'string' || typeof id === 'number') return [id]; + if (id && typeof id === 'object' && Array.isArray((id as any).$in)) { + return (id as any).$in.filter((v: unknown) => typeof v === 'string' || typeof v === 'number'); + } + return null; +} + +/** + * Install the tombstone hooks on `sys_attachment`. Lifecycle bookkeeping + * must never block or fail a user's delete/insert — every handler is + * best-effort and only logs on failure. + */ +export function installAttachmentLifecycleHooks( + engine: AttachmentLifecycleEngine, + logger: AttachmentLifecycleLogger, +): void { + // beforeDelete: resolve the file_id(s) of the join row(s) about to die — + // after the delete they are unreadable. Stash on the shared HookContext. + engine.registerHook( + 'beforeDelete', + async (ctx: any) => { + try { + const fileIds = new Set(); + const ids = asIdList(ctx?.input?.id); + if (ids) { + for (const id of ids) { + const row = await engine.findOne('sys_attachment', { where: { id }, context: { ...SYSTEM_CTX } }); + if (row?.file_id) fileIds.add(String(row.file_id)); + } + } else if (ctx?.input?.options?.where) { + const rows = await engine.find('sys_attachment', { + where: ctx.input.options.where, + limit: MULTI_DELETE_RESOLVE_LIMIT, + context: { ...SYSTEM_CTX }, + }); + for (const row of rows ?? []) { + if (row?.file_id) fileIds.add(String(row.file_id)); + } + } + ctx[STASH_KEY] = [...fileIds]; + } catch (err) { + // Never block the delete; the reap guard's sweep-time re-verification + // cannot resurrect a missed tombstone, but a missed tombstone only + // means the orphan lingers — fail toward retention, not data loss. + logger.warn( + `[storage] attachment lifecycle: failed to resolve file ids before delete (${(err as Error)?.message ?? err})`, + ); + ctx[STASH_KEY] = []; + } + }, + { object: 'sys_attachment', packageId: PACKAGE_ID }, + ); + + // afterDelete: any stashed file with zero remaining references is + // tombstoned — if (and only if) it is an attachments-scope committed file. + engine.registerHook( + 'afterDelete', + async (ctx: any) => { + const fileIds: string[] = Array.isArray(ctx?.[STASH_KEY]) ? ctx[STASH_KEY] : []; + for (const fileId of fileIds) { + try { + const remaining = await engine.find('sys_attachment', { + where: { file_id: fileId }, + limit: 1, + context: { ...SYSTEM_CTX }, + }); + 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 } }, + ); + logger.debug?.(`[storage] attachment lifecycle: tombstoned orphan sys_file ${fileId}`); + } catch (err) { + logger.warn( + `[storage] attachment lifecycle: failed to tombstone sys_file ${fileId} (${(err as Error)?.message ?? err})`, + ); + } + } + }, + { object: 'sys_attachment', packageId: PACKAGE_ID }, + ); + + // afterInsert: re-attaching a tombstoned file (grace window not yet + // expired) brings it back to life. + engine.registerHook( + 'afterInsert', + async (ctx: any) => { + try { + const row: any = ctx?.result ?? ctx?.input?.doc ?? ctx?.input?.data; + const fileId = row?.file_id; + if (!fileId) return; + const file = await engine.findOne('sys_file', { where: { id: String(fileId) }, context: { ...SYSTEM_CTX } }); + if (!file || file.status !== 'deleted') return; + await engine.update( + 'sys_file', + { id: String(fileId), status: 'committed', deleted_at: null }, + { context: { ...SYSTEM_CTX } }, + ); + logger.debug?.(`[storage] attachment lifecycle: un-tombstoned re-attached sys_file ${fileId}`); + } catch (err) { + logger.warn( + `[storage] attachment lifecycle: failed to un-tombstone on re-attach (${(err as Error)?.message ?? err})`, + ); + } + }, + { object: 'sys_attachment', packageId: PACKAGE_ID }, + ); +} + +/** + * The `sys_file` reap guard ({@link LifecycleReapGuard} shape from + * `@objectstack/objectql`, duck-typed here to avoid the dependency). + * Candidates arrive from the two declared policies — tombstones past the + * TTL and `pending` uploads past retention — and each is either confirmed + * (bytes reclaimed first) or vetoed (kept this sweep): + * + * - `pending`: the upload was never completed; bytes may or may not exist. + * Best-effort byte delete, then confirm. + * - `deleted`: re-verify ZERO sys_attachment references at sweep time. + * References found (hook bypass, restore) → un-tombstone and veto. + * Zero references → delete bytes; a byte-delete failure vetoes so the + * row is retried next sweep (the row is the only pointer to the bytes — + * dropping it first would leak the bytes forever). + * - anything else: veto (shouldn't be a candidate; fail toward retention). + */ +export function createSysFileReapGuard( + engine: AttachmentLifecycleEngine, + getStorage: () => IStorageService | null | undefined, + logger: AttachmentLifecycleLogger, +): (object: string, rows: Array>) => Promise> { + return async (_object, rows) => { + const confirmed: Array = []; + const storage = getStorage(); + for (const row of rows) { + const id = row?.id as string | number | undefined; + if (id === undefined || id === null) continue; + + if (row.status === 'pending') { + try { + if (storage && typeof row.key === 'string' && row.key) await storage.delete(row.key); + confirmed.push(id); + } catch (err) { + logger.warn( + `[storage] reap guard: byte delete failed for pending sys_file ${id} (${(err as Error)?.message ?? err}); retrying next sweep`, + ); + } + continue; + } + + if (row.status === 'deleted') { + try { + const refs = await engine.find('sys_attachment', { + where: { file_id: String(id) }, + limit: 1, + context: { ...SYSTEM_CTX }, + }); + if (refs?.length) { + await engine.update( + 'sys_file', + { id, status: 'committed', deleted_at: null }, + { context: { ...SYSTEM_CTX } }, + ); + logger.info( + `[storage] reap guard: sys_file ${id} regained references since tombstoning — un-tombstoned, not reaped`, + ); + continue; + } + if (storage && typeof row.key === 'string' && row.key) await storage.delete(row.key); + confirmed.push(id); + } catch (err) { + logger.warn( + `[storage] reap guard: reclaim failed for sys_file ${id} (${(err as Error)?.message ?? err}); retrying next sweep`, + ); + } + continue; + } + // Not a state this guard reaps — veto (fail toward retention). + } + return confirmed; + }; +} diff --git a/packages/services/service-storage/src/index.ts b/packages/services/service-storage/src/index.ts index 66978632f8..981fa7a409 100644 --- a/packages/services/service-storage/src/index.ts +++ b/packages/services/service-storage/src/index.ts @@ -12,3 +12,7 @@ export type { FileRecord, UploadSessionRecord } from './metadata-store.js'; export { registerStorageRoutes } from './storage-routes.js'; export type { StorageRoutesOptions } from './storage-routes.js'; export { SystemFile, SystemUploadSession } from './objects/index.js'; +export { installAttachmentLifecycleHooks, createSysFileReapGuard } from './attachment-lifecycle.js'; +export type { AttachmentLifecycleEngine, AttachmentLifecycleLogger } from './attachment-lifecycle.js'; +export { installAttachmentAccessHooks } from './attachment-access-hooks.js'; +export type { AttachmentSharingLike } from './attachment-access-hooks.js'; diff --git a/packages/services/service-storage/src/metadata-store.ts b/packages/services/service-storage/src/metadata-store.ts index 0de6e3d85b..7e03552ab0 100644 --- a/packages/services/service-storage/src/metadata-store.ts +++ b/packages/services/service-storage/src/metadata-store.ts @@ -20,6 +20,9 @@ export interface FileRecord { metadata?: string; created_at?: string; updated_at?: string; + /** Orphan tombstone (#2755) — set when the last sys_attachment reference + * is removed; the sys_file lifecycle TTL reaps after the grace window. */ + deleted_at?: string | null; } /** diff --git a/packages/services/service-storage/src/objects/system-file.object.ts b/packages/services/service-storage/src/objects/system-file.object.ts index 94540d8388..d01e1e8c9c 100644 --- a/packages/services/service-storage/src/objects/system-file.object.ts +++ b/packages/services/service-storage/src/objects/system-file.object.ts @@ -63,6 +63,11 @@ export const SystemFile = ObjectSchema.create({ { label: 'Public', value: 'public' }, { label: 'Private', value: 'private' }, { label: 'Temp', value: 'temp' }, + // Files uploaded through the generic Attachments surface (#2727). + // Their only legitimate referrers are sys_attachment join rows, so + // this scope is the discriminator for orphan tombstoning (#2755) — + // field-attachment scopes above are never tombstoned. + { label: 'Attachments', value: 'attachments' }, ], }), @@ -107,5 +112,27 @@ export const SystemFile = ObjectSchema.create({ updated_at: Field.datetime({ label: 'Updated At', }), + + deleted_at: Field.datetime({ + label: 'Deleted At', + description: + 'Tombstone timestamp — set when the last sys_attachment reference to an attachments-scope file is removed; the lifecycle TTL reaps the row (and its storage bytes, via the sys_file reap guard) after the grace window. NULL for live rows.', + }), + }, + + // ADR-0057 (#2755): sys_file rows are mostly permanent business truth, but + // two terminal states are garbage that would otherwise grow forever: + // - tombstoned attachment orphans (status='deleted', deleted_at set by + // the attachment lifecycle hooks when the last join row is removed) + // - never-completed presigned/chunked uploads (status='pending') + // Committed rows carry neither trigger (NULL deleted_at, status≠pending), + // so they are immortal. Byte reclaim + sweep-time re-verification happen in + // the reap guard registered by StorageServicePlugin. A kernel that loads + // ObjectQL without this plugin reaps matching rows without byte cleanup — + // harmless there, since only this plugin's hooks ever write the triggers. + lifecycle: { + class: 'transient', + ttl: { field: 'deleted_at', expireAfter: '30d' }, + retention: { maxAge: '7d', onlyWhen: { status: 'pending' } }, }, }); diff --git a/packages/services/service-storage/src/storage-routes.test.ts b/packages/services/service-storage/src/storage-routes.test.ts index 5582545d0a..f0c54a8258 100644 --- a/packages/services/service-storage/src/storage-routes.test.ts +++ b/packages/services/service-storage/src/storage-routes.test.ts @@ -284,4 +284,79 @@ describe('Storage REST Routes', () => { expect(putRes._status).toBe(403); }); }); + + describe('upload auth gate (#2755)', () => { + const presignBody = { filename: 'a.txt', mimeType: 'text/plain', size: 3 }; + + function registerWithResolver(resolveSession: any) { + const server = createMockHttpServer(); + registerStorageRoutes(server as any, adapter, store, { + basePath: '/api/v1/storage', + resolveSession, + }); + return server; + } + + it('401s anonymous requests on every upload entry point when a resolver is wired', async () => { + const server = registerWithResolver(async () => null); + const uploadRoutes: Array<[string, string, any]> = [ + ['POST', '/api/v1/storage/upload/presigned', { body: presignBody }], + ['POST', '/api/v1/storage/upload/complete', { body: { fileId: 'x' } }], + ['POST', '/api/v1/storage/upload/chunked', { body: { filename: 'a', mimeType: 't', totalSize: 1 } }], + ['PUT', '/api/v1/storage/upload/chunked/:uploadId/chunk/:chunkIndex', { params: { uploadId: 'u', chunkIndex: '0' } }], + ['POST', '/api/v1/storage/upload/chunked/:uploadId/complete', { params: { uploadId: 'u' } }], + ['GET', '/api/v1/storage/upload/chunked/:uploadId/progress', { params: { uploadId: 'u' } }], + ]; + for (const [method, path, reqOpts] of uploadRoutes) { + const res = createMockRes(); + await server._getHandler(method, path)!(createMockReq(reqOpts as any), res); + expect(res._status, `${method} ${path}`).toBe(401); + expect(res._json?.code, `${method} ${path}`).toBe('AUTH_REQUIRED'); + } + }); + + it('stamps owner_id from the resolved session on presigned uploads', async () => { + const server = registerWithResolver(async () => ({ userId: 'user-42' })); + const res = createMockRes(); + await server._getHandler('POST', '/api/v1/storage/upload/presigned')!( + createMockReq({ body: presignBody }), + res, + ); + expect(res._status).toBe(200); + const file = await store.getFile(res._json.data.fileId); + expect(file?.owner_id).toBe('user-42'); + }); + + it('download routes stay open even with a resolver wired (capability URLs)', async () => { + const server = registerWithResolver(async () => null); + const res = createMockRes(); + await server._getHandler('GET', '/api/v1/storage/files/:fileId/url')!( + createMockReq({ params: { fileId: 'missing' } }), + res, + ); + expect(res._status).toBe(404); // not 401 — anonymous reads reach the handler + }); + + it('stays open (back-compat) when no resolver is wired', async () => { + // The default beforeEach registration has no resolver. + const res = createMockRes(); + await httpServer._getHandler('POST', '/api/v1/storage/upload/presigned')!( + createMockReq({ body: presignBody }), + res, + ); + expect(res._status).toBe(200); + const file = await store.getFile(res._json.data.fileId); + expect(file?.owner_id).toBeUndefined(); + }); + + it('a throwing resolver fails closed (401), never open', async () => { + const server = registerWithResolver(async () => { throw new Error('auth backend down'); }); + const res = createMockRes(); + await server._getHandler('POST', '/api/v1/storage/upload/presigned')!( + createMockReq({ body: presignBody }), + res, + ); + expect(res._status).toBe(401); + }); + }); }); diff --git a/packages/services/service-storage/src/storage-routes.ts b/packages/services/service-storage/src/storage-routes.ts index 9d828a1ee9..3a85ff018d 100644 --- a/packages/services/service-storage/src/storage-routes.ts +++ b/packages/services/service-storage/src/storage-routes.ts @@ -14,6 +14,18 @@ export interface StorageRoutesOptions { presignedTtl?: number; /** Default chunked upload session TTL in seconds */ sessionTtl?: number; + /** + * Session resolver for the UPLOAD entry points (#2755). When wired, the + * presigned/complete/chunked upload routes reject anonymous requests with + * 401 `AUTH_REQUIRED`, and new sys_file rows are stamped with + * `owner_id = session.userId`. When absent (bare kernels, tests), the + * routes stay open — back-compat, logged once. Download routes are NOT + * gated here (capability URLs embedded in /; gating them + * is a tracked follow-up needing cookie sessions or signed links). + */ + resolveSession?: (req: IHttpRequest) => Promise<{ userId?: string } | null | undefined>; + /** Optional logger for the one-time open-mode notice. */ + logger?: { info(msg: string): void; warn(msg: string): void }; } /** @@ -44,11 +56,43 @@ export function registerStorageRoutes( const presignedTtl = opts.presignedTtl ?? 3600; const sessionTtl = opts.sessionTtl ?? 86400; + // ── Upload auth gate (#2755) ───────────────────────────────────────── + // `false` ⇒ the 401 was already sent and the handler must stop. + // `null` ⇒ open mode (no resolver wired) — proceed unauthenticated. + let warnedOpenUploads = false; + const requireUploadSession = async ( + req: IHttpRequest, + res: IHttpResponse, + ): Promise<{ userId?: string } | null | false> => { + if (!opts.resolveSession) { + if (!warnedOpenUploads) { + warnedOpenUploads = true; + opts.logger?.info( + '[storage] no session resolver wired — upload routes accept anonymous requests (bare-kernel mode)', + ); + } + return null; + } + let session: { userId?: string } | null | undefined; + try { + session = await opts.resolveSession(req); + } catch { + session = null; + } + if (!session?.userId) { + res.status(401).json({ error: 'Authentication required to upload files', code: 'AUTH_REQUIRED' }); + return false; + } + return session; + }; + // --------------------------------------------------------------------------- // POST /storage/upload/presigned // --------------------------------------------------------------------------- httpServer.post(`${basePath}/upload/presigned`, async (req: IHttpRequest, res: IHttpResponse) => { try { + const session = await requireUploadSession(req, res); + if (session === false) return; const { filename, mimeType, size, scope, bucket } = req.body ?? {}; if (!filename || !mimeType || size == null) { res.status(400).json({ error: 'filename, mimeType, and size are required' }); @@ -69,6 +113,7 @@ export function registerStorageRoutes( bucket, acl: 'private', status: 'pending', + owner_id: session?.userId, }); // If adapter supports presigned upload, use it; otherwise build a local stub URL @@ -108,6 +153,7 @@ export function registerStorageRoutes( // --------------------------------------------------------------------------- httpServer.post(`${basePath}/upload/complete`, async (req: IHttpRequest, res: IHttpResponse) => { try { + if ((await requireUploadSession(req, res)) === false) return; const { fileId, eTag } = req.body ?? {}; if (!fileId) { res.status(400).json({ error: 'fileId is required' }); @@ -146,6 +192,8 @@ export function registerStorageRoutes( // --------------------------------------------------------------------------- httpServer.post(`${basePath}/upload/chunked`, async (req: IHttpRequest, res: IHttpResponse) => { try { + const session = await requireUploadSession(req, res); + if (session === false) return; const { filename, mimeType, totalSize, chunkSize: reqChunkSize, scope, bucket, metadata } = req.body ?? {}; if (!filename || !mimeType || !totalSize) { res.status(400).json({ error: 'filename, mimeType, and totalSize are required' }); @@ -170,6 +218,7 @@ export function registerStorageRoutes( acl: 'private', status: 'pending', metadata: metadata ? JSON.stringify(metadata) : undefined, + owner_id: session?.userId, }); // Initiate chunked upload in backend @@ -224,6 +273,7 @@ export function registerStorageRoutes( // --------------------------------------------------------------------------- httpServer.put(`${basePath}/upload/chunked/:uploadId/chunk/:chunkIndex`, async (req: IHttpRequest, res: IHttpResponse) => { try { + if ((await requireUploadSession(req, res)) === false) return; const { uploadId, chunkIndex: chunkIndexStr } = req.params; const chunkIndex = parseInt(chunkIndexStr, 10); if (!uploadId || isNaN(chunkIndex)) { @@ -291,6 +341,7 @@ export function registerStorageRoutes( // --------------------------------------------------------------------------- httpServer.post(`${basePath}/upload/chunked/:uploadId/complete`, async (req: IHttpRequest, res: IHttpResponse) => { try { + if ((await requireUploadSession(req, res)) === false) return; const { uploadId } = req.params; const session = await store.getSession(uploadId); if (!session) { @@ -334,6 +385,7 @@ export function registerStorageRoutes( // --------------------------------------------------------------------------- httpServer.get(`${basePath}/upload/chunked/:uploadId/progress`, async (req: IHttpRequest, res: IHttpResponse) => { try { + if ((await requireUploadSession(req, res)) === false) return; const { uploadId } = req.params; const session = await store.getSession(uploadId); if (!session) { diff --git a/packages/services/service-storage/src/storage-service-plugin.test.ts b/packages/services/service-storage/src/storage-service-plugin.test.ts index d22ef14361..a1e8327f00 100644 --- a/packages/services/service-storage/src/storage-service-plugin.test.ts +++ b/packages/services/service-storage/src/storage-service-plugin.test.ts @@ -189,3 +189,54 @@ describe('StorageServicePlugin: settings live-wire', () => { expect(proxy.getInner()).toBe(before); // no swap }); }); + +describe('StorageServicePlugin: sys_file orphan lifecycle wiring (#2755)', () => { + it('installs sys_attachment hooks and registers the sys_file reap guard at kernel:ready', async () => { + const dir = await fs.mkdtemp(join(tmpdir(), 'oss-lifecycle-')); + const plugin = new StorageServicePlugin({ adapter: 'local', local: { rootDir: dir }, registerRoutes: false }); + const ctx = makeCtx(); + + const hookEvents: string[] = []; + ctx.registerService('objectql', { + registerHook: (event: string, _fn: unknown, opts: any) => { + expect(opts?.object).toBe('sys_attachment'); + hookEvents.push(event); + }, + find: async () => [], + findOne: async () => null, + update: async () => ({}), + }); + const guards: Array<{ object: string; guard: unknown }> = []; + ctx.registerService('lifecycle', { + registerReapGuard: (object: string, guard: unknown) => guards.push({ object, guard }), + }); + + await plugin.init(ctx); + await plugin.start(ctx); + await ctx._flushReady(); + + // Lifecycle hooks (beforeDelete/afterDelete/afterInsert) + access hooks + // (beforeInsert/beforeDelete) — see attachment-lifecycle.ts and + // attachment-access-hooks.ts. + expect(hookEvents.sort()).toEqual([ + 'afterDelete', + 'afterInsert', + 'beforeDelete', + 'beforeDelete', + 'beforeInsert', + ]); + expect(guards).toHaveLength(1); + expect(guards[0].object).toBe('sys_file'); + expect(typeof guards[0].guard).toBe('function'); + }); + + it('degrades silently on a bare kernel (no engine, no lifecycle service)', async () => { + const dir = await fs.mkdtemp(join(tmpdir(), 'oss-bare-')); + const plugin = new StorageServicePlugin({ adapter: 'local', local: { rootDir: dir }, registerRoutes: false }); + const ctx = makeCtx(); + + await plugin.init(ctx); + await plugin.start(ctx); + await expect(ctx._flushReady()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index 8a56f0dd1d..60a3aa4299 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -13,6 +13,8 @@ import { S3StorageAdapter } from './s3-storage-adapter.js'; import type { S3StorageAdapterOptions } from './s3-storage-adapter.js'; import { StorageMetadataStore } from './metadata-store.js'; import { registerStorageRoutes } from './storage-routes.js'; +import { installAttachmentLifecycleHooks, createSysFileReapGuard } from './attachment-lifecycle.js'; +import { installAttachmentAccessHooks } from './attachment-access-hooks.js'; import { SystemFile, SystemUploadSession } from './objects/index.js'; // ADR-0052 §3 ownership: `sys_attachment` (a file↔record link) belongs with the // storage domain, not the audit/compliance ledger. Definition stays in @@ -183,6 +185,49 @@ export class StorageServicePlugin implements Plugin { async start(ctx: PluginContext): Promise { ctx.hook('kernel:ready', async () => { + let engine: IDataEngine | null = null; + try { + engine = ctx.getService('objectql'); + } catch { + // data engine not wired — routes fall back to the in-memory store, + // attachment lifecycle is inert (nothing persists sys_attachment). + } + + // ── sys_file orphan lifecycle (#2755) ───────────────────────── + // Tombstone hooks on sys_attachment + the reap guard that reclaims + // storage bytes (and re-verifies references) inside the platform + // lifecycle sweep. Both degrade silently on bare kernels. + if (engine && typeof (engine as any).registerHook === 'function') { + installAttachmentLifecycleHooks(engine as any, ctx.logger); + // Parent-derived access on the join rows (#2755, ADR-0049) — the + // sharing service resolves lazily so plugin order doesn't matter. + installAttachmentAccessHooks( + engine as any, + () => { + try { + return ctx.getService('sharing'); + } catch { + return null; + } + }, + ctx.logger, + ); + try { + const lifecycle = ctx.getService('lifecycle'); + if (lifecycle && typeof lifecycle.registerReapGuard === 'function') { + lifecycle.registerReapGuard( + 'sys_file', + createSysFileReapGuard(engine as any, () => this.storage, ctx.logger), + ); + ctx.logger.info('StorageServicePlugin: sys_file reap guard registered with the lifecycle service'); + } + } catch { + // lifecycle service absent (bare kernel) — the sys_file lifecycle + // declaration stays safe: rows only gain reap triggers via the + // hooks above, and nothing sweeps without the LifecycleService. + } + } + // ── HTTP routes (existing behaviour) ─────────────────────────── if (this.options.registerRoutes !== false) { let httpServer: IHttpServer | null = null; @@ -193,18 +238,14 @@ export class StorageServicePlugin implements Plugin { } if (httpServer && this.storage) { - let engine: IDataEngine | null = null; - try { - engine = ctx.getService('objectql'); - } catch { - // data engine not wired — use in-memory fallback - } this.store = new StorageMetadataStore(engine); registerStorageRoutes(httpServer, this.storage, this.store, { basePath: this.options.basePath ?? '/api/v1/storage', presignedTtl: this.options.presignedTtl, sessionTtl: this.options.sessionTtl, + resolveSession: buildAuthSessionResolver(ctx), + logger: ctx.logger, }); ctx.logger.info( @@ -325,6 +366,55 @@ function resolveMetrics( return new NoopMetricsRegistry(); } +/** + * Bridge the kernel's `auth` service (better-auth) into the storage routes' + * upload gate (#2755). Returns `undefined` when no auth service is present — + * the routes then stay open (bare kernels/tests, logged once there). + * + * Normalization mirrors rest-server's session resolution: the service may be + * the AuthManager wrapper (`getApi()`) or the raw better-auth instance + * (`.api`), and `getSession` needs a Web `Headers` instance. + */ +function buildAuthSessionResolver( + ctx: PluginContext, +): ((req: { headers?: unknown }) => Promise<{ userId?: string } | null>) | undefined { + let authService: any; + try { + authService = ctx.getService('auth'); + } catch { + return undefined; + } + if (!authService) return undefined; + + return async (req) => { + try { + let api: any = authService.api; + if (!api && typeof authService.getApi === 'function') api = await authService.getApi(); + if (!api?.getSession) return null; + + const rawHeaders: any = req?.headers; + let headers: any; + if (rawHeaders && typeof rawHeaders.get === 'function') { + headers = rawHeaders; + } else if (rawHeaders && typeof rawHeaders === 'object') { + headers = new (globalThis as any).Headers(); + for (const [k, v] of Object.entries(rawHeaders)) { + if (Array.isArray(v)) v.forEach((x) => headers.append(k, String(x))); + else if (v != null) headers.set(k, String(v)); + } + } else { + return null; + } + + const session: any = await api.getSession({ headers }); + const userId = session?.user?.id; + return userId ? { userId: String(userId) } : null; + } catch { + return null; + } + }; +} + function extractOverrides(payload: unknown): Record { if (!payload || typeof payload !== 'object') return {}; const p = payload as Record; diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index a0cc72ff8f..d1700a2f86 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -93,6 +93,15 @@ export interface BootOptions { * default boot stays lean for apps that don't exercise flows. Default `false`. */ automation?: boolean; + /** + * Extra plugins to register between the app/service pairs and the + * SecurityPlugin — the slot where `objectstack dev` auto-loads optional + * service pairs the lean harness omits (e.g. `StorageServicePlugin` + + * `AuditPlugin` for the attachments surface). The caller instantiates the + * plugins so `@objectstack/verify` gains no new dependencies. Registered in + * array order. Default `[]`. + */ + extraPlugins?: unknown[]; } /** @@ -189,6 +198,13 @@ export async function bootStack( await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); } + // Caller-supplied optional service pairs (see BootOptions.extraPlugins). + // Before SecurityPlugin, mirroring the CLI's ordering for service pairs. + for (const plugin of opts.extraPlugins ?? []) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await kernel.use(plugin as any); + } + await kernel.use(opts.security ?? new SecurityPlugin()); // Sharing service — apps that declare `requires: ['sharing']` rely on it for // record-share grants; without it their RLS/sharing rules are inert and the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 366f61d18c..a683d35f75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -748,12 +748,18 @@ importers: '@objectstack/objectql': specifier: workspace:* version: link:../objectql + '@objectstack/plugin-audit': + specifier: workspace:* + version: link:../plugins/plugin-audit '@objectstack/plugin-auth': specifier: workspace:* version: link:../plugins/plugin-auth '@objectstack/plugin-security': specifier: workspace:* version: link:../plugins/plugin-security + '@objectstack/service-storage': + specifier: workspace:* + version: link:../services/service-storage '@objectstack/spec': specifier: workspace:* version: link:../spec