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:
@@ -37,6 +37,7 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
|
||||
github: {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||
scope: ["repo", "read:org"],
|
||||
},
|
||||
}
|
||||
: {},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user