mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
## 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>
243 lines
7.1 KiB
TypeScript
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,
|
|
};
|
|
}
|