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
244 lines
6.9 KiB
TypeScript
244 lines
6.9 KiB
TypeScript
"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 };
|
|
},
|
|
});
|