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.
| 23d1a81 | 1 | /** |
| 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 | ||
| 30 | import { and, eq, isNotNull } from "drizzle-orm"; | |
| 31 | import { db } from "../db"; | |
| 32 | import { repositories, users, repoCollaborators } from "../db/schema"; | |
| 33 | import type { Repository, User } from "../db/schema"; | |
| 34 | ||
| 35 | export 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 | */ | |
| 41 | export 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`. */ | |
| 50 | export 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 | */ | |
| 62 | export 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 | */ | |
| 75 | export 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 | */ | |
| 141 | export 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 | } |