diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index b4ad9b62..450e65ff 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -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); diff --git a/Client/tauri-client/src/lib/icons.ts b/Client/tauri-client/src/lib/icons.ts index 274e66fe..0db8c5ca 100644 --- a/Client/tauri-client/src/lib/icons.ts +++ b/Client/tauri-client/src/lib/icons.ts @@ -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 = { // Speaker with sound waves "volume-2": ``, + megaphone: ``, // Speaker muted (X) "volume-x": ``, diff --git a/Server/db/message_queries.go b/Server/db/message_queries.go index 13cc1511..a6c99037 100644 --- a/Server/db/message_queries.go +++ b/Server/db/message_queries.go @@ -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, ) diff --git a/Server/migrations/016_announcement_channel_type.sql b/Server/migrations/016_announcement_channel_type.sql new file mode 100644 index 00000000..b8dcc5a7 --- /dev/null +++ b/Server/migrations/016_announcement_channel_type.sql @@ -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; diff --git a/Server/service/message.go b/Server/service/message.go index 9dd0a4d8..4a428e66 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -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 } diff --git a/Server/service/message_test.go b/Server/service/message_test.go index 3d9aa170..41a0020c 100644 --- a/Server/service/message_test.go +++ b/Server/service/message_test.go @@ -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 diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index 05160eca..8d21a328 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -219,4 +219,3 @@ func (h *Hub) broadcastExcludeLow(channelID, excludeUserID int64, msg []byte) { } h.pubsub.PublishLow(ChannelTopic(channelID), msg, excludeUserID) } - diff --git a/Server/ws/serve.go b/Server/ws/serve.go index 903ddc4b..3010d8d0 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -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 diff --git a/docs/api.md b/docs/api.md index 2372b574..c2f1fffa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 | diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 3f52fb6d..ba2072eb 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -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. | diff --git a/docs/audit-2026-07-19.md b/docs/audit-2026-07-19.md index 9697d08c..c5a1ab49 100644 --- a/docs/audit-2026-07-19.md +++ b/docs/audit-2026-07-19.md @@ -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 | diff --git a/docs/plans/audit-2026-07-19-decisions.md b/docs/plans/audit-2026-07-19-decisions.md index 23a9beeb..53818afe 100644 --- a/docs/plans/audit-2026-07-19-decisions.md +++ b/docs/plans/audit-2026-07-19-decisions.md @@ -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. | diff --git a/docs/protocol.md b/docs/protocol.md index 152061cd..6e51bacd 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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` diff --git a/docs/schema.md b/docs/schema.md index ca13420e..b44320ae 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -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`). ---