diff --git a/.changeset/30209-audit-log-default-view.md b/.changeset/30209-audit-log-default-view.md new file mode 100644 index 00000000000..f8c1b8a99b5 --- /dev/null +++ b/.changeset/30209-audit-log-default-view.md @@ -0,0 +1,15 @@ +--- +'@objectstack/plugin-audit': patch +'@objectstack/platform-objects': patch +--- + +fix(plugin-audit,platform-objects): the audit log's default list shows what people did, not the rows the platform writes beside it; the audit objects read in Chinese + +Clause-②: no +Metadata-protocol: none + +Setup's 审计日志 opens the first `sys_audit_log` list view, `recent`. One sign-in used to put two rows there (the `login` row and an `update` of the person's `sys_user` stamping `last_login_at` / `last_login_ip`), the first sign-in through the console a third (the token-signing key in `sys_jwks`), and one settings change three or four (the `config_change` row, the stored setting, a secret's ciphertext, and the settings trail's own entry). + +- The ledger writer now marks the sign-in stamp row with `metadata: {"event":"auth.last_login_stamp"}`, the way `config_change` rows carry `settings.set`. Only an update of `sys_user` whose changes are the stamp's fields alone is marked; an administrator's edit of a user is not. The row is still written. +- `recent` leaves out the marked row, the rows on `sys_secret`, `sys_setting_audit`, `sys_platform_setting_audit`, `sys_jwks`, `sys_migration` and `sys_migration_journal`, and the rows on `sys_setting` / `sys_platform_setting` other than `config_change`. Nothing is removed from storage, and `all_events` (全部) still lists every row. +- `sys_platform_setting` and `sys_platform_setting_audit` were in no translation bundle, so they read English in every locale; they now ship in `@objectstack/platform-objects`' bundles with zh-CN translations (ja-JP and es-ES carry the English fill). `sys_comment_reaction`'s zh-CN bundle is translated. diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index 1231574da5d..21092751b77 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -188,7 +188,18 @@ import { } from '../src/metadata/index.js'; // ── System ──────────────────────────────────────────────────────────────── -import { SysSetting, SysSecret, SysSettingAudit, SysMigration, SysExchangeRate } from '../src/system/index.js'; +// `SysPlatformSetting` / `SysPlatformSettingAudit` are the settings global +// rung (ADR-0131 D7). service-settings registers them, and their labels are +// served from this package's bundles like `sys_setting`'s. +import { + SysSetting, + SysPlatformSetting, + SysSecret, + SysSettingAudit, + SysPlatformSettingAudit, + SysMigration, + SysExchangeRate, +} from '../src/system/index.js'; // ── Existing Setup app + dashboards + translations ──────────────────────── import { SETUP_APP } from '../src/apps/setup.app.js'; @@ -316,8 +327,10 @@ const config: ObjectStackDefinition = defineStack({ // System SysSetting, + SysPlatformSetting, SysSecret, SysSettingAudit, + SysPlatformSettingAudit, SysMigration, SysExchangeRate, ] as any, diff --git a/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts index b47a3b7c23b..352de7491a7 100644 --- a/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts +++ b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts @@ -38,7 +38,7 @@ const OWNED_OBJECTS = new Set([ // metadata (sys_view_definition retired as inert, ADR-0131 D13) 'sys_metadata', 'sys_metadata_history', 'sys_metadata_audit', // system - 'sys_setting', 'sys_secret', 'sys_setting_audit', 'sys_migration', 'sys_exchange_rate', + 'sys_setting', 'sys_platform_setting', 'sys_secret', 'sys_setting_audit', 'sys_platform_setting_audit', 'sys_migration', 'sys_exchange_rate', ]); describe('objects translation bundle ownership (ADR-0029 D8)', () => { diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 423e402c972..3b69b249cb7 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -3446,6 +3446,54 @@ export const enObjects: NonNullable = { } } }, + sys_platform_setting: { + label: "Platform Setting", + pluralLabel: "Platform Settings", + description: "Deployment-wide settings values: the global rung of the settings cascade.", + fields: { + id: { + label: "Setting ID" + }, + created_at: { + label: "Created At" + }, + updated_at: { + label: "Updated At" + }, + namespace: { + label: "Namespace", + help: "Manifest namespace (e.g. mail, storage, ai)." + }, + key: { + label: "Key", + help: "Specifier key inside the namespace (snake_case)." + }, + value: { + label: "Value", + help: "JSON-encoded value. Null when encrypted=true (see value_enc)." + }, + encrypted: { + label: "Encrypted", + help: "When true, the value is stored encrypted-at-rest in value_enc; value column is null." + }, + locked: { + label: "Locked", + help: "When true, tenant and user rows cannot override this value; writes against those scopes return 409." + }, + locked_reason: { + label: "Lock Reason", + help: "Human-readable explanation surfaced in the UI tooltip when locked=true." + }, + value_enc: { + label: "Encrypted Value", + help: "Handle of the sys_secret row holding the ciphertext. Set only when encrypted=true." + }, + updated_by: { + label: "Updated By", + help: "Last actor who wrote this row through the settings service." + } + } + }, sys_secret: { label: "Secret", pluralLabel: "Secrets", @@ -3573,6 +3621,84 @@ export const enObjects: NonNullable = { } } }, + sys_platform_setting_audit: { + label: "Platform Setting Audit Entry", + pluralLabel: "Platform Setting Audit", + description: "Append-only audit trail for SettingsService mutations of global-scope keys.", + fields: { + id: { + label: "ID" + }, + created_at: { + label: "Created At", + help: "When the mutation was recorded." + }, + namespace: { + label: "Namespace" + }, + key: { + label: "Key" + }, + scope: { + label: "Scope", + help: "Cascade layer the row was written to — always the global rung here.", + options: { + global: "Global" + } + }, + action: { + label: "Action", + help: "Mutation kind.", + options: { + set: "Set", + reset: "Reset", + lock: "Lock", + unlock: "Unlock", + rotate: "Rotate" + } + }, + actor_id: { + label: "Actor", + help: "User who performed the mutation; null for system jobs." + }, + source: { + label: "Source", + help: "Mutation entry-point.", + options: { + ui: "UI", + api: "API", + migration: "Migration", + import: "Import", + system: "System" + } + }, + reason: { + label: "Reason", + help: "Free-text justification provided by the actor (optional)." + }, + old_hash: { + label: "Old Hash", + help: "Digest of the previous value, never the plaintext: sha256: hex for a plain value, the keyed hmac-sha256: digest for a secret. Null when previously unset, or for a secret with no keyed digest." + }, + new_hash: { + label: "New Hash", + help: "Digest of the new value, never the plaintext: sha256: hex for a plain value, the keyed hmac-sha256: digest for a secret. Null on reset, or for a secret with no keyed digest." + }, + encrypted: { + label: "Encrypted", + help: "True when the field carries secret material (rotation is interesting)." + }, + request_id: { + label: "Request ID", + help: "Correlates with sys_audit_log / tracing." + } + }, + _views: { + recent: { + label: "Recent" + } + } + }, sys_migration: { label: "Data Migration", pluralLabel: "Data Migrations", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 511a7f28a29..9743cce9022 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -3446,6 +3446,54 @@ export const esESObjects: NonNullable = { } } }, + sys_platform_setting: { + label: "Platform Setting", + pluralLabel: "Platform Settings", + description: "Deployment-wide settings values: the global rung of the settings cascade.", + fields: { + id: { + label: "Setting ID" + }, + created_at: { + label: "Created At" + }, + updated_at: { + label: "Updated At" + }, + namespace: { + label: "Namespace", + help: "Manifest namespace (e.g. mail, storage, ai)." + }, + key: { + label: "Key", + help: "Specifier key inside the namespace (snake_case)." + }, + value: { + label: "Value", + help: "JSON-encoded value. Null when encrypted=true (see value_enc)." + }, + encrypted: { + label: "Encrypted", + help: "When true, the value is stored encrypted-at-rest in value_enc; value column is null." + }, + locked: { + label: "Locked", + help: "When true, tenant and user rows cannot override this value; writes against those scopes return 409." + }, + locked_reason: { + label: "Lock Reason", + help: "Human-readable explanation surfaced in the UI tooltip when locked=true." + }, + value_enc: { + label: "Encrypted Value", + help: "Handle of the sys_secret row holding the ciphertext. Set only when encrypted=true." + }, + updated_by: { + label: "Updated By", + help: "Last actor who wrote this row through the settings service." + } + } + }, sys_secret: { label: "Secreto", pluralLabel: "Secretos", @@ -3573,6 +3621,84 @@ export const esESObjects: NonNullable = { } } }, + sys_platform_setting_audit: { + label: "Platform Setting Audit Entry", + pluralLabel: "Platform Setting Audit", + description: "Append-only audit trail for SettingsService mutations of global-scope keys.", + fields: { + id: { + label: "ID" + }, + created_at: { + label: "Created At", + help: "When the mutation was recorded." + }, + namespace: { + label: "Namespace" + }, + key: { + label: "Key" + }, + scope: { + label: "Scope", + help: "Cascade layer the row was written to — always the global rung here.", + options: { + global: "Global" + } + }, + action: { + label: "Action", + help: "Mutation kind.", + options: { + set: "Set", + reset: "Reset", + lock: "Lock", + unlock: "Unlock", + rotate: "Rotate" + } + }, + actor_id: { + label: "Actor", + help: "User who performed the mutation; null for system jobs." + }, + source: { + label: "Source", + help: "Mutation entry-point.", + options: { + ui: "UI", + api: "API", + migration: "Migration", + import: "Import", + system: "System" + } + }, + reason: { + label: "Reason", + help: "Free-text justification provided by the actor (optional)." + }, + old_hash: { + label: "Old Hash", + help: "Digest of the previous value, never the plaintext: sha256: hex for a plain value, the keyed hmac-sha256: digest for a secret. Null when previously unset, or for a secret with no keyed digest." + }, + new_hash: { + label: "New Hash", + help: "Digest of the new value, never the plaintext: sha256: hex for a plain value, the keyed hmac-sha256: digest for a secret. Null on reset, or for a secret with no keyed digest." + }, + encrypted: { + label: "Encrypted", + help: "True when the field carries secret material (rotation is interesting)." + }, + request_id: { + label: "Request ID", + help: "Correlates with sys_audit_log / tracing." + } + }, + _views: { + recent: { + label: "Recent" + } + } + }, sys_migration: { label: "Migración de datos", pluralLabel: "Migraciones de datos", diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index 4086c3c4669..f1d665a29ff 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -48,6 +48,66 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_oauth_refresh_token.fields.token.label": "6e8de3393d2cee54", "objects.sys_oauth_resource.fields.id.label": "00b0385c9c152888", "objects.sys_organization.fields.slug.label": "fe4132df05ec637b", + "objects.sys_platform_setting.description": "4315513e18b2390f", + "objects.sys_platform_setting.fields.created_at.label": "1f02d416befb595b", + "objects.sys_platform_setting.fields.encrypted.help": "b27cae2274daaa79", + "objects.sys_platform_setting.fields.encrypted.label": "29b30b23533882be", + "objects.sys_platform_setting.fields.id.label": "2d02427860a1dbe9", + "objects.sys_platform_setting.fields.key.help": "22aa91b917dbb555", + "objects.sys_platform_setting.fields.key.label": "46b40e4691127a79", + "objects.sys_platform_setting.fields.locked.help": "6f3f8434f2434258", + "objects.sys_platform_setting.fields.locked.label": "f3908487f3d6cc18", + "objects.sys_platform_setting.fields.locked_reason.help": "0a02c26f22809bb0", + "objects.sys_platform_setting.fields.locked_reason.label": "58e43841c0631262", + "objects.sys_platform_setting.fields.namespace.help": "b2f92bac6a552ebe", + "objects.sys_platform_setting.fields.namespace.label": "eca03ee21fc17d64", + "objects.sys_platform_setting.fields.updated_at.label": "aba63dc2a9c79b8d", + "objects.sys_platform_setting.fields.updated_by.help": "12e2a511a747707d", + "objects.sys_platform_setting.fields.updated_by.label": "310c37ac80d3f080", + "objects.sys_platform_setting.fields.value.help": "b9a30858cc4c9f82", + "objects.sys_platform_setting.fields.value.label": "5aa388d2f13c2a79", + "objects.sys_platform_setting.fields.value_enc.help": "ff7442255674b6b0", + "objects.sys_platform_setting.fields.value_enc.label": "fc11ba789882b928", + "objects.sys_platform_setting.label": "bf0723e44898d7c4", + "objects.sys_platform_setting.pluralLabel": "01031b4643b67fb0", + "objects.sys_platform_setting_audit._views.recent.label": "62d27bb9d0349c99", + "objects.sys_platform_setting_audit.description": "5f94a7a832c0189c", + "objects.sys_platform_setting_audit.fields.action.help": "8896314a5251fe52", + "objects.sys_platform_setting_audit.fields.action.label": "a36bba313a7aa9ab", + "objects.sys_platform_setting_audit.fields.action.options.lock": "2a5a07715afe0072", + "objects.sys_platform_setting_audit.fields.action.options.reset": "ce838c10166f608c", + "objects.sys_platform_setting_audit.fields.action.options.rotate": "f122c3555d9bee5e", + "objects.sys_platform_setting_audit.fields.action.options.set": "6a1c5070568f714a", + "objects.sys_platform_setting_audit.fields.action.options.unlock": "8b01ea2154ccd959", + "objects.sys_platform_setting_audit.fields.actor_id.help": "3466d59a5734edf5", + "objects.sys_platform_setting_audit.fields.actor_id.label": "b155813f8a7f06e3", + "objects.sys_platform_setting_audit.fields.created_at.help": "c1620685c202cc45", + "objects.sys_platform_setting_audit.fields.created_at.label": "1f02d416befb595b", + "objects.sys_platform_setting_audit.fields.encrypted.help": "cdd6ae2f56711889", + "objects.sys_platform_setting_audit.fields.encrypted.label": "29b30b23533882be", + "objects.sys_platform_setting_audit.fields.id.label": "00b0385c9c152888", + "objects.sys_platform_setting_audit.fields.key.label": "46b40e4691127a79", + "objects.sys_platform_setting_audit.fields.namespace.label": "eca03ee21fc17d64", + "objects.sys_platform_setting_audit.fields.new_hash.help": "896992c166f5e17b", + "objects.sys_platform_setting_audit.fields.new_hash.label": "820041de2f4f9cce", + "objects.sys_platform_setting_audit.fields.old_hash.help": "ab9198aa8f163cf0", + "objects.sys_platform_setting_audit.fields.old_hash.label": "52a6f7d91e628dee", + "objects.sys_platform_setting_audit.fields.reason.help": "3a4c6e236fd3d467", + "objects.sys_platform_setting_audit.fields.reason.label": "ac399f03d7f2e64d", + "objects.sys_platform_setting_audit.fields.request_id.help": "3c6596e6d79d1de9", + "objects.sys_platform_setting_audit.fields.request_id.label": "1d33071b7d9648f3", + "objects.sys_platform_setting_audit.fields.scope.help": "0d13bd3ce48cf5c9", + "objects.sys_platform_setting_audit.fields.scope.label": "9abfae35f7425b9e", + "objects.sys_platform_setting_audit.fields.scope.options.global": "5e377106508d2ecd", + "objects.sys_platform_setting_audit.fields.source.help": "a0e2dc217d1e1104", + "objects.sys_platform_setting_audit.fields.source.label": "8fe786e8e29c8cec", + "objects.sys_platform_setting_audit.fields.source.options.api": "4baeeff968e192d1", + "objects.sys_platform_setting_audit.fields.source.options.import": "05c80d3a61ec3290", + "objects.sys_platform_setting_audit.fields.source.options.migration": "477a479630b97ad5", + "objects.sys_platform_setting_audit.fields.source.options.system": "d2c13a5a8c70ea8d", + "objects.sys_platform_setting_audit.fields.source.options.ui": "95caad102a5c97ba", + "objects.sys_platform_setting_audit.label": "82cfc386727fcbac", + "objects.sys_platform_setting_audit.pluralLabel": "9bbba1feac14033f", "objects.sys_scim_connection_binding.fields.id.label": "00b0385c9c152888", "objects.sys_scim_connection_credential.fields.id.label": "00b0385c9c152888", "objects.sys_scim_group.fields.id.label": "00b0385c9c152888", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index e723f3b8358..4a2db1d6333 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -3446,6 +3446,54 @@ export const jaJPObjects: NonNullable = { } } }, + sys_platform_setting: { + label: "Platform Setting", + pluralLabel: "Platform Settings", + description: "Deployment-wide settings values: the global rung of the settings cascade.", + fields: { + id: { + label: "Setting ID" + }, + created_at: { + label: "Created At" + }, + updated_at: { + label: "Updated At" + }, + namespace: { + label: "Namespace", + help: "Manifest namespace (e.g. mail, storage, ai)." + }, + key: { + label: "Key", + help: "Specifier key inside the namespace (snake_case)." + }, + value: { + label: "Value", + help: "JSON-encoded value. Null when encrypted=true (see value_enc)." + }, + encrypted: { + label: "Encrypted", + help: "When true, the value is stored encrypted-at-rest in value_enc; value column is null." + }, + locked: { + label: "Locked", + help: "When true, tenant and user rows cannot override this value; writes against those scopes return 409." + }, + locked_reason: { + label: "Lock Reason", + help: "Human-readable explanation surfaced in the UI tooltip when locked=true." + }, + value_enc: { + label: "Encrypted Value", + help: "Handle of the sys_secret row holding the ciphertext. Set only when encrypted=true." + }, + updated_by: { + label: "Updated By", + help: "Last actor who wrote this row through the settings service." + } + } + }, sys_secret: { label: "シークレット", pluralLabel: "シークレット", @@ -3573,6 +3621,84 @@ export const jaJPObjects: NonNullable = { } } }, + sys_platform_setting_audit: { + label: "Platform Setting Audit Entry", + pluralLabel: "Platform Setting Audit", + description: "Append-only audit trail for SettingsService mutations of global-scope keys.", + fields: { + id: { + label: "ID" + }, + created_at: { + label: "Created At", + help: "When the mutation was recorded." + }, + namespace: { + label: "Namespace" + }, + key: { + label: "Key" + }, + scope: { + label: "Scope", + help: "Cascade layer the row was written to — always the global rung here.", + options: { + global: "Global" + } + }, + action: { + label: "Action", + help: "Mutation kind.", + options: { + set: "Set", + reset: "Reset", + lock: "Lock", + unlock: "Unlock", + rotate: "Rotate" + } + }, + actor_id: { + label: "Actor", + help: "User who performed the mutation; null for system jobs." + }, + source: { + label: "Source", + help: "Mutation entry-point.", + options: { + ui: "UI", + api: "API", + migration: "Migration", + import: "Import", + system: "System" + } + }, + reason: { + label: "Reason", + help: "Free-text justification provided by the actor (optional)." + }, + old_hash: { + label: "Old Hash", + help: "Digest of the previous value, never the plaintext: sha256: hex for a plain value, the keyed hmac-sha256: digest for a secret. Null when previously unset, or for a secret with no keyed digest." + }, + new_hash: { + label: "New Hash", + help: "Digest of the new value, never the plaintext: sha256: hex for a plain value, the keyed hmac-sha256: digest for a secret. Null on reset, or for a secret with no keyed digest." + }, + encrypted: { + label: "Encrypted", + help: "True when the field carries secret material (rotation is interesting)." + }, + request_id: { + label: "Request ID", + help: "Correlates with sys_audit_log / tracing." + } + }, + _views: { + recent: { + label: "Recent" + } + } + }, sys_migration: { label: "データマイグレーション", pluralLabel: "データマイグレーション", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index 1582af3d0ee..3e0dade624d 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -38,6 +38,66 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_oauth_consent.fields.id.label": "00b0385c9c152888", "objects.sys_oauth_refresh_token.fields.id.label": "00b0385c9c152888", "objects.sys_oauth_resource.fields.id.label": "00b0385c9c152888", + "objects.sys_platform_setting.description": "4315513e18b2390f", + "objects.sys_platform_setting.fields.created_at.label": "1f02d416befb595b", + "objects.sys_platform_setting.fields.encrypted.help": "b27cae2274daaa79", + "objects.sys_platform_setting.fields.encrypted.label": "29b30b23533882be", + "objects.sys_platform_setting.fields.id.label": "2d02427860a1dbe9", + "objects.sys_platform_setting.fields.key.help": "22aa91b917dbb555", + "objects.sys_platform_setting.fields.key.label": "46b40e4691127a79", + "objects.sys_platform_setting.fields.locked.help": "6f3f8434f2434258", + "objects.sys_platform_setting.fields.locked.label": "f3908487f3d6cc18", + "objects.sys_platform_setting.fields.locked_reason.help": "0a02c26f22809bb0", + "objects.sys_platform_setting.fields.locked_reason.label": "58e43841c0631262", + "objects.sys_platform_setting.fields.namespace.help": "b2f92bac6a552ebe", + "objects.sys_platform_setting.fields.namespace.label": "eca03ee21fc17d64", + "objects.sys_platform_setting.fields.updated_at.label": "aba63dc2a9c79b8d", + "objects.sys_platform_setting.fields.updated_by.help": "12e2a511a747707d", + "objects.sys_platform_setting.fields.updated_by.label": "310c37ac80d3f080", + "objects.sys_platform_setting.fields.value.help": "b9a30858cc4c9f82", + "objects.sys_platform_setting.fields.value.label": "5aa388d2f13c2a79", + "objects.sys_platform_setting.fields.value_enc.help": "ff7442255674b6b0", + "objects.sys_platform_setting.fields.value_enc.label": "fc11ba789882b928", + "objects.sys_platform_setting.label": "bf0723e44898d7c4", + "objects.sys_platform_setting.pluralLabel": "01031b4643b67fb0", + "objects.sys_platform_setting_audit._views.recent.label": "62d27bb9d0349c99", + "objects.sys_platform_setting_audit.description": "5f94a7a832c0189c", + "objects.sys_platform_setting_audit.fields.action.help": "8896314a5251fe52", + "objects.sys_platform_setting_audit.fields.action.label": "a36bba313a7aa9ab", + "objects.sys_platform_setting_audit.fields.action.options.lock": "2a5a07715afe0072", + "objects.sys_platform_setting_audit.fields.action.options.reset": "ce838c10166f608c", + "objects.sys_platform_setting_audit.fields.action.options.rotate": "f122c3555d9bee5e", + "objects.sys_platform_setting_audit.fields.action.options.set": "6a1c5070568f714a", + "objects.sys_platform_setting_audit.fields.action.options.unlock": "8b01ea2154ccd959", + "objects.sys_platform_setting_audit.fields.actor_id.help": "3466d59a5734edf5", + "objects.sys_platform_setting_audit.fields.actor_id.label": "b155813f8a7f06e3", + "objects.sys_platform_setting_audit.fields.created_at.help": "c1620685c202cc45", + "objects.sys_platform_setting_audit.fields.created_at.label": "1f02d416befb595b", + "objects.sys_platform_setting_audit.fields.encrypted.help": "cdd6ae2f56711889", + "objects.sys_platform_setting_audit.fields.encrypted.label": "29b30b23533882be", + "objects.sys_platform_setting_audit.fields.id.label": "00b0385c9c152888", + "objects.sys_platform_setting_audit.fields.key.label": "46b40e4691127a79", + "objects.sys_platform_setting_audit.fields.namespace.label": "eca03ee21fc17d64", + "objects.sys_platform_setting_audit.fields.new_hash.help": "896992c166f5e17b", + "objects.sys_platform_setting_audit.fields.new_hash.label": "820041de2f4f9cce", + "objects.sys_platform_setting_audit.fields.old_hash.help": "ab9198aa8f163cf0", + "objects.sys_platform_setting_audit.fields.old_hash.label": "52a6f7d91e628dee", + "objects.sys_platform_setting_audit.fields.reason.help": "3a4c6e236fd3d467", + "objects.sys_platform_setting_audit.fields.reason.label": "ac399f03d7f2e64d", + "objects.sys_platform_setting_audit.fields.request_id.help": "3c6596e6d79d1de9", + "objects.sys_platform_setting_audit.fields.request_id.label": "1d33071b7d9648f3", + "objects.sys_platform_setting_audit.fields.scope.help": "0d13bd3ce48cf5c9", + "objects.sys_platform_setting_audit.fields.scope.label": "9abfae35f7425b9e", + "objects.sys_platform_setting_audit.fields.scope.options.global": "5e377106508d2ecd", + "objects.sys_platform_setting_audit.fields.source.help": "a0e2dc217d1e1104", + "objects.sys_platform_setting_audit.fields.source.label": "8fe786e8e29c8cec", + "objects.sys_platform_setting_audit.fields.source.options.api": "4baeeff968e192d1", + "objects.sys_platform_setting_audit.fields.source.options.import": "05c80d3a61ec3290", + "objects.sys_platform_setting_audit.fields.source.options.migration": "477a479630b97ad5", + "objects.sys_platform_setting_audit.fields.source.options.system": "d2c13a5a8c70ea8d", + "objects.sys_platform_setting_audit.fields.source.options.ui": "95caad102a5c97ba", + "objects.sys_platform_setting_audit.label": "82cfc386727fcbac", + "objects.sys_platform_setting_audit.pluralLabel": "9bbba1feac14033f", "objects.sys_scim_connection_binding.fields.id.label": "00b0385c9c152888", "objects.sys_scim_connection_credential.fields.id.label": "00b0385c9c152888", "objects.sys_scim_group.fields.id.label": "00b0385c9c152888", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 3ed89209302..d3d2e06909c 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -3446,6 +3446,54 @@ export const zhCNObjects: NonNullable = { } } }, + sys_platform_setting: { + label: "平台设置", + pluralLabel: "平台设置", + description: "整个部署共用的设置值:设置级联中的全局层。", + fields: { + id: { + label: "设置 ID" + }, + created_at: { + label: "创建时间" + }, + updated_at: { + label: "更新时间" + }, + namespace: { + label: "命名空间", + help: "Manifest 命名空间(例如 mail、storage、ai)。" + }, + key: { + label: "键", + help: "命名空间内的标识键(snake_case)。" + }, + value: { + label: "值", + help: "JSON 编码的值。加密存储时为空(见「加密值」)。" + }, + encrypted: { + label: "已加密", + help: "开启后,值加密存放在「加密值」中,「值」一列为空。" + }, + locked: { + label: "已锁定", + help: "开启后,组织和用户的设置不能覆盖该值;对这两层的写入会被拒绝(409)。" + }, + locked_reason: { + label: "锁定原因", + help: "锁定时在界面提示中显示的说明。" + }, + value_enc: { + label: "加密值", + help: "保存密文的 sys_secret 记录的句柄。仅在加密存储时有值。" + }, + updated_by: { + label: "更新人", + help: "最近一次通过设置服务写入该记录的人。" + } + } + }, sys_secret: { label: "密钥", pluralLabel: "密钥", @@ -3573,6 +3621,84 @@ export const zhCNObjects: NonNullable = { } } }, + sys_platform_setting_audit: { + label: "平台设置审计记录", + pluralLabel: "平台设置审计", + description: "设置服务对全局设置的每次变更的追加写入审计记录。", + fields: { + id: { + label: "ID" + }, + created_at: { + label: "创建时间", + help: "记录该变更的时间。" + }, + namespace: { + label: "命名空间" + }, + key: { + label: "键" + }, + scope: { + label: "范围", + help: "该记录写入的级联层;这里始终是全局层。", + options: { + global: "全局" + } + }, + action: { + label: "操作", + help: "变更类型。", + options: { + set: "设置", + reset: "重置", + lock: "锁定", + unlock: "解锁", + rotate: "轮换" + } + }, + actor_id: { + label: "执行人", + help: "执行该变更的用户;系统任务时为空。" + }, + source: { + label: "来源", + help: "变更入口。", + options: { + ui: "UI", + api: "API", + migration: "迁移", + import: "导入", + system: "系统" + } + }, + reason: { + label: "原因", + help: "执行人提供的说明(可选)。" + }, + old_hash: { + label: "旧哈希", + help: "旧值的摘要,绝不是明文:普通值为 sha256: 十六进制摘要,机密值为带密钥的 hmac-sha256: 摘要。此前未设置时为空,机密值没有可用的带密钥摘要时也为空。" + }, + new_hash: { + label: "新哈希", + help: "新值的摘要,绝不是明文:普通值为 sha256: 十六进制摘要,机密值为带密钥的 hmac-sha256: 摘要。重置时为空,机密值没有可用的带密钥摘要时也为空。" + }, + encrypted: { + label: "已加密", + help: "该设置保存的是密钥材料时为是(轮换更值得关注)。" + }, + request_id: { + label: "请求 ID", + help: "用于与审计日志和链路追踪关联。" + } + }, + _views: { + recent: { + label: "最近" + } + } + }, sys_migration: { label: "数据迁移", pluralLabel: "数据迁移", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index a953e376fb1..65033c3554a 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -37,6 +37,9 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_oauth_consent.fields.id.label": "00b0385c9c152888", "objects.sys_oauth_refresh_token.fields.id.label": "00b0385c9c152888", "objects.sys_oauth_resource.fields.id.label": "00b0385c9c152888", + "objects.sys_platform_setting_audit.fields.id.label": "00b0385c9c152888", + "objects.sys_platform_setting_audit.fields.source.options.api": "4baeeff968e192d1", + "objects.sys_platform_setting_audit.fields.source.options.ui": "95caad102a5c97ba", "objects.sys_scim_connection_binding.fields.id.label": "00b0385c9c152888", "objects.sys_scim_connection_credential.fields.id.label": "00b0385c9c152888", "objects.sys_scim_group.fields.id.label": "00b0385c9c152888", diff --git a/packages/platform-objects/src/system/platform-setting-zh-cn.test.ts b/packages/platform-objects/src/system/platform-setting-zh-cn.test.ts new file mode 100644 index 00000000000..ed997fa9585 --- /dev/null +++ b/packages/platform-objects/src/system/platform-setting-zh-cn.test.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The settings global rung's two objects read in Chinese under zh-CN. + * + * `sys_platform_setting` (the stored global value) and + * `sys_platform_setting_audit` (its trail) are the objects a global-scope + * `config_change` row on Setup's 审计日志 names, and the trail is listed on + * the same screen. Both were missing from this package's extract config, so no + * bundle carried them in any locale and the screen read + * `Platform Setting Audit Entry` in a Chinese console. Judged on what the + * platform SERVES (`SetupAppTranslations`, after the source-hash fallback), + * not on the generated file alone. + */ + +import { describe, it, expect } from 'vitest'; +import { SetupAppTranslations } from '../apps/translations/setup.translation.js'; +import { SysPlatformSetting } from './sys-platform-setting.object.js'; +import { SysPlatformSettingAudit } from './sys-platform-setting-audit.object.js'; + +const CJK = /[一-鿿]/; +/** Leaves this bundle already keeps as the bare initialism, like `sys_setting_audit`. */ +const ECHOES = new Set(['ID', 'UI', 'API']); + +type ObjectEntry = { + label?: string; + pluralLabel?: string; + fields?: Record }>; +}; + +const served = (SetupAppTranslations['zh-CN'] as { objects?: Record }).objects ?? {}; + +const chinese = (text: string | undefined) => !!text && (CJK.test(text) || ECHOES.has(text)); + +describe('zh-CN — the settings global rung on the audit screens', () => { + for (const object of [SysPlatformSetting, SysPlatformSettingAudit] as any[]) { + describe(object.name, () => { + const entry = served[object.name]; + + it('the object is named in Chinese', () => { + expect(entry?.label, `${object.name}.label`).toMatch(CJK); + expect(entry?.pluralLabel, `${object.name}.pluralLabel`).toMatch(CJK); + }); + + it('every declared field, and every option, is named in Chinese', () => { + const untranslated: string[] = []; + for (const [name, field] of Object.entries(object.fields)) { + const leaf = entry?.fields?.[name]; + if (!chinese(leaf?.label)) untranslated.push(`${name}.label = ${leaf?.label}`); + for (const option of field.options ?? []) { + const text = leaf?.options?.[option.value]; + if (!chinese(text)) untranslated.push(`${name}.options.${option.value} = ${text}`); + } + } + expect(untranslated).toEqual([]); + }); + }); + } +}); diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 75180cd62c4..1ac08c8b78d 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -89,6 +89,7 @@ import { type ActivityTextSources, } from './activity-field-redaction.js'; import type { LedgerRecordWriteAction } from './audit-log-field-redaction.js'; +import { SIGN_IN_STAMP_EVENT, isSignInStamp } from './sign-in-stamp.js'; import type { MentionGateInput } from './mention-recipient-gate.js'; // [ADR-0139 E6] The ONE derivation of the two writer-stamped columns the // withheld-row rule reads, shared with the before-insert hook and the backfill. @@ -2138,6 +2139,9 @@ export function installAuditWriters( let oldValue: Record | null = null; let newValue: Record | null = null; + // The sign-in stamp (`sign-in-stamp.ts`): judged on the diff below, and + // marked in `metadata.event` so the ledger's default view can leave it out. + let signInStamp = false; if (action === 'create') { newValue = ledgerView(ctx.object, after, { dropComputed: true, storedType: storedBodyType }); } else if (action === 'update') { @@ -2147,6 +2151,7 @@ export function installAuditWriters( const d = diff(before || {}, after || {}, getFieldDefs(ctx.object)); // If nothing meaningfully changed, skip the audit row to avoid noise. if (Object.keys(d.next).length === 0) return; + signInStamp = isSignInStamp(ctx.object, Object.keys(d.next)); oldValue = ledgerView(ctx.object, d.old, { dropComputed: false, storedType: storedBodyType }); newValue = ledgerView(ctx.object, d.next, { dropComputed: false, storedType: storedBodyType }); } else if (action === 'delete') { @@ -2189,10 +2194,12 @@ export function installAuditWriters( // `user_id` column rather than from a second carrier: rule 4 asks for both // sides on the row, and one source for one fact is what keeps them from // ever disagreeing. - if (performedByClientId) { + if (performedByClientId || signInStamp) { auditRow.metadata = safeStringify({ - performed_by: performedByClientId, - on_behalf_of: userId ?? null, + // A sign-in stamp names its event the way a `config_change` row does + // (`metadata.event`); every other CRUD row still carries no event. + ...(signInStamp ? { event: SIGN_IN_STAMP_EVENT } : {}), + ...(performedByClientId ? { performed_by: performedByClientId, on_behalf_of: userId ?? null } : {}), }); } 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 35df1040e6c..f8d3da144fb 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 @@ -1,6 +1,66 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { SystemObjectName } from '@objectstack/spec/system'; +import { + SysMigration, + SysMigrationJournal, + SysPlatformSetting, + SysPlatformSettingAudit, + SysSecret, + SysSetting, + SysSettingAudit, +} from '@objectstack/platform-objects/system'; +import { SIGN_IN_STAMP_EVENT } from '../sign-in-stamp.js'; + +/** + * The platform objects whose rows the default `recent` view leaves out: the + * rows one act writes beside the row that records it. Measured on a fresh + * showcase boot: + * + * - a settings write records `config_change`, and ALSO writes the stored + * setting (`sys_setting`, the global rung's `sys_platform_setting`, a + * secret's ciphertext in `sys_secret`) and the settings trail's own entry + * (`sys_setting_audit` / `sys_platform_setting_audit`), each mirrored here; + * - the first sign-in through the console mints the token-signing key + * (`sys_jwks`) under the signing-in person's id, beside its `login` row; + * - a boot writes the data-migration ledger (`sys_migration`, + * `sys_migration_journal`). + * + * The two settings stores are listed apart, in + * {@link AUDIT_LOG_SETTINGS_STORE_OBJECTS}: the `config_change` row itself + * names one of them as its object, so the view leaves out their other rows and + * keeps that one. + * + * The sign-in stamp on `sys_user` is the third such row, and it is left out by + * its mark instead (`sign-in-stamp.ts`): `sys_user` itself stays in, because an + * administrator's edit of a user is an act. + * + * Read from the definitions and the protocol's name constants. Deliberately not + * a name prefix and not the identity domain: `sys_*` also names objects whose + * writes are acts (a member's role, a sharing rule, a user's ban). + * `sys-audit-log-default-view.integration.test.ts` holds the view to it on a + * real engine and driver. + */ +export const AUDIT_LOG_DEFAULT_VIEW_EXCLUDED_OBJECTS: readonly string[] = Object.freeze([ + SysSecret.name, + SysSettingAudit.name, + SysPlatformSettingAudit.name, + SystemObjectName.JWKS, + SysMigration.name, + SysMigrationJournal.name, +]); + +/** + * The settings stores a `config_change` row names as its object + * (`config-change-audit.ts` in service-settings): `sys_setting` for a tenant or + * user value, `sys_platform_setting` for a global one. The default view keeps + * the `config_change` row and leaves out the store's own mirrored write. + */ +export const AUDIT_LOG_SETTINGS_STORE_OBJECTS: readonly string[] = Object.freeze([ + SysSetting.name, + SysPlatformSetting.name, +]); /** * sys_audit_log — System Audit Log Object @@ -59,6 +119,16 @@ export const SysAuditLog = ObjectSchema.create({ // `object_name` for that cell without a column (objectui#13036). A list // view never shows the type half, which would draw the stored API name as // text (`sys-audit-log-pointer-pair.test.ts`). + // + // `recent` is the FIRST declared view, so it is the list Setup's 审计日志 + // opens with no view named. It keeps every act and leaves out the rows an act + // writes beside it (`AUDIT_LOG_DEFAULT_VIEW_EXCLUDED_OBJECTS`, the settings + // stores' rows other than `config_change`, and the sign-in stamp by its + // `metadata.event`). `metadata` is not a value-bearing column + // (`parent-field-query-guard.ts` names only `old_value` / `new_value`), so + // every reader may run the filter; the `or` keeps the rows with no metadata, + // which a bare `not_contains` would drop. Nothing changes in what is written: + // `all_events` lists every row. listViews: { recent: { type: 'grid', @@ -66,6 +136,21 @@ export const SysAuditLog = ObjectSchema.create({ label: 'Recent', data: { provider: 'object', object: 'sys_audit_log' }, columns: ['created_at', 'action', 'record_id', 'user_id'], + filter: [ + { field: 'object_name', operator: 'not_in', value: [...AUDIT_LOG_DEFAULT_VIEW_EXCLUDED_OBJECTS] }, + { + or: [ + { field: 'object_name', operator: 'not_in', value: [...AUDIT_LOG_SETTINGS_STORE_OBJECTS] }, + { field: 'action', operator: 'equals', value: 'config_change' }, + ], + }, + { + or: [ + { field: 'metadata', operator: 'is_null' }, + { field: 'metadata', operator: 'not_contains', value: SIGN_IN_STAMP_EVENT }, + ], + }, + ], sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 }, emptyState: { title: 'No audit events', message: 'Activity will appear here as users interact with the platform.' }, diff --git a/packages/plugins/plugin-audit/src/sign-in-stamp.ts b/packages/plugins/plugin-audit/src/sign-in-stamp.ts new file mode 100644 index 00000000000..9d3b1767b4a --- /dev/null +++ b/packages/plugins/plugin-audit/src/sign-in-stamp.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The sign-in stamp on the compliance ledger: which `sys_audit_log` row it is, + * and the mark the ledger writer leaves on it. + * + * Every successful sign-in writes two ledger rows. The auth-event sink writes + * the `login` row on `sys_session` (actor, client address, endpoint), and + * plugin-auth's `stampLastLogin` updates `last_login_at` / `last_login_ip` on + * the person's `sys_user` row, which the CRUD writer mirrors as an `update` + * under that person's own id (ADR-0069 D7; attributed rather than excluded, so + * the address trail stays on the ledger). The second row restates the first. + * + * Nothing on the row told it apart from an administrator's edit of the same + * user: both are `update` on `sys_user`, and the only columns that differ are + * the before/after snapshots, which a list filter may not name for every + * reader (`parent-field-query-guard.ts`). So the writer marks the stamp in + * `metadata.event`, the column `config_change` rows already carry their event + * name in (`settings.set`), and the ledger's default view leaves marked rows + * out (`sys-audit-log.object.ts`, `recent`). The row is still written, and the + * `all_events` view still lists it. + * + * The test is the CHANGED fields, never the write's context: an update of + * `sys_user` whose meaningful changes are the stamp's fields and nothing else. + * Any other change on the same write (a rename, a ban) makes it an act, and it + * carries no mark. + */ + +/** The `metadata.event` the ledger writer stamps on a sign-in stamp row. */ +export const SIGN_IN_STAMP_EVENT = 'auth.last_login_stamp'; + +/** The object the sign-in stamp updates. */ +const SIGN_IN_STAMP_OBJECT = 'sys_user'; + +/** The fields the sign-in stamp writes (plugin-auth `stampLastLogin`). */ +const SIGN_IN_STAMP_FIELDS: ReadonlySet = new Set(['last_login_at', 'last_login_ip']); + +/** + * Whether an `update` row is the sign-in stamp: the object is `sys_user` and + * every changed field (the writer's diff, noise fields already dropped) is one + * of the stamp's. No changed field answers `false`. + */ +export function isSignInStamp(object: string, changedFields: readonly string[]): boolean { + return ( + object === SIGN_IN_STAMP_OBJECT + && changedFields.length > 0 + && changedFields.every((field) => SIGN_IN_STAMP_FIELDS.has(field)) + ); +} diff --git a/packages/plugins/plugin-audit/src/sys-audit-log-default-view.integration.test.ts b/packages/plugins/plugin-audit/src/sys-audit-log-default-view.integration.test.ts new file mode 100644 index 00000000000..d2aa0b9cd44 --- /dev/null +++ b/packages/plugins/plugin-audit/src/sys-audit-log-default-view.integration.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_audit_log`'s default list: what an administrator sees when Setup's + * 审计日志 opens the bare object route. + * + * The door names no view, so the Console opens the FIRST declared list view. + * Measured on a fresh showcase boot, one act writes several rows, and all but + * one of them are the platform's own machinery: + * + * - a sign-in writes `login` on `sys_session` AND an `update` of `sys_user` + * that only stamps `last_login_at` / `last_login_ip`, under the signing-in + * person's own id; the first sign-in through the console also mints the + * token-signing key (`create` on `sys_jwks`); + * - a settings write writes `config_change` AND the stored setting + * (`sys_setting`, `sys_platform_setting`, a secret's `sys_secret`) AND the + * settings trail's own entry (`sys_setting_audit` / `sys_platform_setting_audit`); + * - a boot writes the migration ledger (`sys_migration`, `sys_migration_journal`). + * + * The default view (`recent`) keeps the act and leaves the machinery out. The + * rows are still written and still readable: `all_events` lists every one. + * + * ## Why a real engine, a real driver and the real writer + * + * The view is a filter the Console sends to the server, so whether it selects + * the right rows is a question about the Filter Protocol as a SQL driver runs + * it (`$nin`, `$null`, `$notContains` under an `or`). The rules are lowered the + * way the Console lowers a view filter (one node per element under `and`, an + * any-of group as one `or` node of its rules) and parsed by the server's own + * `parseFilterAST`. The two `sys_user` updates are written through the real + * audit writer, so "the sign-in stamp is told apart from an administrator's + * edit" is the writer's behaviour, not the fixture's. + */ + +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 { parseFilterAST } from '@objectstack/spec/data'; +import { SystemObjectName } from '@objectstack/spec/system'; +import { + SysMigration, + SysMigrationJournal, + SysPlatformSetting, + SysPlatformSettingAudit, + SysSecret, + SysSetting, + SysSettingAudit, +} from '@objectstack/platform-objects/system'; +import { SysUser } from '@objectstack/platform-objects/identity'; + +import { AuditPlugin } from './audit-plugin.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; +import { SysAuditLog } from './objects/index.js'; + +const LEDGER = 'sys_audit_log'; +const SYS = { isSystem: true } as const; +const ADMIN = { userId: 'u_admin', tenantId: 'org_default', positions: ['org_admin'] }; +/** The context `stampLastLogin` (plugin-auth) writes the sign-in stamp under. */ +const SIGN_IN_STAMP_CTX = { isSystem: true, attributedUserId: 'u_ada', positions: [], permissions: [] }; + +type Row = Record; +type ViewRule = { field: string; operator: string; value?: unknown }; +type ViewElement = ViewRule | { or: ViewRule[] }; +type ListView = { filter?: ViewElement[]; sort?: Array<{ field: string; order: 'asc' | 'desc' }> }; + +const views = (SysAuditLog as unknown as { listViews: Record }).listViews; + +/** The Console's lowering of a view filter: one node per element under `and`; a group is one `or` node. */ +function whereOf(view: ListView): Record | undefined { + const elements = view.filter ?? []; + if (elements.length === 0) return undefined; + const node = (r: ViewRule) => [r.field, r.operator, r.value]; + return parseFilterAST([ + 'and', + ...elements.map((e) => ('or' in e ? ['or', ...e.or.map(node)] : node(e))), + ] as any) as Record; +} + +/** The machinery objects, read from their definitions. */ +const MACHINERY_OBJECTS = [ + SysSetting.name, + SysPlatformSetting.name, + SysSecret.name, + SysSettingAudit.name, + SysPlatformSettingAudit.name, + SystemObjectName.JWKS, + SysMigration.name, + SysMigrationJournal.name, +]; + +describe('sys_audit_log default list view — the rows it serves', () => { + let kernel: ObjectKernel; + let engine: ObjectQL; + + const ledger = async (view: ListView): Promise => + (await engine.find(LEDGER, { where: whereOf(view), orderBy: view.sort, context: SYS })) as Row[]; + const userUpdates = async (): Promise => + (await engine.find(LEDGER, { + where: { object_name: 'sys_user', action: 'update' }, + orderBy: [{ field: 'created_at', order: 'asc' }], + context: SYS, + })) as Row[]; + + 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(SysUser as any, 'com.objectstack.audit.test.audit-default-view'); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + + await engine.insert('sys_user', { id: 'u_ada', name: 'Ada', email: 'ada@example.com' }, { context: SYS }); + // The sign-in stamp, exactly as plugin-auth writes it. + await engine.update( + 'sys_user', + { id: 'u_ada', last_login_at: new Date('2026-11-05T16:05:27Z'), last_login_ip: '127.0.0.1' }, + { context: SIGN_IN_STAMP_CTX } as any, + ); + // An administrator's edit of the same user: an act. + await engine.update('sys_user', { id: 'u_ada', name: 'Ada Lovelace' }, { context: ADMIN } as any); + // A write under the same envelope that also renames is not the stamp alone: + // the mark is read off the changed fields, never off the context. + await engine.update( + 'sys_user', + { id: 'u_ada', name: 'Ada L.', last_login_at: new Date('2026-11-05T16:06:00Z') }, + { context: SIGN_IN_STAMP_CTX } as any, + ); + + // The rows the other writers leave, in the shapes measured on the fresh boot. + const at = (second: number) => new Date(Date.UTC(2026, 10, 5, 16, 7, second)).toISOString(); + const rows: Row[] = [ + { action: 'login', object_name: 'sys_session', record_id: 's1', user_id: 'u_ada', created_at: at(1), + metadata: JSON.stringify({ endpoint: '/sign-in/email' }) }, + { action: 'config_change', object_name: SysSetting.name, record_id: null, user_id: 'u_admin', created_at: at(2), + metadata: JSON.stringify({ event: 'settings.set', namespace: 'branding', key: 'workspace_name', scope: 'tenant' }) }, + { action: 'config_change', object_name: SysPlatformSetting.name, record_id: null, user_id: 'u_admin', created_at: at(3), + metadata: JSON.stringify({ event: 'settings.set', namespace: 'mail', key: 'smtp_password', scope: 'global', encrypted: true }) }, + ...MACHINERY_OBJECTS.map((object, i) => ({ + action: 'create', object_name: object, record_id: `m${i}`, user_id: null, created_at: at(10 + i), + })), + ]; + for (const row of rows) await engine.insert(LEDGER, row, { context: SYS }); + }); + + afterAll(async () => { + await kernel?.shutdown?.(); + }); + + it('the first declared view is `recent`, the one Setup opens with no view named', () => { + expect(Object.keys(views)[0]).toBe('recent'); + }); + + it('the writer marks the sign-in stamp, and only the stamp', async () => { + const [stamp, rename, renameAndStamp] = await userUpdates(); + expect(JSON.parse(stamp.new_value)).toEqual({ last_login_at: expect.any(String), last_login_ip: '127.0.0.1' }); + expect(JSON.parse(stamp.metadata ?? 'null')).toMatchObject({ event: 'auth.last_login_stamp' }); + // Controls: an administrator's edit carries no mark, nor does an edit that also stamps. + expect(JSON.parse(rename.new_value)).toEqual({ name: 'Ada Lovelace' }); + expect(rename.metadata ?? null).toBeNull(); + expect(Object.keys(JSON.parse(renameAndStamp.new_value)).sort()).toEqual(['last_login_at', 'name']); + expect(renameAndStamp.metadata ?? null).toBeNull(); + }); + + it('`recent` keeps the acts and leaves out the sign-in stamp and the machinery rows', async () => { + const recent = await ledger(views.recent); + const shape = recent.map((r) => `${r.action} ${r.object_name}`); + expect(shape).toContain('login sys_session'); + expect(shape).toContain(`config_change ${SysSetting.name}`); + // The administrator's two edits of the user are in; the stamp is not. + const users = recent.filter((r) => r.object_name === 'sys_user' && r.action === 'update'); + expect(users.map((r) => Object.keys(JSON.parse(r.new_value)).sort())).toEqual( + expect.arrayContaining([['name'], ['last_login_at', 'name']]), + ); + expect(users).toHaveLength(2); + // A settings store's own write is out; the `config_change` row naming that store is in. + expect(shape).toContain(`config_change ${SysPlatformSetting.name}`); + for (const row of recent.filter((r) => r.action !== 'config_change')) { + expect(MACHINERY_OBJECTS).not.toContain(row.object_name); + } + }); + + it('`recent` reads newest first', async () => { + const stamps = (await ledger(views.recent)).map((r) => Date.parse(r.created_at)); + expect(stamps).toEqual([...stamps].sort((a, b) => b - a)); + }); + + it('`all_events` still lists every row `recent` leaves out', async () => { + expect(views.all_events.filter).toBeUndefined(); + const all = await ledger(views.all_events); + for (const object of MACHINERY_OBJECTS) expect(all.map((r) => r.object_name)).toContain(object); + expect(all.filter((r) => r.object_name === 'sys_user' && r.action === 'update')).toHaveLength(3); + }); +}); diff --git a/packages/plugins/plugin-audit/src/translations/comment-reaction-zh-cn.test.ts b/packages/plugins/plugin-audit/src/translations/comment-reaction-zh-cn.test.ts new file mode 100644 index 00000000000..8b7c1050be6 --- /dev/null +++ b/packages/plugins/plugin-audit/src/translations/comment-reaction-zh-cn.test.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_comment_reaction` reads in Chinese under zh-CN. + * + * It is one of this package's four objects and the only one whose zh-CN bundle + * still held the English extract fill, so its rows on Setup's 审计日志 (a + * reaction added or removed) named it `Comment Reaction`. Judged on what the + * plugin SERVES (`AuditTranslations`, after the source-hash fallback). + */ + +import { describe, it, expect } from 'vitest'; +import { AuditTranslations } from './index.js'; +import { SysCommentReaction } from '../objects/sys-comment-reaction.object.js'; + +const CJK = /[一-鿿]/; + +type ObjectEntry = { label?: string; pluralLabel?: string; description?: string; fields?: Record }; + +const entry = ((AuditTranslations['zh-CN'] as { objects?: Record }).objects ?? {})[SysCommentReaction.name]; + +describe('zh-CN — sys_comment_reaction', () => { + it('the object is named and described in Chinese', () => { + expect(entry?.label).toMatch(CJK); + expect(entry?.pluralLabel).toMatch(CJK); + expect(entry?.description).toMatch(CJK); + }); + + it('every declared field label, and every help, is in Chinese', () => { + const untranslated: string[] = []; + for (const [name, field] of Object.entries((SysCommentReaction as any).fields)) { + const leaf = entry?.fields?.[name]; + if (!CJK.test(leaf?.label ?? '')) untranslated.push(`${name}.label = ${leaf?.label}`); + if (field.description && !CJK.test(leaf?.help ?? '')) untranslated.push(`${name}.help = ${leaf?.help}`); + } + expect(untranslated).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts index 7908b7faf63..4882979e304 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts @@ -263,27 +263,27 @@ export const zhCNObjects: NonNullable = { } }, sys_comment_reaction: { - label: "Comment Reaction", - pluralLabel: "Comment Reactions", - description: "An emoji reaction to a comment, owned by the user who reacted", + label: "评论表情回应", + pluralLabel: "评论表情回应", + description: "对一条评论的表情回应,归回应的用户所有", fields: { id: { - label: "Reaction ID" + label: "回应 ID" }, comment_id: { - label: "Comment", - help: "The id of the sys_comment this reaction is on. The reaction is readable exactly when that comment is." + label: "评论", + help: "该回应所在的评论。评论可读时,回应才可读。" }, emoji: { - label: "Emoji", - help: "The emoji, as the character sequence the client renders (for example 👍)" + label: "表情", + help: "表情本身,即客户端显示的字符(例如 👍)" }, user_id: { - label: "User", - help: "The user who reacted. Stamped from the session on create; a client-supplied value is replaced." + label: "用户", + help: "做出回应的用户。创建时取自当前会话;客户端传入的值会被替换。" }, created_at: { - label: "Created At" + label: "创建时间" } } } diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.source-hashes.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.source-hashes.generated.ts index 8c4bfa089f4..def45595778 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.source-hashes.generated.ts @@ -20,15 +20,4 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_activity.fields.type.help": "5d8df777db464241", "objects.sys_activity.fields.url.label": "2203b0b9f72534f7", - "objects.sys_comment_reaction.description": "d0fe7e44049573c2", - "objects.sys_comment_reaction.fields.comment_id.help": "054975bf4ebc079c", - "objects.sys_comment_reaction.fields.comment_id.label": "98e6afea98d2698b", - "objects.sys_comment_reaction.fields.created_at.label": "1f02d416befb595b", - "objects.sys_comment_reaction.fields.emoji.help": "3db9cdfe983791c3", - "objects.sys_comment_reaction.fields.emoji.label": "f281bddefc11042a", - "objects.sys_comment_reaction.fields.id.label": "effa90a604eeb22e", - "objects.sys_comment_reaction.fields.user_id.help": "dfde9740ea61d31b", - "objects.sys_comment_reaction.fields.user_id.label": "6c0d22a931e235bd", - "objects.sys_comment_reaction.label": "8139110e90c79f0a", - "objects.sys_comment_reaction.pluralLabel": "d95da4d8f4a703b4", };