Skip to content

feat: add invite QR and mobile direct join#1957

Merged
brow merged 8 commits into
mainfrom
mari/invite-qr-direct-join
Jul 16, 2026
Merged

feat: add invite QR and mobile direct join#1957
brow merged 8 commits into
mainfrom
mari/invite-qr-direct-join

Conversation

@tlongwell-block

@tlongwell-block tlongwell-block commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • render existing community invite URLs as QR codes in the desktop invite link section
  • parse invite links on mobile from both canonical https://<host>/invite/<code> URLs and buzz://join?relay=...&code=... handoffs
  • add mobile direct-join flow that generates a fresh Nostr keypair after confirmation, claims the invite with NIP-98, and protects existing same-relay community auth from overwrite
  • add relay invite mint/claim e2e coverage to the existing relay e2e CI lane

Safety notes

  • QR payload is the existing minted HTTPS invite URL; this does not introduce a separate invite format or encode a new secret.
  • Mobile confirmation shows the parsed raw host as the primary trust string.
  • Same-relay invites switch to the existing community before key generation, claim POST, or auth storage mutation.

Validation

  • git diff --check origin/main...HEAD
  • ./bin/cargo fmt --check
  • ./bin/cargo test -p buzz-test-client --test e2e_relay --no-run
  • cd 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)
  • pre-push hook passed during push after ensuring Hermit bin/ is first on PATH

Known follow-ups / review focus

  • I could not run the live relay invite e2e locally against Postgres/relay; CI should exercise the newly wired e2e_relay lane.
  • Quinn added the four redteam cases in 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, and flutter test test/shared/deeplink/deep_link_test.dart.

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>
@tlongwell-block tlongwell-block requested a review from a team as a code owner July 16, 2026 15:05
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm and others added 2 commits July 16, 2026 11:23
…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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Requesting changes for three blocking issues:

  1. Retry after a failed claim is currently a no-op. confirmJoin() only proceeds when the state is confirming, but its catch path transitions to error. The sheet leaves Join enabled in that state and calls confirmJoin() again, which immediately returns. Please either allow error as a retryable state (while retaining the invite) or transition back to confirming, and add coverage for fail → retry → success.

  2. Cold-start invites cannot reach the new direct-join flow on a fresh install. pendingDeepLinkProvider correctly parks links pre-auth, but DeepLinkDispatcher is only mounted for AuthStatus.authenticated; unauthenticated users are sent to PairingPage. Please route invite deep links from the unauthenticated branch as well (without regressing message-link parking), and cover the fresh-install path.

  3. Rebase onto current main and preserve the join-policy receipt end to end. Current main added optional join policy acceptance and requires policy_receipt on /api/invites/claim when configured. This PR currently conflicts in InvitePage.tsx, drops policy_receipt while parsing the buzz://join handoff, and posts only {code} from mobile, so policy-enabled relays would reject every mobile direct join. Preserve the receipt in InviteDeepLink and include it in the signed claim body, with policy-enabled coverage.

@brow brow 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.

🤖 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

npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 added 2 commits July 16, 2026 14:50
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 brow 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.

🤖 load-bearing — independent review of head e560056 complete. I verified all four outstanding blockers directly in code rather than restating them:

  1. Dead retryconfirmJoin() early-returns unless status == confirming, but the catch path lands in error while the sheet leaves Join enabled (invite_join_provider.dart, invite_join_sheet.dart). Confirmed no-op.
  2. Fresh-install gapDeepLinkDispatcher is mounted only in the AuthStatus.authenticated branch of app.dart:75; unauthenticated users get PairingPage, so a parked InviteDeepLink never dispatches on a fresh install — the primary audience of this feature.
  3. Stale base / dropped receipt — branch base 2510a4b4 predates #1894 (policy_receipt on claim, now on main). Mobile posts bare {code}, buzz://join parsing has no receipt param, and InvitePage.tsx is in genuine conflict with main (verified via merge-tree).
  4. CI never runs the red-team testsclaim_rejects_expired_code, claim_rejects_replayed_nip98_auth, and claim_rate_limit_fires_on_repeat_pubkey are #[ignore = "requires Postgres"] tests in buzz-relay, but the added lane runs only cargo test -p buzz-test-client --test e2e_relay invite -- --ignored, and just test-unit covers only buzz-core/buzz-auth/buzz-db libs. Nothing in CI executes them. Related: invite_test_state returns Option and the tests silently return (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.

npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 added 2 commits July 16, 2026 15:25
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();

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.

🤖 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

@brow brow Jul 16, 2026

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.

🤖 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>
@brow brow merged commit 648cbf3 into main Jul 16, 2026
32 checks passed
@brow brow deleted the mari/invite-qr-direct-join branch July 16, 2026 20:40
atishpatel added a commit that referenced this pull request Jul 16, 2026
…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
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.

3 participants