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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ and post-commit watch notifications.

This service includes the forums schema, Prisma client export, topic read workflows, transactional command-side workflows, per-member post reactions, centralized forums authorization and runtime moderation for topics, posts, watches, and explicit read-state updates, plus best-effort watch notification publishing for new posts and successful allowed child-topic starter posts. The policy resolves inherited topic restrictions, challenge access, resource-role/copilot elevation, role matching, ownership, scoped M2M write access, and M2M on-behalf target-member visibility before writes, reads, or notification delivery. Runtime moderation enforces active member bans, trusted exact-IP bans for human request traffic, and locked-topic mutation rules.

Topic reads are exposed under `read:forums-topics`. `GET /v6/forums/topics` returns visible non-challenge root topics; `GET /v6/forums/topics/challenges/:challengeId` checks base challenge visibility before returning visible challenge roots; `GET /v6/forums/topics/:topicId/children` requires parent visibility before filtering direct children; and `GET /v6/forums/topics/:topicId` returns topic detail with an embedded post tree. Active member bans and trusted exact-IP bans return 403 before read policy or query work. Topic summaries include `locked`, `lockedBy`, and `lockedAt`; `lockedBy` and `lockedAt` may be null for imported legacy locked topics. Locked topics remain readable for callers that otherwise pass moderation and visibility checks. Detail embeds posts under `read:forums-topics`, keeps deleted post placeholders with null content, counts only non-deleted posts, derives unread state from `TopicReadState.lastReadAt`, and includes each post's shared `thumbsUpCount`/`thumbsDownCount` plus the authenticated member's nullable `viewerReaction`. `read:forums-posts` remains reserved for future post-specific read APIs in v1.
Topic reads are exposed under `read:forums-topics`. `GET /v6/forums/topics` returns visible non-challenge root topics; `GET /v6/forums/topics/challenges/:challengeId` checks base challenge visibility before returning visible challenge roots; `GET /v6/forums/topics/:topicId/children` requires parent visibility before filtering direct children; and `GET /v6/forums/topics/:topicId` returns topic detail with an embedded post tree. Active member bans and trusted exact-IP bans return 403 before read policy or query work. Topic summaries include `locked`, `lockedBy`, and `lockedAt`; `lockedBy` and `lockedAt` may be null for imported legacy locked topics. Locked topics remain readable for callers that otherwise pass moderation and visibility checks. Detail embeds posts under `read:forums-topics`, orders every top-level and nested sibling list chronologically from oldest to newest, keeps deleted post placeholders with null content, counts only non-deleted posts, derives unread state from `TopicReadState.lastReadAt`, and includes each post's current challenge-resource `authorIsCopilot` projection, shared `thumbsUpCount`/`thumbsDownCount`, and the authenticated member's nullable `viewerReaction`. `read:forums-posts` remains reserved for future post-specific read APIs in v1.

Human members set or switch their one-per-post reaction with `PUT /v6/forums/posts/:postId/reaction` and `{ "reaction": "THUMBS_UP" }` or `{ "reaction": "THUMBS_DOWN" }`. `DELETE` on the same route idempotently removes the member's reaction. Both commands return the resulting `viewerReaction` and current shared counts, enforce runtime bans and inherited post visibility, and reject deleted posts. They remain available on locked topics because reactions do not change discussion content. The reaction routes are human-member-only; M2M callers cannot own reaction state.

Top-level non-challenge topics may be created by human admins and scoped M2M callers. Top-level challenge topics may be created by eligible challenge members, challenge copilots, and admins; M2M callers cannot create challenge roots. Regular authenticated members may create child topics only under parents they can see and only when the resolved effective child context remains non-challenge; requests that inherit or introduce a non-null `challengeId` under a parent are rejected before writes. Allowed child topics must keep monotonic role restrictions: inherited roles cannot be cleared or replaced. Challenge-scoped visibility is verified through the configured challenge and resource adapters, including challenge-resource membership and challenge-copilot elevation. Challenge copilots may access or moderate challenge-scoped forums only when any effective `roleName` forum restriction is also satisfied; admin and scoped M2M bypass behavior is unchanged. Active member bans and trusted exact-IP bans return 403 for human writes before content, watch, or read-state changes. M2M on-behalf watch and read-state commands enforce active bans on the resolved target member and do not evaluate IP bans. Locked topics reject child-topic creation under the locked parent, replies, topic updates/deletes, and post updates/deletes unless the actor is an administrator or a human challenge copilot acting on a challenge-scoped topic. Topic authors may edit their own topics, but deletion remains limited to administrators and scoped M2M callers; challenge-copilot elevation does not grant deletion. Optional command DTO fields whose transformed runtime value is `undefined` are treated as omitted, including post parent fields and announcement state.
Top-level non-challenge topics may be created by human admins and scoped M2M callers. Top-level challenge topics may be created by eligible challenge members, challenge copilots, and admins; M2M callers cannot create challenge roots. Regular authenticated members may create child topics only under parents they can see and only when the resolved effective child context remains non-challenge; requests that inherit or introduce a non-null `challengeId` under a parent are rejected before writes. Allowed child topics must keep monotonic role restrictions: inherited roles cannot be cleared or replaced. Challenge-scoped visibility is verified through the configured challenge and resource adapters, including challenge-resource membership and challenge-copilot elevation. Challenge copilots may access or moderate challenge-scoped forums only when any effective `roleName` forum restriction is also satisfied; admin and scoped M2M bypass behavior is unchanged. Active member bans and trusted exact-IP bans return 403 for human writes before content, watch, or read-state changes. M2M on-behalf watch and read-state commands enforce active bans on the resolved target member and do not evaluate IP bans. Locked topics reject child-topic creation under the locked parent, replies, topic updates/deletes, and post updates/deletes unless the actor is an administrator or a human challenge copilot acting on a challenge-scoped topic. Topic and post authors may edit their own content, but deletion remains limited to administrators and scoped M2M callers; challenge-copilot elevation does not grant deletion. Optional command DTO fields whose transformed runtime value is `undefined` are treated as omitted, including post parent fields and announcement state.

Moderation management is exposed under `/v6/forums/moderation`. Human callers must have the case-insensitive `administrator` role; M2M callers must have `moderate:forums`. Human tokens do not gain moderation-route access from scopes alone, and M2M tokens do not gain access from roles alone. Challenge copilots do not gain moderation-endpoint access unless they are also administrators. Topic lock/unlock endpoints return `topicId`, `locked`, `lockedBy`, `lockedAt`, and `updatedAt`. Member and IP ban endpoints return the persisted ban row plus an `active` flag. Ban audit columns store the human administrator member id when available; M2M moderation stores null audit member ids. IP moderation accepts only exact bare IPv4 or IPv6 host values and rejects CIDR, wildcards, comma-delimited values, bracketed IPv6, host:port, quoted values, and invalid text.

Expand Down Expand Up @@ -75,8 +75,9 @@ DATABASE_URL="postgresql://user:password@localhost:5432/forums"
AUTH_SECRET="replace-with-a-secure-secret"
VALID_ISSUERS='["https://topcoder-dev.auth0.com/","https://auth.topcoder-dev.com/","https://topcoder.auth0.com/","https://auth.topcoder.com/","https://api.topcoder.com","https://api.topcoder-dev.com"]'
SENDGRID_NOTIFICATION_TEMPLATE="sendgrid-template-id"
BUS_API_URL="http://localhost:4000/eventBus"
BUSAPI_URL="http://localhost:4000/eventBus"
BUSAPI_URL="https://api.topcoder-dev.com/v6"
BUS_API_URL="https://api.topcoder-dev.com/v6/bus/events"
TOPCODER_API_URL_BASE="https://api.topcoder-dev.com"
KAFKA_ERROR_TOPIC="common.error.reporting"
AUTH0_URL="https://auth.topcoder-dev.com/"
AUTH0_AUDIENCE="https://m2m.topcoder-dev.com/"
Expand All @@ -96,7 +97,7 @@ PORT=3000
`VANILLA_DB_URL` is used only by the standalone Vanilla import CLI for legacy MySQL reads. The runtime HTTP service does not connect to Vanilla.
`AUTH_SECRET` is required; the service fails during startup when it is omitted.
`SENDGRID_NOTIFICATION_TEMPLATE` enables forum watch notification emails. When omitted, notification publishing is skipped and content writes still succeed.
`BUS_API_URL` or `BUSAPI_URL` configures the shared event-bus endpoint for `external.action.email`. `KAFKA_ERROR_TOPIC`, `AUTH0_URL`, `AUTH0_AUDIENCE`, `TOKEN_CACHE_TIME`, `M2M_CLIENT_ID`, `M2M_CLIENT_SECRET`, and `AUTH0_PROXY_SERVER_URL` are passed to the standard bus wrapper for outbound authenticated publishing.
`BUSAPI_URL` configures the shared Bus API v6 base for `external.action.email`; the backwards-compatible `BUS_API_URL` alias may contain either that base or the complete `/v6/bus/events` endpoint. Both values are normalized to the `/v6` base because `tc-bus-api-wrapper` appends `/bus/events`, and conflicting aliases or legacy `/eventBus` and `/v5` values are rejected. When neither alias is set, the service derives the v6 base from `TOPCODER_API_URL_BASE`. `KAFKA_ERROR_TOPIC`, `AUTH0_URL`, `AUTH0_AUDIENCE`, `TOKEN_CACHE_TIME`, `M2M_CLIENT_ID`, `M2M_CLIENT_SECRET`, and `AUTH0_PROXY_SERVER_URL` are passed to the standard bus wrapper for outbound authenticated publishing.
`TRUST_FORWARDED_CLIENT_IP=true` enables forwarded client-IP moderation using the first exact IPv4/IPv6 host from trusted forwarding headers. When disabled, or when the forwarded value is missing, malformed, CIDR, wildcard, or otherwise non-exact, no client IP is resolved and IP-ban enforcement is skipped for that request. Do not enable this unless the service is behind infrastructure that strips or controls inbound forwarding headers.

Health and readiness checks intentionally remain DB-only; they do not validate
Expand Down
6 changes: 4 additions & 2 deletions src/config/notifications.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
* notification publisher. Missing template configuration disables notification
* publishing without affecting forum writes.
*
* @returns SendGrid template, event bus, Kafka error topic, and Auth0 M2M values.
* @returns SendGrid template, Bus API aliases/base, Kafka error topic, and Auth0 M2M values.
* @throws Does not throw; consumers decide whether optional values are required.
*/
export default () => ({
notifications: {
sendgridNotificationTemplate: process.env.SENDGRID_NOTIFICATION_TEMPLATE,
busApiUrl: process.env.BUS_API_URL ?? process.env.BUSAPI_URL,
busApiUrl: process.env.BUSAPI_URL,
busApiUrlAlias: process.env.BUS_API_URL,
topcoderApiUrlBase: process.env.TOPCODER_API_URL_BASE,
kafkaErrorTopic: process.env.KAFKA_ERROR_TOPIC,
auth0Url: process.env.AUTH0_URL,
auth0Audience: process.env.AUTH0_AUDIENCE,
Expand Down
11 changes: 9 additions & 2 deletions src/forums/dto/forums-read.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,12 @@ export class ForumsPostTreeNodeDto {
@ApiProperty({ description: 'Handle captured on the post author snapshot.' })
authorHandle: string;

@ApiProperty({
description:
'Whether the author currently holds a copilot resource role on the effective challenge.',
})
authorIsCopilot: boolean;

@ApiProperty({
description:
'Number of non-deleted posts by this author in the current topic.',
Expand Down Expand Up @@ -312,7 +318,7 @@ export class ForumsPostTreeNodeDto {
viewerReaction: PostReactionType | null;

@ApiProperty({
description: 'Nested replies ordered by newest visible subtree activity.',
description: 'Nested replies ordered chronologically from oldest to newest.',
type: () => [ForumsPostTreeNodeDto],
})
replies: ForumsPostTreeNodeDto[];
Expand All @@ -333,7 +339,8 @@ export class ForumsTopicDetailDto {
topic: ForumsTopicSummaryDto;

@ApiProperty({
description: 'Top-level posts and nested replies for the topic.',
description:
'Top-level posts and nested replies ordered chronologically from oldest to newest.',
type: [ForumsPostTreeNodeDto],
})
posts: ForumsPostTreeNodeDto[];
Expand Down
69 changes: 69 additions & 0 deletions src/forums/event-bus.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { ConfigService } from '@nestjs/config';
import { resolveV6BusApiBase } from './event-bus.service';

/**
* Creates a nested notification configuration test double.
*
* @param values Configuration values keyed by their resolved Nest paths.
* @returns ConfigService-compatible lookup object.
* @throws Does not throw.
*/
function configWith(values: Record<string, string>): ConfigService {
return {
get: jest.fn((key: string) => values[key]),
} as unknown as ConfigService;
}

describe('resolveV6BusApiBase', () => {
it('normalizes the canonical v6 base and the legacy full-endpoint alias', () => {
expect(
resolveV6BusApiBase(
configWith({
'notifications.busApiUrl': 'https://api.topcoder-dev.com/v6/',
'notifications.busApiUrlAlias':
'https://api.topcoder-dev.com/v6/bus/events',
}),
),
).toBe('https://api.topcoder-dev.com/v6');
});

it('derives the v6 Bus base from the shared Topcoder API host', () => {
expect(
resolveV6BusApiBase(
configWith({
'notifications.topcoderApiUrlBase':
'https://api.topcoder-dev.com/',
}),
),
).toBe('https://api.topcoder-dev.com/v6');
});

it.each([
['http://localhost:4000/eventBus', 'Bus API base ending in /v6'],
['https://api.topcoder-dev.com/v5', 'Bus API base ending in /v6'],
['not-a-url', 'absolute Bus API v6 URL'],
])('rejects an invalid configured Bus URL %s', (configured, message) => {
expect(() =>
resolveV6BusApiBase(
configWith({ 'notifications.busApiUrl': configured }),
),
).toThrow(message);
});

it('rejects conflicting canonical and alias bases', () => {
expect(() =>
resolveV6BusApiBase(
configWith({
'notifications.busApiUrl': 'https://api.topcoder-dev.com/v6',
'notifications.busApiUrlAlias': 'https://api.topcoder.com/v6',
}),
),
).toThrow('resolve to different Bus API v6 bases');
});

it('rejects missing Bus and shared API configuration', () => {
expect(() => resolveV6BusApiBase(configWith({}))).toThrow(
'BUSAPI_URL, BUS_API_URL, or TOPCODER_API_URL_BASE',
);
});
});
95 changes: 92 additions & 3 deletions src/forums/event-bus.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,97 @@ type BusApiClient = {
postEvent: <T>(message: EventBusMessage<T>) => Promise<void>;
};

/**
* Resolves the Bus API v6 base consumed by `tc-bus-api-wrapper`.
*
* The wrapper appends `/bus/events` itself. The canonical `BUSAPI_URL` and
* backwards-compatible `BUS_API_URL` alias may therefore provide either the
* v6 base or the complete event URL; complete URLs are reduced to the base.
* When neither is configured, `TOPCODER_API_URL_BASE` supplies the API host.
*
* @param configService Nest configuration service containing notification settings.
* @returns An absolute URL whose path ends in `/v6`.
* @throws Error when configuration is missing, invalid, non-v6, or conflicting.
*/
export function resolveV6BusApiBase(configService: ConfigService): string {
const canonical = configService
.get<string>('notifications.busApiUrl')
?.trim();
const alias = configService
.get<string>('notifications.busApiUrlAlias')
?.trim();
const canonicalBase = canonical
? normalizeV6BusApiBase(canonical, 'BUSAPI_URL')
: undefined;
const aliasBase = alias
? normalizeV6BusApiBase(alias, 'BUS_API_URL')
: undefined;

if (canonicalBase && aliasBase && canonicalBase !== aliasBase) {
throw new Error(
'BUSAPI_URL and BUS_API_URL resolve to different Bus API v6 bases.',
);
}

if (canonicalBase) {
return canonicalBase;
}

if (aliasBase) {
return aliasBase;
}

const topcoderApiUrlBase = configService
.get<string>('notifications.topcoderApiUrlBase')
?.trim();

if (!topcoderApiUrlBase) {
throw new Error(
'BUSAPI_URL, BUS_API_URL, or TOPCODER_API_URL_BASE must configure the Bus API v6 base.',
);
}

return normalizeV6BusApiBase(
`${topcoderApiUrlBase.replace(/\/+$/, '')}/v6`,
'TOPCODER_API_URL_BASE',
);
}

/**
* Normalizes a configured Bus API base or complete event URL.
*
* @param configured Configured URL value.
* @param key Environment key represented by the value.
* @returns Absolute v6 API base without a trailing slash.
* @throws Error when the URL is invalid or does not target Bus API v6.
*/
function normalizeV6BusApiBase(configured: string, key: string): string {
let parsed: URL;

try {
parsed = new URL(configured);
} catch {
throw new Error(`${key} must be an absolute Bus API v6 URL.`);
}

let pathname = parsed.pathname.replace(/\/+$/, '');

if (pathname.endsWith('/bus/events')) {
pathname = pathname.slice(0, -'/bus/events'.length);
}

if (!pathname.endsWith('/v6')) {
throw new Error(
`${key} must be the Bus API base ending in /v6 or its /bus/events endpoint.`,
);
}

parsed.pathname = pathname;
parsed.search = '';
parsed.hash = '';
return parsed.toString().replace(/\/$/, '');
}

/**
* Local adapter for publishing forums events to the shared event bus.
*
Expand Down Expand Up @@ -98,9 +189,7 @@ export class EventBusService {
AUTH0_CLIENT_SECRET: this.configService.get<string>(
'notifications.m2mClientSecret',
),
BUSAPI_URL:
this.configService.get<string>('notifications.busApiUrl') ||
'http://localhost:4000/eventBus',
BUSAPI_URL: resolveV6BusApiBase(this.configService),
KAFKA_ERROR_TOPIC:
this.configService.get<string>('notifications.kafkaErrorTopic') ||
'common.error.reporting',
Expand Down
Loading
Loading