feat: add optional bearer token support for webhook subscriptions

Token is stored in the DB but never returned in API responses (write-only).
fireWebhooks() sends Authorization: Bearer <token> header when present.
This commit is contained in:
Julien Calixte
2026-03-14 18:16:28 +01:00
parent 06ac3142a8
commit 8c9ab34565
4 changed files with 19 additions and 7 deletions

View File

@@ -24,10 +24,13 @@ const fireWebhooks = async (
const webhooks = getWebhooksByDid(did); const webhooks = getWebhooksByDid(did);
if (webhooks.length === 0) return; if (webhooks.length === 0) return;
const results = await Promise.allSettled( const results = await Promise.allSettled(
webhooks.map(({ method, url }) => webhooks.map(({ method, url, token }) =>
fetch(url, { fetch(url, {
method, method,
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
), ),

View File

@@ -43,13 +43,13 @@ router.post("/notes/feed", async (ctx) => {
router.post("/:did/webhooks", async (ctx) => { router.post("/:did/webhooks", async (ctx) => {
const { did } = ctx.params; const { did } = ctx.params;
const body = await ctx.request.body.json(); const body = await ctx.request.body.json();
const { method, url } = body ?? {}; const { method, url, token } = body ?? {};
if (!method || !url) { if (!method || !url) {
ctx.response.status = 400; ctx.response.status = 400;
ctx.response.body = { error: "method and url are required" }; ctx.response.body = { error: "method and url are required" };
return; return;
} }
const subscription = addWebhookSubscription({ did, method, url }); const subscription = addWebhookSubscription({ did, method, url, token });
ctx.response.status = 201; ctx.response.status = 201;
ctx.response.body = subscription; ctx.response.body = subscription;
}); });

View File

@@ -89,20 +89,23 @@ type WebhookSubscriptionRow = {
did: string; did: string;
method: string; method: string;
url: string; url: string;
token?: string;
}; };
export const addWebhookSubscription = ( export const addWebhookSubscription = (
{ did, method, url }: Omit<WebhookSubscriptionRow, "id">, { did, method, url, token }: Omit<WebhookSubscriptionRow, "id">,
): WebhookSubscriptionRow => { ): WebhookSubscriptionRow => {
db.exec( db.exec(
"INSERT INTO webhook_subscription (did, method, url) VALUES (?, ?, ?)", "INSERT INTO webhook_subscription (did, method, url, token) VALUES (?, ?, ?, ?)",
did, did,
method, method,
url, url,
token ?? null,
); );
return db.prepare( return db.prepare(
"SELECT id, did, method, url FROM webhook_subscription WHERE id = last_insert_rowid()", "SELECT id, did, method, url FROM webhook_subscription WHERE id = last_insert_rowid()",
).get<WebhookSubscriptionRow>()!; ).get<WebhookSubscriptionRow>()!;
// Note: token is intentionally excluded from the SELECT (write-only)
}; };
export const deleteWebhooksByDid = (did: string): void => { export const deleteWebhooksByDid = (did: string): void => {
@@ -111,7 +114,7 @@ export const deleteWebhooksByDid = (did: string): void => {
export const getWebhooksByDid = (did: string): WebhookSubscriptionRow[] => { export const getWebhooksByDid = (did: string): WebhookSubscriptionRow[] => {
return db.prepare( return db.prepare(
"SELECT id, did, method, url FROM webhook_subscription WHERE did = ? ORDER BY id DESC LIMIT 10", "SELECT id, did, method, url, token FROM webhook_subscription WHERE did = ? ORDER BY id DESC LIMIT 10",
).all<WebhookSubscriptionRow>(did); ).all<WebhookSubscriptionRow>(did);
}; };

View File

@@ -55,4 +55,10 @@ db.exec(`
ON webhook_subscription(did); ON webhook_subscription(did);
`); `);
try {
db.exec(`ALTER TABLE webhook_subscription ADD COLUMN token TEXT;`);
} catch {
// Column already exists — no-op
}
db.close(); db.close();