Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/30209-record-pages-audit-queue.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 20 additions & 11 deletions packages/plugins/plugin-audit/scripts/i18n-extract.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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`
Expand All @@ -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'] } },
],
});

Expand Down
17 changes: 11 additions & 6 deletions packages/plugins/plugin-audit/src/audit-log-read-visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,18 +49,20 @@
* 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
* exactly as before — through a predicate on the ledger's own columns
* ({@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
Expand Down
5 changes: 5 additions & 0 deletions packages/plugins/plugin-audit/src/audit-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 10 additions & 5 deletions packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
155 changes: 155 additions & 0 deletions packages/plugins/plugin-audit/src/sys-audit-log-page.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;

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<AnyRec> {
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<string, { safeParse: (v: unknown) => { 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);
}
});
});
Loading
Loading