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
75 changes: 75 additions & 0 deletions src/server/core/domain/channel/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Canonical wire codes for channel-domain errors.
*
* The daemon transport forwards a thrown {@link ChannelError}'s `code` verbatim
* in its `{success: false, code, error}` envelope, so these strings are part of
* the client-facing contract. Trimmed to the codes the skeleton needs; more
* land with the milestones that introduce them.
*/
export const CHANNEL_ERROR_CODE = {
DISABLED: 'CHANNEL_DISABLED',
INVALID_REQUEST: 'CHANNEL_INVALID_REQUEST',
NOT_IMPLEMENTED: 'CHANNEL_NOT_IMPLEMENTED'
} as const

/**
* Represents any channel-domain error. Carries the canonical wire `code` and
* optional structured `details` for the transport error envelope.
*/
export class ChannelError extends Error {
public readonly code: string
public readonly details?: unknown

public constructor(message: string, code: string, details?: unknown) {
super(message)
this.name = 'ChannelError'
this.code = code
this.details = details
}
}

/**
* Signals that a `channel:*` event is registered but has no behavior yet. The
* skeleton throws this from every handler once the payload validates, so the
* wire surface stays stable while later milestones supply behavior.
*/
export class ChannelNotImplementedError extends ChannelError {
public constructor(event?: string) {
super(
event === undefined
? 'channel operation is not implemented yet'
: `channel operation '${event}' is not implemented yet`,
CHANNEL_ERROR_CODE.NOT_IMPLEMENTED
)
this.name = 'ChannelNotImplementedError'
}
}

/**
* Signals that the channel surface is administratively disabled on this host
* (`BRV_CHANNELS_ENABLED` is set to a falsy value). A stub throwing this is
* registered for every `channel:*` event so the Socket.IO ack still fires
* instead of hanging.
*/
export class ChannelDisabledError extends ChannelError {
public constructor(message?: string) {
super(
message ??
'channel surface is disabled on this host (BRV_CHANNELS_ENABLED is set to a falsy value)',
CHANNEL_ERROR_CODE.DISABLED,
)
this.name = 'ChannelDisabledError'
}
}

/**
* Signals that a request payload failed schema validation at the wire boundary.
* `details` carries the flattened zod error so clients can surface specific
* field problems.
*/
export class ChannelInvalidRequestError extends ChannelError {
public constructor(message: string, details?: unknown) {
super(message, CHANNEL_ERROR_CODE.INVALID_REQUEST, details)
this.name = 'ChannelInvalidRequestError'
}
}
28 changes: 28 additions & 0 deletions src/server/infra/process/feature-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ import {FileReviewBackupStore} from '../storage/file-review-backup-store.js'
import {createTokenStore} from '../storage/token-store.js'
import {HttpTeamService} from '../team/http-team-service.js'
import {FsTemplateLoader} from '../template/fs-template-loader.js'
import {channelsEnabled, registerDisabledStubs} from '../transport/handlers/channel-disabled-handler.js'
import {ChannelCancelHandler} from '../transport/handlers/channel/channel-cancel-handler.js'
import {ChannelCreateHandler} from '../transport/handlers/channel/channel-create-handler.js'
import {ChannelGetHandler} from '../transport/handlers/channel/channel-get-handler.js'
import {ChannelInviteHandler} from '../transport/handlers/channel/channel-invite-handler.js'
import {ChannelListHandler} from '../transport/handlers/channel/channel-list-handler.js'
import {ChannelListTurnsHandler} from '../transport/handlers/channel/channel-list-turns-handler.js'
import {ChannelMentionHandler} from '../transport/handlers/channel/channel-mention-handler.js'
import {ChannelOnboardHandler} from '../transport/handlers/channel/channel-onboard-handler.js'
import {ChannelShowHandler} from '../transport/handlers/channel/channel-show-handler.js'
import {ChannelSubscribeHandler} from '../transport/handlers/channel/channel-subscribe-handler.js'
import {
AuthHandler,
BillingHandler,
Expand Down Expand Up @@ -136,6 +147,23 @@ export async function setupFeatureHandlers({
new ConfigHandler({transport}).setup()
new SettingsHandler({store: settingsStore, transport}).setup()

// Channel handlers are gated behind BRV_CHANNELS_ENABLED (opt-out). When the
// surface is disabled, register stubs so `channel:*` requests still ack.
if (channelsEnabled()) {
new ChannelCreateHandler(transport).setup()
new ChannelListHandler(transport).setup()
new ChannelGetHandler(transport).setup()
new ChannelInviteHandler(transport).setup()
new ChannelOnboardHandler(transport).setup()
new ChannelMentionHandler(transport).setup()
new ChannelShowHandler(transport).setup()
new ChannelListTurnsHandler(transport).setup()
new ChannelSubscribeHandler(transport).setup()
new ChannelCancelHandler(transport).setup()
} else {
registerDisabledStubs(transport)
}

new AuthHandler({
authService: new OAuthService(authConfig),
authStateStore,
Expand Down
44 changes: 44 additions & 0 deletions src/server/infra/transport/handlers/channel-disabled-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type {ITransportServer} from '../../../core/interfaces/transport/index.js'

import {ChannelEvents} from '../../../../shared/transport/events/channel-events.js'
import {ChannelDisabledError} from '../../../core/domain/channel/errors.js'

/** Every `channel:*` request event the live per-event handlers register. */
const STUBBABLE_EVENTS = [
ChannelEvents.CREATE,
ChannelEvents.LIST,
ChannelEvents.GET,
ChannelEvents.INVITE,
ChannelEvents.ONBOARD,
ChannelEvents.MENTION,
ChannelEvents.SHOW,
ChannelEvents.LIST_TURNS,
ChannelEvents.SUBSCRIBE,
ChannelEvents.CANCEL,
] as const
Comment on lines +7 to +18
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (maintainability): STUBBABLE_EVENTS re-lists the same 10 events that feature-handlers.ts instantiates handlers for (lines 153-162). If a new channel:* request event is added later (e.g. M4's channel:permission-decision), three places need to stay in sync: the per-event handler instantiation in feature-handlers.ts, this list, and ChannelEvents itself. A drift here means a request event registered in the "enabled" branch silently hangs in the "disabled" branch with no ack.

One option: drive both from a single source — e.g. a CHANNEL_REQUEST_EVENTS tuple exported from channel-events.ts, used by both feature-handlers.ts (as keys for the handler-instance map) and registerDisabledStubs. Out of scope for the skeleton, but worth a TODO if you don't want to do it now.


/**
* Registers a stub for every `channel:*` request event that throws
* {@link ChannelDisabledError}. Without it, a `channel:*` request sent while the
* surface is disabled would never receive an ack and the client would hang.
* Returns the list of stubbed events.
*/
export const registerDisabledStubs = (transport: ITransportServer): readonly string[] => {
for (const event of STUBBABLE_EVENTS) {
transport.onRequest(event, () => {
throw new ChannelDisabledError()
})
}

return STUBBABLE_EVENTS
}

/**
* Reports whether the channel surface is enabled. Opt-out: enabled unless
* `BRV_CHANNELS_ENABLED` is `0`, `false`, `no`, or `off` (case-insensitive).
*/
export const channelsEnabled = (env: NodeJS.ProcessEnv = process.env): boolean => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (code quality): Default-arg evaluation is fine here, but the gate is only consulted once at boot in feature-handlers.ts — using process.env directly inside is a tiny readability win (the parameter signals "this is testable" — keep it). The bigger note: BRV_CHANNELS_ENABLED should land in CLAUDE.md's Environment section so future contributors can find it; right now the only documentation of this env var is this file's docstring.

const value = env.BRV_CHANNELS_ENABLED
if (value === undefined) return true
return !['0', 'false', 'no', 'off'].includes(value.trim().toLowerCase())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type {ITransportServer} from '../../../../core/interfaces/transport/index.js'
import type {ITransportHandler} from '../i-transport-handler.js'

import {
ChannelCancelRequest,
ChannelCancelRequestSchema,
ChannelCancelResponse,
ChannelEvents,
} from '../../../../../shared/transport/events/channel-events.js'
import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js'
import {parseOrThrow} from './parse-or-throw.js'

/**
* Handles `channel:cancel`. `handle` validates the payload, then throws
* {@link ChannelNotImplementedError}; the cancel behavior lands in a later
* milestone, written after the validation step in `handle`.
*/
export class ChannelCancelHandler
implements ITransportHandler<ChannelCancelRequest, ChannelCancelResponse>
{
private readonly event: string
private readonly transportServer: ITransportServer

public constructor(transportServer: ITransportServer) {
this.event = ChannelEvents.CANCEL
this.transportServer = transportServer
}

public async handle(request: ChannelCancelRequest): Promise<ChannelCancelResponse> {
parseOrThrow(ChannelCancelRequestSchema, request)
throw new ChannelNotImplementedError(this.event)
}

public setup(): void {
this.transportServer.onRequest<ChannelCancelRequest, ChannelCancelResponse>(this.event, (request) =>
this.handle(request),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type {ITransportServer} from "../../../../core/interfaces/transport/index.js";
import type {ITransportHandler} from "../i-transport-handler.js";

import {type ChannelCreateRequest, ChannelCreateRequestSchema, type ChannelCreateResponse, ChannelEvents} from "../../../../../shared/transport/events/channel-events.js";
import {ChannelNotImplementedError} from "../../../../core/domain/channel/errors.js";
import {parseOrThrow} from "./parse-or-throw.js";

export class ChannelCreateHandler implements ITransportHandler<ChannelCreateRequest, ChannelCreateResponse> {
private readonly event: string
private readonly transportServer: ITransportServer

public constructor(transportServer: ITransportServer) {
this.event = ChannelEvents.CREATE
this.transportServer = transportServer
}

public async handle(request: ChannelCreateRequest): Promise<ChannelCreateResponse> {
parseOrThrow(ChannelCreateRequestSchema, request)
throw new ChannelNotImplementedError(this.event)
}

public setup(): void {
this.transportServer.onRequest<ChannelCreateRequest, ChannelCreateResponse>(this.event, request => this.handle(request))
}
}
Comment on lines +1 to +25
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (style): This file is the odd one out — all 9 sibling handlers use single quotes, no semicolons, multi-line imports, a class-level JSDoc, and (request) => … arrow syntax. This handler uses double quotes, semicolons, an inlined import, no JSDoc, and request => …. The PR body claims npm run lint is clean (so perhaps Prettier isn't enforced cross-file), but the visual drift across an otherwise uniform skeleton is jarring and will keep showing up in future diffs to this directory.

Suggested change
import type {ITransportServer} from "../../../../core/interfaces/transport/index.js";
import type {ITransportHandler} from "../i-transport-handler.js";
import {type ChannelCreateRequest, ChannelCreateRequestSchema, type ChannelCreateResponse, ChannelEvents} from "../../../../../shared/transport/events/channel-events.js";
import {ChannelNotImplementedError} from "../../../../core/domain/channel/errors.js";
import {parseOrThrow} from "./parse-or-throw.js";
export class ChannelCreateHandler implements ITransportHandler<ChannelCreateRequest, ChannelCreateResponse> {
private readonly event: string
private readonly transportServer: ITransportServer
public constructor(transportServer: ITransportServer) {
this.event = ChannelEvents.CREATE
this.transportServer = transportServer
}
public async handle(request: ChannelCreateRequest): Promise<ChannelCreateResponse> {
parseOrThrow(ChannelCreateRequestSchema, request)
throw new ChannelNotImplementedError(this.event)
}
public setup(): void {
this.transportServer.onRequest<ChannelCreateRequest, ChannelCreateResponse>(this.event, request => this.handle(request))
}
}
import type {ITransportServer} from '../../../../core/interfaces/transport/index.js'
import type {ITransportHandler} from '../i-transport-handler.js'
import {
ChannelCreateRequest,
ChannelCreateRequestSchema,
ChannelCreateResponse,
ChannelEvents,
} from '../../../../../shared/transport/events/channel-events.js'
import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js'
import {parseOrThrow} from './parse-or-throw.js'
/**
* Handles `channel:create`. `handle` validates the payload, then throws
* {@link ChannelNotImplementedError}; the create behavior lands in a later
* milestone, written after the validation step in `handle`.
*/
export class ChannelCreateHandler
implements ITransportHandler<ChannelCreateRequest, ChannelCreateResponse>
{
private readonly event: string
private readonly transportServer: ITransportServer
public constructor(transportServer: ITransportServer) {
this.event = ChannelEvents.CREATE
this.transportServer = transportServer
}
public async handle(request: ChannelCreateRequest): Promise<ChannelCreateResponse> {
parseOrThrow(ChannelCreateRequestSchema, request)
throw new ChannelNotImplementedError(this.event)
}
public setup(): void {
this.transportServer.onRequest<ChannelCreateRequest, ChannelCreateResponse>(this.event, (request) =>
this.handle(request),
)
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type {ITransportServer} from '../../../../core/interfaces/transport/index.js'
import type {ITransportHandler} from '../i-transport-handler.js'

import {
ChannelEvents,
ChannelGetRequest,
ChannelGetRequestSchema,
ChannelGetResponse,
} from '../../../../../shared/transport/events/channel-events.js'
import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js'
import {parseOrThrow} from './parse-or-throw.js'

/**
* Handles `channel:get`. `handle` validates the payload, then throws
* {@link ChannelNotImplementedError}; the get behavior lands in a later
* milestone, written after the validation step in `handle`.
*/
export class ChannelGetHandler implements ITransportHandler<ChannelGetRequest, ChannelGetResponse> {
private readonly event: string
private readonly transportServer: ITransportServer

public constructor(transportServer: ITransportServer) {
this.event = ChannelEvents.GET
this.transportServer = transportServer
}

public async handle(request: ChannelGetRequest): Promise<ChannelGetResponse> {
parseOrThrow(ChannelGetRequestSchema, request)
throw new ChannelNotImplementedError(this.event)
}

public setup(): void {
this.transportServer.onRequest<ChannelGetRequest, ChannelGetResponse>(this.event, (request) =>
this.handle(request),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type {ITransportServer} from '../../../../core/interfaces/transport/index.js'
import type {ITransportHandler} from '../i-transport-handler.js'

import {
ChannelEvents,
ChannelInviteRequest,
ChannelInviteRequestSchema,
ChannelInviteResponse,
} from '../../../../../shared/transport/events/channel-events.js'
import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js'
import {parseOrThrow} from './parse-or-throw.js'

/**
* Handles `channel:invite`. `handle` validates the payload, then throws
* {@link ChannelNotImplementedError}; the invite behavior lands in a later
* milestone, written after the validation step in `handle`.
*/
export class ChannelInviteHandler
implements ITransportHandler<ChannelInviteRequest, ChannelInviteResponse>
{
private readonly event: string
private readonly transportServer: ITransportServer

public constructor(transportServer: ITransportServer) {
this.event = ChannelEvents.INVITE
this.transportServer = transportServer
}

public async handle(request: ChannelInviteRequest): Promise<ChannelInviteResponse> {
parseOrThrow(ChannelInviteRequestSchema, request)
throw new ChannelNotImplementedError(this.event)
}

public setup(): void {
this.transportServer.onRequest<ChannelInviteRequest, ChannelInviteResponse>(this.event, (request) =>
this.handle(request),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type {ITransportServer} from '../../../../core/interfaces/transport/index.js'
import type {ITransportHandler} from '../i-transport-handler.js'

import {
ChannelEvents,
ChannelListRequest,
ChannelListRequestSchema,
ChannelListResponse,
} from '../../../../../shared/transport/events/channel-events.js'
import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js'
import {parseOrThrow} from './parse-or-throw.js'

/**
* Handles `channel:list`. `handle` validates the payload, then throws
* {@link ChannelNotImplementedError}; the list behavior lands in a later
* milestone, written after the validation step in `handle`.
*/
export class ChannelListHandler implements ITransportHandler<ChannelListRequest, ChannelListResponse> {
private readonly event: string
private readonly transportServer: ITransportServer

public constructor(transportServer: ITransportServer) {
this.event = ChannelEvents.LIST
this.transportServer = transportServer
}

public async handle(request: ChannelListRequest): Promise<ChannelListResponse> {
parseOrThrow(ChannelListRequestSchema, request)
throw new ChannelNotImplementedError(this.event)
}

public setup(): void {
this.transportServer.onRequest<ChannelListRequest, ChannelListResponse>(this.event, (request) =>
this.handle(request),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type {ITransportServer} from '../../../../core/interfaces/transport/index.js'
import type {ITransportHandler} from '../i-transport-handler.js'

import {
ChannelEvents,
ChannelListTurnsRequest,
ChannelListTurnsRequestSchema,
ChannelListTurnsResponse,
} from '../../../../../shared/transport/events/channel-events.js'
import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js'
import {parseOrThrow} from './parse-or-throw.js'

/**
* Handles `channel:list-turns`. `handle` validates the payload, then throws
* {@link ChannelNotImplementedError}; the list-turns behavior lands in a later
* milestone, written after the validation step in `handle`.
*/
export class ChannelListTurnsHandler
implements ITransportHandler<ChannelListTurnsRequest, ChannelListTurnsResponse>
{
private readonly event: string
private readonly transportServer: ITransportServer

public constructor(transportServer: ITransportServer) {
this.event = ChannelEvents.LIST_TURNS
this.transportServer = transportServer
}

public async handle(request: ChannelListTurnsRequest): Promise<ChannelListTurnsResponse> {
parseOrThrow(ChannelListTurnsRequestSchema, request)
throw new ChannelNotImplementedError(this.event)
}

public setup(): void {
this.transportServer.onRequest<ChannelListTurnsRequest, ChannelListTurnsResponse>(
this.event,
(request) => this.handle(request),
)
}
}
Loading
Loading