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

repo-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.

repo-access.tsBlame221 lines · 1 contributor
23d1a81Claude1/**
2 * Repo access middleware — resolves a viewer's effective access level to a
3 * repository and enforces a minimum-level gate on routes.
4 *
5 * Access hierarchy (ordered): none < read < write < admin < owner.
6 *
7 * The repo owner (repositories.ownerId === userId) always resolves to
8 * "owner", regardless of any collaborator row. Beyond that, we look up an
9 * ACCEPTED row in repo_collaborators (acceptedAt IS NOT NULL) and return the
10 * stored role. If no row exists, public repos fall back to "read" for any
11 * viewer (including anonymous); private repos return "none".
12 *
13 * The middleware factory reads `:owner` + `:repo` from the URL, looks up
14 * the repository, computes the access level, stashes both on the context
15 * (so downstream handlers don't re-query), and renders a 403 HTML page via
16 * Layout if the caller's level is below the required minimum.
17 *
18 * Implementation notes:
19 * - Hono and JSX dependencies are loaded via *dynamic* `import()` inside
20 * `requireRepoAccess` so that `resolveRepoAccess` — the pure, unit-
21 * testable half — has no static hono/hono-jsx imports at module load.
22 * This lets `bun test` run the logic tests without needing the full
23 * hono runtime (notably `hono/jsx/jsx-dev-runtime`, which a given
24 * install may not have yet when the schema/parallel agent ships).
25 * - The middleware signature `(c, next) => Promise<Response | void>`
26 * matches Hono's `MiddlewareHandler` structurally without importing
27 * the type; routes can pass this directly to `.use()` / handler chains.
28 */
29
30import { and, eq, isNotNull } from "drizzle-orm";
31import { db } from "../db";
32import { repositories, users, repoCollaborators } from "../db/schema";
33import type { Repository, User } from "../db/schema";
34
35export type RepoAccessLevel = "none" | "read" | "write" | "admin" | "owner";
36
37/**
38 * Ordered access hierarchy. Higher index = more access. Use `ACCESS_RANK`
39 * to compare two levels; callers should prefer {@link satisfiesAccess}.
40 */
41export const ACCESS_RANK: Record<RepoAccessLevel, number> = {
42 none: 0,
43 read: 1,
44 write: 2,
45 admin: 3,
46 owner: 4,
47};
48
49/** True if `actual` meets or exceeds `required`. */
50export function satisfiesAccess(
51 actual: RepoAccessLevel,
52 required: RepoAccessLevel
53): boolean {
54 return ACCESS_RANK[actual] >= ACCESS_RANK[required];
55}
56
57/**
58 * Env type for Hono routes that sit behind `requireRepoAccess`. Extends the
59 * existing auth variables — `user` is populated by softAuth/requireAuth,
60 * `repository` and `repoAccess` are populated here.
61 */
62export type RepoAccessEnv = {
63 Variables: {
64 user: User | null;
65 repository: Repository;
66 repoAccess: RepoAccessLevel;
67 };
68};
69
70/**
71 * Pure access resolution — no HTTP, no context. Exposed so callers (e.g.
72 * API responses, view-layer conditionals, tests) can ask "what can this
73 * user do with this repo?" without running the middleware.
74 */
75export async function resolveRepoAccess(args: {
76 repoId: string;
77 userId: string | null;
78 isPublic: boolean;
79}): Promise<RepoAccessLevel> {
80 const { repoId, userId, isPublic } = args;
81
82 // Anonymous viewer: only public repos grant anything, and only "read".
83 if (!userId) {
84 return isPublic ? "read" : "none";
85 }
86
87 // Owner check — look up the repo row once. If the caller IS the owner,
88 // short-circuit before hitting repo_collaborators.
89 try {
90 const [repo] = await db
91 .select({ ownerId: repositories.ownerId })
92 .from(repositories)
93 .where(eq(repositories.id, repoId))
94 .limit(1);
95
96 if (repo && repo.ownerId === userId) {
97 return "owner";
98 }
99 } catch {
100 // Fall through — if the owner lookup fails we still try the
101 // collaborator path below (which may also fail, in which case we'll
102 // end up at the public/private fallback).
103 }
104
105 // Accepted collaborator row wins over the public fallback.
106 try {
107 const [collab] = await db
108 .select({ role: repoCollaborators.role })
109 .from(repoCollaborators)
110 .where(
111 and(
112 eq(repoCollaborators.repositoryId, repoId),
113 eq(repoCollaborators.userId, userId),
114 isNotNull(repoCollaborators.acceptedAt)
115 )
116 )
117 .limit(1);
118
119 if (collab) {
120 return collab.role as RepoAccessLevel;
121 }
122 } catch {
123 // Ignore — fall through to public/private fallback.
124 }
125
126 return isPublic ? "read" : "none";
127}
128
129/**
130 * Middleware factory: gate a route on a minimum access level.
131 *
132 * Assumes the URL has `:owner` and `:repo` params. Looks up the repository,
133 * 404s if it doesn't exist, resolves the viewer's access, and 403s if the
134 * viewer is below `level`. On success, sets `c.var.repository` and
135 * `c.var.repoAccess` for downstream handlers.
136 *
137 * Returns a bare `(c, next)` async function — structurally compatible with
138 * Hono's `MiddlewareHandler`. Hono is loaded lazily inside the handler so
139 * the unit-testable exports above don't force-import the jsx runtime.
140 */
141export function requireRepoAccess(
142 level: "read" | "write" | "admin"
143): (c: any, next: () => Promise<void>) => Promise<Response | void> {
144 return async (c: any, next: () => Promise<void>) => {
145 const { owner: ownerName, repo: repoName } = c.req.param() as {
146 owner?: string;
147 repo?: string;
148 };
149 const user: User | null = c.get("user") ?? null;
150
151 if (!ownerName || !repoName) {
152 return c.notFound();
153 }
154
155 // Resolve owner -> user row, then repo by (owner, name).
156 const [ownerRow] = await db
157 .select()
158 .from(users)
159 .where(eq(users.username, ownerName))
160 .limit(1);
161
162 if (!ownerRow) {
163 return c.notFound();
164 }
165
166 const [repo] = await db
167 .select()
168 .from(repositories)
169 .where(
170 and(
171 eq(repositories.ownerId, ownerRow.id),
172 eq(repositories.name, repoName)
173 )
174 )
175 .limit(1);
176
177 if (!repo) {
178 return c.notFound();
179 }
180
181 const access = await resolveRepoAccess({
182 repoId: repo.id,
183 userId: user?.id ?? null,
184 isPublic: !repo.isPrivate,
185 });
186
187 if (!satisfiesAccess(access, level)) {
188 const reason =
189 access === "none"
190 ? "You don't have permission to view this repository."
191 : `This action requires ${level} access. You have ${access} access.`;
192 // Lazy-load hono/jsx + Layout so the top of this module is safe to
193 // import from unit tests that can't resolve the jsx runtime.
194 const [{ jsx }, { Layout }] = await Promise.all([
195 import("hono/jsx"),
196 import("../views/layout"),
197 ]);
198 const body = jsx(
199 "div",
200 {
201 style:
202 "max-width: 600px; margin: 80px auto; padding: 24px; text-align: center;",
203 },
204 [
205 jsx("h1", { style: "margin-bottom: 12px" }, ["403 — Access denied"]),
206 jsx("p", { style: "color: var(--muted, #8b949e)" }, [reason]),
207 ]
208 );
209 const page = jsx(
210 Layout as any,
211 { title: "Access denied", user },
212 [body]
213 );
214 return c.html(page, 403);
215 }
216
217 c.set("repoAccess", access);
218 c.set("repository", repo);
219 return next();
220 };
221}