From 0f9b372cbdba483f122cce5cc57fa0670e5f332a Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 11:12:54 -0700 Subject: [PATCH 01/88] add tenancy crud --- .../latest/internal/tenancy/current/crud.tsx | 26 +++++++++++++++ .../src/interface/crud/tenancy.ts | 33 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 apps/backend/src/app/api/latest/internal/tenancy/current/crud.tsx create mode 100644 packages/stack-shared/src/interface/crud/tenancy.ts diff --git a/apps/backend/src/app/api/latest/internal/tenancy/current/crud.tsx b/apps/backend/src/app/api/latest/internal/tenancy/current/crud.tsx new file mode 100644 index 0000000000..80587df95a --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/tenancy/current/crud.tsx @@ -0,0 +1,26 @@ +import { createCrudHandlers } from "@/route-handlers/crud-handler"; +import { tenancyCrud } from "@stackframe/stack-shared/dist/interface/crud/tenancy"; +import { yupObject } from "@stackframe/stack-shared/dist/schema-fields"; +import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; + +export const tenancyCrudHandlers = createLazyProxy(() => createCrudHandlers(tenancyCrud, { + paramsSchema: yupObject({}), + onRead: async ({ auth }) => { + return { + id: auth.tenancy.id, + project_id: auth.project.id, + branch_id: auth.tenancy.branchId, + organization_id: auth.tenancy.organization?.id, + config: auth.tenancy.completeConfig, + }; + }, + onUpdate: async ({ auth, data }) => { + return { + id: auth.tenancy.id, + project_id: auth.project.id, + branch_id: auth.tenancy.branchId, + organization_id: auth.tenancy.organization?.id, + config: auth.tenancy.completeConfig, + }; + }, +})); diff --git a/packages/stack-shared/src/interface/crud/tenancy.ts b/packages/stack-shared/src/interface/crud/tenancy.ts new file mode 100644 index 0000000000..5ff5794749 --- /dev/null +++ b/packages/stack-shared/src/interface/crud/tenancy.ts @@ -0,0 +1,33 @@ +import { CrudTypeOf, createCrud } from "../../crud"; +import * as schemaFields from "../../schema-fields"; +import { yupObject } from "../../schema-fields"; + +export const tenancyCrudAdminReadSchema = yupObject({ + project_id: schemaFields.yupString().defined(), + branch_id: schemaFields.yupString().defined(), + organization_id: schemaFields.yupString().optional(), + id: schemaFields.yupString().defined(), + config: schemaFields.yupMixed().defined(), +}).defined(); + +export const tenancyCrudAdminUpdateSchema = yupObject({ + config: schemaFields.yupMixed().optional(), +}).defined(); + +export const tenancyCrud = createCrud({ + adminReadSchema: tenancyCrudAdminReadSchema, + adminUpdateSchema: tenancyCrudAdminUpdateSchema, + docs: { + adminRead: { + summary: 'Get the current tenancy', + description: 'Get the current tenancy information and configuration', + tags: ['Tenancies'], + }, + adminUpdate: { + summary: 'Update the current tenancy', + description: 'Update the current tenancy information and configuration', + tags: ['Tenancies'], + }, + }, +}); +export type TenancyCrud = CrudTypeOf; From a9ceab2fb439666236d06c9cd2825d4a88d2964d Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 13:00:50 -0700 Subject: [PATCH 02/88] remove old config --- .../oauth/callback/[provider_id]/route.tsx | 17 +++++--- .../api/latest/emails/render-email/route.tsx | 6 +-- .../environment-config/current/crud.tsx | 39 +++++++++++++++++++ .../app/api/latest/projects/current/crud.tsx | 3 +- apps/backend/src/lib/redirect-urls.tsx | 6 +-- apps/backend/src/lib/tenancies.tsx | 7 +--- apps/backend/src/oauth/index.tsx | 28 ++++++------- .../src/interface/crud/environment-config.ts | 33 ++++++++++++++++ 8 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 apps/backend/src/app/api/latest/internal/environment-config/current/crud.tsx create mode 100644 packages/stack-shared/src/interface/crud/environment-config.ts diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index 3f8fd3ff4a..f68241b3b6 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -44,7 +44,7 @@ async function createProjectUserOAuthAccount(prisma: PrismaClient, params: { } const redirectOrThrowError = (error: KnownError, tenancy: Tenancy, errorRedirectUrl?: string) => { - if (!errorRedirectUrl || !validateRedirectUrl(errorRedirectUrl, tenancy.config.domains, tenancy.config.allow_localhost)) { + if (!errorRedirectUrl || !validateRedirectUrl(errorRedirectUrl, Object.values(tenancy.config.domains), tenancy.config.domains.allowLocalhost)) { throw error; } @@ -119,12 +119,17 @@ const handler = createSmartRouteHandler({ throw new KnownErrors.OuterOAuthTimeout(); } - const provider = tenancy.config.oauth_providers.find((p) => p.id === params.provider_id); - if (!provider) { + const providerRaw = Object.entries(tenancy.config.auth.oauth.providers).find(([providerId, _]) => providerId === params.provider_id); + if (!providerRaw) { throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); } - const providerObj = await getProvider(provider); + const provider = { + id: providerRaw[0], + ...providerRaw[1], + }; + + const providerObj = await getProvider(provider as any); let callbackResult: Awaited>; try { callbackResult = await providerObj.getCallback({ @@ -279,7 +284,7 @@ const handler = createSmartRouteHandler({ // ========================== sign up user ========================== - if (!tenancy.config.sign_up_enabled) { + if (!tenancy.config.auth.allowSignUp) { throw new KnownErrors.SignUpNotEnabled(); } @@ -298,7 +303,7 @@ const handler = createSmartRouteHandler({ // Check if we should link this OAuth account to an existing user based on email if (oldContactChannel && oldContactChannel.usedForAuth) { - const oauthAccountMergeStrategy = tenancy.config.oauth_account_merge_strategy; + const oauthAccountMergeStrategy = tenancy.config.auth.oauth.accountMergeStrategy; switch (oauthAccountMergeStrategy) { case 'link_method': { if (!oldContactChannel.isVerified) { diff --git a/apps/backend/src/app/api/latest/emails/render-email/route.tsx b/apps/backend/src/app/api/latest/emails/render-email/route.tsx index 9832e43130..f812806bd8 100644 --- a/apps/backend/src/app/api/latest/emails/render-email/route.tsx +++ b/apps/backend/src/app/api/latest/emails/render-email/route.tsx @@ -2,7 +2,7 @@ import { renderEmailWithTemplate } from "@/lib/email-rendering"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { adaptSchema, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { captureError, StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; export const POST = createSmartRouteHandler({ @@ -40,8 +40,8 @@ export const POST = createSmartRouteHandler({ if ((!body.template_id && !body.template_tsx_source) || (body.template_id && body.template_tsx_source)) { throw new StatusError(400, "Exactly one of template_id or template_tsx_source must be provided"); } - const themeList = new Map(Object.entries(tenancy.completeConfig.emails.themeList)); - const templateList = new Map(Object.entries(tenancy.completeConfig.emails.templateList)); + const themeList = new Map(Object.entries(tenancy.config.emails.themeList)); + const templateList = new Map(Object.entries(tenancy.config.emails.templateList)); const themeSource = body.theme_id ? themeList.get(body.theme_id)?.tsxSource : body.theme_tsx_source; const templateSource = body.template_id ? templateList.get(body.template_id)?.tsxSource : body.template_tsx_source; if (!themeSource) { diff --git a/apps/backend/src/app/api/latest/internal/environment-config/current/crud.tsx b/apps/backend/src/app/api/latest/internal/environment-config/current/crud.tsx new file mode 100644 index 0000000000..d251a36067 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/environment-config/current/crud.tsx @@ -0,0 +1,39 @@ +import { overrideEnvironmentConfigOverride } from "@/lib/config"; +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createCrudHandlers } from "@/route-handlers/crud-handler"; +import { environmentConfigCrud } from "@stackframe/stack-shared/dist/interface/crud/environment-config"; +import { yupObject } from "@stackframe/stack-shared/dist/schema-fields"; +import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; + +export const environmentConfigCrudHandlers = createLazyProxy(() => createCrudHandlers(environmentConfigCrud, { + paramsSchema: yupObject({}), + onRead: async ({ auth }) => { + return { + id: auth.tenancy.id, + project_id: auth.project.id, + branch_id: auth.tenancy.branchId, + organization_id: auth.tenancy.organization?.id, + config: auth.tenancy.completeConfig, + }; + }, + onUpdate: async ({ auth, data }) => { + const prisma = await getPrismaClientForTenancy(auth.tenancy); + + if (data.config) { + await overrideEnvironmentConfigOverride({ + tx: prisma, + projectId: auth.project.id, + branchId: auth.tenancy.branchId, + environmentConfigOverrideOverride: data.config, + }); + } + + return { + id: auth.tenancy.id, + project_id: auth.project.id, + branch_id: auth.tenancy.branchId, + organization_id: auth.tenancy.organization?.id, + config: auth.tenancy.completeConfig, + }; + }, +})); diff --git a/apps/backend/src/app/api/latest/projects/current/crud.tsx b/apps/backend/src/app/api/latest/projects/current/crud.tsx index 6f230c1562..7d4b2c8c7c 100644 --- a/apps/backend/src/app/api/latest/projects/current/crud.tsx +++ b/apps/backend/src/app/api/latest/projects/current/crud.tsx @@ -1,3 +1,4 @@ +import { renderedOrganizationConfigToProjectCrud } from "@/lib/config"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { clientProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { yupObject } from "@stackframe/stack-shared/dist/schema-fields"; @@ -8,7 +9,7 @@ export const clientProjectsCrudHandlers = createLazyProxy(() => createCrudHandle onRead: async ({ auth }) => { return { ...auth.project, - config: auth.tenancy.config, + config: renderedOrganizationConfigToProjectCrud(auth.tenancy.config), }; }, })); diff --git a/apps/backend/src/lib/redirect-urls.tsx b/apps/backend/src/lib/redirect-urls.tsx index 08e0b2d36f..aa70255d1b 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -1,7 +1,7 @@ import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; import { createUrlIfValid, isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; -export function validateRedirectUrl(urlOrString: string | URL, domains: { domain: string, handler_path: string }[], allowLocalhost: boolean): boolean { +export function validateRedirectUrl(urlOrString: string | URL, domains: { baseUrl: string }[], allowLocalhost: boolean): boolean { const url = createUrlIfValid(urlOrString); if (!url) return false; if (allowLocalhost && isLocalhost(url)) { @@ -9,10 +9,10 @@ export function validateRedirectUrl(urlOrString: string | URL, domains: { domain } return domains.some((domain) => { const testUrl = url; - const baseUrl = createUrlIfValid(domain.domain); + const baseUrl = createUrlIfValid(domain.baseUrl); if (!baseUrl) { captureError("invalid-redirect-domain", new StackAssertionError("Invalid redirect domain; maybe this should be fixed in the database", { - domain: domain.domain, + domain: domain.baseUrl, })); return false; } diff --git a/apps/backend/src/lib/tenancies.tsx b/apps/backend/src/lib/tenancies.tsx index 25eeec6ed2..bb99f0f9d8 100644 --- a/apps/backend/src/lib/tenancies.tsx +++ b/apps/backend/src/lib/tenancies.tsx @@ -2,7 +2,7 @@ import { globalPrismaClient, rawQuery } from "@/prisma-client"; import { Prisma } from "@prisma/client"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { getRenderedOrganizationConfigQuery, renderedOrganizationConfigToProjectCrud } from "./config"; +import { getRenderedOrganizationConfigQuery } from "./config"; import { getProject } from "./projects"; /** @@ -31,13 +31,10 @@ export async function tenancyPrismaToCrud(prisma: Prisma.TenancyGetPayload<{}>) branchId: prisma.branchId, organizationId: prisma.organizationId, })); - const oldProjectConfig = renderedOrganizationConfigToProjectCrud(completeConfig); return { id: prisma.id, - /** @deprecated */ - config: oldProjectConfig, - completeConfig, + config: completeConfig, branchId: prisma.branchId, organization: prisma.organizationId === null ? null : { // TODO actual organization type diff --git a/apps/backend/src/oauth/index.tsx b/apps/backend/src/oauth/index.tsx index 20d14f0948..d7165a2c11 100644 --- a/apps/backend/src/oauth/index.tsx +++ b/apps/backend/src/oauth/index.tsx @@ -1,7 +1,6 @@ -import { DEFAULT_BRANCH_ID } from "@/lib/tenancies"; +import { DEFAULT_BRANCH_ID, Tenancy } from "@/lib/tenancies"; import { DiscordProvider } from "@/oauth/providers/discord"; import OAuth2Server from "@node-oauth/oauth2-server"; -import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { OAuthModel } from "./model"; @@ -16,8 +15,8 @@ import { LinkedInProvider } from "./providers/linkedin"; import { MicrosoftProvider } from "./providers/microsoft"; import { MockProvider } from "./providers/mock"; import { SpotifyProvider } from "./providers/spotify"; -import { XProvider } from "./providers/x"; import { TwitchProvider } from "./providers/twitch"; +import { XProvider } from "./providers/x"; const _providers = { github: GithubProvider, @@ -57,27 +56,28 @@ export function getProjectBranchFromClientId(clientId: string): [projectId: stri return [projectId, branchId]; } -export async function getProvider(provider: ProjectsCrud['Admin']['Read']['config']['oauth_providers'][number]): Promise { - if (provider.type === 'shared') { - const clientId = _getEnvForProvider(provider.id).clientId; - const clientSecret = _getEnvForProvider(provider.id).clientSecret; +export async function getProvider(provider: Tenancy['config']['auth']['oauth']['providers'][string]): Promise { + const providerType = provider.type || throwErr("Provider type is required for shared providers"); + if (provider.isShared) { + const clientId = _getEnvForProvider(providerType).clientId; + const clientSecret = _getEnvForProvider(providerType).clientSecret; if (clientId === "MOCK") { if (clientSecret !== "MOCK") { throw new StackAssertionError("If OAuth provider client ID is set to MOCK, then client secret must also be set to MOCK"); } - return await mockProvider.create(provider.id); + return await mockProvider.create(providerType); } else { - return await _providers[provider.id].create({ + return await _providers[providerType].create({ clientId, clientSecret, }); } } else { - return await _providers[provider.id].create({ - clientId: provider.client_id || throwErr("Client ID is required for standard providers"), - clientSecret: provider.client_secret || throwErr("Client secret is required for standard providers"), - facebookConfigId: provider.facebook_config_id, - microsoftTenantId: provider.microsoft_tenant_id, + return await _providers[providerType].create({ + clientId: provider.clientId || throwErr("Client ID is required for standard providers"), + clientSecret: provider.clientSecret || throwErr("Client secret is required for standard providers"), + facebookConfigId: provider.facebookConfigId, + microsoftTenantId: provider.microsoftTenantId, }); } } diff --git a/packages/stack-shared/src/interface/crud/environment-config.ts b/packages/stack-shared/src/interface/crud/environment-config.ts new file mode 100644 index 0000000000..f44fc53d0e --- /dev/null +++ b/packages/stack-shared/src/interface/crud/environment-config.ts @@ -0,0 +1,33 @@ +import { CrudTypeOf, createCrud } from "../../crud"; +import * as schemaFields from "../../schema-fields"; +import { yupObject } from "../../schema-fields"; + +export const environmentConfigCrudAdminReadSchema = yupObject({ + project_id: schemaFields.yupString().defined(), + branch_id: schemaFields.yupString().defined(), + organization_id: schemaFields.yupString().optional(), + id: schemaFields.yupString().defined(), + config: schemaFields.yupMixed().defined(), +}).defined(); + +export const environmentConfigCrudAdminUpdateSchema = yupObject({ + config: schemaFields.yupMixed().optional(), +}).defined(); + +export const environmentConfigCrud = createCrud({ + adminReadSchema: environmentConfigCrudAdminReadSchema, + adminUpdateSchema: environmentConfigCrudAdminUpdateSchema, + docs: { + adminRead: { + summary: 'Get the current environment config', + description: 'Get the current environment config', + tags: ['Environment Config'], + }, + adminUpdate: { + summary: 'Update the current environment config', + description: 'Update the current environment config', + tags: ['Environment Config'], + }, + }, +}); +export type EnvironmentConfigCrud = CrudTypeOf; From 6390832ac4e27c523cb018bbd576a465352d4092 Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 13:23:40 -0700 Subject: [PATCH 03/88] fix validateRedirectUrl --- .../auth/oauth/callback/[provider_id]/route.tsx | 2 +- .../api/latest/auth/password/sign-up/route.tsx | 10 +++------- .../app/api/latest/emails/send-email/route.tsx | 8 ++++---- .../email-templates/[templateId]/route.tsx | 8 ++++---- .../latest/internal/email-templates/route.tsx | 2 +- .../latest/internal/email-themes/[id]/route.tsx | 8 ++++---- .../api/latest/internal/email-themes/route.tsx | 7 +++---- .../internal/environment-config/current/crud.tsx | 4 ++-- .../latest/internal/projects/current/crud.tsx | 7 ++++--- .../internal/send-sign-in-invitation/route.tsx | 2 +- .../src/app/api/latest/oauth-providers/crud.tsx | 2 +- apps/backend/src/app/api/latest/users/crud.tsx | 4 ++-- .../src/lib/ai-chat/email-template-adapter.ts | 2 +- .../src/lib/ai-chat/email-theme-adapter.ts | 2 +- apps/backend/src/lib/permissions.tsx | 16 ++++++++-------- apps/backend/src/lib/redirect-urls.tsx | 14 +++++++++++--- apps/backend/src/lib/tenancies.tsx | 4 ++-- apps/backend/src/oauth/model.tsx | 14 +++++--------- apps/backend/src/prisma-client.tsx | 4 ++-- .../route-handlers/verification-code-handler.tsx | 6 +----- 20 files changed, 61 insertions(+), 65 deletions(-) diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index f68241b3b6..d5b12f7800 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -44,7 +44,7 @@ async function createProjectUserOAuthAccount(prisma: PrismaClient, params: { } const redirectOrThrowError = (error: KnownError, tenancy: Tenancy, errorRedirectUrl?: string) => { - if (!errorRedirectUrl || !validateRedirectUrl(errorRedirectUrl, Object.values(tenancy.config.domains), tenancy.config.domains.allowLocalhost)) { + if (!errorRedirectUrl || !validateRedirectUrl(errorRedirectUrl, tenancy)) { throw error; } diff --git a/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx b/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx index ca954beb98..7679af03cb 100644 --- a/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx +++ b/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx @@ -36,15 +36,11 @@ export const POST = createSmartRouteHandler({ }).defined(), }), async handler({ auth: { tenancy }, body: { email, password, verification_callback_url: verificationCallbackUrl } }, fullReq) { - if (!tenancy.config.credential_enabled) { + if (!tenancy.config.auth.password.allowSignIn) { throw new KnownErrors.PasswordAuthenticationNotEnabled(); } - if (!validateRedirectUrl( - verificationCallbackUrl, - tenancy.config.domains, - tenancy.config.allow_localhost, - )) { + if (!validateRedirectUrl(verificationCallbackUrl, tenancy)) { throw new KnownErrors.RedirectUrlNotWhitelisted(); } @@ -53,7 +49,7 @@ export const POST = createSmartRouteHandler({ throw passwordError; } - if (!tenancy.config.sign_up_enabled) { + if (!tenancy.config.auth.allowSignUp) { throw new KnownErrors.SignUpNotEnabled(); } diff --git a/apps/backend/src/app/api/latest/emails/send-email/route.tsx b/apps/backend/src/app/api/latest/emails/send-email/route.tsx index fd2a6901f9..7fc9aef4ba 100644 --- a/apps/backend/src/app/api/latest/emails/send-email/route.tsx +++ b/apps/backend/src/app/api/latest/emails/send-email/route.tsx @@ -48,7 +48,7 @@ export const POST = createSmartRouteHandler({ if (!getEnvVariable("STACK_FREESTYLE_API_KEY")) { throw new StatusError(500, "STACK_FREESTYLE_API_KEY is not set"); } - if (auth.tenancy.config.email_config.type === "shared") { + if (auth.tenancy.config.emails.server.isShared) { throw new StatusError(400, "Cannot send custom emails when using shared email config"); } const emailConfig = await getEmailConfig(auth.tenancy); @@ -56,11 +56,11 @@ export const POST = createSmartRouteHandler({ if (!notificationCategory) { throw new StatusError(404, "Notification category not found"); } - const themeList = auth.tenancy.completeConfig.emails.themeList; - if (!Object.keys(themeList).includes(auth.tenancy.completeConfig.emails.theme)) { + const themeList = auth.tenancy.config.emails.themeList; + if (!Object.keys(themeList).includes(auth.tenancy.config.emails.theme)) { throw new StatusError(400, "No active theme found"); } - const activeTheme = themeList[auth.tenancy.completeConfig.emails.theme]; + const activeTheme = themeList[auth.tenancy.config.emails.theme]; const prisma = await getPrismaClientForTenancy(auth.tenancy); diff --git a/apps/backend/src/app/api/latest/internal/email-templates/[templateId]/route.tsx b/apps/backend/src/app/api/latest/internal/email-templates/[templateId]/route.tsx index c58af98711..24dc52743c 100644 --- a/apps/backend/src/app/api/latest/internal/email-templates/[templateId]/route.tsx +++ b/apps/backend/src/app/api/latest/internal/email-templates/[templateId]/route.tsx @@ -1,10 +1,10 @@ import { overrideEnvironmentConfigOverride } from "@/lib/config"; +import { renderEmailWithTemplate } from "@/lib/email-rendering"; import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { adaptSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; -import { renderEmailWithTemplate } from "@/lib/email-rendering"; -import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; export const PATCH = createSmartRouteHandler({ @@ -31,11 +31,11 @@ export const PATCH = createSmartRouteHandler({ }).defined(), }), async handler({ auth: { tenancy }, params: { templateId }, body }) { - const templateList = tenancy.completeConfig.emails.templateList; + const templateList = tenancy.config.emails.templateList; if (!Object.keys(templateList).includes(templateId)) { throw new StatusError(StatusError.NotFound, "No template found with given id"); } - const theme = tenancy.completeConfig.emails.themeList[tenancy.completeConfig.emails.theme]; + const theme = tenancy.config.emails.themeList[tenancy.config.emails.theme]; const result = await renderEmailWithTemplate(body.tsx_source, theme.tsxSource, { projectDisplayName: tenancy.project.display_name }); if (result.status === "error") { throw new KnownErrors.EmailRenderingError(result.error); diff --git a/apps/backend/src/app/api/latest/internal/email-templates/route.tsx b/apps/backend/src/app/api/latest/internal/email-templates/route.tsx index 8901625708..dc651219e9 100644 --- a/apps/backend/src/app/api/latest/internal/email-templates/route.tsx +++ b/apps/backend/src/app/api/latest/internal/email-templates/route.tsx @@ -25,7 +25,7 @@ export const GET = createSmartRouteHandler({ }).defined(), }), async handler({ auth: { tenancy } }) { - const templates = Object.entries(tenancy.completeConfig.emails.templateList).map(([id, template]) => ({ + const templates = Object.entries(tenancy.config.emails.templateList).map(([id, template]) => ({ id, subject: template.subject, display_name: template.displayName, diff --git a/apps/backend/src/app/api/latest/internal/email-themes/[id]/route.tsx b/apps/backend/src/app/api/latest/internal/email-themes/[id]/route.tsx index 393c450e85..965761f17c 100644 --- a/apps/backend/src/app/api/latest/internal/email-themes/[id]/route.tsx +++ b/apps/backend/src/app/api/latest/internal/email-themes/[id]/route.tsx @@ -1,8 +1,8 @@ import { overrideEnvironmentConfigOverride } from "@/lib/config"; -import { globalPrismaClient } from "@/prisma-client"; import { renderEmailWithTemplate } from "@/lib/email-rendering"; -import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails"; +import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { adaptSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; @@ -29,7 +29,7 @@ export const GET = createSmartRouteHandler({ }).defined(), }), async handler({ auth: { tenancy }, params: { id } }) { - const themeList = tenancy.completeConfig.emails.themeList; + const themeList = tenancy.config.emails.themeList; if (!Object.keys(themeList).includes(id)) { throw new StatusError(404, "No theme found with given id"); } @@ -69,7 +69,7 @@ export const PATCH = createSmartRouteHandler({ }).defined(), }), async handler({ auth: { tenancy }, params: { id }, body }) { - const themeList = tenancy.completeConfig.emails.themeList; + const themeList = tenancy.config.emails.themeList; if (!Object.keys(themeList).includes(id)) { throw new StatusError(404, "No theme found with given id"); } diff --git a/apps/backend/src/app/api/latest/internal/email-themes/route.tsx b/apps/backend/src/app/api/latest/internal/email-themes/route.tsx index b9be630f4d..906cc9d3e8 100644 --- a/apps/backend/src/app/api/latest/internal/email-themes/route.tsx +++ b/apps/backend/src/app/api/latest/internal/email-themes/route.tsx @@ -1,8 +1,7 @@ import { overrideEnvironmentConfigOverride } from "@/lib/config"; -import { LightEmailTheme } from "@stackframe/stack-shared/dist/helpers/emails"; import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { DEFAULT_EMAIL_THEME_ID } from "@stackframe/stack-shared/dist/helpers/emails"; +import { DEFAULT_EMAIL_THEME_ID, LightEmailTheme } from "@stackframe/stack-shared/dist/helpers/emails"; import { adaptSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; @@ -69,8 +68,8 @@ export const GET = createSmartRouteHandler({ }).defined(), }), async handler({ auth: { tenancy } }) { - const themeList = tenancy.completeConfig.emails.themeList; - const currentActiveTheme = tenancy.completeConfig.emails.theme; + const themeList = tenancy.config.emails.themeList; + const currentActiveTheme = tenancy.config.emails.theme; if (!(currentActiveTheme in themeList)) { let newActiveTheme: string; if (DEFAULT_EMAIL_THEME_ID in themeList) { diff --git a/apps/backend/src/app/api/latest/internal/environment-config/current/crud.tsx b/apps/backend/src/app/api/latest/internal/environment-config/current/crud.tsx index d251a36067..aa8fda02e6 100644 --- a/apps/backend/src/app/api/latest/internal/environment-config/current/crud.tsx +++ b/apps/backend/src/app/api/latest/internal/environment-config/current/crud.tsx @@ -13,7 +13,7 @@ export const environmentConfigCrudHandlers = createLazyProxy(() => createCrudHan project_id: auth.project.id, branch_id: auth.tenancy.branchId, organization_id: auth.tenancy.organization?.id, - config: auth.tenancy.completeConfig, + config: auth.tenancy.config, }; }, onUpdate: async ({ auth, data }) => { @@ -33,7 +33,7 @@ export const environmentConfigCrudHandlers = createLazyProxy(() => createCrudHan project_id: auth.project.id, branch_id: auth.tenancy.branchId, organization_id: auth.tenancy.organization?.id, - config: auth.tenancy.completeConfig, + config: auth.tenancy.config, }; }, })); diff --git a/apps/backend/src/app/api/latest/internal/projects/current/crud.tsx b/apps/backend/src/app/api/latest/internal/projects/current/crud.tsx index 3975b40938..d52f26ebeb 100644 --- a/apps/backend/src/app/api/latest/internal/projects/current/crud.tsx +++ b/apps/backend/src/app/api/latest/internal/projects/current/crud.tsx @@ -1,3 +1,4 @@ +import { renderedOrganizationConfigToProjectCrud } from "@/lib/config"; import { createOrUpdateProject } from "@/lib/projects"; import { getTenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; @@ -12,7 +13,7 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro onUpdate: async ({ auth, data }) => { if ( data.config?.email_theme && - !Object.keys(auth.tenancy.completeConfig.emails.themeList).includes(data.config.email_theme) + !Object.keys(auth.tenancy.config.emails.themeList).includes(data.config.email_theme) ) { throw new StatusError(400, "Invalid email theme"); } @@ -25,13 +26,13 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro const tenancy = await getTenancy(auth.tenancy.id) ?? throwErr("Tenancy not found after project update?"); // since we updated the project, we need to re-fetch the new tenancy config return { ...project, - config: tenancy.config, + config: renderedOrganizationConfigToProjectCrud(tenancy.config), }; }, onRead: async ({ auth }) => { return { ...auth.project, - config: auth.tenancy.config, + config: renderedOrganizationConfigToProjectCrud(auth.tenancy.config), }; }, onDelete: async ({ auth }) => { diff --git a/apps/backend/src/app/api/latest/internal/send-sign-in-invitation/route.tsx b/apps/backend/src/app/api/latest/internal/send-sign-in-invitation/route.tsx index 2eb7c8dcf7..ca1f18d498 100644 --- a/apps/backend/src/app/api/latest/internal/send-sign-in-invitation/route.tsx +++ b/apps/backend/src/app/api/latest/internal/send-sign-in-invitation/route.tsx @@ -28,7 +28,7 @@ export const POST = createSmartRouteHandler({ }).defined(), }), async handler({ auth, body }) { - if (!validateRedirectUrl(body.callback_url, auth.tenancy.config.domains, auth.tenancy.config.allow_localhost)) { + if (!validateRedirectUrl(body.callback_url, auth.tenancy)) { throw new KnownErrors.RedirectUrlNotWhitelisted(); } diff --git a/apps/backend/src/app/api/latest/oauth-providers/crud.tsx b/apps/backend/src/app/api/latest/oauth-providers/crud.tsx index 47202bc399..591355def0 100644 --- a/apps/backend/src/app/api/latest/oauth-providers/crud.tsx +++ b/apps/backend/src/app/api/latest/oauth-providers/crud.tsx @@ -108,7 +108,7 @@ async function ensureProviderExists(tenancy: Tenancy, userId: string, providerId } function getProviderConfig(tenancy: Tenancy, providerConfigId: string) { - const config = tenancy.completeConfig; + const config = tenancy.config; let providerConfig: (typeof config.auth.oauth.providers)[number] & { id: string } | undefined; for (const [providerId, provider] of Object.entries(config.auth.oauth.providers)) { if (providerId === providerConfigId) { diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index fb5e28dba7..0f97a37807 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -474,7 +474,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC primaryEmailAuthEnabled: !!data.primary_email_auth_enabled, }); - const config = auth.tenancy.completeConfig; + const config = auth.tenancy.config; const newUser = await tx.projectUser.create({ data: { @@ -642,7 +642,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC const result = await retryTransaction(prisma, async (tx) => { await ensureUserExists(tx, { tenancyId: auth.tenancy.id, userId: params.user_id }); - const config = auth.tenancy.completeConfig; + const config = auth.tenancy.config; if (data.selected_team_id !== undefined) { if (data.selected_team_id !== null) { diff --git a/apps/backend/src/lib/ai-chat/email-template-adapter.ts b/apps/backend/src/lib/ai-chat/email-template-adapter.ts index 2de77e5275..8e172905d4 100644 --- a/apps/backend/src/lib/ai-chat/email-template-adapter.ts +++ b/apps/backend/src/lib/ai-chat/email-template-adapter.ts @@ -21,7 +21,7 @@ export const emailTemplateAdapter = (context: ChatAdapterContext) => ({ const CREATE_EMAIL_TEMPLATE_TOOL_DESCRIPTION = (context: ChatAdapterContext) => { - const currentEmailTemplate = context.tenancy.completeConfig.emails.templateList[context.threadId]; + const currentEmailTemplate = context.tenancy.config.emails.templateList[context.threadId]; return ` Create a new email template. diff --git a/apps/backend/src/lib/ai-chat/email-theme-adapter.ts b/apps/backend/src/lib/ai-chat/email-theme-adapter.ts index b74432f629..5f5fb75e1e 100644 --- a/apps/backend/src/lib/ai-chat/email-theme-adapter.ts +++ b/apps/backend/src/lib/ai-chat/email-theme-adapter.ts @@ -18,7 +18,7 @@ export const emailThemeAdapter = (context: ChatAdapterContext) => ({ }); const CREATE_EMAIL_THEME_TOOL_DESCRIPTION = (context: ChatAdapterContext) => { - const currentEmailTheme = context.tenancy.completeConfig.emails.themeList[context.threadId].tsxSource || ""; + const currentEmailTheme = context.tenancy.config.emails.themeList[context.threadId].tsxSource || ""; return ` Create a new email theme. diff --git a/apps/backend/src/lib/permissions.tsx b/apps/backend/src/lib/permissions.tsx index 1c541785b7..d1ec8dcd28 100644 --- a/apps/backend/src/lib/permissions.tsx +++ b/apps/backend/src/lib/permissions.tsx @@ -103,7 +103,7 @@ export async function grantTeamPermission( } ) { // sanity check: make sure that the permission exists - const permissionDefinition = getOrUndefined(options.tenancy.completeConfig.rbac.permissions, options.permissionId); + const permissionDefinition = getOrUndefined(options.tenancy.config.rbac.permissions, options.permissionId); if (permissionDefinition === undefined) { if (!has(teamSystemPermissionMap, options.permissionId)) { throw new KnownErrors.PermissionNotFound(options.permissionId); @@ -164,7 +164,7 @@ export async function listPermissionDefinitions( tenancy: Tenancy, } ): Promise<(TeamPermissionDefinitionsCrud["Admin"]["Read"])[]> { - const renderedConfig = options.tenancy.completeConfig; + const renderedConfig = options.tenancy.config; const permissions = typedEntries(renderedConfig.rbac.permissions).filter(([_, p]) => p.scope === options.scope); @@ -194,7 +194,7 @@ export async function createPermissionDefinition( }, } ) { - const oldConfig = options.tenancy.completeConfig; + const oldConfig = options.tenancy.config; const existingPermission = oldConfig.rbac.permissions[options.data.id] as OrganizationRenderedConfig['rbac']['permissions'][string] | undefined; const allIds = Object.keys(oldConfig.rbac.permissions) @@ -256,7 +256,7 @@ export async function updatePermissionDefinition( } ) { const newId = options.data.id ?? options.oldId; - const oldConfig = options.tenancy.completeConfig; + const oldConfig = options.tenancy.config; const existingPermission = oldConfig.rbac.permissions[options.oldId] as OrganizationRenderedConfig['rbac']['permissions'][string] | undefined; @@ -351,7 +351,7 @@ export async function deletePermissionDefinition( permissionId: string, } ) { - const oldConfig = options.tenancy.completeConfig; + const oldConfig = options.tenancy.config; const existingPermission = oldConfig.rbac.permissions[options.permissionId] as OrganizationRenderedConfig['rbac']['permissions'][string] | undefined; @@ -414,7 +414,7 @@ export async function grantProjectPermission( } ) { // sanity check: make sure that the permission exists - const permissionDefinition = getOrUndefined(options.tenancy.completeConfig.rbac.permissions, options.permissionId); + const permissionDefinition = getOrUndefined(options.tenancy.config.rbac.permissions, options.permissionId); if (permissionDefinition === undefined) { throw new KnownErrors.PermissionNotFound(options.permissionId); } else if (permissionDefinition.scope !== "project") { @@ -473,7 +473,7 @@ export async function grantDefaultProjectPermissions( userId: string, } ) { - const config = options.tenancy.completeConfig; + const config = options.tenancy.config; for (const permissionId of Object.keys(config.rbac.defaultPermissions.signUp)) { await grantProjectPermission(tx, { @@ -501,7 +501,7 @@ export async function grantDefaultTeamPermissions( type: "creator" | "member", } ) { - const config = options.tenancy.completeConfig; + const config = options.tenancy.config; const defaultPermissions = config.rbac.defaultPermissions[options.type === "creator" ? "teamCreator" : "teamMember"]; diff --git a/apps/backend/src/lib/redirect-urls.tsx b/apps/backend/src/lib/redirect-urls.tsx index aa70255d1b..33acfa8135 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -1,13 +1,21 @@ import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; import { createUrlIfValid, isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; +import { Tenancy } from "./tenancies"; -export function validateRedirectUrl(urlOrString: string | URL, domains: { baseUrl: string }[], allowLocalhost: boolean): boolean { +export function validateRedirectUrl( + urlOrString: string | URL, + tenancy: Tenancy, +): boolean { const url = createUrlIfValid(urlOrString); if (!url) return false; - if (allowLocalhost && isLocalhost(url)) { + if (tenancy.config.domains.allowLocalhost && isLocalhost(url)) { return true; } - return domains.some((domain) => { + return Object.values(tenancy.config.domains.trustedDomains).some((domain) => { + if (!domain.baseUrl) { + return false; + } + const testUrl = url; const baseUrl = createUrlIfValid(domain.baseUrl); if (!baseUrl) { diff --git a/apps/backend/src/lib/tenancies.tsx b/apps/backend/src/lib/tenancies.tsx index bb99f0f9d8..a2b877340a 100644 --- a/apps/backend/src/lib/tenancies.tsx +++ b/apps/backend/src/lib/tenancies.tsx @@ -26,7 +26,7 @@ export async function tenancyPrismaToCrud(prisma: Prisma.TenancyGetPayload<{}>) const projectCrud = await getProject(prisma.projectId) ?? throwErr("Project in tenancy not found"); - const completeConfig = await rawQuery(globalPrismaClient, getRenderedOrganizationConfigQuery({ + const config = await rawQuery(globalPrismaClient, getRenderedOrganizationConfigQuery({ projectId: projectCrud.id, branchId: prisma.branchId, organizationId: prisma.organizationId, @@ -34,7 +34,7 @@ export async function tenancyPrismaToCrud(prisma: Prisma.TenancyGetPayload<{}>) return { id: prisma.id, - config: completeConfig, + config, branchId: prisma.branchId, organization: prisma.organizationId === null ? null : { // TODO actual organization type diff --git a/apps/backend/src/oauth/model.tsx b/apps/backend/src/oauth/model.tsx index 3af89542bd..178331f8a2 100644 --- a/apps/backend/src/oauth/model.tsx +++ b/apps/backend/src/oauth/model.tsx @@ -52,8 +52,8 @@ export class OAuthModel implements AuthorizationCodeModel { let redirectUris: string[] = []; try { - redirectUris = tenancy.config.domains.map( - ({ domain, handler_path }) => new URL(handler_path, domain).toString() + redirectUris = Object.entries(tenancy.config.domains.trustedDomains).map( + ([_, domain]) => new URL(domain.handlerPath, domain.baseUrl).toString() ); } catch (e) { captureError("get redirect uris", { @@ -64,7 +64,7 @@ export class OAuthModel implements AuthorizationCodeModel { throw e; } - if (redirectUris.length === 0 && tenancy.config.allow_localhost) { + if (redirectUris.length === 0 && tenancy.config.domains.allowLocalhost) { redirectUris.push("http://localhost"); } @@ -267,7 +267,7 @@ export class OAuthModel implements AuthorizationCodeModel { assertScopeIsValid(code.scope); const tenancy = await getSoleTenancyFromProjectBranch(...getProjectBranchFromClientId(client.id)); - if (!validateRedirectUrl(code.redirectUri, tenancy.config.domains, tenancy.config.allow_localhost)) { + if (!validateRedirectUrl(code.redirectUri, tenancy)) { throw new KnownErrors.RedirectUrlNotWhitelisted(); } @@ -353,10 +353,6 @@ export class OAuthModel implements AuthorizationCodeModel { async validateRedirectUri(redirect_uri: string, client: Client): Promise { const tenancy = await getSoleTenancyFromProjectBranch(...getProjectBranchFromClientId(client.id)); - return validateRedirectUrl( - redirect_uri, - tenancy.config.domains, - tenancy.config.allow_localhost, - ); + return validateRedirectUrl(redirect_uri, tenancy); } } diff --git a/apps/backend/src/prisma-client.tsx b/apps/backend/src/prisma-client.tsx index 13218a24e7..7933689e44 100644 --- a/apps/backend/src/prisma-client.tsx +++ b/apps/backend/src/prisma-client.tsx @@ -48,11 +48,11 @@ function getSchemaFromConnectionString(connectionString: string) { } export async function getPrismaClientForTenancy(tenancy: Tenancy) { - return await getPrismaClientForSourceOfTruth(tenancy.completeConfig.sourceOfTruth, tenancy.branchId); + return await getPrismaClientForSourceOfTruth(tenancy.config.sourceOfTruth, tenancy.branchId); } export function getPrismaSchemaForTenancy(tenancy: Tenancy) { - return getPrismaSchemaForSourceOfTruth(tenancy.completeConfig.sourceOfTruth, tenancy.branchId); + return getPrismaSchemaForSourceOfTruth(tenancy.config.sourceOfTruth, tenancy.branchId); } function getPostgresPrismaClient(connectionString: string) { diff --git a/apps/backend/src/route-handlers/verification-code-handler.tsx b/apps/backend/src/route-handlers/verification-code-handler.tsx index a1e26b264b..cd01416d1b 100644 --- a/apps/backend/src/route-handlers/verification-code-handler.tsx +++ b/apps/backend/src/route-handlers/verification-code-handler.tsx @@ -228,11 +228,7 @@ export function createVerificationCodeHandler< }); const tenancy = await getSoleTenancyFromProjectBranch(project.id, branchId); - if (callbackUrl !== undefined && !validateRedirectUrl( - callbackUrl, - tenancy.config.domains, - tenancy.config.allow_localhost, - )) { + if (callbackUrl !== undefined && !validateRedirectUrl(callbackUrl, tenancy)) { throw new KnownErrors.RedirectUrlNotWhitelisted(); } From 1f0253258acb6729eb209025c2c96984c0c4464e Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 14:06:00 -0700 Subject: [PATCH 04/88] fix tenancy config --- apps/backend/prisma/seed.ts | 4 +- .../app/api/latest/(api-keys)/handlers.tsx | 20 +++---- .../oauth/authorize/[provider_id]/route.tsx | 8 ++- .../oauth/callback/[provider_id]/route.tsx | 5 +- .../auth/otp/send-sign-in-code/route.tsx | 2 +- .../otp/sign-in/verification-code-handler.tsx | 2 +- .../initiate-passkey-authentication/route.tsx | 2 +- .../initiate-passkey-registration/route.tsx | 2 +- .../register/verification-code-handler.tsx | 9 ++- .../sign-in/verification-code-handler.tsx | 9 ++- .../reset/verification-code-handler.tsx | 2 +- .../auth/password/send-reset-code/route.tsx | 2 +- .../api/latest/auth/password/set/route.tsx | 2 +- .../latest/auth/password/sign-in/route.tsx | 2 +- .../api/latest/auth/password/update/route.tsx | 2 +- .../[provider_id]/access-token/crud.tsx | 8 ++- .../app/api/latest/email-templates/crud.tsx | 2 +- .../neon/oauth-providers/crud.tsx | 55 +++++++++++++------ 18 files changed, 82 insertions(+), 56 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 8a8135d8ed..39d6cb118b 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -71,7 +71,9 @@ async function seed() { allow_localhost: allowLocalhost, domains: [ ...(dashboardDomain && new URL(dashboardDomain).hostname !== 'localhost' ? [{ domain: dashboardDomain, handler_path: '/handler' }] : []), - ...internalTenancy.config.domains.filter((d) => d.domain !== dashboardDomain), + ...Object.values(internalTenancy.config.domains.trustedDomains) + .filter((d) => d.baseUrl !== dashboardDomain && d.baseUrl) + .map((d) => ({ domain: d.baseUrl!, handler_path: d.handlerPath })), ] }, }, diff --git a/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx b/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx index 01260083d8..1ef6876411 100644 --- a/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx +++ b/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx @@ -1,4 +1,5 @@ import { listPermissions } from "@/lib/permissions"; +import { Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { SmartRequestAuth } from "@/route-handlers/smart-request"; @@ -15,16 +16,13 @@ import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import * as yup from "yup"; -async function throwIfFeatureDisabled(project: { - allow_team_api_keys: boolean, - allow_user_api_keys: boolean, -}, type: "team" | "user") { +async function throwIfFeatureDisabled(tenancy: Tenancy, type: "team" | "user") { if (type === "team") { - if (!project.allow_team_api_keys) { + if (!tenancy.config.apiKeys.enabled.team) { throw new StatusError(StatusError.BadRequest, "Team API keys are not enabled for this project."); } } else { - if (!project.allow_user_api_keys) { + if (!tenancy.config.apiKeys.enabled.user) { throw new StatusError(StatusError.BadRequest, "User API keys are not enabled for this project."); } } @@ -175,7 +173,7 @@ function createApiKeyHandlers(type: Type) { body: type === 'user' ? userApiKeysCreateOutputSchema.defined() : teamApiKeysCreateOutputSchema.defined(), }), handler: async ({ url, auth, body }) => { - await throwIfFeatureDisabled(auth.tenancy.config, type); + await throwIfFeatureDisabled(auth.tenancy, type); const { userId, teamId } = await parseTypeAndParams({ type, params: body }); await ensureUserCanManageApiKeys(auth, { userId, @@ -247,7 +245,7 @@ function createApiKeyHandlers(type: Type) { body: (type === 'user' ? userApiKeysCrud : teamApiKeysCrud).server.readSchema.defined(), }), handler: async ({ auth, body }) => { - await throwIfFeatureDisabled(auth.tenancy.config, type); + await throwIfFeatureDisabled(auth.tenancy, type); const prisma = await getPrismaClientForTenancy(auth.tenancy); const apiKey = await prisma.projectApiKey.findUnique({ @@ -297,7 +295,7 @@ function createApiKeyHandlers(type: Type) { }), onList: async ({ auth, query }) => { - await throwIfFeatureDisabled(auth.tenancy.config, type); + await throwIfFeatureDisabled(auth.tenancy, type); const { userId, teamId } = await parseTypeAndParams({ type, params: query }); await ensureUserCanManageApiKeys(auth, { userId, @@ -323,7 +321,7 @@ function createApiKeyHandlers(type: Type) { }, onRead: async ({ auth, query, params }) => { - await throwIfFeatureDisabled(auth.tenancy.config, type); + await throwIfFeatureDisabled(auth.tenancy, type); const prisma = await getPrismaClientForTenancy(auth.tenancy); @@ -348,7 +346,7 @@ function createApiKeyHandlers(type: Type) { }, onUpdate: async ({ auth, data, params, query }) => { - await throwIfFeatureDisabled(auth.tenancy.config, type); + await throwIfFeatureDisabled(auth.tenancy, type); const prisma = await getPrismaClientForTenancy(auth.tenancy); diff --git a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx index 6c74da4f84..d8b6879750 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx @@ -64,11 +64,13 @@ export const GET = createSmartRouteHandler({ throw new KnownErrors.InvalidPublishableClientKey(tenancy.project.id); } - const provider = tenancy.config.oauth_providers.find((p) => p.id === params.provider_id); - if (!provider) { + const providerRaw = Object.entries(tenancy.config.auth.oauth.providers).find(([providerId, _]) => providerId === params.provider_id); + if (!providerRaw) { throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); } + const provider = { id: providerRaw[0], ...providerRaw[1] }; + // If the authorization token is present, we are adding new scopes to the user instead of sign-in/sign-up let projectUserId: string | undefined; if (query.type === "link") { @@ -85,7 +87,7 @@ export const GET = createSmartRouteHandler({ throw new StatusError(StatusError.Forbidden, "The access token is not valid for this branch"); } - if (query.provider_scope && provider.type === "shared") { + if (query.provider_scope && provider.isShared) { throw new KnownErrors.OAuthExtraScopeNotAvailableWithSharedOAuthKeys(); } projectUserId = userId; diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index d5b12f7800..62bafd0c4d 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -124,10 +124,7 @@ const handler = createSmartRouteHandler({ throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); } - const provider = { - id: providerRaw[0], - ...providerRaw[1], - }; + const provider = { id: providerRaw[0], ...providerRaw[1] }; const providerObj = await getProvider(provider as any); let callbackResult: Awaited>; diff --git a/apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx b/apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx index 7a7c83b2cf..a92d4c5465 100644 --- a/apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx +++ b/apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx @@ -32,7 +32,7 @@ export const POST = createSmartRouteHandler({ }).defined(), }), async handler({ auth: { tenancy }, body: { email, callback_url: callbackUrl }, clientVersion }, fullReq) { - if (!tenancy.config.magic_link_enabled) { + if (!tenancy.config.auth.otp.allowSignIn) { throw new StatusError(StatusError.Forbidden, "Magic link is not enabled for this project"); } diff --git a/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx b/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx index 8e42f59c3e..440b834747 100644 --- a/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx @@ -50,7 +50,7 @@ export async function ensureUserForEmailAllowsOtp(tenancy: Tenancy, email: strin throw new KnownErrors.UserWithEmailAlreadyExists(contactChannel.value, true); } } else { - if (!tenancy.config.sign_up_enabled) { + if (!tenancy.config.auth.allowSignUp) { throw new KnownErrors.SignUpNotEnabled(); } return null; diff --git a/apps/backend/src/app/api/latest/auth/passkey/initiate-passkey-authentication/route.tsx b/apps/backend/src/app/api/latest/auth/passkey/initiate-passkey-authentication/route.tsx index b64c42c866..67d3c647fb 100644 --- a/apps/backend/src/app/api/latest/auth/passkey/initiate-passkey-authentication/route.tsx +++ b/apps/backend/src/app/api/latest/auth/passkey/initiate-passkey-authentication/route.tsx @@ -30,7 +30,7 @@ export const POST = createSmartRouteHandler({ }), async handler({ auth: { tenancy } }) { - if (!tenancy.config.passkey_enabled) { + if (!tenancy.config.auth.passkey.allowSignIn) { throw new KnownErrors.PasskeyAuthenticationNotEnabled(); } diff --git a/apps/backend/src/app/api/latest/auth/passkey/initiate-passkey-registration/route.tsx b/apps/backend/src/app/api/latest/auth/passkey/initiate-passkey-registration/route.tsx index c6357c8ea4..27dd3cdb4a 100644 --- a/apps/backend/src/app/api/latest/auth/passkey/initiate-passkey-registration/route.tsx +++ b/apps/backend/src/app/api/latest/auth/passkey/initiate-passkey-registration/route.tsx @@ -32,7 +32,7 @@ export const POST = createSmartRouteHandler({ }), }), async handler({ auth: { tenancy, user } }) { - if (!tenancy.config.passkey_enabled) { + if (!tenancy.config.auth.passkey.allowSignIn) { throw new KnownErrors.PasskeyAuthenticationNotEnabled(); } diff --git a/apps/backend/src/app/api/latest/auth/passkey/register/verification-code-handler.tsx b/apps/backend/src/app/api/latest/auth/passkey/register/verification-code-handler.tsx index cb93cb25b9..a2de637c49 100644 --- a/apps/backend/src/app/api/latest/auth/passkey/register/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/auth/passkey/register/verification-code-handler.tsx @@ -39,7 +39,7 @@ export const registerVerificationCodeHandler = createVerificationCodeHandler({ throw new StackAssertionError("send() called on a Passkey registration verification code handler"); }, async handler(tenancy, _, { challenge }, { credential }, user) { - if (!tenancy.config.passkey_enabled) { + if (!tenancy.config.auth.passkey.allowSignIn) { throw new KnownErrors.PasskeyAuthenticationNotEnabled(); } @@ -55,7 +55,7 @@ export const registerVerificationCodeHandler = createVerificationCodeHandler({ let expectedOrigin = ""; const clientDataJSON = decodeClientDataJSON(credential.response.clientDataJSON); const { origin } = clientDataJSON; - const localhostAllowed = tenancy.config.allow_localhost; + const localhostAllowed = tenancy.config.domains.allowLocalhost; const parsedOrigin = new URL(origin); const isLocalhost = parsedOrigin.hostname === "localhost"; @@ -69,7 +69,10 @@ export const registerVerificationCodeHandler = createVerificationCodeHandler({ } if (!isLocalhost) { - if (!tenancy.config.domains.map(e => e.domain).includes(parsedOrigin.origin)) { + if (!Object.values(tenancy.config.domains.trustedDomains) + .filter(e => e.baseUrl) + .map(e => e.baseUrl) + .includes(parsedOrigin.origin)) { throw new KnownErrors.PasskeyAuthenticationFailed("Passkey registration failed because the origin is not allowed"); } else { expectedRPID = parsedOrigin.hostname; diff --git a/apps/backend/src/app/api/latest/auth/passkey/sign-in/verification-code-handler.tsx b/apps/backend/src/app/api/latest/auth/passkey/sign-in/verification-code-handler.tsx index 5574e6a2dc..690c0753a5 100644 --- a/apps/backend/src/app/api/latest/auth/passkey/sign-in/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/auth/passkey/sign-in/verification-code-handler.tsx @@ -38,7 +38,7 @@ export const passkeySignInVerificationCodeHandler = createVerificationCodeHandle }, async handler(tenancy, _, { challenge }, { authentication_response }) { - if (!tenancy.config.passkey_enabled) { + if (!tenancy.config.auth.passkey.allowSignIn) { throw new KnownErrors.PasskeyAuthenticationNotEnabled(); } @@ -67,7 +67,7 @@ export const passkeySignInVerificationCodeHandler = createVerificationCodeHandle let expectedOrigin = ""; const clientDataJSON = decodeClientDataJSON(authentication_response.response.clientDataJSON); const { origin } = clientDataJSON; - const localhostAllowed = tenancy.config.allow_localhost; + const localhostAllowed = tenancy.config.domains.allowLocalhost; const parsedOrigin = new URL(origin); const isLocalhost = parsedOrigin.hostname === "localhost"; @@ -81,7 +81,10 @@ export const passkeySignInVerificationCodeHandler = createVerificationCodeHandle } if (!isLocalhost) { - if (!tenancy.config.domains.map(e => e.domain).includes(parsedOrigin.origin)) { + if (!Object.values(tenancy.config.domains.trustedDomains) + .filter(e => e.baseUrl) + .map(e => e.baseUrl) + .includes(parsedOrigin.origin)) { throw new KnownErrors.PasskeyAuthenticationFailed("Passkey authentication failed because the origin is not allowed"); } else { expectedRPID = parsedOrigin.hostname; diff --git a/apps/backend/src/app/api/latest/auth/password/reset/verification-code-handler.tsx b/apps/backend/src/app/api/latest/auth/password/reset/verification-code-handler.tsx index c8d37df8a8..532b42ade9 100644 --- a/apps/backend/src/app/api/latest/auth/password/reset/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/auth/password/reset/verification-code-handler.tsx @@ -48,7 +48,7 @@ export const resetPasswordVerificationCodeHandler = createVerificationCodeHandle }); }, async handler(tenancy, { email }, data, { password }) { - if (!tenancy.config.credential_enabled) { + if (!tenancy.config.auth.password.allowSignIn) { throw new KnownErrors.PasswordAuthenticationNotEnabled(); } diff --git a/apps/backend/src/app/api/latest/auth/password/send-reset-code/route.tsx b/apps/backend/src/app/api/latest/auth/password/send-reset-code/route.tsx index 86ec5cccff..117c868946 100644 --- a/apps/backend/src/app/api/latest/auth/password/send-reset-code/route.tsx +++ b/apps/backend/src/app/api/latest/auth/password/send-reset-code/route.tsx @@ -31,7 +31,7 @@ export const POST = createSmartRouteHandler({ }).defined(), }), async handler({ auth: { tenancy }, body: { email, callback_url: callbackUrl } }, fullReq) { - if (!tenancy.config.credential_enabled) { + if (!tenancy.config.auth.password.allowSignIn) { throw new KnownErrors.PasswordAuthenticationNotEnabled(); } diff --git a/apps/backend/src/app/api/latest/auth/password/set/route.tsx b/apps/backend/src/app/api/latest/auth/password/set/route.tsx index b44bed2aa7..067a94b5ab 100644 --- a/apps/backend/src/app/api/latest/auth/password/set/route.tsx +++ b/apps/backend/src/app/api/latest/auth/password/set/route.tsx @@ -28,7 +28,7 @@ export const POST = createSmartRouteHandler({ bodyType: yupString().oneOf(["success"]).defined(), }), async handler({ auth: { tenancy, user }, body: { password } }) { - if (!tenancy.config.credential_enabled) { + if (!tenancy.config.auth.password.allowSignIn) { throw new KnownErrors.PasswordAuthenticationNotEnabled(); } diff --git a/apps/backend/src/app/api/latest/auth/password/sign-in/route.tsx b/apps/backend/src/app/api/latest/auth/password/sign-in/route.tsx index a7f3e16fb9..3b94b7bd0b 100644 --- a/apps/backend/src/app/api/latest/auth/password/sign-in/route.tsx +++ b/apps/backend/src/app/api/latest/auth/password/sign-in/route.tsx @@ -34,7 +34,7 @@ export const POST = createSmartRouteHandler({ }).defined(), }), async handler({ auth: { tenancy }, body: { email, password } }, fullReq) { - if (!tenancy.config.credential_enabled) { + if (!tenancy.config.auth.password.allowSignIn) { throw new KnownErrors.PasswordAuthenticationNotEnabled(); } diff --git a/apps/backend/src/app/api/latest/auth/password/update/route.tsx b/apps/backend/src/app/api/latest/auth/password/update/route.tsx index 54ba287186..db6d43f244 100644 --- a/apps/backend/src/app/api/latest/auth/password/update/route.tsx +++ b/apps/backend/src/app/api/latest/auth/password/update/route.tsx @@ -31,7 +31,7 @@ export const POST = createSmartRouteHandler({ bodyType: yupString().oneOf(["success"]).defined(), }), async handler({ auth: { tenancy, user }, body: { old_password, new_password }, headers: { "x-stack-refresh-token": refreshToken } }, fullReq) { - if (!tenancy.config.credential_enabled) { + if (!tenancy.config.auth.password.allowSignIn) { throw new KnownErrors.PasswordAuthenticationNotEnabled(); } diff --git a/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx b/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx index 905f30047d..824ccdda60 100644 --- a/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx +++ b/apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx @@ -22,12 +22,14 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre throw new StatusError(StatusError.Forbidden, "Client can only access its own connected accounts"); } - const provider = auth.tenancy.config.oauth_providers.find((p) => p.id === params.provider_id); - if (!provider) { + const providerRaw = Object.entries(auth.tenancy.config.auth.oauth.providers).find(([providerId, _]) => providerId === params.provider_id); + if (!providerRaw) { throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); } - if (provider.type === 'shared' && getEnvVariable('STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS') !== 'true') { + const provider = { id: providerRaw[0], ...providerRaw[1] }; + + if (provider.isShared && getEnvVariable('STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS') !== 'true') { throw new KnownErrors.OAuthAccessTokenNotAvailableWithSharedOAuthKeys(); } diff --git a/apps/backend/src/app/api/latest/email-templates/crud.tsx b/apps/backend/src/app/api/latest/email-templates/crud.tsx index c1f9262d42..12aa3ce0e9 100644 --- a/apps/backend/src/app/api/latest/email-templates/crud.tsx +++ b/apps/backend/src/app/api/latest/email-templates/crud.tsx @@ -48,7 +48,7 @@ export const emailTemplateCrudHandlers = createLazyProxy(() => createCrudHandler } }, async onUpdate({ auth, data, params }) { - if (auth.tenancy.config.email_config.type === 'shared') { + if (auth.tenancy.config.emails.server.isShared) { throw new StatusError(StatusError.BadRequest, 'Cannot update email templates in shared email config. Set up a custom email config to update email templates.'); } diff --git a/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx b/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx index eef086f7fa..06737d5da5 100644 --- a/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx +++ b/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx @@ -1,5 +1,5 @@ import { createOrUpdateProject } from "@/lib/projects"; -import { getTenancy } from "@/lib/tenancies"; +import { Tenancy, getTenancy } from "@/lib/tenancies"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { createCrud } from "@stackframe/stack-shared/dist/crud"; import * as schemaFields from "@stackframe/stack-shared/dist/schema-fields"; @@ -69,23 +69,42 @@ const oauthProvidersCrud = createCrud({ }, }); +function oauthProviderConfigToLegacyConfig(provider: Tenancy['config']['auth']['oauth']['providers'][string]) { + return { + id: provider.type!, + type: provider.isShared ? 'shared' : 'standard', + client_id: provider.clientId, + client_secret: provider.clientSecret, + facebook_config_id: provider.facebookConfigId, + microsoft_tenant_id: provider.microsoftTenantId, + } as const; +} + +function findLegacyProvider(tenancy: Tenancy, providerType: string) { + const providerRaw = Object.entries(tenancy.config.auth.oauth.providers).find(([_, provider]) => provider.type === providerType); + if (!providerRaw) { + return null; + } + return oauthProviderConfigToLegacyConfig(providerRaw[1]); +} + export const oauthProvidersCrudHandlers = createLazyProxy(() => createCrudHandlers(oauthProvidersCrud, { paramsSchema: yupObject({ oauth_provider_id: schemaFields.oauthIdSchema.defined(), }), onCreate: async ({ auth, data }) => { - if (auth.tenancy.config.oauth_providers.find(provider => provider.id === data.id)) { + if (findLegacyProvider(auth.tenancy, data.id)) { throw new StatusError(StatusError.BadRequest, 'OAuth provider already exists'); } - const updated = await createOrUpdateProject({ + await createOrUpdateProject({ type: 'update', projectId: auth.project.id, branchId: auth.branchId, data: { config: { oauth_providers: [ - ...auth.tenancy.config.oauth_providers, + ...Object.values(auth.tenancy.config.auth.oauth.providers).map(oauthProviderConfigToLegacyConfig), { id: data.id, type: data.type ?? 'shared', @@ -98,51 +117,51 @@ export const oauthProvidersCrudHandlers = createLazyProxy(() => createCrudHandle }); const updatedTenancy = await getTenancy(auth.tenancy.id) ?? throwErr('Tenancy not found after update?'); // since we updated the config, we need to re-fetch the tenancy - return updatedTenancy.config.oauth_providers.find(provider => provider.id === data.id) ?? throwErr('Provider not found'); + return findLegacyProvider(updatedTenancy, data.id) ?? throwErr('Provider not found'); }, onUpdate: async ({ auth, data, params }) => { - if (!auth.tenancy.config.oauth_providers.find(provider => provider.id === params.oauth_provider_id)) { + if (!findLegacyProvider(auth.tenancy, params.oauth_provider_id)) { throw new StatusError(StatusError.NotFound, 'OAuth provider not found'); } - const updated = await createOrUpdateProject({ + await createOrUpdateProject({ type: 'update', projectId: auth.project.id, branchId: auth.branchId, data: { config: { - oauth_providers: auth.tenancy.config.oauth_providers - .map(provider => provider.id === params.oauth_provider_id ? { - ...provider, + oauth_providers: Object.values(auth.tenancy.config.auth.oauth.providers) + .map(provider => provider.type === params.oauth_provider_id ? { + ...oauthProviderConfigToLegacyConfig(provider), ...data, - } : provider), + } : oauthProviderConfigToLegacyConfig(provider)), } } }); const updatedTenancy = await getTenancy(auth.tenancy.id) ?? throwErr('Tenancy not found after update?'); // since we updated the config, we need to re-fetch the tenancy - return updatedTenancy.config.oauth_providers.find(provider => provider.id === params.oauth_provider_id) ?? throwErr('Provider not found'); + return findLegacyProvider(updatedTenancy, params.oauth_provider_id) ?? throwErr('Provider not found'); }, onList: async ({ auth }) => { return { - items: auth.tenancy.config.oauth_providers, + items: Object.values(auth.tenancy.config.auth.oauth.providers).map(oauthProviderConfigToLegacyConfig), is_paginated: false, }; }, onDelete: async ({ auth, params }) => { - if (!auth.tenancy.config.oauth_providers.find(provider => provider.id === params.oauth_provider_id)) { + if (!findLegacyProvider(auth.tenancy, params.oauth_provider_id)) { throw new StatusError(StatusError.NotFound, 'OAuth provider not found'); } - const updated = await createOrUpdateProject({ + await createOrUpdateProject({ type: 'update', projectId: auth.project.id, branchId: auth.branchId, data: { config: { - oauth_providers: auth.tenancy.config.oauth_providers.filter(provider => - provider.id !== params.oauth_provider_id - ) + oauth_providers: Object.values(auth.tenancy.config.auth.oauth.providers) + .filter(provider =>provider.type !== params.oauth_provider_id) + .map(oauthProviderConfigToLegacyConfig), } } }); From 588019d4c6002f96bfdd8b2f85ab5f057691a84c Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 14:08:13 -0700 Subject: [PATCH 05/88] fix --- apps/backend/src/app/api/latest/users/crud.tsx | 2 +- apps/backend/src/lib/emails.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index 0f97a37807..de02d02501 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -1042,7 +1042,7 @@ export const currentUserCrudHandlers = createLazyProxy(() => createCrudHandlers( }); }, async onDelete({ auth }) { - if (auth.type === 'client' && !auth.tenancy.config.client_user_deletion_enabled) { + if (auth.type === 'client' && !auth.tenancy.config.users.allowClientUserDeletion) { throw new StatusError(StatusError.BadRequest, "Client user deletion is not enabled for this project"); } diff --git a/apps/backend/src/lib/emails.tsx b/apps/backend/src/lib/emails.tsx index 0e1d0d51a7..ec8126e5d0 100644 --- a/apps/backend/src/lib/emails.tsx +++ b/apps/backend/src/lib/emails.tsx @@ -353,12 +353,12 @@ export async function sendEmailFromTemplate(options: { } export async function getEmailConfig(tenancy: Tenancy): Promise { - const projectEmailConfig = tenancy.config.email_config; + const projectEmailConfig = tenancy.config.emails.server; - if (projectEmailConfig.type === 'shared') { + if (projectEmailConfig.isShared) { return await getSharedEmailConfig(tenancy.project.display_name); } else { - if (!projectEmailConfig.host || !projectEmailConfig.port || !projectEmailConfig.username || !projectEmailConfig.password || !projectEmailConfig.sender_email || !projectEmailConfig.sender_name) { + if (!projectEmailConfig.host || !projectEmailConfig.port || !projectEmailConfig.username || !projectEmailConfig.password || !projectEmailConfig.senderEmail || !projectEmailConfig.senderName) { throw new StackAssertionError("Email config is not complete despite not being shared. This should never happen?", { projectId: tenancy.id, emailConfig: projectEmailConfig }); } return { @@ -366,8 +366,8 @@ export async function getEmailConfig(tenancy: Tenancy): Promise { port: projectEmailConfig.port, username: projectEmailConfig.username, password: projectEmailConfig.password, - senderEmail: projectEmailConfig.sender_email, - senderName: projectEmailConfig.sender_name, + senderEmail: projectEmailConfig.senderEmail, + senderName: projectEmailConfig.senderName, secure: isSecureEmailPort(projectEmailConfig.port), type: 'standard', }; From 86466a958e6c48ae6c5a457128880eece02af7da Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 14:10:35 -0700 Subject: [PATCH 06/88] better error handling --- apps/backend/prisma/seed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 39d6cb118b..fc19c6dd73 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -73,7 +73,7 @@ async function seed() { ...(dashboardDomain && new URL(dashboardDomain).hostname !== 'localhost' ? [{ domain: dashboardDomain, handler_path: '/handler' }] : []), ...Object.values(internalTenancy.config.domains.trustedDomains) .filter((d) => d.baseUrl !== dashboardDomain && d.baseUrl) - .map((d) => ({ domain: d.baseUrl!, handler_path: d.handlerPath })), + .map((d) => ({ domain: d.baseUrl || throwErr('Domain base URL is required'), handler_path: d.handlerPath })), ] }, }, From 3d38936ab15633e5c11a0432e35b14e6eb9f44b6 Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 14:16:26 -0700 Subject: [PATCH 07/88] fix --- .../latest/integrations/custom/domains/crud.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/app/api/latest/integrations/custom/domains/crud.tsx b/apps/backend/src/app/api/latest/integrations/custom/domains/crud.tsx index 552cb4d500..2d763985ff 100644 --- a/apps/backend/src/app/api/latest/integrations/custom/domains/crud.tsx +++ b/apps/backend/src/app/api/latest/integrations/custom/domains/crud.tsx @@ -1,8 +1,9 @@ +import { Tenancy } from "@/lib/tenancies"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { CrudTypeOf, createCrud } from "@stackframe/stack-shared/dist/crud"; import * as schemaFields from "@stackframe/stack-shared/dist/schema-fields"; import { yupMixed, yupObject } from "@stackframe/stack-shared/dist/schema-fields"; -import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; import { projectsCrudHandlers } from "../../../internal/projects/current/crud"; @@ -44,17 +45,21 @@ export const domainCrud = createCrud({ }); export type DomainCrud = CrudTypeOf; +function domainConfigToLegacyConfig(domain: Tenancy['config']['domains']['trustedDomains'][string]) { + return { domain: domain.baseUrl || throwErr('Domain base URL is required'), handler_path: domain.handlerPath }; +} + export const domainCrudHandlers = createLazyProxy(() => createCrudHandlers(domainCrud, { paramsSchema: yupObject({ domain: domainSchema.optional(), }), onCreate: async ({ auth, data, params }) => { - const oldDomains = auth.tenancy.config.domains; + const oldDomains = auth.tenancy.config.domains.trustedDomains; await projectsCrudHandlers.adminUpdate({ data: { config: { - domains: [...oldDomains, { domain: data.domain, handler_path: "/handler" }], + domains: [...Object.values(oldDomains).map(domainConfigToLegacyConfig), { domain: data.domain, handler_path: "/handler" }], }, }, tenancy: auth.tenancy, @@ -64,10 +69,10 @@ export const domainCrudHandlers = createLazyProxy(() => createCrudHandlers(domai return { domain: data.domain }; }, onDelete: async ({ auth, params }) => { - const oldDomains = auth.tenancy.config.domains; + const oldDomains = auth.tenancy.config.domains.trustedDomains; await projectsCrudHandlers.adminUpdate({ data: { - config: { domains: oldDomains.filter((domain) => domain.domain !== params.domain) }, + config: { domains: Object.values(oldDomains).filter((domain) => domain.baseUrl !== params.domain).map(domainConfigToLegacyConfig) }, }, tenancy: auth.tenancy, allowedErrorTypes: [StatusError], @@ -75,7 +80,7 @@ export const domainCrudHandlers = createLazyProxy(() => createCrudHandlers(domai }, onList: async ({ auth }) => { return { - items: auth.tenancy.config.domains.map((domain) => ({ domain: domain.domain })), + items: Object.values(auth.tenancy.config.domains.trustedDomains).map(domainConfigToLegacyConfig), is_paginated: false, }; }, From 506ff7d718dae1b8b999d01834d4d14955fac73f Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 14:21:14 -0700 Subject: [PATCH 08/88] fix --- .../latest/integrations/neon/domains/crud.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/app/api/latest/integrations/neon/domains/crud.tsx b/apps/backend/src/app/api/latest/integrations/neon/domains/crud.tsx index 4a1235ef37..61442585f5 100644 --- a/apps/backend/src/app/api/latest/integrations/neon/domains/crud.tsx +++ b/apps/backend/src/app/api/latest/integrations/neon/domains/crud.tsx @@ -1,8 +1,9 @@ +import { Tenancy } from "@/lib/tenancies"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { CrudTypeOf, createCrud } from "@stackframe/stack-shared/dist/crud"; import * as schemaFields from "@stackframe/stack-shared/dist/schema-fields"; import { yupMixed, yupObject } from "@stackframe/stack-shared/dist/schema-fields"; -import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; import { projectsCrudHandlers } from "../../../internal/projects/current/crud"; @@ -44,17 +45,20 @@ export const domainCrud = createCrud({ }); export type DomainCrud = CrudTypeOf; +function domainConfigToLegacyConfig(domain: Tenancy['config']['domains']['trustedDomains'][string]) { + return { domain: domain.baseUrl || throwErr('Domain base URL is required'), handler_path: domain.handlerPath }; +} export const domainCrudHandlers = createLazyProxy(() => createCrudHandlers(domainCrud, { paramsSchema: yupObject({ domain: domainSchema.optional(), }), onCreate: async ({ auth, data, params }) => { - const oldDomains = auth.tenancy.config.domains; + const oldDomains = auth.tenancy.config.domains.trustedDomains; await projectsCrudHandlers.adminUpdate({ data: { config: { - domains: [...oldDomains, { domain: data.domain, handler_path: "/handler" }], + domains: [...Object.values(oldDomains).map(domainConfigToLegacyConfig), { domain: data.domain, handler_path: "/handler" }], }, }, tenancy: auth.tenancy, @@ -64,10 +68,10 @@ export const domainCrudHandlers = createLazyProxy(() => createCrudHandlers(domai return { domain: data.domain }; }, onDelete: async ({ auth, params }) => { - const oldDomains = auth.tenancy.config.domains; + const oldDomains = auth.tenancy.config.domains.trustedDomains; await projectsCrudHandlers.adminUpdate({ data: { - config: { domains: oldDomains.filter((domain) => domain.domain !== params.domain) }, + config: { domains: Object.values(oldDomains).filter((domain) => domain.baseUrl !== params.domain).map(domainConfigToLegacyConfig) }, }, tenancy: auth.tenancy, allowedErrorTypes: [StatusError], @@ -75,7 +79,7 @@ export const domainCrudHandlers = createLazyProxy(() => createCrudHandlers(domai }, onList: async ({ auth }) => { return { - items: auth.tenancy.config.domains.map((domain) => ({ domain: domain.domain })), + items: Object.values(auth.tenancy.config.domains.trustedDomains).map(domainConfigToLegacyConfig), is_paginated: false, }; }, From 5d7bcc09d6e22aba690dbde6ca01ebc2dd87289e Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 14:26:53 -0700 Subject: [PATCH 09/88] fix --- apps/backend/src/app/api/latest/internal/projects/crud.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/app/api/latest/internal/projects/crud.tsx b/apps/backend/src/app/api/latest/internal/projects/crud.tsx index ba0acfa526..5c8c5fca5a 100644 --- a/apps/backend/src/app/api/latest/internal/projects/crud.tsx +++ b/apps/backend/src/app/api/latest/internal/projects/crud.tsx @@ -1,3 +1,4 @@ +import { renderedOrganizationConfigToProjectCrud } from "@/lib/config"; import { createOrUpdateProject, getProjectQuery, listManagedProjectIds } from "@/lib/projects"; import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; import { globalPrismaClient, rawQueryAll } from "@/prisma-client"; @@ -37,7 +38,7 @@ export const adminUserProjectsCrudHandlers = createLazyProxy(() => createCrudHan const tenancy = await getSoleTenancyFromProjectBranch(project.id, DEFAULT_BRANCH_ID); return { ...project, - config: tenancy.config, + config: renderedOrganizationConfigToProjectCrud(tenancy.config), }; }, onList: async ({ auth }) => { @@ -52,7 +53,7 @@ export const adminUserProjectsCrudHandlers = createLazyProxy(() => createCrudHan const projectsWithConfig = await Promise.all(projects.map(async (project) => { return { ...project, - config: (await getSoleTenancyFromProjectBranch(project.id, DEFAULT_BRANCH_ID)).config, + config: renderedOrganizationConfigToProjectCrud((await getSoleTenancyFromProjectBranch(project.id, DEFAULT_BRANCH_ID)).config), }; })); From 3626c5c609d045a8a7bdf13d110a4618179c3045 Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 14:31:03 -0700 Subject: [PATCH 10/88] fix --- apps/backend/src/app/api/latest/teams/crud.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/app/api/latest/teams/crud.tsx b/apps/backend/src/app/api/latest/teams/crud.tsx index d1d26a3b31..7a662c732c 100644 --- a/apps/backend/src/app/api/latest/teams/crud.tsx +++ b/apps/backend/src/app/api/latest/teams/crud.tsx @@ -46,7 +46,7 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC throw new KnownErrors.UserAuthenticationRequired; } - if (!auth.tenancy.config.client_team_creation_enabled) { + if (!auth.tenancy.config.teams.allowClientTeamCreation) { throw new StatusError(StatusError.Forbidden, 'Client team creation is disabled for this project'); } From 76f593a14a3119672c05304eaf537d4b51d399fb Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 14:52:47 -0700 Subject: [PATCH 11/88] fix --- apps/backend/src/app/api/latest/users/crud.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index de02d02501..b81ef4e2c3 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -600,7 +600,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC }); // TODO why is this outside the transaction? is there a reason? - if (auth.tenancy.config.create_team_on_sign_up) { + if (auth.tenancy.config.teams.createPersonalTeamOnSignUp) { const team = await teamsCrudHandlers.adminCreate({ data: { display_name: data.display_name ? From 0b00d8c864a4b9addd494454f1b1409728f0ebb6 Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 15:08:41 -0700 Subject: [PATCH 12/88] remove environment-config --- .../environment-config/current/crud.tsx | 39 ------------------- .../src/interface/crud/environment-config.ts | 33 ---------------- 2 files changed, 72 deletions(-) delete mode 100644 apps/backend/src/app/api/latest/internal/environment-config/current/crud.tsx delete mode 100644 packages/stack-shared/src/interface/crud/environment-config.ts diff --git a/apps/backend/src/app/api/latest/internal/environment-config/current/crud.tsx b/apps/backend/src/app/api/latest/internal/environment-config/current/crud.tsx deleted file mode 100644 index aa8fda02e6..0000000000 --- a/apps/backend/src/app/api/latest/internal/environment-config/current/crud.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { overrideEnvironmentConfigOverride } from "@/lib/config"; -import { getPrismaClientForTenancy } from "@/prisma-client"; -import { createCrudHandlers } from "@/route-handlers/crud-handler"; -import { environmentConfigCrud } from "@stackframe/stack-shared/dist/interface/crud/environment-config"; -import { yupObject } from "@stackframe/stack-shared/dist/schema-fields"; -import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; - -export const environmentConfigCrudHandlers = createLazyProxy(() => createCrudHandlers(environmentConfigCrud, { - paramsSchema: yupObject({}), - onRead: async ({ auth }) => { - return { - id: auth.tenancy.id, - project_id: auth.project.id, - branch_id: auth.tenancy.branchId, - organization_id: auth.tenancy.organization?.id, - config: auth.tenancy.config, - }; - }, - onUpdate: async ({ auth, data }) => { - const prisma = await getPrismaClientForTenancy(auth.tenancy); - - if (data.config) { - await overrideEnvironmentConfigOverride({ - tx: prisma, - projectId: auth.project.id, - branchId: auth.tenancy.branchId, - environmentConfigOverrideOverride: data.config, - }); - } - - return { - id: auth.tenancy.id, - project_id: auth.project.id, - branch_id: auth.tenancy.branchId, - organization_id: auth.tenancy.organization?.id, - config: auth.tenancy.config, - }; - }, -})); diff --git a/packages/stack-shared/src/interface/crud/environment-config.ts b/packages/stack-shared/src/interface/crud/environment-config.ts deleted file mode 100644 index f44fc53d0e..0000000000 --- a/packages/stack-shared/src/interface/crud/environment-config.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { CrudTypeOf, createCrud } from "../../crud"; -import * as schemaFields from "../../schema-fields"; -import { yupObject } from "../../schema-fields"; - -export const environmentConfigCrudAdminReadSchema = yupObject({ - project_id: schemaFields.yupString().defined(), - branch_id: schemaFields.yupString().defined(), - organization_id: schemaFields.yupString().optional(), - id: schemaFields.yupString().defined(), - config: schemaFields.yupMixed().defined(), -}).defined(); - -export const environmentConfigCrudAdminUpdateSchema = yupObject({ - config: schemaFields.yupMixed().optional(), -}).defined(); - -export const environmentConfigCrud = createCrud({ - adminReadSchema: environmentConfigCrudAdminReadSchema, - adminUpdateSchema: environmentConfigCrudAdminUpdateSchema, - docs: { - adminRead: { - summary: 'Get the current environment config', - description: 'Get the current environment config', - tags: ['Environment Config'], - }, - adminUpdate: { - summary: 'Update the current environment config', - description: 'Update the current environment config', - tags: ['Environment Config'], - }, - }, -}); -export type EnvironmentConfigCrud = CrudTypeOf; From 62b77755742747fb06b33e2908d97d5b20c3d6a8 Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Fri, 25 Jul 2025 00:29:28 +0200 Subject: [PATCH 13/88] Update apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../app/api/latest/integrations/neon/oauth-providers/crud.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx b/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx index 06737d5da5..3cee378618 100644 --- a/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx +++ b/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx @@ -160,7 +160,7 @@ export const oauthProvidersCrudHandlers = createLazyProxy(() => createCrudHandle data: { config: { oauth_providers: Object.values(auth.tenancy.config.auth.oauth.providers) - .filter(provider =>provider.type !== params.oauth_provider_id) + .filter(provider => provider.type !== params.oauth_provider_id) .map(oauthProviderConfigToLegacyConfig), } } From 0bcafcd3b8ed9610220366fdfa68f7aa348cb0e7 Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Fri, 25 Jul 2025 00:30:19 +0200 Subject: [PATCH 14/88] Update apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../src/app/api/latest/auth/otp/send-sign-in-code/route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx b/apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx index a92d4c5465..4846cae51b 100644 --- a/apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx +++ b/apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx @@ -33,7 +33,7 @@ export const POST = createSmartRouteHandler({ }), async handler({ auth: { tenancy }, body: { email, callback_url: callbackUrl }, clientVersion }, fullReq) { if (!tenancy.config.auth.otp.allowSignIn) { - throw new StatusError(StatusError.Forbidden, "Magic link is not enabled for this project"); + throw new StatusError(StatusError.Forbidden, "OTP sign-in is not enabled for this project"); } const user = await ensureUserForEmailAllowsOtp(tenancy, email); From 15f73de10b8109d4ea851b23338374015429ff2b Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Fri, 25 Jul 2025 00:30:48 +0200 Subject: [PATCH 15/88] Update apps/backend/prisma/seed.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- apps/backend/prisma/seed.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index fc19c6dd73..179099dff5 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -73,6 +73,7 @@ async function seed() { ...(dashboardDomain && new URL(dashboardDomain).hostname !== 'localhost' ? [{ domain: dashboardDomain, handler_path: '/handler' }] : []), ...Object.values(internalTenancy.config.domains.trustedDomains) .filter((d) => d.baseUrl !== dashboardDomain && d.baseUrl) + .map((d) => ({ domain: d.baseUrl, handler_path: d.handlerPath })), .map((d) => ({ domain: d.baseUrl || throwErr('Domain base URL is required'), handler_path: d.handlerPath })), ] }, From 073ab15f1036220a4e6f14808220aa6662a6961f Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 15:35:27 -0700 Subject: [PATCH 16/88] fix --- apps/backend/prisma/seed.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 179099dff5..fc19c6dd73 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -73,7 +73,6 @@ async function seed() { ...(dashboardDomain && new URL(dashboardDomain).hostname !== 'localhost' ? [{ domain: dashboardDomain, handler_path: '/handler' }] : []), ...Object.values(internalTenancy.config.domains.trustedDomains) .filter((d) => d.baseUrl !== dashboardDomain && d.baseUrl) - .map((d) => ({ domain: d.baseUrl, handler_path: d.handlerPath })), .map((d) => ({ domain: d.baseUrl || throwErr('Domain base URL is required'), handler_path: d.handlerPath })), ] }, From bc069b083c4206ce0dda9ed4758cc2ba92175b03 Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Thu, 24 Jul 2025 16:03:13 -0700 Subject: [PATCH 17/88] fix tests --- .../backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts index 6bbfb1f9c5..930bfc4deb 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/send-sign-in-code.test.ts @@ -54,7 +54,7 @@ it("should refuse to sign up a new user if magic links are disabled on the proje expect(response).toMatchInlineSnapshot(` NiceResponse { "status": 403, - "body": "Magic link is not enabled for this project", + "body": "OTP sign-in is not enabled for this project", "headers": Headers {