Files
remanso-jetstream/server.ts
Julien Calixte 34faa10be2 feat(webhooks): add admin endpoint to list every subscription
Adds GET /admin/webhooks gated by an ADMIN_DIDS env-var allowlist of
verified AT Proto DIDs. Fail-closed: if ADMIN_DIDS is unset, the route
always returns 403 — no accidental exposure on deploys that forget it.
2026-05-05 14:07:22 +02:00

249 lines
6.7 KiB
TypeScript

import { Application, Router } from "@oak/oak";
import {
addWebhookSubscription,
deleteWebhookById,
deleteWebhooksByDid,
getNotes,
getNotesByDid,
getNotesByDids,
listAllWebhooks,
listWebhooksByDid,
type WebhookVerb,
} from "./src/data/db.ts";
import { authenticateRequest } from "./src/auth/verify.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 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 PAGINATION = 20;
const GITHUB_CLIENT_ID = Deno.env.get("GITHUB_CLIENT_ID") ?? "";
const GITHUB_CLIENT_SECRET = Deno.env.get("GITHUB_CLIENT_SECRET") ?? "";
router.get("/", (ctx) => {
ctx.response.body = "Hello world";
});
router.get("/auth/github", async (ctx) => {
const code = ctx.request.url.searchParams.get("code");
const type = ctx.request.url.searchParams.get("type");
if (!code) {
ctx.response.status = 400;
ctx.response.body = { error: "code is required" };
return;
}
const params: Record<string, string> = {
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
};
if (type === "refresh") {
params.grant_type = "refresh_token";
params.refresh_token = code;
} else {
params.code = code;
}
const response = await fetch(
"https://github.com/login/oauth/access_token",
{
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(params),
},
);
ctx.response.status = response.status;
ctx.response.body = await response.json();
});
router.get("/health", (ctx) => {
ctx.response.body = { status: "ok" };
});
router.get("/notes", (ctx) => {
const cursor = ctx.request.url.searchParams.get("cursor") ?? undefined;
const limit = Number(ctx.request.url.searchParams.get("limit")) || PAGINATION;
ctx.response.body = getNotes(cursor, limit);
});
router.get("/:did/notes", (ctx) => {
const { did } = ctx.params;
const cursor = ctx.request.url.searchParams.get("cursor") ?? undefined;
const limit = Number(ctx.request.url.searchParams.get("limit")) || PAGINATION;
ctx.response.body = getNotesByDid(did, cursor, limit);
});
router.post("/notes/feed", async (ctx) => {
const body = await ctx.request.body.json();
const { dids, cursor, limit } = body ?? {};
if (!Array.isArray(dids) || dids.length === 0) {
ctx.response.status = 400;
ctx.response.body = { error: "dids must be a non-empty array" };
return;
}
ctx.response.body = getNotesByDids(dids, cursor, Number(limit) || PAGINATION);
});
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) => {
const { did } = ctx.params;
if (!(await requireDidOwnership(ctx, did))) return;
ctx.response.body = listWebhooksByDid(did);
});
router.post("/:did/webhooks", async (ctx) => {
const { did } = ctx.params;
if (!(await requireDidOwnership(ctx, did))) return;
const body = await ctx.request.body.json();
const { method, url, token, verb } = body ?? {};
if (!method || !url) {
ctx.response.status = 400;
ctx.response.body = { error: "method and url are required" };
return;
}
if (verb !== undefined && !ALLOWED_VERBS.includes(verb)) {
ctx.response.status = 400;
ctx.response.body = {
error: `verb must be one of ${ALLOWED_VERBS.join(", ")}`,
};
return;
}
const verbsToInsert: WebhookVerb[] = verb ? [verb] : ["create", "delete"];
const subscriptions = verbsToInsert.map((v) =>
addWebhookSubscription({ did, method, url, token, verb: v })
);
ctx.response.status = 201;
ctx.response.body = subscriptions;
});
router.delete("/:did/webhooks", async (ctx) => {
const { did } = ctx.params;
if (!(await requireDidOwnership(ctx, did))) return;
deleteWebhooksByDid(did);
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) => {
// const { did, rkey } = ctx.params;
// let verifiedDid: string;
// 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 delete your own notes" };
// return;
// }
// deleteNote({ did, rkey });
// ctx.response.status = 204;
// })
const app = new Application();
app.use(async (ctx, next) => {
ctx.response.headers.set("Access-Control-Allow-Origin", "*");
ctx.response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS",
);
ctx.response.headers.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization",
);
if (ctx.request.method === "OPTIONS") {
ctx.response.status = 204;
return;
}
await next();
});
app.use(router.routes());
app.use(router.allowedMethods());
log("[server] listening on port 8080");
app.listen({ port: 8080 });