@wopr-network/platform-core 1.58.0 → 1.59.0

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.
Files changed (47) hide show
  1. package/dist/billing/crypto/key-server-entry.js +1 -0
  2. package/dist/billing/crypto/watcher-service.d.ts +2 -0
  3. package/dist/billing/crypto/watcher-service.js +7 -3
  4. package/dist/db/schema/index.d.ts +2 -0
  5. package/dist/db/schema/index.js +2 -0
  6. package/dist/db/schema/product-config.d.ts +610 -0
  7. package/dist/db/schema/product-config.js +51 -0
  8. package/dist/db/schema/products.d.ts +565 -0
  9. package/dist/db/schema/products.js +43 -0
  10. package/dist/product-config/boot.d.ts +36 -0
  11. package/dist/product-config/boot.js +30 -0
  12. package/dist/product-config/drizzle-product-config-repository.d.ts +19 -0
  13. package/dist/product-config/drizzle-product-config-repository.js +200 -0
  14. package/dist/product-config/drizzle-product-config-repository.test.d.ts +1 -0
  15. package/dist/product-config/drizzle-product-config-repository.test.js +114 -0
  16. package/dist/product-config/index.d.ts +24 -0
  17. package/dist/product-config/index.js +37 -0
  18. package/dist/product-config/repository-types.d.ts +143 -0
  19. package/dist/product-config/repository-types.js +53 -0
  20. package/dist/product-config/service.d.ts +27 -0
  21. package/dist/product-config/service.js +74 -0
  22. package/dist/product-config/service.test.d.ts +1 -0
  23. package/dist/product-config/service.test.js +107 -0
  24. package/dist/trpc/index.d.ts +1 -0
  25. package/dist/trpc/index.js +1 -0
  26. package/dist/trpc/product-config-router.d.ts +117 -0
  27. package/dist/trpc/product-config-router.js +137 -0
  28. package/docs/specs/2026-03-23-product-config-db-migration-plan.md +2260 -0
  29. package/docs/specs/2026-03-23-product-config-db-migration.md +371 -0
  30. package/drizzle/migrations/0020_product_config_tables.sql +109 -0
  31. package/drizzle/migrations/meta/_journal.json +7 -0
  32. package/package.json +1 -1
  33. package/scripts/seed-products.ts +268 -0
  34. package/src/billing/crypto/key-server-entry.ts +1 -0
  35. package/src/billing/crypto/watcher-service.ts +8 -2
  36. package/src/db/schema/index.ts +2 -0
  37. package/src/db/schema/product-config.ts +56 -0
  38. package/src/db/schema/products.ts +58 -0
  39. package/src/product-config/boot.ts +57 -0
  40. package/src/product-config/drizzle-product-config-repository.test.ts +132 -0
  41. package/src/product-config/drizzle-product-config-repository.ts +229 -0
  42. package/src/product-config/index.ts +62 -0
  43. package/src/product-config/repository-types.ts +222 -0
  44. package/src/product-config/service.test.ts +127 -0
  45. package/src/product-config/service.ts +105 -0
  46. package/src/trpc/index.ts +1 -0
  47. package/src/trpc/product-config-router.ts +161 -0
@@ -0,0 +1,30 @@
1
+ import { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
2
+ import { deriveCorsOrigins } from "./repository-types.js";
3
+ import { ProductConfigService } from "./service.js";
4
+ /**
5
+ * Bootstrap product configuration from DB.
6
+ *
7
+ * Call once at startup, after DB + migrations, before route registration.
8
+ * Returns the service (for tRPC router wiring) and the resolved config
9
+ * (for CORS, email, fleet, auth initialization).
10
+ *
11
+ * This replaces: BRAND_NAME, PLATFORM_DOMAIN, UI_ORIGIN, FROM_EMAIL,
12
+ * SUPPORT_EMAIL, COOKIE_DOMAIN, APP_BASE_URL, and all other product-
13
+ * specific env vars that platform-core modules previously read from
14
+ * process.env.
15
+ *
16
+ * Product backends still own their specific wiring (crypto watchers,
17
+ * fleet updaters, notification pipelines). platformBoot handles the
18
+ * config-driven parts that are identical across products.
19
+ */
20
+ export async function platformBoot(opts) {
21
+ const { slug, db, devOrigins = [] } = opts;
22
+ const repo = new DrizzleProductConfigRepository(db);
23
+ const service = new ProductConfigService(repo);
24
+ const config = await service.getBySlug(slug);
25
+ if (!config) {
26
+ throw new Error(`Product "${slug}" not found in database. Run the seed script: DATABASE_URL=... npx tsx scripts/seed-products.ts`);
27
+ }
28
+ const corsOrigins = [...deriveCorsOrigins(config.product, config.domains), ...devOrigins];
29
+ return { service, config, corsOrigins };
30
+ }
@@ -0,0 +1,19 @@
1
+ import type { DrizzleDb } from "../db/index.js";
2
+ import type { IProductConfigRepository, NavItemInput, Product, ProductBillingConfig as ProductBillingConfigType, ProductBrandUpdate, ProductConfig, ProductFeatures as ProductFeaturesType, ProductFleetConfig as ProductFleetConfigType } from "./repository-types.js";
3
+ export declare class DrizzleProductConfigRepository implements IProductConfigRepository {
4
+ private db;
5
+ constructor(db: DrizzleDb);
6
+ getBySlug(slug: string): Promise<ProductConfig | null>;
7
+ listAll(): Promise<ProductConfig[]>;
8
+ upsertProduct(slug: string, data: ProductBrandUpdate): Promise<Product>;
9
+ replaceNavItems(productId: string, items: NavItemInput[]): Promise<void>;
10
+ upsertFeatures(productId: string, data: Partial<ProductFeaturesType>): Promise<void>;
11
+ upsertFleetConfig(productId: string, data: Partial<ProductFleetConfigType>): Promise<void>;
12
+ upsertBillingConfig(productId: string, data: Partial<ProductBillingConfigType>): Promise<void>;
13
+ private mapProduct;
14
+ private mapNavItem;
15
+ private mapDomain;
16
+ private mapFeatures;
17
+ private mapFleet;
18
+ private mapBilling;
19
+ }
@@ -0,0 +1,200 @@
1
+ import { asc, eq } from "drizzle-orm";
2
+ import { productBillingConfig, productFeatures, productFleetConfig } from "../db/schema/product-config.js";
3
+ import { productDomains, productNavItems, products } from "../db/schema/products.js";
4
+ export class DrizzleProductConfigRepository {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ async getBySlug(slug) {
10
+ const [product] = await this.db.select().from(products).where(eq(products.slug, slug)).limit(1);
11
+ if (!product)
12
+ return null;
13
+ const [navItems, domains, featuresRows, fleetRows, billingRows] = await Promise.all([
14
+ this.db
15
+ .select()
16
+ .from(productNavItems)
17
+ .where(eq(productNavItems.productId, product.id))
18
+ .orderBy(asc(productNavItems.sortOrder)),
19
+ this.db.select().from(productDomains).where(eq(productDomains.productId, product.id)),
20
+ this.db.select().from(productFeatures).where(eq(productFeatures.productId, product.id)).limit(1),
21
+ this.db.select().from(productFleetConfig).where(eq(productFleetConfig.productId, product.id)).limit(1),
22
+ this.db.select().from(productBillingConfig).where(eq(productBillingConfig.productId, product.id)).limit(1),
23
+ ]);
24
+ return {
25
+ product: this.mapProduct(product),
26
+ navItems: navItems.map((n) => this.mapNavItem(n)),
27
+ domains: domains.map((d) => this.mapDomain(d)),
28
+ features: featuresRows[0] ? this.mapFeatures(featuresRows[0]) : null,
29
+ fleet: fleetRows[0] ? this.mapFleet(fleetRows[0]) : null,
30
+ billing: billingRows[0] ? this.mapBilling(billingRows[0]) : null,
31
+ };
32
+ }
33
+ async listAll() {
34
+ const allProducts = await this.db.select().from(products);
35
+ const configs = await Promise.all(allProducts.map((p) => this.getBySlug(p.slug)));
36
+ return configs.filter((c) => c !== null);
37
+ }
38
+ async upsertProduct(slug, data) {
39
+ const base = {
40
+ slug,
41
+ storagePrefix: slug,
42
+ domain: "",
43
+ appDomain: "",
44
+ cookieDomain: "",
45
+ brandName: "",
46
+ productName: "",
47
+ };
48
+ const [result] = await this.db
49
+ .insert(products)
50
+ .values({ ...base, ...data })
51
+ .onConflictDoUpdate({
52
+ target: products.slug,
53
+ set: { ...data, updatedAt: new Date() },
54
+ })
55
+ .returning();
56
+ return this.mapProduct(result);
57
+ }
58
+ async replaceNavItems(productId, items) {
59
+ await this.db.transaction(async (tx) => {
60
+ await tx.delete(productNavItems).where(eq(productNavItems.productId, productId));
61
+ if (items.length > 0) {
62
+ await tx.insert(productNavItems).values(items.map((item) => ({
63
+ productId,
64
+ label: item.label,
65
+ href: item.href,
66
+ icon: item.icon ?? null,
67
+ sortOrder: item.sortOrder,
68
+ requiresRole: item.requiresRole ?? null,
69
+ enabled: item.enabled !== false,
70
+ })));
71
+ }
72
+ });
73
+ }
74
+ async upsertFeatures(productId, data) {
75
+ const { productId: _, ...rest } = data;
76
+ await this.db
77
+ .insert(productFeatures)
78
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
79
+ .values({ productId, ...rest })
80
+ .onConflictDoUpdate({
81
+ target: productFeatures.productId,
82
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
83
+ set: { ...rest, updatedAt: new Date() },
84
+ });
85
+ }
86
+ async upsertFleetConfig(productId, data) {
87
+ const { productId: _, ...rest } = data;
88
+ await this.db
89
+ .insert(productFleetConfig)
90
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
91
+ .values({ productId, containerImage: "", ...rest })
92
+ .onConflictDoUpdate({
93
+ target: productFleetConfig.productId,
94
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
95
+ set: { ...rest, updatedAt: new Date() },
96
+ });
97
+ }
98
+ async upsertBillingConfig(productId, data) {
99
+ // TODO: stripeSecretKey and stripeWebhookSecret must be encrypted via the credential vault
100
+ // (CRYPTO_SERVICE_KEY) before reaching this method. The schema stores encrypted ciphertext.
101
+ const { productId: _, ...rest } = data;
102
+ await this.db
103
+ .insert(productBillingConfig)
104
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
105
+ .values({ productId, ...rest })
106
+ .onConflictDoUpdate({
107
+ target: productBillingConfig.productId,
108
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
109
+ set: { ...rest, updatedAt: new Date() },
110
+ });
111
+ }
112
+ // ---------------------------------------------------------------------------
113
+ // Mappers
114
+ // ---------------------------------------------------------------------------
115
+ mapProduct(row) {
116
+ return {
117
+ id: row.id,
118
+ slug: row.slug,
119
+ brandName: row.brandName,
120
+ productName: row.productName,
121
+ tagline: row.tagline,
122
+ domain: row.domain,
123
+ appDomain: row.appDomain,
124
+ cookieDomain: row.cookieDomain,
125
+ companyLegal: row.companyLegal,
126
+ priceLabel: row.priceLabel,
127
+ defaultImage: row.defaultImage,
128
+ emailSupport: row.emailSupport,
129
+ emailPrivacy: row.emailPrivacy,
130
+ emailLegal: row.emailLegal,
131
+ fromEmail: row.fromEmail,
132
+ homePath: row.homePath,
133
+ storagePrefix: row.storagePrefix,
134
+ createdAt: row.createdAt,
135
+ updatedAt: row.updatedAt,
136
+ };
137
+ }
138
+ mapNavItem(row) {
139
+ return {
140
+ id: row.id,
141
+ productId: row.productId,
142
+ label: row.label,
143
+ href: row.href,
144
+ icon: row.icon ?? null,
145
+ sortOrder: row.sortOrder,
146
+ requiresRole: row.requiresRole ?? null,
147
+ enabled: row.enabled,
148
+ };
149
+ }
150
+ mapDomain(row) {
151
+ return {
152
+ id: row.id,
153
+ productId: row.productId,
154
+ host: row.host,
155
+ role: row.role,
156
+ };
157
+ }
158
+ mapFeatures(row) {
159
+ return {
160
+ productId: row.productId,
161
+ chatEnabled: row.chatEnabled,
162
+ onboardingEnabled: row.onboardingEnabled,
163
+ onboardingDefaultModel: row.onboardingDefaultModel ?? null,
164
+ onboardingSystemPrompt: row.onboardingSystemPrompt ?? null,
165
+ onboardingMaxCredits: row.onboardingMaxCredits,
166
+ onboardingWelcomeMsg: row.onboardingWelcomeMsg ?? null,
167
+ sharedModuleBilling: row.sharedModuleBilling,
168
+ sharedModuleMonitoring: row.sharedModuleMonitoring,
169
+ sharedModuleAnalytics: row.sharedModuleAnalytics,
170
+ };
171
+ }
172
+ mapFleet(row) {
173
+ return {
174
+ productId: row.productId,
175
+ containerImage: row.containerImage,
176
+ containerPort: row.containerPort,
177
+ lifecycle: row.lifecycle,
178
+ billingModel: row.billingModel,
179
+ maxInstances: row.maxInstances,
180
+ imageAllowlist: row.imageAllowlist ?? null,
181
+ dockerNetwork: row.dockerNetwork,
182
+ placementStrategy: row.placementStrategy,
183
+ fleetDataDir: row.fleetDataDir,
184
+ };
185
+ }
186
+ mapBilling(row) {
187
+ return {
188
+ productId: row.productId,
189
+ stripePublishableKey: row.stripePublishableKey ?? null,
190
+ stripeSecretKey: row.stripeSecretKey ?? null,
191
+ stripeWebhookSecret: row.stripeWebhookSecret ?? null,
192
+ creditPrices: (row.creditPrices ?? {}),
193
+ affiliateBaseUrl: row.affiliateBaseUrl ?? null,
194
+ affiliateMatchRate: Number(row.affiliateMatchRate),
195
+ affiliateMaxCap: row.affiliateMaxCap,
196
+ dividendRate: Number(row.dividendRate),
197
+ marginConfig: row.marginConfig ?? null,
198
+ };
199
+ }
200
+ }
@@ -0,0 +1,114 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import { products } from "../db/schema/products.js";
3
+ import { createTestDb } from "../test/db.js";
4
+ import { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
5
+ const PRODUCT_SEED = {
6
+ slug: "test-product",
7
+ brandName: "Test Brand",
8
+ productName: "Test Product",
9
+ domain: "test.example.com",
10
+ appDomain: "app.test.example.com",
11
+ cookieDomain: ".test.example.com",
12
+ storagePrefix: "test",
13
+ };
14
+ describe("DrizzleProductConfigRepository", () => {
15
+ let db;
16
+ let pool;
17
+ let repo;
18
+ beforeAll(async () => {
19
+ const result = await createTestDb();
20
+ db = result.db;
21
+ pool = result.pool;
22
+ repo = new DrizzleProductConfigRepository(db);
23
+ });
24
+ afterAll(async () => {
25
+ await pool.close();
26
+ });
27
+ it("getBySlug returns null for unknown slug", async () => {
28
+ const result = await repo.getBySlug("no-such-slug");
29
+ expect(result).toBeNull();
30
+ });
31
+ it("getBySlug returns full config for seeded product with empty related tables", async () => {
32
+ await db.insert(products).values(PRODUCT_SEED);
33
+ const config = await repo.getBySlug("test-product");
34
+ expect(config).not.toBeNull();
35
+ expect(config?.product.slug).toBe("test-product");
36
+ expect(config?.product.brandName).toBe("Test Brand");
37
+ expect(config?.navItems).toHaveLength(0);
38
+ expect(config?.domains).toHaveLength(0);
39
+ expect(config?.features).toBeNull();
40
+ expect(config?.fleet).toBeNull();
41
+ expect(config?.billing).toBeNull();
42
+ });
43
+ it("replaceNavItems then getBySlug returns sorted nav items", async () => {
44
+ const config = await repo.getBySlug("test-product");
45
+ if (!config)
46
+ throw new Error("product not found");
47
+ const productId = config.product.id;
48
+ await repo.replaceNavItems(productId, [
49
+ { label: "Beta", href: "/beta", sortOrder: 2 },
50
+ { label: "Alpha", href: "/alpha", sortOrder: 1 },
51
+ { label: "Gamma", href: "/gamma", sortOrder: 3, requiresRole: "admin" },
52
+ ]);
53
+ const updated = await repo.getBySlug("test-product");
54
+ expect(updated?.navItems).toHaveLength(3);
55
+ expect(updated?.navItems[0].label).toBe("Alpha");
56
+ expect(updated?.navItems[1].label).toBe("Beta");
57
+ expect(updated?.navItems[2].label).toBe("Gamma");
58
+ expect(updated?.navItems[2].requiresRole).toBe("admin");
59
+ });
60
+ it("upsertFeatures then getBySlug returns features", async () => {
61
+ const config = await repo.getBySlug("test-product");
62
+ if (!config)
63
+ throw new Error("product not found");
64
+ const productId = config.product.id;
65
+ await repo.upsertFeatures(productId, {
66
+ chatEnabled: false,
67
+ onboardingEnabled: true,
68
+ onboardingDefaultModel: "gpt-4o",
69
+ onboardingMaxCredits: 50,
70
+ });
71
+ const updated = await repo.getBySlug("test-product");
72
+ expect(updated?.features).not.toBeNull();
73
+ expect(updated?.features?.chatEnabled).toBe(false);
74
+ expect(updated?.features?.onboardingDefaultModel).toBe("gpt-4o");
75
+ expect(updated?.features?.onboardingMaxCredits).toBe(50);
76
+ });
77
+ it("upsertFleetConfig with lifecycle ephemeral and billingModel none", async () => {
78
+ const config = await repo.getBySlug("test-product");
79
+ if (!config)
80
+ throw new Error("product not found");
81
+ const productId = config.product.id;
82
+ await repo.upsertFleetConfig(productId, {
83
+ containerImage: "ghcr.io/example/app:latest",
84
+ lifecycle: "ephemeral",
85
+ billingModel: "none",
86
+ containerPort: 8080,
87
+ maxInstances: 10,
88
+ });
89
+ const updated = await repo.getBySlug("test-product");
90
+ expect(updated?.fleet).not.toBeNull();
91
+ expect(updated?.fleet?.containerImage).toBe("ghcr.io/example/app:latest");
92
+ expect(updated?.fleet?.lifecycle).toBe("ephemeral");
93
+ expect(updated?.fleet?.billingModel).toBe("none");
94
+ expect(updated?.fleet?.containerPort).toBe(8080);
95
+ expect(updated?.fleet?.maxInstances).toBe(10);
96
+ });
97
+ it("listAll returns all products", async () => {
98
+ // Insert a second product
99
+ await db.insert(products).values({
100
+ slug: "second-product",
101
+ brandName: "Second Brand",
102
+ productName: "Second Product",
103
+ domain: "second.example.com",
104
+ appDomain: "app.second.example.com",
105
+ cookieDomain: ".second.example.com",
106
+ storagePrefix: "second",
107
+ });
108
+ const all = await repo.listAll();
109
+ const slugs = all.map((c) => c.product.slug);
110
+ expect(slugs).toContain("test-product");
111
+ expect(slugs).toContain("second-product");
112
+ expect(all.length).toBeGreaterThanOrEqual(2);
113
+ });
114
+ });
@@ -0,0 +1,24 @@
1
+ import type { DrizzleDb } from "../db/index.js";
2
+ import type { PlatformBootOptions, PlatformBootResult } from "./boot.js";
3
+ import type { IProductConfigRepository } from "./repository-types.js";
4
+ import { ProductConfigService } from "./service.js";
5
+ export type { PlatformBootOptions, PlatformBootResult } from "./boot.js";
6
+ /**
7
+ * Bootstrap product config from DB and register the service globally.
8
+ * Wraps the raw boot to ensure getProductConfigService() works after calling this.
9
+ */
10
+ export declare function platformBoot(opts: PlatformBootOptions): Promise<PlatformBootResult>;
11
+ export { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
12
+ export type { FleetBillingModel, FleetLifecycle, IProductConfigRepository, NavItemInput, Product, ProductBillingConfig, ProductBrandConfig, ProductBrandUpdate, ProductConfig, ProductDomain, ProductFeatures, ProductFleetConfig, ProductNavItem, } from "./repository-types.js";
13
+ export { deriveCorsOrigins, toBrandConfig } from "./repository-types.js";
14
+ export { ProductConfigService } from "./service.js";
15
+ /** Initialize the product config system. Call once at startup. */
16
+ export declare function initProductConfig(db: DrizzleDb): ProductConfigService;
17
+ /** Initialize with a custom repository (for testing). */
18
+ export declare function initProductConfigWithRepo(repo: IProductConfigRepository): ProductConfigService;
19
+ /**
20
+ * Get the product config service.
21
+ * This is the ONLY way to access product config — reads are cached,
22
+ * writes auto-invalidate the cache.
23
+ */
24
+ export declare function getProductConfigService(): ProductConfigService;
@@ -0,0 +1,37 @@
1
+ import { platformBoot as rawPlatformBoot } from "./boot.js";
2
+ import { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
3
+ import { ProductConfigService } from "./service.js";
4
+ /**
5
+ * Bootstrap product config from DB and register the service globally.
6
+ * Wraps the raw boot to ensure getProductConfigService() works after calling this.
7
+ */
8
+ export async function platformBoot(opts) {
9
+ const result = await rawPlatformBoot(opts);
10
+ _service = result.service;
11
+ return result;
12
+ }
13
+ export { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
14
+ export { deriveCorsOrigins, toBrandConfig } from "./repository-types.js";
15
+ export { ProductConfigService } from "./service.js";
16
+ let _service = null;
17
+ /** Initialize the product config system. Call once at startup. */
18
+ export function initProductConfig(db) {
19
+ const repo = new DrizzleProductConfigRepository(db);
20
+ _service = new ProductConfigService(repo);
21
+ return _service;
22
+ }
23
+ /** Initialize with a custom repository (for testing). */
24
+ export function initProductConfigWithRepo(repo) {
25
+ _service = new ProductConfigService(repo);
26
+ return _service;
27
+ }
28
+ /**
29
+ * Get the product config service.
30
+ * This is the ONLY way to access product config — reads are cached,
31
+ * writes auto-invalidate the cache.
32
+ */
33
+ export function getProductConfigService() {
34
+ if (!_service)
35
+ throw new Error("Product config not initialized. Call initProductConfig() first.");
36
+ return _service;
37
+ }
@@ -0,0 +1,143 @@
1
+ /** Plain domain object representing a product — mirrors `products` table. */
2
+ export interface Product {
3
+ id: string;
4
+ slug: string;
5
+ brandName: string;
6
+ productName: string;
7
+ tagline: string;
8
+ domain: string;
9
+ appDomain: string;
10
+ cookieDomain: string;
11
+ companyLegal: string;
12
+ priceLabel: string;
13
+ defaultImage: string;
14
+ emailSupport: string;
15
+ emailPrivacy: string;
16
+ emailLegal: string;
17
+ fromEmail: string;
18
+ homePath: string;
19
+ storagePrefix: string;
20
+ createdAt: Date;
21
+ updatedAt: Date;
22
+ }
23
+ export interface ProductNavItem {
24
+ id: string;
25
+ productId: string;
26
+ label: string;
27
+ href: string;
28
+ icon: string | null;
29
+ sortOrder: number;
30
+ requiresRole: string | null;
31
+ enabled: boolean;
32
+ }
33
+ export interface ProductDomain {
34
+ id: string;
35
+ productId: string;
36
+ host: string;
37
+ role: "canonical" | "redirect";
38
+ }
39
+ export interface ProductFeatures {
40
+ productId: string;
41
+ chatEnabled: boolean;
42
+ onboardingEnabled: boolean;
43
+ onboardingDefaultModel: string | null;
44
+ onboardingSystemPrompt: string | null;
45
+ onboardingMaxCredits: number;
46
+ onboardingWelcomeMsg: string | null;
47
+ sharedModuleBilling: boolean;
48
+ sharedModuleMonitoring: boolean;
49
+ sharedModuleAnalytics: boolean;
50
+ }
51
+ export type FleetLifecycle = "managed" | "ephemeral";
52
+ export type FleetBillingModel = "monthly" | "per_use" | "none";
53
+ export interface ProductFleetConfig {
54
+ productId: string;
55
+ containerImage: string;
56
+ containerPort: number;
57
+ lifecycle: FleetLifecycle;
58
+ billingModel: FleetBillingModel;
59
+ maxInstances: number;
60
+ imageAllowlist: string[] | null;
61
+ dockerNetwork: string;
62
+ placementStrategy: string;
63
+ fleetDataDir: string;
64
+ }
65
+ export interface ProductBillingConfig {
66
+ productId: string;
67
+ stripePublishableKey: string | null;
68
+ stripeSecretKey: string | null;
69
+ stripeWebhookSecret: string | null;
70
+ creditPrices: Record<string, number>;
71
+ affiliateBaseUrl: string | null;
72
+ affiliateMatchRate: number;
73
+ affiliateMaxCap: number;
74
+ dividendRate: number;
75
+ marginConfig: unknown;
76
+ }
77
+ /** Full product config resolved from all tables. */
78
+ export interface ProductConfig {
79
+ product: Product;
80
+ navItems: ProductNavItem[];
81
+ domains: ProductDomain[];
82
+ features: ProductFeatures | null;
83
+ fleet: ProductFleetConfig | null;
84
+ billing: ProductBillingConfig | null;
85
+ }
86
+ /** Brand config shape served to UI (matches BrandConfig in platform-ui-core). */
87
+ export interface ProductBrandConfig {
88
+ productName: string;
89
+ brandName: string;
90
+ domain: string;
91
+ appDomain: string;
92
+ tagline: string;
93
+ emails: {
94
+ privacy: string;
95
+ legal: string;
96
+ support: string;
97
+ };
98
+ defaultImage: string;
99
+ storagePrefix: string;
100
+ companyLegalName: string;
101
+ price: string;
102
+ homePath: string;
103
+ chatEnabled: boolean;
104
+ navItems: Array<{
105
+ label: string;
106
+ href: string;
107
+ }>;
108
+ domains?: Array<{
109
+ host: string;
110
+ role: string;
111
+ }>;
112
+ }
113
+ /** Upsert payload for product brand fields. */
114
+ export type ProductBrandUpdate = Partial<Omit<Product, "id" | "slug" | "createdAt" | "updatedAt">>;
115
+ /** Upsert payload for a nav item (no id — replaced in bulk). */
116
+ export interface NavItemInput {
117
+ label: string;
118
+ href: string;
119
+ icon?: string;
120
+ sortOrder: number;
121
+ requiresRole?: string;
122
+ enabled?: boolean;
123
+ }
124
+ export interface IProductConfigRepository {
125
+ getBySlug(slug: string): Promise<ProductConfig | null>;
126
+ listAll(): Promise<ProductConfig[]>;
127
+ upsertProduct(slug: string, data: ProductBrandUpdate): Promise<Product>;
128
+ replaceNavItems(productId: string, items: NavItemInput[]): Promise<void>;
129
+ upsertFeatures(productId: string, data: Partial<ProductFeatures>): Promise<void>;
130
+ upsertFleetConfig(productId: string, data: Partial<ProductFleetConfig>): Promise<void>;
131
+ upsertBillingConfig(productId: string, data: Partial<ProductBillingConfig>): Promise<void>;
132
+ }
133
+ /** Derive CORS origins from product config. */
134
+ /** Derive CORS origins — excludes redirect-only domains (they 301, never make requests). */
135
+ export declare function deriveCorsOrigins(product: Product, domains: ProductDomain[]): string[];
136
+ /**
137
+ * Derive brand config for UI from full product config.
138
+ *
139
+ * @param config - Full product config.
140
+ * @param userRole - Optional role of the requesting user. When omitted (public/unauthenticated),
141
+ * nav items that require a role are excluded from the result.
142
+ */
143
+ export declare function toBrandConfig(config: ProductConfig, userRole?: string): ProductBrandConfig;
@@ -0,0 +1,53 @@
1
+ // src/product-config/repository-types.ts
2
+ //
3
+ // Plain TypeScript interfaces for product configuration domain.
4
+ // No Drizzle types. These are the contract all consumers work against.
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+ /** Derive CORS origins from product config. */
9
+ /** Derive CORS origins — excludes redirect-only domains (they 301, never make requests). */
10
+ export function deriveCorsOrigins(product, domains) {
11
+ const origins = new Set();
12
+ origins.add(`https://${product.domain}`);
13
+ origins.add(`https://${product.appDomain}`);
14
+ for (const d of domains) {
15
+ if (d.role !== "redirect") {
16
+ origins.add(`https://${d.host}`);
17
+ }
18
+ }
19
+ return [...origins];
20
+ }
21
+ /**
22
+ * Derive brand config for UI from full product config.
23
+ *
24
+ * @param config - Full product config.
25
+ * @param userRole - Optional role of the requesting user. When omitted (public/unauthenticated),
26
+ * nav items that require a role are excluded from the result.
27
+ */
28
+ export function toBrandConfig(config, userRole) {
29
+ const { product, navItems, domains, features } = config;
30
+ return {
31
+ productName: product.productName,
32
+ brandName: product.brandName,
33
+ domain: product.domain,
34
+ appDomain: product.appDomain,
35
+ tagline: product.tagline,
36
+ emails: {
37
+ privacy: product.emailPrivacy,
38
+ legal: product.emailLegal,
39
+ support: product.emailSupport,
40
+ },
41
+ defaultImage: product.defaultImage,
42
+ storagePrefix: product.storagePrefix,
43
+ companyLegalName: product.companyLegal,
44
+ price: product.priceLabel,
45
+ homePath: product.homePath,
46
+ chatEnabled: features?.chatEnabled ?? true,
47
+ navItems: navItems
48
+ .filter((n) => n.enabled)
49
+ .filter((n) => !n.requiresRole || n.requiresRole === userRole)
50
+ .map((n) => ({ label: n.label, href: n.href })),
51
+ domains: domains.length > 0 ? domains.map((d) => ({ host: d.host, role: d.role })) : undefined,
52
+ };
53
+ }
@@ -0,0 +1,27 @@
1
+ import type { IProductConfigRepository, NavItemInput, Product, ProductBillingConfig, ProductBrandConfig, ProductBrandUpdate, ProductConfig, ProductFeatures, ProductFleetConfig } from "./repository-types.js";
2
+ /**
3
+ * Single point of access for product configuration.
4
+ *
5
+ * Wraps IProductConfigRepository with an in-memory cache.
6
+ * All mutations automatically invalidate the cache — no caller
7
+ * needs to remember to invalidate. This is the ONLY public
8
+ * interface to product config; consumers never touch the repo directly.
9
+ */
10
+ export declare class ProductConfigService {
11
+ private repo;
12
+ private cache;
13
+ private ttlMs;
14
+ constructor(repo: IProductConfigRepository, opts?: {
15
+ ttlMs?: number;
16
+ });
17
+ getBySlug(slug: string): Promise<ProductConfig | null>;
18
+ getBrandConfig(slug: string): Promise<ProductBrandConfig | null>;
19
+ listAll(): Promise<ProductConfig[]>;
20
+ upsertProduct(slug: string, data: ProductBrandUpdate): Promise<Product>;
21
+ replaceNavItems(slug: string, productId: string, items: NavItemInput[]): Promise<void>;
22
+ upsertFeatures(slug: string, productId: string, data: Partial<ProductFeatures>): Promise<void>;
23
+ upsertFleetConfig(slug: string, productId: string, data: Partial<ProductFleetConfig>): Promise<void>;
24
+ upsertBillingConfig(slug: string, productId: string, data: Partial<ProductBillingConfig>): Promise<void>;
25
+ invalidate(slug: string): void;
26
+ invalidateAll(): void;
27
+ }