| 1 | import { Hono } from "hono"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import { requireAccount, requireAccountScope, type AppContext, type AppVars } from "../auth/principal.js"; |
| 4 | import type { Account } from "../lib/directory.js"; |
| 5 | import { EarlyAccess, REQUEST_STATES, declineRequest, deleteRequest, inviteRequest, requestJson, type RequestState } from "../lib/early-access.js"; |
| 6 | import { HttpError } from "../lib/util.js"; |
| 7 | |
| 8 | /** |
| 9 | * Early access requests over the REST API (lib/early-access.ts), for site administrators only. |
| 10 | * To anyone else these endpoints don't exist (404), like the requests themselves. |
| 11 | */ |
| 12 | export const earlyAccessApi = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3"); |
| 13 | |
| 14 | const siteAdmin = (c: AppContext, scope: "read" | "admin"): Account => { |
| 15 | const a = requireAccount(c); |
| 16 | if (!a.site_admin || a.kind !== "user") throw new HttpError(404, "not found"); |
| 17 | requireAccountScope(c, scope); |
| 18 | return a; |
| 19 | }; |
| 20 | |
| 21 | const audit = (c: AppContext) => (e: Parameters<AppContext["var"]["dir"]["audit"]>[0]) => c.get("dir").audit(e); |
| 22 | |
| 23 | earlyAccessApi.get("/diffinery/admin/early-access", async (c) => { |
| 24 | siteAdmin(c, "read"); |
| 25 | const s = c.req.query("state"); |
| 26 | if (s && !REQUEST_STATES.includes(s as RequestState)) throw new HttpError(422, `state must be one of ${REQUEST_STATES.join(", ")}`); |
| 27 | const n = Number(c.req.query("per_page") ?? 100); |
| 28 | const page = Math.max(1, Math.min(1000, Number(c.req.query("page") ?? 1) || 1)); |
| 29 | const limit = Number.isFinite(n) ? Math.min(Math.max(Math.trunc(n), 1), 500) : 100; |
| 30 | const { items } = await new EarlyAccess(c.env.DB).list({ state: (s as RequestState) || undefined, q: c.req.query("q") ?? "", limit, offset: (page - 1) * limit }); |
| 31 | return c.json(items.map((r) => requestJson(c.env, r))); |
| 32 | }); |
| 33 | |
| 34 | earlyAccessApi.get("/diffinery/admin/early-access/:id", async (c) => { |
| 35 | siteAdmin(c, "read"); |
| 36 | const r = await new EarlyAccess(c.env.DB).get(c.req.param("id")); |
| 37 | if (!r) throw new HttpError(404, "no early access request with that id"); |
| 38 | return c.json(requestJson(c.env, r)); |
| 39 | }); |
| 40 | |
| 41 | /** Send the request's address the instance's invitation email. Audited. */ |
| 42 | earlyAccessApi.post("/diffinery/admin/early-access/:id/invite", async (c) => { |
| 43 | const me = siteAdmin(c, "admin"); |
| 44 | return c.json(requestJson(c.env, await inviteRequest(c.env, c.get("dir"), audit(c), me, c.req.param("id"), "api"))); |
| 45 | }); |
| 46 | |
| 47 | /** Decline a request (`{"state": "declined"}`) or put a declined one back (`{"state": "confirmed"}`). Audited; nobody is emailed. */ |
| 48 | earlyAccessApi.patch("/diffinery/admin/early-access/:id", async (c) => { |
| 49 | const me = siteAdmin(c, "admin"); |
| 50 | let b: { state?: unknown }; |
| 51 | try { |
| 52 | b = (await c.req.json()) as typeof b; |
| 53 | } catch { |
| 54 | throw new HttpError(400, "request body must be JSON"); |
| 55 | } |
| 56 | if (!b || typeof b !== "object" || (b.state !== "declined" && b.state !== "confirmed")) throw new HttpError(422, 'send {"state": "declined"} to decline, or {"state": "confirmed"} to put a declined request back'); |
| 57 | return c.json(requestJson(c.env, await declineRequest(c.env.DB, audit(c), me, c.req.param("id"), b.state === "confirmed", "api"))); |
| 58 | }); |
| 59 | |
| 60 | /** Delete a request and its daily-limit rows (data removal). Audited without its content. */ |
| 61 | earlyAccessApi.delete("/diffinery/admin/early-access/:id", async (c) => { |
| 62 | const me = siteAdmin(c, "admin"); |
| 63 | await deleteRequest(c.env.DB, audit(c), me, c.req.param("id"), "api"); |
| 64 | return c.body(null, 204); |
| 65 | }); |