Compare commits
2 Commits
3e96de730a
...
7e944a08f6
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e944a08f6 | |||
| e45ad91340 |
@@ -7,6 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
|
"print:commands-key": "node dist/cli/print-commands-sync-key.js",
|
||||||
"start:dev": "ts-node src/main.ts",
|
"start:dev": "ts-node src/main.ts",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||||
|
|||||||
37
src/cli/print-commands-sync-key.ts
Normal file
37
src/cli/print-commands-sync-key.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Operator convenience (Guild C-2): print the commands-sync key that this
|
||||||
|
// guild process actually has in its environment, so it can be copied into
|
||||||
|
// the OpenClaw plugin's FABRIC_COMMANDS_SYNC_KEY.
|
||||||
|
//
|
||||||
|
// Usage (inside the deployed container — authoritative, reflects compose):
|
||||||
|
// docker exec fabric-backend-guild node dist/cli/print-commands-sync-key.js
|
||||||
|
// docker exec fabric-backend-guild node dist/cli/print-commands-sync-key.js --export
|
||||||
|
//
|
||||||
|
// Default: prints the raw value only (so KEY=$(... ) works).
|
||||||
|
// --export: prints `FABRIC_COMMANDS_SYNC_KEY=<value>` for pasting.
|
||||||
|
// Exit 1 (no stdout) when unset — guild is then in the weaker
|
||||||
|
// "any authenticated user" fallback for PUT /commands.
|
||||||
|
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (args.has('--help') || args.has('-h')) {
|
||||||
|
process.stderr.write(
|
||||||
|
'print-commands-sync-key: outputs FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY\n' +
|
||||||
|
' (no flag) print the raw key value\n' +
|
||||||
|
' --export print FABRIC_COMMANDS_SYNC_KEY=<value>\n',
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = (process.env.FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY ?? '').trim();
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
process.stderr.write(
|
||||||
|
'FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY is not set — PUT /commands is in ' +
|
||||||
|
'the fallback mode (any authenticated user). Set it to harden (Guild C-2).\n',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
(args.has('--export') ? `FABRIC_COMMANDS_SYNC_KEY=${key}` : key) + '\n',
|
||||||
|
);
|
||||||
@@ -1,29 +1,51 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
Put,
|
Put,
|
||||||
Req,
|
Req,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
import { CommandsService } from './commands.service.js';
|
import { CommandsService } from './commands.service.js';
|
||||||
|
import { SyncCommandsDto } from './dto.sync-commands.dto.js';
|
||||||
|
|
||||||
type AuthedRequest = { userId?: string };
|
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')
|
@Controller('commands')
|
||||||
export class CommandsController {
|
export class CommandsController {
|
||||||
constructor(private readonly commands: CommandsService) {}
|
constructor(private readonly commands: CommandsService) {}
|
||||||
|
|
||||||
// Plugin syncs the OpenClaw native-command catalog here (any authenticated
|
// Guild C-2: catalog write is privileged. When
|
||||||
// agent/user; idempotent full replace).
|
// 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()
|
@Put()
|
||||||
sync(
|
sync(
|
||||||
@Req() req: AuthedRequest,
|
@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 configured = process.env.FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY ?? '';
|
||||||
const commands = Array.isArray(body?.commands) ? body.commands : [];
|
if (configured) {
|
||||||
return this.commands.sync(commands);
|
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.
|
// Frontend reads the catalog to drive `/` autocomplete.
|
||||||
|
|||||||
102
src/commands/dto.sync-commands.dto.ts
Normal file
102
src/commands/dto.sync-commands.dto.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
Headers,
|
Headers,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -10,11 +11,13 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { CreateMessageDto } from './dto.create-message.dto.js';
|
import { CreateMessageDto } from './dto.create-message.dto.js';
|
||||||
import { Channel } from '../entities/channel.entity.js';
|
import { Channel } from '../entities/channel.entity.js';
|
||||||
|
import { ChannelMember } from '../entities/channel-member.entity.js';
|
||||||
import { Message } from '../entities/message.entity.js';
|
import { Message } from '../entities/message.entity.js';
|
||||||
import { IdempotencyRecord } from '../entities/idempotency-record.entity.js';
|
import { IdempotencyRecord } from '../entities/idempotency-record.entity.js';
|
||||||
import { WakeMapping } from '../entities/wake-mapping.entity.js';
|
import { WakeMapping } from '../entities/wake-mapping.entity.js';
|
||||||
@@ -36,6 +39,8 @@ export class MessagingController {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@InjectRepository(Channel)
|
@InjectRepository(Channel)
|
||||||
private readonly channelRepo: Repository<Channel>,
|
private readonly channelRepo: Repository<Channel>,
|
||||||
|
@InjectRepository(ChannelMember)
|
||||||
|
private readonly memberRepo: Repository<ChannelMember>,
|
||||||
@InjectRepository(Message)
|
@InjectRepository(Message)
|
||||||
private readonly messageRepo: Repository<Message>,
|
private readonly messageRepo: Repository<Message>,
|
||||||
@InjectRepository(IdempotencyRecord)
|
@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<Channel> {
|
||||||
|
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
|
// Persists one message (allocates a seq under a channel row lock) and
|
||||||
// returns its view. Used for normal messages and for guild /ack messages.
|
// returns its view. Used for normal messages and for guild /ack messages.
|
||||||
private async persistMessage(
|
private async persistMessage(
|
||||||
@@ -136,20 +154,25 @@ export class MessagingController {
|
|||||||
async create(
|
async create(
|
||||||
@Param('id') channelId: string,
|
@Param('id') channelId: string,
|
||||||
@Body() body: CreateMessageDto,
|
@Body() body: CreateMessageDto,
|
||||||
|
@Req() req: { userId?: string },
|
||||||
@Headers('idempotency-key') idempotencyKey?: string,
|
@Headers('idempotency-key') idempotencyKey?: string,
|
||||||
) {
|
) {
|
||||||
const scope = `POST:/channels/${channelId}/messages`;
|
const scope = `POST:/channels/${channelId}/messages`;
|
||||||
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
||||||
if (existed) return existed;
|
if (existed) return existed;
|
||||||
|
|
||||||
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
|
// Guild C-1: caller must be a participant of the channel, and the
|
||||||
if (!channel) throw new NotFoundException('channel not found');
|
// 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) {
|
if (channel.closed) {
|
||||||
throw new ConflictException({ error: 'channel_closed', message: 'channel is closed' });
|
throw new ConflictException({ error: 'channel_closed', message: 'channel is closed' });
|
||||||
}
|
}
|
||||||
const xType = channel.xType ?? 'general';
|
const xType = channel.xType ?? 'general';
|
||||||
const isRotating = xType === 'discuss' || xType === 'work';
|
const isRotating = xType === 'discuss' || xType === 'work';
|
||||||
const authorUserId = String(body.authorUserId ?? 'anonymous');
|
const authorUserId = userId;
|
||||||
|
|
||||||
// ---- translate <@user.name:NAME> -> <@userId> (outside backticks) via
|
// ---- translate <@user.name:NAME> -> <@userId> (outside backticks) via
|
||||||
// Center before anything else persists/parses the content
|
// Center before anything else persists/parses the content
|
||||||
@@ -223,14 +246,23 @@ export class MessagingController {
|
|||||||
@Param('id') channelId: string,
|
@Param('id') channelId: string,
|
||||||
@Param('messageId') messageId: string,
|
@Param('messageId') messageId: string,
|
||||||
@Body() body: { content?: string },
|
@Body() body: { content?: string },
|
||||||
|
@Req() req: { userId?: string },
|
||||||
@Headers('idempotency-key') idempotencyKey?: string,
|
@Headers('idempotency-key') idempotencyKey?: string,
|
||||||
) {
|
) {
|
||||||
const scope = `PATCH:/channels/${channelId}/messages/${messageId}`;
|
const scope = `PATCH:/channels/${channelId}/messages/${messageId}`;
|
||||||
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
||||||
if (existed) return existed;
|
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 } });
|
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
|
||||||
if (!item) return { status: 'not_found' };
|
if (!item) return { status: 'not_found' };
|
||||||
|
if (item.authorUserId !== userId) {
|
||||||
|
throw new ForbiddenException('not the message author');
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const createdAt = new Date(item.createdAt).getTime();
|
const createdAt = new Date(item.createdAt).getTime();
|
||||||
@@ -259,14 +291,23 @@ export class MessagingController {
|
|||||||
async remove(
|
async remove(
|
||||||
@Param('id') channelId: string,
|
@Param('id') channelId: string,
|
||||||
@Param('messageId') messageId: string,
|
@Param('messageId') messageId: string,
|
||||||
|
@Req() req: { userId?: string },
|
||||||
@Headers('idempotency-key') idempotencyKey?: string,
|
@Headers('idempotency-key') idempotencyKey?: string,
|
||||||
) {
|
) {
|
||||||
const scope = `DELETE:/channels/${channelId}/messages/${messageId}`;
|
const scope = `DELETE:/channels/${channelId}/messages/${messageId}`;
|
||||||
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
||||||
if (existed) return existed;
|
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 } });
|
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
|
||||||
if (!item) return { status: 'not_found' };
|
if (!item) return { status: 'not_found' };
|
||||||
|
if (item.authorUserId !== userId) {
|
||||||
|
throw new ForbiddenException('not the message author');
|
||||||
|
}
|
||||||
|
|
||||||
item.isDeleted = true;
|
item.isDeleted = true;
|
||||||
item.deletedAt = new Date();
|
item.deletedAt = new Date();
|
||||||
@@ -304,10 +345,14 @@ export class MessagingController {
|
|||||||
@Get()
|
@Get()
|
||||||
async listBySeq(
|
async listBySeq(
|
||||||
@Param('id') channelId: string,
|
@Param('id') channelId: string,
|
||||||
|
@Req() req: { userId?: string },
|
||||||
@Query('seq_from') seqFrom?: string,
|
@Query('seq_from') seqFrom?: string,
|
||||||
@Query('seq_to') seqTo?: string,
|
@Query('seq_to') seqTo?: string,
|
||||||
@Query('limit') limit?: 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 from = seqFrom ? Number(seqFrom) : 1;
|
||||||
const to = seqTo ? Number(seqTo) : Number.MAX_SAFE_INTEGER;
|
const to = seqTo ? Number(seqTo) : Number.MAX_SAFE_INTEGER;
|
||||||
const safeLimit = clampLimit(limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT);
|
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 } });
|
const channel = await this.assertParticipant(channelId, userId);
|
||||||
if (!channel) {
|
|
||||||
throw new NotFoundException('channel not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const qb = this.messageRepo
|
const qb = this.messageRepo
|
||||||
.createQueryBuilder('m')
|
.createQueryBuilder('m')
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { MessagingController } from './messaging.controller.js';
|
import { MessagingController } from './messaging.controller.js';
|
||||||
import { Channel } from '../entities/channel.entity.js';
|
import { Channel } from '../entities/channel.entity.js';
|
||||||
|
import { ChannelMember } from '../entities/channel-member.entity.js';
|
||||||
import { Message } from '../entities/message.entity.js';
|
import { Message } from '../entities/message.entity.js';
|
||||||
import { IdempotencyRecord } from '../entities/idempotency-record.entity.js';
|
import { IdempotencyRecord } from '../entities/idempotency-record.entity.js';
|
||||||
import { WakeMapping } from '../entities/wake-mapping.entity.js';
|
import { WakeMapping } from '../entities/wake-mapping.entity.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Channel, Message, IdempotencyRecord, WakeMapping])],
|
imports: [TypeOrmModule.forFeature([Channel, ChannelMember, Message, IdempotencyRecord, WakeMapping])],
|
||||||
controllers: [MessagingController],
|
controllers: [MessagingController],
|
||||||
})
|
})
|
||||||
export class MessagingModule {}
|
export class MessagingModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user