1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | /**
* Read authorization for the package registry.
*
* Why this is its own module rather than middleware: the npm protocol routes
* address packages by package name alone, so they never carry the
* owner-plus-repo path shape that the platform-wide private-repo gate in
* app.tsx keys on. The JSON helper routes sit under an api prefix and miss it
* for the same reason. Without an explicit check here, a private package's
* packument AND tarball are world-readable — `npm install` hands the full
* source of a private package to an anonymous caller.
*
* A package is publicly readable only when the owning repository is public
* AND the package row is not itself marked private. Both are consulted
* because the two drift apart in opposite directions:
*
* - `packages.visibility` is stamped from the repository at publish time
* and is never rewritten afterwards, so a repo that later flips to
* private still has packages marked "public". The live repository row is
* the authority there.
* - A package explicitly marked private inside a public repository must
* stay private, so the package row is the authority in that direction.
*
* Anything not publicly readable falls through to `resolveRepoAccess`, which
* grants the repository owner and accepted collaborators.
*
* Callers report a denial as 404, never 403, so that probing the registry
* cannot be used to enumerate which private package names exist.
*/
import { eq } from "drizzle-orm";
import { db } from "../db";
import { repositories } from "../db/schema";
import type { Package } from "../db/schema";
import {
resolveRepoAccess as realResolveRepoAccess,
satisfiesAccess,
} from "../middleware/repo-access";
// --- injectable deps -------------------------------------------------------
//
// Both external touchpoints go through `_deps` so tests can drive the access
// RULE without a live database. This is deliberately NOT `mock.module`: bun
// loads every test module before running any test and `mock.module` swaps the
// registry entry rather than rebinding namespaces an importer already holds,
// so mocking a widely-imported module like `../db` here would leak into every
// unrelated test in the run.
export type RepoRow = { id: string; isPrivate: boolean };
export type PackageAccessDeps = {
resolveRepoAccess: typeof realResolveRepoAccess;
loadRepoById: (repoId: string) => Promise<RepoRow | null>;
};
const realLoadRepoById = async (repoId: string): Promise<RepoRow | null> => {
const [repo] = await db
.select({ id: repositories.id, isPrivate: repositories.isPrivate })
.from(repositories)
.where(eq(repositories.id, repoId))
.limit(1);
return repo || null;
};
const realDeps: PackageAccessDeps = {
resolveRepoAccess: realResolveRepoAccess,
loadRepoById: realLoadRepoById,
};
let _deps: PackageAccessDeps = realDeps;
/** Test seam. Pass null to restore the real dependencies. */
export function __setPackageAccessDepsForTests(
overrides: Partial<PackageAccessDeps> | null
): void {
_deps = overrides ? { ...realDeps, ...overrides } : realDeps;
}
/**
* True if `userId` is a member of the repository — its owner or an accepted
* collaborator — as opposed to someone who merely gets "read" from the
* public fallback.
*
* Resolving with `isPublic: false` is what draws that line: the public
* fallback is switched off, so only a real membership row can satisfy it.
* This is the test for whether individually-private packages are visible.
*/
export async function isRepoMember(
repoId: string,
userId: string | null
): Promise<boolean> {
if (!userId) return false;
const level = await _deps.resolveRepoAccess({
repoId,
userId,
isPublic: false,
});
return satisfiesAccess(level, "read");
}
/** True if `userId` may read packages belonging to `repo`. */
export async function canReadRepoPackages(
repo: { id: string; isPrivate: boolean },
userId: string | null,
pkgVisibility?: string | null
): Promise<boolean> {
const isPublic = !repo.isPrivate && pkgVisibility !== "private";
if (isPublic) return true;
return isRepoMember(repo.id, userId);
}
/**
* Read gate for the npm protocol routes, which resolve a package by name and
* therefore have to look the owning repository up by id.
*/
export async function canReadPackage(
pkg: Package,
userId: string | null
): Promise<boolean> {
const repo = await _deps.loadRepoById(pkg.repositoryId);
// Dangling package with no owning repository: nothing can vouch for it.
if (!repo) return false;
return canReadRepoPackages(repo, userId, pkg.visibility);
}
|