diff --git a/.changeset/org-role-vocabulary-derived.md b/.changeset/org-role-vocabulary-derived.md new file mode 100644 index 0000000000..99cf896a6e --- /dev/null +++ b/.changeset/org-role-vocabulary-derived.md @@ -0,0 +1,27 @@ +--- +'@object-ui/auth': patch +--- + +refactor(auth): derive the org-role vocabulary from `@objectstack/spec` instead of mirroring it + +`org-roles.ts` restated the four membership-role names that `@objectstack/spec` +owns as `BUILTIN_MEMBERSHIP_ROLES`. That was a mirror for packaging reasons +only: this package took no dependency on the spec, and no published spec +carried the constants. Both blockers are gone — `@objectstack/spec@17.0.0-rc.0` +ships ADR-0108's closed vocabulary and the workspace already pins +`^17.0.0-rc.0` — so the four `ORG_ROLE_*` constants are now re-exports, +`OrgRole` is `BuiltinMembershipRole`, and `ORG_ROLES` is +`[...BUILTIN_MEMBERSHIP_ROLES]`. The list cannot drift from what the server's +enforced `select` accepts, by construction. + +Deliberately still local: `ORG_ROLE_LABELS` and the grade ladder +(`orgRoleGrade` / `invitableOrgRoles` / `assignableOrgRoles`). They are console +concerns — i18n keys and screen-narrowing rules — and folding them into the +name list would be the modeling error ADR-0108 D4 warns about: *what names +exist* is a list; *which names mean authority* and *how a name projects* are +rules that belong next to what they govern. + +The #2907 drift guard (`is EXACTLY the framework four`) is dropped — a derived +list cannot drift, and asserting a re-export against a literal is noise. No +behaviour changes: the four names, their display order, and their labels are +exactly what they already were. diff --git a/packages/auth/package.json b/packages/auth/package.json index 59ba38226a..789133565c 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "@object-ui/types": "workspace:*", + "@objectstack/spec": "^17.0.0-rc.0", "better-auth": "^1.6.25" }, "devDependencies": { diff --git a/packages/auth/src/__tests__/org-roles.test.ts b/packages/auth/src/__tests__/org-roles.test.ts index d5256b2c68..c20729a2b7 100644 --- a/packages/auth/src/__tests__/org-roles.test.ts +++ b/packages/auth/src/__tests__/org-roles.test.ts @@ -29,26 +29,16 @@ import { } from '../org-roles'; describe('vocabulary', () => { - it('carries the delegated issuer grade the framework registers', () => { + it('offers the spec vocabulary WHOLE — including the delegated issuer grade', () => { + // The names themselves derive from `@objectstack/spec` + // (`BUILTIN_MEMBERSHIP_ROLES`), so there is no drift to guard against any + // more — #2907's exact-list assertion is gone with the mirror. What this + // pins is a console decision: `ORG_ROLES` is the spec list UNFILTERED. + // Hiding a grade from the screens belongs in the narrowing helpers below, + // never in the vocabulary. expect(ORG_ROLES).toContain(ORG_ROLE_DELEGATED_ADMIN); }); - it('is EXACTLY the framework four — the closed vocabulary, mirrored', () => { - // [framework ADR-0108 / objectstack#3723] `sys_member.role` is a closed, - // framework-owned list; an app can no longer register a fifth name. This - // is the drift guard until `@object-ui/auth` can import - // `BUILTIN_MEMBERSHIP_ROLES` from `@objectstack/spec` (see org-roles.ts). - // - // Order matters: it is the display order both screens list, and it matches - // `BUILTIN_MEMBERSHIP_ROLE_OPTIONS` server-side. - // - // If this fails because the framework list changed, mirror the change — - // do NOT add a name the server does not offer. A value outside this set is - // rejected on write by an enforced `Field.select`, so the console would be - // offering something that always 400s. - expect([...ORG_ROLES]).toEqual(['owner', 'admin', 'delegated_admin', 'member']); - }); - it('every role has a label — a role with no label renders as a raw key', () => { for (const role of ORG_ROLES) { expect(ORG_ROLE_LABELS[role]?.key).toBeTruthy(); diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 1fbe18ef0e..bbfc041c34 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -38,8 +38,10 @@ export { createAuthenticatedFetch, ActiveOrganizationStorage, type Authenticated export { getUserInitials } from './types'; // Organization membership-role vocabulary — a CLOSED, framework-owned list of -// four (framework ADR-0108) — plus the narrowing helpers every org screen -// shares. See `org-roles.ts` for why this still mirrors rather than derives. +// four (framework ADR-0108), re-exported from `@objectstack/spec`'s +// `BUILTIN_MEMBERSHIP_ROLES` so it cannot drift — plus the labels and +// narrowing helpers every org screen shares, which stay console-owned +// (see `org-roles.ts` for the boundary). export { ORG_ROLE_OWNER, ORG_ROLE_ADMIN, diff --git a/packages/auth/src/org-roles.ts b/packages/auth/src/org-roles.ts index d26dd1bc9f..08e4591e46 100644 --- a/packages/auth/src/org-roles.ts +++ b/packages/auth/src/org-roles.ts @@ -7,7 +7,7 @@ */ /** - * Organization membership roles — the ONE place the console names them. + * Organization membership roles — the console's ONE entry point for them. * * These are better-auth's organization roles as the ObjectStack framework * registers them, stored in `sys_member.role` / `sys_invitation.role`. Before @@ -15,7 +15,7 @@ * 'member'` in both `MembersPage` and `InviteMemberDialog`, so a role the * server had learned about was still unreachable from either screen. * - * ## The vocabulary is CLOSED — this mirror is complete by construction + * ## The vocabulary is CLOSED — and derived, not mirrored * * [framework ADR-0108 / objectstack#3723] These four names are the WHOLE list, * and the framework owns them. An application cannot add a fifth: the server @@ -23,51 +23,45 @@ * organization role, and that was retired, because every value stored in * `sys_member.role` is projected into `current_user.positions` — so a business * role handed out this way was capability with none of the position system's - * controls (no `granted_by`, no validity window, no scope check). + * controls (no `granted_by`, no validity window, no scope check). A membership + * role is an organization GRADE (what you may reach); what a person may DO is + * a position, granted through `sys_user_position` or an invitation's placement + * (framework ADR-0105 D8). * - * So the standing instruction that used to live here — *"a role added - * server-side must be added HERE too"* — no longer applies. There are no - * server-side additions to chase. A membership role is an organization GRADE - * (what you may reach); what a person may DO is a position, granted through - * `sys_user_position` or an invitation's placement (framework ADR-0105 D8). + * The names below are re-exports of `BUILTIN_MEMBERSHIP_ROLES` from + * `@objectstack/spec` — the same constant the enforced `sys_member.role` / + * `sys_invitation.role` selects are built from — so this list cannot drift + * from what the server accepts, by construction. * - * ⚠️ Still a mirror, not a derivation — but now only for a packaging reason, - * not a design one. The names live in `@objectstack/spec` as - * `BUILTIN_MEMBERSHIP_ROLES` / `BUILTIN_MEMBERSHIP_ROLE_OPTIONS`, which - * `@object-ui/auth` cannot import yet: this package does not depend on - * `@objectstack/spec`, and the constants ship in the first release carrying - * ADR-0108 (absent from the published 16.1.0). Once this package takes that - * dependency at a version that has them, the four `export const`s below become - * a re-export and `ORG_ROLES` becomes `[...BUILTIN_MEMBERSHIP_ROLES]` — the - * labels and grade ladder stay here, since they are console concerns. - * `orgRolesMatchFramework` in the tests pins the list until then. + * What stays LOCAL, deliberately: `ORG_ROLE_LABELS` and the grade ladder + * (`orgRoleGrade` / `invitableOrgRoles` / `assignableOrgRoles`). They are + * console concerns — i18n keys and screen-narrowing rules — and folding them + * into the name list would be the modeling error ADR-0108 D4 warns about: + * *what names exist* is a list; *which names mean authority* and *how a name + * projects* are rules that belong next to what they govern. */ -export const ORG_ROLE_OWNER = 'owner'; -export const ORG_ROLE_ADMIN = 'admin'; -/** - * [framework ADR-0105 D8] The delegated issuer grade. May reach - * `/organization/invite-member` WITHOUT being an org admin, which is what gives - * D8's scope-bounded issuance gate a caller. Carries no authority of its own: - * what a delegate may actually *place* comes from a separately-granted - * `adminScope`, surfaced to this console by `describeDelegableScope()`. - */ -export const ORG_ROLE_DELEGATED_ADMIN = 'delegated_admin'; -export const ORG_ROLE_MEMBER = 'member'; +import { + BUILTIN_MEMBERSHIP_ROLES, + MEMBERSHIP_ROLE_OWNER as ORG_ROLE_OWNER, + MEMBERSHIP_ROLE_ADMIN as ORG_ROLE_ADMIN, + MEMBERSHIP_ROLE_DELEGATED_ADMIN as ORG_ROLE_DELEGATED_ADMIN, + MEMBERSHIP_ROLE_MEMBER as ORG_ROLE_MEMBER, + type BuiltinMembershipRole, +} from '@objectstack/spec'; + +// Kept under the console's own names so call sites read as org-screen +// vocabulary. `ORG_ROLE_DELEGATED_ADMIN` is ADR-0105 D8's delegated issuer +// grade: it may reach `/organization/invite-member` WITHOUT being an org +// admin, and carries no authority of its own — what a delegate may actually +// *place* comes from a separately-granted `adminScope`, surfaced to this +// console by `describeDelegableScope()`. +export { ORG_ROLE_OWNER, ORG_ROLE_ADMIN, ORG_ROLE_DELEGATED_ADMIN, ORG_ROLE_MEMBER }; -export type OrgRole = - | typeof ORG_ROLE_OWNER - | typeof ORG_ROLE_ADMIN - | typeof ORG_ROLE_DELEGATED_ADMIN - | typeof ORG_ROLE_MEMBER; +export type OrgRole = BuiltinMembershipRole; -/** Display order: most privileged first, as the screens list them. */ -export const ORG_ROLES: readonly OrgRole[] = [ - ORG_ROLE_OWNER, - ORG_ROLE_ADMIN, - ORG_ROLE_DELEGATED_ADMIN, - ORG_ROLE_MEMBER, -] as const; +/** Display order: most privileged first, as the spec ships it and the screens list it. */ +export const ORG_ROLES: readonly OrgRole[] = [...BUILTIN_MEMBERSHIP_ROLES]; /** i18n key + English fallback for a role, so every screen labels it identically. */ export const ORG_ROLE_LABELS: Record = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 073234d9e7..76ce83dc81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -782,6 +782,9 @@ importers: '@object-ui/types': specifier: workspace:* version: link:../types + '@objectstack/spec': + specifier: ^17.0.0-rc.0 + version: 17.0.0-rc.0(ai@7.0.37(zod@4.4.3)) better-auth: specifier: ^1.6.25 version: 1.6.25(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(mongodb@7.2.0)(next@16.2.12(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10)