This commit is contained in:
Nathan Flurry 2026-03-14 20:28:41 -07:00
parent 3263d4f5e1
commit 0fbea6ce61
166 changed files with 6675 additions and 7105 deletions

View file

@ -38,6 +38,12 @@ export interface GitHubRepositoryRecord {
fullName: string;
cloneUrl: string;
private: boolean;
defaultBranch: string;
}
export interface GitHubBranchRecord {
name: string;
commitSha: string;
}
export interface GitHubMemberRecord {
@ -341,12 +347,14 @@ export class GitHubAppClient {
full_name: string;
clone_url: string;
private: boolean;
default_branch: string;
}>("/user/repos?per_page=100&affiliation=owner,collaborator,organization_member&sort=updated", accessToken);
return repositories.map((repository) => ({
fullName: repository.full_name,
cloneUrl: repository.clone_url,
private: repository.private,
defaultBranch: repository.default_branch,
}));
}
@ -356,12 +364,14 @@ export class GitHubAppClient {
full_name: string;
clone_url: string;
private: boolean;
default_branch: string;
}>("/installation/repositories?per_page=100", accessToken);
return repositories.map((repository) => ({
fullName: repository.full_name,
cloneUrl: repository.clone_url,
private: repository.private,
defaultBranch: repository.default_branch,
}));
}
@ -371,11 +381,13 @@ export class GitHubAppClient {
full_name: string;
clone_url: string;
private: boolean;
default_branch: string;
}>(`/repos/${fullName}`, accessToken);
return {
fullName: repository.full_name,
cloneUrl: repository.clone_url,
private: repository.private,
defaultBranch: repository.default_branch,
};
} catch (error) {
if (error instanceof GitHubAppError && error.status === 404) {
@ -390,6 +402,15 @@ export class GitHubAppClient {
return await this.getUserRepository(accessToken, fullName);
}
async listUserRepositoryBranches(accessToken: string, fullName: string): Promise<GitHubBranchRecord[]> {
return await this.listRepositoryBranches(accessToken, fullName);
}
async listInstallationRepositoryBranches(installationId: number, fullName: string): Promise<GitHubBranchRecord[]> {
const accessToken = await this.createInstallationAccessToken(installationId);
return await this.listRepositoryBranches(accessToken, fullName);
}
async listOrganizationMembers(accessToken: string, organizationLogin: string): Promise<GitHubMemberRecord[]> {
const members = await this.paginate<{
id: number;
@ -687,6 +708,20 @@ export class GitHubAppClient {
nextUrl: parseNextLink(response.headers.get("link")),
};
}
private async listRepositoryBranches(accessToken: string, fullName: string): Promise<GitHubBranchRecord[]> {
const branches = await this.paginate<{
name: string;
commit?: { sha?: string | null } | null;
}>(`/repos/${fullName}/branches?per_page=100`, accessToken);
return branches
.map((branch) => ({
name: branch.name?.trim() ?? "",
commitSha: branch.commit?.sha?.trim() ?? "",
}))
.filter((branch) => branch.name.length > 0 && branch.commitSha.length > 0);
}
}
function parseNextLink(linkHeader: string | null): string | null {

View file

@ -1,7 +1,7 @@
import { betterAuth } from "better-auth";
import { createAdapterFactory } from "better-auth/adapters";
import { APP_SHELL_WORKSPACE_ID } from "../actors/workspace/app-shell.js";
import { authUserKey, workspaceKey } from "../actors/keys.js";
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/app-shell.js";
import { authUserKey, organizationKey } from "../actors/keys.js";
import { logger } from "../logging.js";
const AUTH_BASE_PATH = "/v1/auth";
@ -43,7 +43,7 @@ async function callAuthEndpoint(auth: any, url: string, init?: RequestInit): Pro
return await auth.handler(new Request(url, init));
}
function resolveRouteUserId(workspace: any, resolved: any): string | null {
function resolveRouteUserId(organization: any, resolved: any): string | null {
if (!resolved) {
return null;
}
@ -75,11 +75,11 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
// getOrCreate is intentional here: the adapter runs during Better Auth callbacks
// which can fire before any explicit create path. The app workspace and auth user
// which can fire before any explicit create path. The app organization and auth user
// actors must exist by the time the adapter needs them.
const appWorkspace = () =>
actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
createWithInput: APP_SHELL_WORKSPACE_ID,
const appOrganization = () =>
actorClient.organization.getOrCreate(organizationKey(APP_SHELL_ORGANIZATION_ID), {
createWithInput: APP_SHELL_ORGANIZATION_ID,
});
// getOrCreate is intentional: Better Auth creates user records during OAuth
@ -109,9 +109,9 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const email = direct("email");
if (typeof email === "string" && email.length > 0) {
const workspace = await appWorkspace();
const resolved = await workspace.authFindEmailIndex({ email: email.toLowerCase() });
return resolveRouteUserId(workspace, resolved);
const organization = await appOrganization();
const resolved = await organization.authFindEmailIndex({ email: email.toLowerCase() });
return resolveRouteUserId(organization, resolved);
}
return null;
}
@ -124,12 +124,12 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const sessionId = direct("id") ?? data?.id;
const sessionToken = direct("token") ?? data?.token;
if (typeof sessionId === "string" || typeof sessionToken === "string") {
const workspace = await appWorkspace();
const resolved = await workspace.authFindSessionIndex({
const organization = await appOrganization();
const resolved = await organization.authFindSessionIndex({
...(typeof sessionId === "string" ? { sessionId } : {}),
...(typeof sessionToken === "string" ? { sessionToken } : {}),
});
return resolveRouteUserId(workspace, resolved);
return resolveRouteUserId(organization, resolved);
}
return null;
}
@ -142,14 +142,14 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const accountRecordId = direct("id") ?? data?.id;
const providerId = direct("providerId") ?? data?.providerId;
const accountId = direct("accountId") ?? data?.accountId;
const workspace = await appWorkspace();
const organization = await appOrganization();
if (typeof accountRecordId === "string" && accountRecordId.length > 0) {
const resolved = await workspace.authFindAccountIndex({ id: accountRecordId });
return resolveRouteUserId(workspace, resolved);
const resolved = await organization.authFindAccountIndex({ id: accountRecordId });
return resolveRouteUserId(organization, resolved);
}
if (typeof providerId === "string" && providerId.length > 0 && typeof accountId === "string" && accountId.length > 0) {
const resolved = await workspace.authFindAccountIndex({ providerId, accountId });
return resolveRouteUserId(workspace, resolved);
const resolved = await organization.authFindAccountIndex({ providerId, accountId });
return resolveRouteUserId(organization, resolved);
}
return null;
}
@ -157,9 +157,9 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return null;
};
const ensureWorkspaceVerification = async (method: string, payload: Record<string, unknown>) => {
const workspace = await appWorkspace();
return await workspace[method](payload);
const ensureOrganizationVerification = async (method: string, payload: Record<string, unknown>) => {
const organization = await appOrganization();
return await organization[method](payload);
};
return {
@ -170,7 +170,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
create: async ({ model, data }) => {
const transformed = await transformInput(data, model, "create", true);
if (model === "verification") {
return await ensureWorkspaceVerification("authCreateVerification", { data: transformed });
return await ensureOrganizationVerification("authCreateVerification", { data: transformed });
}
const userId = await resolveUserIdForQuery(model, undefined, transformed);
@ -180,17 +180,17 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const userActor = await getAuthUser(userId);
const created = await userActor.createAuthRecord({ model, data: transformed });
const workspace = await appWorkspace();
const organization = await appOrganization();
if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) {
await workspace.authUpsertEmailIndex({
await organization.authUpsertEmailIndex({
email: transformed.email.toLowerCase(),
userId,
});
}
if (model === "session") {
await workspace.authUpsertSessionIndex({
await organization.authUpsertSessionIndex({
sessionId: String(created.id),
sessionToken: String(created.token),
userId,
@ -198,7 +198,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
if (model === "account") {
await workspace.authUpsertAccountIndex({
await organization.authUpsertAccountIndex({
id: String(created.id),
providerId: String(created.providerId),
accountId: String(created.accountId),
@ -212,7 +212,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
findOne: async ({ model, where, join }) => {
const transformedWhere = transformWhereClause({ model, where, action: "findOne" });
if (model === "verification") {
return await ensureWorkspaceVerification("authFindOneVerification", { where: transformedWhere, join });
return await ensureOrganizationVerification("authFindOneVerification", { where: transformedWhere, join });
}
const userId = await resolveUserIdForQuery(model, transformedWhere);
@ -228,7 +228,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
findMany: async ({ model, where, limit, sortBy, offset, join }) => {
const transformedWhere = transformWhereClause({ model, where, action: "findMany" });
if (model === "verification") {
return await ensureWorkspaceVerification("authFindManyVerification", {
return await ensureOrganizationVerification("authFindManyVerification", {
where: transformedWhere,
limit,
sortBy,
@ -240,11 +240,11 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
if (model === "session") {
const tokenClause = transformedWhere?.find((entry: any) => entry.field === "token" && entry.operator === "in");
if (tokenClause && Array.isArray(tokenClause.value)) {
const workspace = await appWorkspace();
const organization = await appOrganization();
const resolved = await Promise.all(
(tokenClause.value as string[]).map(async (sessionToken: string) => ({
sessionToken,
route: await workspace.authFindSessionIndex({ sessionToken }),
route: await organization.authFindSessionIndex({ sessionToken }),
})),
);
const byUser = new Map<string, string[]>();
@ -284,7 +284,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const transformedWhere = transformWhereClause({ model, where, action: "update" });
const transformedUpdate = (await transformInput(update as Record<string, unknown>, model, "update", true)) as Record<string, unknown>;
if (model === "verification") {
return await ensureWorkspaceVerification("authUpdateVerification", { where: transformedWhere, update: transformedUpdate });
return await ensureOrganizationVerification("authUpdateVerification", { where: transformedWhere, update: transformedUpdate });
}
const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate);
@ -302,19 +302,19 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
: null;
const updated = await userActor.updateAuthRecord({ model, where: transformedWhere, update: transformedUpdate });
const workspace = await appWorkspace();
const organization = await appOrganization();
if (model === "user" && updated) {
if (before?.email && before.email !== updated.email) {
await workspace.authDeleteEmailIndex({ email: before.email.toLowerCase() });
await organization.authDeleteEmailIndex({ email: before.email.toLowerCase() });
}
if (updated.email) {
await workspace.authUpsertEmailIndex({ email: updated.email.toLowerCase(), userId });
await organization.authUpsertEmailIndex({ email: updated.email.toLowerCase(), userId });
}
}
if (model === "session" && updated) {
await workspace.authUpsertSessionIndex({
await organization.authUpsertSessionIndex({
sessionId: String(updated.id),
sessionToken: String(updated.token),
userId,
@ -322,7 +322,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
if (model === "account" && updated) {
await workspace.authUpsertAccountIndex({
await organization.authUpsertAccountIndex({
id: String(updated.id),
providerId: String(updated.providerId),
accountId: String(updated.accountId),
@ -337,7 +337,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const transformedWhere = transformWhereClause({ model, where, action: "updateMany" });
const transformedUpdate = (await transformInput(update as Record<string, unknown>, model, "update", true)) as Record<string, unknown>;
if (model === "verification") {
return await ensureWorkspaceVerification("authUpdateManyVerification", { where: transformedWhere, update: transformedUpdate });
return await ensureOrganizationVerification("authUpdateManyVerification", { where: transformedWhere, update: transformedUpdate });
}
const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate);
@ -352,7 +352,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
delete: async ({ model, where }) => {
const transformedWhere = transformWhereClause({ model, where, action: "delete" });
if (model === "verification") {
await ensureWorkspaceVerification("authDeleteVerification", { where: transformedWhere });
await ensureOrganizationVerification("authDeleteVerification", { where: transformedWhere });
return;
}
@ -362,19 +362,19 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getAuthUser(userId);
const workspace = await appWorkspace();
const organization = await appOrganization();
const before = await userActor.findOneAuthRecord({ model, where: transformedWhere });
await userActor.deleteAuthRecord({ model, where: transformedWhere });
if (model === "session" && before) {
await workspace.authDeleteSessionIndex({
await organization.authDeleteSessionIndex({
sessionId: before.id,
sessionToken: before.token,
});
}
if (model === "account" && before) {
await workspace.authDeleteAccountIndex({
await organization.authDeleteAccountIndex({
id: before.id,
providerId: before.providerId,
accountId: before.accountId,
@ -382,14 +382,14 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
if (model === "user" && before?.email) {
await workspace.authDeleteEmailIndex({ email: before.email.toLowerCase() });
await organization.authDeleteEmailIndex({ email: before.email.toLowerCase() });
}
},
deleteMany: async ({ model, where }) => {
const transformedWhere = transformWhereClause({ model, where, action: "deleteMany" });
if (model === "verification") {
return await ensureWorkspaceVerification("authDeleteManyVerification", { where: transformedWhere });
return await ensureOrganizationVerification("authDeleteManyVerification", { where: transformedWhere });
}
if (model === "session") {
@ -398,11 +398,11 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return 0;
}
const userActor = await getAuthUser(userId);
const workspace = await appWorkspace();
const organization = await appOrganization();
const sessions = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit: 5000 });
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
for (const session of sessions) {
await workspace.authDeleteSessionIndex({
await organization.authDeleteSessionIndex({
sessionId: session.id,
sessionToken: session.token,
});
@ -423,7 +423,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
count: async ({ model, where }) => {
const transformedWhere = transformWhereClause({ model, where, action: "count" });
if (model === "verification") {
return await ensureWorkspaceVerification("authCountVerification", { where: transformedWhere });
return await ensureOrganizationVerification("authCountVerification", { where: transformedWhere });
}
const userId = await resolveUserIdForQuery(model, transformedWhere);
@ -476,8 +476,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
},
async getAuthState(sessionId: string) {
const workspace = await appWorkspace();
const route = await workspace.authFindSessionIndex({ sessionId });
const organization = await appOrganization();
const route = await organization.authFindSessionIndex({ sessionId });
if (!route?.userId) {
return null;
}

View file

@ -1,20 +0,0 @@
import type { AppConfig } from "@sandbox-agent/foundry-shared";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
function expandPath(input: string): string {
if (input.startsWith("~/")) {
return `${homedir()}/${input.slice(2)}`;
}
return input;
}
export function foundryDataDir(config: AppConfig): string {
// Keep data collocated with the backend DB by default.
const dbPath = expandPath(config.backend.dbPath);
return resolve(dirname(dbPath));
}
export function foundryRepoClonePath(config: AppConfig, workspaceId: string, repoId: string): string {
return resolve(join(foundryDataDir(config), "repos", workspaceId, repoId));
}

View file

@ -1,20 +1,20 @@
import { getOrCreateWorkspace } from "../actors/handles.js";
import { APP_SHELL_WORKSPACE_ID } from "../actors/workspace/app-shell.js";
import { getOrCreateOrganization } from "../actors/handles.js";
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/app-shell.js";
export interface ResolvedGithubAuth {
githubToken: string;
scopes: string[];
}
export async function resolveWorkspaceGithubAuth(c: any, workspaceId: string): Promise<ResolvedGithubAuth | null> {
if (!workspaceId || workspaceId === APP_SHELL_WORKSPACE_ID) {
export async function resolveOrganizationGithubAuth(c: any, organizationId: string): Promise<ResolvedGithubAuth | null> {
if (!organizationId || organizationId === APP_SHELL_ORGANIZATION_ID) {
return null;
}
try {
const appWorkspace = await getOrCreateWorkspace(c, APP_SHELL_WORKSPACE_ID);
const resolved = await appWorkspace.resolveAppGithubToken({
organizationId: workspaceId,
const appOrganization = await getOrCreateOrganization(c, APP_SHELL_ORGANIZATION_ID);
const resolved = await appOrganization.resolveAppGithubToken({
organizationId: organizationId,
requireRepoScope: true,
});
if (!resolved?.accessToken) {

View file

@ -1,45 +0,0 @@
interface RepoLockState {
locked: boolean;
waiters: Array<() => void>;
}
const repoLocks = new Map<string, RepoLockState>();
async function acquireRepoLock(repoPath: string): Promise<() => void> {
let state = repoLocks.get(repoPath);
if (!state) {
state = { locked: false, waiters: [] };
repoLocks.set(repoPath, state);
}
if (!state.locked) {
state.locked = true;
return () => releaseRepoLock(repoPath, state);
}
await new Promise<void>((resolve) => {
state!.waiters.push(resolve);
});
return () => releaseRepoLock(repoPath, state!);
}
function releaseRepoLock(repoPath: string, state: RepoLockState): void {
const next = state.waiters.shift();
if (next) {
next();
return;
}
state.locked = false;
repoLocks.delete(repoPath);
}
export async function withRepoGitLock<T>(repoPath: string, fn: () => Promise<T>): Promise<T> {
const release = await acquireRepoLock(repoPath);
try {
return await fn();
} finally {
release();
}
}

View file

@ -82,3 +82,30 @@ export function repoLabelFromRemote(remoteUrl: string): string {
return basename(trimmed.replace(/\.git$/i, ""));
}
export function githubRepoFullNameFromRemote(remoteUrl: string): string | null {
const normalized = normalizeRemoteUrl(remoteUrl);
if (!normalized) {
return null;
}
try {
const url = new URL(normalized);
const hostname = url.hostname.replace(/^www\./i, "").toLowerCase();
if (hostname !== "github.com") {
return null;
}
const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
if (parts.length < 2) {
return null;
}
const owner = parts[0]?.trim();
const repo = (parts[1] ?? "").replace(/\.git$/i, "").trim();
if (!owner || !repo) {
return null;
}
return `${owner}/${repo}`;
} catch {
return null;
}
}