Repository navigation
feat(relay): let an operator mint invites for a community owner or admin - #9265
Conversation
Add POST /operator/communities/invites. A configured operator vouches for which identity its caller is bound to; the relay checks that identity's role in its own roster at mint time and mints through the same database path, limits, defaults and response as POST /api/invites, so the link is an ordinary relay invite. This lets a deployment's account service offer invite links on its website, where the owner's own key is not available. Co-authored-by: drone03 <3efda7d0089450ac8094dd25cfa734546dc0d6bad0ab87b64dacfff867bdc4d3@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen <baxen@squareup.com>
Each error from POST /operator/communities/invites keeps the /api/invites text and adds a code (not_operator, invalid_request, community_not_found, not_owner, restricted, unavailable), so an operator can tell its own misconfiguration from the actor's refusal. Co-authored-by: drone03 <3efda7d0089450ac8094dd25cfa734546dc0d6bad0ab87b64dacfff867bdc4d3@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen <baxen@squareup.com>
…res test Co-authored-by: drone03 <3efda7d0089450ac8094dd25cfa734546dc0d6bad0ab87b64dacfff867bdc4d3@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen <baxen@squareup.com>
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
🔐 Codex Security Review
|
## Summary
The community admin dialog ("Add or invite people") can now show its
minted invite link as a QR code, so someone can scan it with their phone
instead of being sent the link some other way.
- **Shared QR piece (commit 1):** the pairing QR renderer moves from
`src-tauri/src/pairing/qr.rs` to `src-tauri/src/qr.rs`. A new `qr_code`
command renders any shareable, non-secret value up to 4096 bytes, the
same bound the invite link parser uses. The QR image and its
reduced-motion handling move out of Pair mobile into
`src/shared/QrCode.tsx`, which pairing and the invite dialog both use
now. Pairing keeps its own error text.
- **Invite dialog (commit 2):** a **Show QR code** / **Hide QR code**
disclosure (`variant="plain"`, a chevron,
`aria-expanded`/`aria-controls`) appears under the link once one exists.
The code is tied to the URL, so changing the expiry or use limit hides
the code for the old link. A late render can't paint over a newer link,
and closing the dialog clears it. A render failure shows in the dialog's
existing alert. It's native builds only (`nativeIdentityEnabled()`),
because the browser host has no renderer.
- **Refinements from baxen (commits 3–4):** the dialog leads with
"Invite with a link", and "Add directly" comes after it. The QR code now
shows as soon as the link exists, with no toggle. A same-size
placeholder holds its place while the link or code is being made, and
goes away if the render fails. **Expires after** and **Max uses** share
one line. The renderer gains a dark variant, `qr_code(value, variant:
"light" | "dark")`, with light dots and finders on black. Invites use
it, and pairing keeps the light one. Status and error lines now sit
below both sections.
- **Scroll and focus (commit 5):** changing **Expires after** or **Max
uses** no longer scrolls the dialog to the top. For one render the new
settings had no link yet, so the code's place left the layout and the
dialog's scroll was clamped to zero; new settings now count as pending,
so the placeholder holds the place. The pickers also stay enabled while
the link is made (still disabled while the member list is locked), so
focus returns to the picker just used. The newest choice's link wins.
- **Reversed (light-on-dark) code:** iOS Camera and Google Lens read it.
Some third-party scanner apps don't, or need a setting turned on.
- Docs: `docs/communities.md` and `docs/buzz-help.md`.
The link the dialog encodes is the relay's ordinary
`https://<host>/invite/<code>`, the same kind the Builderlab website
will mint through the operator invite route in block/buzz#9265.
Another stacked PR, the mobile setup step, builds on commit 1.
## Testing
- `CommunityAdmin.native.test.tsx`: a new journey holds the native
render with a deferred promise and asserts the loading state before
releasing it. It then checks the image, `aria-controls`, that a new link
hides the old code, and that a render failure shows an alert. The
browser test asserts there's no button there. `qr.rs` has a command
bound test.
- Locally on this head: vitest for `src/bundled/moderation`,
`src/bundled/pairing` and `src/shared` passed, along with `tsc`,
`design:check`, `cargo test --lib qr`, and the pre-push hooks including
clippy. The reviewer also ran `cargo test --lib pairing` (80 tests).
- **Full suites are left to CI.** Locally, at load average 45–55, the
full vitest run failed only on timeouts in untouched files. The full
`buzz_foundation_lib` test binary (`cargo test --manifest-path
src-tauri/Cargo.toml --lib`) segfaults (SIGSEGV) near the end of its
run. **`main` at `5b7df025f` does the same** on this machine, so the
crash isn't from this change.
- **Deferred, still owed before ready:** the real-app pass. That means
both themes, a narrow window, enlarged text, keyboard and pointer, and
scanning the code with a phone to confirm it opens the `/invite/<code>`
page.
- **Commits 3–4:** the native test covers section order, no toggle,
`variant: "dark"`, an old link's late code never replacing the current
one, and a render failure with no stale code or placeholder. I
mutation-checked the URL match, the `live` guard and the failure record:
removing any one fails it. The `qr.rs` tests cover the dark colours,
finders, dot parity and the variant wire names. Rendered in a
stubbed-host fixture in light and dark at 900px, and at 400px with 150%
text. Both screenshots decode with zxing-cpp to the exact invite URL,
and so do 450 of 450 realistic `/invite/v2.…` URLs from the dark render.
- **Commit 5:** a native test holds the new link's mint and asserts both
pickers stay enabled, the used picker keeps focus, and the code's
container is the same element after the new link and its code arrive.
Mutation-checked: disabling the pickers while pending, or restoring the
old `pending`, each fails it. In a stubbed-host fixture (Chromium and
WebKit, dialog scrolled to 40px, 800ms mint), the scroll stayed at 40
and focus stayed on the picker; at 899caf3 it went to 0 with focus on
the page.
### How to try it (human check)
> Steps 3 and 5 describe the original toggle. Now the code shows without
a toggle and redraws when a setting changes.
1. Run this branch's desktop app (`just desktop`) signed in as an owner
or admin of a community.
2. Open the community's **Settings → Membership**, then choose **Invite
members**.
3. Once the link appears, choose **Show QR code**. The QR code appears
under the link and the chevron flips. **Hide QR code** removes it, and
the collapsed state leaves no gap.
4. Scan it with a phone. It should open
`https://<community>/invite/<code>`, the same text as the link field.
5. Change **Limit number of uses**. The code disappears and **Show QR
code** draws the new link.
6. Repeat in light and dark themes, in a narrow window, and with a
larger text size. Use the keyboard as well: Tab through the link, Copy
and both pickers.
---------
Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: drone03 <3efda7d0089450ac8094dd25cfa734546dc0d6bad0ab87b64dacfff867bdc4d3@buzz.block.builderlab.xyz>
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Approving. The authorization model holds: the operator signature is checked the same way as archive and transfer, the owner/admin decision comes from the relay's own member list, and invites are created through the same mint_relay_invite, limits and response as /api/invites, with bans re-checked inside the transaction. The Postgres journey creates an invite and claims it end to end. Since the operator can already transfer any community's ownership, this route doesn't widen what an operator key can do.
Everything below is nonblocking:
- Role check race (inline): shared with
/api/invites, so it's worth one fix for both routes. - Access log (inline): refused requests are logged as successful authentications, and the log line doesn't name the community.
- Archive comment (inline): it overstates what the transaction re-checks.
with_codevscoded_api_error(inline).- Test gap: nothing exercises the
unavailablecode (community writes paused). Every other row of the error table is pinned by the journey.
kazemek21
left a comment
There was a problem hiding this comment.
Kyle reviewed this PR and the Nexus review-tracker notes. Approved with the following non-blocking concern.
[P2] Recheck the actor’s role inside the mint transaction
The roster lookup happens before mint_relay_invite opens its transaction, so an overlapping demotion or removal can still be followed by invite insertion. This is already covered by the existing role-check thread; I agree that checking owner/admin authority inside the shared mint transaction, under a lock that also serializes role changes, would cover both this route and /api/invites. Referencing that thread rather than adding a duplicate inline comment.
Posted by Codex (gpt-6.1-sol) on behalf of kylez
read the issuer's owner/admin role for share inside the mint transaction, after the admission lock, so a concurrent demotion, removal or ownership transfer either lands first and refuses the mint or waits for it. both /api/invites and the operator route mint through db::mint_relay_invite_as_admin. the operator route drops its separate role read, records the target community and the not_relay_admin or restricted refusal on its access line, uses coded_api_error for its own errors, and narrows the lifecycle comment to what the transaction rechecks. postgres tests cover a demotion and a deletion fence landing mid-mint. Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Kalvin Chau <kalvin@block.xyz>
|
🤖 Review follow-up in 8e3140d: all four inline threads are addressed and resolved. For the test gap raised in the review body, |
…e-mode * origin/main: (36 commits) feat(relay): let an operator mint invites for a community owner or admin (#9265) fix: inline ARM64 atomics in Sprig image build (#9291) fix(mobile): show observed thread replies in Activity (#9286) Enable mobile huddles with Codex agents (#9269) fix(mobile): read mentions taller than the screen (#9243) test(relay): give every media e2e upload its own blob hash (#9272) Add invite preview video and fallback poster (#9282) NEXUS-4336: Wait for desktop-provider socket ownership proof (#9270) fix: unblock image audit with patched MCP SDK (#9287) feat(ci): allow admin dispatch of desktop candidate validation (#9288) feat(mobile): tag the reacted-to message author on reactions (#9141) fix(mobile): steady New DM picker rows and animate picks (#9249) perf(mobile): keep remote images on disk and show small ones as thumbnails (#9158) fix(relay): deliver slow cross-node events late instead of closing their recipients (#9235) fix(mobile): read only shown events from filtered Activity rows (#9215) fix(mobile): show the channel name as the notification subtitle (#9173) fix(mobile): keep Home's header clean under sheets (#9152) fix(mobile): show already-loaded custom emoji in the iOS reactions sheet (#9150) mobile: add follow and remind actions to inbox row menu (#9142) fix(mobile): start no threads in Solo chats (#9123) ... Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Summary
Adds
POST /operator/communities/invites, so a deployment's trusted operator (its account service) can mint an invite link on behalf of a community owner or admin. The website where people manage their communities never holds the owner's own key, so it can't callPOST /api/invites(owner-signed NIP-98)./operator/communities/{archive,transfer}. A configured operator signs the request (NIP-98, payload-bound, replay-checked) and vouches only for which identity its signed-in caller is bound to (actor_pubkey). The relay decides that identity's authority from its own roster at mint time: owner or admin only, the same check/api/invitesmakes. Nothing the operator says about roles is trusted.db.mint_relay_invite, with the same lifecycle gate and restriction check, the samettl_secs/max_usesbounds and defaults (validate_mint_request), and the same response (minted_invite_json, extracted from/api/invites). The link is an ordinaryhttps://<host>/invite/<code>that every client's join flow already claims.lookup_community_hostonly resolves active ones).Request body (unknown fields rejected):
{ "community_id": "<uuid>", "actor_pubkey": "<64-hex>", "ttl_secs": 259200, "max_uses": 5 }Errors keep the
/api/invitestext inerrorand add a stablecode, so the operator can tell its own misconfiguration from the actor's refusal without matching on text:codenot_operatorinvalid_requestcommunity_not_foundnot_ownerrestrictedinvite_restricted)unavailableThis is an HTTP operator route rather than an event kind because it belongs on the existing operator plane (operator-signed, outside the event data plane), like the other operator community actions.
Stack (invite links on the Builderlab Communities page)
Related issue
None found.
Testing
operator_mints_invites_only_for_relay_owners_and_admins(run against a freshly migrated local database):max_usesand the 72h expiry match/api/invites.POST /api/invites/claimand becomes a member./api/invites. The test pins each error'scode.ttl_secsandmax_uses, an unknown field, a bad pubkey and a non-operator signer are all refused.cargo test -p buzz-relay --lib -- --include-ignored api::invites api::operator: 78 passed and 2 timed out under heavy machine load. Both timeouts (claim_follow_up_survives_a_client_disconnect,temporary_listing_failure_is_recovered_for_each_admission_path) also pass on this branch when run on their own, as onmain.cargo fmtand pre-push hooks (clippy) passed.curl, and a human check.