From 339ec670181638a48da528586d6cbcd86980d056 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Sat, 14 Feb 2026 15:48:41 -0800 Subject: [PATCH] docs: add auth convergence proposals Proposal 1 converges the auth model across wxyc-shared, Backend-Service, and dj-site by removing admin from the station role hierarchy. Proposal 2 adds cross-cutting capabilities (editor, webmaster) end-to-end from Backend-Service storage through JWT to dj-site UI. Proposal 1 status: - wxyc-shared admin removal: done (PR WXYC/wxyc-shared#8) - Backend-Service PR #156: closed - Org hooks audit: completed, hooks are safe - Remaining: re-add organizationClient, isAdmin in JWT, dj-site adoption --- docs/proposal-1-converge-auth-model.md | 214 +++++++++++++++++++++++ docs/proposal-2-capabilities.md | 228 +++++++++++++++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 docs/proposal-1-converge-auth-model.md create mode 100644 docs/proposal-2-capabilities.md diff --git a/docs/proposal-1-converge-auth-model.md b/docs/proposal-1-converge-auth-model.md new file mode 100644 index 00000000..c419ee69 --- /dev/null +++ b/docs/proposal-1-converge-auth-model.md @@ -0,0 +1,214 @@ +# Proposal 1: Converge on a Shared Auth Model + +**Date:** 2026-02-14 +**Status:** In Progress + +## Background + +The three WXYC repos each define their own auth types, role mappings, and organization resolution logic. This has led to divergence: wxyc-shared defines a 5-role hierarchy with `admin` at the top, but `admin` is actually a better-auth system concept, not a WXYC station role. Meanwhile, dj-site defines its own local types that don't match either model, and wxyc-shared removed the `organizationClient` plugin that dj-site depends on. This proposal aligns all three repos on a single auth model owned by wxyc-shared. + +### Admin is a system role, not a station role + +better-auth's admin plugin provides a built-in `admin` role on `auth_user.role`. It gates user-management operations: creating accounts, banning users, listing users, setting roles. This is orthogonal to what a user can do _at the station_ (play songs, manage the catalog, manage the roster). + +The WXYC role hierarchy should only contain station roles: + +| Level | Org role | Station permissions | +|-------|----------|---------------------| +| 0 | (none/member) | Read catalog, read/write bin | +| 1 | dj | + Read/write flowsheet | +| 2 | musicDirector | + Write catalog | +| 3 | stationManager | + Manage roster | + +Admin (`auth_user.role = 'admin'`) is a separate axis — like capabilities. A station manager who is also a system admin has `auth_member.role = 'stationManager'` and `auth_user.role = 'admin'`. + +This resolves the **role mismatch gotcha**: org member roles are always one of the four WXYC roles, so `requirePermissions` never encounters an unrecognized role. better-auth's built-in org roles (`owner`, `admin`, `member`) should never be used as org member roles in WXYC's organization. + +## How Role Data Flows Today + +```mermaid +graph TD + subgraph "Backend-Service (auth server)" + OP["organizationPlugin()"] + DB_MEMBER["auth_member table
stores member.role per org"] + DB_USER["auth_user table
user.role synced by hooks"] + DP["definePayload()"] + OP --> DB_MEMBER + OP -->|"org hooks sync admin-level
roles to user.role"| DB_USER + DB_MEMBER -->|"queries member.role"| DP + DP --> JWT["JWT: role from member table"] + end + + subgraph "dj-site (main)" + DJ_CLIENT["organizationClient()"] + DJ_ORG["organization-utils.ts
resolve slug, listMembers()"] + DJ_MAP["mapRoleToAuthorization()
local types, admin → SM"] + DJ_CLIENT --> DJ_ORG --> DJ_MAP + end + + subgraph "wxyc-shared (updated)" + WS_DECODE["No organizationClient
(removed in c892b1a)"] + WS_MAP["roleToAuthorization()
4-role model, admin → SM ✅"] + WS_ROLES["ROLES: 4 station roles ✅
isSystemAdmin() for admin checks"] + end + + DB_MEMBER -.->|"queried at runtime"| DJ_ORG + JWT -.->|"decoded"| WS_DECODE + WS_DECODE --> WS_MAP + + style WS_DECODE fill:#f99,stroke:#c00 + style DJ_MAP fill:#f99,stroke:#c00 +``` + +The red boxes are the problems: wxyc-shared can't query org roles (no plugin), dj-site's local types diverge, and wxyc-shared conflates the system admin role with the station role hierarchy. + +## Type Divergence + +| Type | wxyc-shared | dj-site (local) | Status | +|---|---|---|---| +| `WXYCRole` | 4 station roles | 4 roles, no `"admin"` | **Aligned** ([PR #8](https://github.com/WXYC/wxyc-shared/pull/8)) | +| `Authorization` | `NO=0 .. SM=3` | `NO=0 .. SM=3` | **Aligned** ([PR #8](https://github.com/WXYC/wxyc-shared/pull/8)) | +| Role mapping | `admin` → `SM (3)` | `admin` → `SM (3)` | **Aligned** ([PR #8](https://github.com/WXYC/wxyc-shared/pull/8)) | +| `User` type | `AuthorizableUser` with `capabilities` | Local `User`, no `capabilities` | Pending (dj-site needs to adopt shared types) | + +## Related PRs + +### Backend-Service + +| PR | Branch | Status | Action | +|---|---|---|---| +| [#156](https://github.com/WXYC/Backend-Service/pull/156) | `fix/add-admin-role-to-wxyc-roles` | **Closed** | Closed -- adding admin to `WXYCRoles` was the wrong direction | + +### dj-site + +| PR | Branch | Status | Relevance | +|---|---|---|---| +| [#145](https://github.com/WXYC/dj-site/pull/145) | `fix/auth-organization-utils` | **Closed** | Removed org plugin entirely -- conflicts with org-based direction | +| [#186](https://github.com/WXYC/dj-site/pull/186) | `chore/add-shared-dependency` | Open | Adds `@wxyc/shared` as a dependency (PR 1 of 4) | +| [#183](https://github.com/WXYC/dj-site/pull/183) | `refactor/add-shared-types` | Open | Replaces local DTO types with `@wxyc/shared/dtos` imports (PR 2 of 4) | +| [#136](https://github.com/WXYC/dj-site/pull/136) | `feat/authorized-view-components` | Open | `AuthorizedView` React components using `Authorization` enum | +| [#137](https://github.com/WXYC/dj-site/pull/137) | `feat/admin-role-dropdown-v2` | Open | Role dropdown selector for roster | + +## Proposal + +### End State + +```mermaid +graph TD + subgraph "Backend-Service" + OP2["organizationPlugin()"] + DB2["auth_member.role
(4 WXYC roles only)"] + DB2_USER["auth_user.role
('admin' for system admins)"] + DP2["definePayload()"] + OP2 --> DB2 -->|"queries member.role"| DP2 + DP2 --> JWT2["JWT: station role + isAdmin flag"] + end + + subgraph "wxyc-shared (updated)" + WS_CLIENT["organizationClient() restored"] + WS_ORG["organization.ts
canonical org resolution"] + WS_TYPES["Canonical types:
4 WXYCRoles, Authorization 0-3
isAdmin from user.role"] + WS_CLIENT --> WS_ORG + WS_ORG --> WS_TYPES + end + + subgraph "dj-site (updated)" + DJ_IMPORT["Imports from @wxyc/shared"] + DJ_IMPORT -->|"uses"| WS_ORG + DJ_IMPORT -->|"uses"| WS_TYPES + end + + DB2 -.->|"queried at runtime"| WS_ORG + JWT2 -.->|"fallback: decode"| WS_TYPES +``` + +### Two axes of authorization + +``` +Station role (org membership) System role (user record) +───────────────────────────── ──────────────────────── +member < dj < MD < SM user.role = "admin" (or not) + ↓ +Checked by requirePermissions() Checked by better-auth admin plugin +Stored in auth_member.role Stored in auth_user.role +Embedded in JWT as "role" Available via session / JWT +``` + +### Changes by repo + +#### 1. wxyc-shared: remove admin from the role hierarchy + +Done in [PR #8](https://github.com/WXYC/wxyc-shared/pull/8) (version 0.2.0): + +1. ~~**`roles.ts`**: Remove `"admin"` from `ROLES` array and `ROLE_PERMISSIONS`. Update helper functions.~~ Done. +2. ~~**`authorization.ts`**: Remove `Authorization.ADMIN = 4`. Update `roleToAuthorization()`.~~ Done. +3. ~~**`capabilities.ts`**: Update `CAPABILITY_ASSIGNERS` to reference `stationManager` only.~~ Done. +4. **Re-add `organizationClient()`** to the auth client plugins (partially reverts `c892b1a`). **Pending** -- separate follow-up PR. +5. **Add a new `organization.ts` module** ported from dj-site's `organization-utils.ts`. **Pending** -- separate follow-up PR. +6. ~~**Add `isSystemAdmin()`** utility.~~ Done (exported from `@wxyc/shared/auth-client/auth`). +7. ~~**Keep the pure auth entry point**.~~ Already exists. +8. ~~**Publish a new version** of `@wxyc/shared`.~~ Version 0.2.0 (pending merge of PR #8). + +#### 2. Backend-Service: close PR #156, update org hooks + +1. ~~**Close PR [#156](https://github.com/WXYC/Backend-Service/pull/156)**~~ Done. + +2. **Org hooks audit** (`auth.definition.ts`, completed 2026-02-14): + - The hooks **read** `member.role` to decide whether to sync `user.role`; they do **not write** to `member.role`. The risk of a member ending up with `role = 'admin'` or `'owner'` comes from the better-auth org API being called directly (e.g., manually or from a future admin UI) -- the hooks themselves are safe. + - The `user.role = 'admin'` sync is correct: it grants station managers access to better-auth admin endpoints (createUser, banUser), which is orthogonal to the station role hierarchy. + - **Remaining risk**: if someone sets `member.role` to `'admin'` or `'owner'` via the better-auth API, `requirePermissions` will 403. Recommendation: add a role normalization step in the middleware that maps unknown roles to `'member'`-level access with a warning log, as defense in depth. + +3. **Update `definePayload()`** to include an `isAdmin` flag derived from `auth_user.role`, so dj-site can show admin UI without an extra session query. **Pending** -- enhancement, not a bug fix. + +#### 3. dj-site: replace local types with shared imports + +This builds on the existing PR chain (#186 → #183) that is already adding `@wxyc/shared` and replacing local DTO types. + +1. **Replace local auth types** with imports from `@wxyc/shared/auth-client`: + - `WXYCRole` in `authentication/types.ts` → `@wxyc/shared/auth-client` + - `Authorization` enum in `admin/types.ts` → `@wxyc/shared/auth-client` (same 4 levels, now canonical) + - `mapRoleToAuthorization()` → `roleToAuthorization()` from `@wxyc/shared/auth-client` + +2. **Replace local `organization-utils.ts`** with the canonical version from `@wxyc/shared/auth-client`. + +3. **Gate admin UI** using `isSystemAdmin()` from `@wxyc/shared/auth-client` (checks `session.user.role === 'admin'`), not `Authorization.ADMIN`. This affects PRs #136 and #137. + +## Sequencing + +```mermaid +gantt + title Converge on Shared Auth Model + dateFormat X + axisFormat %s + + section wxyc-shared + Remove admin from role hierarchy :done, ws0, 0, 1 + Re-add organizationClient :ws1, 1, 2 + Add canonical organization.ts :ws2, after ws1, 2 + Publish new version :milestone, ws3, after ws2, 0 + + section Backend-Service + Close PR #156 :done, bs0, 0, 1 + Audit org hooks :done, bs1, 0, 1 + Add isAdmin to JWT payload :bs2, 1, 2 + + section dj-site + Merge PR #186 (add shared dep) :dj0, 0, 1 + Replace local auth types with shared :dj2, after ws3 dj0, 3 +``` + +All three repos can work in parallel. dj-site waits for the new wxyc-shared version before replacing its local types. + +## Testing Plan + +- [x] wxyc-shared: `ROLES` array has 4 entries, no admin ([PR #8](https://github.com/WXYC/wxyc-shared/pull/8)) +- [x] wxyc-shared: `Authorization` enum has 4 levels (NO through SM) +- [x] wxyc-shared: `roleToAuthorization("admin")` returns `SM` (safe fallback) +- [ ] wxyc-shared: `organizationClient` works with `listMembers()` and org slug resolution +- [x] wxyc-shared: `isSystemAdmin()` returns true only for `user.role === 'admin'` +- [x] Backend-Service: org hooks never assign 'admin' or 'owner' as org member roles (audit confirmed) +- [ ] Backend-Service: `requirePermissions` accepts all 4 WXYC roles without 403 +- [ ] Backend-Service: JWT includes `isAdmin` flag for admin users +- [ ] dj-site: role-based access still works after switching to shared types +- [ ] dj-site: admin UI gated by `isSystemAdmin()`, not `Authorization.ADMIN` +- [x] All three repos agree on the same 4-role hierarchy diff --git a/docs/proposal-2-capabilities.md b/docs/proposal-2-capabilities.md new file mode 100644 index 00000000..6dc608f1 --- /dev/null +++ b/docs/proposal-2-capabilities.md @@ -0,0 +1,228 @@ +# Proposal 2: Add Cross-Cutting Capabilities + +**Date:** 2026-02-14 +**Status:** Draft +**Depends on:** [Proposal 1 -- Converge on a Shared Auth Model](./proposal-1-converge-auth-model.md) + +## Background + +Some permissions don't fit the role hierarchy. A DJ who edits the website shouldn't need to be promoted to Station Manager just to unlock that feature. Capabilities are cross-cutting permissions that can be granted to any user independently of their role. + +wxyc-shared already defines the capabilities module. This proposal lands the remaining pieces: database storage in Backend-Service and consumption in dj-site. + +## What wxyc-shared Already Provides + +**Capabilities** (`src/auth-client/capabilities.ts`): + +- `editor` -- can edit website content +- `webmaster` -- can assign the editor capability; has all editor permissions + +**Assignment rules** (Proposal 1 removed admin from the role hierarchy in [wxyc-shared PR #8](https://github.com/WXYC/wxyc-shared/pull/8)): + +| Capability | Grantable by roles | Grantable by capabilities | +|---|---|---| +| `editor` | stationManager | webmaster | +| `webmaster` | stationManager | (none) | + +System admins (`user.role = 'admin'`) can also manage capabilities directly through better-auth's admin API. + +**Branded types** (`src/auth-client/authorization.ts`): + +- `CapabilityAuthorizedUser` -- user verified to have capability C +- `checkCapability()`, `checkRoleAndCapability()` -- return branded types on success, structured errors on failure + +## Problem + +```mermaid +graph LR + subgraph "wxyc-shared" + CAP_TYPES["Capability types
+ helpers ✅"] + end + + subgraph "Backend-Service" + NO_COL["No capabilities column ❌"] + NO_JWT["Not in JWT payload ❌"] + NO_MW["No middleware check ❌"] + end + + subgraph "dj-site" + NO_USER["Not on User type ❌"] + NO_SESSION["Not extracted from session ❌"] + NO_UI["No management UI ❌
(PR #117 pending)"] + end + + CAP_TYPES -.->|"types exist
but nothing uses them"| NO_COL + CAP_TYPES -.->|"types exist
but nothing uses them"| NO_USER +``` + +1. **No storage.** Backend-Service has no `capabilities` column on `auth_user`. +2. **Not in JWT.** `definePayload` doesn't include capabilities. +3. **Not in payload type.** `WXYCAuthJwtPayload` doesn't declare `capabilities`, so `req.auth.capabilities` is invisible to TypeScript. +4. **Not in session flow.** dj-site's `User` type and `getUserFromSession()` don't extract capabilities. +5. **better-auth version drift.** Backend-Service uses `^1.3.23`; the other repos use `^1.4.9`. + +## Related PRs + +### Backend-Service + +| PR | Branch | Status | Relevance | +|---|---|---|---| +| [#148](https://github.com/WXYC/Backend-Service/pull/148) | `feature/capabilities` | Open | Adds capabilities column, schema field, and JWT payload -- **this is the core PR** | +| [#150](https://github.com/WXYC/Backend-Service/pull/150) | `feature/account-setup-email-capabilities` | Open | Superset of #148 + email changes (stale) -- **close** | + +### dj-site + +| PR | Branch | Status | Relevance | +|---|---|---|---| +| [#117](https://github.com/WXYC/dj-site/pull/117) | (capabilities UI) | Open | Capability management in roster | + +## Proposal + +### How capabilities flow end-to-end + +```mermaid +graph TD + subgraph "Backend-Service" + DB["auth_user.capabilities
text[] column (PR #148)"] + BA_FIELD["better-auth additionalField
type: string[]"] + DP["definePayload()"] + DB --> BA_FIELD + BA_FIELD -->|"included in"| JWT["JWT payload"] + BA_FIELD -->|"returned by"| SESSION["getSession() response"] + DB -->|"queried"| DP --> JWT + end + + subgraph "dj-site" + DJ_SESSION["Session user object"] + DJ_USER["User type with capabilities"] + DJ_CHECK["checkCapability()
from @wxyc/shared"] + DJ_UI["Capability management UI
(PR #117)"] + SESSION --> DJ_SESSION --> DJ_USER --> DJ_CHECK + DJ_UI -->|"updates"| DB + end + + subgraph "Backend-Service API" + MW["requireCapability()
middleware (future)"] + JWT -->|"decoded"| MW + end +``` + +Capabilities are stored on `auth_user` (not `auth_member`), so they're available through both the JWT and the session user object. The org API (`listMembers()`) cannot surface them because it returns member records, not user records. + +### Changes by repo + +#### Backend-Service: revive PR #148 + +Rebase [#148](https://github.com/WXYC/Backend-Service/pull/148) onto main (after Proposal 1 changes) and apply these additional changes: + +1. **Bump better-auth to `^1.4.9`** in both `shared/authentication/package.json` and `apps/auth/package.json` to match dj-site and wxyc-shared. + + ```diff + - "better-auth": "^1.3.23" + + "better-auth": "^1.4.9" + ``` + +2. **Add `capabilities` to `WXYCAuthJwtPayload`** in `auth.middleware.ts`: + + ```diff + export type WXYCAuthJwtPayload = JWTPayload & { + id?: string; + sub?: string; + email: string; + role: WXYCRole; + + capabilities?: string[]; + }; + ``` + + Without this, capabilities are in the JWT but invisible to `req.auth` consumers. + +3. **Import capability types from `@wxyc/shared`** in the test file instead of redefining `CAPABILITIES`, `hasCapability`, and `canEditWebsite` locally. + +**Housekeeping:** close PR [#150](https://github.com/WXYC/Backend-Service/pull/150). It overlaps with #148, and its email changes were already shipped in #147. + +#### wxyc-shared: add auth context type + +Provide a utility that combines role (from org membership) and capabilities (from user record) into a single auth context: + +```typescript +type WXYCAuthContext = { + role: WXYCRole; + authority: Authorization; + capabilities: Capability[]; + isAdmin: boolean; // from user.role, not the org role hierarchy +}; +``` + +This type is what Proposal 1's org resolution utilities would return when they combine data from `listMembers()` (role) and the session user object (capabilities, admin status). + +#### dj-site: surface capabilities + +1. **Add `capabilities` to the `User` type** and `BetterAuthSession`. + +2. **Update `getUserFromSession()`** to extract capabilities from the session user object. Since `capabilities` is a better-auth `additionalField`, it appears on the user record returned by `getSession()`. + +3. **Land PR [#117](https://github.com/WXYC/dj-site/pull/117)** for capability management UI in the roster, using `canAssignCapability()` from `@wxyc/shared/auth-client` to enforce assignment rules. Note that after Proposal 1, `canAssignCapability` checks for `stationManager` (not `admin`) as the role that can assign capabilities. + +## Migration + +PR #148 includes migration `0026_capabilities_column.sql`: + +```sql +ALTER TABLE "auth_user" ADD COLUMN "capabilities" text[] DEFAULT '{}' NOT NULL; +``` + +This is additive and non-breaking. Existing users get an empty capabilities array. No data migration is needed. + +## Out of Scope (Future Work) + +### `requireCapability` middleware in Backend-Service + +`requirePermissions` only checks role-based permissions. A companion middleware would enforce capability checks server-side: + +```typescript +flowsheet_route.put( + '/website-content', + requirePermissions({ flowsheet: ['write'] }), + requireCapability('editor'), + controller.updateWebsiteContent +); +``` + +This would use `req.auth.capabilities` and the types from `@wxyc/shared/auth-client`. + +### Add `roster` resource to Backend-Service access control + +wxyc-shared defines `roster` as a resource with read/write permissions for stationManager. Backend-Service's access control statements (`auth.roles.ts`) don't include it yet. This should be added alongside roster management API endpoints. + +## Sequencing + +Assumes Proposal 1 is complete (shared auth model converged). + +```mermaid +gantt + title Add Cross-Cutting Capabilities + dateFormat X + axisFormat %s + + section Backend-Service + Revive PR #148 (capabilities + JWT) :bs1, 0, 2 + Close PR #150 (stale) :milestone, bs2, 0, 0 + + section wxyc-shared + Add WXYCAuthContext type :ws1, 0, 1 + Publish new version :milestone, ws2, after ws1, 0 + + section dj-site + Add capabilities to User + session :dj1, after bs1 ws2, 3 + Land PR #117 (capability mgmt UI) :dj2, after dj1, 4 +``` + +## Testing Plan + +- [ ] Unit tests for capability storage pass (existing in #148) +- [ ] Build succeeds with bumped better-auth version +- [ ] Integration: create a user with `capabilities: ['editor']`, fetch a JWT, verify the `capabilities` claim is present +- [ ] Integration: `getSession()` returns `capabilities` on the user object +- [ ] Integration: dj-site reads capabilities from session and gates UI accordingly +- [ ] `canAssignCapability()` enforces delegation chain (stationManager can assign anything, webmaster can assign editor only) +- [ ] System admins can manage capabilities via better-auth admin API