feat(channels): PUT /channels/:id/wake-mapping to reassign triage on-duty
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>,
|
||||
) {
|
||||
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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user