fix(buzz): treat a scalar allowed_users entry as one member pubkey (#6811)

* fix(buzz): treat a scalar allowed_users entry as one member pubkey

BuzzChannel built its allowlist by iterating config.get("allowed_users") directly. A scalar string -- what an allowed_users: $BUZZ_OWNER_PUBKEY reference resolves to -- was iterated character by character, feeding each character to parse_pubkey, whose first non-hex character raised and aborted channel construction; a numeric scalar raised TypeError the same way. The sibling QQ (#6730) and Discord (#6776) connectors already treat a scalar as a single entry.

Parse the value with the shared parse_allowed_users helper via a parse_pubkey coercer: a scalar is one entry, list entries keep their per-entry validation, unusable entries (bad hex, numeric refs, mappings) are dropped with the shared warning instead of crashing, and the deny-by-default contract is unchanged -- an empty or blank value still yields an empty allowlist with start()'s existing deny-all warning.

config.example.yaml states the accepted shape next to the key, matching the mention_free_channels note added by #6776.

* docs(config): split buzz allowed_users note into aligned comment lines

The two-line note on the buzz example entry was emitted with a literal
`/n` where a newline was intended, so the second note never became its
own comment line and the `#`-column alignment shared with the
DENY-BY-DEFAULT lines below was broken. Split it to match the
mention_free_channels sibling shape (review comment on #6811).
This commit is contained in:
jay 2026-10-14 07:55:06 +08:00 committed by GitHub
parent bf40985fa8
commit eecfebd419
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 65 additions and 3 deletions

View File

@ -49,7 +49,7 @@ from typing import Any
from urllib.parse import urlparse
from app.channels import buzz_nostr
from app.channels.allowed_users import parse_channel_ids
from app.channels.allowed_users import parse_allowed_users, parse_channel_ids
from app.channels.base import Channel
from app.channels.buzz_seen_events import BuzzSeenEventStore
from app.channels.commands import is_known_channel_command
@ -250,6 +250,19 @@ def _is_transient_close(reason: str) -> bool:
return not any(marker in text for marker in _PERMANENT_CLOSE_MARKERS)
def _parse_member_pubkey(value: Any) -> str | None:
"""Return the member's hex pubkey, or None when the entry is not one.
Shared parse_allowed_users coerces config entries through this, so a
scalar pubkey is one member and an unusable entry is dropped with a
warning instead of aborting channel construction.
"""
try:
return buzz_nostr.parse_pubkey(str(value))
except ValueError:
return None
def _parse_mention_free_channels(mention_free_channels: Any) -> frozenset[str]:
"""Parse Buzz mention exemptions without splitting scalar channel IDs."""
return parse_channel_ids(
@ -277,7 +290,18 @@ class BuzzChannel(Channel):
self._workspace_id = urlparse(self._relay_url).netloc
self._private_key_raw = str(config.get("private_key", ""))
self._keys: buzz_nostr.NostrKeys | None = None # parsed in start() so coincurve stays lazy
self._allowed_users = {buzz_nostr.parse_pubkey(v) for v in config.get("allowed_users", []) or []}
self._allowed_users = (
parse_allowed_users(
config.get("allowed_users"),
parse_user_id=_parse_member_pubkey,
logger=logger,
channel_name="buzz",
expected_id="a 64-hex or npub1... member pubkey",
valid_id_name="member pubkey",
warn_on_scalar_separators=True,
)
or frozenset()
)
self._require_mention = bool(config.get("require_mention", True))
self._mention_free: frozenset[str] = _parse_mention_free_channels(config.get("mention_free_channels"))
self._channel_meta: dict[str, dict[str, Any]] = {}

View File

@ -98,6 +98,43 @@ def test_config_mention_free_channel_scalar_with_separators_warns(caplog):
assert "treating it as one literal channel id" in caplog.text
def test_config_allowed_users_accepts_a_single_scalar_pubkey():
"""One pubkey scalar is one member entry -- not the characters of it.
An ``allowed_users: $BUZZ_OWNER_PUBKEY`` style reference resolves to a
scalar string. Iterating it character by character fed each character to
``parse_pubkey``, whose first non-hex character raised and aborted channel
construction. Sibling channels (QQ #6730, Discord) already treat a scalar
as a single entry.
"""
assert _channel(allowed_users=OWNER)._allowed_users == frozenset({OWNER})
assert _channel(allowed_users=f" {OWNER} ")._allowed_users == frozenset({OWNER})
def test_config_allowed_users_accepts_a_non_string_scalar_without_crashing():
"""An unquoted scalar YAML typed as a number must not abort channel setup.
An int is not iterable, so constructing the channel raised
``TypeError: 'int' object is not iterable``; the value is not a usable
pubkey, so the deny-by-default allowlist stays empty.
"""
assert _channel(allowed_users=123)._allowed_users == frozenset()
def test_config_allowed_users_drops_invalid_entries_with_a_warning(caplog):
"""A mix of valid and unusable entries keeps the valid ones, fail-closed."""
with caplog.at_level(logging.WARNING, logger="app.channels.buzz"):
ch = _channel(allowed_users=[OWNER, "not-a-pubkey", 42])
assert ch._allowed_users == frozenset({OWNER})
assert "Ignoring allowed_users entry" in caplog.text
def test_config_allowed_users_empty_shapes_still_deny_by_default():
"""Unset, blank and empty shapes keep the empty allowlist (deny all)."""
for value in (None, "", " ", [], frozenset()):
assert _channel(allowed_users=value)._allowed_users == frozenset()
def test_config_rejects_non_websocket_relay_url():
with pytest.raises(ValueError):
_channel(relay_url="https://buzz.example.com")

View File

@ -3207,7 +3207,8 @@ run_ownership:
# enabled: false
# relay_url: wss://buzz.example.com
# private_key: $BUZZ_PRIVATE_KEY # hex or nsec1…
# allowed_users: [] # pubkeys (hex or npub) allowed to trigger runs.
# allowed_users: [] # pubkeys (hex or npub) allowed to trigger runs:
# # one pubkey (hex or npub) or a YAML list.
# # DENY-BY-DEFAULT (unlike other channels): empty
# # means nobody, and DeerFlow logs a startup warning.
# require_mention: true # @mention needed in channels