Skip to content

feat(channels): shared hashtag channel proposals, with admin revoke - #99

Merged
dborup merged 9 commits into
masterfrom
codex/shared-channel-proposals
Sep 26, 2026
Merged

dborup merged 9 commits into
masterfrom
codex/shared-channel-proposals

Conversation

@adminopenclaw8-sketch

@adminopenclaw8-sketch adminopenclaw8-sketch commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Formål

Anyone can suggest a public hashtag channel on the Channels page; an
administrator (the existing apiKey) reviews suggestions at
#/channels?view=proposals — approve, reject, and now also revoke a
channel that was already approved.

Produktbeslutninger

  • Godkendte kanaler dekrypteres af ingestoren og vises offentligt for alle
    (GET /api/channels → approvedChannels), selv før de har trafik.
  • Rate limit på forslag er global (ikke per bruger/IP), som ønsket — en
    per-klient grænse ville være upålidelig bag en reverse proxy.
  • Fjernelse af en godkendt kanal er et krav i denne PR, ikke en senere
    udvidelse — admin kan altså altid fortryde en godkendelse.
  • Fjernelse stopper kun fremtidig dekryptering. Allerede dekodede og
    gemte beskeder slettes eller skjules ikke — de forbliver synlige på
    Channels-siden i deres eksisterende form, uændret.

Arkitektur

  • internal/channelregistry: delt navnevalidering (firmware-grænsen på 31
    UTF-8 bytes inkl. #, case bevares, ingen kontrol-/bidi-tegn, linjeskift
    U+2028/U+2029 eller usynlige formateringstegn), config/limits,
    en bounded atomic fil-kø mellem den read-only server og ingestoren, og
    read-only SQL-hjælpere der behandler en manglende tabel som tom (rolling
    upgrade).
  • Server er read-only: den skriver kun kommando-filer til kø-mappen ved
    siden af databasen. Ingen ny skrive-sti på *DB — håndhævet af
    cmd/server/readonly_invariant_test.go.
  • Ingestoren skriver: den anvender hver kommando i en transaktion,
    skriver et resultat, og opdaterer hotKeys (den levende
    dekrypteringsnøgle-lag) i samme operation.
  • Tabellen channel_proposals oprettes af internal/dbschema.Apply (kun
    ingestoren), og er bevidst uden for AssertReady — en ny server
    starter altså stadig fint mod en database uden tabellen (rolling upgrade).

Tilstandstabel

Kilde Handling Mål Sideeffekt
pending approve approved hotKeys.AddApproved (nøgle tilføjes)
pending reject rejected ingen nøgleændring
approved revoke revoked hotKeys.RemoveApproved (nøgle fjernes, medmindre samme kanal også er konfigureret manuelt — den vinder stadig)
revoked approve — konflikt (approve accepterer kun kilde=pending)
revoked genforslag (samme navn) pending samme række-id genbruges; aldrig auto-godkendt
rejected genforslag (samme navn) rejected (uændret) spærret indtil retention sletter rækken (retentionDays, standard 30 dage efter afvisningen)
status == mål allerede enhver uændret idempotent no-op
andet enhver — konflikt, ingen DB- eller nøgle-mutation

Fjernede rækker slettes ikke ved selve fjernelsen (status ændres blot,
reviewed_at bevares som audit-tidsstempel), men følger samme
retention-regel som afviste rækker. Godkendte rækker beskæres aldrig.

Endpoints og auth

  • GET /api/channel-proposals/config, POST /api/channel-proposals,
    GET /api/channel-proposals/requests/{requestId} — åbne (kræver kun at
    channelProposals.enabled er sat og en stærk apiKey er konfigureret).
  • GET /api/admin/channel-proposals[?status=],
    POST /api/admin/channel-proposals/{id}/approve|reject|revoke — kræver
    X-API-Key.
  • revoke er den eneste beslutning med et synkront forhåndstjek: kun en
    aktuelt godkendt kanal kan fjernes — er den ikke det, svares 409 med det
    samme, og intet skrives til kø-mappen. Der er stadig et reelt kapløb mellem
    dette tjek og ingestorens faktiske anvendelse; ingestoren genvalidere
    status fra bunden og er den egentlige autoritet.

Rettelser efter det uafhængige review (BLOCK)

  1. Stored XSS i pakke-detaljen (P2). decoded.channel blev indsat
    uescaped i innerHTML (public/packets.js, meta-linjen under
    beskeden). Beskedblokken er nu den rene funktion buildDetailMessageHtml,
    hvor hver del (kanal, channel_idx, hops, SNR) escapes med escapeHtml.
    Gennemgået og rettet i samme omgang:
    • packets.js: data-leveret channelHashHex i listevisningens preview
      (GRP_TXT og GRP_DATA), i detaljens GRP_TXT-blok og i byte-oversigten.
    • channels.js: tidsopdateringens querySelector bruger nu
      CSS.escape (et navn med " kastede før en SyntaxError hvert sekund
      og stoppede resten af rækkerne); katalogets region fra
      knownChannelsUrl escapes nu.
    • analytics.js: kanal-tabellens c.hash i data-value og celle.
    • app.js: den (i dag døde) kanal-gren i global søgning.
    • Allerede sikre og uændrede: kanalrækker og mobilrækker i
      channels.js, channel-proposals.js, live.js, map.js/route-view,
      channel-qr.js, farvevælgeren.
  2. Gammel CHECK uden 'revoked'. Commit 4868622 blev pushet med
    CHECK(status IN ('pending','approved','rejected')), og CREATE TABLE IF NOT EXISTS retter ikke en eksisterende tabel. internal/dbschema.Apply
    læser nu tabellens DDL i sqlite_master; mangler 'revoked', genopbygges
    tabellen i én transaktion (ny tabel, kopiér rækker, drop, rename, genskab
    indeks) og logger antallet af bevarede rækker. En anden kørsel er no-op.
  3. Usynlige tegn. Navne afviser Unicode-kategori Cf undtagen ZWJ
    (U+200D) — bl.a. ZWSP, BOM, soft hyphen og tag-tegnene
    U+E0000–U+E007F — samt U+2028/U+2029 (Zl/Zp, men linjeskift). Variation
    selectors er ikke Cf og forbliver tilladt, så emoji med ZWJ/VS16 virker.
    Samme regel i channelregistry (server og ingestorens genvalidering) og
    i frontendens validering. Det er CoreScopes egen præsentationsregel;
    firmwaren begrænser kun længden. Konsekvens: emoji-flag med tag-sekvens
    (fx subdivision-flag) afvises.
  4. Indbyggede navne. Ingestorens base-lag (indbyggede nøgler,
    channel-rainbow.json med ca. 320 navne som #test, #chat,
    #general, samt hashChannels/channelKeys) vinder over godkendte
    forslag. Ingestoren skriver nu disse hashtag-navne til
    builtin-channels.json i kø-mappen ved opstart og efter hver SIGHUP;
    serveren markerer dem builtIn: true i admin-listen og i
    request-status. Admin-UI'et viser "Built in: already decrypted",
    bekræftelsen ved Remove siger at dekrypteringen fortsætter, og den der
    foreslår får en advarsel. Den forældede kommentar i
    cmd/ingestor/hot_reload.go ("only ever grows") er rettet.
  5. Afviste navne forbliver spærret indtil retention (30 dage som
    standard); dokumenteret og testet.
  6. Øvrigt: CSS.escape i channels.js (se 1); approved-cachen tømmes
    kun af et resultat, der er afsluttet efter snapshot'et blev læst (gentagne
    anonyme polls tømmer den ikke længere); revoke-konflikten siger nu
    "suggestion is not approved (it is pending)"; submit afviser
    trailing data og ukendte felter; kø-mappen fsync'es efter rename; en
    statisk test fejler ved ny skrivende SQL i cmd/server; api-spec og
    OpenAPI nævner revoked, builtIn og 409.

CI / Docker

  • .github/workflows/deploy.yml: nye testtrin for internal/channelregistry
    og internal/dbschema, test-channel-proposals.js,
    test-packet-detail-channel-xss.js og test-channels-name-escaping.js
    tilføjet til unit-blokken, bygger en ny corescope-ingestor-binær til E2E, og én ny
    E2E-linje for test-channel-proposals-e2e.js.
  • Dockerfile: COPY internal/channelregistry/ ... tilføjet til både
    server- og ingestor-build-stages, samme mønster som eksisterende interne
    pakker.
  • Ingen deploy-triggere, secrets eller fork-guards er ændret — udelukkende
    additive CI-/build-trin.

Tests

  • Go (ingestor + server, -race): fuld dækning af tilstandstabellen
    ovenfor, idempotens, crash-replay, SIGHUP, genstart, manglende tabel,
    genforslag-efter-fjernelse, retention. Read-only-invariant og
    OpenAPI-completeness kørt eksplicit.
  • Frontend: test-channel-proposals.js (26 tests), test-packet-detail-channel-xss.js (8) og test-channels-name-escaping.js (4) — inkl. XSS-escape af
    kanalnavnet og fokus/Escape/Tab-trap for bekræftelsesdialogen ved fjernelse.
  • E2E: test-channel-proposals-e2e.js (21 tests) — hele flowet: foreslå
    → godkend → en anden session ser den delt → admin fjerner (med
    bekræftelsesdialog) → sessionen ser den ikke længere → genstart af
    ingestor+server genopliver den ikke → genforslag lander som pending.
    Desktop lys/mørk + mobil 390×844 valideret, ingen horisontal overflow.
  • Mutationer (alle bevist røde, derefter rettet): glemt
    RemoveApproved-kald, SIGHUP der nulstiller den godkendte nøgle-lag,
    approvedChannels der ikke filtrerer fjernede kanaler, og
    auto-godkendelse ved genforslag.

Kendte begrænsninger

  • builtIn kræver en ingestor fra denne PR; en ældre ingestor skriver ikke
    builtin-channels.json, og så markeres intet. Filen skrives kun ved
    opstart og SIGHUP — slettes den manuelt, kommer den igen ved næste
    genstart/reload.

  • cmd/server har fra før denne PR skrivende SQL mod den delte database i
    hash_migrate.go og routes.go (samt ping_score_history.go mod sin egen
    database og backup.gos VACUUM INTO). Den nye statiske test tillader
    præcis disse og fejler på alt nyt; selve stederne er ikke ændret her.

  • Frontendens Cf-tjek bruger \p{Cf}; en browser uden Unicode property
    escapes springer tjekket over, og serveren afviser så navnet.

  • Den globale rate limit for forslag kan lukke for nye forslag i op til 1
    time, hvis grænsen nås.

  • En klient kan i op til ~15 sekunder blive ved med at vise en kanal som
    delt/godkendt efter admin har fjernet den (cache-TTL), før næste
    hentning/genindlæsning viser den korrekte tilstand.

  • Grænseværdier (maxPending, maxApproved, retentionDays,
    submissionsPerHour) er kun i config.json for nu — de bør eksponeres i
    customizeren i en senere milestone (jf. AGENTS.md regel 8).

Rækkefølge

Cache-rettelsen i #98 ligger også (uændret) inde i denne branchs historik,
fordi den oprindeligt blev fundet og rettet her, før den blev udskilt til sin
egen branch. #98 bør merges først — når den merges til master, falder
den sammen med den identiske rettelse her ved næste merge fra master.

Staging

Denne PR tilføjer en ny tabel (channel_proposals) via
internal/dbschema.Apply. Et staging-deploy af denne branch bør derfor
have en frisk DB-snapshot klar til rollback, i tilfælde af at
migrationen eller den nye kø-mekanisme opfører sig uventet mod rigtig data.

🤖 Generated with Claude Code

Openclaw and others added 7 commits September 25, 2026 13:59
Anyone can suggest a public hashtag channel on the Channels page (a new
section in the Add Channel dialog); the administrator approves or rejects
suggestions at #/channels?view=proposals with the existing apiKey.
Approved channels are decrypted by the ingestor and listed for everyone
via GET /api/channels approvedChannels, even before they carry traffic.

Architecture:
- internal/channelregistry: shared name validation (firmware limit of 31
  UTF-8 bytes incl. '#', case preserved, no control/bidi characters), the
  config/limits, a bounded atomic file queue between the read-only server
  and the ingestor, and read-only SQL helpers that treat a missing table
  as empty (rolling upgrade).
- Server (read-only): /api/channel-proposals{,/config,/requests/{id}} and
  /api/admin/channel-proposals{,/{id}/approve,/{id}/reject}; submissions
  open only with channelProposals.enabled and a strong apiKey; global
  hourly rate limit and queue bound. approvedChannels comes from a short
  cache and never touches the cached channel list; the includeEncrypted
  append no longer writes into that cached slice's spare capacity.
- Ingestor: channel_proposals table (dbschema.Apply, BINARY unique name),
  idempotent command replay after a crash, limits, retention, and an
  approved-channel layer in hotKeys that SIGHUP cannot drop (configured
  keys keep priority).
- Frontend: channel-proposals.js (suggest form, admin dialog with keyboard,
  Escape and focus handling, admin key in memory only, single bounded
  poller stopped on navigation) and O(n+m) merge of approved channels,
  shown under Network with a Shared label and no local remove control.

Tests: registry, ingestor and server Go tests (limits, case semantics,
replay, retention, restart, SIGHUP, missing table, cache immutability),
frontend unit tests on the production file, and a Playwright E2E that runs
the real ingestor and server with independent browser sessions. CI runs
all of them; Dockerfile copies the new module. Docs, OpenAPI and
config.example.json updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a revoke action so an administrator can undo a previous approval of
a publicly suggested hashtag channel, on top of the existing
submit/approve/reject flow (internal/channelregistry, cmd/ingestor,
cmd/server, public/channel-proposals.js).

State machine: pending --approve--> approved --revoke--> revoked;
pending --reject--> rejected; revoked --resuggest same name--> pending
(never auto-approved; the row's original id is reused, so the resuggestion
is idempotent under command replay the same way plain submit already is).
Approving, rejecting or revoking a row outside its required source status
is a conflict; repeating the exact decision already stored is idempotent.

- internal/channelregistry: StatusRevoked/OpRevoke/RequestRevoked; the
  queue accepts revoke commands the same way as approve/reject.
- internal/dbschema: the channel_proposals CHECK constraint accepts
  'revoked'. This table has never shipped to any real database (part of
  the same unreleased commit that introduced it), so the CREATE TABLE
  literal is changed in place — no migration path was needed.
- cmd/ingestor: hotKeys.RemoveApproved drops a name from the approved
  layer only, never touching base, so a manually configured key for the
  same channel keeps decrypting it. reviewChannelProposal now takes an
  explicit source status (approve/reject: pending; revoke: approved) so
  approving a revoked row is correctly a conflict, not a silent
  re-approval. submitChannelProposal resurrects a revoked row to pending
  on resuggestion instead of leaving it revoked or auto-approving it.
  PruneChannelProposals prunes revoked rows on the same retention rule as
  rejected ones (reviewed_at is reused as the revoke timestamp).
- cmd/server: a dedicated revoke handler performs a synchronous
  precondition check (only an approved proposal may be revoked) before
  enqueueing anything — a deliberate exception to this package's usual
  "let the ingestor validate asynchronously" pattern, since the caller
  wants a side-effect-free 409 for the common case. A real TOCTOU window
  remains between that check and the ingestor's own re-validation, which
  is the true source of truth. GET /api/channels' approvedChannels cache
  is invalidated on an observed revoke the same way it already is on an
  observed approval.
- public/channel-proposals.js: a Revoked filter tab; an approved row gets
  a Remove action guarded by a confirmation dialog reusing the admin
  dialog's own overlay, focus trap and single keydown listener (no second,
  independently-stacked Escape/Tab handler).

Historical messages already decoded and stored while a channel was
approved are never deleted or hidden by a revoke — only future
decryption stops. Documented in docs/api-spec.md and
docs/user-guide/channels.md alongside the retention behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A code-review pass over the revoke feature (previous commit) found a
leftover "#TBD" placeholder in the comment explaining why the status
CHECK constraint was widened in place. No behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nel-proposals

Confirmed master SHA f8ee2be, CI green
(Go Build & Test, Playwright E2E, Docker image) before merging.

One conflict, in internal/dbschema/dbschema.go's Apply(): both branches
inserted a new migration call at the same point (this branch's
ensureChannelProposalsTable, master's ensureTransmissionsRouteMaskColumn
+ ensureRouteMaskChangesTable). Resolved by keeping both, in sequence;
neither depends on the other. channel_proposals remains outside
AssertReady/assertReady in the merged version, so a new server still
starts against a database the ingestor has not migrated yet.

Verified after resolving: cmd/server, cmd/ingestor, internal/dbschema
and internal/channelregistry build and their full test suites (incl.
-race, and the read-only-invariant and OpenAPI-completeness tests) pass
against the merged tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed master SHA fa5bf4f, touching
cmd/server/db.go, routes.go and obs_raw_hex_test.go. Pre-checked with
git merge-tree: conflict-free, result tree matches
8f8320c.
…-channel-proposals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nvisible names

Fixes from the independent review of the shared channel proposals.

1. XSS: the packet detail inserted decoded.channel (and channel_idx,
   path_len, SNR) into innerHTML unescaped; a publicly suggested name such
   as "#<img src=x onerror=...>" ran on open. The message block is now
   buildDetailMessageHtml with every meta part escaped. Also escaped:
   data-supplied channelHashHex in the list preview, detail and byte
   breakdown, the analytics channel table hash, the known-channels
   catalogue region, and the (currently dead) global search channel link.
2. dbschema: a channel_proposals table created by 4868622 has a status
   CHECK without 'revoked', which CREATE TABLE IF NOT EXISTS never fixes.
   Apply now rebuilds it in one transaction, keeping every row and the
   index, and logs it; a second run is a no-op.
3. Channel names reject invisible formatting characters (Unicode Cf,
   except ZWJ; variation selectors are not Cf) and U+2028/U+2029, in
   channelregistry (server and ingestor revalidation) and the frontend.
   This is CoreScope's own presentation rule; the firmware only limits
   the length.
4. Built-in names: the ingestor publishes the hashtag names of its
   config-derived key layer (built-in, channel-rainbow.json, hashChannels,
   channelKeys) to builtin-channels.json in the queue directory at startup
   and after each reload. The server marks them builtIn in the admin list
   and request status; the UI labels them, warns the submitter and says in
   the Remove confirmation that decryption continues. Stale hot_reload
   comment ("only ever grows") corrected.
5. A rejected name stays blocked until retention; documented and tested.
6. channels.js CSS.escape's the hash in the time-ticker selectors; the
   approved-channel cache is only invalidated by a result that completed
   after the snapshot was read (repeated anonymous polls no longer flush
   it); revoke conflict reads "suggestion is not approved (it is X)";
   submit refuses trailing data and unknown fields; the queue fsyncs its
   directory after rename; a static test fails on new write SQL in
   cmd/server; api-spec/OpenAPI list 'revoked' and the 409.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator Author

Review feedback addressed (commit 596dd0a9, efter merge af master i 7d4cb0ca)

  1. Stored XSS i pakke-detaljen: meta-linjen escapes nu del for del (buildDetailMessageHtml); desuden escapes channelHashHex i packets.js, katalogets region i channels.js, c.hash i analytics.js og den døde søgegren i app.js. Browser-repro: payloaden udløser på master 7f6f410, ikke på denne head.
  2. Gammel CHECK uden 'revoked': dbschema.Apply genopbygger tabellen i én transaktion, bevarer rækker og indeks, logger det; anden kørsel er no-op.
  3. Usynlige tegn: Cf (undtagen ZWJ) og U+2028/U+2029 afvises i Go, ingestor-genvalidering og frontend; emoji med ZWJ/VS16 tilladt.
  4. Indbyggede navne: ingestoren publicerer base-lagets navne (builtin-channels.json); server markerer builtIn, UI viser det og advarer; hot_reload-kommentaren er rettet.
  5. Afviste navne spærret indtil retention (30 dage); dokumenteret, test dræber M25.
  6. CSS.escape i channels.js; cache-invalidering kun for nye resultater; revoke-konflikttekst; strict JSON-body; fsync af kø-mappen; statisk test mod skrivende SQL i cmd/server (M18); docs/OpenAPI med revoked, builtIn og 409. M21/M22 dræbes af nye tests.

Mutationer: 18/18 røde. E2E: test-channel-proposals-e2e 21/21; test-e2e-playwright 132/135 (3 skip, 0 fejl) mod master 131/135 (4 skip, 0 fejl).

dborup and others added 2 commits September 26, 2026 13:57
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The preflight XSS gate flagged dlg.innerHTML = confirmDialogHtml(id, name,
builtIn). The name was escaped with esc(), but the gate only accepts an
escape on the sink line itself. Instead of opting out, the confirmation
is now built with createElement, textContent and setAttribute, so the
channel name never reaches an HTML string. Structure, classes, ids, roles,
aria attributes, focus, Escape and the Tab trap are unchanged.

The string-based confirmDialogHtml test is replaced by a DOM test that
opens the dialog with "#\"><img src=x onerror=__x=1>" and checks the name
is text in the title and a plain attribute value on the confirm button,
plus a source check that openConfirm uses no HTML-string sink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator Author

CI gate fix (commit cd518d94, oven på merge af master 8a7a69e i 85b3bae1)

  1. Preflight-XSS-gaten flaggede dlg.innerHTML = confirmDialogHtml(id, name, builtIn) i public/channel-proposals.js. Navnet var escaped, men gaten godkender kun escape på selve sink-linjen. I stedet for et opt-out er bekræftelsesdialogen nu bygget med createElement, textContent og setAttribute; kanalnavnet når aldrig en HTML-streng. Struktur, klasser, id'er, roller, aria-attributter, fokus, Escape og Tab-trap er uændrede.
  2. confirmDialogHtml er fjernet. Strengtesten er erstattet af en DOM-test med #"><img src=x onerror=__x=1> (navnet vises som tekst i titlen og som ren attributværdi, intet element og ingen handler), samt et kildetjek af at openConfirm ikke bruger innerHTML/insertAdjacentHTML/outerHTML. Mutanten "titel sat via innerHTML uden escaping" er rød.
  3. Lokalt som i CI: check-xss-sinks.sh --diff origin/master exit 0, CI's unit-blok 76/76, ESLint 0 fejl, css-var OK, test-channel-proposals-e2e.js 21/21.

@dborup
dborup marked this pull request as ready for review September 26, 2026 13:03
@dborup
dborup merged commit 6977550 into master Sep 26, 2026
6 checks passed
dborup added a commit that referenced this pull request Sep 26, 2026
efiten added a commit to Kpa-clawbot/CoreScope that referenced this pull request Oct 8, 2026
…2139)

Part D of #2128: logged-in users propose a hashtag channel, an admin
approves, rejects or later revokes it, and the ingestor decrypts
approved channels without a restart. It is the first consumer of a
generic proposal table, and answers the request in #2092.

Stacked on #2138 (part C). Review the commits after that branch; GitHub
shows C's commits here until it is merged.

## The situation

- An instance decrypts only the hashtag channels listed in
`hashChannels`. Users who want a city or club channel shown have to ask
the operator, who edits `config.json` and restarts the ingestor.
- #2092 asked for a suggest-and-approve flow; a downstream fork
(dborup#99) runs one with anonymous suggestions and a file
queue.

## What this PR adds

**Name rules (`internal/channel`).** `ValidateHashtagName`, mirrored in
the browser:
- at most 31 UTF-8 bytes including `#` (firmware
`ChannelDetails.name[32]`), case preserved, `Public` refused in any
case;
- control, bidi, separator and format characters refused except ZWJ,
plus invisible fillers (`Other_Default_Ignorable_Code_Point`, U+2800,
non-ASCII spaces).

Go and JS give the same verdict for every code point.

**Store.** Schema v4, one generic `proposals` table (kind, subject,
status pending/approved/rejected/revoked, proposer, reviewer, note). One
row per (kind, subject); limits are checked in the same transaction as
the change.

**Server** (only with `userManagement.channelProposals.enabled`)
- `POST /api/proposals`, `GET /api/account/proposals`, `GET
/api/admin/proposals`, `POST
/api/admin/proposals/{id}/approve|reject|revoke`.
- 400 for an invalid name. 409 for a duplicate, a rejected name, or a
name already in `hashChannels`/`channelKeys` ("this channel is already
decrypted on this instance"). 429 over a limit (`maxPending` 100,
`maxApproved` 128, `perUserPerDay` 5).
- `GET /api/channels` gains `approvedChannels`, so approved channels are
listed before they have traffic. With the feature off the response is
byte-identical.

**Ingestor.** Opens `users.db` with `mode=ro` every 60 seconds,
re-validates the names, and swaps its key map through an
`atomic.Pointer`. Configured channels always win, and a failed read
keeps the last good set. The ingestor never writes `users.db`; AGENTS.md
now says so.

**Frontend.** "Propose for everyone" in the add-channel dialog, an
"approved" marker in the channel list, "My proposals" on the account
page, and an admin "Proposals" tab with a warning on approve. The packet
detail pane now escapes the channel name: until now only the operator
chose those names.

Spec:
[`docs/specs/2026-10-07-channel-proposals-design.md`](https://github.com/efiten/CoreScope/blob/feat/channel-proposals/docs/specs/2026-10-07-channel-proposals-design.md).

## Performance

Every key is tried on each GRP_TXT that no key opens (`decodeGrpTxt`).
Benchmark of that worst case:

| Keys | Time per packet |
|---|---|
| 320 configured | 204 to 216 µs |
| 320 configured + 128 approved | 272 to 320 µs |

That is about +33%, linear in the key count and capped by `maxApproved`.
With the feature off the decoder gets the identical map and no ticker
runs.

## Verification

- `internal/channel`, `internal/users` (with `-race`), `cmd/server` and
`cmd/ingestor` pass locally, 53 new Go tests. The ingestor's
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced` needs symlink rights on
Windows and fails on `master` too.
- `sh test-all.sh` exits 0; the XSS gate in diff mode passes.
- User-management E2E: 21 of 21 steps locally (propose, approve, the
channel appears, revoke removes it).
- On our staging and production instance since 7 October 2026.

## Not in this PR

- Private (PSK) channels: their keys stay in the browser (#725).
- Anonymous proposals.
- Names that need ZWNJ (some Persian and Urdu spellings) and subdivision
flags are refused, because ZWNJ and tag characters are format
characters. Allowing them later only widens the rule, so no approved
name gets stranded.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants