Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/org-role-vocabulary-derived.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
},
"dependencies": {
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0-rc.0",
"better-auth": "^1.6.25"
},
"devDependencies": {
Expand Down
24 changes: 7 additions & 17 deletions packages/auth/src/__tests__/org-roles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
76 changes: 35 additions & 41 deletions packages/auth/src/org-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,67 +7,61 @@
*/

/**
* 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
* this module the vocabulary was inlined as `type Role = 'owner' | 'admin' |
* '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
* used to register every declared `position` / `permission` name as an
* 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<OrgRole, { key: string; defaultValue: string }> = {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading