feat(cli): add buzz invites mint and buzz invites claim - #4479
Conversation
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>
`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>
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>
|
It fails on this machine, on one test, and the failure is not from this branch: Evidence it is pre-existing and load-dependent:
The cause looks structural rather than environmental: the test is Everything this branch touches is green: |
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>
Closes #3014.
The relay has had a complete invite API since the desktop client shipped invite links, but
buzz-cliexposed none of it. That left a fresh identity with no way onto a closed relay:buzz channels joinpublishes a kind:9021 join request, which a membership-gated relay rejects withrelay_membership_required, and the only alternative was an operator runningbuzz-admin add-memberout of band with the pubkey exchanged by hand.POST /api/invites/claimis 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
Bounds are checked locally against the shared
buzz_core::inviteconstants, so a bad flag is an exit-1 usage error rather than a round-trip 400.--max-usesgoes 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-policyrequires the caller to name the exact--policy-versionthey 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 missingage_attestation_requiredfails closed, so an unreadable field can never silently downgrade an age gate.policyprints 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/termsand/api/join-policy/privacy.claimrewrites403 join_policy_requiredinto 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
psby any process on the host. Every credential argument accepts the CLI's standard-stdin sentinel, matchingmessages 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), sopost_authedkeeps 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_oncetherefore sends exactly once and, followingsubmit_moderation_event, reports ambiguous outcomes asDeliveryUnknown— including a 429 whose body does not begin withrate-limited:, since only the relay's own pre-ingest limiter proves non-execution.accept-policyuses 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-memberfrom the issue.buzz-admin add-memberwrites the membership row directly and needsDATABASE_URLplusBUZZ_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
claimreturns exit 3 for403 join_policy_requiredbecause 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-policyhas neither auth nor a rate limiter, whileclaimhas 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 --checkclean.Exercised against a local relay with
BUZZ_REQUIRE_RELAY_MEMBERSHIP=trueandBUZZ_REQUIRE_AUTH_TOKEN=true:uses_remaininghonoured403 relay_membership_required{"status":"joined","role":"member"}403 only relay owners and admins can create invites{"status":"already_member"}403 invite_invalid--ttl-secs 5Plus a stub relay serving the real response shapes for every policy refusal path, the no-auth assertion on
accept-policy, and theaccept-policy | jq -r .receipt | claim --policy-receipt -pipeline.Exit codes follow the CLI contract: 3 auth, 1 usage, 0 success.