// Manage webhook subscriptions on the Remanso API. Resolves your PDS from // 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 delete-all // // Inputs (env or flag, env preferred): // ATPROTO_HANDLE / --handle e.g. alice.eurosky.social // ATPROTO_APP_PASSWORD / --app-password app password (NOT your account password) // REMANSO_API / --api default: https://api.remanso.space const HELP = ` Usage: deno task webhooks register \\ --url \\ [--verb create|delete|bulk-create] \\ [--method POST] \\ [--token ] deno task webhooks delete-all Inputs (env or flag, env preferred): ATPROTO_HANDLE / --handle your AT Protocol handle ATPROTO_APP_PASSWORD / --app-password app password (NOT your account password) REMANSO_API / --api default: https://api.remanso.space Your PDS is resolved automatically from the handle. `; type ResolveHandleResponse = { did: string }; type DidDocument = { service?: { id: string; serviceEndpoint: string }[]; }; type CreateSessionResponse = { did: string; accessJwt: string }; const parseArgs = (args: string[]): Record => { const out: Record = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (!arg.startsWith("--")) continue; const key = arg.slice(2); const next = args[i + 1]; if (next === undefined || next.startsWith("--")) { out[key] = "true"; } else { out[key] = next; i++; } } return out; }; const die = (msg: string): never => { console.error(`error: ${msg}`); console.error(HELP); Deno.exit(1); }; const resolveHandleToDid = async (handle: string): Promise => { 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 => { 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 ( pds: string, identifier: string, password: string, ): Promise => { const res = await fetch(`${pds}/xrpc/com.atproto.server.createSession`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identifier, password }), }); if (!res.ok) { throw new Error( `createSession failed (${res.status}): ${await res.text()}`, ); } return await res.json(); }; const main = async () => { const [command, ...rest] = Deno.args; if (!command || command === "--help" || command === "-h") { console.log(HELP); Deno.exit(command ? 0 : 1); } const flags = parseArgs(rest); const handle = flags.handle ?? Deno.env.get("ATPROTO_HANDLE"); const password = flags["app-password"] ?? Deno.env.get("ATPROTO_APP_PASSWORD"); const api = flags.api ?? Deno.env.get("REMANSO_API") ?? "https://api.remanso.space"; if (!handle) die("ATPROTO_HANDLE (or --handle) is required"); if (!password) die("ATPROTO_APP_PASSWORD (or --app-password) is required"); const did = await resolveHandleToDid(handle); const pds = await resolveDidToPds(did); console.log(`[resolve] ${handle} → ${did} via ${pds}`); const session = await createSession(pds, handle, password); if (command === "register") { const url = flags.url ?? die("--url is required for register"); const body: Record = { method: flags.method ?? "POST", url, }; if (flags.verb) body.verb = flags.verb; if (flags.token) body.token = flags.token; const res = await fetch(`${api}/${session.did}/webhooks`, { method: "POST", headers: { "Authorization": `Bearer ${session.accessJwt}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (!res.ok) { console.error(`register failed (${res.status}): ${await res.text()}`); Deno.exit(1); } console.log(JSON.stringify(await res.json(), null, 2)); return; } if (command === "delete-all") { const res = await fetch(`${api}/${session.did}/webhooks`, { 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] all webhooks for ${session.did} deleted`); return; } die(`unknown command: ${command}`); }; await main();