Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

packages-api.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

packages-api.tsBlame608 lines · 2 contributors
25a91a6Claude1/**
2 * npm-compatible package registry HTTP endpoints (Block C2).
3 *
4 * Two surfaces on one sub-app:
5 * 1) /api/packages/... — JSON helpers the UI uses
6 * 2) /npm/... — the actual npm client protocol
7 *
8 * The npm client URL-encodes scoped names like `@acme/foo` as
9 * `@acme%2Ffoo`, and also may send them un-encoded. Both work because we
10 * parse the full tail of the path ourselves instead of relying on Hono's
11 * parameter extraction (which struggles with `@` and `/` in names).
12 */
13
14import { Hono } from "hono";
15import { and, desc, eq } from "drizzle-orm";
16import { db } from "../db";
17import {
18 packages,
19 packageVersions,
20 packageTags,
21 repositories,
22 users,
23} from "../db/schema";
24import type { Package, PackageVersion } from "../db/schema";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
88f5bd1ccantynz-alt27import {
28 canReadPackage,
29 canReadRepoPackages,
30 isRepoMember,
31} from "../lib/package-access";
25a91a6Claude32import { audit } from "../lib/notify";
33import {
34 parsePackageName,
35 computeShasum,
36 computeIntegrity,
37 buildPackument,
38 resolveRepoFromPackageJson,
39 tarballFilename,
40} from "../lib/packages";
41
42const api = new Hono<AuthEnv>();
43api.use("*", softAuth);
44
45// ---------------------------------------------------------------------------
46// Helpers
47// ---------------------------------------------------------------------------
48
49type NpmPublishBody = {
50 name?: string;
51 "dist-tags"?: Record<string, string>;
52 versions?: Record<string, Record<string, unknown>>;
53 _attachments?: Record<
54 string,
55 { content_type?: string; data: string; length?: number }
56 >;
57};
58
59/**
60 * Extract the package name and optional trailing segment from a path like
61 * /npm/@scope/foo
62 * /npm/@scope%2Ffoo
63 * /npm/foo
64 * /npm/@scope/foo/-/foo-1.0.0.tgz
65 * /npm/foo/-/foo-1.0.0.tgz
66 * /npm/foo/-rev/42
67 */
68function parseNpmPath(path: string): {
69 nameRaw: string;
70 tail: string | null;
71 revTail: string | null;
72} {
73 const rest = path.replace(/^\/+/, "").replace(/^npm\/+/, "");
74
75 // Split on "/-/" (tarball endpoint) first.
76 const dashIdx = rest.indexOf("/-/");
77 if (dashIdx !== -1) {
78 return {
79 nameRaw: rest.slice(0, dashIdx),
80 tail: rest.slice(dashIdx + 3),
81 revTail: null,
82 };
83 }
84 // "-rev" style (npm unpublish).
85 const revIdx = rest.indexOf("/-rev/");
86 if (revIdx !== -1) {
87 return {
88 nameRaw: rest.slice(0, revIdx),
89 tail: null,
90 revTail: rest.slice(revIdx + 6),
91 };
92 }
93 return { nameRaw: rest, tail: null, revTail: null };
94}
95
96function baseUrlFrom(req: Request): string {
97 try {
98 const u = new URL(req.url);
99 return `${u.protocol}//${u.host}`;
100 } catch {
101 return "";
102 }
103}
104
105async function loadRepo(owner: string, repo: string) {
106 const [row] = await db
107 .select({
108 id: repositories.id,
109 name: repositories.name,
110 ownerId: repositories.ownerId,
0316dbbClaude111 isPrivate: repositories.isPrivate,
25a91a6Claude112 })
113 .from(repositories)
114 .innerJoin(users, eq(repositories.ownerId, users.id))
115 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
116 .limit(1);
117 return row || null;
118}
119
120async function loadPackage(
121 repoId: string,
122 scope: string | null,
123 name: string
124): Promise<Package | null> {
125 const conds = [
126 eq(packages.repositoryId, repoId),
127 eq(packages.ecosystem, "npm"),
128 eq(packages.name, name),
129 ];
130 const rows = await db
131 .select()
132 .from(packages)
133 .where(and(...conds))
134 .limit(20);
135 // Manual scope equality because drizzle's eq + null is awkward.
136 const match = rows.find((p) => (p.scope ?? null) === (scope ?? null));
137 return match || null;
138}
139
140async function loadPackageByName(
141 scope: string | null,
142 name: string
143): Promise<Package | null> {
144 const rows = await db
145 .select()
146 .from(packages)
147 .where(and(eq(packages.ecosystem, "npm"), eq(packages.name, name)))
148 .limit(50);
149 const match = rows.find((p) => (p.scope ?? null) === (scope ?? null));
150 return match || null;
151}
152
153// ---------------------------------------------------------------------------
154// UI-facing JSON helpers
155// ---------------------------------------------------------------------------
156
157api.get("/api/packages/:owner/:repo", async (c) => {
158 const { owner, repo } = c.req.param();
159 try {
160 const repoRow = await loadRepo(owner, repo);
161 if (!repoRow) return c.json({ error: "repo not found" }, 404);
88f5bd1ccantynz-alt162 if (!(await canReadRepoPackages(repoRow, c.get("user")?.id ?? null))) {
163 return c.json({ error: "repo not found" }, 404);
164 }
25a91a6Claude165 const rows = await db
166 .select()
167 .from(packages)
168 .where(
169 and(
170 eq(packages.repositoryId, repoRow.id),
171 eq(packages.ecosystem, "npm")
172 )
173 )
174 .orderBy(desc(packages.updatedAt));
88f5bd1ccantynz-alt175 // A public repo can still hold individually-private packages. Listing is
176 // repo-scoped, so drop those unless the viewer is a member (owner or
177 // accepted collaborator) rather than a public-fallback reader.
178 const visible = (await isRepoMember(repoRow.id, c.get("user")?.id ?? null))
179 ? rows
180 : rows.filter((r) => r.visibility !== "private");
181 return c.json({ packages: visible });
25a91a6Claude182 } catch (err) {
183 console.error("[packages] list:", err);
184 return c.json({ error: "service unavailable" }, 503);
185 }
186});
187
188api.get("/api/packages/:owner/:repo/:pkgName{.+}", async (c) => {
189 const { owner, repo, pkgName } = c.req.param();
190 const parsed = parsePackageName(pkgName);
191 if (!parsed) return c.json({ error: "invalid package name" }, 400);
192 try {
193 const repoRow = await loadRepo(owner, repo);
194 if (!repoRow) return c.json({ error: "repo not found" }, 404);
195 const pkg = await loadPackage(repoRow.id, parsed.scope, parsed.name);
196 if (!pkg) return c.json({ error: "package not found" }, 404);
88f5bd1ccantynz-alt197 const canRead = await canReadRepoPackages(
198 repoRow,
199 c.get("user")?.id ?? null,
200 pkg.visibility
201 );
202 if (!canRead) return c.json({ error: "package not found" }, 404);
25a91a6Claude203 const versions = await db
204 .select()
205 .from(packageVersions)
206 .where(eq(packageVersions.packageId, pkg.id))
207 .orderBy(desc(packageVersions.publishedAt));
208 const tags = await db
209 .select()
210 .from(packageTags)
211 .where(eq(packageTags.packageId, pkg.id));
212 return c.json({ package: pkg, versions, tags });
213 } catch (err) {
214 console.error("[packages] detail:", err);
215 return c.json({ error: "service unavailable" }, 503);
216 }
217});
218
219// Yank endpoint (owner-only; marks a version as yanked but leaves it
220// downloadable so existing installs don't break — matches npm semantics).
221api.post(
222 "/api/packages/:owner/:repo/:pkgName/:version/yank",
223 requireAuth,
224 async (c) => {
225 const user = c.get("user")!;
226 const { owner, repo, pkgName, version } = c.req.param();
227 const parsed = parsePackageName(pkgName);
228 if (!parsed) return c.json({ error: "invalid package name" }, 400);
229 try {
230 const repoRow = await loadRepo(owner, repo);
231 if (!repoRow) return c.json({ error: "repo not found" }, 404);
232 if (repoRow.ownerId !== user.id) {
233 return c.json({ error: "forbidden" }, 403);
234 }
235 const pkg = await loadPackage(repoRow.id, parsed.scope, parsed.name);
236 if (!pkg) return c.json({ error: "package not found" }, 404);
237
238 await db
239 .update(packageVersions)
240 .set({ yanked: true, yankedReason: "yanked by owner" })
241 .where(
242 and(
243 eq(packageVersions.packageId, pkg.id),
244 eq(packageVersions.version, version)
245 )
246 );
247
248 await audit({
249 userId: user.id,
250 repositoryId: repoRow.id,
251 action: "package.yank",
252 targetType: "package_version",
253 targetId: pkg.id,
254 metadata: { version, name: parsed.full },
255 });
256
257 return c.json({ ok: true });
258 } catch (err) {
259 console.error("[packages] yank:", err);
260 return c.json({ error: "service unavailable" }, 503);
261 }
262 }
263);
264
265// ---------------------------------------------------------------------------
266// npm protocol: packument + tarball
267// ---------------------------------------------------------------------------
268
269api.get("/npm/*", async (c) => {
270 const { nameRaw, tail } = parseNpmPath(c.req.path);
271 if (!nameRaw) return c.json({ error: "not found" }, 404);
272
273 const parsed = parsePackageName(nameRaw);
274 if (!parsed) return c.json({ error: "invalid package name" }, 400);
275
276 try {
277 const pkg = await loadPackageByName(parsed.scope, parsed.name);
278 if (!pkg) return c.json({ error: "not found" }, 404);
279
88f5bd1ccantynz-alt280 // Private packages are indistinguishable from missing ones to a viewer
281 // without read access — same 404, no existence disclosure.
282 const viewer = c.get("user");
283 if (!(await canReadPackage(pkg, viewer?.id ?? null))) {
284 return c.json({ error: "not found" }, 404);
285 }
286
25a91a6Claude287 // Tarball request?
288 if (tail) {
289 const filename = decodeURIComponent(tail);
290 const versions = await db
291 .select()
292 .from(packageVersions)
293 .where(eq(packageVersions.packageId, pkg.id));
294
295 // Match by filename → version. Filename is `<name>-<version>.tgz`.
296 const match = versions.find(
297 (v) => tarballFilename(parsed, v.version) === filename
298 );
299 if (!match || !match.tarball) {
300 return c.json({ error: "tarball not found" }, 404);
301 }
302 const bytes = Buffer.from(match.tarball, "base64");
303 return new Response(bytes, {
304 status: 200,
305 headers: {
306 "Content-Type": "application/octet-stream",
307 "Content-Length": String(bytes.length),
308 "Cache-Control": "public, max-age=31536000, immutable",
309 },
310 });
311 }
312
313 // Packument request.
314 const versions = await db
315 .select()
316 .from(packageVersions)
317 .where(eq(packageVersions.packageId, pkg.id))
318 .orderBy(desc(packageVersions.publishedAt));
319 const tags = await db
320 .select()
321 .from(packageTags)
322 .where(eq(packageTags.packageId, pkg.id));
323
324 const doc = buildPackument(pkg, versions, tags, baseUrlFrom(c.req.raw));
325 return c.json(doc);
326 } catch (err) {
327 console.error("[packages] npm get:", err);
328 return c.json({ error: "service unavailable" }, 503);
329 }
330});
331
332// ---------------------------------------------------------------------------
333// npm protocol: publish (`npm publish` → PUT /<name>)
334// ---------------------------------------------------------------------------
335
336api.put("/npm/*", requireAuth, async (c) => {
337 const user = c.get("user")!;
338 const { nameRaw } = parseNpmPath(c.req.path);
339 const parsed = parsePackageName(nameRaw);
340 if (!parsed) {
341 return c.json({ error: "invalid package name" }, 400);
342 }
343
344 let body: NpmPublishBody;
345 try {
346 body = await c.req.json<NpmPublishBody>();
347 } catch {
348 return c.json({ error: "invalid JSON body" }, 400);
349 }
350
351 const versionsObj = body.versions || {};
352 const versionKeys = Object.keys(versionsObj);
353 if (versionKeys.length === 0) {
354 return c.json({ error: "no version in payload" }, 400);
355 }
356 // npm always sends exactly one version per publish.
357 const version = versionKeys[0];
358 const versionMeta = versionsObj[version] || {};
359
360 const attachments = body._attachments || {};
361 const attachKeys = Object.keys(attachments);
362 if (attachKeys.length === 0) {
363 return c.json({ error: "no tarball attachment" }, 400);
364 }
365 const attachment = attachments[attachKeys[0]];
366 if (!attachment || !attachment.data) {
367 return c.json({ error: "empty tarball attachment" }, 400);
368 }
369
370 const tarballBytes = Buffer.from(attachment.data, "base64");
371 if (tarballBytes.length === 0) {
372 return c.json({ error: "tarball decoded to zero bytes" }, 400);
373 }
374
375 // Resolve owner+repo from the metadata's repository.url.
376 const repoRef = resolveRepoFromPackageJson(versionMeta);
377 if (!repoRef) {
378 return c.json(
379 {
380 error:
381 "repository.url must point to a gluecron repo you own (e.g. http://host/:owner/:repo.git)",
382 },
383 400
384 );
385 }
386
387 try {
388 const repoRow = await loadRepo(repoRef.owner, repoRef.repo);
389 if (!repoRow) {
390 return c.json(
391 { error: `repo ${repoRef.owner}/${repoRef.repo} not found` },
392 404
393 );
394 }
395 if (repoRow.ownerId !== user.id) {
396 return c.json(
397 { error: "you do not own the repository named in repository.url" },
398 403
399 );
400 }
401
402 // Upsert the package row.
403 let pkg = await loadPackage(repoRow.id, parsed.scope, parsed.name);
404 if (!pkg) {
405 const description =
406 typeof versionMeta.description === "string"
407 ? (versionMeta.description as string)
408 : null;
409 const homepage =
410 typeof versionMeta.homepage === "string"
411 ? (versionMeta.homepage as string)
412 : null;
413 const license =
414 typeof versionMeta.license === "string"
415 ? (versionMeta.license as string)
416 : null;
417 const readme =
418 typeof (body as Record<string, unknown>).readme === "string"
419 ? ((body as Record<string, unknown>).readme as string)
420 : typeof versionMeta.readme === "string"
421 ? (versionMeta.readme as string)
422 : null;
423
424 const [inserted] = await db
425 .insert(packages)
426 .values({
427 repositoryId: repoRow.id,
428 ecosystem: "npm",
429 scope: parsed.scope,
430 name: parsed.name,
431 description,
432 readme,
433 homepage,
434 license,
0316dbbClaude435 visibility: repoRow.isPrivate ? "private" : "public",
25a91a6Claude436 })
437 .returning();
438 pkg = inserted;
439 }
440 if (!pkg) {
441 return c.json({ error: "failed to create package" }, 503);
442 }
443
444 // Reject duplicate version.
445 const [existing] = await db
446 .select()
447 .from(packageVersions)
448 .where(
449 and(
450 eq(packageVersions.packageId, pkg.id),
451 eq(packageVersions.version, version)
452 )
453 )
454 .limit(1);
455 if (existing) {
456 return c.json(
457 {
458 error: `You cannot publish over the previously published version ${version}.`,
459 },
460 409
461 );
462 }
463
464 const shasum = computeShasum(tarballBytes);
465 const integrity = computeIntegrity(tarballBytes);
466
467 const [insertedVersion] = await db
468 .insert(packageVersions)
469 .values({
470 packageId: pkg.id,
471 version,
472 shasum,
473 integrity,
474 sizeBytes: tarballBytes.length,
475 metadata: JSON.stringify(versionMeta),
476 tarball: tarballBytes.toString("base64"),
477 publishedBy: user.id,
478 })
479 .returning();
480
481 // Upsert "latest" dist-tag (and any other tags from the payload).
482 const distTags = body["dist-tags"] || { latest: version };
483 for (const [tag, tagVersion] of Object.entries(distTags)) {
484 if (tagVersion !== version) continue; // Only set tags pointing at this publish.
485 const [existingTag] = await db
486 .select()
487 .from(packageTags)
488 .where(
489 and(
490 eq(packageTags.packageId, pkg.id),
491 eq(packageTags.tag, tag)
492 )
493 )
494 .limit(1);
495 if (existingTag) {
496 await db
497 .update(packageTags)
498 .set({ versionId: insertedVersion.id, updatedAt: new Date() })
499 .where(eq(packageTags.id, existingTag.id));
500 } else {
501 await db.insert(packageTags).values({
502 packageId: pkg.id,
503 tag,
504 versionId: insertedVersion.id,
505 });
506 }
507 }
508
509 // Update package bookkeeping fields on every publish (license/description
510 // may evolve version-to-version; we keep the most recent).
511 await db
512 .update(packages)
513 .set({
514 updatedAt: new Date(),
515 description:
516 typeof versionMeta.description === "string"
517 ? (versionMeta.description as string)
518 : pkg.description,
519 homepage:
520 typeof versionMeta.homepage === "string"
521 ? (versionMeta.homepage as string)
522 : pkg.homepage,
523 license:
524 typeof versionMeta.license === "string"
525 ? (versionMeta.license as string)
526 : pkg.license,
527 })
528 .where(eq(packages.id, pkg.id));
529
530 await audit({
531 userId: user.id,
532 repositoryId: repoRow.id,
533 action: "package.publish",
534 targetType: "package_version",
535 targetId: insertedVersion.id,
536 metadata: {
537 name: parsed.full,
538 version,
539 size: tarballBytes.length,
540 },
541 });
542
543 return c.json({ ok: true, id: parsed.full, version }, 201);
544 } catch (err) {
545 console.error("[packages] publish:", err);
546 return c.json({ error: "service unavailable" }, 503);
547 }
548});
549
550// npm unpublish: DELETE /npm/<name>/-rev/<rev> — we treat this as a yank.
551api.delete("/npm/*", requireAuth, async (c) => {
552 const user = c.get("user")!;
553 const { nameRaw, revTail } = parseNpmPath(c.req.path);
554 const parsed = parsePackageName(nameRaw);
555 if (!parsed) return c.json({ error: "invalid package name" }, 400);
556 if (!revTail) {
557 return c.json(
558 { error: "unpublish without rev is not supported" },
559 400
560 );
561 }
562
563 try {
564 const pkg = await loadPackageByName(parsed.scope, parsed.name);
565 if (!pkg) return c.json({ error: "not found" }, 404);
566 const [repoRow] = await db
567 .select()
568 .from(repositories)
569 .where(eq(repositories.id, pkg.repositoryId))
570 .limit(1);
571 if (!repoRow || repoRow.ownerId !== user.id) {
572 return c.json({ error: "forbidden" }, 403);
573 }
574
575 // Yank the latest version.
576 const [latest] = await db
577 .select()
578 .from(packageVersions)
579 .where(eq(packageVersions.packageId, pkg.id))
580 .orderBy(desc(packageVersions.publishedAt))
581 .limit(1);
582 if (latest) {
583 await db
584 .update(packageVersions)
585 .set({ yanked: true, yankedReason: "unpublished" })
586 .where(eq(packageVersions.id, latest.id));
587 }
588
589 await audit({
590 userId: user.id,
591 repositoryId: repoRow.id,
592 action: "package.unpublish",
593 targetType: "package",
594 targetId: pkg.id,
595 metadata: { name: parsed.full },
596 });
597
598 return c.json({ ok: true });
599 } catch (err) {
600 console.error("[packages] unpublish:", err);
601 return c.json({ error: "service unavailable" }, 503);
602 }
603});
604
605export default api;
606
607// Re-export helpers internally for the UI route.
608export type { Package, PackageVersion };