Files
zopu-code/packages/backend/convex/gitConnections.ts
-Puter c644ec8d01 feat(git): thin project onboarding, provider integration, and AgentOS repository access
Effect primitives:
- git-provider: GitProvider, connection states, normalized errors, URL
  normalization, host compatibility, credential freshness window
- git-provisioning: validated Puter commands, migration states, idempotency
  keys, safe replacement rules, owner-safe guards
- git-webhook: supported events, signature verification, delivery states
- host-repository: provider-neutral credential-safe clone via GIT_ASKPASS

Normalized Convex schema:
- gitProviderAccounts, refined gitConnections, gitProviderOrganizations,
  gitRepositories, gitMigrations, gitWebhookDeliveries
- projects: gitRepositoryId + instructions fields
- Schema fields optional for backward compatibility, with backfill cron

Backend:
- Connection health: verify action, hourly reconciliation (covers stale
  active + reauth-required + undefined-state legacy connections)
- Puter provisioning: createPuterUser/Organization/Repository with owner
  binding, startGithubMigration (durable via scheduler), getMigration
- Org ownership: explicit member add with admin role + verification
- Webhook HTTP actions: HMAC verification, delivery persistence with
  idempotency, repository resolution, byte-length payload limit
- Automatic Puter webhook creation after repo creation/migration with
  fail-loud state tracking
- Repository sync after connection (Gitea + GitHub)
- AgentOS execution resolves gitRepositoryId for real clone URL
- Credential gating: state + freshness checks before execution and
  project creation
- listForOrganization for cross-project Work filtering

Frontend:
- /projects onboarding page with GitHub OAuth (linkSocial) and Puter PAT
- Zero-project redirect, repository selection, context editor
- Provider-aware settings panel (no serverUrl for Puter)
- Project selection via ?project= query param
- GitHub scopes: repo + read:org

Agent runtime:
- Clones user repository with GIT_ASKPASS credential helper (no token in
  URL, args, or git config), provider-aware username
- Removed fixed Zopu source path and .env copy
2026-07-31 14:36:56 +05:30

312 lines
9.0 KiB
TypeScript

"use node";
import { env } from "@code/env/convex";
import { decodeGitConnectionInput } from "@code/primitives/execution-runtime";
import {
GITHUB_SERVER_URL,
PUTER_GIT_SERVER_URL,
} from "@code/primitives/git-provider";
import { makeFunctionReference } from "convex/server";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import { action } from "./_generated/server";
import { authComponent, createAuth } from "./auth";
const encryptionKey = async (): Promise<CryptoKey> => {
if (!env.GIT_CREDENTIAL_ENCRYPTION_KEY) {
throw new ConvexError("Git credential encryption is not configured");
}
const bytes = Buffer.from(env.GIT_CREDENTIAL_ENCRYPTION_KEY, "base64url");
if (bytes.byteLength !== 32) {
throw new ConvexError("Git credential encryption key must be 32 bytes");
}
return await crypto.subtle.importKey("raw", bytes, "AES-GCM", false, [
"encrypt",
"decrypt",
]);
};
const encryptCredential = async (credential: string) => {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ iv, name: "AES-GCM" },
await encryptionKey(),
new TextEncoder().encode(credential)
);
return {
credentialCiphertext: Buffer.from(encrypted).toString("base64url"),
credentialIv: Buffer.from(iv).toString("base64url"),
};
};
export const decryptCredential = async (
credentialCiphertext: string,
credentialIv: string
): Promise<string> => {
const decrypted = await crypto.subtle.decrypt(
{
iv: Buffer.from(credentialIv, "base64url"),
name: "AES-GCM",
},
await encryptionKey(),
Buffer.from(credentialCiphertext, "base64url")
);
return new TextDecoder().decode(decrypted);
};
interface ExternalUserInfo {
readonly externalAccountId: string;
readonly externalEmail?: string;
readonly externalUsername: string;
}
/** Fetch the Gitea user identity from /api/v1/user using a PAT. */
const fetchGiteaUser = async (
serverUrl: string,
token: string
): Promise<ExternalUserInfo> => {
const response = await fetch(
`${serverUrl.replace(/\/+$/u, "")}/api/v1/user`,
{
headers: { authorization: `token ${token}` },
}
);
if (!response.ok) {
throw new ConvexError(
`Gitea user verification failed (${response.status})`
);
}
const user = (await response.json()) as {
readonly email?: string;
readonly id: number;
readonly login: string;
};
return {
externalAccountId: String(user.id),
externalEmail: user.email,
externalUsername: user.login,
};
};
/** Fetch the GitHub user identity from /user using an OAuth token. */
const fetchGithubUser = async (token: string): Promise<ExternalUserInfo> => {
const response = await fetch("https://api.github.com/user", {
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new ConvexError(
`GitHub user verification failed (${response.status})`
);
}
const user = (await response.json()) as {
readonly email?: string;
readonly id: number;
readonly login: string;
};
return {
externalAccountId: String(user.id),
externalEmail: user.email,
externalUsername: user.login,
};
};
const syncRepositoriesRef = makeFunctionReference<
"action",
{ connectionId: Id<"gitConnections"> },
{ synced: number }
>("gitConnections:syncRepositories");
export const connectGitea = action({
args: {
token: v.string(),
username: v.optional(v.string()),
},
handler: async (
ctx,
args
): Promise<{ connectionId: Id<"gitConnections"> }> => {
const userId = await ctx.auth.getUserIdentity().then((identity) => {
if (!identity) {
throw new ConvexError("Authentication required");
}
return identity.tokenIdentifier;
});
const connection = await Effect.runPromise(
decodeGitConnectionInput({
credential: args.token,
credentialKind: "token",
provider: "gitea",
serverUrl: PUTER_GIT_SERVER_URL,
username: args.username,
})
);
// Verify the token and fetch external identity before persisting.
const externalUser = await fetchGiteaUser(
connection.serverUrl,
connection.credential
);
const encrypted = await encryptCredential(connection.credential);
const connectionId = await ctx.runMutation(
internal.gitConnectionData.persist,
{
...encrypted,
...externalUser,
credentialKind: connection.credentialKind,
provider: connection.provider,
serverUrl: connection.serverUrl,
userId,
username: connection.username ?? externalUser.externalUsername,
}
);
// Sync accessible repositories after connecting.
await ctx.runAction(syncRepositoriesRef, {
connectionId,
});
return { connectionId };
},
});
export const connectGithub = action({
args: {},
handler: async (ctx): Promise<{ connectionId: Id<"gitConnections"> }> => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new ConvexError("Authentication required");
}
const { auth, headers } = await authComponent.getAuth(createAuth, ctx);
const token = await auth.api.getAccessToken({
body: { providerId: "github" },
headers,
});
if (!token.accessToken) {
throw new ConvexError("GitHub account is not connected");
}
const externalUser = await fetchGithubUser(token.accessToken);
const encrypted = await encryptCredential(token.accessToken);
const connectionId = await ctx.runMutation(
internal.gitConnectionData.persist,
{
...encrypted,
...externalUser,
credentialKind: "oauth",
provider: "github",
serverUrl: GITHUB_SERVER_URL,
userId: identity.tokenIdentifier,
}
);
// Sync accessible repositories after connecting.
await ctx.runAction(syncRepositoriesRef, {
connectionId,
});
return { connectionId };
},
});
const getConnectionForOwnerSyncRef = makeFunctionReference<
"query",
{ connectionId: Id<"gitConnections"> },
Doc<"gitConnections"> | null
>("gitConnectionHealth:getConnectionForOwner");
const syncRepositoriesBatchRef = makeFunctionReference<
"mutation",
{
providerAccountId: Id<"gitProviderAccounts">;
provider: "github" | "gitea";
repos: {
cloneUrl: string;
defaultBranch: string;
fullName: string;
name: string;
owner: string;
private: boolean;
providerRepositoryId: string;
serverUrl: string;
webUrl: string;
}[];
serverUrl: string;
},
number
>("gitProvisioning:syncRepositoriesBatch");
export const syncRepositories = action({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args): Promise<{ synced: number }> => {
const connection = await ctx.runQuery(getConnectionForOwnerSyncRef, {
connectionId: args.connectionId,
});
if (!connection) {
throw new ConvexError("Git connection not found");
}
const token = await decryptCredential(
connection.credentialCiphertext,
connection.credentialIv
);
let repos: {
clone_url: string;
default_branch: string;
full_name: string;
html_url: string;
id: number;
name: string;
owner: { login: string };
private: boolean;
}[];
if (connection.provider === "gitea") {
const response = await fetch(
`${connection.serverUrl.replace(/\/+$/u, "")}/api/v1/repos/search?limit=50`,
{ headers: { authorization: `token ${token}` } }
);
if (!response.ok) {
throw new ConvexError(
`Failed to list Gitea repositories (${response.status})`
);
}
const body = (await response.json()) as { data: typeof repos };
repos = body.data ?? [];
} else {
const response = await fetch(
"https://api.github.com/user/repos?sort=updated&per_page=50",
{
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
throw new ConvexError(
`Failed to list GitHub repositories (${response.status})`
);
}
repos = (await response.json()) as typeof repos;
}
const synced = await ctx.runMutation(syncRepositoriesBatchRef, {
provider: connection.provider,
providerAccountId:
connection.gitProviderAccountId ?? ("" as Id<"gitProviderAccounts">),
repos: repos.map((repo) => ({
cloneUrl: repo.clone_url,
defaultBranch: repo.default_branch ?? "main",
fullName: repo.full_name,
name: repo.name,
owner: repo.owner.login,
private: repo.private,
providerRepositoryId: String(repo.id),
serverUrl: connection.serverUrl,
webUrl: repo.html_url,
})),
serverUrl: connection.serverUrl,
});
return { synced };
},
});