| 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 } 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 { packages: repo } = repos(c.env); |
| 27 | const row = await repo.bySlug(slug); |
| 28 | if (!row || row.status !== "active") throw new ApiError(404, "not_found", "No such package."); |
| 29 | const versions = await repo.versions(row.id); |
| 30 | return c.json({ package: toPackageDTO(row), versions }); |
| 31 | }); |
| 32 | |
| 33 | packages.post("/", writeRateLimit, requireAuth, async (c) => { |
| 34 | const user = currentUser(c); |
| 35 | if (!user.emailVerified) { |
| 36 | throw new ApiError(403, "email_unverified", "Verify your email at id.reasonix.io before publishing."); |
| 37 | } |
| 38 | const input = await parseBody(c, PublishSchema); |
| 39 | const { packages: repo, events } = repos(c.env); |
| 40 | const { row, created, version } = await repo.publish(user, input, now()); |
| 41 | // Announce only what is public. A pending submission waits for an admin to |
| 42 | // approve it before it surfaces in the feed or the listing. |
| 43 | if (row.status === "active") { |
| 44 | await events.log({ |
| 45 | type: created ? "publish" : "update", |
| 46 | packageId: row.id, |
| 47 | actorHandle: user.handle, |
| 48 | summary: `${created ? "published" : "updated"} ${row.slug}@${version}`, |
| 49 | now: now(), |
| 50 | }); |
| 51 | } |
| 52 | return c.json({ package: toPackageDTO(row), created, version }, created ? 201 : 200); |
| 53 | }); |
| 54 | |
| 55 | packages.post("/:handle/:name/installed", writeRateLimit, async (c) => { |
| 56 | const slug = `${c.req.param("handle")}/${c.req.param("name")}`; |
| 57 | const { packages: repo, events } = repos(c.env); |
| 58 | const count = await repo.recordInstall(slug); |
| 59 | if (count === null) throw new ApiError(404, "not_found", "No such package."); |
| 60 | const row = await repo.bySlug(slug); |
| 61 | await events.log({ type: "install", packageId: row?.id ?? null, actorHandle: "", summary: `installed ${slug}`, now: now() }); |
| 62 | if (isMilestone(count) && row) { |
| 63 | await events.log({ |
| 64 | type: "milestone", |
| 65 | packageId: row.id, |
| 66 | actorHandle: row.scope_handle, |
| 67 | summary: `${slug} reached ${count} installs`, |
| 68 | now: now(), |
| 69 | }); |
| 70 | } |
| 71 | return c.json({ ok: true, installCount: count }); |
| 72 | }); |
| 73 | |
| 74 | packages.post("/:handle/:name/star", writeRateLimit, requireAuth, async (c) => { |
| 75 | const slug = `${c.req.param("handle")}/${c.req.param("name")}`; |
| 76 | const user = currentUser(c); |
| 77 | const { packages: repo, events } = repos(c.env); |
| 78 | const result = await repo.toggleStar(slug, user.id, now()); |
| 79 | if (result === null) throw new ApiError(404, "not_found", "No such package."); |
| 80 | if (result.starred) { |
| 81 | await events.log({ type: "star", packageId: null, actorHandle: user.handle, summary: `starred ${slug}`, now: now() }); |
| 82 | } |
| 83 | return c.json(result); |
| 84 | }); |
| 85 | |
| 86 | export default packages; |
| 87 |