From 218c8fba0213b462088dd6b831937dcf3d552397 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 15:54:06 +0200 Subject: [PATCH] feat(channels): opt-in auto-approval for new shared channels --- cmd/ingestor/channel_proposals.go | 66 ++++++++++------ cmd/ingestor/channel_proposals_test.go | 102 ++++++++++++++++++++++++- config.example.json | 3 +- docs/api-spec.md | 2 +- docs/user-guide/channels.md | 4 +- docs/user-guide/configuration.md | 5 +- internal/channelregistry/config.go | 23 ++++-- test-channel-proposals-e2e.js | 46 +++++++++-- 8 files changed, 210 insertions(+), 41 deletions(-) diff --git a/cmd/ingestor/channel_proposals.go b/cmd/ingestor/channel_proposals.go index 78a11eea9..ef6abfb48 100644 --- a/cmd/ingestor/channel_proposals.go +++ b/cmd/ingestor/channel_proposals.go @@ -68,7 +68,7 @@ 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 @@ -76,8 +76,9 @@ func scanProposalRow(row *sql.Row) (channelregistry.Proposal, bool, error) { // 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 { @@ -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 { @@ -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 @@ -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. @@ -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, } } @@ -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) } @@ -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: diff --git a/cmd/ingestor/channel_proposals_test.go b/cmd/ingestor/channel_proposals_test.go index 429fbbf1a..c58bb8335 100644 --- a/cmd/ingestor/channel_proposals_test.go +++ b/cmd/ingestor/channel_proposals_test.go @@ -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) } @@ -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) { diff --git a/config.example.json b/config.example.json index f7e1cac3d..b339be9fa 100644 --- a/config.example.json +++ b/config.example.json @@ -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, diff --git a/docs/api-spec.md b/docs/api-spec.md index 6ce429d45..e3e9a1030 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -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 diff --git a/docs/user-guide/channels.md b/docs/user-guide/channels.md index 46c895dc1..a8782c0fa 100644 --- a/docs/user-guide/channels.md +++ b/docs/user-guide/channels.md @@ -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. diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index 89b61eaed..06666691e 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -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. | @@ -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 diff --git a/internal/channelregistry/config.go b/internal/channelregistry/config.go index 4c4caa6d4..db2e6778d 100644 --- a/internal/channelregistry/config.go +++ b/internal/channelregistry/config.go @@ -24,12 +24,23 @@ type Config struct { // Enabled opens public submissions. It only takes effect when a strong // admin apiKey is configured too. Approved channels stay active and // visible when this is later turned off. - Enabled *bool `json:"enabled,omitempty"` - MaxPending int `json:"maxPending,omitempty"` - MaxApproved int `json:"maxApproved,omitempty"` - MaxQueuedRequests int `json:"maxQueuedRequests,omitempty"` - RetentionDays int `json:"retentionDays,omitempty"` - SubmissionsPerHour int `json:"submissionsPerHour,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + // AutoApprove approves only brand-new names at submission time. It has no + // effect unless public submissions are enabled; existing pending, rejected + // and revoked rows are never auto-approved. + AutoApprove bool `json:"autoApprove,omitempty"` + MaxPending int `json:"maxPending,omitempty"` + MaxApproved int `json:"maxApproved,omitempty"` + MaxQueuedRequests int `json:"maxQueuedRequests,omitempty"` + RetentionDays int `json:"retentionDays,omitempty"` + SubmissionsPerHour int `json:"submissionsPerHour,omitempty"` +} + +// AutoApprovalRequested reports whether new public suggestions should be +// approved immediately. The server still requires a strong admin API key +// before it accepts any public submission. +func (c *Config) AutoApprovalRequested() bool { + return c.SubmissionsRequested() && c.AutoApprove } // Limits are the resolved limits, with defaults applied. diff --git a/test-channel-proposals-e2e.js b/test-channel-proposals-e2e.js index 92357eac6..7f9bbbde8 100644 --- a/test-channel-proposals-e2e.js +++ b/test-channel-proposals-e2e.js @@ -38,6 +38,7 @@ const FIXTURE_DB = path.resolve(process.env.FIXTURE_DB || path.join(ROOT, 'test- const PUBLIC_DIR = path.resolve(process.env.PUBLIC_DIR || path.join(ROOT, 'public')); const API_KEY = 'e2e-proposals-admin-key-0123456789'; const NAME = '#E2eShared' + Date.now().toString(36).slice(-6); // unique, case preserved, < 31 bytes +const AUTO_NAME = '#E2eAuto' + Date.now().toString(36).slice(-6); const TIMEOUT = 20000; let passed = 0; @@ -199,12 +200,15 @@ async function main() { fs.copyFileSync(FIXTURE_DB, env.db); fs.mkdirSync(env.serverDir); const proposals = { enabled: true }; - fs.writeFileSync(env.ingestorConfig, JSON.stringify({ - dbPath: env.db, - mqttSources: [{ name: 'e2e', broker: 'mqtt://127.0.0.1:' + broker.port, topics: ['meshcore/#'], connectTimeoutSec: 5 }], - channelProposals: proposals, - })); - fs.writeFileSync(path.join(env.serverDir, 'config.json'), JSON.stringify({ apiKey: API_KEY, channelProposals: proposals })); + function writeConfigs() { + fs.writeFileSync(env.ingestorConfig, JSON.stringify({ + dbPath: env.db, + mqttSources: [{ name: 'e2e', broker: 'mqtt://127.0.0.1:' + broker.port, topics: ['meshcore/#'], connectTimeoutSec: 5 }], + channelProposals: proposals, + })); + fs.writeFileSync(path.join(env.serverDir, 'config.json'), JSON.stringify({ apiKey: API_KEY, channelProposals: proposals })); + } + writeConfigs(); let stack; const pageErrors = []; @@ -452,6 +456,36 @@ async function main() { assert.ok(!approvedNames.includes(NAME), 'resuggestion must never be auto-approved'); }); + await step('autoApprove config takes effect after restart without approving old pending suggestions', async () => { + await stopProcess(stack.server); + await stopProcess(stack.ingestor); + proposals.autoApprove = true; + writeConfigs(); + stack = await startStack(env); + const pendingRes = await fetch(env.base + '/api/admin/channel-proposals?status=pending', { headers: { 'X-API-Key': API_KEY } }); + const pendingNames = ((await pendingRes.json()).proposals || []).map((p) => p.name); + assert.ok(pendingNames.includes(NAME), 'old pending suggestion was auto-approved on restart'); + }); + + await step('autoApprove shares a new browser suggestion without admin action', async () => { + await pageA.goto(env.base + '/#/channels'); + if (!(await pageA.isVisible('#chAddChannelModal'))) await pageA.click('#chAddChannelBtn'); + await pageA.fill('#chSuggestName', AUTO_NAME.slice(1)); + await pageA.click('#chSuggestBtn'); + await pageA.waitForFunction((n) => { + const s = document.getElementById('chSuggestStatus'); + return s && s.textContent.includes(n) && /already shared with everyone/.test(s.textContent); + }, AUTO_NAME); + await waitFor('auto-approved key activation', () => logHas(stack.ingestor, new RegExp('approved "' + AUTO_NAME + '" — added to channel keys'))); + await pageB.goto(env.base + '/#/channels'); + await pageB.reload(); + await pageB.waitForSelector(`#chList .ch-item[data-hash="${AUTO_NAME}"][data-shared="true"]`); + const pendingRes = await fetch(env.base + '/api/admin/channel-proposals?status=pending', { headers: { 'X-API-Key': API_KEY } }); + const pendingNames = ((await pendingRes.json()).proposals || []).map((p) => p.name); + assert.ok(pendingNames.includes(NAME), 'old pending suggestion changed when a new name was auto-approved'); + assert.ok(!pendingNames.includes(AUTO_NAME), 'new auto-approved suggestion remains pending'); + }); + await step('no uncaught page errors', async () => { assert.deepStrictEqual(pageErrors, []); });