From 5a182827c8131fd1ffe590f8fdaf6bfb6de8710b Mon Sep 17 00:00:00 2001 From: hzhang Date: Wed, 10 Jun 2026 17:32:50 +0100 Subject: [PATCH] feat(channels): PUT /channels/:id/wake-mapping to reassign triage on-duty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-call handoff needs to move a triage channel's wake_mapping (who triage arrivals wake) from the outgoing to the incoming agent. Until now wake rows were only written at channel creation; the only post-create mutation was leave() deleting your own row, and PATCH allows just `purpose`. So there was no way for an agent to hand off the baton without an admin DB UPDATE. Add a privileged PUT /channels/:id/wake-mapping {wakeUserIds:[]}: - Gated on the system key (req.isSystem from x-fabric-system-key) — a normal agent's guild token gets 403. The handoff is driven by ClawSkills fabric-ctrl set-on-duty, which reads the shared key from secret-mgr's public scope so the agent never handles it. - Restricted to xType=triage (other types derive wake differently); non-triage → 400. - Atomic swap of all wake rows; ensures each new wake user is a channel member first (the realtime gateway only routes triage to members) and notifies them. Verified on the sim guild backend: 403 without the key, 400 on a report channel, reassign succeeds with the key, DB wake_mapping row flips, and the set-on-duty script round-trips without ever echoing the secret. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/channels/channels.controller.ts | 30 +++++++++++++++++++--- src/channels/channels.service.ts | 40 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/channels/channels.controller.ts b/src/channels/channels.controller.ts index 2eed767..d7f14a5 100644 --- a/src/channels/channels.controller.ts +++ b/src/channels/channels.controller.ts @@ -1,8 +1,9 @@ -import { BadRequestException, Body, Controller, Get, Param, Patch, Post, Query, Req, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, Body, Controller, ForbiddenException, Get, Param, Patch, Post, Put, Query, Req, UnauthorizedException } from '@nestjs/common'; import { ChannelsService } from './channels.service.js'; -// ApiKeyGuard attaches the introspected Center user id onto the request. -type AuthedRequest = { userId?: string }; +// ApiKeyGuard attaches the introspected Center user id onto the request, +// and sets isSystem=true for system-key callers (x-fabric-system-key). +type AuthedRequest = { userId?: string; isSystem?: boolean }; @Controller('channels') export class ChannelsController { @@ -59,6 +60,29 @@ export class ChannelsController { return this.channelsService.updatePurpose(channelId, userId, body.purpose); } + // Reassign a triage channel's wake_mapping (who triage arrivals wake) — + // the on-call-handoff baton. PRIVILEGED: requires the system key + // (x-fabric-system-key → req.isSystem); a normal agent's guild token is + // rejected. Restricted to triage channels in the service. Body: + // { wakeUserIds: string[] } (the new on-duty user id(s)). + @Put(':id/wake-mapping') + setWakeMapping( + @Req() req: AuthedRequest, + @Param('id') channelId: string, + @Body() body: Record, + ) { + if (!req.isSystem) { + throw new ForbiddenException('wake_mapping reassignment requires the system key'); + } + const wakeUserIds = Array.isArray(body.wakeUserIds) + ? (body.wakeUserIds as unknown[]).map((u) => String(u ?? '').trim()).filter(Boolean) + : []; + if (!wakeUserIds.length) { + throw new BadRequestException('wakeUserIds (non-empty string[]) is required'); + } + return this.channelsService.setTriageWakeMapping(channelId, wakeUserIds); + } + // Move an order member into the bypass list (discuss/work only). @Post(':id/bypass') bypass( diff --git a/src/channels/channels.service.ts b/src/channels/channels.service.ts index a962e35..7f6ed27 100644 --- a/src/channels/channels.service.ts +++ b/src/channels/channels.service.ts @@ -248,6 +248,46 @@ export class ChannelsService { }; } + // Reassign a TRIAGE channel's wake_mapping — the set of users woken by + // triage arrivals (the on-call-handoff baton). Privileged: the caller is + // gated at the controller on the system key, not a per-user membership + // check, because handing the baton to the *incoming* agent is exactly an + // operation the outgoing agent's own token shouldn't be trusted to scope. + // + // Atomically replaces every wake row for the channel. New wake users are + // ensured to be channel members first (the realtime gateway only routes a + // triage message to members, so a non-member on-duty agent would never be + // woken). Restricted to xType=triage; other types derive wake differently + // (custom=listeners, discuss/work=rotation) and must not be poked here. + async setTriageWakeMapping(channelId: string, wakeUserIds: string[]) { + const channel = await this.channelRepo.findOne({ where: { id: channelId } }); + if (!channel) throw new NotFoundException('channel not found'); + if (channel.xType !== 'triage') { + throw new BadRequestException('wake_mapping reassignment is only allowed for triage channels'); + } + const ids = [...new Set(wakeUserIds.map((u) => String(u ?? '').trim()).filter(Boolean))]; + if (!ids.length) throw new BadRequestException('wakeUserIds must contain at least one user id'); + + const newMembers: string[] = []; + for (const userId of ids) { + const existing = await this.memberRepo.findOne({ where: { channelId, userId } }); + if (!existing) { + await this.memberRepo.save(this.memberRepo.create({ channelId, userId })); + newMembers.push(userId); + } + } + + // Atomic swap: drop all current wake rows, then insert the new set. + await this.wakeRepo.delete({ channelId }); + await this.wakeRepo.save(ids.map((userId) => this.wakeRepo.create({ channelId, userId }))); + + if (newMembers.length) { + this.notifyMembership('joined', channelId, newMembers, { xType: channel.xType }); + } + + return { id: channel.id, name: channel.name, xType: channel.xType, wakeUserIds: ids }; + } + // Move an order member into the bypass list (discuss/work only). // Any channel member may do this. async moveToBypass(channelId: string, actorUserId: string, targetUserId: string) {