Commit

npm on g1t.sh: publish and install @<workspace>/<name> with the npm CLI and a g1t token

- /-/npm/: packuments (full and abbreviated), tarballs, publish (sha512 and sha1 checked, versions never overwritten), dist-tags, deprecate, unpublish (within 72 hours, or with Admin), whoami, ping, Bearer or Basic auth and legacy login. - Linked to the repository its package.json names on g1t.sh, or one named like it; events, audit, storage, limits and soft deletes as for containers; G1T_TOKEN publishes from workflows. - The package page shows the README, npm versions and deprecations, and the real .npmrc lines. Docs: the npm guide. Checked locally with the npm CLI: publish, view, install and require, dist-tag, deprecate, unpublish, refusals for outsiders and anonymous.

syntaqxcommitted Parent8e769b5Browse files
20 files+1595−400/20 viewed
+45−4
741741 checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
742742
743743 [[package]]
744+name = "filetime"
745+version = "0.2.29"
746+source = "registry+https://github.com/rust-lang/crates.io-index"
747+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
748+dependencies = [
749+ "cfg-if",
750+ "libc",
751+]
752+
753+[[package]]
744754 name = "find-msvc-tools"
745755 version = "0.1.14"
746756 source = "registry+https://github.com/rust-lang/crates.io-index"
869879 [[package]]
870880 name = "g1t"
871881 version = "0.1.0"
882+dependencies = [
883+ "base64 0.22.1",
884+ "flate2",
885+ "hex",
886+ "serde_json",
887+ "sha2 0.10.9",
888+ "tar",
889+ "ureq",
890+]
872891
873892 [[package]]
874893 name = "g1t-actions"
9961015 "hmac 0.12.1",
9971016 "serde",
9981017 "serde_json",
1018+ "sha1 0.10.7",
9991019 "sha2 0.10.9",
10001020 "worker",
10011021 ]
10411061 "miniz_oxide",
10421062 "serde",
10431063 "serde_json",
1044− "sha1",
1064+ "sha1 0.11.0",
10451065 "sha2 0.10.9",
10461066 "similar",
10471067 ]
23702390 "salsa20",
23712391 "scrypt",
23722392 "sec1",
2373− "sha1",
2393+ "sha1 0.11.0",
23742394 "sha2 0.11.0",
23752395 "sha3 0.12.0",
23762396 "signature",
27382758
27392759 [[package]]
27402760 name = "sha1"
2761+version = "0.10.7"
2762+source = "registry+https://github.com/rust-lang/crates.io-index"
2763+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
2764+dependencies = [
2765+ "cfg-if",
2766+ "cpufeatures 0.2.17",
2767+ "digest 0.10.7",
2768+]
2769+
2770+[[package]]
2771+name = "sha1"
27412772 version = "0.11.0"
27422773 source = "registry+https://github.com/rust-lang/crates.io-index"
27432774 checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
29332964 "rand_core 0.10.1",
29342965 "rsa",
29352966 "sec1",
2936− "sha1",
2967+ "sha1 0.11.0",
29372968 "sha2 0.11.0",
29382969 "signature",
29392970 "ssh-cipher",
30383069 ]
30393070
30403071 [[package]]
3072+name = "tar"
3073+version = "0.4.46"
3074+source = "registry+https://github.com/rust-lang/crates.io-index"
3075+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
3076+dependencies = [
3077+ "filetime",
3078+ "libc",
3079+]
3080+
3081+[[package]]
30413082 name = "thiserror"
30423083 version = "2.0.21"
30433084 source = "registry+https://github.com/rust-lang/crates.io-index"
32603301 "httparse",
32613302 "log",
32623303 "rand",
3263− "sha1",
3304+ "sha1 0.11.0",
32643305 "thiserror",
32653306 ]
32663307
+1−0
7575 { label: 'Deployments', slug: 'guides/deployments' },
7676 { label: 'Packages', slug: 'guides/packages' },
7777 { label: 'Container images', slug: 'guides/containers' },
78+ { label: 'npm', slug: 'guides/npm' },
7879 { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' },
7980 { label: 'Security', slug: 'guides/security' },
8081 ],
+136−0
1+---
2+title: npm
3+description: Publish and install a workspace's npm packages on g1t.sh, from your machine and from workflows with G1T_TOKEN.
4+---
5+
6+g1t.sh is an npm registry for packages scoped by workspace: `@acme/ui` is
7+the `ui` package of the `acme` workspace. `npm` (and any client that reads
8+`.npmrc`) publishes and installs them with two lines of configuration.
9+
10+```text
11+https://g1t.sh/-/npm/
12+```
13+
14+Packages of other scopes and unscoped ones still come from the registry
15+npm uses by default; only your workspace's scope is pointed at g1t.
16+
17+## Set up `.npmrc`
18+
19+In the project (or in `~/.npmrc` for every project), name g1t as the
20+registry for the workspace's scope, and give it an
21+[access token](https://g1t.sh/settings/tokens):
22+
23+```ini
24+@acme:registry=https://g1t.sh/-/npm/
25+//g1t.sh/-/npm/:_authToken=${G1T_TOKEN}
26+```
27+
28+npm reads `${G1T_TOKEN}` from the environment, so the token itself stays
29+out of the file you commit. A token with full access works; one with scopes
30+needs `packages:read` to install private packages and `packages:write` to
31+publish. Public packages install without a token.
32+
33+Check it:
34+
35+```sh
36+npm whoami --registry https://g1t.sh/-/npm/
37+```
38+
39+`npm login --scope=@acme --auth-type=legacy` also works, with your username
40+and a g1t token (not your password) as the password.
41+
42+## Publish
43+
44+The package's `name` is scoped by its workspace:
45+
46+```json
47+{
48+ "name": "@acme/ui",
49+ "version": "1.0.0",
50+ "repository": "https://g1t.sh/acme/ui"
51+}
52+```
53+
54+```sh
55+npm publish
56+```
57+
58+The first publish makes the package. When its `repository` is a g1t.sh
59+repository of the same workspace, or a repository is named like the
60+package, it is linked to that repository and has its visibility and roles:
61+publishing needs Write on it. Otherwise it is the workspace's, private,
62+and needs the workspace's Write base permission. See
63+[who can see and publish a package](/guides/packages/#who-can-see-and-publish-a-package).
64+
65+A version is published once: publishing a version that is already there is
66+refused, so bump `version` first. `npm publish --tag next` publishes
67+without moving `latest`. The README of the version `latest` points to is
68+shown on the package's page.
69+
70+## Install
71+
72+```sh
73+npm install @acme/ui
74+```
75+
76+Lockfiles record `https://g1t.sh/-/npm/…` tarball addresses and each
77+tarball's `sha512` integrity, which g1t computes when the version is
78+published.
79+
80+## In workflows
81+
82+A workflow's `G1T_TOKEN` is the workspace's own token for the run, and can
83+install and publish the workspace's packages:
84+
85+```yaml
86+jobs:
87+ publish:
88+ runs-on: ubuntu-latest
89+ steps:
90+ - uses: actions/checkout@v4
91+ - uses: actions/setup-node@v4
92+ with:
93+ node-version: 22
94+ - run: |
95+ echo "@acme:registry=https://g1t.sh/-/npm/" >> .npmrc
96+ echo "//g1t.sh/-/npm/:_authToken=\${G1T_TOKEN}" >> .npmrc
97+ - run: npm ci
98+ - run: npm publish
99+ env:
100+ G1T_TOKEN: ${{ secrets.G1T_TOKEN }}
101+```
102+
103+`npm ci` needs the token too when the project depends on private packages;
104+set `G1T_TOKEN` for the whole job instead.
105+
106+## Tags, deprecating and unpublishing
107+
108+```sh
109+npm dist-tag add @acme/ui@1.2.0 stable
110+npm dist-tag ls @acme/ui
111+npm deprecate @acme/ui@1.0.0 "Use 1.2 or later"
112+npm unpublish @acme/ui@1.2.1
113+npm unpublish @acme/ui --force
114+```
115+
116+Moving tags and deprecating need what publishing does. Unpublishing a
117+version needs it too within 72 hours of publishing; after that it needs
118+Admin on the linked repository (an owner, for the workspace's own
119+packages). Deprecate a version instead when people may depend on it.
120+Versions can also be deleted on the package's page.
121+
122+## Size
123+
124+A publish is one request with the tarball inside it, and may hold at most
125+100 MB. Without the [g1t plan](/guides/usage-and-billing/#the-g1t-plan), a
126+workspace's private packages may hold 500 MB and its public ones 10 GB, as
127+for [container images](/guides/containers/#storage-and-pull-limits).
128+
129+## Errors
130+
131+| Error | Means |
132+| --- | --- |
133+| `E401` | No token, or a wrong or expired one. Check `.npmrc` and `npm whoami`. |
134+| `E403` | Signed in, but your role or your token's scopes do not allow it, or the version is already published. The message says which. |
135+| `E404` | No such package, or one you cannot see. A private package needs a token in `.npmrc`. |
136+| `E413` | The publish is over 100 MB. Leave build output and fixtures out with `files` in `package.json` or `.npmignore`. |
+10−6
44 ---
55
66 A workspace can publish packages to g1t and install them from it, beside
7−the code they are built from. Container images come first; npm, Composer,
8−Cargo and Go follow. Each registry speaks its tool's own protocol, so
9−`docker` works with nothing but a login and an address.
7+the code they are built from: container images and npm packages, with
8+Composer, Cargo and Go to follow. Each registry speaks its tool's own
9+protocol, so `docker` and `npm` work with nothing but a login and an
10+address.
1011
1112 | Registry | Address | Guide |
1213 | --- | --- | --- |
1314 | Container images | `g1t.sh/<workspace>/<name>` | [Container images](/guides/containers/) |
15+| npm | `https://g1t.sh/-/npm/`, for the scope `@<workspace>` | [npm](/guides/npm/) |
1416
1517 ## Names
1618
2224
2325 A package is linked to a repository, or belongs to its workspace.
2426
25−- **Linked.** The first push of a package whose name starts with a
27+- **Linked.** The first push of an image whose name starts with a
2628 repository's name (`acme/web`, `acme/web/worker` for the repository
27− `acme/web`) links it to that repository. It then has the repository's
28− visibility and [roles](/guides/access-and-roles/):
29+ `acme/web`) links it to that repository; so does the first publish of
30+ an npm package whose `package.json` `repository` is a g1t.sh repository
31+ of the workspace, or which is named like one (`@acme/web`). It then has
32+ the repository's visibility and [roles](/guides/access-and-roles/):
2933
3034 | | Needs |
3135 | --- | --- |
+10−0
1515 assert.equal(shortDigest("sha256:3f2a9c1b7d0e55aa"), "3f2a9c1b7d0e");
1616 });
1717
18+test("an npm package is installed after .npmrc names its scope's registry, and a token for private ones", () => {
19+ const pkg = { ecosystem: "npm" as const, address: "g1t.sh/-/npm/@acme/ui", name: "ui", workspace: "acme" };
20+ assert.deepEqual(installCommands(pkg, "1.2.0", "ada"), {
21+ registry: "npm config set @acme:registry=https://g1t.sh/-/npm/",
22+ login: "npm config set //g1t.sh/-/npm/:_authToken=YOUR_TOKEN",
23+ install: "npm install @acme/ui@1.2.0",
24+ });
25+ assert.equal(installCommands(pkg, null, "ada").install, "npm install @acme/ui");
26+});
27+
1828 test("a container image is pulled by its address and tag", () => {
1929 const pkg = { ecosystem: "container" as const, address: "g1t.sh/acme/web", name: "web", workspace: "acme" };
2030 assert.deepEqual(installCommands(pkg, "latest", "ada"), {
+9−2
4141 * How to log in and install `pkg` at `version` (a tag or version) with its
4242 * tool. `you` stands in the username a login takes.
4343 */
44−export function installCommands(pkg: Pick<PackageSummary, "ecosystem" | "address" | "name" | "workspace">, version: string | null, you: string): { login: string; install: string } {
44+export function installCommands(
45+ pkg: Pick<PackageSummary, "ecosystem" | "address" | "name" | "workspace">,
46+ version: string | null,
47+ you: string,
48+): { login: string; install: string; registry?: string } {
4549 const host = registryHost(pkg.address);
4650 switch (pkg.ecosystem) {
4751 case "container":
5054 install: `docker pull ${pkg.address}${version ? `:${version}` : ""}`,
5155 };
5256 case "npm":
57+ // The scope's registry (in .npmrc, needed for any install), then the
58+ // token a private package also needs.
5359 return {
54− login: `npm config set @${pkg.workspace}:registry https://${host}/-/npm/`,
60+ registry: `npm config set @${pkg.workspace}:registry=https://${host}/-/npm/`,
61+ login: `npm config set //${host}/-/npm/:_authToken=YOUR_TOKEN`,
5562 install: `npm install @${pkg.workspace}/${pkg.name}${version ? `@${version}` : ""}`,
5663 };
5764 case "composer":
+18−0
1717 }
1818 });
1919
20+test("the npm registry's paths go to the packages service", () => {
21+ for (const path of [
22+ "/-/npm",
23+ "/-/npm/",
24+ "/-/npm/@acme%2fweb",
25+ "/-/npm/@acme/web/-/web-1.0.0.tgz",
26+ "/-/npm/-/package/@acme%2fweb/dist-tags/next",
27+ "/-/npm/-/whoami",
28+ // A package named like a git endpoint is still npm's.
29+ "/-/npm/@acme/info/refs",
30+ ]) {
31+ assert.equal(servicePath(path), "packages", path);
32+ }
33+ for (const path of ["/-/npmx", "/acme/-/npm", "/acme/-/packages"]) {
34+ assert.equal(servicePath(path), null, path);
35+ }
36+});
37+
2038 test("git goes to repos, and everything else is the site's", () => {
2139 assert.equal(servicePath("/acme/web.git/info/refs"), "git");
2240 assert.equal(servicePath("/acme/web/git-receive-pack"), "git");
+3−1
77 const GIT_PATH = /\/(info\/refs|git-upload-pack|git-receive-pack)$/;
88 /** The container registry: OCI Distribution's `/v2/`, and its token endpoint at `/v2/token`. */
99 const REGISTRY_PATH = /^\/v2(?:\/|$)/;
10+/** The npm registry: `/-/npm/`, which `.npmrc` names for a workspace's scope. */
11+const NPM_PATH = /^\/-\/npm(?:\/|$)/;
1012
1113 export type ServicePath = "git" | "packages" | null;
1214
1315 export function servicePath(pathname: string): ServicePath {
14− if (REGISTRY_PATH.test(pathname)) return "packages";
16+ if (REGISTRY_PATH.test(pathname) || NPM_PATH.test(pathname)) return "packages";
1517 if (GIT_PATH.test(pathname)) return "git";
1618 return null;
1719 }
+69−12
1−import { Box, Lock, Package, Trash2 } from "lucide-react";
1+import { BookOpen, Box, Lock, Package, Trash2 } from "lucide-react";
22 import { Form, Link, data, redirect, useNavigation } from "react-router";
33
44 import { ECOSYSTEMS, type Ecosystem, type PackageVersion } from "@g1t/contracts";
55
66 import type { Route } from "./+types/package";
7+import { Markdown } from "../../components/markdown";
78 import { ConfirmDialog } from "../../components/repo-lifecycle";
89 import { Button, CopyLine, ErrorText, TimeAgo } from "../../components/ui";
910 import { Badge } from "../../components/ui/badge";
7273 const outcome = actionData as Outcome | undefined;
7374 const latest = tags.find((tag) => tag.tag === "latest")?.tag ?? tags[0]?.tag ?? null;
7475 const commands = installCommands(pkg, latest, username);
76+ const npm = pkg.ecosystem === "npm";
7577 // Signatures and attestations hang off the images they describe.
7678 const images = versions.filter((version) => !version.subject);
7779 const attached = (digest: string) => versions.filter((version) => version.subject === digest);
9698 )}
9799 <span>{formatBytes(pkg.size)}</span>
98100 <span>
99− {pkg.downloads.toLocaleString("en-US")} {pkg.downloads === 1 ? "pull" : "pulls"}
101+ {pkg.downloads.toLocaleString("en-US")}{" "}
102+ {npm ? (pkg.downloads === 1 ? "download" : "downloads") : pkg.downloads === 1 ? "pull" : "pulls"}
100103 </span>
101104 <span>
102105 Updated <TimeAgo at={pkg.updated_at} />
109112 {outcome?.message && <p className="text-sm text-accent">{outcome.message}</p>}
110113
111114 <section className="space-y-2">
112− <h2 className="text-sm font-semibold">Pull it</h2>
115+ <h2 className="text-sm font-semibold">{npm ? "Install it" : "Pull it"}</h2>
116+ {commands.registry && <CopyLine prompt text={commands.registry} />}
113117 {pkg.visibility === "private" && <CopyLine prompt text={commands.login} />}
114118 <CopyLine prompt text={commands.install} />
119+ {npm && pkg.visibility === "private" && (
120+ <p className="text-xs text-faint">
121+ Put an{" "}
122+ <Link to="/settings/tokens" className="text-muted hover:text-fg">
123+ access token
124+ </Link>{" "}
125+ with <code className="font-mono">packages:read</code> in place of YOUR_TOKEN.
126+ </p>
127+ )}
115128 </section>
116129
130+ {detail.readme && (
131+ <section className="overflow-hidden rounded-xl border border-line">
132+ <h2 className="flex items-center gap-2 border-b border-line bg-surface px-4 py-2.5 text-sm font-medium">
133+ <BookOpen size={15} className="text-faint" />
134+ README
135+ </h2>
136+ <div className="p-6">
137+ <Markdown
138+ source={detail.readme}
139+ repo={pkg.repo ? { namespace: pkg.repo.namespace, name: pkg.repo.name } : undefined}
140+ />
141+ </div>
142+ </section>
143+ )}
144+
117145 <section className="space-y-3">
118146 <h2 className="text-sm font-semibold">
119147 Versions <span className="font-normal text-faint">{images.length}</span>
123151 ) : (
124152 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
125153 {images.map((version) => (
126− <VersionRow key={version.id} version={version} attached={attached(version.digest)} canDelete={permissions.delete} />
154+ <VersionRow key={version.id} version={version} attached={attached(version.digest)} canDelete={permissions.delete} npm={npm} />
127155 ))}
128156 </ul>
129157 )}
134162 );
135163 }
136164
137−function VersionRow({ version, attached, canDelete }: { version: PackageVersion; attached: PackageVersion[]; canDelete: boolean }) {
165+function VersionRow({
166+ version,
167+ attached,
168+ canDelete,
169+ npm,
170+}: {
171+ version: PackageVersion;
172+ attached: PackageVersion[];
173+ canDelete: boolean;
174+ npm: boolean;
175+}) {
138176 return (
139177 <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3">
140178 <div className="min-w-0 grow space-y-1">
141179 <div className="flex flex-wrap items-center gap-1.5">
142− {version.tags.length > 0 ? (
180+ {npm && <span className="font-mono text-sm font-medium">{version.version}</span>}
181+ {npm ? (
182+ version.tags.map((tag) => (
183+ <Badge key={tag} tone={tag === "latest" ? "accent" : "neutral"} className="font-mono">
184+ {tag}
185+ </Badge>
186+ ))
187+ ) : version.tags.length > 0 ? (
143188 version.tags.map((tag) => (
144189 <Badge key={tag} tone={tag === "latest" ? "accent" : "neutral"} className="font-mono">
145190 {tag}
148193 ) : (
149194 <span className="text-xs text-faint">Untagged</span>
150195 )}
151− <code className="font-mono text-xs text-muted" title={version.digest}>
152− {shortDigest(version.digest)}
153− </code>
196+ {!npm && (
197+ <code className="font-mono text-xs text-muted" title={version.digest}>
198+ {shortDigest(version.digest)}
199+ </code>
200+ )}
201+ {version.deprecated && (
202+ <Badge tone="neutral" title={version.deprecated}>
203+ Deprecated
204+ </Badge>
205+ )}
154206 </div>
207+ {version.deprecated && <p className="text-xs text-muted">{version.deprecated}</p>}
155208 <p className="flex flex-wrap gap-x-3 text-xs text-faint tabular-nums">
156209 <span>{formatBytes(version.size)}</span>
157210 {version.platforms.length > 0 && <span>{version.platforms.join(", ")}</span>}
169222 {canDelete && (
170223 <ConfirmDialog
171224 intent="delete-version"
172− fields={{ version: version.digest }}
173− title={`Delete ${version.tags[0] ?? shortDigest(version.digest)}?`}
174− description="Anyone pulling it by this tag or digest gets an error from then on."
225+ fields={{ version: npm ? version.version : version.digest }}
226+ title={`Delete ${npm ? version.version : (version.tags[0] ?? shortDigest(version.digest))}?`}
227+ description={
228+ npm
229+ ? "Anyone installing this version gets an error from then on."
230+ : "Anyone pulling it by this tag or digest gets an error from then on."
231+ }
175232 submit="Delete version"
176233 busy="Deleting…"
177234 trigger={(open) => (
+2−1
3333 // Its answer goes back to the git client as it is: a repository under a
3434 // renamed workspace's old name answers with a 301, which git follows and
3535 // must see, so the redirect is never followed here.
36− // The container registry (`docker login g1t.sh`) is the packages
36+ // The container registry (`docker login g1t.sh`) and the npm registry
37+ // (`g1t.sh/-/npm/`) are the packages
3738 // service's, handed over the same way.
3839 const service = servicePath(pathname);
3940 if (service === "git") {
+6−0
128128 /// The username that published it.
129129 pub published_by: Option<String>,
130130 pub published_at: String,
131+ /// npm: why the version should no longer be used, when it is deprecated.
132+ #[serde(default)]
133+ pub deprecated: Option<String>,
131134 }
132135
133136 #[derive(Clone, Debug, Serialize, Deserialize)]
155158 pub versions: Vec<PackageVersion>,
156159 pub tags: Vec<PackageTag>,
157160 pub permissions: PackagePermissions,
161+ /// The package's README, as markdown: npm's, from its latest version.
162+ #[serde(default)]
163+ pub readme: Option<String>,
158164 }
159165
160166 /// `list_packages`: the packages in a workspace the viewer may pull, newest
+4−0
6060 tags: string[];
6161 published_by: string | null;
6262 published_at: string;
63+ /** npm: why the version should no longer be used, when it is deprecated. */
64+ deprecated?: string | null;
6365 };
6466
6567 export type PackageTag = { tag: string; digest: string; updated_at: string };
7375 versions: PackageVersion[];
7476 tags: PackageTag[];
7577 permissions: PackagePermissions;
78+ /** The package's README, as markdown: npm's, from its latest version. */
79+ readme?: string | null;
7680 };
7781
7882 /** What a workspace's packages hold, for billing: each file once, public when any public package uses it. */
+1−0
1919 hex = "0.4"
2020 hmac = "0.12"
2121 sha2 = { version = "0.10", features = ["compress"] }
22+sha1 = "0.10"
2223
2324 # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
2425 # time (docs/DEPLOYING.md, "Build speed").
+12−1
22 // on its own with `wrangler dev` (see packages.jsonc beside it).
33 //
44 // - Identity knows one person, `dev`, an owner of the workspace `acme`,
5−// whose token is DEV_TOKEN, and one outsider, `bo` (OUTSIDER_TOKEN).
5+// whose token is DEV_TOKEN, a member, `mo` (MEMBER_TOKEN), and one
6+// outsider, `bo` (OUTSIDER_TOKEN).
67 // - Repos knows two repositories of acme: `web` (private) and `site`
78 // (public).
89 // - Events takes every event and audit entry and logs them.
2526 token: { token_id: "tok_dev" },
2627 };
2728 }
29+ if (secret === env.MEMBER_TOKEN) {
30+ return {
31+ id: "usr_mo",
32+ username: "mo",
33+ kind: "user",
34+ verified: true,
35+ workspaces: [{ slug: "acme", role: "member" }],
36+ token: { token_id: "tok_mo" },
37+ };
38+ }
2839 if (secret === env.OUTSIDER_TOKEN) {
2940 return { id: "usr_bo", username: "bo", kind: "user", verified: true, workspaces: [], token: { token_id: "tok_bo" } };
3041 }
+1−1
33 "name": "g1t-packages-stubs",
44 "main": "stubs.js",
55 "compatibility_date": "2026-09-26",
6− "vars": { "DEV_TOKEN": "g1t_devtoken", "OUTSIDER_TOKEN": "g1t_outsider", "FREE_PRIVATE_BYTES": "50000000" }
6+ "vars": { "DEV_TOKEN": "g1t_devtoken", "MEMBER_TOKEN": "g1t_member", "OUTSIDER_TOKEN": "g1t_outsider", "FREE_PRIVATE_BYTES": "50000000" }
77 }
+64−1
8484 pub subject: Option<String>,
8585 pub published_by: Option<String>,
8686 pub published_at: String,
87+ /// npm's deprecation message, when the version is deprecated.
88+ #[serde(default)]
89+ pub deprecated: Option<String>,
8790 }
8891
8992 impl VersionRow {
155158 "id, workspace, ecosystem, name, repo_id, repo_name, visibility, description, created_by, created_at, updated_at, downloads, workspace_deleted_at";
156159 /// Workspaces that are deleted, waiting to be purged or restored.
157160 const DELETED_WORKSPACES: &str = "SELECT workspace FROM packages WHERE workspace_deleted_at IS NOT NULL";
158−const VERSION_COLUMNS: &str = "id, package_id, version, digest, size, metadata, subject, published_by, published_at";
161+const VERSION_COLUMNS: &str = "id, package_id, version, digest, size, metadata, subject, published_by, published_at, deprecated";
159162
160163 pub struct Db {
161164 pub db: D1Database,
581584 .results()
582585 }
583586
587+ /// Points `tag` at a version, made or moved.
588+ pub async fn set_tag(&self, package_id: &str, tag: &str, version_id: &str, now_ms: u64) -> Result<()> {
589+ self.prepare(
590+ "INSERT INTO tags (package_id, tag, version_id, updated_at) VALUES (?, ?, ?, ?)
591+ ON CONFLICT (package_id, tag) DO UPDATE SET version_id = excluded.version_id, updated_at = excluded.updated_at",
592+ &[text(package_id), text(tag), text(version_id), text(&rfc3339(now_ms))],
593+ )?
594+ .run()
595+ .await?;
596+ Ok(())
597+ }
598+
599+ /// A version by its version string alone.
600+ pub async fn version_named(&self, package_id: &str, version: &str) -> Result<Option<VersionRow>> {
601+ self.prepare(
602+ &format!("SELECT {VERSION_COLUMNS} FROM versions WHERE package_id = ? AND version = ?"),
603+ &[text(package_id), text(version)],
604+ )?
605+ .first(None)
606+ .await
607+ }
608+
609+ pub async fn set_deprecated(&self, version_id: &str, message: Option<&str>) -> Result<()> {
610+ self.prepare("UPDATE versions SET deprecated = ? WHERE id = ?", &[opt(message), text(version_id)])?
611+ .run()
612+ .await?;
613+ Ok(())
614+ }
615+
616+ /// The README shown for the package, kept as a blob, and its description.
617+ pub async fn set_readme(&self, package_id: &str, digest: Option<&str>, description: Option<&str>, now_ms: u64) -> Result<()> {
618+ self.prepare(
619+ "UPDATE packages SET readme_digest = ?, description = ?, updated_at = ? WHERE id = ?",
620+ &[opt(digest), opt(description), text(&rfc3339(now_ms)), text(package_id)],
621+ )?
622+ .run()
623+ .await?;
624+ Ok(())
625+ }
626+
627+ pub async fn readme_digest(&self, package_id: &str) -> Result<Option<String>> {
628+ #[derive(Deserialize)]
629+ struct Readme {
630+ readme_digest: Option<String>,
631+ }
632+ let row: Option<Readme> = self
633+ .prepare("SELECT readme_digest FROM packages WHERE id = ?", &[text(package_id)])?
634+ .first(None)
635+ .await?;
636+ Ok(row.and_then(|r| r.readme_digest))
637+ }
638+
639+ pub async fn touch_package(&self, package_id: &str, now_ms: u64) -> Result<()> {
640+ self.prepare("UPDATE packages SET updated_at = ? WHERE id = ?", &[text(&rfc3339(now_ms)), text(package_id)])?
641+ .run()
642+ .await?;
643+ Ok(())
644+ }
645+
584646 pub async fn delete_tag(&self, package_id: &str, tag: &str) -> Result<()> {
585647 self.prepare("DELETE FROM tags WHERE package_id = ? AND tag = ?", &[text(package_id), text(tag)])?
586648 .run()
709771 &format!(
710772 "SELECT digest, size, media_type, object_key FROM blobs b
711773 WHERE touched_at < ? AND NOT EXISTS (SELECT 1 FROM version_files vf WHERE vf.digest = b.digest)
774+ AND NOT EXISTS (SELECT 1 FROM packages p WHERE p.readme_digest = b.digest)
712775 ORDER BY touched_at LIMIT {limit}"
713776 ),
714777 &[text(&rfc3339(now_ms.saturating_sub(DAY_MS)))],
+19−1
1313 mod limits;
1414 mod manifest;
1515 mod names;
16+mod npm;
17+mod npm_http;
1618 mod oci;
1719 mod quota;
1820 mod range;
341343 workspace: p.workspace.clone(),
342344 ecosystem: Ecosystem::parse(&p.ecosystem).unwrap_or(Ecosystem::Container),
343345 name: p.name.clone(),
344− address: format!("{}/{}/{}", self.host, p.workspace, p.name),
346+ address: match p.ecosystem.as_str() {
347+ "npm" => format!("{}/-/npm/@{}/{}", self.host, p.workspace, p.name),
348+ _ => format!("{}/{}/{}", self.host, p.workspace, p.name),
349+ },
345350 visibility: Visibility::parse(&p.visibility),
346351 repo: p.repo_id.as_ref().map(|id| LinkedRepo {
347352 id: id.clone(),
421426 subject: version.subject,
422427 published_by: version.published_by,
423428 published_at: version.published_at,
429+ deprecated: version.deprecated,
424430 }
425431 })
426432 .collect();
433+ // The README its page shows: npm's, from the latest version.
434+ let readme = match self.db.readme_digest(&row.package.id).await?.and_then(|d| digest::Digest::parse(&d)) {
435+ Some(digest) => match self.db.blob(&digest).await? {
436+ Some(blob) => self.store.read(&blob.object_key).await?.map(|b| String::from_utf8_lossy(&b).into_owned()),
437+ None => None,
438+ },
439+ None => None,
440+ };
427441 Ok(Outcome::Ok(PackageDetail {
442+ readme,
428443 package: self.summary(&row),
429444 versions,
430445 tags: tags
619634 async fn fetch(mut request: Request, env: Env, ctx: Context) -> Result<Response> {
620635 let packages = Packages::from_env(&env)?;
621636 let Some(method) = rpc_method(&request) else {
637+ if request.path().starts_with("/-/npm/") || request.path() == "/-/npm" {
638+ return packages.npm(request, &ctx).await;
639+ }
622640 return packages.registry(request, &ctx).await;
623641 };
624642 let body: serde_json::Value = request.json().await?;
+544−0
1+//! What the npm registry needs that does not touch the network: package
2+//! names, versions, the integrity of a tarball, the documents npm reads
3+//! (packuments), and when a version may still be unpublished.
4+//!
5+//! A package is `@<workspace>/<name>`: the scope is the workspace. Its
6+//! versions' `package.json` fields are kept as npm sent them, beside the
7+//! tarball's digest; the packument is put together from them on each read.
8+
9+use base64::Engine;
10+use base64::engine::general_purpose::STANDARD;
11+use serde_json::{Map, Value, json};
12+use sha1::Sha1;
13+use sha2::{Digest as _, Sha512};
14+
15+/// The longest name npm allows, scope included.
16+const MAX_NAME: usize = 214;
17+/// How long after publishing a version anyone who may publish may still
18+/// unpublish it; after that it takes Admin.
19+pub const UNPUBLISH_WINDOW_MS: u64 = 72 * 60 * 60 * 1000;
20+pub const ABBREVIATED: &str = "application/vnd.npm.install-v1+json";
21+
22+/// A scoped package's name.
23+#[derive(Clone, Debug, PartialEq, Eq)]
24+pub struct NpmName {
25+ /// The scope without `@`: the workspace.
26+ pub workspace: String,
27+ pub name: String,
28+}
29+
30+impl NpmName {
31+ /// `@acme/web`.
32+ pub fn full(&self) -> String {
33+ format!("@{}/{}", self.workspace, self.name)
34+ }
35+
36+ /// The tarball's file name, as npm names it: `web-1.0.0.tgz`.
37+ pub fn tarball(&self, version: &str) -> String {
38+ format!("{}-{version}.tgz", self.name)
39+ }
40+}
41+
42+/// Reads `@scope/name` (the `/` as is or as `%2f`) and checks npm's rules:
43+/// lowercase, URL-safe, not starting with `.` or `_`, at most 214
44+/// characters. The scope must look like a workspace's slug.
45+pub fn parse_name(text: &str) -> Result<NpmName, String> {
46+ let text = text.replace("%2f", "/").replace("%2F", "/");
47+ let Some(scoped) = text.strip_prefix('@') else {
48+ return Err(format!(
49+ "{text} has no scope. Packages on g1t are scoped by workspace: @<workspace>/<name>."
50+ ));
51+ };
52+ let Some((workspace, name)) = scoped.split_once('/') else {
53+ return Err(format!("{text} is not @<workspace>/<name>."));
54+ };
55+ if text.len() > MAX_NAME {
56+ return Err(format!("A package name is at most {MAX_NAME} characters."));
57+ }
58+ let workspace_ok = !workspace.is_empty()
59+ && workspace.len() <= 39
60+ && workspace.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
61+ && !workspace.starts_with('-')
62+ && !workspace.ends_with('-')
63+ && !workspace.contains("--");
64+ if !workspace_ok {
65+ return Err(format!("@{workspace} is not a workspace."));
66+ }
67+ let name_ok = !name.is_empty()
68+ && !name.starts_with('.')
69+ && !name.starts_with('_')
70+ && name.trim() == name
71+ && name
72+ .bytes()
73+ .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'.' | b'_' | b'~'));
74+ if !name_ok {
75+ return Err(format!(
76+ "{name} is not a valid package name: lowercase letters, digits, `-`, `.`, `_` and `~`, not starting with `.` or `_`."
77+ ));
78+ }
79+ Ok(NpmName { workspace: workspace.to_owned(), name: name.to_owned() })
80+}
81+
82+/// Whether `version` is semver: `MAJOR.MINOR.PATCH`, with an optional
83+/// `-prerelease` and `+build`, numbers without leading zeros.
84+pub fn valid_version(version: &str) -> bool {
85+ let (core, build) = version.split_once('+').map_or((version, None), |(c, b)| (c, Some(b)));
86+ let (core, pre) = core.split_once('-').map_or((core, None), |(c, p)| (c, Some(p)));
87+ let ident = |part: &str| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
88+ let number = |part: &str| {
89+ !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()) && (part == "0" || !part.starts_with('0'))
90+ };
91+ let numbers: Vec<&str> = core.split('.').collect();
92+ numbers.len() == 3
93+ && numbers.iter().all(|n| number(n))
94+ && pre.is_none_or(|p| p.split('.').all(|part| ident(part) && (!part.bytes().all(|b| b.is_ascii_digit()) || number(part))))
95+ && build.is_none_or(|b| b.split('.').all(ident))
96+ && version.len() <= 256
97+}
98+
99+/// A tag's shape: anything npm accepts that is not itself a version.
100+pub fn valid_tag(tag: &str) -> bool {
101+ !tag.is_empty()
102+ && tag.len() <= 128
103+ && !valid_version(tag)
104+ && tag.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_'))
105+}
106+
107+/// What a tarball is checked and named by: `sha512-<base64>` (`dist.integrity`)
108+/// and its SHA-1 in hex (`dist.shasum`).
109+#[derive(Clone, Debug, PartialEq, Eq)]
110+pub struct Integrity {
111+ pub integrity: String,
112+ pub shasum: String,
113+}
114+
115+pub fn integrity(bytes: &[u8]) -> Integrity {
116+ Integrity {
117+ integrity: format!("sha512-{}", STANDARD.encode(Sha512::digest(bytes))),
118+ shasum: hex::encode(Sha1::digest(bytes)),
119+ }
120+}
121+
122+/// Whether what a client says about the tarball agrees with the tarball.
123+/// What it leaves out is not checked. An `integrity` may list several
124+/// hashes; the sha512 one must match when there is one.
125+pub fn agrees(computed: &Integrity, integrity: Option<&str>, shasum: Option<&str>) -> bool {
126+ let integrity_ok = match integrity {
127+ None => true,
128+ Some(given) => {
129+ let sha512: Vec<&str> = given.split_whitespace().filter(|h| h.starts_with("sha512-")).collect();
130+ sha512.is_empty() || sha512.contains(&computed.integrity.as_str())
131+ }
132+ };
133+ integrity_ok && shasum.is_none_or(|given| given.eq_ignore_ascii_case(&computed.shasum))
134+}
135+
136+/// Whether a version published at `published_ms` may be unpublished at
137+/// `now_ms` by someone who may publish: within 72 hours. Admins may always.
138+pub fn may_unpublish(published_ms: u64, now_ms: u64, admin: bool) -> bool {
139+ admin || now_ms.saturating_sub(published_ms) < UNPUBLISH_WINDOW_MS
140+}
141+
142+/// The repository a package's `repository` field names, as `(workspace,
143+/// name)`, when it is on `host`: `git+https://g1t.sh/acme/web.git`,
144+/// `https://g1t.sh/acme/web`, `{ "url": ... }`.
145+pub fn repository_of(field: &Value, host: &str) -> Option<(String, String)> {
146+ let url = match field {
147+ Value::String(url) => url.as_str(),
148+ Value::Object(map) => map.get("url")?.as_str()?,
149+ _ => return None,
150+ };
151+ let url = url.trim().trim_start_matches("git+");
152+ let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
153+ let rest = rest.split_once('@').map_or(rest, |(_, rest)| rest);
154+ let rest = rest.strip_prefix(host)?;
155+ let rest = rest.strip_prefix('/').or_else(|| rest.strip_prefix(':'))?;
156+ let mut parts = rest.trim_end_matches('/').split('/');
157+ let (workspace, name) = (parts.next()?, parts.next()?);
158+ let name = name.strip_suffix(".git").unwrap_or(name);
159+ (!workspace.is_empty() && !name.is_empty()).then(|| (workspace.to_lowercase(), name.to_lowercase()))
160+}
161+
162+/// One version as kept: its `package.json` fields as npm sent them (with
163+/// `dist` holding the integrity), and what the registry adds.
164+pub struct StoredVersion {
165+ pub version: String,
166+ pub manifest: Value,
167+ pub deprecated: Option<String>,
168+ pub published_at: String,
169+}
170+
171+/// What a packument is made from.
172+pub struct Packument<'a> {
173+ pub name: &'a NpmName,
174+ pub versions: &'a [StoredVersion],
175+ /// Tag and the version it points to.
176+ pub tags: &'a [(String, String)],
177+ pub created: &'a str,
178+ pub modified: &'a str,
179+ pub readme: Option<&'a str>,
180+ /// Where tarballs are: `https://g1t.sh/-/npm`.
181+ pub base: &'a str,
182+}
183+
184+/// The fields npm's abbreviated packument keeps of each version.
185+const ABBREVIATED_FIELDS: [&str; 16] = [
186+ "name",
187+ "version",
188+ "dependencies",
189+ "optionalDependencies",
190+ "devDependencies",
191+ "bundleDependencies",
192+ "bundledDependencies",
193+ "peerDependencies",
194+ "peerDependenciesMeta",
195+ "bin",
196+ "directories",
197+ "engines",
198+ "os",
199+ "cpu",
200+ "_hasShrinkwrap",
201+ "hasInstallScript",
202+];
203+
204+impl Packument<'_> {
205+ /// The revision npm sends back when it changes the packument. Any
206+ /// revision is taken; this one only changes when the package does.
207+ pub fn rev(&self) -> String {
208+ let digest = hex::encode(Sha1::digest(self.modified.as_bytes()));
209+ format!("{}-{}", self.versions.len().max(1), &digest[..16])
210+ }
211+
212+ fn version_json(&self, stored: &StoredVersion) -> Value {
213+ let mut manifest = match &stored.manifest {
214+ Value::Object(map) => map.clone(),
215+ _ => Map::new(),
216+ };
217+ let full = self.name.full();
218+ manifest.insert("name".into(), json!(full));
219+ manifest.insert("version".into(), json!(stored.version));
220+ manifest.insert("_id".into(), json!(format!("{full}@{}", stored.version)));
221+ let mut dist = match manifest.remove("dist") {
222+ Some(Value::Object(dist)) => dist,
223+ _ => Map::new(),
224+ };
225+ dist.insert(
226+ "tarball".into(),
227+ json!(format!("{}/{full}/-/{}", self.base, self.name.tarball(&stored.version))),
228+ );
229+ manifest.insert("dist".into(), Value::Object(dist));
230+ match &stored.deprecated {
231+ Some(message) => {
232+ manifest.insert("deprecated".into(), json!(message));
233+ }
234+ None => {
235+ manifest.remove("deprecated");
236+ }
237+ }
238+ Value::Object(manifest)
239+ }
240+
241+ fn dist_tags(&self) -> Value {
242+ Value::Object(self.tags.iter().map(|(tag, version)| (tag.clone(), json!(version))).collect())
243+ }
244+
245+ /// The whole document, as `npm view` and `npm publish` read it.
246+ pub fn full(&self) -> Value {
247+ let mut versions = Map::new();
248+ let mut time = Map::new();
249+ time.insert("created".into(), json!(self.created));
250+ time.insert("modified".into(), json!(self.modified));
251+ for stored in self.versions {
252+ versions.insert(stored.version.clone(), self.version_json(stored));
253+ time.insert(stored.version.clone(), json!(stored.published_at));
254+ }
255+ let latest = self
256+ .tags
257+ .iter()
258+ .find(|(tag, _)| tag == "latest")
259+ .and_then(|(_, v)| self.versions.iter().find(|s| &s.version == v));
260+ let mut document = json!({
261+ "_id": self.name.full(),
262+ "_rev": self.rev(),
263+ "name": self.name.full(),
264+ "dist-tags": self.dist_tags(),
265+ "versions": versions,
266+ "time": time,
267+ });
268+ if let Some(latest) = latest {
269+ for key in ["description", "keywords", "license", "repository", "homepage", "bugs", "author"] {
270+ if let Some(value) = latest.manifest.get(key) {
271+ document[key] = value.clone();
272+ }
273+ }
274+ }
275+ if let Some(readme) = self.readme {
276+ document["readme"] = json!(readme);
277+ }
278+ document
279+ }
280+
281+ /// What `npm install` asks for: each version's install fields only.
282+ pub fn abbreviated(&self) -> Value {
283+ let mut versions = Map::new();
284+ for stored in self.versions {
285+ let full = self.version_json(stored);
286+ let mut kept = Map::new();
287+ for key in ABBREVIATED_FIELDS.iter().chain(["dist", "deprecated"].iter()) {
288+ if let Some(value) = full.get(*key) {
289+ kept.insert((*key).to_owned(), value.clone());
290+ }
291+ }
292+ versions.insert(stored.version.clone(), Value::Object(kept));
293+ }
294+ json!({
295+ "name": self.name.full(),
296+ "modified": self.modified,
297+ "dist-tags": self.dist_tags(),
298+ "versions": versions,
299+ })
300+ }
301+}
302+
303+/// A version's `package.json` as kept: without its README (kept once, for
304+/// the package) and the registry's own fields, with the integrity in `dist`.
305+pub fn stored_manifest(sent: &Value, computed: &Integrity) -> Value {
306+ let mut manifest = match sent {
307+ Value::Object(map) => map.clone(),
308+ _ => Map::new(),
309+ };
310+ for key in ["readme", "readmeFilename", "_id", "_rev", "_attachments", "deprecated", "_npmUser", "maintainers"] {
311+ manifest.remove(key);
312+ }
313+ manifest.insert(
314+ "dist".into(),
315+ json!({ "integrity": computed.integrity, "shasum": computed.shasum }),
316+ );
317+ Value::Object(manifest)
318+}
319+
320+/// One of the registry's endpoints, under `/-/npm`. Names are unchecked.
321+#[derive(Clone, Debug, PartialEq, Eq)]
322+pub enum NpmRoute {
323+ Ping,
324+ Whoami,
325+ /// `npm login --auth-type=legacy`: `/-/user/org.couchdb.user:<name>`.
326+ Login,
327+ DistTags { name: String, tag: Option<String> },
328+ /// The packument: read, publish, or (with a revision) change.
329+ Package { name: String, rev: Option<String> },
330+ Tarball { name: String, file: String, rev: Option<String> },
331+}
332+
333+/// Which endpoint a path is. `/-/npm/@acme%2fweb`, `/-/npm/@acme/web`,
334+/// `/-/npm/@acme/web/-/web-1.0.0.tgz`, `/-/npm/-/package/@acme%2fweb/dist-tags/next`...
335+pub fn route(path: &str) -> Option<NpmRoute> {
336+ let rest = path.strip_prefix("/-/npm")?;
337+ let rest = rest.strip_prefix('/').unwrap_or(rest);
338+ match rest {
339+ "-/ping" => return Some(NpmRoute::Ping),
340+ "-/whoami" => return Some(NpmRoute::Whoami),
341+ _ => {}
342+ }
343+ if rest.starts_with("-/user/org.couchdb.user:") {
344+ return Some(NpmRoute::Login);
345+ }
346+ // The name: `@scope%2fname`, one segment, or `@scope/name`, two.
347+ let split = |text: &str| -> Option<(String, String)> {
348+ let lower = text.to_ascii_lowercase();
349+ if lower.starts_with('@') && !lower.split('/').next()?.contains("%2f") {
350+ let mut parts = text.splitn(3, '/');
351+ let (scope, name) = (parts.next()?, parts.next()?);
352+ Some((format!("{scope}/{name}"), parts.next().unwrap_or("").to_owned()))
353+ } else {
354+ let mut parts = text.splitn(2, '/');
355+ Some((parts.next()?.to_owned(), parts.next().unwrap_or("").to_owned()))
356+ }
357+ };
358+ if let Some(after) = rest.strip_prefix("-/package/") {
359+ let (name, tail) = split(after)?;
360+ let tag = match tail.as_str() {
361+ "dist-tags" => None,
362+ t => Some(t.strip_prefix("dist-tags/")?.to_owned()).filter(|t| !t.is_empty() && !t.contains('/')),
363+ };
364+ if tail != "dist-tags" && tag.is_none() {
365+ return None;
366+ }
367+ return Some(NpmRoute::DistTags { name, tag });
368+ }
369+ if rest.starts_with("-/") || rest.is_empty() {
370+ return None;
371+ }
372+ let (name, tail) = split(rest)?;
373+ if tail.is_empty() {
374+ return Some(NpmRoute::Package { name, rev: None });
375+ }
376+ if let Some(rev) = tail.strip_prefix("-rev/") {
377+ return Some(NpmRoute::Package { name, rev: Some(rev.to_owned()) });
378+ }
379+ let file = tail.strip_prefix("-/")?;
380+ let (file, rev) = match file.split_once("/-rev/") {
381+ Some((file, rev)) => (file, Some(rev.to_owned())),
382+ None => (file, None),
383+ };
384+ (file.ends_with(".tgz") && !file.contains('/')).then(|| NpmRoute::Tarball { name, file: file.to_owned(), rev })
385+}
386+
387+/// The version a tarball's file name names: `web-1.2.3.tgz` of `web`.
388+pub fn version_of_file(name: &NpmName, file: &str) -> Option<String> {
389+ let version = file.strip_prefix(&format!("{}-", name.name))?.strip_suffix(".tgz")?;
390+ valid_version(version).then(|| version.to_owned())
391+}
392+
393+#[cfg(test)]
394+mod tests {
395+ use super::*;
396+
397+ #[test]
398+ fn every_endpoint_is_routed() {
399+ let package = |name: &str, rev: Option<&str>| Some(NpmRoute::Package { name: name.into(), rev: rev.map(str::to_owned) });
400+ assert_eq!(route("/-/npm/-/ping"), Some(NpmRoute::Ping));
401+ assert_eq!(route("/-/npm/-/whoami"), Some(NpmRoute::Whoami));
402+ assert_eq!(route("/-/npm/-/user/org.couchdb.user:ana"), Some(NpmRoute::Login));
403+ assert_eq!(route("/-/npm/@acme%2fweb"), package("@acme%2fweb", None));
404+ assert_eq!(route("/-/npm/@acme%2Fweb"), package("@acme%2Fweb", None));
405+ assert_eq!(route("/-/npm/@acme/web"), package("@acme/web", None));
406+ assert_eq!(route("/-/npm/@acme%2fweb/-rev/3-abc"), package("@acme%2fweb", Some("3-abc")));
407+ assert_eq!(
408+ route("/-/npm/@acme/web/-/web-1.0.0.tgz"),
409+ Some(NpmRoute::Tarball { name: "@acme/web".into(), file: "web-1.0.0.tgz".into(), rev: None })
410+ );
411+ assert_eq!(
412+ route("/-/npm/@acme/web/-/web-1.0.0.tgz/-rev/2-x"),
413+ Some(NpmRoute::Tarball { name: "@acme/web".into(), file: "web-1.0.0.tgz".into(), rev: Some("2-x".into()) })
414+ );
415+ assert_eq!(route("/-/npm/-/package/@acme%2fweb/dist-tags"), Some(NpmRoute::DistTags { name: "@acme%2fweb".into(), tag: None }));
416+ assert_eq!(
417+ route("/-/npm/-/package/@acme/web/dist-tags/next"),
418+ Some(NpmRoute::DistTags { name: "@acme/web".into(), tag: Some("next".into()) })
419+ );
420+ assert_eq!(route("/-/npm/-/package/@acme/web/other"), None);
421+ assert_eq!(route("/-/npm/"), None);
422+ assert_eq!(route("/-/npm/@acme/web/-/notes.txt"), None);
423+ assert_eq!(route("/v2/acme/web"), None);
424+ let name = parse_name("@acme/web").unwrap();
425+ assert_eq!(version_of_file(&name, "web-1.2.3-rc.1.tgz").as_deref(), Some("1.2.3-rc.1"));
426+ assert_eq!(version_of_file(&name, "other-1.2.3.tgz"), None);
427+ }
428+
429+ #[test]
430+ fn names_are_scoped_by_workspace_and_follow_npms_rules() {
431+ let name = parse_name("@acme/web-ui").unwrap();
432+ assert_eq!((name.workspace.as_str(), name.name.as_str()), ("acme", "web-ui"));
433+ assert_eq!(parse_name("@acme%2fweb").unwrap().full(), "@acme/web");
434+ assert_eq!(parse_name("@acme%2Fweb.js").unwrap().tarball("1.0.0"), "web.js-1.0.0.tgz");
435+ assert!(parse_name("web").unwrap_err().contains("scope"));
436+ assert!(parse_name("@acme").is_err());
437+ assert!(parse_name("@Acme/web").is_err());
438+ assert!(parse_name("@acme/Web").is_err());
439+ assert!(parse_name("@acme/.hidden").is_err());
440+ assert!(parse_name("@acme/_private").is_err());
441+ assert!(parse_name("@acme/a b").is_err());
442+ assert!(parse_name("@acme/a/b").is_err());
443+ assert!(parse_name(&format!("@acme/{}", "a".repeat(210))).is_err());
444+ }
445+
446+ #[test]
447+ fn versions_are_semver_and_tags_are_not() {
448+ for good in ["1.0.0", "0.0.1", "10.20.30", "1.0.0-rc.1", "1.0.0-alpha-2.x", "1.0.0+build.5", "1.0.0-0"] {
449+ assert!(valid_version(good), "{good}");
450+ }
451+ for bad in ["1.0", "01.0.0", "1.0.0-", "1.0.0-01", "v1.0.0", "1.0.0+", "a.b.c", ""] {
452+ assert!(!valid_version(bad), "{bad}");
453+ }
454+ assert!(valid_tag("latest"));
455+ assert!(valid_tag("next-1"));
456+ assert!(!valid_tag("1.0.0"));
457+ assert!(!valid_tag("bad tag"));
458+ }
459+
460+ #[test]
461+ fn integrity_is_npms_sha512_and_sha1() {
462+ let computed = integrity(b"hello");
463+ assert_eq!(computed.shasum, "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d");
464+ assert_eq!(
465+ computed.integrity,
466+ "sha512-m3HSJL1i83hdltRq0+o9czGb+8KJDKra4t/3JRlnPKcjI8PZm6XBHXx6zG4UuMXaDEZjR1wuXDre9G9zvN7AQw=="
467+ );
468+ assert!(agrees(&computed, Some(&computed.integrity), Some("AAF4C61DDCC5E8A2DABEDE0F3B482CD9AEA9434D")));
469+ assert!(agrees(&computed, None, None));
470+ assert!(agrees(&computed, Some("sha1-whatever"), None), "only sha512 is checked");
471+ assert!(!agrees(&computed, Some("sha512-AAAA"), None));
472+ assert!(!agrees(&computed, None, Some("0000")));
473+ }
474+
475+ #[test]
476+ fn unpublishing_is_open_for_72_hours_then_needs_admin() {
477+ let hour = 60 * 60 * 1000;
478+ assert!(may_unpublish(0, 71 * hour, false));
479+ assert!(!may_unpublish(0, 72 * hour, false));
480+ assert!(may_unpublish(0, 1000 * hour, true));
481+ }
482+
483+ #[test]
484+ fn a_repository_on_g1t_is_read_from_package_json() {
485+ let at = |w: &str, n: &str| Some((w.to_owned(), n.to_owned()));
486+ assert_eq!(repository_of(&json!("git+https://g1t.sh/acme/web.git"), "g1t.sh"), at("acme", "web"));
487+ assert_eq!(repository_of(&json!({ "type": "git", "url": "https://g1t.sh/Acme/Web" }), "g1t.sh"), at("acme", "web"));
488+ assert_eq!(repository_of(&json!("git@g1t.sh:acme/web.git"), "g1t.sh"), at("acme", "web"));
489+ assert_eq!(repository_of(&json!("https://example.com/acme/web"), "g1t.sh"), None);
490+ assert_eq!(repository_of(&json!("https://g1t.sh/acme"), "g1t.sh"), None);
491+ assert_eq!(repository_of(&json!(42), "g1t.sh"), None);
492+ }
493+
494+ fn stored(version: &str, deprecated: Option<&str>) -> StoredVersion {
495+ StoredVersion {
496+ version: version.to_owned(),
497+ manifest: stored_manifest(
498+ &json!({ "name": "@acme/web", "version": version, "description": "Web", "dependencies": { "a": "^1" }, "scripts": { "test": "x" }, "readme": "# big" }),
499+ &integrity(version.as_bytes()),
500+ ),
501+ deprecated: deprecated.map(str::to_owned),
502+ published_at: "2026-10-06T00:00:00.000Z".to_owned(),
503+ }
504+ }
505+
506+ #[test]
507+ fn the_packument_names_every_version_its_tarball_and_tags() {
508+ let name = parse_name("@acme/web").unwrap();
509+ let versions = [stored("1.0.0", None), stored("1.1.0", Some("use 2"))];
510+ let tags = [("latest".to_owned(), "1.1.0".to_owned())];
511+ let packument = Packument {
512+ name: &name,
513+ versions: &versions,
514+ tags: &tags,
515+ created: "2026-10-01T00:00:00.000Z",
516+ modified: "2026-10-06T00:00:00.000Z",
517+ readme: Some("# Web"),
518+ base: "https://g1t.sh/-/npm",
519+ };
520+ let full = packument.full();
521+ assert_eq!(full["name"], "@acme/web");
522+ assert_eq!(full["dist-tags"]["latest"], "1.1.0");
523+ assert_eq!(full["description"], "Web");
524+ assert_eq!(full["readme"], "# Web");
525+ let v1 = &full["versions"]["1.0.0"];
526+ assert_eq!(v1["_id"], "@acme/web@1.0.0");
527+ assert_eq!(v1["dist"]["tarball"], "https://g1t.sh/-/npm/@acme/web/-/web-1.0.0.tgz");
528+ assert_eq!(v1["dist"]["integrity"], integrity(b"1.0.0").integrity);
529+ assert!(v1.get("readme").is_none(), "the README is kept once, not per version");
530+ assert!(v1.get("deprecated").is_none());
531+ assert_eq!(full["versions"]["1.1.0"]["deprecated"], "use 2");
532+ assert_eq!(full["time"]["1.0.0"], "2026-10-06T00:00:00.000Z");
533+ assert!(full["_rev"].as_str().unwrap().starts_with("2-"));
534+
535+ let short = packument.abbreviated();
536+ let v1 = &short["versions"]["1.0.0"];
537+ assert_eq!(v1["dependencies"]["a"], "^1");
538+ assert!(v1.get("scripts").is_none(), "install fields only");
539+ assert!(v1.get("description").is_none());
540+ assert_eq!(v1["dist"]["shasum"], integrity(b"1.0.0").shasum);
541+ assert_eq!(short["versions"]["1.1.0"]["deprecated"], "use 2");
542+ assert!(short.get("readme").is_none());
543+ }
544+}
+625−0
1+//! The npm registry: `g1t.sh/-/npm/`, for packages scoped by workspace
2+//! (`@acme/web`). `.npmrc` names it for the scope, with a g1t token:
3+//!
4+//! ```text
5+//! @acme:registry=https://g1t.sh/-/npm/
6+//! //g1t.sh/-/npm/:_authToken=<token>
7+//! ```
8+//!
9+//! npm sends the token as `Authorization: Bearer`; Basic credentials (a
10+//! username and a g1t token, `_auth`) work too. Publishing is one `PUT` of
11+//! the packument with the tarball attached; deprecating and unpublishing a
12+//! version are `PUT`s of the packument as npm changed it; unpublishing a
13+//! package is a `DELETE`. A tarball is stored once, by its SHA-256, like
14+//! every file here.
15+
16+use std::collections::{HashMap, HashSet};
17+
18+use base64::Engine;
19+use base64::engine::general_purpose::STANDARD;
20+use g1t_contracts::User;
21+use g1t_contracts::audit::AuditActor;
22+use g1t_contracts::events::PackageEvent;
23+use g1t_contracts::new_id;
24+use g1t_contracts::time::parse_rfc3339;
25+use g1t_kit::now_ms;
26+use serde_json::{Value, json};
27+use worker::{Context, Headers, Method, Request, Response, ResponseBody, Result, Url};
28+
29+use crate::access::{self, Action};
30+use crate::db::{NewFile, NewVersion, PackageRow, VersionRow};
31+use crate::digest::Digest;
32+use crate::npm::{self, NpmName, NpmRoute, Packument, StoredVersion};
33+use crate::oci::{Credentials, origin, published_by};
34+use crate::store::BlobStore;
35+use crate::{Caller, Packages, TargetOf};
36+
37+const NPM: &str = "npm";
38+/// The most versions a packument lists.
39+const MAX_VERSIONS: u32 = 2000;
40+/// The longest README kept for a package's page.
41+const MAX_README_BYTES: usize = 1024 * 1024;
42+const DOCS: &str = "https://docs.g1t.sh/guides/npm/";
43+
44+/// npm's error shape: `{"error": "..."}`, which it prints.
45+fn error(status: u16, message: impl Into<String>) -> Result<Response> {
46+ let mut response = Response::from_json(&json!({ "error": message.into() }))?.with_status(status);
47+ if status == 401 {
48+ response.headers_mut().set("www-authenticate", "Basic realm=\"g1t\"")?;
49+ }
50+ Ok(response)
51+}
52+
53+fn ok() -> Result<Response> {
54+ Response::from_json(&json!({ "ok": true }))
55+}
56+
57+fn not_found() -> Result<Response> {
58+ error(404, "Not found: no such package, or you cannot see it. Private packages need a token in .npmrc.")
59+}
60+
61+/// The decision's reason as a 403, or the not-found answer when the
62+/// viewer may not even read the package.
63+fn refused(decision: g1t_contracts::credentials::Decision, readable: bool) -> Result<Response> {
64+ if !readable {
65+ return not_found();
66+ }
67+ error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned()))
68+}
69+
70+/// When a version was published, in milliseconds.
71+fn published_ms(version: &VersionRow) -> u64 {
72+ parse_rfc3339(&version.published_at).unwrap_or(0)
73+}
74+
75+impl Packages {
76+ /// Answers an npm request.
77+ pub async fn npm(&self, request: Request, ctx: &Context) -> Result<Response> {
78+ let url = request.url()?;
79+ let Some(route) = npm::route(url.path()) else {
80+ return error(404, "There is nothing at this address.");
81+ };
82+ match self.npm_route(request, &url, route, ctx).await {
83+ Ok(response) => Ok(response),
84+ Err(problem) => {
85+ worker::console_error!("packages: npm {}: {problem}", url.path());
86+ error(500, "Something went wrong on our side. Try again in a moment.")
87+ }
88+ }
89+ }
90+
91+ async fn npm_route(&self, mut request: Request, url: &Url, route: NpmRoute, ctx: &Context) -> Result<Response> {
92+ let method = request.method();
93+ let credentials = self.credentials(&request).await?;
94+ if matches!(method, Method::Get | Method::Head)
95+ && let Some(refused) = self.limited(&request, &credentials, "a token in .npmrc").await?
96+ {
97+ return Ok(refused);
98+ }
99+ let viewer = match credentials {
100+ Credentials::Viewer(viewer) => viewer,
101+ Credentials::None => None,
102+ // The container registry's own tokens are not npm's.
103+ Credentials::Token(_) | Credentials::Bad => {
104+ return error(401, "The token is not right, or has expired. Put a g1t access token in .npmrc: //g1t.sh/-/npm/:_authToken=<token>");
105+ }
106+ };
107+ match route {
108+ NpmRoute::Ping => Response::from_json(&json!({})),
109+ NpmRoute::Whoami => match viewer {
110+ Some(user) => Response::from_json(&json!({ "username": user.username })),
111+ None => error(401, "Not signed in. Put a g1t access token in .npmrc."),
112+ },
113+ NpmRoute::Login => self.npm_login(&mut request).await,
114+ NpmRoute::DistTags { name, tag } => {
115+ let name = match npm::parse_name(&name) {
116+ Ok(name) => name,
117+ Err(message) => return error(400, message),
118+ };
119+ self.dist_tags(&mut request, method, &name, tag.as_deref(), viewer.as_ref()).await
120+ }
121+ NpmRoute::Package { name, rev } => {
122+ let name = match npm::parse_name(&name) {
123+ Ok(name) => name,
124+ Err(message) => return error(400, message),
125+ };
126+ match (method, rev) {
127+ (Method::Get | Method::Head, _) => self.packument(&request, url, &name, viewer.as_ref()).await,
128+ (Method::Put, _) => self.npm_put(&mut request, &name, viewer.as_ref()).await,
129+ (Method::Delete, Some(_)) => self.unpublish_package(&name, viewer.as_ref()).await,
130+ _ => error(405, "Not a method this address takes."),
131+ }
132+ }
133+ NpmRoute::Tarball { name, file, rev } => {
134+ let name = match npm::parse_name(&name) {
135+ Ok(name) => name,
136+ Err(message) => return error(400, message),
137+ };
138+ let Some(version) = npm::version_of_file(&name, &file) else {
139+ return error(404, format!("{file} is not a tarball of {}.", name.full()));
140+ };
141+ let head = method == Method::Head;
142+ match (method, rev) {
143+ (Method::Get | Method::Head, _) => self.tarball(&name, &version, viewer.as_ref(), head, ctx).await,
144+ (Method::Delete, Some(_)) => self.unpublish_tarball(&name, &version, viewer.as_ref()).await,
145+ _ => error(405, "Not a method this address takes."),
146+ }
147+ }
148+ }
149+ }
150+
151+ /// `npm login --auth-type=legacy`: the password has to be a g1t token,
152+ /// which npm then keeps and sends as its bearer token.
153+ async fn npm_login(&self, request: &mut Request) -> Result<Response> {
154+ let body: Value = request.json().await.unwrap_or_default();
155+ let (name, password) = (body["name"].as_str().unwrap_or(""), body["password"].as_str().unwrap_or(""));
156+ if !password.starts_with("g1t_") {
157+ return error(401, "Use a g1t access token as the password: https://g1t.sh/settings/tokens");
158+ }
159+ match self.viewer_for(name, password).await? {
160+ Some(user) => Ok(Response::from_json(&json!({ "ok": true, "id": format!("org.couchdb.user:{}", user.username), "token": password }))?
161+ .with_status(201)),
162+ None => error(401, "That token is not right, or has expired."),
163+ }
164+ }
165+
166+ /// The package, if it is there and its workspace is not deleted.
167+ async fn npm_package(&self, name: &NpmName) -> Result<Option<PackageRow>> {
168+ Ok(self.db.package(&name.workspace, NPM, &name.name).await?.filter(|p| !p.hidden()))
169+ }
170+
171+ /// Whether `viewer` may `action` the package, as the answer when not:
172+ /// 401 for someone not signed in who may not read it (npm then says to
173+ /// log in), 404 for anyone else who may not read it, and 403 with the
174+ /// reason for one who may read it but not do this.
175+ fn npm_check(&self, viewer: Option<&User>, package: &PackageRow, action: Action) -> Option<Result<Response>> {
176+ let target = TargetOf::package(package);
177+ let decision = access::decide(viewer, &target.view(), action);
178+ if decision.allowed {
179+ return None;
180+ }
181+ let readable = action != Action::Pull && access::decide(viewer, &target.view(), Action::Pull).allowed;
182+ if !readable && viewer.is_none() {
183+ return Some(error(401, "Sign in to use this package: put a g1t access token in .npmrc (//g1t.sh/-/npm/:_authToken=<token>)."));
184+ }
185+ Some(refused(decision, readable))
186+ }
187+
188+ async fn stored_versions(&self, package: &PackageRow) -> Result<Vec<(VersionRow, StoredVersion)>> {
189+ let mut rows = self.db.versions(&package.id, MAX_VERSIONS).await?;
190+ rows.reverse();
191+ Ok(rows
192+ .into_iter()
193+ .map(|row| {
194+ let stored = StoredVersion {
195+ version: row.version.clone(),
196+ manifest: row.meta(),
197+ deprecated: row.deprecated.clone(),
198+ published_at: row.published_at.clone(),
199+ };
200+ (row, stored)
201+ })
202+ .collect())
203+ }
204+
205+ async fn tag_pairs(&self, package: &PackageRow, versions: &[(VersionRow, StoredVersion)]) -> Result<Vec<(String, String)>> {
206+ let by_id: HashMap<&str, &str> = versions.iter().map(|(row, _)| (row.id.as_str(), row.version.as_str())).collect();
207+ Ok(self
208+ .db
209+ .tags(&package.id)
210+ .await?
211+ .into_iter()
212+ .filter_map(|tag| by_id.get(tag.version_id.as_str()).map(|version| (tag.tag, (*version).to_owned())))
213+ .collect())
214+ }
215+
216+ async fn packument(&self, request: &Request, url: &Url, name: &NpmName, viewer: Option<&User>) -> Result<Response> {
217+ let Some(package) = self.npm_package(name).await? else {
218+ return not_found();
219+ };
220+ if let Some(refusal) = self.npm_check(viewer, &package, Action::Pull) {
221+ return refusal;
222+ }
223+ let versions = self.stored_versions(&package).await?;
224+ let tags = self.tag_pairs(&package, &versions).await?;
225+ let abbreviated = request
226+ .headers()
227+ .get("accept")?
228+ .is_some_and(|accept| accept.contains(npm::ABBREVIATED))
229+ && url.query_pairs().all(|(k, _)| k != "write");
230+ let readme = match (abbreviated, self.db.readme_digest(&package.id).await?.and_then(|d| Digest::parse(&d))) {
231+ (false, Some(digest)) => match self.db.blob(&digest).await? {
232+ Some(blob) => self.store.read(&blob.object_key).await?.map(|b| String::from_utf8_lossy(&b).into_owned()),
233+ None => None,
234+ },
235+ _ => None,
236+ };
237+ let stored: Vec<StoredVersion> = versions.into_iter().map(|(_, stored)| stored).collect();
238+ let base = format!("{}/-/npm", origin(url));
239+ let packument = Packument {
240+ name,
241+ versions: &stored,
242+ tags: &tags,
243+ created: &package.created_at,
244+ modified: &package.updated_at,
245+ readme: readme.as_deref(),
246+ base: &base,
247+ };
248+ if abbreviated {
249+ let mut response = Response::from_json(&packument.abbreviated())?;
250+ response.headers_mut().set("content-type", npm::ABBREVIATED)?;
251+ return Ok(response);
252+ }
253+ Response::from_json(&packument.full())
254+ }
255+
256+ async fn tarball(&self, name: &NpmName, version: &str, viewer: Option<&User>, head: bool, ctx: &Context) -> Result<Response> {
257+ let Some(package) = self.npm_package(name).await? else {
258+ return not_found();
259+ };
260+ if let Some(refusal) = self.npm_check(viewer, &package, Action::Pull) {
261+ return refusal;
262+ }
263+ let Some(row) = self.db.version_named(&package.id, version).await? else {
264+ return error(404, format!("{}@{version} is not there.", name.full()));
265+ };
266+ let Some(digest) = Digest::parse(&row.digest) else {
267+ return error(404, format!("{}@{version} is not there.", name.full()));
268+ };
269+ let Some(blob) = self.db.package_blob(&package.id, &digest).await? else {
270+ return error(404, format!("{}@{version} is not there.", name.full()));
271+ };
272+ let headers = Headers::new();
273+ headers.set("content-type", "application/octet-stream")?;
274+ headers.set("content-length", &blob.size.to_string())?;
275+ headers.set("cache-control", "max-age=31536000")?;
276+ if head {
277+ return Ok(Response::from_body(ResponseBody::Empty)?.with_headers(headers));
278+ }
279+ let Some(got) = self.store.get(&blob.object_key, None).await? else {
280+ return error(404, format!("{}@{version} is not there.", name.full()));
281+ };
282+ self.count_download(&package.id, ctx);
283+ Ok(Response::from_body(got.body)?.with_headers(headers))
284+ }
285+
286+ /// The package publishing makes, linked to the repository its
287+ /// `package.json` names on g1t, or else the one named like it.
288+ async fn npm_target(&self, name: &NpmName, manifest: &Value) -> Result<TargetOf> {
289+ let named = npm::repository_of(&manifest["repository"], &self.host)
290+ .filter(|(workspace, _)| workspace == &name.workspace)
291+ .map(|(_, repo)| repo);
292+ let mut repo = None;
293+ for candidate in named.iter().map(String::as_str).chain([name.name.as_str()]) {
294+ if let Some(found) = self.repo_by_name(&name.workspace, candidate).await? {
295+ repo = Some(found);
296+ break;
297+ }
298+ }
299+ Ok(TargetOf {
300+ workspace: name.workspace.clone(),
301+ repo: repo.map(|r| (r.id, r.name, r.is_private)),
302+ public: false,
303+ })
304+ }
305+
306+ /// A `PUT` of the packument: a publish when a tarball is attached,
307+ /// otherwise npm's change of an existing one (deprecate, tags, a
308+ /// version unpublished).
309+ async fn npm_put(&self, request: &mut Request, name: &NpmName, viewer: Option<&User>) -> Result<Response> {
310+ let declared = request.headers().get("content-length")?.and_then(|n| n.parse::<u64>().ok());
311+ let too_large = || {
312+ let mb = self.max_request / 1_000_000;
313+ error(413, format!("A publish may be at most {mb} MB, tarball and package.json together. See {DOCS}#size"))
314+ };
315+ if declared.is_some_and(|n| n > self.max_request) {
316+ return too_large();
317+ }
318+ let bytes = request.bytes().await?;
319+ if bytes.len() as u64 > self.max_request {
320+ return too_large();
321+ }
322+ let Ok(body) = serde_json::from_slice::<Value>(&bytes) else {
323+ return error(400, "The body is not JSON.");
324+ };
325+ if body["name"].as_str().is_some_and(|sent| sent != name.full()) {
326+ return error(400, format!("The package's name is {}, not {}.", body["name"].as_str().unwrap_or(""), name.full()));
327+ }
328+ let attached = body["_attachments"].as_object().is_some_and(|a| !a.is_empty());
329+ if attached {
330+ self.publish(name, &body, viewer).await
331+ } else {
332+ self.change(name, &body, viewer).await
333+ }
334+ }
335+
336+ async fn publish(&self, name: &NpmName, body: &Value, viewer: Option<&User>) -> Result<Response> {
337+ let Some(versions) = body["versions"].as_object().filter(|v| v.len() == 1) else {
338+ return error(400, "A publish names exactly one version.");
339+ };
340+ let (version, manifest) = versions.iter().next().map(|(v, m)| (v.clone(), m.clone())).unwrap_or_default();
341+ if !npm::valid_version(&version) {
342+ return error(400, format!("{version} is not a semver version."));
343+ }
344+ let Some((_, attachment)) = body["_attachments"].as_object().and_then(|a| a.iter().next()) else {
345+ return error(400, "The tarball is missing.");
346+ };
347+ let Some(tarball) = attachment["data"].as_str().and_then(|data| STANDARD.decode(data).ok()) else {
348+ return error(400, "The tarball is not base64.");
349+ };
350+ if attachment["length"].as_u64().is_some_and(|length| length != tarball.len() as u64) {
351+ return error(400, "The tarball's length is not what the publish says.");
352+ }
353+ let computed = npm::integrity(&tarball);
354+ let dist = &manifest["dist"];
355+ if !npm::agrees(&computed, dist["integrity"].as_str(), dist["shasum"].as_str()) {
356+ return error(400, "The tarball's integrity is not what the publish says. Publish again.");
357+ }
358+
359+ let found = self.npm_package(name).await?;
360+ if found.is_none() && self.db.workspace_hidden(&name.workspace).await? {
361+ return error(403, format!("The workspace {} is deleted; nothing can be published to it.", name.workspace));
362+ }
363+ let target = match &found {
364+ Some(package) => TargetOf::package(package),
365+ None => self.npm_target(name, &manifest).await?,
366+ };
367+ let decision = access::decide(viewer, &target.view(), Action::Push);
368+ if !decision.allowed {
369+ let readable = access::decide(viewer, &target.view(), Action::Pull).allowed || found.is_none();
370+ return refused(decision, readable);
371+ }
372+ let caller = Caller { actor: viewer.map(AuditActor::of) };
373+ let package = match found {
374+ Some(package) => package,
375+ None => {
376+ self.db
377+ .create_package(
378+ &new_id("pkg", now_ms()),
379+ &name.workspace,
380+ NPM,
381+ &name.name,
382+ target.repo.as_ref().map(|(id, repo, private)| (id.as_str(), repo.as_str(), *private)),
383+ caller.actor.as_ref().map_or("", |actor| actor.actor_id.as_str()),
384+ now_ms(),
385+ )
386+ .await?
387+ }
388+ };
389+ if self.db.version_named(&package.id, &version).await?.is_some() {
390+ return error(403, format!("You cannot publish over the previously published version {version}. Bump the version in package.json."));
391+ }
392+
393+ let digest = Digest::of(&tarball);
394+ let size = tarball.len() as u64;
395+ if let Some(refusal) = self.storage_refusal(&package, &[(digest.to_string(), size)]).await? {
396+ return error(403, refusal);
397+ }
398+ let now = now_ms();
399+ let stored = match self.db.blob(&digest).await? {
400+ Some(blob) => self.store.head(&blob.object_key).await?.is_some(),
401+ None => false,
402+ };
403+ if !stored {
404+ self.store.put(&digest.object_key(), tarball).await?;
405+ }
406+ self.db
407+ .keep_blob(&package.id, &digest, size, Some("application/octet-stream"), &digest.object_key(), now)
408+ .await?;
409+
410+ // The dist-tags that point to this version; `latest` when none.
411+ let mut tags: Vec<String> = body["dist-tags"]
412+ .as_object()
413+ .map(|tags| {
414+ tags.iter()
415+ .filter(|(tag, v)| v.as_str() == Some(version.as_str()) && npm::valid_tag(tag))
416+ .map(|(tag, _)| tag.clone())
417+ .collect()
418+ })
419+ .unwrap_or_default();
420+ if tags.is_empty() {
421+ tags.push("latest".to_owned());
422+ }
423+ let (row, _) = self
424+ .db
425+ .publish(
426+ NewVersion {
427+ id: new_id("ver", now),
428+ package_id: package.id.clone(),
429+ version: version.clone(),
430+ digest: digest.to_string(),
431+ size,
432+ metadata: npm::stored_manifest(&manifest, &computed).to_string(),
433+ subject: None,
434+ published_by: published_by(&caller),
435+ files: vec![NewFile {
436+ name: "tarball".to_owned(),
437+ digest: digest.to_string(),
438+ size,
439+ media_type: Some("application/octet-stream".to_owned()),
440+ }],
441+ },
442+ Some(&tags[0]),
443+ now,
444+ )
445+ .await?;
446+ for tag in &tags[1..] {
447+ self.db.set_tag(&package.id, tag, &row.id, now).await?;
448+ }
449+ // The README the package page shows: the latest version's.
450+ if tags.iter().any(|tag| tag == "latest") {
451+ let readme = body["readme"].as_str().or(manifest["readme"].as_str()).unwrap_or("").trim();
452+ let description = manifest["description"].as_str();
453+ let readme_digest = if readme.is_empty() || readme == "ERROR: No README data found!" || readme.len() > MAX_README_BYTES {
454+ None
455+ } else {
456+ let bytes = readme.as_bytes().to_vec();
457+ let digest = Digest::of(&bytes);
458+ if self.db.blob(&digest).await?.is_none() {
459+ self.store.put(&digest.object_key(), bytes.clone()).await?;
460+ }
461+ self.db
462+ .keep_blob(&package.id, &digest, bytes.len() as u64, Some("text/markdown"), &digest.object_key(), now)
463+ .await?;
464+ Some(digest.to_string())
465+ };
466+ self.db.set_readme(&package.id, readme_digest.as_deref(), description, now).await?;
467+ }
468+ self.db.measure(&package.workspace).await?;
469+ let event = PackageEvent {
470+ version: Some(version.clone()),
471+ digest: Some(digest.to_string()),
472+ size: Some(size),
473+ tags: Some(tags.clone()),
474+ ..self.event_of(&package)
475+ };
476+ self.announce("package.published", &package, event, &caller).await;
477+ self.audit(&caller, "package.publish", &package, Some(&format!("{}@{version}", name.full())), None).await;
478+ Ok(Response::from_json(&json!({ "ok": true, "id": name.full(), "rev": format!("1-{}", &digest.hex()[..16]) }))?.with_status(201))
479+ }
480+
481+ /// npm's change of a packument it read: versions it left out are
482+ /// unpublished, `deprecated` set or cleared, dist-tags made to match.
483+ async fn change(&self, name: &NpmName, body: &Value, viewer: Option<&User>) -> Result<Response> {
484+ let Some(package) = self.npm_package(name).await? else {
485+ return not_found();
486+ };
487+ if let Some(refusal) = self.npm_check(viewer, &package, Action::Push) {
488+ return refusal;
489+ }
490+ let Some(sent) = body["versions"].as_object() else {
491+ return error(400, "The packument names no versions.");
492+ };
493+ let caller = Caller { actor: viewer.map(AuditActor::of) };
494+ let admin = access::decide(viewer, &TargetOf::package(&package).view(), Action::Delete).allowed;
495+ let now = now_ms();
496+ let versions = self.stored_versions(&package).await?;
497+ let removed: Vec<&VersionRow> = versions.iter().map(|(row, _)| row).filter(|row| !sent.contains_key(&row.version)).collect();
498+ if let Some(late) = removed.iter().find(|row| !npm::may_unpublish(published_ms(row), now, admin)) {
499+ return error(
500+ 403,
501+ format!("{}@{} was published more than 72 hours ago: unpublishing it needs the Admin role. Deprecate it instead.", name.full(), late.version),
502+ );
503+ }
504+ for (row, stored) in &versions {
505+ let Some(version) = sent.get(&row.version) else { continue };
506+ let wanted = version["deprecated"].as_str().filter(|m| !m.is_empty()).map(str::to_owned);
507+ if wanted != stored.deprecated {
508+ self.db.set_deprecated(&row.id, wanted.as_deref()).await?;
509+ let action = if wanted.is_some() { "package.deprecate" } else { "package.undeprecate" };
510+ self.audit(&caller, action, &package, Some(&format!("{}@{}", name.full(), row.version)), None).await;
511+ }
512+ }
513+ for row in &removed {
514+ self.remove_version(&package, row, &caller).await?;
515+ }
516+ // The dist-tags, as sent, for the versions that are left.
517+ if let Some(tags) = body["dist-tags"].as_object() {
518+ let left: HashMap<&str, &str> = versions
519+ .iter()
520+ .filter(|(row, _)| sent.contains_key(&row.version))
521+ .map(|(row, _)| (row.version.as_str(), row.id.as_str()))
522+ .collect();
523+ let current = self.tag_pairs(&package, &versions).await?;
524+ let mut wanted = HashSet::new();
525+ for (tag, version) in tags {
526+ let Some(id) = version.as_str().and_then(|v| left.get(v)) else { continue };
527+ wanted.insert(tag.as_str());
528+ if !current.iter().any(|(t, v)| t == tag && Some(v.as_str()) == version.as_str()) {
529+ self.db.set_tag(&package.id, tag, id, now).await?;
530+ }
531+ }
532+ for (tag, _) in current.iter().filter(|(tag, _)| !wanted.contains(tag.as_str())) {
533+ self.db.delete_tag(&package.id, tag).await?;
534+ }
535+ }
536+ self.db.touch_package(&package.id, now).await?;
537+ ok()
538+ }
539+
540+ async fn unpublish_package(&self, name: &NpmName, viewer: Option<&User>) -> Result<Response> {
541+ let Some(package) = self.npm_package(name).await? else {
542+ return not_found();
543+ };
544+ if let Some(refusal) = self.npm_check(viewer, &package, Action::Push) {
545+ return refusal;
546+ }
547+ let admin = access::decide(viewer, &TargetOf::package(&package).view(), Action::Delete).allowed;
548+ let now = now_ms();
549+ let versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
550+ if let Some(late) = versions.iter().find(|row| !npm::may_unpublish(published_ms(row), now, admin)) {
551+ return error(
552+ 403,
553+ format!("{}@{} was published more than 72 hours ago: unpublishing the package needs the Admin role.", name.full(), late.version),
554+ );
555+ }
556+ let caller = Caller { actor: viewer.map(AuditActor::of) };
557+ self.db.delete_package(&package.id).await?;
558+ self.db.measure(&package.workspace).await?;
559+ self.announce("package.deleted", &package, self.event_of(&package), &caller).await;
560+ self.audit(&caller, "package.delete", &package, Some(&name.full()), None).await;
561+ ok()
562+ }
563+
564+ /// The last step of `npm unpublish <name>@<version>`: the packument
565+ /// without the version was sent first, so usually it is gone already.
566+ async fn unpublish_tarball(&self, name: &NpmName, version: &str, viewer: Option<&User>) -> Result<Response> {
567+ let Some(package) = self.npm_package(name).await? else {
568+ return ok();
569+ };
570+ if let Some(refusal) = self.npm_check(viewer, &package, Action::Push) {
571+ return refusal;
572+ }
573+ let Some(row) = self.db.version_named(&package.id, version).await? else {
574+ return ok();
575+ };
576+ let admin = access::decide(viewer, &TargetOf::package(&package).view(), Action::Delete).allowed;
577+ if !npm::may_unpublish(published_ms(&row), now_ms(), admin) {
578+ return error(403, format!("{}@{version} was published more than 72 hours ago: unpublishing it needs the Admin role.", name.full()));
579+ }
580+ let caller = Caller { actor: viewer.map(AuditActor::of) };
581+ self.remove_version(&package, &row, &caller).await?;
582+ ok()
583+ }
584+
585+ async fn dist_tags(&self, request: &mut Request, method: Method, name: &NpmName, tag: Option<&str>, viewer: Option<&User>) -> Result<Response> {
586+ let Some(package) = self.npm_package(name).await? else {
587+ return not_found();
588+ };
589+ let action = if matches!(method, Method::Get | Method::Head) { Action::Pull } else { Action::Push };
590+ if let Some(refusal) = self.npm_check(viewer, &package, action) {
591+ return refusal;
592+ }
593+ let versions = self.stored_versions(&package).await?;
594+ match (method, tag) {
595+ (Method::Get | Method::Head, None) => {
596+ let tags = self.tag_pairs(&package, &versions).await?;
597+ Response::from_json(&Value::Object(tags.into_iter().map(|(t, v)| (t, json!(v))).collect()))
598+ }
599+ (Method::Put | Method::Post, Some(tag)) => {
600+ if !npm::valid_tag(tag) {
601+ return error(400, format!("{tag} is not a valid tag: it may not look like a version."));
602+ }
603+ let body: Value = request.json().await.unwrap_or_default();
604+ let Some(version) = body.as_str() else {
605+ return error(400, "Send the version the tag points to, as a JSON string.");
606+ };
607+ let Some((row, _)) = versions.iter().find(|(row, _)| row.version == version) else {
608+ return error(404, format!("{}@{version} is not there.", name.full()));
609+ };
610+ self.db.set_tag(&package.id, tag, &row.id, now_ms()).await?;
611+ self.db.touch_package(&package.id, now_ms()).await?;
612+ ok()
613+ }
614+ (Method::Delete, Some(tag)) => {
615+ if tag == "latest" {
616+ return error(400, "The latest tag cannot be removed; point it at another version instead.");
617+ }
618+ self.db.delete_tag(&package.id, tag).await?;
619+ self.db.touch_package(&package.id, now_ms()).await?;
620+ ok()
621+ }
622+ _ => error(405, "Not a method this address takes."),
623+ }
624+ }
625+}
+16−10
5757 }
5858
5959 /// Who the request comes from, as its headers say.
60−enum Credentials {
60+pub(crate) enum Credentials {
6161 None,
6262 /// One of this registry's tokens.
6363 Token(Claims),
7474 }
7575
7676 /// `scheme://host`, as the client reached the registry.
77−fn origin(url: &Url) -> String {
77+pub(crate) fn origin(url: &Url) -> String {
7878 let host = url.host_str().unwrap_or("g1t.sh");
7979 match url.port() {
8080 Some(port) => format!("{}://{host}:{port}", url.scheme()),
109109 }
110110
111111 /// The audit actor a version's `published_by` names.
112−fn published_by(caller: &Caller) -> Option<String> {
112+pub(crate) fn published_by(caller: &Caller) -> Option<String> {
113113 caller.actor.as_ref().map(|actor| actor.on_behalf_of.clone().unwrap_or_else(|| actor.actor.clone()))
114114 }
115115
146146 let method = request.method();
147147 let credentials = self.credentials(&request).await?;
148148 if limits::counts(&method, &route)
149− && let Some(refused) = self.limited(&request, &credentials).await?
149+ && let Some(refused) = self.limited(&request, &credentials, "`docker login g1t.sh`").await?
150150 {
151151 return Ok(refused);
152152 }
268268
269269 /// The 429 for a client past its limit, if it is. A limit that is not
270270 /// configured (self-hosted) or cannot be asked lets the request through.
271− async fn limited(&self, request: &Request, credentials: &Credentials) -> Result<Option<Response>> {
271+ /// `sign_in` is how a client of this registry signs in, for the message.
272+ pub(crate) async fn limited(&self, request: &Request, credentials: &Credentials, sign_in: &str) -> Result<Option<Response>> {
272273 let subject = match credentials {
273274 Credentials::Token(claims) => claims.actor.as_ref().map(|actor| actor.actor_id.clone()),
274275 Credentials::Viewer(Some(user)) => Some(user.id.clone()),
285286 429,
286287 "TOOMANYREQUESTS",
287288 match limit {
288− limits::Limit::Anonymous => "Too many requests from this address. Wait a minute, or sign in with `docker login g1t.sh` for a higher limit.",
289− limits::Limit::Signed => "Too many requests. Wait a minute and try again.",
289+ limits::Limit::Anonymous => format!("Too many requests from this address. Wait a minute, or sign in with {sign_in} for a higher limit."),
290+ limits::Limit::Signed => "Too many requests. Wait a minute and try again.".to_owned(),
290291 },
291292 )?;
292293 response.headers_mut().set("retry-after", &limits::RETRY_AFTER_SECONDS.to_string())?;
300301 }
301302 }
302303
303− async fn credentials(&self, request: &Request) -> Result<Credentials> {
304+ pub(crate) async fn credentials(&self, request: &Request) -> Result<Credentials> {
304305 let Some(header) = request.headers().get("authorization")? else {
305306 return Ok(Credentials::None);
306307 };
974975 ..self.event_of(package)
975976 };
976977 self.announce("package.version_deleted", package, event, caller).await;
977− self.audit(caller, "package.delete_version", package, Some(&format!("{}/{}@{}", package.workspace, package.name, version.digest)), None)
978− .await;
978+ // npm names a version by its number; an image by its digest.
979+ let path = if package.ecosystem == "npm" {
980+ format!("@{}/{}@{}", package.workspace, package.name, version.version)
981+ } else {
982+ format!("{}/{}@{}", package.workspace, package.name, version.digest)
983+ };
984+ self.audit(caller, "package.delete_version", package, Some(&path), None).await;
979985 Ok(())
980986 }
981987