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 { 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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user