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

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 };
},
});