diff --git a/src/features/communities/CommunityDialog.tsx b/src/features/communities/CommunityDialog.tsx index 1b2a8d1e6e..f5172a1c74 100644 --- a/src/features/communities/CommunityDialog.tsx +++ b/src/features/communities/CommunityDialog.tsx @@ -1,4 +1,3 @@ -import { profileDefault } from "./profile-default"; import { useEffect, useId, useRef, useState } from "react"; import { Dialog } from "../../shared/design-system/ui/Dialog"; import { Field } from "../../shared/design-system/ui/Field"; @@ -6,32 +5,16 @@ import { Input } from "../../shared/design-system/ui/Input"; import { Checkbox } from "../../shared/design-system/ui/Checkbox"; import { Button } from "../../shared/design-system/ui/Button"; import { ArrowUpRightIcon } from "../../shared/design-system/icons"; -import { - communityRequest, - inspectProfile, - publishProfile, - type CommunityInfo, -} from "./api"; +import { communityRequest, inspectProfile, type CommunityInfo } from "./api"; import type { Communities, PersonalProfile } from "./service"; import { canSaveProfile, ProfileFields, profilesEqual } from "./ProfileFields"; import { communityDestination, relayOrigin } from "./destination"; import { nativeIdentityEnabled } from "../identity/service"; import { readErrorKind } from "../relay/errors"; import { createJoinJournal, type PendingJoin } from "./join-journal"; +import { admitCommunity, completeJoin, type Current } from "./join"; import styles from "./Communities.module.css"; -// Exact relay claim refusal codes, forwarded unchanged by the broker. -const CLAIM_REFUSALS = { - invite_expired: "This invite code has expired — ask for a new one.", - invite_exhausted: - "This invite has reached its use limit. Ask for a new invite.", - invite_invalid: "This invite code is not valid for this community.", - join_policy_required: - "This community requires current policy acceptance. Go back and reopen the relay to review it.", - join_policy_not_accepted: - "The community policy changed. Go back and reopen the relay to review it.", -}; - export function CommunityDialog({ communities, mode, @@ -115,8 +98,8 @@ export function CommunityDialog({ const allowed = (!policy?.age_attestation_required || adult) && (!(policy?.terms_markdown || policy?.privacy_markdown) || agreed); - const current = (entry?: PendingJoin) => - mounted.current && (!entry || journal?.current(entry)); + const current: Current = (entry) => + mounted.current && (!entry || journal?.current(entry) === true); function showProfile( found: Awaited>, pending?: PendingJoin, @@ -150,55 +133,15 @@ export function CommunityDialog({ } else if (step === "access") { if (!allowed) return; await work(async () => { - const pending = journal?.get(id); - if (pending) { - // A lost claim response may already have admitted this identity, even - // when its invite has since expired. Read before attempting another claim. - const found = await inspectProfile(id).catch((reason: unknown) => { - if (readErrorKind(reason) !== "denied") throw reason; - return undefined; - }); - if (!current(pending)) return; - if (found) { - showProfile(found, pending); - return; - } - } - const transaction = journal?.begin(id); - if (code.trim()) { - const receipt = policy - ? ( - await communityRequest<{ receipt: string }>( - id, - "accept-policy", - { - code: code.trim(), - policy_version: policy.version, - age_confirmed: adult, - }, - ) - ).receipt - : undefined; - if (!current(transaction)) return; - const claim = await communityRequest<{ status: string }>( - id, - "claim", - { code: code.trim(), policy_receipt: receipt }, - ).catch((reason: unknown) => { - const known = - reason instanceof Error && - Object.hasOwn(CLAIM_REFUSALS, reason.message) - ? CLAIM_REFUSALS[reason.message as keyof typeof CLAIM_REFUSALS] - : undefined; - throw known ? new Error(known) : reason; - }); - if (!["joined", "already_member"].includes(claim.status)) - throw new Error("Membership was not confirmed"); - } - if (!current(transaction)) return; - const found = await inspectProfile(id); - if (!current(transaction)) return; - showProfile(found, transaction); + const admission = await admitCommunity({ + community: id, + journal, + code, + policy, + consent: { adult, agreed }, + current, + }); + if (admission) showProfile(admission.found, admission.entry); }); } else { if (!profile.name.trim()) return; @@ -206,32 +149,11 @@ export function CommunityDialog({ if (mode === "profile") communities.saveProfile({ ...profile, name: profile.name.trim() }); else { - if (!destination) throw new Error("Choose a community first"); - const transaction = journal?.begin(id, profile); - const found = journal ? await inspectProfile(id) : original; - if (!current(transaction)) return; - const next = - found?.exists && profilesEqual(profile, found.profile) - ? profile - : { - ...profile, - name: profile.name.trim(), - about: profile.about?.trim() ?? "", - }; - if (!found?.exists || !profilesEqual(next, found.profile)) { - await publishProfile(id, next, found?.existing ?? {}); - if (!current(transaction)) return; - // An accepted replaceable event may already be superseded. - const confirmed = await inspectProfile(id); - if (!current(transaction)) return; - if (!confirmed.exists || !profilesEqual(confirmed.profile, next)) - throw new Error( - "Your profile change is not current. Your edits are retained; try again.", - ); - } - if (!current(transaction)) return; - communities.joined( - { + if (!destination || !original) + throw new Error("Choose a community first"); + const done = await completeJoin({ + community: id, + membership: { id, name: info?.name && info.name !== "Buzz Relay" @@ -241,9 +163,13 @@ export function CommunityDialog({ ? { icon: info.icon } : {}), }, - profileDefault(next, communities.snapshot().profile, id), - ); - if (transaction) journal?.finish(transaction); + found: original, + journal, + publish: profile, + communities, + current, + }); + if (!done) return; onJoined?.(id); } close(); diff --git a/src/features/communities/FindCommunity.test.tsx b/src/features/communities/FindCommunity.test.tsx new file mode 100644 index 0000000000..4a3e0b19d1 --- /dev/null +++ b/src/features/communities/FindCommunity.test.tsx @@ -0,0 +1,384 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import { FindCommunity } from "./FindCommunity"; +import type { RelayFetch } from "./find-community"; + +const url = "https://relay.example.com:8443"; +const host = "relay.example.com:8443"; +const self = "76aa9daa7edc9c2e1dc6a2a90dbce10c1bd52f060fa6506ba7bad4f50240fc01"; +const buzz = { + name: "Buzz Relay", + software: "https://github.com/block/buzz", + version: "1.4.2", + icon: "https://cdn.example.com/icon.png", + self, + read_state_snapshot: { community_id: "c1" }, +}; +const policy = { + version: "v1", + terms_markdown: "# Terms", + privacy_markdown: "# Privacy", + age_attestation_required: true, +}; +afterEach(cleanup); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +/** Answers are read when each request is made, so a test can change them before Retry. */ +function relay(answers: () => Record) { + return vi.fn((_origin, path) => { + const answer = answers()[path]; + if (!answer) throw new Error(`Unexpected path ${path}`); + return answer instanceof Error + ? Promise.reject(answer) + : Promise.resolve(answer); + }); +} +const join = () => screen.getByRole("button", { name: /join/i }); +const joinCommunity = () => + screen.queryByRole("heading", { name: "Join Community" }); +/** Secondary first, primary last, as the design system's footers order them. */ +const precedes = (first: HTMLElement, second: HTMLElement) => + Boolean( + first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING, + ); + +it("shows the host while finding, then only published facts, gating Join on every consent", async () => { + const info = deferred(); + const request = relay(() => ({ + "/": new Response(null, { status: 599 }), + "/api/join-policy": Response.json({ policy }), + })); + request.mockImplementationOnce(() => info.promise); + const onJoin = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + expect( + screen.getByRole("heading", { name: "Finding community" }), + ).toHaveAttribute("id", "t"); + // An initial read: the host and Cancel show at once, the status waits. + expect(screen.getByText(host)).toBeVisible(); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled(); + expect(screen.queryByRole("button", { name: /join/i })).toBeNull(); + await act(async () => info.resolve(Response.json(buzz))); + expect(joinCommunity()).toBeVisible(); + expect(screen.getByText(host)).toHaveClass("font-mono"); + expect(document.querySelector("img")).toHaveAttribute( + "src", + "https://cdn.example.com/icon.png", + ); + // Nothing the relay does not publish: no name, description, counts or version. + expect(document.body).not.toHaveTextContent(/member|owner|invite|v1\.4/i); + const terms = screen.getByRole("link", { name: "Terms of Service" }); + expect(terms).toHaveAttribute("href", `${url}/api/join-policy/terms`); + expect(terms).toHaveAttribute("target", "_blank"); + expect(screen.getByRole("link", { name: "Privacy Notice" })).toHaveAttribute( + "href", + `${url}/api/join-policy/privacy`, + ); + const age = screen.getByRole("checkbox", { + name: "I confirm that I am at least 18 years old.", + }); + const agreement = screen.getByRole("checkbox", { + name: "I agree to this community’s Terms of Service and Privacy Notice.", + }); + // Consent gates the button; it never relabels it. + expect(join()).toHaveTextContent(/^Join$/); + expect(join()).toBeDisabled(); + expect(precedes(screen.getByRole("button", { name: "Cancel" }), join())).toBe( + true, + ); + await user.click(age); + expect(join()).toBeDisabled(); + await user.click(agreement); + expect(join()).toBeEnabled(); + await user.click(age); + expect(join()).toBeDisabled(); + await user.click(age); + await user.click(join()); + // The consent travels with the community: an invite claim attests the age. + expect(onJoin).toHaveBeenCalledWith( + { + url, + host, + icon: "https://cdn.example.com/icon.png", + self, + policy, + }, + { adult: true, agreed: true }, + ); + // The two unauthenticated reads are the only pre-join traffic: nothing is signed. + expect(request.mock.calls.map(([origin, path]) => [origin, path])).toEqual([ + [url, "/"], + [url, "/api/join-policy"], + ]); +}); + +it("without a policy Join needs no consent", async () => { + const request = relay(() => ({ + "/": Response.json({ ...buzz, icon: undefined, version: undefined }), + "/api/join-policy": new Response("missing", { status: 404 }), + })); + render( + , + ); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + expect(screen.queryByRole("checkbox")).toBeNull(); + expect(document.querySelector("img")).toBeNull(); + expect(screen.getByRole("button", { name: "Join" })).toBeEnabled(); +}); + +it("with onFound a found community is handed over while the lookup page stays", async () => { + const request = relay(() => ({ + "/": Response.json(buzz), + "/api/join-policy": Response.json({ policy }), + })); + const onFound = vi.fn(); + const onCancel = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await waitFor(() => + expect(onFound).toHaveBeenCalledWith({ + url, + host, + icon: "https://cdn.example.com/icon.png", + self, + policy, + }), + ); + // The caller decides what comes next; nothing here moves on or offers Join. + expect( + screen.getByRole("heading", { name: "Finding community" }), + ).toBeVisible(); + expect(joinCommunity()).toBeNull(); + expect(screen.queryByRole("button", { name: /join/i })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onFound).toHaveBeenCalledTimes(1); +}); + +it("holds the page while onJoin is pending and leaves a failed join's meaning to the caller", async () => { + const request = relay(() => ({ + "/": Response.json(buzz), + "/api/join-policy": new Response("missing", { status: 404 }), + })); + const pending = deferred(); + const onJoin = vi + .fn<(found: unknown) => Promise>() + .mockReturnValueOnce(pending.promise) + .mockRejectedValueOnce(new Error("denied")); + const onCancel = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await user.click(await screen.findByRole("button", { name: "Join" })); + expect(screen.getByRole("status")).toHaveTextContent("Joining…"); + expect(join()).toBeDisabled(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).not.toHaveBeenCalled(); + await act(async () => pending.resolve()); + expect(screen.queryByRole("status")).toBeNull(); + expect(join()).toBeEnabled(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled(); + // A rejected join only ends the wait: no copy of its own, no unhandled rejection. + await user.click(join()); + expect(join()).toBeEnabled(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(joinCommunity()).toBeVisible(); + expect(onJoin).toHaveBeenCalledTimes(2); +}); + +it("announces the lookup only after a second, and again a second after Retry", async () => { + vi.useFakeTimers(); + try { + let info = deferred(); + const request = vi.fn((_origin, path) => + path === "/" + ? info.promise + : Promise.resolve(new Response(null, { status: 404 })), + ); + render( + , + ); + const region = screen.getByText(host).closest("[aria-busy]"); + expect(region).toHaveAttribute("aria-busy", "true"); + expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(999); + }); + expect(screen.queryByRole("status")).toBeNull(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(screen.getByRole("status")).toHaveTextContent(`Looking up ${host}…`); + // Retry is a new initial read: the delay starts again. + const failed = info; + info = deferred(); + await act(async () => failed.resolve(new Response(null, { status: 503 }))); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(screen.getByText(host)).toBeVisible(); + expect(screen.queryByRole("status")).toBeNull(); + await act(async () => { + await vi.advanceTimersByTimeAsync(999); + }); + expect(screen.queryByRole("status")).toBeNull(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(screen.getByRole("status")).toHaveTextContent(`Looking up ${host}…`); + await act(async () => info.resolve(Response.json(buzz))); + expect(joinCommunity()).toBeVisible(); + expect(screen.queryByRole("status")).toBeNull(); + expect(document.querySelector("[aria-busy]")).toBeNull(); + } finally { + vi.useRealTimers(); + } +}); + +it("Cancel while finding leaves at once and discards the late answer", async () => { + const info = deferred(); + let signal: AbortSignal | undefined; + const request = vi.fn((_origin, path, current) => { + signal = current; + return path === "/" ? info.promise : Promise.resolve(Response.json({})); + }); + const onCancel = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(signal?.aborted).toBe(true); + await act(async () => info.resolve(Response.json(buzz))); + expect( + screen.getByRole("heading", { name: "Finding community" }), + ).toBeVisible(); + expect(screen.queryByRole("button", { name: /join/i })).toBeNull(); +}); + +it("an unreachable relay offers Retry, which looks again", async () => { + let answers: Record = { + "/": new TypeError("fetch failed"), + "/api/join-policy": new TypeError("fetch failed"), + }; + const request = relay(() => answers); + const user = userEvent.setup(); + render( + , + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not reach the relay. Check your connection and try again.", + ); + expect( + screen.getByRole("heading", { name: "Couldn’t find community" }), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled(); + expect( + precedes( + screen.getByRole("button", { name: "Cancel" }), + screen.getByRole("button", { name: "Retry" }), + ), + ).toBe(true); + answers = { + "/": Response.json(buzz), + "/api/join-policy": Response.json({}), + }; + await user.click(screen.getByRole("button", { name: "Retry" })); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + expect(request).toHaveBeenCalledTimes(4); +}); + +it.each([ + [ + "an HTML page", + new Response(""), + "This address isn’t a Buzz community.", + ], + [ + "a Buzz relay with no community here", + Response.json({ name: "Buzz Relay" }), + "No community is set up at this address.", + ], +])("%s offers only Cancel", async (_label, answer, message) => { + const request = relay(() => ({ + "/": answer, + "/api/join-policy": Response.json({}), + })); + const onCancel = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + expect(await screen.findByRole("alert")).toHaveTextContent(message); + expect(screen.queryByRole("button", { name: "Retry" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalledTimes(1); +}); diff --git a/src/features/communities/FindCommunity.tsx b/src/features/communities/FindCommunity.tsx new file mode 100644 index 0000000000..6f3e53546c --- /dev/null +++ b/src/features/communities/FindCommunity.tsx @@ -0,0 +1,254 @@ +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { CircleNotchIcon } from "../../shared/design-system/icons/index"; +import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Checkbox } from "../../shared/design-system/ui/Checkbox"; +import { LoadingRegion } from "../../shared/design-system/ui/LoadingRegion"; +import { + FindCommunityError, + findCommunity, + nativeRelayFetch, + type FindFailure, + type FoundCommunity, + type RelayFetch, +} from "./find-community"; + +type State = + | { status: "finding" } + | { status: "found"; found: FoundCommunity } + | { status: "failed"; failure: FindFailure }; + +/** What the user ticked on Join Community; an invite claim attests `adult`. */ +export type JoinConsent = Readonly<{ adult: boolean; agreed: boolean }>; + +/** Classified copy; raw transport errors are never shown. */ +const FAILURES: Record = { + unreachable: { + message: "Could not reach the relay. Check your connection and try again.", + retry: true, + }, + "not-buzz": { message: "This address isn’t a Buzz community.", retry: false }, + "no-community": { + message: "No community is set up at this address.", + retry: false, + }, +}; + +/** + * Looks up a community at a canonical secure origin before any identity or + * session exists. Owns nothing beyond the lookup: the caller either takes the + * found community or decides what Join does. While `onJoin` is pending the page + * holds still; what its failure means is the caller's to show. + */ +export function FindCommunity({ + url, + onCancel, + onFound, + onJoin, + request = nativeRelayFetch, + titleId, +}: { + url: string; + onCancel: () => void; + request?: RelayFetch; + titleId?: string; +} & ( + | { + /** Takes over a found community instead of showing Join Community; the lookup page stays until the caller moves on. */ + onFound: (found: FoundCommunity) => void; + onJoin?: never; + } + | { + onJoin: ( + found: FoundCommunity, + consent: JoinConsent, + ) => Promise | void; + onFound?: never; + } +)) { + const host = new URL(url).host; + const [state, setState] = useState({ status: "finding" }); + const [adult, setAdult] = useState(false); + const [agreed, setAgreed] = useState(false); + const [joining, setJoining] = useState(false); + const inFlight = useRef(null); + const settle = useEffectEvent((found: FoundCommunity) => { + if (onFound) onFound(found); + else setState({ status: "found", found }); + }); + useEffect(() => { + if (state.status !== "finding") return; + const controller = new AbortController(); + inFlight.current = controller; + findCommunity(url, request, controller.signal).then( + (found) => { + if (!controller.signal.aborted) settle(found); + }, + (error: unknown) => { + if (controller.signal.aborted) return; + setState({ + status: "failed", + failure: + error instanceof FindCommunityError ? error.failure : "unreachable", + }); + }, + ); + return () => controller.abort(); + }, [url, request, state.status]); + const cancel = () => { + // Nothing from this lookup may land after leaving, even if we stay mounted. + inFlight.current?.abort(); + onCancel(); + }; + const heading = (text: string) => ( +

+ {text} +

+ ); + if (state.status === "finding") + return ( + <> + {heading("Finding community")} + {/* An initial read: its status waits a second, the hostname and Cancel do not. */} + +
+

{host}

+
+ +
+
+
+ + ); + // Action rows follow the design system's footer order: secondary first, primary last. + if (state.status === "failed") { + const { message, retry } = FAILURES[state.failure]; + return ( + <> + {heading("Couldn’t find community")} +

+ {message} +

+
+ + {retry && ( + + )} +
+ + ); + } + const { found } = state; + const policy = found.policy; + const needsAge = policy?.age_attestation_required === true; + const needsAgreement = Boolean( + policy?.terms_markdown || policy?.privacy_markdown, + ); + const consented = (!needsAge || adult) && (!needsAgreement || agreed); + const join = async () => { + setJoining(true); + try { + await onJoin?.(found, { adult, agreed }); + } catch { + // The caller shows what a failed join means; here it only ends the wait. + } finally { + setJoining(false); + } + }; + return ( + <> + {heading("Join Community")} +
+ +

+ {found.host} +

+
+ {(needsAge || needsAgreement) && ( +
+ {needsAge && ( + + )} + {needsAgreement && ( + + I agree to this community’s{" "} + {policy?.terms_markdown && ( + + Terms of Service + + )} + {policy?.terms_markdown && policy.privacy_markdown && " and "} + {policy?.privacy_markdown && ( + + Privacy Notice + + )} + . + + } + /> + )} +
+ )} + {joining && ( +

+

+ )} +
+ + +
+ + ); +} + +/** Relay-hosted document pages open outside the app, as in `CommunityDialog`. */ +function PolicyLink({ + url, + document, + children, +}: { + url: string; + document: "terms" | "privacy"; + children: string; +}) { + return ( + + {children} + + ); +} diff --git a/src/features/communities/ProfileFields.tsx b/src/features/communities/ProfileFields.tsx index 2e5266557a..e4d0712be4 100644 --- a/src/features/communities/ProfileFields.tsx +++ b/src/features/communities/ProfileFields.tsx @@ -29,6 +29,7 @@ export function ProfileFields({ community, onBusyChange, showAvatar = true, + autoFocus = false, }: { profile: PersonalProfile; onChange(profile: PersonalProfile): void; @@ -36,6 +37,8 @@ export function ProfileFields({ community?: string | undefined; onBusyChange?(busy: boolean): void; showAvatar?: boolean; + /** Focuses the display name on mount, for pages whose only task is this form. */ + autoFocus?: boolean; }) { return (
@@ -52,6 +55,7 @@ export function ProfileFields({ ( id: string, diff --git a/src/features/communities/destination.test.ts b/src/features/communities/destination.test.ts index 68c53b281d..7fe0fa57cd 100644 --- a/src/features/communities/destination.test.ts +++ b/src/features/communities/destination.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "vitest"; import { communityDestination, + enteredRelayOrigin, relayAddress, relayOrigin, parseCommunityAliases, @@ -66,6 +67,38 @@ it.each([ expect(() => relayOrigin(input)).toThrow(/relay URL/); }); +// Typed-address cases from old Buzz's relayProbe.test.mjs, under this app's +// secure-origin rule: ws/http, paths and doubled slashes stay rejected. +it.each([ + ["relay.example.com", "https://relay.example.com"], + [" relay.example.com ", "https://relay.example.com"], + ["relay.example.com:8443", "https://relay.example.com:8443"], + ["localhost:3000", "https://localhost:3000"], + ["wss://relay.example.com", "https://relay.example.com"], + ["WSS://Relay.Example.com/", "https://relay.example.com"], + ["HTTPS://relay.example.com:8443", "https://relay.example.com:8443"], +])("normalizes the typed address %s", (input, expected) => { + expect(enteredRelayOrigin(input)).toBe(expected); +}); + +it.each([ + "", + " ", + "not a url at all", + "ftp://relay.example.com", + "ws://localhost:3000", + "http://relay.example.com", + "javascript:alert(1)", + "user:password@relay.example.com", + "relay.example.com/custom", + "relay.example.com?x=1", + "relay.example.com#x", + "wss://relay.example.com///", + "//relay.example.com", +])("rejects the typed address %j", (input) => { + expect(() => enteredRelayOrigin(input)).toThrow(/relay URL/); +}); + it("preserves old IDs and deduplicates their typed secure URLs", () => { expect(communityDestination("primary")).toEqual( communityDestination(" WSS://PRIMARY.EXAMPLE:443/ "), diff --git a/src/features/communities/destination.ts b/src/features/communities/destination.ts index f4a315868d..d05972659c 100644 --- a/src/features/communities/destination.ts +++ b/src/features/communities/destination.ts @@ -22,6 +22,15 @@ export function relayOrigin(input: string): string { return url.origin; } +/** + * A community address as a person types it: a bare host is assumed to be a + * secure relay. Saved IDs never take this path, so an alias stays a non-URL. + */ +export function enteredRelayOrigin(input: string): string { + const value = input.trim(); + return relayOrigin(value.includes("://") ? value : `wss://${value}`); +} + /** A short deployment ID is not a URL and must never acquire a session by itself. */ export function isCommunityAlias(value: string) { return ( diff --git a/src/features/communities/find-community.test.ts b/src/features/communities/find-community.test.ts new file mode 100644 index 0000000000..ddf5ca5ee4 --- /dev/null +++ b/src/features/communities/find-community.test.ts @@ -0,0 +1,225 @@ +import { invoke } from "@tauri-apps/api/core"; +import { expect, it, vi } from "vitest"; +import { + FindCommunityError, + findCommunity, + nativeRelayFetch, + type RelayFetch, +} from "./find-community"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +const url = "https://relay.example.com:8443"; +const self = "76aa9daa7edc9c2e1dc6a2a90dbce10c1bd52f060fa6506ba7bad4f50240fc01"; +const buzz = { + name: "Buzz Relay", + software: "https://github.com/block/buzz", + version: "1.4.2", + icon: "https://cdn.example.com/icon.png", + self, + read_state_snapshot: { community_id: "c1" }, +}; +const policy = { + version: "v1", + terms_markdown: "# Terms", + privacy_markdown: null, + age_attestation_required: true, +}; +type Answer = Response | Error; +const html = (status = 200) => + new Response("Hi", { status }); +function fetcher(answers: Record) { + return vi.fn((_origin, path) => { + const answer = answers[path]; + if (!answer) throw new Error(`Unexpected path ${path}`); + return answer instanceof Error + ? Promise.reject(answer) + : Promise.resolve(answer); + }); +} +const find = (answers: Record) => + findCommunity(url, fetcher(answers), new AbortController().signal); +const failure = (answers: Record) => + find(answers).then( + () => "found", + (error: unknown) => + error instanceof FindCommunityError ? error.failure : error, + ); + +it("reports only what the relay publishes, with the icon sanitized, from two unauthenticated reads", async () => { + const request = fetcher({ + "/": Response.json(buzz), + "/api/join-policy": Response.json({ policy }), + }); + await expect( + findCommunity(url, request, new AbortController().signal), + ).resolves.toEqual({ + url, + host: "relay.example.com:8443", + icon: "https://cdn.example.com/icon.png", + self, + policy: { + version: "v1", + terms_markdown: "# Terms", + privacy_markdown: undefined, + age_attestation_required: true, + }, + }); + // Nothing is signed before the user chooses to join: GETs only, no body. + expect(request.mock.calls).toEqual([ + [url, "/", expect.any(AbortSignal)], + [url, "/api/join-policy", expect.any(AbortSignal)], + ]); + await expect( + find({ + "/": Response.json({ + software: "https://github.com/block/buzz", + buzz_v1: {}, + icon: "javascript:alert(1)", + version: 3, + }), + "/api/join-policy": Response.json({}), + }), + ).resolves.toEqual({ + url, + host: "relay.example.com:8443", + icon: undefined, + self: undefined, + policy: null, + }); +}); + +it.each([self.toUpperCase(), self.slice(1), 42, null])( + "keeps only a well-formed relay key, dropping self %j", + async (value) => { + await expect( + find({ + "/": Response.json({ ...buzz, self: value }), + "/api/join-policy": html(404), + }), + ).resolves.toMatchObject({ self: undefined }); + }, +); + +it.each([ + ["a network failure", { "/": new TypeError("fetch failed") }, "unreachable"], + ["a relay 5xx", { "/": html(503) }, "unreachable"], + ["an HTML page", { "/": html() }, "not-buzz"], + ["a 404", { "/": html(404) }, "not-buzz"], + ["a JSON array", { "/": Response.json([]) }, "not-buzz"], + [ + "another relay implementation", + { "/": Response.json({ name: "strfry", software: "git+https://x" }) }, + "not-buzz", + ], + [ + "a Buzz relay with no community bound to this host", + { + "/": Response.json({ name: "Buzz Relay", version: "1.4.2" }), + "/api/join-policy": Response.json({}), + }, + "no-community", + ], +] as const)( + "classifies NIP-11 answered by %s", + async (_label, answers, failure_) => { + await expect( + failure({ "/api/join-policy": html(404), ...answers }), + ).resolves.toBe(failure_); + }, +); + +it.each([ + ["404", html(404), null], + ["{}", Response.json({}), null], + ["{policy:null}", Response.json({ policy: null }), null], + ["a network failure", new TypeError("fetch failed"), "unreachable"], + ["a 5xx", html(500), "unreachable"], + ["a redirect", new Response(null, { status: 302 }), "unreachable"], + ["HTML", html(), "unreachable"], + [ + "a malformed policy", + Response.json({ policy: { version: 1 } }), + "unreachable", + ], + ["a JSON array", Response.json([]), "unreachable"], +])( + "treats a join policy answered by %s as %j once NIP-11 is a Buzz relay", + async (_label, answer, expected) => { + const result = await failure({ + "/": Response.json(buzz), + "/api/join-policy": answer, + }).then((value) => (value === "found" ? null : value)); + expect(result).toBe(expected); + }, +); + +it("reads both documents at once and lets the NIP-11 verdict win", async () => { + const paths: string[] = []; + const release: Array<() => void> = []; + const request: RelayFetch = (_origin, path) => { + paths.push(path); + return new Promise((resolve) => + release.push(() => + resolve(path === "/" ? html() : Response.json({ policy })), + ), + ); + }; + const found = findCommunity(url, request, new AbortController().signal); + await vi.waitFor(() => expect(paths).toEqual(["/", "/api/join-policy"])); + for (const resolve of release) resolve(); + await expect(found).rejects.toMatchObject({ failure: "not-buzz" }); +}); + +it("passes the caller's abort through instead of calling it unreachable", async () => { + const controller = new AbortController(); + const request: RelayFetch = (_origin, _path, signal) => + new Promise((_, reject) => + signal.addEventListener("abort", () => reject(signal.reason)), + ); + const found = findCommunity(url, request, controller.signal); + controller.abort(new Error("left the page")); + await expect(found).rejects.toThrow("left the page"); +}); + +it("finds a relay that answers after 12 s, within the native limit", async () => { + vi.useFakeTimers(); + // Node's native AbortSignal timer is not driven by Vitest's clock. + const timeout = vi + .spyOn(AbortSignal, "timeout") + .mockImplementation((milliseconds) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), milliseconds); + return controller.signal; + }); + const answers: Record = { + "/": buzz, + "/api/join-policy": { policy }, + }; + let release!: () => void; + const answered = new Promise((resolve) => (release = resolve)); + vi.mocked(invoke).mockImplementation(async (command, args) => { + expect(command).toBe("relay_http"); + await answered; + return { + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify(answers[(args as { path: string }).path]), + }; + }); + let settled = false; + const found = findCommunity( + url, + nativeRelayFetch, + new AbortController().signal, + ).finally(() => (settled = true)); + try { + await vi.advanceTimersByTimeAsync(20_000); + expect(settled).toBe(false); + release(); + await expect(found).resolves.toMatchObject({ self, policy: {} }); + } finally { + timeout.mockRestore(); + vi.useRealTimers(); + } +}); diff --git a/src/features/communities/find-community.ts b/src/features/communities/find-community.ts new file mode 100644 index 0000000000..fbfe844921 --- /dev/null +++ b/src/features/communities/find-community.ts @@ -0,0 +1,103 @@ +import { nativeRelayRequest, relaySelf } from "../relay/native"; +import { communityIcon } from "./community-icon"; +import { joinPolicy, type JoinPolicy } from "./join-policy"; + +/** Only what a relay publishes before admission: no name, owner or member count. */ +export type FoundCommunity = { + /** Canonical secure origin. */ + url: string; + host: string; + icon?: string | undefined; + /** The relay deployment's key (NIP-11 `self`), when it is well-formed. */ + self?: string | undefined; + policy: JoinPolicy | null; +}; + +export type FindFailure = "unreachable" | "not-buzz" | "no-community"; + +export class FindCommunityError extends Error { + constructor(readonly failure: FindFailure) { + super(failure); + } +} + +export type RelayFetch = ( + origin: string, + path: string, + signal: AbortSignal, +) => Promise; + +/** Unauthenticated `relay_http` GETs: the reads the host allows before a key exists. */ +export const nativeRelayFetch: RelayFetch = (origin, path, signal) => + nativeRelayRequest(origin, path, undefined, signal); + +const BUZZ_SOFTWARE = "https://github.com/block/buzz"; + +/** + * NIP-11 and the join policy in parallel; the NIP-11 verdict is reported first. + * A policy 404 means "none" only once NIP-11 has confirmed a Buzz relay; any + * other non-answer is retryable rather than a silent waiver of consent. + */ +export async function findCommunity( + url: string, + request: RelayFetch, + signal: AbortSignal, +): Promise { + // The native request bounds the wait (10 s connect, 30 s total); IPC cannot + // end it sooner. + const get = (path: string) => + request(url, path, signal).catch((error: unknown) => { + if (signal.aborted) throw error; + throw new FindCommunityError("unreachable"); + }); + const discovery = get("/"); + const policy = get("/api/join-policy"); + policy.catch(() => {}); + const info = await relayInfo(await discovery); + return { + url, + host: new URL(url).host, + icon: communityIcon(info.icon), + self: relaySelf(info), + policy: await policyAnswer(await policy), + }; +} + +async function policyAnswer(response: Response) { + if (response.status === 404) return null; + try { + const body: unknown = response.ok ? await response.json() : undefined; + if (!body || typeof body !== "object" || Array.isArray(body)) + throw new Error(); + return joinPolicy(body as Record); + } catch { + throw new FindCommunityError("unreachable"); + } +} + +async function relayInfo(response: Response) { + if (response.status >= 500) throw new FindCommunityError("unreachable"); + const body: unknown = response.ok + ? await response.json().catch(() => undefined) + : undefined; + if (!body || typeof body !== "object" || Array.isArray(body)) + throw new FindCommunityError("not-buzz"); + const info = body as Record; + // buzz-relay nip11.rs hard-codes both; either identifies the software. + if ( + info.name !== "Buzz Relay" && + !( + typeof info.software === "string" && + info.software.startsWith(BUZZ_SOFTWARE) + ) + ) + throw new FindCommunityError("not-buzz"); + // Host-bound fields are present only when the relay maps this host to a community. + if ( + info.read_state_snapshot === undefined && + info.icon === undefined && + info.buzz_v1 === undefined + ) + throw new FindCommunityError("no-community"); + return info; +} diff --git a/src/features/communities/join-journal.ts b/src/features/communities/join-journal.ts index 0ff1a3d4ec..e3b82f4375 100644 --- a/src/features/communities/join-journal.ts +++ b/src/features/communities/join-journal.ts @@ -7,6 +7,9 @@ export type PendingJoin = { profile?: PersonalProfile; }; +/** The journal's own storage failures: this client's copy, safe to show as is. */ +export class JournalError extends Error {} + /** Public progress only: invite codes, policy receipts and keys are never journaled. */ export function createJoinJournal(viewer: string) { const key = `buzz-community-joins.v1:${viewer}`; @@ -36,7 +39,7 @@ export function createJoinJournal(viewer: string) { } return entries; } catch { - throw new Error( + throw new JournalError( "Could not read unfinished community setup. Restore device storage and try again.", ); } @@ -45,7 +48,7 @@ export function createJoinJournal(viewer: string) { try { localStorage.setItem(key, JSON.stringify(entries)); } catch { - throw new Error( + throw new JournalError( "Could not save community setup on this device. Free storage and try again.", ); } diff --git a/src/features/communities/join-policy.ts b/src/features/communities/join-policy.ts new file mode 100644 index 0000000000..6c358d7c33 --- /dev/null +++ b/src/features/communities/join-policy.ts @@ -0,0 +1,40 @@ +export type JoinPolicy = { + version: string; + terms_markdown?: string | undefined; + privacy_markdown?: string | undefined; + age_attestation_required: boolean; +}; + +const optionalText = (value: unknown): value is string | null | undefined => + value == null || typeof value === "string"; + +/** + * The relay's `/api/join-policy` body (buzz-relay api/invites.rs): `{ policy }`, + * or `{}` / `{ policy: null }` when the deployment has none. Anything else is + * an error, never "no policy": consent is not waived by a malformed answer. + */ +export function joinPolicy(body: Record): JoinPolicy | null { + const raw = body.policy; + if (raw === null || raw === undefined) return null; + if (typeof raw !== "object" || Array.isArray(raw)) + throw new Error("Invalid community join policy"); + const { + version, + age_attestation_required, + terms_markdown, + privacy_markdown, + } = raw as Record; + if ( + typeof version !== "string" || + typeof age_attestation_required !== "boolean" || + !optionalText(terms_markdown) || + !optionalText(privacy_markdown) + ) + throw new Error("Invalid community join policy"); + return { + version, + age_attestation_required, + terms_markdown: terms_markdown ?? undefined, + privacy_markdown: privacy_markdown ?? undefined, + }; +} diff --git a/src/features/communities/join.test.ts b/src/features/communities/join.test.ts new file mode 100644 index 0000000000..b41f053edc --- /dev/null +++ b/src/features/communities/join.test.ts @@ -0,0 +1,338 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { communityRequest, inspectProfile, publishProfile } from "./api"; +import { admitCommunity, CLAIM_REFUSALS, completeJoin } from "./join"; +import { createJoinJournal } from "./join-journal"; +import type { Communities, PersonalProfile } from "./service"; + +vi.mock("./api", () => ({ + communityRequest: vi.fn(), + inspectProfile: vi.fn(), + publishProfile: vi.fn(), +})); + +const viewer = "a".repeat(64); +const community = "https://relay.example.com"; +const key = `buzz-community-joins.v1:${viewer}`; +const policy = { version: "2026-10", age_attestation_required: true }; +const consent = { adult: true, agreed: true }; +const none = { + existing: {}, + profile: { name: "", picture: "", about: "" }, + exists: false, +}; +const held = (profile: PersonalProfile) => ({ + existing: { name: profile.name }, + profile, + exists: true, +}); +const denied = () => Promise.reject(new Error("Relay read failed (403)")); +const entries = () => JSON.parse(localStorage.getItem(key) ?? "[]"); +/** Every remote call, journal write and saved membership, in order. */ +let sequence: string[]; +function communities(profile = none.profile) { + return { + joined: vi.fn(() => { + sequence.push("joined"); + }), + snapshot: () => ({ profile }) as ReturnType, + }; +} +beforeEach(() => { + localStorage.clear(); + sequence = []; + vi.spyOn(Storage.prototype, "setItem").mockImplementation(function ( + this: Storage, + name, + value, + ) { + sequence.push("setItem"); + Reflect.set(this, name, value); + }); + vi.mocked(communityRequest).mockImplementation(async (_id, route) => { + sequence.push(route); + return route === "accept-policy" + ? { receipt: "policy-receipt" } + : { status: "joined" }; + }); + vi.mocked(inspectProfile).mockImplementation(async () => { + sequence.push("read"); + return none; + }); + vi.mocked(publishProfile).mockImplementation(async () => { + sequence.push("publish"); + }); +}); +afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); +}); + +it("journals the join before any remote effect, then accepts the policy, claims and reads", async () => { + const journal = createJoinJournal(viewer); + const admission = await admitCommunity({ + community, + journal, + code: " CODE ", + policy, + consent, + current: () => true, + }); + expect(sequence).toEqual(["setItem", "accept-policy", "claim", "read"]); + expect(communityRequest).toHaveBeenNthCalledWith( + 1, + community, + "accept-policy", + { code: "CODE", policy_version: "2026-10", age_confirmed: true }, + ); + expect(communityRequest).toHaveBeenNthCalledWith(2, community, "claim", { + code: "CODE", + policy_receipt: "policy-receipt", + }); + expect(admission).toEqual({ found: none, entry: entries()[0] }); + expect(entries()).toEqual([{ id: expect.any(String), community }]); +}); + +it("sends nothing when the journal record cannot be persisted", async () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota"); + }); + await expect( + admitCommunity({ + community, + journal: createJoinJournal(viewer), + code: "CODE", + policy, + consent, + current: () => true, + }), + ).rejects.toThrow( + "Could not save community setup on this device. Free storage and try again.", + ); + expect(communityRequest).not.toHaveBeenCalled(); + expect(inspectProfile).not.toHaveBeenCalled(); +}); + +it("reads before reclaiming a pending join and keeps its entry when the key is admitted", async () => { + const journal = createJoinJournal(viewer); + const pending = journal.begin(community); + sequence = []; + const admission = await admitCommunity({ + community, + journal, + code: "EXPIRED", + policy, + consent, + current: () => true, + }); + expect(sequence).toEqual(["read"]); + expect(admission).toEqual({ found: none, entry: pending }); + expect(entries()).toEqual([pending]); +}); + +it("claims again when the pending join's read is denied, replacing its entry", async () => { + const journal = createJoinJournal(viewer); + const pending = journal.begin(community); + vi.mocked(inspectProfile) + .mockImplementationOnce(() => { + sequence.push("read"); + return denied(); + }) + .mockImplementationOnce(async () => { + sequence.push("read"); + return held({ name: "Ada", picture: "", about: "" }); + }); + const admission = await admitCommunity({ + community, + journal, + code: "CODE", + policy: null, + consent, + current: () => true, + }); + expect(sequence).toEqual(["setItem", "read", "setItem", "claim", "read"]); + expect(communityRequest).toHaveBeenCalledTimes(1); + expect(communityRequest).toHaveBeenCalledWith(community, "claim", { + code: "CODE", + policy_receipt: undefined, + }); + expect(admission?.entry?.id).not.toBe(pending.id); + expect(entries()).toEqual([admission?.entry]); +}); + +it("maps a relay refusal to its copy and rethrows anything else", async () => { + const refuse = (message: string) => + vi.mocked(communityRequest).mockRejectedValueOnce(new Error(message)); + refuse("invite_expired"); + await expect( + admitCommunity({ community, code: "CODE", consent, current: () => true }), + ).rejects.toThrow(CLAIM_REFUSALS.invite_expired); + refuse("Community request failed (502)"); + await expect( + admitCommunity({ community, code: "CODE", consent, current: () => true }), + ).rejects.toThrow("Community request failed (502)"); + expect(inspectProfile).not.toHaveBeenCalled(); +}); + +it("rejects a claim that neither joined nor found an existing member", async () => { + vi.mocked(communityRequest).mockResolvedValueOnce({ status: "pending" }); + await expect( + admitCommunity({ community, code: "CODE", consent, current: () => true }), + ).rejects.toThrow("Membership was not confirmed"); + expect(inspectProfile).not.toHaveBeenCalled(); +}); + +it("skips the claim without a code and stops at a fence before reading", async () => { + const current = vi.fn(() => false); + await expect( + admitCommunity({ community, code: " ", consent, current }), + ).resolves.toBeUndefined(); + expect(communityRequest).not.toHaveBeenCalled(); + expect(inspectProfile).not.toHaveBeenCalled(); +}); + +it("saves the membership without publishing when the fresh read already matches", async () => { + const journal = createJoinJournal(viewer); + const profile = { name: "Ada", picture: "", about: "" }; + vi.mocked(inspectProfile).mockImplementation(async () => { + sequence.push("read"); + return held(profile); + }); + const service = communities(); + await expect( + completeJoin({ + community, + membership: { id: community, name: "relay.example.com" }, + found: none, + journal, + publish: { ...profile, name: " Ada " }, + communities: service, + current: () => true, + }), + ).resolves.toBe(true); + expect(sequence).toEqual(["setItem", "read", "joined", "setItem"]); + expect(publishProfile).not.toHaveBeenCalled(); + expect(service.joined).toHaveBeenCalledWith( + { id: community, name: "relay.example.com" }, + { name: "Ada", picture: "", about: "" }, + ); + expect(entries()).toEqual([]); +}); + +it("publishes a differing profile, confirms it by readback, then saves the membership last", async () => { + const journal = createJoinJournal(viewer); + const next = { name: "Ada", picture: "", about: "" }; + vi.mocked(inspectProfile) + .mockImplementationOnce(async () => { + sequence.push("read"); + return none; + }) + .mockImplementationOnce(async () => { + sequence.push("read"); + return held(next); + }); + const service = communities(); + await expect( + completeJoin({ + community, + membership: { id: community, name: "relay.example.com" }, + found: none, + journal, + publish: { name: " Ada ", picture: "", about: " " }, + communities: service, + current: () => true, + }), + ).resolves.toBe(true); + expect(sequence).toEqual([ + "setItem", + "read", + "publish", + "read", + "joined", + "setItem", + ]); + expect(publishProfile).toHaveBeenCalledWith(community, next, {}); + expect(service.joined).toHaveBeenCalledWith( + { id: community, name: "relay.example.com" }, + next, + ); + expect(entries()).toEqual([]); +}); + +it("retains the journaled draft and saves nothing when the readback is not current", async () => { + const journal = createJoinJournal(viewer); + const publish = { name: "Ada", picture: "", about: "" }; + vi.mocked(inspectProfile) + .mockResolvedValueOnce(none) + .mockResolvedValueOnce(held({ name: "Someone else", picture: "" })); + const service = communities(); + await expect( + completeJoin({ + community, + membership: { id: community, name: "relay.example.com" }, + found: none, + journal, + publish, + communities: service, + current: () => true, + }), + ).rejects.toThrow( + "Your profile change is not current. Your edits are retained; try again.", + ); + expect(publishProfile).toHaveBeenCalledTimes(1); + expect(service.joined).not.toHaveBeenCalled(); + expect(entries()).toEqual([ + { id: expect.any(String), community, profile: publish }, + ]); +}); + +it("neither saves the membership nor clears the journal once fenced", async () => { + const journal = createJoinJournal(viewer); + const service = communities(); + let fenced = false; + vi.mocked(publishProfile).mockImplementation(async () => { + fenced = true; + }); + await expect( + completeJoin({ + community, + membership: { id: community, name: "relay.example.com" }, + found: none, + journal, + publish: { name: "Ada", picture: "", about: "" }, + communities: service, + current: () => !fenced, + }), + ).resolves.toBe(false); + expect(inspectProfile).toHaveBeenCalledTimes(1); + expect(service.joined).not.toHaveBeenCalled(); + expect(entries()).toHaveLength(1); +}); + +it("saves the membership with the relay's profile and finishes the admission entry when nothing is published", async () => { + const journal = createJoinJournal(viewer); + const entry = journal.begin(community); + const relay = { name: "Ada", picture: `${community}/media/a.png` }; + const service = communities({ name: "", picture: "local.png", about: "" }); + sequence = []; + await expect( + completeJoin({ + community, + membership: { id: community, name: "relay.example.com" }, + found: held(relay), + journal, + entry, + communities: service, + current: () => true, + }), + ).resolves.toBe(true); + expect(sequence).toEqual(["joined", "setItem"]); + expect(inspectProfile).not.toHaveBeenCalled(); + expect(publishProfile).not.toHaveBeenCalled(); + // The same local-default rule as the dialog: this community's media stays out of it. + expect(service.joined).toHaveBeenCalledWith( + { id: community, name: "relay.example.com" }, + { name: "Ada", picture: "local.png" }, + ); + expect(entries()).toEqual([]); +}); diff --git a/src/features/communities/join.ts b/src/features/communities/join.ts new file mode 100644 index 0000000000..1f2427d884 --- /dev/null +++ b/src/features/communities/join.ts @@ -0,0 +1,170 @@ +import { communityRequest, inspectProfile, publishProfile } from "./api"; +import type { JoinConsent } from "./FindCommunity"; +import type { createJoinJournal, PendingJoin } from "./join-journal"; +import type { JoinPolicy } from "./join-policy"; +import { profileDefault } from "./profile-default"; +import { profilesEqual } from "./ProfileFields"; +import type { Communities, Membership, PersonalProfile } from "./service"; +import { readErrorKind } from "../relay/errors"; + +// Exact relay claim refusal codes, forwarded unchanged by the broker. +export const CLAIM_REFUSALS = { + invite_expired: "This invite code has expired — ask for a new one.", + invite_exhausted: + "This invite has reached its use limit. Ask for a new invite.", + invite_invalid: "This invite code is not valid for this community.", + join_policy_required: + "This community requires current policy acceptance. Go back and reopen the relay to review it.", + join_policy_not_accepted: + "The community policy changed. Go back and reopen the relay to review it.", +}; + +/** The copy for a relay claim refusal; `undefined` for any other failure. */ +export function claimRefusal(reason: unknown) { + return reason instanceof Error && + Object.hasOwn(CLAIM_REFUSALS, reason.message) + ? CLAIM_REFUSALS[reason.message as keyof typeof CLAIM_REFUSALS] + : undefined; +} + +export type JoinJournal = ReturnType; +/** Whether a caller still wants the outcome: mounted, and `entry` (when given) + * is still the journal's current record for its destination. */ +export type Current = (entry?: PendingJoin) => boolean; +export type Admission = { + /** The journal record to finish with the membership; absent without a journal. */ + entry?: PendingJoin; + found: Awaited>; +}; + +/** + * The Add-a-community dialog's access step: when the journal holds a pending + * entry, read before reclaiming (a lost claim reply may already have admitted + * this key, even once its invite expired); otherwise journal the join before + * any remote effect, so a storage failure blocks the claim, then accept the + * policy, claim the code (when given) and prove admission with the signed + * profile read. `undefined` means a `current` fence stopped the caller. + */ +export async function admitCommunity({ + community, + journal, + code, + policy, + consent, + current, +}: { + community: string; + journal?: JoinJournal | undefined; + code: string; + policy?: JoinPolicy | null | undefined; + consent: JoinConsent; + current: Current; +}): Promise { + const pending = journal?.get(community); + if (pending) { + // A lost claim response may already have admitted this identity, even + // when its invite has since expired. Read before attempting another claim. + const found = await inspectProfile(community).catch((reason: unknown) => { + if (readErrorKind(reason) !== "denied") throw reason; + return undefined; + }); + if (!current(pending)) return undefined; + if (found) return { found, entry: pending }; + } + const transaction = journal?.begin(community); + if (code.trim()) { + const receipt = policy + ? ( + await communityRequest<{ receipt: string }>( + community, + "accept-policy", + { + code: code.trim(), + policy_version: policy.version, + age_confirmed: consent.adult, + }, + ) + ).receipt + : undefined; + if (!current(transaction)) return undefined; + const claim = await communityRequest<{ status: string }>( + community, + "claim", + { code: code.trim(), policy_receipt: receipt }, + ).catch((reason: unknown) => { + const known = claimRefusal(reason); + throw known ? new Error(known) : reason; + }); + if (!["joined", "already_member"].includes(claim.status)) + throw new Error("Membership was not confirmed"); + } + if (!current(transaction)) return undefined; + const found = await inspectProfile(community); + if (!current(transaction)) return undefined; + return { found, ...(transaction ? { entry: transaction } : {}) }; +} + +/** + * The dialog's profile step: with `publish`, journal the draft, read the + * profile afresh (native only; the browser path keeps the admission's read), + * publish only when it differs, read it back to confirm the accepted event is + * current, then save the membership last and clear the journal. Without + * `publish` there is no remote write to confirm, so the membership is saved + * with the relay's profile (or the local default) and `entry` is finished. + * `false` means a `current` fence stopped the caller. + */ +export async function completeJoin({ + community, + membership, + found, + journal, + entry, + publish, + communities, + current, +}: { + community: string; + membership: Membership; + found: Admission["found"]; + journal?: JoinJournal | undefined; + entry?: PendingJoin | undefined; + publish?: PersonalProfile | undefined; + communities: Pick; + current: Current; +}): Promise { + let transaction = entry; + let next: PersonalProfile; + if (publish) { + transaction = journal?.begin(community, publish); + const read = journal ? await inspectProfile(community) : found; + if (!current(transaction)) return false; + next = + read.exists && profilesEqual(publish, read.profile) + ? publish + : { + ...publish, + name: publish.name.trim(), + about: publish.about?.trim() ?? "", + }; + if (!read.exists || !profilesEqual(next, read.profile)) { + await publishProfile(community, next, read.existing ?? {}); + if (!current(transaction)) return false; + // An accepted replaceable event may already be superseded. + const confirmed = await inspectProfile(community); + if (!current(transaction)) return false; + if (!confirmed.exists || !profilesEqual(confirmed.profile, next)) + throw new Error( + "Your profile change is not current. Your edits are retained; try again.", + ); + } + } else { + next = found.exists ? found.profile : communities.snapshot().profile; + } + if (!current(transaction)) return false; + communities.joined( + membership, + profileDefault(next, communities.snapshot().profile, community), + ); + if (transaction) journal?.finish(transaction); + return true; +} diff --git a/src/features/communities/native-api.ts b/src/features/communities/native-api.ts index c25de8ec04..6d0e07e3eb 100644 --- a/src/features/communities/native-api.ts +++ b/src/features/communities/native-api.ts @@ -7,6 +7,7 @@ import { nativeRelayRequest, nativeRelaySigner, } from "../relay/native"; +import { joinPolicy as readJoinPolicy } from "./join-policy"; import { leaveRefusal, leaveRequestTemplate } from "./leave-protocol"; import { adminReason, memberCommand } from "./admin-protocol"; import { readRelayLibrary } from "../agents/relay-library"; @@ -59,36 +60,13 @@ export async function nativeCommunityRequest( joinPolicy.catch(() => {}); const info = await discovery; const response = await joinPolicy; - const raw = - response.status === 404 ? null : (await readResponse(response)).policy; - if ( - raw !== null && - raw !== undefined && - (typeof raw !== "object" || Array.isArray(raw)) - ) - throw new Error("Invalid community join policy"); - const policy = raw as Record | null | undefined; - if ( - policy && - (typeof policy.version !== "string" || - typeof policy.age_attestation_required !== "boolean" || - (policy.terms_markdown != null && - typeof policy.terms_markdown !== "string") || - (policy.privacy_markdown != null && - typeof policy.privacy_markdown !== "string")) - ) - throw new Error("Invalid community join policy"); return { name: typeof info.name === "string" ? info.name : undefined, icon: typeof info.icon === "string" ? info.icon : undefined, - policy: policy - ? { - version: policy.version, - age_attestation_required: policy.age_attestation_required, - terms_markdown: policy.terms_markdown ?? undefined, - privacy_markdown: policy.privacy_markdown ?? undefined, - } - : null, + policy: + response.status === 404 + ? null + : readJoinPolicy(await readResponse(response)), }; } if (route === "session") { diff --git a/src/features/relay/native.ts b/src/features/relay/native.ts index 7ccba9bdf4..878aa23a78 100644 --- a/src/features/relay/native.ts +++ b/src/features/relay/native.ts @@ -324,15 +324,22 @@ export async function nativeRelayInfo(community: string, signal?: AbortSignal) { return info as Record; } +/** The relay's own key as NIP-11 `self` publishes it: 64 lowercase hex, else absent. */ +export function relaySelf(info: Record): string | undefined { + const self = info.self; + return typeof self === "string" && /^[a-f0-9]{64}$/.test(self) + ? self + : undefined; +} + export async function connectNativeTransport( community: string, signal?: AbortSignal, ): Promise { const origin = communityDestination(community).url; const info = await nativeRelayInfo(origin, signal); - const author = info.self; - if (typeof author !== "string" || !/^[a-f0-9]{64}$/.test(author)) - throw new Error("Relay did not advertise its identity"); + const author = relaySelf(info); + if (!author) throw new Error("Relay did not advertise its identity"); const creation = Array.isArray(info.supported_nips) && info.supported_nips.includes(29); // NIP-CL: the relay keeps channel labels and answers label lookups.