Files
remanso-jetstream/scripts/manage-webhooks.ts
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

200 lines
6.1 KiB
TypeScript

// 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 <receiver-url> \\
[--verb create|delete|bulk-create] \\
[--method POST] \\
[--token <outbound-bearer>]
deno task webhooks list
deno task webhooks delete --id <id>
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<string, string> => {
const out: Record<string, string> = {};
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<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 (
pds: string,
identifier: string,
password: string,
): Promise<CreateSessionResponse> => {
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<string, unknown> = {
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 === "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") {
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();