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

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.tsBlame227 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).
a7361c0Claude156 let ownerRow: typeof users.$inferSelect | undefined;
157 let repo: typeof repositories.$inferSelect | undefined;
158 try {
159 [ownerRow] = await db
160 .select()
161 .from(users)
162 .where(eq(users.username, ownerName))
163 .limit(1);
164
165 if (!ownerRow) {
166 return c.notFound();
167 }
168
169 [repo] = await db
170 .select()
171 .from(repositories)
172 .where(
173 and(
174 eq(repositories.ownerId, ownerRow.id),
175 eq(repositories.name, repoName)
176 )
23d1a81Claude177 )
a7361c0Claude178 .limit(1);
179 } catch {
180 return c.json({ error: "Service unavailable" }, 503);
181 }
23d1a81Claude182
183 if (!repo) {
184 return c.notFound();
185 }
186
187 const access = await resolveRepoAccess({
188 repoId: repo.id,
189 userId: user?.id ?? null,
190 isPublic: !repo.isPrivate,
191 });
192
193 if (!satisfiesAccess(access, level)) {
194 const reason =
195 access === "none"
196 ? "You don't have permission to view this repository."
197 : `This action requires ${level} access. You have ${access} access.`;
198 // Lazy-load hono/jsx + Layout so the top of this module is safe to
199 // import from unit tests that can't resolve the jsx runtime.
200 const [{ jsx }, { Layout }] = await Promise.all([
201 import("hono/jsx"),
202 import("../views/layout"),
203 ]);
204 const body = jsx(
205 "div",
206 {
207 style:
208 "max-width: 600px; margin: 80px auto; padding: 24px; text-align: center;",
209 },
210 [
211 jsx("h1", { style: "margin-bottom: 12px" }, ["403 — Access denied"]),
212 jsx("p", { style: "color: var(--muted, #8b949e)" }, [reason]),
213 ]
214 );
215 const page = jsx(
216 Layout as any,
217 { title: "Access denied", user },
218 [body]
219 );
220 return c.html(page, 403);
221 }
222
223 c.set("repoAccess", access);
224 c.set("repository", repo);
225 return next();
226 };
227}