Skip to content

Commit 94b8e44

Browse files
committed
feat(attachments): edit-on-parent attach, session lifecycle, trash=false, showcase files (#2970 items 3-6)
- item 3: attach requires EDIT on the parent record (sharing.canEdit), not merely read (Salesforce parity); public parents unchanged, private owner-scoped parents require own/edit. Read fallback without sharing. - item 4: sys_upload_session gains an ADR-0057 transient lifecycle (TTL 1d past expires_at + 7d retention for terminal statuses) so abandoned chunked-upload sessions are reaped. Row reap only; multipart-abort guard is a follow-up. - item 5: sys_attachment enable.trash -> false. The flag is dead in the liveness ledger and deletes are hard (reap guard reclaims bytes), so a restore would dangle; declare the honest state (ADR-0049). - item 6: showcase_project opts into enable.files so the attachments panel renders for browser dogfooding. - dogfood: att_readonly (public_read) fixture + matrix cases for edit-on-parent (read-but-not-edit → 403, still listable) and sys_upload_session reap; access-hook unit tests for the canEdit path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
1 parent 0289d5a commit 94b8e44

8 files changed

Lines changed: 142 additions & 2 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
feat(attachments): edit-on-parent attach, upload-session lifecycle, trash=false (#2970 items 3-5)
7+
8+
Closes the remaining enforce-or-remove / lifecycle items of #2970:
9+
10+
- **Edit-on-parent for attach (item 3, Salesforce parity).** Creating a
11+
`sys_attachment` now requires EDIT access to the parent record (via the
12+
sharing service's `canEdit`), not merely read — public-model parents are
13+
unchanged (canEdit is true for any member), private/owner-scoped parents
14+
require the caller to own/edit them. Degrades to read visibility when no
15+
sharing service is present.
16+
- **`sys_upload_session` lifecycle (item 4).** Abandoned / terminal chunked
17+
upload sessions are reaped by the platform LifecycleService (`transient`;
18+
TTL 1d past `expires_at`; retention 7d for terminal statuses). Row reap
19+
only — a reap guard that aborts backend multipart uploads for partial S3
20+
sessions is a filed follow-up.
21+
- **`sys_attachment.enable.trash``false` (item 5, ADR-0049).** The flag is
22+
`dead` in the liveness ledger (no engine soft-delete reader) and attachment
23+
deletes are hard (the reap guard reclaims a file's bytes once its last join
24+
row is gone, so a restore would dangle) — declare the honest state rather
25+
than claim a restore capability the runtime does not provide.

examples/app-showcase/src/data/objects/project.object.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ export const Project = ObjectSchema.create({
1919
icon: 'folder-kanban',
2020
description: 'A delivery project for an account.',
2121

22+
// [#2727 / #2970] Opt in to the generic Attachments panel on the record
23+
// detail page — projects carry briefs, SOWs, and deliverable files. This is
24+
// the showcase's files-enabled object for browser-dogfooding the attachments
25+
// surface (upload / list / download / delete + parent-derived access).
26+
enable: { files: true },
27+
2228
fields: {
2329
name: Field.text({ label: 'Project Name', required: true, searchable: true, maxLength: 200 }),
2430
// `relatedList*` is the read-side mirror of inline editing: the Account's

packages/dogfood/test/attachments-permission-matrix.dogfood.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,31 @@ describe('attachments permission matrix (#2755)', () => {
199199
expect(allowed.status).toBeLessThan(300);
200200
});
201201

202+
// ── (item 3) edit-on-parent: read is not enough to attach ────────────
203+
it('(item 3) a member who can READ but not EDIT the parent cannot attach — yet can still list its attachments', async () => {
204+
// att_readonly is public_read: every member reads it, only the owner edits.
205+
const ro = await ql.insert('att_readonly', { name: 'ro', owner_id: adminId }, { context: { ...SYS } });
206+
207+
// memberA can READ the record…
208+
const canRead = await stack.apiAs(memberATok, 'GET', `/data/att_readonly/${ro.id}`);
209+
expect(canRead.status).toBe(200);
210+
211+
// …but attaching requires EDIT (Salesforce parity) → 403.
212+
const file = await uploadFile(stack, memberATok);
213+
const denied = await attach(memberATok, 'att_readonly', ro.id, file);
214+
expect(denied.status).toBe(403);
215+
expect(((await denied.json()) as any).code).toBe('ATTACHMENT_PARENT_ACCESS');
216+
217+
// The owner (admin) can attach, and memberA — who can read the parent —
218+
// then sees that attachment in the list (read-visibility, item 1).
219+
const adminFile = await uploadFile(stack, adminTok);
220+
const attached = await attach(adminTok, 'att_readonly', ro.id, adminFile);
221+
expect(attached.status).toBeLessThan(300);
222+
const list = await stack.apiAs(memberATok, 'GET', '/data/sys_attachment');
223+
const rows = ((await list.json()) as any).records ?? [];
224+
expect(rows.some((r: any) => r.file_id === adminFile)).toBe(true);
225+
});
226+
202227
// ── (a) delete gate ──────────────────────────────────────────────────
203228
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 () => {
204229
// ADR-0090 D5 / #2753: `member_default` is the anchor-bound baseline and
@@ -450,6 +475,31 @@ describe('attachments permission matrix (#2755)', () => {
450475
expect(report.errors, JSON.stringify(report.errors)).toEqual([]);
451476
expect(await ql.findOne('sys_file', { where: { id: data.fileId }, context: SYS })).toBeNull();
452477
});
478+
479+
it('(item 4) abandoned sys_upload_session rows are reaped past their expiry window', async () => {
480+
// Initiate a chunked upload (creates a sys_upload_session) but never
481+
// complete it.
482+
const init = await stack.api('/storage/upload/chunked', {
483+
method: 'POST',
484+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${memberATok}` },
485+
body: JSON.stringify({ filename: 'big.bin', mimeType: 'application/octet-stream', totalSize: 10_485_760 }),
486+
});
487+
expect(init.status).toBe(200);
488+
const { uploadId } = ((await init.json()) as any).data;
489+
const session = await ql.findOne('sys_upload_session', { where: { id: uploadId }, context: SYS });
490+
expect(session?.id, 'session row created').toBeTruthy();
491+
492+
// Backdate expires_at past the 1d TTL grace.
493+
await ql.update(
494+
'sys_upload_session',
495+
{ id: uploadId, expires_at: new Date(Date.now() - 2 * DAY_MS) },
496+
{ context: { ...SYS } },
497+
);
498+
499+
const report = await lifecycle.sweep();
500+
expect(report.errors, JSON.stringify(report.errors)).toEqual([]);
501+
expect(await ql.findOne('sys_upload_session', { where: { id: uploadId }, context: SYS })).toBeNull();
502+
});
453503
});
454504
});
455505

packages/dogfood/test/fixtures/attachments-fixture.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,20 @@ export const AttNoFiles = ObjectSchema.create({
6060
},
6161
});
6262

63+
export const AttReadonly = ObjectSchema.create({
64+
name: 'att_readonly',
65+
label: 'Attachment Readonly',
66+
pluralLabel: 'Attachment Readonlys',
67+
// public_read: every member can READ, only the owner can EDIT — the case
68+
// that distinguishes edit-on-parent (#2970 item 3) from read visibility.
69+
sharingModel: 'public_read',
70+
enable: { files: true },
71+
fields: {
72+
name: Field.text({ label: 'Name', required: true }),
73+
owner_id: Field.text({ label: 'Owner' }),
74+
},
75+
});
76+
6377
/**
6478
* The domain grant a real app ships when it turns the attachments panel on
6579
* for members: `member_default` (the `everyone` anchor baseline) carries NO
@@ -93,5 +107,5 @@ export const attachmentsFixtureStack = defineStack({
93107
description:
94108
'Three-object app exercising the #2755 attachment permission matrix: parent visibility, uploader/editor delete, enable.files gate.',
95109
},
96-
objects: [AttCase, AttSecret, AttNoFiles],
110+
objects: [AttCase, AttSecret, AttNoFiles, AttReadonly],
97111
});

packages/platform-objects/src/audit/sys-attachment.object.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,12 @@ export const SysAttachment = ObjectSchema.create({
132132
trackHistory: false,
133133
searchable: true,
134134
apiEnabled: true,
135-
trash: true,
135+
// [#2970 item 5 / ADR-0049] `trash` is `dead` in the liveness ledger (no
136+
// engine soft-delete reader) and attachment deletes ARE hard (#2755): the
137+
// reap guard reclaims a file's bytes once its last join row is gone, so a
138+
// "restore" would dangle. Declare `false` — the honest state — rather than
139+
// claim a restore capability the runtime does not provide.
140+
trash: false,
136141
mru: false,
137142
clone: false,
138143
},

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,31 @@ describe('attachment access — beforeInsert (parent visibility + provenance)',
9999
).resolves.toBeUndefined();
100100
await expect(beforeInsert(insertCtx({ parent_object: 'x', parent_id: 'r' }, {}))).resolves.toBeUndefined();
101101
});
102+
103+
// #2970 item 3 — with a sharing service present, attach requires EDIT
104+
// (canEdit), not merely read visibility.
105+
it('with sharing: rejects attaching when the caller cannot EDIT the parent (even if readable)', async () => {
106+
const canEdit = vi.fn(async () => false);
107+
const { beforeInsert } = install({ sharing: { canEdit } });
108+
const ctx = insertCtx(
109+
{ parent_object: 'att_readonly', parent_id: 'r1', file_id: 'f1' },
110+
{ userId: 'u1', visible: ['att_readonly/r1'] }, // readable, but canEdit=false
111+
);
112+
await expect(beforeInsert(ctx)).rejects.toMatchObject({
113+
code: 'ATTACHMENT_PARENT_ACCESS',
114+
status: 403,
115+
});
116+
expect(canEdit).toHaveBeenCalledWith('att_readonly', 'r1', expect.objectContaining({ userId: 'u1' }));
117+
});
118+
119+
it('with sharing: allows attaching when the caller CAN edit the parent', async () => {
120+
const { beforeInsert } = install({ sharing: { canEdit: async () => true } });
121+
const ctx = insertCtx(
122+
{ parent_object: 'att_case', parent_id: 'r1', file_id: 'f1' },
123+
{ userId: 'u1', visible: [] }, // not readable via api, but canEdit=true governs
124+
);
125+
await expect(beforeInsert(ctx)).resolves.toBeUndefined();
126+
});
102127
});
103128

104129
describe('attachment access — beforeDelete (uploader or parent editor)', () => {
602 Bytes
Binary file not shown.

packages/services/service-storage/src/objects/system-upload-session.object.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,19 @@ export const SystemUploadSession = ObjectSchema.create({
118118
label: 'Updated At',
119119
}),
120120
},
121+
122+
// ADR-0057 (#2970 item 4): an upload session is ephemeral state, never
123+
// business truth — completed, failed, expired, and abandoned in-progress
124+
// sessions would otherwise accumulate forever (the sys_file reaper in
125+
// #2755 covers files, not sessions). The TTL reaps any row 1d past its own
126+
// `expires_at` (abandoned in-progress sessions included); the retention
127+
// backstop reaps terminal-status rows by age even if `expires_at` was
128+
// never set. NOTE: this reaps the session ROW only — a reap guard that
129+
// aborts the backend multipart upload for partial S3 sessions is a filed
130+
// follow-up (row reap is the declared scope of this item).
131+
lifecycle: {
132+
class: 'transient',
133+
ttl: { field: 'expires_at', expireAfter: '1d' },
134+
retention: { maxAge: '7d', onlyWhen: { status: { $in: ['completed', 'failed', 'expired'] } } },
135+
},
121136
});

0 commit comments

Comments
 (0)