feat(guild): system-key bypass + announce-only system path + gen CLI
Three coupled changes that let Dialectic.Backend (and future system broadcasters) post to announce channels without needing a Fabric user bearer. 1. ApiKeyGuard: when x-fabric-system-key matches FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env, skip the Bearer requirement and set req.isSystem=true. Pre-Bearer system bypass; no per-user session token needed. Empty env -> bypass disabled (closed by default). 2. messaging.controller POST /channels/:id/messages: when req.isSystem, skip assertParticipant + fetch channel directly. Enforce xType=announce (system key only writes to announce channels - never to regular chats). Persist with sentinel author 00000000-0000-0000-0000-000000000000. Emit message.created + realtime.emitMessageCreated with xType=announce so the Phase 1 busy-discard logic kicks in for recipients. 3. New cli: src/cli/gen-system-api-key.ts. Generates a random 32-byte hex key (same shape as agent + admin keys) and prints it. Does NOT store - operator pastes into compose env and restarts guild. Pattern mirrors the existing print-commands-sync-key.ts. Removes the need for a FABRIC_BOT_BEARER_TOKEN concept entirely - the system key alone is sufficient. announce-channel posts by regular authenticated users (who happen to know channel id but no system key) are now 403 announce_system_only.
This commit is contained in:
42
src/cli/gen-system-api-key.ts
Normal file
42
src/cli/gen-system-api-key.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Operator convenience: generate a fresh random key suitable for
|
||||||
|
// FABRIC_BACKEND_GUILD_SYSTEM_API_KEY (the env that gates
|
||||||
|
// `x-fabric-system-key` posts to announce channels).
|
||||||
|
//
|
||||||
|
// Does NOT write to env / DB — the generated value is printed to stdout;
|
||||||
|
// operator pastes into compose env + restarts the guild container.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// docker exec fabric-backend-guild node dist/cli/gen-system-api-key.js
|
||||||
|
// docker exec fabric-backend-guild node dist/cli/gen-system-api-key.js --export
|
||||||
|
//
|
||||||
|
// Default: prints the raw value only (so KEY=$(... ) works).
|
||||||
|
// --export: prints `FABRIC_BACKEND_GUILD_SYSTEM_API_KEY=<value>` for pasting.
|
||||||
|
//
|
||||||
|
// Rotation: re-run, paste new value into compose, restart guild. The old
|
||||||
|
// key stops working immediately on container restart (it lives only in
|
||||||
|
// env, not DB). Any process holding the old value will start getting 401
|
||||||
|
// on announce POSTs — coordinate (e.g. update dialectic-backend env in
|
||||||
|
// the same maintenance window).
|
||||||
|
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (args.has('--help') || args.has('-h')) {
|
||||||
|
process.stderr.write(
|
||||||
|
'gen-system-api-key: generate a random key for FABRIC_BACKEND_GUILD_SYSTEM_API_KEY\n' +
|
||||||
|
' (no flag) print the raw key value\n' +
|
||||||
|
' --export print FABRIC_BACKEND_GUILD_SYSTEM_API_KEY=<value>\n' +
|
||||||
|
'\nThe generated value is NOT stored — paste it into your\n' +
|
||||||
|
'compose env and restart the guild container to activate.\n',
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 32 bytes hex = 64 chars. Same shape as agent keys + admin keys
|
||||||
|
// throughout the stack.
|
||||||
|
const key = randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
(args.has('--export') ? `FABRIC_BACKEND_GUILD_SYSTEM_API_KEY=${key}` : key) + '\n',
|
||||||
|
);
|
||||||
@@ -21,6 +21,23 @@ export class ApiKeyGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System-key bypass: when a caller presents x-fabric-system-key matching
|
||||||
|
// FABRIC_BACKEND_GUILD_SYSTEM_API_KEY, skip the Bearer requirement and
|
||||||
|
// mark this as a system caller (no userId). Downstream handlers (e.g.
|
||||||
|
// messaging.controller for announce-type channels) gate per-route on
|
||||||
|
// req.isSystem instead of req.userId.
|
||||||
|
//
|
||||||
|
// This is what makes Dialectic.Backend's lifecycle broadcasts work
|
||||||
|
// without needing a per-user Fabric session token — the system key
|
||||||
|
// alone is sufficient for posting to announce channels.
|
||||||
|
const sysExpected = process.env.FABRIC_BACKEND_GUILD_SYSTEM_API_KEY ?? '';
|
||||||
|
const sysHeader = req.headers['x-fabric-system-key'];
|
||||||
|
const sysProvided = Array.isArray(sysHeader) ? sysHeader[0] : sysHeader;
|
||||||
|
if (sysExpected && sysProvided && sysProvided === sysExpected) {
|
||||||
|
(req as { isSystem?: boolean }).isSystem = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const auth = req.headers['authorization'];
|
const auth = req.headers['authorization'];
|
||||||
const authValue = Array.isArray(auth) ? auth[0] : auth;
|
const authValue = Array.isArray(auth) ? auth[0] : auth;
|
||||||
let token = authValue?.startsWith('Bearer ') ? authValue.slice(7) : '';
|
let token = authValue?.startsWith('Bearer ') ? authValue.slice(7) : '';
|
||||||
|
|||||||
@@ -156,14 +156,59 @@ 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 },
|
@Req() req: { userId?: string; isSystem?: boolean },
|
||||||
@Headers('idempotency-key') idempotencyKey?: string,
|
@Headers('idempotency-key') idempotencyKey?: string,
|
||||||
@Headers('x-fabric-system-key') systemKey?: 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;
|
||||||
|
|
||||||
|
// System caller (ApiKeyGuard set isSystem from x-fabric-system-key):
|
||||||
|
// skip the per-user participant check; resolve channel directly. Only
|
||||||
|
// allowed for xType=announce — system POSTs to non-announce channels
|
||||||
|
// are still rejected (broadcast key shouldn't write to regular chats).
|
||||||
|
if (req.isSystem) {
|
||||||
|
const sysChannel = await this.channelRepo.findOne({ where: { id: channelId } });
|
||||||
|
if (!sysChannel) {
|
||||||
|
throw new ForbiddenException('channel not found');
|
||||||
|
}
|
||||||
|
if (sysChannel.closed) {
|
||||||
|
throw new ConflictException({ error: 'channel_closed', message: 'channel is closed' });
|
||||||
|
}
|
||||||
|
if (sysChannel.xType !== 'announce') {
|
||||||
|
throw new ForbiddenException({
|
||||||
|
error: 'system_key_announce_only',
|
||||||
|
message: 'system api key only valid for announce-type channels',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Persist with the all-zeros sentinel user id (no real user has it;
|
||||||
|
// intentionally not FK-constrained against users since announce
|
||||||
|
// messages don't belong to any one Center user).
|
||||||
|
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
|
||||||
|
const sysMessage = await this.persistMessage(channelId, {
|
||||||
|
authorUserId: SYSTEM_USER_ID,
|
||||||
|
content: body.content ?? '',
|
||||||
|
clientMessageId: body.clientMessageId,
|
||||||
|
replyToMessageId: body.replyToMessageId,
|
||||||
|
mentions: body.mentions,
|
||||||
|
attachments: body.attachments,
|
||||||
|
});
|
||||||
|
const sysView = this.toView(sysMessage) as Record<string, unknown>;
|
||||||
|
await this.saveIdempotentResponse(scope, idempotencyKey, sysView);
|
||||||
|
await this.events.emit({
|
||||||
|
eventType: 'message.created',
|
||||||
|
channelId,
|
||||||
|
actorId: SYSTEM_USER_ID,
|
||||||
|
data: sysView,
|
||||||
|
});
|
||||||
|
await this.realtime.emitMessageCreated(channelId, sysView, {
|
||||||
|
xType: 'announce',
|
||||||
|
authorUserId: SYSTEM_USER_ID,
|
||||||
|
wakeUserIds: new Set<string>(),
|
||||||
|
});
|
||||||
|
return sysView;
|
||||||
|
}
|
||||||
|
|
||||||
// Guild C-1: caller must be a participant of the channel, and the
|
// Guild C-1: caller must be a participant of the channel, and the
|
||||||
// author is always the authenticated user — body.authorUserId is
|
// author is always the authenticated user — body.authorUserId is
|
||||||
// ignored so a caller can never post as someone else.
|
// ignored so a caller can never post as someone else.
|
||||||
@@ -176,21 +221,14 @@ export class MessagingController {
|
|||||||
const xType = channel.xType ?? 'general';
|
const xType = channel.xType ?? 'general';
|
||||||
const isRotating = xType === 'discuss' || xType === 'work';
|
const isRotating = xType === 'discuss' || xType === 'work';
|
||||||
|
|
||||||
// announce channels: posts only allowed when the caller presents a
|
// announce channels: user-bearer POSTs (i.e. not isSystem above) are
|
||||||
// valid system key (matches FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env).
|
// never allowed. The system-key bypass above is the ONLY path that
|
||||||
// The ApiKeyGuard has already validated user identity; this is an
|
// writes to announce channels.
|
||||||
// additional system-only gate on top. Non-system posts are silently
|
|
||||||
// discarded (return 403 + log) so misbehaving clients don't pollute
|
|
||||||
// the broadcast.
|
|
||||||
if (xType === 'announce') {
|
if (xType === 'announce') {
|
||||||
const expected = process.env.FABRIC_BACKEND_GUILD_SYSTEM_API_KEY ?? '';
|
throw new ForbiddenException({
|
||||||
if (!expected || systemKey !== expected) {
|
error: 'announce_system_only',
|
||||||
// log + reject; treat empty env as "no system caller is ever valid"
|
message: 'announce-type channels accept system-signed posts only (use x-fabric-system-key)',
|
||||||
throw new ForbiddenException({
|
});
|
||||||
error: 'announce_system_only',
|
|
||||||
message: 'announce-type channels accept system-signed posts only',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const authorUserId = userId;
|
const authorUserId = userId;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user