Commit6563f0aunknown_key
feat(BLOCK-B1): orgs + teams routes (create, members, teams CRUD)
feat(BLOCK-B1): orgs + teams routes (create, members, teams CRUD) Mounts /orgs tree under requireAuth. All writes audit()'d and guarded: admin-only to add members; owner-only to grant owner role; last-owner demote/remove blocked (ownerCount fails safe to 2 on DB error). Team membership requires org membership (route-layer invariant). Adds 18 unit tests covering slug rules, role rank, type guards, internal tables, and /orgs unauthenticated redirects. 118 tests pass total.
4 files changed+1291−26563f0af179134a167b0c451d272a4438e8d7779
4 changed files+1291−2
ModifiedBUILD_BIBLE.md+8−2View fileUnifiedSplit
@@ -133,7 +133,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
133133| OAuth app provider | ❌ | |
134134| GitHub Apps equivalent | ❌ | |
135135| GraphQL API | ❌ | REST only |
136| Organizations + teams | ❌ | user-owned only |
136| Organizations + teams | 🟡 | schema + routes shipped (B1): `/orgs`, `/orgs/new`, `/orgs/:slug/people`, `/orgs/:slug/teams`, last-owner guard, audit logged. Org-owned repos = B2 next |
137137| Enterprise SAML / SSO | ❌ | |
138138| 2FA / TOTP | ❌ | |
139139| Passkeys / WebAuthn | ❌ | |
@@ -184,7 +184,11 @@ Polish what's shipped before adding more. **Priority: do this first if parity ga
184184**BLOCK A COMPLETE.** Next: BLOCK B (Identity + orgs).
185185
186186### BLOCK B — Identity + orgs
187- **B1** — Organizations (schema: `organizations`, `org_members`, `teams`, `team_members`)
187- **B1** — Organizations (schema: `organizations`, `org_members`, `teams`, `team_members`) ✅
188 - Helpers in `src/lib/orgs.ts`: slug validation, role rank, reserved-slug set, loaders
189 - Routes in `src/routes/orgs.tsx`: list / create / profile / people / teams / team detail
190 - Role-based guards: admin adds members, owner grants owner, last-owner demote/remove blocked
191 - All sensitive actions `audit()`'d (org.create, member.add/role/remove, team.create, team.member.add/remove)
188192- **B2** — Repos owned by orgs (nullable `repositories.orgId`)
189193- **B3** — Team-based CODEOWNERS (`@org/team` resolution)
190194- **B4** — 2FA / TOTP (enroll, recovery codes)
@@ -314,6 +318,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
314318- `src/routes/insights.tsx` — insights + milestones
315319- `src/routes/search.tsx` — global search + `/shortcuts`
316320- `src/routes/health.ts` — `/healthz` `/readyz` `/metrics`
321- `src/routes/orgs.tsx` — `/orgs` list, `/orgs/new` create, `/orgs/:slug` profile, `/orgs/:slug/people` + add/role/remove, `/orgs/:slug/teams` + create, `/orgs/:slug/teams/:teamSlug` + member add/remove. All require auth. Role guards via `orgRoleAtLeast`; last-owner cannot be demoted or removed; every write path `audit()`'d.
322- `src/lib/orgs.ts` — `isValidSlug` (rejects reserved + too-short/long + consecutive/leading/trailing hyphens), `normalizeSlug`, `orgRoleAtLeast` (owner>admin>member), `isValidOrgRole`, `isValidTeamRole`, `loadOrgForUser`, `listOrgsForUser`, `listOrgMembers`, `listTeamsForOrg`, `listTeamMembers`, `__test` export for unit tests.
317323
318324### 4.7 Views (locked contracts)
319325- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
Modifiedsrc/__tests__/green-ecosystem.test.ts+150−0View fileUnifiedSplit
@@ -23,6 +23,14 @@ import {
2323} from "../lib/reactions";
2424import { sendEmail, absoluteUrl } from "../lib/email";
2525import { __internal as notifyInternal } from "../lib/notify";
26import {
27 isValidSlug,
28 normalizeSlug,
29 orgRoleAtLeast,
30 isValidOrgRole,
31 isValidTeamRole,
32 __test as orgsInternal,
33} from "../lib/orgs";
2634
2735describe("secret scanner", () => {
2836 it("detects AWS access keys", () => {
@@ -402,3 +410,145 @@ describe("settings email preferences", () => {
402410 expect(loc.startsWith("/login")).toBe(true);
403411 });
404412});
413
414describe("orgs helpers (B1)", () => {
415 describe("isValidSlug", () => {
416 it("accepts simple slugs", () => {
417 expect(isValidSlug("acme")).toBe(true);
418 expect(isValidSlug("acme-corp")).toBe(true);
419 expect(isValidSlug("a1")).toBe(true);
420 expect(isValidSlug("a-b-c-1-2-3")).toBe(true);
421 });
422
423 it("rejects too-short or too-long", () => {
424 expect(isValidSlug("")).toBe(false);
425 expect(isValidSlug("a")).toBe(false);
426 expect(isValidSlug("a".repeat(40))).toBe(false);
427 });
428
429 it("rejects leading/trailing hyphen", () => {
430 expect(isValidSlug("-acme")).toBe(false);
431 expect(isValidSlug("acme-")).toBe(false);
432 });
433
434 it("rejects consecutive hyphens", () => {
435 expect(isValidSlug("foo--bar")).toBe(false);
436 });
437
438 it("rejects uppercase + invalid chars", () => {
439 expect(isValidSlug("Acme")).toBe(false);
440 expect(isValidSlug("acme_corp")).toBe(false);
441 expect(isValidSlug("acme.corp")).toBe(false);
442 expect(isValidSlug("acme corp")).toBe(false);
443 });
444
445 it("rejects reserved words", () => {
446 expect(isValidSlug("api")).toBe(false);
447 expect(isValidSlug("admin")).toBe(false);
448 expect(isValidSlug("settings")).toBe(false);
449 expect(isValidSlug("orgs")).toBe(false);
450 expect(isValidSlug("new")).toBe(false);
451 });
452 });
453
454 describe("normalizeSlug", () => {
455 it("lowercases and trims", () => {
456 expect(normalizeSlug(" ACME ")).toBe("acme");
457 expect(normalizeSlug("Acme-Corp")).toBe("acme-corp");
458 });
459 });
460
461 describe("orgRoleAtLeast", () => {
462 it("owner beats admin beats member", () => {
463 expect(orgRoleAtLeast("owner", "member")).toBe(true);
464 expect(orgRoleAtLeast("owner", "admin")).toBe(true);
465 expect(orgRoleAtLeast("owner", "owner")).toBe(true);
466 expect(orgRoleAtLeast("admin", "member")).toBe(true);
467 expect(orgRoleAtLeast("admin", "admin")).toBe(true);
468 expect(orgRoleAtLeast("admin", "owner")).toBe(false);
469 expect(orgRoleAtLeast("member", "admin")).toBe(false);
470 expect(orgRoleAtLeast("member", "owner")).toBe(false);
471 });
472
473 it("treats unknown role as rank 0", () => {
474 expect(orgRoleAtLeast("", "member")).toBe(false);
475 expect(orgRoleAtLeast("banana", "member")).toBe(false);
476 });
477 });
478
479 describe("role type guards", () => {
480 it("isValidOrgRole", () => {
481 expect(isValidOrgRole("owner")).toBe(true);
482 expect(isValidOrgRole("admin")).toBe(true);
483 expect(isValidOrgRole("member")).toBe(true);
484 expect(isValidOrgRole("maintainer")).toBe(false);
485 expect(isValidOrgRole("banana")).toBe(false);
486 });
487
488 it("isValidTeamRole", () => {
489 expect(isValidTeamRole("maintainer")).toBe(true);
490 expect(isValidTeamRole("member")).toBe(true);
491 expect(isValidTeamRole("owner")).toBe(false);
492 expect(isValidTeamRole("banana")).toBe(false);
493 });
494 });
495
496 describe("internal", () => {
497 it("rank table orders correctly", () => {
498 const r = orgsInternal.ORG_ROLE_RANK;
499 expect(r.owner).toBeGreaterThan(r.admin);
500 expect(r.admin).toBeGreaterThan(r.member);
501 });
502
503 it("reserved set contains the app's top-level paths", () => {
504 expect(orgsInternal.RESERVED_SLUGS.has("api")).toBe(true);
505 expect(orgsInternal.RESERVED_SLUGS.has("settings")).toBe(true);
506 expect(orgsInternal.RESERVED_SLUGS.has("login")).toBe(true);
507 });
508 });
509});
510
511describe("orgs routes (B1)", () => {
512 it("GET /orgs redirects unauthenticated users to /login", async () => {
513 const res = await app.request("/orgs");
514 expect([301, 302, 303, 307]).toContain(res.status);
515 const loc = res.headers.get("location") || "";
516 expect(loc.startsWith("/login")).toBe(true);
517 });
518
519 it("GET /orgs/new redirects unauthenticated users to /login", async () => {
520 const res = await app.request("/orgs/new");
521 expect([301, 302, 303, 307]).toContain(res.status);
522 const loc = res.headers.get("location") || "";
523 expect(loc.startsWith("/login")).toBe(true);
524 });
525
526 it("POST /orgs/new redirects unauthenticated users to /login", async () => {
527 const res = await app.request("/orgs/new", {
528 method: "POST",
529 headers: { "content-type": "application/x-www-form-urlencoded" },
530 body: "slug=acme&name=Acme",
531 });
532 expect([301, 302, 303, 307]).toContain(res.status);
533 const loc = res.headers.get("location") || "";
534 expect(loc.startsWith("/login")).toBe(true);
535 });
536
537 it("GET /orgs/:slug redirects unauthenticated users to /login", async () => {
538 const res = await app.request("/orgs/some-org");
539 expect([301, 302, 303, 307]).toContain(res.status);
540 const loc = res.headers.get("location") || "";
541 expect(loc.startsWith("/login")).toBe(true);
542 });
543
544 it("POST /orgs/:slug/people/add redirects unauthenticated users to /login", async () => {
545 const res = await app.request("/orgs/some-org/people/add", {
546 method: "POST",
547 headers: { "content-type": "application/x-www-form-urlencoded" },
548 body: "username=alice&role=member",
549 });
550 expect([301, 302, 303, 307]).toContain(res.status);
551 const loc = res.headers.get("location") || "";
552 expect(loc.startsWith("/login")).toBe(true);
553 });
554});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -33,6 +33,7 @@ import auditRoutes from "./routes/audit";
3333import reactionRoutes from "./routes/reactions";
3434import savedReplyRoutes from "./routes/saved-replies";
3535import deploymentRoutes from "./routes/deployments";
36import orgRoutes from "./routes/orgs";
3637import webRoutes from "./routes/web";
3738
3839const app = new Hono();
@@ -85,6 +86,9 @@ app.route("/", savedReplyRoutes);
8586// Environments + deployment history UI
8687app.route("/", deploymentRoutes);
8788
89// Organizations + teams (Block B1)
90app.route("/", orgRoutes);
91
8892// API tokens
8993app.route("/", tokenRoutes);
9094
Addedsrc/routes/orgs.tsx+1129−0View fileUnifiedSplit
Large file (1,130 lines). Load full file