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
102 lines
3.3 KiB
TypeScript
102 lines
3.3 KiB
TypeScript
import { ConvexError } from "convex/values";
|
|
|
|
import type { Id } from "./_generated/dataModel";
|
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
|
|
|
export interface AuthContext {
|
|
readonly auth: {
|
|
readonly getUserIdentity: () => Promise<{
|
|
readonly tokenIdentifier: string;
|
|
} | null>;
|
|
};
|
|
}
|
|
|
|
export const requireAuthUserId = async (ctx: AuthContext): Promise<string> => {
|
|
const identity = await ctx.auth.getUserIdentity();
|
|
if (!identity) {
|
|
throw new ConvexError("Authentication required");
|
|
}
|
|
return identity.tokenIdentifier;
|
|
};
|
|
|
|
/**
|
|
* Resolve the authenticated identity and prove it is a member of the given
|
|
* organization. Throws on unauthenticated or non-member access. Returns the
|
|
* canonical user ID (the Better Auth `tokenIdentifier`).
|
|
*/
|
|
export const requireOrganizationMember = 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) {
|
|
throw new ConvexError("Organization membership required");
|
|
}
|
|
return userId;
|
|
};
|
|
|
|
/**
|
|
* Resolve the authenticated user's personal/current organization. Browsers
|
|
* never submit organization IDs for Project commands; this resolves the
|
|
* tenant boundary server-side through the authenticated identity.
|
|
*/
|
|
export const requireCurrentOrganization = async (
|
|
ctx: QueryCtx | MutationCtx
|
|
): Promise<{ organizationId: Id<"organizations">; userId: string }> => {
|
|
const userId = await requireAuthUserId(ctx);
|
|
const organization = await ctx.db
|
|
.query("organizations")
|
|
.withIndex("by_createdBy_and_kind", (q) =>
|
|
q.eq("createdBy", userId).eq("kind", "personal")
|
|
)
|
|
.unique();
|
|
if (!organization) {
|
|
throw new ConvexError("Organization not found");
|
|
}
|
|
return { organizationId: organization._id, userId };
|
|
};
|
|
|
|
/**
|
|
* Prove the authenticated user is a member of the project's organization.
|
|
* Projects are organization-scoped; unauthorized single-object operations
|
|
* throw "Project not found" to avoid leaking existence.
|
|
*/
|
|
export const requireProjectMember = async (
|
|
ctx: QueryCtx | MutationCtx,
|
|
projectId: Id<"projects">
|
|
): Promise<{ organizationId: Id<"organizations">; userId: string }> => {
|
|
const project = await ctx.db.get(projectId);
|
|
if (!project) {
|
|
throw new ConvexError("Project not found");
|
|
}
|
|
const userId = await requireOrganizationMember(ctx, project.organizationId);
|
|
return { organizationId: project.organizationId, userId };
|
|
};
|
|
|
|
/**
|
|
* Prove the authenticated user is an owner of the given organization.
|
|
* Provisioning functions require owner-level authorization.
|
|
*/
|
|
export const requireCurrentOrganizationOwner = async (
|
|
ctx: QueryCtx | MutationCtx,
|
|
organizationId: Id<"organizations">
|
|
): Promise<string> => {
|
|
const userId = await requireAuthUserId(ctx);
|
|
const membership = await ctx.db
|
|
.query("organizationMembers")
|
|
.withIndex("by_organizationId_and_userId", (q) =>
|
|
q.eq("organizationId", organizationId).eq("userId", userId)
|
|
)
|
|
.unique();
|
|
if (!membership || membership.role !== "owner") {
|
|
throw new ConvexError("Organization owner role required");
|
|
}
|
|
return userId;
|
|
};
|