Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

269 lines · 12.7 KBRaw History
1/** @jsxImportSource hono/jsx */
2import { raw } from "hono/html";
3import type { FC } from "hono/jsx";
4import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js";
5import { ACCESS_TTL_S, OAuth, OAuthError, REFRESH_IDLE_DAYS, adminAllowed, canActAs, formActionSource, redirectLabel, requestedScope, type OAuthClient } from "../auth/oauth.js";
6import type { Account, TokenScope } from "../lib/directory.js";
7import { HttpError } from "../lib/util.js";
8import { ICONS } from "./assets.js";
9import { csrfToken, type FormData } from "./csrf.js";
10import { KRadio } from "./kumo.js";
11import { CopyButton, Form, ago, post, render, type WebApp } from "./ui.js";
12
13/**
14 * Where a person approves an app that wants to use Diffinery through MCP (auth/oauth.ts has the
15 * flow): /oauth/authorize shows who is asking, where they'll be sent back, whom the app acts as and
16 * with what access. It needs a passkey sign-in from the last 2 hours, like creating a token.
17 * Connected apps are listed, and revoked, on Settings → Access tokens.
18 */
19
20const SCOPE_ITEMS = [
21 { value: "read", label: "Read", description: "Read code, issues, pull requests, runs and logs." },
22 { value: "write", label: "Write", description: "Also push and commit, open and merge pull requests, comment, and run workflows (up to maintain)." },
23 { value: "admin", label: "Admin", description: "Everything the account can do, including settings, secrets and variables." },
24];
25
26/** The authorization request's parameters, as the app sent them. */
27const FIELDS = ["client_id", "redirect_uri", "response_type", "code_challenge", "code_challenge_method", "state", "scope", "resource"] as const;
28type Req = Partial<Record<(typeof FIELDS)[number], string>>;
29
30const reqFrom = (get: (k: string) => string | undefined): Req => Object.fromEntries(FIELDS.map((k) => [k, get(k)]).filter(([, v]) => v !== undefined && v !== "")) as Req;
31
32/** Back to the app with a code or an error, and the issuer (RFC 9207) so it knows who answered. */
33function backTo(c: AppContext, redirectUri: string, q: Record<string, string | undefined>): Response {
34 const u = new URL(redirectUri);
35 for (const [k, v] of Object.entries(q)) if (v !== undefined) u.searchParams.set(k, v);
36 u.searchParams.set("iss", c.env.PUBLIC_URL.replace(/\/$/, ""));
37 return c.redirect(u.toString(), 303);
38}
39
40const problem = (c: AppContext, message: string, status = 400) =>
41 render(
42 c,
43 { title: "Can't connect this app", status },
44 <div class="authcard box">
45 <div class="body">
46 <div class="authmark" aria-hidden="true">
47 {raw(ICONS.code)}
48 </div>
49 <h1>Can't connect this app</h1>
50 <p class="muted">{message}</p>
51 </div>
52 </div>,
53 );
54
55/** The accounts a person may connect an app as: themselves first, then the agents they run. */
56async function choices(c: AppContext, me: Account): Promise<Account[]> {
57 const dir = c.get("dir");
58 const orgs = (await dir.orgsFor(me.id)).filter((o) => o.role === "owner" || o.role === "admin");
59 const agents = (await dir.agentsOwnedBy([me.id, ...orgs.map((o) => o.id)])).filter((a) => a.status === "active");
60 return [me, ...agents];
61}
62
63function person(c: AppContext, next: string): Account {
64 const me = requireAccount(c);
65 if (me.kind !== "user") throw new HttpError(403, "Only people can connect apps. Agents use their own tokens.");
66 const p = c.get("principal");
67 if (p.type === "account" && p.via === "token") throw new HttpError(403, "Open this page in your browser, signed in with your passkey.");
68 requireRecentSignIn(c, next);
69 return me;
70}
71
72const Consent: FC<{ c: AppContext; me: Account; client: OAuthClient; redirectUri: string; req: Req; accounts: Account[] }> = ({ c, me, client, redirectUri, req, accounts }) => {
73 const t = csrfToken(c);
74 const verified = client.kind === "metadata" ? new URL(client.id).host : null;
75 const scope: TokenScope = requestedScope(req.scope);
76 return (
77 <div class="authcard box">
78 <div class="body">
79 <div class="authmark" aria-hidden="true">
80 {raw(ICONS.bot)}
81 </div>
82 <h1>
83 Connect <span>{client.name}</span> to Diffinery
84 </h1>
85 <p class="muted">
86 {verified ? (
87 <>
88 An app published by <b>{verified}</b> wants to use Diffinery as you choose below.
89 </>
90 ) : (
91 <>
92 An app calling itself <b>{client.name}</b> (a name it chose, not checked by Diffinery) wants to use Diffinery as you choose below.
93 </>
94 )}
95 </p>
96 <dl class="kv mb">
97 <dt>Signed in as</dt>
98 <dd>
99 <b>{me.login}</b>
100 </dd>
101 <dt>Sends you back to</dt>
102 <dd>
103 <b>{redirectLabel(redirectUri)}</b>
104 </dd>
105 </dl>
106 <Form action="/oauth/authorize" t={t}>
107 {Object.entries(req).map(([k, v]) => (
108 <input type="hidden" name={k} value={v} />
109 ))}
110 <div class="field">
111 <label>Act as</label>
112 <KRadio
113 name="act_as"
114 label="Act as"
115 value={me.login}
116 items={accounts.map((a) => ({
117 value: a.login,
118 label: a.id === me.id ? `${a.login} (you)` : `${a.login} (agent)`,
119 description: a.id === me.id ? "Changes appear as yours." : "Changes appear as the agent's, on your behalf; its pull requests need a person's approval.",
120 }))}
121 />
122 </div>
123 <div class="field">
124 <label>Access</label>
125 <KRadio name="access" label="Access" value={scope} items={SCOPE_ITEMS.map((i) => (i.value === "admin" && !adminAllowed(me) ? { ...i, disabled: true, description: "Not offered to site administrators: the app would carry site-wide administration." } : i))} />
126 </div>
127 {me.site_admin ? <p class="muted small">You're a site administrator, so an app acting as you can do what site administrators can, within the access above. Consider connecting it as one of your agents instead.</p> : null}
128 <p class="muted small">
129 The app gets tokens that last {ACCESS_TTL_S / 3600} hours and renew themselves while it's in use (the connection lapses after {REFRESH_IDLE_DAYS} days unused). It can never do more than the account it acts as, and branch protection still applies. You can disconnect it any time under{" "}
130 <a href="/settings/tokens#connected-apps">Settings → Access tokens</a>.
131 </p>
132 <div class="flash warn small">Only connect an app you are setting up yourself right now. Never approve one because someone sent you a link.</div>
133 <div class="row">
134 <button class="btn primary grow" name="decision" value="approve">
135 Connect
136 </button>
137 <button class="btn" name="decision" value="deny">
138 Cancel
139 </button>
140 </div>
141 </Form>
142 </div>
143 </div>
144 );
145};
146
147/** Checks the request. Problems with the app or its redirect URI are shown here; the rest go back to the app. */
148async function check(c: AppContext, req: Req): Promise<{ client: OAuthClient; redirectUri: string } | Response> {
149 const oauth = new OAuth(c.env, c.get("dir"));
150 try {
151 return await oauth.checkAuthorize(req);
152 } catch (e) {
153 if (e instanceof HttpError) return problem(c, e.detail);
154 if (e instanceof OAuthError) {
155 // checkAuthorize only throws OAuthError once the redirect URI is known to be the app's.
156 const client = await oauth.client(req.client_id);
157 const uri = req.redirect_uri ?? client?.redirect_uris[0];
158 if (uri) return backTo(c, uri, { error: e.error, error_description: e.description, state: req.state });
159 return problem(c, e.description);
160 }
161 throw e;
162 }
163}
164
165export function registerOAuth(web: WebApp): void {
166 web.get("/oauth/authorize", async (c) => {
167 const req = reqFrom((k) => c.req.query(k));
168 // Signed in first: an app's metadata document is fetched only for a person about to approve it.
169 const url = new URL(c.req.url);
170 const me = person(c, url.pathname + url.search);
171 const ok = await check(c, req);
172 if (ok instanceof Response) return ok;
173 return render(c, { title: `Connect ${ok.client.name}`, formAction: formActionSource(ok.redirectUri) }, <Consent c={c} me={me} client={ok.client} redirectUri={ok.redirectUri} req={req} accounts={await choices(c, me)} />);
174 });
175
176 post(web, "/oauth/authorize", async (c, f: FormData) => {
177 const req = reqFrom((k) => f.s(k) || undefined);
178 const me = person(c, `/oauth/authorize?${new URLSearchParams(req as Record<string, string>)}`);
179 const ok = await check(c, req);
180 if (ok instanceof Response) return ok;
181 if (f.s("decision") !== "approve") return backTo(c, ok.redirectUri, { error: "access_denied", error_description: "the person cancelled", state: req.state });
182 const account = (await choices(c, me)).find((a) => a.login === f.s("act_as"));
183 if (!account || !(await canActAs(c.get("dir"), me, account))) return problem(c, "You can connect an app only as yourself or as an agent you run.", 403);
184 const scope = SCOPE_ITEMS.find((s) => s.value === f.s("access"))?.value as TokenScope | undefined;
185 if (!scope) return problem(c, "Choose the access the app gets.");
186 if (scope === "admin" && !adminAllowed(account)) return problem(c, "Admin access can't be given to an app acting as a site administrator. Choose Write, or connect it as an agent.", 403);
187 const code = await new OAuth(c.env, c.get("dir")).issueCode({ client: ok.client, redirectUri: ok.redirectUri, challenge: req.code_challenge!, person: me, account, scope });
188 return backTo(c, ok.redirectUri, { code, state: req.state });
189 });
190
191 post(web, "/settings/connected-apps/:id/revoke", async (c) => {
192 const me = requireAccount(c);
193 const g = await new OAuth(c.env, c.get("dir")).revokeFor(me.id, c.req.param("id") ?? "");
194 if (g) c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "oauth.disconnect", target: g.account_id, detail: { app: g.client_name, client_id: g.client_id } }));
195 return c.redirect("/settings/tokens#connected-apps", 303);
196 });
197}
198
199/** The guides for connecting AI apps over MCP, in the order Settings shows them. */
200export const CONNECT_GUIDES: Array<{ label: string; href: string }> = [
201 { label: "Claude", href: "/docs/connect/claude" },
202 { label: "Claude Code", href: "/docs/connect/claude-code" },
203 { label: "Codex", href: "/docs/connect/codex" },
204 { label: "Other apps", href: "/docs/connect/other-apps" },
205];
206
207/** Settings → Access tokens: how to connect an AI app, the apps this person connected, and a way to disconnect each. */
208export const ConnectedApps: FC<{ t: string; grants: Awaited<ReturnType<OAuth["grantsFor"]>>; mcpUrl: string }> = ({ t, grants, mcpUrl }) => (
209 <div class="box" id="connected-apps">
210 <div class="head">
211 <h2>Connected apps</h2>
212 </div>
213 <div class="body connect-ai">
214 <p class="muted">
215 Connect Claude, Claude Code, Codex or any other MCP app with this server URL. The app sends you here to choose whom it acts as and what it may do, then shows in this list.
216 </p>
217 <div class="row">
218 <code class="grow connect-url">{mcpUrl}</code>
219 <CopyButton text={mcpUrl} size="base" />
220 </div>
221 <p class="small mt">
222 Step-by-step guides:{" "}
223 {CONNECT_GUIDES.map((g, i) => (
224 <>
225 {i ? " · " : ""}
226 <a href={g.href}>{g.label}</a>
227 </>
228 ))}
229 </p>
230 </div>
231 {grants.length ? (
232 <table class="tbl">
233 <thead>
234 <tr>
235 <th>App</th>
236 <th>Acts as</th>
237 <th>Access</th>
238 <th>Connected</th>
239 <th>Last used</th>
240 <th />
241 </tr>
242 </thead>
243 <tbody>
244 {grants.map((g) => (
245 <tr>
246 <td>
247 <b>{g.client_name}</b>
248 <div class="faint small">{g.redirect_host}</div>
249 </td>
250 <td>{g.account_login}</td>
251 <td>{g.scope}</td>
252 <td class="small">{ago(g.created_at)}</td>
253 <td class="small">{g.last_used_at ? ago(g.last_used_at) : <span class="muted">never</span>}</td>
254 <td class="right">
255 <Form action={`/settings/connected-apps/${g.id}/revoke`} t={t} class="inline" confirm={`Disconnect ${g.client_name}? It stops working until you connect it again.`}>
256 <button class="btn sm danger">Disconnect</button>
257 </Form>
258 </td>
259 </tr>
260 ))}
261 </tbody>
262 </table>
263 ) : (
264 <div class="empty">
265 No apps connected yet. Add the URL above to an app and approve it here when it asks; see <a href="/docs/connect">Connect AI apps</a>.
266 </div>
267 )}
268 </div>
269);