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
This commit is contained in:
-Puter
2026-07-31 14:36:56 +05:30
parent 88f53f550d
commit c644ec8d01
36 changed files with 4821 additions and 119 deletions
+1
View File
@@ -37,6 +37,7 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
scope: ["repo", "read:org"],
},
}
: {},
+21
View File
@@ -78,3 +78,24 @@ export const requireProjectMember = async (
const userId = await requireOrganizationMember(ctx, project.organizationId);
return { organizationId: project.organizationId, userId };
};
/**
* Prove the authenticated user is an owner of the given organization.
* Provisioning functions require owner-level authorization.
*/
export const requireCurrentOrganizationOwner = async (
ctx: QueryCtx | MutationCtx,
organizationId: Id<"organizations">
): Promise<string> => {
const userId = await requireAuthUserId(ctx);
const membership = await ctx.db
.query("organizationMembers")
.withIndex("by_organizationId_and_userId", (q) =>
q.eq("organizationId", organizationId).eq("userId", userId)
)
.unique();
if (!membership || membership.role !== "owner") {
throw new ConvexError("Organization owner role required");
}
return userId;
};
+3
View File
@@ -10,10 +10,13 @@ const app = defineApp({
FLUE_URL: v.optional(v.string()),
GITEA_TOKEN: v.optional(v.string()),
GITEA_URL: v.optional(v.string()),
GITEA_WEBHOOK_SECRET: v.optional(v.string()),
GITHUB_CLIENT_ID: v.optional(v.string()),
GITHUB_CLIENT_SECRET: v.optional(v.string()),
GITHUB_WEBHOOK_SECRET: v.optional(v.string()),
GIT_CREDENTIAL_ENCRYPTION_KEY: v.optional(v.string()),
NATIVE_APP_URL: v.optional(v.string()),
PUTER_GIT_ADMIN_TOKEN: v.optional(v.string()),
SITE_URL: v.string(),
},
});
+21
View File
@@ -12,6 +12,16 @@ const reconcileConversationTurnsRef = makeFunctionReference<
Record<string, never>,
{ reconciled: number }
>("conversationMessages:reconcileExpiredTurns");
const reconcileGitConnectionsRef = makeFunctionReference<
"action",
Record<string, never>,
{ checked: number }
>("gitConnectionHealth:reconcileStaleConnections");
const backfillConnectionsRef = makeFunctionReference<
"mutation",
Record<string, never>,
{ migrated: number }
>("gitConnectionData:backfillConnections");
const crons = cronJobs();
@@ -25,5 +35,16 @@ crons.interval(
{ seconds: 30 },
reconcileConversationTurnsRef
);
crons.cron(
"reconcile stale git connections",
"0 * * * *",
reconcileGitConnectionsRef
);
// Backfill legacy connections on startup and every 6 hours until all are migrated.
crons.interval(
"backfill legacy git connections",
{ hours: 6 },
backfillConnectionsRef
);
export default crons;
+125 -18
View File
@@ -1,21 +1,57 @@
import { providerForHost } from "@code/primitives/git-provider";
import { ConvexError, v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { internalMutation, mutation, query } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import { requireCurrentOrganization, requireProjectMember } from "./authz";
// Provider ("forge") that owns a repository host. A project's source must be
// served by the same forge whose credentials are attached, so a Gitea token is
// never offered to GitHub and vice versa. Unknown hosts return null, leaving
// the caller free to attach without a forge constraint.
export const forgeForHost = (host: string): "github" | "gitea" | null => {
const normalized = host.toLowerCase();
if (normalized === "github.com" || normalized.endsWith(".githost.com")) {
return "github";
const upsertProviderAccount = async (
ctx: MutationCtx,
args: {
externalAccountId: string;
externalEmail?: string;
externalUsername: string;
organizationId: Id<"organizations">;
provider: "github" | "gitea";
serverUrl: string;
userId: string;
}
if (normalized === "git.openputer.com") {
return "gitea";
): Promise<Id<"gitProviderAccounts">> => {
const existing = await ctx.db
.query("gitProviderAccounts")
.withIndex("by_userId_and_provider_and_serverUrl", (q) =>
q
.eq("userId", args.userId)
.eq("provider", args.provider)
.eq("serverUrl", args.serverUrl)
)
.unique();
const timestamp = Date.now();
if (existing) {
await ctx.db.patch(existing._id, {
externalAccountId: args.externalAccountId,
externalEmail: args.externalEmail,
externalUsername: args.externalUsername,
status: "active",
updatedAt: timestamp,
});
return existing._id;
}
return null;
return await ctx.db.insert("gitProviderAccounts", {
createdAt: timestamp,
externalAccountId: args.externalAccountId,
externalEmail: args.externalEmail,
externalUsername: args.externalUsername,
organizationId: args.organizationId,
provider: args.provider,
serverUrl: args.serverUrl,
status: "active",
updatedAt: timestamp,
userId: args.userId,
});
};
export const persist = internalMutation({
@@ -23,6 +59,10 @@ export const persist = internalMutation({
credentialCiphertext: v.string(),
credentialIv: v.string(),
credentialKind: v.union(v.literal("oauth"), v.literal("token")),
externalAccountId: v.string(),
externalEmail: v.optional(v.string()),
externalUsername: v.string(),
grantedScopesJson: v.optional(v.string()),
provider: v.union(v.literal("github"), v.literal("gitea")),
serverUrl: v.string(),
userId: v.string(),
@@ -38,34 +78,54 @@ export const persist = internalMutation({
if (!organization) {
throw new ConvexError("Organization not found");
}
const providerAccountId = await upsertProviderAccount(ctx, {
externalAccountId: args.externalAccountId,
externalEmail: args.externalEmail,
externalUsername: args.externalUsername,
organizationId: organization._id,
provider: args.provider,
serverUrl: args.serverUrl,
userId: args.userId,
});
const existing = await ctx.db
.query("gitConnections")
.withIndex("by_organizationId_and_provider_and_serverUrl", (q) =>
q
.eq("organizationId", organization._id)
.eq("provider", args.provider)
.eq("serverUrl", args.serverUrl)
.withIndex("by_gitProviderAccountId", (q) =>
q.eq("gitProviderAccountId", providerAccountId)
)
.unique();
const timestamp = Date.now();
if (existing) {
await ctx.db.patch(existing._id, {
credentialCiphertext: args.credentialCiphertext,
credentialIv: args.credentialIv,
credentialKind: args.credentialKind,
grantedScopesJson: args.grantedScopesJson,
lastError: undefined,
lastVerifiedAt: timestamp,
reauthRequiredAt: undefined,
state: "active",
updatedAt: timestamp,
username: args.username,
});
return existing._id;
}
return await ctx.db.insert("gitConnections", {
connectedAt: timestamp,
creatorUserId: args.userId,
credentialCiphertext: args.credentialCiphertext,
credentialIv: args.credentialIv,
credentialKind: args.credentialKind,
gitProviderAccountId: providerAccountId,
grantedScopesJson: args.grantedScopesJson,
lastVerifiedAt: timestamp,
organizationId: organization._id,
provider: args.provider,
serverUrl: args.serverUrl,
state: "active",
updatedAt: timestamp,
username: args.username,
});
@@ -86,8 +146,10 @@ export const list = query({
connectedAt: connection.connectedAt,
credentialKind: connection.credentialKind,
id: String(connection._id),
lastVerifiedAt: connection.lastVerifiedAt,
provider: connection.provider,
serverUrl: connection.serverUrl,
state: connection.state,
username: connection.username,
}));
},
@@ -106,8 +168,10 @@ export const getForProject = query({
connectedAt: connection.connectedAt,
credentialKind: connection.credentialKind,
id: String(connection._id),
lastVerifiedAt: connection.lastVerifiedAt,
provider: connection.provider,
serverUrl: connection.serverUrl,
state: connection.state,
username: connection.username,
}
: null;
@@ -127,10 +191,10 @@ export const attachToProject = mutation({
}
const project = await ctx.db.get(args.projectId);
if (project) {
const expected = forgeForHost(project.sourceHost);
const expected = providerForHost(project.sourceHost);
if (expected && connection.provider !== expected) {
throw new ConvexError(
`Git credential provider (${connection.provider}) does not match this project's forge (${expected})`
`Git credential provider (${connection.provider}) does not match this project's provider (${expected})`
);
}
}
@@ -141,3 +205,46 @@ export const attachToProject = mutation({
return { attached: true };
},
});
/**
* Backfill legacy gitConnections rows that predate the normalized schema.
* Sets default values for creatorUserId, state, and lastVerifiedAt when
* missing. Also creates a gitProviderAccount for each legacy connection.
*/
export const backfillConnections = internalMutation({
args: {},
handler: async (ctx) => {
const all = await ctx.db.query("gitConnections").collect();
let migrated = 0;
for (const conn of all) {
if (conn.state !== undefined && conn.gitProviderAccountId !== undefined) {
continue;
}
const timestamp = Date.now();
// Create a provider account for the legacy connection.
const providerAccountId = await ctx.db.insert("gitProviderAccounts", {
createdAt: conn.connectedAt,
externalAccountId: conn.username ?? "legacy",
externalUsername: conn.username ?? "legacy",
organizationId: conn.organizationId,
provider: conn.provider,
serverUrl: conn.serverUrl,
status: "reauth-required",
updatedAt: timestamp,
userId: conn.creatorUserId ?? "legacy",
});
// Mark as reauth-required so the health check cron verifies the
// credential before marking active. This prevents trusting legacy
// credentials without verification.
await ctx.db.patch(conn._id, {
creatorUserId: conn.creatorUserId ?? "legacy",
gitProviderAccountId: providerAccountId,
reauthRequiredAt: timestamp,
state: "reauth-required",
updatedAt: timestamp,
});
migrated += 1;
}
return { migrated };
},
});
@@ -0,0 +1,243 @@
"use node";
import {
CREDENTIAL_FRESHNESS_MS,
isCredentialFresh,
} from "@code/primitives/git-provider";
import { makeFunctionReference } from "convex/server";
import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import {
action,
internalMutation,
internalQuery,
query,
} from "./_generated/server";
import { requireCurrentOrganization } from "./authz";
import { decryptCredential } from "./gitConnections";
interface VerifyResult {
readonly lastError?: string;
readonly state: "active" | "reauth-required" | "unavailable";
}
const verifyGiteaCredential = async (
serverUrl: string,
token: string
): Promise<VerifyResult> => {
try {
const response = await fetch(
`${serverUrl.replace(/\/+$/u, "")}/api/v1/user`,
{ headers: { authorization: `token ${token}` } }
);
if (response.status === 401 || response.status === 403) {
return { lastError: "Token rejected", state: "reauth-required" };
}
if (!response.ok) {
return {
lastError: `Provider returned ${response.status}`,
state: "unavailable",
};
}
return { state: "active" };
} catch (error) {
return {
lastError: error instanceof Error ? error.message : "Unreachable",
state: "unavailable",
};
}
};
const verifyGithubCredential = async (token: string): Promise<VerifyResult> => {
try {
const response = await fetch("https://api.github.com/user", {
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
},
});
if (response.status === 401 || response.status === 403) {
return { lastError: "Token rejected", state: "reauth-required" };
}
if (!response.ok) {
return {
lastError: `Provider returned ${response.status}`,
state: "unavailable",
};
}
return { state: "active" };
} catch (error) {
return {
lastError: error instanceof Error ? error.message : "Unreachable",
state: "unavailable",
};
}
};
export const verifyCredential = async (
connection: Doc<"gitConnections">
): Promise<VerifyResult> => {
const credential = await decryptCredential(
connection.credentialCiphertext,
connection.credentialIv
);
return connection.provider === "gitea"
? verifyGiteaCredential(connection.serverUrl, credential)
: verifyGithubCredential(credential);
};
export const updateConnectionState = internalMutation({
args: {
connectionId: v.id("gitConnections"),
lastError: v.optional(v.string()),
state: v.union(
v.literal("active"),
v.literal("reauth-required"),
v.literal("unavailable")
),
},
handler: async (ctx, args) => {
const timestamp = Date.now();
const patch: Record<string, unknown> = {
lastError: args.lastError,
state: args.state,
updatedAt: timestamp,
};
if (args.state === "active") {
patch.lastError = undefined;
patch.lastVerifiedAt = timestamp;
patch.reauthRequiredAt = undefined;
} else if (args.state === "reauth-required") {
patch.reauthRequiredAt = timestamp;
}
await ctx.db.patch(args.connectionId, patch);
},
});
const getConnectionForOwnerRef = makeFunctionReference<
"query",
{ connectionId: Id<"gitConnections"> },
Doc<"gitConnections"> | null
>("gitConnectionHealth:getConnectionForOwner");
const getConnectionRef = makeFunctionReference<
"query",
{ connectionId: Id<"gitConnections"> },
Doc<"gitConnections"> | null
>("gitConnectionHealth:getConnection");
const getStaleConnectionsRef = makeFunctionReference<
"query",
Record<string, never>,
{ connectionId: Id<"gitConnections"> }[]
>("gitConnectionHealth:getStaleConnections");
const updateConnectionStateRef = makeFunctionReference<
"mutation",
{
connectionId: Id<"gitConnections">;
lastError?: string;
state: "active" | "reauth-required" | "unavailable";
},
null
>("gitConnectionHealth:updateConnectionState");
export const getConnectionForOwner = internalQuery({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args) => {
const { organizationId } = await requireCurrentOrganization(ctx);
const connection = await ctx.db.get(args.connectionId);
if (!connection || connection.organizationId !== organizationId) {
return null;
}
return connection;
},
});
export const getConnection = internalQuery({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args) => await ctx.db.get(args.connectionId),
});
export const getStaleConnections = internalQuery({
args: {},
handler: async (ctx) => {
const now = Date.now();
const cutoff = now - CREDENTIAL_FRESHNESS_MS;
const all = await ctx.db.query("gitConnections").collect();
return all
.filter(
(conn) =>
(conn.state === "active" &&
(conn.lastVerifiedAt === undefined ||
conn.lastVerifiedAt < cutoff)) ||
conn.state === "reauth-required" ||
conn.state === undefined
)
.map((conn) => ({ connectionId: conn._id }));
},
});
export const verify = action({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args) => {
const connection = await ctx.runQuery(getConnectionForOwnerRef, {
connectionId: args.connectionId,
});
if (!connection) {
throw new ConvexError("Git connection not found");
}
const result = await verifyCredential(connection);
await ctx.runMutation(updateConnectionStateRef, {
connectionId: args.connectionId,
lastError: result.lastError,
state: result.state,
});
return result;
},
});
export const isFresh = query({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args): Promise<boolean> => {
const { organizationId } = await requireCurrentOrganization(ctx);
const connection = await ctx.db.get(args.connectionId);
if (!connection || connection.organizationId !== organizationId) {
throw new ConvexError("Git connection not found");
}
return isCredentialFresh(connection.lastVerifiedAt, Date.now());
},
});
export const reconcileStaleConnections = action({
args: {},
handler: async (ctx) => {
const stale = await ctx.runQuery(getStaleConnectionsRef, {});
let checked = 0;
for (const { connectionId } of stale) {
const connection = await ctx.runQuery(getConnectionRef, {
connectionId,
});
if (!connection) {
continue;
}
try {
const result = await verifyCredential(connection);
await ctx.runMutation(updateConnectionStateRef, {
connectionId,
lastError: result.lastError,
state: result.state,
});
checked += 1;
} catch {
await ctx.runMutation(updateConnectionStateRef, {
connectionId,
lastError: "Verification failed",
state: "unavailable",
});
}
}
return { checked };
},
});
+192 -5
View File
@@ -2,11 +2,16 @@
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 { Id } from "./_generated/dataModel";
import type { Doc, Id } from "./_generated/dataModel";
import { action } from "./_generated/server";
import { authComponent, createAuth } from "./auth";
@@ -52,9 +57,73 @@ export const decryptCredential = async (
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: {
serverUrl: v.string(),
token: v.string(),
username: v.optional(v.string()),
},
@@ -73,22 +142,32 @@ export const connectGitea = action({
credential: args.token,
credentialKind: "token",
provider: "gitea",
serverUrl: args.serverUrl,
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,
username: connection.username ?? externalUser.externalUsername,
}
);
// Sync accessible repositories after connecting.
await ctx.runAction(syncRepositoriesRef, {
connectionId,
});
return { connectionId };
},
});
@@ -108,17 +187,125 @@ export const connectGithub = action({
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: "https://github.com",
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 };
},
});
File diff suppressed because it is too large Load Diff
+254
View File
@@ -0,0 +1,254 @@
"use node";
import { env } from "@code/env/convex";
import {
isProcessedEvent,
MAX_WEBHOOK_PAYLOAD_BYTES,
} from "@code/primitives/git-webhook";
import { makeFunctionReference } from "convex/server";
import type { Id } from "./_generated/dataModel";
import { httpAction } from "./_generated/server";
const recordWebhookDeliveryRef = makeFunctionReference<
"mutation",
{
deliveryId: string;
event: string;
externalRepositoryId?: string;
payloadHash: string;
provider: "github" | "gitea";
},
{ deliveryId: Id<"gitWebhookDeliveries">; duplicate: boolean }
>("gitProvisioning:recordWebhookDelivery");
const resolveRepositoryByExternalIdRef = makeFunctionReference<
"query",
{
externalRepositoryId: string;
provider: "github" | "gitea";
},
{ _id: Id<"gitRepositories"> } | null
>("gitProvisioning:resolveRepositoryByExternalId");
const markDeliveryProcessedRef = makeFunctionReference<
"mutation",
{
deliveryId: Id<"gitWebhookDeliveries">;
repositoryRef?: Id<"gitRepositories">;
},
null
>("gitProvisioning:markDeliveryProcessed");
/* eslint-disable @typescript-eslint/no-explicit-any -- action ctx type is complex without codegen */
const processDelivery = async (
ctx: any,
input: {
readonly deliveryId: string;
readonly event: string;
readonly externalRepositoryId?: string;
readonly payloadHash: string;
readonly provider: "github" | "gitea";
}
): Promise<{ deliveryId: string; duplicate: boolean }> => {
const result: { deliveryId: Id<"gitWebhookDeliveries">; duplicate: boolean } =
await ctx.runMutation(recordWebhookDeliveryRef, input);
if (result.duplicate) {
return result;
}
// Resolve the repository reference if we have an external ID.
let repositoryRef: Id<"gitRepositories"> | undefined;
if (input.externalRepositoryId) {
const repo = await ctx.runQuery(resolveRepositoryByExternalIdRef, {
externalRepositoryId: input.externalRepositoryId,
provider: input.provider,
});
if (repo) {
repositoryRef = repo._id as Id<"gitRepositories">;
}
}
await ctx.runMutation(markDeliveryProcessedRef, {
deliveryId: result.deliveryId,
...(repositoryRef ? { repositoryRef } : {}),
});
return result;
};
const verifyHmacSignature = async (
body: string,
signature: string,
secret: string,
algorithm: "sha256" | "sha1"
): Promise<boolean> => {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ hash: algorithm, name: "HMAC" },
false,
["verify"]
);
let sigHex = signature;
if (signature.startsWith("sha256=")) {
sigHex = signature.slice(7);
} else if (signature.startsWith("sha1=")) {
sigHex = signature.slice(5);
}
const sigBytes = new Uint8Array(
sigHex.match(/.{2}/gu)?.flatMap((byte) => Number.parseInt(byte, 16)) ?? []
);
return await crypto.subtle.verify(
"HMAC",
key,
sigBytes,
new TextEncoder().encode(body)
);
};
const hashPayload = async (body: string): Promise<string> => {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(body)
);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
};
export const githubWebhook = httpAction(async (ctx, request) => {
const deliveryId = request.headers.get("x-github-delivery") ?? "";
const event = request.headers.get("x-github-event") ?? "";
const signature = request.headers.get("x-hub-signature-256") ?? "";
if (!deliveryId || !event) {
return new Response("Missing GitHub delivery headers", { status: 400 });
}
const rawBody = await request.text();
const payloadBytes = new TextEncoder().encode(rawBody).length;
if (payloadBytes > MAX_WEBHOOK_PAYLOAD_BYTES) {
return new Response("Payload too large", { status: 413 });
}
if (!env.GITHUB_WEBHOOK_SECRET) {
return new Response("GitHub webhook secret not configured", {
status: 500,
});
}
const valid = await verifyHmacSignature(
rawBody,
signature,
env.GITHUB_WEBHOOK_SECRET,
"sha256"
);
if (!valid) {
return new Response("Invalid signature", { status: 401 });
}
if (!isProcessedEvent(event)) {
return new Response("Event ignored", { status: 200 });
}
const payload = JSON.parse(rawBody) as {
action?: string;
repository?: {
full_name: string;
html_url: string;
id: number;
name: string;
owner: { login: string };
};
};
const externalRepositoryId = payload.repository
? String(payload.repository.id)
: undefined;
const payloadHash = await hashPayload(rawBody);
// Persist delivery, resolve repository, and mark processed.
const result = await processDelivery(ctx, {
deliveryId,
event,
externalRepositoryId,
payloadHash,
provider: "github",
});
return Response.json({
deliveryId,
duplicate: result.duplicate,
event,
externalRepositoryId,
payloadHash,
processed: true,
provider: "github",
});
});
export const puterWebhook = httpAction(async (ctx, request) => {
const deliveryId = request.headers.get("x-gitea-delivery") ?? "";
const event = request.headers.get("x-gitea-event") ?? "";
const signature = request.headers.get("x-gitea-signature") ?? "";
if (!deliveryId || !event) {
return new Response("Missing Gitea delivery headers", { status: 400 });
}
const rawBody = await request.text();
const payloadBytes = new TextEncoder().encode(rawBody).length;
if (payloadBytes > MAX_WEBHOOK_PAYLOAD_BYTES) {
return new Response("Payload too large", { status: 413 });
}
if (!env.GITEA_WEBHOOK_SECRET) {
return new Response("Gitea webhook secret not configured", { status: 500 });
}
const valid = await verifyHmacSignature(
rawBody,
signature,
env.GITEA_WEBHOOK_SECRET,
"sha256"
);
if (!valid) {
return new Response("Invalid signature", { status: 401 });
}
if (!isProcessedEvent(event)) {
return new Response("Event ignored", { status: 200 });
}
const payload = JSON.parse(rawBody) as {
action?: string;
repository?: {
full_name: string;
html_url: string;
id: number;
name: string;
owner: { login: string };
};
};
const externalRepositoryId = payload.repository
? String(payload.repository.id)
: undefined;
const payloadHash = await hashPayload(rawBody);
const result = await processDelivery(ctx, {
deliveryId,
event,
externalRepositoryId,
payloadHash,
provider: "gitea",
});
return Response.json({
deliveryId,
duplicate: result.duplicate,
event,
externalRepositoryId,
payloadHash,
processed: true,
provider: "gitea",
});
});
+13
View File
@@ -1,9 +1,22 @@
import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth";
import { githubWebhook, puterWebhook } from "./gitWebhooks";
const http = httpRouter();
authComponent.registerRoutes(http, createAuth, { cors: true });
http.route({
handler: githubWebhook,
method: "POST",
path: "/api/git/webhooks/github",
});
http.route({
handler: puterWebhook,
method: "POST",
path: "/api/git/webhooks/puter",
});
export default http;
+34 -2
View File
@@ -1,3 +1,4 @@
import { validateProjectInstructions } from "@code/primitives/git-provisioning";
import {
decodePublicGitImportResult,
preparePublicGitSource,
@@ -11,8 +12,12 @@ import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import { action, internalMutation, query } from "./_generated/server";
import { requireAuthUserId, requireCurrentOrganization } from "./authz";
import { action, internalMutation, mutation, query } from "./_generated/server";
import {
requireAuthUserId,
requireCurrentOrganization,
requireProjectMember,
} from "./authz";
import { inspectPublicGit } from "./publicGit";
const toProjectView = async (
@@ -201,3 +206,30 @@ export const importPublicGit = action({
});
},
});
export const updateInstructions = mutation({
args: {
instructions: v.string(),
projectId: v.id("projects"),
},
handler: async (ctx, args) => {
await requireProjectMember(ctx, args.projectId);
const validated = await Effect.runPromise(
validateProjectInstructions(args.instructions)
);
await ctx.db.patch(args.projectId, {
instructions: validated,
updatedAt: Date.now(),
});
return { updated: true };
},
});
export const getInstructions = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
await requireProjectMember(ctx, args.projectId);
const project = await ctx.db.get(args.projectId);
return project?.instructions ?? "";
},
});
+148 -8
View File
@@ -44,28 +44,168 @@ export default defineSchema({
.index("by_userId", ["userId"])
.index("by_organizationId", ["organizationId"])
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
gitConnections: defineTable({
connectedAt: v.number(),
credentialCiphertext: v.string(),
credentialIv: v.string(),
credentialKind: v.union(v.literal("oauth"), v.literal("token")),
gitProviderAccounts: defineTable({
createdAt: v.number(),
externalAccountId: v.string(),
externalEmail: v.optional(v.string()),
externalUsername: v.string(),
organizationId: v.id("organizations"),
provider: v.union(v.literal("github"), v.literal("gitea")),
serverUrl: v.string(),
status: v.union(
v.literal("pending-auth"),
v.literal("active"),
v.literal("reauth-required"),
v.literal("revoked"),
v.literal("unavailable")
),
updatedAt: v.number(),
userId: v.string(),
})
.index("by_userId_and_provider_and_serverUrl", [
"userId",
"provider",
"serverUrl",
])
.index("by_organizationId", ["organizationId"]),
gitConnections: defineTable({
connectedAt: v.number(),
creatorUserId: v.optional(v.string()),
credentialCiphertext: v.string(),
credentialIv: v.string(),
credentialKind: v.union(v.literal("oauth"), v.literal("token")),
gitProviderAccountId: v.optional(v.id("gitProviderAccounts")),
grantedScopesJson: v.optional(v.string()),
lastError: v.optional(v.string()),
lastVerifiedAt: v.optional(v.number()),
organizationId: v.id("organizations"),
provider: v.union(v.literal("github"), v.literal("gitea")),
reauthRequiredAt: v.optional(v.number()),
serverUrl: v.string(),
state: v.optional(
v.union(
v.literal("pending-auth"),
v.literal("active"),
v.literal("reauth-required"),
v.literal("revoked"),
v.literal("unavailable")
)
),
updatedAt: v.number(),
username: v.optional(v.string()),
})
.index("by_organizationId", ["organizationId"])
.index("by_organizationId_and_provider_and_serverUrl", [
"organizationId",
.index("by_gitProviderAccountId", ["gitProviderAccountId"]),
gitProviderOrganizations: defineTable({
createdAt: v.number(),
displayName: v.optional(v.string()),
externalId: v.string(),
externalSlug: v.string(),
gitProviderAccountId: v.id("gitProviderAccounts"),
organizationId: v.id("organizations"),
serverUrl: v.string(),
updatedAt: v.number(),
verificationState: v.union(
v.literal("unverified"),
v.literal("verified"),
v.literal("stale")
),
visibility: v.union(v.literal("public"), v.literal("private")),
})
.index("by_gitProviderAccountId", ["gitProviderAccountId"])
.index("by_organizationId", ["organizationId"]),
gitRepositories: defineTable({
cloneUrl: v.string(),
createdAt: v.number(),
defaultBranch: v.string(),
fullName: v.string(),
gitProviderAccountId: v.id("gitProviderAccounts"),
gitProviderOrganizationId: v.optional(v.id("gitProviderOrganizations")),
lfsCapability: v.union(
v.literal("unknown"),
v.literal("supported"),
v.literal("unsupported")
),
name: v.string(),
organizationId: v.id("organizations"),
owner: v.string(),
permissionsAdmin: v.boolean(),
permissionsMaintain: v.boolean(),
permissionsPull: v.boolean(),
permissionsPush: v.boolean(),
permissionsTriage: v.boolean(),
privacy: v.union(v.literal("public"), v.literal("private")),
provider: v.union(v.literal("github"), v.literal("gitea")),
providerRepositoryId: v.string(),
serverUrl: v.string(),
sourceMigrationId: v.optional(v.id("gitMigrations")),
updatedAt: v.number(),
webUrl: v.string(),
webhookState: v.union(
v.literal("none"),
v.literal("pending"),
v.literal("active"),
v.literal("failed")
),
})
.index("by_provider_and_serverUrl_and_externalRepositoryId", [
"provider",
"serverUrl",
]),
"providerRepositoryId",
])
.index("by_organizationId", ["organizationId"]),
gitMigrations: defineTable({
createdAt: v.number(),
failureReason: v.optional(v.string()),
githubConnectionId: v.id("gitConnections"),
idempotencyKey: v.string(),
includeLfs: v.boolean(),
organizationId: v.id("organizations"),
puterConnectionId: v.id("gitConnections"),
resultingRepositoryId: v.optional(v.id("gitRepositories")),
sourceIsPrivate: v.boolean(),
sourceRepositoryUrl: v.string(),
status: v.union(
v.literal("queued"),
v.literal("running"),
v.literal("succeeded"),
v.literal("failed"),
v.literal("cancelled")
),
targetOwner: v.string(),
targetRepositoryName: v.string(),
updatedAt: v.number(),
})
.index("by_organizationId", ["organizationId"])
.index("by_idempotencyKey", ["idempotencyKey"]),
gitWebhookDeliveries: defineTable({
createdAt: v.number(),
deliveryId: v.string(),
error: v.optional(v.string()),
event: v.string(),
externalRepositoryId: v.optional(v.string()),
payloadHash: v.string(),
processingState: v.union(
v.literal("received"),
v.literal("processing"),
v.literal("processed"),
v.literal("ignored"),
v.literal("failed"),
v.literal("duplicate")
),
provider: v.union(v.literal("github"), v.literal("gitea")),
repositoryRef: v.optional(v.id("gitRepositories")),
updatedAt: v.number(),
})
.index("by_provider_and_deliveryId", ["provider", "deliveryId"])
.index("by_processingState", ["processingState"]),
projects: defineTable({
createdAt: v.number(),
defaultBranch: v.optional(v.string()),
description: v.optional(v.string()),
gitConnectionId: v.optional(v.id("gitConnections")),
gitRepositoryId: v.optional(v.id("gitRepositories")),
instructions: v.optional(v.string()),
name: v.string(),
normalizedSourceUrl: v.string(),
organizationId: v.id("organizations"),
+24 -2
View File
@@ -6,13 +6,24 @@ import {
decodeWorkAttemptExecutionFailure,
decodeWorkAttemptExecutionResult,
} from "@code/primitives/execution-runtime";
import { makeFunctionReference } from "convex/server";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { internalAction } from "./_generated/server";
import { decryptCredential } from "./gitConnections";
const getRepositoryRef = makeFunctionReference<
"query",
{ repositoryId: Id<"gitRepositories"> },
{
readonly cloneUrl: string;
readonly defaultBranch: string;
} | null
>("gitProvisioning:getRepository");
const backendUrl = () => env.AGENT_BACKEND_URL ?? env.FLUE_URL;
export const executeAttempt = internalAction({
@@ -33,6 +44,17 @@ export const executeAttempt = internalAction({
context.connection.credentialCiphertext,
context.connection.credentialIv
);
// Resolve the project's normalized repository if available; fall back to
// legacy sourceUrl for backward compatibility with pre-backfill projects.
const repository = context.project.gitRepositoryId
? await ctx.runQuery(getRepositoryRef, {
repositoryId: context.project.gitRepositoryId,
})
: null;
const repositoryUrl = repository?.cloneUrl ?? context.project.sourceUrl;
const baseBranch =
repository?.defaultBranch ?? context.project.defaultBranch ?? "main";
const response = await fetch(
`${backendUrl()}/internal/work-attempts/execute`,
{
@@ -44,9 +66,9 @@ export const executeAttempt = internalAction({
serverUrl: context.connection.serverUrl,
username: context.connection.username,
},
baseBranch: context.project.defaultBranch ?? "main",
baseBranch,
prompt: context.prompt,
repositoryUrl: context.project.sourceUrl,
repositoryUrl,
runId: String(context.run._id),
workId: String(context.work._id),
workspaceKey: context.attempt.workspaceKey,
@@ -343,14 +343,29 @@ describe("forge credential validation", () => {
sourceUrl: `https://${sourceHost}/puter/zopu`,
updatedAt: 1,
});
const connectionId = await ctx.db.insert("gitConnections", {
connectedAt: 1,
credentialCiphertext: "x",
credentialIv: "y",
credentialKind: provider === "github" ? "oauth" : "token",
const providerAccountId = await ctx.db.insert("gitProviderAccounts", {
createdAt: 1,
externalAccountId: "ext-1",
externalUsername: "zopu",
organizationId,
provider,
serverUrl: `https://${sourceHost}`,
status: "active",
updatedAt: 1,
userId: identity.tokenIdentifier,
});
const connectionId = await ctx.db.insert("gitConnections", {
connectedAt: 1,
creatorUserId: identity.tokenIdentifier,
credentialCiphertext: "x",
credentialIv: "y",
credentialKind: provider === "github" ? "oauth" : "token",
gitProviderAccountId: providerAccountId,
lastVerifiedAt: Date.now(),
organizationId,
provider,
serverUrl: `https://${sourceHost}`,
state: "active",
updatedAt: 1,
username: "zopu",
});
@@ -367,7 +382,7 @@ describe("forge credential validation", () => {
connectionId: ids.connectionId,
projectId: ids.projectId,
})
).rejects.toThrow(/does not match this project's forge/u);
).rejects.toThrow(/does not match this project.s provider/u);
});
test("accepts a matching provider for the project forge", async () => {
@@ -1,5 +1,9 @@
import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
import type { WorkAttemptExecutionErrorReason } from "@code/primitives/execution-runtime";
import {
CREDENTIAL_FRESHNESS_MS,
providerForHost as forgeForHost,
} from "@code/primitives/git-provider";
import { defaultCodingKitV0, resolveOutcome } from "@code/primitives/resolver";
import type { AttemptClassification } from "@code/primitives/resolver";
import { WorkflowManager } from "@convex-dev/workflow";
@@ -11,7 +15,6 @@ import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation, internalQuery, mutation } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import { requireProjectMember } from "./authz";
import { forgeForHost } from "./gitConnectionData";
export const workflow = new WorkflowManager(components.workflow);
@@ -120,7 +123,21 @@ const validateProjectDeployment = async (
const expected = forgeForHost(project.sourceHost);
if (expected && connection.provider !== expected) {
throw new ConvexError(
`Git credential provider (${connection.provider}) does not match this project's forge (${expected})`
`Git credential provider (${connection.provider}) does not match this project's provider (${expected})`
);
}
if (connection.state !== undefined && connection.state !== "active") {
throw new ConvexError(
`Git connection is ${connection.state}; verify or reconnect credentials before execution`
);
}
const now = Date.now();
if (
connection.lastVerifiedAt === undefined ||
now - connection.lastVerifiedAt > CREDENTIAL_FRESHNESS_MS
) {
throw new ConvexError(
"Git credentials have not been verified recently; run a connection health check before execution"
);
}
return connection;
@@ -252,7 +269,12 @@ export const executionContext = internalQuery({
const expectedForge = forgeForHost(project.sourceHost);
if (expectedForge && connection.provider !== expectedForge) {
throw new ConvexError(
`Git credential provider (${connection.provider}) does not match this project's forge (${expectedForge})`
`Git credential provider (${connection.provider}) does not match this project's provider (${expectedForge})`
);
}
if (connection.state !== undefined && connection.state !== "active") {
throw new ConvexError(
`Git connection is ${connection.state}; execution is blocked`
);
}
return {
+30 -1
View File
@@ -20,7 +20,7 @@ import {
query,
} from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { requireProjectMember } from "./authz";
import { requireCurrentOrganization, requireProjectMember } from "./authz";
const plannerRef = makeFunctionReference<
"action",
@@ -868,3 +868,32 @@ export const listForProject = query({
);
},
});
export const listForOrganization = query({
args: {},
handler: async (ctx) => {
const { organizationId } = await requireCurrentOrganization(ctx);
const works = await ctx.db
.query("works")
.withIndex("by_organization_and_createdAt", (q) =>
q.eq("organizationId", organizationId)
)
.order("desc")
.take(100);
return await Promise.all(
works.map(async (work) => {
const project = await ctx.db.get(work.projectId);
return {
...work,
project: project
? {
id: String(project._id),
name: project.name,
}
: null,
repositoryReady: Boolean(project?.gitRepositoryId),
};
})
);
},
});