mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +03:00
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:
@@ -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);
|
||||
|
||||
@@ -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"/>`,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -219,4 +219,3 @@ func (h *Hub) broadcastExcludeLow(channelID, excludeUserID int64, msg []byte) {
|
||||
}
|
||||
h.pubsub.PublishLow(ChannelTopic(channelID), msg, excludeUserID)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user