Compare commits
4 Commits
c00f3d631c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f4d8d3b56 | ||
|
|
355fc45316 | ||
|
|
1c160b6c53 | ||
|
|
34faa10be2 |
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# version control / editor / OS
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.github/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# local SQLite + sidecars (DB lives at /data in the container)
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.db-journal
|
||||||
|
remote-db/
|
||||||
|
|
||||||
|
# secrets — provide via the orchestrator's env config, not baked into the image
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# admin / dev-only scripts (run locally, not in prod containers)
|
||||||
|
scripts/
|
||||||
|
|
||||||
|
# logs and caches
|
||||||
|
*.log
|
||||||
|
.cache/
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"server:prod": "deno run --allow-net --allow-read --allow-write --allow-env --allow-ffi --unstable-ffi server.ts",
|
"server:prod": "deno run --allow-net --allow-read --allow-write --allow-env --allow-ffi --unstable-ffi server.ts",
|
||||||
"migrate": "deno run --allow-net --allow-read --allow-write --allow-env --allow-ffi --unstable-ffi src/migrations/init.ts",
|
"migrate": "deno run --allow-net --allow-read --allow-write --allow-env --allow-ffi --unstable-ffi src/migrations/init.ts",
|
||||||
"webhooks": "deno run --allow-net --allow-env scripts/manage-webhooks.ts",
|
"webhooks": "deno run --allow-net --allow-env scripts/manage-webhooks.ts",
|
||||||
"webhooks:all": "deno run --allow-read --allow-write --allow-env --allow-ffi --unstable-ffi scripts/list-all-webhooks.ts"
|
"webhooks:all": "deno run --allow-net --allow-env scripts/list-all-webhooks.ts"
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"@db/sqlite": "jsr:@db/sqlite@^0.13.0",
|
"@db/sqlite": "jsr:@db/sqlite@^0.13.0",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const fireWebhooks = async (
|
|||||||
await dispatchAll(webhooks, payload, `${verb} ${did}`);
|
await dispatchAll(webhooks, payload, `${verb} ${did}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const BULK_CREATE_DEBOUNCE_MS = 15000;
|
const BULK_CREATE_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
type BulkBuffer = {
|
type BulkBuffer = {
|
||||||
records: Record<string, unknown>[];
|
records: Record<string, unknown>[];
|
||||||
|
|||||||
97
scripts/_atproto-session.ts
Normal file
97
scripts/_atproto-session.ts
Normal file
@@ -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<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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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<string, string>,
|
||||||
|
): 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 };
|
||||||
|
};
|
||||||
@@ -1,29 +1,52 @@
|
|||||||
// Admin: list every webhook subscription from the local SQLite, across all
|
// Admin: list every webhook subscription via the Remanso API. Requires the
|
||||||
// DIDs. Talks to the database directly — does NOT go through the API.
|
// authenticated DID to be in the server-side ADMIN_DIDS allowlist.
|
||||||
//
|
//
|
||||||
// deno task webhooks:all
|
// deno task webhooks:all
|
||||||
//
|
//
|
||||||
// Reads SQLITE_PATH (defaults to "notes.db"). The `token` column is
|
// Inputs (env or flag, env preferred):
|
||||||
// intentionally excluded from the output — it is a write-only secret.
|
// 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
|
||||||
|
|
||||||
import { Database } from "@db/sqlite";
|
const HELP = `
|
||||||
|
Usage:
|
||||||
|
deno task webhooks:all
|
||||||
|
|
||||||
type WebhookRow = {
|
Inputs (env or flag, env preferred):
|
||||||
id: number;
|
ATPROTO_HANDLE / --handle your AT Protocol handle
|
||||||
did: string;
|
ATPROTO_APP_PASSWORD / --app-password app password (NOT your account password)
|
||||||
method: string;
|
REMANSO_API / --api default: https://api.remanso.space
|
||||||
url: string;
|
`;
|
||||||
verb: string;
|
|
||||||
|
import { parseArgs, sessionFromFlagsOrEnv } from "./_atproto-session.ts";
|
||||||
|
|
||||||
|
const die = (msg: string): never => {
|
||||||
|
console.error(`error: ${msg}`);
|
||||||
|
console.error(HELP);
|
||||||
|
Deno.exit(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const path = Deno.env.get("SQLITE_PATH") ?? "notes.db";
|
const main = async () => {
|
||||||
const db = new Database(path);
|
const flags = parseArgs(Deno.args);
|
||||||
|
if (flags.help === "true" || flags.h === "true") {
|
||||||
|
console.log(HELP);
|
||||||
|
Deno.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const rows = db.prepare(
|
const { session, api } = await sessionFromFlagsOrEnv(flags).catch(
|
||||||
"SELECT id, did, method, url, verb FROM webhook_subscription ORDER BY did, id",
|
(e: Error) => die(e.message),
|
||||||
).all<WebhookRow>();
|
);
|
||||||
|
|
||||||
console.log(JSON.stringify(rows, null, 2));
|
const res = await fetch(`${api}/admin/webhooks`, {
|
||||||
console.error(`[done] ${rows.length} subscription(s) at ${path}`);
|
headers: { "Authorization": `Bearer ${session.accessJwt}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`list-all failed (${res.status}): ${await res.text()}`);
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
const rows = await res.json();
|
||||||
|
console.log(JSON.stringify(rows, null, 2));
|
||||||
|
console.error(`[done] ${rows.length} subscription(s) at ${api}`);
|
||||||
|
};
|
||||||
|
|
||||||
db.close();
|
await main();
|
||||||
|
|||||||
@@ -32,28 +32,12 @@ Inputs (env or flag, env preferred):
|
|||||||
Your PDS is resolved automatically from the handle.
|
Your PDS is resolved automatically from the handle.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type ResolveHandleResponse = { did: string };
|
import {
|
||||||
type DidDocument = {
|
createSession,
|
||||||
service?: { id: string; serviceEndpoint: string }[];
|
parseArgs,
|
||||||
};
|
resolveDidToPds,
|
||||||
type CreateSessionResponse = { did: string; accessJwt: string };
|
resolveHandleToDid,
|
||||||
|
} from "./_atproto-session.ts";
|
||||||
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 => {
|
const die = (msg: string): never => {
|
||||||
console.error(`error: ${msg}`);
|
console.error(`error: ${msg}`);
|
||||||
@@ -61,53 +45,6 @@ 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 (
|
|
||||||
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 main = async () => {
|
||||||
const [command, ...rest] = Deno.args;
|
const [command, ...rest] = Deno.args;
|
||||||
if (!command || command === "--help" || command === "-h") {
|
if (!command || command === "--help" || command === "-h") {
|
||||||
|
|||||||
32
server.ts
32
server.ts
@@ -6,6 +6,7 @@ import {
|
|||||||
getNotes,
|
getNotes,
|
||||||
getNotesByDid,
|
getNotesByDid,
|
||||||
getNotesByDids,
|
getNotesByDids,
|
||||||
|
listAllWebhooks,
|
||||||
listWebhooksByDid,
|
listWebhooksByDid,
|
||||||
type WebhookVerb,
|
type WebhookVerb,
|
||||||
} from "./src/data/db.ts";
|
} from "./src/data/db.ts";
|
||||||
@@ -39,6 +40,32 @@ const requireDidOwnership = async (
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ADMIN_DIDS = new Set(
|
||||||
|
(Deno.env.get("ADMIN_DIDS") ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((d) => d.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
const requireAdmin = async (ctx: AuthCtx): 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 (!ADMIN_DIDS.has(verifiedDid)) {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = { error: "Admin only" };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
const PAGINATION = 20;
|
const PAGINATION = 20;
|
||||||
@@ -117,6 +144,11 @@ 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("/admin/webhooks", async (ctx) => {
|
||||||
|
if (!(await requireAdmin(ctx))) return;
|
||||||
|
ctx.response.body = listAllWebhooks();
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/:did/webhooks", async (ctx) => {
|
router.get("/:did/webhooks", async (ctx) => {
|
||||||
const { did } = ctx.params;
|
const { did } = ctx.params;
|
||||||
if (!(await requireDidOwnership(ctx, did))) return;
|
if (!(await requireDidOwnership(ctx, did))) return;
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ export const listWebhooksByDid = (
|
|||||||
).all<Omit<WebhookSubscriptionRow, "token">>(did);
|
).all<Omit<WebhookSubscriptionRow, "token">>(did);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listAllWebhooks = (): Omit<WebhookSubscriptionRow, "token">[] => {
|
||||||
|
return db.prepare(
|
||||||
|
"SELECT id, did, method, url, verb FROM webhook_subscription ORDER BY did, id",
|
||||||
|
).all<Omit<WebhookSubscriptionRow, "token">>();
|
||||||
|
};
|
||||||
|
|
||||||
export const getWebhooksByDidAndVerb = (
|
export const getWebhooksByDidAndVerb = (
|
||||||
did: string,
|
did: string,
|
||||||
verb: WebhookVerb,
|
verb: WebhookVerb,
|
||||||
|
|||||||
Reference in New Issue
Block a user