From 1798e521554c0670dfc5fe87c9b106ef009fe52c Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 22 Jul 2025 01:51:11 -0700 Subject: [PATCH 01/40] New payments --- .vscode/settings.json | 2 + apps/backend/src/lib/config.tsx | 76 ++-- apps/backend/src/lib/permissions.tsx | 6 +- .../widget-playground/page-client.tsx | 10 +- packages/stack-shared/src/config/README.md | 10 +- packages/stack-shared/src/config/format.ts | 67 ++-- packages/stack-shared/src/config/schema.ts | 346 ++++++++++++------ packages/stack-shared/src/schema-fields.ts | 42 ++- .../stack-shared/src/utils/currencies.tsx | 58 +++ packages/stack-shared/src/utils/dates.tsx | 59 +++ packages/stack-shared/src/utils/objects.tsx | 11 +- packages/stack-shared/src/utils/types.tsx | 22 ++ 12 files changed, 510 insertions(+), 199 deletions(-) create mode 100644 packages/stack-shared/src/utils/currencies.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 4c12b0592f..af37b3068f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,6 +24,7 @@ "deindent", "Deindentable", "deindented", + "doesntexist", "DUMBASS", "EAUTH", "EDNS", @@ -77,6 +78,7 @@ "RPID", "simplewebauthn", "spoofable", + "stackable", "stackauth", "stackframe", "sucky", diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 6449e34448..bbf231b993 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -1,11 +1,11 @@ import { Prisma } from "@prisma/client"; -import { Config, NormalizationError, NormalizedConfig, getInvalidConfigReason, normalize, override } from "@stackframe/stack-shared/dist/config/format"; +import { Config, NormalizationError, getInvalidConfigReason, normalize, override } from "@stackframe/stack-shared/dist/config/format"; import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, 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 { 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 } from "@stackframe/stack-shared/dist/utils/objects"; +import { filterUndefined, typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import * as yup from "yup"; @@ -28,28 +28,28 @@ type OrganizationOptions = EnvironmentOptions & { organizationId: string | null export function getRenderedProjectConfigQuery(options: ProjectOptions): RawQuery> { return RawQuery.then( getIncompleteProjectConfigQuery(options), - async (incompleteConfig) => applyDefaults(projectConfigDefaults, await incompleteConfig), + async (incompleteConfig) => normalize(await incompleteConfig) as ProjectRenderedConfig, ); } export function getRenderedBranchConfigQuery(options: BranchOptions): RawQuery> { return RawQuery.then( getIncompleteBranchConfigQuery(options), - async (incompleteConfig) => applyDefaults(branchConfigDefaults, await incompleteConfig), + async (incompleteConfig) => normalize(await incompleteConfig) as BranchRenderedConfig, ); } export function getRenderedEnvironmentConfigQuery(options: EnvironmentOptions): RawQuery> { return RawQuery.then( getIncompleteEnvironmentConfigQuery(options), - async (incompleteConfig) => applyDefaults(environmentConfigDefaults, await incompleteConfig), + async (incompleteConfig) => normalize(await incompleteConfig) as EnvironmentRenderedConfig, ); } export function getRenderedOrganizationConfigQuery(options: OrganizationOptions): RawQuery> { return RawQuery.then( getIncompleteOrganizationConfigQuery(options), - async (incompleteConfig) => applyDefaults(organizationConfigDefaults, await incompleteConfig), + async (incompleteConfig) => normalize(await incompleteConfig) as OrganizationRenderedConfig, ); } @@ -62,14 +62,14 @@ export function getRenderedOrganizationConfigQuery(options: OrganizationOptions) * Validates a project config override ([sanity-check valid](./README.md)). */ export async function validateProjectConfigOverride(options: { projectConfigOverride: ProjectConfigOverride }): Promise> { - return await schematicallyValidateAndReturn(projectConfigSchema, {}, options.projectConfigOverride); + return await validateConfigOverrideSchema(projectConfigSchema, {}, options.projectConfigOverride); } /** * Validates a branch config override ([sanity-check valid](./README.md)), based on the given project's rendered project config. */ export async function validateBranchConfigOverride(options: { branchConfigOverride: BranchConfigOverride } & ProjectOptions): Promise> { - return await schematicallyValidateAndReturn(branchConfigSchema, await rawQuery(globalPrismaClient, getIncompleteProjectConfigQuery(options)), options.branchConfigOverride); + return await validateConfigOverrideSchema(branchConfigSchema, await rawQuery(globalPrismaClient, getIncompleteProjectConfigQuery(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) } @@ -78,7 +78,7 @@ export async function validateBranchConfigOverride(options: { branchConfigOverri * Validates an environment config override ([sanity-check valid](./README.md)), based on the given branch's rendered branch config. */ export async function validateEnvironmentConfigOverride(options: { environmentConfigOverride: EnvironmentConfigOverride } & BranchOptions): Promise> { - return await schematicallyValidateAndReturn(environmentConfigSchema, await rawQuery(globalPrismaClient, getIncompleteBranchConfigQuery(options)), options.environmentConfigOverride); + return await validateConfigOverrideSchema(environmentConfigSchema, await rawQuery(globalPrismaClient, getIncompleteBranchConfigQuery(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) } @@ -87,7 +87,7 @@ export async function validateEnvironmentConfigOverride(options: { environmentCo * Validates an organization config override ([sanity-check valid](./README.md)), based on the given environment's rendered environment config. */ export async function validateOrganizationConfigOverride(options: { organizationConfigOverride: OrganizationConfigOverride } & EnvironmentOptions): Promise> { - return await schematicallyValidateAndReturn(organizationConfigSchema, await rawQuery(globalPrismaClient, getIncompleteEnvironmentConfigQuery(options)), options.organizationConfigOverride); + return await validateConfigOverrideSchema(organizationConfigSchema, await rawQuery(globalPrismaClient, getIncompleteEnvironmentConfigQuery(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) } @@ -285,42 +285,35 @@ function getIncompleteOrganizationConfigQuery(options: OrganizationOptions): Raw }); } -function makeIncompleteConfigQuery(options: { previous?: RawQuery>, override: RawQuery>, defaults: any }): RawQuery> { +function makeIncompleteConfigQuery(options: { previous?: RawQuery>, override: RawQuery>, defaults: any }): RawQuery> { return RawQuery.then( RawQuery.all([ options.previous ?? RawQuery.resolve(Promise.resolve({})), options.override, ] as const), - async ([prev, over]) => applyDefaults(options.defaults, normalize(override(await prev, await over))), + async ([prev, over]) => applyDefaults(options.defaults, override(await prev, await over)), ); } /** - * For the difference between schematically valid and sanity-check valid, see `README.md`. + * Validates the config override against three different schemas: the base one, the default one, and an empty base. + * + * */ -async function schematicallyValidateAndReturn(schema: yup.ObjectSchema, base: any, configOverride: any): Promise> { - // First, we check whether the override is valid on its own, in the hypothetical case where all parent configs are empty. - const basicRes = await schematicallyValidateAndReturnImpl(schema, {}, configOverride); - if (basicRes.status === "error") return basicRes; - - // As a sanity check, we also validate that the override is valid if we merge it with the base config. Because of - // how we design schemas, this should always be the case (as changing a base config should not make the yup schema - // invalid). - const mergedRes = await schematicallyValidateAndReturnImpl(schema, base, configOverride); - if (mergedRes.status === "error") { - throw new StackAssertionError('Invalid override is not compatible with the base config: ' + mergedRes.error, { mergedRes }); - } +async function validateConfigOverrideSchema(schema: yup.ObjectSchema, base: any, configOverride: any): Promise> { + const mergedResBase = await _validateConfigOverrideSchemaImpl(schema, base, configOverride); + if (mergedResBase.status === "error") return mergedResBase; return Result.ok(null); } -async function schematicallyValidateAndReturnImpl(schema: yup.ObjectSchema, base: any, configOverride: any): Promise> { +async function _validateConfigOverrideSchemaImpl(schema: yup.ObjectSchema, base: any, configOverride: any): Promise> { const reason = getInvalidConfigReason(configOverride, { configName: 'override' }); if (reason) return Result.error(reason); - const value = override(pick(base, Object.keys(schema.fields)), configOverride); + const value = override(base, configOverride); let normalizedValue; try { - normalizedValue = normalize(value); + normalizedValue = normalize(value, { onDotIntoNonObject: "throw" }); } catch (error) { if (error instanceof NormalizationError) { return Result.error(error.message); @@ -348,18 +341,21 @@ import.meta.vitest?.test('schematicallyValidateAndReturn(...)', async ({ expect a: yupString().optional(), }); - 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 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?`); + expect(await validateConfigOverrideSchema(schema1, {}, {})).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, {})).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, {}, { a: 'b' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, { a: 'c' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, {}, { a: null })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, { a: null })).toEqual(Result.ok(null)); + + expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), {}, { "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).`)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), { a: 'str' }, { "a.b": "c" })).toEqual(Result.error(`Tried to use dot notation to access "a.b", but "a" is not an object.`)); + expect(await validateConfigOverrideSchema(schema1, {}, { a: 123 })).toEqual(Result.error('a must be a `string` type, but the final value was: `123`.')); + + expect(await validateConfigOverrideSchema(projectConfigSchema, {}, {})).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(branchConfigSchema, {}, {})).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {})).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(organizationConfigSchema, {}, {})).toEqual(Result.ok(null)); }); // --------------------------------------------------------------------------------------------------------------------- diff --git a/apps/backend/src/lib/permissions.tsx b/apps/backend/src/lib/permissions.tsx index 1c541785b7..f19482ab7d 100644 --- a/apps/backend/src/lib/permissions.tsx +++ b/apps/backend/src/lib/permissions.tsx @@ -172,7 +172,7 @@ export async function listPermissionDefinitions( ...permissions.map(([id, p]) => ({ id, description: getDescription(id, p.description), - contained_permission_ids: typedEntries(p.containedPermissionIds || {}).map(([id]) => id).sort(stringCompare), + contained_permission_ids: typedEntries(p.containedPermissionIds).map(([id]) => id).sort(stringCompare), })), ...(options.scope === "team" ? typedEntries(teamSystemPermissionMap).map(([id, description]) => ({ id, @@ -294,7 +294,7 @@ export async function updatePermissionDefinition( .filter(([id]) => id !== options.oldId) .map(([id, p]) => [id, { ...p, - containedPermissionIds: typedFromEntries(typedEntries(p.containedPermissionIds || {}).map(([id]) => { + containedPermissionIds: typedFromEntries(typedEntries(p.containedPermissionIds).map(([id]) => { if (id === options.oldId) { return [newId, true]; } else { @@ -377,7 +377,7 @@ export async function deletePermissionDefinition( .map(([id, p]) => [id, { ...p, containedPermissionIds: typedFromEntries( - typedEntries(p.containedPermissionIds || {}) + typedEntries(p.containedPermissionIds) .filter(([containedId]) => containedId !== options.permissionId) ) }]) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/widget-playground/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/widget-playground/page-client.tsx index 6d9f23df03..842003c510 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/widget-playground/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/widget-playground/page-client.tsx @@ -1181,7 +1181,7 @@ function SwappableWidgetInstanceGrid(props: { gridRef: RefState { setActiveInstanceId(event.active.id as string); setDraggingType("element"); - setActiveElementInitialRect(event.activatorEvent.target.getBoundingClientRect()); + setActiveElementInitialRect((event.activatorEvent.target as any).getBoundingClientRect()); }} onDragAbort={() => { setHoverElementSwap(null); @@ -1242,10 +1242,10 @@ function SwappableWidgetInstanceGrid(props: { gridRef: RefState branch -> environment -> organization) -- `$Level` incomplete config: The base config after some overrides have been applied, deeply merged into `configDefaults` -- `$Level` rendered config: An incomplete config with those fields removed that can be overridden by a future override +- `$Level` incomplete config: The base config overridden with the overrides up to level `$Level`, deeply merged into `configDefaults` +- `$Level` rendered config: A normalized incomplete config with those fields removed that can be overridden by a future override - 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. +- `$Level` config override override: An override that overrides the `$Level` config override. This is most often used eg. in the REST API to let users make changes to the branch-level config, without overwriting the entire branch-level config override. *Note that, since config overrides (unlike configs) distinguish between `null` and a property missing (`undefined`), it is currently not possible to say "this property in the config override should be unset" (setting a property to `null` in the override override will simply also set it to `null` in the override). In the future, we'll have to think about how we handle this, probably with a sentinel value.* +- `$Level` config: Could refer to any of the above, depending on the context; if it's not clear, specify it.
Examples diff --git a/packages/stack-shared/src/config/format.ts b/packages/stack-shared/src/config/format.ts index 9c3fc9918d..f2e43e90fc 100644 --- a/packages/stack-shared/src/config/format.ts +++ b/packages/stack-shared/src/config/format.ts @@ -2,6 +2,7 @@ import { StackAssertionError, throwErr } from "../utils/errors"; import { deleteKey, filterUndefined, get, hasAndNotUndefined, set } from "../utils/objects"; +import { OptionalKeys, RequiredKeys } from "../utils/types"; export type ConfigValue = string | number | boolean | null | ConfigValue[] | Config; @@ -16,11 +17,20 @@ export type NormalizedConfig = { export type _NormalizesTo = N extends object ? ( & Config - & { [K in keyof N]?: _NormalizesTo | null } + & { [K in OptionalKeys]?: _NormalizesTo | null } + & { [K in RequiredKeys]: _NormalizesTo } & { [K in `${string}.${string}`]: ConfigValue } ) : N; export type NormalizesTo = _NormalizesTo; + +type T = { + a: 1, + b?: 2, +}; + +type X = T & Record; + /** * Note that a config can both be valid and not normalizable. */ @@ -140,13 +150,12 @@ import.meta.vitest?.test("override(...)", ({ expect }) => { type NormalizeOptions = { /** - * What to do if a dot notation is used on null. + * What to do if a dot notation is used on a value that is not an object. * - * - "empty" (default): Replace the null with an empty object. - * - "throw": Throw an error. + * - "throw" (default): Throw an error. * - "ignore": Ignore the dot notation field. */ - onDotIntoNull?: "empty" | "throw" | "ignore", + onDotIntoNonObject?: "throw" | "ignore", } export class NormalizationError extends Error { @@ -158,7 +167,7 @@ NormalizationError.prototype.name = "NormalizationError"; export function normalize(c: Config, options: NormalizeOptions = {}): NormalizedConfig { assertValidConfig(c); - const onDotIntoNull = options.onDotIntoNull ?? "empty"; + const onDotIntoNonObject = options.onDotIntoNonObject ?? "throw"; const countDots = (s: string) => s.match(/\./g)?.length ?? 0; const result: NormalizedConfig = {}; @@ -173,13 +182,9 @@ export function normalize(c: Config, options: NormalizeOptions = {}): Normalized let current: NormalizedConfig = result; for (const keySegment of keySegmentsWithoutLast) { if (!hasAndNotUndefined(current, keySegment)) { - switch (onDotIntoNull) { - case "empty": { - set(current, keySegment, {}); - break; - } + switch (onDotIntoNonObject) { 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?`); + 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).`); } case "ignore": { continue outer; @@ -188,29 +193,36 @@ export function normalize(c: Config, options: NormalizeOptions = {}): Normalized } const value = get(current, keySegment); if (typeof value !== 'object') { - throw new NormalizationError(`Tried to use dot notation to access ${JSON.stringify(key)}, but ${JSON.stringify(keySegment)} is not an object. Maybe this config is not normalizable?`); + switch (onDotIntoNonObject) { + case "throw": { + throw new NormalizationError(`Tried to use dot notation to access ${JSON.stringify(key)}, but ${JSON.stringify(keySegment)} is not an object.`); + } + case "ignore": { + continue outer; + } + } } current = value as NormalizedConfig; } - setNormalizedValue(current, last, value); + setNormalizedValue(current, last, value, options); } return result; } -function normalizeValue(value: ConfigValue): NormalizedConfigValue { +function normalizeValue(value: ConfigValue, options: NormalizeOptions): NormalizedConfigValue { if (value === null) throw new NormalizationError("Tried to normalize a null value"); - if (Array.isArray(value)) return value.map(normalizeValue); - if (typeof value === 'object') return normalize(value); + if (Array.isArray(value)) return value.map(v => normalizeValue(v, options)); + if (typeof value === 'object') return normalize(value, options); return value; } -function setNormalizedValue(result: NormalizedConfig, key: string, value: ConfigValue) { +function setNormalizedValue(result: NormalizedConfig, key: string, value: ConfigValue, options: NormalizeOptions) { if (value === null) { if (hasAndNotUndefined(result, key)) { deleteKey(result, key); } } else { - set(result, key, normalizeValue(value)); + set(result, key, normalizeValue(value, options)); } } @@ -230,7 +242,7 @@ import.meta.vitest?.test("normalize(...)", ({ expect }) => { k: { l: {} }, "k.l.m": 13, n: undefined, - })).toEqual({ + }, { onDotIntoNonObject: "ignore" })).toEqual({ a: 9, b: 2, c: { @@ -242,23 +254,24 @@ import.meta.vitest?.test("normalize(...)", ({ expect }) => { }); // dotting into null - expect(normalize({ - "b.c": 2, - })).toEqual({ b: { c: 2 } }); expect(() => normalize({ "b.c": 2, - }, { 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?`); + }, { onDotIntoNonObject: "throw" })).toThrow(`Tried to use dot notation to access "b.c", but "b" doesn't exist on the object (or is null)`); expect(() => normalize({ b: null, "b.c": 2, - }, { 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?`); + }, { onDotIntoNonObject: "throw" })).toThrow(`Tried to use dot notation to access "b.c", but "b" doesn't exist on the object (or is null)`); expect(normalize({ "b.c": 2, - }, { onDotIntoNull: "ignore" })).toEqual({}); + }, { onDotIntoNonObject: "ignore" })).toEqual({}); // dotting into non-object expect(() => normalize({ b: 1, "b.c": 2, - })).toThrow(`Tried to use dot notation to access "b.c", but "b" is not an object. Maybe this config is not normalizable?`); + }, { onDotIntoNonObject: "throw" })).toThrow(`Tried to use dot notation to access "b.c", but "b" is not an object`); + expect(normalize({ + b: 1, + "b.c": 2, + }, { onDotIntoNonObject: "ignore" })).toEqual({ b: 1 }); }); diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index df2b848e16..8998cd8a42 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -1,14 +1,12 @@ import * as yup from "yup"; +import { DEFAULT_EMAIL_TEMPLATES, DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_THEME_ID } from "../helpers/emails"; import * as schemaFields from "../schema-fields"; -import { yupBoolean, yupObject, yupRecord, yupString, yupUnion } from "../schema-fields"; +import { userSpecifiedIdSchema, yupBoolean, yupNumber, yupObject, yupRecord, yupString, yupUnion } from "../schema-fields"; +import { SUPPORTED_CURRENCIES } from "../utils/currencies"; import { allProviders } from "../utils/oauth"; -import { DeepMerge, DeepPartial, get, has, isObjectLike, mapValues, set } from "../utils/objects"; -import { Config, NormalizesTo } from "./format"; -import { DEFAULT_EMAIL_THEME_ID, DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_TEMPLATES } from "../helpers/emails"; - -// NOTE: The validation schemas in here are all schematic validators, not sanity-check validators. -// For more info, see ./README.md - +import { DeepMerge, DeepPartial, DeepRequiredOrUndefined, get, has, isObjectLike, mapValues, set, typedFromEntries } from "../utils/objects"; +import { IntersectAll } from "../utils/types"; +import { NormalizesTo } from "./format"; export const configLevels = ['project', 'branch', 'environment', 'organization'] as const; export type ConfigLevel = typeof configLevels[number]; @@ -21,97 +19,155 @@ const customPermissionRegex = /^[a-z0-9_:]+$/; export const projectConfigSchema = yupObject({ sourceOfTruth: yupUnion( yupObject({ - type: yupString().oneOf(['hosted']).optional(), + type: yupString().oneOf(['hosted']).defined(), }).defined(), yupObject({ - type: yupString().oneOf(['neon']).optional(), + type: yupString().oneOf(['neon']).defined(), connectionStrings: yupRecord( yupString().defined(), yupString().defined(), ).defined(), }).defined(), yupObject({ - type: yupString().oneOf(['postgres']).optional(), + type: yupString().oneOf(['postgres']).defined(), connectionString: yupString().defined() }).defined(), - ).optional(), -}); + ).defined(), +}).defined(); // --- NEW RBAC Schema --- const branchRbacDefaultPermissions = yupRecord( - yupString().optional().matches(permissionRegex), + yupString().matches(permissionRegex).defined(), yupBoolean().isTrue().optional(), -).optional(); +).defined(); const branchRbacSchema = yupObject({ permissions: yupRecord( - yupString().optional().matches(customPermissionRegex), + yupString().matches(customPermissionRegex).defined(), yupObject({ description: yupString().optional(), scope: yupString().oneOf(['team', 'project']).optional(), containedPermissionIds: yupRecord( - yupString().optional().matches(permissionRegex), + yupString().matches(permissionRegex).defined(), yupBoolean().isTrue().optional() ).optional(), }).optional(), - ).optional(), + ).defined(), defaultPermissions: yupObject({ teamCreator: branchRbacDefaultPermissions, teamMember: branchRbacDefaultPermissions, signUp: branchRbacDefaultPermissions, - }).optional(), -}).optional(); + }).defined(), +}).defined(); // --- END NEW RBAC Schema --- // --- NEW API Keys Schema --- const branchApiKeysSchema = yupObject({ enabled: yupObject({ - team: yupBoolean().optional(), - user: yupBoolean().optional(), - }).optional(), -}).optional(); + team: yupBoolean().defined(), + user: yupBoolean().defined(), + }).defined(), +}).defined(); // --- END NEW API Keys Schema --- const branchAuthSchema = yupObject({ - allowSignUp: yupBoolean().optional(), + allowSignUp: yupBoolean().defined(), password: yupObject({ - allowSignIn: yupBoolean().optional(), - }).optional(), + allowSignIn: yupBoolean().defined(), + }).defined(), otp: yupObject({ - allowSignIn: yupBoolean().optional(), - }).optional(), + allowSignIn: yupBoolean().defined(), + }).defined(), passkey: yupObject({ - allowSignIn: yupBoolean().optional(), - }).optional(), + allowSignIn: yupBoolean().defined(), + }).defined(), oauth: yupObject({ - accountMergeStrategy: yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).optional(), + accountMergeStrategy: yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).defined(), providers: yupRecord( - yupString().optional().matches(permissionRegex), + yupString().matches(permissionRegex).defined(), yupObject({ - type: yupString().oneOf(allProviders).optional(), - allowSignIn: yupBoolean().optional(), - allowConnectedAccounts: yupBoolean().optional(), + type: yupString().oneOf(allProviders).defined(), + allowSignIn: yupBoolean().defined(), + allowConnectedAccounts: yupBoolean().defined(), }).defined(), - ).optional(), + ).defined(), + }).defined(), +}).defined(); + +const branchPaymentsSchema = yupObject({ + autoPay: yupObject({ + interval: schemaFields.dayIntervalSchema.defined(), }).optional(), -}).optional(); + exclusivityGroups: yupRecord( + userSpecifiedIdSchema("exclusivityGroupId").defined(), + yupRecord( + userSpecifiedIdSchema("offerId").defined(), + yupBoolean().isTrue().defined(), + ).defined(), + ).defined(), + offers: yupRecord( + userSpecifiedIdSchema("offerId").defined(), + yupObject({ + customerType: schemaFields.customerTypeSchema.defined(), + freeTrial: schemaFields.dayIntervalSchema.optional(), + serverOnly: yupBoolean().defined(), + stackable: yupBoolean().defined(), + prices: yupRecord( + userSpecifiedIdSchema("priceId").defined(), + yupObject({ + ...typedFromEntries(SUPPORTED_CURRENCIES.map(currency => [currency.code, schemaFields.moneyAmountSchema(currency).optional()])), + interval: schemaFields.dayIntervalSchema.optional(), + serverOnly: yupBoolean().defined(), + freeTrial: schemaFields.dayIntervalSchema.optional(), + }).defined().test("at-least-one-currency", (value, context) => { + const currencies = Object.keys(value).filter(key => key.toUpperCase() === key); + if (currencies.length === 0) { + return context.createError({ message: "At least one currency is required" }); + } + return true; + }).defined(), + ).defined(), + items: yupRecord( + userSpecifiedIdSchema("itemId").defined(), + yupObject({ + quantity: yupNumber().defined(), + repeat: schemaFields.dayIntervalOrNeverSchema.optional(), + expires: yupString().oneOf(['never', 'when-purchase-expires', 'when-repeated']).defined(), + }).defined(), + ).defined(), + }).defined(), + ).defined(), + items: yupRecord( + userSpecifiedIdSchema("itemId").defined(), + yupObject({ + customerType: schemaFields.customerTypeSchema.defined(), + default: yupObject({ + quantity: yupNumber().defined(), + repeat: schemaFields.dayIntervalOrNeverSchema.optional(), + expires: yupString().oneOf(['never', 'when-repeated']).defined(), + }).defined().default({ + quantity: 0, + }), + }).defined(), + ).defined(), +}).defined(); const branchDomain = yupObject({ - allowLocalhost: yupBoolean().optional(), -}).optional(); + allowLocalhost: yupBoolean().defined(), +}).defined(); export const branchConfigSchema = projectConfigSchema.omit(['sourceOfTruth']).concat(yupObject({ rbac: branchRbacSchema, teams: yupObject({ - createPersonalTeamOnSignUp: yupBoolean().optional(), - allowClientTeamCreation: yupBoolean().optional(), - }).optional(), + createPersonalTeamOnSignUp: yupBoolean().defined(), + allowClientTeamCreation: yupBoolean().defined(), + }).defined(), users: yupObject({ - allowClientUserDeletion: yupBoolean().optional(), - }).optional(), + allowClientUserDeletion: yupBoolean().defined(), + }).defined(), apiKeys: branchApiKeysSchema, @@ -120,11 +176,12 @@ export const branchConfigSchema = projectConfigSchema.omit(['sourceOfTruth']).co auth: branchAuthSchema, emails: yupObject({ - theme: schemaFields.emailThemeSchema.optional(), - themeList: schemaFields.emailThemeListSchema.optional(), - templateList: schemaFields.emailTemplateListSchema.optional(), - }), + theme: schemaFields.emailThemeSchema.defined(), + themeList: schemaFields.emailThemeListSchema.defined(), + templateList: schemaFields.emailTemplateListSchema.defined(), + }).defined(), + payments: branchPaymentsSchema, })); @@ -132,10 +189,10 @@ 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), + yupString().matches(permissionRegex).defined(), yupObject({ - type: yupString().oneOf(allProviders).optional(), - isShared: yupBoolean().optional(), + type: yupString().oneOf(allProviders).defined(), + isShared: yupBoolean().defined(), clientId: schemaFields.oauthClientIdSchema.optional(), clientSecret: schemaFields.oauthClientSecretSchema.optional(), facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(), @@ -143,31 +200,31 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ allowSignIn: yupBoolean().optional(), allowConnectedAccounts: yupBoolean().optional(), }), - ).optional(), - }).optional()), + ).defined(), + }).defined()), })), emails: branchConfigSchema.getNested("emails").concat(yupObject({ server: yupObject({ - isShared: yupBoolean().optional(), + isShared: yupBoolean().defined(), 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()), + }).defined(), + }).defined()), domains: branchConfigSchema.getNested("domains").concat(yupObject({ trustedDomains: yupRecord( - yupString().uuid().optional(), + yupString().uuid().defined(), yupObject({ - baseUrl: schemaFields.urlSchema.optional(), - handlerPath: schemaFields.handlerPathSchema.optional(), + baseUrl: schemaFields.urlSchema.defined(), + handlerPath: schemaFields.handlerPathSchema.defined(), }), - ).optional(), - })), + ).defined(), + }).defined()), })); export const organizationConfigSchema = environmentConfigSchema.concat(yupObject({})); @@ -176,20 +233,21 @@ export const organizationConfigSchema = environmentConfigSchema.concat(yupObject // 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 +// To make sure you don't accidentally forget setting a default value, you must explicitly set fields with no default value to `undefined`. // NOTE: These values are the defaults of the schema, NOT the defaults for newly created projects. The values here signify what `null` means for each property. If you want new projects by default to have a certain value set to true, you should update the corresponding function in the backend instead. export const projectConfigDefaults = { sourceOfTruth: { type: 'hosted', }, -} satisfies DeepReplaceAllowFunctionsForObjects; +} satisfies DefaultsType; -export const branchConfigDefaults = {} satisfies DeepReplaceAllowFunctionsForObjects; - -export const environmentConfigDefaults = {} satisfies DeepReplaceAllowFunctionsForObjects; - -export const organizationConfigDefaults = { +export const branchConfigDefaults = { rbac: { - permissions: (key: string) => ({}), + permissions: (key: string) => ({ + containedPermissionIds: {}, + description: undefined, + scope: undefined, + }), defaultPermissions: { teamCreator: {}, teamMember: {}, @@ -215,9 +273,6 @@ export const organizationConfigDefaults = { domains: { allowLocalhost: false, - trustedDomains: (key: string) => ({ - handlerPath: '/handler', - }), }, auth: { @@ -234,6 +289,7 @@ export const organizationConfigDefaults = { oauth: { accountMergeStrategy: 'link_method', providers: (key: string) => ({ + type: undefined, isShared: true, allowSignIn: false, allowConnectedAccounts: false, @@ -242,75 +298,144 @@ export const organizationConfigDefaults = { }, emails: { - server: { - isShared: true, - }, theme: DEFAULT_EMAIL_THEME_ID, themeList: DEFAULT_EMAIL_THEMES, templateList: DEFAULT_EMAIL_TEMPLATES, }, -} satisfies DeepReplaceAllowFunctionsForObjects; -export type DeepReplaceAllowFunctionsForObjects = T extends object ? { [K in keyof T]: DeepReplaceAllowFunctionsForObjects } | ((arg: keyof T) => DeepReplaceAllowFunctionsForObjects) : T; + payments: { + autoPay: undefined, + exclusivityGroups: {}, + offers: (key: string) => ({ + customerType: undefined, + freeTrial: undefined, + serverOnly: false, + stackable: undefined, + prices: (key: string) => ({ + ...typedFromEntries(SUPPORTED_CURRENCIES.map(currency => [currency.code, undefined])), + interval: undefined, + serverOnly: false, + freeTrial: undefined, + }), + items: (key: string) => ({ + quantity: undefined, + repeat: undefined, + expires: "when-repeated", + }), + }), + items: {}, + }, +} as const satisfies DefaultsType; + +export const environmentConfigDefaults = { + domains: { + trustedDomains: (key: string) => ({ + baseUrl: undefined, + handlerPath: '/handler', + }), + }, + + emails: { + server: { + isShared: true, + host: undefined, + port: undefined, + username: undefined, + password: undefined, + senderName: undefined, + senderEmail: undefined, + }, + }, + + auth: { + oauth: { + providers: (key: string) => ({ + type: undefined, + isShared: true, + allowSignIn: false, + allowConnectedAccounts: false, + clientId: undefined, + clientSecret: undefined, + facebookConfigId: undefined, + microsoftTenantId: undefined, + }), + }, + }, +} as const satisfies DefaultsType; + +export const organizationConfigDefaults = {} satisfies DefaultsType; + + +type DefaultsType = DeepReplaceAllowFunctionsForObjects, IntersectAll<{ [K in keyof U]: DeepReplaceFunctionsWithObjects }>>>; +type DeepOmitDefaults = T extends object ? ( + ( + & /* keys that are both in T and U, *and* the key's value in U is not a subtype of the key's value in T */ { [K in { [Ki in keyof T & keyof U]: U[Ki] extends T[Ki] ? never : Ki }[keyof T & keyof U]]: DeepOmitDefaults } + & /* keys that are in T but not in U */ { [K in Exclude]: T[K] } + ) +) : T; + +export type DeepReplaceAllowFunctionsForObjects = T extends object ? { [K in keyof T]: DeepReplaceAllowFunctionsForObjects } | (string extends keyof T ? (arg: keyof T) => DeepReplaceAllowFunctionsForObjects : never) : T; export type DeepReplaceFunctionsWithObjects = T extends (arg: infer K extends string) => infer R ? DeepReplaceFunctionsWithObjects> : (T extends object ? { [K in keyof T]: DeepReplaceFunctionsWithObjects } : T); export type ApplyDefaults unknown), C extends object> = DeepMerge, C>; export function applyDefaults unknown), C extends object>(defaults: D, config: C): ApplyDefaults { const res: any = typeof defaults === 'function' ? {} : mapValues(defaults, v => typeof v === 'function' ? {} : (typeof v === 'object' ? applyDefaults(v as any, {}) : v)); - 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; + outer: for (const [key, mergeValue] of Object.entries(config)) { + if (mergeValue === undefined) continue; + const keyParts = key.split("."); + let baseValue: any = defaults; + for (const part of keyParts) { + baseValue = typeof baseValue === 'function' ? baseValue(part) : (has(baseValue, part) ? get(baseValue, part) : undefined); + if (baseValue === undefined || !isObjectLike(baseValue) || !isObjectLike(mergeValue)) { + set(res, key, mergeValue); + continue outer; } } - set(res, key, mergeValue); + set(res, key, applyDefaults(baseValue, mergeValue)); } return res as any; } import.meta.vitest?.test("applyDefaults", ({ expect }) => { + // Basic expect(applyDefaults({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); + expect(applyDefaults({ a: { b: 1 } }, { a: { b: 2 } })).toEqual({ a: { b: 2 } }); expect(applyDefaults({ a: { b: 1 } }, { a: { c: 2 } })).toEqual({ a: { b: 1, c: 2 } }); + expect(applyDefaults({ a: { b: { c: 1, d: 2 } } }, { a: { b: { d: 3, e: 4 } } })).toEqual({ a: { b: { c: 1, d: 3, e: 4 } } }); + + // Functions 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 } } }); expect(applyDefaults({ a: (key: string) => ({ b: key }) }, {})).toEqual({ a: {} }); expect(applyDefaults({ a: { b: (key: string) => ({ b: key }) } }, {})).toEqual({ a: { b: {} } }); + + // Dot notation + expect(applyDefaults({ a: { b: 1 } }, { "a.c": 2 })).toEqual({ a: { b: 1 }, "a.c": 2 }); + expect(applyDefaults({ a: { b: { c: 1 } } }, { "a.b": { d: 2 } })).toEqual({ a: { b: { c: 1 } }, "a.b": { c: 1, d: 2 } }); }); // Normalized overrides -export type ProjectConfigNormalizedOverride = yup.InferType; -export type BranchConfigNormalizedOverride = yup.InferType; -export type EnvironmentConfigNormalizedOverride = yup.InferType; -export type OrganizationConfigNormalizedOverride = yup.InferType; - -// Normalized overrides, but only the fields that will NOT be overridden by a future level anymore -export type ProjectConfigStrippedNormalizedOverride = Omit; -export type BranchConfigStrippedNormalizedOverride = Omit; -export type EnvironmentConfigStrippedNormalizedOverride = Omit; -export type OrganizationConfigStrippedNormalizedOverride = OrganizationConfigNormalizedOverride; +// ex.: { a?: { b?: number, c?: string }, d?: number } +export type ProjectConfigNormalizedOverride = DeepPartial>; +export type BranchConfigNormalizedOverride = DeepPartial>; +export type EnvironmentConfigNormalizedOverride = DeepPartial>; +export type OrganizationConfigNormalizedOverride = DeepPartial>; // Overrides +// ex.: { a?: null | { b?: null | number, c: string }, d?: null | number, "a.b"?: number, "a.c"?: string } export type ProjectConfigOverride = NormalizesTo; export type BranchConfigOverride = NormalizesTo; export type EnvironmentConfigOverride = NormalizesTo; export type OrganizationConfigOverride = NormalizesTo; // Override overrides (used to update the overrides) -export type ProjectConfigOverrideOverride = Config & DeepPartial; -export type BranchConfigOverrideOverride = Config & DeepPartial; -export type EnvironmentConfigOverrideOverride = Config & DeepPartial; -export type OrganizationConfigOverrideOverride = Config & DeepPartial; +// ex.: { a?: null | { b?: null | number, c?: string }, d?: null | number, "a.b"?: number, "a.c"?: string } +export type ProjectConfigOverrideOverride = ProjectConfigOverride; +export type BranchConfigOverrideOverride = BranchConfigOverride; +export type EnvironmentConfigOverrideOverride = EnvironmentConfigOverride; +export type OrganizationConfigOverrideOverride = OrganizationConfigOverride; // Incomplete configs +// note that we infer these types from the override types, not from the schema types directly, as there is no guarantee +// that all configs in the DB satisfy the schema (the only guarantee we make is that this once *used* to be true) export type ProjectIncompleteConfig = ApplyDefaults; export type BranchIncompleteConfig = ApplyDefaults; export type EnvironmentIncompleteConfig = ApplyDefaults; @@ -330,3 +455,10 @@ export type EnvironmentRenderedConfig = Omit; export type OrganizationRenderedConfig = OrganizationIncompleteConfig; + + +// Type assertions (just to make sure the types are correct) +const __assertEmptyObjectIsValidProjectOverride: ProjectConfigOverride = {}; +const __assertEmptyObjectIsValidBranchOverride: BranchConfigOverride = {}; +const __assertEmptyObjectIsValidEnvironmentOverride: EnvironmentConfigOverride = {}; +const __assertEmptyObjectIsValidOrganizationOverride: OrganizationConfigOverride = {}; diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 2abeefb6de..17f0d42517 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -1,6 +1,8 @@ import * as yup from "yup"; import { KnownErrors } from "."; import { isBase64 } from "./utils/bytes"; +import { Currency, MoneyAmount } from "./utils/currencies"; +import { DayInterval, Interval } from "./utils/dates"; import { StackAssertionError, throwErr } from "./utils/errors"; import { decodeBasicAuthorizationHeader } from "./utils/http"; import { allProviders } from "./utils/oauth"; @@ -20,7 +22,7 @@ declare module "yup" { getNested>(path: K): yup.Schema[K], TContext, TDefault, TFlags>, // the default types for concat kinda suck, so let's fix that - concat(schema: U): yup.Schema, keyof yup.InferType> & yup.InferType | (TType & (null | undefined)), TContext, TDefault, TFlags>, + concat(schema: U): yup.Schema, keyof yup.InferType> & yup.InferType | (TType & (null | undefined)), TContext, Omit, keyof U['__default']> & U['__default'] | (TDefault & (null | undefined)), TFlags>, } } @@ -138,7 +140,7 @@ export function yupTuple(...args: Parameters< // eslint-disable-next-line no-restricted-syntax return yup.tuple(...args); } -export function yupObject, B extends yup.ObjectShape>(...args: Parameters>) { +export function yupObjectWithAutoDefault, B extends yup.ObjectShape>(...args: Parameters>) { // eslint-disable-next-line no-restricted-syntax const object = yup.object(...args).test( 'no-unknown-object-properties', @@ -161,8 +163,11 @@ export function yupObject, B extends yup.Obje return true; }, ); - + return object; +} +export function yupObject, B extends yup.ObjectShape>(...args: Parameters>) { // we don't want to update the type of `object` to have a default flag + const object = yupObjectWithAutoDefault(...args); return object.default(undefined) as any as typeof object; } @@ -173,14 +178,8 @@ export function yupNever(): yup.MixedSchema { export function yupUnion[]>(...args: T): yup.MixedSchema> { if (args.length === 0) throw new Error('yupUnion must have at least one schema'); - const [first] = args; - const firstDesc = first.describe(); - for (const schema of args) { - const desc = schema.describe(); - if (desc.type !== firstDesc.type) throw new StackAssertionError(`yupUnion must have schemas of the same type (got: ${firstDesc.type} and ${desc.type})`, { first, schema, firstDesc, desc }); - } - return yupMixed().test('is-one-of', 'Invalid value', async (value, context) => { + if (value == null) return true; const errors = []; for (const schema of args) { try { @@ -281,6 +280,26 @@ export const base64Schema = yupString().test("is-base64", (params) => `${params. return isBase64(value); }); export const passwordSchema = yupString().max(70); +export const intervalSchema = yupTuple([yupNumber().min(0).integer().defined(), yupString().oneOf(['millisecond', 'second', 'minute', 'hour', 'day', 'week', 'month', 'year']).defined()]); +export const dayIntervalSchema = yupTuple([yupNumber().min(0).integer().defined(), yupString().oneOf(['day', 'week', 'month', 'year']).defined()]); +export const intervalOrNeverSchema = yupUnion(intervalSchema.defined(), yupString().oneOf(['never']).defined()); +export const dayIntervalOrNeverSchema = yupUnion(dayIntervalSchema.defined(), yupString().oneOf(['never']).defined()); +/** + * This schema is useful for fields where the user can specify the ID, such as price IDs. It is particularly common + * for IDs in the config schema. + */ +export const userSpecifiedIdSchema = (idName: `${string}Id`) => yupString().matches(/^[a-zA-Z_][a-zA-Z0-9_-]*$/); +export const moneyAmountSchema = (currency: Currency) => yupString().test('money-amount', 'Invalid money amount', (value, context) => { + if (value == null) return true; + const regex = /^([0-9]+)(\.([0-9]+))?$/; + const match = value.match(regex); + if (!match) return context.createError({ message: 'Money amount must be in the format of or .' }); + const whole = match[1]; + const decimals = match[3]; + if (decimals && decimals.length > currency.decimals) return context.createError({ message: `Too many decimals; ${currency.code} only has ${currency.decimals} decimals` }); + if (whole !== '0' && whole.startsWith('0')) return context.createError({ message: 'Money amount must not have leading zeros' }); + return true; +}); /** * A stricter email schema that does some additional checks for UX input. (Some emails are allowed by the spec, for @@ -383,6 +402,9 @@ export const emailTemplateListSchema = yupRecord( }) ).meta({ openapiField: { description: 'Record of email template IDs to their display name and source code' } }); +// Payments +export const customerTypeSchema = yupString().oneOf(['user', 'team', 'organization']); + // Users export class ReplaceFieldWithOwnUserId extends Error { constructor(public readonly path: string) { diff --git a/packages/stack-shared/src/utils/currencies.tsx b/packages/stack-shared/src/utils/currencies.tsx new file mode 100644 index 0000000000..d5a122828e --- /dev/null +++ b/packages/stack-shared/src/utils/currencies.tsx @@ -0,0 +1,58 @@ +import { moneyAmountSchema } from "../schema-fields"; +import { StackAssertionError } from "./errors"; + +export type Currency = { + code: Uppercase, + decimals: number, + stripeDecimals: number, +}; + +export type SupportedCurrency = (typeof SUPPORTED_CURRENCIES)[number]; +export const SUPPORTED_CURRENCIES = [ + { + code: 'USD', + decimals: 2, + stripeDecimals: 2, + }, + { + code: 'EUR', + decimals: 2, + stripeDecimals: 2, + }, + { + code: 'GBP', + decimals: 2, + stripeDecimals: 2, + }, + { + code: 'JPY', + decimals: 0, + stripeDecimals: 0, + }, + { + code: 'INR', + decimals: 2, + stripeDecimals: 2, + }, + { + code: 'AUD', + decimals: 2, + stripeDecimals: 2, + }, + { + code: 'CAD', + decimals: 2, + stripeDecimals: 2, + }, +] as const satisfies Currency[]; + +export type MoneyAmount = `${number}` | `${number}.${number}`; + +export function moneyAmountToStripeUnits(amount: MoneyAmount, currency: Currency): number { + const validated = moneyAmountSchema(currency).defined().validateSync(amount); + if (currency.stripeDecimals !== currency.decimals) { + throw new StackAssertionError("unimplemented"); + } + + return Number.parseInt(validated.replace('.', ''), 10); +} diff --git a/packages/stack-shared/src/utils/dates.tsx b/packages/stack-shared/src/utils/dates.tsx index cf7ee10953..49dc0f2987 100644 --- a/packages/stack-shared/src/utils/dates.tsx +++ b/packages/stack-shared/src/utils/dates.tsx @@ -1,3 +1,5 @@ +import { intervalSchema } from "../schema-fields"; +import { StackAssertionError } from "./errors"; import { remainder } from "./math"; export function isWeekend(date: Date): boolean { @@ -138,3 +140,60 @@ import.meta.vitest?.test("getInputDatetimeLocalString", ({ expect }) => { // Restore real timers import.meta.vitest?.vi.useRealTimers(); }); + + +export type Interval = [number, 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year']; +export type DayInterval = [number, 'day' | 'week' | 'month' | 'year']; + +function applyInterval(date: Date, times: number, interval: Interval): Date { + if (!intervalSchema.isValidSync(interval)) { + throw new StackAssertionError(`Invalid interval`, { interval }); + } + const [amount, unit] = interval; + switch (unit) { + case 'millisecond': { + date.setMilliseconds(date.getMilliseconds() + amount * times); + break; + } + case 'second': { + date.setSeconds(date.getSeconds() + amount * times); + break; + } + case 'minute': { + date.setMinutes(date.getMinutes() + amount * times); + break; + } + case 'hour': { + date.setHours(date.getHours() + amount * times); + break; + } + case 'day': { + date.setDate(date.getDate() + amount * times); + break; + } + case 'week': { + date.setDate(date.getDate() + amount * times * 7); + break; + } + case 'month': { + date.setMonth(date.getMonth() + amount * times); + break; + } + case 'year': { + date.setFullYear(date.getFullYear() + amount * times); + break; + } + default: { + throw new StackAssertionError(`Invalid interval despite schema validation`, { interval }); + } + } + return date; +} + +export function subtractInterval(date: Date, interval: Interval): Date { + return applyInterval(date, -1, interval); +} + +export function addInterval(date: Date, interval: Interval): Date { + return applyInterval(date, 1, interval); +} diff --git a/packages/stack-shared/src/utils/objects.tsx b/packages/stack-shared/src/utils/objects.tsx index c36aa7b57c..306137b0e6 100644 --- a/packages/stack-shared/src/utils/objects.tsx +++ b/packages/stack-shared/src/utils/objects.tsx @@ -15,8 +15,10 @@ import.meta.vitest?.test("isNotNull", ({ expect }) => { expect(isNotNull([])).toBe(true); }); -export type DeepPartial = T extends object ? (T extends (infer E)[] ? T : { [P in keyof T]?: DeepPartial }) : T; -export type DeepRequired = T extends object ? (T extends (infer E)[] ? T : { [P in keyof T]-?: DeepRequired }) : T; +export type DeepPartial = T extends object ? (T extends any[] ? { [P in keyof T]: DeepPartial } : { [P in keyof T]?: DeepPartial }) : T; +export type DeepRequired = T extends object ? { [P in keyof T]-?: DeepRequired } : T; +export type DeepRequiredOrUndefined = T extends object ? { [P in keyof { [K in keyof T]-?: K}]: DeepRequiredOrUndefined } : T; + /** * Assumes both objects are primitives, arrays, or non-function plain objects, and compares them deeply. @@ -144,7 +146,7 @@ import.meta.vitest?.test("deepPlainClone", ({ expect }) => { export type DeepMerge = U extends any ? DeepMergeNonDistributive : never; // distributive conditional type https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types type DeepMergeNonDistributive = Omit & Omit & DeepMergeInner, Pick>; type DeepMergeInner = { - [K in keyof U]-?: + [K in { [Ki in keyof U]-?: Ki }[keyof U]]: // we use this weird construct instead of just `keyof U` because TypeScript automatically removes the `undefined` key when using `-?` as a modifier; this is a workaround to make TypeScript not recognize the -? and for us to get the `undefined` key back undefined extends U[K] ? K extends keyof T ? T[K] extends object @@ -255,6 +257,9 @@ import.meta.vitest?.test("deepMerge", ({ expect }) => { expect(() => deepMerge({ a: 1 }, { b: Symbol() })).toThrow(); }); +export type DeepOmit = T extends object ? { [K in keyof T]: K extends keyof U ? (T[K] extends U[K] ? undefined : T[K]) : T[K] } : (T extends U ? undefined : T); +type T = DeepOmit<{a: 1, b: 2}, {a: 1}>; + export function typedEntries(obj: T): [keyof T, T[keyof T]][] { return Object.entries(obj) as any; } diff --git a/packages/stack-shared/src/utils/types.tsx b/packages/stack-shared/src/utils/types.tsx index 33e8bc6797..d66a7183b2 100644 --- a/packages/stack-shared/src/utils/types.tsx +++ b/packages/stack-shared/src/utils/types.tsx @@ -8,6 +8,28 @@ export type NullishCoalesce = T extends null | undefined ? U : T; export type UnionToIntersection = (U extends any ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never +export type IntersectAll = UnionToIntersection; + +export type OptionalKeys = { + [K in keyof T]: {} extends Pick ? K : never; +}[keyof T]; +export type RequiredKeys = { + [K in keyof T]: {} extends Pick ? never : K; +}[keyof T]; + +export type SubtractType = T extends object ? { [K in keyof T]: K extends keyof U ? SubtractType : T[K] } : (T extends U ? never : T); // note: this only works due to the distributive property of conditional types https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types + + +type _AntiIntersectInner = T extends object ? ( + & Omit + & { [K in keyof Pick]: PseudoAntiIntersect } + & { [K in keyof Pick]?: PseudoAntiIntersect } +) : U; +/** + * Returns a type R such that T & R = U. + */ +export type AntiIntersect = U extends T ? _AntiIntersectInner : "Cannot anti-intersect a type with a type that is not a subtype of it"; // NOTE: This type is mostly untested — not sure how well it works on the edge cases +export type PseudoAntiIntersect = _AntiIntersectInner; /** * A variation of TypeScript's conditionals with slightly different semantics. It is the perfect type for cases where: From 83fdba5dc2677366e8c4660107b3ad3b9dc4f700 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 22 Jul 2025 09:39:03 -0700 Subject: [PATCH 02/40] fixes --- packages/stack-shared/src/config/format.ts | 8 -------- packages/stack-shared/src/utils/objects.tsx | 1 - 2 files changed, 9 deletions(-) diff --git a/packages/stack-shared/src/config/format.ts b/packages/stack-shared/src/config/format.ts index f2e43e90fc..4c98f17dcb 100644 --- a/packages/stack-shared/src/config/format.ts +++ b/packages/stack-shared/src/config/format.ts @@ -23,14 +23,6 @@ export type _NormalizesTo = N extends object ? ( ) : N; export type NormalizesTo = _NormalizesTo; - -type T = { - a: 1, - b?: 2, -}; - -type X = T & Record; - /** * Note that a config can both be valid and not normalizable. */ diff --git a/packages/stack-shared/src/utils/objects.tsx b/packages/stack-shared/src/utils/objects.tsx index 306137b0e6..e72b09d033 100644 --- a/packages/stack-shared/src/utils/objects.tsx +++ b/packages/stack-shared/src/utils/objects.tsx @@ -258,7 +258,6 @@ import.meta.vitest?.test("deepMerge", ({ expect }) => { }); export type DeepOmit = T extends object ? { [K in keyof T]: K extends keyof U ? (T[K] extends U[K] ? undefined : T[K]) : T[K] } : (T extends U ? undefined : T); -type T = DeepOmit<{a: 1, b: 2}, {a: 1}>; export function typedEntries(obj: T): [keyof T, T[keyof T]][] { return Object.entries(obj) as any; From 020336e726bd153f83689a589e632719e03e7c53 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 22 Jul 2025 15:39:11 -0700 Subject: [PATCH 03/40] More changes --- .vscode/settings.json | 1 + apps/backend/src/lib/config.tsx | 104 ++++++++--- packages/stack-shared/src/config/schema.ts | 191 +++++++++++++++++++- packages/stack-shared/src/schema-fields.ts | 75 ++++++-- packages/stack-shared/src/utils/strings.tsx | 12 ++ packages/stack-shared/src/utils/types.tsx | 88 ++++++++- 6 files changed, 421 insertions(+), 50 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index af37b3068f..e7a18ea445 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -29,6 +29,7 @@ "EAUTH", "EDNS", "EENVELOPE", + "Elems", "Emailable", "EMESSAGE", "Falsey", diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index aad05fe202..f156b93196 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -1,13 +1,13 @@ import { Prisma } from "@prisma/client"; import { Config, NormalizationError, getInvalidConfigReason, normalize, override } from "@stackframe/stack-shared/dist/config/format"; -import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyDefaults, branchConfigDefaults, branchConfigSchema, environmentConfigDefaults, environmentConfigSchema, organizationConfigDefaults, organizationConfigSchema, projectConfigDefaults, projectConfigSchema } from "@stackframe/stack-shared/dist/config/schema"; +import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyDefaults, assertNoConfigOverrideErrors, branchConfigDefaults, branchConfigSchema, environmentConfigDefaults, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, organizationConfigDefaults, organizationConfigSchema, projectConfigDefaults, projectConfigSchema } from "@stackframe/stack-shared/dist/config/schema"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; 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, typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; -import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import { deindent, stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import * as yup from "yup"; import { PrismaClientTransaction, RawQuery, globalPrismaClient, rawQuery } from "../prisma-client"; import { DEFAULT_BRANCH_ID } from "./tenancies"; @@ -173,6 +173,9 @@ export function getOrganizationConfigOverrideQuery(options: OrganizationOptions) // --------------------------------------------------------------------------------------------------------------------- // Note that the arguments passed in here override the override; they are therefore OverrideOverrides. +// Also, note that the CALLER of these functions is responsible for validating the override, and making sure that +// there are no errors (warnings are allowed, but most UIs should probably ensure there are no warnings before allowing +// a user to save the override). export async function overrideProjectConfigOverride(options: { projectId: string, @@ -187,6 +190,7 @@ export async function overrideProjectConfigOverride(options: { oldConfig, options.projectConfigOverrideOverride, ); + assertNoConfigOverrideErrors(projectConfigSchema, newConfig); await options.tx.project.update({ where: { id: options.projectId, @@ -201,6 +205,7 @@ export function overrideBranchConfigOverride(options: { projectId: string, branchId: string, branchConfigOverrideOverride: BranchConfigOverrideOverride, + tx: PrismaClientTransaction, }): Promise { // update config.json if on local emulator // throw error otherwise @@ -221,6 +226,7 @@ export async function overrideEnvironmentConfigOverride(options: { oldConfig, options.environmentConfigOverrideOverride, ); + assertNoConfigOverrideErrors(environmentConfigSchema, newConfig); await options.tx.environmentConfigOverride.upsert({ where: { projectId_branchId: { @@ -244,6 +250,7 @@ export function overrideOrganizationConfigOverride(options: { branchId: string, organizationId: string | null, organizationConfigOverrideOverride: OrganizationConfigOverrideOverride, + tx: PrismaClientTransaction, }): Promise { // save organization config override on DB (either our own, or the source of truth one) throw new StackAssertionError('Not implemented'); @@ -258,6 +265,7 @@ function getIncompleteProjectConfigQuery(options: ProjectOptions): RawQuery(options: { previous?: RawQuery>, override: RawQuery>, defaults: any }): RawQuery> { +function makeIncompleteConfigQuery(options: { previous?: RawQuery>, override: RawQuery>, defaults: any, schema: yup.AnySchema }): RawQuery> { return RawQuery.then( RawQuery.all([ options.previous ?? RawQuery.resolve(Promise.resolve({})), options.override, ] as const), - async ([prev, over]) => applyDefaults(options.defaults, override(await prev, await over)), + async ([prevPromise, overPromise]) => { + const prev = await prevPromise; + const over = await overPromise; + await assertNoConfigOverrideErrors(options.schema, over); + return applyDefaults(options.defaults, override(prev, over)); + }, ); } @@ -308,8 +324,17 @@ async function validateConfigOverrideSchema(schema: yup.ObjectSchema, base: } async function _validateConfigOverrideSchemaImpl(schema: yup.ObjectSchema, base: any, configOverride: any): Promise> { + // Check config format const reason = getInvalidConfigReason(configOverride, { configName: 'override' }); - if (reason) return Result.error(reason); + if (reason) return Result.error("[FORMAT ERROR]" + reason); + + // Ensure there are no errors in the config override + const errors = await getConfigOverrideErrors(schema, configOverride); + if (errors.status === "error") { + return Result.error("[ERROR] " + errors.error); + } + + // Make sure there are no warnings in the normalized incomplete config const value = override(base, configOverride); let normalizedValue; try { @@ -320,20 +345,11 @@ async function _validateConfigOverrideSchemaImpl(schema: yup.ObjectSchema, } throw error; } - try { - await schema.validate(normalizedValue, { - strict: true, - context: { - noUnknownPathPrefixes: [''], - }, - }); - return Result.ok(null); - } catch (error) { - if (error instanceof yup.ValidationError) { - return Result.error(error.message); - } - throw error; + const warnings = await getIncompleteConfigWarnings(schema, normalizedValue); + if (warnings.status === "error") { + return Result.error("[WARNING] " + warnings.error); } + return Result.ok(null); } import.meta.vitest?.test('schematicallyValidateAndReturn(...)', async ({ expect }) => { @@ -341,21 +357,55 @@ import.meta.vitest?.test('schematicallyValidateAndReturn(...)', async ({ expect a: yupString().optional(), }); + // Base success cases expect(await validateConfigOverrideSchema(schema1, {}, {})).toEqual(Result.ok(null)); expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, {})).toEqual(Result.ok(null)); expect(await validateConfigOverrideSchema(schema1, {}, { a: 'b' })).toEqual(Result.ok(null)); expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, { a: 'c' })).toEqual(Result.ok(null)); expect(await validateConfigOverrideSchema(schema1, {}, { a: null })).toEqual(Result.ok(null)); expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, { a: null })).toEqual(Result.ok(null)); - - expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), {}, { "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).`)); - expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), { a: 'str' }, { "a.b": "c" })).toEqual(Result.error(`Tried to use dot notation to access "a.b", but "a" is not an object.`)); - expect(await validateConfigOverrideSchema(schema1, {}, { a: 123 })).toEqual(Result.error('a must be a `string` type, but the final value was: `123`.')); - - expect(await validateConfigOverrideSchema(projectConfigSchema, {}, {})).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(branchConfigSchema, {}, {})).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {})).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(organizationConfigSchema, {}, {})).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined() }), {}, { a: 'b' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined().oneOf(['b']) }), {}, { a: 'b' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ c: yupString().defined() }).defined() }), { a: {} }, { "a.c": 'd' })).toEqual(Result.ok(null)); + + // Error cases + expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ b: yupObject({ c: yupString().defined() }).defined() }).defined() }), { a: { b: {} } }, { "a.b": { c: 123 } })).toEqual(Result.error("[ERROR] a.b.c must be a `string` type, but the final value was: `123`.")); + expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined().oneOf(['b']) }), {}, { a: 'c' })).toEqual(Result.error("[ERROR] a must be one of the following values: b")); + expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined() }), {}, {})).toEqual(Result.error("[WARNING] a must be defined")); + expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), {}, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), { a: 'str' }, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); + expect(await validateConfigOverrideSchema(schema1, {}, { a: 123 })).toEqual(Result.error('[ERROR] a must be a `string` type, but the final value was: `123`.')); + + // Actual configs — base cases + const projectSchemaBase = applyDefaults(projectConfigDefaults, {}); + expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, {})).toEqual(Result.ok(null)); + const branchSchemaBase = applyDefaults(branchConfigDefaults, projectSchemaBase); + expect(await validateConfigOverrideSchema(branchConfigSchema, branchSchemaBase, {})).toEqual(Result.ok(null)); + const environmentSchemaBase = applyDefaults(environmentConfigDefaults, branchSchemaBase); + expect(await validateConfigOverrideSchema(environmentConfigSchema, environmentSchemaBase, {})).toEqual(Result.ok(null)); + const organizationSchemaBase = applyDefaults(organizationConfigDefaults, environmentSchemaBase); + expect(await validateConfigOverrideSchema(organizationConfigSchema, organizationSchemaBase, {})).toEqual(Result.ok(null)); + + // Actual configs — advanced cases + expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, { + sourceOfTruth: { + type: 'postgres', + connectionString: 'postgres://user:pass@host:port/db', + }, + })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, { + sourceOfTruth: { + type: 'postgres', + }, + })).toEqual(Result.error(deindent` + [WARNING] sourceOfTruth is not matched by any of the provided schemas: + Schema 0: + sourceOfTruth.type must be one of the following values: hosted + Schema 1: + sourceOfTruth.connectionStrings must be defined + Schema 2: + sourceOfTruth.connectionString must be defined + `)); }); // --------------------------------------------------------------------------------------------------------------------- diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 8998cd8a42..53851ac03a 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -1,17 +1,25 @@ import * as yup from "yup"; import { DEFAULT_EMAIL_TEMPLATES, DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_THEME_ID } from "../helpers/emails"; import * as schemaFields from "../schema-fields"; -import { userSpecifiedIdSchema, yupBoolean, yupNumber, yupObject, yupRecord, yupString, yupUnion } from "../schema-fields"; +import { userSpecifiedIdSchema, yupArray, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields"; import { SUPPORTED_CURRENCIES } from "../utils/currencies"; +import { StackAssertionError } from "../utils/errors"; import { allProviders } from "../utils/oauth"; import { DeepMerge, DeepPartial, DeepRequiredOrUndefined, get, has, isObjectLike, mapValues, set, typedFromEntries } from "../utils/objects"; +import { Result } from "../utils/results"; import { IntersectAll } from "../utils/types"; -import { NormalizesTo } from "./format"; +import { NormalizesTo, getInvalidConfigReason } from "./format"; export const configLevels = ['project', 'branch', 'environment', 'organization'] as const; export type ConfigLevel = typeof configLevels[number]; const permissionRegex = /^\$?[a-z0-9_:]+$/; const customPermissionRegex = /^[a-z0-9_:]+$/; +declare module "yup" { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + export interface CustomSchemaMetadata { + stackConfigCanNoLongerBeOverridden?: true, + } +} /** * All fields that can be overridden at this level. @@ -157,7 +165,11 @@ const branchDomain = yupObject({ allowLocalhost: yupBoolean().defined(), }).defined(); -export const branchConfigSchema = projectConfigSchema.omit(['sourceOfTruth']).concat(yupObject({ +export const branchConfigSchema = projectConfigSchema.concat(yupObject({ + sourceOfTruth: projectConfigSchema.getNested("sourceOfTruth").meta({ + stackConfigCanNoLongerBeOverridden: true, + }), + rbac: branchRbacSchema, teams: yupObject({ @@ -412,12 +424,177 @@ import.meta.vitest?.test("applyDefaults", ({ expect }) => { expect(applyDefaults({ a: { b: { c: 1 } } }, { "a.b": { d: 2 } })).toEqual({ a: { b: { c: 1 } }, "a.b": { c: 1, d: 2 } }); }); +/** + * Does not require a base config, and hence solely relies on the override itself to validate the config. If it returns + * no error, you know that the + * + * It's crucial that our DB never contains any configs that are not valid according to this function, as this would mean + * that the config object does not satisfy the ValidatedToHaveNoConfigOverrideErrors type (which is used as an assumption + * in a whole bunch of places in the code). + */ +export async function getConfigOverrideErrors(schema: T, configOverride: unknown, options: { allowPropertiesThatCanNoLongerBeOverridden?: boolean } = {}): Promise> { + // currently, we go over the schema and ensure that the general requirements for each property are satisfied + // importantly, we cannot check any cross-property constraints, as those may change depending on the base config + // also, since overrides can be empty, we cannot have any required properties (TODO: can we have required properties in nested objects? would that even make sense? think about it) + if (typeof configOverride !== "object" || configOverride === null) { + return Result.error("Config override must be a non-null object."); + } + if (Object.getPrototypeOf(configOverride) !== Object.getPrototypeOf({})) { + return Result.error("Config override must be plain old JavaScript object."); + } + // Check config format + const reason = getInvalidConfigReason(configOverride, { configName: 'override' }); + if (reason) return Result.error("Invalid config format: " + reason); + + const getSubSchema = (schema: yup.AnySchema, key: string): yup.AnySchema | undefined => { + const keyParts = key.split("."); + if (!schema.hasNested(keyParts[0])) { + return undefined; + } + const nestedSchema = schema.getNested(keyParts[0]); + if (nestedSchema.meta()?.stackConfigCanNoLongerBeOverridden && !options.allowPropertiesThatCanNoLongerBeOverridden) { + return undefined; + } + if (keyParts.length === 1) { + return nestedSchema; + } else { + return getSubSchema(nestedSchema, keyParts.slice(1).join(".")); + } + }; + + const getRestrictedSchemaBase = (path: string, schema: yup.AnySchema): yup.AnySchema => { + const schemaInfo = schema.meta()?.stackSchemaInfo; + switch (schemaInfo?.type) { + case "string": { + const stringSchema = schema as yup.StringSchema; + return yupString(); + } + case "number": { + return yupNumber(); + } + case "boolean": { + return yupBoolean(); + } + case "date": { + return yupDate(); + } + case "mixed": { + return yupMixed(); + } + case "array": { + const arraySchema = schema as yup.ArraySchema; + const innerType = arraySchema.innerType; + return yupArray(innerType ? getRestrictedSchema(path + ".[]", innerType as any) : undefined); + } + case "tuple": { + return yupTuple(schemaInfo.items.map((s, index) => getRestrictedSchema(path + `[${index}]`, s))); + } + case "union": { + return yupUnion(...schemaInfo.items.map((s, index) => getRestrictedSchema(path + `|${index}|`, s))); + } + case "record": { + return yupRecord(getRestrictedSchema(path + ".key", schemaInfo.keySchema) as any, getRestrictedSchema(path + ".value", schemaInfo.valueSchema)); + } + case "object": { + const objectSchema = schema as yup.ObjectSchema; + return yupObject( + Object.fromEntries( + Object.entries(objectSchema.fields) + .map(([key, value]) => [key, getRestrictedSchema(path + "." + key, value as any)]) + ) + ); + } + case "never": { + return yupNever(); + } + default: { + throw new StackAssertionError(`Unknown schema info at path ${path}: ${JSON.stringify(schemaInfo)}`, { schemaInfo, schema }); + } + } + }; + const getRestrictedSchema = (path: string, schema: yup.AnySchema): yup.AnySchema => { + let restricted = getRestrictedSchemaBase(path, schema); + restricted = restricted.nullable(); + const description = schema.describe(); + if (description.oneOf.length > 0) { + restricted = restricted.oneOf(description.oneOf); + } + if (description.notOneOf.length > 0) { + restricted = restricted.notOneOf(description.notOneOf); + } + return restricted; + }; + + for (const [key, value] of Object.entries(configOverride)) { + if (value === undefined) continue; + const subSchema = getSubSchema(schema, key); + if (!subSchema) { + return Result.error(`The key ${JSON.stringify(key)} is not valid for the schema.`); + } + let restrictedSchema = getRestrictedSchema(key, subSchema); + try { + await restrictedSchema.validate(value, { + strict: true, + ...{ + // Although `path` is not part of the yup types, it is actually recognized and does the correct thing + path: key + }, + context: { + noUnknownPathPrefixes: [''], + }, + }); + } catch (error) { + if (error instanceof yup.ValidationError) { + return Result.error(error.message); + } + throw error; + } + } + return Result.ok(null); +} +export type ValidatedToHaveNoConfigOverrideErrors = {}; +export async function assertNoConfigOverrideErrors(schema: T, config: unknown, options: { allowPropertiesThatCanNoLongerBeOverridden?: boolean } = {}): Promise { + const res = await getConfigOverrideErrors(schema, config, options); + if (res.status === "error") throw new StackAssertionError(`Config override is invalid — at a place where it should have already been validated! ${res.error}`, { config, schema }); +} + +/** + * Checks whether there are any warnings in the incomplete config. A warning doesn't stop the config from being valid, + * but may require action regardless. + * + * The DB can contain configs that are not valid according to this function, as long as they are valid according to + * the getConfigOverrideErrors function. (This is necessary, because a changing base config may make an override invalid + * that was previously valid.) + */ +export async function getIncompleteConfigWarnings(schema: T, incompleteConfig: unknown): Promise> { + await assertNoConfigOverrideErrors(schema, incompleteConfig, { allowPropertiesThatCanNoLongerBeOverridden: true }); + // TODO maybe we should check here whether the config is normalized? just to be safe + // although the schema below should already deal with it in most cases + + try { + await schema.validate(incompleteConfig, { + strict: true, + context: { + noUnknownPathPrefixes: [''], + }, + }); + return Result.ok(null); + } catch (error) { + if (error instanceof yup.ValidationError) { + return Result.error(error.message); + } + throw error; + } +} +export type ValidatedToHaveNoIncompleteConfigWarnings = yup.InferType; + + // Normalized overrides // ex.: { a?: { b?: number, c?: string }, d?: number } -export type ProjectConfigNormalizedOverride = DeepPartial>; -export type BranchConfigNormalizedOverride = DeepPartial>; -export type EnvironmentConfigNormalizedOverride = DeepPartial>; -export type OrganizationConfigNormalizedOverride = DeepPartial>; +export type ProjectConfigNormalizedOverride = DeepPartial>; +export type BranchConfigNormalizedOverride = DeepPartial>; +export type EnvironmentConfigNormalizedOverride = DeepPartial>; +export type OrganizationConfigNormalizedOverride = DeepPartial>; // Overrides // ex.: { a?: null | { b?: null | number, c: string }, d?: null | number, "a.b"?: number, "a.c"?: string } diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index f908c14333..a962f49109 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -19,11 +19,33 @@ declare module "yup" { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Schema { + hasNested>(path: K): boolean, getNested>(path: K): yup.Schema[K], TContext, TDefault, TFlags>, // the default types for concat kinda suck, so let's fix that concat(schema: U): yup.Schema, keyof yup.InferType> & yup.InferType | (TType & (null | undefined)), TContext, Omit, keyof U['__default']> & U['__default'] | (TDefault & (null | undefined)), TFlags>, } + + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + export interface CustomSchemaMetadata { + stackSchemaInfo?: + | { + type: "object" | "array" | "string" | "number" | "boolean" | "date" | "mixed" | "never", + } + | { + type: "tuple", + items: yup.AnySchema[], + } + | { + type: "union", + items: yup.AnySchema[], + } + | { + type: "record", + keySchema: yup.StringSchema, + valueSchema: yup.AnySchema, + }, + } } // eslint-disable-next-line no-restricted-syntax @@ -37,11 +59,32 @@ yup.addMethod(yup.string, "nonEmpty", function (message?: string) { ); }); +yup.addMethod(yup.Schema, "hasNested", function (path: any) { + if (!path.match(/^[a-zA-Z_$:\-][a-zA-Z0-9_$:\-]*$/)) throw new StackAssertionError(`yupSchema.getNested can currently only be used with alphanumeric keys, underscores, dollar signs, colons, and hyphens. Fix this in the future. Provided key: ${path}`); + try { + yup.reach(this, path); + return true as any; + } catch (e) { + if (e instanceof Error && e.message.includes("The schema does not contain the path")) { + return false as any; + } + throw e; + } +}); + yup.addMethod(yup.Schema, "getNested", function (path: any) { - if (!path.match(/^[a-zA-Z_][a-zA-Z0-9_]*$/)) throw new StackAssertionError(`yupSchema.getNested can currently only be used with alphanumeric keys. Fix this in the future. Provided key: ${path}`); + if (!path.match(/^[a-zA-Z_$:\-][a-zA-Z0-9_$:\-]*$/)) throw new StackAssertionError(`yupSchema.getNested can currently only be used with alphanumeric keys, underscores, dollar signs, colons, and hyphens. Fix this in the future. Provided key: ${path}`); return yup.reach(this, path) as any; }); +import.meta.vitest?.test("getNested & hasNested", ({ expect }) => { + expect(yupObject({ a: yupString() }).hasNested("a")).toBe(true); + expect(yupObject({}).hasNested("a" as never)).toBe(false); + expect(yupObject({ a: yupString() }).getNested("a")).toBeDefined(); + expect(() => yupObject({}).getNested("a" as never)).toThrow(); + expect(() => yupObject({ a: yupObject({ b: yupString() }) }).getNested("a.b" as never)).toThrow(); +}); + export async function yupValidate>( schema: S, obj: unknown, @@ -111,34 +154,38 @@ export type StackAdaptSentinel = typeof StackAdaptSentinel; // Built-in replacements export function yupString = yup.AnyObject>(...args: Parameters>) { // eslint-disable-next-line no-restricted-syntax - return yup.string(...args); + return yup.string(...args).meta({ stackSchemaInfo: { type: "string" } }); } export function yupNumber = yup.AnyObject>(...args: Parameters>) { // eslint-disable-next-line no-restricted-syntax - return yup.number(...args); + return yup.number(...args).meta({ stackSchemaInfo: { type: "number" } }); } export function yupBoolean = yup.AnyObject>(...args: Parameters>) { // eslint-disable-next-line no-restricted-syntax - return yup.boolean(...args); + return yup.boolean(...args).meta({ stackSchemaInfo: { type: "boolean" } }); } /** * @deprecated, use number of milliseconds since epoch instead */ export function yupDate = yup.AnyObject>(...args: Parameters>) { // eslint-disable-next-line no-restricted-syntax - return yup.date(...args); + return yup.date(...args).meta({ stackSchemaInfo: { type: "date" } }); } -export function yupMixed(...args: Parameters>) { +function _yupMixedInternal(...args: Parameters>) { // eslint-disable-next-line no-restricted-syntax return yup.mixed(...args); } +export function yupMixed(...args: Parameters>) { + return _yupMixedInternal(...args).meta({ stackSchemaInfo: { type: "mixed" } }); +} export function yupArray = yup.AnyObject, B = any>(...args: Parameters>) { // eslint-disable-next-line no-restricted-syntax - return yup.array(...args); + return yup.array(...args).meta({ stackSchemaInfo: { type: "array" } }); } -export function yupTuple(...args: Parameters>) { +export function yupTuple(schemas: yup.AnySchema[]) { + if (schemas.length === 0) throw new Error('yupTuple must have at least one schema'); // eslint-disable-next-line no-restricted-syntax - return yup.tuple(...args); + return yup.tuple(schemas as any).meta({ stackSchemaInfo: { type: "tuple", items: schemas } }); } export function yupObjectWithAutoDefault, B extends yup.ObjectShape>(...args: Parameters>) { // eslint-disable-next-line no-restricted-syntax @@ -162,7 +209,7 @@ export function yupObjectWithAutoDefault, B e } return true; }, - ); + ).meta({ stackSchemaInfo: { type: "object" } }); return object; } export function yupObject, B extends yup.ObjectShape>(...args: Parameters>) { @@ -172,13 +219,13 @@ export function yupObject, B extends yup.Obje } export function yupNever(): yup.MixedSchema { - return yupMixed().test('never', 'This value should never be reached', () => false) as any; + return _yupMixedInternal().meta({ stackSchemaInfo: { type: "never" } }).test('never', 'This value should never be reached', () => false) as any; } -export function yupUnion[]>(...args: T): yup.MixedSchema> { +export function yupUnion(...args: T): yup.MixedSchema> { if (args.length === 0) throw new Error('yupUnion must have at least one schema'); - return yupMixed().test('is-one-of', 'Invalid value', async (value, context) => { + return _yupMixedInternal().meta({ stackSchemaInfo: { type: "union", items: args } }).test('is-one-of', 'Invalid value', async (value, context) => { if (value == null) return true; const errors = []; for (const schema of args) { @@ -200,7 +247,7 @@ export function yupRecord( keySchema: K, valueSchema: T, ): yup.MixedSchema>> { - return yupObject().unknown(true).test( + return yupObject().meta({ stackSchemaInfo: { type: "record", keySchema, valueSchema } }).unknown(true).test( 'record', '${path} must be a record of valid values', async function (value: unknown, context: yup.TestContext) { diff --git a/packages/stack-shared/src/utils/strings.tsx b/packages/stack-shared/src/utils/strings.tsx index 99f5085b40..c03748c4a2 100644 --- a/packages/stack-shared/src/utils/strings.tsx +++ b/packages/stack-shared/src/utils/strings.tsx @@ -2,6 +2,18 @@ import { findLastIndex, unique } from "./arrays"; import { StackAssertionError } from "./errors"; import { filterUndefined } from "./objects"; +export type Join = + T extends [] ? "" + : T extends [infer U extends string, ...infer Rest extends string[]] + ? `${U}${Rest extends [any, ...any[]] ? `${Separator}${Join}` : ""}` + : ""; + +type T = Join<["a", "b", "c"], ", ">; + +export function typedJoin(strings: T, separator: Separator): Join { + return strings.join(separator) as Join; +} + export function typedToLowercase(s: S): Lowercase { if (typeof s !== "string") throw new StackAssertionError("Expected a string for typedToLowercase", { s }); return s.toLowerCase() as Lowercase; diff --git a/packages/stack-shared/src/utils/types.tsx b/packages/stack-shared/src/utils/types.tsx index d66a7183b2..900d902d86 100644 --- a/packages/stack-shared/src/utils/types.tsx +++ b/packages/stack-shared/src/utils/types.tsx @@ -1,13 +1,27 @@ +import { Join } from "./strings"; + export type IsAny = 0 extends (1 & T) ? true : false; export type IsNever = [T] extends [never] ? true : false; -export type IsNullish = T extends null | undefined ? true : false; +export type IsNullish = [T] extends [null | undefined] ? true : false; +export type IsUnion = + IsNever extends true ? false + : IsAny extends true ? false + : T extends U // distributive conditional https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types + ? /* if the *whole* original type (`U`) still fits inside the current variant, then `T` wasn’t a union */ ([U] extends [T] ? false : true) + : never; export type NullishCoalesce = T extends null | undefined ? U : T; -// distributive conditional type magic. See: https://stackoverflow.com/a/50375286 +export type LastUnionElement = UnionToIntersection 0 : never> extends (x: infer L) => 0 ? L & U : never; + +// why this works: https://stackoverflow.com/a/50375286 export type UnionToIntersection = (U extends any ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never +type _UnionToTupleInner = UnionToTuple, [...R, Last]> +export type UnionToTuple = [U] extends [never] ? R : _UnionToTupleInner>; + + export type IntersectAll = UnionToIntersection; export type OptionalKeys = { @@ -50,3 +64,73 @@ export type IfAndOnlyIf = * Can be used to prettify a type in the IDE; for example, some complicated intersected types can be flattened into a single type. */ export type PrettifyType = T extends object ? { [K in keyof T]: T[K] } & {} : T; + +type _ToStringAndJoin = + T extends [infer U extends string, ...infer Rest extends any[]] + ? `${TypeToString}${Rest extends [any, ...any[]] ? `${Separator}${_ToStringAndJoin}` : ""}` + : ""; +type _TypeToStringInner = + IsAny extends true ? "any" + : IsNever extends true ? "never" + : IsUnion extends true ? _ToStringAndJoin, " | "> + : [T] extends [number] ? (number extends T ? "number" : `${T}`) + : [T] extends [boolean] ? `${T}` + : [T] extends [undefined] ? "undefined" + : [T] extends [null] ? "null" + : [T] extends [string] ? `'${T}'` + : [T] extends [[]] ? "[]" + : [T] extends [[any, ...any[]]] ? `[${_ToStringAndJoin}]` + : [T] extends [(infer E)[]] ? `${TypeToString}[]` + : [T] extends [Function] ? "function" + : [T] extends [symbol] ? `symbol(${T['description']})` + : [T] extends [object] ? `{ ${Join}: ${TypeToString}` }[keyof T]>, ", ">} }` + : "" +export type TypeToString = _TypeToStringInner extends `${infer S}` ? S : never; + +/** + * Can be used to create assertions on types. For example, if passed any T other than `true`, the following will + * show a type error: + * + * ```ts + * typeAssert()(); // the second pair of braces is important! + * ``` + */ +export function typeAssert(): ( + IsAny extends true ? TypeAssertionError<`Type assertion failed. Expected true, but got any.`> + : IsNever extends true ? TypeAssertionError<`Type assertion failed. Expected true, but got never.`> + : T extends true ? (() => undefined) + : TypeAssertionError<`Type assertion failed. Expected true, but got: ${TypeToString}`> +) { + return (() => undefined) as any; +} +type TypeAssertionError = + & [T] + & /* this promise makes sure that if we accidentally forget the second pair of braces, eslint will complain (if we have no-floating-promises enabled) */ Promise; + + +typeAssertExtends>, () => undefined>()(); +typeAssertExtends>, TypeAssertionError<`Type assertion failed. Expected true, but got: false`>>()(); +typeAssertExtends>, TypeAssertionError<`Type assertion failed. Expected true, but got never.`>>()(); +typeAssertExtends>, TypeAssertionError<`Type assertion failed. Expected true, but got any.`>>()(); + +/** + * Functionally equivalent to `typeAssert()()`, but with better error messages. + */ +export function typeAssertExtends(): ( + [T] extends [S] ? (() => undefined) : TypeAssertionError<`Type assertion failed. Expected ${TypeToString} to extend ${TypeToString}`> +) { + return (() => undefined) as any; +} + +typeAssertExtends>, () => undefined>()(); +typeAssertExtends>, () => undefined>()(); +typeAssertExtends>, () => undefined>()(); +typeAssertExtends>, () => undefined>()(); +typeAssertExtends>, () => undefined>()(); +typeAssertExtends>, () => undefined>()(); +typeAssertExtends>, () => undefined>()(); + +typeAssertExtends>, ["Type assertion failed. Expected { 'a': number } to extend { 'a': 1 }"]>()(); +typeAssertExtends>, ["Type assertion failed. Expected any to extend never"]>()(); +typeAssertExtends>, ["Type assertion failed. Expected false to extend true"]>()(); +typeAssertExtends>, ["Type assertion failed. Expected false to extend never"]>()(); From 9e589d92ce9b6f001c6bf1d987dbf950c802b87b Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 23 Jul 2025 00:36:09 -0700 Subject: [PATCH 04/40] Fixes --- apps/backend/src/lib/config.tsx | 56 ++++++++++--- packages/stack-shared/src/config/schema.ts | 88 +++++++++++++++------ packages/stack-shared/src/schema-fields.ts | 61 +++++++++++--- packages/stack-shared/src/utils/objects.tsx | 2 + packages/stack-shared/src/utils/types.tsx | 50 +++++++++++- 5 files changed, 205 insertions(+), 52 deletions(-) diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index f156b93196..26c576e0a9 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client"; import { Config, NormalizationError, getInvalidConfigReason, normalize, override } from "@stackframe/stack-shared/dist/config/format"; import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyDefaults, assertNoConfigOverrideErrors, branchConfigDefaults, branchConfigSchema, environmentConfigDefaults, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, organizationConfigDefaults, organizationConfigSchema, projectConfigDefaults, projectConfigSchema } from "@stackframe/stack-shared/dist/config/schema"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; -import { yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } 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, typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; @@ -266,6 +266,7 @@ function getIncompleteProjectConfigQuery(options: ProjectOptions): RawQuery(options: { previous?: RawQuery>, override: RawQuery>, defaults: any, schema: yup.AnySchema }): RawQuery> { +function makeIncompleteConfigQuery(options: { previous?: RawQuery>, override: RawQuery>, defaults: any, schema: yup.AnySchema, extraInfo: any }): RawQuery> { return RawQuery.then( RawQuery.all([ options.previous ?? RawQuery.resolve(Promise.resolve({})), @@ -305,7 +309,7 @@ function makeIncompleteConfigQuery(options: { previous?: RawQuery { const prev = await prevPromise; const over = await overPromise; - await assertNoConfigOverrideErrors(options.schema, over); + await assertNoConfigOverrideErrors(options.schema, over, { extraInfo: options.extraInfo }); return applyDefaults(options.defaults, override(prev, over)); }, ); @@ -316,14 +320,14 @@ function makeIncompleteConfigQuery(options: { previous?: RawQuery, base: any, configOverride: any): Promise> { +async function validateConfigOverrideSchema(schema: yup.AnySchema, base: any, configOverride: any): Promise> { const mergedResBase = await _validateConfigOverrideSchemaImpl(schema, base, configOverride); if (mergedResBase.status === "error") return mergedResBase; return Result.ok(null); } -async function _validateConfigOverrideSchemaImpl(schema: yup.ObjectSchema, base: any, configOverride: any): Promise> { +async function _validateConfigOverrideSchemaImpl(schema: yup.AnySchema, base: any, configOverride: any): Promise> { // Check config format const reason = getInvalidConfigReason(configOverride, { configName: 'override' }); if (reason) return Result.error("[FORMAT ERROR]" + reason); @@ -352,10 +356,18 @@ async function _validateConfigOverrideSchemaImpl(schema: yup.ObjectSchema, return Result.ok(null); } -import.meta.vitest?.test('schematicallyValidateAndReturn(...)', async ({ expect }) => { +import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expect }) => { const schema1 = yupObject({ a: yupString().optional(), }); + const recordSchema = yupObject({ a: yupRecord(yupString().defined(), yupString().defined()) }).defined(); + const unionSchema = yupObject({ + a: yupUnion( + yupString().defined().oneOf(['never']), + yupObject({ time: yupString().defined().oneOf(['now']) }).defined(), + yupObject({ time: yupString().defined().oneOf(['tomorrow']), morning: yupBoolean().defined() }).defined() + ).defined() + }).defined(); // Base success cases expect(await validateConfigOverrideSchema(schema1, {}, {})).toEqual(Result.ok(null)); @@ -367,6 +379,11 @@ import.meta.vitest?.test('schematicallyValidateAndReturn(...)', async ({ expect expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined() }), {}, { a: 'b' })).toEqual(Result.ok(null)); expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined().oneOf(['b']) }), {}, { a: 'b' })).toEqual(Result.ok(null)); expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ c: yupString().defined() }).defined() }), { a: {} }, { "a.c": 'd' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(recordSchema, { a: {} }, { "a.c": 'd' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(unionSchema, {}, { "a": 'never' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(unionSchema, { a: {} }, { "a": 'never' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(unionSchema, { a: {} }, { "a.time": 'now' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(unionSchema, { a: { "time": "tomorrow" } }, { "a.morning": true })).toEqual(Result.ok(null)); // Error cases expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ b: yupObject({ c: yupString().defined() }).defined() }).defined() }), { a: { b: {} } }, { "a.b": { c: 123 } })).toEqual(Result.error("[ERROR] a.b.c must be a `string` type, but the final value was: `123`.")); @@ -375,6 +392,21 @@ import.meta.vitest?.test('schematicallyValidateAndReturn(...)', async ({ expect expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), {}, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), { a: 'str' }, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); expect(await validateConfigOverrideSchema(schema1, {}, { a: 123 })).toEqual(Result.error('[ERROR] a must be a `string` type, but the final value was: `123`.')); + expect(await validateConfigOverrideSchema(unionSchema, { a: { "time": "now" } }, { "a.morning": true })).toMatchInlineSnapshot(` + { + "error": "[WARNING] a is not matched by any of the provided schemas: + Schema 0: + a must be a \`string\` type, but the final value was: \`{ + "time": "\\"now\\"", + "morning": "true" + }\`. + Schema 1: + a contains unknown properties: morning + Schema 2: + a.time must be one of the following values: tomorrow", + "status": "error", + } + `); // Actual configs — base cases const projectSchemaBase = applyDefaults(projectConfigDefaults, {}); @@ -399,12 +431,12 @@ import.meta.vitest?.test('schematicallyValidateAndReturn(...)', async ({ expect }, })).toEqual(Result.error(deindent` [WARNING] sourceOfTruth is not matched by any of the provided schemas: - Schema 0: - sourceOfTruth.type must be one of the following values: hosted - Schema 1: - sourceOfTruth.connectionStrings must be defined - Schema 2: - sourceOfTruth.connectionString must be defined + Schema 0: + sourceOfTruth.type must be one of the following values: hosted + Schema 1: + sourceOfTruth.connectionStrings must be defined + Schema 2: + sourceOfTruth.connectionString must be defined `)); }); diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 53851ac03a..c57f3d309c 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -1,3 +1,5 @@ +// TODO: rename this file to spaghetti.ts because that's the kind of code here + import * as yup from "yup"; import { DEFAULT_EMAIL_TEMPLATES, DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_THEME_ID } from "../helpers/emails"; import * as schemaFields from "../schema-fields"; @@ -5,9 +7,9 @@ import { userSpecifiedIdSchema, yupArray, yupBoolean, yupDate, yupMixed, yupNeve import { SUPPORTED_CURRENCIES } from "../utils/currencies"; import { StackAssertionError } from "../utils/errors"; import { allProviders } from "../utils/oauth"; -import { DeepMerge, DeepPartial, DeepRequiredOrUndefined, get, has, isObjectLike, mapValues, set, typedFromEntries } from "../utils/objects"; +import { DeepFilterUndefined, DeepMerge, DeepRequiredOrUndefined, get, has, isObjectLike, mapValues, set, typedFromEntries } from "../utils/objects"; import { Result } from "../utils/results"; -import { IntersectAll } from "../utils/types"; +import { CollapseObjectUnion, Expand, IntersectAll, IsUnion, typeAssert, typeAssertExtends, typeAssertIs } from "../utils/types"; import { NormalizesTo, getInvalidConfigReason } from "./format"; export const configLevels = ['project', 'branch', 'environment', 'organization'] as const; @@ -21,6 +23,13 @@ declare module "yup" { } } +function canNoLongerBeOverridden(schema: T, keys: K): yup.Schema, K[number]>, T['__context'], Omit, T['__flags']> { + const notOmitted = schema.concat(yupObject( + Object.fromEntries(keys.map(key => [key, schema.getNested(key).meta({ stackConfigCanNoLongerBeOverridden: true })])) + )); + return notOmitted as any; +} + /** * All fields that can be overridden at this level. */ @@ -165,11 +174,7 @@ const branchDomain = yupObject({ allowLocalhost: yupBoolean().defined(), }).defined(); -export const branchConfigSchema = projectConfigSchema.concat(yupObject({ - sourceOfTruth: projectConfigSchema.getNested("sourceOfTruth").meta({ - stackConfigCanNoLongerBeOverridden: true, - }), - +export const branchConfigSchema = canNoLongerBeOverridden(projectConfigSchema, ["sourceOfTruth"]).concat(yupObject({ rbac: branchRbacSchema, teams: yupObject({ @@ -250,6 +255,8 @@ export const organizationConfigSchema = environmentConfigSchema.concat(yupObject export const projectConfigDefaults = { sourceOfTruth: { type: 'hosted', + connectionStrings: undefined, + connectionString: undefined, }, } satisfies DefaultsType; @@ -377,16 +384,17 @@ export const environmentConfigDefaults = { export const organizationConfigDefaults = {} satisfies DefaultsType; - -type DefaultsType = DeepReplaceAllowFunctionsForObjects, IntersectAll<{ [K in keyof U]: DeepReplaceFunctionsWithObjects }>>>; -type DeepOmitDefaults = T extends object ? ( +type _DeepOmitDefaultsImpl = T extends object ? ( ( & /* keys that are both in T and U, *and* the key's value in U is not a subtype of the key's value in T */ { [K in { [Ki in keyof T & keyof U]: U[Ki] extends T[Ki] ? never : Ki }[keyof T & keyof U]]: DeepOmitDefaults } & /* keys that are in T but not in U */ { [K in Exclude]: T[K] } ) ) : T; +type DeepOmitDefaults = _DeepOmitDefaultsImpl, U>; +type DefaultsType = DeepReplaceAllowFunctionsForObjects, IntersectAll<{ [K in keyof U]: DeepReplaceFunctionsWithObjects }>>>; +typeAssertIs, c: 456 } }, [{ a: { c: 456 } }]>, { a: { b: Record | ((key: string) => 123) } }>()(); -export type DeepReplaceAllowFunctionsForObjects = T extends object ? { [K in keyof T]: DeepReplaceAllowFunctionsForObjects } | (string extends keyof T ? (arg: keyof T) => DeepReplaceAllowFunctionsForObjects : never) : T; +export type DeepReplaceAllowFunctionsForObjects = T extends object ? { [K in keyof T]: DeepReplaceAllowFunctionsForObjects } | (string extends keyof T ? (arg: Exclude) => DeepReplaceAllowFunctionsForObjects : never) : T; export type DeepReplaceFunctionsWithObjects = T extends (arg: infer K extends string) => infer R ? DeepReplaceFunctionsWithObjects> : (T extends object ? { [K in keyof T]: DeepReplaceFunctionsWithObjects } : T); export type ApplyDefaults unknown), C extends object> = DeepMerge, C>; export function applyDefaults unknown), C extends object>(defaults: D, config: C): ApplyDefaults { @@ -487,10 +495,27 @@ export async function getConfigOverrideErrors(schema: T return yupArray(innerType ? getRestrictedSchema(path + ".[]", innerType as any) : undefined); } case "tuple": { - return yupTuple(schemaInfo.items.map((s, index) => getRestrictedSchema(path + `[${index}]`, s))); + return yupTuple(schemaInfo.items.map((s, index) => getRestrictedSchema(path + `[${index}]`, s)) as any); } case "union": { - return yupUnion(...schemaInfo.items.map((s, index) => getRestrictedSchema(path + `|${index}|`, s))); + const schemas = schemaInfo.items; + const nonObjectSchemas = [...schemas.entries()].filter(([index, s]) => s.meta()?.stackSchemaInfo?.type !== "object"); + const objectSchemas = schemas.filter((s): s is yup.ObjectSchema => s.meta()?.stackSchemaInfo?.type === "object"); + + // merge all object schemas into a single schema + const allObjectSchemaKeys = [...new Set(objectSchemas.flatMap(s => Object.keys(s.fields)))]; + const mergedObjectSchema = yupObject( + Object.fromEntries( + allObjectSchemaKeys.map(key => [key, yupUnion( + ...objectSchemas.flatMap((s, index) => s.hasNested(key) ? [s.getNested(key)] : []) + )]) + ) + ); + + return yupUnion( + ...nonObjectSchemas.map(([index, s]) => getRestrictedSchema(path + `|variant-${index}|`, s)), + ...objectSchemas.length > 0 ? [getRestrictedSchema(path + (nonObjectSchemas.length > 0 ? `|variant|` : ""), mergedObjectSchema)] : [], + ); } case "record": { return yupRecord(getRestrictedSchema(path + ".key", schemaInfo.keySchema) as any, getRestrictedSchema(path + ".value", schemaInfo.valueSchema)); @@ -552,11 +577,18 @@ export async function getConfigOverrideErrors(schema: T } return Result.ok(null); } -export type ValidatedToHaveNoConfigOverrideErrors = {}; -export async function assertNoConfigOverrideErrors(schema: T, config: unknown, options: { allowPropertiesThatCanNoLongerBeOverridden?: boolean } = {}): Promise { +export async function assertNoConfigOverrideErrors(schema: T, config: unknown, options: { allowPropertiesThatCanNoLongerBeOverridden?: boolean, extraInfo?: any } = {}): Promise { const res = await getConfigOverrideErrors(schema, config, options); - if (res.status === "error") throw new StackAssertionError(`Config override is invalid — at a place where it should have already been validated! ${res.error}`, { config, schema }); + if (res.status === "error") throw new StackAssertionError(`Config override is invalid — at a place where it should have already been validated! ${res.error}`, { options, config, schema }); } +type _ValidatedToHaveNoConfigOverrideErrorsImpl = + IsUnion extends true ? _ValidatedToHaveNoConfigOverrideErrorsImpl | Exclude> + : T extends object ? (T extends any[] ? T : { [K in keyof T]+?: _ValidatedToHaveNoConfigOverrideErrorsImpl }) + : T; +export type ValidatedToHaveNoConfigOverrideErrors = _ValidatedToHaveNoConfigOverrideErrorsImpl>; +typeAssertIs<_ValidatedToHaveNoConfigOverrideErrorsImpl<{ a: string } | { b: number } | boolean>, { a?: string, b?: number } | boolean>()(); +typeAssertExtends<_ValidatedToHaveNoConfigOverrideErrorsImpl<"abc" | 123 | null>, "abc" | 123 | null>()(); +typeAssertExtends<_ValidatedToHaveNoConfigOverrideErrorsImpl<{ a: { b: { c: string } | { d: number } } }>, { a?: { b?: { c?: string, d?: number } } }>()(); /** * Checks whether there are any warnings in the incomplete config. A warning doesn't stop the config from being valid, @@ -588,13 +620,12 @@ export async function getIncompleteConfigWarnings(schem } export type ValidatedToHaveNoIncompleteConfigWarnings = yup.InferType; - // Normalized overrides // ex.: { a?: { b?: number, c?: string }, d?: number } -export type ProjectConfigNormalizedOverride = DeepPartial>; -export type BranchConfigNormalizedOverride = DeepPartial>; -export type EnvironmentConfigNormalizedOverride = DeepPartial>; -export type OrganizationConfigNormalizedOverride = DeepPartial>; +export type ProjectConfigNormalizedOverride = Expand>; +export type BranchConfigNormalizedOverride = Expand>; +export type EnvironmentConfigNormalizedOverride = Expand>; +export type OrganizationConfigNormalizedOverride = Expand>; // Overrides // ex.: { a?: null | { b?: null | number, c: string }, d?: null | number, "a.b"?: number, "a.c"?: string } @@ -613,10 +644,10 @@ export type OrganizationConfigOverrideOverride = OrganizationConfigOverride; // Incomplete configs // note that we infer these types from the override types, not from the schema types directly, as there is no guarantee // that all configs in the DB satisfy the schema (the only guarantee we make is that this once *used* to be true) -export type ProjectIncompleteConfig = ApplyDefaults; -export type BranchIncompleteConfig = ApplyDefaults; -export type EnvironmentIncompleteConfig = ApplyDefaults; -export type OrganizationIncompleteConfig = ApplyDefaults; +export type ProjectIncompleteConfig = Expand>; +export type BranchIncompleteConfig = Expand>; +export type EnvironmentIncompleteConfig = Expand>; +export type OrganizationIncompleteConfig = Expand>; // Rendered configs export type ProjectRenderedConfig = Omit()(); +typeAssertExtends()(); +typeAssertExtends()(); +typeAssertExtends()(); +typeAssert()(); +typeAssert()(); +typeAssertExtends()(); diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index a962f49109..676f900b9a 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -7,6 +7,7 @@ import { StackAssertionError, throwErr } from "./utils/errors"; import { decodeBasicAuthorizationHeader } from "./utils/http"; import { allProviders } from "./utils/oauth"; import { deepPlainClone, omit } from "./utils/objects"; +import { deindent } from "./utils/strings"; import { isValidUrl } from "./utils/urls"; import { isUuid } from "./utils/uuids"; @@ -60,29 +61,58 @@ yup.addMethod(yup.string, "nonEmpty", function (message?: string) { }); yup.addMethod(yup.Schema, "hasNested", function (path: any) { - if (!path.match(/^[a-zA-Z_$:\-][a-zA-Z0-9_$:\-]*$/)) throw new StackAssertionError(`yupSchema.getNested can currently only be used with alphanumeric keys, underscores, dollar signs, colons, and hyphens. Fix this in the future. Provided key: ${path}`); - try { - yup.reach(this, path); - return true as any; - } catch (e) { - if (e instanceof Error && e.message.includes("The schema does not contain the path")) { - return false as any; + if (!path.match(/^[a-zA-Z_$:\-][a-zA-Z0-9_$:\-]*$/)) throw new StackAssertionError(`yupSchema.hasNested can currently only be used with alphanumeric keys, underscores, dollar signs, colons, and hyphens. Fix this in the future. Provided key: ${path}`); + const schemaInfo = this.meta()?.stackSchemaInfo as any; + if (schemaInfo?.type === "record") { + return schemaInfo.keySchema.isValidSync(path); + } else if (schemaInfo?.type === "union") { + return schemaInfo.items.some((s: any) => s.hasNested(path)); + } else { + try { + yup.reach(this, path); + return true as any; + } catch (e) { + if (e instanceof Error && e.message.includes("The schema does not contain the path")) { + return false as any; + } + throw e; } - throw e; } }); yup.addMethod(yup.Schema, "getNested", function (path: any) { if (!path.match(/^[a-zA-Z_$:\-][a-zA-Z0-9_$:\-]*$/)) throw new StackAssertionError(`yupSchema.getNested can currently only be used with alphanumeric keys, underscores, dollar signs, colons, and hyphens. Fix this in the future. Provided key: ${path}`); - return yup.reach(this, path) as any; + + if (!this.hasNested(path as never)) throw new StackAssertionError(`Tried to call yupSchema.getNested, but key is not present in the schema. Provided key: ${path}`, { path, schema: this }); + + const schemaInfo = this.meta()?.stackSchemaInfo; + if (schemaInfo?.type === "record") { + return schemaInfo.valueSchema; + } else if (schemaInfo?.type === "union") { + const schemasWithNested = schemaInfo.items.filter((s: any) => s.hasNested(path)); + return yupUnion(...schemasWithNested.map(s => s.getNested(path))); + } else { + return yup.reach(this, path) as any; + } }); -import.meta.vitest?.test("getNested & hasNested", ({ expect }) => { +import.meta.vitest?.test("hasNested", ({ expect }) => { expect(yupObject({ a: yupString() }).hasNested("a")).toBe(true); expect(yupObject({}).hasNested("a" as never)).toBe(false); - expect(yupObject({ a: yupString() }).getNested("a")).toBeDefined(); + expect(yupRecord(yupString(), yupString()).hasNested("a")).toBe(true); + expect(yupRecord(yupString().oneOf(["a"]), yupString()).hasNested("b")).toBe(false); + expect(yupUnion(yupString(), yupNumber()).hasNested("a" as any)).toBe(false); + expect(yupUnion(yupString(), yupObject({ b: yupNumber() })).hasNested("a" as never)).toBe(false); + expect(yupUnion(yupString(), yupObject({ a: yupNumber() })).hasNested("a" as never)).toBe(true); +}); +import.meta.vitest?.test("getNested", ({ expect }) => { + expect(yupObject({ a: yupNumber() }).getNested("a").describe().type).toEqual("number"); expect(() => yupObject({}).getNested("a" as never)).toThrow(); expect(() => yupObject({ a: yupObject({ b: yupString() }) }).getNested("a.b" as never)).toThrow(); + expect(yupRecord(yupString().oneOf(["a"]), yupNumber()).getNested("a").describe().type).toEqual("number"); + expect(() => yupRecord(yupString().oneOf(["a"]), yupString()).getNested("b" as never)).toThrow(); + expect(yupUnion(yupString(), yupObject({ a: yupNumber() })).getNested("a" as never).describe().type).toEqual("mixed"); + expect(yupUnion(yupObject({ a: yupString() }), yupObject({ a: yupNumber() })).getNested("a").describe().type).toEqual("mixed"); }); export async function yupValidate>( @@ -182,7 +212,7 @@ export function yupArray = yup.AnyObject, B = // eslint-disable-next-line no-restricted-syntax return yup.array(...args).meta({ stackSchemaInfo: { type: "array" } }); } -export function yupTuple(schemas: yup.AnySchema[]) { +export function yupTuple(schemas: { [K in keyof T]: yup.Schema }) { if (schemas.length === 0) throw new Error('yupTuple must have at least one schema'); // eslint-disable-next-line no-restricted-syntax return yup.tuple(schemas as any).meta({ stackSchemaInfo: { type: "tuple", items: schemas } }); @@ -237,7 +267,12 @@ export function yupUnion(...args: T): yup.MixedSchema } } return context.createError({ - message: `${context.path} is not matched by any of the provided schemas:\n${errors.map((e: any, i) => '\tSchema ' + i + ": \n\t\t" + e.errors.join('\n\t\t')).join('\n')}`, + message: deindent` + ${context.path} is not matched by any of the provided schemas: + ${errors.map((e: any, i) => deindent` + Schema ${i}: + ${e.errors.join('\n')} + `).join('\n')}`, path: context.path, }); }); diff --git a/packages/stack-shared/src/utils/objects.tsx b/packages/stack-shared/src/utils/objects.tsx index e72b09d033..72b75f7555 100644 --- a/packages/stack-shared/src/utils/objects.tsx +++ b/packages/stack-shared/src/utils/objects.tsx @@ -1,6 +1,7 @@ import { StackAssertionError } from "./errors"; import { identity } from "./functions"; import { stringCompare } from "./strings"; +import { typeAssertIs } from "./types"; export function isNotNull(value: T): value is NonNullable { return value !== null && value !== undefined; @@ -396,6 +397,7 @@ import.meta.vitest?.test("filterUndefinedOrNull", ({ expect }) => { }); export type DeepFilterUndefined = T extends object ? FilterUndefined<{ [K in keyof T]: DeepFilterUndefined }> : T; +typeAssertIs, { a: { b: { d?: 123 } } }>()(); export function deepFilterUndefined(obj: T): DeepFilterUndefined { return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined).map(([k, v]) => [k, isObjectLike(v) ? deepFilterUndefined(v) : v])) as any; diff --git a/packages/stack-shared/src/utils/types.tsx b/packages/stack-shared/src/utils/types.tsx index 900d902d86..4e09ca6b47 100644 --- a/packages/stack-shared/src/utils/types.tsx +++ b/packages/stack-shared/src/utils/types.tsx @@ -1,3 +1,4 @@ +import { DeepPartial } from "./objects"; import { Join } from "./strings"; export type IsAny = 0 extends (1 & T) ? true : false; @@ -14,6 +15,17 @@ export type NullishCoalesce = T extends null | undefined ? U : T; export type LastUnionElement = UnionToIntersection 0 : never> extends (x: infer L) => 0 ? L & U : never; +/** + * Makes a type prettier by recursively expanding all object types. For example, `Omit<{ a: 1 }, "a">` becomes just `{}`. + */ +export type Expand = T extends object ? { [K in keyof T]: Expand } : T; + + +/** + * Removes all optional undefined/never keys from an object. + */ +export type DeepRemoveOptionalUndefined = T extends object ? { [K in keyof T]: DeepRemoveOptionalUndefined } : T; + // why this works: https://stackoverflow.com/a/50375286 export type UnionToIntersection = (U extends any ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never @@ -21,6 +33,11 @@ export type UnionToIntersection = type _UnionToTupleInner = UnionToTuple, [...R, Last]> export type UnionToTuple = [U] extends [never] ? R : _UnionToTupleInner>; +export type CollapseObjectUnion = { + [K in AllUnionKeys]?: T extends Record ? V : never; +}; +typeAssertIs, { a?: string, b?: number }>()(); +typeAssertIs, { a?: string | number }>()(); export type IntersectAll = UnionToIntersection; @@ -31,6 +48,12 @@ export type RequiredKeys = { [K in keyof T]: {} extends Pick ? never : K; }[keyof T]; +/** + * Returns ALL keys of all union elements. + */ +export type AllUnionKeys = T extends T ? keyof T : never; +typeAssertIs, "a" | "b">()(); + export type SubtractType = T extends object ? { [K in keyof T]: K extends keyof U ? SubtractType : T[K] } : (T extends U ? never : T); // note: this only works due to the distributive property of conditional types https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types @@ -66,7 +89,7 @@ export type IfAndOnlyIf = export type PrettifyType = T extends object ? { [K in keyof T]: T[K] } & {} : T; type _ToStringAndJoin = - T extends [infer U extends string, ...infer Rest extends any[]] + T extends [infer U, ...infer Rest extends any[]] ? `${TypeToString}${Rest extends [any, ...any[]] ? `${Separator}${_ToStringAndJoin}` : ""}` : ""; type _TypeToStringInner = @@ -77,7 +100,7 @@ type _TypeToStringInner = : [T] extends [boolean] ? `${T}` : [T] extends [undefined] ? "undefined" : [T] extends [null] ? "null" - : [T] extends [string] ? `'${T}'` + : [T] extends [string] ? (string extends T ? "string" : `'${T}'`) : [T] extends [[]] ? "[]" : [T] extends [[any, ...any[]]] ? `[${_ToStringAndJoin}]` : [T] extends [(infer E)[]] ? `${TypeToString}[]` @@ -134,3 +157,26 @@ typeAssertExtends>, ["T typeAssertExtends>, ["Type assertion failed. Expected any to extend never"]>()(); typeAssertExtends>, ["Type assertion failed. Expected false to extend true"]>()(); typeAssertExtends>, ["Type assertion failed. Expected false to extend never"]>()(); + + +export function typeAssertIs(): ( + IsAny extends true ? (IsAny extends true ? (() => undefined) : TypeAssertionError<`Type assertion failed. Expected ${TypeToString} to be ${TypeToString}`>) + : IsAny extends true ? TypeAssertionError<`Type assertion failed. Expected ${TypeToString} to be ${TypeToString}`> + : [T] extends [U] ? ([U] extends [T] ? (() => undefined) : TypeAssertionError<`Type assertion failed. Expected ${TypeToString} to be ${TypeToString}`>) + : TypeAssertionError<`Type assertion failed. Expected ${TypeToString} to be ${TypeToString}`> +) { + return (() => undefined) as any; +} + +typeAssertExtends>, () => undefined>()(); +typeAssertExtends>, () => undefined>()(); +typeAssertExtends, {a?: 1}>>, () => undefined>()(); +typeAssertExtends>, () => undefined>()(); +typeAssertExtends>, () => undefined>()(); +typeAssertExtends>, ["Type assertion failed. Expected 1 to be any"]>()(); +typeAssertExtends>, ["Type assertion failed. Expected any to be 1"]>()(); +typeAssertExtends>, ["Type assertion failed. Expected false to be true"]>()(); +typeAssertExtends>, ["Type assertion failed. Expected { 'a': number } to be { 'a': 1 }"]>()(); +typeAssertExtends>, ["Type assertion failed. Expected any to be never"]>()(); +typeAssertExtends>, ["Type assertion failed. Expected false to be true"]>()(); +typeAssertExtends>, ["Type assertion failed. Expected false to be never"]>()(); From 3a021cca437f2119e110b500e630bdecd6d585dc Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 25 Jul 2025 11:10:38 -0700 Subject: [PATCH 05/40] Fixes --- .vscode/settings.json | 1 + apps/backend/package.json | 2 +- .../api/latest/emails/render-email/route.tsx | 2 +- .../latest/internal/email-themes/route.tsx | 18 -- apps/backend/src/lib/config.tsx | 92 ++++--- packages/stack-shared/src/config/README.md | 4 +- packages/stack-shared/src/config/schema.ts | 228 +++++++++++++----- packages/stack-shared/src/schema-fields.ts | 1 - packages/stack-shared/src/utils/objects.tsx | 4 +- 9 files changed, 228 insertions(+), 124 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index e7a18ea445..797cfe4b8e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -100,6 +100,7 @@ "webauthn", "Whitespaces", "wolfgunblood", + "xact", "zustand" ], "editor.codeActionsOnSave": { diff --git a/apps/backend/package.json b/apps/backend/package.json index fdd3bcf104..6609b5947e 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -18,7 +18,7 @@ "codegen-route-info": "pnpm run with-env tsx scripts/generate-route-info.ts", "codegen-route-info:watch": "pnpm run with-env tsx watch --clear-screen=false scripts/generate-route-info.ts", "codegen": "pnpm run with-env pnpm run generate-migration-imports && pnpm run with-env bash -c 'if [ \"$STACK_ACCELERATE_ENABLED\" = \"true\" ]; then pnpm run prisma generate --no-engine && pnpm run generate-openapi; else pnpm run codegen-prisma && pnpm run generate-openapi; fi' && pnpm run codegen-route-info", - "codegen:watch": "concurrently -n \"prisma,docs,route-info\" -k \"pnpm run codegen-prisma:watch\" \"pnpm run watch-docs\" \"pnpm run codegen-route-info:watch\"", + "codegen:watch": "concurrently -n \"prisma,docs,route-info\" -k \"pnpm run codegen-prisma:watch\" \"pnpm run watch-docs\" \"pnpm run codegen-route-info:watch\" \"pnpm run generate-migration-imports:watch\"", "psql-inner": "psql $STACK_DATABASE_CONNECTION_STRING", "psql": "pnpm run with-env pnpm run psql-inner", "prisma-studio": "pnpm run with-env prisma studio --port 8106 --browser none", diff --git a/apps/backend/src/app/api/latest/emails/render-email/route.tsx b/apps/backend/src/app/api/latest/emails/render-email/route.tsx index 9832e43130..d30bea309e 100644 --- a/apps/backend/src/app/api/latest/emails/render-email/route.tsx +++ b/apps/backend/src/app/api/latest/emails/render-email/route.tsx @@ -2,7 +2,7 @@ import { renderEmailWithTemplate } from "@/lib/email-rendering"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { adaptSchema, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { captureError, StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; export const POST = createSmartRouteHandler({ diff --git a/apps/backend/src/app/api/latest/internal/email-themes/route.tsx b/apps/backend/src/app/api/latest/internal/email-themes/route.tsx index b9be630f4d..a6587530a3 100644 --- a/apps/backend/src/app/api/latest/internal/email-themes/route.tsx +++ b/apps/backend/src/app/api/latest/internal/email-themes/route.tsx @@ -2,7 +2,6 @@ import { overrideEnvironmentConfigOverride } from "@/lib/config"; import { LightEmailTheme } from "@stackframe/stack-shared/dist/helpers/emails"; import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { DEFAULT_EMAIL_THEME_ID } from "@stackframe/stack-shared/dist/helpers/emails"; import { adaptSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; @@ -71,23 +70,6 @@ export const GET = createSmartRouteHandler({ async handler({ auth: { tenancy } }) { const themeList = tenancy.completeConfig.emails.themeList; const currentActiveTheme = tenancy.completeConfig.emails.theme; - if (!(currentActiveTheme in themeList)) { - let newActiveTheme: string; - if (DEFAULT_EMAIL_THEME_ID in themeList) { - newActiveTheme = DEFAULT_EMAIL_THEME_ID; - } else { - newActiveTheme = Object.keys(themeList)[0]; - } - await overrideEnvironmentConfigOverride({ - tx: globalPrismaClient, - projectId: tenancy.project.id, - branchId: tenancy.branchId, - environmentConfigOverrideOverride: { - "emails.theme": newActiveTheme, - }, - }); - } - const themes = Object.entries(themeList).map(([id, theme]) => ({ id, display_name: theme.displayName, diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 26c576e0a9..1cd5f13269 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -1,6 +1,6 @@ import { Prisma } from "@prisma/client"; import { Config, NormalizationError, getInvalidConfigReason, normalize, override } from "@stackframe/stack-shared/dist/config/format"; -import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyDefaults, assertNoConfigOverrideErrors, branchConfigDefaults, branchConfigSchema, environmentConfigDefaults, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, organizationConfigDefaults, organizationConfigSchema, projectConfigDefaults, projectConfigSchema } from "@stackframe/stack-shared/dist/config/schema"; +import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaultsAndSanitize, applyEnvironmentDefaultsAndSanitize, applyOrganizationDefaultsAndSanitize, applyProjectDefaultsAndSanitize, assertNoConfigOverrideErrors, branchConfigDefaults, branchConfigSchema, environmentConfigDefaults, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, organizationConfigDefaults, organizationConfigSchema, projectConfigDefaults, projectConfigSchema } from "@stackframe/stack-shared/dist/config/schema"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; import { isTruthy } from "@stackframe/stack-shared/dist/utils/booleans"; @@ -28,28 +28,28 @@ type OrganizationOptions = EnvironmentOptions & { organizationId: string | null export function getRenderedProjectConfigQuery(options: ProjectOptions): RawQuery> { return RawQuery.then( getIncompleteProjectConfigQuery(options), - async (incompleteConfig) => normalize(await incompleteConfig) as ProjectRenderedConfig, + async (incompleteConfig) => await applyProjectDefaultsAndSanitize(await incompleteConfig), ); } export function getRenderedBranchConfigQuery(options: BranchOptions): RawQuery> { return RawQuery.then( getIncompleteBranchConfigQuery(options), - async (incompleteConfig) => normalize(await incompleteConfig) as BranchRenderedConfig, + async (incompleteConfig) => await applyBranchDefaultsAndSanitize(await incompleteConfig), ); } export function getRenderedEnvironmentConfigQuery(options: EnvironmentOptions): RawQuery> { return RawQuery.then( getIncompleteEnvironmentConfigQuery(options), - async (incompleteConfig) => normalize(await incompleteConfig) as EnvironmentRenderedConfig, + async (incompleteConfig) => await applyEnvironmentDefaultsAndSanitize(await incompleteConfig), ); } export function getRenderedOrganizationConfigQuery(options: OrganizationOptions): RawQuery> { return RawQuery.then( getIncompleteOrganizationConfigQuery(options), - async (incompleteConfig) => normalize(await incompleteConfig) as OrganizationRenderedConfig, + async (incompleteConfig) => await applyOrganizationDefaultsAndSanitize(await incompleteConfig), ); } @@ -190,7 +190,7 @@ export async function overrideProjectConfigOverride(options: { oldConfig, options.projectConfigOverrideOverride, ); - assertNoConfigOverrideErrors(projectConfigSchema, newConfig); + await assertNoConfigOverrideErrors(projectConfigSchema, newConfig); await options.tx.project.update({ where: { id: options.projectId, @@ -226,7 +226,7 @@ export async function overrideEnvironmentConfigOverride(options: { oldConfig, options.environmentConfigOverrideOverride, ); - assertNoConfigOverrideErrors(environmentConfigSchema, newConfig); + await assertNoConfigOverrideErrors(environmentConfigSchema, newConfig); await options.tx.environmentConfigOverride.upsert({ where: { projectId_branchId: { @@ -262,45 +262,57 @@ export function overrideOrganizationConfigOverride(options: { // --------------------------------------------------------------------------------------------------------------------- function getIncompleteProjectConfigQuery(options: ProjectOptions): RawQuery> { - return makeIncompleteConfigQuery({ - override: getProjectConfigOverrideQuery(options), - defaults: projectConfigDefaults, - schema: projectConfigSchema, - extraInfo: options, - }); + return RawQuery.then( + makeUnsanitizedIncompleteConfigQuery({ + override: getProjectConfigOverrideQuery(options), + defaults: projectConfigDefaults, + schema: projectConfigSchema, + extraInfo: options, + }), + async (config) => await applyProjectDefaultsAndSanitize(await config), + ); } function getIncompleteBranchConfigQuery(options: BranchOptions): RawQuery> { - return makeIncompleteConfigQuery({ - previous: getIncompleteProjectConfigQuery(options), - override: getBranchConfigOverrideQuery(options), - defaults: branchConfigDefaults, - schema: branchConfigSchema, - extraInfo: options, - }); + return RawQuery.then( + makeUnsanitizedIncompleteConfigQuery({ + previous: getIncompleteProjectConfigQuery(options), + override: getBranchConfigOverrideQuery(options), + defaults: branchConfigDefaults, + schema: branchConfigSchema, + extraInfo: options, + }), + async (config) => await applyBranchDefaultsAndSanitize(await config), + ); } function getIncompleteEnvironmentConfigQuery(options: EnvironmentOptions): RawQuery> { - return makeIncompleteConfigQuery({ - previous: getIncompleteBranchConfigQuery(options), - override: getEnvironmentConfigOverrideQuery(options), - defaults: environmentConfigDefaults, - schema: environmentConfigSchema, - extraInfo: options, - }); + return RawQuery.then( + makeUnsanitizedIncompleteConfigQuery({ + previous: getIncompleteBranchConfigQuery(options), + override: getEnvironmentConfigOverrideQuery(options), + defaults: environmentConfigDefaults, + schema: environmentConfigSchema, + extraInfo: options, + }), + async (config) => await applyEnvironmentDefaultsAndSanitize(await config), + ); } function getIncompleteOrganizationConfigQuery(options: OrganizationOptions): RawQuery> { - return makeIncompleteConfigQuery({ - previous: getIncompleteEnvironmentConfigQuery(options), - override: getOrganizationConfigOverrideQuery(options), - defaults: organizationConfigDefaults, - schema: organizationConfigSchema, - extraInfo: options, - }); + return RawQuery.then( + makeUnsanitizedIncompleteConfigQuery({ + previous: getIncompleteEnvironmentConfigQuery(options), + override: getOrganizationConfigOverrideQuery(options), + defaults: organizationConfigDefaults, + schema: organizationConfigSchema, + extraInfo: options, + }), + async (config) => await applyOrganizationDefaultsAndSanitize(await config), + ); } -function makeIncompleteConfigQuery(options: { previous?: RawQuery>, override: RawQuery>, defaults: any, schema: yup.AnySchema, extraInfo: any }): RawQuery> { +function makeUnsanitizedIncompleteConfigQuery(options: { previous?: RawQuery>, override: RawQuery>, defaults: any, schema: yup.AnySchema, extraInfo: any }): RawQuery> { return RawQuery.then( RawQuery.all([ options.previous ?? RawQuery.resolve(Promise.resolve({})), @@ -310,7 +322,7 @@ function makeIncompleteConfigQuery(options: { previous?: RawQuery branch -> environment -> organization) -- `$Level` incomplete config: The base config overridden with the overrides up to level `$Level`, deeply merged into `configDefaults` -- `$Level` rendered config: A normalized incomplete config with those fields removed that can be overridden by a future override +- `$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, deeply merged into the defaults and sanitized (using `apply{$Level}DefaultsAndSanitize`) - Complete config: The organization rendered config. - `$Level` config override override: An override that overrides the `$Level` config override. This is most often used eg. in the REST API to let users make changes to the branch-level config, without overwriting the entire branch-level config override. *Note that, since config overrides (unlike configs) distinguish between `null` and a property missing (`undefined`), it is currently not possible to say "this property in the config override should be unset" (setting a property to `null` in the override override will simply also set it to `null` in the override). In the future, we'll have to think about how we handle this, probably with a sentinel value.* - `$Level` config: Could refer to any of the above, depending on the context; if it's not clear, specify it. diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index c57f3d309c..eda39229ce 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -3,11 +3,11 @@ import * as yup from "yup"; import { DEFAULT_EMAIL_TEMPLATES, DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_THEME_ID } from "../helpers/emails"; import * as schemaFields from "../schema-fields"; -import { userSpecifiedIdSchema, yupArray, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields"; +import { userSpecifiedIdSchema, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields"; import { SUPPORTED_CURRENCIES } from "../utils/currencies"; import { StackAssertionError } from "../utils/errors"; import { allProviders } from "../utils/oauth"; -import { DeepFilterUndefined, DeepMerge, DeepRequiredOrUndefined, get, has, isObjectLike, mapValues, set, typedFromEntries } from "../utils/objects"; +import { DeepFilterUndefined, DeepMerge, DeepRequiredOrUndefined, filterUndefined, get, has, isObjectLike, mapValues, set, typedFromEntries } from "../utils/objects"; import { Result } from "../utils/results"; import { CollapseObjectUnion, Expand, IntersectAll, IsUnion, typeAssert, typeAssertExtends, typeAssertIs } from "../utils/types"; import { NormalizesTo, getInvalidConfigReason } from "./format"; @@ -258,9 +258,14 @@ export const projectConfigDefaults = { connectionStrings: undefined, connectionString: undefined, }, -} satisfies DefaultsType; +} satisfies DefaultsType; + +export const branchConfigDefaults = {} as const satisfies DefaultsType; + +export const environmentConfigDefaults = {} as const satisfies DefaultsType; + +export const organizationConfigDefaults = { -export const branchConfigDefaults = { rbac: { permissions: (key: string) => ({ containedPermissionIds: {}, @@ -292,6 +297,10 @@ export const branchConfigDefaults = { domains: { allowLocalhost: false, + trustedDomains: (key: string) => ({ + baseUrl: undefined, + handlerPath: '/handler', + }), }, auth: { @@ -312,14 +321,34 @@ export const branchConfigDefaults = { isShared: true, allowSignIn: false, allowConnectedAccounts: false, + clientId: undefined, + clientSecret: undefined, + facebookConfigId: undefined, + microsoftTenantId: undefined, }), }, }, emails: { + server: { + isShared: true, + host: undefined, + port: undefined, + username: undefined, + password: undefined, + senderName: undefined, + senderEmail: undefined, + }, theme: DEFAULT_EMAIL_THEME_ID, - themeList: DEFAULT_EMAIL_THEMES, - templateList: DEFAULT_EMAIL_TEMPLATES, + themeList: (key: string) => ({ + displayName: "Unnamed Theme", + tsxSource: "Error: Theme config is missing TypeScript source code.", + }), + templateList: (key: string) => ({ + displayName: "Unnamed Template", + subject: "", + tsxSource: "Error: Template config is missing TypeScript source code.", + }), }, payments: { @@ -344,45 +373,7 @@ export const branchConfigDefaults = { }), items: {}, }, -} as const satisfies DefaultsType; - -export const environmentConfigDefaults = { - domains: { - trustedDomains: (key: string) => ({ - baseUrl: undefined, - handlerPath: '/handler', - }), - }, - - emails: { - server: { - isShared: true, - host: undefined, - port: undefined, - username: undefined, - password: undefined, - senderName: undefined, - senderEmail: undefined, - }, - }, - - auth: { - oauth: { - providers: (key: string) => ({ - type: undefined, - isShared: true, - allowSignIn: false, - allowConnectedAccounts: false, - clientId: undefined, - clientSecret: undefined, - facebookConfigId: undefined, - microsoftTenantId: undefined, - }), - }, - }, -} as const satisfies DefaultsType; - -export const organizationConfigDefaults = {} satisfies DefaultsType; +} satisfies DefaultsType; type _DeepOmitDefaultsImpl = T extends object ? ( ( @@ -396,7 +387,7 @@ typeAssertIs, c: 456 } }, [{ a: { c: export type DeepReplaceAllowFunctionsForObjects = T extends object ? { [K in keyof T]: DeepReplaceAllowFunctionsForObjects } | (string extends keyof T ? (arg: Exclude) => DeepReplaceAllowFunctionsForObjects : never) : T; export type DeepReplaceFunctionsWithObjects = T extends (arg: infer K extends string) => infer R ? DeepReplaceFunctionsWithObjects> : (T extends object ? { [K in keyof T]: DeepReplaceFunctionsWithObjects } : T); -export type ApplyDefaults unknown), C extends object> = DeepMerge, C>; +export type ApplyDefaults unknown), C extends object> = Expand, C>>; export function applyDefaults unknown), C extends object>(defaults: D, config: C): ApplyDefaults { const res: any = typeof defaults === 'function' ? {} : mapValues(defaults, v => typeof v === 'function' ? {} : (typeof v === 'object' ? applyDefaults(v as any, {}) : v)); outer: for (const [key, mergeValue] of Object.entries(config)) { @@ -432,6 +423,110 @@ import.meta.vitest?.test("applyDefaults", ({ expect }) => { expect(applyDefaults({ a: { b: { c: 1 } } }, { "a.b": { d: 2 } })).toEqual({ a: { b: { c: 1 } }, "a.b": { c: 1, d: 2 } }); }); +export async function applyProjectDefaultsAndSanitize(config: T) { + const withDefaults = applyDefaults(projectConfigDefaults, config); + + const oldSourceOfTruth = withDefaults.sourceOfTruth; + const sourceOfTruth = + oldSourceOfTruth.type === 'neon' && typeof oldSourceOfTruth.connectionStrings === 'object' ? { + type: 'neon', + connectionStrings: { ...filterUndefined(oldSourceOfTruth.connectionStrings) as Record } + } as const + : oldSourceOfTruth.type === 'postgres' && typeof oldSourceOfTruth.connectionString === 'string' ? { + type: 'postgres', + connectionString: oldSourceOfTruth.connectionString, + } as const + : { + type: 'hosted', + } as const; + + return { + ...withDefaults, + sourceOfTruth, + }; +} + +export async function applyBranchDefaultsAndSanitize(config: T) { + const withPrevious = await applyProjectDefaultsAndSanitize(config); + const withDefaults = applyDefaults(branchConfigDefaults, withPrevious); + + return { + ...withDefaults, + }; +} + +export async function applyEnvironmentDefaultsAndSanitize(config: T) { + // ------------------------------------------------------------------------------------------------------------------- + // IMPORTANT NOTE: when updating this function, make sure to also update the __typeHack__ versions of it below + // ------------------------------------------------------------------------------------------------------------------- + + const withPrevious = await applyBranchDefaultsAndSanitize(config); + const withDefaults = applyDefaults(environmentConfigDefaults, withPrevious); + + return { + ...withDefaults, + }; +} +// TODO HACK: Because Expand<> can't expand generics, the type of the return values of some of these functions become +// too deep and makes the type checker complain about "type instantiation too deep". +// +// So, here are two 1:1 copies of the function above, but with specific instantiations of the generic type parameter, +// allowing Expand<> to expand the return type, which makes the type checker happy. +// +// It's dumb. We should definitely find a better way to do this. This is really hacky. +export async function __typeHack__applyEnvironmentDefaultsAndSanitize_EnvironmentRenderedConfigBeforeSanitization(config: EnvironmentRenderedConfigBeforeSanitization) { + // ------------------------------------------------------------------------------------------------------------------- + // IMPORTANT NOTE: this is a copy of the generic function above, edit that first + // ------------------------------------------------------------------------------------------------------------------- + + const withPrevious = await applyBranchDefaultsAndSanitize(config); + const withDefaults = applyDefaults(environmentConfigDefaults, withPrevious); + + return { + ...withDefaults, + }; +} +// TODO HACK: Because Expand<> can't expand generics, the type of the return values of some of these functions become +// too deep and makes the type checker complain about "type instantiation too deep". +// +// So, here is a 1:1 copy of the function above, but without the generic type parameter, allowing Expand<> to expand +// the return type, which makes the type checker happy. +// +// It's dumb. We should definitely find a better way to do this. This is really hacky. +export async function __typeHack__applyEnvironmentDefaultsAndSanitize_OrganizationRenderedConfigBeforeSanitization(config: OrganizationRenderedConfigBeforeSanitization) { + // ------------------------------------------------------------------------------------------------------------------- + // IMPORTANT NOTE: this is a copy of the generic function above, edit that first + // ------------------------------------------------------------------------------------------------------------------- + + const withPrevious = await applyBranchDefaultsAndSanitize(config); + const withDefaults = applyDefaults(environmentConfigDefaults, withPrevious); + + return { + ...withDefaults, + }; +} + +export async function applyOrganizationDefaultsAndSanitize(config: T) { + const withPrevious = await __typeHack__applyEnvironmentDefaultsAndSanitize_OrganizationRenderedConfigBeforeSanitization(config); + const withDefaults = applyDefaults(organizationConfigDefaults, withPrevious); + + return { + ...withDefaults, + emails: { + ...withDefaults.emails, + theme: has(withDefaults.emails.themeList, withDefaults.emails.theme) ? withDefaults.emails.theme : DEFAULT_EMAIL_THEME_ID, + themeList: { + ...DEFAULT_EMAIL_THEMES, + ...withDefaults.emails.themeList, + } as typeof withDefaults.emails.themeList, + templateList: { + ...DEFAULT_EMAIL_TEMPLATES, + ...withDefaults.emails.templateList, + } as typeof withDefaults.emails.templateList, + }, + }; +} + /** * Does not require a base config, and hence solely relies on the override itself to validate the config. If it returns * no error, you know that the @@ -475,7 +570,12 @@ export async function getConfigOverrideErrors(schema: T switch (schemaInfo?.type) { case "string": { const stringSchema = schema as yup.StringSchema; - return yupString(); + const description = stringSchema.describe(); + let res = yupString(); + if (description.tests.some(t => t.name === "uuid")) { + res = res.uuid(); + } + return res; } case "number": { return yupNumber(); @@ -490,9 +590,12 @@ export async function getConfigOverrideErrors(schema: T return yupMixed(); } case "array": { - const arraySchema = schema as yup.ArraySchema; - const innerType = arraySchema.innerType; - return yupArray(innerType ? getRestrictedSchema(path + ".[]", innerType as any) : undefined); + throw new StackAssertionError(`Arrays are not supported in config JSON files (besides tuples). Use a record instead.`, { schemaInfo, schema }); + + // This is how the implementation would look like, but we don't support arrays in config JSON files (besides tuples) + // const arraySchema = schema as yup.ArraySchema; + // const innerType = arraySchema.innerType; + // return yupArray(innerType ? getRestrictedSchema(path + ".[]", innerType as any) : undefined); } case "tuple": { return yupTuple(schemaInfo.items.map((s, index) => getRestrictedSchema(path + `[${index}]`, s)) as any); @@ -644,25 +747,32 @@ export type OrganizationConfigOverrideOverride = OrganizationConfigOverride; // Incomplete configs // note that we infer these types from the override types, not from the schema types directly, as there is no guarantee // that all configs in the DB satisfy the schema (the only guarantee we make is that this once *used* to be true) -export type ProjectIncompleteConfig = Expand>; -export type BranchIncompleteConfig = Expand>; -export type EnvironmentIncompleteConfig = Expand>; -export type OrganizationIncompleteConfig = Expand>; +export type ProjectIncompleteConfig = Expand; +export type BranchIncompleteConfig = Expand; +export type EnvironmentIncompleteConfig = Expand; +export type OrganizationIncompleteConfig = Expand; -// Rendered configs -export type ProjectRenderedConfig = Omit; -export type BranchRenderedConfig = Omit; -export type EnvironmentRenderedConfig = Omit; -export type OrganizationRenderedConfig = OrganizationIncompleteConfig; +export type OrganizationRenderedConfigBeforeSanitization = OrganizationIncompleteConfig; + +// Rendered configs + +export type ProjectRenderedConfig = Expand>>>; +export type BranchRenderedConfig = Expand>>>; +export type EnvironmentRenderedConfig = Expand>>; +export type OrganizationRenderedConfig = Expand>>>; // Type assertions (just to make sure the types are correct) diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 514f2312cf..16910f6826 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -478,7 +478,6 @@ export const emailTemplateListSchema = yupRecord( yupObject({ displayName: yupString().meta({ openapiField: { description: 'Email template name', exampleValue: 'Email Verification' } }).defined(), description: yupString().meta({ openapiField: { description: 'Email template description', exampleValue: 'Will be sent to the user when they sign-up with email/password' } }).defined(), - variables: yupArray(yupString().defined()).meta({ openapiField: { description: 'Email template variables' } }).defined(), subject: yupString().meta({ openapiField: { description: 'Email template subject', exampleValue: 'Verify your email' } }).defined(), tsxSource: yupString().meta({ openapiField: { description: 'Email template source code tsx component' } }).defined(), }) diff --git a/packages/stack-shared/src/utils/objects.tsx b/packages/stack-shared/src/utils/objects.tsx index 72b75f7555..d4f559ac8a 100644 --- a/packages/stack-shared/src/utils/objects.tsx +++ b/packages/stack-shared/src/utils/objects.tsx @@ -260,7 +260,7 @@ import.meta.vitest?.test("deepMerge", ({ expect }) => { export type DeepOmit = T extends object ? { [K in keyof T]: K extends keyof U ? (T[K] extends U[K] ? undefined : T[K]) : T[K] } : (T extends U ? undefined : T); -export function typedEntries(obj: T): [keyof T, T[keyof T]][] { +export function typedEntries(obj: T): [Exclude, T[keyof T]][] { return Object.entries(obj) as any; } import.meta.vitest?.test("typedEntries", ({ expect }) => { @@ -301,7 +301,7 @@ import.meta.vitest?.test("typedFromEntries", ({ expect }) => { expect((obj.b as () => string)()).toBe("test"); }); -export function typedKeys(obj: T): (keyof T)[] { +export function typedKeys(obj: T): (Exclude)[] { return Object.keys(obj) as any; } import.meta.vitest?.test("typedKeys", ({ expect }) => { From cad3b3992dc271d85522cda3a550223ce4fb4723 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 25 Jul 2025 12:31:31 -0700 Subject: [PATCH 06/40] Fixes --- apps/backend/src/lib/projects.tsx | 15 +++++++++------ packages/stack-shared/src/config/schema.ts | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 58c8f951c7..36bdd3dfb9 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -165,12 +165,15 @@ export async function createOrUpdateProject( 'teams.createPersonalTeamOnSignUp': dataOptions.create_team_on_sign_up, // ======================= domains ======================= 'domains.allowLocalhost': dataOptions.allow_localhost ?? true, - 'domains.trustedDomains': dataOptions.domains ? dataOptions.domains.map((domain) => { - return { - baseUrl: domain.domain, - handlerPath: domain.handler_path, - } satisfies OrganizationRenderedConfig['domains']['trustedDomains'][string]; - }) : undefined, + 'domains.trustedDomains': dataOptions.domains ? typedFromEntries(dataOptions.domains.map((domain) => { + return [ + generateUuid(), + { + baseUrl: domain.domain, + handlerPath: domain.handler_path, + } satisfies OrganizationRenderedConfig['domains']['trustedDomains'][string], + ]; + })) : undefined, // ======================= api keys ======================= 'apiKeys.enabled.user': dataOptions.allow_user_api_keys, 'apiKeys.enabled.team': dataOptions.allow_team_api_keys, diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index cd1d0631cf..7936f5b853 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -388,7 +388,7 @@ export type DeepReplaceAllowFunctionsForObjects = T extends object ? { [K in export type DeepReplaceFunctionsWithObjects = T extends (arg: infer K extends string) => infer R ? DeepReplaceFunctionsWithObjects> : (T extends object ? { [K in keyof T]: DeepReplaceFunctionsWithObjects } : T); export type ApplyDefaults unknown), C extends object> = Expand, C>>; export function applyDefaults unknown), C extends object>(defaults: D, config: C): ApplyDefaults { - const res: any = typeof defaults === 'function' ? {} : mapValues(defaults, v => typeof v === 'function' ? {} : (typeof v === 'object' ? applyDefaults(v as any, {}) : v)); + const res: any = typeof defaults === 'function' ? {} : mapValues(defaults, v => typeof v === 'function' ? {} : (typeof v === 'object' && v ? applyDefaults(v as any, {}) : v)); outer: for (const [key, mergeValue] of Object.entries(config)) { if (mergeValue === undefined) continue; const keyParts = key.split("."); From 57177be26b2329cfd7e1ca8d589a23567a198332 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 25 Jul 2025 16:46:57 -0700 Subject: [PATCH 07/40] TypeScript mess --- apps/backend/src/lib/config.tsx | 159 ++++++++++------ packages/stack-shared/src/config/README.md | 2 +- packages/stack-shared/src/config/format.ts | 17 +- packages/stack-shared/src/config/schema.ts | 197 ++++++++++---------- packages/stack-shared/src/helpers/emails.ts | 10 +- packages/stack-shared/src/schema-fields.ts | 6 +- 6 files changed, 228 insertions(+), 163 deletions(-) diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 853597bd1f..6e98b266e6 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -1,6 +1,6 @@ import { Prisma } from "@prisma/client"; import { Config, NormalizationError, getInvalidConfigReason, normalize, override } from "@stackframe/stack-shared/dist/config/format"; -import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaultsAndSanitize, applyEnvironmentDefaultsAndSanitize, applyOrganizationDefaultsAndSanitize, applyProjectDefaultsAndSanitize, assertNoConfigOverrideErrors, branchConfigDefaults, branchConfigSchema, environmentConfigDefaults, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, organizationConfigDefaults, organizationConfigSchema, projectConfigDefaults, projectConfigSchema } from "@stackframe/stack-shared/dist/config/schema"; +import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaults, applyEnvironmentDefaults, applyOrganizationDefaults, applyProjectDefaults, assertNoConfigOverrideErrors, branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, getRenderedConfigWarnings, organizationConfigSchema, projectConfigSchema, sanitizeBranchConfig, sanitizeEnvironmentConfig, sanitizeOrganizationConfig, sanitizeProjectConfig } from "@stackframe/stack-shared/dist/config/schema"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; import { isTruthy } from "@stackframe/stack-shared/dist/utils/booleans"; @@ -28,28 +28,28 @@ type OrganizationOptions = EnvironmentOptions & { organizationId: string | null export function getRenderedProjectConfigQuery(options: ProjectOptions): RawQuery> { return RawQuery.then( getIncompleteProjectConfigQuery(options), - async (incompleteConfig) => await applyProjectDefaultsAndSanitize(await incompleteConfig), + async (incompleteConfig) => sanitizeProjectConfig(normalize(await applyProjectDefaults(await incompleteConfig), { onDotIntoNonObject: "ignore" }) as any), ); } export function getRenderedBranchConfigQuery(options: BranchOptions): RawQuery> { return RawQuery.then( getIncompleteBranchConfigQuery(options), - async (incompleteConfig) => await applyBranchDefaultsAndSanitize(await incompleteConfig), + async (incompleteConfig) => sanitizeBranchConfig(normalize(await applyBranchDefaults(await incompleteConfig), { onDotIntoNonObject: "ignore" }) as any), ); } export function getRenderedEnvironmentConfigQuery(options: EnvironmentOptions): RawQuery> { return RawQuery.then( getIncompleteEnvironmentConfigQuery(options), - async (incompleteConfig) => await applyEnvironmentDefaultsAndSanitize(await incompleteConfig), + async (incompleteConfig) => sanitizeEnvironmentConfig(normalize(await applyEnvironmentDefaults(await incompleteConfig), { onDotIntoNonObject: "ignore" }) as any), ); } export function getRenderedOrganizationConfigQuery(options: OrganizationOptions): RawQuery> { return RawQuery.then( getIncompleteOrganizationConfigQuery(options), - async (incompleteConfig) => await applyOrganizationDefaultsAndSanitize(await incompleteConfig), + async (incompleteConfig) => sanitizeOrganizationConfig(normalize(await applyOrganizationDefaults(await incompleteConfig), { onDotIntoNonObject: "ignore" }) as any), ); } @@ -62,14 +62,26 @@ export function getRenderedOrganizationConfigQuery(options: OrganizationOptions) * Validates a project config override ([sanity-check valid](./README.md)). */ export async function validateProjectConfigOverride(options: { projectConfigOverride: ProjectConfigOverride }): Promise> { - return await validateConfigOverrideSchema(projectConfigSchema, {}, options.projectConfigOverride); + return await validateConfigOverrideSchema( + projectConfigSchema, + {}, + options.projectConfigOverride, + applyProjectDefaults, + sanitizeProjectConfig, + ); } /** * Validates a branch config override ([sanity-check valid](./README.md)), based on the given project's rendered project config. */ export async function validateBranchConfigOverride(options: { branchConfigOverride: BranchConfigOverride } & ProjectOptions): Promise> { - return await validateConfigOverrideSchema(branchConfigSchema, await rawQuery(globalPrismaClient, getIncompleteProjectConfigQuery(options)), options.branchConfigOverride); + return await validateConfigOverrideSchema( + branchConfigSchema, + await rawQuery(globalPrismaClient, getIncompleteProjectConfigQuery(options)), + options.branchConfigOverride, + applyBranchDefaults, + sanitizeBranchConfig, + ); // 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) } @@ -78,7 +90,13 @@ export async function validateBranchConfigOverride(options: { branchConfigOverri * Validates an environment config override ([sanity-check valid](./README.md)), based on the given branch's rendered branch config. */ export async function validateEnvironmentConfigOverride(options: { environmentConfigOverride: EnvironmentConfigOverride } & BranchOptions): Promise> { - return await validateConfigOverrideSchema(environmentConfigSchema, await rawQuery(globalPrismaClient, getIncompleteBranchConfigQuery(options)), options.environmentConfigOverride); + return await validateConfigOverrideSchema( + environmentConfigSchema, + await rawQuery(globalPrismaClient, getIncompleteBranchConfigQuery(options)), + options.environmentConfigOverride, + applyEnvironmentDefaults, + sanitizeEnvironmentConfig, + ); // 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) } @@ -87,7 +105,13 @@ export async function validateEnvironmentConfigOverride(options: { environmentCo * Validates an organization config override ([sanity-check valid](./README.md)), based on the given environment's rendered environment config. */ export async function validateOrganizationConfigOverride(options: { organizationConfigOverride: OrganizationConfigOverride } & EnvironmentOptions): Promise> { - return await validateConfigOverrideSchema(organizationConfigSchema, await rawQuery(globalPrismaClient, getIncompleteEnvironmentConfigQuery(options)), options.organizationConfigOverride); + return await validateConfigOverrideSchema( + organizationConfigSchema, + await rawQuery(globalPrismaClient, getIncompleteEnvironmentConfigQuery(options)), + options.organizationConfigOverride, + applyOrganizationDefaults, + sanitizeOrganizationConfig, + ); // 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) } @@ -265,11 +289,10 @@ function getIncompleteProjectConfigQuery(options: ProjectOptions): RawQuery await applyProjectDefaultsAndSanitize(await config), + async (config) => await config, ); } @@ -278,11 +301,10 @@ function getIncompleteBranchConfigQuery(options: BranchOptions): RawQuery await applyBranchDefaultsAndSanitize(await config), + async (config) => await config, ); } @@ -291,11 +313,10 @@ function getIncompleteEnvironmentConfigQuery(options: EnvironmentOptions): RawQu makeUnsanitizedIncompleteConfigQuery({ previous: getIncompleteBranchConfigQuery(options), override: getEnvironmentConfigOverrideQuery(options), - defaults: environmentConfigDefaults, schema: environmentConfigSchema, extraInfo: options, }), - async (config) => await applyEnvironmentDefaultsAndSanitize(await config), + async (config) => await config, ); } @@ -304,15 +325,14 @@ function getIncompleteOrganizationConfigQuery(options: OrganizationOptions): Raw makeUnsanitizedIncompleteConfigQuery({ previous: getIncompleteEnvironmentConfigQuery(options), override: getOrganizationConfigOverrideQuery(options), - defaults: organizationConfigDefaults, schema: organizationConfigSchema, extraInfo: options, }), - async (config) => await applyOrganizationDefaultsAndSanitize(await config), + async (config) => await config, ); } -function makeUnsanitizedIncompleteConfigQuery(options: { previous?: RawQuery>, override: RawQuery>, defaults: any, schema: yup.AnySchema, extraInfo: any }): RawQuery> { +function makeUnsanitizedIncompleteConfigQuery(options: { previous?: RawQuery>, override: RawQuery>, schema: yup.AnySchema, extraInfo: any }): RawQuery> { return RawQuery.then( RawQuery.all([ options.previous ?? RawQuery.resolve(Promise.resolve({})), @@ -332,14 +352,26 @@ function makeUnsanitizedIncompleteConfigQuery(options: { previous?: RawQue * * */ -async function validateConfigOverrideSchema(schema: yup.AnySchema, base: any, configOverride: any): Promise> { - const mergedResBase = await _validateConfigOverrideSchemaImpl(schema, base, configOverride); +async function validateConfigOverrideSchema( + schema: yup.AnySchema, + base: any, + configOverride: any, + defaultApplier: typeof applyProjectDefaults | typeof applyBranchDefaults | typeof applyEnvironmentDefaults | typeof applyOrganizationDefaults, + sanitizer: typeof sanitizeProjectConfig | typeof sanitizeBranchConfig | typeof sanitizeEnvironmentConfig | typeof sanitizeOrganizationConfig, +): Promise> { + const mergedResBase = await _validateConfigOverrideSchemaImpl(schema, base, configOverride, defaultApplier, sanitizer); if (mergedResBase.status === "error") return mergedResBase; return Result.ok(null); } -async function _validateConfigOverrideSchemaImpl(schema: yup.AnySchema, base: any, configOverride: any): Promise> { +async function _validateConfigOverrideSchemaImpl( + schema: yup.AnySchema, + base: any, + configOverride: any, + defaultApplier: typeof applyProjectDefaults | typeof applyBranchDefaults | typeof applyEnvironmentDefaults | typeof applyOrganizationDefaults, + sanitizer: typeof sanitizeProjectConfig | typeof sanitizeBranchConfig | typeof sanitizeEnvironmentConfig | typeof sanitizeOrganizationConfig, +): Promise> { // Check config format const reason = getInvalidConfigReason(configOverride, { configName: 'override' }); if (reason) return Result.error("[FORMAT ERROR]" + reason); @@ -350,18 +382,28 @@ async function _validateConfigOverrideSchemaImpl(schema: yup.AnySchema, base: an return Result.error("[ERROR] " + errors.error); } - // Make sure there are no warnings in the normalized incomplete config - const value = override(base, configOverride); + // Override + const overridden = override(base, configOverride); + + // Apply defaults + const withDefaults = defaultApplier(overridden); + + // Make sure that normalization succeeds let normalizedValue; try { - normalizedValue = normalize(value, { onDotIntoNonObject: "throw" }); + normalizedValue = normalize(withDefaults, { onDotIntoNonObject: "throw" }); } catch (error) { if (error instanceof NormalizationError) { - return Result.error(error.message); + return Result.error("[NORMALIZATION ERROR] " + error.message); } throw error; } - const warnings = await getIncompleteConfigWarnings(schema, normalizedValue); + + // Sanitize + const sanitizedValue = await sanitizer(normalizedValue as any); + + // Get warnings + const warnings = await getRenderedConfigWarnings(schema, sanitizedValue); if (warnings.status === "error") { return Result.error("[WARNING] " + warnings.error); } @@ -381,30 +423,33 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe ).defined() }).defined(); + const da = ((c: any) => c) as any; + const sr = ((c: any) => c) as any; + // Base success cases - expect(await validateConfigOverrideSchema(schema1, {}, {})).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, {})).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(schema1, {}, { a: 'b' })).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, { a: 'c' })).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(schema1, {}, { a: null })).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, { a: null })).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined() }), {}, { a: 'b' })).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined().oneOf(['b']) }), {}, { a: 'b' })).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ c: yupString().defined() }).defined() }), { a: {} }, { "a.c": 'd' })).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(recordSchema, { a: {} }, { "a.c": 'd' })).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(unionSchema, {}, { "a": 'never' })).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(unionSchema, { a: {} }, { "a": 'never' })).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(unionSchema, { a: {} }, { "a.time": 'now' })).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(unionSchema, { a: { "time": "tomorrow" } }, { "a.morning": true })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, {}, {}, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, {}, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, {}, { a: 'b' }, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, { a: 'c' }, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, {}, { a: null }, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, { a: null }, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined() }), {}, { a: 'b' }, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined().oneOf(['b']) }), {}, { a: 'b' }, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ c: yupString().defined() }).defined() }), { a: {} }, { "a.c": 'd' }, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(recordSchema, { a: {} }, { "a.c": 'd' }, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(unionSchema, {}, { "a": 'never' }, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(unionSchema, { a: {} }, { "a": 'never' }, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(unionSchema, { a: {} }, { "a.time": 'now' }, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(unionSchema, { a: { "time": "tomorrow" } }, { "a.morning": true }, da, sr)).toEqual(Result.ok(null)); // Error cases - expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ b: yupObject({ c: yupString().defined() }).defined() }).defined() }), { a: { b: {} } }, { "a.b": { c: 123 } })).toEqual(Result.error("[ERROR] a.b.c must be a `string` type, but the final value was: `123`.")); - expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined().oneOf(['b']) }), {}, { a: 'c' })).toEqual(Result.error("[ERROR] a must be one of the following values: b")); - expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined() }), {}, {})).toEqual(Result.error("[WARNING] a must be defined")); - expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), {}, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); - expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), { a: 'str' }, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); - expect(await validateConfigOverrideSchema(schema1, {}, { a: 123 })).toEqual(Result.error('[ERROR] a must be a `string` type, but the final value was: `123`.')); - expect(await validateConfigOverrideSchema(unionSchema, { a: { "time": "now" } }, { "a.morning": true })).toMatchInlineSnapshot(` + expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ b: yupObject({ c: yupString().defined() }).defined() }).defined() }), { a: { b: {} } }, { "a.b": { c: 123 } }, da, sr)).toEqual(Result.error("[ERROR] a.b.c must be a `string` type, but the final value was: `123`.")); + expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined().oneOf(['b']) }), {}, { a: 'c' }, da, sr)).toEqual(Result.error("[ERROR] a must be one of the following values: b")); + expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined() }), {}, {}, da, sr)).toEqual(Result.error("[WARNING] a must be defined")); + expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), {}, { "a.b": "c" }, da, sr)).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), { a: 'str' }, { "a.b": "c" }, da, sr)).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); + expect(await validateConfigOverrideSchema(schema1, {}, { a: 123 }, da, sr)).toEqual(Result.error('[ERROR] a must be a `string` type, but the final value was: `123`.')); + expect(await validateConfigOverrideSchema(unionSchema, { a: { "time": "now" } }, { "a.morning": true }, da, sr)).toMatchInlineSnapshot(` { "error": "[WARNING] a is not matched by any of the provided schemas: Schema 0: @@ -421,14 +466,14 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe `); // Actual configs — base cases - const projectSchemaBase = await applyProjectDefaultsAndSanitize({}); - expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, {})).toEqual(Result.ok(null)); - const branchSchemaBase = await applyBranchDefaultsAndSanitize(projectSchemaBase); - expect(await validateConfigOverrideSchema(branchConfigSchema, branchSchemaBase, {})).toEqual(Result.ok(null)); - const environmentSchemaBase = await applyEnvironmentDefaultsAndSanitize(branchSchemaBase); - expect(await validateConfigOverrideSchema(environmentConfigSchema, environmentSchemaBase, {})).toEqual(Result.ok(null)); - const organizationSchemaBase = await applyOrganizationDefaultsAndSanitize(environmentSchemaBase); - expect(await validateConfigOverrideSchema(organizationConfigSchema, organizationSchemaBase, {})).toEqual(Result.ok(null)); + const projectSchemaBase = {}; + expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, {}, applyProjectDefaults, sanitizeProjectConfig)).toEqual(Result.ok(null)); + const branchSchemaBase = projectSchemaBase; + expect(await validateConfigOverrideSchema(branchConfigSchema, branchSchemaBase, {}, applyBranchDefaults, sanitizeBranchConfig)).toEqual(Result.ok(null)); + const environmentSchemaBase = branchSchemaBase; + expect(await validateConfigOverrideSchema(environmentConfigSchema, environmentSchemaBase, {}, applyEnvironmentDefaults, sanitizeEnvironmentConfig)).toEqual(Result.ok(null)); + const organizationSchemaBase = environmentSchemaBase; + expect(await validateConfigOverrideSchema(organizationConfigSchema, organizationSchemaBase, {}, applyOrganizationDefaults, sanitizeOrganizationConfig)).toEqual(Result.ok(null)); // Actual configs — advanced cases expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, { @@ -436,12 +481,12 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe type: 'postgres', connectionString: 'postgres://user:pass@host:port/db', }, - })).toEqual(Result.ok(null)); + }, da, sr)).toEqual(Result.ok(null)); expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, { sourceOfTruth: { type: 'postgres', }, - })).toEqual(Result.error(deindent` + }, da, sr)).toEqual(Result.error(deindent` [WARNING] sourceOfTruth is not matched by any of the provided schemas: Schema 0: sourceOfTruth.type must be one of the following values: hosted diff --git a/packages/stack-shared/src/config/README.md b/packages/stack-shared/src/config/README.md index 24b73f9827..3fb3e59436 100644 --- a/packages/stack-shared/src/config/README.md +++ b/packages/stack-shared/src/config/README.md @@ -18,7 +18,7 @@ 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, deeply merged into the defaults and sanitized (using `apply{$Level}DefaultsAndSanitize`) +- `$Level` rendered config: An incomplete config with those fields removed that can be overridden by a future override, deeply merged into the defaults and sanitized (using `apply{$Level}DefaultsAndSanitize`), and then normalized - Complete config: The organization rendered config. - `$Level` config override override: An override that overrides the `$Level` config override. This is most often used eg. in the REST API to let users make changes to the branch-level config, without overwriting the entire branch-level config override. *Note that, since config overrides (unlike configs) distinguish between `null` and a property missing (`undefined`), it is currently not possible to say "this property in the config override should be unset" (setting a property to `null` in the override override will simply also set it to `null` in the override). In the future, we'll have to think about how we handle this, probably with a sentinel value.* - `$Level` config: Could refer to any of the above, depending on the context; if it's not clear, specify it. diff --git a/packages/stack-shared/src/config/format.ts b/packages/stack-shared/src/config/format.ts index 4c98f17dcb..ded30a9149 100644 --- a/packages/stack-shared/src/config/format.ts +++ b/packages/stack-shared/src/config/format.ts @@ -18,7 +18,7 @@ export type NormalizedConfig = { export type _NormalizesTo = N extends object ? ( & Config & { [K in OptionalKeys]?: _NormalizesTo | null } - & { [K in RequiredKeys]: _NormalizesTo } + & { [K in RequiredKeys]: undefined extends N[K] ? _NormalizesTo | null : _NormalizesTo } & { [K in `${string}.${string}`]: ConfigValue } ) : N; export type NormalizesTo = _NormalizesTo; @@ -157,6 +157,21 @@ export class NormalizationError extends Error { } NormalizationError.prototype.name = "NormalizationError"; +export function isNormalized(c: Config): c is NormalizedConfig { + assertValidConfig(c); + for (const [key, value] of Object.entries(c)) { + if (value === undefined) continue; + if (key.includes('.')) return false; + if (value === null) return false; + } + return true; +} + +export function assertNormalized(c: Config): asserts c is NormalizedConfig { + assertValidConfig(c); + if (!isNormalized(c)) throw new StackAssertionError(`Config is not normalized: ${JSON.stringify(c)}`); +} + export function normalize(c: Config, options: NormalizeOptions = {}): NormalizedConfig { assertValidConfig(c); const onDotIntoNonObject = options.onDotIntoNonObject ?? "throw"; diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 7936f5b853..2d0fdd75e3 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -10,7 +10,7 @@ import { allProviders } from "../utils/oauth"; import { DeepFilterUndefined, DeepMerge, DeepRequiredOrUndefined, filterUndefined, get, has, isObjectLike, mapValues, set, typedFromEntries } from "../utils/objects"; import { Result } from "../utils/results"; import { CollapseObjectUnion, Expand, IntersectAll, IsUnion, typeAssert, typeAssertExtends, typeAssertIs } from "../utils/types"; -import { NormalizesTo, getInvalidConfigReason } from "./format"; +import { Config, NormalizesTo, assertNormalized, getInvalidConfigReason } from "./format"; export const configLevels = ['project', 'branch', 'environment', 'organization'] as const; export type ConfigLevel = typeof configLevels[number]; @@ -252,19 +252,19 @@ export const organizationConfigSchema = environmentConfigSchema.concat(yupObject // 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 // To make sure you don't accidentally forget setting a default value, you must explicitly set fields with no default value to `undefined`. // NOTE: These values are the defaults of the schema, NOT the defaults for newly created projects. The values here signify what `null` means for each property. If you want new projects by default to have a certain value set to true, you should update the corresponding function in the backend instead. -export const projectConfigDefaults = { +const projectConfigDefaults = { sourceOfTruth: { type: 'hosted', connectionStrings: undefined, connectionString: undefined, }, -} satisfies DefaultsType; +} satisfies DefaultsType; -export const branchConfigDefaults = {} as const satisfies DefaultsType; +const branchConfigDefaults = {} as const satisfies DefaultsType; -export const environmentConfigDefaults = {} as const satisfies DefaultsType; +const environmentConfigDefaults = {} as const satisfies DefaultsType; -export const organizationConfigDefaults = { +const organizationConfigDefaults = { rbac: { permissions: (key: string) => ({ containedPermissionIds: {}, @@ -345,8 +345,8 @@ export const organizationConfigDefaults = { }), templates: (key: string) => ({ displayName: "Unnamed Template", - themeId: null, tsxSource: "Error: Template config is missing TypeScript source code.", + themeId: undefined, }), }, @@ -372,7 +372,7 @@ export const organizationConfigDefaults = { }), items: {}, }, -} satisfies DefaultsType; +} satisfies DefaultsType; type _DeepOmitDefaultsImpl = T extends object ? ( ( @@ -386,7 +386,7 @@ typeAssertIs, c: 456 } }, [{ a: { c: export type DeepReplaceAllowFunctionsForObjects = T extends object ? { [K in keyof T]: DeepReplaceAllowFunctionsForObjects } | (string extends keyof T ? (arg: Exclude) => DeepReplaceAllowFunctionsForObjects : never) : T; export type DeepReplaceFunctionsWithObjects = T extends (arg: infer K extends string) => infer R ? DeepReplaceFunctionsWithObjects> : (T extends object ? { [K in keyof T]: DeepReplaceFunctionsWithObjects } : T); -export type ApplyDefaults unknown), C extends object> = Expand, C>>; +export type ApplyDefaults unknown), C extends object> = {} extends D ? C : DeepMerge, C>; // the {} extends D makes TypeScript not recurse if the defaults are empty, hence allowing us more recursion until "type instantiation too deep" kicks in... it's a total hack, but it works, so hey? export function applyDefaults unknown), C extends object>(defaults: D, config: C): ApplyDefaults { const res: any = typeof defaults === 'function' ? {} : mapValues(defaults, v => typeof v === 'function' ? {} : (typeof v === 'object' && v ? applyDefaults(v as any, {}) : v)); outer: for (const [key, mergeValue] of Object.entries(config)) { @@ -422,10 +422,53 @@ import.meta.vitest?.test("applyDefaults", ({ expect }) => { expect(applyDefaults({ a: { b: { c: 1 } } }, { "a.b": { d: 2 } })).toEqual({ a: { b: { c: 1 } }, "a.b": { c: 1, d: 2 } }); }); -export async function applyProjectDefaultsAndSanitize(config: T) { - const withDefaults = applyDefaults(projectConfigDefaults, config); +export function applyProjectDefaults(config: T) { + return applyDefaults(projectConfigDefaults, config); +} + +export function applyBranchDefaults(config: T) { + return applyDefaults( + branchConfigDefaults, + applyDefaults( + projectConfigDefaults, + config + ) + ); +} + +export function applyEnvironmentDefaults(config: T): ApplyDefaults>> { + return applyDefaults( + environmentConfigDefaults, + applyDefaults( + branchConfigDefaults, + applyDefaults( + projectConfigDefaults, + config + ) as any + ) as any + ) as any; +} + +export function applyOrganizationDefaults(config: OrganizationRenderedConfigBeforeDefaults): ApplyDefaults>>> { + return applyDefaults( + organizationConfigDefaults, + applyDefaults( + environmentConfigDefaults, + applyDefaults( + branchConfigDefaults, + applyDefaults( + projectConfigDefaults, + config + ) as any + ) as any + ) as any + ) as any; +} + - const oldSourceOfTruth = withDefaults.sourceOfTruth; +export async function sanitizeProjectConfig(config: T) { + assertNormalized(config); + const oldSourceOfTruth = config.sourceOfTruth; const sourceOfTruth = oldSourceOfTruth.type === 'neon' && typeof oldSourceOfTruth.connectionStrings === 'object' ? { type: 'neon', @@ -440,88 +483,43 @@ export async function applyProjectDefaultsAndSanitize(config: T) { - const withPrevious = await applyProjectDefaultsAndSanitize(config); - const withDefaults = applyDefaults(branchConfigDefaults, withPrevious); - - return { - ...withDefaults, - }; -} - -export async function applyEnvironmentDefaultsAndSanitize(config: T) { - // ------------------------------------------------------------------------------------------------------------------- - // IMPORTANT NOTE: when updating this function, make sure to also update the __typeHack__ versions of it below - // ------------------------------------------------------------------------------------------------------------------- - - const withPrevious = await applyBranchDefaultsAndSanitize(config); - const withDefaults = applyDefaults(environmentConfigDefaults, withPrevious); - - return { - ...withDefaults, - }; -} -// TODO HACK: Because Expand<> can't expand generics, the type of the return values of some of these functions become -// too deep and makes the type checker complain about "type instantiation too deep". -// -// So, here are two 1:1 copies of the function above, but with specific instantiations of the generic type parameter, -// allowing Expand<> to expand the return type, which makes the type checker happy. -// -// It's dumb. We should definitely find a better way to do this. This is really hacky. -export async function __typeHack__applyEnvironmentDefaultsAndSanitize_EnvironmentRenderedConfigBeforeSanitization(config: EnvironmentRenderedConfigBeforeSanitization) { - // ------------------------------------------------------------------------------------------------------------------- - // IMPORTANT NOTE: this is a copy of the generic function above, edit that first - // ------------------------------------------------------------------------------------------------------------------- - - const withPrevious = await applyBranchDefaultsAndSanitize(config); - const withDefaults = applyDefaults(environmentConfigDefaults, withPrevious); - +export async function sanitizeBranchConfig(config: T) { + assertNormalized(config); + const prepared = await sanitizeProjectConfig(config); return { - ...withDefaults, + ...prepared, }; } -// TODO HACK: Because Expand<> can't expand generics, the type of the return values of some of these functions become -// too deep and makes the type checker complain about "type instantiation too deep". -// -// So, here is a 1:1 copy of the function above, but without the generic type parameter, allowing Expand<> to expand -// the return type, which makes the type checker happy. -// -// It's dumb. We should definitely find a better way to do this. This is really hacky. -export async function __typeHack__applyEnvironmentDefaultsAndSanitize_OrganizationRenderedConfigBeforeSanitization(config: OrganizationRenderedConfigBeforeSanitization) { - // ------------------------------------------------------------------------------------------------------------------- - // IMPORTANT NOTE: this is a copy of the generic function above, edit that first - // ------------------------------------------------------------------------------------------------------------------- - - const withPrevious = await applyBranchDefaultsAndSanitize(config); - const withDefaults = applyDefaults(environmentConfigDefaults, withPrevious); +export async function sanitizeEnvironmentConfig(config: T) { + assertNormalized(config); + const prepared = await sanitizeBranchConfig(config); return { - ...withDefaults, + ...prepared, }; } -export async function applyOrganizationDefaultsAndSanitize(config: T) { - const withPrevious = await __typeHack__applyEnvironmentDefaultsAndSanitize_OrganizationRenderedConfigBeforeSanitization(config); - const withDefaults = applyDefaults(organizationConfigDefaults, withPrevious); - +export async function sanitizeOrganizationConfig(config: OrganizationRenderedConfigBeforeSanitization) { + assertNormalized(config); + const prepared = await sanitizeEnvironmentConfig(config); return { - ...withDefaults, + ...prepared, emails: { - ...withDefaults.emails, - selectedThemeId: has(withDefaults.emails.themes, withDefaults.emails.selectedThemeId) ? withDefaults.emails.selectedThemeId : DEFAULT_EMAIL_THEME_ID, + ...prepared.emails, + selectedThemeId: has(prepared.emails.themes, prepared.emails.selectedThemeId) ? prepared.emails.selectedThemeId : DEFAULT_EMAIL_THEME_ID, themes: { ...DEFAULT_EMAIL_THEMES, - ...withDefaults.emails.themes, - } as typeof withDefaults.emails.themes, + ...prepared.emails.themes, + } as typeof prepared.emails.themes, templates: { ...DEFAULT_EMAIL_TEMPLATES, - ...withDefaults.emails.templates, - } as typeof withDefaults.emails.templates, + ...prepared.emails.templates, + } as typeof prepared.emails.templates, }, }; } @@ -700,13 +698,15 @@ typeAssertExtends<_ValidatedToHaveNoConfigOverrideErrorsImpl<{ a: { b: { c: stri * the getConfigOverrideErrors function. (This is necessary, because a changing base config may make an override invalid * that was previously valid.) */ -export async function getIncompleteConfigWarnings(schema: T, incompleteConfig: unknown): Promise> { - await assertNoConfigOverrideErrors(schema, incompleteConfig, { allowPropertiesThatCanNoLongerBeOverridden: true }); - // TODO maybe we should check here whether the config is normalized? just to be safe - // although the schema below should already deal with it in most cases +export async function getRenderedConfigWarnings(schema: T, renderedConfig: Config): Promise> { + // every rendered config should also be a config override without errors (regardless of whether it has warnings or not) + await assertNoConfigOverrideErrors(schema, renderedConfig, { allowPropertiesThatCanNoLongerBeOverridden: true }); + + // ensure the rendered config is normalized + assertNormalized(renderedConfig); try { - await schema.validate(incompleteConfig, { + await schema.validate(renderedConfig, { strict: true, context: { noUnknownPathPrefixes: [''], @@ -724,10 +724,10 @@ export type ValidatedToHaveNoIncompleteConfigWarnings = // Normalized overrides // ex.: { a?: { b?: number, c?: string }, d?: number } -export type ProjectConfigNormalizedOverride = Expand>; -export type BranchConfigNormalizedOverride = Expand>; -export type EnvironmentConfigNormalizedOverride = Expand>; -export type OrganizationConfigNormalizedOverride = Expand>; +type ProjectConfigNormalizedOverride = Expand>; +type BranchConfigNormalizedOverride = Expand>; +type EnvironmentConfigNormalizedOverride = Expand>; +type OrganizationConfigNormalizedOverride = Expand>; // Overrides // ex.: { a?: null | { b?: null | number, c: string }, d?: null | number, "a.b"?: number, "a.c"?: string } @@ -751,27 +751,32 @@ export type BranchIncompleteConfig = Expand; export type OrganizationIncompleteConfig = Expand; -// Rendered configs before sanitization -export type ProjectRenderedConfigBeforeSanitization = Omit; -export type BranchRenderedConfigBeforeSanitization = Omit; -export type EnvironmentRenderedConfigBeforeSanitization = Omit; -export type OrganizationRenderedConfigBeforeSanitization = OrganizationIncompleteConfig; - -// Rendered configs +type OrganizationRenderedConfigBeforeDefaults = OrganizationIncompleteConfig; -export type ProjectRenderedConfig = Expand>>>; -export type BranchRenderedConfig = Expand>>>; -export type EnvironmentRenderedConfig = Expand>>; -export type OrganizationRenderedConfig = Expand>>>; +// Rendered configs before sanitization +type ProjectRenderedConfigBeforeSanitization = Expand>>>; +type BranchRenderedConfigBeforeSanitization = Expand>>>; +type EnvironmentRenderedConfigBeforeSanitization = Expand>>>; +type OrganizationRenderedConfigBeforeSanitization = Expand>>; + +// Rendered configs after defaults, normalization, and sanitization +export type ProjectRenderedConfig = Expand>>>; +export type BranchRenderedConfig = Expand>>>; +export type EnvironmentRenderedConfig = Expand>>>; +export type OrganizationRenderedConfig = Expand>>; // Type assertions (just to make sure the types are correct) diff --git a/packages/stack-shared/src/helpers/emails.ts b/packages/stack-shared/src/helpers/emails.ts index e81ecfd2a4..f05bf8e177 100644 --- a/packages/stack-shared/src/helpers/emails.ts +++ b/packages/stack-shared/src/helpers/emails.ts @@ -79,26 +79,26 @@ export const DEFAULT_EMAIL_TEMPLATES = { "e7d009ce-8d47-4528-b245-5bf119f2ffa3": { "displayName": "Email Verification", "tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory } from \"@stackframe/emails\";\n\nexport const schema = type({\n userDisplayName: \"string\",\n projectDisplayName: \"string\",\n emailVerificationLink: \"string\"\n})\n\nexport function EmailTemplate({ \n userDisplayName, \n projectDisplayName, \n emailVerificationLink \n}: typeof schema.infer) \n{\n return (\n <>\n \n \n
\n
\n

\n Verify your email at {projectDisplayName}\n

\n

\n Hi{userDisplayName ? (\", \" + userDisplayName) : ''}! Please click on the following button to verify your email.\n

\n
\n \n Verify my email\n \n
\n
\n
\n
\n

\n If you were not expecting this email, you can safely ignore it. \n

\n
\n
\n \n )\n}\n", - "themeId": null, + "themeId": undefined, }, "a70fb3a4-56c1-4e42-af25-49d25603abd0": { "displayName": "Password Reset", "tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\"\nimport { Subject, NotificationCategory } from \"@stackframe/emails\"\n\nexport const schema = type({\n userDisplayName: \"string\",\n projectDisplayName: \"string\",\n passwordResetLink: \"string\"\n})\n\nexport function EmailTemplate({ userDisplayName, projectDisplayName, passwordResetLink }: typeof schema.infer) {\n return (\n <>\n \n \n
\n
\n

\n Reset your password at {projectDisplayName}\n

\n\n

\n Hi{userDisplayName ? (\", \" + userDisplayName) : \"\"}! Please click on the following button to start the password reset process.\n

\n\n
\n \n Reset my password\n \n
\n\n
\n
\n
\n\n

\n If you were not expecting this email, you can safely ignore it.\n

\n
\n
\n \n )\n}\n", - "themeId": null, + "themeId": undefined, }, "822687fe-8d0a-4467-a0d1-416b6e639478": { "displayName": "Magic Link/OTP", "tsxSource": "import React from 'react';\nimport { type } from 'arktype';\nimport { Section, Hr } from '@react-email/components';\nimport { Subject, NotificationCategory } from '@stackframe/emails';\n\nexport const schema = type({\n userDisplayName: 'string',\n projectDisplayName: 'string',\n magicLink: 'string',\n otp: 'string',\n});\n\nexport function EmailTemplate({ userDisplayName, projectDisplayName, magicLink, otp }: typeof schema.infer) {\n return (\n <>\n \n \n
\n \n );\n}\n", - "themeId": null, + "themeId": undefined, }, "066dd73c-36da-4fd0-b6d6-ebf87683f8bc": { "displayName": "Team Invitation", "tsxSource": "import { type } from \"arktype\";\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory } from \"@stackframe/emails\";\n\n\nexport const schema = type({\n userDisplayName: \"string\",\n teamDisplayName: \"string\",\n teamInvitationLink: \"string\"\n});\n\nexport function EmailTemplate({ userDisplayName, teamDisplayName, teamInvitationLink }: typeof schema.infer) {\n return (\n <>\n \n \n
\n
\n

\n You are invited to {teamDisplayName}\n

\n

\n Hi{userDisplayName ? \", \" + userDisplayName : \"\"}! Please click the button below to join the team {teamDisplayName}\n

\n
\n \n Join team\n \n
\n
\n
\n
\n

\n If you were not expecting this email, you can safely ignore it.\n

\n
\n
\n \n );\n}\n", - "themeId": null, + "themeId": undefined, }, "e84de395-2076-4831-9c19-8e9a96a868e4": { "displayName": "Sign In Invitation", "tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory } from \"@stackframe/emails\";\n\nexport const schema = type({\n userDisplayName: \"string\",\n projectDisplayName: \"string\",\n signInInvitationLink: \"string\",\n teamDisplayName: \"string\"\n})\n\nexport function EmailTemplate({\n userDisplayName,\n projectDisplayName,\n signInInvitationLink,\n teamDisplayName\n}: typeof schema.infer) {\n return (\n <>\n \n \n\n
\n
\n

\n You are invited to sign in to {teamDisplayName}\n

\n\n

\n Hi\n {userDisplayName ? \", \" + userDisplayName : \"\"}! Please click on the following\n link to sign in to your account\n

\n\n
\n \n Sign in\n \n
\n\n
\n
\n
\n\n

\n If you were not expecting this email, you can safely ignore it.\n

\n
\n
\n \n )\n}\n", - "themeId": null, + "themeId": undefined, } }; diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 12ffe33032..8b4e09fde6 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -473,7 +473,7 @@ export const emailThemeListSchema = yupRecord( tsxSource: yupString().meta({ openapiField: { description: 'Email theme source code tsx component' } }).defined(), }) ).meta({ openapiField: { description: 'Record of email theme IDs to their display name and source code' } }); -export const templateThemeIdSchema = yupMixed().test((v: any) => v === undefined || v === false || v === null || (typeof v === 'string' && isUuid(v))).meta({ openapiField: { description: 'Email theme id for the template' } }).nullable(); +export const templateThemeIdSchema = yupMixed().test((v: any) => v === undefined || v === false || v === null || (typeof v === 'string' && isUuid(v))).meta({ openapiField: { description: 'Email theme id for the template' } }).optional(); export const emailTemplateListSchema = yupRecord( yupString().uuid(), yupObject({ @@ -482,8 +482,8 @@ export const emailTemplateListSchema = yupRecord( // themeId can be one of three values: // 1. A valid theme id // 2. false, which means the template uses no theme - // 3. null, which means the template uses the project's active theme - themeId: templateThemeIdSchema.nullable().defined(), + // 3. undefined/not given, which means the template uses the project's active theme + themeId: templateThemeIdSchema, }) ).meta({ openapiField: { description: 'Record of email template IDs to their display name and source code' } }); From 6c17ed776e8ff868c58a8aaeb12fe3f8afe3c7ac Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 25 Jul 2025 17:42:37 -0700 Subject: [PATCH 08/40] more --- apps/backend/src/lib/config.tsx | 91 ++++------ packages/stack-shared/src/config/format.ts | 15 +- packages/stack-shared/src/config/schema.ts | 194 +++++++++++---------- packages/stack-shared/src/schema-fields.ts | 2 +- 4 files changed, 147 insertions(+), 155 deletions(-) diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 6e98b266e6..92f8ffd66f 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -1,6 +1,6 @@ import { Prisma } from "@prisma/client"; -import { Config, NormalizationError, getInvalidConfigReason, normalize, override } from "@stackframe/stack-shared/dist/config/format"; -import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaults, applyEnvironmentDefaults, applyOrganizationDefaults, applyProjectDefaults, assertNoConfigOverrideErrors, branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, getRenderedConfigWarnings, organizationConfigSchema, projectConfigSchema, sanitizeBranchConfig, sanitizeEnvironmentConfig, sanitizeOrganizationConfig, sanitizeProjectConfig } from "@stackframe/stack-shared/dist/config/schema"; +import { Config, getInvalidConfigReason, normalize, override } from "@stackframe/stack-shared/dist/config/format"; +import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaults, applyEnvironmentDefaults, applyOrganizationDefaults, applyProjectDefaults, assertNoConfigOverrideErrors, branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, organizationConfigSchema, projectConfigSchema, sanitizeBranchConfig, sanitizeEnvironmentConfig, sanitizeOrganizationConfig, sanitizeProjectConfig } from "@stackframe/stack-shared/dist/config/schema"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; import { isTruthy } from "@stackframe/stack-shared/dist/utils/booleans"; @@ -66,8 +66,6 @@ export async function validateProjectConfigOverride(options: { projectConfigOver projectConfigSchema, {}, options.projectConfigOverride, - applyProjectDefaults, - sanitizeProjectConfig, ); } @@ -79,8 +77,6 @@ export async function validateBranchConfigOverride(options: { branchConfigOverri branchConfigSchema, await rawQuery(globalPrismaClient, getIncompleteProjectConfigQuery(options)), options.branchConfigOverride, - applyBranchDefaults, - sanitizeBranchConfig, ); // 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) @@ -94,8 +90,6 @@ export async function validateEnvironmentConfigOverride(options: { environmentCo environmentConfigSchema, await rawQuery(globalPrismaClient, getIncompleteBranchConfigQuery(options)), options.environmentConfigOverride, - applyEnvironmentDefaults, - sanitizeEnvironmentConfig, ); // 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) @@ -109,8 +103,6 @@ export async function validateOrganizationConfigOverride(options: { organization organizationConfigSchema, await rawQuery(globalPrismaClient, getIncompleteEnvironmentConfigQuery(options)), options.organizationConfigOverride, - applyOrganizationDefaults, - sanitizeOrganizationConfig, ); // 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) @@ -356,10 +348,8 @@ async function validateConfigOverrideSchema( schema: yup.AnySchema, base: any, configOverride: any, - defaultApplier: typeof applyProjectDefaults | typeof applyBranchDefaults | typeof applyEnvironmentDefaults | typeof applyOrganizationDefaults, - sanitizer: typeof sanitizeProjectConfig | typeof sanitizeBranchConfig | typeof sanitizeEnvironmentConfig | typeof sanitizeOrganizationConfig, ): Promise> { - const mergedResBase = await _validateConfigOverrideSchemaImpl(schema, base, configOverride, defaultApplier, sanitizer); + const mergedResBase = await _validateConfigOverrideSchemaImpl(schema, base, configOverride); if (mergedResBase.status === "error") return mergedResBase; return Result.ok(null); @@ -369,8 +359,6 @@ async function _validateConfigOverrideSchemaImpl( schema: yup.AnySchema, base: any, configOverride: any, - defaultApplier: typeof applyProjectDefaults | typeof applyBranchDefaults | typeof applyEnvironmentDefaults | typeof applyOrganizationDefaults, - sanitizer: typeof sanitizeProjectConfig | typeof sanitizeBranchConfig | typeof sanitizeEnvironmentConfig | typeof sanitizeOrganizationConfig, ): Promise> { // Check config format const reason = getInvalidConfigReason(configOverride, { configName: 'override' }); @@ -385,25 +373,8 @@ async function _validateConfigOverrideSchemaImpl( // Override const overridden = override(base, configOverride); - // Apply defaults - const withDefaults = defaultApplier(overridden); - - // Make sure that normalization succeeds - let normalizedValue; - try { - normalizedValue = normalize(withDefaults, { onDotIntoNonObject: "throw" }); - } catch (error) { - if (error instanceof NormalizationError) { - return Result.error("[NORMALIZATION ERROR] " + error.message); - } - throw error; - } - - // Sanitize - const sanitizedValue = await sanitizer(normalizedValue as any); - // Get warnings - const warnings = await getRenderedConfigWarnings(schema, sanitizedValue); + const warnings = await getIncompleteConfigWarnings(schema, overridden); if (warnings.status === "error") { return Result.error("[WARNING] " + warnings.error); } @@ -427,29 +398,29 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe const sr = ((c: any) => c) as any; // Base success cases - expect(await validateConfigOverrideSchema(schema1, {}, {}, da, sr)).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, {}, da, sr)).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(schema1, {}, { a: 'b' }, da, sr)).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, { a: 'c' }, da, sr)).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(schema1, {}, { a: null }, da, sr)).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, { a: null }, da, sr)).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined() }), {}, { a: 'b' }, da, sr)).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined().oneOf(['b']) }), {}, { a: 'b' }, da, sr)).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ c: yupString().defined() }).defined() }), { a: {} }, { "a.c": 'd' }, da, sr)).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(recordSchema, { a: {} }, { "a.c": 'd' }, da, sr)).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(unionSchema, {}, { "a": 'never' }, da, sr)).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(unionSchema, { a: {} }, { "a": 'never' }, da, sr)).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(unionSchema, { a: {} }, { "a.time": 'now' }, da, sr)).toEqual(Result.ok(null)); - expect(await validateConfigOverrideSchema(unionSchema, { a: { "time": "tomorrow" } }, { "a.morning": true }, da, sr)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, {}, {})).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, {})).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, {}, { a: 'b' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, { a: 'c' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, {}, { a: null })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(schema1, { a: 'b' }, { a: null })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined() }), {}, { a: 'b' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined().oneOf(['b']) }), {}, { a: 'b' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ c: yupString().defined() }).defined() }), { a: {} }, { "a.c": 'd' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(recordSchema, { a: {} }, { "a.c": 'd' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(unionSchema, {}, { "a": 'never' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(unionSchema, { a: {} }, { "a": 'never' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(unionSchema, { a: {} }, { "a.time": 'now' })).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(unionSchema, { a: { "time": "tomorrow" } }, { "a.morning": true })).toEqual(Result.ok(null)); // Error cases - expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ b: yupObject({ c: yupString().defined() }).defined() }).defined() }), { a: { b: {} } }, { "a.b": { c: 123 } }, da, sr)).toEqual(Result.error("[ERROR] a.b.c must be a `string` type, but the final value was: `123`.")); - expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined().oneOf(['b']) }), {}, { a: 'c' }, da, sr)).toEqual(Result.error("[ERROR] a must be one of the following values: b")); - expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined() }), {}, {}, da, sr)).toEqual(Result.error("[WARNING] a must be defined")); - expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), {}, { "a.b": "c" }, da, sr)).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); - expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), { a: 'str' }, { "a.b": "c" }, da, sr)).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); - expect(await validateConfigOverrideSchema(schema1, {}, { a: 123 }, da, sr)).toEqual(Result.error('[ERROR] a must be a `string` type, but the final value was: `123`.')); - expect(await validateConfigOverrideSchema(unionSchema, { a: { "time": "now" } }, { "a.morning": true }, da, sr)).toMatchInlineSnapshot(` + expect(await validateConfigOverrideSchema(yupObject({ a: yupObject({ b: yupObject({ c: yupString().defined() }).defined() }).defined() }), { a: { b: {} } }, { "a.b": { c: 123 } })).toEqual(Result.error("[ERROR] a.b.c must be a `string` type, but the final value was: `123`.")); + expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined().oneOf(['b']) }), {}, { a: 'c' })).toEqual(Result.error("[ERROR] a must be one of the following values: b")); + expect(await validateConfigOverrideSchema(yupObject({ a: yupString().defined() }), {}, {})).toEqual(Result.error("[WARNING] a must be defined")); + expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), {}, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); + expect(await validateConfigOverrideSchema(yupObject({ a: yupMixed() }), { a: 'str' }, { "a.b": "c" })).toEqual(Result.error(`[ERROR] The key \"a.b\" is not valid for the schema.`)); + expect(await validateConfigOverrideSchema(schema1, {}, { a: 123 })).toEqual(Result.error('[ERROR] a must be a `string` type, but the final value was: `123`.')); + expect(await validateConfigOverrideSchema(unionSchema, { a: { "time": "now" } }, { "a.morning": true })).toMatchInlineSnapshot(` { "error": "[WARNING] a is not matched by any of the provided schemas: Schema 0: @@ -467,13 +438,13 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe // Actual configs — base cases const projectSchemaBase = {}; - expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, {}, applyProjectDefaults, sanitizeProjectConfig)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, {})).toEqual(Result.ok(null)); const branchSchemaBase = projectSchemaBase; - expect(await validateConfigOverrideSchema(branchConfigSchema, branchSchemaBase, {}, applyBranchDefaults, sanitizeBranchConfig)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(branchConfigSchema, branchSchemaBase, {})).toEqual(Result.ok(null)); const environmentSchemaBase = branchSchemaBase; - expect(await validateConfigOverrideSchema(environmentConfigSchema, environmentSchemaBase, {}, applyEnvironmentDefaults, sanitizeEnvironmentConfig)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(environmentConfigSchema, environmentSchemaBase, {})).toEqual(Result.ok(null)); const organizationSchemaBase = environmentSchemaBase; - expect(await validateConfigOverrideSchema(organizationConfigSchema, organizationSchemaBase, {}, applyOrganizationDefaults, sanitizeOrganizationConfig)).toEqual(Result.ok(null)); + expect(await validateConfigOverrideSchema(organizationConfigSchema, organizationSchemaBase, {})).toEqual(Result.ok(null)); // Actual configs — advanced cases expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, { @@ -481,12 +452,12 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe type: 'postgres', connectionString: 'postgres://user:pass@host:port/db', }, - }, da, sr)).toEqual(Result.ok(null)); + })).toEqual(Result.ok(null)); expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, { sourceOfTruth: { type: 'postgres', }, - }, da, sr)).toEqual(Result.error(deindent` + })).toEqual(Result.error(deindent` [WARNING] sourceOfTruth is not matched by any of the provided schemas: Schema 0: sourceOfTruth.type must be one of the following values: hosted diff --git a/packages/stack-shared/src/config/format.ts b/packages/stack-shared/src/config/format.ts index ded30a9149..5380245ac9 100644 --- a/packages/stack-shared/src/config/format.ts +++ b/packages/stack-shared/src/config/format.ts @@ -148,6 +148,15 @@ type NormalizeOptions = { * - "ignore": Ignore the dot notation field. */ onDotIntoNonObject?: "throw" | "ignore", + /** + * What to do if a dot notation is used on a value that is not an object. + * + * - "like-non-object" (default): Treat it like a non-object. See `onDotIntoNonObject`. + * - "throw": Throw an error. + * - "ignore": Ignore the dot notation field. + * - "empty-object": Set the value to an empty object. + */ + onDotIntoNull?: "like-non-object" | "throw" | "ignore" | "empty-object", } export class NormalizationError extends Error { @@ -175,6 +184,7 @@ export function assertNormalized(c: Config): asserts c is NormalizedConfig { export function normalize(c: Config, options: NormalizeOptions = {}): NormalizedConfig { assertValidConfig(c); const onDotIntoNonObject = options.onDotIntoNonObject ?? "throw"; + const onDotIntoNull = options.onDotIntoNull ?? "like-non-object"; const countDots = (s: string) => s.match(/\./g)?.length ?? 0; const result: NormalizedConfig = {}; @@ -189,13 +199,16 @@ export function normalize(c: Config, options: NormalizeOptions = {}): Normalized let current: NormalizedConfig = result; for (const keySegment of keySegmentsWithoutLast) { if (!hasAndNotUndefined(current, keySegment)) { - switch (onDotIntoNonObject) { + switch (onDotIntoNull === "like-non-object" ? onDotIntoNonObject : onDotIntoNull) { 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).`); } case "ignore": { continue outer; } + case "empty-object": { + set(current, keySegment, {}); + } } } const value = get(current, keySegment); diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 2d0fdd75e3..dc92bcb47b 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -10,7 +10,7 @@ import { allProviders } from "../utils/oauth"; import { DeepFilterUndefined, DeepMerge, DeepRequiredOrUndefined, filterUndefined, get, has, isObjectLike, mapValues, set, typedFromEntries } from "../utils/objects"; import { Result } from "../utils/results"; import { CollapseObjectUnion, Expand, IntersectAll, IsUnion, typeAssert, typeAssertExtends, typeAssertIs } from "../utils/types"; -import { Config, NormalizesTo, assertNormalized, getInvalidConfigReason } from "./format"; +import { Config, NormalizationError, NormalizesTo, assertNormalized, getInvalidConfigReason, normalize } from "./format"; export const configLevels = ['project', 'branch', 'environment', 'organization'] as const; export type ConfigLevel = typeof configLevels[number]; @@ -37,154 +37,154 @@ export const projectConfigSchema = yupObject({ sourceOfTruth: yupUnion( yupObject({ type: yupString().oneOf(['hosted']).defined(), - }).defined(), + }), yupObject({ type: yupString().oneOf(['neon']).defined(), connectionStrings: yupRecord( yupString().defined(), yupString().defined(), ).defined(), - }).defined(), + }), yupObject({ type: yupString().oneOf(['postgres']).defined(), connectionString: yupString().defined() - }).defined(), - ).defined(), -}).defined(); + }), + ), +}); // --- NEW RBAC Schema --- const branchRbacDefaultPermissions = yupRecord( - yupString().matches(permissionRegex).defined(), + yupString().matches(permissionRegex), yupBoolean().isTrue().optional(), -).defined(); +); const branchRbacSchema = yupObject({ permissions: yupRecord( - yupString().matches(customPermissionRegex).defined(), + yupString().matches(customPermissionRegex), yupObject({ description: yupString().optional(), scope: yupString().oneOf(['team', 'project']).optional(), containedPermissionIds: yupRecord( - yupString().matches(permissionRegex).defined(), + yupString().matches(permissionRegex), yupBoolean().isTrue().optional() ).optional(), }).optional(), - ).defined(), + ), defaultPermissions: yupObject({ teamCreator: branchRbacDefaultPermissions, teamMember: branchRbacDefaultPermissions, signUp: branchRbacDefaultPermissions, - }).defined(), -}).defined(); + }), +}); // --- END NEW RBAC Schema --- // --- NEW API Keys Schema --- const branchApiKeysSchema = yupObject({ enabled: yupObject({ - team: yupBoolean().defined(), - user: yupBoolean().defined(), - }).defined(), -}).defined(); + team: yupBoolean(), + user: yupBoolean(), + }), +}); // --- END NEW API Keys Schema --- const branchAuthSchema = yupObject({ - allowSignUp: yupBoolean().defined(), + allowSignUp: yupBoolean(), password: yupObject({ - allowSignIn: yupBoolean().defined(), - }).defined(), + allowSignIn: yupBoolean(), + }), otp: yupObject({ - allowSignIn: yupBoolean().defined(), - }).defined(), + allowSignIn: yupBoolean(), + }), passkey: yupObject({ - allowSignIn: yupBoolean().defined(), - }).defined(), + allowSignIn: yupBoolean(), + }), oauth: yupObject({ - accountMergeStrategy: yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).defined(), + accountMergeStrategy: yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']), providers: yupRecord( - yupString().matches(permissionRegex).defined(), + yupString().matches(permissionRegex), yupObject({ - type: yupString().oneOf(allProviders).defined(), - allowSignIn: yupBoolean().defined(), - allowConnectedAccounts: yupBoolean().defined(), - }).defined(), - ).defined(), - }).defined(), -}).defined(); + type: yupString().oneOf(allProviders), + allowSignIn: yupBoolean(), + allowConnectedAccounts: yupBoolean(), + }), + ), + }), +}); const branchPaymentsSchema = yupObject({ autoPay: yupObject({ - interval: schemaFields.dayIntervalSchema.defined(), + interval: schemaFields.dayIntervalSchema, }).optional(), exclusivityGroups: yupRecord( - userSpecifiedIdSchema("exclusivityGroupId").defined(), + userSpecifiedIdSchema("exclusivityGroupId"), yupRecord( - userSpecifiedIdSchema("offerId").defined(), - yupBoolean().isTrue().defined(), - ).defined(), - ).defined(), + userSpecifiedIdSchema("offerId"), + yupBoolean().isTrue(), + ), + ), offers: yupRecord( - userSpecifiedIdSchema("offerId").defined(), + userSpecifiedIdSchema("offerId"), yupObject({ - customerType: schemaFields.customerTypeSchema.defined(), + customerType: schemaFields.customerTypeSchema, freeTrial: schemaFields.dayIntervalSchema.optional(), - serverOnly: yupBoolean().defined(), - stackable: yupBoolean().defined(), + serverOnly: yupBoolean(), + stackable: yupBoolean(), prices: yupRecord( - userSpecifiedIdSchema("priceId").defined(), + userSpecifiedIdSchema("priceId"), yupObject({ ...typedFromEntries(SUPPORTED_CURRENCIES.map(currency => [currency.code, schemaFields.moneyAmountSchema(currency).optional()])), interval: schemaFields.dayIntervalSchema.optional(), - serverOnly: yupBoolean().defined(), + serverOnly: yupBoolean(), freeTrial: schemaFields.dayIntervalSchema.optional(), - }).defined().test("at-least-one-currency", (value, context) => { + }).test("at-least-one-currency", (value, context) => { const currencies = Object.keys(value).filter(key => key.toUpperCase() === key); if (currencies.length === 0) { return context.createError({ message: "At least one currency is required" }); } return true; - }).defined(), - ).defined(), + }), + ), items: yupRecord( - userSpecifiedIdSchema("itemId").defined(), + userSpecifiedIdSchema("itemId"), yupObject({ - quantity: yupNumber().defined(), + quantity: yupNumber(), repeat: schemaFields.dayIntervalOrNeverSchema.optional(), - expires: yupString().oneOf(['never', 'when-purchase-expires', 'when-repeated']).defined(), - }).defined(), - ).defined(), - }).defined(), - ).defined(), + expires: yupString().oneOf(['never', 'when-purchase-expires', 'when-repeated']), + }), + ), + }), + ), items: yupRecord( - userSpecifiedIdSchema("itemId").defined(), + userSpecifiedIdSchema("itemId"), yupObject({ - customerType: schemaFields.customerTypeSchema.defined(), + customerType: schemaFields.customerTypeSchema, default: yupObject({ - quantity: yupNumber().defined(), + quantity: yupNumber(), repeat: schemaFields.dayIntervalOrNeverSchema.optional(), - expires: yupString().oneOf(['never', 'when-repeated']).defined(), - }).defined().default({ + expires: yupString().oneOf(['never', 'when-repeated']), + }).default({ quantity: 0, }), - }).defined(), - ).defined(), -}).defined(); + }), + ), +}); const branchDomain = yupObject({ - allowLocalhost: yupBoolean().defined(), -}).defined(); + allowLocalhost: yupBoolean(), +}); export const branchConfigSchema = canNoLongerBeOverridden(projectConfigSchema, ["sourceOfTruth"]).concat(yupObject({ rbac: branchRbacSchema, teams: yupObject({ - createPersonalTeamOnSignUp: yupBoolean().defined(), - allowClientTeamCreation: yupBoolean().defined(), - }).defined(), + createPersonalTeamOnSignUp: yupBoolean(), + allowClientTeamCreation: yupBoolean(), + }), users: yupObject({ - allowClientUserDeletion: yupBoolean().defined(), - }).defined(), + allowClientUserDeletion: yupBoolean(), + }), apiKeys: branchApiKeysSchema, @@ -193,9 +193,9 @@ export const branchConfigSchema = canNoLongerBeOverridden(projectConfigSchema, [ auth: branchAuthSchema, emails: yupObject({ - selectedThemeId: schemaFields.emailThemeSchema.defined(), - themes: schemaFields.emailThemeListSchema.defined(), - templates: schemaFields.emailTemplateListSchema.defined(), + selectedThemeId: schemaFields.emailThemeSchema, + themes: schemaFields.emailThemeListSchema, + templates: schemaFields.emailTemplateListSchema, }), payments: branchPaymentsSchema, @@ -206,10 +206,10 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ auth: branchConfigSchema.getNested("auth").concat(yupObject({ oauth: branchConfigSchema.getNested("auth").getNested("oauth").concat(yupObject({ providers: yupRecord( - yupString().matches(permissionRegex).defined(), + yupString().matches(permissionRegex), yupObject({ - type: yupString().oneOf(allProviders).defined(), - isShared: yupBoolean().defined(), + type: yupString().oneOf(allProviders), + isShared: yupBoolean(), clientId: schemaFields.oauthClientIdSchema.optional(), clientSecret: schemaFields.oauthClientSecretSchema.optional(), facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(), @@ -217,31 +217,31 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ allowSignIn: yupBoolean().optional(), allowConnectedAccounts: yupBoolean().optional(), }), - ).defined(), - }).defined()), + ), + })), })), emails: branchConfigSchema.getNested("emails").concat(yupObject({ server: yupObject({ - isShared: yupBoolean().defined(), + isShared: yupBoolean(), 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(), - }).defined(), - }).defined()), + }), + })), domains: branchConfigSchema.getNested("domains").concat(yupObject({ trustedDomains: yupRecord( - yupString().uuid().defined(), + yupString().uuid(), yupObject({ - baseUrl: schemaFields.urlSchema.defined(), - handlerPath: schemaFields.handlerPathSchema.defined(), + baseUrl: schemaFields.urlSchema, + handlerPath: schemaFields.handlerPathSchema, }), - ).defined(), - }).defined()), + ), + })), })); export const organizationConfigSchema = environmentConfigSchema.concat(yupObject({})); @@ -258,7 +258,7 @@ const projectConfigDefaults = { connectionStrings: undefined, connectionString: undefined, }, -} satisfies DefaultsType; +} as const satisfies DefaultsType; const branchConfigDefaults = {} as const satisfies DefaultsType; @@ -372,7 +372,7 @@ const organizationConfigDefaults = { }), items: {}, }, -} satisfies DefaultsType; +} as const satisfies DefaultsType; type _DeepOmitDefaultsImpl = T extends object ? ( ( @@ -698,15 +698,23 @@ typeAssertExtends<_ValidatedToHaveNoConfigOverrideErrorsImpl<{ a: { b: { c: stri * the getConfigOverrideErrors function. (This is necessary, because a changing base config may make an override invalid * that was previously valid.) */ -export async function getRenderedConfigWarnings(schema: T, renderedConfig: Config): Promise> { +export async function getIncompleteConfigWarnings(schema: T, incompleteConfig: Config): Promise> { // every rendered config should also be a config override without errors (regardless of whether it has warnings or not) - await assertNoConfigOverrideErrors(schema, renderedConfig, { allowPropertiesThatCanNoLongerBeOverridden: true }); + await assertNoConfigOverrideErrors(schema, incompleteConfig, { allowPropertiesThatCanNoLongerBeOverridden: true }); - // ensure the rendered config is normalized - assertNormalized(renderedConfig); + let normalized: Config; + try { + normalized = normalize(incompleteConfig, { onDotIntoNull: "empty-object" }); + } catch (error) { + if (error instanceof NormalizationError) { + return Result.error(`Config is not normalizable. ` + error.message); + } + throw error; + } + // test the schema against the normalized config try { - await schema.validate(renderedConfig, { + await schema.validate(normalized, { strict: true, context: { noUnknownPathPrefixes: [''], diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 8b4e09fde6..345b69caf3 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -281,7 +281,7 @@ export function yupUnion(...args: T): yup.MixedSchema export function yupRecord( keySchema: K, valueSchema: T, -): yup.MixedSchema>> { +): yup.MixedSchema & string, yup.InferType>> { return yupObject().meta({ stackSchemaInfo: { type: "record", keySchema, valueSchema } }).unknown(true).test( 'record', '${path} must be a record of valid values', From b23aee5a736701d9a2f78339729a0a363055105a Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 25 Jul 2025 17:49:48 -0700 Subject: [PATCH 09/40] more --- packages/stack-shared/src/config/schema.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index dc92bcb47b..b3706a73de 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -100,11 +100,11 @@ const branchAuthSchema = yupObject({ allowSignIn: yupBoolean(), }), oauth: yupObject({ - accountMergeStrategy: yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']), + accountMergeStrategy: yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).optional(), providers: yupRecord( yupString().matches(permissionRegex), yupObject({ - type: yupString().oneOf(allProviders), + type: yupString().oneOf(allProviders).optional(), allowSignIn: yupBoolean(), allowConnectedAccounts: yupBoolean(), }), @@ -150,7 +150,7 @@ const branchPaymentsSchema = yupObject({ yupObject({ quantity: yupNumber(), repeat: schemaFields.dayIntervalOrNeverSchema.optional(), - expires: yupString().oneOf(['never', 'when-purchase-expires', 'when-repeated']), + expires: yupString().oneOf(['never', 'when-purchase-expires', 'when-repeated']).optional(), }), ), }), @@ -162,7 +162,7 @@ const branchPaymentsSchema = yupObject({ default: yupObject({ quantity: yupNumber(), repeat: schemaFields.dayIntervalOrNeverSchema.optional(), - expires: yupString().oneOf(['never', 'when-repeated']), + expires: yupString().oneOf(['never', 'when-repeated']).optional(), }).default({ quantity: 0, }), @@ -208,7 +208,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ providers: yupRecord( yupString().matches(permissionRegex), yupObject({ - type: yupString().oneOf(allProviders), + type: yupString().oneOf(allProviders).optional(), isShared: yupBoolean(), clientId: schemaFields.oauthClientIdSchema.optional(), clientSecret: schemaFields.oauthClientSecretSchema.optional(), @@ -730,6 +730,9 @@ export async function getIncompleteConfigWarnings(schem } export type ValidatedToHaveNoIncompleteConfigWarnings = yup.InferType; +type T = (yup.InferType)["auth"]; + + // Normalized overrides // ex.: { a?: { b?: number, c?: string }, d?: number } type ProjectConfigNormalizedOverride = Expand>; From 5c005de9743427ef352b49128e24825c100c77dc Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 25 Jul 2025 18:07:09 -0700 Subject: [PATCH 10/40] fixes --- apps/backend/src/lib/permissions.tsx | 130 +++++++++------------ packages/stack-shared/src/config/schema.ts | 11 +- 2 files changed, 59 insertions(+), 82 deletions(-) diff --git a/apps/backend/src/lib/permissions.tsx b/apps/backend/src/lib/permissions.tsx index f19482ab7d..c2aef76270 100644 --- a/apps/backend/src/lib/permissions.tsx +++ b/apps/backend/src/lib/permissions.tsx @@ -1,11 +1,11 @@ import { KnownErrors } from "@stackframe/stack-shared"; -import { override } from "@stackframe/stack-shared/dist/config/format"; import { OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema"; import { ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions"; import { TeamPermissionDefinitionsCrud, TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions"; import { groupBy } from "@stackframe/stack-shared/dist/utils/arrays"; import { getOrUndefined, has, typedEntries, typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import { overrideEnvironmentConfigOverride } from "./config"; import { Tenancy } from "./tenancies"; import { PrismaTransaction } from "./types"; @@ -210,27 +210,19 @@ export async function createPermissionDefinition( throw new KnownErrors.ContainedPermissionNotFound(containedPermissionIdThatWasNotFound); } - await globalTx.environmentConfigOverride.update({ - where: { - projectId_branchId: { - projectId: options.tenancy.project.id, - branchId: options.tenancy.branchId, - } - }, - data: { - config: override( - oldConfig, - { - "rbac.permissions": { - ...oldConfig.rbac.permissions, - [options.data.id]: { - description: getDescription(options.data.id, options.data.description), - scope: options.scope, - containedPermissionIds: typedFromEntries((options.data.contained_permission_ids ?? []).map(id => [id, true])) - }, - }, + await overrideEnvironmentConfigOverride({ + branchId: options.tenancy.branchId, + projectId: options.tenancy.project.id, + tx: globalTx, + environmentConfigOverrideOverride: { + "rbac.permissions": { + ...oldConfig.rbac.permissions, + [options.data.id]: { + description: getDescription(options.data.id, options.data.description), + scope: options.scope, + containedPermissionIds: typedFromEntries((options.data.contained_permission_ids ?? []).map(id => [id, true])) }, - ) + }, } }); @@ -277,40 +269,32 @@ export async function updatePermissionDefinition( throw new KnownErrors.ContainedPermissionNotFound(containedPermissionIdThatWasNotFound); } - await globalTx.environmentConfigOverride.update({ - where: { - projectId_branchId: { - projectId: options.tenancy.project.id, - branchId: options.tenancy.branchId, + await overrideEnvironmentConfigOverride({ + branchId: options.tenancy.branchId, + projectId: options.tenancy.project.id, + tx: globalTx, + environmentConfigOverrideOverride: { + "rbac.permissions": { + ...typedFromEntries( + typedEntries(oldConfig.rbac.permissions) + .filter(([id]) => id !== options.oldId) + .map(([id, p]) => [id, { + ...p, + containedPermissionIds: typedFromEntries(typedEntries(p.containedPermissionIds).map(([id]) => { + if (id === options.oldId) { + return [newId, true]; + } else { + return [id, true]; + } + })) + }]) + ), + [newId]: { + description: getDescription(newId, options.data.description), + scope: options.scope, + containedPermissionIds: typedFromEntries((options.data.contained_permission_ids ?? []).map(id => [id, true])) + } } - }, - data: { - config: override( - oldConfig, - { - "rbac.permissions": { - ...typedFromEntries( - typedEntries(oldConfig.rbac.permissions) - .filter(([id]) => id !== options.oldId) - .map(([id, p]) => [id, { - ...p, - containedPermissionIds: typedFromEntries(typedEntries(p.containedPermissionIds).map(([id]) => { - if (id === options.oldId) { - return [newId, true]; - } else { - return [id, true]; - } - })) - }]) - ), - [newId]: { - description: getDescription(newId, options.data.description), - scope: options.scope, - containedPermissionIds: typedFromEntries((options.data.contained_permission_ids ?? []).map(id => [id, true])) - } - } - }, - ) } }); @@ -360,29 +344,21 @@ export async function deletePermissionDefinition( } // Remove the permission from the config and update other permissions' containedPermissionIds - await globalTx.environmentConfigOverride.update({ - where: { - projectId_branchId: { - projectId: options.tenancy.project.id, - branchId: options.tenancy.branchId, - } - }, - data: { - config: override( - oldConfig, - { - "rbac.permissions": typedFromEntries( - typedEntries(oldConfig.rbac.permissions) - .filter(([id]) => id !== options.permissionId) - .map(([id, p]) => [id, { - ...p, - containedPermissionIds: typedFromEntries( - typedEntries(p.containedPermissionIds) - .filter(([containedId]) => containedId !== options.permissionId) - ) - }]) - ) - } + await overrideEnvironmentConfigOverride({ + branchId: options.tenancy.branchId, + projectId: options.tenancy.project.id, + tx: globalTx, + environmentConfigOverrideOverride: { + "rbac.permissions": typedFromEntries( + typedEntries(oldConfig.rbac.permissions) + .filter(([id]) => id !== options.permissionId) + .map(([id, p]) => [id, { + ...p, + containedPermissionIds: typedFromEntries( + typedEntries(p.containedPermissionIds) + .filter(([containedId]) => containedId !== options.permissionId) + ) + }]) ) } }); diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index b3706a73de..60619a6086 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -507,15 +507,16 @@ export async function sanitizeEnvironmentConfig Date: Mon, 28 Jul 2025 08:54:50 -0700 Subject: [PATCH 11/40] fix --- packages/stack-shared/src/schema-fields.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 345b69caf3..0e41465dfc 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -61,7 +61,7 @@ yup.addMethod(yup.string, "nonEmpty", function (message?: string) { }); yup.addMethod(yup.Schema, "hasNested", function (path: any) { - if (!path.match(/^[a-zA-Z_$:\-][a-zA-Z0-9_$:\-]*$/)) throw new StackAssertionError(`yupSchema.hasNested can currently only be used with alphanumeric keys, underscores, dollar signs, colons, and hyphens. Fix this in the future. Provided key: ${path}`); + if (!path.match(/^[a-zA-Z_$:-][a-zA-Z0-9_$:-]*$/)) throw new StackAssertionError(`yupSchema.hasNested can currently only be used with alphanumeric keys, underscores, dollar signs, colons, and hyphens. Fix this in the future. Provided key: ${path}`); const schemaInfo = this.meta()?.stackSchemaInfo as any; if (schemaInfo?.type === "record") { return schemaInfo.keySchema.isValidSync(path); @@ -81,7 +81,7 @@ yup.addMethod(yup.Schema, "hasNested", function (path: any) { }); yup.addMethod(yup.Schema, "getNested", function (path: any) { - if (!path.match(/^[a-zA-Z_$:\-][a-zA-Z0-9_$:\-]*$/)) throw new StackAssertionError(`yupSchema.getNested can currently only be used with alphanumeric keys, underscores, dollar signs, colons, and hyphens. Fix this in the future. Provided key: ${path}`); + if (!path.match(/^[a-zA-Z_$:-][a-zA-Z0-9_$:-]*$/)) throw new StackAssertionError(`yupSchema.getNested can currently only be used with alphanumeric keys, underscores, dollar signs, colons, and hyphens. Fix this in the future. Provided key: ${path}`); if (!this.hasNested(path as never)) throw new StackAssertionError(`Tried to call yupSchema.getNested, but key is not present in the schema. Provided key: ${path}`, { path, schema: this }); From 3d1046f0b8466c72432ce6485bf8e10b70664091 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 28 Jul 2025 08:56:14 -0700 Subject: [PATCH 12/40] more fixes --- apps/backend/src/lib/config.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 92f8ffd66f..741828c8cd 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -28,28 +28,28 @@ type OrganizationOptions = EnvironmentOptions & { organizationId: string | null export function getRenderedProjectConfigQuery(options: ProjectOptions): RawQuery> { return RawQuery.then( getIncompleteProjectConfigQuery(options), - async (incompleteConfig) => sanitizeProjectConfig(normalize(await applyProjectDefaults(await incompleteConfig), { onDotIntoNonObject: "ignore" }) as any), + async (incompleteConfig) => await sanitizeProjectConfig(normalize(applyProjectDefaults(await incompleteConfig), { onDotIntoNonObject: "ignore" }) as any), ); } export function getRenderedBranchConfigQuery(options: BranchOptions): RawQuery> { return RawQuery.then( getIncompleteBranchConfigQuery(options), - async (incompleteConfig) => sanitizeBranchConfig(normalize(await applyBranchDefaults(await incompleteConfig), { onDotIntoNonObject: "ignore" }) as any), + async (incompleteConfig) => await sanitizeBranchConfig(normalize(applyBranchDefaults(await incompleteConfig), { onDotIntoNonObject: "ignore" }) as any), ); } export function getRenderedEnvironmentConfigQuery(options: EnvironmentOptions): RawQuery> { return RawQuery.then( getIncompleteEnvironmentConfigQuery(options), - async (incompleteConfig) => sanitizeEnvironmentConfig(normalize(await applyEnvironmentDefaults(await incompleteConfig), { onDotIntoNonObject: "ignore" }) as any), + async (incompleteConfig) => await sanitizeEnvironmentConfig(normalize(applyEnvironmentDefaults(await incompleteConfig), { onDotIntoNonObject: "ignore" }) as any), ); } export function getRenderedOrganizationConfigQuery(options: OrganizationOptions): RawQuery> { return RawQuery.then( getIncompleteOrganizationConfigQuery(options), - async (incompleteConfig) => sanitizeOrganizationConfig(normalize(await applyOrganizationDefaults(await incompleteConfig), { onDotIntoNonObject: "ignore" }) as any), + async (incompleteConfig) => await sanitizeOrganizationConfig(normalize(applyOrganizationDefaults(await incompleteConfig), { onDotIntoNonObject: "ignore" }) as any), ); } From 5332b030f36a721f1ae498a607cac2ad7d78747c Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 28 Jul 2025 10:15:44 -0700 Subject: [PATCH 13/40] fixes --- packages/stack-shared/src/schema-fields.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 0e41465dfc..559a8e36e1 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -61,7 +61,7 @@ yup.addMethod(yup.string, "nonEmpty", function (message?: string) { }); yup.addMethod(yup.Schema, "hasNested", function (path: any) { - if (!path.match(/^[a-zA-Z_$:-][a-zA-Z0-9_$:-]*$/)) throw new StackAssertionError(`yupSchema.hasNested can currently only be used with alphanumeric keys, underscores, dollar signs, colons, and hyphens. Fix this in the future. Provided key: ${path}`); + if (!path.match(/^[a-zA-Z0-9_$:-]+$/)) throw new StackAssertionError(`yupSchema.hasNested can currently only be used with alphanumeric keys, underscores, dollar signs, colons, and hyphens. Fix this in the future. Provided key: ${JSON.stringify(path)}`); const schemaInfo = this.meta()?.stackSchemaInfo as any; if (schemaInfo?.type === "record") { return schemaInfo.keySchema.isValidSync(path); @@ -81,7 +81,7 @@ yup.addMethod(yup.Schema, "hasNested", function (path: any) { }); yup.addMethod(yup.Schema, "getNested", function (path: any) { - if (!path.match(/^[a-zA-Z_$:-][a-zA-Z0-9_$:-]*$/)) throw new StackAssertionError(`yupSchema.getNested can currently only be used with alphanumeric keys, underscores, dollar signs, colons, and hyphens. Fix this in the future. Provided key: ${path}`); + if (!path.match(/^[a-zA-Z0-9_$:-]+$/)) throw new StackAssertionError(`yupSchema.getNested can currently only be used with alphanumeric keys, underscores, dollar signs, colons, and hyphens. Fix this in the future. Provided key: ${path}`); if (!this.hasNested(path as never)) throw new StackAssertionError(`Tried to call yupSchema.getNested, but key is not present in the schema. Provided key: ${path}`, { path, schema: this }); From a70f59d6b0dec5aba0dae7c47dbd4a76ca1091f8 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 28 Jul 2025 14:18:17 -0700 Subject: [PATCH 14/40] more fixes --- apps/backend/src/lib/config.tsx | 1 + packages/stack-shared/src/config/schema.ts | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 741828c8cd..9bfba178ab 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -243,6 +243,7 @@ export async function overrideEnvironmentConfigOverride(options: { options.environmentConfigOverrideOverride, ); await assertNoConfigOverrideErrors(environmentConfigSchema, newConfig); + console.log({ newConfig, oldConfig, environmentConfigOverrideOverride: options.environmentConfigOverrideOverride }); await options.tx.environmentConfigOverride.upsert({ where: { projectId_branchId: { diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 60619a6086..1a4f7420ce 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -388,17 +388,26 @@ export type DeepReplaceAllowFunctionsForObjects = T extends object ? { [K in export type DeepReplaceFunctionsWithObjects = T extends (arg: infer K extends string) => infer R ? DeepReplaceFunctionsWithObjects> : (T extends object ? { [K in keyof T]: DeepReplaceFunctionsWithObjects } : T); export type ApplyDefaults unknown), C extends object> = {} extends D ? C : DeepMerge, C>; // the {} extends D makes TypeScript not recurse if the defaults are empty, hence allowing us more recursion until "type instantiation too deep" kicks in... it's a total hack, but it works, so hey? export function applyDefaults unknown), C extends object>(defaults: D, config: C): ApplyDefaults { - const res: any = typeof defaults === 'function' ? {} : mapValues(defaults, v => typeof v === 'function' ? {} : (typeof v === 'object' && v ? applyDefaults(v as any, {}) : v)); + const replaceFunctionsWithEmptyObjects = (obj: any): any => typeof obj === 'function' ? {} : mapValues(obj, v => typeof v === 'function' ? {} : (typeof v === 'object' && v ? replaceFunctionsWithEmptyObjects(v as any) : v)); + const res: any = replaceFunctionsWithEmptyObjects(defaults); + outer: for (const [key, mergeValue] of Object.entries(config)) { if (mergeValue === undefined) continue; const keyParts = key.split("."); let baseValue: any = defaults; - for (const part of keyParts) { - baseValue = typeof baseValue === 'function' ? baseValue(part) : (has(baseValue, part) ? get(baseValue, part) : undefined); + let currentRes: any = res; + for (const [index, part] of keyParts.entries()) { + if (typeof baseValue === 'function') { + baseValue = baseValue(part); + if (currentRes) set(currentRes, part, replaceFunctionsWithEmptyObjects(baseValue)); + } else { + baseValue = has(baseValue, part) ? get(baseValue, part) : undefined; + } if (baseValue === undefined || !isObjectLike(baseValue) || !isObjectLike(mergeValue)) { set(res, key, mergeValue); continue outer; } + if (currentRes) currentRes = has(currentRes, part) ? get(currentRes, part) : undefined; } set(res, key, applyDefaults(baseValue, mergeValue)); } @@ -407,19 +416,26 @@ export function applyDefaults unknown), C e import.meta.vitest?.test("applyDefaults", ({ expect }) => { // Basic expect(applyDefaults({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); + expect(applyDefaults({}, { a: 1 })).toEqual({ a: 1 }); expect(applyDefaults({ a: { b: 1 } }, { a: { b: 2 } })).toEqual({ a: { b: 2 } }); expect(applyDefaults({ a: { b: 1 } }, { a: { c: 2 } })).toEqual({ a: { b: 1, c: 2 } }); expect(applyDefaults({ a: { b: { c: 1, d: 2 } } }, { a: { b: { d: 3, e: 4 } } })).toEqual({ a: { b: { c: 1, d: 3, e: 4 } } }); // Functions expect(applyDefaults((key: string) => ({ b: key }), { a: {} })).toEqual({ a: { b: "a" } }); + expect(applyDefaults((key1: string) => (key2: string) => ({ a: key1, b: key2 }), { c: { d: {} } })).toEqual({ c: { d: { a: "c", b: "d" } } }); expect(applyDefaults({ a: (key: string) => ({ b: key }) }, { a: { c: { d: 1 } } })).toEqual({ a: { c: { b: "c", d: 1 } } }); expect(applyDefaults({ a: (key: string) => ({ b: key }) }, {})).toEqual({ a: {} }); expect(applyDefaults({ a: { b: (key: string) => ({ b: key }) } }, {})).toEqual({ a: { b: {} } }); // Dot notation expect(applyDefaults({ a: { b: 1 } }, { "a.c": 2 })).toEqual({ a: { b: 1 }, "a.c": 2 }); + expect(applyDefaults({ a: 1 }, { "a.b": 2 })).toEqual({ a: 1, "a.b": 2 }); + expect(applyDefaults({ a: null }, { "a.b": 2 })).toEqual({ a: null, "a.b": 2 }); expect(applyDefaults({ a: { b: { c: 1 } } }, { "a.b": { d: 2 } })).toEqual({ a: { b: { c: 1 } }, "a.b": { c: 1, d: 2 } }); + expect(applyDefaults({ a: { b: { c: { d: 1 } } } }, { "a.b.c": {} })).toEqual({ a: { b: { c: { d: 1 } } }, "a.b.c": { d: 1 } }); + expect(applyDefaults({ a: () => ({ c: 1 }) }, { "a.b": { d: 2 } })).toEqual({ a: { b: { c: 1 } }, "a.b": { c: 1, d: 2 } }); + expect(applyDefaults({ a: () => () => ({ d: 1 }) }, { "a.b.c": {} })).toEqual({ a: { b: { c: { d: 1 } } }, "a.b.c": { d: 1 } }); }); export function applyProjectDefaults(config: T) { From 805c39695bf76ad50fc7f5a47bedf135f00fceb4 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 28 Jul 2025 16:34:47 -0700 Subject: [PATCH 15/40] More fixes --- .vscode/settings.json | 1 + apps/backend/prisma/seed.ts | 26 +++--- apps/backend/src/lib/config.tsx | 14 ++- packages/stack-shared/src/config/schema.ts | 100 ++++++++++++++++++++- 4 files changed, 116 insertions(+), 25 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 797cfe4b8e..4aa2e10c4c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -94,6 +94,7 @@ "typehack", "Uncapitalize", "unindexed", + "Unmigrated", "unsubscribers", "upsert", "webapi", diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 8a8135d8ed..e9153dc6e6 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -162,24 +162,22 @@ async function seed() { } }); - console.log(`Added GitHub account for admin user`); - } - - await internalPrisma.authMethod.create({ - data: { - tenancyId: internalTenancy.id, - projectUserId: newUser.projectUserId, - oauthAuthMethod: { - create: { - projectUserId: newUser.projectUserId, - configOAuthProviderId: 'github', - providerAccountId: adminGithubId, + await internalPrisma.authMethod.create({ + data: { + tenancyId: internalTenancy.id, + projectUserId: newUser.projectUserId, + oauthAuthMethod: { + create: { + projectUserId: newUser.projectUserId, + configOAuthProviderId: 'github', + providerAccountId: adminGithubId, + } } } - } - }); + }); console.log(`Added admin user with GitHub ID ${adminGithubId}`); + } } } } diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 9bfba178ab..a1fc5cab05 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -1,6 +1,6 @@ import { Prisma } from "@prisma/client"; import { Config, getInvalidConfigReason, normalize, override } from "@stackframe/stack-shared/dist/config/format"; -import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaults, applyEnvironmentDefaults, applyOrganizationDefaults, applyProjectDefaults, assertNoConfigOverrideErrors, branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, organizationConfigSchema, projectConfigSchema, sanitizeBranchConfig, sanitizeEnvironmentConfig, sanitizeOrganizationConfig, sanitizeProjectConfig } from "@stackframe/stack-shared/dist/config/schema"; +import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaults, applyEnvironmentDefaults, applyOrganizationDefaults, applyProjectDefaults, assertNoConfigOverrideErrors, branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, migrateConfigOverride, organizationConfigSchema, projectConfigSchema, sanitizeBranchConfig, sanitizeEnvironmentConfig, sanitizeOrganizationConfig, sanitizeProjectConfig } from "@stackframe/stack-shared/dist/config/schema"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; import { isTruthy } from "@stackframe/stack-shared/dist/utils/booleans"; @@ -126,7 +126,7 @@ export function getProjectConfigOverrideQuery(options: ProjectOptions): RawQuery WHERE "Project"."id" = ${options.projectId} `, postProcess: async (queryResult) => { - return queryResult[0].projectConfigOverride ?? {}; + return migrateConfigOverride("project", queryResult[0].projectConfigOverride ?? {}); }, }; } @@ -141,7 +141,7 @@ export function getBranchConfigOverrideQuery(options: BranchOptions): RawQuery

{ - return {}; + return migrateConfigOverride("branch", {}); }, }; } @@ -160,10 +160,7 @@ export function getEnvironmentConfigOverrideQuery(options: EnvironmentOptions): if (queryResult.length > 1) { throw new StackAssertionError(`Expected 0 or 1 environment config overrides for project ${options.projectId} and branch ${options.branchId}, got ${queryResult.length}`, { queryResult }); } - if (queryResult.length === 0) { - return {}; - } - return queryResult[0].config; + return migrateConfigOverride("environment", queryResult[0]?.config ?? {}); }, }; } @@ -178,7 +175,7 @@ export function getOrganizationConfigOverrideQuery(options: OrganizationOptions) supportedPrismaClients: ["global"], sql: Prisma.sql`SELECT 1`, postProcess: async () => { - return {}; + return migrateConfigOverride("organization", {}); }, }; } @@ -243,7 +240,6 @@ export async function overrideEnvironmentConfigOverride(options: { options.environmentConfigOverrideOverride, ); await assertNoConfigOverrideErrors(environmentConfigSchema, newConfig); - console.log({ newConfig, oldConfig, environmentConfigOverrideOverride: options.environmentConfigOverrideOverride }); await options.tx.environmentConfigOverride.upsert({ where: { projectId_branchId: { diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 1a4f7420ce..feb9b722ba 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -4,10 +4,11 @@ import * as yup from "yup"; import { DEFAULT_EMAIL_TEMPLATES, DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_THEME_ID } from "../helpers/emails"; import * as schemaFields from "../schema-fields"; import { userSpecifiedIdSchema, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields"; +import { isShallowEqual } from "../utils/arrays"; import { SUPPORTED_CURRENCIES } from "../utils/currencies"; import { StackAssertionError } from "../utils/errors"; import { allProviders } from "../utils/oauth"; -import { DeepFilterUndefined, DeepMerge, DeepRequiredOrUndefined, filterUndefined, get, has, isObjectLike, mapValues, set, typedFromEntries } from "../utils/objects"; +import { DeepFilterUndefined, DeepMerge, DeepRequiredOrUndefined, deleteKey, filterUndefined, get, has, isObjectLike, mapValues, set, typedFromEntries } from "../utils/objects"; import { Result } from "../utils/results"; import { CollapseObjectUnion, Expand, IntersectAll, IsUnion, typeAssert, typeAssertExtends, typeAssertIs } from "../utils/types"; import { Config, NormalizationError, NormalizesTo, assertNormalized, getInvalidConfigReason, normalize } from "./format"; @@ -235,7 +236,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ domains: branchConfigSchema.getNested("domains").concat(yupObject({ trustedDomains: yupRecord( - yupString().uuid(), + yupString(), yupObject({ baseUrl: schemaFields.urlSchema, handlerPath: schemaFields.handlerPathSchema, @@ -247,6 +248,101 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ export const organizationConfigSchema = environmentConfigSchema.concat(yupObject({})); +// Migration functions +// +// These are used to migrate old config overrides to the new format on the database. +// +// THEY SHOULD NOT BE USED FOR ANY OTHER PURPOSE. They should not be used for default values. They should not be used +// for sanitization. Instead, use the applicable functions for that. +// +// We run these migrations over the database when we do a big migration. USE THESE SPARINGLY. USE OTHER METHODS WHENEVER +// POSSIBLE. +// +// The result of this function should be reproducible, and should not contain ANY randomness/non-determinism. +export function migrateConfigOverride(type: "project" | "branch" | "environment" | "organization", oldUnmigratedConfigOverride: any): any { + const isBranchOrHigher = ["branch", "environment", "organization"].includes(type); + const isEnvironmentOrHigher = ["environment", "organization"].includes(type); + + let res = oldUnmigratedConfigOverride; + + // BEGIN 2025-07-28: emails.theme is now emails.selectedThemeId + if (isBranchOrHigher) { + res = renameProperty(res, "emails.theme", "emails.selectedThemeId"); + } + // END + + // BEGIN 2025-07-28: domains.trustedDomains can no longer be an array + if (isEnvironmentOrHigher) { + res = mapProperty(res, "domains.trustedDomains", (value) => { + if (Array.isArray(value)) { + return typedFromEntries(value.map((v, i) => [`${i}`, v])); + } + return value; + }); + } + // END + + + // return the result + return res; +}; + +function mapProperty(obj: any, path: string, mapper: (value: any) => any): any { + const keyParts = path.split("."); + + for (let i = 0; i < keyParts.length; i++) { + const pathPrefix = keyParts.slice(0, i).join("."); + const pathSuffix = keyParts.slice(i).join("."); + if (has(obj, pathPrefix) && isObjectLike(get(obj, pathPrefix))) { + set(obj, pathPrefix, mapProperty(get(obj, pathPrefix), pathSuffix, mapper)); + } + } + if (has(obj, path)) { + set(obj, path, mapper(get(obj, path))); + } + + return obj; +} +import.meta.vitest?.test("mapProperty - basic property mapping", ({ expect }) => { + expect(mapProperty({ a: { b: { c: 1 } } }, "a.b.c", (value) => value + 1)).toEqual({ a: { b: { c: 2 } } }); + expect(mapProperty({ a: { b: { c: 1 } } }, "a.b.d", (value) => value + 1)).toEqual({ a: { b: { c: 1 } } }); + expect(mapProperty({ x: 5 }, "x", (value) => value * 2)).toEqual({ x: 10 }); + expect(mapProperty({ a: { b: { c: 1 } } }, "b.c", (value) => value * 10)).toEqual({ a: { b: { c: 1 } } }); + expect(mapProperty({ a: 1 }, "b.c", (value) => value)).toEqual({ a: 1 }); +}); + +function renameProperty(obj: any, oldPath: string, newPath: string): any { + const oldKeyParts = oldPath.split("."); + const newKeyParts = newPath.split("."); + if (!isShallowEqual(oldKeyParts.slice(0, -1), newKeyParts.slice(0, -1))) throw new StackAssertionError(`oldPath and newPath must have the same prefix. Provided: ${oldPath} and ${newPath}`); + + for (let i = 0; i < oldKeyParts.length; i++) { + const pathPrefix = oldKeyParts.slice(0, i).join("."); + const oldPathSuffix = oldKeyParts.slice(i).join("."); + const newPathSuffix = newKeyParts.slice(i).join("."); + if (has(obj, pathPrefix) && isObjectLike(get(obj, pathPrefix))) { + set(obj, pathPrefix, renameProperty(get(obj, pathPrefix), oldPathSuffix, newPathSuffix)); + } + } + if (has(obj, oldPath)) { + set(obj, newPath, get(obj, oldPath)); + deleteKey(obj, oldPath); + } + + return obj; +} +import.meta.vitest?.test("renameProperty", ({ expect }) => { + // Basic + expect(renameProperty({ a: 1 }, "a", "b")).toEqual({ b: 1 }); + expect(renameProperty({ b: { c: 1 } }, "b.c", "b.d")).toEqual({ b: { d: 1 } }); + expect(renameProperty({ a: { b: { c: 1 } } }, "a.b.c", "a.b.d")).toEqual({ a: { b: { d: 1 } } }); + expect(renameProperty({ a: { b: { c: 1 } } }, "a.b.c.d", "a.b.c.e")).toEqual({ a: { b: { c: 1 } } }); + + // Errors + expect(() => renameProperty({ a: 1 }, "a", "b.c")).toThrow(); +}); + + // 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 From 00116c9c619fd1f4cb6182b9d817968f4b48a0b6 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 29 Jul 2025 01:01:13 -0700 Subject: [PATCH 16/40] more fixes --- apps/backend/scripts/verify-data-integrity.ts | 9 +++++++ .../src/route-handlers/smart-request.tsx | 2 +- packages/stack-shared/src/config/schema.ts | 26 +++++++++++++++++-- packages/stack-shared/src/helpers/emails.ts | 15 +++++++---- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/backend/scripts/verify-data-integrity.ts b/apps/backend/scripts/verify-data-integrity.ts index 4cbcf54f41..2aeaef02a4 100644 --- a/apps/backend/scripts/verify-data-integrity.ts +++ b/apps/backend/scripts/verify-data-integrity.ts @@ -75,11 +75,15 @@ async function main() { const skipUsers = flags.includes("--skip-users"); const shouldSaveOutput = flags.includes("--save-output"); const shouldVerifyOutput = flags.includes("--verify-output"); + const shouldSkipNeon = flags.includes("--skip-neon"); if (shouldSaveOutput) { console.log(`Will save output to ${OUTPUT_FILE_PATH}`); } + if (shouldSkipNeon) { + console.log(`Will skip Neon projects.`); + } if (shouldVerifyOutput) { if (!fs.existsSync(OUTPUT_FILE_PATH)) { @@ -111,6 +115,7 @@ async function main() { select: { id: true, displayName: true, + description: true, }, orderBy: { id: "asc", @@ -127,6 +132,10 @@ async function main() { for (let i = startAt; i < endAt; i++) { const projectId = projects[i].id; await recurse(`[project ${(i + 1) - startAt}/${endAt - startAt}] ${projectId} ${projects[i].displayName}`, async (recurse) => { + if (shouldSkipNeon && projects[i].description.includes("Neon")) { + return; + } + const [currentProject, users, projectPermissionDefinitions, teamPermissionDefinitions] = await Promise.all([ expectStatusCode(200, `/api/v1/internal/projects/current`, { method: "GET", diff --git a/apps/backend/src/route-handlers/smart-request.tsx b/apps/backend/src/route-handlers/smart-request.tsx index a114a23056..e6578e8504 100644 --- a/apps/backend/src/route-handlers/smart-request.tsx +++ b/apps/backend/src/route-handlers/smart-request.tsx @@ -259,7 +259,7 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque const tenancy = req.method === "GET" && req.url.endsWith("/users/me") ? "tenancy not available in /users/me as a performance hack" as never : await getSoleTenancyFromProjectBranch(projectId, branchId, true); if (developmentKeyOverride) { - if (!["development", "test"].includes(getNodeEnvironment()) && getEnvVariable("STACK_ALLOW_DEVELOPMENT_KEY_OVERRIDE_DESPITE_PRODUCTION", "") === "this-is-dangerous") { // it's not actually that dangerous, but it changes the security model + if (!["development", "test"].includes(getNodeEnvironment()) && getEnvVariable("STACK_ALLOW_DEVELOPMENT_KEY_OVERRIDE_DESPITE_PRODUCTION", "") !== "this-is-dangerous") { // it's not actually that dangerous, but it changes the security model throw new StatusError(401, "Development key override is only allowed in development or test environments"); } const result = await checkApiKeySet("internal", { superSecretAdminKey: developmentKeyOverride }); diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index feb9b722ba..8459aa67e2 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -282,11 +282,27 @@ export function migrateConfigOverride(type: "project" | "branch" | "environment" } // END + // BEGIN 2025-07-28: themeList and templateList have been renamed (this was before the release, so they're safe to remove) + if (isBranchOrHigher) { + res = removeProperty(res, "emails.themeList"); + res = removeProperty(res, "emails.templateList"); + } + // END + + // BEGIN 2025-07-28: sourceOfTruth was mistakenly written to the environment config in some cases, so let's remove it + if (type === "environment") { + res = removeProperty(res, "sourceOfTruth"); + } + // END // return the result return res; }; +function removeProperty(obj: any, path: string): any { + return mapProperty(obj, path, () => undefined); +} + function mapProperty(obj: any, path: string, mapper: (value: any) => any): any { const keyParts = path.split("."); @@ -294,11 +310,17 @@ function mapProperty(obj: any, path: string, mapper: (value: any) => any): any { const pathPrefix = keyParts.slice(0, i).join("."); const pathSuffix = keyParts.slice(i).join("."); if (has(obj, pathPrefix) && isObjectLike(get(obj, pathPrefix))) { - set(obj, pathPrefix, mapProperty(get(obj, pathPrefix), pathSuffix, mapper)); + const newValue = mapProperty(get(obj, pathPrefix), pathSuffix, mapper); + set(obj, pathPrefix, newValue); } } if (has(obj, path)) { - set(obj, path, mapper(get(obj, path))); + const newValue = mapper(get(obj, path)); + if (newValue !== undefined) { + set(obj, path, newValue); + } else { + deleteKey(obj, path); + } } return obj; diff --git a/packages/stack-shared/src/helpers/emails.ts b/packages/stack-shared/src/helpers/emails.ts index 2e65fb5382..d56dcfb808 100644 --- a/packages/stack-shared/src/helpers/emails.ts +++ b/packages/stack-shared/src/helpers/emails.ts @@ -85,23 +85,28 @@ const EMAIL_TEMPLATE_SIGN_IN_INVITATION_ID = "066dd73c-36da-4fd0-b6d6-ebf87683f8 export const DEFAULT_EMAIL_TEMPLATES = { [EMAIL_TEMPLATE_EMAIL_VERIFICATION_ID]: { "displayName": "Email Verification", - "tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory } from \"@stackframe/emails\";\n\nexport const schema = type({\n userDisplayName: \"string\",\n projectDisplayName: \"string\",\n emailVerificationLink: \"string\"\n})\n\nexport function EmailTemplate({ \n userDisplayName, \n projectDisplayName, \n emailVerificationLink \n}: typeof schema.infer) \n{\n return (\n <>\n \n \n

\n
\n

\n Verify your email at {projectDisplayName}\n

\n

\n Hi{userDisplayName ? (\", \" + userDisplayName) : ''}! Please click on the following button to verify your email.\n

\n
\n \n Verify my email\n \n
\n
\n
\n
\n

\n If you were not expecting this email, you can safely ignore it. \n

\n
\n
\n \n )\n}\n", + "tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\nexport const variablesSchema = type({\n emailVerificationLink: \"string\"\n})\n\nexport function EmailTemplate({ user, project, variables }: Props) {\n return (\n <>\n \n \n
\n
\n

\n Verify your email at {project.displayName}\n

\n

\n Hi{user.displayName ? (\", \" + user.displayName) : ''}! Please click on the following button to verify your email.\n

\n
\n \n Verify my email\n \n
\n
\n
\n
\n

\n If you were not expecting this email, you can safely ignore it. \n

\n
\n
\n \n )\n}\n\nEmailTemplate.PreviewVariables = {\n emailVerificationLink: \"\"\n} satisfies typeof variablesSchema.infer", + "themeId": undefined, }, [EMAIL_TEMPLATE_PASSWORD_RESET_ID]: { "displayName": "Password Reset", - "tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\"\nimport { Subject, NotificationCategory } from \"@stackframe/emails\"\n\nexport const schema = type({\n userDisplayName: \"string\",\n projectDisplayName: \"string\",\n passwordResetLink: \"string\"\n})\n\nexport function EmailTemplate({ userDisplayName, projectDisplayName, passwordResetLink }: typeof schema.infer) {\n return (\n <>\n \n \n
\n
\n

\n Reset your password at {projectDisplayName}\n

\n\n

\n Hi{userDisplayName ? (\", \" + userDisplayName) : \"\"}! Please click on the following button to start the password reset process.\n

\n\n
\n \n Reset my password\n \n
\n\n
\n
\n
\n\n

\n If you were not expecting this email, you can safely ignore it.\n

\n
\n
\n \n )\n}\n", + "tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\"\nimport { Subject, NotificationCategory, Props} from \"@stackframe/emails\"\n\nexport const variablesSchema = type({\n passwordResetLink: \"string\"\n})\n\nexport function EmailTemplate({ user, project, variables }: Props) {\n return (\n <>\n \n \n
\n
\n

\n Reset your password at {project.displayName}\n

\n\n

\n Hi{user.displayName ? (\", \" + user.displayName) : \"\"}! Please click on the following button to start the password reset process.\n

\n\n
\n \n Reset my password\n \n
\n\n
\n
\n
\n\n

\n If you were not expecting this email, you can safely ignore it.\n

\n
\n
\n \n )\n}\n\nEmailTemplate.PreviewVariables = {\n passwordResetLink: \"\"\n} satisfies typeof variablesSchema.infer", + "themeId": undefined, }, [EMAIL_TEMPLATE_MAGIC_LINK_ID]: { "displayName": "Magic Link/OTP", - "tsxSource": "import React from 'react';\nimport { type } from 'arktype';\nimport { Section, Hr } from '@react-email/components';\nimport { Subject, NotificationCategory } from '@stackframe/emails';\n\nexport const schema = type({\n userDisplayName: 'string',\n projectDisplayName: 'string',\n magicLink: 'string',\n otp: 'string',\n});\n\nexport function EmailTemplate({ userDisplayName, projectDisplayName, magicLink, otp }: typeof schema.infer) {\n return (\n <>\n \n \n
\n
\n

\n Sign in to {projectDisplayName}\n

\n

\n Hi{userDisplayName ? \", \" + userDisplayName : \"\"}! This is your one-time-password for signing in:\n

\n

\n {otp}\n

\n

\n Or you can click on{' '}\n \n this link\n {' '}\n to sign in\n

\n
\n

\n If you were not expecting this email, you can safely ignore it.\n

\n
\n
\n \n );\n}\n", + "tsxSource": "import { type } from 'arktype';\nimport { Section, Hr } from '@react-email/components';\nimport { Subject, NotificationCategory, Props } from '@stackframe/emails';\n\nexport const variablesSchema = type({\n magicLink: 'string',\n otp: 'string',\n});\n\nexport function EmailTemplate({ user, project, variables }: Props) {\n return (\n <>\n \n \n
\n
\n

\n Sign in to {project.displayName}\n

\n

\n Hi{user.displayName ? \", \" + user.displayName : \"\"}! This is your one-time-password for signing in:\n

\n

\n {variables.otp}\n

\n

\n Or you can click on{' '}\n \n this link\n {' '}\n to sign in\n

\n
\n

\n If you were not expecting this email, you can safely ignore it.\n

\n
\n
\n \n );\n}\n\nEmailTemplate.PreviewVariables = {\n magicLink: \"\",\n otp: \"3SLSWZ\"\n} satisfies typeof variablesSchema.infer", + "themeId": undefined, }, [EMAIL_TEMPLATE_TEAM_INVITATION_ID]: { "displayName": "Team Invitation", - "tsxSource": "import { type } from \"arktype\";\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory } from \"@stackframe/emails\";\n\n\nexport const schema = type({\n userDisplayName: \"string\",\n teamDisplayName: \"string\",\n teamInvitationLink: \"string\"\n});\n\nexport function EmailTemplate({ userDisplayName, teamDisplayName, teamInvitationLink }: typeof schema.infer) {\n return (\n <>\n \n \n
\n
\n

\n You are invited to {teamDisplayName}\n

\n

\n Hi{userDisplayName ? \", \" + userDisplayName : \"\"}! Please click the button below to join the team {teamDisplayName}\n

\n
\n \n Join team\n \n
\n
\n
\n
\n

\n If you were not expecting this email, you can safely ignore it.\n

\n
\n
\n \n );\n}\n", + "tsxSource": "import { type } from \"arktype\";\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\n\nexport const variablesSchema = type({\n teamDisplayName: \"string\",\n teamInvitationLink: \"string\"\n});\n\nexport function EmailTemplate({ user, variables }: Props) {\n return (\n <>\n \n \n
\n
\n

\n You are invited to {variables.teamDisplayName}\n

\n

\n Hi{user.displayName ? \", \" + user.displayName : \"\"}! Please click the button below to join the team {variables.teamDisplayName}\n

\n
\n \n Join team\n \n
\n
\n
\n
\n

\n If you were not expecting this email, you can safely ignore it.\n

\n
\n
\n \n );\n}\n\nEmailTemplate.PreviewVariables = {\n teamDisplayName: \"My Team\",\n teamInvitationLink: \"\"\n} satisfies typeof variablesSchema.infer ", + "themeId": undefined, }, [EMAIL_TEMPLATE_SIGN_IN_INVITATION_ID]: { "displayName": "Sign In Invitation", - "tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory } from \"@stackframe/emails\";\n\nexport const schema = type({\n userDisplayName: \"string\",\n projectDisplayName: \"string\",\n signInInvitationLink: \"string\",\n teamDisplayName: \"string\"\n})\n\nexport function EmailTemplate({\n userDisplayName,\n projectDisplayName,\n signInInvitationLink,\n teamDisplayName\n}: typeof schema.infer) {\n return (\n <>\n \n \n\n
\n
\n

\n You are invited to sign in to {teamDisplayName}\n

\n\n

\n Hi\n {userDisplayName ? \", \" + userDisplayName : \"\"}! Please click on the following\n link to sign in to your account\n

\n\n
\n \n Sign in\n \n
\n\n
\n
\n
\n\n

\n If you were not expecting this email, you can safely ignore it.\n

\n
\n
\n \n )\n}\n", + "tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\nexport const variablesSchema = type({\n signInInvitationLink: \"string\",\n teamDisplayName: \"string\"\n})\n\nexport function EmailTemplate({ user, project, variables }: Props) {\n return (\n <>\n \n \n\n
\n
\n

\n You are invited to sign in to {variables.teamDisplayName}\n

\n\n

\n Hi\n {user.displayName ? \", \" + user.displayName : \"\"}! Please click on the following\n link to sign in to your account\n

\n\n
\n \n Sign in\n \n
\n\n
\n
\n
\n\n

\n If you were not expecting this email, you can safely ignore it.\n

\n
\n
\n \n )\n}\n\nEmailTemplate.PreviewVariables = {\n signInInvitationLink: \"\",\n teamDisplayName: \"My Team\"\n} satisfies typeof variablesSchema.infer", + "themeId": undefined, } }; From 701987a462921a2ee29965ba8e8ab557511124dd Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 29 Jul 2025 12:14:42 -0700 Subject: [PATCH 17/40] more --- .../app/api/latest/auth/sessions/route.tsx | 6 +- .../send-verification-code/route.tsx | 8 +- .../items/[customer_id]/[item_id]/route.ts | 58 ++++++++++++++ .../[offer_id]/create-purchase-url/route.ts | 51 ++++++++++++ apps/backend/src/lib/payments.tsx | 68 ++++++++++++++++ packages/stack-shared/src/config/schema.ts | 39 ++++++--- packages/stack-shared/src/known-errors.tsx | 79 ++++++++++++++++++- packages/stack-shared/src/schema-fields.ts | 2 +- packages/stack-shared/src/utils/types.tsx | 12 ++- 9 files changed, 304 insertions(+), 19 deletions(-) create mode 100644 apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts create mode 100644 apps/backend/src/app/api/latest/payments/purchases/[customer_id]/[offer_id]/create-purchase-url/route.ts create mode 100644 apps/backend/src/lib/payments.tsx diff --git a/apps/backend/src/app/api/latest/auth/sessions/route.tsx b/apps/backend/src/app/api/latest/auth/sessions/route.tsx index e2607ca5b0..df8ba8c363 100644 --- a/apps/backend/src/app/api/latest/auth/sessions/route.tsx +++ b/apps/backend/src/app/api/latest/auth/sessions/route.tsx @@ -1,5 +1,4 @@ import { createAuthTokens } from "@/lib/tokens"; -import { CrudHandlerInvocationError } from "@/route-handlers/crud-handler"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, serverOrHigherAuthTypeSchema, userIdOrMeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; @@ -37,9 +36,12 @@ export const POST = createSmartRouteHandler({ user = await usersCrudHandlers.adminRead({ user_id: userId, tenancy: tenancy, + allowedErrorTypes: [ + KnownErrors.UserNotFound, + ], }); } catch (e) { - if (e instanceof CrudHandlerInvocationError && KnownErrors.UserNotFound.isInstance(e.cause)) { + if (KnownErrors.UserNotFound.isInstance(e)) { throw new KnownErrors.UserIdDoesNotExist(userId); } throw e; diff --git a/apps/backend/src/app/api/latest/contact-channels/[user_id]/[contact_channel_id]/send-verification-code/route.tsx b/apps/backend/src/app/api/latest/contact-channels/[user_id]/[contact_channel_id]/send-verification-code/route.tsx index 9b5e93dc69..4fe4590dc1 100644 --- a/apps/backend/src/app/api/latest/contact-channels/[user_id]/[contact_channel_id]/send-verification-code/route.tsx +++ b/apps/backend/src/app/api/latest/contact-channels/[user_id]/[contact_channel_id]/send-verification-code/route.tsx @@ -1,6 +1,5 @@ import { usersCrudHandlers } from "@/app/api/latest/users/crud"; import { getPrismaClientForTenancy } from "@/prisma-client"; -import { CrudHandlerInvocationError } from "@/route-handlers/crud-handler"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, clientOrHigherAuthTypeSchema, contactChannelIdSchema, emailVerificationCallbackUrlSchema, userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; @@ -43,10 +42,13 @@ export const POST = createSmartRouteHandler({ try { user = await usersCrudHandlers.adminRead({ tenancy: auth.tenancy, - user_id: params.user_id + user_id: params.user_id, + allowedErrorTypes: [ + KnownErrors.UserNotFound, + ], }); } catch (e) { - if (e instanceof CrudHandlerInvocationError && KnownErrors.UserNotFound.isInstance(e.cause)) { + if (KnownErrors.UserNotFound.isInstance(e)) { throw new KnownErrors.UserIdDoesNotExist(params.user_id); } throw e; diff --git a/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts b/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts new file mode 100644 index 0000000000..ceeea42c63 --- /dev/null +++ b/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts @@ -0,0 +1,58 @@ +import { ensureItemCustomerTypeMatches } from "@/lib/payments"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + customer_id: yupString().defined(), + item_id: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + id: yupString().defined(), + displayName: yupString().defined(), + quantity: yupNumber().defined(), + }).defined(), + }), + handler: async (req) => { + const { project, tenancy } = req.auth; + const paymentsConfig = tenancy.completeConfig.payments; + + const itemConfig = getOrUndefined(paymentsConfig.items, req.params.item_id); + if (!itemConfig) { + throw new KnownErrors.ItemNotFound(req.params.item_id); + } + + await ensureItemCustomerTypeMatches(req.params.item_id, itemConfig.customerType, req.params.customer_id, tenancy); + + + // TODO: calculate the total quantity of the item for the customer + const totalQuantity = throwErr("TODO unimplemented"); + + + return { + statusCode: 200, + bodyType: "json", + body: { + id: req.params.item_id, + displayName: itemConfig.displayName, + quantity: totalQuantity, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/payments/purchases/[customer_id]/[offer_id]/create-purchase-url/route.ts b/apps/backend/src/app/api/latest/payments/purchases/[customer_id]/[offer_id]/create-purchase-url/route.ts new file mode 100644 index 0000000000..b62128a56a --- /dev/null +++ b/apps/backend/src/app/api/latest/payments/purchases/[customer_id]/[offer_id]/create-purchase-url/route.ts @@ -0,0 +1,51 @@ +import { ensureOfferCustomerTypeMatches } from "@/lib/payments"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + customer_id: yupString().defined(), + offer_id: yupString().defined(), + }), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + url: yupString().defined(), + }).defined(), + }), + handler: async (req) => { + const { project, tenancy } = req.auth; + + const offerConfig = getOrUndefined(tenancy.completeConfig.payments.offers, req.params.offer_id); + if (!offerConfig || (offerConfig.serverOnly && req.auth.type === "client")) { + throw new KnownErrors.OfferDoesNotExist(req.params.offer_id, req.auth.type); + } + + await ensureOfferCustomerTypeMatches(req.params.offer_id, offerConfig.customerType, req.params.customer_id, tenancy); + + // TODO implement + const url = throwErr(new StackAssertionError("unimplemented")); + + return { + statusCode: 200, + bodyType: "json", + body: { + url, + }, + }; + }, +}); diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx new file mode 100644 index 0000000000..e934969974 --- /dev/null +++ b/apps/backend/src/lib/payments.tsx @@ -0,0 +1,68 @@ +import { teamsCrudHandlers } from "@/app/api/latest/teams/crud"; +import { usersCrudHandlers } from "@/app/api/latest/users/crud"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { Tenancy } from "./tenancies"; + +export async function ensureItemCustomerTypeMatches(itemId: string, itemCustomerType: "user" | "team" | undefined, customerId: string, tenancy: Tenancy) { + const actualCustomerType = await getCustomerType(tenancy, customerId); + if (itemCustomerType !== actualCustomerType) { + throw new KnownErrors.ItemCustomerTypeDoesNotMatch(itemId, customerId, itemCustomerType, actualCustomerType); + } +} + +export async function ensureOfferCustomerTypeMatches(offerId: string, offerCustomerType: "user" | "team" | undefined, customerId: string, tenancy: Tenancy) { + const actualCustomerType = await getCustomerType(tenancy, customerId); + if (offerCustomerType !== actualCustomerType) { + throw new KnownErrors.OfferCustomerTypeDoesNotMatch(offerId, customerId, offerCustomerType, actualCustomerType); + } +} + +export async function getCustomerType(tenancy: Tenancy, customerId: string) { + let user; + try { + user = await usersCrudHandlers.adminRead( + { + user_id: customerId, + tenancy, + allowedErrorTypes: [ + KnownErrors.UserNotFound, + ], + } + ); + } catch (e) { + if (KnownErrors.UserNotFound.isInstance(e)) { + user = null; + } else { + throw e; + } + } + let team; + try { + team = await teamsCrudHandlers.adminRead({ + team_id: customerId, + tenancy, + allowedErrorTypes: [ + KnownErrors.TeamNotFound, + ], + }); + } catch (e) { + if (KnownErrors.TeamNotFound.isInstance(e)) { + team = null; + } else { + throw e; + } + } + + if (user && team) { + throw new StackAssertionError("Found a customer that is both user and team at the same time? This should never happen!", { customerId, user, team, tenancy }); + } + + if (user) { + return "user"; + } + if (team) { + return "team"; + } + throw new KnownErrors.CustomerDoesNotExist(customerId); +} diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index ac74797a4f..250d8d9ad9 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -127,6 +127,7 @@ const branchPaymentsSchema = yupObject({ offers: yupRecord( userSpecifiedIdSchema("offerId"), yupObject({ + displayName: yupString(), customerType: schemaFields.customerTypeSchema, freeTrial: schemaFields.dayIntervalSchema.optional(), serverOnly: yupBoolean(), @@ -159,6 +160,7 @@ const branchPaymentsSchema = yupObject({ items: yupRecord( userSpecifiedIdSchema("itemId"), yupObject({ + displayName: yupString().optional(), customerType: schemaFields.customerTypeSchema, default: yupObject({ quantity: yupNumber(), @@ -385,14 +387,14 @@ const environmentConfigDefaults = {} as const satisfies DefaultsType ({ - containedPermissionIds: {}, + containedPermissionIds: (key: string) => undefined, description: undefined, scope: undefined, }), defaultPermissions: { - teamCreator: {}, - teamMember: {}, - signUp: {}, + teamCreator: (key: string) => undefined, + teamMember: (key: string) => undefined, + signUp: (key: string) => undefined, }, }, @@ -417,7 +419,7 @@ const organizationConfigDefaults = { trustedDomains: (key: string) => ({ baseUrl: undefined, handlerPath: '/handler', - }), + }) as const, }, auth: { @@ -470,8 +472,9 @@ const organizationConfigDefaults = { payments: { autoPay: undefined, - exclusivityGroups: {}, + exclusivityGroups: (key: string) => (key: string) => undefined, offers: (key: string) => ({ + displayName: key, customerType: undefined, freeTrial: undefined, serverOnly: false, @@ -484,11 +487,18 @@ const organizationConfigDefaults = { }), items: (key: string) => ({ quantity: undefined, - repeat: undefined, + repeat: "never", expires: "when-repeated", }), - }), - items: {}, + } as const), + items: (key: string) => ({ + displayName: key, + default: { + quantity: 0, + expires: "when-repeated", + repeat: "never", + }, + } as const), }, } as const satisfies DefaultsType; @@ -500,9 +510,16 @@ type _DeepOmitDefaultsImpl = T extends object ? ( ) : T; type DeepOmitDefaults = _DeepOmitDefaultsImpl, U>; type DefaultsType = DeepReplaceAllowFunctionsForObjects, IntersectAll<{ [K in keyof U]: DeepReplaceFunctionsWithObjects }>>>; -typeAssertIs, c: 456 } }, [{ a: { c: 456 } }]>, { a: { b: Record | ((key: string) => 123) } }>()(); +typeAssertIs, c: 456 } }, [{ a: { c: 456 } }]>, { a: { b: ((key: string) => 123) | Record & ((key: string) => 123) } }>()(); -type DeepReplaceAllowFunctionsForObjects = T extends object ? { [K in keyof T]: DeepReplaceAllowFunctionsForObjects } | (string extends keyof T ? (arg: Exclude) => DeepReplaceAllowFunctionsForObjects : never) : T; +type DeepReplaceAllowFunctionsForObjects = T extends object + ? ( + string extends keyof T + ? ((arg: Exclude) => DeepReplaceAllowFunctionsForObjects) & ({ [K in keyof T]?: DeepReplaceAllowFunctionsForObjects } | {}) + : { [K in keyof T]: DeepReplaceAllowFunctionsForObjects } + ) + : + T; type ReplaceFunctionsWithObjects = T & (T extends (arg: infer K extends string) => infer R ? Record & object : unknown); type DeepReplaceFunctionsWithObjects = T extends object ? { [K in keyof ReplaceFunctionsWithObjects]: DeepReplaceFunctionsWithObjects[K]> } : T; typeAssertIs number) }>, { a: { b: 123, [key: string]: number } }>()(); diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index ea33574827..3adbb0282f 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -1414,6 +1414,78 @@ const RequiresCustomEmailServer = createKnownErrorConstructor( () => [] as const, ); +const ItemNotFound = createKnownErrorConstructor( + KnownError, + "ITEM_NOT_FOUND", + (itemId: string) => [ + 404, + `Item with ID "${itemId}" not found.`, + { + item_id: itemId, + }, + ] as const, + (json) => [json.item_id] as const, +); + +const ItemCustomerTypeDoesNotMatch = createKnownErrorConstructor( + KnownError, + "ITEM_CUSTOMER_TYPE_DOES_NOT_MATCH", + (itemId: string, customerId: string, itemCustomerType: "user" | "team" | undefined, actualCustomerType: "user" | "team") => [ + 400, + `The ${actualCustomerType} with ID ${JSON.stringify(customerId)} is not a valid customer for the item with ID ${JSON.stringify(itemId)}. ${itemCustomerType ? `The item is configured to only be available for ${itemCustomerType} customers, but the customer is a ${actualCustomerType}.` : `The item is missing a customer type field. Please make sure it is set up correctly in your project configuration.`}`, + { + item_id: itemId, + customer_id: customerId, + item_customer_type: itemCustomerType ?? null, + actual_customer_type: actualCustomerType, + }, + ] as const, + (json) => [json.item_id, json.customer_id, json.item_customer_type ?? undefined, json.actual_customer_type] as const, +); + +const CustomerDoesNotExist = createKnownErrorConstructor( + KnownError, + "CUSTOMER_DOES_NOT_EXIST", + (customerId: string) => [ + 400, + `Customer with ID ${JSON.stringify(customerId)} does not exist.`, + { + customer_id: customerId, + }, + ] as const, + (json) => [json.customer_id] as const, +); + +const OfferDoesNotExist = createKnownErrorConstructor( + KnownError, + "OFFER_DOES_NOT_EXIST", + (offerId: string, accessType: "client" | "server" | "admin") => [ + 400, + `Offer with ID ${JSON.stringify(offerId)} does not exist${accessType === "client" ? " or you don't have permissions to access it." : "."}`, + { + offer_id: offerId, + access_type: accessType, + }, + ] as const, + (json) => [json.offer_id, json.access_type] as const, +); + +const OfferCustomerTypeDoesNotMatch = createKnownErrorConstructor( + KnownError, + "OFFER_CUSTOMER_TYPE_DOES_NOT_MATCH", + (offerId: string, customerId: string, offerCustomerType: "user" | "team" | undefined, actualCustomerType: "user" | "team") => [ + 400, + `The ${actualCustomerType} with ID ${JSON.stringify(customerId)} is not a valid customer for the offer with ID ${JSON.stringify(offerId)}. ${offerCustomerType ? `The offer is configured to only be available for ${offerCustomerType} customers, but the customer is a ${actualCustomerType}.` : `The offer is missing a customer type field. Please make sure it is set up correctly in your project configuration.`}`, + { + offer_id: offerId, + customer_id: customerId, + offer_customer_type: offerCustomerType ?? null, + actual_customer_type: actualCustomerType, + }, + ] as const, + (json) => [json.offer_id, json.customer_id, json.offer_customer_type ?? undefined, json.actual_customer_type] as const, +); + export type KnownErrors = { [K in keyof typeof KnownErrors]: InstanceType; @@ -1528,7 +1600,12 @@ export const KnownErrors = { ApiKeyRevoked, WrongApiKeyType, EmailRenderingError, - RequiresCustomEmailServer + RequiresCustomEmailServer, + ItemNotFound, + ItemCustomerTypeDoesNotMatch, + CustomerDoesNotExist, + OfferDoesNotExist, + OfferCustomerTypeDoesNotMatch, } satisfies Record>; diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 559a8e36e1..f60f036287 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -488,7 +488,7 @@ export const emailTemplateListSchema = yupRecord( ).meta({ openapiField: { description: 'Record of email template IDs to their display name and source code' } }); // Payments -export const customerTypeSchema = yupString().oneOf(['user', 'team', 'organization']); +export const customerTypeSchema = yupString().oneOf(['user', 'team']); // Users export class ReplaceFieldWithOwnUserId extends Error { diff --git a/packages/stack-shared/src/utils/types.tsx b/packages/stack-shared/src/utils/types.tsx index 4e09ca6b47..26be70bcbd 100644 --- a/packages/stack-shared/src/utils/types.tsx +++ b/packages/stack-shared/src/utils/types.tsx @@ -18,7 +18,17 @@ export type LastUnionElement = UnionToIntersection /** * Makes a type prettier by recursively expanding all object types. For example, `Omit<{ a: 1 }, "a">` becomes just `{}`. */ -export type Expand = T extends object ? { [K in keyof T]: Expand } : T; +export type Expand = T extends (...args: infer A) => infer R + ? ( + ((...args: A) => R) extends T + ? (...args: Expand) => Expand + : ((...args: Expand) => Expand) & { [K in keyof T]: Expand } + ) + : ( + T extends object + ? { [K in keyof T]: Expand } + : T + ); /** From ebd6e86311ad85587076bc0c5351f7bda7beb078 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 29 Jul 2025 13:03:30 -0700 Subject: [PATCH 18/40] More parts --- .../create-purchase-url/route.ts | 21 +++---- apps/backend/src/lib/payments.tsx | 29 +++++++++- packages/stack-shared/src/config/schema.ts | 6 +- packages/stack-shared/src/known-errors.tsx | 8 +-- packages/stack-shared/src/schema-fields.ts | 32 ++++++++++- .../src/lib/stack-app/customers/index.ts | 55 +++++++++++++++++++ 6 files changed, 127 insertions(+), 24 deletions(-) rename apps/backend/src/app/api/latest/payments/purchases/{[customer_id]/[offer_id] => }/create-purchase-url/route.ts (53%) create mode 100644 packages/template/src/lib/stack-app/customers/index.ts diff --git a/apps/backend/src/app/api/latest/payments/purchases/[customer_id]/[offer_id]/create-purchase-url/route.ts b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts similarity index 53% rename from apps/backend/src/app/api/latest/payments/purchases/[customer_id]/[offer_id]/create-purchase-url/route.ts rename to apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts index b62128a56a..51d593566a 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/[customer_id]/[offer_id]/create-purchase-url/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts @@ -1,9 +1,7 @@ -import { ensureOfferCustomerTypeMatches } from "@/lib/payments"; +import { ensureOfferCustomerTypeMatches, ensureOfferIdOrInlineOffer } from "@/lib/payments"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { KnownErrors } from "@stackframe/stack-shared"; -import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; export const POST = createSmartRouteHandler({ metadata: { @@ -15,9 +13,10 @@ export const POST = createSmartRouteHandler({ project: adaptSchema.defined(), tenancy: adaptSchema.defined(), }).defined(), - params: yupObject({ + body: yupObject({ customer_id: yupString().defined(), - offer_id: yupString().defined(), + offer_id: yupString().optional(), + offer_inline: inlineOfferSchema.optional(), }), }), response: yupObject({ @@ -28,14 +27,10 @@ export const POST = createSmartRouteHandler({ }).defined(), }), handler: async (req) => { - const { project, tenancy } = req.auth; - - const offerConfig = getOrUndefined(tenancy.completeConfig.payments.offers, req.params.offer_id); - if (!offerConfig || (offerConfig.serverOnly && req.auth.type === "client")) { - throw new KnownErrors.OfferDoesNotExist(req.params.offer_id, req.auth.type); - } + const { tenancy } = req.auth; - await ensureOfferCustomerTypeMatches(req.params.offer_id, offerConfig.customerType, req.params.customer_id, tenancy); + let offerConfig = await ensureOfferIdOrInlineOffer(tenancy, req.auth.type, req.body.offer_id, req.body.offer_inline); + await ensureOfferCustomerTypeMatches(req.body.offer_id, offerConfig.customerType, req.body.customer_id, tenancy); // TODO implement const url = throwErr(new StackAssertionError("unimplemented")); diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index e934969974..34437a0006 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -1,9 +1,34 @@ import { teamsCrudHandlers } from "@/app/api/latest/teams/crud"; import { usersCrudHandlers } from "@/app/api/latest/users/crud"; import { KnownErrors } from "@stackframe/stack-shared"; -import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { inlineOfferSchema, yupValidate } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; +import * as yup from "yup"; import { Tenancy } from "./tenancies"; +export async function ensureOfferIdOrInlineOffer(tenancy: Tenancy, accessType: "client" | "server" | "admin", offerId: string | undefined, inlineOffer: object | undefined): Promise> { + if (offerId && inlineOffer) { + throw new StatusError(400, "Cannot specify both offer_id and offer_inline!"); + } + if (inlineOffer && accessType === "client") { + throw new StatusError(400, "Cannot specify offer_inline when calling from client! Please call with a server API key, or use the offer_id parameter."); + } + if (!offerId && !inlineOffer) { + throw new StatusError(400, "Must specify either offer_id or offer_inline!"); + } + if (offerId) { + const offer = getOrUndefined(tenancy.completeConfig.payments.offers, offerId); + if (!offer || (offer.serverOnly && accessType === "client")) { + throw new KnownErrors.OfferDoesNotExist(offerId, accessType); + } + return offer; + } else { + // if we fail the validation here, we should throw an internal server error; inline offers should've been validated in the request schema already + return await yupValidate(inlineOfferSchema, inlineOffer); + } +} + export async function ensureItemCustomerTypeMatches(itemId: string, itemCustomerType: "user" | "team" | undefined, customerId: string, tenancy: Tenancy) { const actualCustomerType = await getCustomerType(tenancy, customerId); if (itemCustomerType !== actualCustomerType) { @@ -11,7 +36,7 @@ export async function ensureItemCustomerTypeMatches(itemId: string, itemCustomer } } -export async function ensureOfferCustomerTypeMatches(offerId: string, offerCustomerType: "user" | "team" | undefined, customerId: string, tenancy: Tenancy) { +export async function ensureOfferCustomerTypeMatches(offerId: string | undefined, offerCustomerType: "user" | "team" | undefined, customerId: string, tenancy: Tenancy) { const actualCustomerType = await getCustomerType(tenancy, customerId); if (offerCustomerType !== actualCustomerType) { throw new KnownErrors.OfferCustomerTypeDoesNotMatch(offerId, customerId, offerCustomerType, actualCustomerType); diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 250d8d9ad9..954ad989d0 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -147,7 +147,7 @@ const branchPaymentsSchema = yupObject({ return true; }), ), - items: yupRecord( + includedItems: yupRecord( userSpecifiedIdSchema("itemId"), yupObject({ quantity: yupNumber(), @@ -238,7 +238,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ domains: branchConfigSchema.getNested("domains").concat(yupObject({ trustedDomains: yupRecord( - yupString(), + userSpecifiedIdSchema("trustedDomainId"), yupObject({ baseUrl: schemaFields.urlSchema, handlerPath: schemaFields.handlerPathSchema, @@ -485,7 +485,7 @@ const organizationConfigDefaults = { serverOnly: false, freeTrial: undefined, }), - items: (key: string) => ({ + includedItems: (key: string) => ({ quantity: undefined, repeat: "never", expires: "when-repeated", diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index 3adbb0282f..5531ffd7b5 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -1473,17 +1473,17 @@ const OfferDoesNotExist = createKnownErrorConstructor( const OfferCustomerTypeDoesNotMatch = createKnownErrorConstructor( KnownError, "OFFER_CUSTOMER_TYPE_DOES_NOT_MATCH", - (offerId: string, customerId: string, offerCustomerType: "user" | "team" | undefined, actualCustomerType: "user" | "team") => [ + (offerId: string | undefined, customerId: string, offerCustomerType: "user" | "team" | undefined, actualCustomerType: "user" | "team") => [ 400, - `The ${actualCustomerType} with ID ${JSON.stringify(customerId)} is not a valid customer for the offer with ID ${JSON.stringify(offerId)}. ${offerCustomerType ? `The offer is configured to only be available for ${offerCustomerType} customers, but the customer is a ${actualCustomerType}.` : `The offer is missing a customer type field. Please make sure it is set up correctly in your project configuration.`}`, + `The ${actualCustomerType} with ID ${JSON.stringify(customerId)} is not a valid customer for the inline offer that has been passed in. ${offerCustomerType ? `The offer is configured to only be available for ${offerCustomerType} customers, but the customer is a ${actualCustomerType}.` : `The offer is missing a customer type field. Please make sure it is set up correctly in your project configuration.`}`, { - offer_id: offerId, + offer_id: offerId ?? null, customer_id: customerId, offer_customer_type: offerCustomerType ?? null, actual_customer_type: actualCustomerType, }, ] as const, - (json) => [json.offer_id, json.customer_id, json.offer_customer_type ?? undefined, json.actual_customer_type] as const, + (json) => [json.offer_id ?? undefined, json.customer_id, json.offer_customer_type ?? undefined, json.actual_customer_type] as const, ); diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index f60f036287..00cb64f514 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -1,12 +1,12 @@ import * as yup from "yup"; import { KnownErrors } from "."; import { isBase64 } from "./utils/bytes"; -import { Currency, MoneyAmount } from "./utils/currencies"; +import { Currency, MoneyAmount, SUPPORTED_CURRENCIES } from "./utils/currencies"; import { DayInterval, Interval } from "./utils/dates"; import { StackAssertionError, throwErr } from "./utils/errors"; import { decodeBasicAuthorizationHeader } from "./utils/http"; import { allProviders } from "./utils/oauth"; -import { deepPlainClone, omit } from "./utils/objects"; +import { deepPlainClone, omit, typedFromEntries } from "./utils/objects"; import { deindent } from "./utils/strings"; import { isValidUrl } from "./utils/urls"; import { isUuid } from "./utils/uuids"; @@ -489,6 +489,34 @@ export const emailTemplateListSchema = yupRecord( // Payments export const customerTypeSchema = yupString().oneOf(['user', 'team']); +export const inlineOfferSchema = yupObject({ + displayName: yupString().defined(), + customerType: customerTypeSchema.defined(), + freeTrial: dayIntervalSchema.optional(), + serverOnly: yupBoolean().oneOf([true]).default(true), + prices: yupRecord( + userSpecifiedIdSchema("priceId"), + yupObject({ + ...typedFromEntries(SUPPORTED_CURRENCIES.map(currency => [currency.code, moneyAmountSchema(currency).optional()])), + interval: dayIntervalSchema.optional(), + freeTrial: dayIntervalSchema.optional(), + }).test("at-least-one-currency", (value, context) => { + const currencies = Object.keys(value).filter(key => key.toUpperCase() === key); + if (currencies.length === 0) { + return context.createError({ message: "At least one currency is required" }); + } + return true; + }), + ), + includedItems: yupRecord( + userSpecifiedIdSchema("itemId"), + yupObject({ + quantity: yupNumber(), + repeat: dayIntervalOrNeverSchema.optional(), + expires: yupString().oneOf(['never', 'when-purchase-expires', 'when-repeated']).optional(), + }), + ), +}); // Users export class ReplaceFieldWithOwnUserId extends Error { diff --git a/packages/template/src/lib/stack-app/customers/index.ts b/packages/template/src/lib/stack-app/customers/index.ts new file mode 100644 index 0000000000..8bc63060e1 --- /dev/null +++ b/packages/template/src/lib/stack-app/customers/index.ts @@ -0,0 +1,55 @@ +import { SupportedCurrency } from "@stackframe/stack-shared/dist/utils/currencies"; +import { DayInterval } from "@stackframe/stack-shared/dist/utils/dates"; +import { AsyncStoreProperty } from "../common"; + +export type InlineOffer = { + displayName: string, + freeTrial?: undefined | DayInterval, + prices: Record> + & { + interval?: undefined | DayInterval, + freeTrial?: undefined | DayInterval, + } + >, + includedItems: Record, +}; + +export type Item = { + displayName: string, + + /** + * May be negative. + */ + quantity: number, + /** + * Equal to Math.max(0, quantity). + */ + nonNegativeQuantity: number, + + increaseQuantity(amount: number): void, + /** + * Decreases the quantity by the given amount. + * + * Note that you may want to use tryDecreaseQuantity instead, as it will prevent the quantity from going below 0 in a race-condition-free way. + */ + decreaseQuantity(amount: number): void, + /** + * Decreases the quantity by the given amount and returns true if the result is non-negative; returns false and does nothing if the result would be negative. + * + * Most useful for pre-paid credits. + */ + tryDecreaseQuantity(amount: number): boolean, +}; + +export type Customer = + & { + readonly id: string, + + createCheckoutUrl(offerIdOrInline: string | InlineOffer): Promise, + } + & AsyncStoreProperty<"item", [itemId: string], Item, false> From 5686372c7f8be32de5fca6eae9a95b611a244ed3 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Tue, 29 Jul 2025 13:56:33 -0700 Subject: [PATCH 19/40] Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/template/src/lib/stack-app/customers/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template/src/lib/stack-app/customers/index.ts b/packages/template/src/lib/stack-app/customers/index.ts index 8bc63060e1..c84d770891 100644 --- a/packages/template/src/lib/stack-app/customers/index.ts +++ b/packages/template/src/lib/stack-app/customers/index.ts @@ -50,6 +50,6 @@ export type Customer = & { readonly id: string, - createCheckoutUrl(offerIdOrInline: string | InlineOffer): Promise, + createCheckoutUrl(offerIdOrInline: string | InlineOffer): Promise, } & AsyncStoreProperty<"item", [itemId: string], Item, false> From 1119ca24c2b92ff6651a3bca59c1e8d59e85f002 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 1 Aug 2025 15:27:15 -0700 Subject: [PATCH 20/40] feat: Add core payment schema and backend foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Customer and Subscription models to Prisma schema - Add database migrations for payments infrastructure - Add payment configuration schema and validation - Add Stripe client and payment utilities - Add SDK interfaces and implementations for payments - Add environment variables and dependency updates 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- apps/backend/.env | 2 + apps/backend/package.json | 1 + .../migration.sql | 2 + .../migration.sql | 34 ++++++++ apps/backend/prisma/schema.prisma | 34 ++++++++ apps/backend/src/lib/config.tsx | 1 + apps/backend/src/lib/payments.tsx | 9 +- apps/backend/src/lib/stripe.tsx | 76 ++++++++++++++++ .../verification-code-handler.tsx | 18 ++++ packages/stack-shared/src/config/schema.ts | 49 +++-------- .../src/interface/admin-interface.ts | 47 ++++++++++ .../src/interface/crud/projects.ts | 2 + packages/stack-shared/src/schema-fields.ts | 32 +++++++ .../apps/implementations/admin-app-impl.ts | 19 ++++ .../stack-app/apps/interfaces/admin-app.ts | 4 + .../lib/stack-app/project-configs/index.ts | 7 ++ pnpm-lock.yaml | 87 +++++++++++++++---- 17 files changed, 366 insertions(+), 58 deletions(-) create mode 100644 apps/backend/prisma/migrations/20250731054821_purchase_url_code/migration.sql create mode 100644 apps/backend/prisma/migrations/20250801170818_add_customer_and_subscriptions/migration.sql create mode 100644 apps/backend/src/lib/stripe.tsx diff --git a/apps/backend/.env b/apps/backend/.env index 282ee9da26..e3f53e62c6 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -65,3 +65,5 @@ OTEL_EXPORTER_OTLP_ENDPOINT=# enter the OpenTelemetry endpoint here. Optional, d STACK_INTEGRATION_CLIENTS_CONFIG=# a list of oidc-provider clients for integrations. If not provided, disables integrations STACK_FREESTYLE_API_KEY=# enter your freestyle.sh api key STACK_OPENAI_API_KEY=# enter your openai api key +STACK_STRIPE_SECRET_KEY=# enter your stripe api key +STACK_STRIPE_WEBHOOK_SECRET=# enter your stripe webhook secret diff --git a/apps/backend/package.json b/apps/backend/package.json index d4bf9e014a..f11f38038b 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -85,6 +85,7 @@ "react-dom": "19.0.0", "semver": "^7.6.3", "sharp": "^0.32.6", + "stripe": "^18.3.0", "svix": "^1.25.0", "vite": "^6.1.0", "yaml": "^2.4.5", diff --git a/apps/backend/prisma/migrations/20250731054821_purchase_url_code/migration.sql b/apps/backend/prisma/migrations/20250731054821_purchase_url_code/migration.sql new file mode 100644 index 0000000000..09a8275bb6 --- /dev/null +++ b/apps/backend/prisma/migrations/20250731054821_purchase_url_code/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "VerificationCodeType" ADD VALUE 'PURCHASE_URL'; diff --git a/apps/backend/prisma/migrations/20250801170818_add_customer_and_subscriptions/migration.sql b/apps/backend/prisma/migrations/20250801170818_add_customer_and_subscriptions/migration.sql new file mode 100644 index 0000000000..fb3c5fb801 --- /dev/null +++ b/apps/backend/prisma/migrations/20250801170818_add_customer_and_subscriptions/migration.sql @@ -0,0 +1,34 @@ +-- CreateEnum +CREATE TYPE "CustomerType" AS ENUM ('USER', 'TEAM'); + +-- CreateTable +CREATE TABLE "Customer" ( + "id" UUID NOT NULL, + "tenancyId" UUID NOT NULL, + "customerType" "CustomerType" NOT NULL, + "stripeCustomerId" TEXT NOT NULL, + + CONSTRAINT "Customer_pkey" PRIMARY KEY ("tenancyId","id") +); + +-- CreateTable +CREATE TABLE "Subscription" ( + "id" UUID NOT NULL, + "tenancyId" UUID NOT NULL, + "customerId" UUID NOT NULL, + "stripeSubscriptionId" TEXT NOT NULL, + "status" TEXT NOT NULL, + "currentPeriodEnd" TIMESTAMP(3) NOT NULL, + "currentPeriodStart" TIMESTAMP(3) NOT NULL, + "cancelAtPeriodEnd" BOOLEAN NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Subscription_pkey" PRIMARY KEY ("tenancyId","id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Customer_tenancyId_stripeCustomerId_key" ON "Customer"("tenancyId", "stripeCustomerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_tenancyId_stripeSubscriptionId_key" ON "Subscription"("tenancyId", "stripeSubscriptionId"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 724127c956..3b241ac01a 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -475,6 +475,7 @@ enum VerificationCodeType { PASSKEY_REGISTRATION_CHALLENGE PASSKEY_AUTHENTICATION_CHALLENGE INTEGRATION_PROJECT_TRANSFER + PURCHASE_URL } //#region API keys @@ -707,3 +708,36 @@ model ThreadMessage { @@id([tenancyId, id]) } + +enum CustomerType { + USER + TEAM +} + +model Customer { + id String @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + customerType CustomerType + stripeCustomerId String + + @@id([tenancyId, id]) + @@unique([tenancyId, stripeCustomerId]) +} + +model Subscription { + id String @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + customerId String @db.Uuid + + stripeSubscriptionId String + status String + currentPeriodEnd DateTime + currentPeriodStart DateTime + cancelAtPeriodEnd Boolean + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([tenancyId, id]) + @@unique([tenancyId, stripeSubscriptionId]) +} diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 3817cd98c4..f6e2cb9e21 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -541,5 +541,6 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Organiza allow_user_api_keys: renderedConfig.apiKeys.enabled.user, allow_team_api_keys: renderedConfig.apiKeys.enabled.team, + payments: renderedConfig.payments, }; }; diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 34437a0006..7cc1c10887 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -7,7 +7,12 @@ import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; import * as yup from "yup"; import { Tenancy } from "./tenancies"; -export async function ensureOfferIdOrInlineOffer(tenancy: Tenancy, accessType: "client" | "server" | "admin", offerId: string | undefined, inlineOffer: object | undefined): Promise> { +export async function ensureOfferIdOrInlineOffer( + tenancy: Tenancy, + accessType: "client" | "server" | "admin", + offerId: string | undefined, + inlineOffer: object | undefined +): Promise> { if (offerId && inlineOffer) { throw new StatusError(400, "Cannot specify both offer_id and offer_inline!"); } @@ -18,7 +23,7 @@ export async function ensureOfferIdOrInlineOffer(tenancy: Tenancy, accessType: " throw new StatusError(400, "Must specify either offer_id or offer_inline!"); } if (offerId) { - const offer = getOrUndefined(tenancy.completeConfig.payments.offers, offerId); + const offer = getOrUndefined(tenancy.config.payments.offers, offerId); if (!offer || (offer.serverOnly && accessType === "client")) { throw new KnownErrors.OfferDoesNotExist(offerId, accessType); } diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx new file mode 100644 index 0000000000..9140ee9c56 --- /dev/null +++ b/apps/backend/src/lib/stripe.tsx @@ -0,0 +1,76 @@ +import { getTenancy, Tenancy } from "@/lib/tenancies"; +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import Stripe from "stripe"; + +export const stackStripe = new Stripe(getEnvVariable("STACK_STRIPE_SECRET_KEY")); + +export const getStripeForAccount = (options: { tenancy?: Tenancy, accountId?: string }) => { + if (!options.tenancy && !options.accountId) { + throwErr(400, "Either tenancy or stripeAccountId must be provided"); + } + const accountId = options.accountId ?? options.tenancy?.config.payments.stripeAccountId; + if (!accountId) { + throwErr(400, "Stripe account not configured"); + } + return new Stripe(getEnvVariable("STACK_STRIPE_SECRET_KEY"), { + stripeAccount: accountId, + }); +}; + +export async function syncStripeDataToDB(stripeAccountId: string, stripeCustomerId: string) { + const stripe = getStripeForAccount({ accountId: stripeAccountId }); + const account = await stripe.accounts.retrieve(stripeAccountId); + if (!account.metadata?.tenancyId) { + throwErr(500, "Stripe account metadata missing tenancyId"); + } + const tenancy = await getTenancy(account.metadata.tenancyId); + if (!tenancy) { + throwErr(500, "Tenancy not found"); + } + const prisma = await getPrismaClientForTenancy(tenancy); + const customer = await prisma.customer.findUnique({ + where: { + tenancyId_stripeCustomerId: { + tenancyId: tenancy.id, + stripeCustomerId, + }, + }, + }); + if (!customer) { + throwErr(500, "Customer not found in DB"); + } + + const subscriptions = await stripe.subscriptions.list({ + customer: stripeCustomerId, + status: "all", + }); + + // TODO: handle in parallel, store payment method? + for (const subscription of subscriptions.data) { + await prisma.subscription.upsert({ + where: { + tenancyId_stripeSubscriptionId: { + tenancyId: tenancy.id, + stripeSubscriptionId: subscription.id, + }, + }, + update: { + status: subscription.status, + currentPeriodEnd: new Date(subscription.items.data[0].current_period_end * 1000), + currentPeriodStart: new Date(subscription.items.data[0].current_period_start * 1000), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + }, + create: { + tenancyId: tenancy.id, + customerId: customer.id, + stripeSubscriptionId: subscription.id, + status: subscription.status, + currentPeriodEnd: new Date(subscription.items.data[0].current_period_end * 1000), + currentPeriodStart: new Date(subscription.items.data[0].current_period_start * 1000), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + }, + }); + } +} diff --git a/apps/backend/src/route-handlers/verification-code-handler.tsx b/apps/backend/src/route-handlers/verification-code-handler.tsx index cd01416d1b..007d082f3b 100644 --- a/apps/backend/src/route-handlers/verification-code-handler.tsx +++ b/apps/backend/src/route-handlers/verification-code-handler.tsx @@ -45,6 +45,7 @@ type VerificationCodeHandler, sendOptions: SendCodeExtraOptions): Promise, listCodes(options: ListCodesOptions): Promise[]>, revokeCode(options: RevokeCodeOptions): Promise, + validateCode(code: string): Promise>, postHandler: SmartRouteHandler, checkHandler: SmartRouteHandler, detailsHandler: HasDetails extends true ? SmartRouteHandler : undefined, @@ -285,6 +286,23 @@ export function createVerificationCodeHandler< }, }); }, + async validateCode(code: string) { + const verificationCode = await globalPrismaClient.verificationCode.findFirst({ + where: { + code, + type: options.type, + usedAt: null, + expiresAt: { gt: new Date() }, + }, + }); + + if (!verificationCode) throw new KnownErrors.VerificationCodeNotFound(); + if (verificationCode.expiresAt < new Date()) throw new KnownErrors.VerificationCodeExpired(); + if (verificationCode.usedAt) throw new KnownErrors.VerificationCodeAlreadyUsed(); + if (verificationCode.attemptCount >= MAX_ATTEMPTS_PER_CODE) throw new KnownErrors.VerificationCodeMaxAttemptsReached; + + return createCodeObjectFromPrismaCode(verificationCode); + }, postHandler: createHandler('post'), checkHandler: createHandler('check'), detailsHandler: (options.detailsResponse ? createHandler('details') : undefined) as any, diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 954ad989d0..1a0689ceb9 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -3,7 +3,7 @@ import * as yup from "yup"; import { DEFAULT_EMAIL_TEMPLATES, DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_THEME_ID } from "../helpers/emails"; import * as schemaFields from "../schema-fields"; -import { userSpecifiedIdSchema, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields"; +import { offerSchema, userSpecifiedIdSchema, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields"; import { isShallowEqual } from "../utils/arrays"; import { SUPPORTED_CURRENCIES } from "../utils/currencies"; import { StackAssertionError } from "../utils/errors"; @@ -113,7 +113,8 @@ const branchAuthSchema = yupObject({ }), }); -const branchPaymentsSchema = yupObject({ +export const branchPaymentsSchema = yupObject({ + stripeAccountId: yupString().optional(), autoPay: yupObject({ interval: schemaFields.dayIntervalSchema, }).optional(), @@ -126,36 +127,7 @@ const branchPaymentsSchema = yupObject({ ), offers: yupRecord( userSpecifiedIdSchema("offerId"), - yupObject({ - displayName: yupString(), - customerType: schemaFields.customerTypeSchema, - freeTrial: schemaFields.dayIntervalSchema.optional(), - serverOnly: yupBoolean(), - stackable: yupBoolean(), - prices: yupRecord( - userSpecifiedIdSchema("priceId"), - yupObject({ - ...typedFromEntries(SUPPORTED_CURRENCIES.map(currency => [currency.code, schemaFields.moneyAmountSchema(currency).optional()])), - interval: schemaFields.dayIntervalSchema.optional(), - serverOnly: yupBoolean(), - freeTrial: schemaFields.dayIntervalSchema.optional(), - }).test("at-least-one-currency", (value, context) => { - const currencies = Object.keys(value).filter(key => key.toUpperCase() === key); - if (currencies.length === 0) { - return context.createError({ message: "At least one currency is required" }); - } - return true; - }), - ), - includedItems: yupRecord( - userSpecifiedIdSchema("itemId"), - yupObject({ - quantity: yupNumber(), - repeat: schemaFields.dayIntervalOrNeverSchema.optional(), - expires: yupString().oneOf(['never', 'when-purchase-expires', 'when-repeated']).optional(), - }), - ), - }), + offerSchema, ), items: yupRecord( userSpecifiedIdSchema("itemId"), @@ -471,6 +443,7 @@ const organizationConfigDefaults = { }, payments: { + stripeAccountId: undefined, autoPay: undefined, exclusivityGroups: (key: string) => (key: string) => undefined, offers: (key: string) => ({ @@ -514,12 +487,12 @@ typeAssertIs, c: 456 } }, [{ a: { c: type DeepReplaceAllowFunctionsForObjects = T extends object ? ( - string extends keyof T - ? ((arg: Exclude) => DeepReplaceAllowFunctionsForObjects) & ({ [K in keyof T]?: DeepReplaceAllowFunctionsForObjects } | {}) - : { [K in keyof T]: DeepReplaceAllowFunctionsForObjects } - ) + string extends keyof T + ? ((arg: Exclude) => DeepReplaceAllowFunctionsForObjects) & ({ [K in keyof T]?: DeepReplaceAllowFunctionsForObjects } | {}) + : { [K in keyof T]: DeepReplaceAllowFunctionsForObjects } + ) : - T; + T; type ReplaceFunctionsWithObjects = T & (T extends (arg: infer K extends string) => infer R ? Record & object : unknown); type DeepReplaceFunctionsWithObjects = T extends object ? { [K in keyof ReplaceFunctionsWithObjects]: DeepReplaceFunctionsWithObjects[K]> } : T; typeAssertIs number) }>, { a: { b: 123, [key: string]: number } }>()(); @@ -528,7 +501,7 @@ function deepReplaceFunctionsWithObjects(obj: any): any { return mapValues({ ...obj }, v => (isObjectLike(v) ? deepReplaceFunctionsWithObjects(v as any) : v)); } import.meta.vitest?.test("deepReplaceFunctionsWithObjects", ({ expect }) => { - expect(deepReplaceFunctionsWithObjects(() => {})).toEqual({}); + expect(deepReplaceFunctionsWithObjects(() => { })).toEqual({}); expect(deepReplaceFunctionsWithObjects({ a: 3 })).toEqual({ a: 3 }); expect(deepReplaceFunctionsWithObjects({ a: () => ({ b: 1 }) })).toEqual({ a: {} }); expect(deepReplaceFunctionsWithObjects({ a: typedAssign(() => ({}), { b: { c: 1 } }) })).toEqual({ a: { b: { c: 1 } } }); diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 04778faf2d..cc2e63f8a8 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -483,4 +483,51 @@ export class StackAdminInterface extends StackServerInterface { ); return await response.json(); } + + async setupPayments(): Promise<{ url: string }> { + const response = await this.sendAdminRequest( + "/internal/payments/setup", + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({}), + }, + null, + ); + return await response.json(); + } + + async createPaymentsAccountSession(): Promise<{ client_secret: string }> { + const response = await this.sendAdminRequest( + "/internal/payments/account-session", + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({}), + }, + null, + ); + return await response.json(); + } + + async createPurchaseUrl(options: { customer_id: string, offer_id: string }): Promise { + const response = await this.sendAdminRequest( + "/payments/purchases/create-purchase-url", + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(options), + }, + null, + ); + const result = await response.json() as { url: string }; + return result.url; + } + } diff --git a/packages/stack-shared/src/interface/crud/projects.ts b/packages/stack-shared/src/interface/crud/projects.ts index 081ddbac71..fb3a7f38a9 100644 --- a/packages/stack-shared/src/interface/crud/projects.ts +++ b/packages/stack-shared/src/interface/crud/projects.ts @@ -1,3 +1,4 @@ +import { branchPaymentsSchema } from "../../config/schema"; import { CrudTypeOf, createCrud } from "../../crud"; import * as schemaFields from "../../schema-fields"; import { yupArray, yupObject, yupString } from "../../schema-fields"; @@ -89,6 +90,7 @@ export const projectsCrudAdminReadSchema = yupObject({ team_member_default_permissions: yupArray(teamPermissionSchema.defined()).defined(), user_default_permissions: yupArray(teamPermissionSchema.defined()).defined(), oauth_account_merge_strategy: schemaFields.oauthAccountMergeStrategySchema.defined(), + payments: branchPaymentsSchema.defined(), }).defined().meta({ openapiField: { hidden: true } }), }).defined(); diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 00cb64f514..9808f9156b 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -383,6 +383,7 @@ export const moneyAmountSchema = (currency: Currency) => yupString( return true; }); + /** * A stricter email schema that does some additional checks for UX input. (Some emails are allowed by the spec, for * example `test@localhost` or `abc@gmail`, but almost certainly a user input error.) @@ -489,6 +490,37 @@ export const emailTemplateListSchema = yupRecord( // Payments export const customerTypeSchema = yupString().oneOf(['user', 'team']); +export const offerPriceSchema = yupObject({ + ...typedFromEntries(SUPPORTED_CURRENCIES.map(currency => [currency.code, moneyAmountSchema(currency).optional()])), + interval: dayIntervalSchema.optional(), + serverOnly: yupBoolean(), + freeTrial: dayIntervalSchema.optional(), +}).test("at-least-one-currency", (value, context) => { + const currencies = Object.keys(value).filter(key => key.toUpperCase() === key); + if (currencies.length === 0) { + return context.createError({ message: "At least one currency is required" }); + } + return true; +}); +export const offerSchema = yupObject({ + displayName: yupString(), + customerType: customerTypeSchema, + freeTrial: dayIntervalSchema.optional(), + serverOnly: yupBoolean(), + stackable: yupBoolean(), + prices: yupRecord( + userSpecifiedIdSchema("priceId"), + offerPriceSchema, + ), + includedItems: yupRecord( + userSpecifiedIdSchema("itemId"), + yupObject({ + quantity: yupNumber(), + repeat: dayIntervalOrNeverSchema.optional(), + expires: yupString().oneOf(['never', 'when-purchase-expires', 'when-repeated']).optional(), + }), + ), +}); export const inlineOfferSchema = yupObject({ displayName: yupString().defined(), customerType: customerTypeSchema.defined(), diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 0c4317841a..71a882b9c2 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -155,6 +155,9 @@ export class _StackAdminAppImplIncomplete { + return await this._interface.setupPayments(); + } + + async createPaymentsAccountSession(): Promise<{ client_secret: string }> { + return await this._interface.createPaymentsAccountSession(); + } + + async createPurchaseUrl(options: { customerId: string, offerId: string }): Promise { + return await this._interface.createPurchaseUrl({ + customer_id: options.customerId, + offer_id: options.offerId, + }); + } + } diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index 17d1d36ccc..b7dfbd99ce 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -80,6 +80,10 @@ export type StackAdminApp }>, updateEmailTemplate(id: string, tsxSource: string, themeId: string | null | false): Promise<{ renderedHtml: string }>, createEmailTemplate(displayName: string): Promise<{ id: string }>, + + setupPayments(): Promise<{ url: string }>, + createPaymentsAccountSession(): Promise<{ client_secret: string }>, + createPurchaseUrl(options: { customerId: string, offerId: string }): Promise, } & StackServerApp ); diff --git a/packages/template/src/lib/stack-app/project-configs/index.ts b/packages/template/src/lib/stack-app/project-configs/index.ts index 2c4144e090..6654834ccb 100644 --- a/packages/template/src/lib/stack-app/project-configs/index.ts +++ b/packages/template/src/lib/stack-app/project-configs/index.ts @@ -1,4 +1,6 @@ import { AdminTeamPermission } from "../permissions"; +import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; +import * as yup from "yup"; export type ProjectConfig = { @@ -36,6 +38,7 @@ export type AdminProjectConfig = { readonly oauthAccountMergeStrategy: 'link_method' | 'raise_error' | 'allow_duplicates', readonly allowUserApiKeys: boolean, readonly allowTeamApiKeys: boolean, + readonly payments: AdminPaymentsConfig, }; export type AdminEmailConfig = ( @@ -94,3 +97,7 @@ export type AdminProjectConfigUpdateOptions = { allowUserApiKeys?: boolean, allowTeamApiKeys?: boolean, }; + +type AdminPaymentsConfig = { + stripeAccountId: string | undefined, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 095465a3ae..ca9e4615e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,6 +240,9 @@ importers: sharp: specifier: ^0.32.6 version: 0.32.6 + stripe: + specifier: ^18.3.0 + version: 18.3.0(@types/node@20.17.6) svix: specifier: ^1.25.0 version: 1.25.0(encoding@0.1.13) @@ -352,6 +355,18 @@ importers: '@stackframe/stack-ui': specifier: workspace:* version: link:../../packages/stack-ui + '@stripe/connect-js': + specifier: ^3.3.27 + version: 3.3.27 + '@stripe/react-connect-js': + specifier: ^3.3.24 + version: 3.3.24(@stripe/connect-js@3.3.27)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@stripe/react-stripe-js': + specifier: ^3.8.1 + version: 3.8.1(@stripe/stripe-js@7.7.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@stripe/stripe-js': + specifier: ^7.7.0 + version: 7.7.0 '@tanstack/react-table': specifier: ^8.20.5 version: 8.20.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -6930,6 +6945,27 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@stripe/connect-js@3.3.27': + resolution: {integrity: sha512-QyPE6CRvS8LO7G3qFpM6qHq5So9UZCjn6PlYU1rKDtybv05B+g14+KLxnAZgi0ngaoNMVweP3slHSzA/eh78ow==} + + '@stripe/react-connect-js@3.3.24': + resolution: {integrity: sha512-xPtHin5cD2yqfOXWFCywzqKk68Y58hpKTiqknzLnP4mMKCjH+v3j7azd/0Vxeb5NFYV9sz1o4FUkrdxgs5K9Bg==} + peerDependencies: + '@stripe/connect-js': '>=3.3.24' + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@stripe/react-stripe-js@3.8.1': + resolution: {integrity: sha512-BExESIwXDwZgUqFmWj046BGpsqK4vNaBCdcmRvagQzOovjO2aBAt8rofW47K1TJRnt3iTH5dciBdHJ7ZA958ng==} + peerDependencies: + '@stripe/stripe-js': '>=1.44.1 <8.0.0' + react: '>=16.8.0 <20.0.0' + react-dom: '>=16.8.0 <20.0.0' + + '@stripe/stripe-js@7.7.0': + resolution: {integrity: sha512-SXuhqhuR5FXaYgKTXzZJeqtVA6JKb9IZWaGeEUxHHiOcFy2p51wccO72bYpXwoK4D5pzQOIYLTuAc7etxyMmwg==} + engines: {node: '>=12.16'} + '@supabase/auth-js@2.69.1': resolution: {integrity: sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==} @@ -12310,10 +12346,6 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} - object-inspect@1.13.2: - resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} - engines: {node: '>= 0.4'} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -13764,10 +13796,6 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.0.6: - resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} - engines: {node: '>= 0.4'} - side-channel@1.1.0: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} @@ -14004,6 +14032,15 @@ packages: strip-literal@2.1.0: resolution: {integrity: sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==} + stripe@18.3.0: + resolution: {integrity: sha512-FkxrTUUcWB4CVN2yzgsfF/YHD6WgYHduaa7VmokCy5TLCgl5UNJkwortxcedrxSavQ8Qfa4Ir4JxcbIYiBsyLg==} + engines: {node: '>=12.*'} + peerDependencies: + '@types/node': '>=12.x.x' + peerDependenciesMeta: + '@types/node': + optional: true + strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} @@ -21860,6 +21897,23 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@stripe/connect-js@3.3.27': {} + + '@stripe/react-connect-js@3.3.24(@stripe/connect-js@3.3.27)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@stripe/connect-js': 3.3.27 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + + '@stripe/react-stripe-js@3.8.1(@stripe/stripe-js@7.7.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@stripe/stripe-js': 7.7.0 + prop-types: 15.8.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + + '@stripe/stripe-js@7.7.0': {} + '@supabase/auth-js@2.69.1': dependencies: '@supabase/node-fetch': 2.6.15 @@ -29044,8 +29098,6 @@ snapshots: object-hash@3.0.0: {} - object-inspect@1.13.2: {} - object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -29807,7 +29859,7 @@ snapshots: qs@6.13.0: dependencies: - side-channel: 1.0.6 + side-channel: 1.1.0 quansync@0.2.10: {} @@ -30952,13 +31004,6 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.0.6: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - get-intrinsic: 1.2.4 - object-inspect: 1.13.2 - side-channel@1.1.0: dependencies: es-errors: 1.3.0 @@ -31198,6 +31243,12 @@ snapshots: dependencies: js-tokens: 9.0.0 + stripe@18.3.0(@types/node@20.17.6): + dependencies: + qs: 6.13.0 + optionalDependencies: + '@types/node': 20.17.6 + strnum@1.1.2: {} strnum@2.1.1: {} From 897b1f56223a07fcb4b7097e6f3e35e1c0e44eb3 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 1 Aug 2025 15:28:09 -0700 Subject: [PATCH 21/40] feat: Add backend payment APIs and Stripe integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Stripe webhook handling for payment events - Add internal payment setup and account session APIs - Add payment items and purchase URL creation endpoints - Add purchase session and validation endpoints - Implement payment processing and code verification 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../integrations/stripe/webhooks/route.tsx | 66 ++++++++++++++++++ .../payments/account-session/route.ts | 51 ++++++++++++++ .../latest/internal/payments/setup/route.ts | 67 +++++++++++++++++++ .../items/[customer_id]/[item_id]/route.ts | 2 +- .../purchases/create-purchase-url/route.ts | 57 ++++++++++++++-- .../purchases/purchase-session/route.tsx | 44 ++++++++++++ .../payments/purchases/validate-code/route.ts | 12 ++++ .../purchases/verification-code-handler.tsx | 19 ++++++ 8 files changed, 311 insertions(+), 7 deletions(-) create mode 100644 apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx create mode 100644 apps/backend/src/app/api/latest/internal/payments/account-session/route.ts create mode 100644 apps/backend/src/app/api/latest/internal/payments/setup/route.ts create mode 100644 apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx create mode 100644 apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts create mode 100644 apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx diff --git a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx new file mode 100644 index 0000000000..edb5e7bf81 --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx @@ -0,0 +1,66 @@ +import { stackStripe, syncStripeDataToDB } from "@/lib/stripe"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { NextRequest, NextResponse } from "next/server"; +import Stripe from "stripe"; + +const allowedEvents = [ + "checkout.session.completed", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "customer.subscription.paused", + "customer.subscription.resumed", + "customer.subscription.pending_update_applied", + "customer.subscription.pending_update_expired", + "customer.subscription.trial_will_end", + "invoice.paid", + "invoice.payment_failed", + "invoice.payment_action_required", + "invoice.upcoming", + "invoice.marked_uncollectible", + "invoice.payment_succeeded", + "payment_intent.succeeded", + "payment_intent.payment_failed", + "payment_intent.canceled", +] as const satisfies Stripe.Event.Type[]; + +const isAllowedEvent = (event: Stripe.Event): event is Stripe.Event & { type: (typeof allowedEvents)[number] } => { + return allowedEvents.includes(event.type as any); +}; + +export async function POST(req: NextRequest) { + const body = await req.text(); + const signature = req.headers.get('stripe-signature'); + if (!signature) { + return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 }); + } + let event; + try { + event = stackStripe.webhooks.constructEvent(body, signature, getEnvVariable("STACK_STRIPE_WEBHOOK_SECRET")); + } catch (err) { + return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); + } + if (!isAllowedEvent(event)) { + return NextResponse.json({ received: true }, { status: 200 }); + } + + const accountId = event.account; + const customerId = event.data.object.customer; + if (!accountId) { + captureError('stripe-webhook-account-id-missing', { event }); + return NextResponse.json({ received: true }, { status: 200 }); + } + if (typeof customerId !== 'string') { + captureError('stripe-webhook-bad-customer-id', { event }); + return NextResponse.json({ received: true }, { status: 200 }); + } + + try { + await syncStripeDataToDB(accountId, customerId); + } catch (e) { + captureError('stripe-webhook-sync-failed', { accountId, customerId, event, error: e }); + return NextResponse.json({ received: true }, { status: 200 }); + } + return NextResponse.json({ received: true }, { status: 200 }); +} diff --git a/apps/backend/src/app/api/latest/internal/payments/account-session/route.ts b/apps/backend/src/app/api/latest/internal/payments/account-session/route.ts new file mode 100644 index 0000000000..a704d62808 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/payments/account-session/route.ts @@ -0,0 +1,51 @@ +import { stackStripe } from "@/lib/stripe"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + client_secret: yupString().defined(), + }).defined(), + }), + handler: async ({ auth }) => { + if (!auth.tenancy.config.payments.stripeAccountId) { + throw new StatusError(400, "Stripe account ID is not set"); + } + + const accountSession = await stackStripe.accountSessions.create({ + account: auth.tenancy.config.payments.stripeAccountId, + components: { + payments: { + enabled: true, + features: { + refund_management: true, + dispute_management: true, + capture_payments: true, + }, + }, + }, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + client_secret: accountSession.client_secret, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/payments/setup/route.ts b/apps/backend/src/app/api/latest/internal/payments/setup/route.ts new file mode 100644 index 0000000000..f06eae0212 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/payments/setup/route.ts @@ -0,0 +1,67 @@ +import { overrideEnvironmentConfigOverride } from "@/lib/config"; +import { stackStripe } from "@/lib/stripe"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + url: yupString().defined(), + }).defined(), + }), + handler: async ({ auth }) => { + let stripeAccountId = auth.tenancy.config.payments.stripeAccountId; + const returnToUrl = new URL(`/projects/${auth.project.id}/payments`, getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL")).toString(); + + if (!stripeAccountId) { + const account = await stackStripe.accounts.create({ + controller: { + stripe_dashboard: { type: "none" }, + }, + capabilities: { + card_payments: { requested: true }, + transfers: { requested: true }, + }, + country: "US", + metadata: { + tenancyId: auth.tenancy.id, + } + }); + stripeAccountId = account.id; + // TODO: listen for webhook to ensure account setup is complete and set payments.setupComplete to true + await overrideEnvironmentConfigOverride({ + projectId: auth.project.id, + branchId: auth.tenancy.branchId, + environmentConfigOverrideOverride: { + [`payments.stripeAccountId`]: stripeAccountId, + }, + }); + } + + const accountLink = await stackStripe.accountLinks.create({ + account: stripeAccountId, + refresh_url: returnToUrl, + return_url: returnToUrl, + type: "account_onboarding", + }); + + return { + statusCode: 200, + bodyType: "json", + body: { url: accountLink.url }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts b/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts index ceeea42c63..d6da6548dc 100644 --- a/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts @@ -31,7 +31,7 @@ export const GET = createSmartRouteHandler({ }), handler: async (req) => { const { project, tenancy } = req.auth; - const paymentsConfig = tenancy.completeConfig.payments; + const paymentsConfig = tenancy.config.payments; const itemConfig = getOrUndefined(paymentsConfig.items, req.params.item_id); if (!itemConfig) { diff --git a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts index 51d593566a..50715b901b 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts @@ -1,7 +1,12 @@ import { ensureOfferCustomerTypeMatches, ensureOfferIdOrInlineOffer } from "@/lib/payments"; +import { getStripeForAccount } from "@/lib/stripe"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { CustomerType } from "@prisma/client"; export const POST = createSmartRouteHandler({ metadata: { @@ -28,18 +33,58 @@ export const POST = createSmartRouteHandler({ }), handler: async (req) => { const { tenancy } = req.auth; - - let offerConfig = await ensureOfferIdOrInlineOffer(tenancy, req.auth.type, req.body.offer_id, req.body.offer_inline); + const stripe = getStripeForAccount({ tenancy }); + const offerConfig = await ensureOfferIdOrInlineOffer(tenancy, req.auth.type, req.body.offer_id, req.body.offer_inline); await ensureOfferCustomerTypeMatches(req.body.offer_id, offerConfig.customerType, req.body.customer_id, tenancy); + const customerType = offerConfig.customerType ?? throwErr(500, "Customer type not found"); + const prisma = await getPrismaClientForTenancy(tenancy); + + let dbCustomer = await prisma.customer.findUnique({ + where: { + tenancyId_id: { + tenancyId: tenancy.id, + id: req.body.customer_id, + }, + }, + }); + if (!dbCustomer) { + const stripeCustomer = await stripe.customers.create({ + metadata: { + customerId: req.body.customer_id, + customerType, + } + }); + dbCustomer = await prisma.customer.create({ + data: { + tenancyId: tenancy.id, + id: req.body.customer_id, + stripeCustomerId: stripeCustomer.id, + customerType: customerType === "user" ? CustomerType.USER : CustomerType.TEAM, + }, + }); + } + + const { code } = await purchaseUrlVerificationCodeHandler.createCode({ + tenancy, + expiresInMs: 1000 * 60 * 60 * 24, + data: { + tenancyId: tenancy.id, + customerId: req.body.customer_id, + offer: offerConfig, + stripeCustomerId: dbCustomer.stripeCustomerId, + stripeAccountId: tenancy.config.payments.stripeAccountId ?? throwErr(500, "Stripe account not configured"), + }, + method: {}, + callbackUrl: undefined, + }); - // TODO implement - const url = throwErr(new StackAssertionError("unimplemented")); + const url = new URL(`/purchase/${code}`, getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL")); return { statusCode: 200, bodyType: "json", body: { - url, + url: url.toString(), }, }; }, diff --git a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx new file mode 100644 index 0000000000..b3a7846f27 --- /dev/null +++ b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx @@ -0,0 +1,44 @@ +import { getStripeForAccount } from "@/lib/stripe"; +import { NextRequest } from "next/server"; +import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import Stripe from "stripe"; + +export async function POST(request: NextRequest) { + const { code, price_id } = await request.json(); + const { data } = await purchaseUrlVerificationCodeHandler.validateCode(code); + const stripe = getStripeForAccount({ accountId: data.stripeAccountId }); + const pricesMap = new Map(Object.entries(data.offer.prices)); + const selectedPrice = pricesMap.get(price_id); + if (!selectedPrice) { + throwErr(400, "Price not found"); + } + // TODO: prices with no interval should be allowed and work without a subscription + if (!selectedPrice.interval) { + throwErr(500, "Price does not have an interval"); + } + const product = await stripe.products.create({ + name: data.offer.displayName ?? "Subscription", + }); + const subscription = await stripe.subscriptions.create({ + customer: data.stripeCustomerId, + payment_behavior: 'default_incomplete', + payment_settings: { save_default_payment_method: 'on_subscription' }, + expand: ['latest_invoice.confirmation_secret', 'pending_setup_intent'], + items: [{ + price_data: { + currency: "usd", + unit_amount: Number(selectedPrice.USD) * 100, + product: product.id, + recurring: { + interval_count: selectedPrice.interval[0], + interval: selectedPrice.interval[1], + }, + }, + quantity: 1, + }], + }); + const clientSecret = (subscription.latest_invoice as Stripe.Invoice).confirmation_secret?.client_secret; + return Response.json({ client_secret: clientSecret }); + +} diff --git a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts new file mode 100644 index 0000000000..a87352c480 --- /dev/null +++ b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from "next/server"; +import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; + +export async function POST(request: NextRequest) { + const { code } = await request.json(); + const verificationCode = await purchaseUrlVerificationCodeHandler.validateCode(code); + + return Response.json({ + offer: verificationCode.data.offer, + stripe_account_id: verificationCode.data.stripeAccountId, + }); +} diff --git a/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx b/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx new file mode 100644 index 0000000000..817577ba00 --- /dev/null +++ b/apps/backend/src/app/api/latest/payments/purchases/verification-code-handler.tsx @@ -0,0 +1,19 @@ +import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; +import { VerificationCodeType } from "@prisma/client"; +import { offerSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +export const purchaseUrlVerificationCodeHandler = createVerificationCodeHandler({ + type: VerificationCodeType.PURCHASE_URL, + method: yupObject({}), + data: yupObject({ + tenancyId: yupString().defined(), + customerId: yupString().defined(), + offer: offerSchema, + stripeCustomerId: yupString().defined(), + stripeAccountId: yupString().defined(), + }), + // @ts-ignore TODO: fix this + async handler(_, __, data) { + return null; + }, +}); From bcb6c11e68199ca3439335b3dec9230bbe036908 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 1 Aug 2025 15:29:18 -0700 Subject: [PATCH 22/40] feat: Add dashboard UI and frontend payment components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add payment management UI pages for project settings - Add purchase flow pages with code validation - Add payment data tables for items and offers - Add Stripe Connect and Elements integration - Add checkout components and theme variables - Add enhanced form fields and environment config 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- apps/dashboard/.env | 1 + apps/dashboard/package.json | 4 + .../[projectId]/payments/page-client.tsx | 390 ++++++++++++++++++ .../projects/[projectId]/payments/page.tsx | 11 + .../(main)/purchase/[code]/page-client.tsx | 136 ++++++ .../src/app/(main)/purchase/[code]/page.tsx | 12 + .../src/app/(main)/purchase/return/page.tsx | 25 ++ .../data-table/payment-item-table.tsx | 84 ++++ .../data-table/payment-offer-table.tsx | 76 ++++ apps/dashboard/src/components/form-fields.tsx | 41 +- .../src/components/payments/checkout.tsx | 63 +++ .../payments/stripe-connect-provider.tsx | 49 +++ .../payments/stripe-elements-provider.tsx | 45 ++ .../payments/stripe-theme-variables.ts | 60 +++ apps/dashboard/src/components/smart-form.tsx | 5 +- apps/dashboard/src/lib/env.tsx | 2 + 16 files changed, 1002 insertions(+), 2 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page.tsx create mode 100644 apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/purchase/[code]/page.tsx create mode 100644 apps/dashboard/src/app/(main)/purchase/return/page.tsx create mode 100644 apps/dashboard/src/components/data-table/payment-item-table.tsx create mode 100644 apps/dashboard/src/components/data-table/payment-offer-table.tsx create mode 100644 apps/dashboard/src/components/payments/checkout.tsx create mode 100644 apps/dashboard/src/components/payments/stripe-connect-provider.tsx create mode 100644 apps/dashboard/src/components/payments/stripe-elements-provider.tsx create mode 100644 apps/dashboard/src/components/payments/stripe-theme-variables.ts diff --git a/apps/dashboard/.env b/apps/dashboard/.env index 30a8a440b9..1701352db1 100644 --- a/apps/dashboard/.env +++ b/apps/dashboard/.env @@ -4,6 +4,7 @@ NEXT_PUBLIC_STACK_PROJECT_ID=internal NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=# enter your Stack publishable client key here. For local development, just enter a random string, then run `pnpm db:reset` STACK_SECRET_SERVER_KEY=# enter your Stack secret client key here. For local development, do the same as above NEXT_PUBLIC_STACK_EXTRA_REQUEST_HEADERS=# a list of extra request headers to add to all Stack Auth API requests, as a JSON record +NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY=# enter your Stripe publishable key here # Webhooks NEXT_PUBLIC_STACK_SVIX_SERVER_URL=# For prod, leave it empty. For local development, use `http://localhost:8113` diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 41c3647901..75573e94f6 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -31,6 +31,10 @@ "@stackframe/stack": "workspace:*", "@stackframe/stack-shared": "workspace:*", "@stackframe/stack-ui": "workspace:*", + "@stripe/connect-js": "^3.3.27", + "@stripe/react-connect-js": "^3.3.24", + "@stripe/react-stripe-js": "^3.8.1", + "@stripe/stripe-js": "^7.7.0", "@tanstack/react-table": "^8.20.5", "@vercel/analytics": "^1.2.2", "@vercel/speed-insights": "^1.0.12", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx new file mode 100644 index 0000000000..5eb9c0f8b1 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { SmartFormDialog } from "@/components/form-dialog"; +import { SelectField } from "@/components/form-fields"; +import { ActionDialog, Button, FormControl, FormField, FormItem, FormLabel, FormMessage, InlineCode, toast, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Label, Card, CardContent, CardHeader, CardTitle, Checkbox } from "@stackframe/stack-ui"; +import { PaymentOfferTable } from "@/components/data-table/payment-offer-table"; +import { PaymentItemTable } from "@/components/data-table/payment-item-table"; +import { useState } from "react"; +import * as yup from "yup"; +import { PageLayout } from "../page-layout"; +import { useAdminApp } from "../use-admin-app"; +import { offerPriceSchema, yupRecord } from "@stackframe/stack-shared/dist/schema-fields"; +import { Control, FieldValues, Path } from "react-hook-form"; +import { Trash2, Plus } from "lucide-react"; +import { AdminProject } from "@stackframe/stack"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { Result } from "@stackframe/stack-shared/dist/utils/results"; + +export default function PageClient() { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const config = project.useConfig(); + const stripeAccountId = config.payments.stripeAccountId; + const paymentsConfig = config.payments; + + const [isCreateOfferOpen, setIsCreateOfferOpen] = useState(false); + const [isCreateItemOpen, setIsCreateItemOpen] = useState(false); + + const setupPayments = async () => { + const { url } = await stackAdminApp.setupPayments(); + window.location.href = url; + }; + + if (!stripeAccountId) { + return ( + +
+ +
+
+ ); + } + + return ( + + + + } + > + } + /> + } + /> + + + + ); +} + + +function CreateOfferDialog({ + open, + onOpenChange, + project, +}: { + open: boolean, + project: AdminProject, + onOpenChange: (open: boolean) => void, +}) { + const offerSchema = yup.object({ + offerId: yup.string().defined().label("Offer ID"), + displayName: yup.string().defined().label("Display Name"), + customerType: yup.string().oneOf(["user", "team"]).defined().label("Customer Type").meta({ + stackFormFieldRender: (props) => ( + + ), + }), + prices: yupRecord(yup.string(), offerPriceSchema).defined().label("Prices").meta({ + stackFormFieldRender: (props) => ( + + ), + }), + freeTrialDays: yup.number().min(0).optional().label("Free Trial (days)"), + serverOnly: yup.boolean().default(false).label("Server Only"), + stackable: yup.boolean().default(false).label("Stackable"), + }); + + return ( + { + await project.updateConfig({ + [`payments.offers.${values.offerId}`]: { + prices: values.prices, + customerType: values.customerType, + displayName: values.displayName, + serverOnly: values.serverOnly, + stackable: values.stackable, + freeTrial: values.freeTrialDays ? [values.freeTrialDays, "day"] : undefined, + }, + }); + }} + /> + ); +} + + +function CreateItemDialog({ open, onOpenChange, project }: { open: boolean, onOpenChange: (open: boolean) => void, project: AdminProject }) { + const itemSchema = yup.object({ + itemId: yup.string().defined().label("Item ID"), + displayName: yup.string().optional().label("Display Name"), + customerType: yup.string().oneOf(["user", "team"]).defined().label("Customer Type").meta({ + stackFormFieldRender: (props) => ( + + ), + }), + defaultQuantity: yup.number().min(0).default(0).label("Default Quantity"), + defaultRepeatDays: yup.number().min(1).optional().label("Default Repeat (days)"), + defaultExpires: yup.string().oneOf(["never", "when-repeated"]).optional().label("Default Expires").meta({ + stackFormFieldRender: (props) => ( + + ), + }), + }); + + return ( + { + await project.updateConfig({ + [`payments.items.${values.itemId}`]: { + displayName: values.displayName, + customerType: values.customerType, + default: { + quantity: values.defaultQuantity, + repeat: values.defaultRepeatDays ? [values.defaultRepeatDays, "day"] : undefined, + expires: values.defaultExpires, + }, + }, + }); + }} + /> + ); +} + + +function CreatePurchaseDialog() { + const stackAdminApp = useAdminApp(); + const [purchaseUrl, setPurchaseUrl] = useState(null); + + const createPurchaseUrl = async (data: { customerId: string, offerId: string }) => { + const result = await Result.fromPromise(stackAdminApp.createPurchaseUrl(data)); + if (result.status === "ok") { + setPurchaseUrl(result.data); + return; + } + if (result.error instanceof KnownErrors.OfferDoesNotExist) { + toast({ title: "Offer with given offerId does not exist", variant: "destructive" }); + } else if (result.error instanceof KnownErrors.OfferCustomerTypeDoesNotMatch) { + toast({ title: "Customer type does not match expected type for this offer", variant: "destructive" }); + } else if (result.error instanceof KnownErrors.CustomerDoesNotExist) { + toast({ title: "Customer with given customerId does not exist", variant: "destructive" }); + } else { + throw result.error; + } + return "prevent-close"; + }; + + return ( + <> + Create New Purchase} + title="Create New Purchase" + formSchema={yup.object({ + customerId: yup.string().uuid().defined().label("Customer ID"), + offerId: yup.string().defined().label("Offer ID"), + })} + cancelButton + okButton={{ label: "Create Purchase URL" }} + onSubmit={values => createPurchaseUrl(values)} + /> + setPurchaseUrl(null)} + title="Purchase URL" + okButton + > + {purchaseUrl} + + + ); +} + + +function PricesFormField(props: { + control: Control, + name: Path, + label: React.ReactNode, + required?: boolean, +}) { + const intervalOptions = [ + { value: "1-week", label: "1 week" }, + { value: "1-month", label: "1 month" }, + { value: "1-year", label: "1 year" }, + ]; + + const freeTrialOptions = [ + { value: "1-week", label: "1 week" }, + { value: "1-month", label: "1 month" }, + { value: "1-year", label: "1 year" }, + ]; + + const parseInterval = (value: string) => { + const [amount, unit] = value.split("-"); + return [parseInt(amount), unit] as [number, string]; + }; + + const formatInterval = (interval: [number, string] | undefined) => { + if (!interval) return ""; + const [amount, unit] = interval; + return `${amount}-${unit}`; + }; + + return ( + { + const prices: Record = field.value || {}; + const priceIds = Object.keys(prices); + + const addPrice = () => { + const newPriceId = `price_${priceIds.length}`; + const newPrices = { + ...prices, + [newPriceId]: { + USD: "20", + interval: [1, "month"] as [number, string], + } + }; + field.onChange(newPrices); + }; + + const removePrice = (priceId: string) => { + const newPrices = { ...prices }; + delete newPrices[priceId]; + field.onChange(newPrices); + }; + + const updatePrice = (priceId: string, updates: Record) => { + const newPrices = { + ...prices, + [priceId]: { + ...prices[priceId], + ...updates, + } + }; + field.onChange(newPrices); + }; + + const updatePriceId = (oldPriceId: string, newPriceId: string) => { + if (oldPriceId !== newPriceId && !prices[newPriceId]) { + const newPrices = { ...prices }; + newPrices[newPriceId] = newPrices[oldPriceId]; + delete newPrices[oldPriceId]; + field.onChange(newPrices); + } + }; + + return ( + + + {props.label} + {props.required ? * : null} + + +
+ {priceIds.map((priceId) => { + const price = prices[priceId]; + return ( + + + Price Settings + + + +
+ + updatePriceId(priceId, e.target.value)} + placeholder="Enter price ID" + /> +
+ +
+ + updatePrice(priceId, { USD: e.target.value })} + placeholder="9" + /> +
+ +
+ + +
+
+
+ ); + })} + + +
+
+ +
+ ); + }} + /> + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page.tsx new file mode 100644 index 0000000000..648cba0775 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page.tsx @@ -0,0 +1,11 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Payments", +}; + +export default function Page() { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx new file mode 100644 index 0000000000..2948719c1a --- /dev/null +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { Card, CardContent, Skeleton, Typography } from "@stackframe/stack-ui"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getPublicEnvVar } from "@/lib/env"; +import { StripeElementsProvider } from "@/components/payments/stripe-elements-provider"; +import { CheckoutForm } from "@/components/payments/checkout"; + +type OfferData = { + offer?: any, + stripe_account_id: string, +}; + +const apiUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? "https://api.stack-auth.com"; +const baseUrl = new URL("/api/v1", apiUrl).toString(); + +export default function PageClient({ code }: { code: string }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedPriceId, setSelectedPriceId] = useState(null); + + const currentAmount = useMemo(() => { + if (!selectedPriceId || !data?.offer?.prices) { + return 0; + } + return data.offer.prices[selectedPriceId]?.USD * 100; + }, [data, selectedPriceId]); + + const shortenedInterval = (interval: [number, string]) => { + if (interval[0] === 1) { + return interval[1]; + } + return `${interval[0]} ${interval[1]}s`; + }; + + const validateCode = useCallback(async () => { + const response = await fetch(`${baseUrl}/payments/purchases/validate-code`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code }), + }); + if (!response.ok) { + throw new Error('Failed to validate code'); + } + const result = await response.json(); + setData(result); + if (result?.offer?.prices) { + const firstPriceId = Object.keys(result.offer.prices)[0]; + setSelectedPriceId(firstPriceId); + } + }, [code]); + + useEffect(() => { + setLoading(true); + validateCode().catch((err) => { + setError(err instanceof Error ? err.message : 'An error occurred'); + }).finally(() => { + setLoading(false); + }); + }, [validateCode]); + + const setupSubscription = async () => { + const response = await fetch(`${baseUrl}/payments/purchases/purchase-session`, { + method: 'POST', + body: JSON.stringify({ code, price_id: selectedPriceId }), + }); + const result = await response.json(); + if (!result.client_secret) { + throw new Error("Failed to setup subscription"); + } + return result.client_secret; + }; + + + return ( +
+
+ {loading ? ( + + ) : error ? ( + <> + The following error occurred: + {error} + + ) : ( + <> +
+ {data?.offer?.displayName || "Plan"} +
+
+ {data?.offer?.prices && Object.entries(data.offer.prices).map(([priceId, priceData]: [string, any]) => ( + setSelectedPriceId(priceId)} + > + +
+
+ {priceId} + + +
+
+ + ${priceData.USD} + + {" "}/ {shortenedInterval(priceData.interval)} + + +
+
+
+
+ ))} +
+ + )} +
+
+ {data && ( + + + + )} +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(main)/purchase/[code]/page.tsx b/apps/dashboard/src/app/(main)/purchase/[code]/page.tsx new file mode 100644 index 0000000000..26b8ea25d2 --- /dev/null +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page.tsx @@ -0,0 +1,12 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Purchase", +}; + +export default async function Page({ params }: { params: Promise<{ code: string }> }) { + const { code } = await params; + return ( + + ); +} diff --git a/apps/dashboard/src/app/(main)/purchase/return/page.tsx b/apps/dashboard/src/app/(main)/purchase/return/page.tsx new file mode 100644 index 0000000000..b35557aadd --- /dev/null +++ b/apps/dashboard/src/app/(main)/purchase/return/page.tsx @@ -0,0 +1,25 @@ +type Props = { + searchParams: Promise<{ redirect_status: string }>, +}; + +export default async function Page({ searchParams }: Props) { + const { redirect_status } = await searchParams; + if (redirect_status === "failed") { + return ( +
+

Purchase failed

+

+ There was an error processing your purchase +

+
+ ); + } + return ( +
+

Purchase successful

+

+ You can now close this page +

+
+ ); +} diff --git a/apps/dashboard/src/components/data-table/payment-item-table.tsx b/apps/dashboard/src/components/data-table/payment-item-table.tsx new file mode 100644 index 0000000000..26e1f5b67a --- /dev/null +++ b/apps/dashboard/src/components/data-table/payment-item-table.tsx @@ -0,0 +1,84 @@ +'use client'; +import { DataTable, DataTableColumnHeader, TextCell, ActionCell, Button } from "@stackframe/stack-ui"; +import { ColumnDef } from "@tanstack/react-table"; +import * as yup from "yup"; +import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; + +type PaymentItem = { + id: string, +} & yup.InferType["items"][string]; + +const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: ({ column }) => , + cell: ({ row }) => {row.original.id}, + enableSorting: false, + }, + { + accessorKey: "displayName", + header: ({ column }) => , + cell: ({ row }) => {row.original.displayName ?? ""}, + enableSorting: false, + }, + { + accessorKey: "customerType", + header: ({ column }) => , + cell: ({ row }) => {row.original.customerType}, + enableSorting: false, + }, + { + accessorKey: "default.quantity", + header: ({ column }) => , + cell: ({ row }) => {row.original.default.quantity}, + enableSorting: false, + }, + { + accessorKey: "default.repeat", + header: ({ column }) => , + cell: ({ row }) => + {row.original.default.repeat === "never" ? "Never" : row.original.default.repeat?.join(" ") ?? ""} + , + enableSorting: false, + }, + { + accessorKey: "default.expires", + header: ({ column }) => , + cell: ({ row }) => {row.original.default.expires || "Never"}, + enableSorting: false, + }, + { + id: "actions", + cell: ({ row }) => { }, + }, + ]} + />, + } +]; + +export function PaymentItemTable({ + items, + toolbarRender, +}: { + items: Record["items"][string]>, + toolbarRender: () => React.ReactNode, +}) { + const data: PaymentItem[] = Object.entries(items).map(([id, item]) => ({ + id, + ...item, + })); + + return ; +} diff --git a/apps/dashboard/src/components/data-table/payment-offer-table.tsx b/apps/dashboard/src/components/data-table/payment-offer-table.tsx new file mode 100644 index 0000000000..96e4cf180a --- /dev/null +++ b/apps/dashboard/src/components/data-table/payment-offer-table.tsx @@ -0,0 +1,76 @@ +'use client'; +import { ActionCell, Button, DataTable, DataTableColumnHeader, TextCell } from "@stackframe/stack-ui"; +import { ColumnDef } from "@tanstack/react-table"; +import * as yup from "yup"; +import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; + +type PaymentOffer = { + id: string, +} & yup.InferType["offers"][string]; + +const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: ({ column }) => , + cell: ({ row }) => {row.original.id}, + enableSorting: false, + }, + { + accessorKey: "displayName", + header: ({ column }) => , + cell: ({ row }) => {row.original.displayName}, + enableSorting: false, + }, + { + accessorKey: "customerType", + header: ({ column }) => , + cell: ({ row }) => {row.original.customerType}, + enableSorting: false, + }, + { + accessorKey: "freeTrial", + header: ({ column }) => , + cell: ({ row }) => {row.original.freeTrial?.join(" ") ?? ""}, + enableSorting: false, + }, + { + accessorKey: "stackable", + header: ({ column }) => , + cell: ({ row }) => {row.original.stackable ? "Yes" : "No"}, + enableSorting: false, + }, + { + id: "actions", + cell: ({ row }) => { }, + }, + ]} + />, + } +]; + +export function PaymentOfferTable({ + offers, + toolbarRender, +}: { + offers: Record["offers"][string]>, + toolbarRender: () => React.ReactNode, +}) { + const data: PaymentOffer[] = Object.entries(offers).map(([id, offer]) => ({ + id, + ...offer, + })); + + return ; +} diff --git a/apps/dashboard/src/components/form-fields.tsx b/apps/dashboard/src/components/form-fields.tsx index 643ff20294..986bc7937c 100644 --- a/apps/dashboard/src/components/form-fields.tsx +++ b/apps/dashboard/src/components/form-fields.tsx @@ -245,7 +245,7 @@ export function SelectField(props: { + + + + + )} + /> + ); +} diff --git a/apps/dashboard/src/components/payments/checkout.tsx b/apps/dashboard/src/components/payments/checkout.tsx new file mode 100644 index 0000000000..399d077ce9 --- /dev/null +++ b/apps/dashboard/src/components/payments/checkout.tsx @@ -0,0 +1,63 @@ +import React, { useState } from "react"; +import { + PaymentElement, + useStripe, + useElements, +} from "@stripe/react-stripe-js"; +import { StripePaymentElementOptions } from "@stripe/stripe-js"; +import { Button } from "@stackframe/stack-ui"; + +const paymentElementOptions = { + layout: "auto", + defaultValues: { + }, + wallets: { + applePay: "auto", + googlePay: "auto", + }, +} satisfies StripePaymentElementOptions; + +export function CheckoutForm({ setupSubscription }: { setupSubscription: () => Promise }) { + const stripe = useStripe(); + const elements = useElements(); + const [message, setMessage] = useState(null); + + const handleSubmit = async () => { + if (!stripe || !elements) { + return; + } + const { error: submitError } = await elements.submit(); + if (submitError) { + return setMessage(submitError.message ?? "An unexpected error occurred."); + } + + const clientSecret = await setupSubscription(); + const { error } = await stripe.confirmPayment({ + elements, + clientSecret, + confirmParams: { + return_url: new URL(`/purchase/return`, window.location.origin).toString(), + }, + }); + + + if (error.type === "card_error" || error.type === "validation_error") { + setMessage(error.message ?? "An unexpected error occurred."); + } else { + setMessage("An unexpected error occurred."); + } + }; + + return ( +
+ + + {message &&
{message}
} +
+ ); +} diff --git a/apps/dashboard/src/components/payments/stripe-connect-provider.tsx b/apps/dashboard/src/components/payments/stripe-connect-provider.tsx new file mode 100644 index 0000000000..349aa255b3 --- /dev/null +++ b/apps/dashboard/src/components/payments/stripe-connect-provider.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { loadConnectAndInitialize } from "@stripe/connect-js"; +import { + ConnectComponentsProvider, +} from "@stripe/react-connect-js"; +import { useTheme } from "next-themes"; +import { useEffect, useMemo } from "react"; +import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; +import { getPublicEnvVar } from "@/lib/env"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { appearanceVariablesForTheme } from "./stripe-theme-variables"; + +type StripeConnectProviderProps = { + children: React.ReactNode, +}; + +export function StripeConnectProvider({ children }: StripeConnectProviderProps) { + const adminApp = useAdminApp(); + const { resolvedTheme } = useTheme(); + + const stripeConnectInstance = useMemo(() => { + const publishableKey = getPublicEnvVar("NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY") ?? throwErr("No Stripe publishable key found"); + const fetchClientSecret = async () => { + const { client_secret } = await adminApp.createPaymentsAccountSession(); + return client_secret; + }; + + return loadConnectAndInitialize({ + publishableKey, + fetchClientSecret, + appearance: { overlays: 'dialog' }, + }); + }, [adminApp]); + + useEffect(() => { + stripeConnectInstance.update({ + appearance: { + variables: appearanceVariablesForTheme(resolvedTheme), + }, + }); + }, [resolvedTheme, stripeConnectInstance]); + + return ( + + {children} + + ); +} diff --git a/apps/dashboard/src/components/payments/stripe-elements-provider.tsx b/apps/dashboard/src/components/payments/stripe-elements-provider.tsx new file mode 100644 index 0000000000..b9439c8203 --- /dev/null +++ b/apps/dashboard/src/components/payments/stripe-elements-provider.tsx @@ -0,0 +1,45 @@ +"use client"; +import { getPublicEnvVar } from "@/lib/env"; +import { useTheme } from "next-themes"; +import { Elements } from "@stripe/react-stripe-js"; +import { loadStripe } from "@stripe/stripe-js"; +import { useMemo } from "react"; +import { appearanceVariablesForTheme } from "./stripe-theme-variables"; + +const stripePublicKey = getPublicEnvVar("NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY"); + +type StripeElementsProviderProps = { + children: React.ReactNode, + stripeAccountId: string, + amount: number, +}; + +export function StripeElementsProvider({ + children, + stripeAccountId, + amount, +}: StripeElementsProviderProps) { + const { resolvedTheme } = useTheme(); + + const stripePromise = useMemo(() => { + return loadStripe(stripePublicKey ?? "", { stripeAccount: stripeAccountId }); + }, [stripeAccountId]); + + + return ( + + {children} + + ); +} diff --git a/apps/dashboard/src/components/payments/stripe-theme-variables.ts b/apps/dashboard/src/components/payments/stripe-theme-variables.ts new file mode 100644 index 0000000000..eafc5baabe --- /dev/null +++ b/apps/dashboard/src/components/payments/stripe-theme-variables.ts @@ -0,0 +1,60 @@ +const darkAppearanceVariables = { + colorPrimary: "hsl(0, 0%, 98%)", + colorText: "hsl(0, 0%, 98%)", + colorBackground: "hsl(240, 10%, 3.9%)", + colorSecondaryText: "hsl(240, 5%, 64.9%)", + colorBorder: "hsl(240, 3.7%, 15.9%)", + buttonSecondaryColorBackground: "hsl(240, 3.7%, 15.9%)", + buttonSecondaryColorText: "hsl(0, 0%, 98%)", + actionSecondaryColorText: "hsl(0, 0%, 98%)", + actionSecondaryTextDecorationColor: "hsl(0, 0%, 98%)", + colorDanger: "hsl(0, 62.8%, 50%)", + badgeNeutralColorBackground: "hsl(240, 3.7%, 15.9%)", + badgeNeutralColorBorder: "hsl(240, 3.7%, 15.9%)", + badgeNeutralColorText: "hsl(240, 5%, 64.9%)", + badgeSuccessColorBackground: "hsl(120, 40%, 15%)", + badgeSuccessColorBorder: "hsl(120, 40%, 25%)", + badgeSuccessColorText: "hsl(120, 40%, 50%)", + badgeWarningColorBackground: "hsl(30, 84%, 15%)", + badgeWarningColorBorder: "hsl(30, 84%, 25%)", + badgeWarningColorText: "hsl(30, 84%, 60%)", + badgeDangerColorBackground: "hsl(0, 62.8%, 15%)", + badgeDangerColorBorder: "hsl(0, 62.8%, 25%)", + badgeDangerColorText: "hsl(0, 62.8%, 50%)", + offsetBackgroundColor: "hsl(240, 3.7%, 15.9%)", + formBackgroundColor: "hsl(240, 10%, 3.9%)", + overlayBackdropColor: "rgba(0,0,0,0.8)", +} as const; + +const lightAppearanceVariables = { + colorPrimary: "hsl(240, 5.9%, 10%)", + colorText: "hsl(240, 10%, 3.9%)", + colorBackground: "hsl(0, 0%, 100%)", + colorSecondaryText: "hsl(240, 3.8%, 46.1%)", + colorBorder: "hsl(240, 5.9%, 90%)", + buttonSecondaryColorBackground: "hsl(240, 4.8%, 95.9%)", + buttonSecondaryColorText: "hsl(240, 5.9%, 10%)", + actionSecondaryColorText: "hsl(240, 5.9%, 10%)", + actionSecondaryTextDecorationColor: "hsl(240, 5.9%, 10%)", + colorDanger: "hsl(0, 84.2%, 60.2%)", + badgeNeutralColorBackground: "hsl(240, 4.8%, 95.9%)", + badgeNeutralColorBorder: "hsl(240, 5.9%, 90%)", + badgeNeutralColorText: "hsl(240, 3.8%, 46.1%)", + badgeSuccessColorBackground: "hsl(120, 40%, 95%)", + badgeSuccessColorBorder: "hsl(120, 40%, 85%)", + badgeSuccessColorText: "hsl(120, 40%, 40%)", + badgeWarningColorBackground: "hsl(30, 84%, 95%)", + badgeWarningColorBorder: "hsl(30, 84%, 85%)", + badgeWarningColorText: "hsl(30, 84%, 40%)", + badgeDangerColorBackground: "hsl(0, 84.2%, 95%)", + badgeDangerColorBorder: "hsl(0, 84.2%, 85%)", + badgeDangerColorText: "hsl(0, 84.2%, 50%)", + offsetBackgroundColor: "hsl(240, 4.8%, 95.9%)", + formBackgroundColor: "hsl(0, 0%, 100%)", + overlayBackdropColor: "rgba(0,0,0,0.5)", +} as const; + + +export const appearanceVariablesForTheme = (theme: string | undefined) => { + return theme === "dark" ? darkAppearanceVariables : lightAppearanceVariables; +}; diff --git a/apps/dashboard/src/components/smart-form.tsx b/apps/dashboard/src/components/smart-form.tsx index 6f65cb0d86..72bf3a1a5a 100644 --- a/apps/dashboard/src/components/smart-form.tsx +++ b/apps/dashboard/src/components/smart-form.tsx @@ -7,7 +7,7 @@ import { Form } from "@stackframe/stack-ui"; import React, { useCallback, useState } from "react"; import { useForm } from "react-hook-form"; import * as yup from "yup"; -import { CheckboxField, DateField, InputField, TextAreaField } from "./form-fields"; +import { CheckboxField, DateField, InputField, NumberField, TextAreaField } from "./form-fields"; // Used for yup TS support declare module 'yup' { @@ -113,6 +113,9 @@ function SmartFormField(props: { case 'boolean': { return ; } + case 'number': { + return ; + } } throw new StackAssertionError(`Unsupported yup field ${props.id}; can't create form automatically from schema of type ${JSON.stringify(props.description.type)}. Maybe you need to implement it, or add a stackFormFieldRender meta property to the schema.`); diff --git a/apps/dashboard/src/lib/env.tsx b/apps/dashboard/src/lib/env.tsx index 1dfedf7818..f569c3f7f7 100644 --- a/apps/dashboard/src/lib/env.tsx +++ b/apps/dashboard/src/lib/env.tsx @@ -18,6 +18,7 @@ const _inlineEnvVars = { NEXT_PUBLIC_STACK_URL: process.env.NEXT_PUBLIC_STACK_URL, NEXT_PUBLIC_STACK_INBUCKET_WEB_URL: process.env.NEXT_PUBLIC_STACK_INBUCKET_WEB_URL, NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS: process.env.NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS, + NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY } as const; // This will be replaced with the actual env vars after a docker build @@ -39,6 +40,7 @@ const _postBuildEnvVars = { NEXT_PUBLIC_STACK_URL: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_URL", NEXT_PUBLIC_STACK_INBUCKET_WEB_URL: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_INBUCKET_WEB_URL", NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS", + NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY", } satisfies typeof _inlineEnvVars; // If this is not replaced with "true", then we will not use inline env vars From 1b07bafaa6fa24751595e0278993c69961340486 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 1 Aug 2025 18:51:29 -0700 Subject: [PATCH 23/40] add mock stripe key to env --- apps/backend/.env.development | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/backend/.env.development b/apps/backend/.env.development index bceceab5e4..66c00ac72a 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -47,6 +47,8 @@ STACK_INTEGRATION_CLIENTS_CONFIG=[{"client_id": "neon-local", "client_secret": " CRON_SECRET=mock_cron_secret STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key STACK_OPENAI_API_KEY=mock_openai_api_key +STACK_STRIPE_SECRET_KEY=mock_stripe_secret_key +STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret # S3 Configuration for local development using s3mock STACK_S3_ENDPOINT=http://localhost:8121 From effebd70701f4f920df675da5aa88e826136b3c8 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 5 Aug 2025 11:44:23 -0700 Subject: [PATCH 24/40] payment tests, account status, smartRoutes --- apps/backend/.env.development | 2 +- .../migration.sql | 20 ++ apps/backend/prisma/schema.prisma | 21 +- .../integrations/stripe/webhooks/route.tsx | 19 +- .../latest/internal/payments/setup/route.ts | 1 - .../items/[customer_id]/[item_id]/route.ts | 30 +- .../purchases/create-purchase-url/route.ts | 30 +- .../purchases/purchase-session/route.tsx | 105 ++++--- .../payments/purchases/validate-code/route.ts | 62 +++- apps/backend/src/lib/config.tsx | 1 - apps/backend/src/lib/payments.tsx | 23 +- apps/backend/src/lib/stripe.tsx | 65 +++-- .../[projectId]/payments/page-client.tsx | 37 ++- .../(main)/purchase/[code]/page-client.tsx | 4 +- apps/e2e/tests/backend/backend-helpers.ts | 61 +++- .../v1/payments/create-purchase-url.test.ts | 266 ++++++++++++++++++ .../endpoints/api/v1/payments/items.test.ts | 147 ++++++++++ .../api/v1/payments/purchase-session.test.ts | 69 +++++ .../api/v1/payments/validate-code.test.ts | 58 ++++ docker/dependencies/docker.compose.yaml | 9 + packages/stack-shared/src/config/schema.ts | 2 + .../src/interface/crud/projects.ts | 1 - packages/stack-shared/src/schema-fields.ts | 12 +- .../apps/implementations/admin-app-impl.ts | 3 - .../lib/stack-app/project-configs/index.ts | 5 - 25 files changed, 914 insertions(+), 139 deletions(-) create mode 100644 apps/backend/prisma/migrations/20250804205834_remove_customers/migration.sql create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 66c00ac72a..95d8b99692 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -47,7 +47,7 @@ STACK_INTEGRATION_CLIENTS_CONFIG=[{"client_id": "neon-local", "client_secret": " CRON_SECRET=mock_cron_secret STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key STACK_OPENAI_API_KEY=mock_openai_api_key -STACK_STRIPE_SECRET_KEY=mock_stripe_secret_key +STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret # S3 Configuration for local development using s3mock diff --git a/apps/backend/prisma/migrations/20250804205834_remove_customers/migration.sql b/apps/backend/prisma/migrations/20250804205834_remove_customers/migration.sql new file mode 100644 index 0000000000..2875675ce9 --- /dev/null +++ b/apps/backend/prisma/migrations/20250804205834_remove_customers/migration.sql @@ -0,0 +1,20 @@ +/* + Warnings: + + - You are about to drop the `Customer` table. If the table is not empty, all the data it contains will be lost. + - Added the required column `customerType` to the `Subscription` table without a default value. This is not possible if the table is not empty. + - Added the required column `offer` to the `Subscription` table without a default value. This is not possible if the table is not empty. + - Changed the type of `status` on the `Subscription` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- CreateEnum +CREATE TYPE "SubscriptionStatus" AS ENUM ('active', 'trialing', 'canceled', 'paused', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid'); + +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "customerType" "CustomerType" NOT NULL, +ADD COLUMN "offer" JSONB NOT NULL, +DROP COLUMN "status", +ADD COLUMN "status" "SubscriptionStatus" NOT NULL; + +-- DropTable +DROP TABLE "Customer"; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 3b241ac01a..e5274a3126 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -714,23 +714,26 @@ enum CustomerType { TEAM } -model Customer { - id String @default(uuid()) @db.Uuid - tenancyId String @db.Uuid - customerType CustomerType - stripeCustomerId String - - @@id([tenancyId, id]) - @@unique([tenancyId, stripeCustomerId]) +enum SubscriptionStatus { + active + trialing + canceled + paused + incomplete + incomplete_expired + past_due + unpaid } model Subscription { id String @default(uuid()) @db.Uuid tenancyId String @db.Uuid customerId String @db.Uuid + customerType CustomerType + offer Json stripeSubscriptionId String - status String + status SubscriptionStatus currentPeriodEnd DateTime currentPeriodStart DateTime cancelAtPeriodEnd Boolean diff --git a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx index edb5e7bf81..6ebf9716d1 100644 --- a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx @@ -1,4 +1,4 @@ -import { stackStripe, syncStripeDataToDB } from "@/lib/stripe"; +import { stackStripe, syncStripeAccountStatus, syncStripeSubscriptions } from "@/lib/stripe"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; import { NextRequest, NextResponse } from "next/server"; @@ -41,10 +41,23 @@ export async function POST(req: NextRequest) { } catch (err) { return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); } - if (!isAllowedEvent(event)) { + + if (event.type === "account.updated") { + if (!event.account) { + captureError('stripe-webhook-account-id-missing', { event }); + return NextResponse.json({ received: true }, { status: 200 }); + } + try { + await syncStripeAccountStatus(event.account); + } catch (e) { + captureError('stripe-webhook-sync-account-status-failed', { event, error: e }); + } return NextResponse.json({ received: true }, { status: 200 }); } + if (!isAllowedEvent(event)) { + return NextResponse.json({ received: true }, { status: 200 }); + } const accountId = event.account; const customerId = event.data.object.customer; if (!accountId) { @@ -57,7 +70,7 @@ export async function POST(req: NextRequest) { } try { - await syncStripeDataToDB(accountId, customerId); + await syncStripeSubscriptions(accountId, customerId); } catch (e) { captureError('stripe-webhook-sync-failed', { accountId, customerId, event, error: e }); return NextResponse.json({ received: true }, { status: 200 }); diff --git a/apps/backend/src/app/api/latest/internal/payments/setup/route.ts b/apps/backend/src/app/api/latest/internal/payments/setup/route.ts index f06eae0212..f8ee25cba5 100644 --- a/apps/backend/src/app/api/latest/internal/payments/setup/route.ts +++ b/apps/backend/src/app/api/latest/internal/payments/setup/route.ts @@ -41,7 +41,6 @@ export const POST = createSmartRouteHandler({ } }); stripeAccountId = account.id; - // TODO: listen for webhook to ensure account setup is complete and set payments.setupComplete to true await overrideEnvironmentConfigOverride({ projectId: auth.project.id, branchId: auth.tenancy.branchId, diff --git a/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts b/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts index d6da6548dc..d2305ab675 100644 --- a/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts @@ -1,8 +1,10 @@ import { ensureItemCustomerTypeMatches } from "@/lib/payments"; +import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { SubscriptionStatus } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; -import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString, offerSchema } from "@stackframe/stack-shared/dist/schema-fields"; +import * as yup from "yup"; import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; export const GET = createSmartRouteHandler({ @@ -25,12 +27,12 @@ export const GET = createSmartRouteHandler({ bodyType: yupString().oneOf(["json"]).defined(), body: yupObject({ id: yupString().defined(), - displayName: yupString().defined(), + display_name: yupString().defined(), quantity: yupNumber().defined(), }).defined(), }), handler: async (req) => { - const { project, tenancy } = req.auth; + const { tenancy } = req.auth; const paymentsConfig = tenancy.config.payments; const itemConfig = getOrUndefined(paymentsConfig.items, req.params.item_id); @@ -39,10 +41,22 @@ export const GET = createSmartRouteHandler({ } await ensureItemCustomerTypeMatches(req.params.item_id, itemConfig.customerType, req.params.customer_id, tenancy); + const prisma = await getPrismaClientForTenancy(tenancy); + const subscriptions = await prisma.subscription.findMany({ + where: { + tenancyId: tenancy.id, + customerId: req.params.customer_id, + status: { + in: [SubscriptionStatus.active, SubscriptionStatus.trialing], + } + }, + }); - - // TODO: calculate the total quantity of the item for the customer - const totalQuantity = throwErr("TODO unimplemented"); + const totalQuantity = subscriptions.reduce((acc, subscription) => { + const offer = subscription.offer as yup.InferType; + const item = getOrUndefined(offer.includedItems, req.params.item_id); + return acc + (item?.quantity ?? 0); + }, 0); return { @@ -50,7 +64,7 @@ export const GET = createSmartRouteHandler({ bodyType: "json", body: { id: req.params.item_id, - displayName: itemConfig.displayName, + display_name: itemConfig.displayName, quantity: totalQuantity, }, }; diff --git a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts index 50715b901b..6a6773b805 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts @@ -5,7 +5,6 @@ import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; -import { getPrismaClientForTenancy } from "@/prisma-client"; import { CustomerType } from "@prisma/client"; export const POST = createSmartRouteHandler({ @@ -19,7 +18,7 @@ export const POST = createSmartRouteHandler({ tenancy: adaptSchema.defined(), }).defined(), body: yupObject({ - customer_id: yupString().defined(), + customer_id: yupString().uuid().defined(), offer_id: yupString().optional(), offer_inline: inlineOfferSchema.optional(), }), @@ -37,30 +36,17 @@ export const POST = createSmartRouteHandler({ const offerConfig = await ensureOfferIdOrInlineOffer(tenancy, req.auth.type, req.body.offer_id, req.body.offer_inline); await ensureOfferCustomerTypeMatches(req.body.offer_id, offerConfig.customerType, req.body.customer_id, tenancy); const customerType = offerConfig.customerType ?? throwErr(500, "Customer type not found"); - const prisma = await getPrismaClientForTenancy(tenancy); - let dbCustomer = await prisma.customer.findUnique({ - where: { - tenancyId_id: { - tenancyId: tenancy.id, - id: req.body.customer_id, - }, - }, + const stripeCustomerSearch = await stripe.customers.search({ + query: `metadata['customerId']:'${req.body.customer_id}'`, }); - if (!dbCustomer) { - const stripeCustomer = await stripe.customers.create({ + let stripeCustomer = stripeCustomerSearch.data.length ? stripeCustomerSearch.data[0] : undefined; + if (!stripeCustomer) { + stripeCustomer = await stripe.customers.create({ metadata: { customerId: req.body.customer_id, - customerType, - } - }); - dbCustomer = await prisma.customer.create({ - data: { - tenancyId: tenancy.id, - id: req.body.customer_id, - stripeCustomerId: stripeCustomer.id, customerType: customerType === "user" ? CustomerType.USER : CustomerType.TEAM, - }, + } }); } @@ -71,7 +57,7 @@ export const POST = createSmartRouteHandler({ tenancyId: tenancy.id, customerId: req.body.customer_id, offer: offerConfig, - stripeCustomerId: dbCustomer.stripeCustomerId, + stripeCustomerId: stripeCustomer.id, stripeAccountId: tenancy.config.payments.stripeAccountId ?? throwErr(500, "Stripe account not configured"), }, method: {}, diff --git a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx index b3a7846f27..00114e4107 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx @@ -1,44 +1,73 @@ +import Stripe from "stripe"; import { getStripeForAccount } from "@/lib/stripe"; -import { NextRequest } from "next/server"; import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import Stripe from "stripe"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -export async function POST(request: NextRequest) { - const { code, price_id } = await request.json(); - const { data } = await purchaseUrlVerificationCodeHandler.validateCode(code); - const stripe = getStripeForAccount({ accountId: data.stripeAccountId }); - const pricesMap = new Map(Object.entries(data.offer.prices)); - const selectedPrice = pricesMap.get(price_id); - if (!selectedPrice) { - throwErr(400, "Price not found"); - } - // TODO: prices with no interval should be allowed and work without a subscription - if (!selectedPrice.interval) { - throwErr(500, "Price does not have an interval"); - } - const product = await stripe.products.create({ - name: data.offer.displayName ?? "Subscription", - }); - const subscription = await stripe.subscriptions.create({ - customer: data.stripeCustomerId, - payment_behavior: 'default_incomplete', - payment_settings: { save_default_payment_method: 'on_subscription' }, - expand: ['latest_invoice.confirmation_secret', 'pending_setup_intent'], - items: [{ - price_data: { - currency: "usd", - unit_amount: Number(selectedPrice.USD) * 100, - product: product.id, - recurring: { - interval_count: selectedPrice.interval[0], - interval: selectedPrice.interval[1], +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + body: yupObject({ + code: yupString().defined(), + price_id: yupString().defined(), + }), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + client_secret: yupString().defined(), + }), + }), + async handler({ body }) { + const { code, price_id } = body; + const { data } = await purchaseUrlVerificationCodeHandler.validateCode(code); + const stripe = getStripeForAccount({ accountId: data.stripeAccountId }); + const pricesMap = new Map(Object.entries(data.offer.prices)); + const selectedPrice = pricesMap.get(price_id); + if (!selectedPrice) { + throwErr(400, "Price not found"); + } + // TODO: prices with no interval should be allowed and work without a subscription + if (!selectedPrice.interval) { + throwErr(500, "Price does not have an interval"); + } + const product = await stripe.products.create({ + name: data.offer.displayName ?? "Subscription", + }); + const subscription = await stripe.subscriptions.create({ + customer: data.stripeCustomerId, + payment_behavior: 'default_incomplete', + payment_settings: { save_default_payment_method: 'on_subscription' }, + expand: ['latest_invoice.confirmation_secret'], + items: [{ + price_data: { + currency: "usd", + unit_amount: Number(selectedPrice.USD) * 100, + product: product.id, + recurring: { + interval_count: selectedPrice.interval[0], + interval: selectedPrice.interval[1], + }, }, + quantity: 1, + }], + metadata: { + offer: JSON.stringify(data.offer), }, - quantity: 1, - }], - }); - const clientSecret = (subscription.latest_invoice as Stripe.Invoice).confirmation_secret?.client_secret; - return Response.json({ client_secret: clientSecret }); - -} + }); + const clientSecret = (subscription.latest_invoice as Stripe.Invoice).confirmation_secret?.client_secret; + // stripe-mock returns an empty string here + if (typeof clientSecret !== "string") { + throwErr(500, "No client secret returned from Stripe for subscription"); + } + return { + statusCode: 200, + bodyType: "json", + body: { client_secret: clientSecret }, + }; + } +}); diff --git a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts index a87352c480..ab94e40ae5 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts +++ b/apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts @@ -1,12 +1,56 @@ -import { NextRequest } from "next/server"; import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler"; +import { dayIntervalSchema, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; -export async function POST(request: NextRequest) { - const { code } = await request.json(); - const verificationCode = await purchaseUrlVerificationCodeHandler.validateCode(code); +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + body: yupObject({ + code: yupString().defined(), + }), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + offer: yupObject({ + display_name: yupString(), + customer_type: yupString(), + prices: yupRecord( + yupString(), + yupObject({ + USD: yupString(), + interval: dayIntervalSchema + }).defined(), + ).defined(), + }).defined(), + stripe_account_id: yupString().defined(), + }).defined(), + }), + async handler({ body }) { + const verificationCode = await purchaseUrlVerificationCodeHandler.validateCode(body.code); + const offer = verificationCode.data.offer; + const offerData = { + display_name: offer.displayName, + customer_type: offer.customerType, + prices: Object.fromEntries(Object.entries(offer.prices).map(([key, value]) => [key, filterUndefined({ + ...value, + free_trial: value.freeTrial, + freeTrial: undefined, + serverOnly: undefined, + })])), + }; - return Response.json({ - offer: verificationCode.data.offer, - stripe_account_id: verificationCode.data.stripeAccountId, - }); -} + return { + statusCode: 200, + bodyType: "json", + body: { + offer: offerData, + stripe_account_id: verificationCode.data.stripeAccountId, + }, + }; + }, +}); diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index f6e2cb9e21..3817cd98c4 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -541,6 +541,5 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Organiza allow_user_api_keys: renderedConfig.apiKeys.enabled.user, allow_team_api_keys: renderedConfig.apiKeys.enabled.team, - payments: renderedConfig.payments, }; }; diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 7cc1c10887..3c6bd1e076 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -11,8 +11,8 @@ export async function ensureOfferIdOrInlineOffer( tenancy: Tenancy, accessType: "client" | "server" | "admin", offerId: string | undefined, - inlineOffer: object | undefined -): Promise> { + inlineOffer: yup.InferType | undefined +): Promise { if (offerId && inlineOffer) { throw new StatusError(400, "Cannot specify both offer_id and offer_inline!"); } @@ -29,8 +29,23 @@ export async function ensureOfferIdOrInlineOffer( } return offer; } else { - // if we fail the validation here, we should throw an internal server error; inline offers should've been validated in the request schema already - return await yupValidate(inlineOfferSchema, inlineOffer); + if (!inlineOffer) { + throw new StatusError(500, "Inline offer does not exist, this should never happen"); + } + return { + displayName: inlineOffer.display_name, + customerType: inlineOffer.customer_type, + freeTrial: inlineOffer.free_trial, + serverOnly: inlineOffer.server_only, + stackable: false, + prices: Object.fromEntries(Object.entries(inlineOffer.prices).map(([key, value]) => [key, { + ...value, + freeTrial: value.free_trial, + serverOnly: true, + free_trial: undefined, + }])) as Tenancy["config"]["payments"]["offers"][string]["prices"], + includedItems: inlineOffer.included_items as Tenancy["config"]["payments"]["offers"][string]["includedItems"], + }; } } diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index 9140ee9c56..cd43dba003 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -1,10 +1,20 @@ import { getTenancy, Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy } from "@/prisma-client"; -import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { overrideEnvironmentConfigOverride } from "./config"; +import { CustomerType } from "@prisma/client"; import Stripe from "stripe"; -export const stackStripe = new Stripe(getEnvVariable("STACK_STRIPE_SECRET_KEY")); +const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY"); +const useStripeMock = stripeSecretKey === "sk_test_mockstripekey" && ["development", "test"].includes(getNodeEnvironment()); +const stripeConfig: Stripe.StripeConfig = useStripeMock ? { + protocol: "http", + host: "localhost", + port: 8120, +} : {}; + +export const stackStripe = new Stripe(stripeSecretKey, stripeConfig); export const getStripeForAccount = (options: { tenancy?: Tenancy, accountId?: string }) => { if (!options.tenancy && !options.accountId) { @@ -14,34 +24,32 @@ export const getStripeForAccount = (options: { tenancy?: Tenancy, accountId?: st if (!accountId) { throwErr(400, "Stripe account not configured"); } - return new Stripe(getEnvVariable("STACK_STRIPE_SECRET_KEY"), { - stripeAccount: accountId, - }); + return new Stripe(stripeSecretKey, { stripeAccount: accountId, ...stripeConfig }); }; -export async function syncStripeDataToDB(stripeAccountId: string, stripeCustomerId: string) { +export async function syncStripeSubscriptions(stripeAccountId: string, stripeCustomerId: string) { const stripe = getStripeForAccount({ accountId: stripeAccountId }); const account = await stripe.accounts.retrieve(stripeAccountId); if (!account.metadata?.tenancyId) { throwErr(500, "Stripe account metadata missing tenancyId"); } + const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId); + if (stripeCustomer.deleted) { + return; + } + const customerId = stripeCustomer.metadata.customerId; + const customerType = stripeCustomer.metadata.customerType; + if (!customerId || !customerType) { + throwErr(500, "Stripe customer metadata missing customerId or customerType"); + } + if (customerType !== CustomerType.USER && customerType !== CustomerType.TEAM) { + throwErr(500, "Stripe customer metadata has invalid customerType"); + } const tenancy = await getTenancy(account.metadata.tenancyId); if (!tenancy) { throwErr(500, "Tenancy not found"); } const prisma = await getPrismaClientForTenancy(tenancy); - const customer = await prisma.customer.findUnique({ - where: { - tenancyId_stripeCustomerId: { - tenancyId: tenancy.id, - stripeCustomerId, - }, - }, - }); - if (!customer) { - throwErr(500, "Customer not found in DB"); - } - const subscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId, status: "all", @@ -58,13 +66,16 @@ export async function syncStripeDataToDB(stripeAccountId: string, stripeCustomer }, update: { status: subscription.status, + offer: JSON.parse(subscription.metadata.offer), currentPeriodEnd: new Date(subscription.items.data[0].current_period_end * 1000), currentPeriodStart: new Date(subscription.items.data[0].current_period_start * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, }, create: { tenancyId: tenancy.id, - customerId: customer.id, + customerId, + customerType, + offer: JSON.parse(subscription.metadata.offer), stripeSubscriptionId: subscription.id, status: subscription.status, currentPeriodEnd: new Date(subscription.items.data[0].current_period_end * 1000), @@ -74,3 +85,19 @@ export async function syncStripeDataToDB(stripeAccountId: string, stripeCustomer }); } } + +export async function syncStripeAccountStatus(stripeAccountId: string) { + const account = await stackStripe.accounts.retrieve(stripeAccountId); + if (!account.metadata?.tenancyId) { + throwErr(500, "Stripe account metadata missing tenancyId"); + } + const tenancy = await getTenancy(account.metadata.tenancyId) ?? throwErr(500, "Tenancy not found"); + const setupComplete = !account.requirements?.past_due?.length; + await overrideEnvironmentConfigOverride({ + projectId: tenancy.project.id, + branchId: tenancy.branchId, + environmentConfigOverrideOverride: { + [`payments.stripeAccountSetupComplete`]: setupComplete, + }, + }); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx index 5eb9c0f8b1..fb5fc3b580 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx @@ -2,14 +2,38 @@ import { SmartFormDialog } from "@/components/form-dialog"; import { SelectField } from "@/components/form-fields"; -import { ActionDialog, Button, FormControl, FormField, FormItem, FormLabel, FormMessage, InlineCode, toast, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Label, Card, CardContent, CardHeader, CardTitle, Checkbox } from "@stackframe/stack-ui"; +import { + ActionDialog, + Button, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + InlineCode, + toast, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Label, + Card, + CardContent, + CardHeader, + CardTitle +} from "@stackframe/stack-ui"; import { PaymentOfferTable } from "@/components/data-table/payment-offer-table"; import { PaymentItemTable } from "@/components/data-table/payment-item-table"; import { useState } from "react"; import * as yup from "yup"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; -import { offerPriceSchema, yupRecord } from "@stackframe/stack-shared/dist/schema-fields"; +import { + offerPriceSchema, + yupRecord +} from "@stackframe/stack-shared/dist/schema-fields"; import { Control, FieldValues, Path } from "react-hook-form"; import { Trash2, Plus } from "lucide-react"; import { AdminProject } from "@stackframe/stack"; @@ -49,8 +73,11 @@ export default function PageClient() { title="Payments" description="Manage your payment offers and items" actions={
- - + {paymentsConfig.stripeAccountSetupComplete ? ( + + ) : ( + + )}
} > Create New Purchase} + trigger={} title="Create New Purchase" formSchema={yup.object({ customerId: yup.string().uuid().defined().label("Customer ID"), diff --git a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx index 2948719c1a..c5376f6d56 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -5,13 +5,14 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { getPublicEnvVar } from "@/lib/env"; import { StripeElementsProvider } from "@/components/payments/stripe-elements-provider"; import { CheckoutForm } from "@/components/payments/checkout"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; type OfferData = { offer?: any, stripe_account_id: string, }; -const apiUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? "https://api.stack-auth.com"; +const apiUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); const baseUrl = new URL("/api/v1", apiUrl).toString(); export default function PageClient({ code }: { code: string }) { @@ -65,6 +66,7 @@ export default function PageClient({ code }: { code: string }) { const setupSubscription = async () => { const response = await fetch(`${baseUrl}/payments/purchases/purchase-session`, { method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, price_id: selectedPriceId }), }); const result = await response.json(); diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index d89bb8cbde..cd93590e55 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -1105,6 +1105,16 @@ export namespace Project { }); return createResult; } + + export async function updateConfig(config: any) { + const response = await niceBackendFetch(`/api/latest/internal/config/override`, { + accessType: "admin", + method: "PATCH", + body: { config_override_string: JSON.stringify(config) }, + }); + expect(response.body).toMatchInlineSnapshot(`{}`); + expect(response.status).toBe(200); + } } export namespace Team { @@ -1223,7 +1233,7 @@ export namespace Team { } export namespace User { - export function setBackendContextFromUser({ mailbox, accessToken, refreshToken }: {mailbox: Mailbox, accessToken: string, refreshToken: string}) { + export function setBackendContextFromUser({ mailbox, accessToken, refreshToken }: { mailbox: Mailbox, accessToken: string, refreshToken: string }) { backendContext.set({ mailbox, userAuth: { @@ -1243,7 +1253,7 @@ export namespace User { return response.body; } - export async function create({ emailAddress }: {emailAddress?: string} = {}) { + export async function create({ emailAddress }: { emailAddress?: string } = {}) { // Create new mailbox const email = emailAddress ?? `unindexed-mailbox--${randomUUID()}${generatedEmailSuffix}`; const mailbox = createMailbox(email); @@ -1279,7 +1289,7 @@ export namespace User { const users = []; for (let i = 0; i < count; i++) { const user = await User.create({}); - users.push(user); + users.push(user); } return users; } @@ -1371,3 +1381,48 @@ export namespace Webhook { return []; } } + +export namespace Payments { + export async function createPurchaseUrlAndGetCode() { + await Project.updateConfig({ + payments: { + stripeAccountId: "acct_test123", + offers: { + "test-offer": { + displayName: "Test Offer", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + "monthly": { + USD: "1000", + interval: [1, "month"], + }, + }, + includedItems: {}, + }, + }, + }, + }); + + const { userId } = await User.create(); + const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "client", + body: { + customer_id: userId, + offer_id: "test-offer", + }, + }); + expect(response.status).toBe(200); + const body = response.body as { url: string }; + expect(body.url).toMatch(/^https?:\/\/localhost:8101\/purchase\/[a-z0-9]+$/); + const codeMatch = body.url.match(/\/purchase\/([a-z0-9]+)/); + const code = codeMatch ? codeMatch[1] : undefined; + expect(code).toBeDefined(); + + return { + code, + }; + } +} diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts new file mode 100644 index 0000000000..04bbdf75c7 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts @@ -0,0 +1,266 @@ +import { it } from "../../../../../helpers"; +import { Auth, Project, User, niceBackendFetch } from "../../../../backend-helpers"; +import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; + +it("should not be able to create purchase URL without offer_id or offer_inline", async ({ expect }) => { + await Project.createAndSwitch(); + await Project.updateConfig({ + payments: { + stripeAccountId: "acct_test123", + }, + }); + const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "client", + body: { + customer_id: generateUuid(), + }, + }); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": "Must specify either offer_id or offer_inline!", + "headers": Headers {