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
2 changes: 1 addition & 1 deletion .snyk
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ ignore:
'SNYK-JS-OPENTELEMETRYCORE-17373280':
- '* > @opentelemetry/core':
reason: 'The fix requires migrating the Azure Functions telemetry stack from OpenTelemetry 1.x to 2.8.0 or newer. The current Azure exporter and 0.57.x SDK packages require the 1.x API family; a forced major override is type-compatible at build time but leaves mixed SDK internals. Accepted temporarily while the existing OTEL 2.x migration is completed.'
expires: '2026-07-18T00:00:00.000Z'
expires: '2026-10-31T00:00:00.000Z'
created: '2026-06-18T00:00:00.000Z'
'SNYK-JS-BRACEEXPANSION-17706650':
- '* > brace-expansion@1.1.13':
Expand Down
33 changes: 33 additions & 0 deletions apps/api/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const {
startUp,
initializeInfrastructureServices,
registerEventHandlers,
recoverDeletedStaffRoles,
registerTimer,
MockServiceApolloServer,
MockServiceClientBlobStorage,
MockServiceBlobStorage,
Expand Down Expand Up @@ -66,6 +68,8 @@ const {
startUp: vi.fn(),
initializeInfrastructureServices: vi.fn(),
registerEventHandlers: vi.fn(),
recoverDeletedStaffRoles: vi.fn(() => Promise.resolve(0)),
registerTimer: vi.fn(),
MockServiceApolloServer: HoistedServiceApolloServer,
MockServiceClientBlobStorage: HoistedServiceClientBlobStorage,
MockServiceBlobStorage: HoistedServiceBlobStorage,
Expand All @@ -85,6 +89,11 @@ const serviceRegistry = {
};

vi.mock('./service-config/otel-starter.ts', () => ({}));
vi.mock('@azure/functions', () => ({
app: {
timer: registerTimer,
},
}));
vi.mock('./cellix.ts', () => ({
Cellix: {
initializeInfrastructureServices,
Expand All @@ -108,6 +117,7 @@ vi.mock('@ocom/application-services', () => ({
}));
vi.mock('@ocom/event-handler', () => ({
RegisterEventHandlers: registerEventHandlers,
RecoverDeletedStaffRoles: recoverDeletedStaffRoles,
}));
vi.mock('./service-config/mongoose/index.ts', () => ({
mongooseConnectionString: 'mongodb://example.test/cellix',
Expand Down Expand Up @@ -254,6 +264,29 @@ describe('apps/api bootstrap', () => {
apolloServerService: { service: 'apollo' },
});
expect(registerEventHandlers).toHaveBeenCalledWith({ domain: 'data-source' });
expect(recoverDeletedStaffRoles).toHaveBeenCalledWith({ domain: 'data-source' });
expect(registerTimer).toHaveBeenCalledWith(
'staffRoleDeletionRecovery',
expect.objectContaining({
schedule: '0 * * * * *',
useMonitor: true,
retry: {
strategy: 'fixedDelay',
maxRetryCount: 3,
delayInterval: 5000,
},
}),
);
const timerOptions = registerTimer.mock.calls[0]?.[1] as
| {
handler: () => Promise<void>;
}
| undefined;
await timerOptions?.handler();
expect(recoverDeletedStaffRoles).toHaveBeenCalledTimes(2);
const timerFailure = new Error('recovery failed');
recoverDeletedStaffRoles.mockRejectedValueOnce(timerFailure);
await expect(timerOptions?.handler()).rejects.toBe(timerFailure);
});

it('registers client-signing blob storage for backend use outside production', async () => {
Expand Down
24 changes: 23 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import './service-config/otel-starter.ts';

import { app } from '@azure/functions';
import { type ApplicationServices, buildApplicationServicesFactory } from '@ocom/application-services';
import type { ApiContextSpec } from '@ocom/context-spec';
import { RegisterEventHandlers } from '@ocom/event-handler';
import { RecoverDeletedStaffRoles, RegisterEventHandlers } from '@ocom/event-handler';
import { type GraphContext, graphHandlerCreator } from '@ocom/graphql-handler';
import { restHandlerCreator } from '@ocom/rest';
import { ServiceApolloServer } from '@ocom/service-apollo-server';
Expand All @@ -19,6 +20,7 @@ import * as TokenValidationConfig from './service-config/token-validation/index.

const { NODE_ENV } = process.env;
const isProd = NODE_ENV === 'production';
let recoverDeletedStaffRolesForHost: (() => Promise<number>) | undefined;

Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>((serviceRegistry) => {
serviceRegistry
Expand Down Expand Up @@ -53,6 +55,10 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>((se

const { domainDataSource } = dataSourcesFactory.withSystemPassport();
RegisterEventHandlers(domainDataSource);
recoverDeletedStaffRolesForHost = () => RecoverDeletedStaffRoles(domainDataSource);
void recoverDeletedStaffRolesForHost().catch((error: unknown) => {
console.error('Failed to recover deleted staff role events during application startup', error);
});

return {
dataSourcesFactory,
Expand All @@ -69,3 +75,19 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>((se
)
.registerAzureFunctionHttpHandler('rest', { route: '{communityId}/{role}/{memberId}/{*rest}' }, restHandlerCreator)
.startUp();

app.timer('staffRoleDeletionRecovery', {
schedule: '0 * * * * *',
useMonitor: true,
retry: {
strategy: 'fixedDelay',
maxRetryCount: 3,
delayInterval: 5000,
},
handler: async () => {
if (!recoverDeletedStaffRolesForHost) {
throw new Error('Staff role deletion recovery is unavailable before application startup completes');
}
await recoverDeletedStaffRolesForHost();
},
});
4 changes: 2 additions & 2 deletions apps/ui-staff/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export default function App() {
}

function StaffSection({ identity }: { identity: Parameters<typeof StaffAuthProvider>[0]['value'] }) {
const { permissions, enterpriseAppRole, user, loading } = useStaffPermissions();
const { permissions, enterpriseAppRole, currentRoleId, user, loading } = useStaffPermissions();

if (loading) {
return (
Expand All @@ -153,7 +153,7 @@ function StaffSection({ identity }: { identity: Parameters<typeof StaffAuthProvi
}

return (
<StaffAuthProvider value={{ ...identity, permissions, enterpriseAppRole, name: user?.displayName, email: user?.email }}>
<StaffAuthProvider value={{ ...identity, permissions, enterpriseAppRole, currentRoleId, name: user?.displayName, email: user?.email }}>
<StaffRoutes />
</StaffAuthProvider>
);
Expand Down
42 changes: 42 additions & 0 deletions apps/ui-staff/src/hooks/use-staff-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { deriveStaffPermissions } from './use-staff-permissions.ts';

const makeRolePermissions = (canRemoveRole: boolean) => ({
communityPermissions: {
canManageCommunities: false,
canManageStaffRolesAndPermissions: true,
},
userPermissions: {
canManageUsers: false,
canAssignStaffRoles: false,
canViewStaffUsers: false,
},
staffRolePermissions: {
canViewRoles: false,
canAddRole: false,
canEditRole: false,
canRemoveRole,
},
financePermissions: {
canManageFinance: false,
},
techAdminPermissions: {
canManageTechAdmin: true,
},
});

describe('deriveStaffPermissions', () => {
it('does not infer role removal from broader management permissions', () => {
const permissions = deriveStaffPermissions(makeRolePermissions(false));

expect(permissions?.canManageStaffRolesAndPermissions).toBe(true);
expect(permissions?.canEditRole).toBe(true);
expect(permissions?.canRemoveRole).toBe(false);
});

it('grants role removal when the explicit permission is present', () => {
const permissions = deriveStaffPermissions(makeRolePermissions(true));

expect(permissions?.canRemoveRole).toBe(true);
});
});
45 changes: 26 additions & 19 deletions apps/ui-staff/src/hooks/use-staff-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,33 @@ interface StaffUserQueryResult {
};
}

type RolePermissions = NonNullable<NonNullable<StaffUserQueryResult['currentStaffUserAndCreateIfNotExists']['role']>['permissions']>;

export const deriveStaffPermissions = (rolePermissions: RolePermissions | undefined): StaffPermissions | undefined => {
if (!rolePermissions) {
return undefined;
}

const isTechAdmin = rolePermissions.techAdminPermissions.canManageTechAdmin;
return {
canManageCommunities: rolePermissions.communityPermissions.canManageCommunities || isTechAdmin,
canManageStaffRolesAndPermissions: rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canManageUsers: rolePermissions.userPermissions.canManageUsers || isTechAdmin,
canAssignStaffRoles: rolePermissions.userPermissions.canAssignStaffRoles || isTechAdmin,
canViewStaffUsers: rolePermissions.userPermissions.canViewStaffUsers || rolePermissions.userPermissions.canManageUsers || isTechAdmin,
canManageFinance: rolePermissions.financePermissions.canManageFinance || isTechAdmin,
canManageTechAdmin: isTechAdmin,
canViewRoles: rolePermissions.staffRolePermissions.canViewRoles || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canAddRole: rolePermissions.staffRolePermissions.canAddRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canEditRole: rolePermissions.staffRolePermissions.canEditRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canRemoveRole: rolePermissions.staffRolePermissions.canRemoveRole,
};
};

export const useStaffPermissions = (): {
permissions: StaffPermissions | undefined;
enterpriseAppRole: string | undefined;
currentRoleId: string | undefined;
user: { id?: string; displayName?: string; firstName?: string; lastName?: string; email?: string } | undefined;
loading: boolean;
error: Error | undefined;
Expand All @@ -91,29 +115,12 @@ export const useStaffPermissions = (): {

const rolePermissions = data?.currentStaffUserAndCreateIfNotExists?.role?.permissions;
const currentUser = data?.currentStaffUserAndCreateIfNotExists;

// Treat a TechAdmin as an implicit manager of all sections
const isTechAdmin = rolePermissions?.techAdminPermissions?.canManageTechAdmin ?? false;

const permissions: StaffPermissions | undefined = rolePermissions
? {
canManageCommunities: rolePermissions.communityPermissions.canManageCommunities || isTechAdmin,
canManageStaffRolesAndPermissions: rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canManageUsers: rolePermissions.userPermissions.canManageUsers || isTechAdmin,
canAssignStaffRoles: rolePermissions.userPermissions.canAssignStaffRoles || isTechAdmin,
canViewStaffUsers: rolePermissions.userPermissions.canViewStaffUsers || rolePermissions.userPermissions.canManageUsers || isTechAdmin,
canManageFinance: rolePermissions.financePermissions.canManageFinance || isTechAdmin,
canManageTechAdmin: isTechAdmin,
canViewRoles: rolePermissions.staffRolePermissions.canViewRoles || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canAddRole: rolePermissions.staffRolePermissions.canAddRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canEditRole: rolePermissions.staffRolePermissions.canEditRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canRemoveRole: rolePermissions.staffRolePermissions.canRemoveRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
}
: undefined;
const permissions = deriveStaffPermissions(rolePermissions);

return {
permissions,
enterpriseAppRole: data?.currentStaffUserAndCreateIfNotExists?.role?.enterpriseAppRole,
currentRoleId: data?.currentStaffUserAndCreateIfNotExists?.role?.id,
user: currentUser
? {
id: currentUser.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Feature: NodeEventBus
Then span.setStatus should be called with ERROR
And recordException should be called
And the span should be ended
And the error should NOT be propagated
And the error should be propagated

Scenario: dispatch catch block is triggered when broadcaster throws synchronously
Given the NodeEventBusInstance singleton
Expand All @@ -52,23 +52,23 @@ Feature: NodeEventBus
And the event is dispatched
Then all handlers should be called in the order they were registered

Scenario: Multiple handlers for the same event, one throws, errors not propagated
Scenario: Multiple handlers for the same event, one throws
Given multiple handlers for the same event class
When all handlers are registered and one throws
And the event is dispatched
Then all handlers should be called and errors are not propagated
Then all handlers should be called and the error is propagated

Scenario: Multiple handlers for the same event, all throw, errors not propagated
Scenario: Multiple handlers for the same event, all throw
Given multiple handlers for the same event class
When all handlers are registered and all throw
And the event is dispatched
Then all handlers should be called and errors are not propagated
Then all handlers should be called and an aggregate error is propagated

Scenario: Dispatch does not wait for handler completion
Scenario: Dispatch waits for handler completion
Given a handler for an event that is asynchronous
When the handler is registered
And the event is dispatched
Then dispatch should resolve before the handler completes
Then dispatch should resolve after the handler completes

Scenario: No handlers registered for an event
When the event is dispatched
Expand Down
33 changes: 16 additions & 17 deletions packages/cellix/event-bus-seedwork-node/src/node-event-bus.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { CustomDomainEventImpl } from '@cellix/domain-seedwork/domain-event';
import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber';
import { expect, vi } from 'vitest';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber';
import { CustomDomainEventImpl } from '@cellix/domain-seedwork/domain-event';
import { expect, vi } from 'vitest';
import { NodeEventBusInstance } from './node-event-bus.ts';
import type { AsyncHandlerMock } from './test-handler.types.ts';

// --- Mocks for OpenTelemetry and performance ---

const test = { for: describeFeature };
Expand Down Expand Up @@ -187,7 +188,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
}));
});
When('the event is dispatched', async () => {
await expect(NodeEventBusInstance.dispatch(errorEvent, { test: 'fail' })).resolves.not.toThrow();
await expect(NodeEventBusInstance.dispatch(errorEvent, { test: 'fail' })).rejects.toThrow('handler error');
});
Then('span.setStatus should be called with ERROR', () => {
expect(spanMock.setStatus).toHaveBeenCalledWith(expect.objectContaining({ code: 2 }));
Expand All @@ -198,8 +199,8 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
And('the span should be ended', () => {
expect(spanMock.end).toHaveBeenCalled();
});
And('the error should NOT be propagated', () => {
// Already checked by .resolves.not.toThrow()
And('the error should be propagated', () => {
// Already checked by .rejects.toThrow()
});
});

Expand Down Expand Up @@ -248,7 +249,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
};
});
When('dispatch is called for an event', async () => {
await expect(NodeEventBusInstance.dispatch(TestEvent, { test: 'fail' })).resolves.not.toThrow();
await expect(NodeEventBusInstance.dispatch(TestEvent, { test: 'fail' })).rejects.toThrow('sync broadcast error');
});
Then('span.setStatus should be called with ERROR', () => {
expect(spanMock.setStatus).toHaveBeenCalledWith(expect.objectContaining({ code: 2 }));
Expand Down Expand Up @@ -291,7 +292,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
});
});

Scenario('Multiple handlers for the same event, one throws, errors not propagated', ({ Given, When, And, Then }) => {
Scenario('Multiple handlers for the same event, one throws', ({ Given, When, And, Then }) => {
Given('multiple handlers for the same event class', () => {
handler1 = vi.fn(() => Promise.reject(new Error('handler1 error')));
handler2 = vi.fn(async () => undefined);
Expand All @@ -301,15 +302,15 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
NodeEventBusInstance.register(TestEvent, handler2);
});
And('the event is dispatched', async () => {
await expect(NodeEventBusInstance.dispatch(TestEvent, { test: 'data' })).resolves.not.toThrow();
await expect(NodeEventBusInstance.dispatch(TestEvent, { test: 'data' })).rejects.toThrow('handler1 error');
});
Then('all handlers should be called and errors are not propagated', () => {
Then('all handlers should be called and the error is propagated', () => {
expect(handler1).toHaveBeenCalledWith({ test: 'data' });
expect(handler2).toHaveBeenCalledWith({ test: 'data' });
});
});

Scenario('Multiple handlers for the same event, all throw, errors not propagated', ({ Given, When, And, Then }) => {
Scenario('Multiple handlers for the same event, all throw', ({ Given, When, And, Then }) => {
Given('multiple handlers for the same event class', () => {
handler1 = vi.fn(() => Promise.reject(new Error('handler1 error')));
handler2 = vi.fn(() => Promise.reject(new Error('handler2 error')));
Expand All @@ -319,15 +320,15 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
NodeEventBusInstance.register(TestEvent, handler2);
});
And('the event is dispatched', async () => {
await expect(NodeEventBusInstance.dispatch(TestEvent, { test: 'data' })).resolves.not.toThrow();
await expect(NodeEventBusInstance.dispatch(TestEvent, { test: 'data' })).rejects.toThrow(AggregateError);
});
Then('all handlers should be called and errors are not propagated', () => {
Then('all handlers should be called and an aggregate error is propagated', () => {
expect(handler1).toHaveBeenCalledWith({ test: 'data' });
expect(handler2).toHaveBeenCalledWith({ test: 'data' });
});
});

Scenario('Dispatch does not wait for handler completion', ({ Given, When, And, Then }) => {
Scenario('Dispatch waits for handler completion', ({ Given, When, And, Then }) => {
let handlerStarted = false;
let handlerCompleted = false;
let asyncHandler: TestEventHandler;
Expand All @@ -346,11 +347,9 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => {
await Promise.resolve(); // allow microtasks to run
expect(handlerStarted).toBe(true);
await dispatchPromise;
expect(handlerCompleted).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 60));
expect(handlerCompleted).toBe(true);
});
Then('dispatch should resolve before the handler completes', () => {
Then('dispatch should resolve after the handler completes', () => {
// Checked in the And step above
});
});
Expand Down
Loading
Loading