Commit Graph

11 Commits

Author SHA1 Message Date
ca20df7618 refactor(guild): drop system-key bypass + announce-only-system limit
Pairs with Dialectic.Backend@5cf4302 which removes the backend-driven
broadcaster that was the only consumer of the x-fabric-system-key
header path. Backend cleanup is complete on the consumer side; this
removes the producer-side surface.

Removed:
  - ApiKeyGuard: x-fabric-system-key bypass branch (sysExpected /
    sysProvided / req.isSystem flag) — only Bearer flow remains.
  - messaging.controller.create(): the entire 'if (req.isSystem)'
    branch including the SYSTEM_USER_ID='00000000-...-0000' sentinel
    persistence path.
  - messaging.controller.create(): the 'if (xType === announce) throw
    announce_system_only' gate. Announce channels are now ordinary
    channels — any participant can POST. Use case: agents post one-off
    recruitment broadcasts via fabric-send-message (e.g. dialectic
    'come participate in topic X' messages).
  - cli/gen-system-api-key.ts: deleted (was the generator for the env
    that's no longer read).

Kept:
  - channel.purpose field + PATCH /api/channels/:id (member auth for
    setting purpose — agents use this to label channels for
    fabric-channel-list discoverability).
  - cli/print-commands-sync-key.ts (separate key, separate lifecycle).
  - GuildRole.isSystem flag (unrelated — system-role permission gate).
2026-05-23 23:49:47 +01:00
985b06a886 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.
2026-05-23 17:49:53 +01:00
80ee9082f3 feat(guild): announce channel type + agent-presence + busy-discard
Phase 1 of DIALECTIC-V2 — adds Fabric infrastructure for
system-broadcast channels with HF-status-aware delivery filtering.

New channel x_type 'announce':
- channels.entity.ts + channels.service.ts + realtime.gateway.ts
  enum + union extended.
- computeDelivery() adds an 'announce' case: recipient with
  presence='busy' → 'skip' (discarded silently); other presences →
  'observer' (delivered, no wake). System-broadcast semantics —
  agents proactively check their announce inbox when they're ready,
  not interrupted out of band.
- messaging.controller POST guard: announce-type channels reject
  posts that don't present x-fabric-system-key header matching
  FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env. Empty env = no system
  caller is valid (closed-by-default).

New entity + module agent_presences:
- agent-presence.entity.ts: per-user (userId PK) status enum
  (idle/on_call/busy/exhausted/offline/unknown), source tag, updatedAt
- agent-presence.service.ts: getStatus/getStatusMap (bulk for
  delivery-time fanout) + setStatus (upsert)
- agent-presence.controller.ts: GET + PUT /agents/:userId/presence
- agent-presence.module.ts: TypeORM forFeature + wired into AppModule
- buildTypeOrmConfig() entities list extended

RealtimeGateway wiring:
- New optional  field on the gateway (typed loosely to avoid
  circular import). RealtimeModule.onModuleInit() assigns from the
  injected AgentPresenceService — degrades gracefully (no busy-discard,
  treat all as 'unknown') if presence wiring is ever removed.
- emitMessageCreated pre-loads presence per fanout only when xType is
  'announce' (other xTypes bypass the lookup entirely).

Note: actual presence data writes come from Fabric.OpenclawPlugin's
presence-sync loop (separate commit on that submodule); without it,
all rows are 'unknown' and announce delivery falls through to the
default observer behavior (no busy filtering). System-only POST gate
is independent and works immediately.

See /home/hzhang/arch/DIALECTIC-V2-DESIGN.md sections 7 + 10 Phase 1.
2026-05-23 11:31:47 +01:00
7cb046d785 feat(triage): 3-state delivery + admin observer + admin cache
Triage channels now compute a 3-state delivery decision per recipient
(wake / observer / skip) instead of the binary wakeup flag, and route
according to:

  1. author never gets back their own message            → skip
  2. wake_mapping member (on-duty)                       → wake
  3. mention (NEW: was 'skip' for triage before)         → wake
  4. Center-scoped admin (at most 1)                     → observer
  5. anyone else                                         → skip
                                                         (was 'deliver wake=false')

Skipping means the websocket emit is omitted entirely — the recipient's
openclaw plugin never sees the message and the agent's session stays
free of background noise. Observer means delivered with wakeup=false
(silent UI / no model dispatch on the plugin side).

## What this PR ships

### realtime/realtime.gateway.ts
- new `computeDelivery()` returns DeliveryDecision = 'wake'|'observer'|'skip'
- old `computeWakeup()` kept as a deprecated wrapper for callers that
  still want the boolean answer (treats observer + skip as false)
- `emitMessageCreated` accepts `adminUserId?: string|null` and now
  short-circuits on 'skip' (no socket emit at all)
- general kept its current behavior; custom kept its current behavior
  (members not in wake_mapping become observer instead of `wake=false`)
  — the user-visible bit is just that the response field is the same
  `wakeup: boolean`; the explicit 'skip' is new for triage

### common/center-auth.ts
- `fetchAdminEmail()` calls GET `${center}/auth/admin-email` with the
  existing x-api-key (same auth as introspect/resolve-names). Returns
  `{email, userId}` or `null` on either "no admin" or any error

### common/admin-cache.service.ts (NEW)
- `AdminCacheService` — in-memory cache, 1-day TTL, lazy refresh.
  `get(force=true)` bypasses TTL for cli-triggered refresh
- exposed by MessagingModule

### messaging/messaging.controller.ts
- non-rotating branch threads `adminUserId` into emitMessageCreated

### cli/admin-refresh.ts (NEW)
- `node dist/cli/admin-refresh.js` — force-refresh cache and print
  before/after JSON. Use after a Center `user set-admin` so triage
  delivery picks up the new admin without waiting for 24h TTL

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:14:05 +01:00
e45ad91340 fix(security): close Critical IDOR/authz gaps (C-1/C-2)
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>
2026-05-16 17:47:08 +01:00
8c41d23a9c refactor: migrate to ES modules
package.json type=module, tsconfig module/moduleResolution=NodeNext,
target es2022, explicit .js on all relative imports. Center: jsonwebtoken
& bcryptjs switched to default imports (ESM/CJS interop). Verified:
builds, boots, full auth + plugin round-trip work under ESM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:47:36 +01:00
9670da400e feat(guild): closed channel (discussion-complete support)
Channel.closed; POST /channels/:id/close (member-only); message/command
posts on closed channel -> 409 {error:channel_closed}; GET history still
allowed; listForUser carries closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:52:43 +01:00
22fd834ed0 feat(guild): translate <@user.name:NAME> -> <@userId>
Before persist/parse, resolve <@user.name:NAME> (outside backticks) via
Center and rewrite to <@userId>; unresolved tokens left as-is. Translated
ids then flow into the existing mention/wakeup/sub-frame logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:47:01 +01:00
02b7c72e70 feat(guild): <@id> mention mechanism
- parse <@user-id> outside backtick spans
- general: message with an at-list wakes only the at'd users (else all)
- report/triage/custom: mentions change nothing
- discuss/work: mention by current speaker pushes a sub-rotation frame
  (atList = mentions - sender, intersected with channel members); single
  linear pass (real/no-reply/force-proceed), then pop back to the saved
  parent pointer (resumes at the pusher); nested frames supported

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:27:35 +01:00
6b993522cf feat(guild): wake_mapping, per-recipient wakeup, discuss/work turn engine, channel join/leave
- wake_mapping table; triage onDuty (auto-added member) / custom listeners
- per-recipient wakeup metadata on message.created (one message-id; added
  only at push). Rules: author=false; triage/custom=wake_mapping only;
  general=all; report=none
- discuss/work rotation: channel_turn_state (order/currentSpeaker/round
  events/cross-round no-reply streak); null activation, queue-jump,
  /no-reply pass, all-/no-reply pause, end-of-round shuffle (trailing
  no-reply run to tail, head shuffled, first != last normal speaker)
- slash-command registry (/no-reply, /force-proceed); registered commands
  intercepted and never delivered; guild-authored /ack persisted
- POST /channels/:id/join|leave; leave cleans channel_members, wake_mapping
  and turn-state order

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:51:09 +01:00
nav
d9c5175233 feat: bootstrap from Fabric monorepo 2026-05-13 07:06:03 +00:00