From 30cdcc29f0558df7ed3eb447ae07c1ced5c4ff59 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 22:55:38 +0000 Subject: [PATCH 1/4] feat(service-settings): the config_change ledger row names the settings row it changed upsertRow reports the id of the stored row a write landed on, and the config_change sink writes it as record_id beside the object the scope selects, so the ledger row and the CRUD mirror's row about the same write share one pointer pair. Claude-Session: https://claude.ai/code/session_016WV4cyf254wVgypzQ5Uj1a Co-authored-by: Claude --- .../src/config-change-audit.test.ts | 27 +++++++++++++-- .../src/config-change-audit.ts | 20 +++++++---- .../service-settings/src/settings-service.ts | 34 +++++++++++++++---- .../src/settings-service.types.ts | 9 +++++ 4 files changed, 75 insertions(+), 15 deletions(-) diff --git a/packages/services/service-settings/src/config-change-audit.test.ts b/packages/services/service-settings/src/config-change-audit.test.ts index b0b158404a0..84215721afb 100644 --- a/packages/services/service-settings/src/config-change-audit.test.ts +++ b/packages/services/service-settings/src/config-change-audit.test.ts @@ -382,8 +382,12 @@ describe('#8145 — a settings write reaches sys_audit_log as `config_change`', expect(rows[0].object_name).toBe(CONFIG_CHANGE_GLOBAL_OBJECT_NAME); expect(rows[0].object_name).toBe('sys_platform_setting'); expect(boot.scopedSettingRows()).toHaveLength(0); - // A settings row has no single id — the composite key is in `metadata`. - expect(rows[0].record_id).toBeNull(); + // The row names the stored settings row the write landed on, so + // `object_name` + `record_id` point at it (#30209). WHICH setting changed is + // still in `metadata`. + const [stored] = boot.settingRows(); + expect(typeof stored?.id).toBe('string'); + expect(rows[0].record_id).toBe(stored.id); // Attribution: both channels, per ADR-0014 D2. expect(rows[0].user_id).toBe('usr_admin'); expect(rows[0].actor).toBe('usr_admin'); @@ -429,6 +433,25 @@ describe('#8145 — a settings write reaches sys_audit_log as `config_change`', // row, and the global rung's store took nothing. expect(boot.scopedSettingRows()).toHaveLength(1); expect(boot.settingRows()).toHaveLength(0); + // The pointer pair names that `sys_setting` row (#30209). + expect(rows[0].record_id).toBe(boot.scopedSettingRows()[0].id); + }); + + it('an OVERWRITE names the same settings row as the write that created it (#30209)', async () => { + const boot = await bootPlugin({ userId: 'usr_admin' }); + await boot.service.setMany('branding_test', { workspace_name: 'A' }, boot.writeCtx); + await boot.service.setMany('branding_test', { workspace_name: 'B' }, boot.writeCtx); + await boot.service.setMany('branding_test', { workspace_name: null }, boot.writeCtx); + + // One stored row, updated in place; every ledger row about it names it, a + // clear included, so the ledger's record page lists all three together. + const stored = boot.settingRows(); + expect(stored).toHaveLength(1); + const rows = boot.ledgerRows(); + expect(rows.map((r) => metaOf(r).event)).toEqual(['settings.set', 'settings.set', 'settings.reset']); + expect(rows.map((r) => r.record_id)).toEqual([stored[0].id, stored[0].id, stored[0].id]); + // `metadata` keeps its shape: the record id is a column, never a new key. + expect(Object.keys(metaOf(rows[1])).sort()).toEqual(['encrypted', 'event', 'key', 'namespace', 'requestId', 'scope']); }); it('records one row per CHANGED KEY, not one per request', async () => { diff --git a/packages/services/service-settings/src/config-change-audit.ts b/packages/services/service-settings/src/config-change-audit.ts index e52c6c71562..15cb879949d 100644 --- a/packages/services/service-settings/src/config-change-audit.ts +++ b/packages/services/service-settings/src/config-change-audit.ts @@ -262,12 +262,20 @@ export function buildConfigChangeAuditSink( // principal, or null for an in-process/boot write. actor, object_name: entry.scope === 'global' ? CONFIG_CHANGE_GLOBAL_OBJECT_NAME : CONFIG_CHANGE_OBJECT_NAME, - // A settings write has no single record id: `sys_setting` is keyed on - // the composite `(namespace, key, scope, user_id)` and - // `sys_platform_setting` on `(namespace, key)`. Null is the honest - // answer and the shape `plugin-auth`'s run-level `import` row already - // uses; WHICH setting changed is in `metadata` and `new_value`. - record_id: null, + // The stored settings row the write landed on, as `upsertRow` + // reports it: the `sys_setting` row of a tenant or user value, the + // `sys_platform_setting` row of a global one, so `object_name` and + // `record_id` are one pointer pair. It is the id the CRUD mirror's own + // row about the same write carries, so the ledger's record page lists + // this row beside that one (`plugin-audit`'s `sys_audit_log` page). + // Null only when the store returned no id (the in-memory fallback); + // WHICH setting changed is still in `metadata`, unchanged. + // + // A row that names a record is read under the ledger's parent-record + // gate (`plugin-audit`'s `audit-log-read-visibility.ts`): a reader + // who cannot read that settings row is not served it, the rule the + // mirror's row already follows. + record_id: entry.recordId ?? null, // The digest, never the value — see the module header. A reset has no // new state to describe. new_value: isReset diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index e015a681c00..67a2d934cd8 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -2760,7 +2760,7 @@ export class SettingsService { updated_at: new Date().toISOString(), updated_by: ctx.userId ?? null, }; - const { previousEnc, previous } = await this.upsertRow(rowForKey); + const { previousEnc, previous, rowId } = await this.upsertRow(rowForKey); // The audit's `old_hash`, taken HERE: after the upsert's probe named the // previous row, and before the reap below destroys the ciphertext a @@ -2798,6 +2798,9 @@ export class SettingsService { : digest ?? '', encrypted: isEncrypted, requestId: ctx.requestId, + // The stored settings row this write landed on, so the ledger row + // names it as its record (see `upsertRow`). + recordId: rowId, }); } catch { // [#8145] Never fail a write because a ledger is unhappy — the same @@ -3725,10 +3728,17 @@ export class SettingsService { * held BEFORE: `previousEnc` for the secret reaper (#8030), `previous` for * the audit's `old_hash` (#26465). Both come off the existence probe the * upsert makes anyway, so a write with no previous row costs no extra read. + * + * `rowId` is the id of the stored row the write landed on: the probed row's + * on an update, the engine's insert result's on an insert. The `config_change` + * ledger row records it as its `record_id`, so the ledger names the settings + * row the way the CRUD mirror's own row about that write does. `null` when the + * store hands back no id (the in-memory fallback, a host engine whose insert + * returns nothing). */ private async upsertRow( row: SettingsRow, - ): Promise<{ previousEnc: string | null; previous: SettingsRow | null }> { + ): Promise<{ previousEnc: string | null; previous: SettingsRow | null; rowId: string | null }> { if (this.engine) { const { object, where, data } = this.rowIdentity(row); // All three engine calls carry the explicit system opt-in @@ -3750,7 +3760,7 @@ export class SettingsService { data, context: SETTINGS_SYSTEM_CONTEXT, }); - return { previousEnc, previous }; + return { previousEnc, previous, rowId: SettingsService.rowIdOf(previous) }; } // Under the opt-in the engine no longer checks that a user-scope row's // `user_id` names a user, so the service keeps that refusal before the @@ -3769,8 +3779,8 @@ export class SettingsService { row.user_id, ); } - await this.engine.insert(object, data, { context: SETTINGS_SYSTEM_CONTEXT }); - return { previousEnc: null, previous: null }; + const inserted = await this.engine.insert(object, data, { context: SETTINGS_SYSTEM_CONTEXT }); + return { previousEnc: null, previous: null, rowId: SettingsService.rowIdOf(inserted) }; } const idx = this.memoryIndexOf(row); if (idx >= 0) { @@ -3779,10 +3789,20 @@ export class SettingsService { // carry keeps what the row held — above all its lock, which a write the // platform rung makes through it must leave in place. this.memory[idx] = { ...previous, ...row }; - return { previousEnc: SettingsService.handleOf(previous.value_enc), previous }; + return { previousEnc: SettingsService.handleOf(previous.value_enc), previous, rowId: null }; } this.memory.push(row); - return { previousEnc: null, previous: null }; + return { previousEnc: null, previous: null, rowId: null }; + } + + /** + * The stored id of a settings row, as the engine returned or read it; `null` + * when there is none. The in-memory fallback's rows carry no id, and nothing + * stores them anywhere a ledger row could point at. + */ + private static rowIdOf(row: unknown): string | null { + const id = (row as { id?: unknown } | null | undefined)?.id; + return typeof id === 'string' && id !== '' ? id : typeof id === 'number' ? String(id) : null; } /** diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index 72379128fcd..2095bcfe73f 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -259,6 +259,15 @@ export interface SettingsAuditSink { valueDigest: string; encrypted: boolean; requestId?: string; + /** + * The id of the stored settings row the write landed on: a `sys_setting` + * row for a tenant or user value, a `sys_platform_setting` row for a global + * one. Recorded as the ledger row's `record_id`, beside the object + * `scope` selects, so the ledger names the settings row the same way the + * CRUD mirror's row about that write does. `null` or absent when the store + * returned no id (the in-memory fallback). + */ + recordId?: string | null; }): Promise | void; } From 2a14593ca469b0a349513e299ade55411d51fad4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 23:01:07 +0000 Subject: [PATCH 2/4] feat(plugin-audit): a ledger row's record page lists the other rows about the same record The sys_audit_log record page draws the row's fields in their groups and, beneath them, every ledger row whose record_id is this row's, so a config_change row's digest sits beside the CRUD mirror's row that carries the value. The page label is translated in the eleven shipped locales, and the read-gate prose names config_change among the rows about a record. Claude-Session: https://claude.ai/code/session_016WV4cyf254wVgypzQ5Uj1a Co-authored-by: Claude --- .../scripts/i18n-extract.config.ts | 31 ++-- .../src/audit-log-read-visibility.ts | 17 +- .../plugins/plugin-audit/src/audit-plugin.ts | 5 + .../src/objects/sys-audit-log.object.ts | 15 +- .../src/sys-audit-log-page.test.ts | 155 ++++++++++++++++ ...-audit-log-same-record.integration.test.ts | 170 ++++++++++++++++++ .../plugin-audit/src/sys-audit-log.page.ts | 158 ++++++++++++++++ .../plugin-audit/src/translations/index.ts | 34 ++-- .../plugin-audit/src/translations/pages.ts | 74 ++++++++ 9 files changed, 621 insertions(+), 38 deletions(-) create mode 100644 packages/plugins/plugin-audit/src/sys-audit-log-page.test.ts create mode 100644 packages/plugins/plugin-audit/src/sys-audit-log-same-record.integration.test.ts create mode 100644 packages/plugins/plugin-audit/src/sys-audit-log.page.ts create mode 100644 packages/plugins/plugin-audit/src/translations/pages.ts diff --git a/packages/plugins/plugin-audit/scripts/i18n-extract.config.ts b/packages/plugins/plugin-audit/scripts/i18n-extract.config.ts index 46d785285b2..a5608b1f69b 100644 --- a/packages/plugins/plugin-audit/scripts/i18n-extract.config.ts +++ b/packages/plugins/plugin-audit/scripts/i18n-extract.config.ts @@ -34,6 +34,12 @@ * recognised flag spelling out of this whole comment, so a second spelling * would keep the opt-in switched on after someone deleted it from the command * block — the one place that decides. + * + * `pages` declares the record page this plugin ships (`sys_audit_log_detail`). + * The command above writes the objects sub-tree only, so the page's one bundle + * key, its page-level `label`, is HAND-AUTHORED in `src/translations/pages.ts` + * and rides in the `translations` baseline below. Declaring the page here is + * what puts its label in the coverage ratchet's population. */ import { defineStack, type ObjectStackDefinition } from '@objectstack/spec'; @@ -52,6 +58,8 @@ import { ptBRObjects } from '../src/translations/pt-BR.objects.generated.js'; import { ruObjects } from '../src/translations/ru.objects.generated.js'; import { deObjects } from '../src/translations/de.objects.generated.js'; import { koObjects } from '../src/translations/ko.objects.generated.js'; +import { SysAuditLogDetailPage } from '../src/sys-audit-log.page.js'; +import { auditPageTranslations } from '../src/translations/pages.js'; /** * The annotation is load-bearing, not decoration (#10868). `defineStack` @@ -69,18 +77,19 @@ import { koObjects } from '../src/translations/ko.objects.generated.js'; */ const config: ObjectStackDefinition = defineStack({ objects: [SysAuditLog, SysActivity, SysComment, SysCommentReaction] as any, + pages: [SysAuditLogDetailPage], translations: [ - { en: { objects: enObjects } }, - { 'zh-CN': { objects: zhCNObjects } }, - { 'ja-JP': { objects: jaJPObjects } }, - { 'es-ES': { objects: esESObjects } }, - { 'zh-Hant': { objects: zhHantObjects } }, - { 'ar': { objects: arObjects } }, - { 'fr': { objects: frObjects } }, - { 'pt-BR': { objects: ptBRObjects } }, - { 'ru': { objects: ruObjects } }, - { 'de': { objects: deObjects } }, - { 'ko': { objects: koObjects } }, + { en: { objects: enObjects, pages: auditPageTranslations.en } }, + { 'zh-CN': { objects: zhCNObjects, pages: auditPageTranslations['zh-CN'] } }, + { 'ja-JP': { objects: jaJPObjects, pages: auditPageTranslations['ja-JP'] } }, + { 'es-ES': { objects: esESObjects, pages: auditPageTranslations['es-ES'] } }, + { 'zh-Hant': { objects: zhHantObjects, pages: auditPageTranslations['zh-Hant'] } }, + { 'ar': { objects: arObjects, pages: auditPageTranslations['ar'] } }, + { 'fr': { objects: frObjects, pages: auditPageTranslations['fr'] } }, + { 'pt-BR': { objects: ptBRObjects, pages: auditPageTranslations['pt-BR'] } }, + { 'ru': { objects: ruObjects, pages: auditPageTranslations['ru'] } }, + { 'de': { objects: deObjects, pages: auditPageTranslations['de'] } }, + { 'ko': { objects: koObjects, pages: auditPageTranslations['ko'] } }, ], }); diff --git a/packages/plugins/plugin-audit/src/audit-log-read-visibility.ts b/packages/plugins/plugin-audit/src/audit-log-read-visibility.ts index c49636dcb4b..c5fa8085808 100644 --- a/packages/plugins/plugin-audit/src/audit-log-read-visibility.ts +++ b/packages/plugins/plugin-audit/src/audit-log-read-visibility.ts @@ -37,7 +37,10 @@ * which name the session, and its `login_failed` / `account_locked`, which * name the account's `sys_user` row (`auth-event-audit.ts`), and an * `action_invocation` row whose action named a subject record - * (`action-invocation-audit.ts`). Judged by the record gate; + * (`action-invocation-audit.ts`), and service-settings' `config_change`, + * which names the stored settings row the write changed (`sys_setting`, or + * `sys_platform_setting` for a global value), the row the mirror's own + * `create` / `update` about that write names. Judged by the record gate; * the session is in the audit writer's `AUDIT_EXCLUDED_OBJECTS`, so the gate * names it ({@link LEDGER_NAMED_PARENTS}) to keep judging it. * A record that no longer exists is read by no caller, so its rows are @@ -46,10 +49,11 @@ * deleted record, and every `logout` row (sign-out deletes the session it * names). * - **Rows about no record** — they name no `record_id` and their action is - * not a record action: the run-level `import` (plugin-auth's user import), - * `config_change` (service-settings) and `platform_admin_standing_change` - * (plugin-security) rows, an auth event that carried no session id, and an - * `action_invocation` row of an action invoked with no subject record. + * not a record action: the run-level `import` (plugin-auth's user import) + * and `platform_admin_standing_change` (plugin-security) rows, a + * `config_change` row stored before its writer recorded the settings row, + * an auth event that carried no session id, and an `action_invocation` row + * of an action invoked with no subject record. * There is no record to judge, and none carries a record's field values * (the per-writer measurement in `audit-log-field-redaction.ts`), so they * are OUTSIDE this gate's class and are served under the ledger's own grant @@ -57,7 +61,8 @@ * ({@link ledgerRowAboutNoRecordWhere}), so a broad read is served whole. This is where the ledger differs from the activity * stream, which excludes every row naming no parent: the stream has no * platform producer of such rows, while these have three, and the shipped - * `config_changes` list view reads two of them through the data door. + * `config_changes` list view reads `import` rows (and the older + * `config_change` rows) through the data door. * - **A record action that names no record** — a `create` / `read` / `update` * / `delete` row with no `record_id` (the CRUD mirror stamps `null` when it * cannot derive the id): about a record the gate cannot name, so excluded diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index ebdbbc20b14..8650c31030f 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -16,6 +16,7 @@ import type { ISharingService, } from '@objectstack/spec/contracts'; import { SysAuditLog, SysActivity, SysComment, SysCommentReaction } from './objects/index.js'; +import { SysAuditLogDetailPage } from './sys-audit-log.page.js'; // `sys_notification` was parked here "until that [ADR-0030] migration lands". // It has landed, so the contribution moved to @objectstack/service-messaging — // the service that writes the row on every `emit()` (#4154). This plugin never @@ -273,6 +274,10 @@ export class AuditPlugin implements Plugin { defaultDatasource: 'cloud', namespace: 'sys', objects: [SysAuditLog, SysActivity, SysComment, SysCommentReaction], + // The ledger row's record page: its fields, and every ledger row about + // the same record beside it, so a `config_change` row's digest sits next + // to the mirror's row that carries the value (`sys-audit-log.page.ts`). + pages: [SysAuditLogDetailPage], // ADR-0029 D7 — contribute the one Audit Log entry into the Setup app's // `group_diagnostics` slot. The plugin owns sys_audit_log (K2), so the // entry lives and dies with this plugin and needs no item gate. diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts index f8d3da144fb..c6bfc5a6613 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts @@ -229,12 +229,17 @@ export const SysAuditLog = ObjectSchema.create({ name: 'config_changes', label: 'Config', data: { provider: 'object', object: 'sys_audit_log' }, - // [#24611] No Object column. Its rows carry no `record_id`, so no pointer - // names their target, and the column drew one of three stored API names + // [#24611] No Object column: it drew one of three stored API names // (`sys_setting`, `sys_platform_setting` for a global-scope change, - // `sys_user` for an import). Which setting changed is in `metadata` and - // `new_value`, and the row's page still shows `object_name` and the - // `tenant_id` a global change leaves empty. + // `sys_user` for an import) as text. A `config_change` row names the + // settings row it changed as its `record_id` (service-settings' + // `config-change-audit.ts`), so the row's page draws that pair as the + // setting's card and lists the ledger's other rows about the same + // setting, the mirror's row with the value among them + // (`sys-audit-log.page.ts`). An `import` row, and a `config_change` row + // written before its writer recorded the id, carry no `record_id`. + // Which setting changed is also in `metadata`, and the row's page shows + // the `tenant_id` a global change leaves empty. columns: ['created_at', 'action', 'user_id'], // `export` removed (#8147) — retired from the enum, nothing ever wrote it. // `import` KEPT deliberately: `plugin-auth`'s admin user-import writes a diff --git a/packages/plugins/plugin-audit/src/sys-audit-log-page.test.ts b/packages/plugins/plugin-audit/src/sys-audit-log-page.test.ts new file mode 100644 index 00000000000..536d029dd66 --- /dev/null +++ b/packages/plugins/plugin-audit/src/sys-audit-log-page.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_audit_log`'s record page: the full page this plugin serves through its + * manifest `pages` (`sys-audit-log.page.ts`), whose job is the list of every + * ledger row about the same record beside the row being read. + * + * Four questions, each answered against the shipped artifacts: + * + * 1. the plugin's manifest carries the page; + * 2. the page parses as a page, and every node's props parse against its + * component's own props row (the per-type door `os validate` judges); + * 3. the body draws every field the object declares (a `full` page draws + * only what it authors), and the same-record list is the ledger keyed on + * `record_id` on both sides, with no "New" on an append-only object; + * 4. its label is translated in every shipped locale, `en` byte-equal to + * the page literal. + * + * What the list SELECTS on a real engine and driver, the mirror's rows about + * a settings write included, is `sys-audit-log-same-record.integration.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { PageSchema, ComponentPropsMap } from '@objectstack/spec/ui'; +import { AuditPlugin } from './audit-plugin.js'; +import { SysAuditLogDetailPage } from './sys-audit-log.page.js'; +import { SysAuditLog } from './objects/index.js'; +import { AuditTranslations } from './translations/index.js'; +import { auditPageTranslations } from './translations/pages.js'; + +type AnyRec = Record; + +const PAGE = SysAuditLogDetailPage as AnyRec; +const COMPONENTS = (PAGE.regions?.[0]?.components ?? []) as AnyRec[]; +const nodeOf = (type: string): AnyRec => COMPONENTS.find((c) => c.type === type) ?? {}; +const FIELDS = SysAuditLog.fields as AnyRec; + +/** The plugin's one manifest, as `init` registers it. */ +async function registeredManifest(): Promise { + const registered: AnyRec[] = []; + const ctx: any = { + getService: (name: string) => { + if (name === 'manifest') return { register: (m: AnyRec) => registered.push(m) }; + throw new Error(`no service '${name}'`); + }, + registerService: () => {}, + hook: () => {}, + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + }; + await new AuditPlugin().init(ctx); + expect(registered).toHaveLength(1); + return registered[0]; +} + +describe('sys_audit_log record page — served through the manifest', () => { + it('the plugin manifest carries the page in `pages`', async () => { + expect((await registeredManifest()).pages).toEqual([SysAuditLogDetailPage]); + }); + + it('is the default full record page of sys_audit_log, and parses as a page', () => { + expect(PAGE).toMatchObject({ + name: 'sys_audit_log_detail', + type: 'record', + object: 'sys_audit_log', + kind: 'full', + isDefault: true, + }); + const parsed = PageSchema.safeParse(PAGE); + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); + }); + + it('every node\'s props parse against its own component props row', () => { + const map = ComponentPropsMap as Record { success: boolean; error?: any } }>; + const verdicts = COMPONENTS.map((c) => { + const row = map[c.type]; + const result = row?.safeParse(c.properties ?? {}); + return { type: c.type, known: !!row, ok: !!result?.success, issues: result?.error?.issues ?? [] }; + }); + expect(verdicts.filter((v) => !v.known || !v.ok)).toEqual([]); + // Control: the door refuses what it should, so the green above is a verdict. + expect(map['record:related_list'].safeParse({ objectName: 'sys_audit_log', relationshipField: 'record_id', relatedField: 'x' }).success) + .toBe(false); + }); +}); + +describe('sys_audit_log record page — the body', () => { + it('authors the header, the strip, the details, the same-record list and the discussion, in that order', () => { + expect(COMPONENTS.map((c) => c.type)).toEqual([ + 'page:header', 'record:highlights', 'record:details', 'record:related_list', 'record:discussion', + ]); + expect(nodeOf('page:header')).toEqual({ type: 'page:header', properties: { placedActions: true } }); + }); + + it('the strip is the object\'s highlightFields minus the title field, and holds neither half of the pointer pair', () => { + const strip = nodeOf('record:highlights').properties.fields as string[]; + expect(strip).toEqual((SysAuditLog.highlightFields ?? []).filter((f: string) => f !== SysAuditLog.nameField)); + expect(strip).not.toContain('record_id'); + expect(strip).not.toContain('object_name'); + }); + + it('the details draw every field the object declares, the system id apart, in its declared groups', () => { + const sections = nodeOf('record:details').properties.sections as AnyRec[]; + const drawn = sections.flatMap((s) => s.fields as string[]); + const declared = Object.keys(FIELDS).filter((f) => f !== 'id'); + expect([...drawn].sort()).toEqual([...declared].sort()); + // Each section holds one declared group's fields, so the body reads as the object groups them. + for (const section of sections) { + const groups = new Set((section.fields as string[]).map((f) => FIELDS[f]?.group)); + expect(groups.size, String(section.label?.en)).toBe(1); + expect([...groups][0]).toBe(section.label.en); + } + }); + + it('the same-record list is the ledger, keyed on `record_id` on both sides, newest first, with no "New"', () => { + const list = nodeOf('record:related_list').properties; + expect(list).toMatchObject({ + objectName: 'sys_audit_log', + relationshipField: 'record_id', + relationshipValueField: 'record_id', + sort: [{ field: 'created_at', order: 'desc' }], + // An authored empty action list withholds "New"; the object is append-only. + actions: [], + }); + expect((SysAuditLog as AnyRec).enable?.apiMethods).toEqual(['get', 'list']); + // The value it keys on is the pointer's id half, a declared column of this object. + expect(FIELDS.record_id?.referenceVia).toBe('object_name'); + // Every column it draws is a field of the object, the two snapshots included. + const columns = list.columns as string[]; + expect(columns.filter((c) => !(c in FIELDS))).toEqual([]); + expect(columns).toEqual(expect.arrayContaining(['old_value', 'new_value'])); + }); + + it('every inline label carries the eleven locales this plugin ships', () => { + const locales = Object.keys(auditPageTranslations).sort(); + const labels: AnyRec[] = [ + ...(nodeOf('record:details').properties.sections as AnyRec[]).map((s) => s.label), + nodeOf('record:related_list').properties.title, + ]; + for (const label of labels) expect(Object.keys(label).sort(), String(label.en)).toEqual(locales); + }); +}); + +describe('sys_audit_log record page — its label is translated', () => { + it('`en` mirrors the page literal byte for byte', () => { + expect(auditPageTranslations.en.sys_audit_log_detail?.label).toBe(PAGE.label); + }); + + it('every shipped locale serves its own label, the object\'s label in that locale', () => { + for (const [locale, bundle] of Object.entries(AuditTranslations)) { + const served = (bundle as AnyRec).pages?.sys_audit_log_detail?.label; + expect(served, locale).toBe((auditPageTranslations as AnyRec)[locale].sys_audit_log_detail.label); + expect(served, locale).toBe((bundle as AnyRec).objects?.sys_audit_log?.label); + } + }); +}); diff --git a/packages/plugins/plugin-audit/src/sys-audit-log-same-record.integration.test.ts b/packages/plugins/plugin-audit/src/sys-audit-log-same-record.integration.test.ts new file mode 100644 index 00000000000..84eb2a1e164 --- /dev/null +++ b/packages/plugins/plugin-audit/src/sys-audit-log-same-record.integration.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * What the `sys_audit_log` record page's same-record list SELECTS, on a real + * engine, a real SQLite driver and the real CRUD mirror. + * + * A settings write leaves two kinds of ledger row about the stored settings + * row: the `config_change` row service-settings writes (a digest, and since it + * records the row it changed, that row's id as `record_id`), and the mirror's + * own `create` / `update` row about the same write. The page lists, beside one + * ledger row, every row whose `record_id` is that row's — so the question is + * whether those two kinds meet on one value, and what the mirror's row shows. + * + * The settings rows are written the way `SettingsService` writes them through + * the plugin's engine adapter (`wrapEngineAsSettingsEngine`): an insert, then + * an update by the row's identity (a `where`, `multi: true`), under the system + * context. The `config_change` rows carry the shape and the `record_id` the + * settings sink writes; the sink's own row shape is pinned beside it, in + * service-settings' `config-change-audit.test.ts`, which cannot see this + * package's object. + * + * The list's filter is read off the page — `{ [relationshipField]: + * parent[relationshipValueField] }`, the filter the console's related list + * sends — never retyped here. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { SysPlatformSetting, SysSetting } from '@objectstack/platform-objects/system'; + +import { AuditPlugin } from './audit-plugin.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; +import { SysAuditLogDetailPage } from './sys-audit-log.page.js'; + +const LEDGER = 'sys_audit_log'; +const SYS = { isSystem: true } as const; +const HARNESS_PACKAGE = 'com.objectstack.audit.test.same-record-list'; + +type Row = Record; + +const list = (SysAuditLogDetailPage.regions?.[0]?.components ?? []).find((c: any) => c.type === 'record:related_list') as + | { properties: { relationshipField: string; relationshipValueField: string } } + | undefined; + +describe('sys_audit_log record page — the same-record list on a real engine', () => { + let kernel: ObjectKernel; + let engine: ObjectQL; + let globalId: string; + let tenantId: string; + let secretId: string; + + const ledger = async (where: Record): Promise => + (await engine.find(LEDGER, { where, orderBy: [{ field: 'created_at', order: 'asc' }], context: SYS })) as Row[]; + /** The rows the page's list shows beside `parent`. */ + const listedBeside = async (parent: Row): Promise => { + const { relationshipField, relationshipValueField } = list!.properties; + return ledger({ [relationshipField]: parent[relationshipValueField] }); + }; + /** A settings write as `SettingsService` makes it through the plugin's engine adapter. */ + const writeSetting = async (object: string, identity: Row, value: Row): Promise => { + const [existing] = (await engine.find(object, { where: identity, limit: 1, context: SYS })) as Row[]; + if (existing) { + await engine.update(object, value, { where: identity, multi: true, context: SYS } as any); + return String(existing.id); + } + const inserted = (await engine.insert(object, { ...identity, ...value }, { context: SYS })) as Row; + return String(inserted.id); + }; + /** The `config_change` row the settings sink writes, `record_id` included. */ + const configChange = (objectName: string, recordId: string, key: string, scope: string) => ({ + action: 'config_change', + object_name: objectName, + record_id: recordId, + user_id: null, + actor: null, + new_value: JSON.stringify({ namespace: 'branding', key, scope, digest: 'sha256:fixture' }), + metadata: JSON.stringify({ event: 'settings.set', namespace: 'branding', key, scope, encrypted: scope === 'secret' }), + }); + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(singlePostureTenancy()); + await kernel.use(new AuditPlugin()); + await kernel.bootstrap(); + + engine = kernel.getService('objectql'); + const driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + engine.registerDriver(driver, true); + engine.registry.registerObject(SysSetting as any, HARNESS_PACKAGE); + engine.registry.registerObject(SysPlatformSetting as any, HARNESS_PACKAGE); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + + // A global value (the deployment's store), written twice. + const g = { namespace: 'branding', key: 'workspace_name' }; + globalId = await writeSetting('sys_platform_setting', g, { value: 'Acme', encrypted: false }); + await engine.insert(LEDGER, configChange('sys_platform_setting', globalId, 'workspace_name', 'global'), { context: SYS }); + await writeSetting('sys_platform_setting', g, { value: 'Acme Two', encrypted: false }); + await engine.insert(LEDGER, configChange('sys_platform_setting', globalId, 'workspace_name', 'global'), { context: SYS }); + + // A tenant value (the organization's store). + const t = { namespace: 'branding', key: 'tagline', scope: 'tenant', user_id: null }; + tenantId = await writeSetting('sys_setting', t, { value: 'Hello', encrypted: false }); + await engine.insert(LEDGER, configChange('sys_setting', tenantId, 'tagline', 'tenant'), { context: SYS }); + + // A secret: the row holds a handle, never the value. + const s = { namespace: 'branding', key: 'api_key' }; + secretId = await writeSetting('sys_platform_setting', s, { value: null, value_enc: 'sec_fixture_handle', encrypted: true }); + await engine.insert(LEDGER, configChange('sys_platform_setting', secretId, 'api_key', 'global'), { context: SYS }); + }); + + afterAll(async () => { + await kernel?.shutdown?.(); + }); + + it('the page declares the list it is read off', () => { + expect(list?.properties).toMatchObject({ relationshipField: 'record_id', relationshipValueField: 'record_id' }); + }); + + it('the mirror names the settings row it wrote by the id the config_change row carries', async () => { + const mirror = await ledger({ object_name: 'sys_platform_setting', record_id: globalId, action: { $in: ['create', 'update'] } }); + expect(mirror.map((r) => r.action)).toEqual(['create', 'update']); + }); + + it('beside a global config_change row: both config_change rows and the mirror\'s create and update, the new value readable', async () => { + const [first] = await ledger({ action: 'config_change', record_id: globalId }); + const listed = await listedBeside(first); + expect(listed.map((r) => r.action).sort()).toEqual(['config_change', 'config_change', 'create', 'update']); + expect(listed.every((r) => r.record_id === globalId)).toBe(true); + // The measure-first reading: the global store's mirror row carries the + // non-secret value in plain text, both sides of the overwrite. + const update = listed.find((r) => r.action === 'update')!; + expect(JSON.parse(update.old_value)).toMatchObject({ value: 'Acme' }); + expect(JSON.parse(update.new_value)).toMatchObject({ value: 'Acme Two' }); + // The config_change row itself still carries only the digest. + expect(JSON.parse(first.new_value)).not.toHaveProperty('value'); + }); + + it('beside a tenant config_change row: that sys_setting row\'s rows, and no other setting\'s', async () => { + const [change] = await ledger({ action: 'config_change', record_id: tenantId }); + const listed = await listedBeside(change); + expect(listed.map((r) => r.action).sort()).toEqual(['config_change', 'create']); + expect(listed.every((r) => r.record_id === tenantId)).toBe(true); + expect(JSON.parse(listed.find((r) => r.action === 'create')!.new_value)).toMatchObject({ value: 'Hello' }); + }); + + it('beside a secret\'s config_change row: the mirror\'s row carries neither the value nor its handle', async () => { + const [change] = await ledger({ action: 'config_change', record_id: secretId }); + const listed = await listedBeside(change); + const create = listed.find((r) => r.action === 'create'); + expect(create).toBeTruthy(); + const snapshot = JSON.parse(create!.new_value); + expect(snapshot.value ?? null).toBeNull(); + expect(snapshot).not.toHaveProperty('value_enc'); + expect(create!.new_value).not.toContain('sec_fixture_handle'); + }); + + it('a row about no record lists nothing beside it', async () => { + const run = (await engine.insert(LEDGER, { action: 'import', object_name: 'sys_user', record_id: null }, { context: SYS })) as Row; + const { relationshipValueField } = list!.properties; + // The console sends no query for a parent with no value (`RelatedList` + // refuses to fetch an unscoped list); the value it would key on is absent. + expect(run[relationshipValueField] ?? null).toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-audit/src/sys-audit-log.page.ts b/packages/plugins/plugin-audit/src/sys-audit-log.page.ts new file mode 100644 index 00000000000..32b54bc5f88 --- /dev/null +++ b/packages/plugins/plugin-audit/src/sys-audit-log.page.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Page } from '@objectstack/spec/ui'; + +/** + * sys_audit_log — Record Detail Page (`kind: 'full'`, default for ALL + * sys_audit_log records). + * + * **Audience**: an administrator opening one ledger row from Setup → 审计日志 + * to find out what changed. + * + * ## Why the page exists: the other rows about the same record + * + * A settings write records a `config_change` row whose `new_value` is a + * DIGEST, never the value (`service-settings`' `config-change-audit.ts`), so + * the row alone cannot answer "what was it changed to". The CRUD mirror + * (`audit-writers.ts`) writes its own row about the same write, on the stored + * settings row, and that row carries the change: a non-secret value's old and + * new state, a secret's only as the mirror's ledger view masks it. Both rows + * name the same settings row as `record_id` (the `config_change` writer + * records the id of the row the write landed on), so this page lists every + * ledger row whose `record_id` is this row's: the `config_change` row and the + * mirror's rows sit side by side, and the value is one row down. + * + * The list is the ledger itself, keyed on the pointer's id half on both sides + * (`relationshipField` and `relationshipValueField` both `record_id`): the + * console filters `sys_audit_log` on `record_id` equal to this row's + * `record_id`. Every ledger row about a record gets the same list: the + * history of that record as the ledger holds it, this row included. A row + * about no record (an `import` run, a platform-admin standing change, a + * `config_change` written before its writer recorded the settings row) has no + * `record_id`, and the console lists nothing for a parent value it does not + * have. + * + * What a reader is served is the ledger's read gate's answer, unchanged: each + * listed row passes the parent-record gate and the field redaction + * (`audit-log-read-visibility.ts`, `audit-log-field-redaction.ts`) exactly as + * it does on the list views. + * + * ## The nodes + * + * - `page:header` with `placedActions: true`: the synthesized header's + * action set, read live. The ledger places no actions of its own. + * - `record:highlights` over the object's `highlightFields` minus the title + * field `action` (the H1). Neither half of the `record_id` / `object_name` + * pointer pair is in the strip: the details grid draws `record_id` as the + * target record's card, headed by its object's label + * (`sys-audit-log-pointer-pair.test.ts`). + * - `record:details` with the object's fields in its declared groups. A + * `full` page draws only what it authors, and a bare node draws the + * object's `highlightFields`, so the groups are spelled out. The type half + * `object_name` is listed beside its id half; the console folds it into + * the card. + * - the `record:related_list` above, newest first, with no "New": the ledger + * is append-only (`enable.apiMethods: ['get', 'list']`). + * - `record:discussion`, the synthesized discussion footer. + * + * Copy inside the components is authored as inline locale maps in the + * locales this plugin's bundles ship, the ruled route for page prose; the + * page-level `label` is the one key a translation bundle reaches + * (`pages.sys_audit_log_detail.label`, in `translations/pages.ts`). + */ +export const SysAuditLogDetailPage: Page = { + name: 'sys_audit_log_detail', + label: 'Audit Log', + type: 'record', + object: 'sys_audit_log', + template: 'default', + kind: 'full', + isDefault: true, + + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header', properties: { placedActions: true } }, + + { type: 'record:highlights', properties: { fields: ['created_at', 'user_id'] } }, + + { + type: 'record:details', + properties: { + sections: [ + { + label: { + en: 'Event', 'zh-CN': '事件', 'zh-Hant': '事件', 'ja-JP': 'イベント', ko: '이벤트', de: 'Ereignis', + fr: 'Événement', 'es-ES': 'Evento', 'pt-BR': 'Evento', ru: 'Событие', ar: 'الحدث', + }, + fields: ['created_at', 'action', 'user_id', 'actor'], + }, + { + label: { + en: 'Target', 'zh-CN': '目标记录', 'zh-Hant': '目標記錄', 'ja-JP': '対象レコード', ko: '대상 레코드', + de: 'Zieldatensatz', fr: 'Enregistrement cible', 'es-ES': 'Registro de destino', + 'pt-BR': 'Registro de destino', ru: 'Целевая запись', ar: 'السجل المستهدف', + }, + fields: ['object_name', 'record_id'], + }, + { + label: { + en: 'Changes', 'zh-CN': '变更内容', 'zh-Hant': '變更內容', 'ja-JP': '変更内容', ko: '변경 내용', + de: 'Änderungen', fr: 'Modifications', 'es-ES': 'Cambios', 'pt-BR': 'Alterações', ru: 'Изменения', + ar: 'التغييرات', + }, + fields: ['old_value', 'new_value'], + }, + { + label: { + en: 'Client', 'zh-CN': '客户端', 'zh-Hant': '用戶端', 'ja-JP': 'クライアント', ko: '클라이언트', de: 'Client', + fr: 'Client', 'es-ES': 'Cliente', 'pt-BR': 'Cliente', ru: 'Клиент', ar: 'العميل', + }, + fields: ['ip_address', 'user_agent'], + }, + { + label: { + en: 'Context', 'zh-CN': '上下文', 'zh-Hant': '上下文', 'ja-JP': 'コンテキスト', ko: '컨텍스트', de: 'Kontext', + fr: 'Contexte', 'es-ES': 'Contexto', 'pt-BR': 'Contexto', ru: 'Контекст', ar: 'السياق', + }, + fields: ['tenant_id', 'metadata'], + }, + ], + }, + }, + + { + type: 'record:related_list', + properties: { + objectName: 'sys_audit_log', + relationshipField: 'record_id', + relationshipValueField: 'record_id', + columns: ['created_at', 'action', 'user_id', 'old_value', 'new_value'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 25, + showViewAll: true, + // Append-only: no "New" on this list. + actions: [], + title: { + en: 'Changes to the same record', + 'zh-CN': '同一记录的变更', + 'zh-Hant': '同一記錄的變更', + 'ja-JP': '同じレコードへの変更', + ko: '같은 레코드의 변경', + de: 'Änderungen am selben Datensatz', + fr: 'Modifications du même enregistrement', + 'es-ES': 'Cambios en el mismo registro', + 'pt-BR': 'Alterações no mesmo registro', + ru: 'Изменения той же записи', + ar: 'التغييرات على السجل نفسه', + }, + }, + }, + + { type: 'record:discussion' }, + ], + }, + ], +}; diff --git a/packages/plugins/plugin-audit/src/translations/index.ts b/packages/plugins/plugin-audit/src/translations/index.ts index 3f13241cc75..73549874f38 100644 --- a/packages/plugins/plugin-audit/src/translations/index.ts +++ b/packages/plugins/plugin-audit/src/translations/index.ts @@ -46,6 +46,7 @@ import { deMessages, koMessages, } from './messages.js'; +import { auditPageTranslations, auditPageSourceHashes } from './pages.js'; /** * ## The provenance companions are READ here, not merely recorded @@ -64,28 +65,29 @@ import { * before it can testify. So the only reader-visible consequence was the wrong * string on the page. * - * `recorded` (3rd argument) stays `undefined` on purpose: it judges the - * HAND-AUTHORED sections (`apps` / `dashboards` / `pages`), which this set does - * not have — its bundles are entirely generated. The companion goes in the 4th - * slot, which judges the generated ones. This is the shape - * `@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses. + * `recorded` (3rd argument) judges the HAND-AUTHORED sections (`apps` / + * `dashboards` / `pages`). This set has exactly one: the `pages` entry of the + * record page the plugin ships (`./pages.ts`), whose recorded digests go there. + * The generated companion goes in the 4th slot, which judges the generated + * `objects` bundles. Two tables, two predicates — the shape + * `@objectstack/plugin-approvals` uses for its own record page. * * ⛔ Do not drop the 4th argument to quiet a staleness report. Serving the * superseded draft is the bug; `check:i18n-stale-fill`'s UNSERVED PROVENANCE * verdict fails the build if a committed companion stops being consulted here. */ -const enSource: TranslationData = { objects: enObjects, messages: enMessages }; +const enSource: TranslationData = { objects: enObjects, pages: auditPageTranslations.en, messages: enMessages }; export const AuditTranslations: TranslationBundle = { en: enSource, - 'zh-CN': withSourceFallback({ objects: zhCNObjects, messages: zhCNMessages }, enSource, undefined, zhCNGeneratedSourceHashes), - 'ja-JP': withSourceFallback({ objects: jaJPObjects, messages: jaJPMessages }, enSource, undefined, jaJPGeneratedSourceHashes), - 'es-ES': withSourceFallback({ objects: esESObjects, messages: esESMessages }, enSource, undefined, esESGeneratedSourceHashes), - 'zh-Hant': withSourceFallback({ objects: zhHantObjects, messages: zhHantMessages }, enSource, undefined, zhHantGeneratedSourceHashes), - 'ar': withSourceFallback({ objects: arObjects, messages: arMessages }, enSource, undefined, arGeneratedSourceHashes), - 'fr': withSourceFallback({ objects: frObjects, messages: frMessages }, enSource, undefined, frGeneratedSourceHashes), - 'pt-BR': withSourceFallback({ objects: ptBRObjects, messages: ptBRMessages }, enSource, undefined, ptBRGeneratedSourceHashes), - 'ru': withSourceFallback({ objects: ruObjects, messages: ruMessages }, enSource, undefined, ruGeneratedSourceHashes), - 'de': withSourceFallback({ objects: deObjects, messages: deMessages }, enSource, undefined, deGeneratedSourceHashes), - 'ko': withSourceFallback({ objects: koObjects, messages: koMessages }, enSource, undefined, koGeneratedSourceHashes), + 'zh-CN': withSourceFallback({ objects: zhCNObjects, pages: auditPageTranslations['zh-CN'], messages: zhCNMessages }, enSource, auditPageSourceHashes['zh-CN'], zhCNGeneratedSourceHashes), + 'ja-JP': withSourceFallback({ objects: jaJPObjects, pages: auditPageTranslations['ja-JP'], messages: jaJPMessages }, enSource, auditPageSourceHashes['ja-JP'], jaJPGeneratedSourceHashes), + 'es-ES': withSourceFallback({ objects: esESObjects, pages: auditPageTranslations['es-ES'], messages: esESMessages }, enSource, auditPageSourceHashes['es-ES'], esESGeneratedSourceHashes), + 'zh-Hant': withSourceFallback({ objects: zhHantObjects, pages: auditPageTranslations['zh-Hant'], messages: zhHantMessages }, enSource, auditPageSourceHashes['zh-Hant'], zhHantGeneratedSourceHashes), + 'ar': withSourceFallback({ objects: arObjects, pages: auditPageTranslations['ar'], messages: arMessages }, enSource, auditPageSourceHashes['ar'], arGeneratedSourceHashes), + 'fr': withSourceFallback({ objects: frObjects, pages: auditPageTranslations['fr'], messages: frMessages }, enSource, auditPageSourceHashes['fr'], frGeneratedSourceHashes), + 'pt-BR': withSourceFallback({ objects: ptBRObjects, pages: auditPageTranslations['pt-BR'], messages: ptBRMessages }, enSource, auditPageSourceHashes['pt-BR'], ptBRGeneratedSourceHashes), + 'ru': withSourceFallback({ objects: ruObjects, pages: auditPageTranslations['ru'], messages: ruMessages }, enSource, auditPageSourceHashes['ru'], ruGeneratedSourceHashes), + 'de': withSourceFallback({ objects: deObjects, pages: auditPageTranslations['de'], messages: deMessages }, enSource, auditPageSourceHashes['de'], deGeneratedSourceHashes), + 'ko': withSourceFallback({ objects: koObjects, pages: auditPageTranslations['ko'], messages: koMessages }, enSource, auditPageSourceHashes['ko'], koGeneratedSourceHashes), }; diff --git a/packages/plugins/plugin-audit/src/translations/pages.ts b/packages/plugins/plugin-audit/src/translations/pages.ts new file mode 100644 index 00000000000..52fa923665f --- /dev/null +++ b/packages/plugins/plugin-audit/src/translations/pages.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Page translations for the record page this plugin ships + * (`SysAuditLogDetailPage`, `sys_audit_log_detail`) — HAND-AUTHORED, the way + * `@objectstack/plugin-approvals` carries its record page's `pages.*` entry. + * + * The generated `.objects.generated.ts` files beside this one are + * written by `os i18n extract` with the objects-only switch this package's + * extract config documents, which emits the `objects` sub-tree alone. A + * page-level `label` is the one key a bundle reaches on the page: every other + * string on it is an inline locale map in `regions[].components[]`. The page + * is declared in the extract config all the same, so the coverage ratchet + * counts its label against this section. + * + * Each value is the locale's own `sys_audit_log` object label, so the page and + * the object it draws read alike. + * + * ## Keeping the copy honest + * + * - `en` mirrors the literal in the page metadata, byte for byte: + * `translatePage` applies the bundle in every locale including `en`, so a + * drifted `en` entry would override the page's own label. + * `sys-audit-log-page.test.ts` pins it. + * - Each translated locale records the digest of the `en` string it was + * translated from ({@link auditPageSourceHashes}). `withSourceFallback` in + * `./index.ts` compares it with the current source and serves the source + * string in place of a translation made from an older one. Re-translated a + * string? Update the value AND its digest (`hashSource` from + * `@objectstack/platform-objects/apps` gives it). ⛔ Never refresh a digest + * alone: that records a translation as current when it is not. + */ + +import type { TranslationData } from '@objectstack/spec/system'; +import type { SourceHashes } from '@objectstack/platform-objects/apps'; + +type PageTranslations = NonNullable; + +/** The shipped locales, the eleven the generated bundles carry. */ +export type AuditLocale = 'en' | 'zh-CN' | 'ja-JP' | 'es-ES' | 'zh-Hant' | 'ar' | 'fr' | 'pt-BR' | 'ru' | 'de' | 'ko'; + +export const auditPageTranslations: Readonly> = { + en: { sys_audit_log_detail: { label: 'Audit Log' } }, + 'zh-CN': { sys_audit_log_detail: { label: '审计日志' } }, + 'ja-JP': { sys_audit_log_detail: { label: '監査ログ' } }, + 'es-ES': { sys_audit_log_detail: { label: 'Registro de auditoría' } }, + 'zh-Hant': { sys_audit_log_detail: { label: '稽核日誌' } }, + ar: { sys_audit_log_detail: { label: 'سجل التدقيق' } }, + fr: { sys_audit_log_detail: { label: 'Journal d’audit' } }, + 'pt-BR': { sys_audit_log_detail: { label: 'Log de auditoria' } }, + ru: { sys_audit_log_detail: { label: 'Журнал аудита' } }, + de: { sys_audit_log_detail: { label: 'Audit-Protokoll' } }, + ko: { sys_audit_log_detail: { label: '감사 로그' } }, +}; + +/** The digest of the `en` label every translation above was made from. */ +const AUDIT_LOG_LABEL_HASH = 'b2fad40733012b18'; + +/** + * Recorded source hashes for the translated locales: the digest of the `en` + * string each translation above was made from. + */ +export const auditPageSourceHashes: Readonly, SourceHashes>> = { + 'zh-CN': { 'pages.sys_audit_log_detail.label': AUDIT_LOG_LABEL_HASH }, + 'ja-JP': { 'pages.sys_audit_log_detail.label': AUDIT_LOG_LABEL_HASH }, + 'es-ES': { 'pages.sys_audit_log_detail.label': AUDIT_LOG_LABEL_HASH }, + 'zh-Hant': { 'pages.sys_audit_log_detail.label': AUDIT_LOG_LABEL_HASH }, + ar: { 'pages.sys_audit_log_detail.label': AUDIT_LOG_LABEL_HASH }, + fr: { 'pages.sys_audit_log_detail.label': AUDIT_LOG_LABEL_HASH }, + 'pt-BR': { 'pages.sys_audit_log_detail.label': AUDIT_LOG_LABEL_HASH }, + ru: { 'pages.sys_audit_log_detail.label': AUDIT_LOG_LABEL_HASH }, + de: { 'pages.sys_audit_log_detail.label': AUDIT_LOG_LABEL_HASH }, + ko: { 'pages.sys_audit_log_detail.label': AUDIT_LOG_LABEL_HASH }, +}; From 96a1f7af848091f6497a1889b9f724fd03cc98f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 23:05:25 +0000 Subject: [PATCH 3/4] feat(plugin-sharing): sys_queue record page lists its members by queue name The organization's queue gets a default full record page whose members list reads sys_queue_member keyed on `queue` = this queue's `name` (relationshipValueField), so the console's filter and its "New" prefill both carry the name the member rows store. sys_queue_member.queue is unchanged. Declared queues have no sys_queue row and so no page. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cyf254wVgypzQ5Uj1a --- .../scripts/i18n-extract.config.ts | 17 +- .../plugin-sharing/src/sharing-plugin.ts | 4 + .../plugin-sharing/src/sys-queue-page.test.ts | 156 ++++++++++++++++++ .../plugin-sharing/src/sys-queue.page.ts | 83 ++++++++++ .../plugin-sharing/src/translations/index.ts | 37 +++-- .../plugin-sharing/src/translations/pages.ts | 54 ++++++ 6 files changed, 335 insertions(+), 16 deletions(-) create mode 100644 packages/plugins/plugin-sharing/src/sys-queue-page.test.ts create mode 100644 packages/plugins/plugin-sharing/src/sys-queue.page.ts create mode 100644 packages/plugins/plugin-sharing/src/translations/pages.ts diff --git a/packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts b/packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts index 427bf81611e..4a98208ce67 100644 --- a/packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts +++ b/packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts @@ -34,6 +34,12 @@ * recognised flag spelling out of this whole comment, so a second spelling * would keep the opt-in switched on after someone deleted it from the command * block — the one place that decides. + * + * `pages` declares the record page this plugin ships (`sys_queue_detail`). + * The command above writes the objects sub-tree only, so the page's one bundle + * key, its page-level `label`, is HAND-AUTHORED in `src/translations/pages.ts` + * and rides in the `translations` baseline below. Declaring the page here is + * what puts its label in the coverage ratchet's population. */ import { defineStack, type ObjectStackDefinition } from '@objectstack/spec'; @@ -42,6 +48,8 @@ import { enObjects } from '../src/translations/en.objects.generated.js'; import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js'; import { esESObjects } from '../src/translations/es-ES.objects.generated.js'; +import { SysQueueDetailPage } from '../src/sys-queue.page.js'; +import { sharingPageTranslations } from '../src/translations/pages.js'; /** * The annotation is load-bearing, not decoration (#10868). `defineStack` @@ -59,11 +67,12 @@ import { esESObjects } from '../src/translations/es-ES.objects.generated.js'; */ const config: ObjectStackDefinition = defineStack({ objects: [SysRecordShare, SysSharingRule, SysShareLink, SysQueue, SysQueueMember] as any, + pages: [SysQueueDetailPage], translations: [ - { en: { objects: enObjects } }, - { 'zh-CN': { objects: zhCNObjects } }, - { 'ja-JP': { objects: jaJPObjects } }, - { 'es-ES': { objects: esESObjects } }, + { en: { objects: enObjects, pages: sharingPageTranslations.en } }, + { 'zh-CN': { objects: zhCNObjects, pages: sharingPageTranslations['zh-CN'] } }, + { 'ja-JP': { objects: jaJPObjects, pages: sharingPageTranslations['ja-JP'] } }, + { 'es-ES': { objects: esESObjects, pages: sharingPageTranslations['es-ES'] } }, ], }); diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index a7beeef2083..0b1ba372fc2 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -57,6 +57,7 @@ import { SysQueueMember, SysAssignmentCursor, } from './objects/index.js'; +import { SysQueueDetailPage } from './sys-queue.page.js'; import { OwnershipQueueService, specsReadBusinessUnits, type OwnershipQueueEngine } from './ownership-queue-service.js'; import { bindQueueGuards, bindQueueRecipientRecompute, reportShadowedAuthoredQueues } from './queue-hooks.js'; import { SysBusinessUnit, SysBusinessUnitMember } from '@objectstack/platform-objects/identity'; @@ -652,6 +653,9 @@ export class SharingServicePlugin implements Plugin { SysQueueMember, SysAssignmentCursor, ], + // The queue's record page: its fields, and its members listed by the + // queue's name with "New" pre-filled (`sys-queue.page.ts`). + pages: [SysQueueDetailPage], // ADR-0029 D7 — contribute the sharing entries into the Setup app's // `group_access_control` slot (priority 200). This plugin owns these // objects (K2). diff --git a/packages/plugins/plugin-sharing/src/sys-queue-page.test.ts b/packages/plugins/plugin-sharing/src/sys-queue-page.test.ts new file mode 100644 index 00000000000..dfb34e46505 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/sys-queue-page.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_queue`'s record page (`sys-queue.page.ts`): an organization's queue + * lists its members, keyed by the queue's NAME, with "New" pre-filled. + * + * No published page keyed a related list on `relationshipValueField: 'name'` + * before this one, so both halves of what the console does with the key are + * pinned here, read off the page and never retyped: + * + * - the FILTER: the list reads `sys_queue_member` where + * `{ [relationshipField]: queue[relationshipValueField] }`; + * - the "New" PREFILL: the member form opens with `relationshipField` set to + * that same value, and "New" is not withheld (no authored `actions: []`). + * + * Both are measured on a REAL engine (ObjectQL + SqlDriver over in-memory + * SQLite) carrying the shipped `sys_queue` / `sys_queue_member`, and the row + * the prefilled form writes is the row the ownership-queue service expands as + * a member: the list and the service agree on who is in the queue. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysOrganization } from '@objectstack/platform-objects/identity'; +import { PageSchema, ComponentPropsMap } from '@objectstack/spec/ui'; +import { SysQueue, SysQueueMember } from './objects/index.js'; +import { SysQueueDetailPage } from './sys-queue.page.js'; +import { OwnershipQueueService } from './ownership-queue-service.js'; +import { SharingTranslations } from './translations/index.js'; +import { sharingPageTranslations } from './translations/pages.js'; + +type AnyRec = Record; + +const SYS = { isSystem: true, userId: 'usr_admin' } as any; +const ORG = 'org_qa'; + +const PAGE = SysQueueDetailPage as AnyRec; +const COMPONENTS = (PAGE.regions?.[0]?.components ?? []) as AnyRec[]; +const nodeOf = (type: string): AnyRec => COMPONENTS.find((c) => c.type === type) ?? {}; +const MEMBERS = nodeOf('record:related_list').properties as AnyRec; + +describe('sys_queue record page — the page', () => { + it('is the default full record page of sys_queue, and parses as a page', () => { + expect(PAGE).toMatchObject({ name: 'sys_queue_detail', type: 'record', object: 'sys_queue', kind: 'full', isDefault: true }); + const parsed = PageSchema.safeParse(PAGE); + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); + }); + + it('every node\'s props parse against its own component props row', () => { + const map = ComponentPropsMap as Record { success: boolean } }>; + const bad = COMPONENTS.filter((c) => !map[c.type]?.safeParse(c.properties ?? {}).success).map((c) => c.type); + expect(bad).toEqual([]); + }); + + it('authors the header, the strip, the details, the members and the discussion, in that order', () => { + expect(COMPONENTS.map((c) => c.type)).toEqual([ + 'page:header', 'record:highlights', 'record:details', 'record:related_list', 'record:discussion', + ]); + // The strip is the object's highlightFields minus the title field. + expect(nodeOf('record:highlights').properties.fields) + .toEqual((SysQueue.highlightFields ?? []).filter((f: string) => f !== SysQueue.nameField)); + // The details draw every field the queue declares. + expect([...nodeOf('record:details').properties.fields].sort()).toEqual(Object.keys(SysQueue.fields).sort()); + }); + + it('the members list is sys_queue_member keyed by the queue NAME, and "New" is not withheld', () => { + expect(MEMBERS).toMatchObject({ objectName: 'sys_queue_member', relationshipField: 'queue', relationshipValueField: 'name' }); + expect(MEMBERS.actions).toBeUndefined(); + // The two sides of the key: the member's `queue` is text holding a name, + // and the queue's `name` is the identity every reference uses. + expect((SysQueueMember.fields as AnyRec).queue).toMatchObject({ type: 'text', required: true }); + expect((SysQueue.indexes ?? []).some((i: AnyRec) => i.unique && i.fields?.join() === 'name')).toBe(true); + const memberFields = Object.keys(SysQueueMember.fields); + const columns = MEMBERS.columns as string[]; + expect(columns.filter((c) => !memberFields.includes(c) && c !== 'updated_at')).toEqual([]); + }); + + it('its label is translated in every shipped locale, `en` byte-equal to the page literal', () => { + expect(sharingPageTranslations.en.sys_queue_detail?.label).toBe(PAGE.label); + for (const [locale, bundle] of Object.entries(SharingTranslations)) { + expect((bundle as AnyRec).pages?.sys_queue_detail?.label, locale) + .toBe((sharingPageTranslations as AnyRec)[locale].sys_queue_detail.label); + } + }); +}); + +describe('sys_queue record page — the members list on a real engine', () => { + let engine: ObjectQL; + let billing: AnyRec; + + /** The rows the page's list shows on `queue`'s page. */ + const listedOn = async (queue: AnyRec): Promise => + (await engine.find('sys_queue_member', { + where: { [MEMBERS.relationshipField]: queue[MEMBERS.relationshipValueField] }, + context: SYS, + })) as AnyRec[]; + /** What the prefilled "New" form carries before the administrator fills in the member. */ + const prefilledNew = (queue: AnyRec): AnyRec => ({ [MEMBERS.relationshipField]: queue[MEMBERS.relationshipValueField] }); + + beforeAll(async () => { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.connect(); + engine = new ObjectQL(); + engine.registerDriver(driver as never, true); + await engine.init(); + for (const def of [SysOrganization, SysQueue, SysQueueMember]) { + engine.registry.registerObject(def as never, 'plugin-sharing', 'sys'); + } + await engine.syncSchemas(); + await engine.insert('sys_organization', { id: ORG, name: 'QA Org', slug: 'qa-org' }, { context: SYS }); + + billing = (await engine.insert('sys_queue', { name: 'billing', label: 'Billing', organization_id: ORG }, { context: SYS })) as AnyRec; + await engine.insert('sys_queue', { name: 'support', label: 'Support', organization_id: ORG }, { context: SYS }); + for (const [queue, type, id] of [ + ['billing', 'user', 'u_ada'], + ['billing', 'position', 'billing_clerk'], + ['support', 'user', 'u_bob'], + ] as const) { + await engine.insert('sys_queue_member', { queue, recipient_type: type, recipient_id: id, organization_id: ORG }, { context: SYS }); + } + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('the FILTER: a queue\'s page lists its own members, by name, and no other queue\'s', async () => { + // Control: keyed on the id, the default, the list would find nothing — the + // members store the name, which is why the page names `name`. + expect(billing.id).not.toBe(billing.name); + expect(await engine.find('sys_queue_member', { where: { queue: billing.id }, context: SYS })).toEqual([]); + + const listed = await listedOn(billing); + expect(listed.map((m) => `${m.recipient_type}:${m.recipient_id}`).sort()).toEqual(['position:billing_clerk', 'user:u_ada']); + }); + + it('the "New" PREFILL: the form carries the queue\'s name, and the member it saves is listed on the page', async () => { + const draft = prefilledNew(billing); + expect(draft).toEqual({ queue: 'billing' }); + await engine.insert( + 'sys_queue_member', + { ...draft, recipient_type: 'user', recipient_id: 'u_cy', organization_id: ORG }, + { context: SYS }, + ); + expect((await listedOn(billing)).map((m) => m.recipient_id)).toContain('u_cy'); + }); + + it('the list and the ownership-queue service agree on who is in the queue', async () => { + const service = new OwnershipQueueService({ engine: () => engine as never }); + const resolved = await service.resolveQueue(billing.name, ORG); + expect(resolved?.source).toBe('authored'); + const listed = (await listedOn(billing)).map((m) => `${m.recipient_type}:${m.recipient_id}`).sort(); + expect((resolved?.members ?? []).map((m) => `${m.type}:${m.value}`).sort()).toEqual(listed); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/sys-queue.page.ts b/packages/plugins/plugin-sharing/src/sys-queue.page.ts new file mode 100644 index 00000000000..e4fb278a835 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/sys-queue.page.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Page } from '@objectstack/spec/ui'; + +/** + * sys_queue — Record Detail Page (`kind: 'full'`, default for ALL sys_queue + * records). + * + * **Audience**: an administrator managing an organization's own queue in + * Setup → Queues, who needs to see and change who is in it without switching + * to the separate Queue Members list. + * + * ## The members list, keyed by NAME + * + * A member row names its queue by the queue's machine name: + * `sys_queue_member.queue` is a text column holding `sys_queue.name`, the key + * every queue reference uses (ADR-0142 D1/D2), and it stays that. So the list + * declares `relationshipValueField: 'name'`: the console filters + * `sys_queue_member` on `queue` equal to this queue's `name`, and its "New" + * opens the member form with `queue` filled in with that name. A member added + * here is the row the ownership-queue service expands, under the same name. + * + * Declared queues are out: a package's queue is a registry item with no + * `sys_queue` row (ADR-0131 D6, regime C), so it has no record page. An + * organization's member rows under a declared queue's name are listed on the + * Queue Members list, as before. + * + * ## The nodes + * + * - `page:header` with `placedActions: true`: the synthesized header's + * action set, read live. + * - `record:highlights` over the object's `highlightFields` minus the title + * field `name` (the H1). + * - `record:details` with the queue's fields. A `full` page draws only what + * it authors, and a bare node draws the object's `highlightFields`, so the + * fields are spelled out. + * - the members `record:related_list` above. + * - `record:discussion`, the synthesized discussion footer. + * + * Copy inside the components is authored as inline locale maps, the ruled + * route for page prose; the page-level `label` is the one key a translation + * bundle reaches (`pages.sys_queue_detail.label`, in `translations/pages.ts`). + */ +export const SysQueueDetailPage: Page = { + name: 'sys_queue_detail', + label: 'Queue', + type: 'record', + object: 'sys_queue', + template: 'default', + kind: 'full', + isDefault: true, + + regions: [ + { + name: 'main', + width: 'full', + components: [ + { type: 'page:header', properties: { placedActions: true } }, + + { type: 'record:highlights', properties: { fields: ['label'] } }, + + { type: 'record:details', properties: { fields: ['name', 'label', 'description'] } }, + + { + type: 'record:related_list', + properties: { + objectName: 'sys_queue_member', + relationshipField: 'queue', + // The member row stores the queue's NAME, not its id. + relationshipValueField: 'name', + columns: ['recipient_type', 'recipient_id', 'updated_at'], + sort: [{ field: 'recipient_type', order: 'asc' }], + limit: 50, + showViewAll: true, + title: { en: 'Members', 'zh-CN': '成员', 'ja-JP': 'メンバー', 'es-ES': 'Miembros' }, + }, + }, + + { type: 'record:discussion' }, + ], + }, + ], +}; diff --git a/packages/plugins/plugin-sharing/src/translations/index.ts b/packages/plugins/plugin-sharing/src/translations/index.ts index b3c914c0208..41767a03e1c 100644 --- a/packages/plugins/plugin-sharing/src/translations/index.ts +++ b/packages/plugins/plugin-sharing/src/translations/index.ts @@ -4,9 +4,11 @@ * SharingTranslations — i18n bundle owned by this plugin (ADR-0029 D8). * * Object label/field/view/action translations for the sys_* objects this - * plugin owns. Loaded at runtime via the plugin's `kernel:ready` hook - * (`i18n.loadTranslations`). Regenerate with `os i18n extract` against - * `scripts/i18n-extract.config.ts`. + * plugin owns, plus the `pages` entry of the record page it ships. Loaded at + * runtime via the plugin's `kernel:ready` hook (`i18n.loadTranslations`). + * Regenerate the `objects` bundles with `os i18n extract` against + * `scripts/i18n-extract.config.ts`; the `pages` entry is hand-authored in + * `./pages.ts`. */ import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; @@ -18,6 +20,7 @@ import { esESObjects } from './es-ES.objects.generated.js'; import { zhCNGeneratedSourceHashes } from './zh-CN.source-hashes.generated.js'; import { jaJPGeneratedSourceHashes } from './ja-JP.source-hashes.generated.js'; import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; +import { sharingPageTranslations, sharingPageSourceHashes } from './pages.js'; /** * ## The provenance companions are READ here, not merely recorded @@ -36,21 +39,31 @@ import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; * before it can testify. So the only reader-visible consequence was the wrong * string on the page. * - * `recorded` (3rd argument) stays `undefined` on purpose: it judges the - * HAND-AUTHORED sections (`apps` / `dashboards` / `pages`), which this set does - * not have — its bundles are entirely generated. The companion goes in the 4th - * slot, which judges the generated ones. This is the shape - * `@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses. + * `recorded` (3rd argument) judges the HAND-AUTHORED sections (`apps` / + * `dashboards` / `pages`). This set has exactly one: the `pages` entry of the + * record page the plugin ships (`./pages.ts`), whose recorded digests go there. + * The generated companion goes in the 4th slot, which judges the generated + * `objects` bundles. Two tables, two predicates — the shape + * `@objectstack/plugin-approvals` uses for its own record page. * * ⛔ Do not drop the 4th argument to quiet a staleness report. Serving the * superseded draft is the bug; `check:i18n-stale-fill`'s UNSERVED PROVENANCE * verdict fails the build if a committed companion stops being consulted here. */ -const enSource: TranslationData = { objects: enObjects }; +const enSource: TranslationData = { objects: enObjects, pages: sharingPageTranslations.en }; export const SharingTranslations: TranslationBundle = { en: enSource, - 'zh-CN': withSourceFallback({ objects: zhCNObjects }, enSource, undefined, zhCNGeneratedSourceHashes), - 'ja-JP': withSourceFallback({ objects: jaJPObjects }, enSource, undefined, jaJPGeneratedSourceHashes), - 'es-ES': withSourceFallback({ objects: esESObjects }, enSource, undefined, esESGeneratedSourceHashes), + 'zh-CN': withSourceFallback( + { objects: zhCNObjects, pages: sharingPageTranslations['zh-CN'] }, + enSource, sharingPageSourceHashes['zh-CN'], zhCNGeneratedSourceHashes, + ), + 'ja-JP': withSourceFallback( + { objects: jaJPObjects, pages: sharingPageTranslations['ja-JP'] }, + enSource, sharingPageSourceHashes['ja-JP'], jaJPGeneratedSourceHashes, + ), + 'es-ES': withSourceFallback( + { objects: esESObjects, pages: sharingPageTranslations['es-ES'] }, + enSource, sharingPageSourceHashes['es-ES'], esESGeneratedSourceHashes, + ), }; diff --git a/packages/plugins/plugin-sharing/src/translations/pages.ts b/packages/plugins/plugin-sharing/src/translations/pages.ts new file mode 100644 index 00000000000..b6fd3471a3f --- /dev/null +++ b/packages/plugins/plugin-sharing/src/translations/pages.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Page translations for the record page this plugin ships + * (`SysQueueDetailPage`, `sys_queue_detail`) — HAND-AUTHORED, the way + * `@objectstack/plugin-approvals` carries its record page's `pages.*` entry. + * + * The generated `.objects.generated.ts` files beside this one are + * written by `os i18n extract` with the objects-only switch this package's + * extract config documents, which emits the `objects` sub-tree alone. A + * page-level `label` is the one key a bundle reaches on the page: every other + * string on it is an inline locale map in `regions[].components[]`. The page + * is declared in the extract config all the same, so the coverage ratchet + * counts its label against this section. + * + * ## Keeping the copy honest + * + * - `en` mirrors the literal in the page metadata, byte for byte: + * `translatePage` applies the bundle in every locale including `en`, so a + * drifted `en` entry would override the page's own label. + * `sys-queue-page.test.ts` pins it. + * - Each translated locale records the digest of the `en` string it was + * translated from ({@link sharingPageSourceHashes}). `withSourceFallback` in + * `./index.ts` compares it with the current source and serves the source + * string in place of a translation made from an older one. Re-translated a + * string? Update the value AND its digest (`hashSource` from + * `@objectstack/platform-objects/apps` gives it). ⛔ Never refresh a digest + * alone: that records a translation as current when it is not. + */ + +import type { TranslationData } from '@objectstack/spec/system'; +import type { SourceHashes } from '@objectstack/platform-objects/apps'; + +type PageTranslations = NonNullable; + +/** The shipped locales, the four the generated bundles carry. */ +export type SharingLocale = 'en' | 'zh-CN' | 'ja-JP' | 'es-ES'; + +export const sharingPageTranslations: Readonly> = { + en: { sys_queue_detail: { label: 'Queue' } }, + 'zh-CN': { sys_queue_detail: { label: '队列' } }, + 'ja-JP': { sys_queue_detail: { label: 'キュー' } }, + 'es-ES': { sys_queue_detail: { label: 'Cola' } }, +}; + +/** + * Recorded source hashes for the translated locales: the digest of the `en` + * string each translation above was made from. + */ +export const sharingPageSourceHashes: Readonly, SourceHashes>> = { + 'zh-CN': { 'pages.sys_queue_detail.label': '87d4b808a85ea103' }, + 'ja-JP': { 'pages.sys_queue_detail.label': '87d4b808a85ea103' }, + 'es-ES': { 'pages.sys_queue_detail.label': '87d4b808a85ea103' }, +}; From 38f170f4be2bdfe2c3e9d76881945ef02de2de41 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 23:11:30 +0000 Subject: [PATCH 4/4] test(dogfood): pin config_change record_id end to end and move the run-level ledger row to an object-less action A settings write's config_change row now names the settings row it changed, so the parent-read-gate fixture loses its only row about no record. It now gets one from the action door (an action invoked with no subject record), and pins that the config_change row follows the gate on the settings row it names while the admin is still served it. The settings audit fixture pins record_id against the stored sys_setting row and finds the mirror's row about the same write beside it. Adds the changeset for the three packages. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cyf254wVgypzQ5Uj1a --- .changeset/30209-record-pages-audit-queue.md | 15 +++++ ...audit-log-parent-read-gate.dogfood.test.ts | 61 ++++++++++++++++--- ...ttings-config-change-audit.dogfood.test.ts | 25 ++++++++ 3 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 .changeset/30209-record-pages-audit-queue.md diff --git a/.changeset/30209-record-pages-audit-queue.md b/.changeset/30209-record-pages-audit-queue.md new file mode 100644 index 00000000000..f9673eab87b --- /dev/null +++ b/.changeset/30209-record-pages-audit-queue.md @@ -0,0 +1,15 @@ +--- +'@objectstack/service-settings': patch +'@objectstack/plugin-audit': patch +'@objectstack/plugin-sharing': patch +--- + +feat(service-settings,plugin-audit,plugin-sharing): a config_change ledger row names the settings row it changed, an audit row's record page lists the other changes to the same record, and a queue's record page lists its members + +Clause-②: no +Metadata-protocol: none + +- **`@objectstack/service-settings`: the `config_change` row's shape changes (not breaking).** A settings write's `config_change` row in `sys_audit_log` now sets `record_id` to the id of the stored settings row the write changed (`sys_setting`, or `sys_platform_setting` for a global value), beside the `object_name` it already named; before, `record_id` was always null. `metadata` and the digest in `new_value` are unchanged, and an in-memory settings store still writes null. Rows written before this release keep their null `record_id`. +- **Who can read those rows narrows.** Because a `config_change` row now names a record, the ledger's parent-record read gate judges it by that settings row: a reader holding only the ledger's own read grant, who cannot read the settings store, no longer sees new `config_change` rows. Holders of the ledger's audit capability (`view_all_audit_log`, administrators by default) still see every row. Older rows with a null `record_id` are still served under the ledger grant. Nothing becomes visible to a reader who could not see it before. +- **`@objectstack/plugin-audit`: `sys_audit_log` gets a default record page** (`sys_audit_log_detail`) that shows the row's fields and lists every other ledger row with the same `record_id`, newest first. Beside a `config_change` row that list holds the CRUD mirror's create and update rows for the same settings row, which carry a non-secret value in plain text; a secret's mirror row carries neither the value nor its handle. The list has no "New". The page label ships in all eleven locales. +- **`@objectstack/plugin-sharing`: `sys_queue` gets a default record page** (`sys_queue_detail`) whose members list reads `sys_queue_member` where `queue` equals this queue's `name`, and whose "New" opens the member form with `queue` filled in with that name. `sys_queue_member.queue` is unchanged. Declared queues have no `sys_queue` row, so they have no page. The page label ships in the plugin's four locales. diff --git a/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts index 6baff4bef46..49df897002a 100644 --- a/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts @@ -7,9 +7,11 @@ // ledger rows about records it can read, plus the rows that are about no // record. This file drives that on a real org-bound boot — better-auth // sign-ups, the platform permission sets plus one explicit ledger read grant, -// plugin-audit's real CRUD mirror and auth-event sink writing the rows, and -// service-settings writing a run-level `config_change` row — through every -// generic door that reads the object: +// plugin-audit's real CRUD mirror and auth-event sink writing the rows, +// an action invoked with no subject record writing a run-level +// `action_invocation` row, and +// service-settings writing a `config_change` row that names the settings row +// it changed — through every generic door that reads the object: // // GET /data/sys_audit_log list (+ its `total`) // GET /data/sys_audit_log/:id by id @@ -43,6 +45,27 @@ import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const LEDGER = 'sys_audit_log'; const READER_EMAIL = 'ledger-gate-reader@verify.test'; +/** + * A run-level row: the action door's `action_invocation` row for an action + * invoked with no subject record names no record (`action-invocation-audit.ts`). + * The settings write that once supplied this file's run-level row now names + * the settings row it changed (#30209), and this boot carries no other + * run-level producer, so the fixture declares one object-scoped action. + */ +const RUN_LEVEL = 'action_invocation'; +const RUN_LEVEL_ACTION = 'cmt_ledger_ping'; +const gateStack = { + ...commentsFixtureStack, + actions: [ + { + name: RUN_LEVEL_ACTION, + label: 'Ledger gate fixture: a run with no subject record', + objectName: 'cmt_open', + type: 'script', + body: { language: 'js', source: 'return { ok: true };', capabilities: [] }, + }, + ], +}; /** The grant under test: object-level read on the ledger, nothing more. */ const ledgerReaderSet: PermissionSet = PermissionSetSchema.parse({ @@ -62,12 +85,13 @@ describe('[#21175] sys_audit_log: a ledger reader reads the rows about records i let privateId: string; // cmt_private, owned by the admin — the reader cannot read it let openId: string; // cmt_open, created by the reader — the reader reads it let goneId: string; // cmt_open, created and deleted by the admin + let settingRef: { object: string; id: string }; // the settings row the PUT below wrote const ledgerAtRest = (where: Record): Promise => ql.find(LEDGER, { where, context: { ...SYS } }); beforeAll(async () => { - stack = await bootStack(commentsFixtureStack as never, { + stack = await bootStack(gateStack as never, { security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, cmtFixtureBaselineSet, ledgerReaderSet], fallbackPermissionSet: cmtFixtureBaselineSet.name, @@ -96,14 +120,20 @@ describe('[#21175] sys_audit_log: a ledger reader reads the rows about records i openId = await idOf(await stack.apiAs(readerTok, 'POST', '/data/cmt_open', { name: 'shared board' })); goneId = await idOf(await stack.apiAs(adminTok, 'POST', '/data/cmt_open', { name: 'board, gone' })); expect((await stack.apiAs(adminTok, 'DELETE', `/data/cmt_open/${goneId}`)).status).toBeLessThan(300); - // A run-level row, from its real producer: a settings write is a - // `config_change` row that names no record. + // A settings write, from its real producer: a `config_change` row that + // names the settings row it changed (#30209), so it is a row ABOUT a record. const put = await stack.raw('/api/settings/branding', { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminTok}` }, body: JSON.stringify({ workspace_name: 'Ledger gate fixture' }), }); expect(put.status, await put.clone().text()).toBe(200); + const [change] = await ledgerAtRest({ action: 'config_change' }); + settingRef = { object: String(change?.object_name), id: String(change?.record_id) }; + // A run-level row, from its real producer: the action door ledgers an + // invocation with no subject record as a row naming no record. + const ran = await stack.apiAs(adminTok, 'POST', `/actions/cmt_open/${RUN_LEVEL_ACTION}`, {}); + expect(ran.status, await ran.clone().text()).toBe(200); await assertArmed([ principalArmed({ @@ -131,9 +161,11 @@ describe('[#21175] sys_audit_log: a ledger reader reads the rows about records i private: (await ledgerAtRest({ object_name: 'cmt_private', record_id: privateId })).length, open: (await ledgerAtRest({ object_name: 'cmt_open', record_id: openId })).length, gone: (await ledgerAtRest({ object_name: 'cmt_open', record_id: goneId })).length, - aboutNoRecord: (await ledgerAtRest({ action: 'config_change' })).filter((r) => r.record_id == null).length, + aboutNoRecord: (await ledgerAtRest({ action: RUN_LEVEL })).filter((r) => r.record_id == null).length, + configChange: (await ledgerAtRest({ action: 'config_change', object_name: settingRef.object, record_id: settingRef.id })).length, + settingRow: (await ql.find(settingRef.object, { where: { id: settingRef.id }, context: { ...SYS } })).length, }), - armed: (o) => o.private === 2 && o.open >= 1 && o.gone === 2 && o.aboutNoRecord >= 1, + armed: (o) => o.private === 2 && o.open >= 1 && o.gone === 2 && o.aboutNoRecord >= 1 && o.configChange >= 1 && o.settingRow === 1, }), ]); }, 180_000); @@ -196,10 +228,21 @@ describe('[#21175] sys_audit_log: a ledger reader reads the rows about records i }); it('a row about no record is still served to the ledger reader', async () => { - const [runLevel] = (await ledgerAtRest({ action: 'config_change' })).filter((r) => r.record_id == null); + const [runLevel] = (await ledgerAtRest({ action: RUN_LEVEL })).filter((r) => r.record_id == null); expect((await stack.apiAs(readerTok, 'GET', `/data/${LEDGER}/${runLevel.id}`)).status).toBe(200); }); + it('a config_change row follows the gate on the settings row it names; the admin is served it', async () => { + const [change] = await ledgerAtRest({ action: 'config_change', object_name: settingRef.object, record_id: settingRef.id }); + const parent = (await stack.apiAs(readerTok, 'GET', `/data/${settingRef.object}/${settingRef.id}`)).status; + const row = (await stack.apiAs(readerTok, 'GET', `/data/${LEDGER}/${change.id}`)).status; + expect(row).toBe(parent === 200 ? 200 : 404); + // The reader holds no read on the settings store, so the ledger-only read + // no longer reaches the row; the admin's audit capability does. + expect(parent).not.toBe(200); + expect((await stack.apiAs(adminTok, 'GET', `/data/${LEDGER}/${change.id}`)).status).toBe(200); + }); + it('a row about a record that no longer exists is not served to the reader; the admin, a default holder of the ledger audit capability, is served it', async () => { const served = async (tok: string) => (await rowsOf(await stack.apiAs(tok, 'GET', `/data/${LEDGER}?limit=1000`))).filter((r) => r.record_id === goneId); diff --git a/packages/qa/dogfood/test/settings-config-change-audit.dogfood.test.ts b/packages/qa/dogfood/test/settings-config-change-audit.dogfood.test.ts index 81815eb87a3..9295d2681a8 100644 --- a/packages/qa/dogfood/test/settings-config-change-audit.dogfood.test.ts +++ b/packages/qa/dogfood/test/settings-config-change-audit.dogfood.test.ts @@ -143,6 +143,31 @@ describe('#8145: a settings write reaches sys_audit_log as config_change', () => // bare timeout, which names neither the object nor the filter. }, 30_000); + it('[#30209] the config_change row names the settings row it changed, and the mirror\'s row about that row carries the value', async () => { + const [change] = await waitForRows( + async () => + (await findRows(ql, 'sys_audit_log', { action: 'config_change' })).filter((r) => + String(r.metadata ?? '').includes('"key":"workspace_name"'), + ), + (r) => r.length >= 1, + 'expected the config_change row for workspace_name', + ); + const settingRows = await findRows(ql, String(change.object_name), { namespace: 'branding', key: 'workspace_name' }); + expect(settingRows).toHaveLength(1); + // The pointer pair: object_name names the store, record_id the row in it. + expect(change.record_id).toBe(String(settingRows[0].id)); + // The record page's same-record list keys on record_id: beside the + // config_change row it finds the CRUD mirror's row about the same write, + // which holds the non-secret value the digest row does not. + const mirror = (await findRows(ql, 'sys_audit_log', { record_id: change.record_id })).filter((r) => + ['create', 'update'].includes(String(r.action)), + ); + expect(mirror.length).toBeGreaterThanOrEqual(1); + expect(mirror.every((r) => r.object_name === change.object_name)).toBe(true); + expect(mirror.some((r) => String(r.new_value ?? '').includes(WORKSPACE_NAME))).toBe(true); + expect(String(change.new_value)).not.toContain(WORKSPACE_NAME); + }, 30_000); + it('the shipped `config_changes` list view — its OWN declared filter — now matches', async () => { // The view's filter is read off the registered object rather than retyped, // so this cannot pass against a filter that agrees only with this test. It