Update config.json schema (#620)

This commit is contained in:
Konsti Wohlwend 2025-04-14 13:23:09 -07:00 committed by GitHub
parent 240f866900
commit a6fbcae21c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 638 additions and 410 deletions

View File

@ -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

View File

@ -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"),
},
});
});
});
}
}
});
}

View File

@ -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,
};
};

View File

@ -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 });
}

View File

@ -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>

View File

@ -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({});

View File

@ -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>>;

View File

@ -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,

View File

@ -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;
}

View File

@ -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;