From de5161bcae95b3cc3d25ca38a5d8d78935ba146a Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Wed, 9 Sep 2026 15:04:22 +0100 Subject: [PATCH] web/api: gate-backed delegate provisioning provisionDelegate mints the pair of service-auth tokens the gate requires and maps its error codes to messages the creation form can show. listDelegatedAccounts lets remote controllers enumerate the accounts they own directly against Tranquil. tranquil-gate: let tranquil enforce the delegate cap Tranquil gained MAX_DELEGATED_ACCOUNTS_PER_CONTROLLER, which it checks against the authenticated controller before minting a PLC DID. That makes the gate's own quota redundant, and the gate's version was the weaker of the two: it counted only what the caller's listing token could see, issued the upstream listing request before validating that token's issuer, and needed a process-wide mutex to be race-safe. Set the limit to 2 on the dev env and drop the gate-side check, the listServiceAuth token the client minted to feed it, and the creation lock. The web client now maps Tranquil's InvalidDelegation to the over-limit message. Listing stays in the client for the repository owner picker. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: oppiliappan --- web/src/lib/api/delegation.test.ts | 159 +++++++++++++++++++++++++++ web/src/lib/api/delegation.ts | 94 ++++++++++++++++ web/src/lib/oauth-client-metadata.ts | 3 + web/src/lib/server/config.ts | 10 ++ 4 files changed, 266 insertions(+) create mode 100644 web/src/lib/api/delegation.test.ts create mode 100644 web/src/lib/api/delegation.ts diff --git a/web/src/lib/api/delegation.test.ts b/web/src/lib/api/delegation.test.ts new file mode 100644 index 000000000..29614e0d5 --- /dev/null +++ b/web/src/lib/api/delegation.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { + provisionDelegate, + CREATE_DELEGATE_NSID, + GATE_CREATE_DELEGATE_NSID, + LIST_DELEGATES_NSID +} from "$lib/api/delegation"; +import metadata from "$lib/oauth-client-metadata"; + +const makeAgent = () => { + const handle = vi.fn<(path: string, init: RequestInit) => Promise>(async () => + Response.json({ token: "service-jwt" }) + ); + return { handle, agent: { sub: "did:plc:alice", handle } as unknown as OAuthUserAgent }; +}; +const account = { did: "did:plc:org", handle: "org.example", controllerDid: "did:plc:alice" }; + +describe("delegate provisioning", () => { + it("mints one method-bound service auth on the user's PDS for the create call", async () => { + const { agent, handle } = makeAgent(); + const fetch = vi.fn(async () => Response.json(account)); + await expect( + provisionDelegate(agent, "org.example", { + serviceUrl: "https://gate.example/", + serviceDid: "did:web:tranquil.example", + fetch + }) + ).resolves.toEqual(account); + const params = new URL(String(handle.mock.calls[0][0]), "https://pds.example").searchParams; + expect(params.get("aud")).toBe("did:web:tranquil.example"); + expect(params.get("lxm")).toBe(CREATE_DELEGATE_NSID); + // Tranquil enforces the delegate cap itself, so no listing token is minted. + expect(handle).toHaveBeenCalledTimes(1); + expect(String(fetch.mock.calls[0][0])).toBe( + `https://gate.example/xrpc/${GATE_CREATE_DELEGATE_NSID}` + ); + expect(fetch.mock.calls[0][1]).toMatchObject({ + method: "POST", + headers: { authorization: "Bearer service-jwt" }, + body: JSON.stringify({ handle: "org.example" }) + }); + }); + it("allows the advertised DID to differ from a forwarded URL port", async () => { + const { agent, handle } = makeAgent(); + await provisionDelegate(agent, "org.example", { + serviceUrl: "https://gate.example:8443", + serviceDid: "did:web:tranquil.example", + fetch: vi.fn(async () => Response.json(account)) + }); + expect( + new URL(String(handle.mock.calls[0][0]), "https://pds.example").searchParams.get("aud") + ).toBe("did:web:tranquil.example"); + }); + it.each([ + ["VerifiedEmailRequired", /Verify an email/], + ["InvalidDelegation", /reached the limit/], + ["UpstreamUnavailable", /temporarily unavailable/] + ])("explains %s", async (error, message) => { + const { agent } = makeAgent(); + await expect( + provisionDelegate(agent, "org.example", { + serviceUrl: "https://gate.example", + serviceDid: "did:web:tranquil.example", + fetch: vi.fn(async () => Response.json({ error }, { status: 403 })) + }) + ).rejects.toThrow(message); + }); + it("shows Tranquil's own message for errors it does not recognize", async () => { + const { agent } = makeAgent(); + await expect( + provisionDelegate(agent, "org.example", { + serviceUrl: "https://gate.example", + serviceDid: "did:web:tranquil.example", + fetch: vi.fn(async () => + Response.json( + { error: "HandleNotAvailable", message: "Handle already taken" }, + { status: 400 } + ) + ) + }) + ).rejects.toThrow(/Handle already taken/); + }); + it("does not request a token when the sidecar is not configured", async () => { + const { agent, handle } = makeAgent(); + await expect(provisionDelegate(agent, "org.example", { serviceUrl: "" })).rejects.toThrow( + /not configured/ + ); + expect(handle).not.toHaveBeenCalled(); + }); + it("rejects an unexpected controller", async () => { + const { agent } = makeAgent(); + await expect( + provisionDelegate(agent, "org.example", { + serviceUrl: "https://gate.example", + serviceDid: "did:web:tranquil.example", + fetch: vi.fn(async () => + Response.json({ ...account, controllerDid: "did:plc:other" }) + ) + }) + ).rejects.toThrow(/unexpected owner/); + }); + it("requests the permission needed to mint delegation service auth", () => { + expect(metadata.scope.split(" ")).toContain(`rpc:${CREATE_DELEGATE_NSID}?aud=*`); + expect(metadata.scope.split(" ")).toContain(`rpc:${LIST_DELEGATES_NSID}?aud=*`); + }); +}); + +it("asks older sessions to reauthorize before any account is created", async () => { + const handle = vi.fn(async () => + Response.json({ error: "InsufficientScope", message: "missing scope" }, { status: 403 }) + ); + const fetch = vi.fn(); + await expect( + provisionDelegate( + { handle, sub: "did:plc:alice" } as unknown as OAuthUserAgent, + "org.example", + { serviceUrl: "https://gate.example", serviceDid: "did:web:tranquil.example", fetch } + ) + ).rejects.toThrow(/Sign in again/); + expect(fetch).not.toHaveBeenCalled(); +}); + +describe("controlled organization accounts", () => { + it("lists on Tranquil with method-bound auth from the controller's PDS", async () => { + const { listDelegatedAccounts } = await import("./delegation"); + const { agent, handle } = makeAgent(); + const accounts = [ + { + did: "did:plc:org", + handle: "org.example", + grantedScopes: "atproto repo:*", + grantedAt: "2026-09-09T00:00:00Z" + } + ]; + const fetch = vi.fn(async () => Response.json({ accounts })); + await expect( + listDelegatedAccounts(agent, "did:web:tranquil.example", fetch) + ).resolves.toEqual(accounts); + const params = new URL(String(handle.mock.calls[0][0]), "https://pds.example").searchParams; + expect(params.get("aud")).toBe("did:web:tranquil.example"); + expect(params.get("lxm")).toBe(LIST_DELEGATES_NSID); + expect(String(fetch.mock.calls[0][0])).toBe( + "https://tranquil.example/xrpc/_delegation.listControlledAccounts" + ); + expect(fetch.mock.calls[0][1]).toEqual({ + headers: { authorization: "Bearer service-jwt" } + }); + }); + it("reports a failed listing instead of treating it as no organizations", async () => { + const { listDelegatedAccounts } = await import("./delegation"); + const { agent } = makeAgent(); + await expect( + listDelegatedAccounts(agent, "did:web:tranquil.example", async () => + Response.json({ error: "InvalidToken" }, { status: 401 }) + ) + ).rejects.toThrow(); + }); +}); diff --git a/web/src/lib/api/delegation.ts b/web/src/lib/api/delegation.ts new file mode 100644 index 000000000..9dfcc9809 --- /dev/null +++ b/web/src/lib/api/delegation.ts @@ -0,0 +1,94 @@ +import { ClientResponseError } from "@atcute/client"; +import * as v from "@atcute/lexicons/validations"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { hostForServiceDid, mintServiceAuth } from "$lib/auth/agent"; +import { buildUrl, toResponseError } from "$lib/api/_request"; +import { + CREATE_DELEGATE_NSID, + LIST_DELEGATES_ENDPOINT, + LIST_DELEGATES_NSID, + listDelegatesSchema +} from "$lib/api/tranquil"; + +export { CREATE_DELEGATE_NSID, LIST_DELEGATES_NSID }; + +// The gate mints its own response, distinct from Tranquil's createAccount output. +const createdDelegateSchema = v.object({ + controllerDid: v.didString(), + did: v.didString(), + handle: v.handleString() +}); + +export type CreatedDelegate = v.InferInput; + +export const GATE_CREATE_DELEGATE_NSID = "sh.tangled.delegation.createAccount"; + +export interface DelegationService { + serviceUrl: string; + serviceDid?: string; + fetch?: typeof globalThis.fetch; +} + +export const provisionDelegate = async ( + agent: OAuthUserAgent, + handle: string, + service: DelegationService +): Promise => { + if (!service.serviceUrl || !service.serviceDid) + throw new Error("Organization creation is not configured."); + let token: string; + try { + token = await mintServiceAuth(agent, { + aud: service.serviceDid, + lxm: CREATE_DELEGATE_NSID + }); + } catch (cause) { + if (cause instanceof ClientResponseError && cause.status === 403) { + throw new Error("Sign in again to grant Tangled permission to create organizations.", { + cause + }); + } + throw cause; + } + const response = await (service.fetch ?? globalThis.fetch)( + buildUrl(service.serviceUrl, GATE_CREATE_DELEGATE_NSID), + { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ handle }) + } + ); + if (!response.ok) { + const error = await toResponseError(response); + const messages: Record = { + VerifiedEmailRequired: + "Verify an email address in Settings → Emails before creating an organization.", + // Tranquil enforces the per-controller delegate cap and reports it as + // InvalidDelegation; the gate relays Tranquil's error code and message. + InvalidDelegation: "You have reached the limit on organizations you can create.", + UpstreamUnavailable: + "Organization creation is temporarily unavailable. Please try again later." + }; + throw new Error(messages[error.error] ?? error.message); + } + const account = v.parse(createdDelegateSchema, await response.json()); + if (account.controllerDid !== agent.sub) + throw new Error("The organization was assigned to an unexpected owner."); + return account; +}; + +// Remote controllers mint this token on their own PDS and list on Tranquil. +export const listDelegatedAccounts = async ( + agent: OAuthUserAgent, + serviceDid: string, + fetch: typeof globalThis.fetch = globalThis.fetch +) => { + const host = hostForServiceDid(serviceDid); + if (!host) throw new Error("Organization accounts are not configured."); + const token = await mintServiceAuth(agent, { aud: serviceDid, lxm: LIST_DELEGATES_NSID }); + const response = await fetch(buildUrl(`https://${host}`, LIST_DELEGATES_ENDPOINT), { + headers: { authorization: `Bearer ${token}` } + }); + if (!response.ok) throw await toResponseError(response); + return v.parse(listDelegatesSchema, await response.json()).accounts; +}; diff --git a/web/src/lib/oauth-client-metadata.ts b/web/src/lib/oauth-client-metadata.ts index abd79f994..27b3be501 100644 --- a/web/src/lib/oauth-client-metadata.ts +++ b/web/src/lib/oauth-client-metadata.ts @@ -61,6 +61,9 @@ const scopes = [ "rpc:sh.tangled.actor.getTrending?aud=*", "rpc:sh.tangled.ci.cancelPipeline?aud=*", "rpc:sh.tangled.ci.triggerPipeline?aud=*", + "rpc:farm.tranquil.delegation.authorize?aud=*", + "rpc:farm.tranquil.delegation.createAccount?aud=*", + "rpc:farm.tranquil.delegation.listControlledAccounts?aud=*", "rpc:sh.tangled.feed.getTimeline?aud=*", "rpc:sh.tangled.git.keepCommit?aud=*", "rpc:sh.tangled.git.mergeCommit?aud=*", diff --git a/web/src/lib/server/config.ts b/web/src/lib/server/config.ts index ded88b2e7..7b3658ae1 100644 --- a/web/src/lib/server/config.ts +++ b/web/src/lib/server/config.ts @@ -18,6 +18,8 @@ export type WebConfig = { knotMirrorUrl: string; apiUrl: string; deliberiUrl: string; + delegationUrl: string; + delegationDid: string; /** the domain user sites are served under, e.g. "tngl.io" */ sitesDomain: string; camoUrl: string; @@ -40,6 +42,8 @@ export type PublicWebConfig = Pick< | "knotMirrorUrl" | "apiUrl" | "deliberiUrl" + | "delegationUrl" + | "delegationDid" | "sitesDomain" | "moderationServiceDid" | "primaryKnotHostname" @@ -58,6 +62,8 @@ type WebConfigEnv = { TANGLED_API_URL?: string; API_URL?: string; DELIBERI_URL?: string; + DELEGATION_URL?: string; + DELEGATION_DID?: string; KNOT_RESOLVER_URL?: string; SITES_DOMAIN?: string; CAMO_URL?: string; @@ -79,6 +85,8 @@ export const resolveConfig = (values: WebConfigEnv): WebConfig => ({ knotMirrorUrl: cleanUrl(values.KNOTMIRROR_URL, ""), apiUrl: cleanUrl(values.TANGLED_API_URL ?? values.API_URL, "http://127.0.0.1:8080"), deliberiUrl: cleanUrl(values.DELIBERI_URL, "https://notifs-dev.tangled.network"), + delegationUrl: cleanUrl(values.DELEGATION_URL, ""), + delegationDid: values.DELEGATION_DID?.trim() ?? "", sitesDomain: values.SITES_DOMAIN?.trim() || "tngl.io", camoUrl: cleanUrl(values.CAMO_URL, "https://camo.tangled.sh"), avatarUrl: cleanUrl(values.AVATAR_URL, "https://avatar.tangled.sh"), @@ -103,6 +111,8 @@ export const getPublicConfig = (): PublicWebConfig => { knotMirrorUrl: config.knotMirrorUrl, apiUrl: config.apiUrl, deliberiUrl: config.deliberiUrl, + delegationUrl: config.delegationUrl, + delegationDid: config.delegationDid, sitesDomain: config.sitesDomain, moderationServiceDid: config.moderationServiceDid, primaryKnotHostname: config.primaryKnotHostname, -- 2.51.2