branch: main
index.ts
40450 bytesRaw
/**
* ripgit auth worker — GitHub OAuth example
*
* Access model:
* Anonymous → read-only (browse, clone, search)
* Authenticated → read + open issues/PRs on any repo
* Authenticated + owner → write (push, merge, admin) on your own repos
*
* Routes handled here (everything else forwarded to ripgit):
* GET / → auth landing page (HTML or text mode)
* GET /login → start GitHub OAuth login, ?next= redirect after
* GET /logout → clear session cookie
* GET /settings → token management page (HTML or text mode, requires login)
* POST /settings/tokens → create access token
* POST /settings/tokens/:id/revoke → revoke access token
* GET /oauth/authorize → OAuth provider flow (programmatic clients)
* GET /oauth/callback → unified GitHub callback
* POST /oauth/token → token exchange (OAuthProvider internal)
* * /admin/* → agent management API (requires admin scope)
*
* Setup:
* 1. Create a GitHub OAuth App (https://github.com/settings/applications/new)
* Callback URL: https://your-worker.workers.dev/oauth/callback
* (local dev: http://localhost:8787/oauth/callback)
* 2. wrangler kv namespace create OAUTH_KV → fill IDs in wrangler.toml
* 3. wrangler secret put GITHUB_CLIENT_SECRET
* 4. wrangler secret put SESSION_SECRET (any random 32+ char string)
* 5. Set GITHUB_CLIENT_ID in wrangler.toml [vars]
*/
import {
OAuthProvider,
type AuthRequest,
type ResolveExternalTokenInput,
type ResolveExternalTokenResult,
} from "@cloudflare/workers-oauth-provider";
import { WorkerEntrypoint } from "cloudflare:workers";
import { exchangeCode, fetchGitHubUser, githubAuthorizeUrl } from "./github";
import type { ActorProps, Env } from "./types";
const ALL_SCOPES = [
"repo:read",
"repo:write",
"issue:write",
"pr:merge",
"admin",
] as const;
const SESSION_COOKIE = "ripgit_session";
const SESSION_MAX_AGE = 7 * 24 * 3600; // 7 days
type PageFormat = "html" | "markdown" | "text";
interface PageFormatSelection {
format: PageFormat;
varyAccept: boolean;
}
interface TextAction {
method: "GET" | "POST";
path: string;
description: string;
requires?: string;
fields?: string[];
effect?: string;
}
// ---------------------------------------------------------------------------
// AdminHandler — WorkerEntrypoint for /admin/* routes (programmatic API).
// ctx.props populated by OAuthProvider after token validation.
// ---------------------------------------------------------------------------
export class AdminHandler extends WorkerEntrypoint<Env> {
async fetch(request: Request): Promise<Response> {
const actor = this.ctx.props as ActorProps;
const url = new URL(request.url);
if (url.pathname === "/admin/agents") {
if (request.method === "POST") return this.createAgent(request, actor);
if (request.method === "GET") return this.listAgents(actor);
return new Response("Method Not Allowed", { status: 405 });
}
return new Response("Not Found", { status: 404 });
}
private async createAgent(
request: Request,
caller: ActorProps,
): Promise<Response> {
if (!caller.scopes.includes("admin")) {
return Response.json({ error: "admin scope required" }, { status: 403 });
}
let body: { name?: unknown; scopes?: unknown };
try {
body = (await request.json()) as { name?: unknown; scopes?: unknown };
} catch {
return Response.json({ error: "invalid JSON body" }, { status: 400 });
}
const name =
typeof body.name === "string" && body.name.trim()
? body.name.trim()
: null;
if (!name) {
return Response.json({ error: "name is required" }, { status: 400 });
}
const requested = Array.isArray(body.scopes)
? (body.scopes as unknown[]).filter(
(s): s is string => typeof s === "string",
)
: [...ALL_SCOPES];
const grantedScopes = requested.filter(
(s) =>
ALL_SCOPES.includes(s as (typeof ALL_SCOPES)[number]) &&
caller.scopes.includes(s),
);
const { token, actor } = await createAgentToken(
this.env,
name,
caller,
grantedScopes,
);
return Response.json(
{ token, actorId: actor.actorId, name, scopes: grantedScopes },
{ status: 201 },
);
}
private async listAgents(caller: ActorProps): Promise<Response> {
if (!caller.scopes.includes("admin")) {
return Response.json({ error: "admin scope required" }, { status: 403 });
}
const agents = await listAgentTokens(this.env, caller.actorId);
return Response.json({ agents });
}
}
// ---------------------------------------------------------------------------
// OAuthProvider — default export.
// ---------------------------------------------------------------------------
export default new OAuthProvider<Env>({
apiRoute: "/admin/",
apiHandler: AdminHandler,
defaultHandler: { fetch: mainHandler },
authorizeEndpoint: "/oauth/authorize",
tokenEndpoint: "/oauth/token",
clientRegistrationEndpoint: "/oauth/register",
scopesSupported: [...ALL_SCOPES],
accessTokenTTL: 3600,
refreshTokenTTL: 30 * 86400,
resolveExternalToken: async ({
token,
env,
}: ResolveExternalTokenInput): Promise<ResolveExternalTokenResult | null> => {
const raw = await (env as Env).OAUTH_KV.get(`agent:${token}`);
if (!raw) return null;
return { props: JSON.parse(raw) as ActorProps };
},
});
// ---------------------------------------------------------------------------
// mainHandler — resolves actor first, routes, then forwards to ripgit
// ---------------------------------------------------------------------------
async function mainHandler(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const pageFormat = preferredPageFormat(request);
// Resolve identity up front — available to all routes below
const actor = await resolveActor(request, env);
// ── Auth + settings routes ────────────────────────────────────────────────
if (url.pathname === "/login") return handleLogin(request, env);
if (url.pathname === "/logout") return handleLogout(request);
if (url.pathname === "/oauth/authorize")
return handleAuthorize(request, env);
if (url.pathname === "/oauth/callback") return handleCallback(request, env);
if (url.pathname === "/settings") {
if (!actor) {
if (pageFormat.format === "html") return redirect(`/login?next=/settings`);
return renderSettingsAuthRequiredPage(pageFormat);
}
return handleSettings(request, env, actor, undefined, pageFormat);
}
if (url.pathname === "/settings/tokens" && request.method === "POST") {
if (!actor) return redirect(`/login?next=/settings`);
return handleCreateToken(request, env, actor);
}
// /settings/tokens/:agentId/revoke
const revokeMatch = url.pathname.match(
/^\/settings\/tokens\/([^/]+)\/revoke$/,
);
if (revokeMatch && request.method === "POST") {
if (!actor) return redirect(`/login?next=/settings`);
// decodeURIComponent because url.pathname preserves %3A rather than
// normalising it to ':', so "agent%3Auuid" would not match the KV key.
return handleRevokeToken(env, actor, decodeURIComponent(revokeMatch[1]));
}
if (url.pathname === "/" && request.method === "GET") {
// Logged-in users go straight to their profile page
if (actor && pageFormat.format === "html") {
return redirect(`/${actor.actorName}/`);
}
return renderLandingPage(new URL(request.url).origin, actor, pageFormat);
}
// ── Everything else → ripgit ──────────────────────────────────────────────
return forwardToRipgit(request, actor, env);
}
function preferredPageFormat(request: Request): PageFormatSelection {
const url = new URL(request.url);
const format = url.searchParams.get("format")?.trim().toLowerCase();
if (format === "html") return { format: "html", varyAccept: false };
if (format === "md" || format === "markdown") {
return { format: "markdown", varyAccept: false };
}
if (format === "text" || format === "txt" || format === "plain") {
return { format: "text", varyAccept: false };
}
const accept = request.headers.get("Accept") ?? "";
if (accept.includes("text/markdown")) {
return { format: "markdown", varyAccept: true };
}
if (accept.includes("text/plain")) {
return { format: "text", varyAccept: true };
}
if (accept.includes("text/html")) {
return { format: "html", varyAccept: true };
}
return { format: "html", varyAccept: false };
}
function respondPage(
body: string,
selection: PageFormatSelection,
status = 200,
): Response {
const headers = new Headers();
if (selection.format === "html") {
headers.set("Content-Type", "text/html; charset=utf-8");
} else {
headers.set(
"Content-Type",
selection.format === "markdown"
? "text/markdown; charset=utf-8"
: "text/plain; charset=utf-8",
);
headers.set("Cache-Control", "no-cache");
}
if (selection.varyAccept) {
headers.set("Vary", "Accept");
}
return new Response(body, { status, headers });
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
function textNavigationHint(selection: PageFormatSelection): string {
const accept =
selection.format === "markdown" ? "text/markdown" : "text/plain";
const format = selection.format === "markdown" ? "md" : "text";
return `GET paths below omit \`?format\`. Keep \`Accept: ${accept}\` to stay in text mode, or append \`?format=${format}\` when following a path without headers.`;
}
function renderTextActions(actions: TextAction[]): string {
if (actions.length === 0) return "";
const lines = ["", "## Actions"];
for (const action of actions) {
let line = `- ${action.method} \`${action.path}\` - ${action.description}`;
if (action.fields?.length) {
line += `; fields: ${action.fields.join(", ")}`;
}
if (action.requires) {
line += `; requires ${action.requires}`;
}
if (action.effect) {
line += `; ${action.effect}`;
}
lines.push(line);
}
return `${lines.join("\n")}\n`;
}
function renderTextHints(hints: string[]): string {
if (hints.length === 0) return "";
return `\n## Hints\n${hints.map((hint) => `- ${hint}`).join("\n")}\n`;
}
function authFooterHtml(): string {
return `ripgit — <a href="https://github.com/deathbyknowledge/ripgit">open source</a> by <a href="https://x.com/caise_p">deathbyknowledge</a>`;
}
function renderAuthPageHtml(options: {
title: string;
topbarRight: string;
content: string;
mainClass?: string;
footer?: string;
}): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(options.title)} — ripgit</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#fff;color:#1f2328;min-height:100vh;display:flex;flex-direction:column}
a{color:#0969da;text-decoration:none}
a:hover{text-decoration:underline}
.site-header{border-bottom:1px solid #d1d9e0;background:#fff}
.site-header-row{min-height:52px;padding:0 24px;display:flex;align-items:center;justify-content:space-between;gap:16px}
.brand{font-weight:700;font-size:16px;color:#1f2328;text-decoration:none}
.site-nav{display:flex;align-items:center;gap:12px;font-size:13px;color:#656d76;flex-wrap:wrap;justify-content:flex-end}
.site-nav a{color:#656d76}
.site-nav a:hover{color:#0969da}
.site-nav strong{color:#1f2328}
.site-shell{max-width:960px;margin:0 auto;width:100%;padding:40px 24px 56px}
.landing-shell{flex:1;display:flex;flex-direction:column;justify-content:center;align-items:center;max-width:1040px;margin:0 auto;padding:72px 24px;text-align:center;width:100%}
.hero{max-width:720px}
.hero h1{font-size:40px;font-weight:700;letter-spacing:-1px;margin-bottom:16px}
.eyebrow{font-size:13px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#656d76;margin-bottom:12px}
.tagline{font-size:18px;color:#656d76;line-height:1.5;margin-bottom:20px}
.hero-copy{font-size:15px;line-height:1.65;color:#3d444d;margin-bottom:28px}
.cta-row{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-bottom:56px}
.signin-btn,.btn-secondary{display:inline-flex;align-items:center;gap:10px;border-radius:6px;padding:12px 20px;font-size:15px;font-weight:600;text-decoration:none}
.signin-btn{background:#1f2328;color:#fff}
.signin-btn:hover{background:#393f47;color:#fff;text-decoration:none}
.signin-btn svg{width:20px;height:20px;fill:#fff}
.btn-secondary{background:#f6f8fa;border:1px solid #d1d9e0;color:#1f2328}
.btn-secondary:hover{background:#eef2f6;text-decoration:none}
.feature-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px;width:100%;max-width:900px;text-align:left}
.feature-card{border:1px solid #d1d9e0;border-radius:8px;padding:18px;background:#fff}
.feature-card h3{font-size:14px;font-weight:600;margin-bottom:6px}
.feature-card p{font-size:13px;color:#656d76;line-height:1.6}
.site-footer{padding:24px;border-top:1px solid #d1d9e0;text-align:center;font-size:12px;color:#656d76}
.site-footer a{color:#656d76}
h1{font-size:28px;margin-bottom:6px}
h2{font-size:16px;margin:28px 0 10px;font-weight:600}
p{line-height:1.6}
.lede{color:#656d76;max-width:700px;margin-bottom:28px}
.section{margin-top:28px}
.banner{border:1px solid #d1d9e0;border-radius:8px;padding:16px;margin-bottom:24px}
.banner-success{background:#dafbe1;border-color:#82cfac}
.token-value{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;background:#fff;border:1px solid #d1d9e0;border-radius:6px;padding:8px 10px;word-break:break-all;margin:10px 0;user-select:all}
.cmd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;background:#f6f8fa;border:1px solid #d1d9e0;border-radius:6px;padding:12px 14px;margin:10px 0;overflow-x:auto;white-space:pre;line-height:1.6}
.form-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:6px}
input[type=text]{border:1px solid #d1d9e0;border-radius:6px;padding:8px 12px;font-size:14px;min-width:280px;max-width:100%}
input[type=text]:focus{outline:none;border-color:#0969da;box-shadow:0 0 0 3px rgba(9,105,218,.1)}
.btn{background:#1f883d;color:#fff;border:none;border-radius:6px;padding:8px 16px;cursor:pointer;font-size:14px}
.btn:hover{background:#1a7f37}
.btn-danger{background:#cf222e;color:#fff;border:none;border-radius:6px;cursor:pointer}
.btn-sm{padding:6px 12px;font-size:13px}
.btn-danger:hover{background:#a40e26}
table{width:100%;border-collapse:collapse;margin-top:8px}
td,th{text-align:left;padding:10px 12px;border-bottom:1px solid #d1d9e0;font-size:14px;vertical-align:top}
th{font-weight:600;background:#f6f8fa}
.actions{text-align:right}
.muted{color:#656d76;font-size:13px}
code{background:#f6f8fa;border:1px solid #d1d9e0;border-radius:4px;padding:1px 5px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
@media (max-width: 720px){
.site-header-row{padding:12px 20px;align-items:flex-start}
.landing-shell{padding:56px 20px}
.site-shell{padding:32px 20px 48px}
.hero h1{font-size:34px}
.tagline{font-size:17px}
}
</style>
</head>
<body>
<header class="site-header">
<div class="site-header-row">
<a href="/" class="brand">ripgit</a>
<div class="site-nav">${options.topbarRight}</div>
</div>
</header>
<main class="${options.mainClass ?? "site-shell"}">${options.content}</main>
${options.footer ? `<footer class="site-footer">${options.footer}</footer>` : ""}
</body>
</html>`;
}
// ---------------------------------------------------------------------------
// Settings — browser UI for token management
// ---------------------------------------------------------------------------
async function handleSettings(
request: Request,
env: Env,
actor: ActorProps,
newToken?: string,
pageFormat = preferredPageFormat(request),
): Promise<Response> {
const tokens = await listAgentTokens(env, actor.actorId);
const origin = new URL(request.url).origin;
if (pageFormat.format === "html") {
return respondPage(
renderSettingsPageHtml(actor.actorName, tokens, origin, newToken),
pageFormat,
);
}
return respondPage(
renderSettingsPageText(actor.actorName, tokens, origin, newToken, pageFormat),
pageFormat,
);
}
async function handleCreateToken(
request: Request,
env: Env,
actor: ActorProps,
): Promise<Response> {
const form = await request.formData();
const name = ((form.get("name") as string) ?? "").trim();
if (!name) return redirect("/settings");
const { token } = await createAgentToken(env, name, actor, [...ALL_SCOPES]);
// Re-render settings page with the new token shown once
return handleSettings(request, env, actor, token);
}
async function handleRevokeToken(
env: Env,
actor: ActorProps,
agentId: string,
): Promise<Response> {
const indexKey = `agent-index:${actor.actorId}:${agentId}`;
const token = await env.OAUTH_KV.get(indexKey);
if (token) {
await Promise.all([
env.OAUTH_KV.delete(`agent:${token}`),
env.OAUTH_KV.delete(indexKey),
]);
}
return redirect("/settings");
}
// ---------------------------------------------------------------------------
// Settings pages
// ---------------------------------------------------------------------------
function renderSettingsAuthRequiredPage(
pageFormat: PageFormatSelection,
): Response {
const body = `# ripgit auth settings
Authentication required.
Settings path: \`/settings\`
${renderTextActions([
{
method: "GET",
path: "/login?next=/settings",
description: "start GitHub OAuth sign-in in a browser",
},
{
method: "GET",
path: "/",
description: "open the auth worker landing page",
},
])}${renderTextHints([
textNavigationHint(pageFormat),
"Browser login creates a session cookie; long-lived agent tokens are created after signing in at `/settings`.",
"If you already have a token, send it as `Authorization: Bearer TOKEN` or as the password in basic auth to avoid the browser login redirect.",
])}`;
return respondPage(body, pageFormat, 401);
}
function renderSettingsPageHtml(
actorName: string,
tokens: { agentId: string; name: string }[],
origin: string,
newToken?: string,
): string {
const host = origin.replace(/^https?:\/\//, "");
const newTokenBanner = newToken
? `<div class="banner banner-success">
<strong>Token created — copy it now, it won't be shown again</strong>
<div class="token-value">${escapeHtml(newToken)}</div>
<p class="muted">Anyone with this token can push to your repos. Store it securely.</p>
<p class="muted" style="margin-top:12px"><strong>Add as a git remote:</strong></p>
<pre class="cmd">git remote add origin https://${escapeHtml(actorName)}:${escapeHtml(newToken)}@${escapeHtml(host)}/${escapeHtml(actorName)}/REPO-NAME
git push origin main</pre>
</div>`
: "";
const tokenRows =
tokens.length > 0
? `<table>
<thead><tr><th>Name</th><th></th></tr></thead>
<tbody>
${tokens
.map(
(t) => `<tr>
<td>${escapeHtml(t.name)}</td>
<td class="actions">
<form method="POST" action="/settings/tokens/${encodeURIComponent(t.agentId)}/revoke">
<button class="btn-danger btn-sm" onclick="return confirm('Revoke this token?')">Revoke</button>
</form>
</td>
</tr>`,
)
.join("")}
</tbody>
</table>`
: `<p class="muted">No tokens yet.</p>`;
return renderAuthPageHtml({
title: "Settings",
topbarRight: `<a href="/${encodeURIComponent(actorName)}/">Profile</a><span>·</span><strong>${escapeHtml(actorName)}</strong><span>·</span><a href="/logout">Sign out</a>`,
mainClass: "site-shell",
footer: authFooterHtml(),
content: `
${newTokenBanner}
<section>
<h1>Access Tokens</h1>
<p class="lede">Create long-lived tokens for git remotes, curl, and agents that need to browse or push through the auth worker.</p>
</section>
<section class="section">
<h2>Create token</h2>
<form method="POST" action="/settings/tokens">
<div class="form-row">
<input type="text" name="name" placeholder="Token name (e.g. laptop, deploy-key)" required autocomplete="off">
<button class="btn" type="submit">Generate</button>
</div>
</form>
<p class="muted" style="margin-top:8px">Generated tokens are shown exactly once in the response that creates them.</p>
</section>
<section class="section">
<h2>Push a new repo</h2>
<p class="muted">Repos are created on first push. Pick any name:</p>
<pre class="cmd">cd my-project
git init
git add .
git commit -m "initial commit"
git remote add origin https://${escapeHtml(actorName)}:TOKEN@${escapeHtml(host)}/${escapeHtml(actorName)}/my-project
git push origin main</pre>
<p class="muted">Replace <code>TOKEN</code> with the token you generate above. Replace <code>my-project</code> with your repo name.</p>
</section>
<section class="section">
<h2>Use tokens with curl</h2>
<pre class="cmd">curl -H "Authorization: Bearer TOKEN" ${escapeHtml(origin)}/settings?format=md</pre>
<p class="muted">Git can also use the token as the password in a standard HTTPS remote.</p>
</section>
<section class="section">
<h2>Active tokens</h2>
${tokenRows}
</section>`,
});
}
function renderSettingsPageText(
actorName: string,
tokens: { agentId: string; name: string }[],
origin: string,
newToken: string | undefined,
pageFormat: PageFormatSelection,
): string {
const host = origin.replace(/^https?:\/\//, "");
const pushExample = renderIndentedBlock(
[
"cd my-project",
"git init",
"git add .",
'git commit -m "initial commit"',
`git remote add origin https://${actorName}:TOKEN@${host}/${actorName}/my-project`,
"git push origin main",
].join("\n"),
);
let body = `# ripgit auth settings
Signed in as: \`${actorName}\`
Profile path: \`/${actorName}/\`
Settings path: \`/settings\`
Active tokens: \`${tokens.length}\`
`;
if (newToken) {
body += `
## New Token
Copy this now; it will not be shown again.
- Value: \`${newToken}\`
- Git remote: \`https://${actorName}:${newToken}@${host}/${actorName}/REPO-NAME\`
`;
}
body += `
## Push a New Repo
${pushExample}
`;
body += "\n## Active Tokens\n";
if (tokens.length === 0) {
body += "No active tokens.\n";
} else {
for (const token of tokens) {
body += `- \`${token.name}\` - revoke path: \`/settings/tokens/${encodeURIComponent(token.agentId)}/revoke\`\n`;
}
}
const actions: TextAction[] = [
{
method: "GET",
path: "/settings",
description: "reload this token management page",
},
{
method: "GET",
path: `/${actorName}/`,
description: "open your ripgit profile and repo index",
},
{
method: "POST",
path: "/settings/tokens",
description: "create a new long-lived token",
fields: ["`name` - label shown in the settings page"],
requires: "authenticated session",
effect: "returns the settings page with the new token shown once",
},
{
method: "GET",
path: "/logout?next=/",
description: "clear the browser session and return to the landing page",
},
];
for (const token of tokens) {
actions.push({
method: "POST",
path: `/settings/tokens/${encodeURIComponent(token.agentId)}/revoke`,
description: `revoke the token named \`${token.name}\``,
requires: "authenticated session",
effect: "deletes the token and redirects back to `/settings`",
});
}
body += renderTextActions(actions);
body += renderTextHints([
textNavigationHint(pageFormat),
"Created tokens currently carry the full auth-worker scope set and should be stored like passwords.",
"Use `Authorization: Bearer TOKEN` for API/page requests, or `https://USER:TOKEN@HOST/USER/REPO` for git remotes.",
"Generated tokens are only shown in the response that creates them; revoking a token does not reveal its original value.",
]);
return body;
}
function renderIndentedBlock(text: string): string {
return text
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
}
// ---------------------------------------------------------------------------
// Landing page (shown at / when not logged in)
// ---------------------------------------------------------------------------
function renderLandingPage(
origin: string,
actor: ActorProps | null,
pageFormat: PageFormatSelection,
): Response {
if (pageFormat.format === "html") {
return respondPage(renderLandingPageHtml(), pageFormat);
}
return respondPage(renderLandingPageText(origin, actor, pageFormat), pageFormat);
}
function renderLandingPageHtml(): string {
return renderAuthPageHtml({
title: "ripgit",
topbarRight: "",
mainClass: "landing-shell",
footer: authFooterHtml(),
content: `<section class="hero">
<h1>ripgit</h1>
<p class="tagline">A lightweight self-hosted Git server running on Cloudflare Durable Objects. Fast, searchable, yours.</p>
<div class="cta-row">
<a href="/login" class="signin-btn">
<svg viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
Sign in with GitHub
</a>
</div>
</section>
<section class="feature-grid">
<div class="feature-card">
<h3>Git-compatible</h3>
<p>Works with any standard git client. Push and clone with the URLs you already know.</p>
</div>
<div class="feature-card">
<h3>Built-in search</h3>
<p>Full-text search across all your code and commit history, powered by SQLite FTS5.</p>
</div>
<div class="feature-card">
<h3>Edge-hosted</h3>
<p>Runs on Cloudflare Durable Objects. No servers to manage, globally distributed.</p>
</div>
</section>`,
});
}
function renderLandingPageText(
origin: string,
actor: ActorProps | null,
pageFormat: PageFormatSelection,
): string {
const host = origin.replace(/^https?:\/\//, "");
if (actor) {
let body = `# ripgit auth worker
Signed in as: \`${actor.actorName}\`
This auth worker fronts the ripgit backend, manages your browser session, and can mint long-lived tokens from \`/settings\`.
## Related Paths (GET paths)
- \`/${actor.actorName}/\`
- \`/settings\`
- \`/logout?next=/\`
`;
body += renderTextActions([
{
method: "GET",
path: `/${actor.actorName}/`,
description: "open your ripgit profile and repositories",
},
{
method: "GET",
path: "/settings",
description: "manage long-lived access tokens",
},
{
method: "GET",
path: "/logout?next=/",
description: "clear the browser session and return here",
},
]);
body += renderTextHints([
textNavigationHint(pageFormat),
`HTML requests to \`/\` redirect signed-in users to \`/${actor.actorName}/\`; text mode stays here so agents can discover the next steps.`,
`Tokens created at \`/settings\` work with git remotes like \`https://${actor.actorName}:TOKEN@${host}/${actor.actorName}/REPO\`.`,
]);
return body;
}
let body = `# ripgit auth worker
This worker handles GitHub sign-in, browser sessions, and long-lived tokens before forwarding requests to the ripgit backend.
Access model:
- anonymous - read-only browsing, cloning, and search
- authenticated - read plus issues and pull requests across repos
- repo owner - push, merge, and admin actions on repos under your username
## Related Paths (GET paths)
- \`/\`
- \`/login\`
- \`/settings\`
- \`/oauth/authorize\`
`;
body += renderTextActions([
{
method: "GET",
path: "/login",
description: "start GitHub OAuth sign-in in a browser",
},
{
method: "GET",
path: "/settings",
description: "open token management after sign-in",
requires: "authenticated session",
},
{
method: "GET",
path: "/oauth/authorize",
description: "start the OAuth provider flow for programmatic clients",
},
]);
body += renderTextHints([
textNavigationHint(pageFormat),
"After signing in, the HTML landing page redirects to your profile while the text-mode landing page stays here and explains the available paths.",
"Long-lived tokens are created from `/settings` and can then be sent as `Authorization: Bearer TOKEN` or used as the password in a standard HTTPS git remote.",
]);
return body;
}
// ---------------------------------------------------------------------------
// GitHub OAuth flows
// ---------------------------------------------------------------------------
async function handleLogin(request: Request, env: Env): Promise<Response> {
const next = new URL(request.url).searchParams.get("next") ?? "/";
const state = crypto.randomUUID();
await env.OAUTH_KV.put(
`state:${state}`,
JSON.stringify({ type: "login", next }),
{ expirationTtl: 600 },
);
const callbackUrl = new URL("/oauth/callback", request.url).toString();
return Response.redirect(
githubAuthorizeUrl(env.GITHUB_CLIENT_ID, state, callbackUrl),
302,
);
}
async function handleAuthorize(request: Request, env: Env): Promise<Response> {
const oauthReq = await env.OAUTH_PROVIDER.parseAuthRequest(request);
const state = crypto.randomUUID();
await env.OAUTH_KV.put(
`state:${state}`,
JSON.stringify({ type: "oauth", oauthReq }),
{ expirationTtl: 600 },
);
const callbackUrl = new URL("/oauth/callback", request.url).toString();
return Response.redirect(
githubAuthorizeUrl(env.GITHUB_CLIENT_ID, state, callbackUrl),
302,
);
}
async function handleCallback(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code || !state) {
return new Response("Missing code or state", { status: 400 });
}
const stateRaw = await env.OAUTH_KV.get(`state:${state}`);
if (!stateRaw) {
return new Response("Invalid or expired state — please try again", {
status: 400,
});
}
await env.OAUTH_KV.delete(`state:${state}`);
type StateData =
| { type: "login"; next: string }
| { type: "oauth"; oauthReq: AuthRequest };
const stateData = JSON.parse(stateRaw) as StateData;
const callbackUrl = new URL("/oauth/callback", request.url).toString();
let githubToken: string;
try {
githubToken = await exchangeCode(code, env, callbackUrl);
} catch (err) {
return new Response(`GitHub auth failed: ${(err as Error).message}`, {
status: 400,
});
}
const user = await fetchGitHubUser(githubToken);
if (stateData.type === "login") {
const actor: ActorProps = {
actorId: `github:${user.id}`,
actorName: user.login,
actorKind: "user",
scopes: [...ALL_SCOPES],
};
const sessionValue = await createSession(actor, env.SESSION_SECRET);
return new Response(null, {
status: 302,
headers: {
Location: stateData.next,
"Set-Cookie": `${SESSION_COOKIE}=${encodeURIComponent(sessionValue)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${SESSION_MAX_AGE}`,
},
});
} else {
const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
request: stateData.oauthReq,
userId: `github:${user.id}`,
metadata: { githubLogin: user.login },
scope: stateData.oauthReq.scope,
props: {
actorId: `github:${user.id}`,
actorName: user.login,
actorKind: "user",
scopes: stateData.oauthReq.scope,
} satisfies ActorProps,
});
return Response.redirect(redirectTo, 302);
}
}
function handleLogout(request: Request): Response {
const next = new URL(request.url).searchParams.get("next") ?? "/";
return new Response(null, {
status: 302,
headers: {
Location: next,
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`,
},
});
}
// ---------------------------------------------------------------------------
// KV helpers for agent tokens
// ---------------------------------------------------------------------------
async function createAgentToken(
env: Env,
name: string,
caller: ActorProps,
scopes: string[],
): Promise<{ token: string; actor: ActorProps }> {
const token = generateToken();
const agentId = `agent:${crypto.randomUUID()}`;
const actor: ActorProps = {
actorId: agentId,
actorName: name,
actorKind: "agent",
ownerActorId: caller.actorId,
ownerActorName: caller.actorName, // caller's GitHub username — used for repo ownership checks
scopes,
};
await env.OAUTH_KV.put(`agent:${token}`, JSON.stringify(actor));
await env.OAUTH_KV.put(`agent-index:${caller.actorId}:${agentId}`, token);
return { token, actor };
}
async function listAgentTokens(
env: Env,
actorId: string,
): Promise<{ agentId: string; name: string }[]> {
const prefix = `agent-index:${actorId}:`;
const list = await env.OAUTH_KV.list({ prefix });
const results = await Promise.all(
list.keys.map(async (k) => {
const agentId = k.name.slice(prefix.length);
const token = await env.OAUTH_KV.get(k.name);
if (!token) return null;
const raw = await env.OAUTH_KV.get(`agent:${token}`);
if (!raw) return null;
const a = JSON.parse(raw) as ActorProps;
return { agentId, name: a.actorName };
}),
);
return results.filter((r): r is { agentId: string; name: string } => r !== null);
}
// ---------------------------------------------------------------------------
// Session cookie helpers — HMAC-SHA256 signed, stateless
// ---------------------------------------------------------------------------
async function createSession(
actor: ActorProps,
secret: string,
): Promise<string> {
const payload = btoa(JSON.stringify(actor));
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(payload),
);
const sigHex = Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `${payload}.${sigHex}`;
}
async function verifySession(
value: string,
secret: string,
): Promise<ActorProps | null> {
const dot = value.lastIndexOf(".");
if (dot < 0) return null;
const payload = value.slice(0, dot);
const sigHex = value.slice(dot + 1);
let sigBytes: Uint8Array;
try {
const pairs = sigHex.match(/.{2}/g) ?? [];
sigBytes = Uint8Array.from(pairs.map((h) => parseInt(h, 16)));
} catch {
return null;
}
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
const valid = await crypto.subtle.verify(
"HMAC",
key,
sigBytes,
new TextEncoder().encode(payload),
);
if (!valid) return null;
try {
return JSON.parse(atob(payload)) as ActorProps;
} catch {
return null;
}
}
function getSessionCookie(request: Request): string | null {
const cookies = request.headers.get("Cookie") ?? "";
const match = cookies.match(/(?:^|;\s*)ripgit_session=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
// ---------------------------------------------------------------------------
// Identity resolution — returns null for anonymous, never blocks
// ---------------------------------------------------------------------------
async function resolveActor(
request: Request,
env: Env,
): Promise<ActorProps | null> {
const token = extractToken(request);
if (token) {
const agentRaw = await env.OAUTH_KV.get(`agent:${token}`);
if (agentRaw) return JSON.parse(agentRaw) as ActorProps;
const tokenData = await env.OAUTH_PROVIDER.unwrapToken<ActorProps>(token);
if (tokenData && tokenData.expiresAt > Math.floor(Date.now() / 1000)) {
return tokenData.grant.props;
}
}
const cookieValue = getSessionCookie(request);
if (cookieValue) {
return verifySession(cookieValue, env.SESSION_SECRET);
}
return null;
}
// ---------------------------------------------------------------------------
// Forward to ripgit
// ---------------------------------------------------------------------------
function forwardToRipgit(
request: Request,
actor: ActorProps | null,
env: Env,
): Promise<Response> {
const headers = new Headers(request.headers);
if (actor) {
// For ownership checks in ripgit, what matters is the GitHub username of the
// person who owns the repos. For agents, that's ownerActorName, not actorName
// (actorName is the token's display name, e.g. "laptop").
const ownerName =
actor.actorKind === "agent" && actor.ownerActorName
? actor.ownerActorName
: actor.actorName;
headers.set("X-Ripgit-Actor-Id", actor.actorId);
headers.set("X-Ripgit-Actor-Name", ownerName); // GitHub username for ownership
headers.set("X-Ripgit-Actor-Display-Name", actor.actorName); // token name for audit/display
headers.set("X-Ripgit-Actor-Kind", actor.actorKind);
headers.set("X-Ripgit-Actor-Scopes", actor.scopes.join(","));
if (actor.ownerActorId) {
headers.set("X-Ripgit-Actor-Owner", actor.ownerActorId);
}
}
headers.delete("Authorization");
headers.delete("Cookie");
return env.RIPGIT.fetch(new Request(request, { headers }));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function extractToken(request: Request): string | null {
const auth = request.headers.get("Authorization") ?? "";
if (auth.startsWith("Bearer ")) {
const t = auth.slice(7).trim();
return t || null;
}
if (auth.startsWith("Basic ")) {
try {
const decoded = atob(auth.slice(6));
const colon = decoded.indexOf(":");
if (colon >= 0) {
const t = decoded.slice(colon + 1);
return t || null;
}
} catch {
/* malformed base64 */
}
}
return null;
}
/**
* Redirect to a relative or absolute URL.
* Response.redirect() only accepts absolute URLs in Cloudflare Workers,
* so use this helper for any relative paths.
*/
function redirect(location: string, status = 302): Response {
return new Response(null, { status, headers: { Location: location } });
}
function generateToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}