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

package-access.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.

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