diff --git a/localinfra/Caddyfile b/localinfra/Caddyfile index 95a54eed8..0dd5aa781 100644 --- a/localinfra/Caddyfile +++ b/localinfra/Caddyfile @@ -63,12 +63,12 @@ knot2.tngl.boltless.dev { # spindle http://spindle.tngl.boltless.dev { - reverse_proxy spindle:6555 + import cors spindle:6555 } spindle.tngl.boltless.dev { tls internal - reverse_proxy spindle:6555 + import cors spindle:6555 } # knotmirror diff --git a/web/src/lib/api/webhooks.test.ts b/web/src/lib/api/webhooks.test.ts new file mode 100644 index 000000000..04c9028b0 --- /dev/null +++ b/web/src/lib/api/webhooks.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Did } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import oauthMetadata from "$lib/oauth-client-metadata"; +import { missingPermissions } from "$lib/auth/scopes"; + +vi.mock("$lib/auth/agent", () => ({ + createClient: () => ({}), + mintServiceAuth: async () => "token", + serviceDidForHost: (host: string) => `did:web:${host}`, + hostForServiceDid: () => null +})); + +const { + createWebhook, + deleteWebhook, + isWebhookUrl, + listWebhookDeliveries, + listWebhooks, + retryWebhookDelivery, + selectedWebhookEvents, + toggleWebhook, + updateWebhook, + webhookActivation, + webhookEventSelection, + webhookIsActive, + webhookPermissions +} = await import("$lib/api/webhooks"); + +const agent = { sub: "did:plc:owner" } as unknown as OAuthUserAgent; +const spindle = new URL("https://spindle.test"); +const repoDid: Did = "did:plc:repo"; + +const respond = (...bodies: unknown[]) => { + let request = 0; + const fetch = vi.fn(async () => { + const body = bodies[request++] ?? {}; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" } + }); + }); + vi.stubGlobal("fetch", fetch); + return fetch; +}; + +const requestUrl = (fetch: ReturnType, index = 0) => + new URL(String(fetch.mock.calls[index][0])); +const requestBody = (fetch: ReturnType, index = 0) => + JSON.parse(String(fetch.mock.calls[index][1]?.body)); + +afterEach(() => vi.unstubAllGlobals()); + +describe("webhook settings", () => { + it("lists hooks and deliveries from the repository spindle", async () => { + const hook = { + id: 7, + url: "https://hooks.test/tangled", + events: ["push"], + active: true, + createdAt: "2026-09-20T00:00:00Z" + }; + const delivery = { + id: 11, + webhookId: 7, + deliveryId: "attempt", + event: "push", + url: hook.url, + success: true, + createdAt: hook.createdAt + }; + const fetch = respond({ webhooks: [hook] }, { deliveries: [delivery] }); + + await expect(listWebhooks(agent, spindle, repoDid)).resolves.toEqual([hook]); + await expect(listWebhookDeliveries(agent, spindle, repoDid, [7, 8], 25)).resolves.toEqual([ + delivery + ]); + + const listed = requestUrl(fetch); + expect(listed.pathname).toBe("/xrpc/org.tangled.temp.webhook.listWebhooks"); + expect(listed.searchParams.get("repoDid")).toBe(repoDid); + const deliveries = requestUrl(fetch, 1); + expect(deliveries.pathname).toBe("/xrpc/org.tangled.temp.webhook.getDeliveriesForWebhooks"); + expect(deliveries.searchParams.getAll("ids")).toEqual(["7", "8"]); + expect(deliveries.searchParams.get("limit")).toBe("25"); + }); + + it("creates, updates, toggles, retries, and deletes through spindle xrpc", async () => { + const fetch = respond({ id: 7 }, {}, { active: false }, {}, {}); + + await expect( + createWebhook(agent, spindle, repoDid, { + url: "https://hooks.test/tangled", + events: ["push"], + secret: "shared", + active: true + }) + ).resolves.toBe(7); + await updateWebhook(agent, spindle, repoDid, 7, { + url: "https://hooks.test/renamed", + events: ["repository:renamed"], + secret: "" + }); + await expect(toggleWebhook(agent, spindle, repoDid, 7)).resolves.toBe(false); + await retryWebhookDelivery(agent, spindle, repoDid, 7, "attempt"); + await deleteWebhook(agent, spindle, repoDid, 7); + + expect(fetch.mock.calls.map(([input]) => new URL(String(input)).pathname)).toEqual([ + "/xrpc/org.tangled.temp.webhook.createWebhook", + "/xrpc/org.tangled.temp.webhook.updateWebhook", + "/xrpc/org.tangled.temp.webhook.toggleWebhook", + "/xrpc/org.tangled.temp.webhook.retryDelivery", + "/xrpc/org.tangled.temp.webhook.deleteWebhook" + ]); + expect(requestBody(fetch)).toEqual({ + repoDid, + url: "https://hooks.test/tangled", + events: ["push"], + secret: "shared", + active: true + }); + expect(requestBody(fetch, 1)).toEqual({ + repoDid, + id: 7, + url: "https://hooks.test/renamed", + events: ["repository:renamed"], + secret: "" + }); + expect(requestBody(fetch, 3)).toEqual({ repoDid, webhookId: 7, deliveryId: "attempt" }); + }); + + it("maps event selections, permissions, and accepts only HTTP webhook URLs", () => { + const selection = webhookEventSelection(["push", "pull_request:merged", "unknown"]); + expect(selectedWebhookEvents(selection)).toEqual(["push", "pull_request:merged"]); + expect(webhookActivation(true)).toBe("active"); + expect(webhookActivation(false)).toBe("inactive"); + expect(webhookIsActive("active")).toBe(true); + expect(webhookIsActive("inactive")).toBe(false); + expect(isWebhookUrl(" https://hooks.test/tangled ")).toBe(true); + expect(isWebhookUrl("http://hooks.test/tangled")).toBe(true); + expect(isWebhookUrl("ftp://hooks.test/tangled")).toBe(false); + expect(isWebhookUrl("not a url")).toBe(false); + const permissions = webhookPermissions(spindle); + expect(permissions).toHaveLength(7); + expect(missingPermissions("atproto", permissions)).toEqual(permissions); + expect(missingPermissions(oauthMetadata.scope, permissions)).toEqual([]); + expect(permissions).toContainEqual({ + resource: "rpc", + lxm: "org.tangled.temp.webhook.listWebhooks", + aud: "did:web:spindle.test" + }); + }); +}); diff --git a/web/src/lib/api/webhooks.ts b/web/src/lib/api/webhooks.ts new file mode 100644 index 000000000..d62fc39a6 --- /dev/null +++ b/web/src/lib/api/webhooks.ts @@ -0,0 +1,198 @@ +import { ok } from "@atcute/client"; +import type { Did, Nsid } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { serviceClient } from "$lib/api/_request"; +import { serviceDidForHost } from "$lib/auth/agent"; +import type { Permission } from "$lib/auth/scopes"; +import { mainSchema as createWebhookSchema } from "$lib/api/lexicons/types/org/tangled/temp/webhook/createWebhook"; +import { mainSchema as deleteWebhookSchema } from "$lib/api/lexicons/types/org/tangled/temp/webhook/deleteWebhook"; +import { + mainSchema as getDeliveriesSchema, + type Delivery +} from "$lib/api/lexicons/types/org/tangled/temp/webhook/getDeliveriesForWebhooks"; +import { + mainSchema as listWebhooksSchema, + type Webhook +} from "$lib/api/lexicons/types/org/tangled/temp/webhook/listWebhooks"; +import { mainSchema as retryDeliverySchema } from "$lib/api/lexicons/types/org/tangled/temp/webhook/retryDelivery"; +import { mainSchema as toggleWebhookSchema } from "$lib/api/lexicons/types/org/tangled/temp/webhook/toggleWebhook"; +import { mainSchema as updateWebhookSchema } from "$lib/api/lexicons/types/org/tangled/temp/webhook/updateWebhook"; +import { didOf } from "$lib/api/syntax"; + +export type { Delivery, Webhook }; + +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, + getDeliveriesSchema.nsid, + listWebhooksSchema.nsid, + retryDeliverySchema.nsid, + toggleWebhookSchema.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 client = (agent: OAuthUserAgent, spindle: URL) => + serviceClient(agent, spindle.toString()).xrpc; +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, + repoDid: Did +): Promise => { + const { webhooks } = await ok( + client(agent, spindle).call(listWebhooksSchema, { params: { repoDid } }) + ); + return webhooks ?? []; +}; + +export interface CreateWebhook { + url: string; + events: WebhookEvent[]; + secret?: string; + active?: boolean; +} + +export const createWebhook = async ( + agent: OAuthUserAgent, + spindle: URL, + repoDid: Did, + webhook: CreateWebhook +): Promise => { + const { id } = await ok( + client(agent, spindle).call(createWebhookSchema, { + input: { ...webhook, 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, + repoDid: Did, + id: number, + webhook: UpdateWebhook +): Promise => { + const { url: webhookUrl, ...changes } = webhook; + await ok( + client(agent, spindle).call(updateWebhookSchema, { + input: { + ...changes, + repoDid, + id, + ...(webhookUrl === undefined ? {} : { url: url(webhookUrl) }) + } + }) + ); +}; + +export const deleteWebhook = async ( + agent: OAuthUserAgent, + spindle: URL, + repoDid: Did, + id: number +): Promise => { + await ok( + client(agent, spindle).call(deleteWebhookSchema, { + input: { repoDid, id } + }) + ); +}; + +export const toggleWebhook = async ( + agent: OAuthUserAgent, + spindle: URL, + repoDid: Did, + id: number +): Promise => { + const { active } = await ok( + client(agent, spindle).call(toggleWebhookSchema, { + input: { repoDid, id } + }) + ); + return active; +}; + +export const listWebhookDeliveries = async ( + agent: OAuthUserAgent, + spindle: URL, + repoDid: Did, + ids: number[], + limit = 100 +): Promise => { + const { deliveries } = await ok( + client(agent, spindle).call(getDeliveriesSchema, { + params: { repoDid, ids, limit } + }) + ); + return deliveries ?? []; +}; + +export const retryWebhookDelivery = async ( + agent: OAuthUserAgent, + spindle: URL, + repoDid: Did, + webhookId: number, + deliveryId: string +): Promise => { + await ok( + client(agent, spindle).call(retryDeliverySchema, { + input: { repoDid, webhookId, deliveryId } + }) + ); +}; diff --git a/web/src/lib/oauth-client-metadata.ts b/web/src/lib/oauth-client-metadata.ts index 0ec206a84..bd1520cce 100644 --- a/web/src/lib/oauth-client-metadata.ts +++ b/web/src/lib/oauth-client-metadata.ts @@ -46,6 +46,13 @@ const scopes = [ "rpc:org.tangled.temp.notification.updatePreferences?aud=*", "rpc:org.tangled.temp.notification.updateSeen?aud=*", "rpc:org.tangled.temp.search.searchCode?aud=*", + "rpc:org.tangled.temp.webhook.createWebhook?aud=*", + "rpc:org.tangled.temp.webhook.deleteWebhook?aud=*", + "rpc:org.tangled.temp.webhook.getDeliveriesForWebhooks?aud=*", + "rpc:org.tangled.temp.webhook.listWebhooks?aud=*", + "rpc:org.tangled.temp.webhook.retryDelivery?aud=*", + "rpc:org.tangled.temp.webhook.toggleWebhook?aud=*", + "rpc:org.tangled.temp.webhook.updateWebhook?aud=*", "rpc:org.tangled.temp.site.claimDomain?aud=*", "rpc:org.tangled.temp.site.getDomainClaim?aud=*", "rpc:org.tangled.temp.site.releaseDomain?aud=*", diff --git a/web/src/routes/[handle]/[repo]/settings/hooks/+layout.svelte b/web/src/routes/[handle]/[repo]/settings/hooks/+layout.svelte new file mode 100644 index 000000000..66eedebf1 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/settings/hooks/+layout.svelte @@ -0,0 +1,45 @@ + + +{#if ungranted.length > 0} + + + + + +{:else} + {@render children()} +{/if} diff --git a/web/src/routes/[handle]/[repo]/settings/hooks/+page.ts b/web/src/routes/[handle]/[repo]/settings/hooks/+layout.ts similarity index 52% rename from web/src/routes/[handle]/[repo]/settings/hooks/+page.ts rename to web/src/routes/[handle]/[repo]/settings/hooks/+layout.ts index a308e5542..19946bac0 100644 --- a/web/src/routes/[handle]/[repo]/settings/hooks/+page.ts +++ b/web/src/routes/[handle]/[repo]/settings/hooks/+layout.ts @@ -1,6 +1,6 @@ import { requireRepoAdmin } from "$lib/api/access"; -import type { PageLoad } from "./$types"; +import type { LayoutLoad } from "./$types"; -export const load: PageLoad = async (event) => { +export const load: LayoutLoad = async (event) => { requireRepoAdmin((await event.parent()).access); }; diff --git a/web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte b/web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte index 2afd3b004..78d5a5c90 100644 --- a/web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte @@ -1,8 +1,17 @@ - - - - {#snippet action()} - + + {#snippet skeleton()} + {/snippet} - {#if hooks.length === 0} - - {:else} - - {#each hooks as hook (hook.id)} -
-
- {hook.url} - - - - {hook.added} - - {hook.by} - -
-
- -
- - - + + + + + {#snippet action()} + + {/snippet} + + {#if !spindle} + + {:else if hooks.loading} + + {:else if hooks.error} + + {:else if (hooks.data ?? []).length === 0} + + {:else} + + {#each hooks.data ?? [] as hook (hook.id)} +
+
+ {hook.url} + + + + Created + +
+
+ { + event.preventDefault(); + void toggle.run(hook.id); + }} + /> +
+ + + +
-
- {/each} - - {/if} - + {/each} + + {/if} + + diff --git a/web/src/routes/[handle]/[repo]/settings/hooks/[id]/+layout.ts b/web/src/routes/[handle]/[repo]/settings/hooks/[id]/+layout.ts new file mode 100644 index 000000000..c89ec33ec --- /dev/null +++ b/web/src/routes/[handle]/[repo]/settings/hooks/[id]/+layout.ts @@ -0,0 +1,8 @@ +import { error } from "@sveltejs/kit"; +import type { LayoutLoad } from "./$types"; + +export const load: LayoutLoad = ({ params }) => { + const id = Number(params.id); + if (!Number.isSafeInteger(id) || id < 1) error(404, "Webhook not found"); + return { id }; +}; diff --git a/web/src/routes/[handle]/[repo]/settings/hooks/[id]/+page.svelte b/web/src/routes/[handle]/[repo]/settings/hooks/[id]/+page.svelte new file mode 100644 index 000000000..fe2856e2b --- /dev/null +++ b/web/src/routes/[handle]/[repo]/settings/hooks/[id]/+page.svelte @@ -0,0 +1,191 @@ + + + + + + {#if webhook.loading} + + {:else if webhook.error} + + {:else if editor.kind === "editing"} + + + + + + +
+ + Remove the existing secret +
+
+ + +
+ {#each WEBHOOK_EVENTS as event (event.id)} + {event.label} + {/each} +
+
+ + + + +
+ + + + + + {/if} +
diff --git a/web/src/routes/[handle]/[repo]/settings/hooks/[id]/deliveries/+page.svelte b/web/src/routes/[handle]/[repo]/settings/hooks/[id]/deliveries/+page.svelte new file mode 100644 index 000000000..85745268f --- /dev/null +++ b/web/src/routes/[handle]/[repo]/settings/hooks/[id]/deliveries/+page.svelte @@ -0,0 +1,147 @@ + + + + + + {#if deliveries.loading} + + {:else if deliveries.error} + + {:else if (deliveries.data ?? []).length === 0} + + {:else} +
+ +
+ + {#each deliveries.data ?? [] as delivery (delivery.id)} +
+ + + {delivery.responseCode ?? (delivery.success ? "Success" : "Failed")} + + {delivery.event} + + {delivery.deliveryId} + + + + +
+
+ Payload URL + + {delivery.url} + +
+ {#if delivery.requestBody} +
+ Request body +
{body(
+										delivery.requestBody
+									)}
+
+ {/if} + {#if delivery.responseBody} +
+ Response body +
{body(
+										delivery.responseBody
+									)}
+
+ {/if} +
+ +
+
+
+ {/each} +
+ {/if} +
diff --git a/web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte b/web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte index c9f9d7706..5475a6bd5 100644 --- a/web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte @@ -1,9 +1,26 @@ + + - + @@ -74,15 +111,31 @@ align="start" >
- {#each EVENTS as event (event.id)} - {event.label} + {#each WEBHOOK_EVENTS as event (event.id)} + {event.label} {/each}
+ + + webhookIsActive(draft.activation), + (checked) => (draft.activation = webhookActivation(checked)) + } + aria-label="Active" + /> +
- +