diff --git a/.changeset/23838-identity-feeds-off.md b/.changeset/23838-identity-feeds-off.md new file mode 100644 index 00000000000..7a2a56c47d8 --- /dev/null +++ b/.changeset/23838-identity-feeds-off.md @@ -0,0 +1,23 @@ +--- +'@objectstack/platform-objects': minor +--- + +feat(platform-objects)!: Setup's organization, team, business-unit and user records carry no Discussion feed: `sys_organization`, `sys_team`, `sys_business_unit` and `sys_user` declare `enable.feeds: false`, so a comment create on one of their records answers `403 FEEDS_DISABLED` + +Clause-②: no (narrowing: the comment create door refuses on four platform objects that accept it today) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +Setup's identity records are directory entries, not discussion surfaces. The four objects declared no `enable.feeds`, so they took the default (`true`), and the console drew "Discussion (0)" with a comment composer on every organization, team, business unit and user record. + +What changes: + +- **The record page.** The console reads the flag (`enable.feeds !== false`), so it no longer draws the Discussion feed or its composer on these four objects' records. The record's activity history is unchanged. +- **The data API.** A `sys_comment` create whose `thread_id` names one of these objects (`sys_user:USER_ID`, `sys_organization:ORG_ID`, `sys_team:TEAM_ID` or `sys_business_unit:UNIT_ID`) answers `403 FEEDS_DISABLED` and writes nothing, through REST, the SDK and every other caller of the data engine. An update that moves an existing comment into one of these threads is refused the same way. +- **Comments already stored** on these records are kept. They stay readable through the data API, and an edit that does not change `thread_id`, such as a body edit, still saves. + +Unchanged: every other platform object and every application object keeps its own `enable.feeds` setting, and an object that declares none still accepts comments. + +Remedy: stop creating comments on these four objects' records. A discussion about a user, a team or an organization belongs on a record of an object that keeps its feed on. diff --git a/packages/platform-objects/src/identity/sys-business-unit.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts index a46eed560e3..57e4f4e62ed 100644 --- a/packages/platform-objects/src/identity/sys-business-unit.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -270,6 +270,9 @@ export const SysBusinessUnit = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, + // Setup's identity records are not discussion surfaces (objectui#12614 item 1): + // no Discussion feed, and a comment create on one answers 403 FEEDS_DISABLED. + feeds: false, // No `apiMethods` — default-open (#3543 audit). The HRIS org-tree sync // scenario (#3025) needs every primitive, and a whitelist naming all six is // equivalent to no whitelist while NOT tracking future primitives; the P1 diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 8935bb7304f..a06aa948895 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -376,6 +376,9 @@ export const SysOrganization = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, + // Setup's identity records are not discussion surfaces (objectui#12614 item 1): + // no Discussion feed, and a comment create on one answers 403 FEEDS_DISABLED. + feeds: false, // #1591 closed the generic writes on this table — "refused by the identity // write guard (ADR-0092 D2) and owned by better-auth; HTTP answers 405 // before the 403" — and that reasoning still holds for better-auth's own diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index 9a3f52ffb7d..3a095490964 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -194,6 +194,9 @@ export const SysTeam = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, + // Setup's identity records are not discussion surfaces (objectui#12614 item 1): + // no Discussion feed, and a comment create on one answers 403 FEEDS_DISABLED. + feeds: false, // Generic writes are refused by the plugin-auth identity write guard // (ADR-0092 D2) and better-auth owns create/update/delete via the // team actions above — so the generic data API exposes reads only. diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index ebcb8cef97d..b4259ae4699 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -1047,6 +1047,9 @@ export const SysUser = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, + // Setup's identity records are not discussion surfaces (objectui#12614 item 1): + // no Discussion feed, and a comment create on one answers 403 FEEDS_DISABLED. + feeds: false, // #1591 — create/delete are refused by the identity write guard // (ADR-0092 D2) and owned by better-auth (Invite / Create User / admin // actions), so they are not exposed. `update` stays: it is the ONE diff --git a/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts b/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts new file mode 100644 index 00000000000..4ac58e197c5 --- /dev/null +++ b/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup's four identity objects — `sys_organization`, `sys_team`, + * `sys_business_unit` and `sys_user` — declare `enable.feeds: false`, so a + * comment cannot be created on their records (objectui#12614 item 1: the + * record page drew a Discussion feed with a composer on each of them). + * + * The gate itself is generic and pinned elsewhere: `audit-writers.test.ts` + * (#2707, insert) and `capability-gate-update-verb.test.ts` (#10170, the + * update verb and the grandfathered body edit). What this file pins is the + * COMPOSITION — the REAL platform-object definitions, imported rather than + * restated, registered in a REAL `ObjectQL` with the real `installAuditWriters` + * hooks — so a `feeds: false` dropped from any one of the four reds its own + * case below. `@objectstack/platform-objects/identity` resolves to source + * (this package's vitest alias), so the verdict is about the checkout, not + * about `platform-objects/dist`. + * + * Every refusal asserts the ADR-0112 envelope (`code` AND `status`) plus the + * object the gate named, and the CONTROL proves the harness does not refuse + * everything: a business object that never mentions the flag still takes a + * comment. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import type { EngineQueryOptions, EngineUpdateOptions } from '@objectstack/spec/data'; +import { + SysBusinessUnit, + SysOrganization, + SysTeam, + SysUser, +} from '@objectstack/platform-objects/identity'; +import { installAuditWriters } from './audit-writers.js'; + +type Row = Record; + +const text = (name: string, primaryKey = false) => ({ + name, + label: name, + type: 'text' as const, + ...(primaryKey ? { primaryKey: true } : {}), +}); + +const fieldMap = (...names: string[]) => + Object.fromEntries(names.map((n) => [n, text(n, n === 'id')])); + +/** + * A stub driver with just enough storage for the write paths under test. Its + * matcher implements the operators these reads send and REFUSES any other, so + * it can never answer a query it does not understand silently wrong + * (`check:where-matcher-conformance`); `find` applies the caller's `limit` by + * presence, after the filter (`check:objectql-double-limit`). + */ +function makeStubDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { + s = new Map(); + stores.set(o, s); + } + return s; + }; + let nextId = 0; + const matches = (row: Row, where: unknown): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where as Record)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((sub) => matches(row, sub))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((sub) => matches(row, sub))) return false; + continue; + } + if (k.startsWith('$')) throw new Error(`stub driver: unsupported where operator '${k}'`); + if (v && typeof v === 'object') { + const ops = Object.keys(v); + if (ops.length === 1 && ops[0] === '$eq') { + if ((row[k] ?? null) !== ((v as { $eq: unknown }).$eq ?? null)) return false; + continue; + } + if (ops.length === 1 && ops[0] === '$in' && Array.isArray((v as { $in: unknown }).$in)) { + if (!(v as { $in: unknown[] }).$in.includes(row[k])) return false; + continue; + } + throw new Error(`stub driver: unsupported where predicate on '${k}': ${JSON.stringify(v)}`); + } + if ((row[k] ?? null) !== (v ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, + async disconnect() {}, + async checkHealth() { + return true; + }, + async execute() { + return null; + }, + async syncSchema() {}, + async find(o: string, ast: any) { + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(o: string, ast: any) { + for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(o: string, data: Row) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(o).set(id, row); + return row; + }, + async update(o: string, id: string, data: Row) { + const s = storeFor(o); + const cur = s.get(id); + if (!cur) return null; + const u = { ...cur, ...data, id }; + s.set(id, u); + return u; + }, + async delete() { + return true; + }, + async count(o: string, ast: any) { + return (await driver.find(o, { where: ast?.where })).length; + }, + async aggregate() { + return []; + }, + }; + return { driver, storeFor }; +} + +/** The four Setup identity objects, as `platform-objects` declares them. */ +const IDENTITY_OBJECTS = [SysOrganization, SysTeam, SysBusinessUnit, SysUser] as const; + +/** + * Boot a real engine carrying the four REAL identity definitions, a business + * object that never mentions `enable.feeds`, `sys_comment`, and the audit + * sinks (registered so the `afterInsert` writers have somewhere real to land — + * their failures are swallowed by design, and a swallowed failure would make + * this harness lie about which write happened). + */ +async function boot() { + const engine = new ObjectQL(); + const { driver, storeFor } = makeStubDriver(); + engine.registerDriver(driver, true); + await engine.init(); + + const reg = engine.registry; + for (const def of IDENTITY_OBJECTS) reg.registerObject(def as any, 'test.fixture'); + reg.registerObject({ + name: 'crm_account', + label: 'Account', + fields: fieldMap('id', 'name'), + } as any, 'test.fixture'); + reg.registerObject({ + name: 'sys_comment', + label: 'Comment', + fields: fieldMap('id', 'thread_id', 'body'), + } as any, 'test.fixture'); + reg.registerObject({ + name: 'sys_audit_log', + label: 'Audit Log', + fields: fieldMap( + 'id', 'action', 'user_id', 'actor', 'object_name', 'record_id', 'old_value', 'new_value', 'tenant_id', + ), + } as any, 'test.fixture'); + reg.registerObject({ + name: 'sys_activity', + label: 'Activity', + fields: fieldMap( + 'id', 'type', 'timestamp', 'summary', 'actor_id', 'object_name', 'record_id', 'record_label', 'metadata', + ), + } as any, 'test.fixture'); + + installAuditWriters(engine as any, 'test.audit'); + return { engine, driver, storeFor }; +} + +describe('Setup identity objects declare enable.feeds: false — a comment create on their records is refused', () => { + it.each(IDENTITY_OBJECTS.map((def) => [def.name] as const))( + '%s: a new comment on one of its records → 403 FEEDS_DISABLED, and no row lands', + async (objectName) => { + const { engine, storeFor } = await boot(); + + await expect( + engine.insert('sys_comment', { thread_id: `${objectName}:rec-1`, body: 'hello' } as any), + ).rejects.toMatchObject({ code: 'FEEDS_DISABLED', status: 403, object: objectName }); + + expect(storeFor('sys_comment').size).toBe(0); + }, + ); + + it('a comment row stored before the flag stays readable, and an ordinary body edit on it still saves', async () => { + // The grandfather rule in `audit-writers.ts` (`enforceFeedsCapability`): + // on update, an ABSENT `thread_id` is not a re-thread, so the narrowing + // reaches new comments and re-points only — never a row that already + // targets the object. The row is written straight through the driver, + // below every hook, which is what a row stored before this change is. + const { engine, driver } = await boot(); + const stored = await driver.create('sys_comment', { thread_id: 'sys_user:usr-1', body: 'before the flag' }); + const id = String(stored.id); + + // The gate is live on this very thread — so what follows is a grandfathered + // row surviving the flag, not a harness in which the flag never applied. + await expect( + engine.insert('sys_comment', { thread_id: 'sys_user:usr-1', body: 'after the flag' } as any), + ).rejects.toMatchObject({ code: 'FEEDS_DISABLED', status: 403, object: 'sys_user' }); + + const read: any[] = await engine.find('sys_comment', { + where: { thread_id: 'sys_user:usr-1' }, + } satisfies EngineQueryOptions); + expect(read.map((r) => [r.id, r.body])).toEqual([[id, 'before the flag']]); + + await expect( + engine.update('sys_comment', { body: 'edited after the flag' } as any, { + where: { id }, + } satisfies EngineUpdateOptions), + ).resolves.toBeTruthy(); + + const after: any = await engine.findOne('sys_comment', { where: { id } } satisfies EngineQueryOptions); + expect(after).toMatchObject({ id, thread_id: 'sys_user:usr-1', body: 'edited after the flag' }); + }); + + it('CONTROL: a business object that never mentions enable.feeds still accepts a comment', async () => { + const { engine, storeFor } = await boot(); + + await expect( + engine.insert('sys_comment', { thread_id: 'crm_account:acc-1', body: 'hello' } as any), + ).resolves.toBeTruthy(); + + expect(Array.from(storeFor('sys_comment').values()).map((r) => r.thread_id)).toEqual(['crm_account:acc-1']); + }); +}); diff --git a/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts b/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts index 0f1303748e6..0bd44fb5cfd 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts @@ -7,7 +7,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * * Generic threaded discussion attached to any record via `thread_id`. * `thread_id` is conventionally formatted as `{object}:{record_id}` - * (e.g. `sys_user:abc123`, `account:9001`) so a single index covers + * (e.g. `account:9001`) so a single index covers * "all comments on this record" lookups across every object. * * Distinct from `feed_item` (Salesforce-style Chatter timeline that @@ -126,7 +126,7 @@ export const SysComment = ObjectSchema.create({ searchable: true, maxLength: 255, description: - 'Thread identifier — conventionally `{object}:{record_id}` (e.g. `sys_user:abc123`)', + 'Thread identifier — conventionally `{object}:{record_id}` (e.g. `account:9001`)', group: 'Thread', }), diff --git a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts index 794caf03079..80b2c0d4498 100644 --- a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts @@ -202,7 +202,7 @@ export const enObjects: NonNullable = { }, thread_id: { label: "Thread", - help: "Thread identifier — conventionally `{object}:{record_id}` (e.g. `sys_user:abc123`)" + help: "Thread identifier — conventionally `{object}:{record_id}` (e.g. `account:9001`)" }, parent_id: { label: "Parent Comment", diff --git a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts index 8823ec5ac22..5e35559b5f1 100644 --- a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts @@ -202,7 +202,7 @@ export const esESObjects: NonNullable = { }, thread_id: { label: "Hilo", - help: "Identificador del hilo; por convención `{object}:{record_id}` (p. ej. `sys_user:abc123`)." + help: "Identificador del hilo; por convención `{object}:{record_id}` (p. ej. `account:9001`)." }, parent_id: { label: "Comentario principal", diff --git a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts index b109f732998..59ede934ec5 100644 --- a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts @@ -202,7 +202,7 @@ export const jaJPObjects: NonNullable = { }, thread_id: { label: "スレッド", - help: "スレッド識別子 — 通常は `{object}:{record_id}`(例: `sys_user:abc123`)" + help: "スレッド識別子 — 通常は `{object}:{record_id}`(例: `account:9001`)" }, parent_id: { label: "親コメント", diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts index 7bbdef13154..1df7465a740 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts @@ -202,7 +202,7 @@ export const zhCNObjects: NonNullable = { }, thread_id: { label: "线程", - help: "线程标识——约定格式为 `{object}:{record_id}`(例如 `sys_user:abc123`)" + help: "线程标识——约定格式为 `{object}:{record_id}`(例如 `account:9001`)" }, parent_id: { label: "父评论",