mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Update config.json schema (#620)
This commit is contained in:
parent
240f866900
commit
a6fbcae21c
@ -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
|
||||
|
||||
@ -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"),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<Result<null, string>> {
|
||||
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<Result<null, string>> {
|
||||
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<Result<null, string>> {
|
||||
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<Result<null, string>> {
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<ProjectIncompleteConfig> {
|
||||
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<BranchIncompleteConfig> {
|
||||
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<EnvironmentIncompleteConfig> {
|
||||
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<OrganizationIncompleteConfig> {
|
||||
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<any>, base: any, configOverride: any): Promise<Result<null, string>> {
|
||||
/**
|
||||
* For the difference between schematically valid and sanity-check valid, see `README.md`.
|
||||
*/
|
||||
async function schematicallyValidateAndReturn(schema: yup.ObjectSchema<any>, base: any, configOverride: any): Promise<Result<null, string>> {
|
||||
// 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<any>, base: any, configOverride: any): Promise<Result<null, string>> {
|
||||
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<any>, 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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -379,7 +379,7 @@ export function getProjectQuery(projectId: string): RawQuery<ProjectsCrud["Admin
|
||||
type: "shared",
|
||||
} as const;
|
||||
} else if (providerConfig.StandardOAuthConfig) {
|
||||
return {
|
||||
return filterUndefined({
|
||||
id: typedToLowercase(providerConfig.StandardOAuthConfig.type),
|
||||
enabled: authMethodConfig.enabled,
|
||||
type: "standard",
|
||||
@ -387,7 +387,7 @@ export function getProjectQuery(projectId: string): RawQuery<ProjectsCrud["Admin
|
||||
client_secret: providerConfig.StandardOAuthConfig.clientSecret,
|
||||
facebook_config_id: providerConfig.StandardOAuthConfig.facebookConfigId ?? undefined,
|
||||
microsoft_tenant_id: providerConfig.StandardOAuthConfig.microsoftTenantId ?? undefined,
|
||||
} as const;
|
||||
} as const);
|
||||
} else {
|
||||
throw new StackAssertionError(`Exactly one of the OAuth provider configs should be set on auth method config ${authMethodConfig.id} of project ${row.id}`, { row });
|
||||
}
|
||||
|
||||
@ -16,9 +16,11 @@ All the logic required for generic usage of the config format are in `format/`.
|
||||
- Base config: The defaults that come with Stack Auth
|
||||
- `$Level` config override: Overrides that are applied to the base config (in the following order: project -> 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.
|
||||
|
||||
<details>
|
||||
<summary>Examples</summary>
|
||||
|
||||
|
||||
@ -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> = 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({});
|
||||
|
||||
@ -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<typeof projectConfigSchema>;
|
||||
export type BranchIncompleteConfig = yup.InferType<typeof branchConfigSchema>;
|
||||
export type EnvironmentIncompleteConfig = yup.InferType<typeof environmentConfigSchema>;
|
||||
export type OrganizationIncompleteConfig = yup.InferType<typeof organizationConfigSchema>;
|
||||
// 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<ProjectConfigStrippedNormalizedOverride>;
|
||||
|
||||
export const IncompleteConfigSymbol = Symbol('stack-auth-incomplete-config');
|
||||
export const branchConfigDefaults = {} satisfies DeepReplaceAllowFunctionsForObjects<BranchConfigStrippedNormalizedOverride>;
|
||||
|
||||
export type ProjectRenderedConfig = Omit<ProjectIncompleteConfig,
|
||||
| keyof yup.InferType<typeof branchConfigSchema>
|
||||
| keyof yup.InferType<typeof environmentConfigSchema>
|
||||
| keyof yup.InferType<typeof organizationConfigSchema>
|
||||
>;
|
||||
export type BranchRenderedConfig = Omit<BranchIncompleteConfig,
|
||||
| keyof yup.InferType<typeof environmentConfigSchema>
|
||||
| keyof yup.InferType<typeof organizationConfigSchema>
|
||||
>;
|
||||
export type EnvironmentRenderedConfig = Omit<EnvironmentIncompleteConfig,
|
||||
| keyof yup.InferType<typeof organizationConfigSchema>
|
||||
>;
|
||||
export type OrganizationRenderedConfig = OrganizationIncompleteConfig;
|
||||
export const environmentConfigDefaults = {} satisfies DeepReplaceAllowFunctionsForObjects<EnvironmentConfigStrippedNormalizedOverride>;
|
||||
|
||||
export type ProjectConfigOverride = NormalizesTo<yup.InferType<typeof projectConfigSchema>>;
|
||||
export type BranchConfigOverride = NormalizesTo<yup.InferType<typeof branchConfigSchema>>;
|
||||
export type EnvironmentConfigOverride = NormalizesTo<yup.InferType<typeof environmentConfigSchema>>;
|
||||
export type OrganizationConfigOverride = NormalizesTo<yup.InferType<typeof organizationConfigSchema>>;
|
||||
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<OrganizationConfigStrippedNormalizedOverride>;
|
||||
|
||||
export type DeepReplaceAllowFunctionsForObjects<T> = T extends object ? { [K in keyof T]: DeepReplaceAllowFunctionsForObjects<T[K]> } | ((arg: keyof T) => DeepReplaceAllowFunctionsForObjects<T[keyof T]>) : T;
|
||||
export type DeepReplaceFunctionsWithObjects<T> = T extends (arg: infer K extends string) => infer R ? DeepReplaceFunctionsWithObjects<Record<K, R>> : (T extends object ? { [K in keyof T]: DeepReplaceFunctionsWithObjects<T[K]> } : T);
|
||||
export type ApplyDefaults<D extends object | ((key: string) => unknown), C extends object> = DeepMerge<DeepReplaceFunctionsWithObjects<D>, C>;
|
||||
export function applyDefaults<D extends object | ((key: string) => unknown), C extends object>(defaults: D, config: C): ApplyDefaults<D, C> {
|
||||
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<typeof projectConfigSchema>;
|
||||
export type BranchConfigNormalizedOverride = yup.InferType<typeof branchConfigSchema>;
|
||||
export type EnvironmentConfigNormalizedOverride = yup.InferType<typeof environmentConfigSchema>;
|
||||
export type OrganizationConfigNormalizedOverride = yup.InferType<typeof organizationConfigSchema>;
|
||||
|
||||
// Normalized overrides, without the properties that may be overridden still
|
||||
export type ProjectConfigStrippedNormalizedOverride = Omit<ProjectConfigNormalizedOverride,
|
||||
| keyof BranchConfigNormalizedOverride
|
||||
| keyof EnvironmentConfigNormalizedOverride
|
||||
| keyof OrganizationConfigNormalizedOverride
|
||||
>;
|
||||
export type BranchConfigStrippedNormalizedOverride = Omit<BranchConfigNormalizedOverride,
|
||||
| keyof EnvironmentConfigNormalizedOverride
|
||||
| keyof OrganizationConfigNormalizedOverride
|
||||
>;
|
||||
export type EnvironmentConfigStrippedNormalizedOverride = Omit<EnvironmentConfigNormalizedOverride,
|
||||
| keyof OrganizationConfigNormalizedOverride
|
||||
>;
|
||||
export type OrganizationConfigStrippedNormalizedOverride = OrganizationConfigNormalizedOverride;
|
||||
|
||||
// Overrides
|
||||
export type ProjectConfigOverride = NormalizesTo<ProjectConfigNormalizedOverride>;
|
||||
export type BranchConfigOverride = NormalizesTo<BranchConfigNormalizedOverride>;
|
||||
export type EnvironmentConfigOverride = NormalizesTo<EnvironmentConfigNormalizedOverride>;
|
||||
export type OrganizationConfigOverride = NormalizesTo<OrganizationConfigNormalizedOverride>;
|
||||
|
||||
// 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<ApplyDefaults<typeof projectConfigDefaults, ProjectConfigStrippedNormalizedOverride>>;
|
||||
export type BranchRenderedConfig = PrettifyType<ProjectRenderedConfig & ApplyDefaults<typeof branchConfigDefaults, BranchConfigStrippedNormalizedOverride>>;
|
||||
export type EnvironmentRenderedConfig = PrettifyType<BranchRenderedConfig & ApplyDefaults<typeof environmentConfigDefaults, EnvironmentConfigStrippedNormalizedOverride>>;
|
||||
export type OrganizationRenderedConfig = PrettifyType<EnvironmentRenderedConfig & ApplyDefaults<typeof organizationConfigDefaults, OrganizationConfigStrippedNormalizedOverride>>;
|
||||
|
||||
@ -17,10 +17,10 @@ declare module "yup" {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface Schema<TType, TContext, TDefault, TFlags> {
|
||||
getNested<K extends keyof TType>(path: K): yup.Schema<TType[K], TContext, TDefault, TFlags>,
|
||||
getNested<K extends keyof NonNullable<TType>>(path: K): yup.Schema<NonNullable<TType>[K], TContext, TDefault, TFlags>,
|
||||
|
||||
// the default types for concat kinda suck, so let's fix that
|
||||
concat<U extends yup.AnySchema>(schema: U): yup.Schema<Omit<TType, keyof yup.InferType<U>> & yup.InferType<U>, TContext, TDefault, TFlags>,
|
||||
concat<U extends yup.AnySchema>(schema: U): yup.Schema<Omit<NonNullable<TType>, keyof yup.InferType<U>> & yup.InferType<U> | (TType & (null | undefined)), TContext, TDefault, TFlags>,
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,9 +151,9 @@ export function yupObject<A extends yup.Maybe<yup.AnyObject>, 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<K extends yup.StringSchema, T extends yup.AnySchema>(
|
||||
'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<K extends yup.StringSchema, T extends yup.AnySchema>(
|
||||
|
||||
// Validate the value
|
||||
try {
|
||||
await yupValidate(valueSchema, (value as Record<string, unknown>)[key], context.options);
|
||||
await yupValidate(valueSchema, (value as Record<string, unknown>)[key], {
|
||||
...context.options,
|
||||
context: {
|
||||
...context.options.context,
|
||||
path: path ? `${path}.${key}` : key,
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
return createError({
|
||||
path: path ? `${path}.${key}` : key,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { StackAssertionError } from "./errors";
|
||||
import { identity } from "./functions";
|
||||
|
||||
export function isNotNull<T>(value: T): value is NonNullable<T> {
|
||||
return value !== null && value !== undefined;
|
||||
@ -14,6 +15,7 @@ import.meta.vitest?.test("isNotNull", ({ expect }) => {
|
||||
});
|
||||
|
||||
export type DeepPartial<T> = T extends object ? { [P in keyof T]?: DeepPartial<T[P]> } : T;
|
||||
export type DeepRequired<T> = T extends object ? { [P in keyof T]-?: DeepRequired<T[P]> } : 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<T>(obj: T): obj is Exclude<T, symbol | Function> {
|
||||
return typeof obj !== 'symbol' && typeof obj !== 'function';
|
||||
}
|
||||
|
||||
export function shallowClone<T extends object>(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<T>(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<T, U> = Omit<T, keyof U> & Omit<U, keyof T> & DeepMergeInner<Pick<T, keyof U & keyof T>, Pick<U, keyof U & keyof T>>;
|
||||
type DeepMergeInner<T, U> = {
|
||||
[K in keyof U]-?:
|
||||
undefined extends U[K]
|
||||
? K extends keyof T
|
||||
? T[K] extends object
|
||||
? Exclude<U[K], undefined> extends object
|
||||
? DeepMerge<T[K], Exclude<U[K], undefined>>
|
||||
: T[K] | Exclude<U[K], undefined>
|
||||
: T[K] | Exclude<U[K], undefined>
|
||||
: Exclude<U[K], undefined>
|
||||
: K extends keyof T
|
||||
? T[K] extends object
|
||||
? U[K] extends object
|
||||
? DeepMerge<T[K], U[K]>
|
||||
: U[K]
|
||||
: U[K]
|
||||
: U[K];
|
||||
};
|
||||
export function deepMerge<T extends {}, U extends {}>(baseObj: T, mergeObj: U): DeepMerge<T, U> {
|
||||
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<T extends {}>(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<K extends PropertyKey, V>(entries: [K, V][]): Record<K, V> {
|
||||
export function typedFromEntries<K extends PropertyKey, V>(entries: (readonly [K, V])[]): Record<K, V> {
|
||||
return Object.fromEntries(entries) as any;
|
||||
}
|
||||
import.meta.vitest?.test("typedFromEntries", ({ expect }) => {
|
||||
@ -232,7 +363,7 @@ export type FilterUndefined<T> =
|
||||
* Returns a new object with all undefined values removed. Useful when spreading optional parameters on an object, as
|
||||
* TypeScript's `Partial<XYZ>` type allows `undefined` values.
|
||||
*/
|
||||
export function filterUndefined<T extends {}>(obj: T): FilterUndefined<T> {
|
||||
export function filterUndefined<T extends object>(obj: T): FilterUndefined<T> {
|
||||
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<T> = 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<XYZ>` type allows `undefined` values.
|
||||
*/
|
||||
export function filterUndefinedOrNull<T extends {}>(obj: T): FilterUndefinedOrNull<T> {
|
||||
export function filterUndefinedOrNull<T extends object>(obj: T): FilterUndefinedOrNull<T> {
|
||||
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> = T extends object ? FilterUndefined<{ [K in keyof T]: DeepFilterUndefined<T[K]> }> : T;
|
||||
|
||||
export function deepFilterUndefined<T extends object>(obj: T): DeepFilterUndefined<T> {
|
||||
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<T extends {}, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([k]) => keys.includes(k as K))) as any;
|
||||
}
|
||||
@ -305,10 +445,14 @@ export function get<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
|
||||
return descriptor.value;
|
||||
}
|
||||
|
||||
export function has<T extends object, K extends keyof T>(obj: T, key: K): obj is T {
|
||||
export function has<T extends object, K extends keyof T>(obj: T, key: K): obj is T & { [k in K]: unknown } {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
export function hasAndNotUndefined<T extends object, K extends keyof T>(obj: T, key: K): obj is T & { [k in K]: Exclude<T[K], undefined> } {
|
||||
return has(obj, key) && get(obj, key) !== undefined;
|
||||
}
|
||||
|
||||
export function deleteKey<T extends object, K extends keyof T>(obj: T, key: K) {
|
||||
if (has(obj, key)) {
|
||||
Reflect.deleteProperty(obj, key);
|
||||
@ -316,3 +460,7 @@ export function deleteKey<T extends object, K extends keyof T>(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;
|
||||
}
|
||||
|
||||
@ -26,4 +26,4 @@ export type IfAndOnlyIf<Value, Extends, Then, Otherwise> =
|
||||
/**
|
||||
* 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<T> = { [K in keyof T]: T[K] } & {};
|
||||
export type PrettifyType<T> = T extends object ? { [K in keyof T]: T[K] } & {} : T;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user