mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(web): authenticate gated community browsing (#2190)
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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()}`;
|
||||
@@ -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 &&
|
||||
@@ -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)}`}
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+28
-13
@@ -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);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* Minimal Nostr client with NIP-01 queries and NIP-42 AUTH.
|
||||
*
|
||||
* Generates an ephemeral keypair per session to authenticate with the relay.
|
||||
* This is sufficient for read-only public queries (e.g. repo listings).
|
||||
* Uses NIP-07 when a browser extension is available, with an ephemeral
|
||||
* page-lifetime identity as the fallback for read-only queries on open relays.
|
||||
*/
|
||||
|
||||
import { generateSecretKey, finalizeEvent } from "nostr-tools/pure";
|
||||
import { makeAuthEvent } from "nostr-tools/nip42";
|
||||
import {
|
||||
type SignedNostrEvent,
|
||||
signNostrEvent,
|
||||
} from "@/shared/lib/nostr-signer";
|
||||
|
||||
export interface NostrFilter {
|
||||
ids?: string[];
|
||||
@@ -18,27 +21,10 @@ export interface NostrFilter {
|
||||
[tag: `#${string}`]: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface NostrEvent {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
created_at: number;
|
||||
sig: string;
|
||||
}
|
||||
export type NostrEvent = SignedNostrEvent;
|
||||
|
||||
const QUERY_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Lazily-generated ephemeral keypair for NIP-42 AUTH. */
|
||||
let _secretKey: Uint8Array | null = null;
|
||||
export function getEphemeralKey(): Uint8Array {
|
||||
if (!_secretKey) {
|
||||
_secretKey = generateSecretKey();
|
||||
}
|
||||
return _secretKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a WebSocket to `wsUrl`, authenticate via NIP-42 if challenged,
|
||||
* send a REQ with the given filter, collect EVENTs until EOSE, then
|
||||
@@ -53,6 +39,8 @@ export function queryEvents(
|
||||
const subId = `q-${Date.now().toString(36)}`;
|
||||
let settled = false;
|
||||
let reqSent = false;
|
||||
let authEventId: string | null = null;
|
||||
let unauthenticatedReqTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
@@ -66,6 +54,9 @@ export function queryEvents(
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
if (unauthenticatedReqTimer) {
|
||||
clearTimeout(unauthenticatedReqTimer);
|
||||
}
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
@@ -83,10 +74,10 @@ export function queryEvents(
|
||||
ws.addEventListener("open", () => {
|
||||
// Wait briefly for an AUTH challenge before sending REQ.
|
||||
// Buzz relays always send AUTH, but other relays may not.
|
||||
setTimeout(() => sendReq(), 100);
|
||||
unauthenticatedReqTimer = setTimeout(() => sendReq(), 100);
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (msg) => {
|
||||
ws.addEventListener("message", async (msg) => {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(String(msg.data));
|
||||
@@ -99,19 +90,45 @@ export function queryEvents(
|
||||
|
||||
if (type === "AUTH" && typeof data[1] === "string") {
|
||||
// NIP-42: relay sent an AUTH challenge — sign and respond.
|
||||
if (unauthenticatedReqTimer) {
|
||||
clearTimeout(unauthenticatedReqTimer);
|
||||
unauthenticatedReqTimer = null;
|
||||
}
|
||||
const challenge = data[1];
|
||||
const sk = getEphemeralKey();
|
||||
const template = makeAuthEvent(wsUrl, challenge);
|
||||
const signed = finalizeEvent(template, sk);
|
||||
ws.send(JSON.stringify(["AUTH", signed]));
|
||||
// After AUTH, send our REQ.
|
||||
sendReq();
|
||||
try {
|
||||
const signed = await signNostrEvent(template);
|
||||
if (settled) return;
|
||||
authEventId = signed.id;
|
||||
ws.send(JSON.stringify(["AUTH", signed]));
|
||||
} catch (error) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Failed to sign relay authentication."),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "OK") {
|
||||
// AUTH response accepted (or rejected). If we haven't sent REQ yet, do so now.
|
||||
sendReq();
|
||||
if (type === "OK" && data[1] === authEventId) {
|
||||
if (data[2] === true) {
|
||||
sendReq();
|
||||
} else if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
typeof data[3] === "string"
|
||||
? data[3]
|
||||
: "Relay authentication failed.",
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
finalizeEvent,
|
||||
generateSecretKey,
|
||||
getPublicKey,
|
||||
} from "nostr-tools/pure";
|
||||
|
||||
export type UnsignedNostrEvent = {
|
||||
kind: number;
|
||||
created_at: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type SignedNostrEvent = UnsignedNostrEvent & {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
sig: string;
|
||||
};
|
||||
|
||||
type Nip07Provider = {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: UnsignedNostrEvent): Promise<SignedNostrEvent>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: Nip07Provider;
|
||||
}
|
||||
}
|
||||
|
||||
export class Nip07UnavailableError extends Error {
|
||||
constructor() {
|
||||
super("A NIP-07 browser extension is required to join in the browser.");
|
||||
this.name = "Nip07UnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
let ephemeralSecretKey: Uint8Array | null = null;
|
||||
|
||||
function getEphemeralSecretKey(): Uint8Array {
|
||||
if (!ephemeralSecretKey) {
|
||||
ephemeralSecretKey = generateSecretKey();
|
||||
}
|
||||
return ephemeralSecretKey;
|
||||
}
|
||||
|
||||
export function hasNip07Provider(): boolean {
|
||||
return typeof window !== "undefined" && window.nostr != null;
|
||||
}
|
||||
|
||||
function sameUnsignedEvent(
|
||||
expected: UnsignedNostrEvent,
|
||||
actual: SignedNostrEvent,
|
||||
): boolean {
|
||||
return (
|
||||
actual.kind === expected.kind &&
|
||||
actual.created_at === expected.created_at &&
|
||||
actual.content === expected.content &&
|
||||
JSON.stringify(actual.tags) === JSON.stringify(expected.tags)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign with NIP-07 when available, otherwise use a page-lifetime key.
|
||||
*
|
||||
* The ephemeral fallback preserves anonymous browsing on open relays. Flows
|
||||
* that create durable membership must set `requireNip07` so a reload cannot
|
||||
* orphan a relay-membership row.
|
||||
*/
|
||||
export async function signNostrEvent(
|
||||
template: Omit<UnsignedNostrEvent, "created_at"> & {
|
||||
created_at?: number;
|
||||
},
|
||||
options?: { requireNip07?: boolean },
|
||||
): Promise<SignedNostrEvent> {
|
||||
const unsigned: UnsignedNostrEvent = {
|
||||
...template,
|
||||
created_at: template.created_at ?? Math.floor(Date.now() / 1000),
|
||||
};
|
||||
const provider = typeof window === "undefined" ? undefined : window.nostr;
|
||||
|
||||
if (provider) {
|
||||
const expectedPubkey = await provider.getPublicKey();
|
||||
const signed = await provider.signEvent(unsigned);
|
||||
if (
|
||||
signed.pubkey !== expectedPubkey ||
|
||||
!sameUnsignedEvent(unsigned, signed) ||
|
||||
typeof signed.id !== "string" ||
|
||||
typeof signed.sig !== "string"
|
||||
) {
|
||||
throw new Error("The NIP-07 extension returned an invalid signed event.");
|
||||
}
|
||||
return signed;
|
||||
}
|
||||
|
||||
if (options?.requireNip07) {
|
||||
throw new Nip07UnavailableError();
|
||||
}
|
||||
|
||||
const secretKey = getEphemeralSecretKey();
|
||||
const signed = finalizeEvent(unsigned, secretKey);
|
||||
if (signed.pubkey !== getPublicKey(secretKey)) {
|
||||
throw new Error("Failed to create the ephemeral browser identity.");
|
||||
}
|
||||
return signed;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("home page loads with Buzz heading", async ({ page }) => {
|
||||
test("home page loads with Buzz branding", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("header")).toContainText("Buzz");
|
||||
await expect(
|
||||
page.getByRole("main").getByRole("img", { name: "Buzz" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("home page shows repositories section", async ({ page }) => {
|
||||
@@ -117,6 +120,87 @@ test("invite requires age and legal consent before opening Buzz", async ({
|
||||
expect(consentBox?.width).toBe(acceptButtonBox?.width);
|
||||
});
|
||||
|
||||
test("invite can enroll a NIP-07 identity for browser access", async ({
|
||||
page,
|
||||
}) => {
|
||||
const pubkey = "ab".repeat(32);
|
||||
await page.addInitScript((extensionPubkey) => {
|
||||
(
|
||||
window as Window & {
|
||||
nostr?: {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(
|
||||
event: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>>;
|
||||
};
|
||||
}
|
||||
).nostr = {
|
||||
async getPublicKey() {
|
||||
return extensionPubkey;
|
||||
},
|
||||
async signEvent(event) {
|
||||
return {
|
||||
...event,
|
||||
id: "cd".repeat(32),
|
||||
pubkey: extensionPubkey,
|
||||
sig: "ef".repeat(64),
|
||||
};
|
||||
},
|
||||
};
|
||||
}, pubkey);
|
||||
await page.route("**/api/join-policy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ policy: null }),
|
||||
});
|
||||
});
|
||||
|
||||
let claimObserved = false;
|
||||
await page.route("**/api/invites/claim", async (route) => {
|
||||
claimObserved = true;
|
||||
const request = route.request();
|
||||
const body = request.postData() ?? "";
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
code: "browser-code",
|
||||
});
|
||||
|
||||
const authorization = request.headers().authorization;
|
||||
expect(authorization).toMatch(/^Nostr /);
|
||||
const event = JSON.parse(
|
||||
Buffer.from(authorization.slice("Nostr ".length), "base64").toString(
|
||||
"utf8",
|
||||
),
|
||||
) as {
|
||||
pubkey: string;
|
||||
tags: string[][];
|
||||
};
|
||||
expect(event.pubkey).toBe(pubkey);
|
||||
expect(event.tags).toContainEqual(["u", request.url()]);
|
||||
expect(event.tags).toContainEqual(["method", "POST"]);
|
||||
expect(event.tags).toContainEqual([
|
||||
"payload",
|
||||
createHash("sha256").update(body).digest("hex"),
|
||||
]);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "joined",
|
||||
community_id: "community-id",
|
||||
host: "127.0.0.1",
|
||||
role: "member",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/invite/browser-code");
|
||||
await page.getByRole("button", { name: "Join in browser" }).click();
|
||||
await expect(page).toHaveURL("/");
|
||||
expect(claimObserved).toBe(true);
|
||||
});
|
||||
|
||||
test("invite asks Safari users to choose their Mac download", async ({
|
||||
browser,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user