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
204 changes: 204 additions & 0 deletions src/middlewares/co-creator-permission.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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',
}),
})
);
});
});
182 changes: 182 additions & 0 deletions src/middlewares/co-creator-permission.middleware.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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.'
);
}
};
}
Loading