feat(webhooks): add list and granular delete endpoints

- 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.
This commit is contained in:
Julien Calixte
2026-05-05 12:38:26 +02:00
parent a3c92254ea
commit bcea56c529
2 changed files with 68 additions and 30 deletions

View File

@@ -1,15 +1,44 @@
import { Application, Router } from "@oak/oak"; import { Application, Router } from "@oak/oak";
import { import {
addWebhookSubscription, addWebhookSubscription,
deleteWebhookById,
deleteWebhooksByDid, deleteWebhooksByDid,
getNotes, getNotes,
getNotesByDid, getNotesByDid,
getNotesByDids, getNotesByDids,
listWebhooksByDid,
type WebhookVerb, type WebhookVerb,
} from "./src/data/db.ts"; } from "./src/data/db.ts";
import { authenticateRequest } from "./src/auth/verify.ts"; import { authenticateRequest } from "./src/auth/verify.ts";
import { log } from "./src/log.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<boolean> => {
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 router = new Router();
const PAGINATION = 20; const PAGINATION = 20;
@@ -88,23 +117,15 @@ router.post("/notes/feed", async (ctx) => {
const ALLOWED_VERBS = ["create", "delete", "bulk-create"] as const; 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) => { router.post("/:did/webhooks", async (ctx) => {
const { did } = ctx.params; const { did } = ctx.params;
let verifiedDid: string; if (!(await requireDidOwnership(ctx, did))) return;
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;
}
const body = await ctx.request.body.json(); const body = await ctx.request.body.json();
const { method, url, token, verb } = body ?? {}; const { method, url, token, verb } = body ?? {};
if (!method || !url) { if (!method || !url) {
@@ -129,25 +150,25 @@ router.post("/:did/webhooks", async (ctx) => {
router.delete("/:did/webhooks", async (ctx) => { router.delete("/:did/webhooks", async (ctx) => {
const { did } = ctx.params; const { did } = ctx.params;
let verifiedDid: string; if (!(await requireDidOwnership(ctx, did))) return;
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;
}
deleteWebhooksByDid(did); deleteWebhooksByDid(did);
ctx.response.status = 204; 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) => { // router.delete("/:did/:rkey", async (ctx) => {
// const { did, rkey } = ctx.params; // const { did, rkey } = ctx.params;
// let verifiedDid: string; // let verifiedDid: string;

View File

@@ -119,6 +119,23 @@ export const deleteWebhooksByDid = (did: string): void => {
db.exec("DELETE FROM webhook_subscription WHERE did = ?", did); 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<WebhookSubscriptionRow, "token">[] => {
return db.prepare(
"SELECT id, did, method, url, verb FROM webhook_subscription WHERE did = ? ORDER BY id DESC",
).all<Omit<WebhookSubscriptionRow, "token">>(did);
};
export const getWebhooksByDidAndVerb = ( export const getWebhooksByDidAndVerb = (
did: string, did: string,
verb: WebhookVerb, verb: WebhookVerb,