import { ok } from "@atcute/client"; import type { Nsid } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { spindleXrpc, type SpindleTarget } from "#lib/api/spindlewrite.ts"; import { serviceDidForHost } from "#lib/auth/agent.ts"; import type { Permission } from "#lib/auth/scopes.ts"; import { mainSchema as createWebhookSchema } from "#lib/api/lexicons/types/org/tangled/webhook/createWebhook.ts"; import type { Delivery as RawDelivery, Webhook } from "#lib/api/lexicons/types/org/tangled/webhook/defs.ts"; import { mainSchema as deleteWebhookSchema } from "#lib/api/lexicons/types/org/tangled/webhook/deleteWebhook.ts"; import { mainSchema as listDeliveriesSchema } from "#lib/api/lexicons/types/org/tangled/webhook/listWebhookDeliveries.ts"; import { mainSchema as listWebhooksSchema } from "#lib/api/lexicons/types/org/tangled/webhook/listWebhooks.ts"; import { mainSchema as retryDeliverySchema } from "#lib/api/lexicons/types/org/tangled/webhook/retryWebhookDelivery.ts"; import { mainSchema as updateWebhookSchema } from "#lib/api/lexicons/types/org/tangled/webhook/updateWebhook.ts"; import { didOf } from "#lib/api/syntax.ts"; export type { Webhook }; // request and response bodies arrive as lexicon bytes; the UI shows them as text. export interface Delivery extends Omit { requestBody?: string; responseBody?: string; } export const WEBHOOK_EVENTS = [ { id: "push", label: "Push events" }, { id: "repository:renamed", label: "Repository renamed" }, { id: "pull_request:created", label: "Pull request opened" }, { id: "pull_request:resubmitted", label: "Pull request resubmitted" }, { id: "pull_request:merged", label: "Pull request merged" }, { id: "pull_request:closed", label: "Pull request closed" }, { id: "pull_request:reopened", label: "Pull request reopened" } ] as const; export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]["id"]; export type WebhookEventSelection = Record; export type WebhookActivation = "active" | "inactive"; export const webhookActivation = (active: boolean): WebhookActivation => active ? "active" : "inactive"; export const webhookIsActive = (activation: WebhookActivation): boolean => activation === "active"; const WEBHOOK_METHODS = [ createWebhookSchema.nsid, deleteWebhookSchema.nsid, listDeliveriesSchema.nsid, listWebhooksSchema.nsid, retryDeliverySchema.nsid, updateWebhookSchema.nsid ] as const; export const webhookPermissions = (spindle: URL): readonly Permission[] => { const aud = didOf(serviceDidForHost(spindle.host)); return WEBHOOK_METHODS.map((lxm) => ({ resource: "rpc", lxm: lxm as Nsid, aud })); }; export const webhookEventSelection = ( events: readonly string[] = ["push"] ): WebhookEventSelection => Object.fromEntries( WEBHOOK_EVENTS.map(({ id }) => [id, events.includes(id)]) ) as WebhookEventSelection; export const selectedWebhookEvents = (selection: WebhookEventSelection): WebhookEvent[] => WEBHOOK_EVENTS.flatMap(({ id }) => (selection[id] ? [id] : [])); export const isWebhookUrl = (value: string): boolean => { try { return ["http:", "https:"].includes(new URL(value.trim()).protocol); } catch { return false; } }; type GenericUri = `${string}:${string}`; const url = (value: string): GenericUri => { const normalized = value.trim(); if (!isWebhookUrl(normalized)) throw new Error("Webhook URL must use HTTP or HTTPS."); return normalized as GenericUri; }; export const listWebhooks = async ( agent: OAuthUserAgent, spindle: URL, target: SpindleTarget ): Promise => { const { webhooks } = await ok( (await spindleXrpc(agent, spindle, target)).call(listWebhooksSchema, { params: { repo: target.repoDid } }) ); return webhooks ?? []; }; export interface CreateWebhook { url: string; events: WebhookEvent[]; secret?: string; active?: boolean; } export const createWebhook = async ( agent: OAuthUserAgent, spindle: URL, target: SpindleTarget, webhook: CreateWebhook ): Promise => { const { id } = await ok( (await spindleXrpc(agent, spindle, target)).call(createWebhookSchema, { input: { ...webhook, repo: target.repoDid, url: url(webhook.url) } }) ); return id; }; export interface UpdateWebhook { url?: string; events?: WebhookEvent[]; secret?: string; active?: boolean; } export const updateWebhook = async ( agent: OAuthUserAgent, spindle: URL, target: SpindleTarget, id: string, webhook: UpdateWebhook ): Promise => { const { url: webhookUrl, ...changes } = webhook; await ok( (await spindleXrpc(agent, spindle, target)).call(updateWebhookSchema, { input: { ...changes, repo: target.repoDid, webhook: id, ...(webhookUrl === undefined ? {} : { url: url(webhookUrl) }) } }) ); }; export const deleteWebhook = async ( agent: OAuthUserAgent, spindle: URL, target: SpindleTarget, id: string ): Promise => { await ok( (await spindleXrpc(agent, spindle, target)).call(deleteWebhookSchema, { input: { repo: target.repoDid, webhook: id } }) ); }; export const listWebhookDeliveries = async ( agent: OAuthUserAgent, spindle: URL, target: SpindleTarget, ids: string[], limit = 100 ): Promise => { const { deliveries } = await ok( (await spindleXrpc(agent, spindle, target)).call(listDeliveriesSchema, { params: { repo: target.repoDid, webhooks: ids, limit } }) ); return (deliveries ?? []).map(({ requestBody, responseBody, ...delivery }) => ({ ...delivery, requestBody: requestBody && text(requestBody), responseBody: responseBody && text(responseBody) })); }; const text = ({ $bytes }: { $bytes: string }): string => new TextDecoder().decode(Uint8Array.from(atob($bytes), (c) => c.charCodeAt(0))); export const retryWebhookDelivery = async ( agent: OAuthUserAgent, spindle: URL, target: SpindleTarget, webhookId: string, deliveryId: string ): Promise => { await ok( (await spindleXrpc(agent, spindle, target)).call(retryDeliverySchema, { input: { repo: target.repoDid, webhook: webhookId, delivery: deliveryId } }) ); };