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:
@@ -1,6 +1,10 @@
|
||||
/* eslint-disable max-classes-per-file -- execution boundary errors live with their schemas. */
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
import { GitProvider } from "./git-provider";
|
||||
|
||||
export type { GitProvider } from "./git-provider";
|
||||
|
||||
const Text = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
@@ -21,9 +25,6 @@ const HttpUrl = Schema.String.check(
|
||||
)
|
||||
);
|
||||
|
||||
export const GitProvider = Schema.Literals(["github", "gitea"]);
|
||||
export type GitProvider = typeof GitProvider.Type;
|
||||
|
||||
export const GitCredentialKind = Schema.Literals(["oauth", "token"]);
|
||||
export type GitCredentialKind = typeof GitCredentialKind.Type;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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}`;
|
||||
@@ -0,0 +1,188 @@
|
||||
/* eslint-disable max-classes-per-file -- host repository preparation service and error form one contract. */
|
||||
import type { Effect } from "effect";
|
||||
import { Context, Schema } from "effect";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Text = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
})
|
||||
);
|
||||
|
||||
const HttpUrl = Schema.String.check(
|
||||
Schema.makeFilter(
|
||||
(value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ expected: "an HTTP URL" }
|
||||
)
|
||||
);
|
||||
|
||||
/** Input for preparing a repository checkout on the host. */
|
||||
export const HostRepositoryInput = Schema.Struct({
|
||||
baseBranch: Text,
|
||||
cloneUrl: HttpUrl,
|
||||
/** Decrypted credential for the provider (PAT or OAuth token). */
|
||||
credential: Schema.UndefinedOr(Text),
|
||||
/** Provider type: github uses x-access-token as the username, gitea uses
|
||||
* the account's external username. */
|
||||
provider: Schema.UndefinedOr(Text),
|
||||
/** Unique workspace key for isolation. */
|
||||
username: Schema.UndefinedOr(Text),
|
||||
workspaceKey: Text,
|
||||
});
|
||||
export type HostRepositoryInput = typeof HostRepositoryInput.Type;
|
||||
|
||||
/** Result of preparing a repository checkout on the host. */
|
||||
export const HostRepositoryResult = Schema.Struct({
|
||||
/** The base revision checked out. */
|
||||
baseRevision: Text,
|
||||
checkoutPath: Text,
|
||||
});
|
||||
export type HostRepositoryResult = typeof HostRepositoryResult.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const HostRepositoryErrorReason = Schema.Literals([
|
||||
"Authentication",
|
||||
"CloneFailed",
|
||||
"CheckoutFailed",
|
||||
"InvalidInput",
|
||||
"Timeout",
|
||||
]);
|
||||
export type HostRepositoryErrorReason = typeof HostRepositoryErrorReason.Type;
|
||||
|
||||
export class HostRepositoryError extends Schema.TaggedErrorClass<HostRepositoryError>()(
|
||||
"HostRepositoryError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: HostRepositoryErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HostRepository service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Provider-neutral host repository preparation. Clones/fetches the repository
|
||||
* using the decrypted credential, removes the credential from the remote URL
|
||||
* and git config, and mounts the checkout into the AgentOS VM.
|
||||
*
|
||||
* The VM never receives the provider credential. It edits and runs Git locally.
|
||||
* The host collects the diff and performs authenticated push/delivery later.
|
||||
*
|
||||
* Future `sandbox credential lease` boundary is documented but not implemented.
|
||||
*/
|
||||
interface HostRepositoryShape {
|
||||
readonly prepare: (
|
||||
input: HostRepositoryInput
|
||||
) => Effect.Effect<HostRepositoryResult, HostRepositoryError>;
|
||||
}
|
||||
|
||||
export class HostRepository extends Context.Service<
|
||||
HostRepository,
|
||||
HostRepositoryShape
|
||||
>()("@code/primitives/host-repository/HostRepository") {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential-safe clone URL construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a clone URL with credentials embedded for the initial clone only.
|
||||
* The credential is removed from the remote URL immediately after cloning
|
||||
* so it never persists in git config.
|
||||
*/
|
||||
export const buildAuthenticatedCloneUrl = (
|
||||
cloneUrl: string,
|
||||
credential?: string
|
||||
): string => {
|
||||
if (!credential) {
|
||||
return cloneUrl;
|
||||
}
|
||||
try {
|
||||
const url = new URL(cloneUrl);
|
||||
// Inject the token as a Bearer credential in the URL.
|
||||
// For GitHub: x-access-token:<token>@
|
||||
// For Gitea: the token is used as the username with no password.
|
||||
url.username = credential;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return cloneUrl;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip credentials from a git remote URL. Called after cloning to ensure
|
||||
* the credential does not persist in `.git/config`.
|
||||
*/
|
||||
export const stripCredentialsFromUrl = (url: string): string => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell command builder for credential-safe clone
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the git arguments for cloning a repository safely. Returns arrays
|
||||
* of string arguments suitable for `execFileSync` (no shell interpolation).
|
||||
*
|
||||
* 1. Clone with credentials (if provided)
|
||||
* 2. Remove credentials from the remote URL
|
||||
* 3. Checkout the base branch
|
||||
* 4. Capture the base revision
|
||||
*
|
||||
* Using argument arrays instead of shell strings prevents credential-driven
|
||||
* command injection. The credential is never written to git config permanently.
|
||||
*/
|
||||
export const buildCloneSteps = (input: {
|
||||
baseBranch: string;
|
||||
checkoutPath: string;
|
||||
cloneUrl: string;
|
||||
credential?: string;
|
||||
}): readonly {
|
||||
readonly args: readonly string[];
|
||||
readonly description: string;
|
||||
}[] => {
|
||||
const { baseBranch, checkoutPath, cloneUrl, credential } = input;
|
||||
const authenticatedUrl = buildAuthenticatedCloneUrl(cloneUrl, credential);
|
||||
const cleanUrl = stripCredentialsFromUrl(cloneUrl);
|
||||
|
||||
return [
|
||||
{
|
||||
args: ["clone", "--no-tags", authenticatedUrl, checkoutPath],
|
||||
description: "git clone",
|
||||
},
|
||||
{
|
||||
args: ["-C", checkoutPath, "remote", "set-url", "origin", cleanUrl],
|
||||
description: "strip credentials from remote URL",
|
||||
},
|
||||
{
|
||||
args: ["-C", checkoutPath, "checkout", baseBranch],
|
||||
description: "checkout base branch",
|
||||
},
|
||||
{
|
||||
args: ["-C", checkoutPath, "rev-parse", "HEAD"],
|
||||
description: "capture base revision",
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -2,6 +2,9 @@
|
||||
export * from "./agent-os.ts";
|
||||
export * from "./execution-runtime.ts";
|
||||
export * from "./git.ts";
|
||||
export * from "./git-provider.ts";
|
||||
export * from "./git-provisioning.ts";
|
||||
export * from "./git-webhook.ts";
|
||||
export * from "./git-local-runtime.ts";
|
||||
export * from "./git-remote-runtime.ts";
|
||||
export * from "./project.ts";
|
||||
@@ -17,3 +20,4 @@ export * from "./work-design.ts";
|
||||
export * from "./work-lifecycle.ts";
|
||||
export * from "./resolver.ts";
|
||||
export * from "./harness-runtime.ts";
|
||||
export * from "./host-repository.ts";
|
||||
|
||||
Reference in New Issue
Block a user