Skip to content

feat(channels): sidebar settings menu, channel rate limits, limits for everyone - #892

Merged
psamiton merged 3 commits into
mainfrom
feat/channel-limits-and-settings-ui
Aug 11, 2026
Merged

feat(channels): sidebar settings menu, channel rate limits, limits for everyone#892
psamiton merged 3 commits into
mainfrom
feat/channel-limits-and-settings-ui

Conversation

@psamiton

Copy link
Copy Markdown
Contributor

Channel settings existed but the feature was half-built: reachable only from a gear inside the channel view, rules read by nothing at all, char_limit enforced for mind senders only, and no rate limit anywhere in the repo.

What this adds

  • Sidebar dots menu on channel rows, matching mind rows: Channel settings / Make private / Leave channel / Delete channel. The settings modal already existed — it just wasn't reachable without opening the channel first.
  • Channel-wide rate limit (rate_limit + rate_window): at most N messages per W seconds, pooled across all senders. Over-limit sends are refused with 429 naming the limit and when it frees up. Nothing is queued or silently dropped.
  • Both limits now bind every sender, not just minds, and char_limit became a per-message rather than per-text-block rule. Enforcement lives in one place, lib/chat/channel-limits.ts.
  • Channels introduce themselves to minds — description, rules and limits — once per channel per session, on the hook that already pays for participant profiles. Previously a mind met a limit only by being refused by it, and never saw rules at all.
  • Composer shows a live counter against the limit and blocks an over-limit send; a refused send surfaces the server's reason and hands the text back.

Behavior changes to know about

  1. Editing settings tightens from any member to creator-or-admin. No schema change was needed: createConversation already stamps the creator role: "owner". A mind therefore can't lift a limit set to restrain it — but it also loses edit rights on channels it didn't create, and ownerless channels (the commons) become admin-only.
  2. The channel row's X is gone. It called deleteConversation — a hard delete that destroyed the channel and its whole history for every participant, available to any participant. Replaced by an explicit two-click Delete channel, gated to owners and admins on both client and server.
  3. Humans are subject to both limits, and char_limit counts the whole message.
  4. Bridged channels: inbound Discord/Slack messages are deliberately not refused (that would delete words said on another platform) but they do count toward the window, so a busy bridge can throttle Volute senders. Documented in the module.

Bugs found and fixed along the way

  • The settings modal called .trim() on bind:value from an <input type="number">, which yields a number — so saving a character limit always threw and silently did nothing, ever since the modal shipped.
  • The X hard-delete above.
  • A follow-up review caught that the sidebar-opened modal mounted before its settings fetch resolved, and the modal seeds its form once at mount — so opening settings on a channel that had them showed a blank form whose Save erased description, rules and both limits and flipped a private channel public. Both callers now load before opening.

The second commit closes ten review findings in total (rate window counting automated "event" rows; files staged before the limit check leaving orphaned pending offers; echo-text bypassing limits; a refused send destroying the typed message; leaving silently forfeiting ownership; a half-cleared rate pair silently disabling enforcement; the channel card never re-announced after a mid-session change).

Verification

  • npm test 3360/3360, npm run test:e2e 70/70, tsc + svelte-check + template typecheck clean.
  • Every new check was broken deliberately and the new tests confirmed red before being accepted green.
  • Driven in a browser against a throwaway daemon (not the dev ~/.volute): menu, settings round-trip on a fully-populated channel, save-unchanged preserving all five settings, the 63/50 counter in red, the real rate-limit text in chat, the refused message returning to the composer, owner-leave and delete confirms, and the authz gating A/B'd by flipping ownership (as a plain member the settings items and the header gear both disappear).

Follow-ups filed rather than folded in: #890 (GET /api/v1/channels/:name has no authorization at all) and #891 (the channels.private / conversations.private drift).

🤖 Generated with Claude Code

psamiton and others added 2 commits August 10, 2026 14:58
…r everyone

Channel settings existed but the feature was half-built: reachable only from a
gear inside the channel view, `rules` read by nothing, `char_limit` enforced for
mind senders only, and no rate limit anywhere.

- Sidebar channel rows get a dots menu (Channel settings / Make private /
  Leave channel), matching the mind rows. The settings modal is now reachable
  without opening the channel first.
- New channel-wide rate limit (`rate_limit` + `rate_window`): at most N messages
  per W seconds pooled across all senders. Over-limit sends are refused with 429
  naming the limit and when it frees up; nothing is queued or dropped.
- Both limits now apply to every sender, not just minds, and `char_limit` is a
  per-message rather than per-text-block rule. Enforcement moves into
  `lib/chat/channel-limits.ts`, called from the `/api/v1/chat` handler.
- Channels introduce themselves to minds — description, rules, and limits —
  once per channel per session, alongside the participant profiles. Previously a
  mind met a limit only by being refused by it and never saw `rules` at all.
- Settings editing tightens from any member to creator-or-admin. The creator is
  the participant already stamped `role: "owner"` at creation, so this needs no
  new column; ownerless channels (the commons) are admin-only.
- The web composer shows a live counter against the limit and blocks an
  over-limit send; a refused send now surfaces the server's reason instead of a
  generic failure, and withdraws its optimistic bubble.

Two bugs fixed along the way:
- The channel row's `X` called `deleteConversation`, hard-deleting the channel
  for every participant. It is now an explicit "Leave channel".
- `handleSave` in the settings modal called `.trim()` on `bind:value` from an
  `<input type="number">`, which yields a number — so saving a character limit
  always threw and silently did nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Code review of the channel-limits branch surfaced ten verified defects. Fixes:

- **Sidebar settings could wipe a channel.** The modal seeds its form once at
  mount, and both callers could open it before the settings fetch resolved — so a
  channel with settings showed a blank form, and Save erased its description,
  rules and both limits and flipped a private channel public. App.svelte now
  loads before mounting; Chat.svelte disables the gear until settings resolve.
- **Owner-only settings were bypassable.** `DELETE /api/v1/conversations/:id`
  let any participant destroy a channel and re-create it as owner with no
  limits. Deleting a channel now takes the same owner-or-admin authority as
  editing it.
- **Automated traffic spent the rate budget.** The window excluded role
  `"system"` but not `"event"`, so commons announcements could rate-limit a
  channel into silence with nobody having spoken. Only `role = "user"` counts.
- **Files staged before the limit check**, leaving minds holding pending offers
  for messages that were never sent, one more per retry. The check now runs
  before staging (and before fixModelEscapes-normalized text is measured).
- **echo-text bypassed the limits** while its writes still consumed the window —
  a mind with echoText on was never held to a limit at all. It now checks, and
  drops + logs an over-limit echo. Bridge inbound stays exempt on purpose:
  refusing it would delete words already said on another platform. Documented.
- **A refused send destroyed the typed message.** The composer clears before
  dispatch, so withdrawing the optimistic bubble lost the only copy. onSend now
  reports refusal and the composer restores the text and attachments.
- **"Leave channel" silently forfeited ownership** for good, since ownership is
  only the participant row. Owners now get an in-place confirm saying so.
- **A half-cleared rate pair** was accepted and silently stopped enforcing while
  the API still reported it — and blocked the patch that would repair it.
  Coherence is now judged against the row as the patch will leave it.
- **The channel card was never re-announced.** A limit added mid-session never
  reached the mind, and a failed read marked the channel introduced for good.
  It is now keyed on the settings row's updated_at, tracked separately from the
  once-per-session participant profiles.
- **No UI path to delete a channel** remained after the destructive X was
  removed. Added a two-click "Delete channel" for owners and admins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the feat label Aug 10, 2026
The #865 restart test respawned the daemon as soon as the `npx` wrapper's exit
event fired. That wrapper is not the daemon: the real process can still hold the
listening socket, so the replacement lost the bind with EADDRINUSE and exited,
and `waitForHealth()` was satisfied by the outgoing daemon — whose HTTP server
closes last in `shutdown()`, long after `scheduler.stop()`. The test then spent
its full 90s fire window watching a scheduler nobody was running.

The cure already existed in this file, at the auto-upgrade restart: give the
shutdown a real budget, then wait for the port itself with `waitForPortFree()`,
which escalates to the pid actually holding it. Mirror it here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@psamiton
psamiton merged commit 0ef3b75 into main Aug 11, 2026
7 checks passed
@psamiton
psamiton deleted the feat/channel-limits-and-settings-ui branch August 11, 2026 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant