From 4df268d16696b718154dd527dd2c2897ef572bfb Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Tue, 21 Apr 2026 13:02:39 -0700 Subject: [PATCH 1/4] Make payments schema validator handle partial overrides --- .../stack-shared/src/config/schema.test.ts | 57 +++++++++++++++++++ packages/stack-shared/src/config/schema.ts | 10 +++- 2 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 packages/stack-shared/src/config/schema.test.ts diff --git a/packages/stack-shared/src/config/schema.test.ts b/packages/stack-shared/src/config/schema.test.ts new file mode 100644 index 0000000000..385fc5be2d --- /dev/null +++ b/packages/stack-shared/src/config/schema.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { branchPaymentsSchema } from "./schema"; + +describe("branchPaymentsSchema", () => { + it("accepts partial payments config without products", async () => { + await expect(branchPaymentsSchema.validate({ + blockNewPurchases: true, + })).resolves.toMatchObject({ + blockNewPurchases: true, + }); + }); + + it("accepts product lines without products", async () => { + await expect(branchPaymentsSchema.validate({ + productLines: { + pro: { + displayName: "Pro", + customerType: "user", + }, + }, + })).resolves.toMatchObject({ + productLines: { + pro: { + displayName: "Pro", + customerType: "user", + }, + }, + }); + }); + + it("rejects a product that references a missing product line", async () => { + await expect(branchPaymentsSchema.validate({ + products: { + pro: { + customerType: "user", + productLineId: "missing-line", + }, + }, + })).rejects.toThrow('Product "pro" specifies product line ID "missing-line", but that product line does not exist'); + }); + + it("rejects a product whose customer type differs from its product line", async () => { + await expect(branchPaymentsSchema.validate({ + productLines: { + teamLine: { + customerType: "team", + }, + }, + products: { + pro: { + customerType: "user", + productLineId: "teamLine", + }, + }, + })).rejects.toThrow('Product "pro" has customer type "user" but its product line "teamLine" has customer type "team"'); + }); +}); diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index a4d43702e5..db90d180e1 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -181,9 +181,13 @@ export const branchPaymentsSchema = yupObject({ 'Product customer type must match its product line customer type', function(this: yup.TestContext, value) { if (!value) return true; - for (const [productId, product] of Object.entries(value.products)) { - if (!product.productLineId) continue; - const productLine = getOrUndefined(value.productLines, product.productLineId); + const products = Reflect.get(value, "products"); + if (!isObjectLike(products)) return true; + + const productLines = Reflect.get(value, "productLines"); + for (const [productId, product] of Object.entries(products)) { + if (product.productLineId == null) continue; + const productLine = isObjectLike(productLines) ? getOrUndefined(productLines, product.productLineId) : undefined; if (productLine === undefined) { return this.createError({ message: `Product "${productId}" specifies product line ID "${product.productLineId}", but that product line does not exist`, From 2a95ebcbd121db45ee9f2c9945b17b0965bd6de6 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 23 Apr 2026 14:45:52 -0700 Subject: [PATCH 2/4] fix: guard null payments config entries --- .../stack-shared/src/config/schema.test.ts | 22 +++++++++++++++++++ packages/stack-shared/src/config/schema.ts | 2 ++ 2 files changed, 24 insertions(+) diff --git a/packages/stack-shared/src/config/schema.test.ts b/packages/stack-shared/src/config/schema.test.ts index 385fc5be2d..782da98685 100644 --- a/packages/stack-shared/src/config/schema.test.ts +++ b/packages/stack-shared/src/config/schema.test.ts @@ -39,6 +39,28 @@ describe("branchPaymentsSchema", () => { })).rejects.toThrow('Product "pro" specifies product line ID "missing-line", but that product line does not exist'); }); + it("rejects null product entries without throwing a raw TypeError", async () => { + await expect(branchPaymentsSchema.validate({ + products: { + pro: null, + }, + })).rejects.toMatchObject({ name: "ValidationError" }); + }); + + it("rejects null product line entries without throwing a raw TypeError", async () => { + await expect(branchPaymentsSchema.validate({ + productLines: { + teamLine: null, + }, + products: { + pro: { + customerType: "user", + productLineId: "teamLine", + }, + }, + })).rejects.toMatchObject({ name: "ValidationError" }); + }); + it("rejects a product whose customer type differs from its product line", async () => { await expect(branchPaymentsSchema.validate({ productLines: { diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index db90d180e1..bb7a748eb5 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -186,6 +186,7 @@ export const branchPaymentsSchema = yupObject({ const productLines = Reflect.get(value, "productLines"); for (const [productId, product] of Object.entries(products)) { + if (!isObjectLike(product)) continue; if (product.productLineId == null) continue; const productLine = isObjectLike(productLines) ? getOrUndefined(productLines, product.productLineId) : undefined; if (productLine === undefined) { @@ -194,6 +195,7 @@ export const branchPaymentsSchema = yupObject({ path: `${this.path}.products.${productId}.productLineId`, }); } + if (!isObjectLike(productLine)) continue; if (product.customerType !== productLine.customerType) { return this.createError({ message: `Product "${productId}" has customer type "${product.customerType}" but its product line "${product.productLineId}" has customer type "${productLine.customerType}"`, From 03347ac1e120fb420332a0923c619a0cff18e826 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 24 Apr 2026 10:03:02 -0700 Subject: [PATCH 3/4] refactor: simplify product and product line access in branchPaymentsSchema validation --- packages/stack-shared/src/config/schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index bb7a748eb5..fcffff8cee 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -181,10 +181,10 @@ export const branchPaymentsSchema = yupObject({ 'Product customer type must match its product line customer type', function(this: yup.TestContext, value) { if (!value) return true; - const products = Reflect.get(value, "products"); + const products = value.products; if (!isObjectLike(products)) return true; - const productLines = Reflect.get(value, "productLines"); + const productLines = value.productLines; for (const [productId, product] of Object.entries(products)) { if (!isObjectLike(product)) continue; if (product.productLineId == null) continue; From 278265484005e35f7b14fb3cdf9fd7c007b0483c Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 24 Apr 2026 15:11:51 -0700 Subject: [PATCH 4/4] refactor: remove old schema tests and integrate them into schema definition with improved validation --- .../stack-shared/src/config/schema.test.ts | 79 --------------- packages/stack-shared/src/config/schema.ts | 98 ++++++++++++++++++- 2 files changed, 94 insertions(+), 83 deletions(-) delete mode 100644 packages/stack-shared/src/config/schema.test.ts diff --git a/packages/stack-shared/src/config/schema.test.ts b/packages/stack-shared/src/config/schema.test.ts deleted file mode 100644 index 782da98685..0000000000 --- a/packages/stack-shared/src/config/schema.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { branchPaymentsSchema } from "./schema"; - -describe("branchPaymentsSchema", () => { - it("accepts partial payments config without products", async () => { - await expect(branchPaymentsSchema.validate({ - blockNewPurchases: true, - })).resolves.toMatchObject({ - blockNewPurchases: true, - }); - }); - - it("accepts product lines without products", async () => { - await expect(branchPaymentsSchema.validate({ - productLines: { - pro: { - displayName: "Pro", - customerType: "user", - }, - }, - })).resolves.toMatchObject({ - productLines: { - pro: { - displayName: "Pro", - customerType: "user", - }, - }, - }); - }); - - it("rejects a product that references a missing product line", async () => { - await expect(branchPaymentsSchema.validate({ - products: { - pro: { - customerType: "user", - productLineId: "missing-line", - }, - }, - })).rejects.toThrow('Product "pro" specifies product line ID "missing-line", but that product line does not exist'); - }); - - it("rejects null product entries without throwing a raw TypeError", async () => { - await expect(branchPaymentsSchema.validate({ - products: { - pro: null, - }, - })).rejects.toMatchObject({ name: "ValidationError" }); - }); - - it("rejects null product line entries without throwing a raw TypeError", async () => { - await expect(branchPaymentsSchema.validate({ - productLines: { - teamLine: null, - }, - products: { - pro: { - customerType: "user", - productLineId: "teamLine", - }, - }, - })).rejects.toMatchObject({ name: "ValidationError" }); - }); - - it("rejects a product whose customer type differs from its product line", async () => { - await expect(branchPaymentsSchema.validate({ - productLines: { - teamLine: { - customerType: "team", - }, - }, - products: { - pro: { - customerType: "user", - productLineId: "teamLine", - }, - }, - })).rejects.toThrow('Product "pro" has customer type "user" but its product line "teamLine" has customer type "team"'); - }); -}); diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index fcffff8cee..4cbeae53a8 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -187,18 +187,19 @@ export const branchPaymentsSchema = yupObject({ const productLines = value.productLines; for (const [productId, product] of Object.entries(products)) { if (!isObjectLike(product)) continue; - if (product.productLineId == null) continue; - const productLine = isObjectLike(productLines) ? getOrUndefined(productLines, product.productLineId) : undefined; + const productLineId = product.productLineId; + if (typeof productLineId !== "string" || productLineId.length === 0) continue; + const productLine = isObjectLike(productLines) ? getOrUndefined(productLines, productLineId) : undefined; if (productLine === undefined) { return this.createError({ - message: `Product "${productId}" specifies product line ID "${product.productLineId}", but that product line does not exist`, + message: `Product "${productId}" specifies product line ID "${productLineId}", but that product line does not exist`, path: `${this.path}.products.${productId}.productLineId`, }); } if (!isObjectLike(productLine)) continue; if (product.customerType !== productLine.customerType) { return this.createError({ - message: `Product "${productId}" has customer type "${product.customerType}" but its product line "${product.productLineId}" has customer type "${productLine.customerType}"`, + message: `Product "${productId}" has customer type "${product.customerType}" but its product line "${productLineId}" has customer type "${productLine.customerType}"`, path: `${this.path}.products.${productId}.customerType`, }); } @@ -206,6 +207,95 @@ export const branchPaymentsSchema = yupObject({ return true; } ); +import.meta.vitest?.test("branchPaymentsSchema accepts partial payments config without products", async ({ expect }) => { + await expect(branchPaymentsSchema.validate({ + blockNewPurchases: true, + }, { abortEarly: false })).resolves.toMatchObject({ + blockNewPurchases: true, + }); +}); + +import.meta.vitest?.test("branchPaymentsSchema accepts product lines without products", async ({ expect }) => { + await expect(branchPaymentsSchema.validate({ + productLines: { + pro: { + displayName: "Pro", + customerType: "user", + }, + }, + }, { abortEarly: false })).resolves.toMatchObject({ + productLines: { + pro: { + displayName: "Pro", + customerType: "user", + }, + }, + }); +}); + +import.meta.vitest?.test("branchPaymentsSchema rejects a product that references a missing product line", async ({ expect }) => { + await expect(branchPaymentsSchema.validate({ + products: { + pro: { + customerType: "user", + productLineId: "missing-line", + prices: "include-by-default", + }, + }, + }, { abortEarly: false })).rejects.toThrowErrorMatchingInlineSnapshot(`[ValidationError: Product "pro" specifies product line ID "missing-line", but that product line does not exist]`); +}); + +import.meta.vitest?.test("branchPaymentsSchema rejects null product entries without throwing a raw TypeError", async ({ expect }) => { + await expect(branchPaymentsSchema.validate({ + products: { + pro: null, + }, + }, { abortEarly: false })).rejects.toThrowErrorMatchingInlineSnapshot(`[ValidationError: products cannot be null]`); +}); + +import.meta.vitest?.test("branchPaymentsSchema rejects null product line entries without throwing a raw TypeError", async ({ expect }) => { + await expect(branchPaymentsSchema.validate({ + productLines: { + teamLine: null, + }, + products: { + pro: { + customerType: "user", + productLineId: "teamLine", + prices: "include-by-default", + }, + }, + }, { abortEarly: false })).rejects.toThrowErrorMatchingInlineSnapshot(`[ValidationError: productLines cannot be null]`); +}); + +import.meta.vitest?.test("branchPaymentsSchema rejects a product whose customer type differs from its product line", async ({ expect }) => { + await expect(branchPaymentsSchema.validate({ + productLines: { + teamLine: { + customerType: "team", + }, + }, + products: { + pro: { + customerType: "user", + productLineId: "teamLine", + prices: "include-by-default", + }, + }, + }, { abortEarly: false })).rejects.toThrowErrorMatchingInlineSnapshot(`[ValidationError: Product "pro" has customer type "user" but its product line "teamLine" has customer type "team"]`); +}); + +import.meta.vitest?.test("branchPaymentsSchema lets productLineId schema reject empty IDs", async ({ expect }) => { + await expect(branchPaymentsSchema.validate({ + products: { + pro: { + customerType: "user", + productLineId: "", + prices: "include-by-default", + }, + }, + }, { abortEarly: false })).rejects.toThrowErrorMatchingInlineSnapshot(`[ValidationError: productLineId must contain only letters, numbers, underscores, and hyphens, and not start with a hyphen]`); +}); const branchDomain = yupObject({});