From 1c160b6c532a93d9770be15785ddfa2806c611c3 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Tue, 5 May 2026 14:07:26 +0200 Subject: [PATCH] refactor(scripts): extract atproto session helpers to shared module --- scripts/_atproto-session.ts | 97 +++++++++++++++++++++++++++++++++++++ scripts/manage-webhooks.ts | 75 +++------------------------- 2 files changed, 103 insertions(+), 69 deletions(-) create mode 100644 scripts/_atproto-session.ts diff --git a/scripts/_atproto-session.ts b/scripts/_atproto-session.ts new file mode 100644 index 0000000..752f5f0 --- /dev/null +++ b/scripts/_atproto-session.ts @@ -0,0 +1,97 @@ +// Shared helpers for admin scripts that authenticate against the Remanso API +// using an AT Protocol session. The handle is resolved to a DID, the DID is +// resolved to a PDS, and a session is created against that PDS — yielding an +// access JWT the API can verify with `authenticateRequest`. + +type ResolveHandleResponse = { did: string }; +type DidDocument = { + service?: { id: string; serviceEndpoint: string }[]; +}; +export type CreateSessionResponse = { did: string; accessJwt: string }; + +export 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; +}; + +export 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; +}; + +export 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; +}; + +export 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(); +}; + +// Convenience: pull credentials from flags or env, resolve the full session +// chain in one call. Throws if required inputs are missing. +export const sessionFromFlagsOrEnv = async ( + flags: Record, +): Promise<{ session: CreateSessionResponse; api: string }> => { + 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) throw new Error("ATPROTO_HANDLE (or --handle) is required"); + if (!password) { + throw new Error("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); + return { session, api }; +}; diff --git a/scripts/manage-webhooks.ts b/scripts/manage-webhooks.ts index 5861495..04a1e63 100644 --- a/scripts/manage-webhooks.ts +++ b/scripts/manage-webhooks.ts @@ -32,28 +32,12 @@ Inputs (env or flag, env preferred): 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; -}; +import { + createSession, + parseArgs, + resolveDidToPds, + resolveHandleToDid, +} from "./_atproto-session.ts"; const die = (msg: string): never => { console.error(`error: ${msg}`); @@ -61,53 +45,6 @@ const die = (msg: string): never => { 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") {