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