import { ClientResponseError } from "@atcute/client"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { mintServiceAuth } from "$lib/auth/agent"; import type { Permission } from "$lib/auth/scopes"; import type { NotificationOffer } from "$lib/components/notifications/types"; import { mainSchema as acceptCollaborationSchema, type $input as CollaborationInput } from "$lib/api/lexicons/types/sh/tangled/repo/acceptCollaboration"; import type * as CollaboratorAcceptance from "$lib/api/lexicons/types/sh/tangled/repo/collaboratorAcceptance"; import { toResponseError } from "$lib/api/_request"; import { putRecord } from "$lib/api/write"; const ACCEPTANCES: { collaboration: { collection: CollaboratorAcceptance.Main["$type"]; procedure: typeof acceptCollaborationSchema.nsid; }; organization: { collection: "sh.tangled.org.memberAcceptance"; procedure: "sh.tangled.org.acceptMembership"; }; } = { collaboration: { collection: "sh.tangled.repo.collaboratorAcceptance", procedure: acceptCollaborationSchema.nsid }, organization: { collection: "sh.tangled.org.memberAcceptance", procedure: "sh.tangled.org.acceptMembership" } }; export const permissionsFor = (offer: NotificationOffer): readonly Permission[] => { const { collection, procedure } = ACCEPTANCES[offer.kind]; return [ { resource: "repo", collection, actions: ["create", "update"] }, { resource: "rpc", lxm: procedure, aud: offer.subject } ]; }; export type AcceptStage = "record" | "token" | "call"; const MINT_DEADLINE_MS = 15_000; const CALL_DEADLINE_MS = 30_000; const SENTENCES: Record< AcceptStage, { silent: (host: string) => string; answered: (host: string, description: string) => string } > = { record: { silent: () => "Your account wouldn't store this acceptance. Nothing was granted.", answered: (_host, description) => `Your account stored nothing, so nothing was granted: ${description}` }, token: { silent: (host) => `Your acceptance is stored, but signing the call to ${host} failed.`, answered: (host, description) => `Your acceptance is stored, but your account wouldn't sign the call to ${host}: ${description}` }, call: { silent: (host) => `${host} didn't answer. Your acceptance is stored, so try again.`, answered: (_host, description) => description } }; export class OfferRefused extends Error { constructor( readonly stage: AcceptStage, cause: unknown ) { super(`accepting failed at ${stage}`, { cause }); } get description(): string | null { return this.cause instanceof ClientResponseError ? (this.cause.description ?? this.cause.error) : null; } sentence(knot: URL): string { const { silent, answered } = SENTENCES[this.stage]; const host = knot.host; return this.description === null ? silent(host) : answered(host, this.description); } } const refusing = async (stage: AcceptStage, act: () => Promise): Promise => { try { return await act(); } catch (cause) { throw new OfferRefused(stage, cause); } }; export const acceptOffer = async ( agent: OAuthUserAgent, offer: NotificationOffer, fetch: typeof globalThis.fetch = globalThis.fetch ): Promise => { const { collection, procedure } = ACCEPTANCES[offer.kind]; const { uri } = await refusing("record", () => putRecord(agent, collection, offer.subject, { $type: collection, createdAt: new Date().toISOString() }) ); const token = await refusing("token", () => mintServiceAuth(agent, { aud: offer.subject, lxm: procedure, signal: AbortSignal.timeout(MINT_DEADLINE_MS) }) ); const body: CollaborationInput = { acceptance: uri }; const response = await refusing("call", () => fetch(new URL(`/xrpc/${procedure}`, offer.knot), { method: "POST", headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(CALL_DEADLINE_MS) }) ); if (!response.ok) throw new OfferRefused("call", await toResponseError(response)); };