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
14 changes: 14 additions & 0 deletions .changeset/26167-activity-summary-reader-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/plugin-audit': patch
---

fix(plugin-audit): an activity summary reads in the reader's language, not the language of the write

Clause-②: no (no exported type, schema field or spec key changes; the code rides in the existing `sys_activity.metadata` JSON, and `summary` keeps its type and meaning)
Metadata-protocol: none

The CRUD mirror now stores two things for each summary: `summary` holds the English sentence, as the fallback text for any reader, search and export, and `metadata.summary_message` holds the message key plus its params (object, record label, field names, from/to values with option values). A new `sys_activity` read middleware composes the summary again in the caller's negotiated locale (from `Accept-Language`, else the workspace locale) with the object, field and option labels of that reader's language pack. A locale with no template, or a code this version cannot compose, keeps the stored sentence. The field redaction still judges the composed text, and the code is served only to system callers.

The write no longer asks for a locale for the summary, so a workspace set to Chinese stores the English sentence: full-text search over `summary` matches the English words. `getLocale` / the localization settings still pick the language of @mention notification titles.

Rows written before this change carry no code: they keep the sentence they were written with, for every reader. There is no migration or backfill, and no row is dropped.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ One of the largest consumers of the flag;
| 42 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `packages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindDelegationWriteGuard` |
| 43 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `packages/plugins/plugin-approvals/src/approval-service.ts#isOverrideActor`, `#resolveActor`, `#sendBack`, `#resubmit`, `#reassign`, `#remind`, `#requestInfo`, `#comment` |
| 44 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentAccessHooks`, `#installAttachmentReadVisibility` |
| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row and the whole `sys_audit_log` row, its before/after snapshots included, on `find` / `findOne` — the audit writer, each read gate's bounded probe and the activity backfill — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` and of `sys_audit_log` to rows whose parent record the caller can read, the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots, the withholding of an activity row recording an update whose every changed field the reader is withheld, of a fired milestone row whose watched fields it is not all served, and of an unstamped row (on `count` and `aggregate` too, so a total agrees with the rows), and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility`, `packages/plugins/plugin-audit/src/audit-log-read-visibility.ts#installAuditLogReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction`, `packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard` |
| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), the activity summary's message code (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row and the whole `sys_audit_log` row, its before/after snapshots included, and the activity row's stored summary code (`metadata.summary_message`), on `find` / `findOne` — the audit writer, each read gate's bounded probe and the activity backfill — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` and of `sys_audit_log` to rows whose parent record the caller can read, the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots, the withholding of an activity row recording an update whose every changed field the reader is withheld, of a fired milestone row whose watched fields it is not all served, and of an unstamped row (on `count` and `aggregate` too, so a total agrees with the rows), the removal of that summary code from the activity row's `metadata` (its params are field values), and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility`, `packages/plugins/plugin-audit/src/audit-log-read-visibility.ts#installAuditLogReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction`, `packages/plugins/plugin-audit/src/activity-summary-message.ts#installActivitySummaryLocale`, `packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard` |
| 46 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter` |
| 47 | The inbound-mail read gates return early (the read AST) | service-mailbox | Get: every `sys_mail_message` and `sys_mail_association` row on `find` / `findOne` — the ingest writer and each gate's own pre-scan, which read the association and mailbox rows a decision needs. Lose: the narrowing of a message to callers who can read a record it is associated with or who own its mailbox (so the unassociated pool is restricted), and of an association to callers who can read the record it names (on `count` and `aggregate` too, so a total agrees with the rows) | `packages/services/service-mailbox/src/mail-read-visibility.ts#gateMiddleware` |
| 47b | The inbound-mail ingest seam: `ingest` admits the system context ALONE, and `rematch` admits it without the organization, owner or `manage_mailboxes` check | service-mailbox | Get: a mail source hands a mailbox its messages, and server code re-matches any mailbox. Lose: nothing a user held — every other caller of `ingest` is refused (`PERMISSION_DENIED`), because an ingested message is shown on every record its correspondents' addresses match and a user who could hand the seam a message could write one in a correspondent's name; every other caller of `rematch` must be in the mailbox's organization and own the mailbox or hold `manage_mailboxes` | `packages/services/service-mailbox/src/ingest/mail-ingest-service.ts#ingest`, `#authorizeRematch` |
Expand Down
35 changes: 35 additions & 0 deletions packages/plugins/plugin-audit/src/__tests__/summary-as-read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#26167] A stored `sys_activity` row's `summary` as a reader in `locale` is
* served it, for suites that write through the audit writer and read the row
* straight from their store (no engine read, so no middleware ran). It applies
* the read side's own overlay (`localizeActivityRow`) to a copy of the row, so
* what it answers is what `installActivitySummaryLocale` serves that reader.
*/

import {
localizeActivityRow,
readerSummaryLocale,
type ActivitySummaryI18n,
} from '../activity-summary-message.js';

/** The engine slice a summary's labels are read from. */
interface DefinitionSource {
getSchema?(objectName: string): unknown;
}

export function summaryAsRead(
row: Record<string, unknown> | undefined,
i18n: unknown,
locale: string | undefined,
engine: DefinitionSource,
): unknown {
if (!row) return undefined;
const copy: Record<string, unknown> = { ...row };
const reader = readerSummaryLocale(i18n as ActivitySummaryI18n | undefined, locale, (objectName) =>
typeof engine.getSchema === 'function' ? engine.getSchema(objectName) : null,
);
localizeActivityRow(copy, reader, false);
return copy.summary;
}
185 changes: 185 additions & 0 deletions packages/plugins/plugin-audit/src/activity-summary-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#26167] The summary code's composer, and the read-side overlay, against a
* REAL locale: `createMemoryI18n` (what the kernel registers as the `i18n`
* fallback) loaded with this plugin's shipped bundle plus an app's labels, so
* the key shapes asked are the bundles' own. The real read path is pinned in
* `activity-summary-reader-locale.integration.test.ts`.
*/

import { describe, it, expect } from 'vitest';
import { createMemoryI18n } from '@objectstack/core';

import { AuditTranslations } from './translations/index.js';
import {
ACTIVITY_SUMMARY_MESSAGE_KEY,
composeActivitySummary,
composeStoredSummary,
localizeActivityRow,
readActivitySummaryMessage,
readerSummaryLocale,
type ActivitySummaryMessage,
} from './activity-summary-message.js';

const DEFS: Record<string, any> = {
biz_deal: {
name: 'biz_deal',
label: 'Deal',
fields: {
stage: { label: 'Stage', options: [{ value: 'won', label: 'Won' }] },
amount: { label: 'Amount' },
owner: { label: 'Owner' },
},
},
};
const getObjectDef = (name: string) => DEFS[name] ?? null;

function makeI18n() {
const i18n = createMemoryI18n();
for (const [locale, data] of Object.entries(AuditTranslations)) {
i18n.loadTranslations(locale, data as Record<string, any>);
}
i18n.loadTranslations('zh-CN', {
objects: {
biz_deal: {
label: '商机',
fields: { stage: { label: '阶段', options: { won: '赢单' } }, amount: { label: '金额' } },
},
},
});
return i18n;
}

const zh = () => readerSummaryLocale(makeI18n(), 'zh-CN', getObjectDef)!;
const ja = () => readerSummaryLocale(makeI18n(), 'ja-JP', getObjectDef)!;

const CREATED: ActivitySummaryMessage = { key: 'activityCreated', object: 'biz_deal', label: 'Acme' };
const NAMED: ActivitySummaryMessage = {
key: 'activityUpdatedFields',
object: 'biz_deal',
label: 'Acme',
fields: ['amount', 'owner'],
more: 2,
};
const CHANGES: ActivitySummaryMessage = {
key: 'fieldChanges',
object: 'biz_deal',
changes: [
{ field: 'stage', from: { text: '∅' }, to: { text: 'Won', option: 'won' } },
{ field: 'owner', from: { text: '张伟' }, to: { text: 'Ana' } },
],
};

describe('composeActivitySummary — one code, each reader\'s language (#26167)', () => {
it('composes every template in the reader locale, labels resolved per reader', () => {
expect(composeActivitySummary(CREATED, zh())).toBe('创建了 商机 "Acme"');
expect(composeActivitySummary({ ...CREATED, key: 'activityDeleted' }, zh())).toBe('删除了 商机 "Acme"');
expect(composeActivitySummary({ ...CREATED, key: 'activityUpdated' }, zh())).toBe('更新了 商机 "Acme"');
// `owner` has no zh-CN label: the authored one answers; the list is zh-CN's.
expect(composeActivitySummary(NAMED, zh())).toBe('更新了 商机 "Acme":金额、Owner和另外 2 个字段');
// An option value is the reader's option label; a title is the record's own datum.
expect(composeActivitySummary(CHANGES, zh())).toBe('阶段: ∅ → 赢单; Owner: 张伟 → Ana');
});

it('falls back to the authored labels where the reader\'s pack has none', () => {
expect(composeActivitySummary(CREATED, ja())).toBe('Deal「Acme」を作成しました');
expect(composeActivitySummary(CHANGES, ja())).toBe('Stage: ∅ → Won; Owner: 张伟 → Ana');
});

it('answers undefined for a locale with no template, and the field-less frame where only that exists', () => {
const fr = createMemoryI18n();
const none = readerSummaryLocale(fr, 'fr-FR', getObjectDef)!;
expect(composeActivitySummary(CREATED, none)).toBeUndefined();
fr.loadTranslations('fr-FR', { messages: { activityUpdated: 'A mis à jour {{object}} « {{label}} »' } });
const older = readerSummaryLocale(fr, 'fr-FR', getObjectDef)!;
expect(composeActivitySummary(NAMED, older)).toBe('A mis à jour Deal « Acme »');
});

it('the stored sentence is the write locale\'s, else the English catalog\'s — never a second spelling', () => {
expect(composeStoredSummary(NAMED, zh())).toBe('更新了 商机 "Acme":金额、Owner和另外 2 个字段');
const none = readerSummaryLocale(createMemoryI18n(), 'fr-FR', getObjectDef)!;
expect(composeStoredSummary(CREATED, none)).toBe('Created Deal "Acme"');
expect(composeStoredSummary(NAMED, none)).toBe('Updated Deal "Acme": Amount, Owner, and 2 more');
});
});

describe('readerSummaryLocale — the reader\'s negotiated locale', () => {
it('negotiates against the held packs: a bare `zh` reads the zh-CN pack', () => {
expect(composeActivitySummary(CREATED, readerSummaryLocale(makeI18n(), 'zh', getObjectDef)!)).toBe(
'创建了 商机 "Acme"',
);
});

it('composes nothing without a locale or a `t()`', () => {
expect(readerSummaryLocale(makeI18n(), undefined, getObjectDef)).toBeUndefined();
expect(readerSummaryLocale(makeI18n(), '', getObjectDef)).toBeUndefined();
expect(readerSummaryLocale(undefined, 'zh-CN', getObjectDef)).toBeUndefined();
});

it('a throwing service is a miss, never a failed read', () => {
const throwing = readerSummaryLocale(
{
t: () => {
throw new Error('boom');
},
},
'zh-CN',
getObjectDef,
)!;
expect(composeActivitySummary(CREATED, throwing)).toBeUndefined();
});
});

describe('readActivitySummaryMessage — only a code this version can compose', () => {
it('accepts each stored shape and refuses anything else', () => {
for (const m of [CREATED, NAMED, CHANGES]) expect(readActivitySummaryMessage(m)).toEqual(m);
expect(readActivitySummaryMessage({ key: 'activityArchived', object: 'biz_deal', label: 'x' })).toBeNull();
expect(readActivitySummaryMessage({ key: 'activityCreated', object: 'biz_deal' })).toBeNull();
expect(readActivitySummaryMessage({ ...NAMED, fields: [1] })).toBeNull();
expect(readActivitySummaryMessage({ key: 'fieldChanges', object: 'biz_deal', changes: [{ field: 'x' }] })).toBeNull();
expect(readActivitySummaryMessage('activityCreated')).toBeNull();
});
});

describe('localizeActivityRow — the read-side overlay', () => {
const row = (metadata: unknown, summary = 'Created Deal "Acme"') => ({ summary, metadata });
const stored = (message: unknown) => JSON.stringify({ old: null, new: { name: 'Acme' }, [ACTIVITY_SUMMARY_MESSAGE_KEY]: message });

it('overlays the summary and drops the code from what a caller is served', () => {
const r: Record<string, unknown> = row(stored(CREATED));
localizeActivityRow(r, zh(), true);
expect(r.summary).toBe('创建了 商机 "Acme"');
expect(JSON.parse(r.metadata as string)).toEqual({ old: null, new: { name: 'Acme' } });
});

it('keeps the code for the system, and works on a parsed `metadata` object too', () => {
const r: Record<string, unknown> = row({ old: null, new: {}, [ACTIVITY_SUMMARY_MESSAGE_KEY]: CREATED });
localizeActivityRow(r, zh(), false);
expect(r.summary).toBe('创建了 商机 "Acme"');
expect((r.metadata as Record<string, unknown>)[ACTIVITY_SUMMARY_MESSAGE_KEY]).toEqual(CREATED);
});

it('keeps the stored sentence with no locale, an unknown code, or a missing template — and still drops the code', () => {
for (const [message, locale] of [
[CREATED, undefined],
[{ key: 'activityArchived', object: 'biz_deal', label: 'Acme' }, zh()],
[CREATED, readerSummaryLocale(createMemoryI18n(), 'fr-FR', getObjectDef)],
] as const) {
const r: Record<string, unknown> = row(stored(message));
localizeActivityRow(r, locale, true);
expect(r.summary).toBe('Created Deal "Acme"');
expect(JSON.parse(r.metadata as string)).not.toHaveProperty(ACTIVITY_SUMMARY_MESSAGE_KEY);
}
});

it('a row written before the change (no code) and a row whose summary was not projected are left alone', () => {
const legacy: Record<string, unknown> = row(JSON.stringify({ old: null, new: { name: 'Acme' } }));
localizeActivityRow(legacy, zh(), true);
expect(legacy).toEqual(row(JSON.stringify({ old: null, new: { name: 'Acme' } })));

const unprojected: Record<string, unknown> = { metadata: stored(CREATED) };
localizeActivityRow(unprojected, zh(), true);
expect(unprojected).not.toHaveProperty('summary');
});
});
Loading
Loading