diff --git a/src/middlewares/co-creator-permission.middleware.test.ts b/src/middlewares/co-creator-permission.middleware.test.ts new file mode 100644 index 00000000..f99f1d6d --- /dev/null +++ b/src/middlewares/co-creator-permission.middleware.test.ts @@ -0,0 +1,204 @@ +// src/middlewares/co-creator-permission.middleware.test.ts + +import { requireCoCreatorPermission } from './co-creator-permission.middleware'; +import * as coCreatorService from '../modules/co-creators/co-creators.service'; +import * as walletOwnershipUtils from '../utils/wallet-ownership.utils'; + +jest.mock('../modules/co-creators/co-creators.service', () => ({ + getCoCreatorDetail: jest.fn(), +})); + +jest.mock('../utils/wallet-ownership.utils', () => ({ + checkCreatorProfileOwnership: jest.fn(), +})); + +describe('requireCoCreatorPermission Middleware (#1156)', () => { + let req: any; + let res: any; + let next: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + req = { + params: { keyId: 'key-1' }, + headers: { 'x-wallet-address': 'GCOCREATORWALLET11111111111111111111111111111111' }, + path: '/api/v1/keys/key-1/settings', + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + }; + next = jest.fn(); + }); + + it('returns 401 when wallet address is missing', async () => { + req.headers = {}; + const middleware = requireCoCreatorPermission(); + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 400 when keyId parameter is missing', async () => { + req.params = {}; + const middleware = requireCoCreatorPermission(); + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(next).not.toHaveBeenCalled(); + }); + + it('bypasses co-creator check when wallet is the primary creator', async () => { + jest + .spyOn(walletOwnershipUtils, 'checkCreatorProfileOwnership') + .mockResolvedValueOnce({ status: 'granted', ownerUserId: 'user-1' }); + + const middleware = requireCoCreatorPermission(); + await middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('ADMIN role passes all creator-action endpoints correctly', async () => { + jest + .spyOn(walletOwnershipUtils, 'checkCreatorProfileOwnership') + .mockResolvedValueOnce({ status: 'forbidden', address: 'a', ownerUserId: 'user-2' }); + jest + .spyOn(coCreatorService, 'getCoCreatorDetail') + .mockResolvedValueOnce({ + key_id: 'key-1', + wallet: 'GCOCREATORWALLET11111111111111111111111111111111', + role: 'ADMIN', + active: true, + added_at: new Date().toISOString(), + removed_at: null, + share_bps: null, + }); + + req.path = '/api/v1/keys/key-1/revenue'; + const middleware = requireCoCreatorPermission(); + await middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('CONTENT role passes announcement endpoints correctly', async () => { + jest + .spyOn(walletOwnershipUtils, 'checkCreatorProfileOwnership') + .mockResolvedValueOnce({ status: 'forbidden', address: 'a', ownerUserId: 'user-2' }); + jest + .spyOn(coCreatorService, 'getCoCreatorDetail') + .mockResolvedValueOnce({ + key_id: 'key-1', + wallet: 'GCOCREATORWALLET11111111111111111111111111111111', + role: 'CONTENT', + active: true, + added_at: new Date().toISOString(), + removed_at: null, + share_bps: null, + }); + + req.path = '/api/v1/keys/key-1/announcements'; + const middleware = requireCoCreatorPermission(); + await middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('CONTENT role is blocked correctly on non-announcement endpoints with 403, role_required, and current_role', async () => { + jest + .spyOn(walletOwnershipUtils, 'checkCreatorProfileOwnership') + .mockResolvedValueOnce({ status: 'forbidden', address: 'a', ownerUserId: 'user-2' }); + jest + .spyOn(coCreatorService, 'getCoCreatorDetail') + .mockResolvedValueOnce({ + key_id: 'key-1', + wallet: 'GCOCREATORWALLET11111111111111111111111111111111', + role: 'CONTENT', + active: true, + added_at: new Date().toISOString(), + removed_at: null, + share_bps: null, + }); + + req.path = '/api/v1/keys/key-1/revenue'; + const middleware = requireCoCreatorPermission(); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'FORBIDDEN', + role_required: 'REVENUE', + current_role: 'CONTENT', + }), + }) + ); + }); + + it('REVENUE role passes revenue endpoints correctly', async () => { + jest + .spyOn(walletOwnershipUtils, 'checkCreatorProfileOwnership') + .mockResolvedValueOnce({ status: 'forbidden', address: 'a', ownerUserId: 'user-2' }); + jest + .spyOn(coCreatorService, 'getCoCreatorDetail') + .mockResolvedValueOnce({ + key_id: 'key-1', + wallet: 'GCOCREATORWALLET11111111111111111111111111111111', + role: 'REVENUE', + active: true, + added_at: new Date().toISOString(), + removed_at: null, + share_bps: 500, + }); + + req.path = '/api/v1/keys/key-1/revenue'; + const middleware = requireCoCreatorPermission(); + await middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('REVENUE role is blocked correctly on non-revenue endpoints with 403, role_required, and current_role', async () => { + jest + .spyOn(walletOwnershipUtils, 'checkCreatorProfileOwnership') + .mockResolvedValueOnce({ status: 'forbidden', address: 'a', ownerUserId: 'user-2' }); + jest + .spyOn(coCreatorService, 'getCoCreatorDetail') + .mockResolvedValueOnce({ + key_id: 'key-1', + wallet: 'GCOCREATORWALLET11111111111111111111111111111111', + role: 'REVENUE', + active: true, + added_at: new Date().toISOString(), + removed_at: null, + share_bps: 500, + }); + + req.path = '/api/v1/keys/key-1/announcements'; + const middleware = requireCoCreatorPermission(); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'FORBIDDEN', + role_required: 'CONTENT', + current_role: 'REVENUE', + }), + }) + ); + }); +}); diff --git a/src/middlewares/co-creator-permission.middleware.ts b/src/middlewares/co-creator-permission.middleware.ts new file mode 100644 index 00000000..8af76d09 --- /dev/null +++ b/src/middlewares/co-creator-permission.middleware.ts @@ -0,0 +1,182 @@ +// src/middlewares/co-creator-permission.middleware.ts +// +// Middleware layer that validates co-creator permissions before proxying +// creator-only API requests, checking the on-chain co-creator registry +// to enforce role-based access for ADMIN, CONTENT, and REVENUE roles (#1156). + +import { Request, Response, NextFunction } from 'express'; +import { getCoCreatorDetail } from '../modules/co-creators/co-creators.service'; +import { checkCreatorProfileOwnership } from '../utils/wallet-ownership.utils'; +import { ErrorCode, sendError } from '../utils/api-response.utils'; +import { logger } from '../utils/logger.utils'; + +export interface CoCreatorAuthenticatedRequest extends Request { + walletAddress?: string; + user?: { + wallet: string; + userId?: string; + }; +} + +export type CoCreatorRole = 'ADMIN' | 'CONTENT' | 'REVENUE' | string; + +/** + * Determine required role based on explicit option or inferred from path. + */ +function inferRequiredRole(path: string): 'ADMIN' | 'CONTENT' | 'REVENUE' { + const lower = path.toLowerCase(); + if ( + lower.includes('announcement') || + lower.includes('post') || + lower.includes('feed') + ) { + return 'CONTENT'; + } + if ( + lower.includes('revenue') || + lower.includes('royalty') || + lower.includes('payout') || + lower.includes('dividend') || + lower.includes('fee') + ) { + return 'REVENUE'; + } + return 'ADMIN'; +} + +function extractWallet(req: CoCreatorAuthenticatedRequest): string | undefined { + if (req.walletAddress) return req.walletAddress; + if (req.user?.wallet) return req.user.wallet; + const header = req.headers['x-wallet-address']; + if (Array.isArray(header)) return header[0]?.trim() || undefined; + if (typeof header === 'string') return header.trim() || undefined; + return undefined; +} + +function extractKeyId( + req: Request, + paramName: string = 'keyId' +): string | undefined { + const raw = + req.params[paramName] ?? + req.params.keyId ?? + req.params.key_id ?? + req.params.creatorId ?? + req.params.id ?? + req.params.wallet; + if (Array.isArray(raw)) return raw[0]; + return raw; +} + +/** + * Middleware factory that enforces co-creator role permissions. + * + * - Primary creator bypasses co-creator check. + * - ADMIN role passes all creator-action endpoints. + * - CONTENT role passes announcements endpoints only. + * - REVENUE role passes revenue/royalties endpoints only. + * - Returns 403 with role_required and current_role when access denied. + */ +export function requireCoCreatorPermission( + explicitRole?: 'ADMIN' | 'CONTENT' | 'REVENUE', + paramName: string = 'keyId' +) { + return async ( + req: CoCreatorAuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise => { + try { + const wallet = extractWallet(req); + const keyId = extractKeyId(req, paramName); + + if (!wallet) { + sendError( + res, + 401, + ErrorCode.UNAUTHORIZED, + 'Wallet address is required for co-creator permission validation.' + ); + return; + } + + if (!keyId) { + sendError( + res, + 400, + ErrorCode.BAD_REQUEST, + `Missing required path parameter "${paramName}" for co-creator check.` + ); + return; + } + + const requiredRole = + explicitRole ?? + inferRequiredRole(req.path || req.originalUrl || ''); + + // 1. Check if primary creator (bypasses co-creator check) + try { + const ownershipVerdict = await checkCreatorProfileOwnership( + wallet, + keyId + ); + if (ownershipVerdict.status === 'granted') { + return next(); + } + } catch { + // Fall through to co-creator check if ownership check errors + } + + // 2. Fetch co-creator detail with 60s cache + const detail = await getCoCreatorDetail(keyId, wallet); + const currentRole = detail && detail.active ? detail.role : null; + const upperCurrentRole = currentRole ? currentRole.toUpperCase() : null; + + // ADMIN role passes all creator-action endpoints + if (upperCurrentRole === 'ADMIN') { + return next(); + } + + // CONTENT role allowed on announcements only + if (upperCurrentRole === 'CONTENT' && requiredRole === 'CONTENT') { + return next(); + } + + // REVENUE role allowed on revenue/royalties only + if (upperCurrentRole === 'REVENUE' && requiredRole === 'REVENUE') { + return next(); + } + + // Access denied: return 403 with role_required and current_role + const errorBody = { + success: false, + error: { + code: ErrorCode.FORBIDDEN, + message: `Access denied. Role "${requiredRole}" required, current role: "${currentRole ?? 'none'}".`, + role_required: requiredRole, + current_role: currentRole ?? null, + details: [ + { field: 'role_required', message: requiredRole }, + { field: 'current_role', message: currentRole ?? 'none' }, + ], + }, + }; + + if (typeof res.setHeader === 'function') { + res.setHeader('Content-Type', 'application/json'); + } + res.status(403).json(errorBody); + } catch (error) { + logger.error( + { error, path: req.path }, + 'Co-creator permission validation failed' + ); + sendError( + res, + 500, + ErrorCode.INTERNAL_ERROR, + 'Failed to validate co-creator permissions.' + ); + } + }; +}