From 3e2a39b1d1bd62e50330efd36d86c6caa300bf10 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 18 Sep 2026 15:54:47 +0900 Subject: [PATCH] web: process invite codes Signed-off-by: Seongmin Lee --- web/src/lib/api/client.ts | 4 +- web/src/lib/api/invite.test.ts | 82 ++++++++++++++++++++ web/src/lib/api/invite.ts | 28 +++++++ web/src/lib/oauth-client-metadata.ts | 1 + web/src/routes/invite/+page.server.ts | 6 ++ web/src/routes/invite/+page.svelte | 105 ++++++++++++++++++++++++++ 6 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/api/invite.test.ts create mode 100644 web/src/lib/api/invite.ts create mode 100644 web/src/routes/invite/+page.server.ts create mode 100644 web/src/routes/invite/+page.svelte diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 66c89cf8c..8d62cb634 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -33,8 +33,8 @@ const nsidOf = (input: RequestInfo | URL): string | null => { }; // pocket keys stored preferences by the token's `aud` -const PREFERENCES_AUD = "did:web:tangled.org"; -const PREFERENCES_LXMS: ReadonlySet = new Set([ +export const PREFERENCES_AUD = "did:web:tangled.org"; +export const PREFERENCES_LXMS: ReadonlySet = new Set([ "com.bad-example.pocket.getPreferences", "com.bad-example.pocket.putPreferences" ]); diff --git a/web/src/lib/api/invite.test.ts b/web/src/lib/api/invite.test.ts new file mode 100644 index 000000000..7357f0ba8 --- /dev/null +++ b/web/src/lib/api/invite.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { ClientResponseError } from "@atcute/client"; +import oauthMetadata from "$lib/oauth-client-metadata"; +import { missingPermissions } from "$lib/auth/scopes"; +import type { Did, Nsid } from "@atcute/lexicons/syntax"; +import type * as Invite from "$lib/api/invite"; + +const agent = { sub: "did:plc:limpet" } as unknown as OAuthUserAgent; +const KNOT = "knot.oyster.cafe"; +const CODE = "8f14e45fceea167a5a36dedd4bea2543"; +const NSID = "org.tangled.temp.server.redeemInviteCode"; + +const load = async (respond: () => Response) => { + vi.resetModules(); + const minted: { aud: string; lxm: string }[] = []; + vi.doMock("$lib/auth/agent", () => ({ + mintServiceAuth: async (_agent: unknown, { aud, lxm }: { aud: string; lxm: string }) => { + minted.push({ aud, lxm }); + return `token-for-${aud}`; + }, + serviceDidForHost: (host: string) => `did:web:${host.replace(/:/g, "%3A")}` + })); + const invite = (await import("$lib/api/invite")) as typeof Invite; + const calls: { url: string; init: RequestInit }[] = []; + const fetch = (async (input: URL | RequestInfo, init?: RequestInit) => { + calls.push({ url: String(input), init: init ?? {} }); + return respond(); + }) as typeof globalThis.fetch; + return { invite, minted, calls, fetch }; +}; + +const ok = () => new Response(JSON.stringify({}), { status: 200 }); + +describe("redeemInviteCode presents the code and the identity together", () => { + it("posts the code to the knot with a token minted for that knot and method", async () => { + const { invite, minted, calls, fetch } = await load(ok); + + await invite.redeemInviteCode(agent, KNOT, CODE, fetch); + + expect(minted).toEqual([{ aud: `did:web:${KNOT}`, lxm: NSID }]); + expect(calls[0].url).toBe(`https://${KNOT}/xrpc/${NSID}`); + expect(calls[0].init.method).toBe("POST"); + expect(JSON.parse(String(calls[0].init.body))).toEqual({ code: CODE }); + expect(new Headers(calls[0].init.headers).get("authorization")).toBe( + `Bearer token-for-did:web:${KNOT}` + ); + }); + + it("keeps a port on the knot host and drops any path", async () => { + const { invite, calls, minted, fetch } = await load(ok); + + await invite.redeemInviteCode(agent, `${KNOT}:8443`, CODE, fetch); + + expect(calls[0].url).toBe(`https://${KNOT}:8443/xrpc/${NSID}`); + expect(minted[0].aud).toBe(`did:web:${KNOT}%3A8443`); + }); + + it("a spent code surfaces the knot's own error", async () => { + const { invite, fetch } = await load( + () => + new Response( + JSON.stringify({ error: "InviteNotFound", message: "already spent" }), + { status: 400, headers: { "content-type": "application/json" } } + ) + ); + + const thrown = await invite.redeemInviteCode(agent, KNOT, CODE, fetch).catch((e) => e); + expect(thrown).toBeInstanceOf(ClientResponseError); + expect((thrown as ClientResponseError).error).toBe("InviteNotFound"); + }); +}); + +describe("the client metadata asks for what the invite flow spends", () => { + it("grants the redeem method against any knot", () => { + const wanted = [ + { resource: "rpc", lxm: NSID as Nsid, aud: `did:web:${KNOT}` as Did } + ] as const; + + expect(missingPermissions(oauthMetadata.scope, wanted)).toEqual([]); + }); +}); diff --git a/web/src/lib/api/invite.ts b/web/src/lib/api/invite.ts new file mode 100644 index 000000000..f61f1b03b --- /dev/null +++ b/web/src/lib/api/invite.ts @@ -0,0 +1,28 @@ +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { mintServiceAuth, serviceDidForHost } from "$lib/auth/agent"; +import { mainSchema as redeemSchema } from "$lib/api/lexicons/types/org/tangled/temp/server/redeemInviteCode"; +import { serviceUrlFor, toResponseError } from "$lib/api/_request"; + +export const REDEEM_NSID = redeemSchema.nsid; + +export const redeemInviteCode = async ( + agent: OAuthUserAgent, + knot: string, + code: string, + fetch: typeof globalThis.fetch = globalThis.fetch +): Promise => { + const token = await mintServiceAuth(agent, { + aud: serviceDidForHost(knot), + lxm: REDEEM_NSID + }); + const response = await fetch(new URL(`/xrpc/${REDEEM_NSID}`, `${serviceUrlFor(knot)}/`), { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ code }) + }); + if (!response.ok) throw await toResponseError(response); +}; diff --git a/web/src/lib/oauth-client-metadata.ts b/web/src/lib/oauth-client-metadata.ts index 7cfa23d31..abd79f994 100644 --- a/web/src/lib/oauth-client-metadata.ts +++ b/web/src/lib/oauth-client-metadata.ts @@ -37,6 +37,7 @@ const scopes = [ "rpc:org.tangled.temp.account.listEmails?aud=*", "rpc:org.tangled.temp.account.setPrimaryEmail?aud=*", "rpc:org.tangled.temp.account.verifyEmail?aud=*", + "rpc:org.tangled.temp.server.redeemInviteCode?aud=*", "rpc:com.bad-example.pocket.getPreferences?aud=*", "rpc:com.bad-example.pocket.putPreferences?aud=*", "rpc:org.tangled.temp.notification.getPreferences?aud=*", diff --git a/web/src/routes/invite/+page.server.ts b/web/src/routes/invite/+page.server.ts new file mode 100644 index 000000000..a68ccc6c9 --- /dev/null +++ b/web/src/routes/invite/+page.server.ts @@ -0,0 +1,6 @@ +import { requireAuth } from "$lib/auth/guards"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = (event) => { + requireAuth(event); +}; diff --git a/web/src/routes/invite/+page.svelte b/web/src/routes/invite/+page.svelte new file mode 100644 index 000000000..72b8e8105 --- /dev/null +++ b/web/src/routes/invite/+page.svelte @@ -0,0 +1,105 @@ + + + + +
+
+ {#if !usable} +
+
-- 2.51.2