From bcea56c529ef84d134d410eeb449184828e6b325 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Tue, 5 May 2026 12:38:26 +0200 Subject: [PATCH] feat(webhooks): add list and granular delete endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /:did/webhooks lists subscriptions for the authenticated owner (token field excluded — write-only as elsewhere). - DELETE /:did/webhooks/:id deletes a single subscription. The query scopes on (did, id) so a verified caller cannot delete rows that belong to a different DID even with a valid id. Also extracts the auth gate into requireDidOwnership now that three endpoints share it. --- server.ts | 81 +++++++++++++++++++++++++++++++------------------- src/data/db.ts | 17 +++++++++++ 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/server.ts b/server.ts index bab0941..ab8ea49 100644 --- a/server.ts +++ b/server.ts @@ -1,15 +1,44 @@ import { Application, Router } from "@oak/oak"; import { addWebhookSubscription, + deleteWebhookById, deleteWebhooksByDid, getNotes, getNotesByDid, getNotesByDids, + listWebhooksByDid, type WebhookVerb, } from "./src/data/db.ts"; import { authenticateRequest } from "./src/auth/verify.ts"; import { log } from "./src/log.ts"; +type AuthCtx = { + request: { headers: { get(key: string): string | null } }; + response: { status: number; body: unknown }; +}; + +const requireDidOwnership = async ( + ctx: AuthCtx, + did: string, +): Promise => { + let verifiedDid: string; + try { + verifiedDid = await authenticateRequest( + ctx.request.headers.get("Authorization"), + ); + } catch { + ctx.response.status = 401; + ctx.response.body = { error: "Unauthorized" }; + return false; + } + if (verifiedDid !== did) { + ctx.response.status = 403; + ctx.response.body = { error: "You can only manage your own webhooks" }; + return false; + } + return true; +}; + const router = new Router(); const PAGINATION = 20; @@ -88,23 +117,15 @@ router.post("/notes/feed", async (ctx) => { const ALLOWED_VERBS = ["create", "delete", "bulk-create"] as const; +router.get("/:did/webhooks", async (ctx) => { + const { did } = ctx.params; + if (!(await requireDidOwnership(ctx, did))) return; + ctx.response.body = listWebhooksByDid(did); +}); + router.post("/:did/webhooks", async (ctx) => { const { did } = ctx.params; - let verifiedDid: string; - try { - verifiedDid = await authenticateRequest( - ctx.request.headers.get("Authorization"), - ); - } catch { - ctx.response.status = 401; - ctx.response.body = { error: "Unauthorized" }; - return; - } - if (verifiedDid !== did) { - ctx.response.status = 403; - ctx.response.body = { error: "You can only manage your own webhooks" }; - return; - } + if (!(await requireDidOwnership(ctx, did))) return; const body = await ctx.request.body.json(); const { method, url, token, verb } = body ?? {}; if (!method || !url) { @@ -129,25 +150,25 @@ router.post("/:did/webhooks", async (ctx) => { router.delete("/:did/webhooks", async (ctx) => { const { did } = ctx.params; - let verifiedDid: string; - try { - verifiedDid = await authenticateRequest( - ctx.request.headers.get("Authorization"), - ); - } catch { - ctx.response.status = 401; - ctx.response.body = { error: "Unauthorized" }; - return; - } - if (verifiedDid !== did) { - ctx.response.status = 403; - ctx.response.body = { error: "You can only manage your own webhooks" }; - return; - } + if (!(await requireDidOwnership(ctx, did))) return; deleteWebhooksByDid(did); ctx.response.status = 204; }); +router.delete("/:did/webhooks/:id", async (ctx) => { + const { did, id } = ctx.params; + if (!(await requireDidOwnership(ctx, did))) return; + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + ctx.response.status = 400; + ctx.response.body = { error: "id must be a positive integer" }; + return; + } + const deleted = deleteWebhookById({ did, id: numericId }); + ctx.response.status = deleted ? 204 : 404; + if (!deleted) ctx.response.body = { error: "webhook not found" }; +}); + // router.delete("/:did/:rkey", async (ctx) => { // const { did, rkey } = ctx.params; // let verifiedDid: string; diff --git a/src/data/db.ts b/src/data/db.ts index db0674b..e62fc3e 100644 --- a/src/data/db.ts +++ b/src/data/db.ts @@ -119,6 +119,23 @@ export const deleteWebhooksByDid = (did: string): void => { db.exec("DELETE FROM webhook_subscription WHERE did = ?", did); }; +export const deleteWebhookById = ( + { did, id }: { did: string; id: number }, +): boolean => { + const result = db.prepare( + "DELETE FROM webhook_subscription WHERE did = ? AND id = ?", + ).run(did, id); + return result > 0; +}; + +export const listWebhooksByDid = ( + did: string, +): Omit[] => { + return db.prepare( + "SELECT id, did, method, url, verb FROM webhook_subscription WHERE did = ? ORDER BY id DESC", + ).all>(did); +}; + export const getWebhooksByDidAndVerb = ( did: string, verb: WebhookVerb,