Files
zopu-code/packages/backend/convex/gitConnectionData.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

251 lines
8.0 KiB
TypeScript

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";
const upsertProviderAccount = async (
ctx: MutationCtx,
args: {
externalAccountId: string;
externalEmail?: string;
externalUsername: string;
organizationId: Id<"organizations">;
provider: "github" | "gitea";
serverUrl: string;
userId: string;
}
): 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 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({
args: {
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(),
username: v.optional(v.string()),
},
handler: async (ctx, args) => {
const organization = await ctx.db
.query("organizations")
.withIndex("by_createdBy_and_kind", (q) =>
q.eq("createdBy", args.userId).eq("kind", "personal")
)
.unique();
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_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,
});
},
});
export const list = query({
args: {},
handler: async (ctx) => {
const { organizationId } = await requireCurrentOrganization(ctx);
const connections = await ctx.db
.query("gitConnections")
.withIndex("by_organizationId", (q) =>
q.eq("organizationId", organizationId)
)
.collect();
return connections.map((connection) => ({
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,
}));
},
});
export const getForProject = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
await requireProjectMember(ctx, args.projectId);
const project = await ctx.db.get(args.projectId);
const connection = project?.gitConnectionId
? await ctx.db.get(project.gitConnectionId)
: null;
return connection
? {
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;
},
});
export const attachToProject = mutation({
args: {
connectionId: v.id("gitConnections"),
projectId: v.id("projects"),
},
handler: async (ctx, args) => {
const { organizationId } = await requireProjectMember(ctx, args.projectId);
const connection = await ctx.db.get(args.connectionId);
if (!connection || connection.organizationId !== organizationId) {
throw new ConvexError("Git connection not found");
}
const project = await ctx.db.get(args.projectId);
if (project) {
const expected = providerForHost(project.sourceHost);
if (expected && connection.provider !== expected) {
throw new ConvexError(
`Git credential provider (${connection.provider}) does not match this project's provider (${expected})`
);
}
}
await ctx.db.patch(args.projectId, {
gitConnectionId: connection._id,
updatedAt: Date.now(),
});
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 };
},
});