diff --git a/src/commands/commands.controller.ts b/src/commands/commands.controller.ts index 0c72d90..423b8fc 100644 --- a/src/commands/commands.controller.ts +++ b/src/commands/commands.controller.ts @@ -1,29 +1,51 @@ import { Body, Controller, + ForbiddenException, Get, + Headers, Put, Req, UnauthorizedException, } from '@nestjs/common'; +import { timingSafeEqual } from 'node:crypto'; import { CommandsService } from './commands.service.js'; +import { SyncCommandsDto } from './dto.sync-commands.dto.js'; type AuthedRequest = { userId?: string }; +function safeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + if (ab.length !== bb.length) return false; + return timingSafeEqual(ab, bb); +} + @Controller('commands') export class CommandsController { constructor(private readonly commands: CommandsService) {} - // Plugin syncs the OpenClaw native-command catalog here (any authenticated - // agent/user; idempotent full replace). + // Guild C-2: catalog write is privileged. When + // FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY is configured (recommended in + // production), the caller must present a matching x-commands-sync-key + // header — this restricts writes to the OpenClaw plugin. When unset, we + // fall back to "any authenticated agent/user" (never weaker than before). + // The body is always strictly validated + size-capped via SyncCommandsDto. @Put() sync( @Req() req: AuthedRequest, - @Body() body: { commands?: unknown[] }, + @Body() body: SyncCommandsDto, + @Headers('x-commands-sync-key') syncKey?: string, ) { - if (!req.userId) throw new UnauthorizedException('missing user'); - const commands = Array.isArray(body?.commands) ? body.commands : []; - return this.commands.sync(commands); + const configured = process.env.FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY ?? ''; + if (configured) { + if (!syncKey || !safeEqual(syncKey, configured)) { + throw new ForbiddenException('invalid commands sync key'); + } + } else if (!req.userId) { + throw new UnauthorizedException('missing user'); + } + return this.commands.sync(body.commands as unknown[]); } // Frontend reads the catalog to drive `/` autocomplete. diff --git a/src/commands/dto.sync-commands.dto.ts b/src/commands/dto.sync-commands.dto.ts new file mode 100644 index 0000000..69f521d --- /dev/null +++ b/src/commands/dto.sync-commands.dto.ts @@ -0,0 +1,102 @@ +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +// Guild C-2: the slash-command catalog is guild-global and rendered by the +// frontend `/` autocomplete. Without a strict schema + caps a single +// authenticated caller could poison it or blow up the DB / clients. +// The global ValidationPipe runs with { whitelist, forbidNonWhitelisted }, +// so any unknown field is rejected. + +class CommandChoiceDto { + @IsString() + @MaxLength(200) + value!: string; + + @IsString() + @MaxLength(200) + label!: string; +} + +class CommandArgDto { + @IsString() + @MaxLength(100) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @IsOptional() + @IsString() + @MaxLength(40) + type?: string; + + @IsOptional() + @IsBoolean() + required?: boolean; + + @IsOptional() + @IsBoolean() + captureRemaining?: boolean; + + @IsOptional() + @IsBoolean() + preferAutocomplete?: boolean; + + // null when there are no choices (plugin sends explicit null). + @IsOptional() + @IsArray() + @ArrayMaxSize(100) + @ValidateNested({ each: true }) + @Type(() => CommandChoiceDto) + choices?: CommandChoiceDto[] | null; +} + +class CommandSpecDto { + @IsString() + @MaxLength(100) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(100) + nativeName?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @IsOptional() + @IsBoolean() + acceptsArgs?: boolean; + + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => CommandArgDto) + args?: CommandArgDto[]; + + @IsOptional() + @IsString() + @MaxLength(20) + argsParsing?: string; +} + +export class SyncCommandsDto { + @IsArray() + @ArrayMaxSize(200) + @ValidateNested({ each: true }) + @Type(() => CommandSpecDto) + commands!: CommandSpecDto[]; +} diff --git a/src/messaging/messaging.controller.ts b/src/messaging/messaging.controller.ts index d230427..e40d57c 100644 --- a/src/messaging/messaging.controller.ts +++ b/src/messaging/messaging.controller.ts @@ -3,6 +3,7 @@ import { ConflictException, Controller, Delete, + ForbiddenException, Get, Headers, NotFoundException, @@ -10,11 +11,13 @@ import { Patch, Post, Query, + Req, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { CreateMessageDto } from './dto.create-message.dto.js'; import { Channel } from '../entities/channel.entity.js'; +import { ChannelMember } from '../entities/channel-member.entity.js'; import { Message } from '../entities/message.entity.js'; import { IdempotencyRecord } from '../entities/idempotency-record.entity.js'; import { WakeMapping } from '../entities/wake-mapping.entity.js'; @@ -36,6 +39,8 @@ export class MessagingController { private readonly dataSource: DataSource, @InjectRepository(Channel) private readonly channelRepo: Repository, + @InjectRepository(ChannelMember) + private readonly memberRepo: Repository, @InjectRepository(Message) private readonly messageRepo: Repository, @InjectRepository(IdempotencyRecord) @@ -86,6 +91,19 @@ export class MessagingController { }; } + // Channel-participant gate (Guild C-1): public channels are readable/ + // writable by any authenticated user; private channels require explicit + // channel_members membership. Returns the channel so callers can reuse it. + private async assertParticipant(channelId: string, userId: string): Promise { + const channel = await this.channelRepo.findOne({ where: { id: channelId } }); + if (!channel) throw new NotFoundException('channel not found'); + if (channel.isPublic) return channel; + if (!userId) throw new ForbiddenException('not a channel member'); + const member = await this.memberRepo.findOne({ where: { channelId, userId } }); + if (!member) throw new ForbiddenException('not a channel member'); + return channel; + } + // Persists one message (allocates a seq under a channel row lock) and // returns its view. Used for normal messages and for guild /ack messages. private async persistMessage( @@ -136,20 +154,25 @@ export class MessagingController { async create( @Param('id') channelId: string, @Body() body: CreateMessageDto, + @Req() req: { userId?: string }, @Headers('idempotency-key') idempotencyKey?: string, ) { const scope = `POST:/channels/${channelId}/messages`; const existed = await this.getIdempotentResponse(scope, idempotencyKey); if (existed) return existed; - const channel = await this.channelRepo.findOne({ where: { id: channelId } }); - if (!channel) throw new NotFoundException('channel not found'); + // Guild C-1: caller must be a participant of the channel, and the + // author is always the authenticated user — body.authorUserId is + // ignored so a caller can never post as someone else. + const userId = String(req.userId ?? ''); + if (!userId) throw new ForbiddenException('missing user'); + const channel = await this.assertParticipant(channelId, userId); if (channel.closed) { throw new ConflictException({ error: 'channel_closed', message: 'channel is closed' }); } const xType = channel.xType ?? 'general'; const isRotating = xType === 'discuss' || xType === 'work'; - const authorUserId = String(body.authorUserId ?? 'anonymous'); + const authorUserId = userId; // ---- translate <@user.name:NAME> -> <@userId> (outside backticks) via // Center before anything else persists/parses the content @@ -223,14 +246,23 @@ export class MessagingController { @Param('id') channelId: string, @Param('messageId') messageId: string, @Body() body: { content?: string }, + @Req() req: { userId?: string }, @Headers('idempotency-key') idempotencyKey?: string, ) { const scope = `PATCH:/channels/${channelId}/messages/${messageId}`; const existed = await this.getIdempotentResponse(scope, idempotencyKey); if (existed) return existed; + // Guild C-1: participant + author-ownership. + const userId = String(req.userId ?? ''); + if (!userId) throw new ForbiddenException('missing user'); + await this.assertParticipant(channelId, userId); + const item = await this.messageRepo.findOne({ where: { channelId, messageId } }); if (!item) return { status: 'not_found' }; + if (item.authorUserId !== userId) { + throw new ForbiddenException('not the message author'); + } const now = Date.now(); const createdAt = new Date(item.createdAt).getTime(); @@ -259,14 +291,23 @@ export class MessagingController { async remove( @Param('id') channelId: string, @Param('messageId') messageId: string, + @Req() req: { userId?: string }, @Headers('idempotency-key') idempotencyKey?: string, ) { const scope = `DELETE:/channels/${channelId}/messages/${messageId}`; const existed = await this.getIdempotentResponse(scope, idempotencyKey); if (existed) return existed; + // Guild C-1: participant + author-ownership. + const userId = String(req.userId ?? ''); + if (!userId) throw new ForbiddenException('missing user'); + await this.assertParticipant(channelId, userId); + const item = await this.messageRepo.findOne({ where: { channelId, messageId } }); if (!item) return { status: 'not_found' }; + if (item.authorUserId !== userId) { + throw new ForbiddenException('not the message author'); + } item.isDeleted = true; item.deletedAt = new Date(); @@ -304,10 +345,14 @@ export class MessagingController { @Get() async listBySeq( @Param('id') channelId: string, + @Req() req: { userId?: string }, @Query('seq_from') seqFrom?: string, @Query('seq_to') seqTo?: string, @Query('limit') limit?: string, ) { + // Guild C-1: only participants may read channel history. + const userId = String(req.userId ?? ''); + if (!userId) throw new ForbiddenException('missing user'); const from = seqFrom ? Number(seqFrom) : 1; const to = seqTo ? Number(seqTo) : Number.MAX_SAFE_INTEGER; const safeLimit = clampLimit(limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT); @@ -327,10 +372,7 @@ export class MessagingController { }; } - const channel = await this.channelRepo.findOne({ where: { id: channelId } }); - if (!channel) { - throw new NotFoundException('channel not found'); - } + const channel = await this.assertParticipant(channelId, userId); const qb = this.messageRepo .createQueryBuilder('m') diff --git a/src/messaging/messaging.module.ts b/src/messaging/messaging.module.ts index b6f5eac..9a57969 100644 --- a/src/messaging/messaging.module.ts +++ b/src/messaging/messaging.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MessagingController } from './messaging.controller.js'; import { Channel } from '../entities/channel.entity.js'; +import { ChannelMember } from '../entities/channel-member.entity.js'; import { Message } from '../entities/message.entity.js'; import { IdempotencyRecord } from '../entities/idempotency-record.entity.js'; import { WakeMapping } from '../entities/wake-mapping.entity.js'; @Module({ - imports: [TypeOrmModule.forFeature([Channel, Message, IdempotencyRecord, WakeMapping])], + imports: [TypeOrmModule.forFeature([Channel, ChannelMember, Message, IdempotencyRecord, WakeMapping])], controllers: [MessagingController], }) export class MessagingModule {}