Files
buzz/desktop/src/shared/api/invites.ts
Kalvin C d500c2d5cf feat(invites): add use-limited invite links (#3141)
## Summary

- add database-backed v2 invite links with optional maximum-use limits
and atomic final-slot redemption
- preserve v1 invite compatibility while adding
exhausted/expired/invalid client handling across desktop, web, and
mobile
- emit structured claim-outcome logs with community, invite ID, outcome,
maximum uses, and post-claim count

## Verification

- `cargo fmt --all -- --check`
- `cargo test -p buzz-db` (85 passed, 134 Postgres-dependent ignored)
- `cargo clippy -p buzz-db --all-targets -- -D warnings`
- desktop `npm run typecheck`
- push hook: desktop checks/tests, desktop Tauri tests, Rust tests, and
branch-skew passed
- Postgres integration tests were previously reviewed green at the
pre-rebase tree; local rerun on this session was unavailable because
Postgres/Docker were not running
- mobile push-hook check could not start because Flutter is unavailable
locally

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: npub1c4alndp82zyt9veaklm5d965quss79vlhk9awv7qu5erwhmf42qqlvc25c <c57bf9b4275088b2b33db7f746975407210f159fbd8bd733c0e532375f69aa80@buzz.block.builderlab.xyz>
Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-07-27 15:19:39 -07:00

243 lines
7.1 KiB
TypeScript

import { relayHttpFromWs } from "@/shared/api/inviteHelpers";
import {
getRelayHttpUrl,
invokeTauri,
signRelayEvent,
} from "@/shared/api/tauri";
// Relay invite data layer. Both endpoints are NIP-98-authed HTTP POSTs
// (mirrors the read path in moderation.ts, plus the payload tag the relay
// requires for signed POST bodies):
//
// - POST /api/invites — mint a code (relay checks owner/admin role)
// - POST /api/invites/claim — claim a code, signed by the *joining* key.
// This one targets an arbitrary relay (the invite's relay, not necessarily
// the active community), so the claim helper takes an explicit ws URL.
const NIP98_KIND = 27235;
// Bound invite requests so an unreachable relay surfaces as an error in the
// invite-loading UI within seconds instead of hanging for the OS-level
// connect timeout (a minute or more on macOS).
const INVITE_REQUEST_TIMEOUT_MS = 15_000;
export type MintedInvite = {
code: string;
expiresAt: number;
url: string;
maxUses: number | null;
usesRemaining: number | null;
};
export type JoinPolicy = {
termsMarkdown?: string;
privacyMarkdown?: string;
ageAttestationRequired: boolean;
version: string;
};
export type ClaimResult = {
status: "joined" | "already_member";
communityId: string;
host: string;
role: string;
};
async function sha256Hex(text: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(text),
);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Build the NIP-98 `Authorization` header for a POST with a body.
*
* The relay requires a `payload` tag carrying sha256(body) for signed POSTs
* (api/invites.rs passes `require_payload: true`), and verifies the `u` tag
* against the exact request URL — so the caller finalizes both before signing.
*/
async function nip98PostHeader(url: string, body: string): Promise<string> {
const authEvent = await signRelayEvent({
kind: NIP98_KIND,
content: "",
tags: [
["u", url],
["method", "POST"],
["payload", await sha256Hex(body)],
["nonce", crypto.randomUUID()],
],
});
// NIP-98 events carry empty content and ASCII-only tags, so btoa is safe here.
return `Nostr ${btoa(JSON.stringify(authEvent))}`;
}
async function invitePost<T>(
httpBase: string,
path: string,
body: string,
): Promise<T> {
const url = `${httpBase.replace(/\/+$/, "")}${path}`;
const authorization = await nip98PostHeader(url, body);
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: authorization,
"Content-Type": "application/json",
},
body,
signal: AbortSignal.timeout(INVITE_REQUEST_TIMEOUT_MS),
});
const json = (await response.json().catch(() => ({}))) as Record<
string,
unknown
>;
if (!response.ok) {
const message =
typeof json.error === "string" ? json.error : `HTTP ${response.status}`;
throw new Error(message);
}
return json as T;
}
/** Absolute URL of a relay-hosted policy document page (system-browser target). */
export function joinPolicyDocumentUrl(
relayWsUrl: string,
document: "terms" | "privacy",
): string {
const base = relayHttpFromWs(relayWsUrl);
return `${base.replace(/\/+$/, "")}/api/join-policy/${document}`;
}
/** Whether a normalized relay URL is complete enough for background policy discovery. */
export function isJoinPolicyDiscoveryCandidate(relayWsUrl: string): boolean {
try {
const url = new URL(relayWsUrl);
if (url.protocol !== "ws:" && url.protocol !== "wss:") return false;
return (
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname.includes(".")
);
} catch {
return false;
}
}
/** Fetch relay-hosted policy content for any join surface. */
export async function getJoinPolicy(
relayWsUrl: string,
transport: "native" | "webview",
): Promise<JoinPolicy | null> {
type RawJoinPolicy = {
terms_markdown?: string;
privacy_markdown?: string;
age_attestation_required: boolean;
version: string;
};
let raw: RawJoinPolicy | null;
if (transport === "native") {
raw = await invokeTauri<RawJoinPolicy | null>("fetch_join_policy", {
relayUrl: relayWsUrl,
});
} else {
const base = relayHttpFromWs(relayWsUrl);
const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`);
// Relays predating join-policy support have no configured policy.
if (response.status === 404) return null;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
raw =
(
(await response.json()) as {
policy?: RawJoinPolicy;
}
).policy ?? null;
}
return raw
? {
termsMarkdown: raw.terms_markdown,
privacyMarkdown: raw.privacy_markdown,
ageAttestationRequired: raw.age_attestation_required,
version: raw.version,
}
: null;
}
/** Accept the current join policy for an invite and receive a bound receipt. */
export async function acceptJoinPolicy(
relayWsUrl: string,
code: string,
policyVersion: string,
ageConfirmed: boolean,
): Promise<string> {
const base = relayHttpFromWs(relayWsUrl);
const response = await fetch(
`${base.replace(/\/+$/, "")}/api/invites/accept-policy`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
policy_version: policyVersion,
age_confirmed: ageConfirmed,
}),
},
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return ((await response.json()) as { receipt: string }).receipt;
}
/** Mint an invite code on the active community's relay (owner/admin only). */
export async function mintInvite(options?: {
ttlSecs?: number;
maxUses?: number | null;
}): Promise<MintedInvite> {
const base = await getRelayHttpUrl();
const payload: Record<string, unknown> = {};
if (options?.ttlSecs != null) payload.ttl_secs = options.ttlSecs;
if (options?.maxUses != null) payload.max_uses = options.maxUses;
const body = JSON.stringify(payload);
const raw = await invitePost<{
code: string;
expires_at: number;
url: string;
max_uses: number | null;
uses_remaining: number | null;
}>(base, "/api/invites", body);
return {
code: raw.code,
expiresAt: raw.expires_at,
url: raw.url,
maxUses: raw.max_uses,
usesRemaining: raw.uses_remaining,
};
}
/**
* Claim an invite code against `relayWsUrl` (the invite's relay — not
* necessarily the active community), signed by this app's identity key.
*/
export async function claimInvite(
relayWsUrl: string,
code: string,
policyReceipt?: string,
): Promise<ClaimResult> {
const base = relayHttpFromWs(relayWsUrl);
const body = JSON.stringify({ code, policy_receipt: policyReceipt });
const raw = await invitePost<{
status: "joined" | "already_member";
community_id: string;
host: string;
role: string;
}>(base, "/api/invites/claim", body);
return {
status: raw.status,
communityId: raw.community_id,
host: raw.host,
role: raw.role,
};
}