Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 42 additions & 24 deletions cmd/ingestor/channel_proposals.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,17 @@ func scanProposalRow(row *sql.Row) (channelregistry.Proposal, bool, error) {
return p, true, nil
}

// submitChannelProposal stores a new pending proposal, or reports the existing
// submitChannelProposal stores a new proposal, or reports the existing
// one for a replayed request or an already-proposed name. A rejected name
// therefore stays blocked — re-suggesting it reports the rejection — until
// retention (RetentionDays after the review) deletes the row; that keeps a
// rejected name from being pushed back into the review queue over and over.
// A name that was previously approved and then revoked is free to re-propose:
// the existing row (its id, and so its identity, is kept — UNIQUE(name) means
// it cannot become a second row) is resurrected to pending rather than left
// revoked forever or auto-approved.
func (s *Store) submitChannelProposal(ctx context.Context, cmd channelregistry.Command, maxPending int, nowMs int64) (channelregistry.Proposal, error) {
// revoked forever or auto-approved. Auto-approval applies only to a new name,
// atomically with its insertion, so a replay cannot change its status.
func (s *Store) submitChannelProposal(ctx context.Context, cmd channelregistry.Command, maxPending, maxApproved int, autoApprove bool, nowMs int64) (channelregistry.Proposal, error) {
// Never trust the queue file: validate again with the shared rules.
name, err := channelregistry.NormalizeName(cmd.Name)
if err != nil {
Expand All @@ -99,12 +100,14 @@ func (s *Store) submitChannelProposal(ctx context.Context, cmd channelregistry.C
if found && existing.Status != channelregistry.StatusRevoked {
return existing, nil
}
var pending int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM channel_proposals WHERE status = 'pending'`).Scan(&pending); err != nil {
return channelregistry.Proposal{}, err
}
if pending >= maxPending {
return channelregistry.Proposal{}, errTooManyPending
if !autoApprove || found {
var pending int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM channel_proposals WHERE status = 'pending'`).Scan(&pending); err != nil {
return channelregistry.Proposal{}, err
}
if pending >= maxPending {
return channelregistry.Proposal{}, errTooManyPending
}
}
created := cmd.CreatedAt
if created <= 0 {
Expand Down Expand Up @@ -138,15 +141,28 @@ func (s *Store) submitChannelProposal(ctx context.Context, cmd channelregistry.C
}
return channelregistry.Proposal{ID: existing.ID, Name: name, Status: channelregistry.StatusPending, CreatedAt: created}, nil
}
status := channelregistry.StatusPending
var reviewedAt *int64
if autoApprove {
var approved int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM channel_proposals WHERE status = 'approved'`).Scan(&approved); err != nil {
return channelregistry.Proposal{}, err
}
if approved >= maxApproved {
return channelregistry.Proposal{}, errTooManyApproved
}
status = channelregistry.StatusApproved
reviewedAt = &nowMs
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO channel_proposals (id, name, status, created_at) VALUES (?, ?, 'pending', ?)`,
cmd.RequestID, name, created); err != nil {
`INSERT INTO channel_proposals (id, name, status, created_at, reviewed_at) VALUES (?, ?, ?, ?, ?)`,
cmd.RequestID, name, status, created, reviewedAt); err != nil {
return channelregistry.Proposal{}, err
}
if err := tx.Commit(); err != nil {
return channelregistry.Proposal{}, err
}
return channelregistry.Proposal{ID: cmd.RequestID, Name: name, Status: channelregistry.StatusPending, CreatedAt: created}, nil
return channelregistry.Proposal{ID: cmd.RequestID, Name: name, Status: status, CreatedAt: created, ReviewedAt: reviewedAt}, nil
}

// reviewChannelProposal moves a proposal currently in status source to
Expand Down Expand Up @@ -218,11 +234,12 @@ func (s *Store) PruneChannelProposals(ctx context.Context, cutoffMs int64) (int6

// channelProposalRunner drains the queue and keeps the live keys in step.
type channelProposalRunner struct {
store *Store
queue *channelregistry.Queue
keys *hotKeys
limits channelregistry.Limits
now func() time.Time
store *Store
queue *channelregistry.Queue
keys *hotKeys
limits channelregistry.Limits
autoApprove bool
now func() time.Time

// publishedBaseGen is the hotKeys base generation last written to the
// builtin names file; 0 means never written.
Expand All @@ -231,11 +248,12 @@ type channelProposalRunner struct {

func newChannelProposalRunner(store *Store, keys *hotKeys, cfg *channelregistry.Config) *channelProposalRunner {
return &channelProposalRunner{
store: store,
queue: channelregistry.NewQueue(channelregistry.QueueDir(store.path)),
keys: keys,
limits: cfg.Limits(),
now: time.Now,
store: store,
queue: channelregistry.NewQueue(channelregistry.QueueDir(store.path)),
keys: keys,
limits: cfg.Limits(),
autoApprove: cfg.AutoApprovalRequested(),
now: time.Now,
}
}

Expand Down Expand Up @@ -269,7 +287,7 @@ func (r *channelProposalRunner) RunOnce(ctx context.Context) {
res := r.apply(ctx, qc.Command)
// Keys first: the row is committed, so the channel is approved even
// if writing the result fails below (it is retried next tick).
if qc.Command.Op == channelregistry.OpApprove && res.Status == channelregistry.RequestApproved && res.Proposal != nil {
if res.Status == channelregistry.RequestApproved && res.Proposal != nil {
if r.keys.AddApproved(res.Proposal.Name) > 0 {
log.Printf("[channel-proposals] approved %q — added to channel keys", res.Proposal.Name)
}
Expand Down Expand Up @@ -316,7 +334,7 @@ func (r *channelProposalRunner) apply(ctx context.Context, cmd channelregistry.C
)
switch cmd.Op {
case channelregistry.OpSubmit:
p, err = r.store.submitChannelProposal(ctx, cmd, r.limits.MaxPending, nowMs)
p, err = r.store.submitChannelProposal(ctx, cmd, r.limits.MaxPending, r.limits.MaxApproved, r.autoApprove, nowMs)
case channelregistry.OpApprove:
p, err = r.store.reviewChannelProposal(ctx, cmd.ProposalID, channelregistry.StatusPending, channelregistry.StatusApproved, r.limits.MaxApproved, nowMs)
case channelregistry.OpReject:
Expand Down
102 changes: 101 additions & 1 deletion cmd/ingestor/channel_proposals_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,7 @@ func TestChannelProposalResubmitAfterRevokeLandsPendingWithSameID(t *testing.T)
t.Fatalf("listing before replay: %v, %v", pending, err)
}
replayCmd := channelregistry.Command{RequestID: resubmitReqID, Op: channelregistry.OpSubmit, Name: "#Resuggest", CreatedAt: f.clock.UnixMilli()}
replayed, err := f.store.submitChannelProposal(context.Background(), replayCmd, 100, f.clock.UnixMilli())
replayed, err := f.store.submitChannelProposal(context.Background(), replayCmd, 100, 128, false, f.clock.UnixMilli())
if err != nil {
t.Fatalf("replayed resubmit: %v", err)
}
Expand All @@ -641,6 +641,106 @@ func TestChannelProposalResubmitAfterRevokeLandsPendingWithSameID(t *testing.T)
}
}

func TestChannelProposalAutoApproveNewNamesOnly(t *testing.T) {
enabled := true
f := newProposalFixture(t, &channelregistry.Config{Enabled: &enabled, AutoApprove: true})
ctx := context.Background()

// An existing pending suggestion stays pending when the policy is enabled.
f.runner.autoApprove = false
pending := f.run(f.enqueue(channelregistry.OpSubmit, "#Pending"))
f.runner.autoApprove = true
if dup := f.run(f.enqueue(channelregistry.OpSubmit, "#Pending")); dup.Status != channelregistry.RequestPending || dup.Proposal.ID != pending.Proposal.ID {
t.Fatalf("existing pending suggestion changed: %+v", dup)
}

requestID := f.enqueue(channelregistry.OpSubmit, "#Auto")
cmds, err := f.runner.queue.Pending()
if err != nil {
t.Fatal(err)
}
var cmd channelregistry.Command
for _, queued := range cmds {
if queued.Command.RequestID == requestID {
cmd = queued.Command
break
}
}
if cmd.RequestID == "" {
t.Fatal("auto-approval command not queued")
}
// Simulate a crash after DB commit but before result and key publication.
first := f.runner.apply(ctx, cmd)
if first.Status != channelregistry.RequestApproved || first.Proposal == nil || first.Proposal.ReviewedAt == nil {
t.Fatalf("auto-approval = %+v", first)
}
if _, ok := f.keys.Channels()["#Auto"]; ok {
t.Fatal("apply must not publish the key before RunOnce")
}
replay := f.run(requestID)
if replay.Status != channelregistry.RequestApproved || replay.Proposal.ID != first.Proposal.ID || f.count("name = '#Auto'") != 1 {
t.Fatalf("replayed auto-approval = %+v", replay)
}
if f.keys.Channels()["#Auto"] == "" {
t.Fatal("approved channel key not activated")
}
if dup := f.run(f.enqueue(channelregistry.OpSubmit, "#Auto")); dup.Status != channelregistry.RequestApproved || dup.Proposal.ID != first.Proposal.ID {
t.Fatalf("duplicate approved suggestion = %+v", dup)
}
if st := f.run(f.enqueue(channelregistry.OpRevoke, first.Proposal.ID)); st.Status != channelregistry.RequestRevoked {
t.Fatalf("revoke = %+v", st)
}
if st := f.run(f.enqueue(channelregistry.OpSubmit, "#Auto")); st.Status != channelregistry.RequestPending || st.Proposal.ID != first.Proposal.ID {
t.Fatalf("revoked name was auto-approved: %+v", st)
}
if f.keys.Channels()["#Auto"] != "" {
t.Fatal("revoked name was reactivated")
}
if st := f.run(f.enqueue(channelregistry.OpReject, pending.Proposal.ID)); st.Status != channelregistry.RequestRejected {
t.Fatalf("reject = %+v", st)
}
if st := f.run(f.enqueue(channelregistry.OpSubmit, "#Pending")); st.Status != channelregistry.RequestRejected || st.Proposal.ID != pending.Proposal.ID {
t.Fatalf("rejected name was auto-approved: %+v", st)
}
}

func TestChannelProposalAutoApproveRespectsLimits(t *testing.T) {
enabled := true
f := newProposalFixture(t, &channelregistry.Config{Enabled: &enabled, AutoApprove: true, MaxApproved: 1, MaxPending: 1})
// A full manual review queue must not block an approval that never uses
// a pending slot.
f.runner.autoApprove = false
if st := f.run(f.enqueue(channelregistry.OpSubmit, "#Pending")); st.Status != channelregistry.RequestPending {
t.Fatalf("pending setup = %+v", st)
}
f.runner.autoApprove = true
if st := f.run(f.enqueue(channelregistry.OpSubmit, "#First")); st.Status != channelregistry.RequestApproved {
t.Fatalf("first auto-approval = %+v", st)
}
if st := f.run(f.enqueue(channelregistry.OpSubmit, "#Second")); st.Status != channelregistry.RequestError || st.Error != errTooManyApproved.Error() {
t.Fatalf("approved limit = %+v", st)
}
if f.count("1=1") != 2 || f.keys.Channels()["#Second"] != "" {
t.Fatal("approved limit still inserted or activated second suggestion")
}
if st := f.run(f.enqueue(channelregistry.OpSubmit, "#"+strings.Repeat("x", 40))); st.Status != channelregistry.RequestError || st.Error != channelregistry.ErrNameTooLong.Error() {
t.Fatalf("invalid auto-approval name = %+v", st)
}
}

func TestChannelProposalAutoApproveRequiresEnabled(t *testing.T) {
disabled := false
for _, cfg := range []*channelregistry.Config{{AutoApprove: true}, {Enabled: &disabled, AutoApprove: true}} {
f := newProposalFixture(t, cfg)
if st := f.run(f.enqueue(channelregistry.OpSubmit, "#Manual")); st.Status != channelregistry.RequestPending {
t.Fatalf("autoApprove without enabled = %+v", st)
}
if f.keys.Channels()["#Manual"] != "" {
t.Fatal("disabled submissions activated a key")
}
}
}

// Retention: a revoked row older than the cutoff is pruned exactly like a
// rejected one; a newer one is kept; an approved row is never pruned.
func TestChannelProposalRevokeRetention(t *testing.T) {
Expand Down
3 changes: 2 additions & 1 deletion config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -233,13 +233,14 @@
],
"channelProposals": {
"enabled": false,
"autoApprove": false,
"maxPending": 100,
"maxApproved": 128,
"maxQueuedRequests": 256,
"retentionDays": 30,
"submissionsPerHour": 20
},
"_comment_channelProposals": "Shared hashtag channels: visitors suggest public hashtag channels (names only, never keys) on the Channels page and an administrator approves or rejects them at #/channels?view=proposals with the apiKey. Submissions open only when enabled is true AND apiKey is strong (16+ characters, not a placeholder). Approved channels are decrypted by the ingestor and listed for everyone, even before they carry traffic; they stay active if enabled is later set to false. Limits: maxPending/maxApproved bound the table, maxQueuedRequests bounds the request queue next to the database, submissionsPerHour is a global sliding window, and rejected or unreviewed suggestions are deleted after retentionDays. Read by both server and ingestor.",
"_comment_channelProposals": "Shared hashtag channels: visitors suggest public hashtag channels (names only, never keys) on the Channels page. Submissions open only when enabled is true AND apiKey is strong (16+ characters, not a placeholder). By default an administrator approves or rejects suggestions at #/channels?view=proposals with the apiKey. Opt-in autoApprove immediately approves only brand-new names; existing pending, rejected and revoked names are not auto-approved. Approved channels are decrypted by the ingestor and listed for everyone, even before they carry traffic; they stay active if enabled is later set to false. Limits: maxPending/maxApproved bound the table, maxQueuedRequests bounds the request queue next to the database, submissionsPerHour is a global sliding window, and rejected or unreviewed suggestions are deleted after retentionDays. Read by both server and ingestor; changing this policy requires an ingestor restart.",
"healthThresholds": {
"infraDegradedHours": 24,
"infraSilentHours": 72,
Expand Down
2 changes: 1 addition & 1 deletion docs/api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1371,7 +1371,7 @@ A proposal:

In `GET /api/admin/channel-proposals` each proposal may also carry `"builtIn": true` (see [Built-in names](#built-in-names)).

State machine: `pending` → `approved` or `rejected` (admin decision); `approved` → `revoked` (admin revoke, see below); `revoked` → `pending` by suggesting the same name again (never auto-approved — see POST /api/channel-proposals). `rejected` is terminal: suggesting a rejected name again reports the earlier rejection and does not reopen it, until retention deletes the rejected row `channelProposals.retentionDays` (default 30) days after the review; from then on the name can be suggested afresh. This keeps a rejected name from being pushed back into the review queue over and over.
State machine: a new name becomes `pending` by default, or `approved` immediately if `channelProposals.autoApprove` is enabled. An administrator can move `pending` → `approved` or `rejected`; `approved` → `revoked` (see below); `revoked` → `pending` by suggesting the same name again (never auto-approved). An existing `pending` or `rejected` name is never auto-approved by another suggestion. `rejected` is terminal: suggesting a rejected name again reports the earlier rejection and does not reopen it, until retention deletes the rejected row `channelProposals.retentionDays` (default 30) days after the review; from then on the name can be suggested afresh. This keeps a rejected name from being pushed back into the review queue over and over while its row is retained.

### Built-in names

Expand Down
4 changes: 3 additions & 1 deletion docs/user-guide/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,13 @@ When the administrator enables `channelProposals` (see [Configuration](configura

Names can be at most 31 bytes including the `#`. That is the firmware's limit (the name is stored in a 32-byte field with a terminating NUL). Any language or emoji is fine; control characters, line breaks, text-direction overrides and other invisible formatting characters (such as zero-width spaces or soft hyphens) are refused. The firmware itself only limits the length; the character rule is CoreScope's own, so that two different channels can never look the same. Emoji built with the zero-width joiner or variation selectors are allowed.

An administrator reviews suggestions at `#/channels?view=proposals`:
By default, an administrator reviews suggestions at `#/channels?view=proposals`:

1. Enter the `apiKey`. It is kept only in the tab's memory and is forgotten on reload or with **Lock**.
2. Approve or reject each pending suggestion.

If the operator enables `channelProposals.autoApprove`, a brand-new valid name is approved as soon as the ingestor processes its submission. Suggestions already pending, rejected or revoked are not auto-approved. See [Configuration](configuration.md#shared-channel-suggestions) for the limits and retention caveat.

Approved channels are decrypted by the ingestor from then on and appear for everyone under **Network** with a **Shared** label, even before they carry any messages. Shared channels have no remove button for a regular visitor, because they are not stored in your browser.

A rejected name stays rejected: suggesting it again shows the earlier rejection instead of starting a new review, until the rejection is deleted by retention (`channelProposals.retentionDays`, 30 days by default, counted from the review). After that it can be suggested again.
Expand Down
5 changes: 4 additions & 1 deletion docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,12 @@ See [Channels](channels.md) for details.

### Shared channel suggestions

`channelProposals` lets visitors suggest public hashtag channels that an administrator approves for everyone. The server and the ingestor read the same block.
`channelProposals` lets visitors suggest public hashtag channels for everyone. By default an administrator approves them; operators can opt into automatic approval of new names. The server and the ingestor read the same block.

| Field | Default | Description |
|-------|---------|-------------|
| `enabled` | `false` | Opens public suggestions. Only takes effect with a strong `apiKey` (16+ characters, not a placeholder). |
| `autoApprove` | `false` | When `enabled` is true, the ingestor immediately approves *brand-new* valid names. Existing pending, rejected and revoked names are never auto-approved. Changing this policy requires an ingestor restart. |
| `maxPending` | `100` | Suggestions waiting for review. Further suggestions are refused until some are reviewed. |
| `maxApproved` | `128` | Shared channels that can be approved. |
| `maxQueuedRequests` | `256` | Requests waiting for the ingestor in the queue directory next to the database. |
Expand All @@ -138,6 +139,8 @@ See [Channels](channels.md) for details.

Approved channels stay decrypted and listed when `enabled` is later set to `false`, and survive restarts and `SIGHUP` reloads. A key configured in `channelKeys` for the same name takes priority, and so does the rainbow table (`channel-rainbow.json`): approving or revoking one of those built-in names changes nothing, and the review dialog marks them (see [Channels](channels.md#built-in-names)).

Auto-approval uses the same name validation, global submission rate limit, queue and `maxApproved` cap as manual approval. At capacity, a new suggestion fails rather than becoming an unapproved row. Be careful on a public instance: visitors can fill the approved-channel allowance. Rejected and revoked names remain protected from automatic re-approval while their rows exist. Retention eventually removes these rows, so an old name can be proposed as new again; a permanent blocklist is not provided. Turning off `autoApprove` affects new submissions only and does not revoke already approved channels.

An administrator can also revoke a previously approved channel (see [Channels](channels.md#revoking-an-approved-channel)) — this undoes the decryption going forward but never deletes or hides messages already decoded while it was approved. A revoked row is retained and pruned by the same `retentionDays` rule as a rejected one (counted from when it was revoked, not when it was first submitted); an approved row is still never pruned. Revoking introduces no new configuration of its own — it reuses the `maxPending`/`maxApproved`/`retentionDays`/`submissionsPerHour` limits above.

## Map defaults
Expand Down
Loading
Loading