Skip to main content
Archived design reference — not the current architecture

These docs describe a proposed Simplified Hexagonal Architecture from an internal design exploration that was not adopted. The design principles (entity invariants, contracts, dependency inversion, load → authorize → validate → execute, in-memory-fake testing) remain useful, but the specifics below do not exist in this codebase: the package paths packages/core / packages/infra / packages/shared, the @packages/* import aliases, the main.ts wiring entry point, and the EnableArchitectureTransition feature flag. Do not follow the paths, aliases, or imports here as-is.

Authorization

← Back to README


Overview​

Authorization answers: "Can this user perform this action?"

ConcernLayerResponsibility
AuthenticationEntry PointsVerify identity (tokens, sessions)
AuthorizationCoreCheck permissions (policies, rules)

Auth Context​

The AuthContext represents the authenticated user's identity and permissions. Entry points create it, actions consume it.

// packages/core/src/shared/authorization/AuthContext.ts
export interface AuthContext {
userId: string;
roles: string[];
isAdmin: boolean;
}

Building Auth Context at Entry Points​

// apps/client/server/middlewares/baseApi.ts
import { AuthContext } from '@packages/core/shared/authorization';

export function baseApi(options: BaseApiOptions = {}) {
return {
post: (handler: Handler) => createHandler('POST', handler, options),
get: (handler: Handler) => createHandler('GET', handler, options),
// ... other methods
};
}

function createHandler(method: string, handler: Handler, options: BaseApiOptions) {
return async (req: NextApiRequest, res: NextApiResponse) => {
if (options.auth) {
const token = req.headers.authorization?.replace('Bearer ', '');

if (!token) {
return res.status(401).json({ error: 'Missing authentication token' });
}

const user = await verifyToken(token); // Your token verification logic

if (!user) {
return res.status(401).json({ error: 'Invalid token' });
}

// Build auth context for core
req.ctx = {
userId: user.id,
roles: user.roles,
isAdmin: user.roles.includes('admin'),
} satisfies AuthContext;
}

try {
await handler(req, res);
} catch (error) {
handleError(error, res);
}
};
}

Policies​

Policies are pure functions that encode authorization rules. They live in core and are grouped by feature.

Folder Structure​

packages/core/src/
├── shared/
│ └── authorization/
│ ├── AuthContext.ts # Auth context interface
│ └── index.ts # Exports
│
├── orders/
│ ├── Order.ts
│ ├── OrderRepository.ts
│ ├── OrderPolicies.ts # Order authorization policies
│ └── actions/
│
└── users/
├── User.ts
├── UserRepository.ts
├── UserPolicies.ts # User authorization policies
└── actions/

Example: Order Policies​

// packages/core/src/orders/OrderPolicies.ts
import { AuthContext } from '../shared/authorization';
import { Order } from './Order';

export const OrderPolicies = {
canView(ctx: AuthContext, order: Order): boolean {
return order.customerId === ctx.userId || ctx.isAdmin;
},

canCancel(ctx: AuthContext, order: Order): boolean {
return order.customerId === ctx.userId || ctx.isAdmin;
},

canRefund(ctx: AuthContext, order: Order): boolean {
return ctx.isAdmin;
},

canViewAll(ctx: AuthContext): boolean {
return ctx.isAdmin;
},
};

Example: User Policies​

// packages/core/src/users/UserPolicies.ts
import { AuthContext } from '../shared/authorization';
import { User } from './User';

export const UserPolicies = {
canView(ctx: AuthContext, user: User): boolean {
return user.id === ctx.userId || ctx.isAdmin;
},

canUpdate(ctx: AuthContext, user: User): boolean {
return user.id === ctx.userId || ctx.isAdmin;
},

canDelete(ctx: AuthContext, user: User): boolean {
return ctx.isAdmin;
},

canListAll(ctx: AuthContext): boolean {
return ctx.isAdmin;
},
};

Using Policies in Actions​

Actions receive AuthContext and use policies to check permissions:

// packages/core/src/orders/actions/cancelOrder.ts
import { Order } from '../Order';
import { OrderRepository } from '../OrderRepository';
import { OrderPolicies } from '../OrderPolicies';
import { AuthContext } from '../../shared/authorization';
import { NotFoundError, BusinessError } from '../../shared/errors';

export interface CancelOrderDeps {
repository: OrderRepository;
}

export interface CancelOrderInput {
orderId: string;
reason: string;
}

export async function cancelOrder(
deps: CancelOrderDeps,
ctx: AuthContext,
input: CancelOrderInput
): Promise<Order> {
// 1. Load
const order = await deps.repository.findById(input.orderId);
if (!order) {
throw new NotFoundError('Order not found');
}

// 2. Authorize
if (!OrderPolicies.canCancel(ctx, order)) {
throw new BusinessError('Not authorized to cancel this order');
}

// 3. Execute (entity validates state)
order.cancel(input.reason);

// 4. Persist
await deps.repository.save(order);

return order;
}

Action Pattern with Authorization​

The updated action flow:

1. LOAD - Fetch required data
2. AUTHORIZE - Check permissions using policies
3. VALIDATE - Business validation (optional)
4. EXECUTE - State change (entity validates invariants)
5. PERSIST - Save changes
6. SIDE EFFECTS - External notifications
export async function refundOrder(
deps: RefundOrderDeps,
ctx: AuthContext,
input: RefundOrderInput
): Promise<Order> {
// 1. LOAD
const order = await deps.repository.findById(input.orderId);
if (!order) {
throw new NotFoundError('Order not found');
}

// 2. AUTHORIZE
if (!OrderPolicies.canRefund(ctx, order)) {
throw new BusinessError('Not authorized to refund orders');
}

// 3. VALIDATE (business rules)
if (order.status !== 'paid') {
throw new BusinessError('Can only refund paid orders');
}

// 4. EXECUTE
const refundResult = await deps.paymentGateway.refund(
order.paymentId,
order.total
);
if (!refundResult.success) {
throw new BusinessError(`Refund failed: ${refundResult.error}`);
}
order.markRefunded(refundResult.transactionId);

// 5. PERSIST
await deps.repository.save(order);

// 6. SIDE EFFECTS
await deps.mailer.send(
input.email,
'Refund Processed',
`Your refund of $${order.total} has been processed.`
);

return order;
}

Entry Points Pass Context​

// apps/client/pages/api/orders/[id].ts
import { baseApi } from '@server/middlewares/baseApi';
import { cancelOrder } from '@packages/core/orders';
import { OrderRepositoryMongo } from '@packages/infra/orders/OrderRepositoryMongo';
import { cancelOrderSchema } from '../validators/orderValidators';

const deps = {
repository: new OrderRepositoryMongo(),
};

const handler = baseApi({ auth: true })
.post(async (req, res) => {
const { id } = req.query;
const parsed = cancelOrderSchema.safeParse({
orderId: id,
reason: req.body.reason,
});

if (!parsed.success) {
return res.status(400).json({
error: 'Validation failed',
details: parsed.error.flatten(),
});
}

const order = await cancelOrder(
deps,
req.ctx, // AuthContext from middleware
parsed.data // Validated input
);
res.json({ id: order.id, status: order.status });
});

export default handler;

Testing Policies​

Policies are pure functions, making them easy to test:

// packages/core/src/orders/OrderPolicies.test.ts
import { OrderPolicies } from './OrderPolicies';
import { Order } from './Order';
import { AuthContext } from '../shared/authorization';

describe('OrderPolicies', () => {
const customerCtx: AuthContext = {
userId: 'user-1',
roles: ['customer'],
isAdmin: false,
};

const adminCtx: AuthContext = {
userId: 'admin-1',
roles: ['admin'],
isAdmin: true,
};

const otherUserCtx: AuthContext = {
userId: 'user-2',
roles: ['customer'],
isAdmin: false,
};

describe('canCancel', () => {
it('allows owner to cancel', () => {
const order = new Order('o1', 'user-1', []);
expect(OrderPolicies.canCancel(customerCtx, order)).toBe(true);
});

it('allows admin to cancel any order', () => {
const order = new Order('o1', 'user-1', []);
expect(OrderPolicies.canCancel(adminCtx, order)).toBe(true);
});

it('denies non-owner from cancelling', () => {
const order = new Order('o1', 'user-1', []);
expect(OrderPolicies.canCancel(otherUserCtx, order)).toBe(false);
});
});

describe('canRefund', () => {
it('only allows admin to refund', () => {
const order = new Order('o1', 'user-1', []);
expect(OrderPolicies.canRefund(customerCtx, order)).toBe(false);
expect(OrderPolicies.canRefund(adminCtx, order)).toBe(true);
});
});
});

Complex Authorization​

For more complex scenarios (team access, resource hierarchies), extend the pattern:

Team-Based Access​

// packages/core/src/shared/authorization/AuthContext.ts
export interface AuthContext {
userId: string;
roles: string[];
isAdmin: boolean;
teamIds: string[]; // Teams the user belongs to
}

// packages/core/src/projects/ProjectPolicies.ts
export const ProjectPolicies = {
canView(ctx: AuthContext, project: Project): boolean {
return (
project.ownerId === ctx.userId ||
ctx.teamIds.includes(project.teamId) ||
ctx.isAdmin
);
},

canEdit(ctx: AuthContext, project: Project): boolean {
return (
project.ownerId === ctx.userId ||
(ctx.teamIds.includes(project.teamId) && project.isEditable) ||
ctx.isAdmin
);
},
};

Role-Based Permissions​

// packages/core/src/shared/authorization/AuthContext.ts
export interface AuthContext {
userId: string;
roles: string[];
permissions: string[]; // Fine-grained permissions
isAdmin: boolean;
}

// packages/core/src/reports/ReportPolicies.ts
export const ReportPolicies = {
canGenerate(ctx: AuthContext): boolean {
return ctx.permissions.includes('reports:generate') || ctx.isAdmin;
},

canExport(ctx: AuthContext): boolean {
return ctx.permissions.includes('reports:export') || ctx.isAdmin;
},
};

Summary​

ConceptLocationPurpose
AuthContextcore/shared/authorization/Identity and permissions
Policiescore/{feature}/Authorization rules
Context creationEntry points (middleware)Build context from token
Permission checksActionsEnforce policies

Next Steps​