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 onClose: () => void;
|
||||||
readonly workspace: WorkspaceState;
|
readonly workspace: WorkspaceState;
|
||||||
}) => {
|
}) => {
|
||||||
const [serverUrl, setServerUrl] = useState("https://git.openputer.com");
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const handleClearOperationError = () => workspace.clearOperationError();
|
const handleClearOperationError = () => workspace.clearOperationError();
|
||||||
@@ -59,13 +58,7 @@ export const ProjectSettingsPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
|
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
|
||||||
<input
|
<input
|
||||||
aria-label="Gitea server URL"
|
aria-label="Puter Git username"
|
||||||
className="h-9 w-full border px-2 text-xs"
|
|
||||||
onChange={(event) => setServerUrl(event.target.value)}
|
|
||||||
value={serverUrl}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
aria-label="Gitea username"
|
|
||||||
className="h-9 w-full border px-2 text-xs"
|
className="h-9 w-full border px-2 text-xs"
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
placeholder="Username (optional)"
|
placeholder="Username (optional)"
|
||||||
@@ -85,7 +78,6 @@ export const ProjectSettingsPanel = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void workspace.connectGitea({
|
void workspace.connectGitea({
|
||||||
serverUrl,
|
|
||||||
token,
|
token,
|
||||||
username: username || undefined,
|
username: username || undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Id } from "@code/backend/convex/_generated/dataModel";
|
|||||||
import { useAction, useMutation, useQuery } from "convex/react";
|
import { useAction, useMutation, useQuery } from "convex/react";
|
||||||
import { makeFunctionReference } from "convex/server";
|
import { makeFunctionReference } from "convex/server";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
|
||||||
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||||
@@ -96,7 +97,7 @@ const projectGitConnectionRef = makeFunctionReference<
|
|||||||
>("gitConnectionData:getForProject");
|
>("gitConnectionData:getForProject");
|
||||||
const connectGiteaRef = makeFunctionReference<
|
const connectGiteaRef = makeFunctionReference<
|
||||||
"action",
|
"action",
|
||||||
{ serverUrl: string; token: string; username?: string },
|
{ token: string; username?: string },
|
||||||
{ connectionId: Id<"gitConnections"> }
|
{ connectionId: Id<"gitConnections"> }
|
||||||
>("gitConnections:connectGitea");
|
>("gitConnections:connectGitea");
|
||||||
const connectGithubRef = makeFunctionReference<
|
const connectGithubRef = makeFunctionReference<
|
||||||
@@ -118,6 +119,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
|||||||
);
|
);
|
||||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||||
const agent = useOrganizationChatAgent(organization);
|
const agent = useOrganizationChatAgent(organization);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [selectedProjectId, setSelectedProjectId] =
|
const [selectedProjectId, setSelectedProjectId] =
|
||||||
useState<Id<"projects"> | null>(null);
|
useState<Id<"projects"> | null>(null);
|
||||||
const [repository, setRepository] = useState("");
|
const [repository, setRepository] = useState("");
|
||||||
@@ -125,11 +127,17 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
|||||||
const [error, setError] = useState<Error>();
|
const [error, setError] = useState<Error>();
|
||||||
const [operationError, setOperationError] = 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(
|
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
|
const activeProjectId = selectedProjectStillExists
|
||||||
? selectedProjectId
|
? (selectedProjectId ?? paramProject)
|
||||||
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
|
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
|
||||||
const works = useQuery(
|
const works = useQuery(
|
||||||
workListRef,
|
workListRef,
|
||||||
@@ -202,11 +210,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectGitea = (input: {
|
const connectGitea = (input: { token: string; username?: string }) =>
|
||||||
serverUrl: string;
|
|
||||||
token: string;
|
|
||||||
username?: string;
|
|
||||||
}) =>
|
|
||||||
runOperation(async () => {
|
runOperation(async () => {
|
||||||
const result = await connectGiteaAction(input);
|
const result = await connectGiteaAction(input);
|
||||||
await attachConnection(result.connectionId);
|
await attachConnection(result.connectionId);
|
||||||
@@ -228,8 +232,8 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
|||||||
designVersion: number
|
designVersion: number
|
||||||
) => approveDesignMutation({ definitionVersion, designVersion, workId }),
|
) => approveDesignMutation({ definitionVersion, designVersion, workId }),
|
||||||
authorizeGithub: () =>
|
authorizeGithub: () =>
|
||||||
authClient.signIn.social({
|
authClient.linkSocial({
|
||||||
callbackURL: window.location.href,
|
callbackURL: `${window.location.origin}/projects?resume=github`,
|
||||||
provider: "github",
|
provider: "github",
|
||||||
}),
|
}),
|
||||||
cancelExecution: (runId: Id<"workRuns">) =>
|
cancelExecution: (runId: Id<"workRuns">) =>
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ export interface WorkspaceState {
|
|||||||
readonly cancelSimulation: (runId: Id<"workRuns">) => Promise<unknown>;
|
readonly cancelSimulation: (runId: Id<"workRuns">) => Promise<unknown>;
|
||||||
readonly clearOperationError: () => void;
|
readonly clearOperationError: () => void;
|
||||||
readonly connectGitea: (input: {
|
readonly connectGitea: (input: {
|
||||||
readonly serverUrl: string;
|
|
||||||
readonly token: string;
|
readonly token: string;
|
||||||
readonly username?: string;
|
readonly username?: string;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
|
|||||||
@@ -6,5 +6,8 @@ export default [
|
|||||||
route("login", "./routes/auth/login/page.tsx"),
|
route("login", "./routes/auth/login/page.tsx"),
|
||||||
route("signup", "./routes/auth/signup/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;
|
] 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 { requireAuthToken } from "@/lib/auth.server";
|
||||||
|
|
||||||
import type { Route } from "./+types/layout";
|
import type { Route } from "./+types/layout";
|
||||||
@@ -8,5 +12,24 @@ export const loader = ({ request }: Route.LoaderArgs) =>
|
|||||||
requireAuthToken(request);
|
requireAuthToken(request);
|
||||||
|
|
||||||
export default function AppLayout() {
|
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 />;
|
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",
|
"name": "code",
|
||||||
"private": true,
|
"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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vp run -r dev",
|
"dev": "vp run -r dev",
|
||||||
@@ -41,5 +88,10 @@
|
|||||||
"vite-plus": "0.2.2",
|
"vite-plus": "0.2.2",
|
||||||
"vitest": "catalog:"
|
"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"
|
"packageManager": "pnpm@11.17.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export const executeAgentOsAttempt = async (
|
|||||||
const hostRepository = new HostRepositoryWorkspace();
|
const hostRepository = new HostRepositoryWorkspace();
|
||||||
const prepared = await hostRepository.prepare({
|
const prepared = await hostRepository.prepare({
|
||||||
attemptId: input.attemptId,
|
attemptId: input.attemptId,
|
||||||
|
cloneUrl: input.repositoryUrl,
|
||||||
|
credential: input.auth.credential,
|
||||||
piHomeFiles: makePiHomeFiles({
|
piHomeFiles: makePiHomeFiles({
|
||||||
api: env.AGENT_MODEL_API,
|
api: env.AGENT_MODEL_API,
|
||||||
apiKeyEnvironmentVariable: "AGENT_MODEL_API_KEY",
|
apiKeyEnvironmentVariable: "AGENT_MODEL_API_KEY",
|
||||||
@@ -112,14 +114,16 @@ export const executeAgentOsAttempt = async (
|
|||||||
model: env.AGENT_MODEL_NAME,
|
model: env.AGENT_MODEL_NAME,
|
||||||
provider: env.AGENT_MODEL_PROVIDER,
|
provider: env.AGENT_MODEL_PROVIDER,
|
||||||
}),
|
}),
|
||||||
|
provider: input.auth.provider,
|
||||||
|
username: input.auth.username,
|
||||||
});
|
});
|
||||||
events.push(
|
events.push(
|
||||||
event(
|
event(
|
||||||
1,
|
1,
|
||||||
"runtime.preparing",
|
"runtime.preparing",
|
||||||
prepared.created
|
prepared.created
|
||||||
? "Isolated Zopu worktree created on the execution host"
|
? "Repository cloned on the execution host"
|
||||||
: "Isolated Zopu worktree recreated"
|
: "Repository re-cloned"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const mounts = [
|
const mounts = [
|
||||||
@@ -128,11 +132,6 @@ export const executeAgentOsAttempt = async (
|
|||||||
path: "/workspace/repository",
|
path: "/workspace/repository",
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
hostPath: prepared.sourceRepositoryPath,
|
|
||||||
path: prepared.sourceRepositoryPath,
|
|
||||||
readOnly: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
hostPath: prepared.piHomePath,
|
hostPath: prepared.piHomePath,
|
||||||
path: "/home/zopu",
|
path: "/home/zopu",
|
||||||
@@ -178,9 +177,9 @@ export const executeAgentOsAttempt = async (
|
|||||||
|
|
||||||
const sessionId = `pi-${input.attemptId}`;
|
const sessionId = `pi-${input.attemptId}`;
|
||||||
await vm.openSession({
|
await vm.openSession({
|
||||||
additionalDirectories: [`${prepared.sourceRepositoryPath}/.git`],
|
additionalDirectories: [`${prepared.checkoutPath}/.git`],
|
||||||
additionalInstructions:
|
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",
|
agent: "pi",
|
||||||
cwd: "/workspace/repository",
|
cwd: "/workspace/repository",
|
||||||
env: piSessionEnv(env.AGENT_MODEL_API_KEY),
|
env: piSessionEnv(env.AGENT_MODEL_API_KEY),
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ import type { PiHomeFiles } from "@code/primitives";
|
|||||||
|
|
||||||
interface PrepareRepositoryInput {
|
interface PrepareRepositoryInput {
|
||||||
attemptId: string;
|
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;
|
piHomeFiles: PiHomeFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,27 +109,17 @@ const pathExists = async (target: string): Promise<boolean> => {
|
|||||||
|
|
||||||
export class HostRepositoryWorkspace {
|
export class HostRepositoryWorkspace {
|
||||||
readonly #root: string;
|
readonly #root: string;
|
||||||
readonly #sourceRepositoryPath: string;
|
|
||||||
readonly #installDependencies: boolean;
|
readonly #installDependencies: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces",
|
root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces",
|
||||||
sourceRepositoryPath = process.env.ZOPU_SOURCE_REPOSITORY ??
|
|
||||||
"/opt/zopu-source",
|
|
||||||
installDependencies = true
|
installDependencies = true
|
||||||
) {
|
) {
|
||||||
this.#root = root;
|
this.#root = root;
|
||||||
this.#sourceRepositoryPath = sourceRepositoryPath;
|
|
||||||
this.#installDependencies = installDependencies;
|
this.#installDependencies = installDependencies;
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepare(input: PrepareRepositoryInput): Promise<PreparedRepository> {
|
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")
|
const identity = createHash("sha256")
|
||||||
.update(input.attemptId)
|
.update(input.attemptId)
|
||||||
.digest("hex")
|
.digest("hex")
|
||||||
@@ -131,42 +128,57 @@ export class HostRepositoryWorkspace {
|
|||||||
const checkoutPath = path.join(workspacePath, "repository");
|
const checkoutPath = path.join(workspacePath, "repository");
|
||||||
const toolsPath = path.join(workspacePath, "tools");
|
const toolsPath = path.join(workspacePath, "tools");
|
||||||
const piHomePath = path.join(workspacePath, "home");
|
const piHomePath = path.join(workspacePath, "home");
|
||||||
const branch = `zopu/attempt-${identity}`;
|
|
||||||
const created = !(await pathExists(path.join(checkoutPath, ".git")));
|
const created = !(await pathExists(path.join(checkoutPath, ".git")));
|
||||||
|
|
||||||
await mkdir(workspacePath, { recursive: true });
|
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 });
|
await rm(checkoutPath, { force: true, recursive: true });
|
||||||
requireSuccess(
|
|
||||||
await runGit(this.#sourceRepositoryPath, [
|
// Clone the user's repository. When a credential is provided, use a
|
||||||
"worktree",
|
// temporary GIT_ASKPASS helper script so the token never appears in the
|
||||||
"add",
|
// clone URL, git config, or process arguments visible in process listings.
|
||||||
"-B",
|
// The askpass script reads the credential from an env var that is only
|
||||||
branch,
|
// set for this clone command and cleared afterward.
|
||||||
checkoutPath,
|
const cloneEnv: Record<string, string> = {};
|
||||||
"HEAD",
|
if (input.credential) {
|
||||||
]),
|
const askpassPath = path.join(workspacePath, ".git-askpass");
|
||||||
"Zopu worktree creation"
|
// 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 =
|
const bunExecutable =
|
||||||
process.env.BUN_EXECUTABLE ??
|
process.env.BUN_EXECUTABLE ??
|
||||||
execFileSync("which", ["bun"], { encoding: "utf-8" }).trim();
|
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) {
|
if (this.#installDependencies) {
|
||||||
requireSuccess(
|
requireSuccess(
|
||||||
await runProcess(
|
await runProcess(
|
||||||
@@ -204,7 +216,7 @@ export class HostRepositoryWorkspace {
|
|||||||
checkoutPath,
|
checkoutPath,
|
||||||
created,
|
created,
|
||||||
piHomePath,
|
piHomePath,
|
||||||
sourceRepositoryPath: this.#sourceRepositoryPath,
|
sourceRepositoryPath: checkoutPath,
|
||||||
toolsPath,
|
toolsPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
|
|||||||
github: {
|
github: {
|
||||||
clientId: env.GITHUB_CLIENT_ID,
|
clientId: env.GITHUB_CLIENT_ID,
|
||||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||||
|
scope: ["repo", "read:org"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
|
|||||||
@@ -78,3 +78,24 @@ export const requireProjectMember = async (
|
|||||||
const userId = await requireOrganizationMember(ctx, project.organizationId);
|
const userId = await requireOrganizationMember(ctx, project.organizationId);
|
||||||
return { organizationId: project.organizationId, userId };
|
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()),
|
FLUE_URL: v.optional(v.string()),
|
||||||
GITEA_TOKEN: v.optional(v.string()),
|
GITEA_TOKEN: v.optional(v.string()),
|
||||||
GITEA_URL: 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_ID: v.optional(v.string()),
|
||||||
GITHUB_CLIENT_SECRET: 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()),
|
GIT_CREDENTIAL_ENCRYPTION_KEY: v.optional(v.string()),
|
||||||
NATIVE_APP_URL: v.optional(v.string()),
|
NATIVE_APP_URL: v.optional(v.string()),
|
||||||
|
PUTER_GIT_ADMIN_TOKEN: v.optional(v.string()),
|
||||||
SITE_URL: v.string(),
|
SITE_URL: v.string(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ const reconcileConversationTurnsRef = makeFunctionReference<
|
|||||||
Record<string, never>,
|
Record<string, never>,
|
||||||
{ reconciled: number }
|
{ reconciled: number }
|
||||||
>("conversationMessages:reconcileExpiredTurns");
|
>("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();
|
const crons = cronJobs();
|
||||||
|
|
||||||
@@ -25,5 +35,16 @@ crons.interval(
|
|||||||
{ seconds: 30 },
|
{ seconds: 30 },
|
||||||
reconcileConversationTurnsRef
|
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;
|
export default crons;
|
||||||
|
|||||||
@@ -1,21 +1,57 @@
|
|||||||
|
import { providerForHost } from "@code/primitives/git-provider";
|
||||||
import { ConvexError, v } from "convex/values";
|
import { ConvexError, v } from "convex/values";
|
||||||
|
|
||||||
|
import type { Id } from "./_generated/dataModel";
|
||||||
import { internalMutation, mutation, query } from "./_generated/server";
|
import { internalMutation, mutation, query } from "./_generated/server";
|
||||||
|
import type { MutationCtx } from "./_generated/server";
|
||||||
import { requireCurrentOrganization, requireProjectMember } from "./authz";
|
import { requireCurrentOrganization, requireProjectMember } from "./authz";
|
||||||
|
|
||||||
// Provider ("forge") that owns a repository host. A project's source must be
|
const upsertProviderAccount = async (
|
||||||
// served by the same forge whose credentials are attached, so a Gitea token is
|
ctx: MutationCtx,
|
||||||
// never offered to GitHub and vice versa. Unknown hosts return null, leaving
|
args: {
|
||||||
// the caller free to attach without a forge constraint.
|
externalAccountId: string;
|
||||||
export const forgeForHost = (host: string): "github" | "gitea" | null => {
|
externalEmail?: string;
|
||||||
const normalized = host.toLowerCase();
|
externalUsername: string;
|
||||||
if (normalized === "github.com" || normalized.endsWith(".githost.com")) {
|
organizationId: Id<"organizations">;
|
||||||
return "github";
|
provider: "github" | "gitea";
|
||||||
|
serverUrl: string;
|
||||||
|
userId: string;
|
||||||
}
|
}
|
||||||
if (normalized === "git.openputer.com") {
|
): Promise<Id<"gitProviderAccounts">> => {
|
||||||
return "gitea";
|
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({
|
export const persist = internalMutation({
|
||||||
@@ -23,6 +59,10 @@ export const persist = internalMutation({
|
|||||||
credentialCiphertext: v.string(),
|
credentialCiphertext: v.string(),
|
||||||
credentialIv: v.string(),
|
credentialIv: v.string(),
|
||||||
credentialKind: v.union(v.literal("oauth"), v.literal("token")),
|
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")),
|
provider: v.union(v.literal("github"), v.literal("gitea")),
|
||||||
serverUrl: v.string(),
|
serverUrl: v.string(),
|
||||||
userId: v.string(),
|
userId: v.string(),
|
||||||
@@ -38,34 +78,54 @@ export const persist = internalMutation({
|
|||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new ConvexError("Organization not found");
|
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
|
const existing = await ctx.db
|
||||||
.query("gitConnections")
|
.query("gitConnections")
|
||||||
.withIndex("by_organizationId_and_provider_and_serverUrl", (q) =>
|
.withIndex("by_gitProviderAccountId", (q) =>
|
||||||
q
|
q.eq("gitProviderAccountId", providerAccountId)
|
||||||
.eq("organizationId", organization._id)
|
|
||||||
.eq("provider", args.provider)
|
|
||||||
.eq("serverUrl", args.serverUrl)
|
|
||||||
)
|
)
|
||||||
.unique();
|
.unique();
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await ctx.db.patch(existing._id, {
|
await ctx.db.patch(existing._id, {
|
||||||
credentialCiphertext: args.credentialCiphertext,
|
credentialCiphertext: args.credentialCiphertext,
|
||||||
credentialIv: args.credentialIv,
|
credentialIv: args.credentialIv,
|
||||||
credentialKind: args.credentialKind,
|
credentialKind: args.credentialKind,
|
||||||
|
grantedScopesJson: args.grantedScopesJson,
|
||||||
|
lastError: undefined,
|
||||||
|
lastVerifiedAt: timestamp,
|
||||||
|
reauthRequiredAt: undefined,
|
||||||
|
state: "active",
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
username: args.username,
|
username: args.username,
|
||||||
});
|
});
|
||||||
return existing._id;
|
return existing._id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await ctx.db.insert("gitConnections", {
|
return await ctx.db.insert("gitConnections", {
|
||||||
connectedAt: timestamp,
|
connectedAt: timestamp,
|
||||||
|
creatorUserId: args.userId,
|
||||||
credentialCiphertext: args.credentialCiphertext,
|
credentialCiphertext: args.credentialCiphertext,
|
||||||
credentialIv: args.credentialIv,
|
credentialIv: args.credentialIv,
|
||||||
credentialKind: args.credentialKind,
|
credentialKind: args.credentialKind,
|
||||||
|
gitProviderAccountId: providerAccountId,
|
||||||
|
grantedScopesJson: args.grantedScopesJson,
|
||||||
|
lastVerifiedAt: timestamp,
|
||||||
organizationId: organization._id,
|
organizationId: organization._id,
|
||||||
provider: args.provider,
|
provider: args.provider,
|
||||||
serverUrl: args.serverUrl,
|
serverUrl: args.serverUrl,
|
||||||
|
state: "active",
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
username: args.username,
|
username: args.username,
|
||||||
});
|
});
|
||||||
@@ -86,8 +146,10 @@ export const list = query({
|
|||||||
connectedAt: connection.connectedAt,
|
connectedAt: connection.connectedAt,
|
||||||
credentialKind: connection.credentialKind,
|
credentialKind: connection.credentialKind,
|
||||||
id: String(connection._id),
|
id: String(connection._id),
|
||||||
|
lastVerifiedAt: connection.lastVerifiedAt,
|
||||||
provider: connection.provider,
|
provider: connection.provider,
|
||||||
serverUrl: connection.serverUrl,
|
serverUrl: connection.serverUrl,
|
||||||
|
state: connection.state,
|
||||||
username: connection.username,
|
username: connection.username,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@@ -106,8 +168,10 @@ export const getForProject = query({
|
|||||||
connectedAt: connection.connectedAt,
|
connectedAt: connection.connectedAt,
|
||||||
credentialKind: connection.credentialKind,
|
credentialKind: connection.credentialKind,
|
||||||
id: String(connection._id),
|
id: String(connection._id),
|
||||||
|
lastVerifiedAt: connection.lastVerifiedAt,
|
||||||
provider: connection.provider,
|
provider: connection.provider,
|
||||||
serverUrl: connection.serverUrl,
|
serverUrl: connection.serverUrl,
|
||||||
|
state: connection.state,
|
||||||
username: connection.username,
|
username: connection.username,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@@ -127,10 +191,10 @@ export const attachToProject = mutation({
|
|||||||
}
|
}
|
||||||
const project = await ctx.db.get(args.projectId);
|
const project = await ctx.db.get(args.projectId);
|
||||||
if (project) {
|
if (project) {
|
||||||
const expected = forgeForHost(project.sourceHost);
|
const expected = providerForHost(project.sourceHost);
|
||||||
if (expected && connection.provider !== expected) {
|
if (expected && connection.provider !== expected) {
|
||||||
throw new ConvexError(
|
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 };
|
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 { env } from "@code/env/convex";
|
||||||
import { decodeGitConnectionInput } from "@code/primitives/execution-runtime";
|
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 { ConvexError, v } from "convex/values";
|
||||||
import { Effect } from "effect";
|
import { Effect } from "effect";
|
||||||
|
|
||||||
import { internal } from "./_generated/api";
|
import { internal } from "./_generated/api";
|
||||||
import type { Id } from "./_generated/dataModel";
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
import { action } from "./_generated/server";
|
import { action } from "./_generated/server";
|
||||||
import { authComponent, createAuth } from "./auth";
|
import { authComponent, createAuth } from "./auth";
|
||||||
|
|
||||||
@@ -52,9 +57,73 @@ export const decryptCredential = async (
|
|||||||
return new TextDecoder().decode(decrypted);
|
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({
|
export const connectGitea = action({
|
||||||
args: {
|
args: {
|
||||||
serverUrl: v.string(),
|
|
||||||
token: v.string(),
|
token: v.string(),
|
||||||
username: v.optional(v.string()),
|
username: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
@@ -73,22 +142,32 @@ export const connectGitea = action({
|
|||||||
credential: args.token,
|
credential: args.token,
|
||||||
credentialKind: "token",
|
credentialKind: "token",
|
||||||
provider: "gitea",
|
provider: "gitea",
|
||||||
serverUrl: args.serverUrl,
|
serverUrl: PUTER_GIT_SERVER_URL,
|
||||||
username: args.username,
|
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 encrypted = await encryptCredential(connection.credential);
|
||||||
const connectionId = await ctx.runMutation(
|
const connectionId = await ctx.runMutation(
|
||||||
internal.gitConnectionData.persist,
|
internal.gitConnectionData.persist,
|
||||||
{
|
{
|
||||||
...encrypted,
|
...encrypted,
|
||||||
|
...externalUser,
|
||||||
credentialKind: connection.credentialKind,
|
credentialKind: connection.credentialKind,
|
||||||
provider: connection.provider,
|
provider: connection.provider,
|
||||||
serverUrl: connection.serverUrl,
|
serverUrl: connection.serverUrl,
|
||||||
userId,
|
userId,
|
||||||
username: connection.username,
|
username: connection.username ?? externalUser.externalUsername,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
// Sync accessible repositories after connecting.
|
||||||
|
await ctx.runAction(syncRepositoriesRef, {
|
||||||
|
connectionId,
|
||||||
|
});
|
||||||
return { connectionId };
|
return { connectionId };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -108,17 +187,125 @@ export const connectGithub = action({
|
|||||||
if (!token.accessToken) {
|
if (!token.accessToken) {
|
||||||
throw new ConvexError("GitHub account is not connected");
|
throw new ConvexError("GitHub account is not connected");
|
||||||
}
|
}
|
||||||
|
const externalUser = await fetchGithubUser(token.accessToken);
|
||||||
const encrypted = await encryptCredential(token.accessToken);
|
const encrypted = await encryptCredential(token.accessToken);
|
||||||
const connectionId = await ctx.runMutation(
|
const connectionId = await ctx.runMutation(
|
||||||
internal.gitConnectionData.persist,
|
internal.gitConnectionData.persist,
|
||||||
{
|
{
|
||||||
...encrypted,
|
...encrypted,
|
||||||
|
...externalUser,
|
||||||
credentialKind: "oauth",
|
credentialKind: "oauth",
|
||||||
provider: "github",
|
provider: "github",
|
||||||
serverUrl: "https://github.com",
|
serverUrl: GITHUB_SERVER_URL,
|
||||||
userId: identity.tokenIdentifier,
|
userId: identity.tokenIdentifier,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
// Sync accessible repositories after connecting.
|
||||||
|
await ctx.runAction(syncRepositoriesRef, {
|
||||||
|
connectionId,
|
||||||
|
});
|
||||||
return { 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 { httpRouter } from "convex/server";
|
||||||
|
|
||||||
import { authComponent, createAuth } from "./auth";
|
import { authComponent, createAuth } from "./auth";
|
||||||
|
import { githubWebhook, puterWebhook } from "./gitWebhooks";
|
||||||
|
|
||||||
const http = httpRouter();
|
const http = httpRouter();
|
||||||
|
|
||||||
authComponent.registerRoutes(http, createAuth, { cors: true });
|
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;
|
export default http;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { validateProjectInstructions } from "@code/primitives/git-provisioning";
|
||||||
import {
|
import {
|
||||||
decodePublicGitImportResult,
|
decodePublicGitImportResult,
|
||||||
preparePublicGitSource,
|
preparePublicGitSource,
|
||||||
@@ -11,8 +12,12 @@ import { Effect } from "effect";
|
|||||||
|
|
||||||
import { internal } from "./_generated/api";
|
import { internal } from "./_generated/api";
|
||||||
import type { Doc, Id } from "./_generated/dataModel";
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
import { action, internalMutation, query } from "./_generated/server";
|
import { action, internalMutation, mutation, query } from "./_generated/server";
|
||||||
import { requireAuthUserId, requireCurrentOrganization } from "./authz";
|
import {
|
||||||
|
requireAuthUserId,
|
||||||
|
requireCurrentOrganization,
|
||||||
|
requireProjectMember,
|
||||||
|
} from "./authz";
|
||||||
import { inspectPublicGit } from "./publicGit";
|
import { inspectPublicGit } from "./publicGit";
|
||||||
|
|
||||||
const toProjectView = async (
|
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_userId", ["userId"])
|
||||||
.index("by_organizationId", ["organizationId"])
|
.index("by_organizationId", ["organizationId"])
|
||||||
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
|
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
|
||||||
gitConnections: defineTable({
|
gitProviderAccounts: defineTable({
|
||||||
connectedAt: v.number(),
|
createdAt: v.number(),
|
||||||
credentialCiphertext: v.string(),
|
externalAccountId: v.string(),
|
||||||
credentialIv: v.string(),
|
externalEmail: v.optional(v.string()),
|
||||||
credentialKind: v.union(v.literal("oauth"), v.literal("token")),
|
externalUsername: v.string(),
|
||||||
organizationId: v.id("organizations"),
|
organizationId: v.id("organizations"),
|
||||||
provider: v.union(v.literal("github"), v.literal("gitea")),
|
provider: v.union(v.literal("github"), v.literal("gitea")),
|
||||||
serverUrl: v.string(),
|
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(),
|
updatedAt: v.number(),
|
||||||
username: v.optional(v.string()),
|
username: v.optional(v.string()),
|
||||||
})
|
})
|
||||||
.index("by_organizationId", ["organizationId"])
|
.index("by_organizationId", ["organizationId"])
|
||||||
.index("by_organizationId_and_provider_and_serverUrl", [
|
.index("by_gitProviderAccountId", ["gitProviderAccountId"]),
|
||||||
"organizationId",
|
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",
|
"provider",
|
||||||
"serverUrl",
|
"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({
|
projects: defineTable({
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
defaultBranch: v.optional(v.string()),
|
defaultBranch: v.optional(v.string()),
|
||||||
description: v.optional(v.string()),
|
description: v.optional(v.string()),
|
||||||
gitConnectionId: v.optional(v.id("gitConnections")),
|
gitConnectionId: v.optional(v.id("gitConnections")),
|
||||||
|
gitRepositoryId: v.optional(v.id("gitRepositories")),
|
||||||
|
instructions: v.optional(v.string()),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
normalizedSourceUrl: v.string(),
|
normalizedSourceUrl: v.string(),
|
||||||
organizationId: v.id("organizations"),
|
organizationId: v.id("organizations"),
|
||||||
|
|||||||
@@ -6,13 +6,24 @@ import {
|
|||||||
decodeWorkAttemptExecutionFailure,
|
decodeWorkAttemptExecutionFailure,
|
||||||
decodeWorkAttemptExecutionResult,
|
decodeWorkAttemptExecutionResult,
|
||||||
} from "@code/primitives/execution-runtime";
|
} from "@code/primitives/execution-runtime";
|
||||||
|
import { makeFunctionReference } from "convex/server";
|
||||||
import { ConvexError, v } from "convex/values";
|
import { ConvexError, v } from "convex/values";
|
||||||
import { Effect } from "effect";
|
import { Effect } from "effect";
|
||||||
|
|
||||||
import { internal } from "./_generated/api";
|
import { internal } from "./_generated/api";
|
||||||
|
import type { Id } from "./_generated/dataModel";
|
||||||
import { internalAction } from "./_generated/server";
|
import { internalAction } from "./_generated/server";
|
||||||
import { decryptCredential } from "./gitConnections";
|
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;
|
const backendUrl = () => env.AGENT_BACKEND_URL ?? env.FLUE_URL;
|
||||||
|
|
||||||
export const executeAttempt = internalAction({
|
export const executeAttempt = internalAction({
|
||||||
@@ -33,6 +44,17 @@ export const executeAttempt = internalAction({
|
|||||||
context.connection.credentialCiphertext,
|
context.connection.credentialCiphertext,
|
||||||
context.connection.credentialIv
|
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(
|
const response = await fetch(
|
||||||
`${backendUrl()}/internal/work-attempts/execute`,
|
`${backendUrl()}/internal/work-attempts/execute`,
|
||||||
{
|
{
|
||||||
@@ -44,9 +66,9 @@ export const executeAttempt = internalAction({
|
|||||||
serverUrl: context.connection.serverUrl,
|
serverUrl: context.connection.serverUrl,
|
||||||
username: context.connection.username,
|
username: context.connection.username,
|
||||||
},
|
},
|
||||||
baseBranch: context.project.defaultBranch ?? "main",
|
baseBranch,
|
||||||
prompt: context.prompt,
|
prompt: context.prompt,
|
||||||
repositoryUrl: context.project.sourceUrl,
|
repositoryUrl,
|
||||||
runId: String(context.run._id),
|
runId: String(context.run._id),
|
||||||
workId: String(context.work._id),
|
workId: String(context.work._id),
|
||||||
workspaceKey: context.attempt.workspaceKey,
|
workspaceKey: context.attempt.workspaceKey,
|
||||||
|
|||||||
@@ -343,14 +343,29 @@ describe("forge credential validation", () => {
|
|||||||
sourceUrl: `https://${sourceHost}/puter/zopu`,
|
sourceUrl: `https://${sourceHost}/puter/zopu`,
|
||||||
updatedAt: 1,
|
updatedAt: 1,
|
||||||
});
|
});
|
||||||
const connectionId = await ctx.db.insert("gitConnections", {
|
const providerAccountId = await ctx.db.insert("gitProviderAccounts", {
|
||||||
connectedAt: 1,
|
createdAt: 1,
|
||||||
credentialCiphertext: "x",
|
externalAccountId: "ext-1",
|
||||||
credentialIv: "y",
|
externalUsername: "zopu",
|
||||||
credentialKind: provider === "github" ? "oauth" : "token",
|
|
||||||
organizationId,
|
organizationId,
|
||||||
provider,
|
provider,
|
||||||
serverUrl: `https://${sourceHost}`,
|
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,
|
updatedAt: 1,
|
||||||
username: "zopu",
|
username: "zopu",
|
||||||
});
|
});
|
||||||
@@ -367,7 +382,7 @@ describe("forge credential validation", () => {
|
|||||||
connectionId: ids.connectionId,
|
connectionId: ids.connectionId,
|
||||||
projectId: ids.projectId,
|
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 () => {
|
test("accepts a matching provider for the project forge", async () => {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
|
import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
|
||||||
import type { WorkAttemptExecutionErrorReason } 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 { defaultCodingKitV0, resolveOutcome } from "@code/primitives/resolver";
|
||||||
import type { AttemptClassification } from "@code/primitives/resolver";
|
import type { AttemptClassification } from "@code/primitives/resolver";
|
||||||
import { WorkflowManager } from "@convex-dev/workflow";
|
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 { internalMutation, internalQuery, mutation } from "./_generated/server";
|
||||||
import type { MutationCtx } from "./_generated/server";
|
import type { MutationCtx } from "./_generated/server";
|
||||||
import { requireProjectMember } from "./authz";
|
import { requireProjectMember } from "./authz";
|
||||||
import { forgeForHost } from "./gitConnectionData";
|
|
||||||
|
|
||||||
export const workflow = new WorkflowManager(components.workflow);
|
export const workflow = new WorkflowManager(components.workflow);
|
||||||
|
|
||||||
@@ -120,7 +123,21 @@ const validateProjectDeployment = async (
|
|||||||
const expected = forgeForHost(project.sourceHost);
|
const expected = forgeForHost(project.sourceHost);
|
||||||
if (expected && connection.provider !== expected) {
|
if (expected && connection.provider !== expected) {
|
||||||
throw new ConvexError(
|
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;
|
return connection;
|
||||||
@@ -252,7 +269,12 @@ export const executionContext = internalQuery({
|
|||||||
const expectedForge = forgeForHost(project.sourceHost);
|
const expectedForge = forgeForHost(project.sourceHost);
|
||||||
if (expectedForge && connection.provider !== expectedForge) {
|
if (expectedForge && connection.provider !== expectedForge) {
|
||||||
throw new ConvexError(
|
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 {
|
return {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
query,
|
query,
|
||||||
} from "./_generated/server";
|
} from "./_generated/server";
|
||||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||||
import { requireProjectMember } from "./authz";
|
import { requireCurrentOrganization, requireProjectMember } from "./authz";
|
||||||
|
|
||||||
const plannerRef = makeFunctionReference<
|
const plannerRef = makeFunctionReference<
|
||||||
"action",
|
"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(),
|
FLUE_URL: z.url().optional(),
|
||||||
GITEA_TOKEN: z.string().min(1).optional(),
|
GITEA_TOKEN: z.string().min(1).optional(),
|
||||||
GITEA_URL: z.url().default("https://git.openputer.com"),
|
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_ID: z.string().min(1).optional(),
|
||||||
GITHUB_CLIENT_SECRET: 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(),
|
GIT_CREDENTIAL_ENCRYPTION_KEY: z.string().min(43).optional(),
|
||||||
NATIVE_APP_URL: z.string().min(1).default("code://"),
|
NATIVE_APP_URL: z.string().min(1).default("code://"),
|
||||||
|
PUTER_GIT_ADMIN_TOKEN: z.string().min(1).optional(),
|
||||||
SITE_URL: z.url(),
|
SITE_URL: z.url(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
"./agent-os": "./src/agent-os.ts",
|
"./agent-os": "./src/agent-os.ts",
|
||||||
"./execution-runtime": "./src/execution-runtime.ts",
|
"./execution-runtime": "./src/execution-runtime.ts",
|
||||||
"./git": "./src/git.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-local-runtime": "./src/git-local-runtime.ts",
|
||||||
"./git-remote-runtime": "./src/git-remote-runtime.ts",
|
"./git-remote-runtime": "./src/git-remote-runtime.ts",
|
||||||
"./project": "./src/project.ts",
|
"./project": "./src/project.ts",
|
||||||
@@ -22,7 +25,8 @@
|
|||||||
"./work-design": "./src/work-design.ts",
|
"./work-design": "./src/work-design.ts",
|
||||||
"./work-lifecycle": "./src/work-lifecycle.ts",
|
"./work-lifecycle": "./src/work-lifecycle.ts",
|
||||||
"./resolver": "./src/resolver.ts",
|
"./resolver": "./src/resolver.ts",
|
||||||
"./harness-runtime": "./src/harness-runtime.ts"
|
"./harness-runtime": "./src/harness-runtime.ts",
|
||||||
|
"./host-repository": "./src/host-repository.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check-types": "tsc --noEmit",
|
"check-types": "tsc --noEmit",
|
||||||
@@ -30,8 +34,8 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentos-software/pi": "0.2.7",
|
|
||||||
"@agentos-software/git": "0.3.3",
|
"@agentos-software/git": "0.3.3",
|
||||||
|
"@agentos-software/pi": "0.2.7",
|
||||||
"@rivet-dev/agentos": "0.2.14",
|
"@rivet-dev/agentos": "0.2.14",
|
||||||
"effect": "catalog:"
|
"effect": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
/* eslint-disable max-classes-per-file -- execution boundary errors live with their schemas. */
|
/* eslint-disable max-classes-per-file -- execution boundary errors live with their schemas. */
|
||||||
import { Effect, Schema } from "effect";
|
import { Effect, Schema } from "effect";
|
||||||
|
|
||||||
|
import { GitProvider } from "./git-provider";
|
||||||
|
|
||||||
|
export type { GitProvider } from "./git-provider";
|
||||||
|
|
||||||
const Text = Schema.String.check(
|
const Text = Schema.String.check(
|
||||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||||
expected: "a non-empty string",
|
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 const GitCredentialKind = Schema.Literals(["oauth", "token"]);
|
||||||
export type GitCredentialKind = typeof GitCredentialKind.Type;
|
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 "./agent-os.ts";
|
||||||
export * from "./execution-runtime.ts";
|
export * from "./execution-runtime.ts";
|
||||||
export * from "./git.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-local-runtime.ts";
|
||||||
export * from "./git-remote-runtime.ts";
|
export * from "./git-remote-runtime.ts";
|
||||||
export * from "./project.ts";
|
export * from "./project.ts";
|
||||||
@@ -17,3 +20,4 @@ export * from "./work-design.ts";
|
|||||||
export * from "./work-lifecycle.ts";
|
export * from "./work-lifecycle.ts";
|
||||||
export * from "./resolver.ts";
|
export * from "./resolver.ts";
|
||||||
export * from "./harness-runtime.ts";
|
export * from "./harness-runtime.ts";
|
||||||
|
export * from "./host-repository.ts";
|
||||||
|
|||||||
Reference in New Issue
Block a user