fix(channel): allow any workspace member to create DM/group_dm channels #32

Merged
sameh-farouk merged 3 commits from fix/dm-create-permission-gate into development 2026-04-27 18:05:03 +00:00
Member

Summary

Closes #30.

Two changes that together restore Slack-parity DM creation for ordinary workspace members:

  1. Server-side: channel.create admin-gated every kind including DMs, blocking startDm for non-admin accounts. Fix splits the gate by kind — public/private remain admin-gated, DM/group_dm only require workspace membership.

  2. Client-side: startDm followed channel.create with two channel.member.add calls for creator + peer. The creator self-add tripped channel.member.add's self-add guard (Cannot self-add to dm channel <N>: user_id matches caller_id) and surfaced as a console error. Replaced with passing peer_ids: [userId] to channel.create itself — the server's kind=='dm' branch (channel.rs:117-133) inserts the peer atomically under the same db.lock as the creator, and the existing dispatch at channel.rs:213-217 fires ChannelAdded to Audience::Users([creator, peer]) covering both participants in one fanout.

The two changes together fix the symptom end-to-end: opening the server-side gate alone produces a working DM but with the self-add console error; client-side adoption of the inline-peers contract eliminates the error and aligns with the contract intent at channel.rs:100-114.

What changed

Server (crates/hero_collab_server/src/handlers/channel.rs::create)

Permission gate splits by kind:

  • kind == "dm" || kind == "group_dm": bypass check_permission. Require an authenticated caller (Unauthenticated if missing) and verify caller is a member of workspace_id. Membership probe is skipped under is_dev_mode() for parity with check_permission's blanket dev bypass — keeps --auth-mode=dev developer flows from needing pre-seeded workspace_members rows for picker-selected identities.
  • kind == "channel" || kind == "private": unchanged — still calls check_permission("channel.create") exactly as before.

Client (crates/hero_collab_ui/static/js/chat-app.js::startDm)

  • Pass peer_ids: [userId] to channel.create.
  • Drop the two channel.member.add calls.

Auth-mode regression check

Scenario Server gate Member rows Console error
Proxy: viewer creates DM with sameh workspace-member gate ✓ creator (admin) + peer (member), atomic none ✓
Proxy: viewer tries public/private channel still admin-gated → 403 unchanged unchanged
Dev: picker user creates DM dev-mode bypass on probe ✓ creator + peer, atomic none ✓
Dev legacy currentUser.id===0 unauthenticated → 401 (DM creation requires auth) matches pre-fix behavior — channel.member.add would also fail with caller_id=None none ✓
Audience::Users([creator,peer]) ChannelAdded dispatch unaffected unchanged both peers see DM in sidebar live

Test plan

  • viewer_test (non-admin, workspace 1 member): DM with sameh → 200 OK, both rows in channel_members
  • viewer_test: public channel create → still 403 PermissionDenied ✓
  • viewer_test: private channel create → still 403 PermissionDenied ✓
  • No console error on DM create (was: Cannot self-add to dm channel <N>: user_id matches caller_id) ✓
  • cargo build clean
  • node --check on chat-app.js passes
  • Single activity_log entry per DM creation (was: 3 — channel.create + 2 member.add)

Out of scope

  • Verifying the peer is also a workspace member is left to the existing user-picker flow (which only ranks workspace members as candidates). Server-side hardening of cross-workspace DM creation is a separate design choice.

🤖 Generated with Claude Code

## Summary Closes #30. Two changes that together restore Slack-parity DM creation for ordinary workspace members: 1. **Server-side**: `channel.create` admin-gated every kind including DMs, blocking `startDm` for non-admin accounts. Fix splits the gate by `kind` — public/private remain admin-gated, DM/group_dm only require workspace membership. 2. **Client-side**: `startDm` followed `channel.create` with two `channel.member.add` calls for creator + peer. The creator self-add tripped channel.member.add's self-add guard (`Cannot self-add to dm channel <N>: user_id matches caller_id`) and surfaced as a console error. Replaced with passing `peer_ids: [userId]` to `channel.create` itself — the server's kind=='dm' branch (channel.rs:117-133) inserts the peer atomically under the same db.lock as the creator, and the existing dispatch at channel.rs:213-217 fires `ChannelAdded` to `Audience::Users([creator, peer])` covering both participants in one fanout. The two changes together fix the symptom end-to-end: opening the server-side gate alone produces a working DM but with the self-add console error; client-side adoption of the inline-peers contract eliminates the error and aligns with the contract intent at channel.rs:100-114. ## What changed ### Server (`crates/hero_collab_server/src/handlers/channel.rs::create`) Permission gate splits by `kind`: - `kind == "dm" || kind == "group_dm"`: bypass `check_permission`. Require an authenticated caller (`Unauthenticated` if missing) and verify caller is a member of `workspace_id`. Membership probe is skipped under `is_dev_mode()` for parity with `check_permission`'s blanket dev bypass — keeps `--auth-mode=dev` developer flows from needing pre-seeded `workspace_members` rows for picker-selected identities. - `kind == "channel" || kind == "private"`: unchanged — still calls `check_permission("channel.create")` exactly as before. ### Client (`crates/hero_collab_ui/static/js/chat-app.js::startDm`) - Pass `peer_ids: [userId]` to `channel.create`. - Drop the two `channel.member.add` calls. ## Auth-mode regression check | Scenario | Server gate | Member rows | Console error | |---|---|---|---| | Proxy: viewer creates DM with sameh | workspace-member gate ✓ | creator (admin) + peer (member), atomic | none ✓ | | Proxy: viewer tries public/private channel | still admin-gated → 403 | unchanged | unchanged | | Dev: picker user creates DM | dev-mode bypass on probe ✓ | creator + peer, atomic | none ✓ | | Dev legacy `currentUser.id===0` | unauthenticated → 401 (DM creation requires auth) | matches pre-fix behavior — `channel.member.add` would also fail with caller_id=None | none ✓ | | `Audience::Users([creator,peer])` `ChannelAdded` dispatch | unaffected | unchanged | both peers see DM in sidebar live | ## Test plan - [x] viewer_test (non-admin, workspace 1 member): DM with sameh → 200 OK, both rows in `channel_members` ✓ - [x] viewer_test: public channel create → still 403 PermissionDenied ✓ - [x] viewer_test: private channel create → still 403 PermissionDenied ✓ - [x] No console error on DM create (was: `Cannot self-add to dm channel <N>: user_id matches caller_id`) ✓ - [x] cargo build clean - [x] node --check on chat-app.js passes - [x] Single activity_log entry per DM creation (was: 3 — channel.create + 2 member.add) ## Out of scope - Verifying the *peer* is also a workspace member is left to the existing user-picker flow (which only ranks workspace members as candidates). Server-side hardening of cross-workspace DM creation is a separate design choice. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
`channel.create` routed every kind through the same admin gate
(`channel_manage` / `workspace_admin`), which blocked ordinary users
from starting DMs. Symptom from dogfooding: clicking a user in the DM
picker surfaced `caller '<id>' lacks permission for 'channel.create'`,
making 1:1 conversations unreachable for non-admin accounts.

Slack-parity contract: any workspace member can DM any other workspace
member without admin involvement. Public/private channels remain
admin-gated.

Fix splits the gate by kind. DM and group_dm bypass `check_permission`
and instead require:
- an authenticated caller (`Unauthenticated` if missing),
- caller is a member of the target workspace
  (skipped under `is_dev_mode()` for parity with the blanket dev
  bypass in `check_permission`, so `--auth-mode=dev` developer flows
  don't need pre-seeded `workspace_members` rows for picker-selected
  identities).

Public/private kinds continue to call `check_permission("channel.create")`
exactly as before — no change in behaviour for the admin-gated path.

Verified empirically:
- viewer_test (non-admin, workspace member): DM create → 200 OK.
- viewer_test: public channel create → still 403 PermissionDenied.
- viewer_test: private channel create → still 403 PermissionDenied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`channel.create` for `kind=='dm'` already inserts both the creator
(as admin) and the peer (as member) into channel_members atomically
on the server (channel.rs::create, lines 85-133). The two
`channel.member.add` calls in startDm were leftover from before that
server-side change and are now actively harmful — the creator self-add
hits `channel.member.add`'s "user_id matches caller_id" guard and
surfaces as a console error:

  Error: Cannot self-add to dm channel <N>: user_id matches caller_id

Symptom: every DM creation logs an error to the browser console even
though the DM works (server-side inline-add already succeeded).

Removing the now-redundant client-side adds is purely cosmetic — DM
behaviour is unchanged. The peer-side `channel.member.add` was also
redundant since the same `channel.create` for kind=='dm' inserts the
peer too.

Folded into the DM permission gate PR because it lives on the same
flow (`startDm` → `channel.create('dm')` → DM ready) and only makes
sense after the server-side permission gate is opened up — the two
together produce the working "any workspace member can DM any other"
behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sameh-farouk force-pushed fix/dm-create-permission-gate from a4c6701b2d to e3b066fb62 2026-04-27 17:21:52 +00:00 Compare
sameh-farouk merged commit 8c9e197518 into development 2026-04-27 18:05:03 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_collab!32
No description provided.