feat(server,client): announcement channels (D1, closes A-2026-07-01)

Make 'announcement' a real channel type, resolving the contradiction where
it was documented and offered by the admin API but hard-rejected by the
migration-013 DB triggers.

Model: announcement channels are readable like text channels (same
READ_MESSAGES visibility), but posting is restricted to users with
MANAGE_MESSAGES — no new permission bit, migration, or client permission
plumbing needed.

Server:
- migrations/016: recreate the channel-type triggers to allow
  text/voice/announcement/dm.
- service/message.go: checkSendPermission now takes the channel type and
  rejects posts to announcement channels from users lacking MANAGE_MESSAGES
  (SendMessage + CanPost paths). Added a service test.
- Unread counts: ready-payload builder (ws/serve.go) and
  GetChannelUnreadCounts (db) now include announcement channels alongside
  text, so they track unread/last-message like text channels.

Client:
- ChannelSidebar renders announcement channels with a megaphone icon
  (added to the icon set) instead of the '#' text prefix; they otherwise
  behave like text channels (already typed in ChannelType).

Specs + trackers (api.md, protocol.md, schema.md incl. migration 016,
architecture/data-model.md, audit A-2026-07-01, decisions D1) updated.

Verified: go build ./...; go test ./service ./db ./ws ./api ./admin;
sqlc-verify; client tsc + oxlint + prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
This commit is contained in:
Claude
2026-07-19 16:00:18 +00:00
parent f66d6c5274
commit 071426c0d8
14 changed files with 92 additions and 18 deletions
@@ -69,7 +69,12 @@ function renderTextChannelItem(
const item = createElement("div", { class: classes, "data-testid": `channel-${channel.id}` });
item.dataset.channelId = String(channel.id);
const prefix = createElement("span", { class: "ch-icon" }, "#");
const prefix = createElement("span", { class: "ch-icon" });
if (channel.type === "announcement") {
prefix.appendChild(createIcon("megaphone", 16));
} else {
prefix.textContent = "#";
}
const name = createElement("span", { class: "ch-name" }, channel.name);
appendChildren(item, prefix, name);
+2
View File
@@ -28,6 +28,7 @@ export type IconName =
| "phone-off"
| "volume-2"
| "volume-x"
| "megaphone"
| "pin"
| "pin-off"
| "users"
@@ -100,6 +101,7 @@ const ICON_PATHS: Record<IconName, string> = {
// Speaker with sound waves
"volume-2": `<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>`,
megaphone: `<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>`,
// Speaker muted (X)
"volume-x": `<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" x2="17" y1="9" y2="15"/><line x1="17" x2="23" y1="9" y2="15"/>`,
+1 -1
View File
@@ -444,7 +444,7 @@ func (d *DB) GetChannelUnreadCounts(userID int64) (map[int64]ChannelUnread, erro
FROM channels c
LEFT JOIN messages m ON m.channel_id = c.id AND m.deleted = 0
LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = ?
WHERE c.type = 'text'
WHERE c.type IN ('text', 'announcement')
GROUP BY c.id`,
userID,
)
@@ -0,0 +1,26 @@
-- Allow the 'announcement' channel type.
--
-- Migration 013 added INSERT/UPDATE triggers restricting channels.type to
-- text/voice/dm, which contradicted the admin UI and specs that already
-- offered 'announcement'. Announcement channels are now a real type: readable
-- like text channels, but posting is restricted to users with MANAGE_MESSAGES
-- (enforced in the service layer). Recreate the triggers to include it.
DROP TRIGGER IF EXISTS trg_channels_type_check_insert;
DROP TRIGGER IF EXISTS trg_channels_type_check_update;
CREATE TRIGGER IF NOT EXISTS trg_channels_type_check_insert
BEFORE INSERT ON channels
FOR EACH ROW
WHEN NEW.type NOT IN ('text', 'voice', 'announcement', 'dm')
BEGIN
SELECT RAISE(ABORT, 'invalid channel type: must be text, voice, announcement, or dm');
END;
CREATE TRIGGER IF NOT EXISTS trg_channels_type_check_update
BEFORE UPDATE OF type ON channels
FOR EACH ROW
WHEN NEW.type NOT IN ('text', 'voice', 'announcement', 'dm')
BEGIN
SELECT RAISE(ABORT, 'invalid channel type: must be text, voice, announcement, or dm');
END;
+13 -4
View File
@@ -148,7 +148,7 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) (
isDM := ch.Type == "dm"
// Permission check.
if err := s.checkSendPermission(p.UserID, p.ChannelID, isDM); err != nil {
if err := s.checkSendPermission(p.UserID, p.ChannelID, ch.Type); err != nil {
return nil, err
}
@@ -687,11 +687,15 @@ func (s *MessageService) CanPost(userID, channelID int64) error {
if err != nil || ch == nil {
return fmt.Errorf("%w: channel not found", ErrNotFound)
}
return s.checkSendPermission(userID, channelID, ch.Type == "dm")
return s.checkSendPermission(userID, channelID, ch.Type)
}
// checkSendPermission validates send permission for DM and non-DM channels.
func (s *MessageService) checkSendPermission(userID, channelID int64, isDM bool) error {
// checkSendPermission validates send permission for a channel of the given
// type. Announcement channels are readable by anyone with READ_MESSAGES but
// only postable by users with MANAGE_MESSAGES (posting is restricted to
// moderators/admins); all other non-DM channels require SEND_MESSAGES.
func (s *MessageService) checkSendPermission(userID, channelID int64, chanType string) error {
isDM := chanType == "dm"
if isDM {
ok, err := s.st.IsDMParticipant(userID, channelID)
if err != nil {
@@ -715,6 +719,11 @@ func (s *MessageService) checkSendPermission(userID, channelID int64, isDM bool)
if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages|permissions.SendMessages) {
return fmt.Errorf("%w: missing SEND_MESSAGES permission", ErrForbidden)
}
// Announcement channels: posting is restricted to users who can manage
// messages, even though everyone with READ_MESSAGES can view them.
if chanType == "announcement" && !s.perms.HasChannelPerm(userID, channelID, permissions.ManageMessages) {
return fmt.Errorf("%w: announcement channels require MANAGE_MESSAGES to post", ErrForbidden)
}
return nil
}
+31
View File
@@ -109,6 +109,37 @@ func TestCanPost_ChannelPermissionRequired(t *testing.T) {
}
}
// TestCanPost_AnnouncementRequiresManageMessages: announcement channels are
// postable only by users with MANAGE_MESSAGES, even when they hold
// READ|SEND. A plain member is refused; a moderator with MANAGE_MESSAGES posts.
func TestCanPost_AnnouncementRequiresManageMessages(t *testing.T) {
ms := store.NewMemStore()
ms.SeedRole(&db.Role{
ID: permissions.MemberRoleID, Name: "member",
Permissions: permissions.ReadMessages | permissions.SendMessages, Position: 1,
})
ms.SeedRole(&db.Role{
ID: permissions.ModeratorRoleID, Name: "moderator",
Permissions: permissions.ReadMessages | permissions.SendMessages | permissions.ManageMessages, Position: 60,
})
ms.SeedUserRole(1, permissions.MemberRoleID)
ms.SeedUserRole(2, permissions.ModeratorRoleID)
ms.SeedUser(&db.User{ID: 1, Username: "alice"})
ms.SeedUser(&db.User{ID: 2, Username: "mod"})
ms.SeedChannel(&db.Channel{ID: 20, Name: "announcements", Type: "announcement"})
checker := permissions.NewChecker(ms)
svc := NewMessageService(ms, NewPermissionService(ms, checker), nil)
// Member has READ|SEND but not MANAGE_MESSAGES → refused in an announcement channel.
if err := svc.CanPost(1, 20); !errors.Is(err, ErrForbidden) {
t.Fatalf("member without MANAGE_MESSAGES must be refused in announcement channel: got %v", err)
}
// Moderator with MANAGE_MESSAGES → allowed.
if err := svc.CanPost(2, 20); err != nil {
t.Fatalf("moderator with MANAGE_MESSAGES must post in announcement channel: got %v", err)
}
}
// TestSendMessage_AttachmentOwnershipAtomic locks the W1-3 semantics: the
// link UPDATE itself enforces ownership, so a foreign, already-linked, or
// nonexistent attachment is skipped (never linked) while the message still
-1
View File
@@ -219,4 +219,3 @@ func (h *Hub) broadcastExcludeLow(channelID, excludeUserID int64, msg []byte) {
}
h.pubsub.PublishLow(ChannelTopic(channelID), msg, excludeUserID)
}
+1 -1
View File
@@ -633,7 +633,7 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte,
"category": visibleChannels[i].Category,
"position": visibleChannels[i].Position,
}
if visibleChannels[i].Type == "text" {
if visibleChannels[i].Type == "text" || visibleChannels[i].Type == "announcement" {
if u, ok := unreadMap[visibleChannels[i].ID]; ok {
entry["unread_count"] = u.UnreadCount
entry["last_message_id"] = u.LastMessageID
+1 -1
View File
@@ -469,7 +469,7 @@ List all channels the authenticated user has `READ_MESSAGES` permission for. DM
| ----- | ---- | ----------- |
| `id` | int64 | Channel ID |
| `name` | string | Channel name |
| `type` | string | `text` or `voice` (`announcement` is planned; the DB currently rejects it) |
| `type` | string | `text`, `voice`, or `announcement` (announcement channels are read like text but only `MANAGE_MESSAGES` holders can post) |
| `topic` | string | Channel topic/description |
| `category` | string | Category grouping |
| `position` | int | Sort order within category |
+1 -1
View File
@@ -98,7 +98,7 @@ erDiagram
| Domain | Tables | Notes |
|--------|--------|-------|
| Identity & access | `roles`, `users`, `sessions`, `channel_overrides`, `user_blocks`, `invites`, `login_attempts`, `rate_lockouts` | Sessions store only SHA-256 token hashes. Permissions are a bitfield on `roles.permissions`; channel overrides use Discord semantics `(role &^ deny) \| allow`. `rate_lockouts` (011) persists rate-limiter lockouts across restarts. |
| Messaging | `channels`, `messages`, `attachments`, `reactions`, `read_states`, `emoji` | `channels.type` is constrained to `text \| voice \| dm` by INSERT/UPDATE triggers from migration 013 — the `announcement` type in the specs/admin UI is rejected at this layer. `attachments.uploader_id` (010) backs upload-ownership checks. |
| Messaging | `channels`, `messages`, `attachments`, `reactions`, `read_states`, `emoji` | `channels.type` is constrained to `text \| voice \| announcement \| dm` by INSERT/UPDATE triggers (migration 013, extended by 016 to allow `announcement`). Announcement channels read like text but require `MANAGE_MESSAGES` to post. `attachments.uploader_id` (010) backs upload-ownership checks. |
| Direct messages | `dm_participants`, `dm_open_state` | DMs are `channels` rows with `type='dm'`; these tables track membership and per-user open/closed UI state (009). |
| Voice | `voice_states` | One row per user (`user_id` is the PK) — a user occupies at most one voice channel. |
| Real-time replay | `events` | Cold tier of the 3-tier reconnect replay ([websocket.md](websocket.md)); written by the async `EventPersister`, pruned by retention. Hub seq counter is seeded from `MAX(events.seq)` at startup so seqs stay monotonic across restarts. |
+2 -2
View File
@@ -14,7 +14,7 @@ accepted-risk note before the beta gate. MEDIUMs are folded into the backlog
| ID | Sev | Finding | Status |
|----|-----|---------|--------|
| A-2026-07-01 | HIGH | `announcement` channel type: documented in 3 specs and offered by the admin API, but hard-rejected by DB triggers | DECIDED 2026-07-19 — implement end-to-end (D1) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) |
| A-2026-07-01 | HIGH | `announcement` channel type: documented in 3 specs and offered by the admin API, but hard-rejected by DB triggers | RESOLVED 2026-07-19 — implemented end-to-end (D1): migration 016 allows the type; posting requires MANAGE_MESSAGES; specs + client updated |
| A-2026-07-02 | HIGH | Client HTTP path accepts any TLS certificate (`allowSelfSigned` hardcoded; no TOFU pinning, unlike WS/LiveKit paths) | CLOSED 2026-07-19 — HTTP TOFU proxy implemented (`http_proxy.rs` + `httpProxy.ts`); REST path now cert-pinned, `acceptInvalidCerts` removed |
| A-2026-07-03 | HIGH | Reference specs (api.md / protocol.md / schema.md) frozen at 2026-04-02; systemic drift incl. whole undocumented subsystems (voice E2EE, plugins) | CLOSED 2026-07-19 — full refresh of api.md/protocol.md/schema.md landed (all §2 fix-spec items); keep-current-per-PR rule now applies |
| A-2026-07-04 | HIGH | Client unit test suite "KNOWN RED" and non-blocking in CI; E2E never gated | OPEN — supersedes prior #11's scope |
@@ -93,7 +93,7 @@ The three reference specs were last meaningfully edited **2026-04-02**
| ID | Spec says | Code does | Evidence | Sev | Resolution |
|----|-----------|-----------|----------|-----|------------|
| A | Channel types: `text, voice, announcement, dm` (also in api.md and protocol.md; admin API offers `announcement`) | DB triggers **reject** any type outside `text\|voice\|dm` | `Server/migrations/013_channel_type_constraint.sql` vs `Server/admin/handlers_channels.go` | **HIGH** | **decide** — either implement announcement channels end-to-end or remove the type from specs + admin API |
| A | Channel types: `text, voice, announcement, dm` | **RESOLVED** — migration 016 allows `announcement`; posting gated on MANAGE_MESSAGES (`Server/service/message.go`) | `Server/migrations/016_announcement_channel_type.sql` | HIGH | done (fix-code, D1) |
| H | Migration history stops at "008", with wrong numbering (two `003_*` entries; DM tables labeled 008) | 15 migrations exist, `001``015`; real order diverges from 004 onward | `Server/migrations/` directory listing | MEDIUM | fix-spec |
| I | — (absent) | 9 tables undocumented: `login_attempts`, `settings`, `emoji`, `sounds`, `rate_lockouts`, `user_blocks`, `events`, `plugins`, `plugin_kv` | `Server/migrations/001,011,012,014,015` — see [architecture/data-model.md](architecture/data-model.md) | MEDIUM | fix-spec |
| J | attachments DDL without `uploader_id` | Column added for upload-ownership checks | `Server/migrations/010_attachment_uploader.sql` | MEDIUM | fix-spec |
+1 -1
View File
@@ -14,7 +14,7 @@ here (and the audit's closure table) as items land.
| # | Decision point | Audit ID | Decision | Status |
|---|----------------|----------|----------|--------|
| D1 | `announcement` channel type (documented + offered by admin API, rejected by DB triggers) | A-2026-07-01 | **Implement end-to-end**: migration to allow the type, posting-permission semantics, admin support, client rendering, spec updates. Not a doc-strip — this becomes a real feature. | Planned (not yet greenlit to start) |
| D1 | `announcement` channel type (documented + offered by admin API, rejected by DB triggers) | A-2026-07-01 | **Implement end-to-end**: migration to allow the type, posting-permission semantics, admin support, client rendering, spec updates. Not a doc-strip — this becomes a real feature. | **Implemented 2026-07-19**: migration 016 allows `announcement`; posting requires MANAGE_MESSAGES (readable like text); admin already offered it; client renders a megaphone icon + unread counts; specs updated. |
| D2 | Data-layer direction (raw SQL vs dead sqlc `db/dbgen` vs `store.Store`) | A-2026-07-05 / A-2026-07-06 | **Adopt sqlc for real**: wire `db.DB` method bodies to the generated `dbgen` queries so sqlc becomes the actual, type-checked query layer. The `sqlc-verify` CI job stays and starts earning its keep. | **Largely done 2026-07-19**: `dbgen.Queries` wired into `db.DB`; 97 methods across all domains delegate to sqlc (no longer dead code). ~43 raw calls remain by design (variable IN, FTS, multi-statement tx, PRAGMA/VACUUM) — tracked in [sqlc-adoption.md](sqlc-adoption.md). |
| D3 | Fate of `Server/store/` (untested abstraction seam) | prior audit #6 | **Remove `store/`**: execute the prior audit's P4 "single data layer" direction. Services call the (sqlc-backed) `db` package directly; tests use in-memory SQLite instead of `MemStore`. | Planned (sequence with/after D2) |
| D4 | Protocol constants sync (`message_types.go` / `protocolTypes.ts` claim a nonexistent `docs/protocol-schema.json`) | A-2026-07-08 | **Create real codegen**: commit an actual `protocol-schema.json` plus a generator that emits the Go and TS constant files (and, ideally, protocol.md's message table), making the "single source of truth" comment true. | **Implemented 2026-07-19**: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-generate`/`protocol-verify` + CI gate. protocol.md table generation deferred to D7. |
+1 -1
View File
@@ -242,7 +242,7 @@ Sent once after `auth_ok` (fresh connection or replay fallback).
### Payload Fields
**channels[]:** `id`, `name`, `type` (`text`/`voice`), `category`, `position`, `unread_count` (text only), `last_message_id` (text only)
**channels[]:** `id`, `name`, `type` (`text`/`voice`/`announcement`), `category`, `position`, `unread_count` (text + announcement), `last_message_id` (text + announcement)
**dm_channels[]:** `channel_id`, `recipient` (user object with `id`, `username`, `avatar`, `status`), `last_message_id`, `last_message`, `last_message_at`, `unread_count`
+6 -4
View File
@@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS schema_versions (
| `013_channel_type_constraint.sql` | INSERT/UPDATE triggers restricting `channels.type` to `text`/`voice`/`dm` |
| `014_events_table.sql` | Adds `events` — persistent broadcast log for reconnect cold-tier replay |
| `015_plugins.sql` | Adds `plugins` and `plugin_kv` for the WASM plugin runtime |
| `016_announcement_channel_type.sql` | Recreates the channel-type triggers to allow `announcement` |
---
@@ -151,10 +152,11 @@ CREATE TABLE channels (
);
```
Channel types: `text`, `voice`, `dm`. Migration 013 installs INSERT/UPDATE
triggers that reject any other value at the database layer. (An `announcement`
type is planned but not yet implemented — see the D1 decision in
[plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md).)
Channel types: `text`, `voice`, `announcement`, `dm`. Migration 013 installs
INSERT/UPDATE triggers restricting the value to this set (migration 016 added
`announcement`). Announcement channels are readable like text channels but
posting is restricted to users with `MANAGE_MESSAGES` (enforced in the service
layer, `Server/service/message.go`).
---