Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit7437605unknown_key

feat(BLOCK-B2): org-owned repositories + namespace resolver

feat(BLOCK-B2): org-owned repositories + namespace resolver

Schema: adds nullable repositories.org_id + partial unique indexes so
(ownerId, name) uniqueness only applies when orgId IS NULL, and
(orgId, name) is unique for org-owned repos.

Routes:
- GET/POST /orgs/:slug/repos/new (admin+ to create)
- GET /orgs/:slug/repos (list)
- POST /api/repos accepts optional orgSlug; places repo in org namespace
  after verifying caller is org admin+
- GET /api/repos/:owner/:name resolves either user- or org-owned via
  new src/lib/namespace.ts (loadRepoByPath, listReposForNamespace)
- /register refuses usernames that collide with existing org slugs

Tests: +5 route + validation tests (123 pass total).

Follow-up (B2.1): thread namespace resolver through web.tsx and sub-routes
(issues, pulls, etc.) so `/:orgSlug/:repo/...` reaches all existing views.
Claude committed on April 15, 2026Parent: 6563f0a
7 files changed+533277437605d7fcd4d33e6fb4d8507e6dff97e95410b
7 changed files+533−27
Addeddrizzle/0004_org_owned_repos.sql+36−0View fileUnifiedSplit
1-- Gluecron migration 0004: Block B2 — repositories can be owned by an org.
2-- Adds repositories.org_id (nullable) + partial unique indexes so a user and
3-- an org can each have a repo named "web" without collision, but two orgs
4-- (or two users) still cannot.
5
6--> statement-breakpoint
7ALTER TABLE "repositories" ADD COLUMN IF NOT EXISTS "org_id" uuid;
8
9--> statement-breakpoint
10ALTER TABLE "repositories"
11 DROP CONSTRAINT IF EXISTS "repositories_org_id_fk";
12
13--> statement-breakpoint
14ALTER TABLE "repositories"
15 ADD CONSTRAINT "repositories_org_id_fk"
16 FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE;
17
18--> statement-breakpoint
19-- Existing unique index was on (owner_id, name) across all rows. That would
20-- block the same user from creating a personal "web" AND an org-owned "web"
21-- where they happen to be the listed creator. Make it partial so it only
22-- enforces uniqueness within the user namespace.
23DROP INDEX IF EXISTS "repos_owner_name";
24
25--> statement-breakpoint
26CREATE UNIQUE INDEX IF NOT EXISTS "repos_owner_name"
27 ON "repositories" ("owner_id", "name")
28 WHERE "org_id" IS NULL;
29
30--> statement-breakpoint
31CREATE UNIQUE INDEX IF NOT EXISTS "repos_org_name"
32 ON "repositories" ("org_id", "name")
33 WHERE "org_id" IS NOT NULL;
34
35--> statement-breakpoint
36CREATE INDEX IF NOT EXISTS "repos_org" ON "repositories" ("org_id");
Modifiedsrc/__tests__/green-ecosystem.test.ts+49−0View fileUnifiedSplit
552552 expect(loc.startsWith("/login")).toBe(true);
553553 });
554554});
555
556describe("org-owned repos (B2)", () => {
557 it("GET /orgs/:slug/repos redirects unauthenticated users to /login", async () => {
558 const res = await app.request("/orgs/some-org/repos");
559 expect([301, 302, 303, 307]).toContain(res.status);
560 const loc = res.headers.get("location") || "";
561 expect(loc.startsWith("/login")).toBe(true);
562 });
563
564 it("GET /orgs/:slug/repos/new redirects unauthenticated users to /login", async () => {
565 const res = await app.request("/orgs/some-org/repos/new");
566 expect([301, 302, 303, 307]).toContain(res.status);
567 const loc = res.headers.get("location") || "";
568 expect(loc.startsWith("/login")).toBe(true);
569 });
570
571 it("POST /orgs/:slug/repos/new redirects unauthenticated users to /login", async () => {
572 const res = await app.request("/orgs/some-org/repos/new", {
573 method: "POST",
574 headers: { "content-type": "application/x-www-form-urlencoded" },
575 body: "name=web",
576 });
577 expect([301, 302, 303, 307]).toContain(res.status);
578 const loc = res.headers.get("location") || "";
579 expect(loc.startsWith("/login")).toBe(true);
580 });
581
582 it("POST /api/repos with orgSlug still validates required fields", async () => {
583 const res = await app.request("/api/repos", {
584 method: "POST",
585 headers: { "Content-Type": "application/json" },
586 body: JSON.stringify({ orgSlug: "acme" }),
587 });
588 // Missing name + owner → 400 before any DB access.
589 expect(res.status).toBe(400);
590 });
591
592 it("POST /api/repos rejects invalid repo names before DB access", async () => {
593 const res = await app.request("/api/repos", {
594 method: "POST",
595 headers: { "Content-Type": "application/json" },
596 body: JSON.stringify({
597 name: "bad name with spaces",
598 owner: "alice",
599 }),
600 });
601 expect(res.status).toBe(400);
602 });
603});
Modifiedsrc/db/schema.ts+11−1View fileUnifiedSplit
4141 {
4242 id: uuid("id").primaryKey().defaultRandom(),
4343 name: text("name").notNull(),
44 // ownerId = creator / user-owner. Always set (for attribution + user
45 // namespace uniqueness). For org-owned repos, also represents "created by".
4446 ownerId: uuid("owner_id")
4547 .notNull()
4648 .references(() => users.id),
49 // Block B2: nullable org owner. When set, the repo lives in the org
50 // namespace and URL resolution routes `/:orgSlug/:repo` to it.
51 orgId: uuid("org_id"),
4752 description: text("description"),
4853 isPrivate: boolean("is_private").default(false).notNull(),
4954 isArchived: boolean("is_archived").default(false).notNull(),
5964 forkCount: integer("fork_count").default(0).notNull(),
6065 issueCount: integer("issue_count").default(0).notNull(),
6166 },
62 (table) => [uniqueIndex("repos_owner_name").on(table.ownerId, table.name)]
67 (table) => [
68 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
69 // Matches the partial index in migration 0004.
70 uniqueIndex("repos_owner_name").on(table.ownerId, table.name),
71 index("repos_org").on(table.orgId),
72 ]
6373);
6474
6575/**
Addedsrc/lib/namespace.ts+113−0View fileUnifiedSplit
1/**
2 * Namespace resolution (Block B2).
3 *
4 * The URL path `/:slug` can resolve to either a user or an organization.
5 * Usernames and org slugs occupy the same routing namespace; at creation
6 * time we refuse an org slug that collides with a username (and vice-versa
7 * at register time — see `routes/auth.tsx`).
8 *
9 * Helpers here are read-only and swallow DB errors: they return `null` on
10 * failure so page handlers can fall through to 404 instead of 500.
11 */
12
13import { and, eq, isNull } from "drizzle-orm";
14import { db } from "../db";
15import { users, organizations, repositories } from "../db/schema";
16
17export type Namespace =
18 | { kind: "user"; id: string; slug: string }
19 | { kind: "org"; id: string; slug: string };
20
21/**
22 * Resolve a URL slug to either a user or an org. User lookups win first
23 * (usernames are the legacy, most-used namespace). Returns `null` if neither
24 * exists or the DB is unreachable.
25 */
26export async function resolveNamespace(
27 slug: string
28): Promise<Namespace | null> {
29 if (!slug) return null;
30 try {
31 const [u] = await db
32 .select({ id: users.id, slug: users.username })
33 .from(users)
34 .where(eq(users.username, slug))
35 .limit(1);
36 if (u) return { kind: "user", id: u.id, slug: u.slug };
37
38 const [o] = await db
39 .select({ id: organizations.id, slug: organizations.slug })
40 .from(organizations)
41 .where(eq(organizations.slug, slug))
42 .limit(1);
43 if (o) return { kind: "org", id: o.id, slug: o.slug };
44
45 return null;
46 } catch (err) {
47 console.error("[namespace] resolveNamespace:", err);
48 return null;
49 }
50}
51
52/**
53 * Load a repo by its URL path `:owner/:repo`. Works for both user-owned
54 * and org-owned repos.
55 */
56export async function loadRepoByPath(
57 ownerSlug: string,
58 repoName: string
59): Promise<typeof repositories.$inferSelect | null> {
60 const ns = await resolveNamespace(ownerSlug);
61 if (!ns) return null;
62 try {
63 if (ns.kind === "user") {
64 const [r] = await db
65 .select()
66 .from(repositories)
67 .where(
68 and(
69 eq(repositories.ownerId, ns.id),
70 eq(repositories.name, repoName),
71 isNull(repositories.orgId)
72 )
73 )
74 .limit(1);
75 return r || null;
76 }
77 const [r] = await db
78 .select()
79 .from(repositories)
80 .where(
81 and(eq(repositories.orgId, ns.id), eq(repositories.name, repoName))
82 )
83 .limit(1);
84 return r || null;
85 } catch (err) {
86 console.error("[namespace] loadRepoByPath:", err);
87 return null;
88 }
89}
90
91/**
92 * List all repos (user or org) for a URL slug. Used by the profile page
93 * to render a unified "repos owned by X" list.
94 */
95export async function listReposForNamespace(ns: Namespace) {
96 try {
97 if (ns.kind === "user") {
98 return await db
99 .select()
100 .from(repositories)
101 .where(
102 and(eq(repositories.ownerId, ns.id), isNull(repositories.orgId))
103 );
104 }
105 return await db
106 .select()
107 .from(repositories)
108 .where(eq(repositories.orgId, ns.id));
109 } catch (err) {
110 console.error("[namespace] listReposForNamespace:", err);
111 return [];
112 }
113}
Modifiedsrc/routes/api.ts+66−21View fileUnifiedSplit
33 */
44
55import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
6import { eq, and, isNull } from "drizzle-orm";
77import { db } from "../db";
8import { users, repositories } from "../db/schema";
8import { users, repositories, organizations, orgMembers } from "../db/schema";
99import { initBareRepo, repoExists } from "../git/repository";
1010import { hashPassword } from "../lib/auth";
11import { orgRoleAtLeast } from "../lib/orgs";
1112
1213const api = new Hono().basePath("/api");
1314
1617 let body: {
1718 name: string;
1819 owner: string;
20 orgSlug?: string;
1921 description?: string;
2022 isPrivate?: boolean;
2123 };
3537 }
3638
3739 try {
38 // Find owner
40 // Find creator (user who is performing the action)
3941 const [owner] = await db
4042 .select()
4143 .from(users)
4547 return c.json({ error: "Owner not found" }, 404);
4648 }
4749
48 // Check duplicate
49 if (await repoExists(body.owner, body.name)) {
50 // B2: if orgSlug supplied, place the repo in the org namespace.
51 // Requires the creator to be an admin+ of the org.
52 let orgId: string | null = null;
53 let namespaceSlug = body.owner;
54 if (body.orgSlug) {
55 const [org] = await db
56 .select({ id: organizations.id, slug: organizations.slug })
57 .from(organizations)
58 .where(eq(organizations.slug, body.orgSlug))
59 .limit(1);
60 if (!org) return c.json({ error: "Organization not found" }, 404);
61
62 const [mem] = await db
63 .select({ role: orgMembers.role })
64 .from(orgMembers)
65 .where(
66 and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, owner.id))
67 )
68 .limit(1);
69 if (!mem || !orgRoleAtLeast(mem.role, "admin")) {
70 return c.json({ error: "Admin rights required on org" }, 403);
71 }
72 orgId = org.id;
73 namespaceSlug = org.slug;
74 }
75
76 // Duplicate check: scoped to the right namespace.
77 if (orgId) {
78 const [existing] = await db
79 .select({ id: repositories.id })
80 .from(repositories)
81 .where(
82 and(eq(repositories.orgId, orgId), eq(repositories.name, body.name))
83 )
84 .limit(1);
85 if (existing) {
86 return c.json({ error: "Repository already exists" }, 409);
87 }
88 } else {
89 const [existing] = await db
90 .select({ id: repositories.id })
91 .from(repositories)
92 .where(
93 and(
94 eq(repositories.ownerId, owner.id),
95 eq(repositories.name, body.name),
96 isNull(repositories.orgId)
97 )
98 )
99 .limit(1);
100 if (existing) {
101 return c.json({ error: "Repository already exists" }, 409);
102 }
103 }
104 if (await repoExists(namespaceSlug, body.name)) {
50105 return c.json({ error: "Repository already exists" }, 409);
51106 }
52107
53 // Init bare repo on disk
54 const diskPath = await initBareRepo(body.owner, body.name);
108 // Init bare repo on disk, keyed by the namespace slug (user or org).
109 const diskPath = await initBareRepo(namespaceSlug, body.name);
55110
56111 // Insert into DB
57112 const [repo] = await db
59114 .values({
60115 name: body.name,
61116 ownerId: owner.id,
117 orgId,
62118 description: body.description || null,
63119 isPrivate: body.isPrivate || false,
64120 diskPath,
103159 }
104160});
105161
106// Get single repository
162// Get single repository (resolves both user- and org-owned namespaces)
107163api.get("/repos/:owner/:name", async (c) => {
108164 const { owner: ownerName, name } = c.req.param();
109165 try {
110 const [owner] = await db
111 .select()
112 .from(users)
113 .where(eq(users.username, ownerName));
114 if (!owner) return c.json({ error: "Not found" }, 404);
115
116 const [repo] = await db
117 .select()
118 .from(repositories)
119 .where(
120 and(eq(repositories.ownerId, owner.id), eq(repositories.name, name))
121 );
166 const { loadRepoByPath } = await import("../lib/namespace");
167 const repo = await loadRepoByPath(ownerName, name);
122168 if (!repo) return c.json({ error: "Not found" }, 404);
123
124169 return c.json(repo);
125170 } catch (err) {
126171 console.error("[api] /repos/:owner/:name:", err);
Modifiedsrc/routes/auth.tsx+11−1View fileUnifiedSplit
66import { setCookie, deleteCookie } from "hono/cookie";
77import { eq } from "drizzle-orm";
88import { db } from "../db";
9import { users, sessions } from "../db/schema";
9import { users, sessions, organizations } from "../db/schema";
1010import {
1111 hashPassword,
1212 verifyPassword,
108108 return c.redirect("/register?error=Username+already+taken");
109109 }
110110
111 // B2: usernames share the URL namespace with org slugs; refuse collisions.
112 const [existingOrg] = await db
113 .select({ id: organizations.id })
114 .from(organizations)
115 .where(eq(organizations.slug, username.toLowerCase()))
116 .limit(1);
117 if (existingOrg) {
118 return c.redirect("/register?error=Username+already+taken");
119 }
120
111121 const [existingEmail] = await db
112122 .select()
113123 .from(users)
Modifiedsrc/routes/orgs.tsx+247−4View fileUnifiedSplit
2929 teams,
3030 teamMembers,
3131 users,
32 repositories,
3233} from "../db/schema";
3334import type { AuthEnv } from "../middleware/auth";
3435import { requireAuth } from "../middleware/auth";
4647 listTeamMembers,
4748} from "../lib/orgs";
4849import { audit } from "../lib/notify";
50import { initBareRepo, repoExists } from "../git/repository";
4951
5052const orgs = new Hono<AuthEnv>();
5153
331333 )}
332334 </div>
333335 </div>
334 {role && orgRoleAtLeast(role, "admin") && (
335 <a href={`/orgs/${org.slug}/people`} class="btn">
336 Manage
336 <div style="display: flex; gap: 8px">
337 <a href={`/orgs/${org.slug}/repos`} class="btn">
338 Repositories
337339 </a>
338 )}
340 {role && orgRoleAtLeast(role, "admin") && (
341 <a href={`/orgs/${org.slug}/people`} class="btn">
342 Manage
343 </a>
344 )}
345 </div>
339346 </div>
340347 {org.description && (
341348 <p style="color: var(--text-muted); margin-bottom: 16px">
11261133 );
11271134});
11281135
1136// --- ORG-OWNED REPOS (B2) ---------------------------------------------------
1137
1138orgs.get("/orgs/:slug/repos/new", async (c) => {
1139 const user = c.get("user")!;
1140 const slug = c.req.param("slug");
1141 const { org, role } = await loadOrgForUser(slug, user.id);
1142 if (!org) return c.notFound();
1143 if (!role || !orgRoleAtLeast(role, "admin")) {
1144 return c.redirect(
1145 errorRedirect(`/orgs/${slug}`, "Admin rights required to create repos")
1146 );
1147 }
1148 const error = c.req.query("error");
1149
1150 return c.html(
1151 <Layout title={`New repo — ${org.name}`} user={user}>
1152 <div class="settings-container" style="max-width: 560px">
1153 <div class="breadcrumb">
1154 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
1155 <span>/</span>
1156 <span>new repo</span>
1157 </div>
1158 <h2>Create repository in {org.name}</h2>
1159 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
1160 <form method="POST" action={`/orgs/${org.slug}/repos/new`}>
1161 <div class="form-group">
1162 <label for="name">Repository name</label>
1163 <input
1164 type="text"
1165 id="name"
1166 name="name"
1167 required
1168 maxLength={100}
1169 pattern="[a-zA-Z0-9._-]+"
1170 placeholder="my-repo"
1171 autocomplete="off"
1172 />
1173 </div>
1174 <div class="form-group">
1175 <label for="description">Description (optional)</label>
1176 <textarea
1177 id="description"
1178 name="description"
1179 rows={3}
1180 maxLength={500}
1181 />
1182 </div>
1183 <div class="form-group">
1184 <label>
1185 <input type="checkbox" name="isPrivate" value="1" /> Private
1186 </label>
1187 </div>
1188 <button type="submit" class="btn btn-primary">
1189 Create repository
1190 </button>
1191 </form>
1192 </div>
1193 </Layout>
1194 );
1195});
1196
1197orgs.post("/orgs/:slug/repos/new", async (c) => {
1198 const user = c.get("user")!;
1199 const slug = c.req.param("slug");
1200 const { org, role } = await loadOrgForUser(slug, user.id);
1201 if (!org) return c.notFound();
1202 if (!role || !orgRoleAtLeast(role, "admin")) {
1203 return c.redirect(
1204 errorRedirect(`/orgs/${slug}`, "Admin rights required")
1205 );
1206 }
1207
1208 const body = await c.req.parseBody();
1209 const name = String(body.name || "").trim();
1210 const description = String(body.description || "").trim() || null;
1211 const isPrivate = body.isPrivate === "1";
1212
1213 if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) {
1214 return c.redirect(
1215 errorRedirect(
1216 `/orgs/${slug}/repos/new`,
1217 "Invalid repo name (a-z, 0-9, . _ - only)"
1218 )
1219 );
1220 }
1221
1222 try {
1223 // Name collision within the org's namespace
1224 const [existing] = await db
1225 .select({ id: repositories.id })
1226 .from(repositories)
1227 .where(
1228 and(eq(repositories.orgId, org.id), eq(repositories.name, name))
1229 )
1230 .limit(1);
1231 if (existing) {
1232 return c.redirect(
1233 errorRedirect(
1234 `/orgs/${slug}/repos/new`,
1235 "A repo with that name already exists in this org"
1236 )
1237 );
1238 }
1239 // Disk-side collision (namespace slug is the org's slug on disk)
1240 if (await repoExists(org.slug, name)) {
1241 return c.redirect(
1242 errorRedirect(
1243 `/orgs/${slug}/repos/new`,
1244 "On-disk path already exists"
1245 )
1246 );
1247 }
1248
1249 const diskPath = await initBareRepo(org.slug, name);
1250 const [repo] = await db
1251 .insert(repositories)
1252 .values({
1253 name,
1254 ownerId: user.id,
1255 orgId: org.id,
1256 description,
1257 isPrivate,
1258 diskPath,
1259 })
1260 .returning();
1261
1262 if (repo) {
1263 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
1264 await bootstrapRepository({
1265 repositoryId: repo.id,
1266 ownerUserId: user.id,
1267 });
1268 await audit({
1269 userId: user.id,
1270 repositoryId: repo.id,
1271 action: "org.repo.create",
1272 targetType: "repository",
1273 targetId: repo.id,
1274 metadata: { orgSlug: slug, name, isPrivate },
1275 });
1276 }
1277
1278 return c.redirect(`/${org.slug}/${name}`);
1279 } catch (err) {
1280 console.error("[orgs] repo create:", err);
1281 return c.redirect(
1282 errorRedirect(`/orgs/${slug}/repos/new`, "Failed to create repository")
1283 );
1284 }
1285});
1286
1287orgs.get("/orgs/:slug/repos", async (c) => {
1288 const user = c.get("user")!;
1289 const slug = c.req.param("slug");
1290 const { org, role } = await loadOrgForUser(slug, user.id);
1291 if (!org) return c.notFound();
1292 const canCreate = role && orgRoleAtLeast(role, "admin");
1293
1294 let repos: (typeof repositories.$inferSelect)[] = [];
1295 try {
1296 repos = await db
1297 .select()
1298 .from(repositories)
1299 .where(eq(repositories.orgId, org.id));
1300 } catch (err) {
1301 console.error("[orgs] list repos:", err);
1302 }
1303
1304 return c.html(
1305 <Layout title={`${org.name} — repositories`} user={user}>
1306 <div style="max-width: 900px">
1307 <div class="breadcrumb">
1308 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
1309 <span>/</span>
1310 <span>repos</span>
1311 </div>
1312 <div
1313 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px"
1314 >
1315 <h2 style="margin: 0">Repositories ({repos.length})</h2>
1316 {canCreate && (
1317 <a
1318 href={`/orgs/${org.slug}/repos/new`}
1319 class="btn btn-primary"
1320 >
1321 New repo
1322 </a>
1323 )}
1324 </div>
1325 {repos.length === 0 ? (
1326 <div class="empty-state">
1327 <h2>No repositories yet</h2>
1328 {canCreate ? (
1329 <a
1330 href={`/orgs/${org.slug}/repos/new`}
1331 class="btn btn-primary"
1332 style="margin-top: 8px"
1333 >
1334 Create your first repo
1335 </a>
1336 ) : (
1337 <p>Ask an admin to create one.</p>
1338 )}
1339 </div>
1340 ) : (
1341 <div
1342 style="display: flex; flex-direction: column; gap: 8px"
1343 >
1344 {repos.map((r) => (
1345 <a
1346 href={`/${org.slug}/${r.name}`}
1347 style="display: block; padding: 12px 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); text-decoration: none; color: var(--text)"
1348 >
1349 <strong>{r.name}</strong>
1350 {r.isPrivate && (
1351 <span
1352 class="gate-status"
1353 style="font-size: 10px; margin-left: 8px"
1354 >
1355 private
1356 </span>
1357 )}
1358 {r.description && (
1359 <div style="color: var(--text-muted); font-size: 13px; margin-top: 2px">
1360 {r.description}
1361 </div>
1362 )}
1363 </a>
1364 ))}
1365 </div>
1366 )}
1367 </div>
1368 </Layout>
1369 );
1370});
1371
11291372export default orgs;
11301373