Skip to content

Commit fc60ad3

Browse files
os-zhuangclaude
andauthored
refactor(auth): derive the org-role vocabulary from @objectstack/spec instead of mirroring it (#3038)
The four ORG_ROLE_* constants become re-exports of the spec's MEMBERSHIP_ROLE_* literals, OrgRole becomes BuiltinMembershipRole, and ORG_ROLES becomes [...BUILTIN_MEMBERSHIP_ROLES] — the same constant the enforced sys_member.role select is built from, so the console's list can no longer drift from what the server accepts. Unblocked by @objectstack/spec@17.0.0-rc.0 (first release carrying framework ADR-0108's closed vocabulary) and the workspace's move to ^17.0.0-rc.0. ORG_ROLE_LABELS and the grade ladder stay console-owned per ADR-0108 D4; the #2907 drift guard is dropped — a derived list cannot drift. Closes #2908 Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c8b7f3d commit fc60ad3

6 files changed

Lines changed: 77 additions & 60 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@object-ui/auth': patch
3+
---
4+
5+
refactor(auth): derive the org-role vocabulary from `@objectstack/spec` instead of mirroring it
6+
7+
`org-roles.ts` restated the four membership-role names that `@objectstack/spec`
8+
owns as `BUILTIN_MEMBERSHIP_ROLES`. That was a mirror for packaging reasons
9+
only: this package took no dependency on the spec, and no published spec
10+
carried the constants. Both blockers are gone — `@objectstack/spec@17.0.0-rc.0`
11+
ships ADR-0108's closed vocabulary and the workspace already pins
12+
`^17.0.0-rc.0` — so the four `ORG_ROLE_*` constants are now re-exports,
13+
`OrgRole` is `BuiltinMembershipRole`, and `ORG_ROLES` is
14+
`[...BUILTIN_MEMBERSHIP_ROLES]`. The list cannot drift from what the server's
15+
enforced `select` accepts, by construction.
16+
17+
Deliberately still local: `ORG_ROLE_LABELS` and the grade ladder
18+
(`orgRoleGrade` / `invitableOrgRoles` / `assignableOrgRoles`). They are console
19+
concerns — i18n keys and screen-narrowing rules — and folding them into the
20+
name list would be the modeling error ADR-0108 D4 warns about: *what names
21+
exist* is a list; *which names mean authority* and *how a name projects* are
22+
rules that belong next to what they govern.
23+
24+
The #2907 drift guard (`is EXACTLY the framework four`) is dropped — a derived
25+
list cannot drift, and asserting a re-export against a literal is noise. No
26+
behaviour changes: the four names, their display order, and their labels are
27+
exactly what they already were.

packages/auth/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
},
3939
"dependencies": {
4040
"@object-ui/types": "workspace:*",
41+
"@objectstack/spec": "^17.0.0-rc.0",
4142
"better-auth": "^1.6.25"
4243
},
4344
"devDependencies": {

packages/auth/src/__tests__/org-roles.test.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,16 @@ import {
2929
} from '../org-roles';
3030

3131
describe('vocabulary', () => {
32-
it('carries the delegated issuer grade the framework registers', () => {
32+
it('offers the spec vocabulary WHOLE — including the delegated issuer grade', () => {
33+
// The names themselves derive from `@objectstack/spec`
34+
// (`BUILTIN_MEMBERSHIP_ROLES`), so there is no drift to guard against any
35+
// more — #2907's exact-list assertion is gone with the mirror. What this
36+
// pins is a console decision: `ORG_ROLES` is the spec list UNFILTERED.
37+
// Hiding a grade from the screens belongs in the narrowing helpers below,
38+
// never in the vocabulary.
3339
expect(ORG_ROLES).toContain(ORG_ROLE_DELEGATED_ADMIN);
3440
});
3541

36-
it('is EXACTLY the framework four — the closed vocabulary, mirrored', () => {
37-
// [framework ADR-0108 / objectstack#3723] `sys_member.role` is a closed,
38-
// framework-owned list; an app can no longer register a fifth name. This
39-
// is the drift guard until `@object-ui/auth` can import
40-
// `BUILTIN_MEMBERSHIP_ROLES` from `@objectstack/spec` (see org-roles.ts).
41-
//
42-
// Order matters: it is the display order both screens list, and it matches
43-
// `BUILTIN_MEMBERSHIP_ROLE_OPTIONS` server-side.
44-
//
45-
// If this fails because the framework list changed, mirror the change —
46-
// do NOT add a name the server does not offer. A value outside this set is
47-
// rejected on write by an enforced `Field.select`, so the console would be
48-
// offering something that always 400s.
49-
expect([...ORG_ROLES]).toEqual(['owner', 'admin', 'delegated_admin', 'member']);
50-
});
51-
5242
it('every role has a label — a role with no label renders as a raw key', () => {
5343
for (const role of ORG_ROLES) {
5444
expect(ORG_ROLE_LABELS[role]?.key).toBeTruthy();

packages/auth/src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ export { createAuthenticatedFetch, ActiveOrganizationStorage, type Authenticated
3838
export { getUserInitials } from './types';
3939

4040
// Organization membership-role vocabulary — a CLOSED, framework-owned list of
41-
// four (framework ADR-0108) — plus the narrowing helpers every org screen
42-
// shares. See `org-roles.ts` for why this still mirrors rather than derives.
41+
// four (framework ADR-0108), re-exported from `@objectstack/spec`'s
42+
// `BUILTIN_MEMBERSHIP_ROLES` so it cannot drift — plus the labels and
43+
// narrowing helpers every org screen shares, which stay console-owned
44+
// (see `org-roles.ts` for the boundary).
4345
export {
4446
ORG_ROLE_OWNER,
4547
ORG_ROLE_ADMIN,

packages/auth/src/org-roles.ts

Lines changed: 35 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,67 +7,61 @@
77
*/
88

99
/**
10-
* Organization membership roles — the ONE place the console names them.
10+
* Organization membership roles — the console's ONE entry point for them.
1111
*
1212
* These are better-auth's organization roles as the ObjectStack framework
1313
* registers them, stored in `sys_member.role` / `sys_invitation.role`. Before
1414
* this module the vocabulary was inlined as `type Role = 'owner' | 'admin' |
1515
* 'member'` in both `MembersPage` and `InviteMemberDialog`, so a role the
1616
* server had learned about was still unreachable from either screen.
1717
*
18-
* ## The vocabulary is CLOSED — this mirror is complete by construction
18+
* ## The vocabulary is CLOSED — and derived, not mirrored
1919
*
2020
* [framework ADR-0108 / objectstack#3723] These four names are the WHOLE list,
2121
* and the framework owns them. An application cannot add a fifth: the server
2222
* used to register every declared `position` / `permission` name as an
2323
* organization role, and that was retired, because every value stored in
2424
* `sys_member.role` is projected into `current_user.positions` — so a business
2525
* role handed out this way was capability with none of the position system's
26-
* controls (no `granted_by`, no validity window, no scope check).
26+
* controls (no `granted_by`, no validity window, no scope check). A membership
27+
* role is an organization GRADE (what you may reach); what a person may DO is
28+
* a position, granted through `sys_user_position` or an invitation's placement
29+
* (framework ADR-0105 D8).
2730
*
28-
* So the standing instruction that used to live here — *"a role added
29-
* server-side must be added HERE too"* — no longer applies. There are no
30-
* server-side additions to chase. A membership role is an organization GRADE
31-
* (what you may reach); what a person may DO is a position, granted through
32-
* `sys_user_position` or an invitation's placement (framework ADR-0105 D8).
31+
* The names below are re-exports of `BUILTIN_MEMBERSHIP_ROLES` from
32+
* `@objectstack/spec` — the same constant the enforced `sys_member.role` /
33+
* `sys_invitation.role` selects are built from — so this list cannot drift
34+
* from what the server accepts, by construction.
3335
*
34-
* ⚠️ Still a mirror, not a derivation — but now only for a packaging reason,
35-
* not a design one. The names live in `@objectstack/spec` as
36-
* `BUILTIN_MEMBERSHIP_ROLES` / `BUILTIN_MEMBERSHIP_ROLE_OPTIONS`, which
37-
* `@object-ui/auth` cannot import yet: this package does not depend on
38-
* `@objectstack/spec`, and the constants ship in the first release carrying
39-
* ADR-0108 (absent from the published 16.1.0). Once this package takes that
40-
* dependency at a version that has them, the four `export const`s below become
41-
* a re-export and `ORG_ROLES` becomes `[...BUILTIN_MEMBERSHIP_ROLES]` — the
42-
* labels and grade ladder stay here, since they are console concerns.
43-
* `orgRolesMatchFramework` in the tests pins the list until then.
36+
* What stays LOCAL, deliberately: `ORG_ROLE_LABELS` and the grade ladder
37+
* (`orgRoleGrade` / `invitableOrgRoles` / `assignableOrgRoles`). They are
38+
* console concerns — i18n keys and screen-narrowing rules — and folding them
39+
* into the name list would be the modeling error ADR-0108 D4 warns about:
40+
* *what names exist* is a list; *which names mean authority* and *how a name
41+
* projects* are rules that belong next to what they govern.
4442
*/
4543

46-
export const ORG_ROLE_OWNER = 'owner';
47-
export const ORG_ROLE_ADMIN = 'admin';
48-
/**
49-
* [framework ADR-0105 D8] The delegated issuer grade. May reach
50-
* `/organization/invite-member` WITHOUT being an org admin, which is what gives
51-
* D8's scope-bounded issuance gate a caller. Carries no authority of its own:
52-
* what a delegate may actually *place* comes from a separately-granted
53-
* `adminScope`, surfaced to this console by `describeDelegableScope()`.
54-
*/
55-
export const ORG_ROLE_DELEGATED_ADMIN = 'delegated_admin';
56-
export const ORG_ROLE_MEMBER = 'member';
44+
import {
45+
BUILTIN_MEMBERSHIP_ROLES,
46+
MEMBERSHIP_ROLE_OWNER as ORG_ROLE_OWNER,
47+
MEMBERSHIP_ROLE_ADMIN as ORG_ROLE_ADMIN,
48+
MEMBERSHIP_ROLE_DELEGATED_ADMIN as ORG_ROLE_DELEGATED_ADMIN,
49+
MEMBERSHIP_ROLE_MEMBER as ORG_ROLE_MEMBER,
50+
type BuiltinMembershipRole,
51+
} from '@objectstack/spec';
52+
53+
// Kept under the console's own names so call sites read as org-screen
54+
// vocabulary. `ORG_ROLE_DELEGATED_ADMIN` is ADR-0105 D8's delegated issuer
55+
// grade: it may reach `/organization/invite-member` WITHOUT being an org
56+
// admin, and carries no authority of its own — what a delegate may actually
57+
// *place* comes from a separately-granted `adminScope`, surfaced to this
58+
// console by `describeDelegableScope()`.
59+
export { ORG_ROLE_OWNER, ORG_ROLE_ADMIN, ORG_ROLE_DELEGATED_ADMIN, ORG_ROLE_MEMBER };
5760

58-
export type OrgRole =
59-
| typeof ORG_ROLE_OWNER
60-
| typeof ORG_ROLE_ADMIN
61-
| typeof ORG_ROLE_DELEGATED_ADMIN
62-
| typeof ORG_ROLE_MEMBER;
61+
export type OrgRole = BuiltinMembershipRole;
6362

64-
/** Display order: most privileged first, as the screens list them. */
65-
export const ORG_ROLES: readonly OrgRole[] = [
66-
ORG_ROLE_OWNER,
67-
ORG_ROLE_ADMIN,
68-
ORG_ROLE_DELEGATED_ADMIN,
69-
ORG_ROLE_MEMBER,
70-
] as const;
63+
/** Display order: most privileged first, as the spec ships it and the screens list it. */
64+
export const ORG_ROLES: readonly OrgRole[] = [...BUILTIN_MEMBERSHIP_ROLES];
7165

7266
/** i18n key + English fallback for a role, so every screen labels it identically. */
7367
export const ORG_ROLE_LABELS: Record<OrgRole, { key: string; defaultValue: string }> = {

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)