diff --git a/shared/authentication/src/auth.definition.ts b/shared/authentication/src/auth.definition.ts index 11f555c..cb97bbe 100644 --- a/shared/authentication/src/auth.definition.ts +++ b/shared/authentication/src/auth.definition.ts @@ -102,7 +102,7 @@ export const auth: Auth = betterAuth({ jwt({ // JWT plugin configuration // JWKS endpoint automatically exposed at /api/auth/jwks - // Custom payload to include organization member role + // Custom payload to include organization member role and capabilities jwt: { definePayload: async ({ user }) => { // Query organization membership to get member role @@ -114,14 +114,26 @@ export const auth: Auth = betterAuth({ .limit(1); if (memberRecord.length > 0) { + // Cast user to access capabilities field + const userWithCapabilities = user as typeof user & { + capabilities?: string[] | null; + }; return { ...user, role: memberRecord[0].role, // Use organization member role instead of default user role + capabilities: userWithCapabilities.capabilities ?? [], // Include capabilities in JWT }; } } // Fallback to default user data if no organization membership found - return user; + // Still include capabilities even without organization membership + const userWithCapabilities = user as typeof user & { + capabilities?: string[] | null; + }; + return { + ...user, + capabilities: userWithCapabilities?.capabilities ?? [], + }; }, }, }), @@ -274,6 +286,8 @@ export const auth: Auth = betterAuth({ djName: { type: 'string', required: false }, appSkin: { type: 'string', required: true, defaultValue: 'modern-light' }, isAnonymous: { type: 'boolean', required: false, defaultValue: false }, + // Cross-cutting capabilities independent of role hierarchy (e.g., 'editor', 'webmaster') + capabilities: { type: 'string[]', required: false, defaultValue: [] }, }, }, }); diff --git a/shared/database/src/migrations/0026_capabilities_column.sql b/shared/database/src/migrations/0026_capabilities_column.sql new file mode 100644 index 0000000..335e081 --- /dev/null +++ b/shared/database/src/migrations/0026_capabilities_column.sql @@ -0,0 +1,4 @@ +-- Add capabilities column to auth_user table +-- Capabilities are cross-cutting permissions independent of role hierarchy (e.g., 'editor', 'webmaster') +--> statement-breakpoint +ALTER TABLE "auth_user" ADD COLUMN "capabilities" text[] DEFAULT '{}' NOT NULL; diff --git a/shared/database/src/migrations/meta/_journal.json b/shared/database/src/migrations/meta/_journal.json index 05d5c99..ff32e2b 100644 --- a/shared/database/src/migrations/meta/_journal.json +++ b/shared/database/src/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1768890229444, "tag": "0025_rate_limiting_tables", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1769990400000, + "tag": "0026_capabilities_column", + "breakpoints": true } ] } diff --git a/shared/database/src/schema.ts b/shared/database/src/schema.ts index f17f2b3..b32a573 100644 --- a/shared/database/src/schema.ts +++ b/shared/database/src/schema.ts @@ -41,6 +41,8 @@ export const user = pgTable( djName: varchar('dj_name', { length: 255 }), appSkin: varchar('app_skin', { length: 255 }).notNull().default('modern-light'), isAnonymous: boolean('is_anonymous').notNull().default(false), + // Cross-cutting capabilities independent of role hierarchy (e.g., 'editor', 'webmaster') + capabilities: text('capabilities').array().notNull().default([]), }, (table) => [ uniqueIndex('auth_user_email_key').on(table.email), diff --git a/tests/mocks/database.mock.ts b/tests/mocks/database.mock.ts index 64a7e21..db03858 100644 --- a/tests/mocks/database.mock.ts +++ b/tests/mocks/database.mock.ts @@ -88,7 +88,26 @@ export const flowsheet = { }; export const shows = {}; export const show_djs = {}; -export const user = {}; +export const user = { + id: 'id', + name: 'name', + email: 'email', + emailVerified: 'email_verified', + image: 'image', + createdAt: 'created_at', + updatedAt: 'updated_at', + role: 'role', + banned: 'banned', + banReason: 'ban_reason', + banExpires: 'ban_expires', + username: 'username', + displayUsername: 'display_username', + realName: 'real_name', + djName: 'dj_name', + appSkin: 'app_skin', + isAnonymous: 'is_anonymous', + capabilities: 'capabilities', +}; export const specialty_shows = {}; // Mock enum diff --git a/tests/unit/services/capabilities.test.ts b/tests/unit/services/capabilities.test.ts new file mode 100644 index 0000000..e685085 --- /dev/null +++ b/tests/unit/services/capabilities.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from '@jest/globals'; + +/** + * Capabilities system tests for Backend-Service + * + * These tests verify that: + * 1. The user schema includes capabilities field + * 2. Capabilities work with the expected data types + * 3. JWT payloads include capabilities + */ + +// Define capabilities locally (mirrors @wxyc/shared/auth-client) +const CAPABILITIES = ['editor', 'webmaster'] as const; +type Capability = (typeof CAPABILITIES)[number]; + +/** + * Check if a user has a specific capability. + */ +function hasCapability( + capabilities: Capability[] | null | undefined, + capability: Capability +): boolean { + return capabilities?.includes(capability) ?? false; +} + +/** + * Check if a user can edit website content. + */ +function canEditWebsite(capabilities: Capability[] | null | undefined): boolean { + return hasCapability(capabilities, 'editor'); +} + +describe('User capabilities storage', () => { + describe('capabilities column', () => { + it('should have capabilities field on user schema', async () => { + // Import the schema to verify capabilities field exists + const { user } = await import('@wxyc/database'); + + // The user table should have a capabilities field + expect(user).toHaveProperty('capabilities'); + }); + }); + + describe('capability values', () => { + it('capabilities should be an array type', () => { + // This tests that the capabilities field accepts array values + const validCapabilities = ['editor', 'webmaster']; + expect(Array.isArray(validCapabilities)).toBe(true); + }); + + it('should accept valid capability values', () => { + const testCapability: Capability = 'editor'; + expect(CAPABILITIES).toContain(testCapability); + }); + }); +}); + +describe('JWT payload with capabilities', () => { + it('should include capabilities in user payload structure', () => { + // Test the expected JWT payload structure + const expectedPayload = { + id: 'user-123', + email: 'test@wxyc.org', + role: 'dj', + capabilities: ['editor'], + }; + + expect(expectedPayload).toHaveProperty('capabilities'); + expect(Array.isArray(expectedPayload.capabilities)).toBe(true); + }); + + it('should handle empty capabilities array', () => { + const payload = { + id: 'user-123', + email: 'test@wxyc.org', + role: 'member', + capabilities: [], + }; + + expect(payload.capabilities).toEqual([]); + }); + + it('should handle null capabilities gracefully', () => { + const payload = { + id: 'user-123', + email: 'test@wxyc.org', + role: 'member', + capabilities: null as string[] | null, + }; + + // Capabilities should default to empty array if null + const capabilities = payload.capabilities ?? []; + expect(capabilities).toEqual([]); + }); +}); + +describe('Capability helper functions', () => { + describe('hasCapability', () => { + it('returns true when capability is present', () => { + expect(hasCapability(['editor'], 'editor')).toBe(true); + }); + + it('returns true when capability is one of many', () => { + expect(hasCapability(['webmaster', 'editor'], 'editor')).toBe(true); + }); + + it('returns false when capability is absent', () => { + expect(hasCapability(['webmaster'], 'editor')).toBe(false); + }); + + it('returns false for empty array', () => { + expect(hasCapability([], 'editor')).toBe(false); + }); + + it('returns false for null', () => { + expect(hasCapability(null, 'editor')).toBe(false); + }); + + it('returns false for undefined', () => { + expect(hasCapability(undefined, 'editor')).toBe(false); + }); + }); + + describe('canEditWebsite', () => { + it('returns true when user has editor capability', () => { + expect(canEditWebsite(['editor'])).toBe(true); + }); + + it('returns false when user only has webmaster capability', () => { + expect(canEditWebsite(['webmaster'])).toBe(false); + }); + + it('returns true when user has both editor and webmaster', () => { + expect(canEditWebsite(['editor', 'webmaster'])).toBe(true); + }); + + it('returns false for empty capabilities', () => { + expect(canEditWebsite([])).toBe(false); + }); + + it('returns false for null capabilities', () => { + expect(canEditWebsite(null)).toBe(false); + }); + }); +});