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
251 lines
8.0 KiB
TypeScript
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 };
|
|
},
|
|
});
|