mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02: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>
97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
export const INVITE_EXPIRED_ERROR = "invite_expired";
|
|
export const INVITE_EXHAUSTED_ERROR = "invite_exhausted";
|
|
|
|
/**
|
|
* Parsed invite — either a full (relay + code) or bare-code form.
|
|
*
|
|
* URL inputs (`https://`, `http://`, `buzz://join`) always carry a
|
|
* `relayWsUrl` (already normalised to `ws(s)://`). A bare code (no scheme,
|
|
* no slashes) omits it — the caller decides which relay to target.
|
|
*/
|
|
export type ParsedInvite =
|
|
| { relayWsUrl: string; code: string }
|
|
| { code: string };
|
|
|
|
/**
|
|
* Parse an invite input into a structured form.
|
|
*
|
|
* Accepted input forms:
|
|
* - `https://<relay>/invite/<code>` → `{ relayWsUrl: "wss://<relay>", code }`
|
|
* - `http://<relay>/invite/<code>` → `{ relayWsUrl: "ws://<relay>", code }`
|
|
* - `buzz://join?relay=<wsUrl>&code=<code>` → `{ relayWsUrl, code }`
|
|
* - bare code (no `://`, no `/`) → `{ code }`
|
|
*
|
|
* Returns `null` for empty input or inputs that don't match any form.
|
|
*/
|
|
export function parseInviteInput(input: string): ParsedInvite | null {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return null;
|
|
|
|
// Try URL-form parse first.
|
|
try {
|
|
const url = new URL(trimmed);
|
|
|
|
// buzz://join?relay=...&code=...
|
|
// Non-special schemes put the authority in `host`, not `pathname`.
|
|
if (url.protocol === "buzz:") {
|
|
if (url.host !== "join") return null;
|
|
const relay = url.searchParams.get("relay");
|
|
const code = url.searchParams.get("code");
|
|
if (!relay || !code) return null;
|
|
if (!relay.startsWith("ws://") && !relay.startsWith("wss://"))
|
|
return null;
|
|
if (url.username || url.password || url.hash) return null;
|
|
// Reject credentials or fragments smuggled inside the nested relay param.
|
|
try {
|
|
const relayUrl = new URL(relay);
|
|
if (relayUrl.username || relayUrl.password || relayUrl.hash)
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
return { relayWsUrl: relay, code };
|
|
}
|
|
|
|
// https(s)://<relay>/invite/<code>
|
|
if (url.protocol === "https:" || url.protocol === "http:") {
|
|
if (url.username || url.password || url.hash) return null;
|
|
// pathname must be /invite/<code> with optional single trailing slash
|
|
const match = url.pathname.match(/^\/invite\/([^/]+)\/?$/);
|
|
if (!match?.[1]) return null;
|
|
const code = decodeURIComponent(match[1]);
|
|
// Convert scheme: https → wss, http → ws. url.host already includes port.
|
|
const relayWsUrl =
|
|
url.protocol === "https:" ? `wss://${url.host}` : `ws://${url.host}`;
|
|
return { relayWsUrl, code };
|
|
}
|
|
|
|
// ws/wss or any other scheme — not an invite URL.
|
|
return null;
|
|
} catch {
|
|
// Not a URL — fall through to bare-code check.
|
|
}
|
|
|
|
// Bare code: no scheme, no slashes.
|
|
if (trimmed.includes("://") || trimmed.includes("/")) return null;
|
|
return { code: trimmed };
|
|
}
|
|
|
|
/** Convert a ws(s) relay URL to its http(s) equivalent. */
|
|
export function relayHttpFromWs(wsUrl: string): string {
|
|
if (wsUrl.startsWith("wss://")) return `https://${wsUrl.slice(6)}`;
|
|
if (wsUrl.startsWith("ws://")) return `http://${wsUrl.slice(5)}`;
|
|
throw new Error(`Expected ws:// or wss:// relay URL, got: ${wsUrl}`);
|
|
}
|
|
|
|
export function inviteErrorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : `${error}`;
|
|
}
|
|
|
|
export function isInviteExpiredError(error: unknown): boolean {
|
|
return inviteErrorMessage(error) === INVITE_EXPIRED_ERROR;
|
|
}
|
|
|
|
export function isInviteExhaustedError(error: unknown): boolean {
|
|
return inviteErrorMessage(error) === INVITE_EXHAUSTED_ERROR;
|
|
}
|