mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Emulator Test / docker (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Test / docker (push) Has been cancelled
Runs E2E API Tests / build (22.x) (push) Has been cancelled
Runs E2E API Tests with external source of truth / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- RECURSEML_SUMMARY:START --> ## High-level PR Summary This PR implements a comprehensive renaming of "offer" to "product" and "offer group" to "product catalog" throughout the codebase. The changes include database migrations, schema updates, API compatibility layers, function renames, and updates to client and server implementations. Backwards compatibility is maintained through migration layers that handle requests using the old terminology, translating them to the new terminology before processing. The PR includes documentation of this approach in CLAUDE-KNOWLEDGE.md. This rename affects multiple parts of the system including the database schema, API endpoints, error types, and SDK interfaces. ⏱️ Estimated Review Time: 1-3 hours <details> <summary>💡 Review Order Suggestion</summary> | Order | File Path | |-------|-----------| | 1 | `apps/backend/prisma/migrations/20250923191615_rename_offers_to_products/migration.sql` | | 2 | `apps/backend/src/app/api/migrations/v2beta1/payments/purchases/offers-compat.ts` | | 3 | `apps/backend/src/app/api/migrations/v2beta1/payments/purchases/create-purchase-url/route.ts` | | 4 | `apps/backend/src/app/api/migrations/v2beta1/payments/purchases/validate-code/route.ts` | | 5 | `apps/backend/src/lib/payments.tsx` | | 6 | `.claude/CLAUDE-KNOWLEDGE.md` | | 7 | `packages/stack-shared/src/schema-fields.ts` | | 8 | `packages/stack-shared/src/known-errors.tsx` | | 9 | `packages/stack-shared/src/config/schema.ts` | | 10 | `packages/template/src/lib/stack-app/customers/index.ts` | | 11 | `packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts` | | 12 | `packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts` | </details> [](https://discord.gg/n3SsVDAW6U) <!-- RECURSEML_SUMMARY:END --> <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Renames 'offer' to 'product' and 'offer group' to 'product catalog' across the codebase, updating database schema, API endpoints, and application logic for consistency and backward compatibility. > > - **Database**: > - Rename columns `offer` to `product` and `offerId` to `productId` in `OneTimePurchase` and `Subscription` tables in `migration.sql`. > - **API & Migrations**: > - Update API endpoints to accept `product_id`/`product_inline` instead of `offer_id`/`offer_inline`. > - Add `v2beta5` compatibility layer to map legacy `offer` fields to `product` equivalents. > - **Shared Schemas**: > - Rename `offerSchema` to `productSchema` and related schemas in `schema-fields.ts`. > - **Server Implementation**: > - Update `createCheckoutUrl` method in `server-app-impl.ts` to use `productId`/`InlineProduct`. > - **Tests**: > - Update tests to reflect renaming in `backend-helpers.ts` and other test files. > - **Miscellaneous**: > - Remove dummy data related to offers in `dummy-data.tsx`. > - Update documentation and comments to reflect terminology changes. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup> fore3227bcbd2. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> ---- <!-- ELLIPSIS_HIDDEN --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Backwards-compatibility: legacy offer_id/offer_inline requests are accepted, normalized, and routed to product-based handlers. * **Refactor** * Global rename from Offer/Group → Product/Catalog across UI, APIs, types, client/server interfaces, and error codes. * **Bug Fixes** * Responses, webhooks and UI consistently surface product_display_name and product-related metadata. * **Documentation** * Migration notes and docs updated to explain compatibility and parameter changes. * **Tests** * Unit and E2E suites updated to cover product/catalog flows. * **Chores** * Database schema migration, seed and config updates applied. <!-- end of auto-generated comment: release notes by coderabbit.ai --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Renames offers→products and groups→catalogs end-to-end (DB, APIs, schemas, UI, SDK, docs), adding v2beta5 compatibility to accept legacy offer fields while updating all internals. > > - **Backend/DB**: > - Prisma migration: rename `offer`/`offerId`→`product`/`productId` in `OneTimePurchase` and `Subscription`. > - Update Stripe webhook, purchase-session, and internal test-mode flows to use `product*` metadata/fields. > - **API & Migrations**: > - Latest endpoints now accept `product_id`/`product_inline`. > - Add `v2beta5` compat layer mapping legacy `offer_id`/`offer_inline` to product equivalents; responses alias conflicting products. > - **Shared Schemas/Errors/Config**: > - `offerSchema`→`productSchema`, `inlineOfferSchema`→`inlineProductSchema`, prices/types renamed. > - KnownErrors renamed (e.g., `PRODUCT_DOES_NOT_EXIST`). > - Config: `groups`→`catalogs`, defaults/migrations updated; improved override validation messages; ID regex loosened; formatter tweaks; add schema fuzzer tests. > - **Payments Lib**: > - Rename APIs and logic (`offers`→`products`, `groupId`→`catalogId`), subscription and item-quantity computation updated. > - **Dashboard/UI**: > - Routes, dialogs, editors, tables, and code samples switched to products/catalogs; removed offers dummy data. > - **SDK/Template**: > - Client/server `createCheckoutUrl` now uses `productId`/`InlineProduct`. > - **Tests/Docs/Utilities**: > - E2E and unit tests updated; add legacy (pre-rename) tests. > - Docs and knowledge base revised; minor script tweaks (recent-first, limits). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commite6e20ecd72. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: BilalG1 <bg2002@gmail.com>
301 lines
9.5 KiB
TypeScript
301 lines
9.5 KiB
TypeScript
// see https://github.com/stack-auth/info/blob/main/eng-handbook/random-thoughts/config-json-format.md
|
|
|
|
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;
|
|
export type Config = {
|
|
[keyOrDotNotation: string]: ConfigValue | undefined, // must support undefined for optional values
|
|
};
|
|
|
|
export type NormalizedConfigValue = string | number | boolean | NormalizedConfig | NormalizedConfigValue[];
|
|
export type NormalizedConfig = {
|
|
[key: string]: NormalizedConfigValue | undefined, // must support undefined for optional values
|
|
};
|
|
|
|
export type _NormalizesTo<N> = N extends object ? (
|
|
& Config
|
|
& { [K in OptionalKeys<N>]?: _NormalizesTo<N[K]> | null }
|
|
& { [K in RequiredKeys<N>]: undefined extends N[K] ? _NormalizesTo<N[K]> | null : _NormalizesTo<N[K]> }
|
|
& { [K in `${string}.${string}`]: ConfigValue }
|
|
) : N;
|
|
export type NormalizesTo<N extends NormalizedConfig> = _NormalizesTo<N>;
|
|
|
|
/**
|
|
* Note that a config can both be valid and not normalizable.
|
|
*/
|
|
export function isValidConfig(c: unknown): c is Config {
|
|
return getInvalidConfigReason(c) === undefined;
|
|
}
|
|
|
|
export function getInvalidConfigReason(c: unknown, options: { configName?: string } = {}): string | undefined {
|
|
const configName = options.configName ?? 'config';
|
|
if (c === null || typeof c !== 'object') return `${configName} must be a non-null object`;
|
|
for (const [key, value] of Object.entries(c)) {
|
|
if (value === undefined) continue;
|
|
if (typeof key !== 'string') return `${configName} must have only string keys (found: ${typeof key})`;
|
|
if (!key.match(/^[a-zA-Z0-9_:$][a-zA-Z_:$0-9\-]*(?:\.[a-zA-Z0-9_:$][a-zA-Z_:$0-9\-]*)*$/)) return `All keys of ${configName} must consist of only alphanumeric characters, dots, underscores, colons, dollar signs, or hyphens and start with a character other than a hyphen (found: ${key})`;
|
|
|
|
const entryName = `${configName}.${key}`;
|
|
const reason = getInvalidConfigValueReason(value, { valueName: entryName });
|
|
if (reason) return reason;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function getInvalidConfigValueReason(value: unknown, options: { valueName?: string } = {}): string | undefined {
|
|
const valueName = options.valueName ?? 'value';
|
|
switch (typeof value) {
|
|
case 'string':
|
|
case 'number':
|
|
case 'boolean': {
|
|
break;
|
|
}
|
|
case 'object': {
|
|
if (value === null) {
|
|
break;
|
|
} else if (Array.isArray(value)) {
|
|
for (const [index, v] of value.entries()) {
|
|
const elementValueName = `${valueName}[${index}]`;
|
|
if (v === null) return `${elementValueName} is null; tuple elements cannot be null`;
|
|
const reason = getInvalidConfigValueReason(v, { valueName: elementValueName });
|
|
if (reason) return reason;
|
|
}
|
|
} else {
|
|
const reason = getInvalidConfigReason(value, { configName: valueName });
|
|
if (reason) return reason;
|
|
}
|
|
break;
|
|
}
|
|
default: {
|
|
return `${valueName} has an invalid value type ${typeof value} (value: ${value})`;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function assertValidConfig(c: unknown) {
|
|
const reason = getInvalidConfigReason(c);
|
|
if (reason) throw new StackAssertionError(`Invalid config: ${reason}`, { c });
|
|
}
|
|
|
|
export function override(c1: Config, ...configs: Config[]) {
|
|
if (configs.length === 0) return c1;
|
|
if (configs.length > 1) return override(override(c1, configs[0]), ...configs.slice(1));
|
|
const c2 = configs[0];
|
|
|
|
assertValidConfig(c1);
|
|
assertValidConfig(c2);
|
|
|
|
let result = c1;
|
|
for (const key of Object.keys(filterUndefined(c2))) {
|
|
result = Object.fromEntries(
|
|
Object.entries(result).filter(([k]) => k !== key && !k.startsWith(key + '.'))
|
|
);
|
|
}
|
|
|
|
return {
|
|
...result,
|
|
...filterUndefined(c2),
|
|
};
|
|
}
|
|
|
|
import.meta.vitest?.test("override(...)", ({ expect }) => {
|
|
expect(
|
|
override(
|
|
{
|
|
a: 1,
|
|
b: 2,
|
|
"c.d": 3,
|
|
"c.e.f": 4,
|
|
"c.g": 5,
|
|
h: [6, { i: 7 }, 8],
|
|
k: 123,
|
|
l: undefined,
|
|
},
|
|
{
|
|
a: 9,
|
|
"c.d": 10,
|
|
"c.e": null,
|
|
"h.0": 11,
|
|
"h.1": {
|
|
j: 12,
|
|
},
|
|
k: undefined,
|
|
},
|
|
)
|
|
).toEqual({
|
|
a: 9,
|
|
b: 2,
|
|
"c.d": 10,
|
|
"c.e": null,
|
|
"c.g": 5,
|
|
h: [6, { i: 7 }, 8],
|
|
"h.0": 11,
|
|
"h.1": {
|
|
j: 12,
|
|
},
|
|
k: 123,
|
|
l: undefined,
|
|
});
|
|
});
|
|
|
|
type NormalizeOptions = {
|
|
/**
|
|
* What to do if a dot notation is used on a value that is not an object.
|
|
*
|
|
* - "throw" (default): Throw an error.
|
|
* - "ignore": Ignore the dot notation field.
|
|
*/
|
|
onDotIntoNonObject?: "throw" | "ignore",
|
|
/**
|
|
* What to do if a dot notation is used on a value that is null.
|
|
*
|
|
* - "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 {
|
|
constructor(...args: ConstructorParameters<typeof Error>) {
|
|
super(...args);
|
|
}
|
|
}
|
|
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";
|
|
const onDotIntoNull = options.onDotIntoNull ?? "like-non-object";
|
|
|
|
const countDots = (s: string) => s.match(/\./g)?.length ?? 0;
|
|
const result: NormalizedConfig = {};
|
|
const keysByDepth = Object.keys(c).sort((a, b) => countDots(a) - countDots(b));
|
|
|
|
outer: for (const key of keysByDepth) {
|
|
const keySegmentsWithoutLast = key.split('.');
|
|
const last = keySegmentsWithoutLast.pop() ?? throwErr('split returns empty array?');
|
|
const value = get(c, key);
|
|
if (value === undefined) continue;
|
|
|
|
let current: NormalizedConfig = result;
|
|
for (const keySegment of keySegmentsWithoutLast) {
|
|
if (!hasAndNotUndefined(current, keySegment)) {
|
|
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, {});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const value = get(current, keySegment);
|
|
if (typeof value !== 'object') {
|
|
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, options);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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(v => normalizeValue(v, options));
|
|
if (typeof value === 'object') return normalize(value, options);
|
|
return value;
|
|
}
|
|
|
|
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, options));
|
|
}
|
|
}
|
|
|
|
import.meta.vitest?.test("normalize(...)", ({ expect }) => {
|
|
expect(normalize({
|
|
a: 9,
|
|
b: 2,
|
|
c: {},
|
|
"c.d": 10,
|
|
"c.e": null,
|
|
"c.g": 5,
|
|
h: [6, { i: 7 }, 8],
|
|
"h.0": 11,
|
|
"h.1": {
|
|
j: 12,
|
|
},
|
|
k: { l: {} },
|
|
"k.l.m": 13,
|
|
n: undefined,
|
|
}, { onDotIntoNonObject: "ignore" })).toEqual({
|
|
a: 9,
|
|
b: 2,
|
|
c: {
|
|
d: 10,
|
|
g: 5,
|
|
},
|
|
h: [11, { j: 12 }, 8],
|
|
k: { l: { m: 13 } },
|
|
});
|
|
|
|
// dotting into null
|
|
expect(() => normalize({
|
|
"b.c": 2,
|
|
}, { 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,
|
|
}, { 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,
|
|
}, { onDotIntoNonObject: "ignore" })).toEqual({});
|
|
|
|
// dotting into non-object
|
|
expect(() => normalize({
|
|
b: 1,
|
|
"b.c": 2,
|
|
}, { 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 });
|
|
});
|