mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
643 lines
18 KiB
TypeScript
643 lines
18 KiB
TypeScript
import { teamsCrudHandlers } from "@/app/api/latest/teams/crud";
|
|
import { usersCrudHandlers } from "@/app/api/latest/users/crud";
|
|
import { BooleanTrue, Prisma } from "@/generated/prisma/client";
|
|
import { getClickhouseAdminClient } from "@/lib/clickhouse";
|
|
import { isPreviewModeEnabled } from "@/lib/preview-mode";
|
|
import { seedDummyProject, refreshDummyProjectLiveTokenRefreshEvents } from "@/lib/seed-dummy-data";
|
|
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from "@/lib/tenancies";
|
|
import { createAuthTokens } from "@/lib/tokens";
|
|
import { getPrismaClientForTenancy, globalPrismaClient, isPrismaError, sqlQuoteIdent, type PrismaClientTransaction } from "@/prisma-client";
|
|
import type { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
|
|
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
|
import { captureError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
|
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
|
|
|
|
const PREVIEW_POOL_METADATA_KEY = "stackPreviewPool";
|
|
const DEFAULT_READY_POOL_SIZE = 3;
|
|
const DEFAULT_LEASE_DURATION_MS = 30 * 60 * 1000;
|
|
const DEFAULT_CLEANUP_MAX_DELETE_PER_RUN = 1;
|
|
|
|
let previewPoolFillMutexTail: Promise<void> = Promise.resolve();
|
|
|
|
async function withPreviewPoolFillMutex<T>(callback: () => Promise<T>): Promise<T> {
|
|
const previousTail = previewPoolFillMutexTail;
|
|
let releaseMutex!: () => void;
|
|
previewPoolFillMutexTail = new Promise<void>((resolve) => {
|
|
releaseMutex = resolve;
|
|
});
|
|
await previousTail;
|
|
try {
|
|
return await callback();
|
|
} finally {
|
|
releaseMutex();
|
|
}
|
|
}
|
|
|
|
type PreviewPoolState = "ready" | "leased";
|
|
|
|
type PreviewPoolMetadata = {
|
|
version: 1,
|
|
state: PreviewPoolState,
|
|
projectId: string,
|
|
userId: string,
|
|
createdAtMillis: number,
|
|
leasedAtMillis: number | null,
|
|
leaseExpiresAtMillis: number | null,
|
|
};
|
|
|
|
type PreviewPoolLease = {
|
|
projectId: string,
|
|
userId: string,
|
|
accessToken: string,
|
|
refreshToken: string,
|
|
};
|
|
|
|
type TableWithColumn = {
|
|
table_schema: string,
|
|
table_name: string,
|
|
};
|
|
|
|
function assertPreviewModeEnabled() {
|
|
if (!isPreviewModeEnabled()) {
|
|
throw new StatusError(StatusError.Forbidden, "Preview pool endpoints are only available in preview mode");
|
|
}
|
|
}
|
|
|
|
function parsePositiveIntegerEnv(name: string, fallback: number): number {
|
|
const raw = getEnvVariable(name, "");
|
|
if (raw === "") {
|
|
return fallback;
|
|
}
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
throw new Error(`${name} must be a positive integer`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function getReadyPoolSize(): number {
|
|
return parsePositiveIntegerEnv("STACK_PREVIEW_POOL_READY_SIZE", DEFAULT_READY_POOL_SIZE);
|
|
}
|
|
|
|
function getLeaseDurationMs(): number {
|
|
return parsePositiveIntegerEnv("STACK_PREVIEW_POOL_LEASE_DURATION_MS", DEFAULT_LEASE_DURATION_MS);
|
|
}
|
|
|
|
function getCleanupMaxDeletePerRun(): number {
|
|
return parsePositiveIntegerEnv("STACK_PREVIEW_POOL_CLEANUP_MAX_DELETE_PER_RUN", DEFAULT_CLEANUP_MAX_DELETE_PER_RUN);
|
|
}
|
|
|
|
function previewPoolMetadataToJson(metadata: PreviewPoolMetadata): Prisma.InputJsonObject {
|
|
return {
|
|
version: metadata.version,
|
|
state: metadata.state,
|
|
projectId: metadata.projectId,
|
|
userId: metadata.userId,
|
|
createdAtMillis: metadata.createdAtMillis,
|
|
leasedAtMillis: metadata.leasedAtMillis,
|
|
leaseExpiresAtMillis: metadata.leaseExpiresAtMillis,
|
|
};
|
|
}
|
|
|
|
function previewTeamServerMetadataToJson(metadata: PreviewPoolMetadata): Prisma.InputJsonObject {
|
|
return {
|
|
[PREVIEW_POOL_METADATA_KEY]: previewPoolMetadataToJson(metadata),
|
|
};
|
|
}
|
|
|
|
function getString(value: unknown): string | null {
|
|
return typeof value === "string" ? value : null;
|
|
}
|
|
|
|
function getNumber(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function getNullableNumber(value: unknown): number | null {
|
|
if (value === null || value === undefined) return null;
|
|
return getNumber(value);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function parsePreviewPoolMetadata(value: unknown): PreviewPoolMetadata | null {
|
|
if (!isRecord(value)) return null;
|
|
const rawMetadata = value[PREVIEW_POOL_METADATA_KEY];
|
|
if (!isRecord(rawMetadata)) return null;
|
|
|
|
const version = rawMetadata.version;
|
|
const state = rawMetadata.state;
|
|
const projectId = getString(rawMetadata.projectId);
|
|
const userId = getString(rawMetadata.userId);
|
|
const createdAtMillis = getNumber(rawMetadata.createdAtMillis);
|
|
const leasedAtMillis = getNullableNumber(rawMetadata.leasedAtMillis);
|
|
const leaseExpiresAtMillis = getNullableNumber(rawMetadata.leaseExpiresAtMillis);
|
|
|
|
if (
|
|
version !== 1 ||
|
|
(state !== "ready" && state !== "leased") ||
|
|
projectId == null ||
|
|
userId == null ||
|
|
createdAtMillis == null
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
version,
|
|
state,
|
|
projectId,
|
|
userId,
|
|
createdAtMillis,
|
|
leasedAtMillis,
|
|
leaseExpiresAtMillis,
|
|
};
|
|
}
|
|
|
|
async function getInternalTenancy(): Promise<Tenancy> {
|
|
return await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID);
|
|
}
|
|
|
|
async function getPreviewOwnerTeamId(options: {
|
|
internalTenancy: Tenancy,
|
|
internalPrisma: PrismaClientTransaction,
|
|
user: UsersCrud["Admin"]["Read"],
|
|
}): Promise<string> {
|
|
const selectedMembership = await options.internalPrisma.teamMember.findFirst({
|
|
where: {
|
|
tenancyId: options.internalTenancy.id,
|
|
projectUserId: options.user.id,
|
|
isSelected: BooleanTrue.TRUE,
|
|
},
|
|
select: {
|
|
teamId: true,
|
|
},
|
|
});
|
|
if (selectedMembership != null) {
|
|
return selectedMembership.teamId;
|
|
}
|
|
|
|
const existingMembership = await options.internalPrisma.teamMember.findFirst({
|
|
where: {
|
|
tenancyId: options.internalTenancy.id,
|
|
projectUserId: options.user.id,
|
|
},
|
|
select: {
|
|
teamId: true,
|
|
},
|
|
});
|
|
if (existingMembership != null) {
|
|
await options.internalPrisma.teamMember.update({
|
|
where: {
|
|
tenancyId_projectUserId_teamId: {
|
|
tenancyId: options.internalTenancy.id,
|
|
projectUserId: options.user.id,
|
|
teamId: existingMembership.teamId,
|
|
},
|
|
},
|
|
data: {
|
|
isSelected: BooleanTrue.TRUE,
|
|
},
|
|
});
|
|
return existingMembership.teamId;
|
|
}
|
|
|
|
const team = await teamsCrudHandlers.adminCreate({
|
|
tenancy: options.internalTenancy,
|
|
user: options.user,
|
|
data: {
|
|
display_name: "Preview Demo Lease",
|
|
creator_user_id: "me",
|
|
},
|
|
});
|
|
|
|
await options.internalPrisma.teamMember.update({
|
|
where: {
|
|
tenancyId_projectUserId_teamId: {
|
|
tenancyId: options.internalTenancy.id,
|
|
projectUserId: options.user.id,
|
|
teamId: team.id,
|
|
},
|
|
},
|
|
data: {
|
|
isSelected: BooleanTrue.TRUE,
|
|
},
|
|
});
|
|
|
|
return team.id;
|
|
}
|
|
|
|
async function createPreviewPoolProject(state: PreviewPoolState): Promise<{ projectId: string, userId: string, teamId: string }> {
|
|
assertPreviewModeEnabled();
|
|
|
|
const internalTenancy = await getInternalTenancy();
|
|
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
|
|
const id = generateUuid();
|
|
const user = await usersCrudHandlers.adminCreate({
|
|
tenancy: internalTenancy,
|
|
data: {
|
|
display_name: "Preview Visitor",
|
|
primary_email: `preview-pool-${id}@preview.stack-auth.com`,
|
|
primary_email_verified: true,
|
|
primary_email_auth_enabled: false,
|
|
server_metadata: {
|
|
[PREVIEW_POOL_METADATA_KEY]: {
|
|
version: 1,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const teamId = await getPreviewOwnerTeamId({
|
|
internalTenancy,
|
|
internalPrisma,
|
|
user,
|
|
});
|
|
|
|
let projectId: string | undefined;
|
|
try {
|
|
const clickhouseClient = getClickhouseAdminClient();
|
|
projectId = await seedDummyProject({
|
|
ownerTeamId: teamId,
|
|
oauthProviderIds: ['github', 'google', 'microsoft', 'spotify'],
|
|
excludeAlphaApps: true,
|
|
skipGithubConfigSource: true,
|
|
clickhouseClient,
|
|
});
|
|
|
|
const now = new Date().getTime();
|
|
const metadata: PreviewPoolMetadata = {
|
|
version: 1,
|
|
state,
|
|
projectId,
|
|
userId: user.id,
|
|
createdAtMillis: now,
|
|
leasedAtMillis: state === "leased" ? now : null,
|
|
leaseExpiresAtMillis: state === "leased" ? now + getLeaseDurationMs() : null,
|
|
};
|
|
|
|
await internalPrisma.team.update({
|
|
where: {
|
|
tenancyId_teamId: {
|
|
tenancyId: internalTenancy.id,
|
|
teamId,
|
|
},
|
|
},
|
|
data: {
|
|
serverMetadata: previewTeamServerMetadataToJson(metadata),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
await rollbackFailedPreviewPoolProjectCreation({
|
|
internalTenancy,
|
|
internalPrisma,
|
|
teamId,
|
|
userId: user.id,
|
|
projectId,
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
return { projectId, userId: user.id, teamId };
|
|
}
|
|
|
|
async function countReadyPreviewPoolProjects(): Promise<number> {
|
|
assertPreviewModeEnabled();
|
|
|
|
const internalTenancy = await getInternalTenancy();
|
|
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
|
|
return await internalPrisma.team.count({
|
|
where: {
|
|
tenancyId: internalTenancy.id,
|
|
serverMetadata: {
|
|
path: [PREVIEW_POOL_METADATA_KEY, "state"],
|
|
equals: "ready",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function claimReadyPreviewPoolProject(): Promise<{ projectId: string, userId: string } | null> {
|
|
assertPreviewModeEnabled();
|
|
|
|
const internalTenancy = await getInternalTenancy();
|
|
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
|
|
const readyTeams = await internalPrisma.team.findMany({
|
|
where: {
|
|
tenancyId: internalTenancy.id,
|
|
serverMetadata: {
|
|
path: [PREVIEW_POOL_METADATA_KEY, "state"],
|
|
equals: "ready",
|
|
},
|
|
},
|
|
orderBy: {
|
|
createdAt: "asc",
|
|
},
|
|
take: 5,
|
|
});
|
|
|
|
for (const team of readyTeams) {
|
|
const metadata = parsePreviewPoolMetadata(team.serverMetadata);
|
|
if (metadata == null || metadata.state !== "ready") {
|
|
continue;
|
|
}
|
|
|
|
const now = new Date().getTime();
|
|
const leasedMetadata: PreviewPoolMetadata = {
|
|
...metadata,
|
|
state: "leased",
|
|
leasedAtMillis: now,
|
|
leaseExpiresAtMillis: now + getLeaseDurationMs(),
|
|
};
|
|
const updateResult = await internalPrisma.team.updateMany({
|
|
where: {
|
|
tenancyId: internalTenancy.id,
|
|
teamId: team.teamId,
|
|
serverMetadata: {
|
|
path: [PREVIEW_POOL_METADATA_KEY, "state"],
|
|
equals: "ready",
|
|
},
|
|
},
|
|
data: {
|
|
serverMetadata: previewTeamServerMetadataToJson(leasedMetadata),
|
|
},
|
|
});
|
|
if (updateResult.count === 1) {
|
|
return {
|
|
projectId: metadata.projectId,
|
|
userId: metadata.userId,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function getTablesWithColumn(columnName: "projectId" | "tenancyId"): Promise<TableWithColumn[]> {
|
|
return await globalPrismaClient.$queryRaw<TableWithColumn[]>`
|
|
SELECT table_schema, table_name
|
|
FROM information_schema.columns
|
|
WHERE table_schema = current_schema()
|
|
AND column_name = ${columnName}
|
|
ORDER BY table_name
|
|
`;
|
|
}
|
|
|
|
async function deleteRowsByColumn(options: {
|
|
columnName: "projectId" | "tenancyId",
|
|
values: string[],
|
|
excludedTables?: Set<string>,
|
|
}) {
|
|
if (options.values.length === 0) return;
|
|
|
|
const tables = await getTablesWithColumn(options.columnName);
|
|
const remainingTables = new Map(
|
|
tables
|
|
.filter((table) => !(options.excludedTables?.has(table.table_name) ?? false))
|
|
.map((table) => [`${table.table_schema}.${table.table_name}`, table]),
|
|
);
|
|
|
|
for (let pass = 0; pass < 8 && remainingTables.size > 0; pass++) {
|
|
let resolvedTableCount = 0;
|
|
for (const [key, table] of remainingTables) {
|
|
try {
|
|
await globalPrismaClient.$executeRaw(Prisma.sql`
|
|
DELETE FROM ${sqlQuoteIdent(table.table_schema)}.${sqlQuoteIdent(table.table_name)}
|
|
WHERE ${sqlQuoteIdent(options.columnName)} IN (${Prisma.join(options.values)})
|
|
`);
|
|
remainingTables.delete(key);
|
|
resolvedTableCount++;
|
|
} catch (error) {
|
|
if (!isPrismaError(error, "FOREIGN_CONSTRAINT_VIOLATION")) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (resolvedTableCount === 0) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (remainingTables.size > 0) {
|
|
throw new Error(`Could not delete preview rows by ${options.columnName}; remaining tables: ${[...remainingTables.keys()].join(", ")}`);
|
|
}
|
|
}
|
|
|
|
async function deletePreviewClickhouseEvents(projectId: string): Promise<void> {
|
|
if (getEnvVariable("STACK_CLICKHOUSE_URL", "") === "") {
|
|
return;
|
|
}
|
|
|
|
await getClickhouseAdminClient().command({
|
|
query: "DELETE FROM analytics_internal.events WHERE project_id = {projectId:String}",
|
|
query_params: { projectId },
|
|
});
|
|
}
|
|
|
|
async function deletePreviewProjectData(projectId: string): Promise<void> {
|
|
const tenancies = await globalPrismaClient.tenancy.findMany({
|
|
where: {
|
|
projectId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
const tenancyIds = tenancies.map((tenancy) => tenancy.id);
|
|
|
|
await deletePreviewClickhouseEvents(projectId);
|
|
await deleteRowsByColumn({ columnName: "tenancyId", values: tenancyIds });
|
|
await deleteRowsByColumn({
|
|
columnName: "projectId",
|
|
values: [projectId],
|
|
excludedTables: new Set(["Project"]),
|
|
});
|
|
await globalPrismaClient.project.deleteMany({
|
|
where: {
|
|
id: projectId,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function deletePreviewInternalLeaseIdentity(options: {
|
|
internalTenancy: Tenancy,
|
|
internalPrisma: PrismaClientTransaction,
|
|
teamId: string,
|
|
userId: string,
|
|
}) {
|
|
await options.internalPrisma.team.deleteMany({
|
|
where: {
|
|
tenancyId: options.internalTenancy.id,
|
|
teamId: options.teamId,
|
|
},
|
|
});
|
|
await options.internalPrisma.projectUser.deleteMany({
|
|
where: {
|
|
tenancyId: options.internalTenancy.id,
|
|
projectUserId: options.userId,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function rollbackFailedPreviewPoolProjectCreation(options: {
|
|
internalTenancy: Tenancy,
|
|
internalPrisma: PrismaClientTransaction,
|
|
teamId: string,
|
|
userId: string,
|
|
projectId?: string,
|
|
}) {
|
|
if (options.projectId != null) {
|
|
await deletePreviewProjectData(options.projectId);
|
|
}
|
|
await deletePreviewInternalLeaseIdentity({
|
|
internalTenancy: options.internalTenancy,
|
|
internalPrisma: options.internalPrisma,
|
|
teamId: options.teamId,
|
|
userId: options.userId,
|
|
});
|
|
}
|
|
|
|
export async function cleanupExpiredPreviewPoolLeases(options?: { maxDelete?: number }): Promise<{
|
|
deletedCount: number,
|
|
}> {
|
|
assertPreviewModeEnabled();
|
|
|
|
const internalTenancy = await getInternalTenancy();
|
|
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
|
|
const maxDelete = options?.maxDelete ?? getCleanupMaxDeletePerRun();
|
|
if (maxDelete <= 0) {
|
|
return { deletedCount: 0 };
|
|
}
|
|
|
|
const leasedTeams = await internalPrisma.team.findMany({
|
|
where: {
|
|
tenancyId: internalTenancy.id,
|
|
serverMetadata: {
|
|
path: [PREVIEW_POOL_METADATA_KEY, "state"],
|
|
equals: "leased",
|
|
},
|
|
},
|
|
orderBy: {
|
|
updatedAt: "asc",
|
|
},
|
|
take: Math.max(maxDelete * 5, maxDelete),
|
|
});
|
|
|
|
const now = new Date().getTime();
|
|
let deletedCount = 0;
|
|
for (const team of leasedTeams) {
|
|
if (deletedCount >= maxDelete) {
|
|
break;
|
|
}
|
|
|
|
const metadata = parsePreviewPoolMetadata(team.serverMetadata);
|
|
if (
|
|
metadata == null ||
|
|
metadata.state !== "leased" ||
|
|
metadata.leaseExpiresAtMillis == null ||
|
|
metadata.leaseExpiresAtMillis > now
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
await deletePreviewProjectData(metadata.projectId);
|
|
await deletePreviewInternalLeaseIdentity({
|
|
internalTenancy,
|
|
internalPrisma,
|
|
teamId: team.teamId,
|
|
userId: metadata.userId,
|
|
});
|
|
deletedCount++;
|
|
}
|
|
|
|
return { deletedCount };
|
|
}
|
|
|
|
export async function fillPreviewPool(options?: { maxCreate?: number }): Promise<{
|
|
readyCountBefore: number,
|
|
createdCount: number,
|
|
targetReadyCount: number,
|
|
}> {
|
|
assertPreviewModeEnabled();
|
|
|
|
return await withPreviewPoolFillMutex(async () => {
|
|
const targetReadyCount = getReadyPoolSize();
|
|
const readyCountBefore = await countReadyPreviewPoolProjects();
|
|
const requestedMaxCreate = options?.maxCreate ?? 1;
|
|
const createCount = Math.max(0, Math.min(requestedMaxCreate, targetReadyCount - readyCountBefore));
|
|
|
|
for (let i = 0; i < createCount; i++) {
|
|
await createPreviewPoolProject("ready");
|
|
}
|
|
|
|
return {
|
|
readyCountBefore,
|
|
createdCount: createCount,
|
|
targetReadyCount,
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function claimPreviewPoolLease(options: { apiUrl: string }): Promise<PreviewPoolLease> {
|
|
assertPreviewModeEnabled();
|
|
|
|
const claimed = await claimReadyPreviewPoolProject() ?? await createPreviewPoolProject("leased");
|
|
const internalTenancy = await getInternalTenancy();
|
|
|
|
const { accessToken, refreshToken } = await createAuthTokens({
|
|
tenancy: internalTenancy,
|
|
projectUserId: claimed.userId,
|
|
apiUrl: options.apiUrl,
|
|
});
|
|
|
|
try {
|
|
await refreshDummyProjectLiveTokenRefreshEvents(claimed.projectId, getClickhouseAdminClient());
|
|
} catch (error) {
|
|
captureError(
|
|
"preview-pool-live-token-refresh",
|
|
error instanceof Error ? error : new Error(String(error)),
|
|
);
|
|
}
|
|
|
|
return {
|
|
projectId: claimed.projectId,
|
|
userId: claimed.userId,
|
|
accessToken,
|
|
refreshToken,
|
|
};
|
|
}
|
|
|
|
export async function getPreviewPoolProjectForUser(userId: string): Promise<string | null> {
|
|
assertPreviewModeEnabled();
|
|
|
|
const internalTenancy = await getInternalTenancy();
|
|
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
|
|
const membership = await internalPrisma.teamMember.findFirst({
|
|
where: {
|
|
tenancyId: internalTenancy.id,
|
|
projectUserId: userId,
|
|
team: {
|
|
serverMetadata: {
|
|
path: [PREVIEW_POOL_METADATA_KEY, "state"],
|
|
equals: "leased",
|
|
},
|
|
},
|
|
},
|
|
include: {
|
|
team: true,
|
|
},
|
|
});
|
|
|
|
const metadata = parsePreviewPoolMetadata(membership?.team.serverMetadata);
|
|
if (metadata == null) {
|
|
return null;
|
|
}
|
|
const now = new Date().getTime();
|
|
if (metadata.leaseExpiresAtMillis == null || metadata.leaseExpiresAtMillis <= now) {
|
|
return null;
|
|
}
|
|
return metadata.projectId;
|
|
}
|