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