@webstudio-is/plans 0.260.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/package.json +32 -0
- package/src/dev-plan.server.test.ts +84 -0
- package/src/dev-plan.server.ts +85 -0
- package/src/index.server.ts +11 -0
- package/src/index.ts +6 -0
- package/src/plan-client.server.test.ts +272 -0
- package/src/plan-client.server.ts +218 -0
- package/src/plan-features.test.ts +132 -0
- package/src/plan-features.ts +150 -0
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { Client } from "@webstudio-is/postgrest/index.server";
|
|
2
|
+
import {
|
|
3
|
+
type PlanFeatures,
|
|
4
|
+
PlanFeaturesSchema,
|
|
5
|
+
defaultPlanFeatures,
|
|
6
|
+
parsePlansEnv,
|
|
7
|
+
type Purchase,
|
|
8
|
+
} from "./plan-features";
|
|
9
|
+
|
|
10
|
+
export { parsePlansEnv } from "./plan-features";
|
|
11
|
+
|
|
12
|
+
type PostgrestContext = { client: Client };
|
|
13
|
+
|
|
14
|
+
type AuthorizationContext =
|
|
15
|
+
| { type: "token"; ownerId: string }
|
|
16
|
+
| { type: "user"; userId: string }
|
|
17
|
+
| { type: "service" }
|
|
18
|
+
| { type: "anonymous" };
|
|
19
|
+
|
|
20
|
+
type PlanInfo = {
|
|
21
|
+
planFeatures: PlanFeatures;
|
|
22
|
+
purchases: Array<Purchase>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const parseProductMeta = (meta: unknown): Partial<PlanFeatures> => {
|
|
26
|
+
const result = PlanFeaturesSchema.partial().safeParse(meta);
|
|
27
|
+
return result.success ? result.data : {};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const mergeProductMetas = (
|
|
31
|
+
productMetas: Array<PlanFeatures>
|
|
32
|
+
): PlanFeatures => {
|
|
33
|
+
if (productMetas.length === 0) {
|
|
34
|
+
return defaultPlanFeatures;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return Object.fromEntries(
|
|
38
|
+
(Object.keys(defaultPlanFeatures) as Array<keyof PlanFeatures>).map(
|
|
39
|
+
(key) => {
|
|
40
|
+
const vals = productMetas.map((item) => item[key]);
|
|
41
|
+
const merged =
|
|
42
|
+
typeof defaultPlanFeatures[key] === "boolean"
|
|
43
|
+
? (vals as Array<boolean>).some(Boolean)
|
|
44
|
+
: Math.max(...(vals as Array<number>));
|
|
45
|
+
return [key, merged];
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
) as PlanFeatures;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const buildPurchases = (
|
|
52
|
+
userProducts: Array<{
|
|
53
|
+
productId: string | null | undefined;
|
|
54
|
+
subscriptionId: string | null | undefined;
|
|
55
|
+
}>,
|
|
56
|
+
productIdToName: Map<string, string>
|
|
57
|
+
): Array<Purchase> => {
|
|
58
|
+
const purchases: Array<Purchase> = [];
|
|
59
|
+
for (const userProduct of userProducts) {
|
|
60
|
+
if (userProduct.productId === null || userProduct.productId === undefined) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
purchases.push({
|
|
64
|
+
planName: productIdToName.get(userProduct.productId) ?? "",
|
|
65
|
+
subscriptionId: userProduct.subscriptionId ?? undefined,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return purchases;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type Product = { id: string; name: string; meta: unknown };
|
|
72
|
+
|
|
73
|
+
// Products are admin-created and rarely change. Cache them for the lifetime of the
|
|
74
|
+
// server instance to avoid a repeated DB round-trip on every request.
|
|
75
|
+
// The promise itself is cached to deduplicate concurrent cold-start requests.
|
|
76
|
+
let productCachePromise: Promise<Map<string, Product>> | undefined;
|
|
77
|
+
|
|
78
|
+
const getProductCache = (
|
|
79
|
+
postgrest: PostgrestContext
|
|
80
|
+
): Promise<Map<string, Product>> => {
|
|
81
|
+
if (productCachePromise !== undefined) {
|
|
82
|
+
return productCachePromise;
|
|
83
|
+
}
|
|
84
|
+
const promise: Promise<Map<string, Product>> = Promise.resolve(
|
|
85
|
+
postgrest.client.from("Product").select("id, name, meta")
|
|
86
|
+
).then((result) => {
|
|
87
|
+
if (result.error) {
|
|
88
|
+
productCachePromise = undefined;
|
|
89
|
+
console.error(result.error);
|
|
90
|
+
throw new Error("Failed to fetch products");
|
|
91
|
+
}
|
|
92
|
+
return new Map(result.data.map((p) => [p.id, p as Product]));
|
|
93
|
+
});
|
|
94
|
+
productCachePromise = promise;
|
|
95
|
+
return promise;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const getPlanInfo = async (
|
|
99
|
+
userIds: string[],
|
|
100
|
+
context: { postgrest: PostgrestContext }
|
|
101
|
+
): Promise<Map<string, PlanInfo>> => {
|
|
102
|
+
const { postgrest } = context;
|
|
103
|
+
|
|
104
|
+
if (userIds.length === 0) {
|
|
105
|
+
return new Map();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const userProductsResult = await postgrest.client
|
|
109
|
+
.from("UserProduct")
|
|
110
|
+
.select("userId, subscriptionId, productId")
|
|
111
|
+
.in("userId", userIds);
|
|
112
|
+
|
|
113
|
+
if (userProductsResult.error) {
|
|
114
|
+
console.error(userProductsResult.error);
|
|
115
|
+
throw new Error("Failed to fetch user products");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const userProductsByUserId = new Map<
|
|
119
|
+
string,
|
|
120
|
+
Array<{
|
|
121
|
+
productId: string | null;
|
|
122
|
+
subscriptionId: string | null;
|
|
123
|
+
}>
|
|
124
|
+
>();
|
|
125
|
+
|
|
126
|
+
for (const row of userProductsResult.data) {
|
|
127
|
+
if (row.userId === null) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const rows = userProductsByUserId.get(row.userId) ?? [];
|
|
131
|
+
rows.push({
|
|
132
|
+
productId: row.productId,
|
|
133
|
+
subscriptionId: row.subscriptionId,
|
|
134
|
+
});
|
|
135
|
+
userProductsByUserId.set(row.userId, rows);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const productIds = [
|
|
139
|
+
...new Set(
|
|
140
|
+
userProductsResult.data.flatMap(({ productId }) =>
|
|
141
|
+
productId === null || productId === undefined ? [] : [productId]
|
|
142
|
+
)
|
|
143
|
+
),
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
if (productIds.length === 0) {
|
|
147
|
+
return new Map(
|
|
148
|
+
userIds.map((userId) => [
|
|
149
|
+
userId,
|
|
150
|
+
{ planFeatures: defaultPlanFeatures, purchases: [] },
|
|
151
|
+
])
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const allProducts = await getProductCache(postgrest);
|
|
156
|
+
const productById = new Map(
|
|
157
|
+
productIds.flatMap((id) => {
|
|
158
|
+
const product = allProducts.get(id);
|
|
159
|
+
return product !== undefined ? [[id, product] as const] : [];
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
const productIdToName = new Map(
|
|
163
|
+
[...productById.values()].map((product) => [product.id, product.name])
|
|
164
|
+
);
|
|
165
|
+
const plansByName = parsePlansEnv(process.env.PLANS ?? "[]");
|
|
166
|
+
|
|
167
|
+
return new Map(
|
|
168
|
+
userIds.map((userId) => {
|
|
169
|
+
const userProducts = userProductsByUserId.get(userId) ?? [];
|
|
170
|
+
const productMetas = userProducts.flatMap(({ productId }) => {
|
|
171
|
+
if (productId === null || productId === undefined) {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
const product = productById.get(productId);
|
|
175
|
+
if (product === undefined) {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
return [
|
|
179
|
+
{
|
|
180
|
+
...(plansByName.get(product.name) ?? defaultPlanFeatures),
|
|
181
|
+
...parseProductMeta(product.meta),
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return [
|
|
187
|
+
userId,
|
|
188
|
+
{
|
|
189
|
+
planFeatures: mergeProductMetas(productMetas),
|
|
190
|
+
purchases: buildPurchases(userProducts, productIdToName),
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
})
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Resets the module-level product cache. Only for use in tests. */
|
|
198
|
+
const resetProductCache = () => {
|
|
199
|
+
productCachePromise = undefined;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const __testing__ = {
|
|
203
|
+
parseProductMeta,
|
|
204
|
+
mergeProductMetas,
|
|
205
|
+
resetProductCache,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export const getAuthorizationOwnerId = (
|
|
209
|
+
authorization: AuthorizationContext
|
|
210
|
+
): string | undefined => {
|
|
211
|
+
if (authorization.type === "token") {
|
|
212
|
+
return authorization.ownerId;
|
|
213
|
+
}
|
|
214
|
+
if (authorization.type === "user") {
|
|
215
|
+
return authorization.userId;
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
type PlanFeatures,
|
|
4
|
+
defaultPlanFeatures,
|
|
5
|
+
parsePlansEnv,
|
|
6
|
+
} from "./plan-features";
|
|
7
|
+
|
|
8
|
+
/** Full-featured plan for use in tests — all booleans true, numeric limits at max */
|
|
9
|
+
const fullFeatures: PlanFeatures = {
|
|
10
|
+
...defaultPlanFeatures,
|
|
11
|
+
canDownloadAssets: true,
|
|
12
|
+
canRestoreBackups: true,
|
|
13
|
+
allowAdditionalPermissions: true,
|
|
14
|
+
allowDynamicData: true,
|
|
15
|
+
allowContentMode: true,
|
|
16
|
+
allowStagingPublish: true,
|
|
17
|
+
maxContactEmailsPerProject: 5,
|
|
18
|
+
maxDomainsAllowedPerUser: 100,
|
|
19
|
+
maxDailyPublishesPerUser: 100,
|
|
20
|
+
maxWorkspaces: 1,
|
|
21
|
+
maxProjectsAllowedPerUser: 100,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("parsePlansEnv", () => {
|
|
25
|
+
const validEntry = JSON.stringify({ name: "Pro", features: fullFeatures });
|
|
26
|
+
const twoEntries = JSON.stringify([
|
|
27
|
+
{ name: "Pro", features: fullFeatures },
|
|
28
|
+
{ name: "Workspaces", features: { ...fullFeatures, maxWorkspaces: 10 } },
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
test("returns empty map for empty JSON array", () => {
|
|
32
|
+
expect(parsePlansEnv("[]").size).toBe(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("returns empty map for invalid JSON", () => {
|
|
36
|
+
expect(parsePlansEnv("not json").size).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns empty map for non-array JSON", () => {
|
|
40
|
+
expect(parsePlansEnv(validEntry).size).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("extends-only entry (no features key) inherits parent features", () => {
|
|
44
|
+
const result = parsePlansEnv(
|
|
45
|
+
JSON.stringify([
|
|
46
|
+
{ name: "Pro", features: fullFeatures },
|
|
47
|
+
{ name: "LTD T2", extends: "Pro" },
|
|
48
|
+
])
|
|
49
|
+
);
|
|
50
|
+
expect(result.size).toBe(2);
|
|
51
|
+
expect(result.get("LTD T2")).toEqual(result.get("Pro"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("entry with no features key and no extends resolves to defaultPlanFeatures", () => {
|
|
55
|
+
const result = parsePlansEnv(JSON.stringify([{ name: "Free" }]));
|
|
56
|
+
expect(result.size).toBe(1);
|
|
57
|
+
expect(result.get("Free")).toEqual(defaultPlanFeatures);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("extends: child inherits parent features and overrides specific fields", () => {
|
|
61
|
+
const result = parsePlansEnv(
|
|
62
|
+
JSON.stringify([
|
|
63
|
+
{ name: "Pro", features: fullFeatures },
|
|
64
|
+
{ name: "Team", extends: "Pro", features: { maxWorkspaces: 50 } },
|
|
65
|
+
])
|
|
66
|
+
);
|
|
67
|
+
expect(result.size).toBe(2);
|
|
68
|
+
expect(result.get("Team")!.canDownloadAssets).toBe(true);
|
|
69
|
+
expect(result.get("Team")!.maxWorkspaces).toBe(50);
|
|
70
|
+
expect(result.get("Pro")!.maxWorkspaces).toBe(fullFeatures.maxWorkspaces);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("extends: throws when extending an unknown plan", () => {
|
|
74
|
+
expect(() =>
|
|
75
|
+
parsePlansEnv(
|
|
76
|
+
JSON.stringify([
|
|
77
|
+
{
|
|
78
|
+
name: "Team",
|
|
79
|
+
extends: "NonExistent",
|
|
80
|
+
features: { maxWorkspaces: 5 },
|
|
81
|
+
},
|
|
82
|
+
])
|
|
83
|
+
)
|
|
84
|
+
).toThrow('PLANS entry "Team" extends unknown plan "NonExistent"');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("parses a single valid entry", () => {
|
|
88
|
+
const result = parsePlansEnv(`[${validEntry}]`);
|
|
89
|
+
expect(result.size).toBe(1);
|
|
90
|
+
expect(result.get("Pro")).toEqual(fullFeatures);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("parses multiple valid entries", () => {
|
|
94
|
+
const result = parsePlansEnv(twoEntries);
|
|
95
|
+
expect(result.size).toBe(2);
|
|
96
|
+
expect(result.get("Workspaces")!.maxWorkspaces).toBe(10);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("skips entries with invalid features (wrong type), keeps valid ones", () => {
|
|
100
|
+
const result = parsePlansEnv(
|
|
101
|
+
JSON.stringify([
|
|
102
|
+
{ name: "Bad", features: { canDownloadAssets: "not-a-bool" } },
|
|
103
|
+
{ name: "Pro", features: fullFeatures },
|
|
104
|
+
])
|
|
105
|
+
);
|
|
106
|
+
expect(result.size).toBe(1);
|
|
107
|
+
expect(result.get("Pro")).toEqual(fullFeatures);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("partial features without extends fills in from defaultPlanFeatures", () => {
|
|
111
|
+
const result = parsePlansEnv(
|
|
112
|
+
JSON.stringify([{ name: "Pro", features: { canDownloadAssets: true } }])
|
|
113
|
+
);
|
|
114
|
+
expect(result.size).toBe(1);
|
|
115
|
+
expect(result.get("Pro")).toEqual({
|
|
116
|
+
...defaultPlanFeatures,
|
|
117
|
+
canDownloadAssets: true,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("strips unknown keys from features", () => {
|
|
122
|
+
const result = parsePlansEnv(
|
|
123
|
+
JSON.stringify([
|
|
124
|
+
{ name: "Pro", features: { ...fullFeatures, admin: true } },
|
|
125
|
+
])
|
|
126
|
+
);
|
|
127
|
+
expect(result.size).toBe(1);
|
|
128
|
+
expect(
|
|
129
|
+
(result.get("Pro") as Record<string, unknown>)["admin"]
|
|
130
|
+
).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Zod schema for validating partial plan features from external sources
|
|
5
|
+
* (e.g. product.meta JSON blobs). Only known keys of the correct type
|
|
6
|
+
* are kept — anything else is silently stripped.
|
|
7
|
+
*/
|
|
8
|
+
export const PlanFeaturesSchema = z.object({
|
|
9
|
+
canDownloadAssets: z.boolean(),
|
|
10
|
+
canRestoreBackups: z.boolean(),
|
|
11
|
+
allowAdditionalPermissions: z.boolean(),
|
|
12
|
+
allowDynamicData: z.boolean(),
|
|
13
|
+
allowContentMode: z.boolean(),
|
|
14
|
+
allowStagingPublish: z.boolean(),
|
|
15
|
+
maxContactEmailsPerProject: z.number().nonnegative(),
|
|
16
|
+
maxDomainsAllowedPerUser: z.number().nonnegative(),
|
|
17
|
+
maxDailyPublishesPerUser: z.number().nonnegative(),
|
|
18
|
+
maxWorkspaces: z.number().nonnegative(),
|
|
19
|
+
maxProjectsAllowedPerUser: z.number().nonnegative(),
|
|
20
|
+
maxAssetsPerProject: z.number().nonnegative(),
|
|
21
|
+
minSeats: z.number().nonnegative(),
|
|
22
|
+
maxSeatsPerWorkspace: z.number().nonnegative(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export type PlanFeatures = z.infer<typeof PlanFeaturesSchema>;
|
|
26
|
+
|
|
27
|
+
// Compile-time guard: all PlanFeatures values must be boolean or number.
|
|
28
|
+
// If a new field with a different type is added to PlanFeaturesSchema, this line will error.
|
|
29
|
+
type _AssertBooleanOrNumber =
|
|
30
|
+
PlanFeatures extends Record<string, boolean | number> ? true : never;
|
|
31
|
+
const _checkPlanFeaturesValueTypes: _AssertBooleanOrNumber = true;
|
|
32
|
+
void _checkPlanFeaturesValueTypes;
|
|
33
|
+
|
|
34
|
+
/** Default (free) plan — the baseline, all features disabled / at minimum limits */
|
|
35
|
+
export const defaultPlanFeatures: PlanFeatures = {
|
|
36
|
+
canDownloadAssets: false,
|
|
37
|
+
canRestoreBackups: false,
|
|
38
|
+
allowAdditionalPermissions: false,
|
|
39
|
+
allowDynamicData: false,
|
|
40
|
+
allowContentMode: false,
|
|
41
|
+
allowStagingPublish: false,
|
|
42
|
+
maxContactEmailsPerProject: 0,
|
|
43
|
+
maxDomainsAllowedPerUser: 0,
|
|
44
|
+
maxDailyPublishesPerUser: 10,
|
|
45
|
+
maxWorkspaces: 1,
|
|
46
|
+
maxProjectsAllowedPerUser: 100,
|
|
47
|
+
maxAssetsPerProject: 50,
|
|
48
|
+
minSeats: 0,
|
|
49
|
+
maxSeatsPerWorkspace: 0,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** All user purchases (subscriptions and LTDs). subscriptionId present only for recurring subscriptions */
|
|
53
|
+
export type Purchase = {
|
|
54
|
+
planName: string;
|
|
55
|
+
subscriptionId?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse the PLANS env variable (JSON array of {name, extends?, features}).
|
|
60
|
+
* - features is partial when extends is used; the parent plan fills in the rest.
|
|
61
|
+
* - The final merged features are validated against the full PlanFeaturesSchema.
|
|
62
|
+
* - Invalid entries are skipped with a console.error.
|
|
63
|
+
* - An extends reference to an unknown plan name throws an error.
|
|
64
|
+
* Returns a Map of plan name → resolved PlanFeatures.
|
|
65
|
+
*/
|
|
66
|
+
export const parsePlansEnv = (raw: string): Map<string, PlanFeatures> => {
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(raw);
|
|
69
|
+
if (!Array.isArray(parsed)) {
|
|
70
|
+
return new Map();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type PlanEntry = {
|
|
74
|
+
name: string;
|
|
75
|
+
extends?: string;
|
|
76
|
+
features: Partial<PlanFeatures>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// First pass: validate entry structure and collect partial features.
|
|
80
|
+
const entries: PlanEntry[] = parsed.flatMap((item) => {
|
|
81
|
+
if (
|
|
82
|
+
typeof item !== "object" ||
|
|
83
|
+
item === null ||
|
|
84
|
+
typeof item.name !== "string"
|
|
85
|
+
) {
|
|
86
|
+
console.error("Invalid PLANS entry (missing name):", item);
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
if ("extends" in item && typeof item.extends !== "string") {
|
|
90
|
+
console.error("Invalid PLANS entry (extends must be a string):", item);
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const result = PlanFeaturesSchema.partial().safeParse(
|
|
94
|
+
"features" in item ? item.features : {}
|
|
95
|
+
);
|
|
96
|
+
if (!result.success) {
|
|
97
|
+
console.error(
|
|
98
|
+
`Invalid PLANS entry "${item.name}" features:`,
|
|
99
|
+
result.error.flatten()
|
|
100
|
+
);
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
name: item.name,
|
|
106
|
+
extends: "extends" in item ? (item.extends as string) : undefined,
|
|
107
|
+
features: result.data,
|
|
108
|
+
} satisfies PlanEntry,
|
|
109
|
+
];
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Build name → partial features map for resolving extends.
|
|
113
|
+
const byName = new Map<string, Partial<PlanFeatures>>(
|
|
114
|
+
entries.map((e) => [e.name, e.features])
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Second pass: resolve extends and validate the full feature set.
|
|
118
|
+
// Every entry implicitly extends defaultPlanFeatures; "extends" picks a named plan on top of that.
|
|
119
|
+
const resolved = new Map<string, PlanFeatures>();
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (entry.extends !== undefined && !byName.has(entry.extends)) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`PLANS entry "${entry.name}" extends unknown plan "${entry.extends}"`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const parentFeatures: PlanFeatures =
|
|
127
|
+
entry.extends === undefined
|
|
128
|
+
? defaultPlanFeatures
|
|
129
|
+
: { ...defaultPlanFeatures, ...byName.get(entry.extends)! };
|
|
130
|
+
const result = PlanFeaturesSchema.safeParse({
|
|
131
|
+
...parentFeatures,
|
|
132
|
+
...entry.features,
|
|
133
|
+
});
|
|
134
|
+
if (!result.success) {
|
|
135
|
+
console.error(
|
|
136
|
+
`Invalid PLANS entry "${entry.name}" features after resolving extends:`,
|
|
137
|
+
result.error.flatten()
|
|
138
|
+
);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
resolved.set(entry.name, result.data);
|
|
142
|
+
}
|
|
143
|
+
return resolved;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error instanceof Error && error.message.startsWith("PLANS entry")) {
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
return new Map();
|
|
149
|
+
}
|
|
150
|
+
};
|
package/tsconfig.json
ADDED