Compare commits
3 Commits
e0fe4ce16f
...
5cb581123d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cb581123d | ||
|
|
bcea56c529 | ||
|
|
a3c92254ea |
@@ -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",
|
||||||
|
|||||||
81
server.ts
81
server.ts
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user