镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

feat(relay): let an operator mint invites for a community owner or admin - #9265

Merged
kalvinnchau merged 4 commits into
mainfrom
drone03/buzz-660-operator-invites
Nov 9, 2026
Merged

kalvinnchau merged 4 commits into
mainfrom
drone03/buzz-660-operator-invites

Conversation

@baxen

@baxen baxen commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

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 call POST /api/invites (owner-signed NIP-98).

  • Trust model: the same as /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/invites makes. Nothing the operator says about roles is trusted.
  • Same invite: it mints through db.mint_relay_invite, with the same lifecycle gate and restriction check, the same ttl_secs/max_uses bounds and defaults (validate_mint_request), and the same response (minted_invite_json, extracted from /api/invites). The link is an ordinary https://<host>/invite/<code> that every client's join flow already claims.
  • Archived, deleted and deleting communities return 404 (lookup_community_host only 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/invites text in error and add a stable code, so the operator can tell its own misconfiguration from the actor's refusal without matching on text:

Status code When
403 not_operator the signer is not a configured operator
400 invalid_request bad JSON, an unknown field, a bad pubkey, or out-of-range limits
404 community_not_found unknown, archived, deleted or deleting community
403 not_owner the actor isn't an owner or admin in the relay's roster
403 restricted the actor is banned or restricted (invite_restricted)
503 unavailable community writes are paused

This 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)

  1. This PR: the relay route. It ships first.
  2. The account service endpoint that calls it (private repo). Depends on this PR.
  3. The website UI (private repo). Depends on 2.

Related issue

None found.

Testing

  • New Postgres journey operator_mints_invites_only_for_relay_owners_and_admins (run against a freshly migrated local database):
    • An owner mints with the defaults. The URL, null max_uses and the 72h expiry match /api/invites.
    • A joiner claims the minted code through POST /api/invites/claim and becomes a member.
    • An admin mints with limits.
    • A banned admin is refused.
    • A member and an outsider get 403 with the same refusal text as /api/invites. The test pins each error's code.
    • Out-of-range ttl_secs and max_uses, an unknown field, a bad pubkey and a non-operator signer are all refused.
    • An unknown community and an archived community get 404.
  • 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 on main.
  • cargo fmt and pre-push hooks (clippy) passed.
  • Not yet done: a run against a local relay with curl, and a human check.

baxen and others added 2 commits November 8, 2026 14:01
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>
@baxen
baxen marked this pull request as ready for review November 9, 2026 04:40
@baxen
baxen requested a review from a team as a code owner November 9, 2026 04:40
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-09T04:42:50.261344Z 0e560c3 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 66095e55985777dd84be140b328f0ee97af19ae9...8e3140dded19b8f9294612fbb7a85613ea4a6618.
A new review must complete for this exact range. When manual authorization
is required, a user with write access must comment exactly
@buzz-security-review 8e3140dded19b8f9294612fbb7a85613ea4a6618 to authorize a new review.
Any previous review applies only to its recorded range.

baxen added a commit to block/buzz-app that referenced this pull request Nov 9, 2026
## 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 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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_code vs coded_api_error (inline).
  • Test gap: nothing exercises the unavailable code (community writes paused). Every other row of the error table is pinned by the journey.

@kazemek21 kazemek21 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 9, 2026
@kalvinnchau
kalvinnchau deployed to codex-review November 9, 2026 20:38 — with GitHub Actions Active
@kalvinnchau

Copy link
Copy Markdown
Contributor

🤖 Review follow-up in 8e3140d: all four inline threads are addressed and resolved. For the test gap raised in the review body, operator_mint_rechecks_role_and_lifecycle_inside_the_mint now covers unavailable. It holds the deletion lock and an uncommitted quiescing fence, set up the way the deletion executor sets it, after the host lookup has passed. It then asserts 503 unavailable with no invite row. The operator invite, /api/invites, relay invite and admission Postgres tests pass locally, and CI is green on this head. The push dismissed the earlier approvals, so this needs re-approval.

@kalvinnchau
kalvinnchau merged commit 2b16d0e into main Nov 9, 2026
90 checks passed
@kalvinnchau
kalvinnchau deleted the drone03/buzz-660-operator-invites branch November 9, 2026 21:24
wpfleger96 added a commit that referenced this pull request Nov 9, 2026
…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>

This branch was successfully deployed

1 active deployment
codex-review — 8e3140dd Deployed Nov 9, 2026 by kalvinnchau via Run Codex Security Review #14092
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.

4 participants