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
33 changes: 33 additions & 0 deletions .changeset/org-role-vocabulary-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@object-ui/auth': patch
---

docs(auth): the org-role vocabulary is closed — correct the mirror's standing instruction (framework ADR-0108)

`org-roles.ts` carried a standing instruction that is now wrong: *"a role added
server-side must be added HERE too."* There are no server-side additions left
to chase.

The framework used to register every declared `position` / `permission` name as
an organization role, so the console's list could always fall behind the
server's. That channel was retired (framework ADR-0108, objectstack#3723):
every value stored in `sys_member.role` is projected into
`current_user.positions`, so a business role handed out that way was capability
with none of the position system's controls — no `granted_by`, no validity
window, no scope check. `sys_member.role` is now a closed, framework-owned list
of `owner` / `admin` / `delegated_admin` / `member`, and an app's own business
roles are positions, granted through `sys_user_position` or an invitation's
placement (framework ADR-0105 D8).

So this mirror is now complete **by construction** rather than by vigilance.
Nothing about the console's behaviour changes — the four names and their labels
are what they already were.

Still a mirror rather than a derivation, but only for a packaging reason now:
the names live in `@objectstack/spec` as `BUILTIN_MEMBERSHIP_ROLES` /
`BUILTIN_MEMBERSHIP_ROLE_OPTIONS`, which `@object-ui/auth` cannot import yet —
this package takes no dependency on `@objectstack/spec`, and those constants
ship in the first release carrying ADR-0108 (they are absent from the published
16.1.0). A new test pins the list to exactly those four in display order until
then, so drift fails loudly instead of silently offering a value the server's
enforced `select` would reject.
16 changes: 16 additions & 0 deletions packages/auth/src/__tests__/org-roles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ describe('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
5 changes: 3 additions & 2 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ export { normalizePhoneIdentifier, looksLikePhoneIdentifier } from './phone-iden
export { createAuthenticatedFetch, ActiveOrganizationStorage, type AuthenticatedAdapterOptions } from './createAuthenticatedFetch';
export { getUserInitials } from './types';

// Organization membership-role vocabulary + the narrowing helpers every org
// screen shares (see `org-roles.ts` for why this mirrors rather than derives).
// 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.
export {
ORG_ROLE_OWNER,
ORG_ROLE_ADMIN,
Expand Down
33 changes: 26 additions & 7 deletions packages/auth/src/org-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,32 @@
* 'member'` in both `MembersPage` and `InviteMemberDialog`, so a role the
* server had learned about was still unreachable from either screen.
*
* ⚠️ This list MIRRORS the server; it does not derive from it. `/auth/config`
* exposes feature flags but no role vocabulary, so there is no surface to read
* — see objectstack-ai/objectstack#3723, which tracks making one list the
* source for all of them (the framework already carries the same list twice:
* the better-auth roles map and two enforced `Field.select` option sets). Until
* that lands, a role added server-side must be added HERE too — one place, not
* two.
* ## The vocabulary is CLOSED — this mirror is complete by construction
*
* [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).
*
* 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).
*
* ⚠️ 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.
*/

export const ORG_ROLE_OWNER = 'owner';
Expand Down
Loading