Skip to content

Commit 23c8668

Browse files
os-zhuangclaude
andauthored
feat(data): enable.files goes live — opt-in gate for the generic Attachments surface (#2727) (#2742)
The last dead ObjectCapabilities flag gets its enforcement contract. enable.files stays opt-IN (default false) — the Attachments panel is a new surface, not an existing behavior: - plugin-audit sys_attachment beforeInsert hook: attachment join rows may only target objects explicitly declaring enable.files true; absent block/flag, explicit false, and unknown parent objects all reject fail-closed with 403 FILES_DISABLED (CLONE_DISABLED/FEEDS_DISABLED pattern). - mapDataError maps FILES_DISABLED → 403 with the gated target object (generic data routes bypass sendError's .status passthrough — the #2707 lesson applied at introduction time). - Field.file / Field.image stay independent: they store the file URL in the record's own column and never create sys_attachment rows. - Liveness ledger: enable.files dead→live, authorWarn dropped — ObjectCapabilities is now 100% live; describe() and reference docs state the real contract; cli lint contract tests re-anchored on `versioning` as the canonical misleading-dead example. Verified: unit (4 new gate tests, FILES_DISABLED mapping, lint contract, 32/139/464 suites green), check:liveness + check:api-surface green, and live E2E on a fresh showcase — presigned 3-step upload → attach to a files:true probe object 201; attach targeting a no-flag object → 403 FILES_DISABLED; stable download 200. Browser-verified via the console dev server: panel renders with upload/list/download/delete on the files:true object and is absent on a no-flag object. Companion objectui PR ships RecordAttachmentsPanel. Closes #2727. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b8ce2da commit 23c8668

10 files changed

Lines changed: 172 additions & 50 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-audit': minor
4+
'@objectstack/rest': patch
5+
'@objectstack/cli': patch
6+
---
7+
8+
feat(data): `enable.files` goes live — opt-in gate for the generic Attachments surface (#2727)
9+
10+
The last dead ObjectCapabilities flag gets its enforcement contract.
11+
`enable.files` is opt-IN (spec default stays `false`): the generic record
12+
Attachments panel is a new surface, not an existing behavior.
13+
14+
- plugin-audit registers a `sys_attachment` beforeInsert hook: attachment
15+
join rows may only target objects that explicitly declare
16+
`enable: { files: true }` — anything else (absent block, absent flag,
17+
explicit false, unknown object) rejects fail-closed with
18+
403 `FILES_DISABLED` (CLONE_DISABLED / FEEDS_DISABLED pattern).
19+
- `mapDataError` maps `FILES_DISABLED` → 403 with the gated target object
20+
(generic data routes bypass `sendError`'s `.status` passthrough — the
21+
#2707 lesson, applied at introduction time).
22+
- `Field.file` / `Field.image` are deliberately independent: they store
23+
the file URL in the record's own column and never create
24+
`sys_attachment` rows, so field-level attachments work regardless of
25+
this flag.
26+
- Liveness ledger: `enable.files` dead→live, authorWarn dropped —
27+
ObjectCapabilities is now 100% live. The compile-time
28+
liveness-dead-property warning no longer fires for it; `describe()` and
29+
the reference docs state the real contract.
30+
31+
Companion objectui PR ships `RecordAttachmentsPanel` (upload/list/
32+
download/delete over the presigned three-step storage flow), rendered on
33+
record pages when the flag is true.

content/docs/references/data/object.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ const result = ApiMethod.parse(data);
8181
| **searchable** | `boolean` || Index records for global search |
8282
| **apiEnabled** | `boolean` || Expose object via automatic APIs |
8383
| **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]` | optional | Whitelist of allowed API operations |
84-
| **files** | `boolean` || RESERVED (no runtime effect yet) — use Field.file/Field.image for attachments; this flag will gate the future generic Attachments panel |
84+
| **files** | `boolean` || Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments targeting this object; otherwise creation is rejected. Field.file/Field.image are independent |
8585
| **feeds** | `boolean` || Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects new comments for this object |
8686
| **activities** | `boolean` || Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline |
8787
| **trash** | `boolean` || Enable soft-delete with restore capability |

packages/cli/src/utils/lint-liveness-properties.test.ts

Lines changed: 15 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
/**
1010
* These run against the REAL ledgers shipped by `@objectstack/spec` (the same
1111
* files the gate enforces), so they double as a contract test: if an
12-
* `authorWarn` annotation is removed from `enable.files` / `columnName` / etc.,
12+
* `authorWarn` annotation is removed from `versioning` / `columnName` / etc.,
1313
* the matching assertion fails.
1414
*/
1515

@@ -18,40 +18,28 @@ const rules = (findings: { rule: string }[]) => findings.map((f) => f.rule);
1818
const paths = (findings: { message: string }[]) => findings.map((f) => f.message);
1919

2020
describe('lintLivenessProperties', () => {
21-
it('warns on an authored dead capability flag (enable.files: true)', () => {
22-
const findings = lintLivenessProperties(objStack({ enable: { files: true } }));
23-
expect(findings.length).toBeGreaterThan(0);
24-
const files = findings.find((f) => f.message.includes('enable.files'));
25-
expect(files).toBeDefined();
26-
expect(files!.rule).toBe(LIVENESS_DEAD_PROPERTY);
27-
expect(files!.where).toBe("object 'widget'");
28-
expect(files!.hint.length).toBeGreaterThan(0);
21+
it('warns on a present dead object block (versioning)', () => {
22+
const findings = lintLivenessProperties(objStack({ versioning: { enabled: true } }));
23+
const v = findings.find((f) => f.message.includes('versioning'));
24+
expect(v).toBeDefined();
25+
expect(v!.rule).toBe(LIVENESS_DEAD_PROPERTY);
26+
expect(v!.where).toBe("object 'widget'");
27+
expect(v!.hint.length).toBeGreaterThan(0);
2928
});
3029

3130
it('does NOT warn on a default-on flag the author left alone (enable.trash: true)', () => {
3231
const findings = lintLivenessProperties(objStack({ enable: { trash: true } }));
3332
expect(paths(findings).some((m) => m.includes('enable.trash'))).toBe(false);
3433
});
3534

36-
// #2707: feeds/activities/trackHistory went LIVE (opt-out writer/UI gates +
37-
// History-tab master switch) — authoring them must no longer warn.
38-
it('does NOT warn on the now-live capability flags (feeds/activities/trackHistory)', () => {
35+
// #2707/#2727: every ObjectCapabilities flag is now LIVE (opt-out
36+
// writer/UI gates, the History-tab master switch, the opt-in Attachments
37+
// gate) — authoring them must no longer warn.
38+
it('does NOT warn on the now-live capability flags (feeds/activities/trackHistory/files)', () => {
3939
const findings = lintLivenessProperties(
40-
objStack({ enable: { feeds: true, activities: true, trackHistory: true } }),
40+
objStack({ enable: { feeds: true, activities: true, trackHistory: true, files: true } }),
4141
);
42-
expect(paths(findings).some((m) => m.includes('enable.feeds'))).toBe(false);
43-
expect(paths(findings).some((m) => m.includes('enable.activities'))).toBe(false);
44-
expect(paths(findings).some((m) => m.includes('enable.trackHistory'))).toBe(false);
45-
});
46-
47-
it('does NOT warn when a dead boolean flag is explicitly false (enable.files: false)', () => {
48-
const findings = lintLivenessProperties(objStack({ enable: { files: false } }));
49-
expect(paths(findings).some((m) => m.includes('enable.files'))).toBe(false);
50-
});
51-
52-
it('warns on a present dead object block (versioning)', () => {
53-
const findings = lintLivenessProperties(objStack({ versioning: { enabled: true } }));
54-
expect(paths(findings).some((m) => m.includes('versioning'))).toBe(true);
42+
expect(paths(findings).some((m) => m.includes('enable.'))).toBe(false);
5543
});
5644

5745
it('warns on a misleading dead field prop (columnName)', () => {
@@ -83,7 +71,7 @@ describe('lintLivenessProperties', () => {
8371

8472
it('handles objects as a keyed record (not just arrays)', () => {
8573
const findings = lintLivenessProperties({
86-
objects: { widget: { name: 'widget', enable: { files: true } } },
74+
objects: { widget: { name: 'widget', versioning: { enabled: true } } },
8775
});
8876
expect(rules(findings)).toContain(LIVENESS_DEAD_PROPERTY);
8977
});

packages/plugins/plugin-audit/src/audit-writers.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,3 +385,57 @@ describe('audit writers — enable.feeds server-side enforcement (#2707)', () =>
385385
await expect(fire('beforeInsert', commentInsert('ghost_object:rec-9'))).resolves.toBeUndefined();
386386
});
387387
});
388+
389+
describe('audit writers — enable.files server-side enforcement (#2727)', () => {
390+
const SCHEMA = {
391+
sys_audit_log: SINGLE_TENANT.sys_audit_log,
392+
sys_activity: SINGLE_TENANT.sys_activity,
393+
crm_lead: ['id', 'name'],
394+
};
395+
396+
const attachmentInsert = (parentObject?: unknown) => ({
397+
object: 'sys_attachment',
398+
input: { data: { parent_object: parentObject, parent_id: 'rec-1', file_id: 'file-1' } },
399+
session: {},
400+
});
401+
402+
it('allows sys_attachment creation when the parent object declares files: true', async () => {
403+
const { engine, fire } = makeEngine(SCHEMA, {
404+
crm_lead: { enable: { files: true } },
405+
});
406+
installAuditWriters(engine as any, 'test.audit');
407+
408+
await expect(fire('beforeInsert', attachmentInsert('crm_lead'))).resolves.toBeUndefined();
409+
});
410+
411+
it('rejects when the flag is absent — opt-in means explicit (403 FILES_DISABLED)', async () => {
412+
const noBlock = makeEngine(SCHEMA);
413+
installAuditWriters(noBlock.engine as any, 'test.audit');
414+
await expect(noBlock.fire('beforeInsert', attachmentInsert('crm_lead'))).rejects.toMatchObject({
415+
code: 'FILES_DISABLED',
416+
status: 403,
417+
object: 'crm_lead',
418+
});
419+
420+
const explicitFalse = makeEngine(SCHEMA, { crm_lead: { enable: { files: false } } });
421+
installAuditWriters(explicitFalse.engine as any, 'test.audit');
422+
await expect(explicitFalse.fire('beforeInsert', attachmentInsert('crm_lead'))).rejects.toMatchObject({
423+
code: 'FILES_DISABLED',
424+
});
425+
});
426+
427+
it('rejects an unknown parent object (fail-closed, unlike the opt-out feeds gate)', async () => {
428+
const { engine, fire } = makeEngine(SCHEMA);
429+
installAuditWriters(engine as any, 'test.audit');
430+
await expect(fire('beforeInsert', attachmentInsert('ghost_object'))).rejects.toMatchObject({
431+
code: 'FILES_DISABLED',
432+
object: 'ghost_object',
433+
});
434+
});
435+
436+
it('leaves a missing parent_object to schema validation (no gate error)', async () => {
437+
const { engine, fire } = makeEngine(SCHEMA, { crm_lead: { enable: { files: false } } });
438+
installAuditWriters(engine as any, 'test.audit');
439+
await expect(fire('beforeInsert', attachmentInsert(undefined))).resolves.toBeUndefined();
440+
});
441+
});

packages/plugins/plugin-audit/src/audit-writers.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,36 @@ export function installAuditWriters(
562562
};
563563
engine.registerHook('beforeInsert', enforceFeedsCapability, { object: 'sys_comment', packageId });
564564

565+
/**
566+
* `enable.files` server-side enforcement (#2727). The generic Attachments
567+
* panel persists `sys_attachment` join rows through the generic data path,
568+
* so — like the feeds gate above — the engine hook seam is the one gate
569+
* every caller crosses. Unlike feeds, `files` is opt-IN (spec default
570+
* `false`): the panel is a new surface, not an existing behavior, so a
571+
* parent object must declare `enable: { files: true }` before attachments
572+
* may target it. Fail-closed: an absent enable block, an absent flag, and
573+
* an unknown parent object all reject — opt-in means *explicit*.
574+
*
575+
* Deliberately NOT gated: `Field.file` / `Field.image` uploads. Those
576+
* store the file URL in the record's own column via service-storage and
577+
* never create a sys_attachment row, so field-level attachments keep
578+
* working regardless of this flag.
579+
*/
580+
const enforceFilesCapability = async (ctx: HookContext) => {
581+
const data: any = (ctx.input as any)?.data;
582+
const parentObject = data?.parent_object;
583+
if (typeof parentObject !== 'string' || parentObject.length === 0) return; // schema requires it; let validation report the miss
584+
const def = getObjectDef(parentObject);
585+
if (def?.enable?.files !== true) {
586+
const err: any = new Error(`File attachments are not enabled for object '${parentObject}' (requires enable.files: true)`);
587+
err.code = 'FILES_DISABLED';
588+
err.status = 403;
589+
err.object = parentObject;
590+
throw err;
591+
}
592+
};
593+
engine.registerHook('beforeInsert', enforceFilesCapability, { object: 'sys_attachment', packageId });
594+
565595
/**
566596
* M10.8: Dedicated hook on `sys_comment` afterInsert that parses the
567597
* `mentions` JSON field and writes one sys_notification per mentioned

packages/rest/src/rest-server.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,18 +104,19 @@ export function mapDataError(error: any, object?: string): { status: number; bod
104104
},
105105
};
106106
}
107-
// Capability gate (#2707): the target object opted out of comments
108-
// (`enable.feeds: false`) — plugin-audit's engine hook rejects the
109-
// sys_comment insert fail-closed. 403 like CLONE_DISABLED; surfaced by
110-
// `code` because the generic data routes map through here (they never
111-
// reach sendError's `.status` passthrough). `error.object` names the
112-
// gated TARGET object (not sys_comment), so prefer it.
113-
if (error?.code === 'FEEDS_DISABLED') {
107+
// Capability gates (#2707 feeds / #2727 files): plugin-audit's engine
108+
// hooks reject sys_comment / sys_attachment inserts fail-closed when the
109+
// TARGET object's capability flag disallows them. 403 like
110+
// CLONE_DISABLED; surfaced by `code` because the generic data routes map
111+
// through here (they never reach sendError's `.status` passthrough).
112+
// `error.object` names the gated TARGET object (not the join table), so
113+
// prefer it.
114+
if (error?.code === 'FEEDS_DISABLED' || error?.code === 'FILES_DISABLED') {
114115
return {
115116
status: 403,
116117
body: {
117-
error: error?.message ?? 'Comments are disabled for this object',
118-
code: 'FEEDS_DISABLED',
118+
error: error?.message ?? 'This capability is disabled for the target object',
119+
code: error.code,
119120
...(error?.object || object ? { object: error?.object ?? object } : {}),
120121
},
121122
};

packages/rest/src/rest.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1947,6 +1947,21 @@ describe('mapDataError — schema/constraint envelopes', () => {
19471947
expect(r.body.object).toBe('gate_probe');
19481948
});
19491949

1950+
// #2727: same shape for the opt-in attachments gate on sys_attachment.
1951+
it('maps FILES_DISABLED → 403 with the gated target object', () => {
1952+
const r = mapDataError(
1953+
Object.assign(new Error("File attachments are not enabled for object 'crm_lead' (requires enable.files: true)"), {
1954+
code: 'FILES_DISABLED',
1955+
status: 403,
1956+
object: 'crm_lead',
1957+
}),
1958+
'sys_attachment',
1959+
);
1960+
expect(r.status).toBe(403);
1961+
expect(r.body.code).toBe('FILES_DISABLED');
1962+
expect(r.body.object).toBe('crm_lead');
1963+
});
1964+
19501965
it('maps SQLite "has no column named" → 400 INVALID_FIELD with the field', () => {
19511966
const r = mapDataError(
19521967
sqliteError(

packages/spec/liveness/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ Signal over noise is the whole point, so warnings are **opt-in per entry**:
105105
Two rules keep it false-positive-free, **both of which the marker author must respect**:
106106

107107
1. **Only mark genuinely *misleading* dead props** — ones that imply a capability/behavior
108-
that doesn't exist (`enable.files`, `field.columnName`, `versioning`). Benign display/doc
108+
that doesn't exist (`versioning`, `field.columnName`, `softDelete`). Benign display/doc
109109
metadata that's "dead" (no runtime reader) — `description`, `tags`, `icon` — must NOT be
110110
marked; an author isn't misled by them.
111111
2. **Booleans: only mark `default(false)` flags.** The lint warns on a boolean only when set
@@ -163,7 +163,7 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type
163163

164164
| Type | live | exp | dead | Notes |
165165
|---|---|---|---|---|
166-
| object | 34 || 14 | versioning/partitioning/cdc tier dead; ObjectCapabilities mostly live post-#2707 (`apiEnabled`/`apiMethods` enforced #1937; `feeds`/`activities` opt-out gates + `trackHistory` UI master switch); `enable.files` reserved/dead |
166+
| object | 35 || 13 | versioning/partitioning/cdc tier dead; ObjectCapabilities fully live post-#2707/#2727 (`apiEnabled`/`apiMethods` enforced #1937; `feeds`/`activities` opt-out gates; `trackHistory` UI master switch; `files` opt-in Attachments gate) |
167167
| field | 34 || 39 | ~half dead — aspirational enhanced-type + governance config; naming-drift props server-live/client-snake |
168168
| flow | 29 | 1 | 7 | `runAs` experimental (unenforced identity switch); status/active gate nothing; FlowNodeAction enum out of sync |
169169
| action | 26 || 5 | `disabled` CEL ignored (renderers read non-spec `enabled`); type:'form'/shortcut/bulkEnabled dead |

packages/spec/liveness/object.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,8 @@
165165
"_authorWarnSkipped": "defaults to true in the schema — the lint can't distinguish author-set-true from the default, so warning here would fire on every object with an enable block. Only default-FALSE booleans are safe to authorWarn."
166166
},
167167
"files": {
168-
"status": "dead",
169-
"evidence": "no behavior-changing reader in framework or objectui — reserved for the generic Attachments related-list (Salesforce Notes & Attachments parity), tracked in #2727",
170-
"authorWarn": true,
171-
"authorHint": "File attachments are modeled with a `Field.file`/`Field.image` (or the sys_attachment object), not this object flag — it enables nothing on its own yet (reserved for the future generic Attachments panel)."
168+
"status": "live",
169+
"evidence": "packages/plugins/plugin-audit/src/audit-writers.ts (enforceFilesCapability beforeInsert hook: sys_attachment rows may only target objects declaring enable.files true — 403 FILES_DISABLED otherwise); objectui RecordAttachmentsPanel renders the record Attachments surface (upload/list/download/delete) when the flag is true. Opt-in — spec default false (#2727)."
172170
},
173171
"feeds": {
174172
"status": "live",

packages/spec/src/data/object.zod.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,18 @@ export const ObjectCapabilities = z.object({
7474
apiMethods: z.array(ApiMethod).optional().describe('Whitelist of allowed API operations'),
7575

7676
/**
77-
* Standard attachments/files engine — opt-in, NOT YET CONSUMED at runtime.
77+
* Generic Attachments panel (Salesforce "Notes & Attachments" parity) —
78+
* opt-in.
7879
*
79-
* Reserved for the generic Attachments related-list (Salesforce
80-
* "Notes & Attachments" parity). Until that ships, model attachments with
81-
* `Field.file` / `Field.image` (or relate to `sys_attachment`) — setting
82-
* this flag alone enables nothing, and the compile-time liveness lint
83-
* warns on it.
80+
* Contract (#2727): `true` surfaces the record Attachments panel in the
81+
* console (upload/list/download/delete over `sys_attachment` join rows)
82+
* and permits `sys_attachment` rows to target this object; anything else
83+
* rejects new attachments server-side (403 FILES_DISABLED, enforced at
84+
* the engine hook seam by plugin-audit — opt-in means explicit).
85+
* `Field.file` / `Field.image` column attachments are independent of
86+
* this flag.
8487
*/
85-
files: z.boolean().default(false).describe('RESERVED (no runtime effect yet) — use Field.file/Field.image for attachments; this flag will gate the future generic Attachments panel'),
88+
files: z.boolean().default(false).describe('Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments targeting this object; otherwise creation is rejected. Field.file/Field.image are independent'),
8689

8790
/**
8891
* Social collaboration (Comments, Mentions, Feeds) — opt-out.

0 commit comments

Comments
 (0)