From a6fbcae21c1b8707107bff20671128d76c3d4e74 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Mon, 14 Apr 2025 13:23:09 -0700 Subject: [PATCH] Update config.json schema (#620) --- apps/backend/prisma/seed.ts | 4 +- apps/backend/scripts/verify-data-integrity.ts | 26 +- apps/backend/src/lib/config.tsx | 357 ++++++++------- apps/backend/src/lib/projects.tsx | 4 +- packages/stack-shared/src/config/README.md | 4 +- packages/stack-shared/src/config/format.ts | 57 ++- packages/stack-shared/src/config/schema.ts | 419 ++++++++++-------- packages/stack-shared/src/schema-fields.ts | 19 +- packages/stack-shared/src/utils/objects.tsx | 156 ++++++- packages/stack-shared/src/utils/types.tsx | 2 +- 10 files changed, 638 insertions(+), 410 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index aa1fdd19c..9bea5a532 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -1,7 +1,7 @@ /* eslint-disable no-restricted-syntax */ import { getSoleTenancyFromProject } from '@/lib/tenancies'; import { PrismaClient } from '@prisma/client'; -import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; +import { errorToNiceString, throwErr } from '@stackframe/stack-shared/dist/utils/errors'; import { hashPassword } from "@stackframe/stack-shared/dist/utils/hashes"; import { generateUuid } from '@stackframe/stack-shared/dist/utils/uuids'; @@ -513,7 +513,7 @@ async function seed() { } seed().catch(async (e) => { - console.error(e); + console.error(errorToNiceString(e)); await prisma.$disconnect(); process.exit(1); // eslint-disable-next-line @typescript-eslint/no-misused-promises diff --git a/apps/backend/scripts/verify-data-integrity.ts b/apps/backend/scripts/verify-data-integrity.ts index 8aead21fa..b3cfdc06f 100644 --- a/apps/backend/scripts/verify-data-integrity.ts +++ b/apps/backend/scripts/verify-data-integrity.ts @@ -56,6 +56,8 @@ async function main() { console.log(); const startAt = Math.max(0, +(process.argv[2] || "1") - 1); + const flags = process.argv.slice(3); + const skipUsers = flags.includes("--skip-users"); const projects = await prismaClient.project.findMany({ select: { @@ -98,18 +100,20 @@ async function main() { usersUserCount: users.items.length, }); - for (let j = 0; j < users.items.length; j++) { - const user = users.items[j]; - await recurse(`[user ${j + 1}/${users.items.length}] ${user.display_name ?? user.primary_email}`, async (recurse) => { - await expectStatusCode(200, `/api/v1/users/${user.id}`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, + if (!skipUsers) { + for (let j = 0; j < users.items.length; j++) { + const user = users.items[j]; + await recurse(`[user ${j + 1}/${users.items.length}] ${user.display_name ?? user.primary_email}`, async (recurse) => { + await expectStatusCode(200, `/api/v1/users/${user.id}`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }); }); - }); + } } }); } diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index e384091d2..aea1cf728 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -1,16 +1,16 @@ import { Prisma } from "@prisma/client"; import { NormalizationError, getInvalidConfigReason, normalize, override } from "@stackframe/stack-shared/dist/config/format"; -import { BranchConfigOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectIncompleteConfig, ProjectRenderedConfig, baseConfig, branchConfigSchema, environmentConfigSchema, organizationConfigSchema, projectConfigSchema } from "@stackframe/stack-shared/dist/config/schema"; +import { BranchConfigOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyDefaults, branchConfigDefaults, branchConfigSchema, environmentConfigDefaults, environmentConfigSchema, organizationConfigDefaults, organizationConfigSchema, projectConfigDefaults, projectConfigSchema } from "@stackframe/stack-shared/dist/config/schema"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; -import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { isTruthy } from "@stackframe/stack-shared/dist/utils/booleans"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { filterUndefined, pick, typedEntries, typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { stringCompare, typedToLowercase } from "@stackframe/stack-shared/dist/utils/strings"; -import { base64url } from "jose"; +import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import * as yup from "yup"; import { RawQuery, prismaClient } from "../prisma-client"; -import { permissionDefinitionJsonFromDbType, permissionDefinitionJsonFromSystemDbType } from "./permissions"; import { DBProject, fullProjectInclude } from "./projects"; // These are placeholder types that should be replaced after the config json db migration @@ -48,7 +48,7 @@ export function getRenderedProjectConfigQuery(options: { projectId: string }): R if (!dbProject) { return null; } - return await getIncompleteProjectConfig({ project: dbProject }); + return applyDefaults(projectConfigDefaults, await getIncompleteProjectConfig({ project: dbProject })); }, }; } @@ -61,7 +61,7 @@ export function getRenderedBranchConfigQuery(options: { projectId: string, branc if (!dbProject) { return null; } - return await getIncompleteBranchConfig({ project: dbProject, branch: { id: options.branchId } }); + return applyDefaults(branchConfigDefaults, await getIncompleteBranchConfig({ project: dbProject, branch: { id: options.branchId } })); }, }; } @@ -74,7 +74,7 @@ export function getRenderedEnvironmentConfigQuery(options: { projectId: string, if (!dbProject) { return null; } - return await getIncompleteEnvironmentConfig({ project: dbProject, branch: { id: options.branchId }, environment: {} }); + return applyDefaults(environmentConfigDefaults, await getIncompleteEnvironmentConfig({ project: dbProject, branch: { id: options.branchId }, environment: {} })); }, }; } @@ -87,7 +87,7 @@ export function getRenderedOrganizationConfigQuery(options: { projectId: string, if (!dbProject) { return null; } - return await getIncompleteOrganizationConfig({ project: dbProject, branch: { id: options.branchId }, environment: {}, organization: { id: options.organizationId } }); + return applyDefaults(organizationConfigDefaults, await getIncompleteOrganizationConfig({ project: dbProject, branch: { id: options.branchId }, environment: {}, organization: { id: options.organizationId } })); }, }; } @@ -98,31 +98,37 @@ export function getRenderedOrganizationConfigQuery(options: { projectId: string, // --------------------------------------------------------------------------------------------------------------------- /** - * Validates a project config override, based on the base config. + * Validates a project config override ([sanity-check valid](./README.md)). */ export async function validateProjectConfigOverride(options: { projectConfigOverride: ProjectConfigOverride }): Promise> { - return await validateAndReturn(projectConfigSchema, baseConfig, options.projectConfigOverride); + return await schematicallyValidateAndReturn(projectConfigSchema, {}, options.projectConfigOverride); } /** - * Validates a branch config override, based on the given project's rendered project config. + * Validates a branch config override ([sanity-check valid](./README.md)), based on the given project's rendered project config. */ export async function validateBranchConfigOverride(options: { branchConfigOverride: BranchConfigOverride } & ProjectOptions): Promise> { - return await validateAndReturn(branchConfigSchema, await getIncompleteProjectConfig(options), options.branchConfigOverride); + return await schematicallyValidateAndReturn(branchConfigSchema, await getIncompleteProjectConfig(options), options.branchConfigOverride); + // TODO add some more checks that depend on the base config; eg. an override config shouldn't set email server connection if isShared==true + // (these are schematically valid, but make no sense, so we should be nice and reject them) } /** - * Validates an environment config override, based on the given branch's rendered branch config. + * Validates an environment config override ([sanity-check valid](./README.md)), based on the given branch's rendered branch config. */ export async function validateEnvironmentConfigOverride(options: { environmentConfigOverride: EnvironmentConfigOverride } & BranchOptions): Promise> { - return await validateAndReturn(environmentConfigSchema, await getIncompleteBranchConfig(options), options.environmentConfigOverride); + return await schematicallyValidateAndReturn(environmentConfigSchema, await getIncompleteBranchConfig(options), options.environmentConfigOverride); + // TODO add some more checks that depend on the base config; eg. an override config shouldn't set email server connection if isShared==true + // (these are schematically valid, but make no sense, so we should be nice and reject them) } /** - * Validates an organization config override, based on the given environment's rendered environment config. + * Validates an organization config override ([sanity-check valid](./README.md)), based on the given environment's rendered environment config. */ export async function validateOrganizationConfigOverride(options: { organizationConfigOverride: OrganizationConfigOverride } & EnvironmentOptions): Promise> { - return await validateAndReturn(organizationConfigSchema, await getIncompleteEnvironmentConfig(options), options.organizationConfigOverride); + return await schematicallyValidateAndReturn(organizationConfigSchema, await getIncompleteEnvironmentConfig(options), options.organizationConfigOverride); + // TODO add some more checks that depend on the base config; eg. an override config shouldn't set email server connection if isShared==true + // (these are schematically valid, but make no sense, so we should be nice and reject them) } @@ -157,88 +163,71 @@ export async function getEnvironmentConfigOverride(options: EnvironmentOptions): const oldConfig = options.project.config; // =================== TEAM =================== - - if (oldConfig.clientTeamCreationEnabled !== baseConfig.teams.clientTeamCreationEnabled) { - configOverride['teams.clientTeamCreationEnabled'] = oldConfig.clientTeamCreationEnabled; - } - - if (oldConfig.createTeamOnSignUp !== baseConfig.teams.createTeamOnSignUp) { - configOverride['teams.createTeamOnSignUp'] = oldConfig.createTeamOnSignUp; - } + configOverride['teams.allowClientTeamCreation'] = oldConfig.clientTeamCreationEnabled; + configOverride['teams.createPersonalTeamOnSignUp'] = oldConfig.createTeamOnSignUp; // =================== USER =================== - - if (oldConfig.clientUserDeletionEnabled !== baseConfig.users.clientUserDeletionEnabled) { - configOverride['users.clientUserDeletionEnabled'] = oldConfig.clientUserDeletionEnabled; - } - - if (oldConfig.signUpEnabled !== baseConfig.users.signUpEnabled) { - configOverride['users.signUpEnabled'] = oldConfig.signUpEnabled; - } + configOverride['users.allowClientUserDeletion'] = oldConfig.clientUserDeletionEnabled; // =================== DOMAIN =================== - - if (oldConfig.allowLocalhost !== baseConfig.domains.allowLocalhost) { - configOverride['domains.allowLocalhost'] = oldConfig.allowLocalhost; - } - + configOverride['domains.allowLocalhost'] = oldConfig.allowLocalhost; for (const domain of oldConfig.domains) { - configOverride['domains.trustedDomains.' + base64url.encode(domain.domain)] = { + configOverride['domains.trustedDomains.' + generateUuid()] = { baseUrl: domain.domain, handlerPath: domain.handlerPath, } satisfies OrganizationRenderedConfig['domains']['trustedDomains'][string]; } // =================== AUTH =================== + configOverride['auth.allowSignUp'] = oldConfig.signUpEnabled; + configOverride['auth.oauth.accountMergeStrategy'] = typedToLowercase(oldConfig.oauthAccountMergeStrategy) satisfies OrganizationRenderedConfig['auth']['oauth']['accountMergeStrategy']; - if (oldConfig.oauthAccountMergeStrategy !== baseConfig.auth.oauthAccountMergeStrategy) { - configOverride['auth.oauthAccountMergeStrategy'] = typedToLowercase(oldConfig.oauthAccountMergeStrategy) satisfies OrganizationRenderedConfig['auth']['oauthAccountMergeStrategy']; - } - + const authEnabledOAuthProviders = new Set(); for (const authMethodConfig of oldConfig.authMethodConfigs) { - const baseAuthMethod = { - enabled: authMethodConfig.enabled, - }; - - let authMethodOverride: OrganizationRenderedConfig['auth']['authMethods'][string]; if (authMethodConfig.oauthProviderConfig) { const oauthConfig = authMethodConfig.oauthProviderConfig.proxiedOAuthConfig || authMethodConfig.oauthProviderConfig.standardOAuthConfig; if (!oauthConfig) { throw new StackAssertionError('Either ProxiedOAuthConfig or StandardOAuthConfig must be set on authMethodConfigs.oauthProviderConfig', { authMethodConfig }); } - authMethodOverride = { - ...baseAuthMethod, - type: 'oauth', - oauthProviderId: oauthConfig.id, - } as const; + if (authMethodConfig.enabled) { + authEnabledOAuthProviders.add(oauthConfig.id); + } } else if (authMethodConfig.passwordConfig) { - authMethodOverride = { - ...baseAuthMethod, - type: 'password', - } as const; + if (authMethodConfig.enabled) { + configOverride['auth.password.allowSignIn'] = true; + } } else if (authMethodConfig.otpConfig) { - authMethodOverride = { - ...baseAuthMethod, - type: 'otp', - } as const; + if (authMethodConfig.enabled) { + configOverride['auth.otp.allowSignIn'] = true; + } } else if (authMethodConfig.passkeyConfig) { - authMethodOverride = { - ...baseAuthMethod, - type: 'passkey', - } as const; + configOverride['auth.passkey.allowSignIn'] = true; } else { throw new StackAssertionError('Unknown auth method config', { authMethodConfig }); } + } - configOverride['auth.authMethods.' + authMethodConfig.id] = authMethodOverride; + const connectedAccountsEnabledOAuthProviders = new Set(); + for (const provider of oldConfig.oauthProviderConfigs) { + const authMethodConfig = oldConfig.authMethodConfigs.find(config => config.oauthProviderConfig?.id === provider.id); + + if (!authMethodConfig) { + throw new StackAssertionError('No auth method config found for oauth provider', { provider }); + } + + if (authMethodConfig.enabled) { + connectedAccountsEnabledOAuthProviders.add(provider.id); + } } for (const provider of oldConfig.oauthProviderConfigs) { - let providerOverride: OrganizationRenderedConfig['auth']['oauthProviders'][string]; + let providerOverride: OrganizationRenderedConfig['auth']['oauth']['providers'][string]; if (provider.proxiedOAuthConfig) { providerOverride = { type: typedToLowercase(provider.proxiedOAuthConfig.type), isShared: true, + allowSignIn: authEnabledOAuthProviders.has(provider.id), + allowConnectedAccounts: connectedAccountsEnabledOAuthProviders.has(provider.id), } as const; } else if (provider.standardOAuthConfig) { providerOverride = filterUndefined({ @@ -248,31 +237,20 @@ export async function getEnvironmentConfigOverride(options: EnvironmentOptions): clientSecret: provider.standardOAuthConfig.clientSecret, facebookConfigId: provider.standardOAuthConfig.facebookConfigId ?? undefined, microsoftTenantId: provider.standardOAuthConfig.microsoftTenantId ?? undefined, + allowSignIn: authEnabledOAuthProviders.has(provider.id), + allowConnectedAccounts: connectedAccountsEnabledOAuthProviders.has(provider.id), } as const); } else { throw new StackAssertionError('Unknown oauth provider config', { provider }); } - configOverride['auth.oauthProviders.' + provider.id] = providerOverride; - } - - for (const provider of oldConfig.oauthProviderConfigs) { - const authMethodConfig = oldConfig.authMethodConfigs.find(config => config.oauthProviderConfig?.id === provider.id); - - if (!authMethodConfig) { - throw new StackAssertionError('No auth method config found for oauth provider', { provider }); - } - - configOverride['auth.connectedAccounts.' + provider.id] = { - enabled: authMethodConfig.enabled, - oauthProviderId: provider.id, - } satisfies OrganizationRenderedConfig['auth']['connectedAccounts'][string]; + configOverride['auth.oauth.providers.' + provider.id] = providerOverride; } // =================== EMAIL =================== if (oldConfig.emailServiceConfig?.standardEmailServiceConfig) { - configOverride['emails.emailServer'] = { + configOverride['emails.server'] = { isShared: false, host: oldConfig.emailServiceConfig.standardEmailServiceConfig.host, port: oldConfig.emailServiceConfig.standardEmailServiceConfig.port, @@ -280,71 +258,65 @@ export async function getEnvironmentConfigOverride(options: EnvironmentOptions): password: oldConfig.emailServiceConfig.standardEmailServiceConfig.password, senderName: oldConfig.emailServiceConfig.standardEmailServiceConfig.senderName, senderEmail: oldConfig.emailServiceConfig.standardEmailServiceConfig.senderEmail, - } satisfies OrganizationRenderedConfig['emails']['emailServer']; + } satisfies OrganizationRenderedConfig['emails']['server']; } - // =================== PERMISSIONS =================== + // =================== RBAC =================== - // Team permission definitions - for (const perm of oldConfig.permissions.filter(perm => perm.scope === 'TEAM') - .map(permissionDefinitionJsonFromDbType) - .sort((a, b) => stringCompare(a.id, b.id))) { - configOverride[`teams.teamPermissionDefinitions.${perm.id}`] = filterUndefined({ - description: perm.description, - containedPermissions: typedFromEntries(perm.contained_permission_ids.map(containedPerm => [containedPerm, {}])) - }); - } + // Permission definitions + const permissions = oldConfig.permissions + .sort((a, b) => stringCompare(a.queryableId, b.queryableId)) + .map(p => [ + p.queryableId, + filterUndefined({ + scope: typedToLowercase(p.scope), + description: p.description ?? undefined, + containedPermissionIds: typedFromEntries( + p.parentEdges + .map(edge => { + if (edge.parentPermission) { + return edge.parentPermission.queryableId; + } else if (edge.parentTeamSystemPermission) { + return '$' + typedToLowercase(edge.parentTeamSystemPermission); + } else { + throw new StackAssertionError('Permission edge should have either parentPermission or parentSystemPermission', { edge }); + } + }) + .sort((a, b) => stringCompare(a, b)) + .map(id => [id, true] as const) + ), + }) satisfies OrganizationRenderedConfig['rbac']['permissions'][string], + ] as const); + configOverride['rbac.permissions'] = typedFromEntries(permissions); - // Default creator team permissions - const defaultCreatorTeamPermissions = oldConfig.permissions.filter(perm => perm.isDefaultTeamCreatorPermission) - .map(permissionDefinitionJsonFromDbType) - .concat(oldConfig.teamCreateDefaultSystemPermissions.map(db => permissionDefinitionJsonFromSystemDbType(db, oldConfig))) - .sort((a, b) => stringCompare(a.id, b.id)); - - for (const perm of defaultCreatorTeamPermissions) { - configOverride[`teams.defaultCreatorTeamPermissions.${perm.id}`] = {}; - } - - // Default member team permissions - const defaultMemberTeamPermissions = oldConfig.permissions.filter(perm => perm.isDefaultTeamMemberPermission) - .map(permissionDefinitionJsonFromDbType) - .concat(oldConfig.teamMemberDefaultSystemPermissions.map(db => permissionDefinitionJsonFromSystemDbType(db, oldConfig))) - .sort((a, b) => stringCompare(a.id, b.id)); - - for (const perm of defaultMemberTeamPermissions) { - configOverride[`teams.defaultMemberTeamPermissions.${perm.id}`] = {}; - } - - // Project permission definitions - const projectPermissionDefinitions = oldConfig.permissions.filter(perm => perm.scope === 'PROJECT') - .map(permissionDefinitionJsonFromDbType) - .sort((a, b) => stringCompare(a.id, b.id)); - - for (const perm of projectPermissionDefinitions) { - configOverride[`users.userPermissionDefinitions.${perm.id}`] = filterUndefined({ - description: perm.description, - containedPermissions: typedFromEntries(perm.contained_permission_ids.map(containedPerm => [containedPerm, {}])) - }); - } - - // Default project permissions - const defaultProjectPermissions = oldConfig.permissions.filter(perm => perm.isDefaultProjectPermission) - .map(permissionDefinitionJsonFromDbType) - // TODO: add project default system permissions after creating the first project system permission - .sort((a, b) => stringCompare(a.id, b.id)); - - for (const perm of defaultProjectPermissions) { - configOverride[`users.defaultProjectPermissions.${perm.id}`] = {}; - } + // Default permissions + configOverride['rbac.defaultPermissions'] = { + teamCreator: typedFromEntries([ + ...oldConfig.permissions.filter(perm => perm.isDefaultTeamCreatorPermission).map(perm => perm.queryableId), + ...oldConfig.teamCreateDefaultSystemPermissions, + ].map((id) => [id, true])), + teamMember: typedFromEntries([ + ...oldConfig.permissions.filter(perm => perm.isDefaultTeamMemberPermission).map(perm => perm.queryableId), + ...oldConfig.teamMemberDefaultSystemPermissions, + ].map((id) => [id, true])), + signUp: typedFromEntries([ + ...oldConfig.permissions.filter(perm => perm.isDefaultProjectPermission).map(perm => perm.queryableId), + ].map((id) => [id, true])), + }; // =================== API KEYS =================== + configOverride['apiKeys.enabled.user'] = oldConfig.allowUserApiKeys; + configOverride['apiKeys.enabled.team'] = oldConfig.allowTeamApiKeys; - if (oldConfig.allowUserApiKeys !== baseConfig.users.allowUserApiKeys) { - configOverride['users.allowUserApiKeys'] = oldConfig.allowUserApiKeys; - } - if (oldConfig.allowTeamApiKeys !== baseConfig.teams.allowTeamApiKeys) { - configOverride['teams.allowTeamApiKeys'] = oldConfig.allowTeamApiKeys; + // validate, just to make sure we didn't miss anything + const validationResult = await validateEnvironmentConfigOverride({ + project: options.project, + branch: options.branch, + environmentConfigOverride: configOverride, + }); + if (validationResult.status === 'error') { + throw new StackAssertionError('getEnvironmentConfigOverride returned an invalid config override: ' + validationResult.error, { validationResult }); } return configOverride; @@ -407,22 +379,41 @@ export function setOrganizationConfigOverride(options: { // --------------------------------------------------------------------------------------------------------------------- async function getIncompleteProjectConfig(options: ProjectOptions): Promise { - return normalize(override(baseConfig, await getProjectConfigOverride(options)), { onDotIntoNull: "ignore" }) as any; + return normalize(override({}, await getProjectConfigOverride(options))) as any; } async function getIncompleteBranchConfig(options: BranchOptions): Promise { - return normalize(override(await getIncompleteProjectConfig(options), await getBranchConfigOverride(options)), { onDotIntoNull: "ignore" }) as any; + return normalize(override(await getIncompleteProjectConfig(options), await getBranchConfigOverride(options))) as any; } async function getIncompleteEnvironmentConfig(options: EnvironmentOptions): Promise { - return normalize(override(await getIncompleteBranchConfig(options), await getEnvironmentConfigOverride(options)), { onDotIntoNull: "ignore" }) as any; + return normalize(override(await getIncompleteBranchConfig(options), await getEnvironmentConfigOverride(options))) as any; } async function getIncompleteOrganizationConfig(options: OrganizationOptions): Promise { - return normalize(override(await getIncompleteEnvironmentConfig(options), await getOrganizationConfigOverride(options)), { onDotIntoNull: "ignore" }) as any; + return normalize(override(await getIncompleteEnvironmentConfig(options), await getOrganizationConfigOverride(options))) as any; } -async function validateAndReturn(schema: yup.ObjectSchema, base: any, configOverride: any): Promise> { +/** + * For the difference between schematically valid and sanity-check valid, see `README.md`. + */ +async function schematicallyValidateAndReturn(schema: yup.ObjectSchema, base: any, configOverride: any): Promise> { + // First, we check whether the override is valid on its own, in the hypothetical case where all parent configs are empty. + const basicRes = await schematicallyValidateAndReturnImpl(schema, {}, configOverride); + if (basicRes.status === "error") return basicRes; + + // As a sanity check, we also validate that the override is valid if we merge it with the base config. Because of + // how we design schemas, this should always be the case (as changing a base config should not make the yup schema + // invalid). + const mergedRes = await schematicallyValidateAndReturnImpl(schema, base, configOverride); + if (mergedRes.status === "error") { + throw new StackAssertionError('Invalid override is not compatible with the base config: ' + mergedRes.error, { mergedRes }); + } + + return Result.ok(null); +} + +async function schematicallyValidateAndReturnImpl(schema: yup.ObjectSchema, base: any, configOverride: any): Promise> { const reason = getInvalidConfigReason(configOverride, { configName: 'override' }); if (reason) return Result.error(reason); const value = override(pick(base, Object.keys(schema.fields)), configOverride); @@ -451,19 +442,23 @@ async function validateAndReturn(schema: yup.ObjectSchema, base: any, confi } } -import.meta.vitest?.test('validateAndReturn(...)', async ({ expect }) => { +import.meta.vitest?.test('schematicallyValidateAndReturn(...)', async ({ expect }) => { const schema1 = yupObject({ a: yupString().optional(), }); - expect(await validateAndReturn(schema1, {}, {})).toEqual(Result.ok(null)); - expect(await validateAndReturn(schema1, { a: 'b' }, {})).toEqual(Result.ok(null)); - expect(await validateAndReturn(schema1, {}, { a: 'b' })).toEqual(Result.ok(null)); - expect(await validateAndReturn(schema1, { a: 'b' }, { a: 'c' })).toEqual(Result.ok(null)); - expect(await validateAndReturn(schema1, {}, { a: null })).toEqual(Result.ok(null)); - expect(await validateAndReturn(schema1, { a: 'b' }, { a: null })).toEqual(Result.ok(null)); + expect(await schematicallyValidateAndReturn(schema1, {}, {})).toEqual(Result.ok(null)); + expect(await schematicallyValidateAndReturn(schema1, { a: 'b' }, {})).toEqual(Result.ok(null)); + expect(await schematicallyValidateAndReturn(schema1, {}, { a: 'b' })).toEqual(Result.ok(null)); + expect(await schematicallyValidateAndReturn(schema1, { a: 'b' }, { a: 'c' })).toEqual(Result.ok(null)); + expect(await schematicallyValidateAndReturn(schema1, {}, { a: null })).toEqual(Result.ok(null)); + expect(await schematicallyValidateAndReturn(schema1, { a: 'b' }, { a: null })).toEqual(Result.ok(null)); + expect(await schematicallyValidateAndReturn(yupObject({ a: yupMixed() }), {}, { "a.b": "c" })).toEqual(Result.ok(null)); - expect(await validateAndReturn(yupObject({}), { a: 'b' }, { "a.b": "c" })).toEqual(Result.error(`Tried to use dot notation to access "a.b", but "a" doesn't exist on the object (or is null). Maybe this config is not normalizable?`)); + expect(await schematicallyValidateAndReturn(yupObject({}), { a: 'b' }, { "a.b": "c" })).toEqual(Result.error(`Object contains unknown properties: a`)); + expect(await schematicallyValidateAndReturn(schema1, {}, { a: 123 })).toEqual(Result.error('a must be a `string` type, but the final value was: `123`.')); + + await expect(schematicallyValidateAndReturn(yupObject({ a: yupMixed() }), { a: 'b' }, { "a.b": "c" })).rejects.toThrow(`Invalid override is not compatible with the base config: Tried to use dot notation to access "a.b", but "a" is not an object. Maybe this config is not normalizable?`); }); // --------------------------------------------------------------------------------------------------------------------- @@ -472,17 +467,14 @@ import.meta.vitest?.test('validateAndReturn(...)', async ({ expect }) => { // C -> A export const renderedOrganizationConfigToProjectCrud = (renderedConfig: OrganizationRenderedConfig, configId: string): ProjectsCrud["Admin"]["Read"]['config'] => { - const oauthProviders = typedEntries(renderedConfig.auth.authMethods) - .filter(([_, authMethod]) => authMethod.type === 'oauth') - .map(([_, authMethod]) => { - if (authMethod.type !== 'oauth') { - throw new StackAssertionError('Expected oauth provider', { authMethod }); + const oauthProviders = typedEntries(renderedConfig.auth.oauth.providers) + .map(([oauthProviderId, oauthProvider]) => { + if (!oauthProvider.type) { + return undefined; } - const oauthProvider = renderedConfig.auth.oauthProviders[authMethod.oauthProviderId]; - return filterUndefined({ id: oauthProvider.type, - enabled: authMethod.enabled, + enabled: oauthProvider.allowSignIn, type: oauthProvider.isShared ? 'shared' : 'standard', client_id: oauthProvider.clientId, client_secret: oauthProvider.clientSecret, @@ -490,53 +482,58 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Organiza microsoft_tenant_id: oauthProvider.microsoftTenantId, } as const) satisfies ProjectsCrud["Admin"]["Read"]['config']['oauth_providers'][number]; }) + .filter(isTruthy) .sort((a, b) => stringCompare(a.id, b.id)); return { id: configId, allow_localhost: renderedConfig.domains.allowLocalhost, - client_team_creation_enabled: renderedConfig.teams.clientTeamCreationEnabled, - client_user_deletion_enabled: renderedConfig.users.clientUserDeletionEnabled, - sign_up_enabled: renderedConfig.users.signUpEnabled, - oauth_account_merge_strategy: renderedConfig.auth.oauthAccountMergeStrategy, - create_team_on_sign_up: renderedConfig.teams.createTeamOnSignUp, - credential_enabled: typedEntries(renderedConfig.auth.authMethods).filter(([_, authMethod]) => authMethod.enabled && authMethod.type === 'password').length > 0, - magic_link_enabled: typedEntries(renderedConfig.auth.authMethods).filter(([_, authMethod]) => authMethod.enabled && authMethod.type === 'otp').length > 0, - passkey_enabled: typedEntries(renderedConfig.auth.authMethods).filter(([_, authMethod]) => authMethod.enabled && authMethod.type === 'passkey').length > 0, + client_team_creation_enabled: renderedConfig.teams.allowClientTeamCreation, + client_user_deletion_enabled: renderedConfig.users.allowClientUserDeletion, + sign_up_enabled: renderedConfig.auth.allowSignUp, + oauth_account_merge_strategy: renderedConfig.auth.oauth.accountMergeStrategy, + create_team_on_sign_up: renderedConfig.teams.createPersonalTeamOnSignUp, + credential_enabled: renderedConfig.auth.password.allowSignIn, + magic_link_enabled: renderedConfig.auth.otp.allowSignIn, + passkey_enabled: renderedConfig.auth.passkey.allowSignIn, oauth_providers: oauthProviders, enabled_oauth_providers: oauthProviders.filter(provider => provider.enabled), domains: typedEntries(renderedConfig.domains.trustedDomains) - .map(([_, domainConfig]) => ({ + .map(([_, domainConfig]) => domainConfig.baseUrl === undefined ? undefined : ({ domain: domainConfig.baseUrl, handler_path: domainConfig.handlerPath, })) + .filter(isTruthy) .sort((a, b) => stringCompare(a.domain, b.domain)), - email_config: renderedConfig.emails.emailServer.isShared ? { + email_config: renderedConfig.emails.server.isShared ? { type: 'shared', } : { type: 'standard', - host: renderedConfig.emails.emailServer.host, - port: renderedConfig.emails.emailServer.port, - username: renderedConfig.emails.emailServer.username, - password: renderedConfig.emails.emailServer.password, - sender_name: renderedConfig.emails.emailServer.senderName, - sender_email: renderedConfig.emails.emailServer.senderEmail, + host: renderedConfig.emails.server.host, + port: renderedConfig.emails.server.port, + username: renderedConfig.emails.server.username, + password: renderedConfig.emails.server.password, + sender_name: renderedConfig.emails.server.senderName, + sender_email: renderedConfig.emails.server.senderEmail, }, - team_creator_default_permissions: typedEntries(renderedConfig.teams.defaultCreatorTeamPermissions) + team_creator_default_permissions: typedEntries(renderedConfig.rbac.defaultPermissions.teamCreator) + .filter(([_, perm]) => perm) .map(([id, perm]) => ({ id })) .sort((a, b) => stringCompare(a.id, b.id)), - team_member_default_permissions: typedEntries(renderedConfig.teams.defaultMemberTeamPermissions) + team_member_default_permissions: typedEntries(renderedConfig.rbac.defaultPermissions.teamMember) + .filter(([_, perm]) => perm) .map(([id, perm]) => ({ id })) .sort((a, b) => stringCompare(a.id, b.id)), - user_default_permissions: typedEntries(renderedConfig.users.defaultProjectPermissions) + user_default_permissions: typedEntries(renderedConfig.rbac.defaultPermissions.signUp) + .filter(([_, perm]) => perm) .map(([id, perm]) => ({ id })) .sort((a, b) => stringCompare(a.id, b.id)), - allow_user_api_keys: renderedConfig.users.allowUserApiKeys, - allow_team_api_keys: renderedConfig.teams.allowTeamApiKeys, + allow_user_api_keys: renderedConfig.apiKeys.enabled.user, + allow_team_api_keys: renderedConfig.apiKeys.enabled.team, }; }; diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 1b2e923ed..bdb109860 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -379,7 +379,7 @@ export function getProjectQuery(projectId: string): RawQuery branch -> environment -> organization) - `$Level` incomplete config: The base config after some overrides have been applied -- `$Level` rendered config: An incomplete config with those fields removed that can be overridden by a future override +- `$Level` rendered config: An incomplete config with those fields removed that can be overridden by a future override, deeply merged into `configDefaults` (with properties in the former taking precedence) - Complete config: The organization rendered config. +**Validation**: A config override can be both "schematically valid" and "sanity-check valid" (I would call it "semantically valid" but that word is so easily confused with "schematically"). The `validateXYZ` functions in `config.ts` check for the latter, while the yup schemas in `schema.ts` check for the former. The main difference is that whether an override is schematically valid depends only on the override itself; while its sanity-check validity depends on the base config that it overrides. +
Examples diff --git a/packages/stack-shared/src/config/format.ts b/packages/stack-shared/src/config/format.ts index b949f2e92..9c3fc9918 100644 --- a/packages/stack-shared/src/config/format.ts +++ b/packages/stack-shared/src/config/format.ts @@ -1,17 +1,17 @@ // see https://github.com/stack-auth/info/blob/main/eng-handbook/random-thoughts/config-json-format.md -import { StackAssertionError } from "../utils/errors"; -import { deleteKey, get, has, set } from "../utils/objects"; +import { StackAssertionError, throwErr } from "../utils/errors"; +import { deleteKey, filterUndefined, get, hasAndNotUndefined, set } from "../utils/objects"; export type ConfigValue = string | number | boolean | null | ConfigValue[] | Config; export type Config = { - [keyOrDotNotation: string]: ConfigValue, + [keyOrDotNotation: string]: ConfigValue | undefined, // must support undefined for optional values }; -export type NormalizedConfigValue = string | number | boolean | NormalizedConfigValue[] | NormalizedConfig; +export type NormalizedConfigValue = string | number | boolean | NormalizedConfig | NormalizedConfigValue[]; export type NormalizedConfig = { - [key: string]: NormalizedConfigValue, + [key: string]: NormalizedConfigValue | undefined, // must support undefined for optional values }; export type _NormalizesTo = N extends object ? ( @@ -32,6 +32,7 @@ export function getInvalidConfigReason(c: unknown, options: { configName?: strin const configName = options.configName ?? 'config'; if (c === null || typeof c !== 'object') return `${configName} must be a non-null object`; for (const [key, value] of Object.entries(c)) { + if (value === undefined) continue; if (typeof key !== 'string') return `${configName} must have only string keys (found: ${typeof key})`; if (!key.match(/^[a-zA-Z0-9_:$][a-zA-Z_:$0-9\-]*(?:\.[a-zA-Z0-9_:$][a-zA-Z_:$0-9\-]*)*$/)) return `All keys of ${configName} must consist of only alphanumeric characters, dots, underscores, colons, dollar signs, or hyphens and start with a character other than a hyphen (found: ${key})`; @@ -85,7 +86,7 @@ export function override(c1: Config, ...configs: Config[]) { assertValidConfig(c2); let result = c1; - for (const key of Object.keys(c2)) { + for (const key of Object.keys(filterUndefined(c2))) { result = Object.fromEntries( Object.entries(result).filter(([k]) => k !== key && !k.startsWith(key + '.')) ); @@ -93,7 +94,7 @@ export function override(c1: Config, ...configs: Config[]) { return { ...result, - ...c2, + ...filterUndefined(c2), }; } @@ -107,6 +108,8 @@ import.meta.vitest?.test("override(...)", ({ expect }) => { "c.e.f": 4, "c.g": 5, h: [6, { i: 7 }, 8], + k: 123, + l: undefined, }, { a: 9, @@ -116,6 +119,7 @@ import.meta.vitest?.test("override(...)", ({ expect }) => { "h.1": { j: 12, }, + k: undefined, }, ) ).toEqual({ @@ -129,6 +133,8 @@ import.meta.vitest?.test("override(...)", ({ expect }) => { "h.1": { j: 12, }, + k: 123, + l: undefined, }); }); @@ -136,13 +142,11 @@ type NormalizeOptions = { /** * What to do if a dot notation is used on null. * - * - "throw" (default): Throw an error. This is the safest option, and you should return this to the user if they - * attempt to save a config which you know is invalid given the current set of overloads. - * - "ignore": Ignore the dot notation field. This is useful for applying the config, as we don't want to error out - * if a base config has changed to delete a value that was overridden in another config. Note that you should - * still show a warning to the user, and notify them to update their config. + * - "empty" (default): Replace the null with an empty object. + * - "throw": Throw an error. + * - "ignore": Ignore the dot notation field. */ - onDotIntoNull?: "throw" | "ignore", + onDotIntoNull?: "empty" | "throw" | "ignore", } export class NormalizationError extends Error { @@ -154,7 +158,7 @@ NormalizationError.prototype.name = "NormalizationError"; export function normalize(c: Config, options: NormalizeOptions = {}): NormalizedConfig { assertValidConfig(c); - const onDotIntoNull = options.onDotIntoNull ?? "throw"; + const onDotIntoNull = options.onDotIntoNull ?? "empty"; const countDots = (s: string) => s.match(/\./g)?.length ?? 0; const result: NormalizedConfig = {}; @@ -162,15 +166,18 @@ export function normalize(c: Config, options: NormalizeOptions = {}): Normalized outer: for (const key of keysByDepth) { const keySegmentsWithoutLast = key.split('.'); - const last = keySegmentsWithoutLast.pop(); - if (!last) { - throw new NormalizationError(`Tried to normalize ${JSON.stringify(key)}, but it doesn't contain any dots. Maybe this config is not normalizable?`); - } + const last = keySegmentsWithoutLast.pop() ?? throwErr('split returns empty array?'); + const value = get(c, key); + if (value === undefined) continue; let current: NormalizedConfig = result; for (const keySegment of keySegmentsWithoutLast) { - if (!has(current, keySegment)) { + if (!hasAndNotUndefined(current, keySegment)) { switch (onDotIntoNull) { + case "empty": { + set(current, keySegment, {}); + break; + } case "throw": { throw new NormalizationError(`Tried to use dot notation to access ${JSON.stringify(key)}, but ${JSON.stringify(keySegment)} doesn't exist on the object (or is null). Maybe this config is not normalizable?`); } @@ -185,7 +192,7 @@ export function normalize(c: Config, options: NormalizeOptions = {}): Normalized } current = value as NormalizedConfig; } - setNormalizedValue(current, last, get(c, key)); + setNormalizedValue(current, last, value); } return result; } @@ -199,7 +206,7 @@ function normalizeValue(value: ConfigValue): NormalizedConfigValue { function setNormalizedValue(result: NormalizedConfig, key: string, value: ConfigValue) { if (value === null) { - if (has(result, key)) { + if (hasAndNotUndefined(result, key)) { deleteKey(result, key); } } else { @@ -222,6 +229,7 @@ import.meta.vitest?.test("normalize(...)", ({ expect }) => { }, k: { l: {} }, "k.l.m": 13, + n: undefined, })).toEqual({ a: 9, b: 2, @@ -234,13 +242,16 @@ import.meta.vitest?.test("normalize(...)", ({ expect }) => { }); // dotting into null + expect(normalize({ + "b.c": 2, + })).toEqual({ b: { c: 2 } }); expect(() => normalize({ "b.c": 2, - })).toThrow(`Tried to use dot notation to access "b.c", but "b" doesn't exist on the object (or is null). Maybe this config is not normalizable?`); + }, { onDotIntoNull: "throw" })).toThrow(`Tried to use dot notation to access "b.c", but "b" doesn't exist on the object (or is null). Maybe this config is not normalizable?`); expect(() => normalize({ b: null, "b.c": 2, - })).toThrow(`Tried to use dot notation to access "b.c", but "b" doesn't exist on the object (or is null). Maybe this config is not normalizable?`); + }, { onDotIntoNull: "throw" })).toThrow(`Tried to use dot notation to access "b.c", but "b" doesn't exist on the object (or is null). Maybe this config is not normalizable?`); expect(normalize({ "b.c": 2, }, { onDotIntoNull: "ignore" })).toEqual({}); diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 9f08fd33f..9a4701956 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -1,224 +1,283 @@ import * as yup from "yup"; import * as schemaFields from "../schema-fields"; -import { yupBoolean, yupObject, yupRecord, yupString, yupUnion } from "../schema-fields"; +import { yupBoolean, yupObject, yupRecord, yupString } from "../schema-fields"; import { allProviders } from "../utils/oauth"; +import { DeepMerge, get, has, isObjectLike, set } from "../utils/objects"; +import { PrettifyType } from "../utils/types"; import { NormalizesTo } from "./format"; +// NOTE: The validation schemas in here are all schematic validators, not sanity-check validators. +// For more info, see ./README.md + + export const configLevels = ['project', 'branch', 'environment', 'organization'] as const; export type ConfigLevel = typeof configLevels[number]; const permissionRegex = /^\$?[a-z0-9_:]+$/; -export const baseConfig = { - teams: { - createTeamOnSignUp: false, - clientTeamCreationEnabled: false, - defaultCreatorTeamPermissions: {}, - defaultMemberTeamPermissions: {}, - teamPermissionDefinitions: {}, - allowTeamApiKeys: false, - }, - users: { - clientUserDeletionEnabled: false, - signUpEnabled: true, - defaultProjectPermissions: {}, - userPermissionDefinitions: {}, - allowUserApiKeys: false, - }, - domains: { - allowLocalhost: true, - trustedDomains: {}, - }, - auth: { - oauthAccountMergeStrategy: 'link_method', - oauthProviders: {}, - authMethods: {}, - connectedAccounts: {}, - }, - emails: { - emailServer: { - isShared: true, - }, - }, -}; - /** * All fields that can be overridden at this level. */ -export const projectConfigSchema = yupObject({ - // This is just an example of a field that can only be configured at the project level. Will be actually implemented in the future. - sourceOfTruthDbConnectionString: yupString().optional(), -}); +export const projectConfigSchema = yupObject({}); -// key: id of the permission definition. -const _permissionDefinitions = yupRecord( - yupString().defined().matches(permissionRegex), - yupObject({ - description: yupString().optional(), - // key: id of the contained permission. - containedPermissions: yupRecord( - yupString().defined().matches(permissionRegex), - yupObject({}), - ).defined(), - }).defined(), -).defined(); +// --- NEW RBAC Schema --- +const branchRbacDefaultPermissions = yupRecord( + yupString().optional().matches(permissionRegex), + yupBoolean().isTrue().optional(), +).optional(); -const _permissionDefault = yupRecord( - yupString().defined().matches(permissionRegex), - yupObject({}), -).defined(); - -const branchAuth = yupObject({ - oauthAccountMergeStrategy: yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).defined(), - - // key: id of the oauth provider. - oauthProviders: yupRecord( - yupString().defined().matches(permissionRegex), +const branchRbacSchema = yupObject({ + permissions: yupRecord( + yupString().optional().matches(permissionRegex), yupObject({ - type: yupString().oneOf(allProviders).defined(), - }), - ).defined(), + description: yupString().optional(), + scope: yupString().oneOf(['team', 'project']).optional(), + containedPermissionIds: yupRecord( + yupString().optional().matches(permissionRegex), + yupBoolean().isTrue().optional() + ).optional(), + }).optional(), + ).optional(), + defaultPermissions: yupObject({ + teamCreator: branchRbacDefaultPermissions, + teamMember: branchRbacDefaultPermissions, + signUp: branchRbacDefaultPermissions, + }).optional(), +}).optional(); +// --- END NEW RBAC Schema --- - // key: id of the auth method. - authMethods: yupRecord( - yupString().defined().matches(permissionRegex), - yupUnion( - yupObject({ - // @deprecated should remove after the config json db migration - enabled: yupBoolean().defined(), - type: yupString().oneOf(['password']).defined(), - }), - yupObject({ - // @deprecated should remove after the config json db migration - enabled: yupBoolean().defined(), - type: yupString().oneOf(['otp']).defined(), - }), - yupObject({ - // @deprecated should remove after the config json db migration - enabled: yupBoolean().defined(), - type: yupString().oneOf(['passkey']).defined(), - }), - yupObject({ - // @deprecated should remove after the config json db migration - enabled: yupBoolean().defined(), - type: yupString().oneOf(['oauth']).defined(), - oauthProviderId: yupString().defined(), - }), - ), - ).defined(), +// --- NEW API Keys Schema --- +const branchApiKeysSchema = yupObject({ + enabled: yupObject({ + team: yupBoolean().optional(), + user: yupBoolean().optional(), + }).optional(), +}).optional(); +// --- END NEW API Keys Schema --- - // key: id of the connected account. - connectedAccounts: yupRecord( - yupString().defined().matches(permissionRegex), - yupObject({ - // @deprecated should remove after the config json db migration - enabled: yupBoolean().defined(), - oauthProviderId: yupString().defined(), - }), - ).defined(), -}).defined(); + +const branchAuthSchema = yupObject({ + allowSignUp: yupBoolean().optional(), + password: yupObject({ + allowSignIn: yupBoolean().optional(), + }).optional(), + otp: yupObject({ + allowSignIn: yupBoolean().optional(), + }).optional(), + passkey: yupObject({ + allowSignIn: yupBoolean().optional(), + }).optional(), + oauth: yupObject({ + accountMergeStrategy: yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).optional(), + providers: yupRecord( + yupString().optional().matches(permissionRegex), + yupObject({ + type: yupString().oneOf(allProviders).optional(), + allowSignIn: yupBoolean().optional(), + allowConnectedAccounts: yupBoolean().optional(), + }).defined(), + ).optional(), + }).optional(), +}).optional(); const branchDomain = yupObject({ - allowLocalhost: yupBoolean().defined(), -}).defined(); + allowLocalhost: yupBoolean().optional(), +}).optional(); + +export const branchConfigSchema = projectConfigSchema.concat(yupObject({ + rbac: branchRbacSchema, -export const branchConfigSchema = projectConfigSchema.omit(["sourceOfTruthDbConnectionString"]).concat(yupObject({ teams: yupObject({ - createTeamOnSignUp: yupBoolean().defined(), - clientTeamCreationEnabled: yupBoolean().defined(), - - defaultCreatorTeamPermissions: _permissionDefault, - defaultMemberTeamPermissions: _permissionDefault, - teamPermissionDefinitions: _permissionDefinitions, - - allowTeamApiKeys: yupBoolean().defined(), - }).defined(), + createPersonalTeamOnSignUp: yupBoolean().optional(), + allowClientTeamCreation: yupBoolean().optional(), + }).optional(), users: yupObject({ - clientUserDeletionEnabled: yupBoolean().defined(), - signUpEnabled: yupBoolean().defined(), + allowClientUserDeletion: yupBoolean().optional(), + }).optional(), - defaultProjectPermissions: _permissionDefault, - userPermissionDefinitions: _permissionDefinitions, - - allowUserApiKeys: yupBoolean().defined(), - }).defined(), + apiKeys: branchApiKeysSchema, domains: branchDomain, - auth: branchAuth, + auth: branchAuthSchema, + + emails: yupObject({}), })); -export const environmentConfigSchema = branchConfigSchema.omit(["auth", "domains"]).concat(yupObject({ - auth: branchAuth.omit(["oauthProviders"]).concat(yupObject({ - // key: id of the oauth provider. - oauthProviders: yupRecord( - yupString().defined().matches(permissionRegex), - yupObject({ - type: yupString().oneOf(allProviders).defined(), - isShared: yupBoolean().defined(), - clientId: schemaFields.yupDefinedAndNonEmptyWhen(schemaFields.oauthClientIdSchema, { type: 'standard', enabled: true }), - clientSecret: schemaFields.yupDefinedAndNonEmptyWhen(schemaFields.oauthClientSecretSchema, { type: 'standard', enabled: true }), - facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(), - microsoftTenantId: schemaFields.oauthMicrosoftTenantIdSchema.optional(), - }), - ).defined(), - }).defined()), +export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ + auth: branchConfigSchema.getNested("auth").concat(yupObject({ + oauth: branchConfigSchema.getNested("auth").getNested("oauth").concat(yupObject({ + providers: yupRecord( + yupString().optional().matches(permissionRegex), + yupObject({ + type: yupString().oneOf(allProviders).optional(), + isShared: yupBoolean().optional(), + clientId: schemaFields.oauthClientIdSchema.optional(), + clientSecret: schemaFields.oauthClientSecretSchema.optional(), + facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(), + microsoftTenantId: schemaFields.oauthMicrosoftTenantIdSchema.optional(), + allowSignIn: yupBoolean().optional(), + allowConnectedAccounts: yupBoolean().optional(), + }), + ).optional(), + }).optional()), + })), - emails: yupObject({ - emailServer: yupUnion( - yupObject({ - isShared: yupBoolean().isTrue().defined(), - }), - yupObject({ - isShared: yupBoolean().isFalse().defined(), - host: schemaFields.emailHostSchema.defined().nonEmpty(), - port: schemaFields.emailPortSchema.defined(), - username: schemaFields.emailUsernameSchema.defined().nonEmpty(), - password: schemaFields.emailPasswordSchema.defined().nonEmpty(), - senderName: schemaFields.emailSenderNameSchema.defined().nonEmpty(), - senderEmail: schemaFields.emailSenderEmailSchema.defined().nonEmpty(), - }) - ).defined(), - }).defined(), + emails: branchConfigSchema.getNested("emails").concat(yupObject({ + server: yupObject({ + isShared: yupBoolean().optional(), + host: schemaFields.emailHostSchema.optional().nonEmpty(), + port: schemaFields.emailPortSchema.optional(), + username: schemaFields.emailUsernameSchema.optional().nonEmpty(), + password: schemaFields.emailPasswordSchema.optional().nonEmpty(), + senderName: schemaFields.emailSenderNameSchema.optional().nonEmpty(), + senderEmail: schemaFields.emailSenderEmailSchema.optional().nonEmpty(), + }), + }).optional()), - domains: branchDomain.concat(yupObject({ - // keys to the domains are url base64 encoded + domains: branchConfigSchema.getNested("domains").concat(yupObject({ trustedDomains: yupRecord( - yupString().defined().matches(permissionRegex), + yupString().uuid().optional(), yupObject({ - baseUrl: schemaFields.urlSchema.defined(), - handlerPath: schemaFields.handlerPathSchema.defined(), + baseUrl: schemaFields.urlSchema.optional(), + handlerPath: schemaFields.handlerPathSchema.optional(), }), - ).defined(), + ).optional(), })), })); export const organizationConfigSchema = environmentConfigSchema.concat(yupObject({})); -export type ProjectIncompleteConfig = yup.InferType; -export type BranchIncompleteConfig = yup.InferType; -export type EnvironmentIncompleteConfig = yup.InferType; -export type OrganizationIncompleteConfig = yup.InferType; +// Defaults +// these are objects that are merged together to form the rendered config (see ./README.md) +// Wherever an object could be used as a value, a function can instead be used to generate the default values on a per-key basis +export const projectConfigDefaults = {} satisfies DeepReplaceAllowFunctionsForObjects; -export const IncompleteConfigSymbol = Symbol('stack-auth-incomplete-config'); +export const branchConfigDefaults = {} satisfies DeepReplaceAllowFunctionsForObjects; -export type ProjectRenderedConfig = Omit - | keyof yup.InferType - | keyof yup.InferType ->; -export type BranchRenderedConfig = Omit - | keyof yup.InferType ->; -export type EnvironmentRenderedConfig = Omit ->; -export type OrganizationRenderedConfig = OrganizationIncompleteConfig; +export const environmentConfigDefaults = {} satisfies DeepReplaceAllowFunctionsForObjects; -export type ProjectConfigOverride = NormalizesTo>; -export type BranchConfigOverride = NormalizesTo>; -export type EnvironmentConfigOverride = NormalizesTo>; -export type OrganizationConfigOverride = NormalizesTo>; +export const organizationConfigDefaults = { + rbac: { + permissions: (key: string) => ({}), + defaultPermissions: { + teamCreator: {}, + teamMember: {}, + signUp: {}, + }, + }, + + apiKeys: { + enabled: { + team: false, + user: false, + }, + }, + + teams: { + createPersonalTeamOnSignUp: false, + allowClientTeamCreation: false, + }, + + users: { + allowClientUserDeletion: false, + }, + + domains: { + allowLocalhost: false, + trustedDomains: (key: string) => ({ + handlerPath: '/handler', + }), + }, + + auth: { + allowSignUp: true, + password: { + allowSignIn: false, + }, + otp: { + allowSignIn: false, + }, + passkey: { + allowSignIn: false, + }, + oauth: { + accountMergeStrategy: 'link_method', + providers: (key: string) => ({ + allowSignIn: false, + allowConnectedAccounts: false, + }), + }, + }, + + emails: { + server: { + isShared: true, + }, + }, +} satisfies DeepReplaceAllowFunctionsForObjects; + +export type DeepReplaceAllowFunctionsForObjects = T extends object ? { [K in keyof T]: DeepReplaceAllowFunctionsForObjects } | ((arg: keyof T) => DeepReplaceAllowFunctionsForObjects) : T; +export type DeepReplaceFunctionsWithObjects = T extends (arg: infer K extends string) => infer R ? DeepReplaceFunctionsWithObjects> : (T extends object ? { [K in keyof T]: DeepReplaceFunctionsWithObjects } : T); +export type ApplyDefaults unknown), C extends object> = DeepMerge, C>; +export function applyDefaults unknown), C extends object>(defaults: D, config: C): ApplyDefaults { + const res: any = { ...typeof defaults === 'function' ? {} : defaults }; + for (const [key, mergeValue] of Object.entries(config)) { + const baseValue = typeof defaults === 'function' ? defaults(key) : (has(defaults, key as any) ? get(defaults, key as any) : undefined); + if (baseValue !== undefined) { + if (isObjectLike(baseValue) && isObjectLike(mergeValue)) { + set(res, key, applyDefaults(baseValue, mergeValue)); + continue; + } + } + set(res, key, mergeValue); + } + return res as any; +} +import.meta.vitest?.test("applyDefaults", ({ expect }) => { + expect(applyDefaults({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); + expect(applyDefaults({ a: { b: 1 } }, { a: { c: 2 } })).toEqual({ a: { b: 1, c: 2 } }); + expect(applyDefaults((key: string) => ({ b: key }), { a: {} })).toEqual({ a: { b: "a" } }); + expect(applyDefaults({ a: (key: string) => ({ b: key }) }, { a: { c: { d: 1 } } })).toEqual({ a: { c: { b: "c", d: 1 } } }); +}); + +// Normalized overrides +export type ProjectConfigNormalizedOverride = yup.InferType; +export type BranchConfigNormalizedOverride = yup.InferType; +export type EnvironmentConfigNormalizedOverride = yup.InferType; +export type OrganizationConfigNormalizedOverride = yup.InferType; + +// Normalized overrides, without the properties that may be overridden still +export type ProjectConfigStrippedNormalizedOverride = Omit; +export type BranchConfigStrippedNormalizedOverride = Omit; +export type EnvironmentConfigStrippedNormalizedOverride = Omit; +export type OrganizationConfigStrippedNormalizedOverride = OrganizationConfigNormalizedOverride; + +// Overrides +export type ProjectConfigOverride = NormalizesTo; +export type BranchConfigOverride = NormalizesTo; +export type EnvironmentConfigOverride = NormalizesTo; +export type OrganizationConfigOverride = NormalizesTo; + +// Incomplete configs +export type ProjectIncompleteConfig = ProjectConfigNormalizedOverride; +export type BranchIncompleteConfig = ProjectIncompleteConfig & BranchConfigNormalizedOverride; +export type EnvironmentIncompleteConfig = BranchIncompleteConfig & EnvironmentConfigNormalizedOverride; +export type OrganizationIncompleteConfig = EnvironmentIncompleteConfig & OrganizationConfigNormalizedOverride; + +// Rendered configs +export type ProjectRenderedConfig = PrettifyType>; +export type BranchRenderedConfig = PrettifyType>; +export type EnvironmentRenderedConfig = PrettifyType>; +export type OrganizationRenderedConfig = PrettifyType>; diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index f32308a03..1e752f7c9 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -17,10 +17,10 @@ declare module "yup" { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Schema { - getNested(path: K): yup.Schema, + getNested>(path: K): yup.Schema[K], TContext, TDefault, TFlags>, // the default types for concat kinda suck, so let's fix that - concat(schema: U): yup.Schema> & yup.InferType, TContext, TDefault, TFlags>, + concat(schema: U): yup.Schema, keyof yup.InferType> & yup.InferType | (TType & (null | undefined)), TContext, TDefault, TFlags>, } } @@ -151,9 +151,9 @@ export function yupObject, B extends yup.Obje if (unknownKeys.length > 0) { // TODO "did you mean XYZ" return context.createError({ - message: `${context.path} contains unknown properties: ${unknownKeys.join(', ')}`, + message: `${context.path || "Object"} contains unknown properties: ${unknownKeys.join(', ')}`, path: context.path, - params: { unknownKeys }, + params: { unknownKeys, availableKeys }, }); } } @@ -205,8 +205,9 @@ export function yupRecord( 'record', '${path} must be a record of valid values', async function (value: unknown, context: yup.TestContext) { + if (value == null) return true; const { path, createError } = this as any; - if (typeof value !== 'object' || value === null) { + if (typeof value !== 'object') { return createError({ message: `${path} must be an object` }); } @@ -217,7 +218,13 @@ export function yupRecord( // Validate the value try { - await yupValidate(valueSchema, (value as Record)[key], context.options); + await yupValidate(valueSchema, (value as Record)[key], { + ...context.options, + context: { + ...context.options.context, + path: path ? `${path}.${key}` : key, + }, + }); } catch (e: any) { return createError({ path: path ? `${path}.${key}` : key, diff --git a/packages/stack-shared/src/utils/objects.tsx b/packages/stack-shared/src/utils/objects.tsx index 6cfeac115..de5269585 100644 --- a/packages/stack-shared/src/utils/objects.tsx +++ b/packages/stack-shared/src/utils/objects.tsx @@ -1,4 +1,5 @@ import { StackAssertionError } from "./errors"; +import { identity } from "./functions"; export function isNotNull(value: T): value is NonNullable { return value !== null && value !== undefined; @@ -14,6 +15,7 @@ import.meta.vitest?.test("isNotNull", ({ expect }) => { }); export type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial } : T; +export type DeepRequired = T extends object ? { [P in keyof T]-?: DeepRequired } : T; /** * Assumes both objects are primitives, arrays, or non-function plain objects, and compares them deeply. @@ -83,6 +85,22 @@ import.meta.vitest?.test("deepPlainEquals", ({ expect }) => { expect(deepPlainEquals({ a: 1, b: undefined }, { a: 1 })).toBe(false); }); +export function isCloneable(obj: T): obj is Exclude { + return typeof obj !== 'symbol' && typeof obj !== 'function'; +} + +export function shallowClone(obj: T): T { + if (!isCloneable(obj)) throw new StackAssertionError("shallowClone does not support symbols or functions", { obj }); + + if (Array.isArray(obj)) return obj.map(identity) as T; + return { ...obj }; +} +import.meta.vitest?.test("shallowClone", ({ expect }) => { + expect(shallowClone({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 }); + expect(shallowClone([1, 2, 3])).toEqual([1, 2, 3]); + expect(() => shallowClone(() => {})).toThrow(); +}); + export function deepPlainClone(obj: T): T { if (typeof obj === 'function') throw new StackAssertionError("deepPlainClone does not support functions"); if (typeof obj === 'symbol') throw new StackAssertionError("deepPlainClone does not support symbols"); @@ -122,6 +140,119 @@ import.meta.vitest?.test("deepPlainClone", ({ expect }) => { expect(() => deepPlainClone(Symbol())).toThrow(); }); +export type DeepMerge = Omit & Omit & DeepMergeInner, Pick>; +type DeepMergeInner = { + [K in keyof U]-?: + undefined extends U[K] + ? K extends keyof T + ? T[K] extends object + ? Exclude extends object + ? DeepMerge> + : T[K] | Exclude + : T[K] | Exclude + : Exclude + : K extends keyof T + ? T[K] extends object + ? U[K] extends object + ? DeepMerge + : U[K] + : U[K] + : U[K]; +}; +export function deepMerge(baseObj: T, mergeObj: U): DeepMerge { + if ([baseObj, mergeObj, ...Object.values(baseObj), ...Object.values(mergeObj)].some(o => !isCloneable(o))) throw new StackAssertionError("deepMerge does not support functions or symbols", { baseObj, mergeObj }); + + const res: any = shallowClone(baseObj); + for (const [key, mergeValue] of Object.entries(mergeObj)) { + if (has(res, key as any)) { + const baseValue = get(res, key as any); + if (isObjectLike(baseValue) && isObjectLike(mergeValue)) { + set(res, key, deepMerge(baseValue, mergeValue)); + continue; + } + } + set(res, key, mergeValue); + } + return res as any; +} +import.meta.vitest?.test("deepMerge", ({ expect }) => { + // Test merging flat objects + expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }); + expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); + expect(deepMerge({ a: 1, b: 2 }, { b: 3, c: 4 })).toEqual({ a: 1, b: 3, c: 4 }); + + // Test with nested objects + expect(deepMerge( + { a: { x: 1, y: 2 }, b: 3 }, + { a: { y: 3, z: 4 }, c: 5 } + )).toEqual({ a: { x: 1, y: 3, z: 4 }, b: 3, c: 5 }); + + // Test with arrays + expect(deepMerge( + { a: [1, 2], b: 3 }, + { a: [3, 4], c: 5 } + )).toEqual({ a: [3, 4], b: 3, c: 5 }); + + // Test with null values + expect(deepMerge( + { a: { x: 1 }, b: null }, + { a: { y: 2 }, b: { z: 3 } } + )).toEqual({ a: { x: 1, y: 2 }, b: { z: 3 } }); + + // Test with undefined values + expect(deepMerge( + { a: 1, b: undefined }, + { b: 2, c: 3 } + )).toEqual({ a: 1, b: 2, c: 3 }); + + // Test deeply nested structures + expect(deepMerge( + { + a: { + x: { deep: 1 }, + y: [1, 2] + }, + b: 2 + }, + { + a: { + x: { deeper: 3 }, + y: [3, 4] + }, + c: 3 + } + )).toEqual({ + a: { + x: { deep: 1, deeper: 3 }, + y: [3, 4] + }, + b: 2, + c: 3 + }); + + // Test with empty objects + expect(deepMerge({}, { a: 1 })).toEqual({ a: 1 }); + expect(deepMerge({ a: 1 }, {})).toEqual({ a: 1 }); + expect(deepMerge({}, {})).toEqual({}); + + // Test that original objects are not modified + const base = { a: { x: 1 }, b: 2 }; + const merge = { a: { y: 2 }, c: 3 }; + const baseClone = deepPlainClone(base); + const mergeClone = deepPlainClone(merge); + + const result = deepMerge(base, merge); + expect(base).toEqual(baseClone); + expect(merge).toEqual(mergeClone); + expect(result).toEqual({ a: { x: 1, y: 2 }, b: 2, c: 3 }); + + // Test error cases + expect(() => deepMerge({ a: () => {} }, { b: 2 })).toThrow(); + expect(() => deepMerge({ a: 1 }, { b: () => {} })).toThrow(); + expect(() => deepMerge({ a: Symbol() }, { b: 2 })).toThrow(); + expect(() => deepMerge({ a: 1 }, { b: Symbol() })).toThrow(); +}); + export function typedEntries(obj: T): [keyof T, T[keyof T]][] { return Object.entries(obj) as any; } @@ -140,7 +271,7 @@ import.meta.vitest?.test("typedEntries", ({ expect }) => { expect(typeof entries[1][1]).toBe("function"); }); -export function typedFromEntries(entries: [K, V][]): Record { +export function typedFromEntries(entries: (readonly [K, V])[]): Record { return Object.fromEntries(entries) as any; } import.meta.vitest?.test("typedFromEntries", ({ expect }) => { @@ -232,7 +363,7 @@ export type FilterUndefined = * Returns a new object with all undefined values removed. Useful when spreading optional parameters on an object, as * TypeScript's `Partial` type allows `undefined` values. */ -export function filterUndefined(obj: T): FilterUndefined { +export function filterUndefined(obj: T): FilterUndefined { return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as any; } import.meta.vitest?.test("filterUndefined", ({ expect }) => { @@ -250,7 +381,7 @@ export type FilterUndefinedOrNull = FilterUndefined<{ [k in keyof T]: null ex * Returns a new object with all undefined and null values removed. Useful when spreading optional parameters on an object, as * TypeScript's `Partial` type allows `undefined` values. */ -export function filterUndefinedOrNull(obj: T): FilterUndefinedOrNull { +export function filterUndefinedOrNull(obj: T): FilterUndefinedOrNull { return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null)) as any; } import.meta.vitest?.test("filterUndefinedOrNull", ({ expect }) => { @@ -258,6 +389,15 @@ import.meta.vitest?.test("filterUndefinedOrNull", ({ expect }) => { expect(filterUndefinedOrNull({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 }); }); +export type DeepFilterUndefined = T extends object ? FilterUndefined<{ [K in keyof T]: DeepFilterUndefined }> : T; + +export function deepFilterUndefined(obj: T): DeepFilterUndefined { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined).map(([k, v]) => [k, isObjectLike(v) ? deepFilterUndefined(v) : v])) as any; +} +import.meta.vitest?.test("deepFilterUndefined", ({ expect }) => { + expect(deepFilterUndefined({ a: 1, b: undefined })).toEqual({ a: 1 }); +}); + export function pick(obj: T, keys: K[]): Pick { return Object.fromEntries(Object.entries(obj).filter(([k]) => keys.includes(k as K))) as any; } @@ -305,10 +445,14 @@ export function get(obj: T, key: K): T[K] { return descriptor.value; } -export function has(obj: T, key: K): obj is T { +export function has(obj: T, key: K): obj is T & { [k in K]: unknown } { return Object.prototype.hasOwnProperty.call(obj, key); } +export function hasAndNotUndefined(obj: T, key: K): obj is T & { [k in K]: Exclude } { + return has(obj, key) && get(obj, key) !== undefined; +} + export function deleteKey(obj: T, key: K) { if (has(obj, key)) { Reflect.deleteProperty(obj, key); @@ -316,3 +460,7 @@ export function deleteKey(obj: T, key: K) { throw new StackAssertionError(`deleteKey: key ${String(key)} does not exist`, { obj, key }); } } + +export function isObjectLike(value: unknown): value is object { + return (typeof value === 'object' || typeof value === 'function') && value !== null; +} diff --git a/packages/stack-shared/src/utils/types.tsx b/packages/stack-shared/src/utils/types.tsx index e646b1356..9cb372563 100644 --- a/packages/stack-shared/src/utils/types.tsx +++ b/packages/stack-shared/src/utils/types.tsx @@ -26,4 +26,4 @@ export type IfAndOnlyIf = /** * Can be used to prettify a type in the IDE; for example, some complicated intersected types can be flattened into a single type. */ -export type PrettifyType = { [K in keyof T]: T[K] } & {}; +export type PrettifyType = T extends object ? { [K in keyof T]: T[K] } & {} : T;