Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions web/src/features/invite/invite-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { makeNip98AuthHeader } from "@/shared/lib/nip98";
import { relayHttpBaseUrl } from "@/shared/lib/relay-url";

const INVITE_REQUEST_TIMEOUT_MS = 15_000;

export type BrowserInviteClaim = {
status: "joined" | "already_member";
communityId: string;
host: string;
role: string;
};

export async function claimInviteInBrowser(
code: string,
policyReceipt?: string,
): Promise<BrowserInviteClaim> {
const url = `${relayHttpBaseUrl().replace(/\/+$/, "")}/api/invites/claim`;
const body = JSON.stringify({
code,
policy_receipt: policyReceipt,
});
const authorization = await makeNip98AuthHeader(url, "POST", {
body,
requireNip07: true,
});
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 {
status: json.status as BrowserInviteClaim["status"],
communityId: String(json.community_id),
host: String(json.host),
role: String(json.role),
};
}
82 changes: 65 additions & 17 deletions web/src/features/invite/ui/InvitePage.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import buzzAppIcon from "@/assets/app-icon@3x.png";
import { claimInviteInBrowser } from "@/features/invite/invite-api";
import {
BUZZ_RELEASES_URL,
type BuzzDownloadPlatform,
detectBuzzDownloadPlatform,
resolveBuzzDownloadUrlForPlatform,
} from "@/shared/lib/buzz-download";
import { hasNip07Provider } from "@/shared/lib/nostr-signer";
import { relayWsUrl } from "@/shared/lib/relay-url";
import { Button } from "@/shared/ui/button";
import * as React from "react";
Expand Down Expand Up @@ -33,6 +35,10 @@ export function InvitePage({ code }: { code: string }) {
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
const [opening, setOpening] = React.useState(false);
const [joiningBrowser, setJoiningBrowser] = React.useState(false);
const [browserJoinError, setBrowserJoinError] = React.useState<string | null>(
null,
);
const [downloadUrl, setDownloadUrl] = React.useState(BUZZ_RELEASES_URL);
const [needsMacChoice, setNeedsMacChoice] = React.useState(false);
const [showMacChoice, setShowMacChoice] = React.useState(false);
Expand Down Expand Up @@ -69,23 +75,25 @@ export function InvitePage({ code }: { code: string }) {
.catch(() => setPolicy(undefined));
}, []);

const acceptPolicy = async (): Promise<string | undefined> => {
if (!policy) return undefined;
const response = await fetch("/api/invites/accept-policy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
policy_version: policy.version,
age_confirmed: ageConfirmed,
}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return ((await response.json()) as { receipt: string }).receipt;
};

const openInvite = async () => {
setOpening(true);
try {
let receipt: string | undefined;
if (policy) {
const response = await fetch("/api/invites/accept-policy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
policy_version: policy.version,
age_confirmed: ageConfirmed,
}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
receipt = ((await response.json()) as { receipt: string }).receipt;
}
const receipt = await acceptPolicy();
const query = new URLSearchParams({ relay, code });
if (receipt) query.set("policy_receipt", receipt);
window.location.href = `buzz://join?${query.toString()}`;
Expand All @@ -94,9 +102,27 @@ export function InvitePage({ code }: { code: string }) {
}
};

const joinInBrowser = async () => {
setBrowserJoinError(null);
setJoiningBrowser(true);
try {
const receipt = await acceptPolicy();
await claimInviteInBrowser(code, receipt);
window.location.assign("/");
} catch (error) {
setBrowserJoinError(
error instanceof Error ? error.message : "Could not claim this invite.",
);
} finally {
setJoiningBrowser(false);
}
};

const browserSigningAvailable = hasNip07Provider();
const disabled =
policy === undefined ||
opening ||
joiningBrowser ||
Boolean(policy?.age_attestation_required && !ageConfirmed) ||
Boolean(
policy &&
Expand Down Expand Up @@ -185,11 +211,24 @@ export function InvitePage({ code }: { code: string }) {
</div>
</div>

<div className="mt-9 w-full max-w-md">
<div className="mt-9 w-full max-w-md space-y-2">
{browserSigningAvailable ? (
<Button
className="h-10 w-full bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
disabled={disabled}
onClick={joinInBrowser}
>
{joiningBrowser ? "Joining…" : "Join in browser"}
</Button>
) : null}
{policy === null ? (
<Button
asChild
className="h-10 w-full bg-black text-white hover:bg-black/90 focus-visible:ring-black"
className={`h-10 w-full ${
browserSigningAvailable
? "border border-black bg-white text-black hover:bg-black/5"
: "bg-black text-white hover:bg-black/90 focus-visible:ring-black"
}`}
>
<a
href={`buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`}
Expand All @@ -199,13 +238,22 @@ export function InvitePage({ code }: { code: string }) {
</Button>
) : (
<Button
className="h-10 w-full bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
className={`h-10 w-full disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70 ${
browserSigningAvailable
? "border border-black bg-white text-black hover:bg-black/5"
: "bg-black text-white hover:bg-black/90 focus-visible:ring-black"
}`}
disabled={disabled}
onClick={openInvite}
>
Accept invite in Buzz
</Button>
)}
{browserJoinError ? (
<p className="text-sm text-red-700" role="alert">
{browserJoinError}
</p>
) : null}
</div>
</div>
<p className="flex h-[3.125rem] items-center justify-center rounded-2xl bg-white text-sm text-black/60">
Expand Down
12 changes: 9 additions & 3 deletions web/src/features/repos/git-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,15 @@ function repoAuthUrl(owner: string, repoName: string): string {
return `${relayHttpBaseUrl()}/git/${owner}/${repoName}.git`;
}

function authHeaders(owner: string, repoName: string): Record<string, string> {
async function authHeaders(
owner: string,
repoName: string,
): Promise<Record<string, string>> {
return {
Authorization: makeNip98AuthHeader(repoAuthUrl(owner, repoName), "GET"),
Authorization: await makeNip98AuthHeader(
repoAuthUrl(owner, repoName),
"GET",
),
};
}

Expand All @@ -67,7 +73,7 @@ export async function ensureClone(
const fs = getFs(owner, repoName);
const dir = getDir(owner, repoName);
const url = repoGitUrl(owner, repoName);
const headers = authHeaders(owner, repoName);
const headers = await authHeaders(owner, repoName);

let exists = false;
try {
Expand Down
41 changes: 28 additions & 13 deletions web/src/shared/lib/nip98.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,43 @@
* HTTP requests to the relay (used by isomorphic-git for smart HTTP transport).
*/

import { finalizeEvent } from "nostr-tools/pure";
import { getEphemeralKey } from "./nostr-client";
import { signNostrEvent } from "./nostr-signer";

async function sha256Hex(value: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(value),
);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}

/**
* Build a NIP-98 Authorization header value.
*
* Creates a kind:27235 event with `u` and `method` tags, signs it with the
* session's ephemeral key, base64-encodes the JSON, and returns
* `"Nostr <base64>"`.
* Signed POST bodies include the payload digest required by invite endpoints.
*/
export function makeNip98AuthHeader(url: string, method: string): string {
const event = finalizeEvent(
export async function makeNip98AuthHeader(
url: string,
method: string,
options?: { body?: string; requireNip07?: boolean },
): Promise<string> {
const tags = [
["u", url],
["method", method],
];
if (options?.body !== undefined) {
tags.push(["payload", await sha256Hex(options.body)]);
tags.push(["nonce", crypto.randomUUID()]);
}
const event = await signNostrEvent(
{
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", url],
["method", method],
],
tags,
content: "",
},
getEphemeralKey(),
{ requireNip07: options?.requireNip07 },
);

const json = JSON.stringify(event);
Expand Down
Loading
Loading