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:
465
apps/web/src/components/projects/projects-page.tsx
Normal file
465
apps/web/src/components/projects/projects-page.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import { authClient } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import {
|
||||
FileText,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
LoaderCircle,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
const connectGithubRef = makeFunctionReference<
|
||||
"action",
|
||||
Record<string, never>,
|
||||
{ connectionId: Id<"gitConnections"> }
|
||||
>("gitConnections:connectGithub");
|
||||
const connectGiteaRef = makeFunctionReference<
|
||||
"action",
|
||||
{ token: string; username?: string },
|
||||
{ connectionId: Id<"gitConnections"> }
|
||||
>("gitConnections:connectGitea");
|
||||
const createProjectFromRepositoryRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ gitRepositoryId: Id<"gitRepositories">; name: string },
|
||||
{ projectId: Id<"projects"> }
|
||||
>("gitProvisioning:createProjectFromRepository");
|
||||
|
||||
const LoadingState = () => (
|
||||
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
||||
<LoaderCircle className="size-5 animate-spin text-[#20201d]" />
|
||||
</main>
|
||||
);
|
||||
|
||||
const EmptyProjects = () => (
|
||||
<div className="mt-12 text-center">
|
||||
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
|
||||
<FolderGit2 className="size-5" />
|
||||
</span>
|
||||
<h1 className="mt-6 text-2xl font-semibold text-[#20201d]">
|
||||
Connect your first project
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[#68665e]">
|
||||
Choose a Git provider below to get started.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ProjectCard = ({
|
||||
instructions,
|
||||
name,
|
||||
projectId,
|
||||
sourceUrl,
|
||||
}: {
|
||||
instructions: string;
|
||||
name: string;
|
||||
projectId: string;
|
||||
sourceUrl: string;
|
||||
}) => (
|
||||
<Link
|
||||
className="block border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
||||
to={`/?project=${projectId}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="size-4 text-[#747168]" />
|
||||
<h3 className="text-sm font-semibold text-[#20201d]">{name}</h3>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-[#747168]">{sourceUrl}</p>
|
||||
{instructions ? (
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-[#747168]">
|
||||
<FileText className="size-3" />
|
||||
<span className="truncate">Context configured</span>
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
|
||||
const ContextEditor = ({
|
||||
projectId,
|
||||
}: {
|
||||
readonly projectId: Id<"projects">;
|
||||
}) => {
|
||||
const instructions = useQuery(api.projects.getInstructions, {
|
||||
projectId,
|
||||
});
|
||||
const updateInstructions = useMutation(api.projects.updateInstructions);
|
||||
const [draft, setDraft] = useState<string>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const currentDraft = draft ?? instructions ?? "";
|
||||
const dirty = draft !== undefined && draft !== (instructions ?? "");
|
||||
|
||||
const save = async () => {
|
||||
if (!dirty || saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateInstructions({
|
||||
instructions: draft ?? "",
|
||||
projectId,
|
||||
});
|
||||
setDraft(undefined);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<label
|
||||
className="text-xs font-medium text-[#20201d]"
|
||||
htmlFor="context-textarea"
|
||||
>
|
||||
Context
|
||||
</label>
|
||||
<textarea
|
||||
className="mt-1 h-24 w-full resize-y border border-[#c9c5b9] bg-[#fffefa] p-2 text-xs outline-none focus:border-[#55564e]"
|
||||
id="context-textarea"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder="Project-specific instructions for agents..."
|
||||
value={currentDraft}
|
||||
/>
|
||||
{dirty ? (
|
||||
<Button
|
||||
className="mt-1 h-8 text-xs"
|
||||
disabled={saving}
|
||||
onClick={save}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{saving ? <LoaderCircle className="size-3 animate-spin" /> : null}
|
||||
{saving ? "Saving" : "Save context"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PuterConnectForm = ({
|
||||
onConnected,
|
||||
}: {
|
||||
readonly onConnected: () => void;
|
||||
}) => {
|
||||
const connectGitea = useAction(connectGiteaRef);
|
||||
const [token, setToken] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!token.trim() || pending) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await connectGitea({
|
||||
token: token.trim(),
|
||||
username: username.trim() || undefined,
|
||||
});
|
||||
setToken("");
|
||||
setUsername("");
|
||||
onConnected();
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error ? caughtError.message : String(caughtError)
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="mt-4 space-y-3" onSubmit={submit}>
|
||||
<p className="text-xs text-[#68665e]">
|
||||
Connect your Puter Git personal access token. The Puter Git instance is
|
||||
at <code className="text-[#20201d]">git.openputer.com</code>.
|
||||
</p>
|
||||
<input
|
||||
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="Puter username (optional)"
|
||||
value={username}
|
||||
/>
|
||||
<input
|
||||
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="Personal access token"
|
||||
required
|
||||
type="password"
|
||||
value={token}
|
||||
/>
|
||||
{error ? <p className="text-xs text-red-700">{error}</p> : null}
|
||||
<Button className="h-10 w-full" disabled={pending} type="submit">
|
||||
{pending ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
{pending ? "Connecting" : "Connect Puter Git"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const RepositorySelector = ({
|
||||
onProjectCreated,
|
||||
}: {
|
||||
readonly onProjectCreated: (projectId: string) => void;
|
||||
}) => {
|
||||
const repositories = useQuery(
|
||||
makeFunctionReference<
|
||||
"query",
|
||||
Record<string, never>,
|
||||
readonly {
|
||||
cloneUrl: string;
|
||||
defaultBranch: string;
|
||||
fullName: string;
|
||||
id: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
privacy: "public" | "private";
|
||||
provider: "github" | "gitea";
|
||||
webUrl: string;
|
||||
}[]
|
||||
>("gitProvisioning:listRepositories"),
|
||||
{}
|
||||
);
|
||||
const createProject = useMutation(createProjectFromRepositoryRef);
|
||||
const [pending, setPending] = useState<string>();
|
||||
|
||||
const create = async (repoId: string, name: string) => {
|
||||
setPending(repoId);
|
||||
try {
|
||||
const result = await createProject({
|
||||
gitRepositoryId: repoId as Id<"gitRepositories">,
|
||||
name,
|
||||
});
|
||||
onProjectCreated(String(result.projectId));
|
||||
} finally {
|
||||
setPending(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
if (repositories === undefined) {
|
||||
return <LoaderCircle className="size-4 animate-spin text-[#747168]" />;
|
||||
}
|
||||
if (repositories.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-[#747168]">
|
||||
No repositories found. Create one on your provider first, or import a
|
||||
public repository below.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
{repositories.map((repo) => (
|
||||
<button
|
||||
className="flex w-full items-center justify-between border border-[#d7d3c7] bg-[#fffefa] p-3 text-left transition-colors hover:bg-[#f5f3eb]"
|
||||
key={repo.id}
|
||||
onClick={() => create(repo.id, repo.name)}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#20201d]">
|
||||
{repo.fullName}
|
||||
</p>
|
||||
<p className="text-xs text-[#747168]">
|
||||
{repo.provider} - {repo.defaultBranch}
|
||||
</p>
|
||||
</div>
|
||||
{pending === repo.id ? (
|
||||
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GitHubConnectButton = () => {
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const linkGithub = async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
await authClient.linkSocial({
|
||||
callbackURL: `${window.location.origin}/projects?resume=github`,
|
||||
provider: "github",
|
||||
});
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
||||
onClick={linkGithub}
|
||||
type="button"
|
||||
>
|
||||
<GitBranch className="size-5 text-[#20201d]" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="text-sm font-semibold text-[#20201d]">GitHub</p>
|
||||
<p className="text-xs text-[#747168]">OAuth with private repo access</p>
|
||||
</div>
|
||||
{pending ? (
|
||||
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ProviderSection = () => {
|
||||
const [showPuterForm, setShowPuterForm] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<h2 className="text-sm font-semibold text-[#20201d]">Git providers</h2>
|
||||
<p className="mt-1 text-xs text-[#68665e]">
|
||||
Connect a repository to create a Project.
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<GitHubConnectButton />
|
||||
<button
|
||||
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
||||
onClick={() => setShowPuterForm((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
<Server className="size-5 text-[#20201d]" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="text-sm font-semibold text-[#20201d]">Puter Git</p>
|
||||
<p className="text-xs text-[#747168]">
|
||||
Connect with a personal access token
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{showPuterForm ? (
|
||||
<PuterConnectForm onConnected={() => setShowPuterForm(false)} />
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const RepositorySection = ({
|
||||
onProjectCreated,
|
||||
}: {
|
||||
readonly onProjectCreated: (projectId: string) => void;
|
||||
}) => (
|
||||
<section className="mt-6">
|
||||
<h2 className="text-sm font-semibold text-[#20201d]">Your repositories</h2>
|
||||
<p className="mt-1 text-xs text-[#68665e]">
|
||||
Select a repository to create a Project.
|
||||
</p>
|
||||
<RepositorySelector onProjectCreated={onProjectCreated} />
|
||||
</section>
|
||||
);
|
||||
|
||||
export const ProjectsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const projects = useQuery(api.projects.list, {});
|
||||
const connectGithubAction = useAction(connectGithubRef);
|
||||
const [resumeError, setResumeError] = useState<string>();
|
||||
|
||||
// Handle GitHub OAuth callback resume
|
||||
useEffect(() => {
|
||||
const resume = searchParams.get("resume");
|
||||
if (resume !== "github") {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
await connectGithubAction({});
|
||||
setSearchParams({}, { replace: true });
|
||||
} catch (caughtError) {
|
||||
setResumeError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "GitHub connection failed"
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [searchParams, setSearchParams, connectGithubAction]);
|
||||
|
||||
if (projects === undefined) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const hasProjects = projects.length > 0;
|
||||
const handleProjectCreated = (projectId: string) => {
|
||||
void navigate(`/?project=${projectId}`, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-svh bg-[#f2f0e7] px-5 py-8 text-[#20201d]">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Projects</h1>
|
||||
{hasProjects ? (
|
||||
<Button
|
||||
className="h-9"
|
||||
onClick={() => navigate("/")}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Home
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{resumeError ? (
|
||||
<p className="mt-4 border border-red-300 bg-red-50 p-3 text-xs text-red-700">
|
||||
{resumeError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hasProjects ? null : <EmptyProjects />}
|
||||
|
||||
{hasProjects ? (
|
||||
<section className="mt-6 grid gap-3">
|
||||
{projects.map((project) => (
|
||||
<div key={project.id}>
|
||||
<ProjectCard
|
||||
instructions={
|
||||
project.contextDocuments.find(
|
||||
(doc) => doc.kind === "readme"
|
||||
)?.content ?? ""
|
||||
}
|
||||
name={project.name}
|
||||
projectId={project.id}
|
||||
sourceUrl={project.sources[0]?.url ?? ""}
|
||||
/>
|
||||
<ContextEditor
|
||||
projectId={project.id as unknown as Id<"projects">}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<RepositorySection onProjectCreated={handleProjectCreated} />
|
||||
<ProviderSection />
|
||||
|
||||
<section className="mt-8">
|
||||
<p className="text-xs text-[#68665e]">
|
||||
Or import a public repository:
|
||||
</p>
|
||||
<Link
|
||||
className="mt-2 inline-flex items-center gap-2 text-sm text-[#20201d] underline"
|
||||
to="/?import=public"
|
||||
>
|
||||
Import public Git URL
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,6 @@ export const ProjectSettingsPanel = ({
|
||||
readonly onClose: () => void;
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => {
|
||||
const [serverUrl, setServerUrl] = useState("https://git.openputer.com");
|
||||
const [username, setUsername] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const handleClearOperationError = () => workspace.clearOperationError();
|
||||
@@ -59,13 +58,7 @@ export const ProjectSettingsPanel = ({
|
||||
</div>
|
||||
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
|
||||
<input
|
||||
aria-label="Gitea server URL"
|
||||
className="h-9 w-full border px-2 text-xs"
|
||||
onChange={(event) => setServerUrl(event.target.value)}
|
||||
value={serverUrl}
|
||||
/>
|
||||
<input
|
||||
aria-label="Gitea username"
|
||||
aria-label="Puter Git username"
|
||||
className="h-9 w-full border px-2 text-xs"
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="Username (optional)"
|
||||
@@ -85,7 +78,6 @@ export const ProjectSettingsPanel = ({
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void workspace.connectGitea({
|
||||
serverUrl,
|
||||
token,
|
||||
username: username || undefined,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
@@ -96,7 +97,7 @@ const projectGitConnectionRef = makeFunctionReference<
|
||||
>("gitConnectionData:getForProject");
|
||||
const connectGiteaRef = makeFunctionReference<
|
||||
"action",
|
||||
{ serverUrl: string; token: string; username?: string },
|
||||
{ token: string; username?: string },
|
||||
{ connectionId: Id<"gitConnections"> }
|
||||
>("gitConnections:connectGitea");
|
||||
const connectGithubRef = makeFunctionReference<
|
||||
@@ -118,6 +119,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
||||
);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const agent = useOrganizationChatAgent(organization);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
const [repository, setRepository] = useState("");
|
||||
@@ -125,11 +127,17 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
||||
const [error, setError] = useState<Error>();
|
||||
const [operationError, setOperationError] = useState<Error>();
|
||||
|
||||
const projectParam = searchParams.get("project");
|
||||
const paramProject = projectParam
|
||||
? (projectParam as unknown as Id<"projects">)
|
||||
: null;
|
||||
const selectedProjectStillExists = projects?.some(
|
||||
(project) => project.id === (selectedProjectId as unknown as string)
|
||||
(project) =>
|
||||
project.id === (selectedProjectId as unknown as string) ||
|
||||
project.id === (paramProject as unknown as string)
|
||||
);
|
||||
const activeProjectId = selectedProjectStillExists
|
||||
? selectedProjectId
|
||||
? (selectedProjectId ?? paramProject)
|
||||
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
|
||||
const works = useQuery(
|
||||
workListRef,
|
||||
@@ -202,11 +210,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
||||
});
|
||||
};
|
||||
|
||||
const connectGitea = (input: {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
username?: string;
|
||||
}) =>
|
||||
const connectGitea = (input: { token: string; username?: string }) =>
|
||||
runOperation(async () => {
|
||||
const result = await connectGiteaAction(input);
|
||||
await attachConnection(result.connectionId);
|
||||
@@ -228,8 +232,8 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
||||
designVersion: number
|
||||
) => approveDesignMutation({ definitionVersion, designVersion, workId }),
|
||||
authorizeGithub: () =>
|
||||
authClient.signIn.social({
|
||||
callbackURL: window.location.href,
|
||||
authClient.linkSocial({
|
||||
callbackURL: `${window.location.origin}/projects?resume=github`,
|
||||
provider: "github",
|
||||
}),
|
||||
cancelExecution: (runId: Id<"workRuns">) =>
|
||||
|
||||
@@ -95,7 +95,6 @@ export interface WorkspaceState {
|
||||
readonly cancelSimulation: (runId: Id<"workRuns">) => Promise<unknown>;
|
||||
readonly clearOperationError: () => void;
|
||||
readonly connectGitea: (input: {
|
||||
readonly serverUrl: string;
|
||||
readonly token: string;
|
||||
readonly username?: string;
|
||||
}) => Promise<void>;
|
||||
|
||||
@@ -6,5 +6,8 @@ export default [
|
||||
route("login", "./routes/auth/login/page.tsx"),
|
||||
route("signup", "./routes/auth/signup/page.tsx"),
|
||||
]),
|
||||
layout("./routes/app/layout.tsx", [index("./routes/app/workspace/page.tsx")]),
|
||||
layout("./routes/app/layout.tsx", [
|
||||
index("./routes/app/workspace/page.tsx"),
|
||||
route("projects", "./routes/app/projects/page.tsx"),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Outlet } from "react-router";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import { useQuery } from "convex/react";
|
||||
import { useEffect } from "react";
|
||||
import { Outlet, useNavigate } from "react-router";
|
||||
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
import { requireAuthToken } from "@/lib/auth.server";
|
||||
|
||||
import type { Route } from "./+types/layout";
|
||||
@@ -8,5 +12,24 @@ export const loader = ({ request }: Route.LoaderArgs) =>
|
||||
requireAuthToken(request);
|
||||
|
||||
export default function AppLayout() {
|
||||
const organization = usePersonalOrganization();
|
||||
const navigate = useNavigate();
|
||||
const projects = useQuery(
|
||||
api.projects.list,
|
||||
organization.organizationId ? {} : "skip"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
projects !== undefined &&
|
||||
projects.length === 0 &&
|
||||
window.location.pathname === "/" &&
|
||||
!window.location.search.includes("resume=") &&
|
||||
!window.location.search.includes("project=")
|
||||
) {
|
||||
void navigate("/projects", { replace: true });
|
||||
}
|
||||
}, [projects, navigate]);
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
5
apps/web/src/routes/app/projects/page.tsx
Normal file
5
apps/web/src/routes/app/projects/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ProjectsPage } from "@/components/projects/projects-page";
|
||||
|
||||
export default function ProjectsRoute() {
|
||||
return <ProjectsPage />;
|
||||
}
|
||||
128
docs/git-provider-setup.md
Normal file
128
docs/git-provider-setup.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Git Provider Setup
|
||||
|
||||
This document covers manual setup for GitHub OAuth, Puter Git (Gitea), webhooks, and Convex environment variables.
|
||||
|
||||
> **Never place admin or user tokens in checked-in `.env` files or browser variables.** All secrets must be Convex environment variables set via `npx convex env set`.
|
||||
|
||||
## GitHub OAuth Application
|
||||
|
||||
1. Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App.
|
||||
2. Set the application name (e.g., "Zopu").
|
||||
3. Set the homepage URL to your `SITE_URL` (e.g., `https://zopu.cheaptricks.puter.wtf`).
|
||||
4. Set the callback URL to `<SITE_URL>/api/auth/callback/github`.
|
||||
5. Generate a client secret.
|
||||
6. Set Convex environment variables:
|
||||
```
|
||||
npx convex env set GITHUB_CLIENT_ID <your-client-id>
|
||||
npx convex env set GITHUB_CLIENT_SECRET <your-client-secret>
|
||||
```
|
||||
|
||||
### Requested scopes
|
||||
|
||||
Zopu requests `repo` and `read:org`. The `repo` scope grants access to private repositories. `read:org` allows listing organization repositories.
|
||||
|
||||
## GitHub Webhook (manual setup for OAuth-based version)
|
||||
|
||||
1. Go to the GitHub repository Settings > Webhooks > Add webhook.
|
||||
2. Payload URL: `<CONVEX_SITE_URL>/api/git/webhooks/github`
|
||||
3. Content type: `application/json`
|
||||
4. Secret: generate a strong random string and set it as:
|
||||
```
|
||||
npx convex env set GITHUB_WEBHOOK_SECRET <your-webhook-secret>
|
||||
```
|
||||
5. Select individual events:
|
||||
- Push
|
||||
- Repository (created, deleted, transferred, renamed, visibility)
|
||||
- Delete
|
||||
- Create
|
||||
- Public
|
||||
- Fork
|
||||
6. Do not select "Send me everything."
|
||||
|
||||
## Puter Git (Gitea) Admin Token
|
||||
|
||||
1. As a Gitea admin, go to Settings > Applications > Generate New Token.
|
||||
2. Select scopes: `write:admin`, `write:organization`, `write:repository`.
|
||||
3. Set the token:
|
||||
```
|
||||
npx convex env set PUTER_GIT_ADMIN_TOKEN <your-admin-token>
|
||||
```
|
||||
|
||||
This token is used only for platform administration (user creation, organization creation, repository creation, migration). It is never used for end-user operations.
|
||||
|
||||
## Puter Git Webhook
|
||||
|
||||
After a repository is created or migrated, Zopu ensures a webhook is configured automatically. If manual setup is needed:
|
||||
|
||||
1. Go to the Gitea repository Settings > Webhooks > Add Webhook > Gitea.
|
||||
2. Target URL: `<CONVEX_SITE_URL>/api/git/webhooks/puter`
|
||||
3. HTTP method: POST
|
||||
4. Content-Type: `application/json`
|
||||
5. Secret: generate a strong random string and set it as:
|
||||
```
|
||||
npx convex env set GITEA_WEBHOOK_SECRET <your-webhook-secret>
|
||||
```
|
||||
6. Trigger on: Push events, Repository events.
|
||||
|
||||
## Credential Encryption Key
|
||||
|
||||
Generate a 32-byte encryption key for AES-GCM credential encryption:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32 | base64 | tr -d '\n' | pbcopy
|
||||
```
|
||||
|
||||
Set it as:
|
||||
|
||||
```
|
||||
npx convex env set GIT_CREDENTIAL_ENCRYPTION_KEY <base64url-encoded-32-bytes>
|
||||
```
|
||||
|
||||
The key must be exactly 32 bytes when base64url-decoded.
|
||||
|
||||
## Same-Origin Proxy for Better Auth Callbacks
|
||||
|
||||
Better Auth requires the callback URL to be on the same origin as `SITE_URL`. If your Convex deployment uses a different origin:
|
||||
|
||||
1. Configure a reverse proxy (e.g., Caddy) to forward `/api/auth/*` to the Convex deployment.
|
||||
2. Set `SITE_URL` to the proxy origin.
|
||||
3. Set `CONVEX_SITE_URL` to the Convex deployment URL.
|
||||
|
||||
## Validation Procedures
|
||||
|
||||
### Verify a Puter PAT connection
|
||||
|
||||
1. Connect a Puter PAT through the UI.
|
||||
2. Verify the connection state shows `active`.
|
||||
3. List private repositories to confirm token validity.
|
||||
|
||||
### Verify GitHub OAuth
|
||||
|
||||
1. Click "Connect GitHub" in the UI.
|
||||
2. Complete the GitHub OAuth flow.
|
||||
3. Verify the connection state shows `active`.
|
||||
|
||||
### Reconnection
|
||||
|
||||
If a token is expired or revoked:
|
||||
|
||||
1. The connection state shows `reauth-required`.
|
||||
2. Reconnect the provider through the UI.
|
||||
3. The old connection state is updated to `active`.
|
||||
4. Projects, repositories, Work, and artifacts are not deleted.
|
||||
|
||||
## Environment Variables Summary
|
||||
|
||||
| Variable | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `SITE_URL` | Yes | Public-facing URL |
|
||||
| `CONVEX_SITE_URL` | Yes | Convex deployment URL (may differ from SITE_URL with proxy) |
|
||||
| `GIT_CREDENTIAL_ENCRYPTION_KEY` | Yes | Base64url-encoded 32-byte AES-GCM key |
|
||||
| `PUTER_GIT_ADMIN_TOKEN` | Yes | Gitea admin token for provisioning |
|
||||
| `GITHUB_CLIENT_ID` | For GitHub | OAuth app client ID |
|
||||
| `GITHUB_CLIENT_SECRET` | For GitHub | OAuth app client secret |
|
||||
| `GITHUB_WEBHOOK_SECRET` | For GitHub webhooks | HMAC verification secret |
|
||||
| `GITEA_WEBHOOK_SECRET` | For Puter webhooks | HMAC verification secret |
|
||||
| `FLUE_DB_TOKEN` | Yes | Private agent backend token |
|
||||
| `FLUE_URL` | Optional | Private agent backend URL |
|
||||
| `NATIVE_APP_URL` | Optional | Native app deep-link scheme |
|
||||
52
package.json
52
package.json
@@ -1,6 +1,53 @@
|
||||
{
|
||||
"name": "code",
|
||||
"private": true,
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"apps/web",
|
||||
"packages/agents",
|
||||
"packages/auth",
|
||||
"packages/backend",
|
||||
"packages/config",
|
||||
"packages/env",
|
||||
"packages/primitives",
|
||||
"packages/ui"
|
||||
],
|
||||
"catalog": {
|
||||
"@rivet-dev/agentos": "0.2.14",
|
||||
"@rivet-dev/agentos-core": "0.2.14",
|
||||
"@effect/platform-bun": "4.0.0-beta.99",
|
||||
"dotenv": "17.4.2",
|
||||
"zod": "4.4.3",
|
||||
"lucide-react": "1.27.0",
|
||||
"next-themes": "0.4.6",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"sonner": "2.0.7",
|
||||
"convex": "1.42.3",
|
||||
"better-auth": "1.6.15",
|
||||
"@convex-dev/better-auth": "0.12.5",
|
||||
"@tanstack/react-form": "1.33.2",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"@better-auth/expo": "1.6.15",
|
||||
"effect": "4.0.0-beta.99",
|
||||
"typescript": "7.0.2",
|
||||
"@types/bun": "1.3.14",
|
||||
"heroui-native": "1.0.6",
|
||||
"vite": "8.1.5",
|
||||
"vitest": "4.1.10",
|
||||
"convex-test": "0.0.54",
|
||||
"react-native": "0.86.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/node": "22.20.1",
|
||||
"hono": "4.12.32",
|
||||
"valibot": "1.4.2",
|
||||
"streamdown": "2.5.0",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/vite": "4.3.3"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vp run -r dev",
|
||||
@@ -41,5 +88,10 @@
|
||||
"vite-plus": "0.2.2",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"overrides": {
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"
|
||||
},
|
||||
"packageManager": "pnpm@11.17.0"
|
||||
}
|
||||
|
||||
@@ -103,6 +103,8 @@ export const executeAgentOsAttempt = async (
|
||||
const hostRepository = new HostRepositoryWorkspace();
|
||||
const prepared = await hostRepository.prepare({
|
||||
attemptId: input.attemptId,
|
||||
cloneUrl: input.repositoryUrl,
|
||||
credential: input.auth.credential,
|
||||
piHomeFiles: makePiHomeFiles({
|
||||
api: env.AGENT_MODEL_API,
|
||||
apiKeyEnvironmentVariable: "AGENT_MODEL_API_KEY",
|
||||
@@ -112,14 +114,16 @@ export const executeAgentOsAttempt = async (
|
||||
model: env.AGENT_MODEL_NAME,
|
||||
provider: env.AGENT_MODEL_PROVIDER,
|
||||
}),
|
||||
provider: input.auth.provider,
|
||||
username: input.auth.username,
|
||||
});
|
||||
events.push(
|
||||
event(
|
||||
1,
|
||||
"runtime.preparing",
|
||||
prepared.created
|
||||
? "Isolated Zopu worktree created on the execution host"
|
||||
: "Isolated Zopu worktree recreated"
|
||||
? "Repository cloned on the execution host"
|
||||
: "Repository re-cloned"
|
||||
)
|
||||
);
|
||||
const mounts = [
|
||||
@@ -128,11 +132,6 @@ export const executeAgentOsAttempt = async (
|
||||
path: "/workspace/repository",
|
||||
readOnly: false,
|
||||
},
|
||||
{
|
||||
hostPath: prepared.sourceRepositoryPath,
|
||||
path: prepared.sourceRepositoryPath,
|
||||
readOnly: false,
|
||||
},
|
||||
{
|
||||
hostPath: prepared.piHomePath,
|
||||
path: "/home/zopu",
|
||||
@@ -178,9 +177,9 @@ export const executeAgentOsAttempt = async (
|
||||
|
||||
const sessionId = `pi-${input.attemptId}`;
|
||||
await vm.openSession({
|
||||
additionalDirectories: [`${prepared.sourceRepositoryPath}/.git`],
|
||||
additionalDirectories: [`${prepared.checkoutPath}/.git`],
|
||||
additionalInstructions:
|
||||
"Work only inside /workspace/repository. This is an isolated worktree of the Zopu product repository. Read AGENTS.md and the relevant product specifications before changing code. Never reveal credentials, modify the read-only base checkout, push, or open a pull request. Implement the requested change and run focused verification.",
|
||||
"Work only inside /workspace/repository. This is an isolated checkout of the project repository. Read AGENTS.md and the relevant product specifications before changing code. Never reveal credentials, push, or open a pull request. Implement the requested change and run focused verification.",
|
||||
agent: "pi",
|
||||
cwd: "/workspace/repository",
|
||||
env: piSessionEnv(env.AGENT_MODEL_API_KEY),
|
||||
|
||||
@@ -15,6 +15,13 @@ import type { PiHomeFiles } from "@code/primitives";
|
||||
|
||||
interface PrepareRepositoryInput {
|
||||
attemptId: string;
|
||||
cloneUrl: string;
|
||||
/** Provider credential for the initial clone, stripped after cloning. */
|
||||
credential?: string;
|
||||
/** Provider type: "github" or "gitea". Determines the askpass username. */
|
||||
provider?: string;
|
||||
/** Account username for Gitea provider authentication. */
|
||||
username?: string;
|
||||
piHomeFiles: PiHomeFiles;
|
||||
}
|
||||
|
||||
@@ -102,27 +109,17 @@ const pathExists = async (target: string): Promise<boolean> => {
|
||||
|
||||
export class HostRepositoryWorkspace {
|
||||
readonly #root: string;
|
||||
readonly #sourceRepositoryPath: string;
|
||||
readonly #installDependencies: boolean;
|
||||
|
||||
constructor(
|
||||
root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces",
|
||||
sourceRepositoryPath = process.env.ZOPU_SOURCE_REPOSITORY ??
|
||||
"/opt/zopu-source",
|
||||
installDependencies = true
|
||||
) {
|
||||
this.#root = root;
|
||||
this.#sourceRepositoryPath = sourceRepositoryPath;
|
||||
this.#installDependencies = installDependencies;
|
||||
}
|
||||
|
||||
async prepare(input: PrepareRepositoryInput): Promise<PreparedRepository> {
|
||||
if (!(await pathExists(path.join(this.#sourceRepositoryPath, ".git")))) {
|
||||
throw new Error(
|
||||
`Fixed Zopu source repository is unavailable at ${this.#sourceRepositoryPath}`
|
||||
);
|
||||
}
|
||||
|
||||
const identity = createHash("sha256")
|
||||
.update(input.attemptId)
|
||||
.digest("hex")
|
||||
@@ -131,42 +128,57 @@ export class HostRepositoryWorkspace {
|
||||
const checkoutPath = path.join(workspacePath, "repository");
|
||||
const toolsPath = path.join(workspacePath, "tools");
|
||||
const piHomePath = path.join(workspacePath, "home");
|
||||
const branch = `zopu/attempt-${identity}`;
|
||||
const created = !(await pathExists(path.join(checkoutPath, ".git")));
|
||||
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
if (!created) {
|
||||
requireSuccess(
|
||||
await runGit(this.#sourceRepositoryPath, [
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
checkoutPath,
|
||||
]),
|
||||
"Existing worktree removal"
|
||||
);
|
||||
}
|
||||
await rm(checkoutPath, { force: true, recursive: true });
|
||||
requireSuccess(
|
||||
await runGit(this.#sourceRepositoryPath, [
|
||||
"worktree",
|
||||
"add",
|
||||
"-B",
|
||||
branch,
|
||||
checkoutPath,
|
||||
"HEAD",
|
||||
]),
|
||||
"Zopu worktree creation"
|
||||
);
|
||||
|
||||
// Clone the user's repository. When a credential is provided, use a
|
||||
// temporary GIT_ASKPASS helper script so the token never appears in the
|
||||
// clone URL, git config, or process arguments visible in process listings.
|
||||
// The askpass script reads the credential from an env var that is only
|
||||
// set for this clone command and cleared afterward.
|
||||
const cloneEnv: Record<string, string> = {};
|
||||
if (input.credential) {
|
||||
const askpassPath = path.join(workspacePath, ".git-askpass");
|
||||
// GitHub uses x-access-token as the username for OAuth tokens;
|
||||
// Gitea uses the account's actual username.
|
||||
const gitUsername =
|
||||
input.provider === "gitea"
|
||||
? (input.username ?? "git")
|
||||
: "x-access-token";
|
||||
// eslint-disable-next-line no-template-curly-in-string -- shell variable, not JS template
|
||||
const tokenRef = "${ZOPU_GIT_TOKEN}";
|
||||
await writeFile(
|
||||
askpassPath,
|
||||
`#!/bin/sh\ncase "$1" in\nUsername*) echo "${gitUsername}";;\nPassword*) echo "${tokenRef}";;\nesac\n`
|
||||
);
|
||||
await chmod(askpassPath, 0o700);
|
||||
cloneEnv.GIT_ASKPASS = askpassPath;
|
||||
cloneEnv.ZOPU_GIT_TOKEN = input.credential;
|
||||
}
|
||||
|
||||
try {
|
||||
requireSuccess(
|
||||
await runProcess(
|
||||
"git",
|
||||
["clone", "--no-tags", input.cloneUrl, checkoutPath],
|
||||
workspacePath,
|
||||
cloneEnv
|
||||
),
|
||||
"Repository clone"
|
||||
);
|
||||
} finally {
|
||||
// The credential env var is never written to disk or git config.
|
||||
// The askpass script is cleaned up with the workspace.
|
||||
cloneEnv.ZOPU_GIT_TOKEN = "";
|
||||
delete process.env.ZOPU_GIT_TOKEN;
|
||||
}
|
||||
|
||||
const bunExecutable =
|
||||
process.env.BUN_EXECUTABLE ??
|
||||
execFileSync("which", ["bun"], { encoding: "utf-8" }).trim();
|
||||
|
||||
const sourceEnvPath = path.join(this.#sourceRepositoryPath, ".env");
|
||||
if (await pathExists(sourceEnvPath)) {
|
||||
await copyFile(sourceEnvPath, path.join(checkoutPath, ".env"));
|
||||
}
|
||||
if (this.#installDependencies) {
|
||||
requireSuccess(
|
||||
await runProcess(
|
||||
@@ -204,7 +216,7 @@ export class HostRepositoryWorkspace {
|
||||
checkoutPath,
|
||||
created,
|
||||
piHomePath,
|
||||
sourceRepositoryPath: this.#sourceRepositoryPath,
|
||||
sourceRepositoryPath: checkoutPath,
|
||||
toolsPath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
243
packages/backend/convex/gitConnectionHealth.ts
Normal file
243
packages/backend/convex/gitConnectionHealth.ts
Normal file
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
1538
packages/backend/convex/gitProvisioning.ts
Normal file
1538
packages/backend/convex/gitProvisioning.ts
Normal file
File diff suppressed because it is too large
Load Diff
254
packages/backend/convex/gitWebhooks.ts
Normal file
254
packages/backend/convex/gitWebhooks.ts
Normal file
@@ -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),
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
3
packages/env/src/convex.ts
vendored
3
packages/env/src/convex.ts
vendored
@@ -11,10 +11,13 @@ export const env = createEnv({
|
||||
FLUE_URL: z.url().optional(),
|
||||
GITEA_TOKEN: z.string().min(1).optional(),
|
||||
GITEA_URL: z.url().default("https://git.openputer.com"),
|
||||
GITEA_WEBHOOK_SECRET: z.string().min(1).optional(),
|
||||
GITHUB_CLIENT_ID: z.string().min(1).optional(),
|
||||
GITHUB_CLIENT_SECRET: z.string().min(1).optional(),
|
||||
GITHUB_WEBHOOK_SECRET: z.string().min(1).optional(),
|
||||
GIT_CREDENTIAL_ENCRYPTION_KEY: z.string().min(43).optional(),
|
||||
NATIVE_APP_URL: z.string().min(1).default("code://"),
|
||||
PUTER_GIT_ADMIN_TOKEN: z.string().min(1).optional(),
|
||||
SITE_URL: z.url(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"./agent-os": "./src/agent-os.ts",
|
||||
"./execution-runtime": "./src/execution-runtime.ts",
|
||||
"./git": "./src/git.ts",
|
||||
"./git-provider": "./src/git-provider.ts",
|
||||
"./git-provisioning": "./src/git-provisioning.ts",
|
||||
"./git-webhook": "./src/git-webhook.ts",
|
||||
"./git-local-runtime": "./src/git-local-runtime.ts",
|
||||
"./git-remote-runtime": "./src/git-remote-runtime.ts",
|
||||
"./project": "./src/project.ts",
|
||||
@@ -22,7 +25,8 @@
|
||||
"./work-design": "./src/work-design.ts",
|
||||
"./work-lifecycle": "./src/work-lifecycle.ts",
|
||||
"./resolver": "./src/resolver.ts",
|
||||
"./harness-runtime": "./src/harness-runtime.ts"
|
||||
"./harness-runtime": "./src/harness-runtime.ts",
|
||||
"./host-repository": "./src/host-repository.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit",
|
||||
@@ -30,8 +34,8 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentos-software/pi": "0.2.7",
|
||||
"@agentos-software/git": "0.3.3",
|
||||
"@agentos-software/pi": "0.2.7",
|
||||
"@rivet-dev/agentos": "0.2.14",
|
||||
"effect": "catalog:"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/* eslint-disable max-classes-per-file -- execution boundary errors live with their schemas. */
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
import { GitProvider } from "./git-provider";
|
||||
|
||||
export type { GitProvider } from "./git-provider";
|
||||
|
||||
const Text = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
@@ -21,9 +25,6 @@ const HttpUrl = Schema.String.check(
|
||||
)
|
||||
);
|
||||
|
||||
export const GitProvider = Schema.Literals(["github", "gitea"]);
|
||||
export type GitProvider = typeof GitProvider.Type;
|
||||
|
||||
export const GitCredentialKind = Schema.Literals(["oauth", "token"]);
|
||||
export type GitCredentialKind = typeof GitCredentialKind.Type;
|
||||
|
||||
|
||||
229
packages/primitives/src/git-provider.test.ts
Normal file
229
packages/primitives/src/git-provider.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
canonicalServerUrl,
|
||||
decideConnectionHostCompatibility,
|
||||
isCredentialFresh,
|
||||
isOperational,
|
||||
isProviderHostCompatible,
|
||||
normalizeRepositoryUrl,
|
||||
providerForHost,
|
||||
} from "./git-provider";
|
||||
import {
|
||||
canSafelyReplaceRepository,
|
||||
idempotencyKeyFor,
|
||||
isTerminalMigration,
|
||||
validateProjectInstructions,
|
||||
validatePuterOrgName,
|
||||
validatePuterUsername,
|
||||
validateRepositoryName,
|
||||
} from "./git-provisioning";
|
||||
import { isProcessedEvent } from "./git-webhook";
|
||||
|
||||
describe("git-provider normalization", () => {
|
||||
test("normalizes a standard HTTPS GitHub URL", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
normalizeRepositoryUrl("https://github.com/owner/repo.git")
|
||||
);
|
||||
expect(result.owner).toBe("owner");
|
||||
expect(result.name).toBe("repo");
|
||||
expect(result.fullName).toBe("owner/repo");
|
||||
expect(result.host).toBe("github.com");
|
||||
});
|
||||
|
||||
test("normalizes an SSH git URL", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
normalizeRepositoryUrl("git@git.openputer.com:zopu/project.git")
|
||||
);
|
||||
expect(result.owner).toBe("zopu");
|
||||
expect(result.name).toBe("project");
|
||||
expect(result.host).toBe("git.openputer.com");
|
||||
});
|
||||
|
||||
test("rejects URLs with embedded credentials", async () => {
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
normalizeRepositoryUrl("https://user:pass@github.com/owner/repo")
|
||||
)
|
||||
).rejects.toMatchObject({ reason: "InvalidInput" });
|
||||
});
|
||||
|
||||
test("rejects URLs without owner/name path", async () => {
|
||||
await expect(
|
||||
Effect.runPromise(normalizeRepositoryUrl("https://github.com"))
|
||||
).rejects.toMatchObject({ reason: "InvalidInput" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider/host compatibility", () => {
|
||||
test("recognizes github.com as github", () => {
|
||||
expect(providerForHost("github.com")).toBe("github");
|
||||
});
|
||||
|
||||
test("recognizes git.openputer.com as gitea", () => {
|
||||
expect(providerForHost("git.openputer.com")).toBe("gitea");
|
||||
});
|
||||
|
||||
test("returns null for unknown hosts", () => {
|
||||
expect(providerForHost("example.com")).toBeNull();
|
||||
});
|
||||
|
||||
test("allows same-provider connections", () => {
|
||||
expect(
|
||||
isProviderHostCompatible(
|
||||
"gitea",
|
||||
"https://git.openputer.com",
|
||||
"git.openputer.com"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects cross-provider connections", async () => {
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
decideConnectionHostCompatibility(
|
||||
"gitea",
|
||||
"https://git.openputer.com",
|
||||
"github.com"
|
||||
)
|
||||
)
|
||||
).rejects.toMatchObject({ reason: "InvalidInput" });
|
||||
});
|
||||
|
||||
test("returns canonical server URLs", () => {
|
||||
expect(canonicalServerUrl("github")).toBe("https://github.com");
|
||||
expect(canonicalServerUrl("gitea")).toBe("https://git.openputer.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("connection state decisions", () => {
|
||||
test("only active is operational", () => {
|
||||
expect(isOperational("active")).toBe(true);
|
||||
expect(isOperational("pending-auth")).toBe(false);
|
||||
expect(isOperational("reauth-required")).toBe(false);
|
||||
});
|
||||
|
||||
test("freshness window is 15 minutes", () => {
|
||||
const now = Date.now();
|
||||
expect(isCredentialFresh(now - 14 * 60 * 1000, now)).toBe(true);
|
||||
expect(isCredentialFresh(now - 16 * 60 * 1000, now)).toBe(false);
|
||||
expect(isCredentialFresh(undefined, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provisioning validation", () => {
|
||||
test("validates a correct Puter username", async () => {
|
||||
const result = await Effect.runPromise(validatePuterUsername("zopu-user"));
|
||||
expect(result).toBe("zopu-user");
|
||||
});
|
||||
|
||||
test("rejects invalid Puter username", async () => {
|
||||
await expect(
|
||||
Effect.runPromise(validatePuterUsername("-invalid"))
|
||||
).rejects.toMatchObject({ reason: "InvalidInput" });
|
||||
});
|
||||
|
||||
test("validates a correct repository name", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
validateRepositoryName("my-repo.v2")
|
||||
);
|
||||
expect(result).toBe("my-repo.v2");
|
||||
});
|
||||
|
||||
test("validates a Puter org name", async () => {
|
||||
const result = await Effect.runPromise(validatePuterOrgName("zopu-org"));
|
||||
expect(result).toBe("zopu-org");
|
||||
});
|
||||
|
||||
test("validates project instructions within size limit", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
validateProjectInstructions("# Context\nSome notes.")
|
||||
);
|
||||
expect(result).toContain("# Context");
|
||||
});
|
||||
|
||||
test("rejects project instructions exceeding limit", async () => {
|
||||
const long = "x".repeat(10_001);
|
||||
await expect(
|
||||
Effect.runPromise(validateProjectInstructions(long))
|
||||
).rejects.toMatchObject({ reason: "InvalidInput" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("idempotency and safe replacement", () => {
|
||||
test("idempotency keys are deterministic", () => {
|
||||
const key1 = idempotencyKeyFor({
|
||||
_tag: "CreatePuterUser",
|
||||
betterAuthEmail: "a@b.com",
|
||||
betterAuthName: "Test",
|
||||
organizationId: "org-1",
|
||||
providerAccountId: "acct-1",
|
||||
puterUsername: "zopu",
|
||||
});
|
||||
const key2 = idempotencyKeyFor({
|
||||
_tag: "CreatePuterUser",
|
||||
betterAuthEmail: "a@b.com",
|
||||
betterAuthName: "Test",
|
||||
organizationId: "org-1",
|
||||
providerAccountId: "acct-1",
|
||||
puterUsername: "zopu",
|
||||
});
|
||||
expect(key1).toBe(key2);
|
||||
expect(key1).toContain("puter-user:acct-1:zopu");
|
||||
});
|
||||
|
||||
test("safe replacement allows same provider/owner/name", () => {
|
||||
expect(
|
||||
canSafelyReplaceRepository(
|
||||
{ name: "repo", owner: "owner", provider: "gitea" },
|
||||
{ name: "repo", owner: "owner", provider: "gitea" }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("safe replacement rejects different owner", () => {
|
||||
expect(
|
||||
canSafelyReplaceRepository(
|
||||
{ name: "repo", owner: "owner", provider: "gitea" },
|
||||
{ name: "repo", owner: "other", provider: "gitea" }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("safe replacement allows undefined existing", () => {
|
||||
expect(
|
||||
canSafelyReplaceRepository(undefined, {
|
||||
name: "repo",
|
||||
owner: "owner",
|
||||
provider: "gitea",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("terminal migration states are correctly identified", () => {
|
||||
expect(isTerminalMigration("succeeded")).toBe(true);
|
||||
expect(isTerminalMigration("failed")).toBe(true);
|
||||
expect(isTerminalMigration("cancelled")).toBe(true);
|
||||
expect(isTerminalMigration("queued")).toBe(false);
|
||||
expect(isTerminalMigration("running")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webhook event filtering", () => {
|
||||
test("processes push events", () => {
|
||||
expect(isProcessedEvent("push")).toBe(true);
|
||||
});
|
||||
|
||||
test("processes repository events", () => {
|
||||
expect(isProcessedEvent("repository")).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores ping events", () => {
|
||||
expect(isProcessedEvent("ping")).toBe(false);
|
||||
});
|
||||
|
||||
test("ignores release events", () => {
|
||||
expect(isProcessedEvent("release")).toBe(false);
|
||||
});
|
||||
});
|
||||
393
packages/primitives/src/git-provider.ts
Normal file
393
packages/primitives/src/git-provider.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
/* eslint-disable max-classes-per-file -- normalized git-provider schemas and errors form one domain module. */
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Text = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
})
|
||||
);
|
||||
|
||||
const HttpUrl = Schema.String.check(
|
||||
Schema.makeFilter(
|
||||
(value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ expected: "an HTTP URL" }
|
||||
)
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider and host identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GitProvider = Schema.Literals(["github", "gitea"]);
|
||||
export type GitProvider = typeof GitProvider.Type;
|
||||
|
||||
export const CredentialKind = Schema.Literals(["oauth", "token"]);
|
||||
export type CredentialKind = typeof CredentialKind.Type;
|
||||
|
||||
/** Known canonical server URLs for each provider. */
|
||||
export const GITHUB_SERVER_URL = "https://github.com";
|
||||
export const PUTER_GIT_SERVER_URL = "https://git.openputer.com";
|
||||
|
||||
export const canonicalServerUrl = (provider: GitProvider): string =>
|
||||
provider === "github" ? GITHUB_SERVER_URL : PUTER_GIT_SERVER_URL;
|
||||
|
||||
/**
|
||||
* Returns the product-known provider for a host, or null for unrecognized
|
||||
* hosts. GitHub owns `github.com` and `.githost.com`; Puter Git owns
|
||||
* `git.openputer.com`.
|
||||
*/
|
||||
export const providerForHost = (host: string): GitProvider | null => {
|
||||
const normalized = host.toLowerCase();
|
||||
if (normalized === "github.com" || normalized.endsWith(".githost.com")) {
|
||||
return "github";
|
||||
}
|
||||
if (normalized === "git.openputer.com") {
|
||||
return "gitea";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Whether a host is served by a product-known provider. */
|
||||
export const isKnownHost = (host: string): boolean =>
|
||||
providerForHost(host) !== null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection states
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ConnectionState = Schema.Literals([
|
||||
"pending-auth",
|
||||
"active",
|
||||
"reauth-required",
|
||||
"revoked",
|
||||
"unavailable",
|
||||
]);
|
||||
export type ConnectionState = typeof ConnectionState.Type;
|
||||
|
||||
/**
|
||||
* Whether a connection state allows operations that use its credential.
|
||||
* Only `active` is permitted; every other state requires resolution first.
|
||||
*/
|
||||
export const isOperational = (state: ConnectionState): boolean =>
|
||||
state === "active";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalized errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GitProviderErrorReason = Schema.Literals([
|
||||
"InvalidInput",
|
||||
"Unauthorized",
|
||||
"Forbidden",
|
||||
"NotFound",
|
||||
"Conflict",
|
||||
"RateLimited",
|
||||
"Unreachable",
|
||||
"InvalidResponse",
|
||||
]);
|
||||
export type GitProviderErrorReason = typeof GitProviderErrorReason.Type;
|
||||
|
||||
export class GitProviderError extends Schema.TaggedErrorClass<GitProviderError>()(
|
||||
"GitProviderError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: GitProviderErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalized provider account
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ProviderAccount = Schema.Struct({
|
||||
externalAccountId: Text,
|
||||
externalEmail: Schema.UndefinedOr(Text),
|
||||
externalUsername: Text,
|
||||
provider: GitProvider,
|
||||
serverUrl: HttpUrl,
|
||||
});
|
||||
export type ProviderAccount = typeof ProviderAccount.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalized provider organization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ProviderOrganization = Schema.Struct({
|
||||
displayName: Schema.UndefinedOr(Text),
|
||||
externalId: Text,
|
||||
externalSlug: Text,
|
||||
provider: GitProvider,
|
||||
serverUrl: HttpUrl,
|
||||
visibility: Schema.Literals(["public", "private"]),
|
||||
});
|
||||
export type ProviderOrganization = typeof ProviderOrganization.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repository permissions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RepositoryPermissions = Schema.Struct({
|
||||
admin: Schema.Boolean,
|
||||
maintain: Schema.Boolean,
|
||||
pull: Schema.Boolean,
|
||||
push: Schema.Boolean,
|
||||
triage: Schema.Boolean,
|
||||
});
|
||||
export type RepositoryPermissions = typeof RepositoryPermissions.Type;
|
||||
|
||||
export const FULL_PERMISSIONS: RepositoryPermissions = {
|
||||
admin: true,
|
||||
maintain: true,
|
||||
pull: true,
|
||||
push: true,
|
||||
triage: true,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalized repository
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RepositoryPrivacy = Schema.Literals(["public", "private"]);
|
||||
export type RepositoryPrivacy = typeof RepositoryPrivacy.Type;
|
||||
|
||||
export const Repository = Schema.Struct({
|
||||
cloneUrl: HttpUrl,
|
||||
defaultBranch: Text,
|
||||
fullName: Text,
|
||||
lfsCapability: Schema.Literals(["unknown", "supported", "unsupported"]),
|
||||
name: Text,
|
||||
owner: Text,
|
||||
permissions: RepositoryPermissions,
|
||||
privacy: RepositoryPrivacy,
|
||||
provider: GitProvider,
|
||||
providerOrganizationExternalId: Schema.UndefinedOr(Text),
|
||||
providerRepositoryId: Text,
|
||||
serverUrl: HttpUrl,
|
||||
webUrl: HttpUrl,
|
||||
});
|
||||
export type Repository = typeof Repository.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection health
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ConnectionHealthResult = Schema.Struct({
|
||||
checkedAt: Schema.Number,
|
||||
normalizedError: Schema.UndefinedOr(Text),
|
||||
state: ConnectionState,
|
||||
});
|
||||
export type ConnectionHealthResult = typeof ConnectionHealthResult.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repository URL normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const NormalizedRepositoryUrl = Schema.Struct({
|
||||
fullName: Text,
|
||||
host: Text,
|
||||
name: Text,
|
||||
normalizedUrl: Text,
|
||||
owner: Text,
|
||||
repositoryPath: Text,
|
||||
webUrl: Text,
|
||||
});
|
||||
export type NormalizedRepositoryUrl = typeof NormalizedRepositoryUrl.Type;
|
||||
|
||||
const invalidInput = (message: string) =>
|
||||
new GitProviderError({ message, reason: "InvalidInput" });
|
||||
|
||||
/**
|
||||
* Normalize a raw Git repository URL (SSH or HTTP) into its canonical
|
||||
* `owner/name` parts, host, and web URL. Strips credentials, `.git`
|
||||
* suffixes, and fragments.
|
||||
*/
|
||||
export const normalizeRepositoryUrl = (
|
||||
rawUrl: string
|
||||
): Effect.Effect<NormalizedRepositoryUrl, GitProviderError> =>
|
||||
Effect.gen(function* normalize() {
|
||||
const parsed = (() => {
|
||||
// Support git@host:owner/name.git SSH URLs.
|
||||
const scpMatch = /^git@(?<host>[^:]+):(?<path>.+?)(?:\.git)?$/u.exec(
|
||||
rawUrl
|
||||
);
|
||||
if (scpMatch) {
|
||||
if (!scpMatch.groups) {
|
||||
return null;
|
||||
}
|
||||
const { host, path: repositoryPath } = scpMatch.groups;
|
||||
return new URL(`https://${host}/${repositoryPath}`);
|
||||
}
|
||||
try {
|
||||
return new URL(rawUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!parsed) {
|
||||
return yield* Effect.fail(invalidInput("Invalid repository URL"));
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return yield* Effect.fail(
|
||||
invalidInput("Repository URL must use http(s) or git SSH protocol")
|
||||
);
|
||||
}
|
||||
|
||||
// Reject embedded credentials in the URL.
|
||||
if (parsed.username || parsed.password) {
|
||||
return yield* Effect.fail(
|
||||
invalidInput("Repository URL must not contain credentials")
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.search || parsed.hash) {
|
||||
return yield* Effect.fail(
|
||||
invalidInput("Repository URL must not contain query or fragment")
|
||||
);
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
let repositoryPath = parsed.pathname
|
||||
.replace(/^\/+/u, "")
|
||||
.replace(/\/+$/u, "");
|
||||
if (repositoryPath.endsWith(".git")) {
|
||||
repositoryPath = repositoryPath.slice(0, -4);
|
||||
}
|
||||
if (repositoryPath.length === 0) {
|
||||
return yield* Effect.fail(
|
||||
invalidInput("Repository URL must include an owner/name path")
|
||||
);
|
||||
}
|
||||
|
||||
const segments = repositoryPath.split("/").filter((s) => s.length > 0);
|
||||
if (segments.length < 2) {
|
||||
return yield* Effect.fail(
|
||||
invalidInput("Repository URL must include an owner/name path")
|
||||
);
|
||||
}
|
||||
|
||||
const owner = segments.slice(0, -1).join("/");
|
||||
const name = segments.at(-1) ?? "";
|
||||
if (!name) {
|
||||
return yield* Effect.fail(
|
||||
invalidInput("Repository URL must include a repository name")
|
||||
);
|
||||
}
|
||||
const fullName = `${owner}/${name}`;
|
||||
const normalizedUrl = `${parsed.protocol}//${host}/${repositoryPath}`;
|
||||
const webUrl = normalizedUrl;
|
||||
|
||||
return {
|
||||
fullName,
|
||||
host,
|
||||
name,
|
||||
normalizedUrl,
|
||||
owner,
|
||||
repositoryPath,
|
||||
webUrl,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Default branch normalization: trim whitespace and reject empty strings.
|
||||
* Falls back to a caller-provided default when the source does not supply one.
|
||||
*/
|
||||
export const normalizeDefaultBranch = (
|
||||
raw: string | undefined | null,
|
||||
fallback = "main"
|
||||
): string => {
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed.length === 0 ? fallback : trimmed;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider/host compatibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether a repository host is compatible with a provider's server URL.
|
||||
* Both must resolve to the same product-known provider, or be the same host.
|
||||
*/
|
||||
export const isProviderHostCompatible = (
|
||||
provider: GitProvider,
|
||||
serverUrl: string,
|
||||
repositoryHost: string
|
||||
): boolean => {
|
||||
const serverHost = (() => {
|
||||
try {
|
||||
return new URL(serverUrl).hostname.toLowerCase();
|
||||
} catch {
|
||||
return serverUrl.toLowerCase();
|
||||
}
|
||||
})();
|
||||
const repoHost = repositoryHost.toLowerCase();
|
||||
|
||||
if (serverHost === repoHost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const providerForServer = providerForHost(serverHost);
|
||||
const providerForRepo = providerForHost(repoHost);
|
||||
if (providerForServer === null || providerForRepo === null) {
|
||||
return false;
|
||||
}
|
||||
return providerForServer === provider && providerForRepo === provider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide whether a connection may be used for a repository on the given host.
|
||||
* Returns a rejection reason when incompatible.
|
||||
*/
|
||||
export const decideConnectionHostCompatibility = (
|
||||
connectionProvider: GitProvider,
|
||||
connectionServerUrl: string,
|
||||
repositoryHost: string
|
||||
): Effect.Effect<void, GitProviderError> =>
|
||||
Effect.gen(function* decide() {
|
||||
if (
|
||||
!isProviderHostCompatible(
|
||||
connectionProvider,
|
||||
connectionServerUrl,
|
||||
repositoryHost
|
||||
)
|
||||
) {
|
||||
return yield* Effect.fail(
|
||||
invalidInput(
|
||||
`Connection provider ${connectionProvider} (${connectionServerUrl}) is not compatible with repository host ${repositoryHost}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Freshness window for credential verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CREDENTIAL_FRESHNESS_MS = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Whether the last successful credential verification is still fresh
|
||||
* enough for sensitive operations. Returns true when `lastVerifiedAt`
|
||||
* is within the freshness window of `now`.
|
||||
*/
|
||||
export const isCredentialFresh = (
|
||||
lastVerifiedAt: number | undefined,
|
||||
now: number
|
||||
): boolean =>
|
||||
lastVerifiedAt !== undefined &&
|
||||
now - lastVerifiedAt <= CREDENTIAL_FRESHNESS_MS;
|
||||
366
packages/primitives/src/git-provisioning.ts
Normal file
366
packages/primitives/src/git-provisioning.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
/* eslint-disable max-classes-per-file -- provisioning commands, results, and errors form one validated-command module. */
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
import type { GitProvider, ProviderAccount } from "./git-provider";
|
||||
import {
|
||||
GitProviderError,
|
||||
RepositoryPrivacy,
|
||||
PUTER_GIT_SERVER_URL,
|
||||
} from "./git-provider";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Text = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
})
|
||||
);
|
||||
|
||||
const HttpUrl = Schema.String.check(
|
||||
Schema.makeFilter(
|
||||
(value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ expected: "an HTTP URL" }
|
||||
)
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migration states
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MigrationState = Schema.Literals([
|
||||
"queued",
|
||||
"running",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
export type MigrationState = typeof MigrationState.Type;
|
||||
|
||||
export const isTerminalMigration = (state: MigrationState): boolean =>
|
||||
state === "succeeded" || state === "failed" || state === "cancelled";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provisioning command types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CreatePuterUserInput = Schema.Struct({
|
||||
_tag: Schema.Literal("CreatePuterUser"),
|
||||
/** Better Auth user identity to provision. */
|
||||
betterAuthEmail: Text,
|
||||
betterAuthName: Text,
|
||||
/** Organization that owns this provisioning request. */
|
||||
organizationId: Text,
|
||||
providerAccountId: Text,
|
||||
/** Username on the Puter Gitea instance. */
|
||||
puterUsername: Text,
|
||||
});
|
||||
export type CreatePuterUserInput = typeof CreatePuterUserInput.Type;
|
||||
|
||||
export const CreatePuterUserResult = Schema.Struct({
|
||||
_tag: Schema.Literal("CreatePuterUser"),
|
||||
/** Whether the user already existed before this request (idempotent). */
|
||||
alreadyExisted: Schema.Boolean,
|
||||
/** External user ID assigned by Gitea. */
|
||||
externalUserId: Text,
|
||||
puterUsername: Text,
|
||||
});
|
||||
export type CreatePuterUserResult = typeof CreatePuterUserResult.Type;
|
||||
|
||||
export const CreatePuterOrganizationInput = Schema.Struct({
|
||||
_tag: Schema.Literal("CreatePuterOrganization"),
|
||||
displayName: Schema.UndefinedOr(Text),
|
||||
organizationId: Text,
|
||||
providerAccountId: Text,
|
||||
puterOrgName: Text,
|
||||
});
|
||||
export type CreatePuterOrganizationInput =
|
||||
typeof CreatePuterOrganizationInput.Type;
|
||||
|
||||
export const CreatePuterOrganizationResult = Schema.Struct({
|
||||
_tag: Schema.Literal("CreatePuterOrganization"),
|
||||
alreadyExisted: Schema.Boolean,
|
||||
externalOrgId: Text,
|
||||
puterOrgName: Text,
|
||||
});
|
||||
export type CreatePuterOrganizationResult =
|
||||
typeof CreatePuterOrganizationResult.Type;
|
||||
|
||||
export const CreatePuterRepositoryInput = Schema.Struct({
|
||||
_tag: Schema.Literal("CreatePuterRepository"),
|
||||
autoInit: Schema.Boolean,
|
||||
defaultBranch: Text,
|
||||
description: Schema.UndefinedOr(Text),
|
||||
organizationId: Text,
|
||||
owner: Text,
|
||||
privacy: RepositoryPrivacy,
|
||||
providerAccountId: Text,
|
||||
providerOrganizationExternalId: Schema.UndefinedOr(Text),
|
||||
repositoryName: Text,
|
||||
});
|
||||
export type CreatePuterRepositoryInput = typeof CreatePuterRepositoryInput.Type;
|
||||
|
||||
export const CreatePuterRepositoryResult = Schema.Struct({
|
||||
_tag: Schema.Literal("CreatePuterRepository"),
|
||||
alreadyExisted: Schema.Boolean,
|
||||
cloneUrl: HttpUrl,
|
||||
externalRepositoryId: Text,
|
||||
fullName: Text,
|
||||
name: Text,
|
||||
owner: Text,
|
||||
webUrl: HttpUrl,
|
||||
});
|
||||
export type CreatePuterRepositoryResult =
|
||||
typeof CreatePuterRepositoryResult.Type;
|
||||
|
||||
export const MigrateGithubRepositoryInput = Schema.Struct({
|
||||
_tag: Schema.Literal("MigrateGitHubRepository"),
|
||||
/** GitHub connection that provides the source token for private repos. */
|
||||
githubConnectionId: Text,
|
||||
includeLfs: Schema.Boolean,
|
||||
organizationId: Text,
|
||||
/** Puter connection/account used for the migration target. */
|
||||
puterConnectionId: Text,
|
||||
/** Whether the source is private and requires the GitHub token. */
|
||||
sourceIsPrivate: Schema.Boolean,
|
||||
/** Normalized GitHub source repository URL. */
|
||||
sourceRepositoryUrl: HttpUrl,
|
||||
/** Puter target owner (user or org). */
|
||||
targetOwner: Text,
|
||||
targetRepositoryName: Text,
|
||||
});
|
||||
export type MigrateGithubRepositoryInput =
|
||||
typeof MigrateGithubRepositoryInput.Type;
|
||||
|
||||
export const MigrateGithubRepositoryResult = Schema.Struct({
|
||||
_tag: Schema.Literal("MigrateGitHubRepository"),
|
||||
externalRepositoryId: Text,
|
||||
migrationId: Text,
|
||||
resultingCloneUrl: HttpUrl,
|
||||
resultingFullName: Text,
|
||||
resultingWebUrl: HttpUrl,
|
||||
});
|
||||
export type MigrateGithubRepositoryResult =
|
||||
typeof MigrateGithubRepositoryResult.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Idempotency-key construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const shortHash = (data: string): string => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
const char = data.codePointAt(i);
|
||||
if (char === undefined) {
|
||||
continue;
|
||||
}
|
||||
hash = (hash * 31 + char) % 4_294_967_295;
|
||||
}
|
||||
return hash.toString(16).padStart(8, "0");
|
||||
};
|
||||
|
||||
export type ProvisioningCommand =
|
||||
| CreatePuterUserInput
|
||||
| CreatePuterOrganizationInput
|
||||
| CreatePuterRepositoryInput
|
||||
| MigrateGithubRepositoryInput;
|
||||
|
||||
/** Construct a deterministic idempotency key for a provisioning command. */
|
||||
export const idempotencyKeyFor = (command: ProvisioningCommand): string => {
|
||||
switch (command._tag) {
|
||||
case "CreatePuterUser": {
|
||||
return `puter-user:${command.providerAccountId}:${command.puterUsername}`;
|
||||
}
|
||||
case "CreatePuterOrganization": {
|
||||
return `puter-org:${command.providerAccountId}:${command.puterOrgName}`;
|
||||
}
|
||||
case "CreatePuterRepository": {
|
||||
return `puter-repo:${command.providerAccountId}:${command.owner}:${command.repositoryName}`;
|
||||
}
|
||||
case "MigrateGitHubRepository": {
|
||||
const sourceHash = shortHash(command.sourceRepositoryUrl);
|
||||
return `gh-migrate:${command.githubConnectionId}:${command.puterConnectionId}:${sourceHash}:${command.targetOwner}:${command.targetRepositoryName}`;
|
||||
}
|
||||
default: {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe repository replacement rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether replacing an existing active repository on a Project is safe.
|
||||
* Replacement is permitted only when the new repository's provider and owner
|
||||
* match the existing one, or when the existing one was never set (undefined).
|
||||
*/
|
||||
export const canSafelyReplaceRepository = (
|
||||
existing: { provider: GitProvider; owner: string; name: string } | undefined,
|
||||
next: { provider: GitProvider; owner: string; name: string }
|
||||
): boolean => {
|
||||
if (!existing) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
existing.provider === next.provider &&
|
||||
existing.owner === next.owner &&
|
||||
existing.name === next.name
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project instructions validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MAX_INSTRUCTIONS_CHARACTERS = 10_000;
|
||||
|
||||
export const validateProjectInstructions = (
|
||||
raw: unknown
|
||||
): Effect.Effect<string, GitProviderError> =>
|
||||
Effect.gen(function* validate() {
|
||||
if (typeof raw !== "string") {
|
||||
return yield* Effect.fail(
|
||||
new GitProviderError({
|
||||
message: "Project instructions must be a string",
|
||||
reason: "InvalidInput",
|
||||
})
|
||||
);
|
||||
}
|
||||
if (raw.length > MAX_INSTRUCTIONS_CHARACTERS) {
|
||||
return yield* Effect.fail(
|
||||
new GitProviderError({
|
||||
message: `Project instructions exceed ${MAX_INSTRUCTIONS_CHARACTERS} characters`,
|
||||
reason: "InvalidInput",
|
||||
})
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provisioning validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate a username against Puter Gitea's username constraints:
|
||||
* alphanumeric, hyphens, and underscores; 1-40 characters; must start
|
||||
* with an alphanumeric character.
|
||||
*/
|
||||
export const USERNAME_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9-_]{0,39})$/u;
|
||||
|
||||
export const validatePuterUsername = (
|
||||
raw: string
|
||||
): Effect.Effect<string, GitProviderError> =>
|
||||
Effect.gen(function* validate() {
|
||||
if (!USERNAME_PATTERN.test(raw)) {
|
||||
return yield* Effect.fail(
|
||||
new GitProviderError({
|
||||
message:
|
||||
"Username must be 1-40 characters, start alphanumeric, and contain only letters, digits, hyphens, or underscores",
|
||||
reason: "InvalidInput",
|
||||
})
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate a repository name: alphanumeric, hyphens, underscores, dots;
|
||||
* must not start/end with a dot or hyphen; 1-100 characters.
|
||||
*/
|
||||
export const REPOSITORY_NAME_PATTERN = /^(?:[a-zA-Z0-9][a-zA-Z0-9._-]{0,99})$/u;
|
||||
|
||||
export const validateRepositoryName = (
|
||||
raw: string
|
||||
): Effect.Effect<string, GitProviderError> =>
|
||||
Effect.gen(function* validate() {
|
||||
if (!REPOSITORY_NAME_PATTERN.test(raw)) {
|
||||
return yield* Effect.fail(
|
||||
new GitProviderError({
|
||||
message:
|
||||
"Repository name must be 1-100 characters, start alphanumeric, and contain only letters, digits, dots, hyphens, or underscores",
|
||||
reason: "InvalidInput",
|
||||
})
|
||||
);
|
||||
}
|
||||
if (raw.startsWith(".") || raw.startsWith("-") || raw.endsWith(".")) {
|
||||
return yield* Effect.fail(
|
||||
new GitProviderError({
|
||||
message: "Repository name must not start/end with a dot or hyphen",
|
||||
reason: "InvalidInput",
|
||||
})
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate a Puter organization name (slug): same constraints as username
|
||||
* but with a separate error path.
|
||||
*/
|
||||
export const validatePuterOrgName = (
|
||||
raw: string
|
||||
): Effect.Effect<string, GitProviderError> =>
|
||||
Effect.gen(function* validate() {
|
||||
if (!USERNAME_PATTERN.test(raw)) {
|
||||
return yield* Effect.fail(
|
||||
new GitProviderError({
|
||||
message:
|
||||
"Organization name must be 1-40 characters, start alphanumeric, and contain only letters, digits, hyphens, or underscores",
|
||||
reason: "InvalidInput",
|
||||
})
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Owner-safe provisioning input guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Enforce that a provisioning command's provider account belongs to the
|
||||
* authenticated owner. This prevents an owner from inventing arbitrary
|
||||
* external identities and creating unrelated platform users.
|
||||
*/
|
||||
export const assertOwnerSafeProvisioningInput = (
|
||||
command: { providerAccountId: string },
|
||||
ownerAccount: ProviderAccount
|
||||
): Effect.Effect<void, GitProviderError> =>
|
||||
Effect.gen(function* assert() {
|
||||
if (command.providerAccountId !== ownerAccount.externalAccountId) {
|
||||
return yield* Effect.fail(
|
||||
new GitProviderError({
|
||||
message:
|
||||
"Provisioning command must target the authenticated owner's provider account",
|
||||
reason: "Forbidden",
|
||||
})
|
||||
);
|
||||
}
|
||||
if (ownerAccount.provider !== "gitea") {
|
||||
return yield* Effect.fail(
|
||||
new GitProviderError({
|
||||
message: "Puter provisioning requires a Gitea provider account",
|
||||
reason: "InvalidInput",
|
||||
})
|
||||
);
|
||||
}
|
||||
if (ownerAccount.serverUrl !== PUTER_GIT_SERVER_URL) {
|
||||
return yield* Effect.fail(
|
||||
new GitProviderError({
|
||||
message: `Puter provisioning requires server ${PUTER_GIT_SERVER_URL}`,
|
||||
reason: "InvalidInput",
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
184
packages/primitives/src/git-webhook.ts
Normal file
184
packages/primitives/src/git-webhook.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/* eslint-disable max-classes-per-file -- webhook event envelopes and errors form one normalized module. */
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
import { GitProvider } from "./git-provider";
|
||||
|
||||
const Text = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
})
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supported event names
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GithubEventName = Schema.Literals([
|
||||
"ping",
|
||||
"push",
|
||||
"repository",
|
||||
"delete",
|
||||
"create",
|
||||
"public",
|
||||
"member",
|
||||
"organization",
|
||||
"fork",
|
||||
"release",
|
||||
]);
|
||||
export type GithubEventName = typeof GithubEventName.Type;
|
||||
|
||||
export const PuterEventName = Schema.Literals([
|
||||
"ping",
|
||||
"push",
|
||||
"repository",
|
||||
"create",
|
||||
"delete",
|
||||
"fork",
|
||||
"release",
|
||||
]);
|
||||
export type PuterEventName = typeof PuterEventName.Type;
|
||||
|
||||
/**
|
||||
* Events we normalize into repository lifecycle now: repository changes,
|
||||
* pushes, deletions, transfers, and visibility changes.
|
||||
*/
|
||||
export const PROCESSED_EVENTS = new Set([
|
||||
"push",
|
||||
"repository",
|
||||
"delete",
|
||||
"create",
|
||||
"public",
|
||||
"fork",
|
||||
]);
|
||||
|
||||
export const isProcessedEvent = (event: string): boolean =>
|
||||
PROCESSED_EVENTS.has(event);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signature verification inputs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SignatureVerificationInput = Schema.Struct({
|
||||
body: Schema.String,
|
||||
provider: GitProvider,
|
||||
signature: Text,
|
||||
/** Hex-encoded HMAC signature from the provider header. */
|
||||
signatureHeaderName: Text,
|
||||
token: Text,
|
||||
});
|
||||
export type SignatureVerificationInput = typeof SignatureVerificationInput.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook delivery processing states
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DeliveryState = Schema.Literals([
|
||||
"received",
|
||||
"processing",
|
||||
"processed",
|
||||
"ignored",
|
||||
"failed",
|
||||
"duplicate",
|
||||
]);
|
||||
export type DeliveryState = typeof DeliveryState.Type;
|
||||
|
||||
export const isTerminalDelivery = (state: DeliveryState): boolean =>
|
||||
state === "processed" ||
|
||||
state === "ignored" ||
|
||||
state === "failed" ||
|
||||
state === "duplicate";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalized repository event envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RepositoryEventAction = Schema.Literals([
|
||||
"push",
|
||||
"created",
|
||||
"deleted",
|
||||
"transferred",
|
||||
"visibility_changed",
|
||||
"default_branch_changed",
|
||||
"renamed",
|
||||
"forked",
|
||||
"other",
|
||||
]);
|
||||
export type RepositoryEventAction = typeof RepositoryEventAction.Type;
|
||||
|
||||
export const RepositoryEventEnvelope = Schema.Struct({
|
||||
action: RepositoryEventAction,
|
||||
/** Provider-assigned delivery GUID. */
|
||||
deliveryId: Text,
|
||||
event: Text,
|
||||
/** External repository ID from the provider payload. */
|
||||
externalRepositoryId: Text,
|
||||
/** Full owner/name from the provider payload. */
|
||||
fullName: Text,
|
||||
owner: Text,
|
||||
provider: GitProvider,
|
||||
pushedTo: Schema.UndefinedOr(Text),
|
||||
/** Ref or branch that changed, when applicable. */
|
||||
ref: Schema.UndefinedOr(Text),
|
||||
refType: Schema.UndefinedOr(Text),
|
||||
repositoryName: Text,
|
||||
/** ISO-like timestamp from the provider, or epoch 0 when absent. */
|
||||
repositoryUpdatedAt: Schema.Number,
|
||||
serverUrl: Text,
|
||||
visibility: Schema.UndefinedOr(Text),
|
||||
});
|
||||
export type RepositoryEventEnvelope = typeof RepositoryEventEnvelope.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const WebhookErrorReason = Schema.Literals([
|
||||
"InvalidInput",
|
||||
"Unauthorized",
|
||||
"NotFound",
|
||||
"Conflict",
|
||||
"InvalidResponse",
|
||||
"PayloadTooLarge",
|
||||
]);
|
||||
export type WebhookErrorReason = typeof WebhookErrorReason.Type;
|
||||
|
||||
export class WebhookError extends Schema.TaggedErrorClass<WebhookError>()(
|
||||
"WebhookError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: WebhookErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
export const MAX_WEBHOOK_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Apply a payload-size limit before JSON decoding. Rejects payloads larger
|
||||
* than the maximum before any parsing.
|
||||
*/
|
||||
export const assertPayloadSize = (
|
||||
byteLength: number
|
||||
): Effect.Effect<void, WebhookError> =>
|
||||
Effect.gen(function* assert() {
|
||||
if (byteLength > MAX_WEBHOOK_PAYLOAD_BYTES) {
|
||||
return yield* Effect.fail(
|
||||
new WebhookError({
|
||||
message: `Webhook payload exceeds ${MAX_WEBHOOK_PAYLOAD_BYTES} bytes`,
|
||||
reason: "PayloadTooLarge",
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delivery idempotency helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Construct the idempotency key for a webhook delivery. Duplicate deliveries
|
||||
* with the same provider/deliveryId are no-ops.
|
||||
*/
|
||||
export const deliveryIdempotencyKey = (
|
||||
provider: GitProvider,
|
||||
deliveryId: string
|
||||
): string => `webhook:${provider}:${deliveryId}`;
|
||||
188
packages/primitives/src/host-repository.ts
Normal file
188
packages/primitives/src/host-repository.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/* eslint-disable max-classes-per-file -- host repository preparation service and error form one contract. */
|
||||
import type { Effect } from "effect";
|
||||
import { Context, Schema } from "effect";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Text = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
})
|
||||
);
|
||||
|
||||
const HttpUrl = Schema.String.check(
|
||||
Schema.makeFilter(
|
||||
(value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ expected: "an HTTP URL" }
|
||||
)
|
||||
);
|
||||
|
||||
/** Input for preparing a repository checkout on the host. */
|
||||
export const HostRepositoryInput = Schema.Struct({
|
||||
baseBranch: Text,
|
||||
cloneUrl: HttpUrl,
|
||||
/** Decrypted credential for the provider (PAT or OAuth token). */
|
||||
credential: Schema.UndefinedOr(Text),
|
||||
/** Provider type: github uses x-access-token as the username, gitea uses
|
||||
* the account's external username. */
|
||||
provider: Schema.UndefinedOr(Text),
|
||||
/** Unique workspace key for isolation. */
|
||||
username: Schema.UndefinedOr(Text),
|
||||
workspaceKey: Text,
|
||||
});
|
||||
export type HostRepositoryInput = typeof HostRepositoryInput.Type;
|
||||
|
||||
/** Result of preparing a repository checkout on the host. */
|
||||
export const HostRepositoryResult = Schema.Struct({
|
||||
/** The base revision checked out. */
|
||||
baseRevision: Text,
|
||||
checkoutPath: Text,
|
||||
});
|
||||
export type HostRepositoryResult = typeof HostRepositoryResult.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const HostRepositoryErrorReason = Schema.Literals([
|
||||
"Authentication",
|
||||
"CloneFailed",
|
||||
"CheckoutFailed",
|
||||
"InvalidInput",
|
||||
"Timeout",
|
||||
]);
|
||||
export type HostRepositoryErrorReason = typeof HostRepositoryErrorReason.Type;
|
||||
|
||||
export class HostRepositoryError extends Schema.TaggedErrorClass<HostRepositoryError>()(
|
||||
"HostRepositoryError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: HostRepositoryErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HostRepository service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Provider-neutral host repository preparation. Clones/fetches the repository
|
||||
* using the decrypted credential, removes the credential from the remote URL
|
||||
* and git config, and mounts the checkout into the AgentOS VM.
|
||||
*
|
||||
* The VM never receives the provider credential. It edits and runs Git locally.
|
||||
* The host collects the diff and performs authenticated push/delivery later.
|
||||
*
|
||||
* Future `sandbox credential lease` boundary is documented but not implemented.
|
||||
*/
|
||||
interface HostRepositoryShape {
|
||||
readonly prepare: (
|
||||
input: HostRepositoryInput
|
||||
) => Effect.Effect<HostRepositoryResult, HostRepositoryError>;
|
||||
}
|
||||
|
||||
export class HostRepository extends Context.Service<
|
||||
HostRepository,
|
||||
HostRepositoryShape
|
||||
>()("@code/primitives/host-repository/HostRepository") {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential-safe clone URL construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a clone URL with credentials embedded for the initial clone only.
|
||||
* The credential is removed from the remote URL immediately after cloning
|
||||
* so it never persists in git config.
|
||||
*/
|
||||
export const buildAuthenticatedCloneUrl = (
|
||||
cloneUrl: string,
|
||||
credential?: string
|
||||
): string => {
|
||||
if (!credential) {
|
||||
return cloneUrl;
|
||||
}
|
||||
try {
|
||||
const url = new URL(cloneUrl);
|
||||
// Inject the token as a Bearer credential in the URL.
|
||||
// For GitHub: x-access-token:<token>@
|
||||
// For Gitea: the token is used as the username with no password.
|
||||
url.username = credential;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return cloneUrl;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip credentials from a git remote URL. Called after cloning to ensure
|
||||
* the credential does not persist in `.git/config`.
|
||||
*/
|
||||
export const stripCredentialsFromUrl = (url: string): string => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell command builder for credential-safe clone
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the git arguments for cloning a repository safely. Returns arrays
|
||||
* of string arguments suitable for `execFileSync` (no shell interpolation).
|
||||
*
|
||||
* 1. Clone with credentials (if provided)
|
||||
* 2. Remove credentials from the remote URL
|
||||
* 3. Checkout the base branch
|
||||
* 4. Capture the base revision
|
||||
*
|
||||
* Using argument arrays instead of shell strings prevents credential-driven
|
||||
* command injection. The credential is never written to git config permanently.
|
||||
*/
|
||||
export const buildCloneSteps = (input: {
|
||||
baseBranch: string;
|
||||
checkoutPath: string;
|
||||
cloneUrl: string;
|
||||
credential?: string;
|
||||
}): readonly {
|
||||
readonly args: readonly string[];
|
||||
readonly description: string;
|
||||
}[] => {
|
||||
const { baseBranch, checkoutPath, cloneUrl, credential } = input;
|
||||
const authenticatedUrl = buildAuthenticatedCloneUrl(cloneUrl, credential);
|
||||
const cleanUrl = stripCredentialsFromUrl(cloneUrl);
|
||||
|
||||
return [
|
||||
{
|
||||
args: ["clone", "--no-tags", authenticatedUrl, checkoutPath],
|
||||
description: "git clone",
|
||||
},
|
||||
{
|
||||
args: ["-C", checkoutPath, "remote", "set-url", "origin", cleanUrl],
|
||||
description: "strip credentials from remote URL",
|
||||
},
|
||||
{
|
||||
args: ["-C", checkoutPath, "checkout", baseBranch],
|
||||
description: "checkout base branch",
|
||||
},
|
||||
{
|
||||
args: ["-C", checkoutPath, "rev-parse", "HEAD"],
|
||||
description: "capture base revision",
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -2,6 +2,9 @@
|
||||
export * from "./agent-os.ts";
|
||||
export * from "./execution-runtime.ts";
|
||||
export * from "./git.ts";
|
||||
export * from "./git-provider.ts";
|
||||
export * from "./git-provisioning.ts";
|
||||
export * from "./git-webhook.ts";
|
||||
export * from "./git-local-runtime.ts";
|
||||
export * from "./git-remote-runtime.ts";
|
||||
export * from "./project.ts";
|
||||
@@ -17,3 +20,4 @@ export * from "./work-design.ts";
|
||||
export * from "./work-lifecycle.ts";
|
||||
export * from "./resolver.ts";
|
||||
export * from "./harness-runtime.ts";
|
||||
export * from "./host-repository.ts";
|
||||
|
||||
Reference in New Issue
Block a user