sandbox-agent/foundry/packages/backend/src/actors/organization/app-shell.ts

1950 lines
70 KiB
TypeScript

import { and, asc, count as sqlCount, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, notInArray, or } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import type {
FoundryAppSnapshot,
FoundryBillingPlanId,
FoundryBillingState,
FoundryOrganization,
FoundryOrganizationMember,
FoundryUser,
UpdateFoundryOrganizationProfileInput,
WorkspaceModelId,
} from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateGithubData, getOrCreateOrganization, selfOrganization } from "../handles.js";
import { GitHubAppError } from "../../services/app-github.js";
import { getBetterAuthService } from "../../services/better-auth.js";
import { repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js";
import { logger } from "../../logging.js";
import {
authAccountIndex,
authEmailIndex,
authSessionIndex,
authVerification,
invoices,
organizationMembers,
organizationProfile,
repos,
seatAssignments,
stripeLookup,
} from "./db/schema.js";
export const APP_SHELL_ORGANIZATION_ID = "app";
// ── Better Auth adapter where-clause helpers ──
// These convert the adapter's `{ field, value, operator }` clause arrays into
// Drizzle predicates for organization-level auth index / verification tables.
function organizationAuthColumn(table: any, field: string): any {
const column = table[field];
if (!column) {
throw new Error(`Unknown auth table field: ${field}`);
}
return column;
}
function normalizeAuthValue(value: unknown): unknown {
if (value instanceof Date) {
return value.getTime();
}
if (Array.isArray(value)) {
return value.map((entry) => normalizeAuthValue(entry));
}
return value;
}
function organizationAuthClause(table: any, clause: { field: string; value: unknown; operator?: string }): any {
const column = organizationAuthColumn(table, clause.field);
const value = normalizeAuthValue(clause.value);
switch (clause.operator) {
case "ne":
return value === null ? isNotNull(column) : ne(column, value as any);
case "lt":
return lt(column, value as any);
case "lte":
return lte(column, value as any);
case "gt":
return gt(column, value as any);
case "gte":
return gte(column, value as any);
case "in":
return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "not_in":
return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "contains":
return like(column, `%${String(value ?? "")}%`);
case "starts_with":
return like(column, `${String(value ?? "")}%`);
case "ends_with":
return like(column, `%${String(value ?? "")}`);
case "eq":
default:
return value === null ? isNull(column) : eq(column, value as any);
}
}
function organizationAuthWhere(table: any, clauses: any[] | undefined): any {
if (!clauses || clauses.length === 0) {
return undefined;
}
let expr = organizationAuthClause(table, clauses[0]);
for (const clause of clauses.slice(1)) {
const next = organizationAuthClause(table, clause);
expr = clause.connector === "OR" ? or(expr, next) : and(expr, next);
}
return expr;
}
const githubWebhookLogger = logger.child({
scope: "github-webhook",
});
const PROFILE_ROW_ID = 1;
function roundDurationMs(start: number): number {
return Math.round((performance.now() - start) * 100) / 100;
}
function assertAppOrganization(c: any): void {
if (c.state.organizationId !== APP_SHELL_ORGANIZATION_ID) {
throw new Error(`App shell action requires organization ${APP_SHELL_ORGANIZATION_ID}, got ${c.state.organizationId}`);
}
}
function assertOrganizationShell(c: any): void {
if (c.state.organizationId === APP_SHELL_ORGANIZATION_ID) {
throw new Error("Organization action cannot run on the reserved app organization");
}
}
function slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function personalOrganizationId(login: string): string {
return `personal-${slugify(login)}`;
}
function organizationOrganizationId(kind: FoundryOrganization["kind"], login: string): string {
return kind === "personal" ? personalOrganizationId(login) : slugify(login);
}
function hasRepoScope(scopes: string[]): boolean {
return scopes.some((scope) => scope === "repo" || scope.startsWith("repo:"));
}
function parseEligibleOrganizationIds(value: string): string[] {
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
} catch {
return [];
}
}
function encodeEligibleOrganizationIds(value: string[]): string {
return JSON.stringify([...new Set(value)]);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function seatsIncludedForPlan(planId: FoundryBillingPlanId): number {
switch (planId) {
case "free":
return 1;
case "team":
return 5;
}
}
function stripeStatusToBillingStatus(stripeStatus: string, cancelAtPeriodEnd: boolean): FoundryBillingState["status"] {
if (cancelAtPeriodEnd) {
return "scheduled_cancel";
}
if (stripeStatus === "trialing") {
return "trialing";
}
if (stripeStatus === "past_due" || stripeStatus === "unpaid" || stripeStatus === "incomplete") {
return "past_due";
}
return "active";
}
function formatUnixDate(value: number): string {
return new Date(value * 1000).toISOString().slice(0, 10);
}
function legacyRepoImportStatusToGithubSyncStatus(value: string | null | undefined): FoundryOrganization["github"]["syncStatus"] {
switch (value) {
case "ready":
return "synced";
case "importing":
return "syncing";
default:
return "pending";
}
}
function stringFromMetadata(metadata: unknown, key: string): string | null {
if (!metadata || typeof metadata !== "object") {
return null;
}
const value = (metadata as Record<string, unknown>)[key];
return typeof value === "string" && value.length > 0 ? value : null;
}
function stripeWebhookSubscription(event: any) {
const object = event.data.object as Record<string, unknown>;
const items = (object.items as { data?: Array<Record<string, unknown>> } | undefined)?.data ?? [];
const price = items[0]?.price as Record<string, unknown> | undefined;
return {
id: typeof object.id === "string" ? object.id : "",
customerId: typeof object.customer === "string" ? object.customer : "",
priceId: typeof price?.id === "string" ? price.id : null,
status: typeof object.status === "string" ? object.status : "active",
cancelAtPeriodEnd: object.cancel_at_period_end === true,
currentPeriodEnd: typeof object.current_period_end === "number" ? object.current_period_end : null,
trialEnd: typeof object.trial_end === "number" ? object.trial_end : null,
defaultPaymentMethodLabel: "Payment method on file",
};
}
async function getOrganizationState(organization: any) {
return await organization.getOrganizationShellState({});
}
async function getOrganizationStateIfInitialized(organization: any) {
return await organization.getOrganizationShellStateIfInitialized({});
}
async function listSnapshotOrganizations(c: any, sessionId: string, organizationIds: string[]) {
const results = await Promise.all(
organizationIds.map(async (organizationId) => {
const organizationStartedAt = performance.now();
try {
const organization = await getOrCreateOrganization(c, organizationId);
const organizationState = await getOrganizationStateIfInitialized(organization);
if (!organizationState) {
logger.warn(
{
sessionId,
actorOrganizationId: c.state.organizationId,
organizationId,
durationMs: roundDurationMs(organizationStartedAt),
},
"build_app_snapshot_organization_uninitialized",
);
return { organizationId, snapshot: null, status: "uninitialized" as const };
}
logger.info(
{
sessionId,
actorOrganizationId: c.state.organizationId,
organizationId,
durationMs: roundDurationMs(organizationStartedAt),
},
"build_app_snapshot_organization_completed",
);
return { organizationId, snapshot: organizationState.snapshot, status: "ok" as const };
} catch (error) {
const message = errorMessage(error);
if (!message.includes("Actor not found")) {
logger.error(
{
sessionId,
actorOrganizationId: c.state.organizationId,
organizationId,
durationMs: roundDurationMs(organizationStartedAt),
errorMessage: message,
errorStack: error instanceof Error ? error.stack : undefined,
},
"build_app_snapshot_organization_failed",
);
throw error;
}
logger.info(
{
sessionId,
actorOrganizationId: c.state.organizationId,
organizationId,
durationMs: roundDurationMs(organizationStartedAt),
},
"build_app_snapshot_organization_missing",
);
return { organizationId, snapshot: null, status: "missing" as const };
}
}),
);
return {
organizations: results.map((result) => result.snapshot).filter((organization): organization is FoundryOrganization => organization !== null),
uninitializedOrganizationIds: results.filter((result) => result.status === "uninitialized").map((result) => result.organizationId),
};
}
async function buildAppSnapshot(c: any, sessionId: string, allowOrganizationRepair = true): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const startedAt = performance.now();
const auth = getBetterAuthService();
let authState = await auth.getAuthState(sessionId);
// Inline fallback: if the user is signed in but has no eligible organizations yet
// (e.g. first load after OAuth callback), sync GitHub orgs before building the snapshot.
if (authState?.user && parseEligibleOrganizationIds(authState.profile?.eligibleOrganizationIdsJson ?? "[]").length === 0) {
const token = await auth.getAccessTokenForSession(sessionId);
if (token?.accessToken) {
logger.info({ sessionId }, "build_app_snapshot_sync_orgs");
await syncGithubOrganizations(c, { sessionId, accessToken: token.accessToken });
authState = await auth.getAuthState(sessionId);
} else {
logger.warn({ sessionId }, "build_app_snapshot_no_access_token");
}
}
const session = authState?.session ?? null;
const user = authState?.user ?? null;
const profile = authState?.profile ?? null;
const currentSessionState = authState?.sessionState ?? null;
const githubAccount = authState?.accounts?.find((account: any) => account.providerId === "github") ?? null;
const eligibleOrganizationIds = parseEligibleOrganizationIds(profile?.eligibleOrganizationIdsJson ?? "[]");
logger.info(
{
sessionId,
organizationId: c.state.organizationId,
eligibleOrganizationCount: eligibleOrganizationIds.length,
eligibleOrganizationIds,
},
"build_app_snapshot_started",
);
let { organizations, uninitializedOrganizationIds } = await listSnapshotOrganizations(c, sessionId, eligibleOrganizationIds);
if (allowOrganizationRepair && uninitializedOrganizationIds.length > 0) {
const token = await auth.getAccessTokenForSession(sessionId);
if (token?.accessToken) {
logger.info(
{
sessionId,
organizationId: c.state.organizationId,
organizationIds: uninitializedOrganizationIds,
},
"build_app_snapshot_repairing_organizations",
);
await syncGithubOrganizationsInternal(c, { sessionId, accessToken: token.accessToken }, { broadcast: false });
return await buildAppSnapshot(c, sessionId, false);
}
logger.warn(
{
sessionId,
organizationId: c.state.organizationId,
organizationIds: uninitializedOrganizationIds,
},
"build_app_snapshot_repair_skipped_no_access_token",
);
}
const currentUser: FoundryUser | null = user
? {
id: profile?.githubAccountId ?? githubAccount?.accountId ?? user.id,
name: user.name,
email: user.email,
githubLogin: profile?.githubLogin ?? "",
roleLabel: profile?.roleLabel ?? "GitHub user",
eligibleOrganizationIds,
defaultModel: profile?.defaultModel ?? "claude-sonnet-4",
}
: null;
const activeOrganizationId =
currentUser &&
currentSessionState?.activeOrganizationId &&
organizations.some((organization) => organization.id === currentSessionState.activeOrganizationId)
? currentSessionState.activeOrganizationId
: currentUser && organizations.length === 1
? (organizations[0]?.id ?? null)
: null;
const snapshot: FoundryAppSnapshot = {
auth: {
status: currentUser ? "signed_in" : "signed_out",
currentUserId: currentUser?.id ?? null,
},
activeOrganizationId,
onboarding: {
starterRepo: {
repoFullName: "rivet-dev/sandbox-agent",
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
status: profile?.starterRepoStatus ?? "pending",
starredAt: profile?.starterRepoStarredAt ?? null,
skippedAt: profile?.starterRepoSkippedAt ?? null,
},
},
users: currentUser ? [currentUser] : [],
organizations,
};
logger.info(
{
sessionId,
organizationId: c.state.organizationId,
eligibleOrganizationCount: eligibleOrganizationIds.length,
organizationCount: organizations.length,
durationMs: roundDurationMs(startedAt),
},
"build_app_snapshot_completed",
);
return snapshot;
}
async function requireSignedInSession(c: any, sessionId: string) {
const auth = getBetterAuthService();
const authState = await auth.getAuthState(sessionId);
const user = authState?.user ?? null;
const profile = authState?.profile ?? null;
const githubAccount = authState?.accounts?.find((account: any) => account.providerId === "github") ?? null;
if (!authState?.session || !user?.email) {
throw new Error("User must be signed in");
}
const token = await auth.getAccessTokenForSession(sessionId);
return {
...authState.session,
authUserId: user.id,
currentUserId: profile?.githubAccountId ?? githubAccount?.accountId ?? user.id,
currentUserName: user.name,
currentUserEmail: user.email,
currentUserGithubLogin: profile?.githubLogin ?? "",
currentUserRoleLabel: profile?.roleLabel ?? "GitHub user",
eligibleOrganizationIdsJson: profile?.eligibleOrganizationIdsJson ?? "[]",
githubAccessToken: token?.accessToken ?? null,
githubScope: (token?.scopes ?? []).join(","),
starterRepoStatus: profile?.starterRepoStatus ?? "pending",
starterRepoStarredAt: profile?.starterRepoStarredAt ?? null,
starterRepoSkippedAt: profile?.starterRepoSkippedAt ?? null,
};
}
function requireEligibleOrganization(session: any, organizationId: string): void {
const eligibleOrganizationIds = parseEligibleOrganizationIds(session.eligibleOrganizationIdsJson);
if (!eligibleOrganizationIds.includes(organizationId)) {
throw new Error(`Organization ${organizationId} is not available in this app session`);
}
}
async function upsertStripeLookupEntries(c: any, organizationId: string, customerId: string | null, subscriptionId: string | null): Promise<void> {
assertAppOrganization(c);
const now = Date.now();
for (const lookupKey of [customerId ? `customer:${customerId}` : null, subscriptionId ? `subscription:${subscriptionId}` : null]) {
if (!lookupKey) {
continue;
}
await c.db
.insert(stripeLookup)
.values({
lookupKey,
organizationId,
updatedAt: now,
})
.onConflictDoUpdate({
target: stripeLookup.lookupKey,
set: {
organizationId,
updatedAt: now,
},
})
.run();
}
}
async function findOrganizationIdForStripeEvent(c: any, customerId: string | null, subscriptionId: string | null): Promise<string | null> {
assertAppOrganization(c);
const customerLookup = customerId
? await c.db
.select({ organizationId: stripeLookup.organizationId })
.from(stripeLookup)
.where(eq(stripeLookup.lookupKey, `customer:${customerId}`))
.get()
: null;
if (customerLookup?.organizationId) {
return customerLookup.organizationId;
}
const subscriptionLookup = subscriptionId
? await c.db
.select({ organizationId: stripeLookup.organizationId })
.from(stripeLookup)
.where(eq(stripeLookup.lookupKey, `subscription:${subscriptionId}`))
.get()
: null;
return subscriptionLookup?.organizationId ?? null;
}
async function safeListOrganizations(accessToken: string): Promise<any[]> {
const { appShell } = getActorRuntimeContext();
try {
return await appShell.github.listOrganizations(accessToken);
} catch (error) {
if (error instanceof GitHubAppError && error.status === 403) {
return [];
}
throw error;
}
}
async function safeListInstallations(accessToken: string): Promise<any[]> {
const { appShell } = getActorRuntimeContext();
try {
return await appShell.github.listInstallations(accessToken);
} catch (error) {
if (error instanceof GitHubAppError && (error.status === 403 || error.status === 404)) {
return [];
}
throw error;
}
}
/**
* Slow path: list GitHub orgs + installations, sync each org organization,
* and update the session's eligible organization list. Called from the
* workflow queue so it runs in the background after the callback has
* already returned a redirect to the browser.
*/
export async function syncGithubOrganizations(c: any, input: { sessionId: string; accessToken: string }): Promise<void> {
await syncGithubOrganizationsInternal(c, input, { broadcast: true });
}
async function syncGithubOrganizationsInternal(c: any, input: { sessionId: string; accessToken: string }, options: { broadcast: boolean }): Promise<void> {
assertAppOrganization(c);
const auth = getBetterAuthService();
const { appShell } = getActorRuntimeContext();
const { sessionId, accessToken } = input;
const authState = await auth.getAuthState(sessionId);
if (!authState?.user) {
throw new Error("User must be signed in");
}
const viewer = await appShell.github.getViewer(accessToken);
const organizations = await safeListOrganizations(accessToken);
const installations = await safeListInstallations(accessToken);
const authUserId = authState.user.id;
const githubUserId = String(viewer.id);
const linkedOrganizationIds: string[] = [];
const accounts = [
{
githubAccountId: viewer.id,
githubLogin: viewer.login,
githubAccountType: "User",
kind: "personal" as const,
displayName: viewer.name || viewer.login,
},
...organizations.map((organization) => ({
githubAccountId: organization.id,
githubLogin: organization.login,
githubAccountType: "Organization",
kind: "organization" as const,
displayName: organization.name || organization.login,
})),
];
for (const account of accounts) {
const organizationId = organizationOrganizationId(account.kind, account.githubLogin);
const installation = installations.find((candidate) => candidate.accountLogin === account.githubLogin) ?? null;
const organization = await getOrCreateOrganization(c, organizationId);
await organization.syncOrganizationShellFromGithub({
userId: githubUserId,
userName: viewer.name || viewer.login,
userEmail: viewer.email ?? `${viewer.login}@users.noreply.github.com`,
githubUserLogin: viewer.login,
githubAccountId: account.githubAccountId,
githubLogin: account.githubLogin,
githubAccountType: account.githubAccountType,
kind: account.kind,
displayName: account.displayName,
installationId: installation?.id ?? null,
appConfigured: appShell.github.isAppConfigured(),
});
linkedOrganizationIds.push(organizationId);
}
const activeOrganizationId =
authState.sessionState?.activeOrganizationId && linkedOrganizationIds.includes(authState.sessionState.activeOrganizationId)
? authState.sessionState.activeOrganizationId
: linkedOrganizationIds.length === 1
? (linkedOrganizationIds[0] ?? null)
: null;
await auth.setActiveOrganization(sessionId, activeOrganizationId);
await auth.upsertUserProfile(authUserId, {
githubAccountId: String(viewer.id),
githubLogin: viewer.login,
roleLabel: "GitHub user",
eligibleOrganizationIdsJson: encodeEligibleOrganizationIds(linkedOrganizationIds),
});
if (!options.broadcast) {
return;
}
c.broadcast("appUpdated", {
type: "appUpdated",
snapshot: await buildAppSnapshot(c, sessionId),
});
}
async function readOrganizationProfileRow(c: any) {
assertOrganizationShell(c);
return await c.db.select().from(organizationProfile).where(eq(organizationProfile.id, PROFILE_ROW_ID)).get();
}
async function requireOrganizationProfileRow(c: any) {
const row = await readOrganizationProfileRow(c);
if (!row) {
throw new Error(`Organization profile is not initialized for organization ${c.state.organizationId}`);
}
return row;
}
async function listOrganizationMembers(c: any): Promise<FoundryOrganizationMember[]> {
assertOrganizationShell(c);
const rows = await c.db.select().from(organizationMembers).orderBy(organizationMembers.role, organizationMembers.name).all();
return rows.map((row) => ({
id: row.id,
name: row.name,
email: row.email,
role: row.role,
state: row.state,
}));
}
async function listOrganizationSeatAssignments(c: any): Promise<string[]> {
assertOrganizationShell(c);
const rows = await c.db.select({ email: seatAssignments.email }).from(seatAssignments).orderBy(seatAssignments.email).all();
return rows.map((row) => row.email);
}
async function listOrganizationInvoices(c: any): Promise<FoundryBillingState["invoices"]> {
assertOrganizationShell(c);
const rows = await c.db.select().from(invoices).orderBy(desc(invoices.issuedAt), desc(invoices.createdAt)).all();
return rows.map((row) => ({
id: row.id,
label: row.label,
issuedAt: row.issuedAt,
amountUsd: row.amountUsd,
status: row.status,
}));
}
async function listOrganizationRepoCatalog(c: any): Promise<string[]> {
assertOrganizationShell(c);
const rows = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all();
return rows.map((row) => repoLabelFromRemote(row.remoteUrl)).sort((left, right) => left.localeCompare(right));
}
async function buildOrganizationState(c: any) {
const startedAt = performance.now();
const row = await requireOrganizationProfileRow(c);
return await buildOrganizationStateFromRow(c, row, startedAt);
}
async function buildOrganizationStateIfInitialized(c: any) {
const startedAt = performance.now();
const row = await readOrganizationProfileRow(c);
if (!row) {
return null;
}
return await buildOrganizationStateFromRow(c, row, startedAt);
}
async function buildOrganizationStateFromRow(c: any, row: any, startedAt: number) {
const repoCatalog = await listOrganizationRepoCatalog(c);
const members = await listOrganizationMembers(c);
const seatAssignmentEmails = await listOrganizationSeatAssignments(c);
const invoiceRows = await listOrganizationInvoices(c);
const state = {
id: c.state.organizationId,
organizationId: c.state.organizationId,
kind: row.kind,
githubLogin: row.githubLogin,
githubInstallationId: row.githubInstallationId ?? null,
stripeCustomerId: row.stripeCustomerId ?? null,
stripeSubscriptionId: row.stripeSubscriptionId ?? null,
stripePriceId: row.stripePriceId ?? null,
billingPlanId: row.billingPlanId,
snapshot: {
id: c.state.organizationId,
organizationId: c.state.organizationId,
kind: row.kind,
settings: {
displayName: row.displayName,
slug: row.slug,
primaryDomain: row.primaryDomain,
seatAccrualMode: "first_prompt",
autoImportRepos: row.autoImportRepos === 1,
},
github: {
connectedAccount: row.githubConnectedAccount,
installationStatus: row.githubInstallationStatus,
syncStatus: row.githubSyncStatus ?? legacyRepoImportStatusToGithubSyncStatus(row.repoImportStatus),
importedRepoCount: repoCatalog.length,
lastSyncLabel: row.githubLastSyncLabel,
lastSyncAt: row.githubLastSyncAt ?? null,
lastWebhookAt: row.githubLastWebhookAt ?? null,
lastWebhookEvent: row.githubLastWebhookEvent ?? "",
},
billing: {
planId: row.billingPlanId,
status: row.billingStatus,
seatsIncluded: row.billingSeatsIncluded,
trialEndsAt: row.billingTrialEndsAt,
renewalAt: row.billingRenewalAt,
stripeCustomerId: row.stripeCustomerId ?? "",
paymentMethodLabel: row.billingPaymentMethodLabel,
invoices: invoiceRows,
},
members,
seatAssignments: seatAssignmentEmails,
repoCatalog,
},
};
logger.info(
{
organizationId: c.state.organizationId,
githubLogin: row.githubLogin,
repoCount: repoCatalog.length,
memberCount: members.length,
seatAssignmentCount: seatAssignmentEmails.length,
invoiceCount: invoiceRows.length,
durationMs: roundDurationMs(startedAt),
},
"build_organization_state_completed",
);
return state;
}
async function applySubscriptionState(
organization: any,
subscription: {
id: string;
customerId: string;
priceId: string | null;
status: string;
cancelAtPeriodEnd: boolean;
currentPeriodEnd: number | null;
trialEnd: number | null;
defaultPaymentMethodLabel: string;
},
fallbackPlanId: FoundryBillingPlanId,
): Promise<void> {
await organization.applyOrganizationStripeSubscription({
subscription,
fallbackPlanId,
});
}
export const organizationAppActions = {
async authFindSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) {
assertAppOrganization(c);
const clauses = [
...(input.sessionId ? [{ field: "sessionId", value: input.sessionId }] : []),
...(input.sessionToken ? [{ field: "sessionToken", value: input.sessionToken }] : []),
];
if (clauses.length === 0) {
return null;
}
const predicate = organizationAuthWhere(authSessionIndex, clauses);
return await c.db.select().from(authSessionIndex).where(predicate!).get();
},
async authUpsertSessionIndex(c: any, input: { sessionId: string; sessionToken: string; userId: string }) {
assertAppOrganization(c);
const now = Date.now();
await c.db
.insert(authSessionIndex)
.values({
sessionId: input.sessionId,
sessionToken: input.sessionToken,
userId: input.userId,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: authSessionIndex.sessionId,
set: {
sessionToken: input.sessionToken,
userId: input.userId,
updatedAt: now,
},
})
.run();
return await c.db.select().from(authSessionIndex).where(eq(authSessionIndex.sessionId, input.sessionId)).get();
},
async authDeleteSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) {
assertAppOrganization(c);
const clauses = [
...(input.sessionId ? [{ field: "sessionId", value: input.sessionId }] : []),
...(input.sessionToken ? [{ field: "sessionToken", value: input.sessionToken }] : []),
];
if (clauses.length === 0) {
return;
}
const predicate = organizationAuthWhere(authSessionIndex, clauses);
await c.db.delete(authSessionIndex).where(predicate!).run();
},
async authFindEmailIndex(c: any, input: { email: string }) {
assertAppOrganization(c);
return await c.db.select().from(authEmailIndex).where(eq(authEmailIndex.email, input.email)).get();
},
async authUpsertEmailIndex(c: any, input: { email: string; userId: string }) {
assertAppOrganization(c);
const now = Date.now();
await c.db
.insert(authEmailIndex)
.values({
email: input.email,
userId: input.userId,
updatedAt: now,
})
.onConflictDoUpdate({
target: authEmailIndex.email,
set: {
userId: input.userId,
updatedAt: now,
},
})
.run();
return await c.db.select().from(authEmailIndex).where(eq(authEmailIndex.email, input.email)).get();
},
async authDeleteEmailIndex(c: any, input: { email: string }) {
assertAppOrganization(c);
await c.db.delete(authEmailIndex).where(eq(authEmailIndex.email, input.email)).run();
},
async authFindAccountIndex(c: any, input: { id?: string; providerId?: string; accountId?: string }) {
assertAppOrganization(c);
if (input.id) {
return await c.db.select().from(authAccountIndex).where(eq(authAccountIndex.id, input.id)).get();
}
if (!input.providerId || !input.accountId) {
return null;
}
return await c.db
.select()
.from(authAccountIndex)
.where(and(eq(authAccountIndex.providerId, input.providerId), eq(authAccountIndex.accountId, input.accountId)))
.get();
},
async authUpsertAccountIndex(c: any, input: { id: string; providerId: string; accountId: string; userId: string }) {
assertAppOrganization(c);
const now = Date.now();
await c.db
.insert(authAccountIndex)
.values({
id: input.id,
providerId: input.providerId,
accountId: input.accountId,
userId: input.userId,
updatedAt: now,
})
.onConflictDoUpdate({
target: authAccountIndex.id,
set: {
providerId: input.providerId,
accountId: input.accountId,
userId: input.userId,
updatedAt: now,
},
})
.run();
return await c.db.select().from(authAccountIndex).where(eq(authAccountIndex.id, input.id)).get();
},
async authDeleteAccountIndex(c: any, input: { id?: string; providerId?: string; accountId?: string }) {
assertAppOrganization(c);
if (input.id) {
await c.db.delete(authAccountIndex).where(eq(authAccountIndex.id, input.id)).run();
return;
}
if (input.providerId && input.accountId) {
await c.db
.delete(authAccountIndex)
.where(and(eq(authAccountIndex.providerId, input.providerId), eq(authAccountIndex.accountId, input.accountId)))
.run();
}
},
async authCreateVerification(c: any, input: { data: Record<string, unknown> }) {
assertAppOrganization(c);
await c.db
.insert(authVerification)
.values(input.data as any)
.run();
return await c.db
.select()
.from(authVerification)
.where(eq(authVerification.id, input.data.id as string))
.get();
},
async authFindOneVerification(c: any, input: { where: any[] }) {
assertAppOrganization(c);
const predicate = organizationAuthWhere(authVerification, input.where);
return predicate ? await c.db.select().from(authVerification).where(predicate).get() : null;
},
async authFindManyVerification(c: any, input: { where?: any[]; limit?: number; sortBy?: any; offset?: number }) {
assertAppOrganization(c);
const predicate = organizationAuthWhere(authVerification, input.where);
let query = c.db.select().from(authVerification);
if (predicate) {
query = query.where(predicate);
}
if (input.sortBy?.field) {
const column = organizationAuthColumn(authVerification, input.sortBy.field);
query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column));
}
if (typeof input.limit === "number") {
query = query.limit(input.limit);
}
if (typeof input.offset === "number") {
query = query.offset(input.offset);
}
return await query.all();
},
async authUpdateVerification(c: any, input: { where: any[]; update: Record<string, unknown> }) {
assertAppOrganization(c);
const predicate = organizationAuthWhere(authVerification, input.where);
if (!predicate) {
return null;
}
await c.db
.update(authVerification)
.set(input.update as any)
.where(predicate)
.run();
return await c.db.select().from(authVerification).where(predicate).get();
},
async authUpdateManyVerification(c: any, input: { where: any[]; update: Record<string, unknown> }) {
assertAppOrganization(c);
const predicate = organizationAuthWhere(authVerification, input.where);
if (!predicate) {
return 0;
}
await c.db
.update(authVerification)
.set(input.update as any)
.where(predicate)
.run();
const row = await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get();
return row?.value ?? 0;
},
async authDeleteVerification(c: any, input: { where: any[] }) {
assertAppOrganization(c);
const predicate = organizationAuthWhere(authVerification, input.where);
if (!predicate) {
return;
}
await c.db.delete(authVerification).where(predicate).run();
},
async authDeleteManyVerification(c: any, input: { where: any[] }) {
assertAppOrganization(c);
const predicate = organizationAuthWhere(authVerification, input.where);
if (!predicate) {
return 0;
}
const rows = await c.db.select().from(authVerification).where(predicate).all();
await c.db.delete(authVerification).where(predicate).run();
return rows.length;
},
async authCountVerification(c: any, input: { where?: any[] }) {
assertAppOrganization(c);
const predicate = organizationAuthWhere(authVerification, input.where);
const row = predicate
? await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get()
: await c.db.select({ value: sqlCount() }).from(authVerification).get();
return row?.value ?? 0;
},
async getAppSnapshot(c: any, input: { sessionId: string }): Promise<FoundryAppSnapshot> {
return await buildAppSnapshot(c, input.sessionId);
},
async resolveAppGithubToken(
c: any,
input: { organizationId: string; requireRepoScope?: boolean },
): Promise<{ accessToken: string; scopes: string[] } | null> {
assertAppOrganization(c);
const auth = getBetterAuthService();
const rows = await c.db.select().from(authSessionIndex).orderBy(desc(authSessionIndex.updatedAt)).all();
for (const row of rows) {
const authState = await auth.getAuthState(row.sessionId);
if (authState?.sessionState?.activeOrganizationId !== input.organizationId) {
continue;
}
const token = await auth.getAccessTokenForSession(row.sessionId);
if (!token?.accessToken) {
continue;
}
const scopes = token.scopes;
if (input.requireRepoScope !== false && scopes.length > 0 && !hasRepoScope(scopes)) {
continue;
}
return {
accessToken: token.accessToken,
scopes,
};
}
return null;
},
async skipAppStarterRepo(c: any, input: { sessionId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
await getBetterAuthService().upsertUserProfile(session.authUserId, {
starterRepoStatus: "skipped",
starterRepoSkippedAt: Date.now(),
starterRepoStarredAt: null,
});
return await buildAppSnapshot(c, input.sessionId);
},
async starAppStarterRepo(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const organization = await getOrCreateOrganization(c, input.organizationId);
await organization.starSandboxAgentRepo({
organizationId: input.organizationId,
});
await getBetterAuthService().upsertUserProfile(session.authUserId, {
starterRepoStatus: "starred",
starterRepoStarredAt: Date.now(),
starterRepoSkippedAt: null,
});
return await buildAppSnapshot(c, input.sessionId);
},
async selectAppOrganization(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
await getBetterAuthService().setActiveOrganization(input.sessionId, input.organizationId);
// Ensure the GitHub data actor exists. If it's newly created, its own
// workflow will detect the pending sync status and run the initial
// full sync automatically — no orchestration needed here.
await getOrCreateGithubData(c, input.organizationId);
return await buildAppSnapshot(c, input.sessionId);
},
async setAppDefaultModel(c: any, input: { sessionId: string; defaultModel: WorkspaceModelId }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
await getBetterAuthService().upsertUserProfile(session.authUserId, {
defaultModel: input.defaultModel,
});
return await buildAppSnapshot(c, input.sessionId);
},
async updateAppOrganizationProfile(
c: any,
input: { sessionId: string; organizationId: string } & UpdateFoundryOrganizationProfileInput,
): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const organization = await getOrCreateOrganization(c, input.organizationId);
await organization.updateOrganizationShellProfile({
displayName: input.displayName,
slug: input.slug,
primaryDomain: input.primaryDomain,
});
return await buildAppSnapshot(c, input.sessionId);
},
async triggerAppRepoImport(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const githubData = await getOrCreateGithubData(c, input.organizationId);
const summary = await githubData.getSummary({});
if (summary.syncStatus === "syncing") {
return await buildAppSnapshot(c, input.sessionId);
}
// Mark sync started on the organization, then send directly to the
// GitHub data actor's own workflow queue.
const organizationHandle = await getOrCreateOrganization(c, input.organizationId);
await organizationHandle.markOrganizationSyncStarted({
label: "Importing repository catalog...",
});
await githubData.send("githubData.command.syncRepos", { label: "Importing repository catalog..." }, { wait: false });
return await buildAppSnapshot(c, input.sessionId);
},
async beginAppGithubInstall(c: any, input: { sessionId: string; organizationId: string }): Promise<{ url: string }> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const { appShell } = getActorRuntimeContext();
const organizationHandle = await getOrCreateOrganization(c, input.organizationId);
const organizationState = await getOrganizationState(organizationHandle);
if (organizationState.snapshot.kind !== "organization") {
return {
url: `${appShell.appUrl}/organizations/${input.organizationId}`,
};
}
return {
url: await appShell.github.buildInstallationUrl(organizationState.githubLogin, randomUUID()),
};
},
async createAppCheckoutSession(c: any, input: { sessionId: string; organizationId: string; planId: FoundryBillingPlanId }): Promise<{ url: string }> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const { appShell } = getActorRuntimeContext();
const organizationHandle = await getOrCreateOrganization(c, input.organizationId);
const organizationState = await getOrganizationState(organizationHandle);
if (input.planId === "free") {
await organizationHandle.applyOrganizationFreePlan({ clearSubscription: false });
return {
url: `${appShell.appUrl}/organizations/${input.organizationId}/billing`,
};
}
if (!appShell.stripe.isConfigured()) {
throw new Error("Stripe is not configured");
}
let customerId = organizationState.stripeCustomerId;
if (!customerId) {
customerId = (
await appShell.stripe.createCustomer({
organizationId: input.organizationId,
displayName: organizationState.snapshot.settings.displayName,
email: session.currentUserEmail,
})
).id;
await organizationHandle.applyOrganizationStripeCustomer({ customerId });
await upsertStripeLookupEntries(c, input.organizationId, customerId, null);
}
return {
url: await appShell.stripe
.createCheckoutSession({
organizationId: input.organizationId,
customerId,
customerEmail: session.currentUserEmail,
planId: input.planId,
successUrl: `${appShell.apiUrl}/v1/billing/checkout/complete?organizationId=${encodeURIComponent(
input.organizationId,
)}&session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing`,
})
.then((checkout) => checkout.url),
};
},
async finalizeAppCheckoutSession(c: any, input: { sessionId: string; organizationId: string; checkoutSessionId: string }): Promise<{ redirectTo: string }> {
assertAppOrganization(c);
const { appShell } = getActorRuntimeContext();
const organizationHandle = await getOrCreateOrganization(c, input.organizationId);
const organizationState = await getOrganizationState(organizationHandle);
const completion = await appShell.stripe.retrieveCheckoutCompletion(input.checkoutSessionId);
if (completion.customerId) {
await organizationHandle.applyOrganizationStripeCustomer({ customerId: completion.customerId });
}
await upsertStripeLookupEntries(c, input.organizationId, completion.customerId, completion.subscriptionId);
if (completion.subscriptionId) {
const subscription = await appShell.stripe.retrieveSubscription(completion.subscriptionId);
await applySubscriptionState(organizationHandle, subscription, completion.planId ?? organizationState.billingPlanId);
}
if (completion.paymentMethodLabel) {
await organizationHandle.setOrganizationBillingPaymentMethod({
label: completion.paymentMethodLabel,
});
}
return {
redirectTo: `${appShell.appUrl}/organizations/${input.organizationId}/billing`,
};
},
async createAppBillingPortalSession(c: any, input: { sessionId: string; organizationId: string }): Promise<{ url: string }> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const { appShell } = getActorRuntimeContext();
const organizationHandle = await getOrCreateOrganization(c, input.organizationId);
const organizationState = await getOrganizationState(organizationHandle);
if (!organizationState.stripeCustomerId) {
throw new Error("Stripe customer is not available for this organization");
}
const portal = await appShell.stripe.createPortalSession({
customerId: organizationState.stripeCustomerId,
returnUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing`,
});
return { url: portal.url };
},
async cancelAppScheduledRenewal(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const { appShell } = getActorRuntimeContext();
const organizationHandle = await getOrCreateOrganization(c, input.organizationId);
const organizationState = await getOrganizationState(organizationHandle);
if (organizationState.stripeSubscriptionId && appShell.stripe.isConfigured()) {
const subscription = await appShell.stripe.updateSubscriptionCancellation(organizationState.stripeSubscriptionId, true);
await applySubscriptionState(organizationHandle, subscription, organizationState.billingPlanId);
await upsertStripeLookupEntries(c, input.organizationId, subscription.customerId ?? organizationState.stripeCustomerId, subscription.id);
} else {
await organizationHandle.setOrganizationBillingStatus({ status: "scheduled_cancel" });
}
return await buildAppSnapshot(c, input.sessionId);
},
async resumeAppSubscription(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const { appShell } = getActorRuntimeContext();
const organizationHandle = await getOrCreateOrganization(c, input.organizationId);
const organizationState = await getOrganizationState(organizationHandle);
if (organizationState.stripeSubscriptionId && appShell.stripe.isConfigured()) {
const subscription = await appShell.stripe.updateSubscriptionCancellation(organizationState.stripeSubscriptionId, false);
await applySubscriptionState(organizationHandle, subscription, organizationState.billingPlanId);
await upsertStripeLookupEntries(c, input.organizationId, subscription.customerId ?? organizationState.stripeCustomerId, subscription.id);
} else {
await organizationHandle.setOrganizationBillingStatus({ status: "active" });
}
return await buildAppSnapshot(c, input.sessionId);
},
async recordAppSeatUsage(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const organization = await getOrCreateOrganization(c, input.organizationId);
await organization.recordOrganizationSeatUsage({
email: session.currentUserEmail,
});
return await buildAppSnapshot(c, input.sessionId);
},
async handleAppStripeWebhook(c: any, input: { payload: string; signatureHeader: string | null }): Promise<{ ok: true }> {
assertAppOrganization(c);
const { appShell } = getActorRuntimeContext();
const event = appShell.stripe.verifyWebhookEvent(input.payload, input.signatureHeader);
if (event.type === "checkout.session.completed") {
const object = event.data.object as Record<string, unknown>;
const organizationId =
stringFromMetadata(object.metadata, "organizationId") ??
(await findOrganizationIdForStripeEvent(
c,
typeof object.customer === "string" ? object.customer : null,
typeof object.subscription === "string" ? object.subscription : null,
));
if (organizationId) {
const organization = await getOrCreateOrganization(c, organizationId);
if (typeof object.customer === "string") {
await organization.applyOrganizationStripeCustomer({ customerId: object.customer });
}
await upsertStripeLookupEntries(
c,
organizationId,
typeof object.customer === "string" ? object.customer : null,
typeof object.subscription === "string" ? object.subscription : null,
);
}
return { ok: true };
}
if (event.type === "customer.subscription.updated" || event.type === "customer.subscription.created") {
const subscription = stripeWebhookSubscription(event);
const organizationId = await findOrganizationIdForStripeEvent(c, subscription.customerId, subscription.id);
if (organizationId) {
const organizationHandle = await getOrCreateOrganization(c, organizationId);
const organizationState = await getOrganizationState(organizationHandle);
await applySubscriptionState(
organizationHandle,
subscription,
appShell.stripe.planIdForPriceId(subscription.priceId ?? "") ?? organizationState.billingPlanId,
);
await upsertStripeLookupEntries(c, organizationId, subscription.customerId, subscription.id);
}
return { ok: true };
}
if (event.type === "customer.subscription.deleted") {
const subscription = stripeWebhookSubscription(event);
const organizationId = await findOrganizationIdForStripeEvent(c, subscription.customerId, subscription.id);
if (organizationId) {
const organization = await getOrCreateOrganization(c, organizationId);
await organization.applyOrganizationFreePlan({ clearSubscription: true });
}
return { ok: true };
}
if (event.type === "invoice.paid" || event.type === "invoice.payment_failed") {
const invoice = event.data.object as Record<string, unknown>;
const organizationId = await findOrganizationIdForStripeEvent(c, typeof invoice.customer === "string" ? invoice.customer : null, null);
if (organizationId) {
const organization = await getOrCreateOrganization(c, organizationId);
const rawAmount = typeof invoice.amount_paid === "number" ? invoice.amount_paid : invoice.amount_due;
const amountUsd = Math.round((typeof rawAmount === "number" ? rawAmount : 0) / 100);
await organization.upsertOrganizationInvoice({
id: String(invoice.id),
label: typeof invoice.number === "string" ? `Invoice ${invoice.number}` : "Stripe invoice",
issuedAt: formatUnixDate(typeof invoice.created === "number" ? invoice.created : Math.floor(Date.now() / 1000)),
amountUsd: Number.isFinite(amountUsd) ? amountUsd : 0,
status: event.type === "invoice.paid" ? "paid" : "open",
});
}
}
return { ok: true };
},
async handleAppGithubWebhook(c: any, input: { payload: string; signatureHeader: string | null; eventHeader: string | null }): Promise<{ ok: true }> {
assertAppOrganization(c);
const { appShell } = getActorRuntimeContext();
const { event, body } = appShell.github.verifyWebhookEvent(input.payload, input.signatureHeader, input.eventHeader);
const accountLogin = body.installation?.account?.login ?? body.repository?.owner?.login ?? body.organization?.login ?? null;
const accountType = body.installation?.account?.type ?? (body.organization?.login ? "Organization" : null);
if (!accountLogin) {
githubWebhookLogger.info(
{
event,
action: body.action ?? null,
reason: "missing_installation_account",
},
"ignored",
);
return { ok: true };
}
const kind: FoundryOrganization["kind"] = accountType === "User" ? "personal" : "organization";
const organizationId = organizationOrganizationId(kind, accountLogin);
const receivedAt = Date.now();
const organization = await getOrCreateOrganization(c, organizationId);
await organization.recordGithubWebhookReceipt({
organizationId: organizationId,
event,
action: body.action ?? null,
receivedAt,
});
const githubData = await getOrCreateGithubData(c, organizationId);
if (event === "installation" && (body.action === "created" || body.action === "deleted" || body.action === "suspend" || body.action === "unsuspend")) {
githubWebhookLogger.info(
{
event,
action: body.action,
accountLogin,
organizationId,
},
"installation_event",
);
if (body.action === "deleted") {
await githubData.adminClearState({
connectedAccount: accountLogin,
installationStatus: "install_required",
installationId: null,
label: "GitHub App installation removed",
});
} else if (body.action === "created") {
await githubData.adminFullSync({
connectedAccount: accountLogin,
installationStatus: "connected",
installationId: body.installation?.id ?? null,
githubLogin: accountLogin,
kind,
label: "Syncing GitHub data from installation webhook...",
});
} else if (body.action === "suspend") {
await githubData.adminClearState({
connectedAccount: accountLogin,
installationStatus: "reconnect_required",
installationId: body.installation?.id ?? null,
label: "GitHub App installation suspended",
});
} else if (body.action === "unsuspend") {
await githubData.adminFullSync({
connectedAccount: accountLogin,
installationStatus: "connected",
installationId: body.installation?.id ?? null,
githubLogin: accountLogin,
kind,
label: "Resyncing GitHub data after unsuspend...",
});
}
return { ok: true };
}
if (event === "installation_repositories") {
githubWebhookLogger.info(
{
event,
action: body.action ?? null,
accountLogin,
organizationId,
repositoriesAdded: body.repositories_added?.length ?? 0,
repositoriesRemoved: body.repositories_removed?.length ?? 0,
},
"repository_membership_changed",
);
await githubData.adminFullSync({
connectedAccount: accountLogin,
installationStatus: "connected",
installationId: body.installation?.id ?? null,
githubLogin: accountLogin,
kind,
label: "Resyncing GitHub data after repository access change...",
});
return { ok: true };
}
if (
event === "push" ||
event === "pull_request" ||
event === "pull_request_review" ||
event === "pull_request_review_comment" ||
event === "check_run" ||
event === "check_suite" ||
event === "status" ||
event === "create" ||
event === "delete"
) {
const repoFullName = body.repository?.full_name;
if (repoFullName) {
githubWebhookLogger.info(
{
event,
action: body.action ?? null,
accountLogin,
organizationId,
repoFullName,
},
"repository_event",
);
if (event === "pull_request" && body.repository?.clone_url && body.pull_request) {
await githubData.handlePullRequestWebhook({
connectedAccount: accountLogin,
installationStatus: "connected",
installationId: body.installation?.id ?? null,
repository: {
fullName: body.repository.full_name,
cloneUrl: body.repository.clone_url,
private: Boolean(body.repository.private),
},
pullRequest: {
number: body.pull_request.number,
title: body.pull_request.title ?? "",
body: body.pull_request.body ?? null,
state: body.pull_request.state ?? "open",
url: body.pull_request.html_url ?? `https://github.com/${body.repository.full_name}/pull/${body.pull_request.number}`,
headRefName: body.pull_request.head?.ref ?? "",
baseRefName: body.pull_request.base?.ref ?? "",
authorLogin: body.pull_request.user?.login ?? null,
isDraft: Boolean(body.pull_request.draft),
merged: Boolean(body.pull_request.merged),
},
});
}
if ((event === "push" || event === "create" || event === "delete") && body.repository?.clone_url) {
const repoId = repoIdFromRemote(body.repository.clone_url);
const knownRepository = await githubData.getRepository({ repoId });
if (knownRepository) {
await githubData.reloadRepository({ repoId });
}
}
}
return { ok: true };
}
githubWebhookLogger.info(
{
event,
action: body.action ?? null,
accountLogin,
organizationId,
},
"unhandled_event",
);
return { ok: true };
},
async syncOrganizationShellFromGithub(
c: any,
input: {
userId: string;
userName: string;
userEmail: string;
githubUserLogin: string;
githubAccountId: string;
githubLogin: string;
githubAccountType: string;
kind: FoundryOrganization["kind"];
displayName: string;
installationId: number | null;
appConfigured: boolean;
},
): Promise<{ organizationId: string }> {
assertOrganizationShell(c);
const now = Date.now();
const existing = await readOrganizationProfileRow(c);
const slug = existing?.slug ?? slugify(input.githubLogin);
const organizationId = organizationOrganizationId(input.kind, input.githubLogin);
if (organizationId !== c.state.organizationId) {
throw new Error(`Organization actor mismatch: actor=${c.state.organizationId} github=${organizationId}`);
}
const installationStatus =
input.kind === "personal" ? "connected" : input.installationId ? "connected" : input.appConfigured ? "install_required" : "reconnect_required";
const syncStatus = existing?.githubSyncStatus ?? legacyRepoImportStatusToGithubSyncStatus(existing?.repoImportStatus);
const lastSyncLabel =
syncStatus === "synced"
? existing.githubLastSyncLabel
: installationStatus === "connected"
? "Waiting for first import"
: installationStatus === "install_required"
? "GitHub App installation required"
: "GitHub App configuration incomplete";
const hasStripeBillingState = Boolean(existing?.stripeCustomerId || existing?.stripeSubscriptionId || existing?.stripePriceId);
const defaultBillingPlanId = input.kind === "personal" || !hasStripeBillingState ? "free" : (existing?.billingPlanId ?? "team");
const defaultSeatsIncluded = input.kind === "personal" || !hasStripeBillingState ? 1 : (existing?.billingSeatsIncluded ?? 5);
const defaultPaymentMethodLabel =
input.kind === "personal"
? "No card required"
: hasStripeBillingState
? (existing?.billingPaymentMethodLabel ?? "Payment method on file")
: "No payment method on file";
await c.db
.insert(organizationProfile)
.values({
id: PROFILE_ROW_ID,
kind: input.kind,
githubAccountId: input.githubAccountId,
githubLogin: input.githubLogin,
githubAccountType: input.githubAccountType,
displayName: input.displayName,
slug,
primaryDomain: existing?.primaryDomain ?? (input.kind === "personal" ? "personal" : `${slug}.github`),
autoImportRepos: existing?.autoImportRepos ?? 1,
repoImportStatus: existing?.repoImportStatus ?? "not_started",
githubConnectedAccount: input.githubLogin,
githubInstallationStatus: installationStatus,
githubSyncStatus: syncStatus,
githubInstallationId: input.installationId,
githubLastSyncLabel: lastSyncLabel,
githubLastSyncAt: existing?.githubLastSyncAt ?? null,
stripeCustomerId: existing?.stripeCustomerId ?? null,
stripeSubscriptionId: existing?.stripeSubscriptionId ?? null,
stripePriceId: existing?.stripePriceId ?? null,
billingPlanId: defaultBillingPlanId,
billingStatus: existing?.billingStatus ?? "active",
billingSeatsIncluded: defaultSeatsIncluded,
billingTrialEndsAt: existing?.billingTrialEndsAt ?? null,
billingRenewalAt: existing?.billingRenewalAt ?? null,
billingPaymentMethodLabel: defaultPaymentMethodLabel,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
})
.onConflictDoUpdate({
target: organizationProfile.id,
set: {
kind: input.kind,
githubAccountId: input.githubAccountId,
githubLogin: input.githubLogin,
githubAccountType: input.githubAccountType,
displayName: input.displayName,
githubConnectedAccount: input.githubLogin,
githubInstallationStatus: installationStatus,
githubSyncStatus: syncStatus,
githubInstallationId: input.installationId,
githubLastSyncLabel: lastSyncLabel,
githubLastSyncAt: existing?.githubLastSyncAt ?? null,
billingPlanId: defaultBillingPlanId,
billingSeatsIncluded: defaultSeatsIncluded,
billingPaymentMethodLabel: defaultPaymentMethodLabel,
updatedAt: now,
},
})
.run();
await c.db
.insert(organizationMembers)
.values({
id: input.userId,
name: input.userName,
email: input.userEmail,
role: input.kind === "personal" ? "owner" : "admin",
state: "active",
updatedAt: now,
})
.onConflictDoUpdate({
target: organizationMembers.id,
set: {
name: input.userName,
email: input.userEmail,
role: input.kind === "personal" ? "owner" : "admin",
state: "active",
updatedAt: now,
},
})
.run();
return { organizationId };
},
async getOrganizationShellState(c: any): Promise<any> {
assertOrganizationShell(c);
return await buildOrganizationState(c);
},
async getOrganizationShellStateIfInitialized(c: any): Promise<any | null> {
assertOrganizationShell(c);
return await buildOrganizationStateIfInitialized(c);
},
async updateOrganizationShellProfile(c: any, input: Pick<UpdateFoundryOrganizationProfileInput, "displayName" | "slug" | "primaryDomain">): Promise<void> {
assertOrganizationShell(c);
const existing = await requireOrganizationProfileRow(c);
await c.db
.update(organizationProfile)
.set({
displayName: input.displayName.trim() || existing.displayName,
slug: input.slug.trim() || existing.slug,
primaryDomain: input.primaryDomain.trim() || existing.primaryDomain,
updatedAt: Date.now(),
})
.where(eq(organizationProfile.id, PROFILE_ROW_ID))
.run();
},
async markOrganizationSyncStarted(c: any, input: { label: string }): Promise<void> {
assertOrganizationShell(c);
await c.db
.update(organizationProfile)
.set({
githubSyncStatus: "syncing",
githubLastSyncLabel: input.label,
updatedAt: Date.now(),
})
.where(eq(organizationProfile.id, PROFILE_ROW_ID))
.run();
},
async applyOrganizationSyncCompleted(
c: any,
input: {
repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>;
installationStatus: FoundryOrganization["github"]["installationStatus"];
lastSyncLabel: string;
},
): Promise<void> {
assertOrganizationShell(c);
const now = Date.now();
for (const repository of input.repositories) {
const remoteUrl = repository.cloneUrl;
await c.db
.insert(repos)
.values({
repoId: repoIdFromRemote(remoteUrl),
remoteUrl,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: repos.repoId,
set: {
remoteUrl,
updatedAt: now,
},
})
.run();
}
await c.db
.update(organizationProfile)
.set({
githubInstallationStatus: input.installationStatus,
githubSyncStatus: "synced",
githubLastSyncLabel: input.lastSyncLabel,
githubLastSyncAt: now,
updatedAt: now,
})
.where(eq(organizationProfile.id, PROFILE_ROW_ID))
.run();
},
async markOrganizationSyncFailed(c: any, input: { message: string; installationStatus: FoundryOrganization["github"]["installationStatus"] }): Promise<void> {
assertOrganizationShell(c);
await c.db
.update(organizationProfile)
.set({
githubInstallationStatus: input.installationStatus,
githubSyncStatus: "error",
githubLastSyncLabel: input.message,
updatedAt: Date.now(),
})
.where(eq(organizationProfile.id, PROFILE_ROW_ID))
.run();
},
async applyOrganizationStripeCustomer(c: any, input: { customerId: string }): Promise<void> {
assertOrganizationShell(c);
await c.db
.update(organizationProfile)
.set({
stripeCustomerId: input.customerId,
updatedAt: Date.now(),
})
.where(eq(organizationProfile.id, PROFILE_ROW_ID))
.run();
},
async applyOrganizationStripeSubscription(
c: any,
input: {
subscription: {
id: string;
customerId: string;
priceId: string | null;
status: string;
cancelAtPeriodEnd: boolean;
currentPeriodEnd: number | null;
trialEnd: number | null;
defaultPaymentMethodLabel: string;
};
fallbackPlanId: FoundryBillingPlanId;
},
): Promise<void> {
assertOrganizationShell(c);
const { appShell } = getActorRuntimeContext();
const planId = appShell.stripe.planIdForPriceId(input.subscription.priceId ?? "") ?? input.fallbackPlanId;
await c.db
.update(organizationProfile)
.set({
stripeCustomerId: input.subscription.customerId || null,
stripeSubscriptionId: input.subscription.id || null,
stripePriceId: input.subscription.priceId,
billingPlanId: planId,
billingStatus: stripeStatusToBillingStatus(input.subscription.status, input.subscription.cancelAtPeriodEnd),
billingSeatsIncluded: seatsIncludedForPlan(planId),
billingTrialEndsAt: input.subscription.trialEnd ? new Date(input.subscription.trialEnd * 1000).toISOString() : null,
billingRenewalAt: input.subscription.currentPeriodEnd ? new Date(input.subscription.currentPeriodEnd * 1000).toISOString() : null,
billingPaymentMethodLabel: input.subscription.defaultPaymentMethodLabel || "Payment method on file",
updatedAt: Date.now(),
})
.where(eq(organizationProfile.id, PROFILE_ROW_ID))
.run();
},
async applyOrganizationFreePlan(c: any, input: { clearSubscription: boolean }): Promise<void> {
assertOrganizationShell(c);
const patch: Record<string, unknown> = {
billingPlanId: "free",
billingStatus: "active",
billingSeatsIncluded: 1,
billingTrialEndsAt: null,
billingRenewalAt: null,
billingPaymentMethodLabel: "No card required",
updatedAt: Date.now(),
};
if (input.clearSubscription) {
patch.stripeSubscriptionId = null;
patch.stripePriceId = null;
}
await c.db.update(organizationProfile).set(patch).where(eq(organizationProfile.id, PROFILE_ROW_ID)).run();
},
async setOrganizationBillingPaymentMethod(c: any, input: { label: string }): Promise<void> {
assertOrganizationShell(c);
await c.db
.update(organizationProfile)
.set({
billingPaymentMethodLabel: input.label,
updatedAt: Date.now(),
})
.where(eq(organizationProfile.id, PROFILE_ROW_ID))
.run();
},
async setOrganizationBillingStatus(c: any, input: { status: FoundryBillingState["status"] }): Promise<void> {
assertOrganizationShell(c);
await c.db
.update(organizationProfile)
.set({
billingStatus: input.status,
updatedAt: Date.now(),
})
.where(eq(organizationProfile.id, PROFILE_ROW_ID))
.run();
},
async upsertOrganizationInvoice(c: any, input: { id: string; label: string; issuedAt: string; amountUsd: number; status: "paid" | "open" }): Promise<void> {
assertOrganizationShell(c);
await c.db
.insert(invoices)
.values({
id: input.id,
label: input.label,
issuedAt: input.issuedAt,
amountUsd: input.amountUsd,
status: input.status,
createdAt: Date.now(),
})
.onConflictDoUpdate({
target: invoices.id,
set: {
label: input.label,
issuedAt: input.issuedAt,
amountUsd: input.amountUsd,
status: input.status,
},
})
.run();
},
async recordOrganizationSeatUsage(c: any, input: { email: string }): Promise<void> {
assertOrganizationShell(c);
await c.db
.insert(seatAssignments)
.values({
email: input.email,
createdAt: Date.now(),
})
.onConflictDoNothing()
.run();
},
async applyGithubInstallationCreated(c: any, input: { installationId: number }): Promise<void> {
assertOrganizationShell(c);
await c.db
.update(organizationProfile)
.set({
githubInstallationId: input.installationId,
githubInstallationStatus: "connected",
updatedAt: Date.now(),
})
.where(eq(organizationProfile.id, PROFILE_ROW_ID))
.run();
},
async applyGithubInstallationRemoved(c: any, _input: {}): Promise<void> {
assertOrganizationShell(c);
await c.db
.update(organizationProfile)
.set({
githubInstallationId: null,
githubInstallationStatus: "install_required",
githubSyncStatus: "pending",
githubLastSyncLabel: "GitHub App installation removed",
updatedAt: Date.now(),
})
.where(eq(organizationProfile.id, PROFILE_ROW_ID))
.run();
},
async applyGithubRepositoryChanges(c: any, input: { added: Array<{ fullName: string; private: boolean }>; removed: string[] }): Promise<void> {
assertOrganizationShell(c);
const now = Date.now();
for (const repo of input.added) {
const remoteUrl = `https://github.com/${repo.fullName}.git`;
const repoId = repoIdFromRemote(remoteUrl);
await c.db
.insert(repos)
.values({
repoId,
remoteUrl,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: repos.repoId,
set: {
remoteUrl,
updatedAt: now,
},
})
.run();
}
for (const fullName of input.removed) {
const remoteUrl = `https://github.com/${fullName}.git`;
const repoId = repoIdFromRemote(remoteUrl);
await c.db.delete(repos).where(eq(repos.repoId, repoId)).run();
}
const repoCount = (await c.db.select().from(repos).all()).length;
await c.db
.update(organizationProfile)
.set({
githubSyncStatus: "synced",
githubLastSyncLabel: `${repoCount} repositories synced`,
githubLastSyncAt: now,
updatedAt: now,
})
.where(eq(organizationProfile.id, PROFILE_ROW_ID))
.run();
},
};