| 1 | import { Hono } from "hono"; |
| 2 | import type { AppEnv } from "../env"; |
| 3 | import { toPackageDTO } from "../types"; |
| 4 | import { repos } from "../db"; |
| 5 | import { requireAuth, currentUser } from "../http/auth"; |
| 6 | import { writeRateLimit } from "../http/ratelimit"; |
| 7 | import { ApiError } from "../http/errors"; |
| 8 | import { parseBody, parseQuery, PublishSchema, ListQuerySchema, VersionQuerySchema } from "../lib/validation"; |
| 9 | |
| 10 | const packages = new Hono<AppEnv>(); |
| 11 | |
| 12 | const now = () => new Date().toISOString(); |
| 13 | |
| 14 | // Install-count thresholds worth announcing in the activity feed. |
| 15 | const MILESTONES = new Set([10, 50, 100, 500, 1000]); |
| 16 | const isMilestone = (n: number) => MILESTONES.has(n) || (n >= 1000 && n % 1000 === 0); |
| 17 | |
| 18 | packages.get("/", async (c) => { |
| 19 | const q = parseQuery(c, ListQuerySchema); |
| 20 | const rows = await repos(c.env).packages.list({ ...q, now: now() }); |
| 21 | return c.json({ packages: rows.map(toPackageDTO), limit: q.limit, offset: q.offset }); |
| 22 | }); |
| 23 | |
| 24 | packages.get("/:handle/:name", async (c) => { |
| 25 | const slug = `${c.req.param("handle")}/${c.req.param("name")}`; |
| 26 | const page = parseQuery(c, VersionQuerySchema); |
| 27 | const { packages: repo } = repos(c.env); |
| 28 | const row = await repo.bySlug(slug); |
| 29 | if (!row || row.status !== "active") throw new ApiError(404, "not_found", "No such package."); |
| 30 | const versions = await repo.versions(row.id, page); |
| 31 | return c.json({ package: toPackageDTO(row), ...versions }); |
| 32 | }); |
| 33 | |
| 34 | packages.post("/", writeRateLimit, requireAuth, async (c) => { |
| 35 | const user = currentUser(c); |
| 36 | if (!user.emailVerified) { |
| 37 | throw new ApiError(403, "email_unverified", "Verify your email at id.reasonix.io before publishing."); |
| 38 | } |
| 39 | const input = await parseBody(c, PublishSchema); |
| 40 | const { packages: repo, events } = repos(c.env); |
| 41 | const { row, created, version } = await repo.publish(user, input, now()); |
| 42 | // Announce only what is public. A pending submission waits for an admin to |
| 43 | // approve it before it surfaces in the feed or the listing. |
| 44 | if (row.status === "active") { |
| 45 | await events.log({ |
| 46 | type: created ? "publish" : "update", |
| 47 | packageId: row.id, |
| 48 | actorHandle: user.handle, |
| 49 | summary: `${created ? "published" : "updated"} ${row.slug}@${version}`, |
| 50 | now: now(), |
| 51 | }); |
| 52 | } |
| 53 | return c.json({ package: toPackageDTO(row), created, version }, created ? 201 : 200); |
| 54 | }); |
| 55 | |
| 56 | packages.post("/:handle/:name/installed", writeRateLimit, async (c) => { |
| 57 | const slug = `${c.req.param("handle")}/${c.req.param("name")}`; |
| 58 | const { packages: repo, events } = repos(c.env); |
| 59 | const result = await repo.recordInstall(slug, now()); |
| 60 | if (result === null) throw new ApiError(404, "not_found", "No such package."); |
| 61 | if (isMilestone(result.count)) { |
| 62 | await events.log({ |
| 63 | type: "milestone", |
| 64 | packageId: result.packageId, |
| 65 | actorHandle: result.scopeHandle, |
| 66 | summary: `${slug} reached ${result.count} installs`, |
| 67 | now: now(), |
| 68 | }); |
| 69 | } |
| 70 | return c.json({ ok: true, installCount: result.count }); |
| 71 | }); |
| 72 | |
| 73 | packages.post("/:handle/:name/star", writeRateLimit, requireAuth, async (c) => { |
| 74 | const slug = `${c.req.param("handle")}/${c.req.param("name")}`; |
| 75 | const user = currentUser(c); |
| 76 | const { packages: repo, events } = repos(c.env); |
| 77 | const result = await repo.toggleStar(slug, user.id, now()); |
| 78 | if (result === null) throw new ApiError(404, "not_found", "No such package."); |
| 79 | if (result.starred) { |
| 80 | await events.log({ type: "star", packageId: null, actorHandle: user.handle, summary: `starred ${slug}`, now: now() }); |
| 81 | } |
| 82 | return c.json(result); |
| 83 | }); |
| 84 | |
| 85 | export default packages; |
| 86 |