Skip to content
Open
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
18 changes: 16 additions & 2 deletions shared/authentication/src/auth.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ?? [],
};
},
},
}),
Expand Down Expand Up @@ -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: [] },
},
},
});
4 changes: 4 additions & 0 deletions shared/database/src/migrations/0026_capabilities_column.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions shared/database/src/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
2 changes: 2 additions & 0 deletions shared/database/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
21 changes: 20 additions & 1 deletion tests/mocks/database.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
145 changes: 145 additions & 0 deletions tests/unit/services/capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading