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:
81
server.ts
81
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<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 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;
|
||||
|
||||
@@ -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<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 = (
|
||||
did: string,
|
||||
verb: WebhookVerb,
|
||||
|
||||
Reference in New Issue
Block a user