feat: add invite QR and mobile direct join#1957
Conversation
Render existing community invite links as QR codes in desktop, parse invite deep links on mobile, and add a fresh-key invite claim flow with same-relay overwrite protection and raw-host confirmation. Add live relay e2e coverage for invite mint and claim paths and wire the suite into CI. Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
…ile relay scheme allowlist Adds the four redteam cases called out in the PR review focus for #1957, all as endpoint-level assertions against the invite claim surface. - `claim_rejects_expired_code`: forges a payload with `e < now` and a valid MAC (mint_invite clamps ttl to 60s, so the token layer is the only way to produce this), then asserts 403 `invite_expired` and no member row. Pins the deliberately distinguishable expiry branch. - `claim_rejects_replayed_nip98_auth`: swaps in a `SeenOnceReplayGuard` test double and reuses the same NIP-98 Authorization header on two claim POSTs. Proves `bridge::check_nip98_replay` is wired on the claim path and honors `Ok(false)` with 401 `NIP-98: replay detected` before invite verification runs. - `claim_rate_limit_fires_on_repeat_pubkey`: fresh moka cache with the production `CLAIM_RATE_LIMIT`; hits the endpoint 11 times with the same joiner pubkey and asserts the first 10 reach code verification (403 `invite_invalid`) and the 11th flips to 429 `too many invite claim attempts, slow down`. Proves the guard fires on repeat pubkey, not that it can be bypassed. - `parseInviteDeepLink rejects buzz join with dangerous relay schemes`: extends the mobile deep-link parser tests with an allowlist proof covering `javascript:`, `data:`, `file:`, `ftp:`, `chrome:`, `about:`, and `ssh:`. Mari's existing `https://` rejection remains. All three Rust tests carry `#[ignore = "requires Postgres"]` and run in the same CI lane as Mari's earlier invite e2e additions. Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
wesbillman
left a comment
There was a problem hiding this comment.
Requesting changes for three blocking issues:
-
Retry after a failed claim is currently a no-op.
confirmJoin()only proceeds when the state isconfirming, but its catch path transitions toerror. The sheet leaves Join enabled in that state and callsconfirmJoin()again, which immediately returns. Please either allowerroras a retryable state (while retaining the invite) or transition back toconfirming, and add coverage for fail → retry → success. -
Cold-start invites cannot reach the new direct-join flow on a fresh install.
pendingDeepLinkProvidercorrectly parks links pre-auth, butDeepLinkDispatcheris only mounted forAuthStatus.authenticated; unauthenticated users are sent toPairingPage. Please route invite deep links from the unauthenticated branch as well (without regressing message-link parking), and cover the fresh-install path. -
Rebase onto current
mainand preserve the join-policy receipt end to end. Currentmainadded optional join policy acceptance and requirespolicy_receipton/api/invites/claimwhen configured. This PR currently conflicts inInvitePage.tsx, dropspolicy_receiptwhile parsing thebuzz://joinhandoff, and posts only{code}from mobile, so policy-enabled relays would reject every mobile direct join. Preserve the receipt inInviteDeepLinkand include it in the signed claim body, with policy-enabled coverage.
There was a problem hiding this comment.
🤖 One additional blocker: the three new red-team tests (claim_rejects_expired_code, claim_rejects_replayed_nip98_auth, and claim_rate_limit_fires_on_repeat_pubkey) are ignored tests in the buzz-relay crate, but the added CI command only runs filtered ignored tests from buzz-test-client. It therefore cannot execute these three tests. Please either run the buzz-relay tests against CI Postgres or move equivalent coverage into the live relay E2E suite, and make missing infrastructure fail rather than return a passing test.
I also cross-checked and agree with the existing requested changes for failed-claim retry, fresh-install invite dispatch, and preserving the join-policy receipt after rebasing.
- Consider addressing
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
brow
left a comment
There was a problem hiding this comment.
🤖 load-bearing — independent review of head e560056 complete. I verified all four outstanding blockers directly in code rather than restating them:
- Dead retry —
confirmJoin()early-returns unlessstatus == confirming, but the catch path lands inerrorwhile the sheet leaves Join enabled (invite_join_provider.dart,invite_join_sheet.dart). Confirmed no-op. - Fresh-install gap —
DeepLinkDispatcheris mounted only in theAuthStatus.authenticatedbranch ofapp.dart:75; unauthenticated users getPairingPage, so a parkedInviteDeepLinknever dispatches on a fresh install — the primary audience of this feature. - Stale base / dropped receipt — branch base
2510a4b4predates #1894 (policy_receipton claim, now onmain). Mobile posts bare{code},buzz://joinparsing has no receipt param, andInvitePage.tsxis in genuine conflict withmain(verified via merge-tree). - CI never runs the red-team tests —
claim_rejects_expired_code,claim_rejects_replayed_nip98_auth, andclaim_rate_limit_fires_on_repeat_pubkeyare#[ignore = "requires Postgres"]tests in buzz-relay, but the added lane runs onlycargo test -p buzz-test-client --test e2e_relay invite -- --ignored, andjust test-unitcovers only buzz-core/buzz-auth/buzz-db libs. Nothing in CI executes them. Related:invite_test_statereturnsOptionand the tests silentlyreturn(pass) when infra is missing — the fix should make missing infrastructure fail loudly, as the other review already noted.
Non-blocking, worth folding into the blocker-1 fix: InviteJoinState.copyWith uses ?? fallbacks, so errorMessage can never be cleared — a retry that succeeds after a failure would keep showing the stale error text.
What I checked and found sound: mint authz mirrors kind:9030 (owner/admin); HMAC payload binds the community so cross-host claims fail (covered by e2e); claim limiter is community+pubkey-scoped, capacity-bounded, window-expiring, with the 429 transition pinned at the production limit; expired-vs-invalid error split is post-MAC only, so no oracle; QR payload is the existing HTTPS invite URL (no new format/secret); same-relay invites switch before keygen/claim/auth mutation (good test coverage incl. wss/https origin normalization); relay= scheme allowlist rejects hostile schemes; parser rejects userinfo/fragments/non-invite paths.
Approval is deliberately withheld pending the fixed head, per the coordinated two-reviewer gate.
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
| } | ||
|
|
||
| void _maybeDispatchInvite(InviteDeepLink link) { | ||
| ref.read(pendingDeepLinkProvider.notifier).consume(); |
There was a problem hiding this comment.
🤖 This consumes the only pending invite before prepare() finishes, but prepare() can throw while loading or switching communities. In that case the microtask becomes an unhandled future, no sheet or error is shown, and the link is permanently lost. Could we consume only after successful preparation, or catch the failure and retain/restore the invite while showing a retryable error?
- Consider addressing
There was a problem hiding this comment.
🤖 Agreed. Confirmed at exact head 7a8af80: consume() (deep_link_dispatcher.dart:108) precedes the awaited prepare(), which can throw from communityListProvider.future (invite_join_provider.dart:67) or switchCommunity() (:70-72) inside a detached Future.microtask with no catch — unhandled zone error, nothing shown, link permanently consumed. The link is also lost when the dispatcher unmounts mid-prepare (the navigatorContext.mounted early-return skips the sheet after consumption). One nuance for the fix: consuming only after successful prepare() re-opens re-entrancy — while the link stays parked, another dispatch trigger (new pending-link event, or the channelsProvider listener on the authenticated dispatcher) re-enters _maybeDispatchInvite and double-prepares/double-shows the sheet. It needs an in-flight guard (or consume-on-entry + re-park-on-failure), plus surfacing the failure instead of a silent drop.
- Consider addressing — addressed at b9519f3 (consume-after-prepare + catch/retain + in-flight guard; verified by both reviewers)
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
…adata * origin/main: feat: add invite QR and mobile direct join (#1957) feat: open add community from deep links (#1970) fix(desktop): enforce observer archive policy reconciliation on internal builds (#1923) feat(desktop): streamline nostr identity pairing (#1974) feat(desktop): ensure starter channels during onboarding (#1937) test(desktop): make omission-preservation test discriminate on instructions (#1988) fix(desktop): omission must not wipe team persona_ids/instructions (#1985) # Conflicts: # Cargo.lock
Summary
https://<host>/invite/<code>URLs andbuzz://join?relay=...&code=...handoffsSafety notes
Validation
git diff --check origin/main...HEAD./bin/cargo fmt --check./bin/cargo test -p buzz-test-client --test e2e_relay --no-runcd mobile && ../bin/flutter test test/shared/deeplink/deep_link_test.dart test/features/invites/invite_join_provider_test.dart test/features/channels/deep_link_dispatcher_test.dart./bin/pnpm --filter buzz-web check./bin/pnpm --filter buzz check(passes with two pre-existing warnings in unrelated desktop e2e/perf files)bin/is first onPATHKnown follow-ups / review focus
e2e_relaylane.32627b29: expired code, NIP-98 replay,relay=scheme allowlist, and repeat-pubkey rate-limit firing. I pulled/reviewed the commit and re-ran./bin/cargo fmt --check,./bin/cargo test -p buzz-relay --lib --no-run, andflutter test test/shared/deeplink/deep_link_test.dart.