Compare commits

..

3 Commits

Author SHA1 Message Date
Julien Calixte
5cb581123d chore(scripts): add list and delete commands to manage-webhooks
`deno task webhooks list` prints the authenticated DID's subscriptions
(id, url, method, verb). `deno task webhooks delete --id <id>` removes
a single one — pair with `list` to pick which to drop instead of
nuking everything via delete-all.
2026-05-05 12:38:32 +02:00
Julien Calixte
bcea56c529 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.
2026-05-05 12:38:26 +02:00
Julien Calixte
a3c92254ea refactor(scripts): auto-resolve PDS from handle, drop BSKY_PDS
Inputs are now just ATPROTO_HANDLE, ATPROTO_APP_PASSWORD, REMANSO_API
(or matching --handle, --app-password, --api). The script resolves the
handle to a DID via public.api.bsky.app, then resolves the DID to a
PDS via plc.directory — same path the server's verifier uses. Works
for any AT Protocol PDS (Bluesky, eurosky, self-hosted) without the
caller having to know the PDS URL.
2026-05-05 12:36:22 +02:00
3 changed files with 153 additions and 59 deletions

View File

@@ -1,21 +1,14 @@
// Manage webhook subscriptions on the Remanso API using a Bluesky session // Manage webhook subscriptions on the Remanso API. Resolves your PDS from
// token from your own PDS. Usage: // your AT Protocol handle, logs in with an app password, then registers or
// deletes webhooks against the Remanso API.
// //
// deno task webhooks register --url https://your-receiver --verb bulk-create // deno task webhooks register --url https://your-receiver --verb bulk-create
// deno task webhooks delete-all // deno task webhooks delete-all
// //
// Required env vars (or matching --flags): // Inputs (env or flag, env preferred):
// BSKY_IDENTIFIER your handle (e.g. alice.eurosky.social) or email // ATPROTO_HANDLE / --handle e.g. alice.eurosky.social
// BSKY_PASSWORD app password (NOT your main password) // ATPROTO_APP_PASSWORD / --app-password app password (NOT your account password)
// BSKY_PDS your PDS base URL (default: https://bsky.social) // REMANSO_API / --api default: https://api.remanso.space
// REMANSO_API Remanso API base URL (default: https://api.remanso.space)
//
// Example for an eurosky-hosted account:
//
// BSKY_IDENTIFIER=alice.eurosky.social \
// BSKY_PASSWORD='xxxx-xxxx-xxxx-xxxx' \
// BSKY_PDS=https://eurosky.social \
// deno task webhooks register --url https://example.com/hook --verb bulk-create
const HELP = ` const HELP = `
Usage: Usage:
@@ -25,19 +18,25 @@ Usage:
[--method POST] \\ [--method POST] \\
[--token <outbound-bearer>] [--token <outbound-bearer>]
deno task webhooks list
deno task webhooks delete --id <id>
deno task webhooks delete-all deno task webhooks delete-all
Auth (env or flag, env preferred): Inputs (env or flag, env preferred):
BSKY_IDENTIFIER / --identifier ATPROTO_HANDLE / --handle your AT Protocol handle
BSKY_PASSWORD / --password ATPROTO_APP_PASSWORD / --app-password app password (NOT your account password)
BSKY_PDS / --pds (default: https://bsky.social) REMANSO_API / --api default: https://api.remanso.space
REMANSO_API / --api (default: https://api.remanso.space)
Your PDS is resolved automatically from the handle.
`; `;
type CreateSessionResponse = { type ResolveHandleResponse = { did: string };
did: string; type DidDocument = {
accessJwt: string; service?: { id: string; serviceEndpoint: string }[];
}; };
type CreateSessionResponse = { did: string; accessJwt: string };
const parseArgs = (args: string[]): Record<string, string> => { const parseArgs = (args: string[]): Record<string, string> => {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
@@ -62,6 +61,35 @@ const die = (msg: string): never => {
Deno.exit(1); Deno.exit(1);
}; };
const resolveHandleToDid = async (handle: string): Promise<string> => {
const url =
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${
encodeURIComponent(handle)
}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(
`resolveHandle failed for ${handle} (${res.status}): ${await res.text()}`,
);
}
const { did } = await res.json() as ResolveHandleResponse;
return did;
};
const resolveDidToPds = async (did: string): Promise<string> => {
if (!did.startsWith("did:plc:")) {
throw new Error(`Unsupported DID method (server only handles did:plc): ${did}`);
}
const res = await fetch(`https://plc.directory/${did}`);
if (!res.ok) {
throw new Error(`plc.directory lookup failed (${res.status})`);
}
const doc = await res.json() as DidDocument;
const pds = doc.service?.find((s) => s.id === "#atproto_pds");
if (!pds) throw new Error("No #atproto_pds service in DID document");
return pds.serviceEndpoint;
};
const createSession = async ( const createSession = async (
pds: string, pds: string,
identifier: string, identifier: string,
@@ -88,17 +116,19 @@ const main = async () => {
} }
const flags = parseArgs(rest); const flags = parseArgs(rest);
const identifier = flags.identifier ?? Deno.env.get("BSKY_IDENTIFIER"); const handle = flags.handle ?? Deno.env.get("ATPROTO_HANDLE");
const password = flags.password ?? Deno.env.get("BSKY_PASSWORD"); const password = flags["app-password"] ??
const pds = flags.pds ?? Deno.env.get("BSKY_PDS") ?? "https://bsky.social"; Deno.env.get("ATPROTO_APP_PASSWORD");
const api = flags.api ?? Deno.env.get("REMANSO_API") ?? const api = flags.api ?? Deno.env.get("REMANSO_API") ??
"https://api.remanso.space"; "https://api.remanso.space";
if (!identifier) die("BSKY_IDENTIFIER (or --identifier) is required"); if (!handle) die("ATPROTO_HANDLE (or --handle) is required");
if (!password) die("BSKY_PASSWORD (or --password) is required"); if (!password) die("ATPROTO_APP_PASSWORD (or --app-password) is required");
const session = await createSession(pds, identifier, password); const did = await resolveHandleToDid(handle);
console.log(`[auth] verified DID: ${session.did} via ${pds}`); const pds = await resolveDidToPds(did);
console.log(`[resolve] ${handle}${did} via ${pds}`);
const session = await createSession(pds, handle, password);
if (command === "register") { if (command === "register") {
const url = flags.url ?? die("--url is required for register"); const url = flags.url ?? die("--url is required for register");
@@ -124,6 +154,32 @@ const main = async () => {
return; return;
} }
if (command === "list") {
const res = await fetch(`${api}/${session.did}/webhooks`, {
headers: { "Authorization": `Bearer ${session.accessJwt}` },
});
if (!res.ok) {
console.error(`list failed (${res.status}): ${await res.text()}`);
Deno.exit(1);
}
console.log(JSON.stringify(await res.json(), null, 2));
return;
}
if (command === "delete") {
const id = flags.id ?? die("--id is required for delete");
const res = await fetch(`${api}/${session.did}/webhooks/${id}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${session.accessJwt}` },
});
if (!res.ok) {
console.error(`delete failed (${res.status}): ${await res.text()}`);
Deno.exit(1);
}
console.log(`[done] webhook ${id} deleted`);
return;
}
if (command === "delete-all") { if (command === "delete-all") {
const res = await fetch(`${api}/${session.did}/webhooks`, { const res = await fetch(`${api}/${session.did}/webhooks`, {
method: "DELETE", method: "DELETE",

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,