Resurrects the x-fabric-system-key bypass + isSystem branch on POST
/channels/:id/messages, dropped in ca20df7 when dialectic stopped
broadcasting topic lifecycle events to Fabric. Re-enabling now because
Fabric.OpenclawPlugin's close-sub-discussion needs to write a callback
into a parent channel as a system-authored message (not as the closing
host), with an optional precision wakeup so the recruitment workflow can
resume immediately after an interview sub-discussion closes.
Three coupled bits:
1. ApiKeyGuard pre-Bearer bypass: when x-fabric-system-key matches
FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY, set req.isSystem=true and
skip the Bearer check. Intentionally reuses the existing commands
sync env — same shared secret, same consumer (the OpenclawPlugin
reads channels.fabric.commandsSyncKey for both paths). One less env
to rotate, one less secret to manage.
2. messaging.controller POST /channels/:id/messages adds an isSystem
branch (runs before the participant gate):
- Looks up the channel directly (not via assertParticipant).
- Persists with sentinel author 00000000-0000-0000-0000-000000000000,
same UUID the old impl used.
- Translates <@user.name:NAME> mentions like the regular path.
- When wakeupUserId is set, delivers via emitMessageTargeted so that
exactly that one recipient receives wakeup=true; everyone else gets
wakeup=false. When omitted, delivers via emitMessageCreated with an
empty wakeUserIds set so nobody is woken — silent system log.
Two intentional differences from the 985b06a original:
- No xType=announce restriction. The original was limited to announce
because that was Dialectic's only use case; we now need this on dm /
general / discuss / etc. for the sub-discussion callback. Closed
channels are still rejected (409) on both paths.
- The wakeupUserId field is new — old impl only ever sent silent
announces.
3. DTO carries wakeupUserId? optional string. Ignored on the regular
user-bearer path; load-bearing on the system path.
Shared helper: extracted commands.controller's private safeEqual into
src/common/safe-equal.ts so api-key.guard.ts can use the same constant-
time check. Vitest spec covers equal / inequal / length-mismatch / empty
cases. Existing unit tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
C-1: messaging endpoints now enforce channel participation (public
channels open; private require channel_members). authorUserId is
forced to the authenticated user (no more author spoofing); edit/
delete require message-author ownership; history read gated too.
C-2: PUT /commands body strictly validated + size-capped via
SyncCommandsDto (kills catalog poisoning / DoS). Optional
FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY restricts the write to the
plugin when set; never weaker than before when unset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Guild-global slash-command catalog (one row per node guild). The
OpenClaw plugin PUTs the native-command specs (same data Discord
registers as slash commands); the frontend GETs it for / autocomplete.
- GuildCommand entity (guild_id unique, commands json, updatedAt)
- PUT /api/commands -> idempotent full replace (any authed agent/user)
- GET /api/commands -> { commands, updatedAt } (authed)
- stored verbatim (NativeCommandSpec-shaped); execution path unchanged:
a /<cmd> message is delivered as a normal message -> plugin ->
OpenClaw command system (only /no-reply, /force-proceed stay
server-intercepted).
Verified: PUT->{ok,count}, GET round-trips args/choices, no-auth->401.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>