diff --git a/.changeset/org-hierarchy-timezone-columns.md b/.changeset/org-hierarchy-timezone-columns.md new file mode 100644 index 0000000000..8c1144266e --- /dev/null +++ b/.changeset/org-hierarchy-timezone-columns.md @@ -0,0 +1,26 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/plugin-auth': minor +--- + +feat(platform-objects,plugin-auth): `sys_business_unit.timezone` and `sys_organization.timezone` — the organization hierarchy carries the IANA zone a date boundary is computed in (#14238) + + + +Maintainer ruling 2026-09-02 (director summon #8), quoted verbatim and untranslated: 「同意」 — adopting option A on #14238. + +**The gap.** No platform object carried a timezone, so every application that has to answer "when does this day / week / period end?" invented a column of its own — on its tenant object, its team object or its user — and two apps in one deployment would disagree about when Tuesday ended, with nothing to report. A date boundary decides *which record exists*, not how one is shown: a monthly duty "due on the 5th" expires at midnight, and in UTC+8 that midnight is 08:00 UTC. + +**What lands.** + +- `sys_business_unit.timezone` — `text`, optional, `maxLength: 64`, `valueDomain: 'iana_time_zone'`, no default, in the Hierarchy group. Null means **inherit**: the nearest ancestor up the `parent_business_unit_id` chain that carries a value, then `sys_organization.timezone`, then `UTC`. +- `sys_organization.timezone` — the same shape, in the Configuration group: the **root default** of that chain. Null means `UTC`. +- plugin-auth registers `sys_organization.timezone` as an ADR-0105 D7 extension field (the collision guard proves better-auth's organization schema owns no `timezone` at the pinned version) and as generically editable under the ADR-0092 D2 identity write guard — the same tier as `require_mfa` and the group-structure fields. A root default the guard stripped on every administrator write would be a column nobody can set. `sys_business_unit` is `managedBy: 'platform'` and needs no entry. + +**The inheritance is a documented contract, not a mechanism.** Measured on the tree: nothing on the platform walks `parent_business_unit_id` *upward* to resolve an attribute. The three existing walkers (plugin-sharing's business-unit graph, plugin-approvals' recursive department approver, plugin-security's delegated-admin frontier) all descend to a unit's *descendants* and read no column beyond the parent link, `active` and `organization_id`. **No resolver API ships with this change** — the ruling holds option B ("the effective zone for this record") for a second consumer — so an application resolving a boundary reads the columns and walks the chain itself, in the order above. Nothing on the platform reads either column yet; both docblocks say so, so the next author does not read inheritance onto a field that stores what was written. + +**Validated on write.** Both columns declare `valueDomain: 'iana_time_zone'` — the ruling's own precondition (「rather than shipping an unvalidated text column」), met now that the record validator reads the key (#14168 / #15161). A non-member written to either column (`Mars/Olympus`, `Europe/Munich`, `UTC+8`) is refused with the ADR-0114 field code `value_domain` and `constraint.valueDomain`; membership is the shared `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC` — the very fallback this contract names. `UTC` is admitted, and pinned. + +**One shape, on purpose.** The platform's own two earlier IANA columns disagree with each other — `sys_job.timezone` (`maxLength: 100`, no default) and `sys_report_schedule.timezone` (`maxLength: 64`, default `UTC`), neither validated. The ruled pair takes 64 (the smaller precedent, and twice the domain's real ceiling: the enumeration's longest name on the repo's Node baseline is 30 characters, the longest tzdb link 32) and no schema default on either column (a default on the unit would mean "stop inheriting"; one on the organization would give UTC two spellings). Those two precedent columns are not retrofitted here — outside the ruling's scope, carded separately. + +**Not the home.** `sys_user` (option C): two people in different zones owning work in the same period would compute different boundaries for what the business considers one period. A per-user zone is a display preference on top of an org-resolved boundary, not a substitute for it. This change is distinct from the settings door's `localization.timezone` (the deployment-wide default analytics buckets dates in today); how the two relate is the future resolver's question. 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 61f17e21e7..a04bc7204c 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -534,6 +534,10 @@ export const enObjects: NonNullable = { label: "Require Multi-Factor Auth", help: "When true, every member of this organization must enroll an authenticator app to access data." }, + timezone: { + label: "Timezone", + help: "IANA time zone (e.g. UTC, Asia/Shanghai) date boundaries are computed in for this organization — the root default every business unit without a zone of its own inherits. Unset means UTC." + }, parent_organization_id: { label: "Parent Organization", help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)." @@ -857,6 +861,10 @@ export const enObjects: NonNullable = { label: "Parent Business Unit", help: "Self-reference for the org tree. Null = root of tenant." }, + timezone: { + label: "Timezone", + help: "IANA time zone (e.g. Asia/Shanghai) date boundaries are computed in for this unit. Leave unset to inherit the parent unit's zone, then the organization's, then UTC." + }, organization_id: { label: "Organization", help: "Tenant scope. Null in single-tenant; auto-stamped in multi-tenant." 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 83f8108d2a..83f0052680 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 @@ -534,6 +534,10 @@ export const esESObjects: NonNullable = { label: "Exigir autenticación multifactor", help: "Si es true, todos los miembros de esta organización deben registrar una aplicación de autenticación para acceder a los datos." }, + timezone: { + label: "Zona horaria", + help: "Zona horaria IANA (p. ej. UTC, Asia/Shanghai) en la que se calculan los límites de fecha de esta organización: el valor raíz que hereda toda unidad de negocio sin zona propia. Sin definir significa UTC." + }, parent_organization_id: { label: "Parent Organization", help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)." @@ -857,6 +861,10 @@ export const esESObjects: NonNullable = { label: "Unidad de negocio principal", help: "Autorreferencia para el árbol organizativo. Null = raíz del tenant." }, + timezone: { + label: "Zona horaria", + help: "Zona horaria IANA (p. ej. Asia/Shanghai) en la que se calculan los límites de fecha de esta unidad. Déjala sin definir para heredar la zona de la unidad de negocio principal, luego la de la organización y, por último, UTC." + }, organization_id: { label: "Organización", help: "Ámbito del tenant." 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 fd43d37bde..b5c488f863 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 @@ -534,6 +534,10 @@ export const jaJPObjects: NonNullable = { label: "多要素認証を必須化", help: "true の場合、この組織のすべてのメンバーはデータにアクセスするために認証アプリの登録が必要になります。" }, + timezone: { + label: "タイムゾーン", + help: "この組織で日付の境界を計算する IANA タイムゾーン(例: UTC、Asia/Shanghai)。独自のタイムゾーンを持たないすべてのビジネスユニットが継承するルートの既定値です。未設定の場合は UTC です。" + }, parent_organization_id: { label: "Parent Organization", help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)." @@ -857,6 +861,10 @@ export const jaJPObjects: NonNullable = { label: "親ビジネスユニット", help: "組織ツリーの自己参照。null = テナントのルート。" }, + timezone: { + label: "タイムゾーン", + help: "このユニットで日付の境界を計算する IANA タイムゾーン(例: Asia/Shanghai)。未設定の場合は親ビジネスユニット、次に組織のタイムゾーン、最後に UTC を継承します。" + }, organization_id: { label: "組織", help: "テナントスコープ。" 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 d2d6fdd5f8..8192a55010 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 @@ -534,6 +534,10 @@ export const zhCNObjects: NonNullable = { label: "强制多因素认证", help: "为 true 时,该组织的每位成员都必须注册身份验证器 App 才能访问数据。" }, + timezone: { + label: "时区", + help: "该组织计算日期边界所用的 IANA 时区(如 UTC、Asia/Shanghai)——所有未自行设置时区的业务单元都继承的根默认值。未设置表示 UTC。" + }, parent_organization_id: { label: "Parent Organization", help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)." @@ -857,6 +861,10 @@ export const zhCNObjects: NonNullable = { label: "上级业务单元", help: "组织树的自关联字段。Null 表示租户根节点。" }, + timezone: { + label: "时区", + help: "该业务单元计算日期边界所用的 IANA 时区(如 Asia/Shanghai)。留空则依次继承上级业务单元的时区、组织的时区,最后为 UTC。" + }, organization_id: { label: "组织", help: "租户范围。" diff --git a/packages/platform-objects/src/identity/org-hierarchy-timezone.test.ts b/packages/platform-objects/src/identity/org-hierarchy-timezone.test.ts new file mode 100644 index 0000000000..6beb1caa4f --- /dev/null +++ b/packages/platform-objects/src/identity/org-hierarchy-timezone.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #14238 — the organization hierarchy carries the IANA zone a date boundary is + * computed in. Maintainer ruling 2026-09-02 (option A, verbatim 「同意」): + * `sys_business_unit` gains a nullable `timezone` that inherits down the + * `parent_business_unit_id` chain, and `sys_organization` gains `timezone` as + * that chain's root default. No resolver API (option B waits for a second + * consumer); `sys_user` is not the home (option C refused). + * + * This file pins the DECLARED shape, in three parts: + * + * 1. both columns exist, on `text`, optional, and declare + * `valueDomain: 'iana_time_zone'` — the ruling's own precondition: 「if not, + * the engine seat sequences this card behind it rather than shipping an + * unvalidated text column」. A column that lost the declaration would be + * exactly the shape the ruling refused, and every other assertion here + * would still pass over it; + * 2. the two columns are ONE shape — same type, optionality, bound and domain, + * no default on either. The card's thesis is that every author invents this + * column differently, and the platform's own two precedents + * (`sys_job.timezone`: 100, no default; `sys_report_schedule.timezone`: 64, + * default `UTC`; neither validated) already disagree in three dimensions. + * The ruled pair must not become a third and a fourth spelling; + * 3. the declared domain admits `UTC`, the fallback the contract names for a + * wholly unset chain, and the declared bound admits every zone the runtime + * enumerates. Why the first is not automatic — `Intl.supportedValuesOf` + * omits `UTC`, so a column judged against the enumeration would refuse the + * platform's own default — is measured and pinned beside the predicate in + * `packages/spec` (`value-domain.test.ts`), once; this file asks the + * predicate the column actually inherits and does not re-implement it. + * + * The write-path half — a non-member WRITTEN to either column is refused with + * the ADR-0114 code `value_domain` — lives in plugin-auth's + * `org-hierarchy-timezone-write-contract.test.ts`, the one package that depends + * on both the columns and the evaluator. + */ + +import { describe, it, expect } from 'vitest'; +import { isValueDomainMember } from '@objectstack/spec/shared'; +import { SysBusinessUnit } from './sys-business-unit.object'; +import { SysOrganization } from './sys-organization.object'; + +type ColumnShape = { + type?: unknown; + required?: unknown; + maxLength?: unknown; + valueDomain?: unknown; + defaultValue?: unknown; + readonly?: unknown; + group?: unknown; +}; + +const unitColumn = () => (SysBusinessUnit.fields as Record).timezone; +const orgColumn = () => (SysOrganization.fields as Record).timezone; + +/** The keys on which the two ruled columns must agree — "one spelling". */ +const SHAPE_KEYS = ['type', 'required', 'maxLength', 'valueDomain', 'defaultValue', 'readonly'] as const; + +describe('#14238 — sys_business_unit.timezone and sys_organization.timezone', () => { + it('reads the real declarations, not an empty probe', () => { + // Vacuity control: a renamed column or a changed export would otherwise let + // every assertion below pass over `undefined`. + expect(SysBusinessUnit.name).toBe('sys_business_unit'); + expect(SysOrganization.name).toBe('sys_organization'); + expect(unitColumn()).toBeTypeOf('object'); + expect(orgColumn()).toBeTypeOf('object'); + }); + + it.each([ + ['sys_business_unit', unitColumn], + ['sys_organization', orgColumn], + ])('%s.timezone is an optional, domain-validated, bounded text column', (_object, column) => { + const c = column(); + expect(c.type).toBe('text'); + // Nullable, by the ruling's word: on the unit "null" means INHERIT, on the + // organization it means UTC. `required: true` would make both meanings + // unreachable. + expect(c.required).toBe(false); + // The ruling's precondition — the one line that turns an unvalidated text + // column into a validated one. `VALUE_DOMAIN_FIELD_TYPES` is `{text}`, so + // the declaration is also the reason the type above must stay `text`. + expect(c.valueDomain).toBe('iana_time_zone'); + expect(c.maxLength).toBe(64); + // No schema default on either column, deliberately: on the unit a default + // would mean "stop inheriting"; on the organization it would give UTC two + // spellings (unset on rows that predate the column, 'UTC' on rows minted + // after it). The contract has one: unset resolves to UTC. + expect('defaultValue' in c).toBe(false); + // `stripReadonlyFields` runs on the update path BEFORE the validator, so a + // readonly column is one an administrator could never set — and the root + // default is, by the ruling's word, a value an administrator sets. + expect(c.readonly ?? false).toBe(false); + }); + + it('the two columns are ONE shape — the platform does not invent it twice', () => { + const pick = (c: ColumnShape) => Object.fromEntries(SHAPE_KEYS.map((k) => [k, c[k]])); + expect(pick(unitColumn())).toEqual(pick(orgColumn())); + }); + + it('the unit column lives in the Hierarchy group — it is resolved along the hierarchy', () => { + expect(unitColumn().group).toBe('Hierarchy'); + }); + + it('the declared domain admits the fallback the contract names (`UTC`) and refuses a zone that does not exist', () => { + // Asked of the predicate the write path calls (`isValueDomainMember`), under + // the domain the column actually declares — not of a re-implementation. + const domain = unitColumn().valueDomain as 'iana_time_zone'; + expect(isValueDomainMember(domain, 'UTC')).toBe(true); + expect(isValueDomainMember(domain, 'Asia/Shanghai')).toBe(true); + // Shape-valid and nonexistent — the case a `pattern` cannot refuse. + expect(isValueDomainMember(domain, 'Mars/Olympus')).toBe(false); + }); + + it('the declared bound admits every zone the runtime enumerates, with room for the tzdb links it omits', () => { + // A sourced bound, not an alignment convenience: the enumeration's longest + // name on the repo's Node baseline is 30 characters and the tzdb caps each + // path component at 14, so 64 is twice the domain's real ceiling. If a + // future ICU ever enumerates a name the bound refuses, this goes red + // instead of the column silently refusing a legal zone. + // `Intl.supportedValuesOf` is ES2022; the package's `lib` predates it, so + // the call is typed here rather than the whole program's lib widened. + const intl = Intl as unknown as { supportedValuesOf(key: 'timeZone'): string[] }; + const longest = Math.max(...intl.supportedValuesOf('timeZone').map((z) => z.length)); + expect(longest).toBeLessThanOrEqual(unitColumn().maxLength as number); + // The longest identifier in the tzdb itself is a backward link the + // enumeration omits and the probe admits — 32 characters, still under half + // the bound. + const longestLink = 'America/Argentina/ComodRivadavia'; + expect(longestLink.length).toBe(32); + expect(isValueDomainMember('iana_time_zone', longestLink)).toBe(true); + expect(longestLink.length).toBeLessThanOrEqual(unitColumn().maxLength as number); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-business-unit.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts index 895902c986..d94825d81a 100644 --- a/packages/platform-objects/src/identity/sys-business-unit.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -132,6 +132,55 @@ export const SysBusinessUnit = ObjectSchema.create({ group: 'Hierarchy', }), + // #14238 — maintainer ruling 2026-09-02, option A (verbatim 「同意」): the + // IANA zone a date boundary ("when does this day / week / period end?") is + // computed in for work that belongs to this unit. A boundary decides WHICH + // record exists, not how one is shown: a monthly duty "due on the 5th" + // expires at midnight, and in UTC+8 that midnight is 08:00 UTC, so work + // finished at 09:00 local on the 5th is recorded late unless the zone is + // known. Nullable — null means INHERIT: the nearest ancestor up the + // `parent_business_unit_id` chain that carries a value, then the + // organization's `sys_organization.timezone` (the chain's root default), + // then `UTC`. + // + // ⚠️ The inheritance is a DOCUMENTED CONTRACT, not a mechanism. Measured on + // the tree while this column landed: nothing on the platform walks this + // chain UPWARD to resolve an attribute. The three existing walkers of + // `parent_business_unit_id` — plugin-sharing's `BusinessUnitGraphService` + // (`business-unit-graph.ts`, the `unit_and_subordinates` subtree walk), + // plugin-approvals' recursive department approver (`approval-service.ts`) + // and plugin-security's delegated-admin frontier (`delegated-admin-gate.ts`) + // — all descend from a unit to its DESCENDANTS and read no column beyond + // the parent link, `active` and `organization_id`. No resolver API ships + // with this column (the ruling: option B, "the effective zone for this + // record", waits for a second consumer), so a reader that needs the + // effective zone walks the chain itself, in exactly the order above, and + // nothing on the platform reads this column yet. Do not read inheritance + // onto a field that does not inherit: the column stores what was written. + // + // Validated on write by `valueDomain: 'iana_time_zone'` (#14168 / #15161 — + // the ruling's own precondition, 「rather than shipping an unvalidated text + // column」): membership is the shared `Intl.DateTimeFormat` probe, never + // the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC` + // — the very fallback this contract names. `maxLength: 64` follows + // `sys_report_schedule.timezone`, the platform's other IANA column that + // pairs a bound with the `UTC` default; the enumeration's longest name on + // the repo's Node baseline is 30 characters and the tzdb caps each path + // component at 14, so 64 is twice the domain's real ceiling and the smaller + // of the two precedents (`sys_job.timezone` says 100, neither declares a + // domain — the residue card). No `defaultValue`, deliberately: an explicit + // default here would mean "stop inheriting", which is the opposite of what + // an unset unit means. + timezone: Field.text({ + label: 'Timezone', + required: false, + maxLength: 64, + valueDomain: 'iana_time_zone', + description: + 'IANA time zone (e.g. Asia/Shanghai) date boundaries are computed in for this unit. Leave unset to inherit the parent unit\'s zone, then the organization\'s, then UTC.', + group: 'Hierarchy', + }), + organization_id: Field.lookup('sys_organization', { label: 'Organization', // Optional: single-tenant deployments have no organization row (org-scoping diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index bf8a544428..f5878cab70 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -220,6 +220,51 @@ export const SysOrganization = ObjectSchema.create({ description: 'When true, every member of this organization must enroll an authenticator app to access data.', }), + // #14238 — maintainer ruling 2026-09-02, option A (verbatim 「同意」): the + // organization's IANA zone, the ROOT DEFAULT of the business-unit chain. A + // `sys_business_unit` whose `timezone` is null, and whose ancestors' are + // all null, resolves to this value; a null here resolves to `UTC`. So an + // existing deployment computes every date boundary in UTC until an + // administrator sets this column — exactly what it did before the column + // existed — and the changeset's migration note says so in those words. + // + // No `defaultValue`, deliberately: "UTC" has ONE spelling in this contract + // (unset), not two (unset on rows that predate the column, 'UTC' on rows + // minted after it). Not a limitation — better-auth's `organization/create` + // inserts through the ObjectQL engine (`objectql-adapter.ts`), so a schema + // default WOULD reach new rows; it is omitted so that a reader handles one + // shape instead of a pair that means the same thing. + // + // Nothing on the platform reads this column yet, and no resolver ships with + // it (the ruling: option B waits for a second consumer) — the documented + // resolution order lives on `sys_business_unit.timezone`. ⚠️ Distinct from + // the settings door's `localization.timezone` (service-settings, tenant + // scope, default `UTC`), the deployment-wide default analytics buckets + // dates in today; how the two relate is the resolver's question, not this + // column's, and this column does not read or write that setting. + // + // Owned by objectql, not better-auth: an ADR-0105 D7 extension field, + // registered in plugin-auth's `MANAGED_EXTENSION_FIELDS` (whose collision + // guard proves better-auth's organization schema owns no `timezone` at the + // pinned version) and generically editable under the ADR-0092 D2 whitelist + // (`MANAGED_EXTENSION_EDITABLE_FIELDS`) — the path `require_mfa` above and + // the D6 group-structure fields below take. ⚠️ Same shape as + // `sys_business_unit.timezone` by design (`text`, optional, `maxLength: 64`, + // `valueDomain: 'iana_time_zone'`, no default): the card's thesis is that + // every author invents this column differently, and the platform's own two + // precedents (`sys_job`, `sys_report_schedule`) already disagree on length, + // default and validation — the ruled pair is one spelling, pinned in + // `org-hierarchy-timezone.test.ts`. + timezone: Field.text({ + label: 'Timezone', + required: false, + maxLength: 64, + valueDomain: 'iana_time_zone', + description: + 'IANA time zone (e.g. UTC, Asia/Shanghai) date boundaries are computed in for this organization — the root default every business unit without a zone of its own inherits. Unset means UTC.', + group: 'Configuration', + }), + // ── Group structure (ADR-0105 D6) ──────────────────────────── // // ⛔ REPORTING DIMENSION ONLY. These fields describe how organizations roll 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 fa6c1399c0..dbd83a9cee 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts @@ -861,6 +861,14 @@ describe('managed extension fields (ADR-0105 D7)', () => { expect(managedExtensionEditableFields('sys_organization')).toContain('sort_order'); }); + it('#14238 — `sys_organization.timezone` (the hierarchy root default) is declared AND editable', () => { + // Declared: the D7 collision check above judges it against better-auth's + // organization schema. Editable: a root default an administrator cannot + // set through the generic path is a declared-but-unusable column. + expect(managedExtensionFields('sys_organization')).toContain('timezone'); + expect(managedExtensionEditableFields('sys_organization')).toContain('timezone'); + }); + 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. diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.ts index 0fc864ee2a..802551b6a1 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.ts @@ -91,6 +91,14 @@ export const MANAGED_EXTENSION_FIELDS: Readonly> = { + sys_business_unit: { name: 'EMEA Sales', kind: 'division' }, + sys_organization: { name: 'Acme' }, +}; + +const OBJECTS = [ + ['sys_business_unit', SysBusinessUnit], + ['sys_organization', SysOrganization], +] as const; + +/** Run the record validator over a payload; returns the thrown error, or null. */ +function refusal(schema: unknown, data: Record, mode: Mode): any { + try { + validateRecord(schema as never, data, mode); + return null; + } catch (e) { + return e; + } +} + +/** A payload carrying `timezone`, legal for `mode` on `object` apart from that value. */ +function payload(object: string, mode: Mode, timezone: unknown): Record { + return mode === 'insert' ? { ...INSERT_BASE[object], timezone } : { timezone }; +} + +describe.each(OBJECTS)('%s.timezone — a non-member is refused on the write path (ADR-0114 `value_domain`)', (object, schema) => { + it('the base payloads are legal on their own (control — a refusal below is the timezone\'s)', () => { + expect(refusal(schema, INSERT_BASE[object], 'insert')).toBeNull(); + expect(refusal(schema, {}, 'update')).toBeNull(); + }); + + it.each([ + ['a shape-valid zone that does not exist', 'Mars/Olympus'], + ['a plausible city that is not a tzdb zone', 'Europe/Munich'], + ['an offset spelling, not an identifier', 'UTC+8'], + ['a display name, not an identifier', 'China Standard Time'], + ])('refuses %s on insert and on update', (_why, value) => { + for (const mode of ['insert', 'update'] as const) { + const err = refusal(schema, payload(object, mode, value), mode); + expect(err, `${value} was accepted on ${object} ${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 — the ADR-0114 catalog member, with the constraint + // a client acts on. `constraint.valueDomain` names the domain rather than + // an options list: the domain has no enumeration to hand back. + expect(err.fields).toEqual([ + expect.objectContaining({ + field: 'timezone', + code: 'value_domain', + constraint: { valueDomain: 'iana_time_zone' }, + }), + ]); + } + }); + + it('the refusal is the domain\'s, not the length bound\'s', () => { + // `maxLength: 64` is a different check in the same validator. Every + // non-member above is far shorter than the bound, and this one is the + // shortest of all — so the refusal comes from membership, not from length + // happening to catch the same inputs. + const shortNonMember = 'Mars/Olympus'; + expect(shortNonMember.length).toBeLessThan((schema.fields as any).timezone.maxLength); + expect(refusal(schema, payload(object, 'update', shortNonMember), 'update')).toBeTruthy(); + }); +}); + +describe.each(OBJECTS)('%s.timezone — members are admitted, and `UTC` above all', (object, schema) => { + it.each([ + // ⭐ The single most valuable case in the round: `UTC` is the fallback the + // contract names for a wholly unset chain, and `Intl.supportedValuesOf` + // OMITS it — a column judged against that enumeration would refuse the + // platform's own default. The shared predicate is the `Intl.DateTimeFormat` + // probe; this proves the column inherits the probe, not the list. + ['the contract\'s own fallback', 'UTC'], + ['the Etc/ spelling of the same zone', 'Etc/UTC'], + ['a canonical zone', 'Asia/Shanghai'], + ['a zone the enumeration spells differently (Asia/Calcutta)', 'Asia/Kolkata'], + // 32 characters — the longest identifier in the tzdb, a backward link the + // enumeration omits; admitted by the probe and inside the 64 bound. + ['the longest tzdb identifier, a link the enumeration omits', 'America/Argentina/ComodRivadavia'], + ])('accepts %s on insert and on update', (_why, value) => { + expect(refusal(schema, payload(object, 'insert', value), 'insert')).toBeNull(); + expect(refusal(schema, payload(object, 'update', value), 'update')).toBeNull(); + }); + + it.each([ + ['absent', {}], + ['null', { timezone: null }], + ])('leaves %s alone — an unset column is how inheritance (and the UTC root) applies', (_why, data) => { + // Nullable is the ruling's word. On the unit, unset means "inherit"; on the + // organization, unset means UTC. A domain check that also enforced + // presence would make both states unreachable, and CLEARING a zone (back + // to inheriting) has to stay a legal update. + expect(refusal(schema, { ...INSERT_BASE[object], ...data }, 'insert')).toBeNull(); + expect(refusal(schema, data as Record, 'update')).toBeNull(); + }); +}); + +describe('sys_organization.timezone — the identity write guard admits it, and the column still judges it', () => { + /** Fake engine capturing hook registrations (same shape the real engine builds). */ + function makeEngine(object: string, managedBy: string) { + const handlers: Record Promise>> = {}; + return { + handlers, + getSchema: () => ({ name: object, managedBy }), + registerHook: (event: string, handler: (ctx: any) => Promise) => { + (handlers[event] ??= []).push(handler); + }, + }; + } + + const USER_SESSION = { userId: 'usr_1', positions: [] }; + + function guardedOrganizationUpdate(data: Record) { + const engine = makeEngine('sys_organization', 'better-auth'); + // The whitelist the plugin registers at `kernel:ready` is this map's row — + // registered here from the SAME constant, so the pin reads what ships. + registerManagedUpdateWhitelist('sys_organization', managedExtensionEditableFields('sys_organization')); + registerIdentityWriteGuard(engine as any, { packageId: 'test.org-hierarchy-timezone-write-contract' }); + return engine.handlers.beforeUpdate[0]({ + object: 'sys_organization', + session: USER_SESSION, + input: { id: 'org_1', data }, + }); + } + + it('is a declared extension field AND generically editable (ADR-0105 D7 / ADR-0092 D2)', () => { + expect(managedExtensionFields('sys_organization')).toContain('timezone'); + expect(managedExtensionEditableFields('sys_organization')).toContain('timezone'); + }); + + it('a user-context edit of the root default passes the guard un-stripped and is then admitted by the column', async () => { + const data: Record = { id: 'org_1', timezone: 'Asia/Shanghai' }; + await guardedOrganizationUpdate(data); + expect(data, 'the guard must not strip a whitelisted column').toEqual({ id: 'org_1', timezone: 'Asia/Shanghai' }); + expect(refusal(SysOrganization, data, 'update')).toBeNull(); + }); + + it('a non-member clears the guard and is then refused by the column — the two layers answer different questions', async () => { + // The guard asks "may this caller write this COLUMN" and says yes; the + // domain check is the only thing between an administrator's typo and a + // stored root default no reader could ever resolve. + const data: Record = { id: 'org_1', timezone: 'Mars/Olympus' }; + await guardedOrganizationUpdate(data); + expect(data, 'the guard must not strip a whitelisted column').toEqual({ id: 'org_1', timezone: 'Mars/Olympus' }); + expect(refusal(SysOrganization, data, 'update')).toMatchObject({ code: 'VALIDATION_FAILED' }); + }); + + it('the guard really ran: a protocol field in the same payload is stripped (control)', async () => { + // Without this, the two pins above would also pass against a guard that + // never registered. `slug` is better-auth's own column, never whitelisted. + const data: Record = { id: 'org_1', slug: 'acme-2', timezone: 'UTC' }; + await guardedOrganizationUpdate(data); + expect(data).toEqual({ id: 'org_1', timezone: 'UTC' }); + }); + + it('sys_business_unit is managedBy platform — the guard never judges it, so it needs no whitelist entry', () => { + // Not an omission: the D2 guard reads `managedBy` from the schema registry + // and only judges `'better-auth'`. A reader copying the organization's + // registration onto the unit would be adding a whitelist to a table the + // guard never consults. + expect(SysBusinessUnit.managedBy).toBe('platform'); + expect(SysOrganization.managedBy).toBe('better-auth'); + expect(managedExtensionFields('sys_business_unit').size).toBe(0); + }); +});