Skip to content

feat(cli): add buzz invites mint and buzz invites claim - #4479

Open
ashbrener wants to merge 5 commits into
block:mainfrom
ashbrener:feat/cli-invites-mint-claim
Open

feat(cli): add buzz invites mint and buzz invites claim#4479
ashbrener wants to merge 5 commits into
block:mainfrom
ashbrener:feat/cli-invites-mint-claim

Conversation

@ashbrener

@ashbrener ashbrener commented Aug 3, 2026

Copy link
Copy Markdown

Closes #3014.

The relay has had a complete invite API since the desktop client shipped invite links, but buzz-cli exposed none of it. That left a fresh identity with no way onto a closed relay: buzz channels join publishes a kind:9021 join request, which a membership-gated relay rejects with relay_membership_required, and the only alternative was an operator running buzz-admin add-member out of band with the pubkey exchanged by hand. POST /api/invites/claim is deliberately exempt from that gate — it is the intended self-service path — so the CLI now speaks it, along with the join-policy flow a claim may require.

Surface

buzz invites mint  [--ttl-secs N] [--max-uses N]
  -> {"code","expires_at","max_uses","uses_remaining","url"}
buzz invites policy
  -> {"configured","version","age_attestation_required","terms_markdown","privacy_markdown"}
     or {"configured": false} (exit 0) when the operator configured no policy
buzz invites accept-policy --code <token|-> --policy-version <v> [--age-confirmed]
  -> {"receipt"}
buzz invites claim --code <token|-> [--policy-receipt <receipt|->]
  -> {"status","community_id","host","role"}      status = joined | already_member

Bounds are checked locally against the shared buzz_core::invite constants, so a bad flag is an exit-1 usage error rather than a round-trip 400.

--max-uses goes beyond the issue text, which predates the relay's move to v2 database-backed codes. Uses are now a property of the code, and a mint command that cannot produce a single-use invite cannot express the most security-conscious case.

Acceptance is never automatic

accept-policy requires the caller to name the exact --policy-version they read, and where the operator demands an age attestation, to pass --age-confirmed. Neither is inferred from the policy document: both are assertions about a human, and the CLI has no standing to make them. A version mismatch names both versions and exits 1; a missing attestation names the flag. A policy response missing age_attestation_required fails closed, so an unreadable field can never silently downgrade an age gate.

policy prints the full terms and privacy Markdown so that human has something to read — buzz invites policy | jq -r .terms_markdown. The relay also serves the same documents as browser pages at /api/join-policy/terms and /api/join-policy/privacy.

claim rewrites 403 join_policy_required into a message naming both commands and distinguishing "no receipt sent" from "receipt rejected", so a scripted caller gets a path rather than a dead end. The 403 is preserved, so the exit code stays 3.

Credentials off the command line

Invite codes and receipts are bearer credentials — holding one is the whole authorization — and as argv they are written to shell history and readable from ps by any process on the host. Every credential argument accepts the CLI's standard - stdin sentinel, matching messages send --content -, and that is the documented preferred form. Input is bounded to 8 KiB, reading one byte past the limit so oversize is rejected rather than truncated, and strips one trailing newline and nothing else. stdin is one stream, so --code - --policy-receipt - is an exit-1 usage error rather than a hang.

Retry posture

Two authenticated POST helpers rather than one, because the calls differ in a way that matters. Claiming is effectively idempotent (a second claim answers already_member), so post_authed keeps the standard retry policy. Minting is not: a blind retry can leave an unexpired invite code in circulation that the caller never saw. post_authed_once therefore sends exactly once and, following submit_moderation_event, reports ambiguous outcomes as DeliveryUnknown — including a 429 whose body does not begin with rate-limited:, since only the relay's own pre-ingest limiter proves non-execution.

accept-policy uses a third helper, post_public: the endpoint takes no NIP-98 auth, because the receipt is a MAC over (code, policy_version) bound to the invite rather than to a pubkey, so signing it would prove nothing the subsequent claim does not already prove.

Not included

invites add-member from the issue. buzz-admin add-member writes the membership row directly and needs DATABASE_URL plus BUZZ_RELAY_PRIVATE_KEY; there is no REST surface for buzz-cli to call, and adding one would mean the relay changes the issue rules out.

For the reviewer

claim returns exit 3 for 403 join_policy_required because that is the established relay-status mapping. It is arguably a user error — a missing flag — and exit 3 reads as "your key is wrong" to a scripting agent. Remapping it is a one-line change if you would prefer the clearer signal.

Separately, and outside this PR: POST /api/invites/accept-policy has neither auth nor a rate limiter, while claim has both. A receipt is worthless without a valid code, so this is likely fine, but the CLI now makes it easy to exercise at volume and it may be worth a relay-side limiter.

Testing

cargo test -p buzz-cli — 355 passed, 0 failed. Clippy clean at -D warnings, cargo fmt --check clean.

Exercised against a local relay with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true and BUZZ_REQUIRE_AUTH_TOKEN=true:

case result
mint as owner code minted, uses_remaining honoured
non-member, before claim 403 relay_membership_required
claim as that identity {"status":"joined","role":"member"}
same identity after claim reads succeed, no 403
plain member mints 403 only relay owners and admins can create invites
re-claim same code {"status":"already_member"}
bogus code 403 invite_invalid
--ttl-secs 5 rejected locally, exit 1

Plus a stub relay serving the real response shapes for every policy refusal path, the no-auth assertion on accept-policy, and the accept-policy | jq -r .receipt | claim --policy-receipt - pipeline.

Exit codes follow the CLI contract: 3 auth, 1 usage, 0 success.

Ash Brener and others added 3 commits August 2, 2026 23:28
The relay has had a complete invite API since the desktop client shipped
invite links, but `buzz-cli` exposed neither half of it. That left a fresh
identity with no way onto a closed relay: `buzz channels join` publishes a
kind:9021 join *request*, which a membership-gated relay rejects with
`relay_membership_required`, and the only alternative was an operator
running `buzz-admin add-member` out of band with the pubkey exchanged by
hand. `POST /api/invites/claim` is deliberately exempt from that gate — it
is the intended self-service path — so the CLI now speaks it.

- `buzz invites mint [--ttl-secs N] [--max-uses N]` — owner/admin only,
  defaults to the relay's 72 h TTL and unlimited uses. Bounds are checked
  locally against the shared `buzz_core::invite` constants so a bad flag
  is an exit-1 usage error rather than a round-trip 400.
- `buzz invites claim --code <token> [--policy-receipt <receipt>]` —
  signed by the joining pubkey; works before membership exists.

Both go through two new `client.rs` helpers built on the existing
`sign_nip98` + `with_auth_tag` pattern. They differ in retry posture, and
that difference is the reason there are two: claiming is idempotent (a
second claim answers `already_member`), so `post_authed` keeps the
standard retry policy; minting is not, and a blind retry can leave an
unexpired invite code in circulation that the caller never saw. So
`post_authed_once` sends exactly once and, mirroring the moderation
command policy, reports ambiguous outcomes as `DeliveryUnknown` rather
than as something a caller might retry.

No relay changes and no new event kinds. `invites add-member` from the
issue is deliberately left out: `buzz-admin add-member` writes the
membership row directly and needs `DATABASE_URL` plus
`BUZZ_RELAY_PRIVATE_KEY`, so there is no REST surface for buzz-cli to
call and it is an operator action rather than an agent one.

Signed-off-by: Ash Brener <ashley@midletearth.com>
post_authed_once intercepted 502-504 but let 429 fall through to
handle_response, which yields Relay { status: 429 } — and error::is_retryable
marks that retryable. A caller obeying the CLI's own retryable flag would
re-mint, leaving a second live invite credential in circulation that nobody
ever saw. That is precisely the outcome the helper exists to prevent.

Adopt submit_moderation_event's split in full: only a body starting with
`rate-limited:` proves the relay's pre-ingest limiter rejected the request
before execution, so that stays Relay { 429 } (retryable, same command is safe
to re-send). Any other 429 — proxy-level, or a body we do not recognise —
becomes DeliveryUnknown.

Also soften the claim-side idempotency note: the relay checks invite expiry
before existing membership, so a retry landing after expiry reports
invite_expired even though the earlier attempt joined.

Signed-off-by: Ash Brener <ashley@starlogik.com>
…-claim

Signed-off-by: Ash Brener <ashley@starlogik.com>
Ash Brener added 2 commits August 3, 2026 08:04
`buzz invites claim --policy-receipt <r>` accepted a value the CLI had no
way to produce. On a relay with a join policy configured, the claim path
therefore dead-ended: the relay answers `403 join_policy_required`, and the
only way forward was to hand-roll `POST /api/invites/accept-policy` with
curl. The relay exposes the whole flow, so the CLI now speaks all of it.

- `buzz invites policy` — `GET /api/join-policy`, public and
  unauthenticated (a caller who is not a member yet still has to be able to
  read the terms). Prints
  `{configured, version, age_attestation_required, terms_markdown,
  privacy_markdown}` — the Markdown verbatim, so a human can actually read
  what they would be accepting via `jq -r .terms_markdown`. A relay with no
  policy answers `{}` rather than 404; that is a normal outcome, not an
  error, and reports `{"configured": false}` with exit 0.
- `buzz invites accept-policy --code <token> --policy-version <v>
  [--age-confirmed]` — `POST /api/invites/accept-policy`, also
  unauthenticated: the receipt is a MAC over `(code, policy_version)`,
  bound to the invite rather than to a pubkey, so signing it would prove
  nothing the subsequent claim does not already prove. Idempotent (the same
  inputs return the byte-identical receipt), so the standard retry policy
  applies via a new `client.rs` helper, `post_public`.

Acceptance is an explicit, deliberate act by construction, never inferred:

- `--policy-version` is required and is checked against the version the
  relay currently serves. A mismatch is an exit-1 usage error naming both
  versions — the terms changed since they were read, and accepting terms
  nobody saw is exactly the failure mode worth preventing.
- `--age-confirmed` is never implied. Where the operator sets
  `age_attestation_required`, omitting the flag is an exit-1 usage error
  that says so, because the attestation is a claim about a person and the
  CLI has no standing to make it. For the same reason a policy response
  missing `age_attestation_required` fails *closed*: an unreadable field
  must not silently downgrade an age gate.

`claim` now rewrites the relay's opaque `403 join_policy_required` into a
path forward, pointing at `invites policy` / `invites accept-policy` — and
distinguishing "no receipt was sent" from "the receipt did not verify",
which the relay reports with the same one string. Only the message changes;
the 403 is preserved so the exit code stays 3.

17 new unit tests cover policy parsing (including the unconfigured relay,
absent documents, a missing version, and the fail-closed age field), the
output contract, every accept-policy refusal, and the claim-error rewrite.

Signed-off-by: Ash Brener <ashley@midletearth.com>
Invite codes and policy receipts are bearer credentials — holding one is
the whole authorization. Passed as argv they are written to shell history
and are readable from `ps` by any process on the host, for the entire
lifetime of the invite. The module doc already alluded to "`--code -`-style
shell plumbing" as a reason to trim the value, but stdin was never actually
implemented, so there was no way to avoid argv.

`--code` and `--policy-receipt` on `invites claim`, and `--code` on
`invites accept-policy`, now accept `-`. That is the same sentinel
`messages send --content -` uses, resolved through a new
`validate::read_secret_or_stdin` that differs from the existing
`read_or_stdin` in exactly two ways, both because the value is a credential
rather than message content:

- the read is bounded to 8 KiB, so a misdirected pipe
  (`buzz invites claim --code - < some.tar`) is an exit-1 usage error
  instead of an unbounded buffer shipped to the relay. One byte past the
  limit is read deliberately, so an over-long input is detected rather than
  silently truncated into a credential that looks well-formed; and
- one trailing newline (`\n` or `\r\n`) is stripped, so `echo "$CODE" |`
  and `printf %s "$CODE" |` behave the same.

Nothing else is stripped. Interior whitespace survives the read and is
rejected by the same validator that handles an argv value, so a pasted
invite URL is still refused rather than silently repaired.

stdin is a single stream, so `--code - --policy-receipt -` would either
block or read the tail of the first token as the second. That combination
is now an exit-1 usage error naming both flags instead of a hang.

stdin is documented as the preferred form in the module doc, in `--help`,
and in the README; the literal-value form stays supported unchanged.

8 new unit tests cover the sentinel, the one-newline rule, preserved
interior whitespace, the exact size boundary and one byte past it, a
non-UTF-8 pipe, and the single-stdin guard.

Signed-off-by: Ash Brener <ashley@midletearth.com>
ashbrener added a commit to ashbrener/buzz that referenced this pull request Aug 3, 2026
Invoking the skill was a reading exercise: mint an identity, source an env
file, find a channel UUID, paste it into a Monitor command, and do it again in
every terminal. Each of those steps is derivable, and every one of them was a
chance to get it wrong quietly.

buzz-connect.sh is now the only thing anyone runs. It resolves the session
name, mints or adopts the identity, loads it, enrols from an invite code if
one is configured, publishes the display name, finds or creates the channel,
announces HELLO, and prints the exact Monitor call to arm. It is idempotent,
so re-running it is the way to check state rather than a risk. buzz-msg.sh
send/read and buzz-watch.sh load the identity themselves — nobody is told to
source anything by hand, and "buzz-watch.sh - <channel>" resolves the session
so the Monitor command stays correct across a /rename.

Profile publication is part of connecting and refreshes on rename: the
published name is recorded in the identity's .meta sidecar and compared each
run, so a session renamed mid-flight republishes and keeps its keypair. Live
against a relay with membership enforcement, a session renamed from
"Auth Refactor A" to "Auth Refactor A v2" republished and kept pubkey
492d3f89.

Authorising a new pubkey on a closed relay is the one step that cannot be
derived, so it is handled rather than papered over. An invite code in
~/.buzz/config — read by every session on the machine, parsed rather than
sourced, warned about if it is group-readable — lets each session self-enrol
via block#4479's `buzz invites claim`. Only with no code does the skill surface the
pubkey and make a single, exact ask.

The channel UUID is written back to ~/.buzz/config on creation. Without that,
the second session cannot see the first one's private channel — a non-member
gets no rows from `channels list` — and silently creates a second
"agent-coordination" that nobody shares. That failure appeared in end-to-end
testing and is precisely the kind of quiet divergence the skill exists to
prevent.

The three failures that cost real time now name themselves: not a relay
member, a relay member but not a channel member, and a watcher that was never
armed. Each prints the command that fixes it. `--status` exits non-zero when
the watcher is down, so "connected but deaf" is checkable. The watcher writes
a liveness marker keyed on the session id, because otherwise "not armed" and
"channel is quiet" are indistinguishable.

Two smaller fixes found by running it: `git rev-parse --abbrev-ref HEAD`
prints "HEAD" *and* fails on an unborn branch, which put "branch=HEAD no-git"
into HELLO; and the relay's "no community is configured for this host" 404 was
being reported as "cannot reach the relay", so the relay's own words are now
always included.

Signed-off-by: Ash Brener <ashley@starlogik.com>
@ashbrener

Copy link
Copy Markdown
Author

just ci status, reported rather than left to be discovered.

It fails on this machine, on one test, and the failure is not from this branch:

relay_admission::tests::concurrent_429_extends_the_window_for_parked_waiters
  left: 300.001s   right: 5s
desktop-tauri-test  exit 101

Evidence it is pre-existing and load-dependent:

  • passes in isolation on unmodified main
  • passes in isolation on this branch
  • fails only inside the full parallel just ci run

The cause looks structural rather than environmental: the test is
#[tokio::test(start_paused = true)] (desktop/src-tauri/src/relay_admission.rs:152)
and asserts an exact Instant::now() - start == 5s. With a paused clock, tokio
auto-advances to the next pending timer, so under a parallel run it can adopt a
300s timer from elsewhere instead of its own — which is exactly the 300.001s
reading. Happy to file that separately if it is not already known.

Everything this branch touches is green: cargo test -p buzz-cli 355 passed,
cargo clippy -p buzz-cli --all-targets -- -D warnings clean, cargo fmt --check
clean, and the full pre-push gate (rust-tests, desktop-check, desktop-test,
mobile-test, desktop-tauri-checks) passed on the pushes that landed here.

ashbrener added a commit to ashbrener/buzz that referenced this pull request Aug 4, 2026
This PR is a skill; it should not also be shipping a new buzz-cli verb.
The subscribe work now lives on feat/cli-messages-subscribe, where it can
be reviewed as the CLI change it is, and where it stops colliding with the
invites work in block#4479 — both were adding methods to the same regions of
client.rs and lib.rs, so whichever merged first would have broken the other.

Nothing here regresses. buzz-stream.sh already probes for the verb
(`messages subscribe --help`) and falls back to the HTTP sweep when it is
absent, which is the polling loop this skill has always used. With the CLI
change merged the same skill gets push delivery for free; without it, it
behaves exactly as it did before.

Signed-off-by: Ash Brener <ashley@starlogik.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.

feat: Add buzz invites mint and buzz invites claim commands

1 participant