From 411fc429a0703dcf5436769e2076e70250c6325f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 11:00:02 +0000 Subject: [PATCH 1/5] wip(#14787): sys_user.locale becomes user-writable (ruling B) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .../platform-objects/src/identity/index.ts | 2 +- .../src/identity/sys-user.object.ts | 96 ++++++++- .../plugins/plugin-auth/src/auth-plugin.ts | 9 +- .../src/identity-write-guard.test.ts | 107 ++++++++- .../plugin-auth/src/identity-write-guard.ts | 55 ++++- .../src/managed-extension-fields.test.ts | 43 +++- .../src/managed-extension-fields.ts | 23 +- .../sys-user-locale-write-contract.test.ts | 204 ++++++++++++++++++ .../src/sys-user-writable-fields.ts | 34 ++- .../src/recipient-locale-shape-parity.test.ts | 89 ++++++++ .../service-messaging/src/recipient-locale.ts | 31 ++- 11 files changed, 663 insertions(+), 30 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/sys-user-locale-write-contract.test.ts create mode 100644 packages/services/service-messaging/src/recipient-locale-shape-parity.test.ts diff --git a/packages/platform-objects/src/identity/index.ts b/packages/platform-objects/src/identity/index.ts index d83b789196..5a4c95902b 100644 --- a/packages/platform-objects/src/identity/index.ts +++ b/packages/platform-objects/src/identity/index.ts @@ -7,7 +7,7 @@ */ // ── Core Auth Objects ────────────────────────────────────────────────────── -export { SysUser } from './sys-user.object.js'; +export { SysUser, SYS_USER_LOCALE_TAG_PATTERN } from './sys-user.object.js'; export { SysSession } from './sys-session.object.js'; export { SysAccount } from './sys-account.object.js'; export { SysVerification } from './sys-verification.object.js'; diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 5b3b001ea4..775b0aae6f 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -2,6 +2,35 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; +/** + * The BCP-47 shape `sys_user.locale` accepts — a 2–8 letter language subtag + * followed by any number of 1–8 alphanumeric subtags (`zh`, `zh-CN`, + * `zh-Hans-CN`, `es-419`). Shape only: membership in a shipped template bundle + * is the bundle's business, and the delivery-time ladders handle a + * shipped-nowhere tag by falling to their floor. + * + * A JS regex SOURCE string, because that is what a `format` validation rule + * carries (`new RegExp(rule.regex)` in objectql's rule validator) — anchored at + * both ends on purpose, since the rule compiles it unanchored. + * + * ## Why this is exported rather than inlined + * + * There are two readers of this shape and they live in different packages: the + * WRITE side is the `locale_bcp47_shape` rule below, and the READ side is + * `LOCALE_TAG_SHAPE` in `@objectstack/service-messaging`'s + * `recipient-locale.ts`, which refuses anything non-tag-shaped before it + * reaches a template lookup. The two must not drift — a write path that + * accepts what the read path discards would store values that silently fall + * back to the deployment default forever. service-messaging keeps its own + * compiled copy rather than importing this one (that module is documented as + * pure and total, and pulling the identity barrel into it for a regex would + * make a per-recipient normalizer pay a package barrel's load), so the + * agreement is held by a PIN instead of by a shared binding: + * `recipient-locale-shape-parity.test.ts` in service-messaging asserts the two + * spellings are byte-identical and names what breaks if they are not. + */ +export const SYS_USER_LOCALE_TAG_PATTERN = '^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$'; + /** * sys_user — System User Object * @@ -23,7 +52,8 @@ export const SysUser = ObjectSchema.create({ // ADR-0092 D4 — the ONE generic affordance opened on an identity table: // standard row editing. Safe because the plugin-auth identity write guard // (ADR-0092 D2) enforces the profile whitelist server-side — a user-context - // update may only touch SYS_USER_PROFILE_EDIT_FIELDS ({name, image}); + // update may only touch SYS_USER_PROFILE_EDIT_FIELDS ({name, image, locale} + // since the 2026-09-03 ruling; see the `locale` field below); // everything else is stripped/rejected regardless of what a form submits. // The permission layer still decides WHO may edit (platform admins only by // default; member/org-admin sets keep allowEdit: false). create / import / @@ -753,16 +783,35 @@ export const SysUser = ObjectSchema.create({ // MANAGED_EXTENSION_FIELDS, whose ADR-0105 D7 guard proves the name does // not collide with better-auth's own user schema at the pinned version. // - // `readonly` for the same reason as every non-whitelisted field above - // (ADR-0092 D4): the identity write guard's self-service whitelist is - // {name, image}, this column is not on it, so a form edit would be - // stripped server-side; rendering it editable would advertise a write the - // runtime refuses. Widening that whitelist is a security-boundary decision - // recorded as an open question on #13881, not made here. + // WRITABLE, and `readonly` is deliberately absent (maintainer ruling + // 2026-09-03, option B — quoted verbatim and untranslated on the ruling + // card): 「同意」to widening the ADR-0092 D2 self-service whitelist from + // {name, image} to {name, image, locale}. The column landed `readonly` + // three weeks earlier because the whitelist did NOT carry it and a + // readonly-but-not-whitelisted column would have advertised a write the + // runtime strips; the ruling moved the whitelist, so the `readonly` that + // mirrored it goes with it. Option A (system-context writes only) was + // considered and rejected: it would make every application build its own + // stamping route for a first-class user attribute. + // + // ⚠️ `readonly` here is a UI/strip affordance, never the boundary — the + // boundary is plugin-auth's identity write guard (ADR-0092 D2), whose + // whitelist is `SYS_USER_PROFILE_EDIT_FIELDS`. Removing `readonly` + // without that entry would change nothing; adding that entry without + // removing `readonly` would strip the value before the guard ever saw it + // (`stripReadonlyFields` runs on the update path). The two move together + // or not at all, and `sys-user-locale-write-contract.test.ts` in + // plugin-auth is what says so out loud. + // + // A malformed tag is REFUSED, not stored and not silently dropped: the + // `locale_bcp47_shape` rule in `validations` below is evaluated + // server-side on insert, by-id update and bulk update, so the only value + // that can reach the column is one the delivery-time reader + // (`service-messaging/src/recipient-locale.ts`) recognises. An unset or + // cleared column keeps falling back to the deployment default. locale: Field.text({ label: 'Locale', required: false, - readonly: true, maxLength: 35, group: 'Profile', description: @@ -850,7 +899,7 @@ export const SysUser = ObjectSchema.create({ // (ADR-0092 D2) and owned by better-auth (Invite / Create User / admin // actions), so they are not exposed. `update` stays: it is the ONE // generic write opened on an identity table (ADR-0092 D4), server-side - // clamped to the profile-field whitelist ({name, image}) by the guard — + // clamped to the profile-field whitelist ({name, image, locale}) by the guard — // `userActions.edit: true` above declares the affordance. `bulk` grants the // updateMany surface (bulk ∧ update after #3391); paired with the sole // `update` write, only bulk-update is admitted (createMany/deleteMany still @@ -862,4 +911,33 @@ export const SysUser = ObjectSchema.create({ // managed user table). A declarative `unique` validation rule is intentionally // not used — uniqueness needs a DB lookup, not a synchronous validation, so it // is not one of the declarable validation-rule types. + // + // What IS declarable — and is the whole reason this array exists — is the + // shape of a column a user may now set for themselves. + validations: [ + { + // The loud half of the 2026-09-03 ruling that made `locale` writable: + // "a malformed value is refused loudly … and never dead-letters a + // notification". Enforcement, not decoration — objectql's rule validator + // runs `validations` on insert, by-id update AND bulk update, so there is + // no write shape that reaches the column without passing here. + // + // Why a `format` rule and not a field-level flag: field-level `readonly` + // is a strip, `maxLength` bounds length only, and the field schema's + // `format` key is authoring metadata no write path reads. The object's + // `validations` array is the platform's one server-enforced channel for + // "this column's values must look like X" (ADR-0049 declared = enforced). + // + // An absent, null or empty value is NOT a violation (`checkFormat` + // returns early) — clearing the column is how a user goes back to the + // deployment default, which the ruling preserves as the fallback for an + // unset column. + type: 'format', + name: 'locale_bcp47_shape', + field: 'locale', + regex: SYS_USER_LOCALE_TAG_PATTERN, + severity: 'error', + message: 'Locale must be a BCP-47 language tag, such as zh-CN or ja-JP.', + }, + ], }); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 6e96f028e1..6d1f194c99 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1322,7 +1322,14 @@ export class AuthPlugin implements Plugin { // fields stay rejected. `managed-extension-fields.test.ts` proves the two // populations are disjoint at the pinned better-auth version. for (const [object, fields] of Object.entries(MANAGED_EXTENSION_EDITABLE_FIELDS)) { - if (object === SystemObjectName.USER) continue; // sys_user tiering above + // `sys_user` is registered ABOVE, from the tiered constant, and is + // skipped here rather than merged: this map has one tier and that + // table has two (form vs. admin bulk import). Its entry there is a + // declaration that must stay a SUBSET of what was registered above — + // pinned in `managed-extension-fields.test.ts`, because a name that + // reaches this map but never the whitelist is a write the platform + // advertises and the guard refuses (ADR-0049). + if (object === SystemObjectName.USER) continue; registerManagedUpdateWhitelist(object, fields); } // [#8317] Canonicalise `sys_member.role` on every ObjectQL write, at diff --git a/packages/plugins/plugin-auth/src/identity-write-guard.test.ts b/packages/plugins/plugin-auth/src/identity-write-guard.test.ts index 8114f4924d..d79487fc2f 100644 --- a/packages/plugins/plugin-auth/src/identity-write-guard.test.ts +++ b/packages/plugins/plugin-auth/src/identity-write-guard.test.ts @@ -129,7 +129,7 @@ describe('identity write guard — update whitelist (ADR-0092 D2)', () => { // The error names what IS editable so the caller can fix the payload. await expect( guardOn(engine, 'beforeUpdate')({ object: 'sys_user', session: USER_SESSION, input: { id: 'u1', data: { email: 'e@x' } } }), - ).rejects.toThrow(/Editable fields: name, image/); + ).rejects.toThrow(/Editable fields: name, image, locale/); }); it('passes engine-stamped lifecycle columns through, but they never satisfy the whitelist alone', async () => { @@ -178,9 +178,68 @@ describe('identity write guard — update whitelist (ADR-0092 D2)', () => { }); it('exposes the registered whitelist for introspection', () => { - expect(getManagedUpdateWhitelist('sys_user')).toEqual(new Set(['name', 'image'])); + // FLIPPED, not deleted (maintainer ruling 2026-09-03, option B — adopted + // 「同意」): this pin recorded `{name, image}` from ADR-0092 until the + // ruling grew the identity table's user-writable set to three fields. The + // old reading was a real decision that a later decision overturned, so it + // is reversed here with the reversal named rather than removed. + expect(getManagedUpdateWhitelist('sys_user')).toEqual(new Set(['name', 'image', 'locale'])); expect(getManagedUpdateWhitelist('sys_session')).toBeUndefined(); }); + + // ── The security boundary the widening moved, pinned from both sides ── + // + // The first pin says the ruling shipped. The SECOND is the one that catches + // a widening that widened too far, and it is the reason this block exists: + // "the whitelist grew" and "the whitelist grew by exactly one name" are + // different claims, and only the second is what was ruled. + + it('lets a user set their own locale — the write the 2026-09-03 ruling opened', async () => { + const data: any = { id: 'u1', locale: 'zh-CN' }; + await guardOn(engine, 'beforeUpdate')({ + object: 'sys_user', + session: USER_SESSION, + input: { id: 'u1', data }, + }); + // Survived the guard untouched — no strip, no throw. + expect(data).toEqual({ id: 'u1', locale: 'zh-CN' }); + }); + + it('still refuses EVERY other sys_user column — the widening is one name wide', async () => { + // One entry per ADR-0092 D1 tier-2/tier-3 family, so a whitelist that + // widened past `locale` fails here rather than in production: authorization + // state, the sign-in identifier, the credential stamps, the org-structure + // projections, the AI seat, and the identity provenance. + const forbiddenColumns = [ + 'role', 'banned', 'ban_reason', 'ban_expires', + 'email', 'email_verified', 'phone_number', + 'must_change_password', 'password_changed_at', + 'manager_id', 'primary_business_unit_id', + 'ai_access', 'source', 'two_factor_enabled', + ]; + for (const column of forbiddenColumns) { + // Alone: refused loudly, never a silent no-op. + await expect( + guardOn(engine, 'beforeUpdate')({ + object: 'sys_user', + session: USER_SESSION, + input: { id: 'u1', data: { [column]: 'x' } }, + }), + `${column} must not be user-writable`, + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + // Smuggled beside the one column that IS writable: stripped in place, + // and the legitimate half still commits. This is the shape a form + // round-trip actually produces, and the one a "the payload was accepted" + // check would miss. + const smuggled: any = { id: 'u1', locale: 'ja-JP', [column]: 'x' }; + await guardOn(engine, 'beforeUpdate')({ + object: 'sys_user', + session: USER_SESSION, + input: { id: 'u1', data: smuggled }, + }); + expect(smuggled, `${column} must be stripped, not committed`).toEqual({ id: 'u1', locale: 'ja-JP' }); + } + }); }); describe('identity write guard — session snapshot refresh (ADR-0092 D6)', () => { @@ -242,6 +301,35 @@ describe('identity write guard — session snapshot refresh (ADR-0092 D6)', () = expect(storage.delete).not.toHaveBeenCalled(); }); + it('does NOT mirror `locale` into the snapshot — better-auth has no such user field', async () => { + // The whitelist grew on 2026-09-03; this mirror deliberately did not. + // better-auth neither reads nor writes `locale` (not on its user model, not + // an `additionalFields` entry), so there is no cached copy to keep + // coherent — and writing one would invent a `user.locale` that exists only + // on sessions that happen to be cached, only after a profile edit. That is + // the opposite of what D6 is for. + const storage = makeStorage('u1', ['tok-a']); + const engine = engineWithStorage(storage); + await guardOn(engine, 'afterUpdate')({ + object: 'sys_user', + session: USER_SESSION, + input: { id: 'u1', data: { locale: 'zh-CN' } }, + }); + expect(storage.set).not.toHaveBeenCalled(); + expect(JSON.parse(storage.store.get('tok-a')!).user).not.toHaveProperty('locale'); + + // …and a locale change riding along with a mirrored one refreshes ONLY the + // mirrored half, rather than dragging `locale` in behind it. + await guardOn(engine, 'afterUpdate')({ + object: 'sys_user', + session: USER_SESSION, + input: { id: 'u1', data: { name: 'New Name', locale: 'ja-JP' } }, + }); + const entry = JSON.parse(storage.store.get('tok-a')!); + expect(entry.user).toMatchObject({ id: 'u1', name: 'New Name' }); + expect(entry.user).not.toHaveProperty('locale'); + }); + it('no-ops without secondary storage, without a whitelisted change, or for system writes', async () => { const storage = makeStorage('u1', ['tok-a']); // System write — better-auth's own paths already refresh. @@ -300,4 +388,19 @@ describe('sys-user writable-field tiers (ADR-0092 D3)', () => { expect(SYS_USER_PROFILE_EDIT_FIELDS.has('role')).toBe(false); expect(SYS_USER_PROFILE_EDIT_FIELDS.has('email')).toBe(false); }); + + it('the profile tier is exactly {name, image, locale} (2026-09-03 ruling)', () => { + // The set literal, pinned as a whole rather than by membership probes: a + // `has()` pin per name cannot see a FOURTH name arriving, which is the + // direction a security-boundary widening drifts. The old two-name reading + // is not deleted — it is this assertion, reversed by the ruling that + // reversed the decision. + expect([...SYS_USER_PROFILE_EDIT_FIELDS].sort()).toEqual(['image', 'locale', 'name']); + // Import inherits the widening by construction (a spread, not a second + // list) and adds its own two — so this stays a strict superset of exactly + // five, and a hand-edit that de-linked the two lists shows up here. + expect([...SYS_USER_IMPORT_UPDATE_FIELDS].sort()).toEqual([ + 'image', 'locale', 'name', 'phone_number', 'role', + ]); + }); }); diff --git a/packages/plugins/plugin-auth/src/identity-write-guard.ts b/packages/plugins/plugin-auth/src/identity-write-guard.ts index 3ee538ab29..99252fbd80 100644 --- a/packages/plugins/plugin-auth/src/identity-write-guard.ts +++ b/packages/plugins/plugin-auth/src/identity-write-guard.ts @@ -23,8 +23,9 @@ * The only opening is a per-object UPDATE whitelist * ({@link registerManagedUpdateWhitelist}); non-whitelisted keys are * stripped, and a payload that strips to nothing throws — a loud failure, - * not a silent no-op. First (and currently only) registration: - * `sys_user → SYS_USER_PROFILE_EDIT_FIELDS` (name, image). + * not a silent no-op. First registration: + * `sys_user → SYS_USER_PROFILE_EDIT_FIELDS` (name, image, locale — `locale` + * added by the maintainer ruling of 2026-09-03). * * Rejections use `code: 'PERMISSION_DENIED'` + `status: 403`, which the REST * layer's `mapDataError` / `sendError` already translate — same pattern as @@ -120,6 +121,37 @@ const DEDICATED_SURFACE_HINT = */ const LIFECYCLE_PASSTHROUGH = new Set(['updated_at', 'updated_by']); +/** + * [ADR-0092 D6] The `sys_user` columns whose values better-auth ALSO keeps in + * its cached `{session, user}` snapshots — and therefore the only ones the + * companion `afterUpdate` hook merges into those snapshots after a guarded + * edit. + * + * This used to be "whatever the update whitelist admits", which was true only + * while the whitelist and better-auth's user model happened to coincide + * (`name → name`, `image → image`). The 2026-09-03 ruling separated them: it + * added `locale`, a column better-auth is DELIBERATELY oblivious to — not one + * of its own fields and not an `additionalFields` entry, because declaring it + * there would make `getSession` SELECT a column an environment that has not + * run schema-sync does not have (the `ai_access` note in `auth-manager.ts`). + * + * So `locale` is excluded, and the exclusion is the CORRECT behaviour rather + * than a deferral. D6 exists to stop a cached snapshot going stale against the + * row; a column better-auth never reads into the snapshot has no stale copy to + * repair. Merging it anyway would do the opposite of D6's job — it would + * MANUFACTURE an incoherence, a `user.locale` key present on sessions that + * happen to be cached and absent on sessions that are not, appearing only + * after a profile edit and differing per session between two callers of the + * same endpoint. + * + * ⚠️ Widening the update whitelist does not widen this set. Add a column here + * only when better-auth actually carries it on its user model (its own field, + * or a declared `additionalFields` entry), and then only under the name + * better-auth uses — a snake_case ObjectStack column whose better-auth + * spelling is camelCase needs a translation, not an entry. + */ +const SESSION_SNAPSHOT_MIRRORED_FIELDS: ReadonlySet = new Set(['name', 'image']); + /** * Register the identity write guard on an ObjectQL engine. Idempotent per * package: callers re-binding after hot reload should first run @@ -204,10 +236,11 @@ export function registerIdentityWriteGuard(engine: any, opts: IdentityWriteGuard // storage, plus an `active-sessions-${userId}` index. Its OWN update paths // re-write those snapshots (internal-adapter `refreshUserSessions`); a // guarded engine write bypasses that, so we mirror it here for the fields - // the guard let through. sys_user Tier-1 columns map 1:1 onto better-auth - // user-model field names (name → name, image → image); anything that would - // need a snake_case → camelCase translation is not whitelisted today, and - // widening the whitelist must extend this mapping deliberately. + // better-auth actually caches — `SESSION_SNAPSHOT_MIRRORED_FIELDS` above, + // NOT the update whitelist. The two were the same set (name → name, + // image → image) until the 2026-09-03 ruling admitted `locale`, a column + // better-auth does not carry on its user model at all; see that constant for + // why mirroring it would manufacture an incoherence rather than repair one. const refreshSessionSnapshots = async (ctx: any) => { try { if (ctx.object !== 'sys_user' || !isUserContextWrite(ctx.session)) return; @@ -218,7 +251,15 @@ export function registerIdentityWriteGuard(engine: any, opts: IdentityWriteGuard const whitelist = updateWhitelists.get('sys_user'); const changed: Record = {}; for (const key of Object.keys(data)) { - if (key !== 'id' && whitelist?.has(key)) changed[key] = data[key]; + // BOTH tests, and they are different questions: the whitelist answers + // "did the guard let this write through" (a key it stripped never + // reached the row, so mirroring it would cache a value the database + // does not hold), and the mirror set answers "does better-auth keep + // this column in the snapshot at all". + if (key === 'id') continue; + if (!whitelist?.has(key)) continue; + if (!SESSION_SNAPSHOT_MIRRORED_FIELDS.has(key)) continue; + changed[key] = data[key]; } if (Object.keys(changed).length === 0) return; diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts index 6b643a8672..fa6c1399c0 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts @@ -123,6 +123,7 @@ import { managedExtensionFields, managedExtensionEditableFields, } from './managed-extension-fields.js'; +import { SYS_USER_PROFILE_EDIT_FIELDS } from './sys-user-writable-fields.js'; /** better-auth model name → the ObjectStack object it materializes as. */ const MODEL_TO_OBJECT: Record = { @@ -862,17 +863,47 @@ describe('managed extension fields (ADR-0105 D7)', () => { it('admin-surface-only sys_user fields are declared but NOT generically editable', () => { // `manager_id` / `ai_access` drive authorization and AI seating; - // `primary_business_unit_id` is a projection plugin-sharing maintains; - // `locale` (#13881) is declared as ours so the D7 guard judges it, and - // stays off the editable map until a ruling widens the ADR-0092 D2 - // profile whitelist — an unwidened whitelist is the recorded state, not - // an oversight. - for (const field of ['manager_id', 'ai_access', 'primary_business_unit_id', 'locale']) { + // `primary_business_unit_id` is a projection plugin-sharing maintains. + // + // ⚠️ `locale` was in this list until 2026-09-03 and has been REVERSED out + // of it, not dropped: the entry recorded "stays off the editable map until + // a ruling widens the ADR-0092 D2 profile whitelist", and that ruling + // arrived (option B, adopted 「同意」). It is now pinned in the opposite + // direction by the test below. The sentence it used to carry was right on + // its own terms — an unwidened whitelist was the recorded state, not an + // oversight — which is exactly why the reversal is named here instead of + // erasing it. + for (const field of ['manager_id', 'ai_access', 'primary_business_unit_id']) { expect(managedExtensionFields('sys_user')).toContain(field); expect(managedExtensionEditableFields('sys_user')).not.toContain(field); } }); + it('`sys_user.locale` is declared AND generically editable (2026-09-03 ruling)', () => { + expect(managedExtensionFields('sys_user')).toContain('locale'); + expect(managedExtensionEditableFields('sys_user')).toContain('locale'); + // One name, not a family: the ruling grew the user-writable set by exactly + // one field, so this map's `sys_user` entry has exactly one member. + expect([...managedExtensionEditableFields('sys_user')]).toEqual(['locale']); + }); + + it('`sys_user`\'s editable declaration is a subset of the whitelist actually registered', () => { + // `auth-plugin.ts` SKIPS `sys_user` when it registers whitelists from this + // map and registers the tiered `SYS_USER_PROFILE_EDIT_FIELDS` instead — so + // for this one object the map is a declaration and something else is the + // enforcement. A name that reaches the map but never the whitelist would be + // a write the platform advertises and the guard refuses: declared ≠ + // enforced, the shape ADR-0049 exists to ban. Nothing else pins the two + // together, because nothing else reads both. + for (const field of managedExtensionEditableFields('sys_user')) { + expect( + SYS_USER_PROFILE_EDIT_FIELDS.has(field), + `sys_user.${field} is declared generically editable but is not in ` + + `SYS_USER_PROFILE_EDIT_FIELDS, which is the set auth-plugin.ts actually registers`, + ).toBe(true); + } + }); + it('returns empty sets for an object with no extensions', () => { expect(managedExtensionFields('sys_session').size).toBe(0); expect(managedExtensionEditableFields('sys_session').size).toBe(0); diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.ts index fdf2d5b142..0fc864ee2a 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.ts @@ -77,8 +77,11 @@ export const MANAGED_EXTENSION_FIELDS: Readonly>> = { + // [2026-09-03 ruling, option B] The user's own notification language. The + // whole of `sys_user`'s editable extension surface: `manager_id`, + // `ai_access` and `primary_business_unit_id` stay admin-surface-only, and + // this entry does not change that. Registered through + // `SYS_USER_PROFILE_EDIT_FIELDS` — see the warning above. + sys_user: new Set(['locale']), sys_organization: new Set([ 'require_mfa', 'parent_organization_id', diff --git a/packages/plugins/plugin-auth/src/sys-user-locale-write-contract.test.ts b/packages/plugins/plugin-auth/src/sys-user-locale-write-contract.test.ts new file mode 100644 index 0000000000..568e5fdbdd --- /dev/null +++ b/packages/plugins/plugin-auth/src/sys-user-locale-write-contract.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The write contract for `sys_user.locale`, end to end — maintainer ruling + * 2026-09-03, option B, adopted 「同意」 (quoted verbatim and untranslated). + * + * The ruling opened a security boundary and stated a safety property in the + * same breath: a user may now set their own notification language, and "a + * malformed value is refused loudly by the column's BCP-47 shape check … and + * never dead-letters a notification". A ruling's stated safety property that no + * code enforces is the thing this file exists to catch — so it does not assert + * that the rule is DECLARED, it drives the evaluator that runs it. + * + * ## Why the pin spans two packages + * + * The opening is not one edit, it is three that only work together, and each + * one is invisible from where the others live: + * + * 1. `SYS_USER_PROFILE_EDIT_FIELDS` admits `locale` (plugin-auth) — without + * it the identity write guard strips the key and, on a locale-only PATCH, + * throws; + * 2. `sys_user.locale` no longer declares `readonly` (platform-objects) — + * `stripReadonlyFields` runs on the update path BEFORE the validator, so a + * readonly column's value never reaches either the row or the rule. A + * whitelist entry alone would have been a silent no-op; + * 3. the column declares `locale_bcp47_shape` (platform-objects) and + * objectql's rule validator enforces it. + * + * Nothing in either package sees all three. A test in plugin-auth that stopped + * at the whitelist would be green over a column the engine still strips; a test + * in platform-objects that stopped at the declaration would be green over a + * rule nothing runs. This file holds all three at once, which is why it lives + * here — plugin-auth is the one package that already depends on + * `@objectstack/platform-objects` (the column) and `@objectstack/objectql` + * (the evaluator). + */ + +import { describe, it, expect } from 'vitest'; +import { SysUser, SYS_USER_LOCALE_TAG_PATTERN } from '@objectstack/platform-objects/identity'; +import { evaluateValidationRules } from '@objectstack/objectql'; +import { + registerIdentityWriteGuard, + registerManagedUpdateWhitelist, +} from './identity-write-guard.js'; +import { SYS_USER_PROFILE_EDIT_FIELDS } from './sys-user-writable-fields.js'; + +/** The column definition as the object actually declares it. */ +const localeField = (SysUser as any).fields?.locale; + +/** Every rule the object declares on `locale`. */ +const localeRules = ((SysUser as any).validations ?? []).filter( + (r: any) => r?.field === 'locale', +); + +/** Run the object's rules over a payload; returns the thrown error, or null. */ +function refusal(data: Record, mode: 'insert' | 'update'): any { + try { + evaluateValidationRules(SysUser as any, data, mode, { previous: mode === 'update' ? {} : undefined }); + return null; + } catch (e) { + return e; + } +} + +describe('sys_user.locale — the column is writable at all (2026-09-03 ruling)', () => { + it('declares no `readonly`, so a caller-supplied value survives to the validator', () => { + expect(localeField, 'sys_user.locale is missing from the object').toBeTruthy(); + // ⚠️ Not cosmetic and not a UI hint here: `stripReadonlyFields` deletes a + // caller-supplied value for any field carrying `readonly` before + // `evaluateValidationRules` ever runs, so a `readonly` column is one the + // whitelist below can admit and the engine will still silently drop. + expect(localeField.readonly ?? false).toBe(false); + }); + + it('is on the identity write guard whitelist, and reaching the row needs BOTH', () => { + expect(SYS_USER_PROFILE_EDIT_FIELDS.has('locale')).toBe(true); + }); +}); + +describe('sys_user.locale — malformed tags are refused loudly, never stored', () => { + it('declares exactly one shape rule, and it carries the shared BCP-47 pattern', () => { + expect(localeRules.map((r: any) => r.name)).toEqual(['locale_bcp47_shape']); + const [rule] = localeRules; + expect(rule.type).toBe('format'); + expect(rule.regex).toBe(SYS_USER_LOCALE_TAG_PATTERN); + // `severity: 'warning' | 'info'` would log the violation and COMMIT the + // write — the evaluator only collects `error` into the thrown envelope. A + // rule that refuses nothing is how "declared" drifts from "enforced". + expect(rule.severity ?? 'error').toBe('error'); + // The rule must run on both write shapes: an insert-only rule leaves every + // profile edit unchecked, which is the shape a user actually performs. + expect([...(rule.events ?? ['insert', 'update'])].sort()).toEqual(['insert', 'update']); + }); + + it.each([ + ['a language name rather than a tag', 'Chinese (Simplified)'], + ['an underscore separator (POSIX, not BCP-47)', 'zh_CN'], + ['a leading separator', '-CN'], + ['a subtag over eight characters', 'zh-Hansumlaut'], + ['a digit-led primary subtag', '1zh'], + ['whitespace inside the tag', 'zh CN'], + ['the hotcrm dead-letter shape', 'undefined'], + ['a path traversal probe', '../../etc/passwd'], + ])('refuses %s on insert and on update', (_why, value) => { + for (const mode of ['insert', 'update'] as const) { + const err = refusal({ locale: value }, mode); + expect(err, `${value} was accepted on ${mode}`).toBeTruthy(); + // ADR-0112 envelope. `status` is not carried on the error object: the + // REST boundary derives it, and `mapDataError` in `@objectstack/rest` + // keys the 400 on EXACTLY the two discriminators asserted here + // (`error.code === 'VALIDATION_FAILED' || error.name === + // 'ValidationError'`), so pinning both is what pins the status. + expect(err.code).toBe('VALIDATION_FAILED'); + expect(err.name).toBe('ValidationError'); + // The per-field half — what a form focuses and what a client acts on. + expect(err.fields).toEqual([ + expect.objectContaining({ field: 'locale', code: 'invalid_format' }), + ]); + // The wording is part of the contract here: it is the only place a user + // is told what a legal value looks like. + expect(err.fields[0].message).toMatch(/BCP-47/); + } + }); + + it.each([ + ['a bare language', 'zh'], + ['language-region', 'zh-CN'], + ['language-script-region', 'zh-Hans-CN'], + ['a UN M.49 region', 'es-419'], + ['the deployment-default spelling', 'en-US'], + ['a three-letter language', 'yue-HK'], + ])('accepts %s', (_why, value) => { + expect(refusal({ locale: value }, 'insert')).toBeNull(); + expect(refusal({ locale: value }, 'update')).toBeNull(); + }); + + it.each([ + ['absent', {}], + ['null', { locale: null }], + ['empty string', { locale: '' }], + ])('leaves %s alone — an unset column is how the deployment default applies', (_why, data) => { + // The ruling keeps the deployment default as the fallback for an unset + // column, so CLEARING the field has to remain legal. A shape rule that + // also enforced presence would take that away and turn "use the server + // default" into an unreachable state. + expect(refusal(data as Record, 'insert')).toBeNull(); + expect(refusal(data as Record, 'update')).toBeNull(); + }); + + it('the rule that refuses is the one the column declares — not the length bound', () => { + // `maxLength: 35` is enforced by `validateRecord`, a different check in a + // different module. A 35-character malformed value proves the refusal + // above comes from the shape rule rather than from the length bound + // happening to catch the same inputs. + const malformedButShort = 'not a tag'; + expect(malformedButShort.length).toBeLessThan(localeField.maxLength); + expect(refusal({ locale: malformedButShort }, 'update')).toBeTruthy(); + }); +}); + +describe('sys_user.locale — the guard and the shape check compose', () => { + /** Fake engine capturing hook registrations (same shape the real engine builds). */ + function makeEngine() { + const handlers: Record Promise>> = {}; + return { + handlers, + getSchema: () => ({ name: 'sys_user', managedBy: 'better-auth' }), + registerHook: (event: string, handler: (ctx: any) => Promise) => { + (handlers[event] ??= []).push(handler); + }, + }; + } + + const USER_SESSION = { userId: 'usr_1', positions: [] }; + + function guardedUpdate(data: Record) { + const engine = makeEngine(); + registerManagedUpdateWhitelist('sys_user', SYS_USER_PROFILE_EDIT_FIELDS); + registerIdentityWriteGuard(engine as any, { packageId: 'test.locale-write-contract' }); + return engine.handlers.beforeUpdate[0]({ + object: 'sys_user', + session: USER_SESSION, + input: { id: 'u1', data }, + }); + } + + it('a well-formed self-service locale edit passes the guard AND the shape check', async () => { + const data: Record = { id: 'u1', locale: 'ja-JP' }; + await guardedUpdate(data); + expect(data).toEqual({ id: 'u1', locale: 'ja-JP' }); + expect(refusal(data, 'update')).toBeNull(); + }); + + it('a malformed locale clears the guard and is then refused by the column', async () => { + // The two layers answer different questions and the order matters: the + // guard asks "may this caller write this COLUMN" and says yes, so the + // shape check is the only thing between a user's typo and a stored value + // that would silently fall back to the deployment default forever. + const data: Record = { id: 'u1', locale: 'Japanese' }; + await guardedUpdate(data); + expect(data, 'the guard must not strip a whitelisted column').toEqual({ id: 'u1', locale: 'Japanese' }); + expect(refusal(data, 'update')).toMatchObject({ code: 'VALIDATION_FAILED' }); + }); +}); diff --git a/packages/plugins/plugin-auth/src/sys-user-writable-fields.ts b/packages/plugins/plugin-auth/src/sys-user-writable-fields.ts index 081895fc5d..4f9b3f55de 100644 --- a/packages/plugins/plugin-auth/src/sys-user-writable-fields.ts +++ b/packages/plugins/plugin-auth/src/sys-user-writable-fields.ts @@ -19,10 +19,42 @@ * `manager_id`, `ai_access`, …) or never-direct (email, credentials, every * system-managed stamp). See ADR-0092 D1 for the full tier table. Adding a * field to `sys_user` never silently opens it — absence means denied. + * + * ## Tier 1 has three members, not two (maintainer ruling 2026-09-03) + * + * `locale` joined `name` and `image` by a ruling on the "may a user set their + * own language" question, quoted verbatim and untranslated as adopted: + * 「同意」to option B. Widening this set is a SECURITY-BOUNDARY act and is + * the maintainer's to take — the ADR-0092 D1 tier table records the pre-ruling + * two, and this constant is now the widened one; a reader who finds them + * disagreeing should trust the ruling and the pins, and see the PR body for + * the ADR-amendment follow-up. + * + * Three things travel with the entry and none of them is optional: + * - `sys_user.locale` drops its `readonly` (platform-objects) — otherwise the + * engine strips the value before the guard can admit it; + * - the column carries a `locale_bcp47_shape` `format` validation rule, so a + * malformed tag is refused loudly instead of stored; + * - the ADR-0092 D6 session-snapshot mirror does NOT gain it — better-auth + * has no `locale` on its user model, so merging one into a cached snapshot + * would invent a field only cached sessions carry (see + * `SESSION_SNAPSHOT_MIRRORED_FIELDS` in `identity-write-guard.ts`). + * + * ⚠️ What this set does NOT decide is WHO. ADR-0092 D5 keeps that with the + * permission layer, and `member_default` still denies `allowEdit` on + * `sys_user`, so a rank-and-file member reaches this column through no shipped + * surface yet. That is a separate opening, deliberately not taken here. */ /** Tier 1 — standard form / data-API editable (identity write guard whitelist). */ -export const SYS_USER_PROFILE_EDIT_FIELDS: ReadonlySet = new Set(['name', 'image']); +export const SYS_USER_PROFILE_EDIT_FIELDS: ReadonlySet = new Set([ + 'name', + 'image', + // Maintainer ruling 2026-09-03 (option B). Read per recipient at delivery + // time by service-messaging; shape-checked at the write by the column's own + // `locale_bcp47_shape` rule. + 'locale', +]); /** Import-upsert may additionally touch these (admin bulk-identity surface). */ export const SYS_USER_IMPORT_UPDATE_FIELDS: ReadonlySet = new Set([ diff --git a/packages/services/service-messaging/src/recipient-locale-shape-parity.test.ts b/packages/services/service-messaging/src/recipient-locale-shape-parity.test.ts new file mode 100644 index 0000000000..563bba4022 --- /dev/null +++ b/packages/services/service-messaging/src/recipient-locale-shape-parity.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The read side and the write side of `sys_user.locale` must agree on what a + * locale tag looks like. + * + * Until the maintainer ruling of 2026-09-03 there was only one reader: this + * package normalized whatever happened to be in the column, and nothing + * checked values on the way in (the column was `readonly` and off the ADR-0092 + * D2 whitelist, so no user-facing surface could write it). The ruling made the + * column user-writable and, in the same sentence, required that "a malformed + * value is refused loudly by the column's BCP-47 shape check … and never + * dead-letters a notification" — which put a SECOND copy of the shape on the + * write path, in `@objectstack/platform-objects`. + * + * Two copies is a deliberate choice, not an oversight: `recipient-locale.ts` is + * documented as pure and total and runs per recipient after fan-out, so making + * it import the identity barrel to borrow a regex would have a notification + * normalizer pay a package barrel's module load. What the choice costs is + * drift, and this file is what is paid instead. + * + * ## What drift would actually do + * + * The two directions fail differently and neither is loud on its own: + * + * - **write looser than read** — a tag the column accepts and this module + * discards. The user picks a language, the write succeeds, every + * notification keeps arriving in the deployment default, and nothing + * anywhere reports a problem: the row holds exactly what the user asked + * for. This is the direction that is indistinguishable from the setting + * never having existed. + * - **write stricter than read** — a tag this module would have honoured but + * the column refuses. The user is told their language is invalid when it is + * not, and the refusal names a shape the platform contradicts one layer + * over. + * + * Neither shows up in a test of either package alone, which is the whole + * argument for this file living in the package that can see both. + */ + +import { describe, it, expect } from 'vitest'; +import { SYS_USER_LOCALE_TAG_PATTERN } from '@objectstack/platform-objects/identity'; +import { LOCALE_TAG_SHAPE, normalizeRecipientLocale } from './recipient-locale.js'; + +describe('recipient locale shape ↔ sys_user.locale write rule', () => { + it('is the same regex source, byte for byte', () => { + // Source equality rather than a behavioural sample, because a sample can + // only catch a divergence it happens to contain, and this is the assertion + // that cannot miss one. The write side stores the SOURCE STRING (a + // `format` validation rule carries `regex` as text and objectql compiles it + // with `new RegExp(...)`), so both ends are compared as text. + expect(LOCALE_TAG_SHAPE.source).toBe(SYS_USER_LOCALE_TAG_PATTERN); + // Flags too: `i` or `m` on one side alone changes which tags match without + // changing a character of the pattern. + expect(LOCALE_TAG_SHAPE.flags).toBe(''); + }); + + it.each([ + 'zh', 'zh-CN', 'zh-Hans-CN', 'es-419', 'en-US', 'yue-HK', + 'Chinese (Simplified)', 'zh_CN', '-CN', 'zh-Hansumlaut', '1zh', 'zh CN', + '../../etc/passwd', '', + ])('agrees with the compiled write-side pattern on %j', (value) => { + // The behavioural half. Redundant while the sources are equal — and that + // is the point: it is what still answers if a future change makes the two + // sides share intent instead of a literal (a named export on one side, a + // generated constant on the other), when source equality would stop being + // expressible but agreement would still be required. + const writeSide = new RegExp(SYS_USER_LOCALE_TAG_PATTERN).test(value); + expect(LOCALE_TAG_SHAPE.test(value)).toBe(writeSide); + }); + + it('the read side is deliberately STRICTER on the stringified-nothing literals', () => { + // `"null"` is four letters, so it is shape-legal on both sides — the write + // rule accepts it and only `NOTHING_LITERALS` in this module stops it + // reaching a template lookup. Pinned as intended asymmetry so a future + // reader does not "fix" the shared regex to close it: a lossy producer's + // stringified nothing arrives below the data API, where the write rule + // never runs, so tightening the shape would cost real tags (`null` is not + // a language, but neither is it reachable by a user picking one from a + // list) without closing the path it arrived by. + expect(new RegExp(SYS_USER_LOCALE_TAG_PATTERN).test('null')).toBe(true); + expect(LOCALE_TAG_SHAPE.test('null')).toBe(true); + expect(normalizeRecipientLocale('null')).toBeUndefined(); + // `"undefined"` is nine letters and fails the shape on both sides — it + // never depended on the literal list. + expect(new RegExp(SYS_USER_LOCALE_TAG_PATTERN).test('undefined')).toBe(false); + expect(normalizeRecipientLocale('undefined')).toBeUndefined(); + }); +}); diff --git a/packages/services/service-messaging/src/recipient-locale.ts b/packages/services/service-messaging/src/recipient-locale.ts index a361f6e6fd..c74cc7156a 100644 --- a/packages/services/service-messaging/src/recipient-locale.ts +++ b/packages/services/service-messaging/src/recipient-locale.ts @@ -78,8 +78,37 @@ export const USER_OBJECT = 'sys_user'; * alphanumeric subtags (`zh`, `zh-CN`, `zh-Hans-CN`, `es-419`). Shape only — * membership is the template bundle's business, and the ladders below handle * a shipped-nowhere tag by falling to their floor. + * + * ⚠️ Since the 2026-09-03 ruling made `sys_user.locale` user-writable this is + * no longer the only reader of the shape: the column now carries a + * `locale_bcp47_shape` `format` validation rule (`SYS_USER_LOCALE_TAG_PATTERN` + * in `@objectstack/platform-objects`'s `sys-user.object.ts`) that refuses a + * malformed tag at the WRITE. The two spellings must stay identical — a write + * path that accepted what this one discards would store values that fall back + * to the deployment default forever, which is indistinguishable from the user + * never having set one. Nothing imports across the two packages (this module + * is pure and total, and a per-recipient normalizer should not pay the + * identity barrel's load), so the agreement is held by a pin instead: + * `recipient-locale-shape-parity.test.ts`. Change one, change both — that test + * is what tells you. + * + * This module keeps its independent job either way, and the two are NOT + * redundant. The write-side rule guards values ARRIVING through the data API; + * this guards values already at rest — rows written before the rule existed + * and rows written below the data API. It is also strictly the stricter of the + * two: `"null"` is four letters and therefore SHAPE-LEGAL, so the write rule + * would accept it and only {@link NOTHING_LITERALS} below stops it reaching a + * template lookup. That asymmetry is deliberate rather than a gap in the + * regex — the shape is shared with the write side byte-for-byte, and a + * lossy producer's stringified nothing is a data-at-rest problem, not a + * language a user can be typing. + * + * Exported for that pin and for nothing else — the normalizer below is the + * supported entry point, and a caller that reaches for the raw regex is + * re-implementing {@link normalizeRecipientLocale} minus the parts that + * matter. */ -const LOCALE_TAG_SHAPE = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/; +export const LOCALE_TAG_SHAPE = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/; /** * The stringified-nothing literals a lossy producer can leave in a column. From 7ec39fda4dd2711f18c63efb8702f6dbbda6c1bf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 11:22:55 +0000 Subject: [PATCH 2/5] test(#14787): pin the BCP-47 check as shape-only, not membership Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .../src/sys-user-locale-write-contract.test.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/sys-user-locale-write-contract.test.ts b/packages/plugins/plugin-auth/src/sys-user-locale-write-contract.test.ts index 568e5fdbdd..0afc8d0c32 100644 --- a/packages/plugins/plugin-auth/src/sys-user-locale-write-contract.test.ts +++ b/packages/plugins/plugin-auth/src/sys-user-locale-write-contract.test.ts @@ -196,9 +196,22 @@ describe('sys_user.locale — the guard and the shape check compose', () => { // guard asks "may this caller write this COLUMN" and says yes, so the // shape check is the only thing between a user's typo and a stored value // that would silently fall back to the deployment default forever. - const data: Record = { id: 'u1', locale: 'Japanese' }; + const data: Record = { id: 'u1', locale: 'ja_JP' }; await guardedUpdate(data); - expect(data, 'the guard must not strip a whitelisted column').toEqual({ id: 'u1', locale: 'Japanese' }); + expect(data, 'the guard must not strip a whitelisted column').toEqual({ id: 'u1', locale: 'ja_JP' }); expect(refusal(data, 'update')).toMatchObject({ code: 'VALIDATION_FAILED' }); }); + + it('the check is of SHAPE, not of membership — an 8-letter word is a legal tag', () => { + // Measured while writing this file, and worth a pin rather than a comment: + // `Japanese` is eight letters, so it satisfies the primary-subtag rule and + // the column accepts it. That is correct and deliberate — membership in a + // shipped template bundle is the bundle's business, and a tag nothing + // ships falls to the delivery ladder's floor (`en-US` in `sendTemplate`, + // `DEFAULT_LOCALE` in the store) rather than dead-lettering. The ruling's + // safety property is about malformed values, NOT unknown ones, and a + // reader who expects this rule to reject `Japanese` would "fix" it into a + // closed vocabulary the platform does not have. + expect(refusal({ locale: 'Japanese' }, 'update')).toBeNull(); + }); }); From 592d39156daf09e20dcc0873fff65d41ecbe2fc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 11:57:38 +0000 Subject: [PATCH 3/5] chore(#14787): changeset, i18n bundles for the new rule message, census anchors Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .changeset/sys-user-locale-user-writable.md | 71 +++++++++++++++++++ content/docs/permissions/system-context.mdx | 4 +- .../apps/translations/en.objects.generated.ts | 5 ++ .../translations/es-ES.objects.generated.ts | 5 ++ .../translations/ja-JP.objects.generated.ts | 5 ++ .../translations/zh-CN.objects.generated.ts | 5 ++ 6 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 .changeset/sys-user-locale-user-writable.md diff --git a/.changeset/sys-user-locale-user-writable.md b/.changeset/sys-user-locale-user-writable.md new file mode 100644 index 0000000000..2a750c9e91 --- /dev/null +++ b/.changeset/sys-user-locale-user-writable.md @@ -0,0 +1,71 @@ +--- +"@objectstack/platform-objects": minor +"@objectstack/plugin-auth": minor +"@objectstack/service-messaging": patch +--- + +feat(platform-objects,plugin-auth): a user may set their own `sys_user.locale` (#14787) + +Maintainer ruling 2026-09-03, option B, quoted verbatim and untranslated as +adopted: + +> 「同意」 + +The identity table's user-writable set grows from two fields to three. This is a +security-boundary act, taken by the maintainer and recorded as one — it is the +first widening of the ADR-0092 D2 self-service whitelist since that ADR shipped +`{name, image}` as its first and only entry. `sys_user.locale` landed +`readonly` and off the whitelist three weeks earlier (#13881 / #14775), which +recorded a decision nobody had made yet; the ruling made it. + +Three edits move together, and each one is inert without the other two: + +- `SYS_USER_PROFILE_EDIT_FIELDS` becomes `{name, image, locale}`, so the + identity write guard admits the column instead of stripping it (and, on a + locale-only PATCH, throwing). `SYS_USER_IMPORT_UPDATE_FIELDS` inherits the + widening by construction — it is a spread of the profile set, not a second + list. +- `MANAGED_EXTENSION_EDITABLE_FIELDS` gains a `sys_user` entry holding + `locale` and nothing else. +- `sys_user.locale` drops `readonly`. Without this the engine's readonly strip + removes a caller-supplied value before the guard or the validator ever sees + it, so the whitelist entry alone would have been a silent no-op. + +**A malformed value is refused, not stored.** The column now declares a +`locale_bcp47_shape` `format` validation rule carrying the same BCP-47 pattern +the delivery-time reader uses, so objectql's rule validator rejects a malformed +tag on insert, by-id update and bulk update with the standard +`VALIDATION_FAILED` / `invalid_format` envelope (HTTP 400). The check is of +SHAPE, not of membership: an unknown-but-well-formed tag is accepted and falls +to the delivery ladder's floor rather than dead-lettering a notification, which +is the property #13881's per-recipient chain was built to hold. An absent, null +or empty column stays legal — clearing it is how a user returns to the +deployment default, which remains the fallback. + +**What did NOT widen.** The ADR-0092 D6 session-snapshot mirror keeps +`{name, image}`: better-auth has no `locale` on its user model and it is +deliberately not an `additionalFields` entry, so there is no cached copy to keep +coherent, and merging one in would manufacture a `user.locale` key present only +on sessions that happen to be cached and only after a profile edit. The mirror +set is now named separately from the update whitelist rather than derived from +it. + +**Who may perform the write is unchanged, and is a separate question.** ADR-0092 +D5 leaves that with the permission layer: `member_default` still denies +`allowEdit` on `sys_user`, so a rank-and-file member reaches this column through +no shipped surface yet — the widening opens the COLUMN, not a self-service +route. Granting one (the `sys_api_key` shape: an explicit `member_default` entry +plus a `_self` row-scope for writes) is a further security-boundary decision +that this ruling did not take. + +The `identity-write-guard` and `managed-extension-fields` pins that recorded the +old posture are FLIPPED, not deleted, each naming the ruling that reversed it — +a pin that recorded a real decision is evidence, and evidence of a superseded +decision is what tells the next reader the reversal was deliberate. + +`@objectstack/service-messaging` is a docs-and-export change only: its +`LOCALE_TAG_SHAPE` is unchanged in behaviour and now exported so a parity pin +can hold it byte-identical to the write-side pattern. Read-side normalization +stays — it is strictly the stricter of the two (`"null"` is shape-legal and only +the read side refuses it) and it guards values that arrive below the data API, +where no write rule runs. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index d872b83f6f..67241083ce 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -97,7 +97,7 @@ that silently does not happen. | 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | | 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` | -| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1405` | +| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1412` | | 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` | | 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` | | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` | @@ -117,7 +117,7 @@ that silently does not happen. | 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5891` | | 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3735`, `:3745`, `:3772` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | -| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` | +| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:99` | | 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6589` | | 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12084` | | 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12013` | 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 f60488dc8d..ed722edf31 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -304,6 +304,11 @@ export const enObjects: NonNullable = { } } } + }, + _validations: { + locale_bcp47_shape: { + message: "Locale must be a BCP-47 language tag, such as zh-CN or ja-JP." + } } }, sys_session: { 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 d0d9e7105a..83f8108d2a 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 @@ -304,6 +304,11 @@ export const esESObjects: NonNullable = { } } } + }, + _validations: { + locale_bcp47_shape: { + message: "El idioma debe ser una etiqueta de idioma BCP-47, por ejemplo zh-CN o ja-JP." + } } }, sys_session: { 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 7f3c5d7a58..fd43d37bde 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 @@ -304,6 +304,11 @@ export const jaJPObjects: NonNullable = { } } } + }, + _validations: { + locale_bcp47_shape: { + message: "言語は BCP-47 言語タグ(例: zh-CN、ja-JP)である必要があります。" + } } }, sys_session: { 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 19fb6bd7f6..d2d6fdd5f8 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 @@ -304,6 +304,11 @@ export const zhCNObjects: NonNullable = { } } } + }, + _validations: { + locale_bcp47_shape: { + message: "语言必须是 BCP-47 语言标签,如 zh-CN 或 ja-JP。" + } } }, sys_session: { From cacedbd16119631a2af8fbf952d9ffb9cde699c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 13:16:41 +0000 Subject: [PATCH 4/5] docs(#14787): re-render the tenant-audit census artefacts this diff moved The new `locale_bcp47_shape` validation-rule name is a snake_case `name:` literal inside a `*.object.ts` file, which `declaredObjects()` counts, so the corpus-scale figure moved 297 -> 298 (and sources scanned 540 -> 542 for the two new test files). Regenerated with the one mechanical repair path, `node scripts/tenant-audit-census.mjs --write`, and updated the hand-written prose figure outside the generated region so it still cites the table it points at. The gate and its self-test are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- content/docs/permissions/tenant-audit-census.mdx | 8 ++++---- .../2026-08-tenant-audit-write-call-sites.counts.md | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 1425f813e1..c51b36edd8 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -84,7 +84,7 @@ receiver that none of the three place is an error, never a default.** Tenancy itself is enabled *by default* — `isTenancyDisabled()` reads `tenancy.enabled === false` and nothing else — so the object registry only has to -find the opt-outs. Across 297 declared objects — the dated, ⛔ unenforced +find the opt-outs. Across 298 declared objects — the dated, ⛔ unenforced corpus-scale figure below — exactly two opt out (`sys_api_key`, `sys_sso_provider`), and no write call site on this surface targets either. @@ -224,13 +224,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-03 at `98b1cf0b7`. +Measured on 2026-09-03 at `631038b03`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 540 | +| tracked non-test sources scanned | 542 | | engine-shaped types recognised | 57 | -| declared objects in the registry | 297 | +| declared objects in the registry | 298 | | same-named calls subtracted as non-engine | 130 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 39b4ad0b64..a831460e8d 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -52,13 +52,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-03 at `98b1cf0b7`. +Measured on 2026-09-03 at `631038b03`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 540 | +| tracked non-test sources scanned | 542 | | engine-shaped types recognised | 57 | -| declared objects in the registry | 297 | +| declared objects in the registry | 298 | | same-named calls subtracted as non-engine | 130 | ## Every site From 9288c301dde70d9654c6d52a939bdbfa1023020c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 14:53:12 +0000 Subject: [PATCH 5/5] chore(#14787): bump service-messaging changeset to minor `@objectstack/service-messaging` now exports `LOCALE_TAG_SHAPE`, a new public symbol. Per the director's contract review (631038b03, comment 5527214281): "by the same mechanical floor that makes a new export Clause-2 yes, the level is minor." Declared `patch`; corrected to `minor`. No other change -- this is exactly the "(b) the service-messaging changeset level" half of the envelope the same review authorized alongside the census prose fix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .changeset/sys-user-locale-user-writable.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/sys-user-locale-user-writable.md b/.changeset/sys-user-locale-user-writable.md index 2a750c9e91..881f2184de 100644 --- a/.changeset/sys-user-locale-user-writable.md +++ b/.changeset/sys-user-locale-user-writable.md @@ -1,7 +1,7 @@ --- "@objectstack/platform-objects": minor "@objectstack/plugin-auth": minor -"@objectstack/service-messaging": patch +"@objectstack/service-messaging": minor --- feat(platform-objects,plugin-auth): a user may set their own `sys_user.locale` (#14787)