diff --git a/.changeset/22560-platform-objects-approvals-visible-to-readers-form-row.md b/.changeset/22560-platform-objects-approvals-visible-to-readers-form-row.md new file mode 100644 index 00000000000..e4691934741 --- /dev/null +++ b/.changeset/22560-platform-objects-approvals-visible-to-readers-form-row.md @@ -0,0 +1,7 @@ +--- +'@objectstack/platform-objects': patch +--- + +Studio's object form now offers the `approvalsVisibleToReaders` row in its capabilities panel, with its label and help text in the metadata-form translation catalogs for `en`, `zh-CN`, `ja-JP` and `es-ES`. + +Clause-②: no diff --git a/.changeset/22560-plugin-approvals-declared-record-reader-tier.md b/.changeset/22560-plugin-approvals-declared-record-reader-tier.md new file mode 100644 index 00000000000..6679c1a4e48 --- /dev/null +++ b/.changeset/22560-plugin-approvals-declared-record-reader-tier.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-approvals': minor +--- + +feat(plugin-approvals): the record-reader approval tier honours an object's own `enable.approvalsVisibleToReaders` declaration + +Clause-②: yes (widening) + +- **Before this,** the read-only record-reader tier could be switched on only through the `recordReaderVisibleObjects` constructor option. A config-driven app, whose host builds the plugin with no options, had no way to reach it. +- **Now** the tier is on for an object when either source turns it on: the host's `recordReaderVisibleObjects`, or the object's own `enable.approvalsVisibleToReaders: true`. With neither, visibility is exactly as before. +- **Read where it is used.** The service reads the declaration from the object's live registered definition on each read that names a record, not once at start. An object registered after boot (an installed package, a Studio edit, a dev reload) takes effect at once. Removing the flag turns the tier off at once, with no restart. +- **Fails closed.** An engine that cannot answer an object definition, an unknown object, or any value other than `true` leaves the tier off. Default OFF costs one in-memory registry lookup on a read that names a record. The business record is never probed for an object that declares nothing. +- **Unchanged.** The rule itself: who counts as a reader, the read-only boundary, the request tables it covers on both doors, and the constructor option. diff --git a/.changeset/22560-spec-approvals-visible-to-readers.md b/.changeset/22560-spec-approvals-visible-to-readers.md new file mode 100644 index 00000000000..7de714d046e --- /dev/null +++ b/.changeset/22560-spec-approvals-visible-to-readers.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): `enable.approvalsVisibleToReaders`, the per-object opt-in for read-only approval visibility to a record's readers + +Clause-②: yes (widening) + +- **The key.** `ObjectCapabilities` (an object's `enable` block) gains `approvalsVisibleToReaders`, a boolean that defaults to `false`. An object that declares nothing is unchanged. +- **What `true` grants.** A caller who can read a record of the object sees that record's approval requests and their full action history, read-only. "Can read" is the object's own CRUD, sharing and RLS, asked as the caller. No new permission is granted. This holds on the approvals API and on the generic data API alike (`sys_approval_request`, `sys_approval_action` and `sys_approval_approver`), on a read that names the record. A list that names no record, such as the inbox, is not widened. No approval action is offered: approve, reject, reassign, recall and comment keep authorizing as before. +- **What becomes visible.** The request row, including its snapshot of the record at submission, and each action's actor, decision, time, comment text and attachments. Turn it on only for objects whose approval commentary the record's readers are meant to see. +- **Studio.** The object form's Capabilities section lists the new toggle. +- **Nothing to migrate.** To opt an object in, write `enable: { approvalsVisibleToReaders: true }` on it. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 3c6f1ce61a1..6ef2ea767cd 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -429,6 +429,7 @@ const result = ApiMethod.parse(data); | **feeds** | `boolean` | optional (default: `true`) | Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike | | **activities** | `boolean` | optional (default: `true`) | Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline | | **clone** | `boolean` | optional (default: `true`) | Allow record deep cloning | +| **approvalsVisibleToReaders** | `boolean` | optional (default: `false`) | Opt-in: true lets a caller who can read a record of this object see that record's approval requests and full action history (comments and attachments included), read-only, on the approvals API and the generic data API alike, on a read that names the record. No approval action is offered. Default false: only the request's participants (submitter, approvers, past actors) and administrators see it | ### Nested Shape: `Object.publicSharing` @@ -532,6 +533,7 @@ const result = ApiMethod.parse(data); | **feeds** | `boolean` | optional (default: `true`) | Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike | | **activities** | `boolean` | optional (default: `true`) | Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline | | **clone** | `boolean` | optional (default: `true`) | Allow record deep cloning | +| **approvalsVisibleToReaders** | `boolean` | optional (default: `false`) | Opt-in: true lets a caller who can read a record of this object see that record's approval requests and full action history (comments and attachments included), read-only, on the approvals API and the generic data API alike, on a read that names the record. No approval action is offered. Default false: only the request's participants (submitter, approvers, past actors) and administrators see it | --- diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 62e85d6d852..8c9d4ec1246 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -293,6 +293,10 @@ export const enMetadataForms: NonNullable = { "enable.clone": { label: "Clone" }, + "enable.approvalsVisibleToReaders": { + label: "Approvals Visible To Readers", + helpText: "Readers of a record see its approval requests and history, read-only, with no approval action" + }, validations: { label: "Validations", helpText: "Object-level validation rules — an array of rule objects, e.g. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. State-machine transition tables are declared here too (ADR-0020)" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index a0c5d13c7ad..51c85238e24 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -293,6 +293,10 @@ export const esESMetadataForms: NonNullable = "enable.clone": { label: "Clonación" }, + "enable.approvalsVisibleToReaders": { + label: "Aprobaciones visibles para lectores", + helpText: "Quien puede leer un registro ve sus solicitudes de aprobación y su historial, en solo lectura y sin ninguna acción de aprobación" + }, validations: { label: "Validaciones", helpText: "Reglas de validación a nivel de objeto — un array de objetos de regla, p. ej. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. Las tablas de transición de máquinas de estado también se declaran aquí (ADR-0020)" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index e97480f186c..cfca34ab2dc 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -293,6 +293,10 @@ export const jaJPMetadataForms: NonNullable = "enable.clone": { label: "クローン" }, + "enable.approvalsVisibleToReaders": { + label: "閲覧者に承認を表示", + helpText: "レコードを閲覧できるユーザーが、その承認リクエストと履歴を読み取り専用で参照できます。承認アクションは提供されません" + }, validations: { label: "検証ルール", helpText: "オブジェクトレベルの検証ルール — ルールオブジェクトの配列。例: [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]。ステートマシンの遷移テーブルもここで宣言します(ADR-0020)" diff --git a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts index ec131f454fc..ff5a8b8cdfc 100644 --- a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts @@ -313,6 +313,24 @@ const DECISIONS: readonly Decision[] = [ reason: '⛔ NO AUTHORED TWIN FOR "clone" OR "cloning" IN EITHER CATALOG — stated, not borrowed. The nearest thing either catalog holds is object.fields["fields.unique"].helpText ("Disallow duplicate values") 不允许重复值 / 重複値を許可しない / No permite valores duplicados, and that 重复 / 重複 / duplicado is the "repeated value" sense, ⛔ not the "make a copy of this record" sense the schema means ("Allow record deep cloning"). ⇒ composed: 克隆 / クローン / Clonación. ⚠️ es takes the NOUN (Clonación) rather than the infinitive, following its own siblings on this panel, which are nominal or adjectival throughout (Archivos, Actividades, Buscable, Seguimiento de historial) — and unlike enable.label above, which copies a twin that is itself an action label.', }, + { + section: 'capabilities', + path: 'enable.approvalsVisibleToReaders', + prop: 'label', + en: 'Approvals Visible To Readers', + verdict: ALL_TRANSLATE, + reason: + 'ARRIVED WITH THE FLAG ITSELF (the per-object opt-in for the read-only record-reader approval tier), so all three leaves were authored by the card that added the row, never left as fills. The English is the extractor humanize of the key (objectForm declares no label, asserted by the third leg), decided against the CONCEPT: who sees what. "Approvals" takes the approvals plugin\'s own authored head noun, sys_approval_request.label 审批请求 / 承認リクエスト / Solicitud de aprobación ⇒ 审批 / 承認 / Aprobaciones. "Readers" takes the reader twin on the view panel, the visibility section description ("who can see it") 谁可以查看 / 閲覧可能者 / quién puede verla ⇒ 读者 / 閲覧者 / lectores. ⇒ 审批对读者可见 / 閲覧者に承認を表示 / Aprobaciones visibles para lectores. ⛔ NOT 只读 / 読み取り専用 / Solo lectura in the label: that is the read-only BOUNDARY, which the helpText below states, and this catalog spends those words on the readonly field row.', + }, + { + section: 'capabilities', + path: 'enable.approvalsVisibleToReaders', + prop: 'helpText', + en: 'Readers of a record see its approval requests and history, read-only, with no approval action', + verdict: ALL_TRANSLATE, + reason: + 'EVERY NOUN HAS AN AUTHORED TWIN and the row names each, because this sentence states what a security-relevant switch grants and a loose word would widen it. "approval requests": sys_approval_request.pluralLabel 审批请求 / 承認リクエスト / Solicitudes de aprobación. "approval action": sys_approval_action.pluralLabel 审批动作 / 承認アクション / Acciones de aprobación, so the leaf names the same rows the tier refuses to act on. "read-only": fields.readonly.label 只读 / 読み取り専用 / Solo lectura. "history": the 历史 / 履歴 / historial of enable.trackHistory\'s twin. "can read a record": 读取 / 閲覧 / leer, the record-read the tier anchors on. ⇒ 能读取记录的用户可只读查看该记录的审批请求与历史,不提供任何审批动作 / レコードを閲覧できるユーザーが、その承認リクエストと履歴を読み取り専用で参照できます。承認アクションは提供されません / Quien puede leer un registro ve sus solicitudes de aprobación y su historial, en solo lectura y sin ninguna acción de aprobación. ⚠️ zh and ja name the grantee as the user who can read the record rather than "readers", so the sentence cannot be read as granting anything to a reader of the approval tables themselves.', + }, { section: 'advanced', path: 'validations', @@ -487,9 +505,10 @@ const MINIMAL_OBJECT = { name: 'acct', label: 'Acct', fields: { id: { type: 'tex describe('#19403 round 8 — the ledger itself (controls before verdicts)', () => { it('decides every string leaf of the two keys this round takes, and nothing else', () => { - // Lit — the ledger is the size it claims: 9 form rows, 11 leaves, three - // locales, 33 decisions. - expect(DECISIONS.length).toBe(11); + // Lit — the ledger is the size it claims: 10 form rows, 13 leaves, three + // locales, 39 decisions (the opt-in flag's row brought one form row, two + // leaves and six decisions). + expect(DECISIONS.length).toBe(13); expect(new Set(DECISIONS.map((d) => d.path))).toEqual( new Set([ 'enable', @@ -500,12 +519,13 @@ describe('#19403 round 8 — the ledger itself (controls before verdicts)', () = 'enable.feeds', 'enable.activities', 'enable.clone', + 'enable.approvalsVisibleToReaders', 'validations', ]), ); - expect(DECISIONS.filter((d) => d.prop === 'label').length).toBe(9); - expect(DECISIONS.filter((d) => d.prop === 'helpText').length).toBe(2); - expect(DECISIONS.flatMap((d) => Object.keys(d.verdict)).length).toBe(33); + expect(DECISIONS.filter((d) => d.prop === 'label').length).toBe(10); + expect(DECISIONS.filter((d) => d.prop === 'helpText').length).toBe(3); + expect(DECISIONS.flatMap((d) => Object.keys(d.verdict)).length).toBe(39); for (const d of DECISIONS) { expect(Object.keys(d.verdict).sort(), `${idOf(d)} names every translated locale`).toEqual([ 'es-ES', @@ -685,7 +705,7 @@ describe('#19403 round 8 — ADR-0020 and the validations schema, asserted AT TH // The class-(c) question, answered at the schema rather than assumed: could // translating these labels produce metadata the runtime rejects? No — the // labels name keys whose values are booleans, and the block is strict. - for (const key of ['trackHistory', 'searchable', 'apiEnabled', 'files', 'feeds', 'activities', 'clone']) { + for (const key of ['trackHistory', 'searchable', 'apiEnabled', 'files', 'feeds', 'activities', 'clone', 'approvalsVisibleToReaders']) { expect(ObjectCapabilities.safeParse({ [key]: true }).success, `${key} is a declared capability`).toBe(true); expect( ObjectCapabilities.safeParse({ [key]: 'Clone' }).success, @@ -918,9 +938,13 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape', // a label and a help text each, thirty-six leaves, all hundred and eight // translated leaves authored by the same flight. `advanced` reads 60 → 96; // `capabilities` is untouched. - expect(PANEL_LEAVES.length).toBe(105); + // 107 since the record-reader approval opt-in gave `capabilities` its + // `enable.approvalsVisibleToReaders` toggle — a label and a help text, two + // leaves, all six translated leaves authored by the same card, decided in + // the two rows above. `capabilities` reads 9 → 11; `advanced` is untouched. + expect(PANEL_LEAVES.length).toBe(107); expect(PANEL_LEAVES.every((l) => l.prop === 'label' || l.prop === 'helpText')).toBe(true); - expect(PANEL_LEAVES.filter((l) => l.section === 'capabilities').length).toBe(9); + expect(PANEL_LEAVES.filter((l) => l.section === 'capabilities').length).toBe(11); expect(PANEL_LEAVES.filter((l) => l.section === 'advanced').length).toBe(96); }); @@ -1063,14 +1087,14 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape', expect(flagged.length).toBe(PANEL_LEAVES.length); }); - it('⭐ the capability block is now DONE — zero of its 9 leaves echoes in any locale', () => { + it('⭐ the capability block is now DONE — zero of its 11 leaves echoes in any locale', () => { const capability = PANEL_LEAVES.filter((l) => l.section === 'capabilities'); const echoing = capability.filter((l) => TRANSLATED_LOCALES.some(([, forms]) => catalogLeaf(forms, l.path, l.prop) === l.en), ); expect(echoing.map((l) => `${l.path}.${l.prop}`)).toEqual([]); // Lit — and it really walked the section, which a zero alone would not show. - expect(capability.length).toBe(9); + expect(capability.length).toBe(11); }); }); diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 28f2644987c..338d229f583 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1177,7 +1177,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 667 since the field form offers the deadline pair on date and datetime // fields, `dueLike` and `settledWhen` — two new row labels, authored in // all three locales. - expect(translated.length, `${locale} positive control`).toBe(667); + // 668 since the object form offers `enable.approvalsVisibleToReaders`, + // the per-object opt-in for the read-only record-reader approval tier — + // one new row label, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(668); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 388e8d33a57..9267d2a0aa5 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -293,6 +293,10 @@ export const zhCNMetadataForms: NonNullable = "enable.clone": { label: "克隆" }, + "enable.approvalsVisibleToReaders": { + label: "审批对读者可见", + helpText: "能读取记录的用户可只读查看该记录的审批请求与历史,不提供任何审批动作" + }, validations: { label: "校验规则", helpText: "对象级校验规则——由规则对象组成的数组,例如 [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]。状态机转移表也在此声明(ADR-0020)" diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 39ec14d8d5b..f4370556fe1 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -57,6 +57,7 @@ import type { import type { ExecutionContext } from '@objectstack/spec/kernel'; import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts'; import { isFileIdToken, referenceTargetOf } from '@objectstack/spec/data'; +import type { ObjectCapabilitiesParsed } from '@objectstack/spec/data'; // [#11993] The SANCTIONED renderer for OPERATION-level refusal copy. The // Operation Message Catalog is the ONE seat for these sentences — its own // header bars both a package-local string table and a second rendering @@ -1271,8 +1272,12 @@ export interface ApprovalServiceOptions { /** * [#8652] Objects on which a user holding READ access to the target business * record may also see that record's approval requests and action history — - * read-only. Empty or absent (the default) leaves visibility exactly as it - * was. See {@link ApprovalService.recordReaderVisibleIds} for the rule and + * read-only. This is the HOST's half of the opt-in; the other half is the + * object's own declaration, `enable.approvalsVisibleToReaders: true`, which + * the service reads from the live registry on every call (#22560). Either + * source turns the tier on for an object. Empty or absent here, with no + * object declaring the flag (the default), leaves visibility exactly as it + * was. See {@link ApprovalService.addRecordReaderVisibleIds} for the rule and * its boundaries. */ recordReaderVisibleObjects?: string[]; @@ -1371,9 +1376,11 @@ export class ApprovalService implements IApprovalService { /** [#11993] Lazily-resolved deployment i18n lookup for refusal copy. */ private messageTranslator?: () => ValidationMessageTranslator | undefined; /** - * [#8652] The enabled object set for the record-reader visibility tier. - * EMPTY means the tier is off — the default, and the shape every existing - * deployment gets on upgrade. + * [#8652] The HOST-named object set for the record-reader visibility tier + * (the constructor option). EMPTY is the default, and the tier is then off + * for every object that does not declare it itself — see + * {@link ApprovalService.recordReaderTierOn}, the one place both sources are + * asked. */ private readonly recordReaderVisibleObjects: ReadonlySet; /** @@ -6989,13 +6996,16 @@ export class ApprovalService implements IApprovalService { tenantOrg: string | null, target?: { object?: string | null; recordId?: string | null }, ): Promise { - // Default OFF: a deployment that declares nothing must see no behaviour - // change at all — not even a probe whose answer is discarded. - if (this.recordReaderVisibleObjects.size === 0) return; const object = String(target?.object ?? '').trim(); const recordId = String(target?.recordId ?? '').trim(); if (!object || !recordId) return; - if (!this.recordReaderVisibleObjects.has(object)) return; + // Default OFF: a deployment that declares nothing must see no behaviour + // change at all — not even a probe whose answer is discarded. What it pays + // is one in-memory registry lookup on a read that names a record. + // ⛔ No `recordReaderVisibleObjects.size === 0` early return above this + // line: with the host's set empty, an object that DECLARES the flag must + // still widen (#22560). + if (!this.recordReaderTierOn(object)) return; // [#22559] An approval request is never the anchor, even when the opt-in // names its object: the anchor read below runs as the caller, so on this // object it passes through the generic door's read gate, which asks this @@ -7036,6 +7046,48 @@ export class ApprovalService implements IApprovalService { } } + /** + * [#8652 / #22560] Is the record-reader tier on for `object`? Two sources, + * and either one suffices: + * + * - the HOST's set, the constructor option + * {@link ApprovalServiceOptions.recordReaderVisibleObjects}, for a host + * that builds the plugin itself; + * - the object's OWN declaration, `enable.approvalsVisibleToReaders: true` + * in its live registered definition — what a config-driven app, whose + * host builds the plugin with no options, can write. + * + * ## Why the declaration is read here, on every call + * + * Never collected once at `start()`: the registry is still filling then. + * Objects from installed packages arrive on `kernel:ready`, Studio edits and + * dev reloads re-register an object at any later moment, and a set frozen at + * start would leave such a declaration inert — and, worse, keep a REMOVED one + * in force until restart, an exposure its author believes is closed + * (AGENTS.md "Startup registry reads", cure 1: resolve where the value is + * used). `getSchema` answers the registry as it is at the call. + * + * ## Fails closed + * + * An engine with no `getSchema`, a name the registry does not hold, a lookup + * that throws, or any value but literal `true` reads as "not declared". The + * spec default is `false`, so an absent block or flag is the default, not a + * gap. + */ + private recordReaderTierOn(object: string): boolean { + if (this.recordReaderVisibleObjects.has(object)) return true; + let schema: { enable?: Partial | null } | null | undefined; + try { + schema = this.engine.getSchema?.(object) as typeof schema; + } catch (err) { + this.logger?.debug?.('[approvals] record-reader opt-in lookup failed; the tier stays off', { + object, error: err instanceof Error ? err.message : String(err), + }); + return false; + } + return schema?.enable?.approvalsVisibleToReaders === true; + } + /** Intersect an existing `where.id` constraint with the participant set. */ private applyVisibility(where: any, visible: Set | null): boolean { if (!visible) return true; diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts index 76cfb41fe75..6915ea259f0 100644 --- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts +++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts @@ -66,11 +66,20 @@ export interface ApprovalsPluginOptions { * new ApprovalsServicePlugin({ recordReaderVisibleObjects: ['exam_sheet'] }) * ``` * - * **Default OFF.** Omitted (or empty) leaves visibility precisely as it is - * today — submitter ∪ current approver ∪ historical actor, plus the admin - * override — so an existing deployment's confidentiality posture is unchanged - * on upgrade. Opting in is per object and deliberate: enabling it for a - * ledger object does not enable it anywhere else. + * **Two sources, either one suffices.** This option is the HOST's half, for a + * host that builds the plugin itself. The other half is the object's own + * metadata — `enable: { approvalsVisibleToReaders: true }` — which a + * config-driven app (whose host builds this plugin with no options) can + * write. The service reads that declaration from the object's live + * registered definition on every read, so an object registered after boot + * takes effect and a removed flag stops widening, with no restart. + * + * **Default OFF.** Omitted (or empty), with no object declaring the flag, + * leaves visibility precisely as it is today — submitter ∪ current approver ∪ + * historical actor, plus the admin override — so an existing deployment's + * confidentiality posture is unchanged on upgrade. Opting in is per object + * and deliberate: enabling it for a ledger object does not enable it anywhere + * else. * * **What an enabled object exposes**, so the opt-in is informed — on the * approvals door and, for a deployment that grants read on @@ -209,7 +218,10 @@ export class ApprovalsServicePlugin implements Plugin { logger: ctx.logger, publicBaseUrl: this.options.publicBaseUrl, // [#8652] Read-only record-reader visibility. Default OFF — an absent - // declaration reaches the service as an empty set and changes nothing. + // option reaches the service as an empty set. This is the host's half + // only: an object's own `enable.approvalsVisibleToReaders` is read by + // the service per call, from the live registry (#22560), never folded + // into this set here, where the registry is still filling. recordReaderVisibleObjects: this.options.recordReaderVisibleObjects, // [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture // capability. Read LAZILY (not captured at start) because the tenancy diff --git a/packages/plugins/plugin-approvals/src/record-reader-opt-in.integration.test.ts b/packages/plugins/plugin-approvals/src/record-reader-opt-in.integration.test.ts new file mode 100644 index 00000000000..42036862a32 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/record-reader-opt-in.integration.test.ts @@ -0,0 +1,374 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22560] The record-reader approval tier (#8652), switched on by the OBJECT'S + * OWN metadata — `enable: { approvalsVisibleToReaders: true }` — on both doors. + * + * ## What the rulings fix, and what each pin below holds + * + * #8652 ruled the tier: a caller who can read a record may see that record's + * approval requests and full action history, READ-ONLY, behind a per-object + * switch that is default OFF, which the downstream project opts in. Until this + * card the only switch was a constructor option, which a config-driven app + * cannot set (its host builds the plugin with no options). The declaration now + * lives where an app writes it, and the service reads it where it is used: + * from the object's live registered definition, on every read. + * + * - an object that declares the flag, with the host option EMPTY: a reader of + * one of its records sees that record's requests on the approvals door and, + * on the generic data door, in all three request tables — read-only, with + * no approval action offered or accepted; + * - control: an object that declares nothing is unchanged, and a caller who + * cannot read the record sees nothing, whatever the object declares; + * - the host option still works alone; + * - read where it is used: an object registered after the plugin started, + * declaring the flag, widens; re-registered without it, it stops — with no + * restart. A set collected at start() fails both halves. + * + * ## The rig + * + * The sibling read-gate suites' (`request-read-gate.integration.test.ts`): a + * real ObjectQL engine over better-sqlite3, so the flag is read from the real + * registry; the real `ApprovalsServicePlugin.start()`, so the gates are bound + * as in a deployment; the real data-door normalizer. RECORD READABILITY is the + * one stand-in: one middleware per business object refuses a caller with no + * read on it and scopes one with read to its own records. No security plugin + * is mounted, so every caller holds read on the `sys_approval_*` tables. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import type { EngineQueryOptions } from '@objectstack/spec/data'; +import { ApprovalService } from './approval-service.js'; +import { ApprovalsServicePlugin, type ApprovalsPluginOptions } from './approvals-plugin.js'; +import { SysApprovalRequest } from './sys-approval-request.object.js'; +import { SysApprovalAction } from './sys-approval-action.object.js'; +import { SysApprovalApprover } from './sys-approval-approver.object.js'; +import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; + +const REQUEST = 'sys_approval_request'; +const ACTION = 'sys_approval_action'; +const APPROVER_INDEX = 'sys_approval_approver'; +const CHILDREN = [ACTION, APPROVER_INDEX] as const; + +/** Declares the opt-in in its own `enable` block. */ +const DECLARED = 'exam_sheet'; +/** Declares nothing: the control. */ +const UNDECLARED = 'memo_sheet'; +/** Registered only after the plugin has started. */ +const LATE = 'late_sheet'; +const PKG = 'approvals-record-reader-opt-in-test'; +const READ_OPS = new Set(['find', 'findOne', 'count', 'aggregate']); + +const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; +const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; + +const SUBMITTER = 'u_submitter'; +const APPROVER = 'u_approver'; +/** Reads record `_a` of every business object and participates in nothing. */ +const READER = 'u_reader'; +/** Holds no read on any business object and participates in nothing. */ +const OUTSIDER = 'u_outsider'; + +const recordA = (object: string) => `${object}_a`; +const recordB = (object: string) => `${object}_b`; + +function sheet(name: string, enable?: Record) { + return { + name, + label: name, + fields: { + id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + }, + ...(enable ? { enable } : {}), + }; +} + +const OPT_IN = { approvalsVisibleToReaders: true }; + +const nodeConfig = { + approvers: [{ type: 'user' as const, value: APPROVER }], + behavior: 'first_response' as const, +}; + +interface Rig { + engine: ObjectQL; + svc: ApprovalService; + protocol: any; + /** object -> [request on record `_a`, request on record `_b`] */ + requests: Map; +} + +let current: ObjectQL | undefined; + +afterEach(async () => { + try { await current?.destroy(); } catch { /* noop */ } + current = undefined; +}); + +/** Record readability, the stand-in (see the header), on one business object. */ +function guardReads(engine: ObjectQL, object: string): void { + const readable = new Map>([ + [SUBMITTER, new Set([recordA(object), recordB(object)])], + [APPROVER, new Set([recordA(object), recordB(object)])], + [READER, new Set([recordA(object)])], + ]); + engine.registerMiddleware(async (op: any, next: () => Promise) => { + const ctx = op.context; + if (READ_OPS.has(op.operation) && ctx && !ctx.isSystem && op.ast) { + const grants = readable.get(String(ctx.userId ?? '')); + if (!grants) { + throw Object.assign(new Error(`[Security] Access denied: no read on '${object}'`), { + code: 'PERMISSION_DENIED', status: 403, + }); + } + const scope = { id: { $in: [...grants] } }; + op.ast.where = op.ast.where ? { $and: [op.ast.where, scope] } : scope; + } + return next(); + }, { object }); +} + +/** Two records of `object` and one request on each, with a reply in each history. */ +async function seed(rig: Omit, object: string): Promise<[string, string]> { + const ids: string[] = []; + for (const recordId of [recordA(object), recordB(object)]) { + await rig.engine.insert(object, { id: recordId, title: recordId }, { context: SYSTEM } as any); + const opened = await rig.svc.openNodeRequest({ + object, recordId, runId: `run_${recordId}`, nodeId: 'review', flowName: 'exam_review', + config: nodeConfig as any, submitterId: SUBMITTER, record: { id: recordId, title: recordId }, + }, asUser(SUBMITTER)) as any; + await rig.svc.comment(opened.id, { actorId: APPROVER, comment: `reply on ${recordId}` }, asUser(APPROVER)); + ids.push(String(opened.id)); + } + return [ids[0], ids[1]]; +} + +async function boot(options: ApprovalsPluginOptions = {}): Promise { + const engine = new ObjectQL(); + current = engine; + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + for (const def of [ + sheet(DECLARED, OPT_IN), sheet(UNDECLARED), + SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalDelegation, + ]) { + engine.registry.registerObject(def as any, PKG, PKG); + } + await engine.syncSchemas(); + for (const object of [DECLARED, UNDECLARED]) guardReads(engine, object); + + const services: Record = { objectql: engine }; + const ctx: any = { + getService: (name: string) => { + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); + return services[name]; + }, + registerService: (name: string, svc: unknown) => { services[name] = svc; }, + // Boot-time sweeps are not under test; they are collected and never fired. + hook: () => {}, + logger: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }, + }; + await new ApprovalsServicePlugin(options).start(ctx); + const base = { + engine, + svc: services.approvals as ApprovalService, + protocol: new ObjectStackProtocolImplementation(engine as any), + }; + const requests = new Map(); + for (const object of [DECLARED, UNDECLARED]) requests.set(object, await seed(base, object)); + return { ...base, requests }; +} + +/** The ids of `table`'s rows on `requestId`, as the system reads them. */ +async function rowIdsOf(rig: Rig, table: string, requestId: string): Promise { + const rows = await rig.engine.find(table, { context: SYSTEM } satisfies EngineQueryOptions) as any[]; + return rows.filter((r) => r.request_id === requestId).map((r) => String(r.id)).sort(); +} + +/** The data door's list, as `GET /data/TABLE` hands it in. */ +async function listIds(rig: Rig, table: string, context: any, query: Record = {}): Promise { + const res = await rig.protocol.findData({ object: table, query, context }); + return (res.records as any[]).map((r) => String(r.id)).sort(); +} + +/** The data door's by-id read; the refusal, when it refuses. */ +async function byId(rig: Rig, table: string, context: any, id: string): Promise<{ record?: any; error?: any }> { + try { + return { record: (await rig.protocol.getData({ object: table, id, context })).record }; + } catch (error) { + return { error }; + } +} + +const NOT_FOUND = { code: 'RECORD_NOT_FOUND', status: 404 }; +const envelope = (error: any) => ({ code: error?.code, status: error?.status }); + +/** A record page's approval list: the record named by implicit field filters. */ +const forRecord = (object: string, recordId: string) => ({ object_name: object, record_id: recordId }); +/** A request's related list (its Timeline) by implicit field filter. */ +const forRequest = (requestId: string) => ({ request_id: requestId }); + +/** + * What `caller` is served about `requestId` (on `recordId` of `object`), on + * both doors and all three tables, as one comparable reading. + */ +async function servedOn(rig: Rig, caller: any, object: string, recordId: string, requestId: string) { + const children: Record = {}; + for (const table of CHILDREN) { + const rows = await rowIdsOf(rig, table, requestId); + const byIds: (string | null)[] = []; + for (const row of rows) byIds.push((await byId(rig, table, caller, row)).record?.id ?? null); + children[table] = { list: await listIds(rig, table, caller, forRequest(requestId)), byId: byIds }; + } + return { + approvalsDoor: { + list: (await rig.svc.listRequests({ object, recordId }, caller)).map((r) => String(r.id)), + byId: (await rig.svc.getRequest(requestId, caller))?.id ?? null, + history: (await rig.svc.listActions(requestId, caller)).map((a) => String(a.id)).sort(), + }, + dataDoor: { + [REQUEST]: { + list: await listIds(rig, REQUEST, caller, forRecord(object, recordId)), + byId: (await byId(rig, REQUEST, caller, requestId)).record?.id ?? null, + }, + ...children, + }, + }; +} + +/** The reading of a caller who is served the request and its whole history. */ +async function seesAll(rig: Rig, requestId: string) { + const actions = await rowIdsOf(rig, ACTION, requestId); + const approvers = await rowIdsOf(rig, APPROVER_INDEX, requestId); + expect(actions.length, 'the scene: a submit row and a reply').toBe(2); + expect(approvers.length, 'the scene: one pending approver').toBe(1); + return { + approvalsDoor: { list: [requestId], byId: requestId, history: actions }, + dataDoor: { + [REQUEST]: { list: [requestId], byId: requestId }, + [ACTION]: { list: actions, byId: actions }, + [APPROVER_INDEX]: { list: approvers, byId: approvers }, + }, + }; +} + +/** The reading of a caller who is served nothing about the request. */ +async function seesNothing(rig: Rig, requestId: string) { + const none = async (table: string) => (await rowIdsOf(rig, table, requestId)).map(() => null); + return { + approvalsDoor: { list: [], byId: null, history: [] }, + dataDoor: { + [REQUEST]: { list: [], byId: null }, + [ACTION]: { list: [], byId: await none(ACTION) }, + [APPROVER_INDEX]: { list: [], byId: await none(APPROVER_INDEX) }, + }, + }; +} + +describe('an object that declares enable.approvalsVisibleToReaders, with the host option empty', () => { + it('a reader of the record sees its request and full history on both doors, in all three tables', async () => { + const rig = await boot(); + const [requestA] = rig.requests.get(DECLARED)!; + expect(await servedOn(rig, asUser(READER), DECLARED, recordA(DECLARED), requestA)) + .toEqual(await seesAll(rig, requestA)); + }); + + it('read-only: no approval action is offered, and none is accepted', async () => { + const rig = await boot(); + const [requestA] = rig.requests.get(DECLARED)!; + const ctx = asUser(READER); + // Offered: the per-caller affordance block the console gates its decision actions on. + expect((await rig.svc.getRequest(requestA, ctx))?.viewer) + .toEqual({ can_act: false, can_override: false, is_submitter: false }); + // …and the data door's row carries no such block at all. + expect((await byId(rig, REQUEST, ctx, requestA)).record).not.toHaveProperty('viewer'); + // Accepted: `FORBIDDEN:` is the code this service's refusals carry, the + // prefix the REST layer parses into a 403. + await expect(rig.svc.decide(requestA, { decision: 'approve', actorId: READER }, ctx)).rejects.toThrow(/^FORBIDDEN:/); + await expect(rig.svc.decide(requestA, { decision: 'reject', actorId: READER }, ctx)).rejects.toThrow(/^FORBIDDEN:/); + await expect(rig.svc.reassign(requestA, { actorId: READER, to: READER }, ctx)).rejects.toThrow(/^FORBIDDEN:/); + await expect(rig.svc.comment(requestA, { actorId: READER, comment: 'hi' }, ctx)).rejects.toThrow(/^FORBIDDEN:/); + await expect(rig.svc.recall(requestA, { actorId: READER }, ctx)).rejects.toThrow(/^FORBIDDEN:/); + }); + + it('a caller who cannot read the record sees nothing: the reader on another record, and a caller with no read', async () => { + const rig = await boot(); + const [requestA, requestB] = rig.requests.get(DECLARED)!; + expect(await servedOn(rig, asUser(READER), DECLARED, recordB(DECLARED), requestB)) + .toEqual(await seesNothing(rig, requestB)); + expect(await servedOn(rig, asUser(OUTSIDER), DECLARED, recordA(DECLARED), requestA)) + .toEqual(await seesNothing(rig, requestA)); + // A hidden request answers on the data door exactly as a missing one does. + expect(envelope((await byId(rig, REQUEST, asUser(OUTSIDER), requestA)).error)).toEqual(NOT_FOUND); + }); + + it('a read that names no record stays the reader\'s participant set: the inbox is not widened', async () => { + const rig = await boot(); + const ctx = asUser(READER); + expect(await rig.svc.listRequests({}, ctx)).toEqual([]); + for (const table of [REQUEST, ...CHILDREN]) expect(await listIds(rig, table, ctx), table).toEqual([]); + }); +}); + +describe('controls', () => { + it('an object that declares nothing is unchanged: its reader sees nothing on either door', async () => { + const rig = await boot(); + const [requestA] = rig.requests.get(UNDECLARED)!; + // The control half first: this caller really can read the record. + expect(await rig.engine.find(UNDECLARED, { where: { id: recordA(UNDECLARED) }, context: asUser(READER) })).toHaveLength(1); + expect(await servedOn(rig, asUser(READER), UNDECLARED, recordA(UNDECLARED), requestA)) + .toEqual(await seesNothing(rig, requestA)); + }); + + it('the participants are unchanged by the declaration: the submitter sees both requests of the declared object', async () => { + const rig = await boot(); + const [requestA, requestB] = rig.requests.get(DECLARED)!; + for (const [recordId, requestId] of [[recordA(DECLARED), requestA], [recordB(DECLARED), requestB]]) { + expect(await servedOn(rig, asUser(SUBMITTER), DECLARED, recordId, requestId)) + .toEqual(await seesAll(rig, requestId)); + } + }); + + it('the host option still works alone: naming the undeclared object turns the tier on for it', async () => { + const rig = await boot({ recordReaderVisibleObjects: [UNDECLARED] }); + const [requestA, requestB] = rig.requests.get(UNDECLARED)!; + expect(await servedOn(rig, asUser(READER), UNDECLARED, recordA(UNDECLARED), requestA)) + .toEqual(await seesAll(rig, requestA)); + // …bounded by record readability, as the declaration is. + expect(await servedOn(rig, asUser(READER), UNDECLARED, recordB(UNDECLARED), requestB)) + .toEqual(await seesNothing(rig, requestB)); + }); +}); + +describe('read where it is used: the registry as it is at the read, not at start()', () => { + it('an object registered after the plugin started, declaring the flag, widens; re-registered without it, it stops, with no restart', async () => { + const rig = await boot(); + // After start(): the moment an installed package's objects (kernel:ready), + // a Studio edit or a dev reload arrive. + rig.engine.registry.registerObject(sheet(LATE, OPT_IN) as any, PKG, PKG); + await rig.engine.syncSchemas(); + guardReads(rig.engine, LATE); + const [requestA] = await seed(rig, LATE); + expect(await servedOn(rig, asUser(READER), LATE, recordA(LATE), requestA)) + .toEqual(await seesAll(rig, requestA)); + + // The author removes the flag; the same package re-registers the object. + rig.engine.registry.registerObject(sheet(LATE) as any, PKG, PKG); + // The registry really answers the new definition (the control for the read below)… + expect((rig.engine.getSchema(LATE) as any)?.enable?.approvalsVisibleToReaders).toBeUndefined(); + // …and the tier is off for it at once, on both doors. + expect(await servedOn(rig, asUser(READER), LATE, recordA(LATE), requestA)) + .toEqual(await seesNothing(rig, requestA)); + // Its participants are untouched by either change. + expect(await servedOn(rig, asUser(SUBMITTER), LATE, recordA(LATE), requestA)) + .toEqual(await seesAll(rig, requestA)); + }); +}); diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index 91328c785ed..fc90cd51c59 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -71,6 +71,7 @@ "data/ObjectAccessConfig:default = \"public\"", "data/ObjectCapabilities:activities = true", "data/ObjectCapabilities:apiEnabled = true", + "data/ObjectCapabilities:approvalsVisibleToReaders = false", "data/ObjectCapabilities:clone = true", "data/ObjectCapabilities:feeds = true", "data/ObjectCapabilities:files = false", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 0d8e3b38b1e..f89aaf14749 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -694,6 +694,7 @@ "data/ObjectCapabilities:activities", "data/ObjectCapabilities:apiEnabled", "data/ObjectCapabilities:apiMethods", + "data/ObjectCapabilities:approvalsVisibleToReaders", "data/ObjectCapabilities:clone", "data/ObjectCapabilities:feeds", "data/ObjectCapabilities:files", diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 8f33a46b12d..4314d16ebf8 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -219,6 +219,11 @@ "verifiedAt": "2026-08-28", "evidence": "packages/metadata-protocol/src/protocol.ts#cloneData (`if (schema?.enable?.clone === false)` ⇒ 403 CLONE_DISABLED; an absent block or absent flag stays enabled, matching the spec default)", "note": "cloneData() gates on schema.enable.clone (explicit false ⇒ 403 CLONE_DISABLED); exposed at POST /data/:object/:id/clone (rest-server.ts registerDataActionEndpoints). POINTER REPAIRED 2026-07 — cited packages/objectql/src/protocol.ts:2259 until the protocol moved to @objectstack/metadata-protocol. The claim never stopped being true, but the stale-evidence check could not surface the rot: 47 parse-artefact false positives were burying it (fixed in the same pass). Behaviour guarded by packages/objectql/src/protocol-clone-real-engine.test.ts. 2026-08-28: RE-ANCHORED (commit 93ea19bca) and REPOINTED AGAIN — the 2026-07 repair moved the citation to the right FILE and it has been wrong ever since inside it: `:2938` lands in `normalizeSortNodes` while `cloneData` is at ~:9648, a 6700-line in-range drift. This entry is therefore the worked example of why the anchor exists — its pointer has now been repaired twice, once for a cross-package move that the existence check could see, and once for an in-file move that nothing in the gate could. Re-closed by hand against c459da6bc." + }, + "approvalsVisibleToReaders": { + "status": "live", + "evidence": "packages/plugins/plugin-approvals/src/approval-service.ts#recordReaderTierOn (reads the object's live registered definition on every call: `engine.getSchema(object)` then `enable.approvalsVisibleToReaders === true`, beside the host's `recordReaderVisibleObjects` set; addRecordReaderVisibleIds then admits a caller who can read the named record, as the caller, to that record's requests)", + "note": "Opt-in, spec default false (#8652 ruling: per-object, default OFF, the downstream project opts in). One visibility definition serves the approvals door and the generic data door for sys_approval_request, sys_approval_action and sys_approval_approver (requestVisibilitySourceOf), so the flag widens both doors together, read-only: no approval action authorizes on it. Read per call, never collected at start(), so an object registered after boot (installed packages on kernel:ready, Studio edits, dev reloads) takes effect and a removed flag stops widening without a restart. Behaviour pinned by packages/plugins/plugin-approvals/src/record-reader-opt-in.integration.test.ts." } } }, diff --git a/packages/spec/liveness/state-counts/object.md b/packages/spec/liveness/state-counts/object.md index 2f270ddebb2..f7fd0d7725b 100644 --- a/packages/spec/liveness/state-counts/object.md +++ b/packages/spec/liveness/state-counts/object.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `object` | 52 | 0 | 0 | 0 | 1 | 53 | +| `object` | 53 | 0 | 0 | 0 | 1 | 54 | diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index d867e358e5e..61248bc5358 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -426,6 +426,11 @@ export const objectForm = defineForm({ { field: 'feeds', type: 'boolean' }, { field: 'activities', type: 'boolean' }, { field: 'clone', type: 'boolean' }, + { + field: 'approvalsVisibleToReaders', + type: 'boolean', + helpText: 'Readers of a record see its approval requests and history, read-only, with no approval action', + }, ], }, ], diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 7d3c3ca5408..aaca3c8c865 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -27,6 +27,9 @@ describe('ObjectCapabilities', () => { expect(result.feeds).toBe(true); expect(result.activities).toBe(true); expect(result.clone).toBe(true); + // The record-reader approval tier is opt-IN (#8652: default OFF, the + // downstream project opts in): an absent flag grants nothing. + expect(result.approvalsVisibleToReaders).toBe(false); }); it('should accept custom capability values', () => { @@ -38,12 +41,30 @@ describe('ObjectCapabilities', () => { feeds: true, activities: false, clone: true, + approvalsVisibleToReaders: true, }; const result = ObjectCapabilities.parse(capabilities); expect(result).toEqual(capabilities); }); + it('approvalsVisibleToReaders is a boolean an object declares in its enable block', () => { + // The authorable surface: the key is recognised on the object itself, not + // only on the bare block… + const declared = ObjectSchema.safeParse({ + name: 'exam_sheet', + label: 'Exam Sheet', + fields: { title: { type: 'text', label: 'Title' } }, + enable: { approvalsVisibleToReaders: true }, + }); + expect(declared.success).toBe(true); + expect(declared.success ? declared.data.enable?.approvalsVisibleToReaders : undefined).toBe(true); + // …and it takes a boolean only: a truthy string is refused, never read as on. + const wrongType = ObjectCapabilities.safeParse({ approvalsVisibleToReaders: 'yes' }); + expect(wrongType.success).toBe(false); + expect(wrongType.success ? undefined : wrongType.error.issues[0]?.code).toBe('invalid_type'); + }); + // #2377/#3207 (ADR-0049): `trash`/`mru` parsed-but-did-nothing for years — // the retired keys must fail loudly with the upgrade prescription, not strip // silently (#1535; pattern of the tenancy tombstones, #2763). diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 758e3c6ca1e..36c1980af85 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -208,8 +208,8 @@ const CAPABILITIES_HISTORY = * * Opt-out flags (`feeds`, `activities`, `clone`, `searchable`, `apiEnabled`) * default to `true`: absent block/flag = enabled, and consumers gate on - * explicit `false` only. Opt-in flags (`trackHistory`, `files`) default to - * `false`. + * explicit `false` only. Opt-in flags (`trackHistory`, `files`, + * `approvalsVisibleToReaders`) default to `false`. * * `.strict()`: unknown keys (incl. the retired `trash` / `mru`, #2377) are * rejected with guidance, not stripped (#1535). @@ -308,6 +308,31 @@ export const ObjectCapabilities = strictObject({ /** Allow cloning records */ clone: z.boolean().default(true).describe('Allow record deep cloning'), + + /** + * Record-reader approval visibility — opt-in, default OFF (#8652). + * + * Contract: `true` lets a caller who can READ a record of this object see + * that record's approval requests and their full action history, READ-ONLY. + * Readability is the object's own CRUD, sharing and RLS, asked as the + * caller; no new permission is granted. It holds on the approvals API and on + * the generic data API alike (`sys_approval_request`, `sys_approval_action` + * and `sys_approval_approver` answer by one visibility rule), and only on a + * read that names the record — the inbox is not widened. No approval action + * is offered: approve, reject, reassign, recall and comment keep authorizing + * on the pending approver slate, the submitter, or administrator override. + * What becomes visible: the request row (with its snapshot of the record at + * submission) and each action's actor, decision, time, comment text and + * attachments. + * + * Read by `@objectstack/plugin-approvals` on every such read, from the + * object's live registered definition: an object registered or edited after + * boot takes effect, and removing the flag turns the tier off, with no + * restart. A host that builds the plugin itself may also name objects in + * `ApprovalsPluginOptions.recordReaderVisibleObjects`; either source turns + * the tier on for an object. + */ + approvalsVisibleToReaders: z.boolean().default(false).describe('Opt-in: true lets a caller who can read a record of this object see that record\'s approval requests and full action history (comments and attachments included), read-only, on the approvals API and the generic data API alike, on a read that names the record. No approval action is offered. Default false: only the request\'s participants (submitter, approvers, past actors) and administrators see it'), }); /**