Commit4d9e72bunknown_key
feat(claude-web): open Claude chat to all authenticated users (was admin-only)
feat(claude-web): open Claude chat to all authenticated users (was admin-only) Replace the isSiteAdmin gate with a resolveRepoAccess check so any authenticated user with at least READ access to the repo can use the Claude on the web feature. Unauthenticated requests redirect to /login; requests to private repos the user cannot read return 404. https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
2 files changed+58−194d9e72b1f82431f41b189e94ecb8f825da99568d
2 changed files+58−19
Addedsrc/__tests__/claude-web.test.ts+42−0View fileUnifiedSplit
@@ -0,0 +1,42 @@
1/**
2 * Tests for src/routes/claude-web.tsx.
3 *
4 * We only verify the auth gate behaviour here — the Claude session logic
5 * itself is covered by claude-web-session.test.ts.
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10
11describe("claude-web route registration", () => {
12 it("GET /:owner/:repo/claude redirects unauthenticated users to /login", async () => {
13 // The route exists and enforces authentication — without a session
14 // cookie the response must be a redirect to /login.
15 const res = await app.request("/testowner/testrepo/claude", {
16 redirect: "manual",
17 });
18 expect(res.status).toBe(302);
19 const location = res.headers.get("location") ?? "";
20 expect(location).toContain("/login");
21 });
22
23 it("GET /:owner/:repo/claude/:sessionId redirects unauthenticated users to /login", async () => {
24 const res = await app.request(
25 "/testowner/testrepo/claude/00000000-0000-0000-0000-000000000001",
26 { redirect: "manual" }
27 );
28 expect(res.status).toBe(302);
29 const location = res.headers.get("location") ?? "";
30 expect(location).toContain("/login");
31 });
32
33 it("GET /:owner/:repo/claude/:sessionId/stream redirects unauthenticated users to /login", async () => {
34 const res = await app.request(
35 "/testowner/testrepo/claude/00000000-0000-0000-0000-000000000001/stream",
36 { redirect: "manual" }
37 );
38 expect(res.status).toBe(302);
39 const location = res.headers.get("location") ?? "";
40 expect(location).toContain("/login");
41 });
42});
Modifiedsrc/routes/claude-web.tsx+16−19View fileUnifiedSplit
@@ -7,12 +7,14 @@
77 * GET /:owner/:repo/claude/:sessionId/stream — SSE stream of a single turn
88 * POST /:owner/:repo/claude/:sessionId/delete — delete session
99 *
10 * v1 admin-only. The page renders server-side with a small inline
11 * EventSource client that POSTs nothing — the SSE GET is parameterised
12 * by `?prompt=...` so iPad keyboards (no JS fetch issues) just need to
13 * follow a link the form submits to. The endpoint persists the user
14 * message before opening the stream, so a flaky network mid-stream
15 * still leaves the question in the transcript.
10 * Open to all authenticated users with at least READ access to the repo
11 * (owner, accepted collaborator, or any user for public repos). The page
12 * renders server-side with a small inline EventSource client that POSTs
13 * nothing — the SSE GET is parameterised by `?prompt=...` so iPad
14 * keyboards (no JS fetch issues) just need to follow a link the form
15 * submits to. The endpoint persists the user message before opening the
16 * stream, so a flaky network mid-stream still leaves the question in the
17 * transcript.
1618 */
1719
1820import { Hono } from "hono";
@@ -22,7 +24,7 @@ import { repositories, users } from "../db/schema";
2224import { Layout } from "../views/layout";
2325import { softAuth } from "../middleware/auth";
2426import type { AuthEnv } from "../middleware/auth";
25import { isSiteAdmin } from "../lib/admin";
27import { resolveRepoAccess } from "../middleware/repo-access";
2628import {
2729 appendMessage,
2830 createSession,
@@ -46,26 +48,21 @@ async function gate(
4648 const target = encodeURIComponent(c.req.url);
4749 return c.redirect(`/login?next=${target}`);
4850 }
49 if (!(await isSiteAdmin(user.id))) {
50 return c.html(
51 <Layout title="Forbidden" user={user}>
52 <main style="max-width:640px;margin:40px auto;padding:0 20px">
53 <h1>403 — site admin required</h1>
54 <p>Claude on the web is admin-only in v1.</p>
55 </main>
56 </Layout>,
57 403
58 );
59 }
6051 const ownerName = c.req.param("owner");
6152 const repoName = c.req.param("repo");
6253 const [row] = await db
63 .select({ id: repositories.id })
54 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
6455 .from(repositories)
6556 .innerJoin(users, eq(repositories.ownerId, users.id))
6657 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
6758 .limit(1);
6859 if (!row) return c.notFound();
60 const access = await resolveRepoAccess({
61 repoId: row.id,
62 userId: user.id,
63 isPublic: !row.isPrivate,
64 });
65 if (access === "none") return c.notFound();
6966 return { userId: user.id, repoId: row.id, ownerName, repoName };
7067}
7168
7269