@wopr-network/platform-core 1.58.1 → 1.60.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 (63) hide show
  1. package/dist/billing/crypto/__tests__/key-server.test.js +3 -0
  2. package/dist/billing/crypto/key-server.js +1 -0
  3. package/dist/billing/crypto/oracle/coingecko.js +1 -0
  4. package/dist/billing/crypto/payment-method-store.d.ts +1 -0
  5. package/dist/billing/crypto/payment-method-store.js +3 -0
  6. package/dist/billing/crypto/tron/__tests__/address-convert.test.d.ts +1 -0
  7. package/dist/billing/crypto/tron/__tests__/address-convert.test.js +55 -0
  8. package/dist/billing/crypto/tron/address-convert.d.ts +14 -0
  9. package/dist/billing/crypto/tron/address-convert.js +83 -0
  10. package/dist/billing/crypto/watcher-service.js +21 -11
  11. package/dist/db/schema/crypto.d.ts +17 -0
  12. package/dist/db/schema/crypto.js +1 -0
  13. package/dist/db/schema/index.d.ts +2 -0
  14. package/dist/db/schema/index.js +2 -0
  15. package/dist/db/schema/product-config.d.ts +610 -0
  16. package/dist/db/schema/product-config.js +51 -0
  17. package/dist/db/schema/products.d.ts +565 -0
  18. package/dist/db/schema/products.js +43 -0
  19. package/dist/product-config/boot.d.ts +36 -0
  20. package/dist/product-config/boot.js +30 -0
  21. package/dist/product-config/drizzle-product-config-repository.d.ts +19 -0
  22. package/dist/product-config/drizzle-product-config-repository.js +200 -0
  23. package/dist/product-config/drizzle-product-config-repository.test.d.ts +1 -0
  24. package/dist/product-config/drizzle-product-config-repository.test.js +114 -0
  25. package/dist/product-config/index.d.ts +24 -0
  26. package/dist/product-config/index.js +37 -0
  27. package/dist/product-config/repository-types.d.ts +143 -0
  28. package/dist/product-config/repository-types.js +53 -0
  29. package/dist/product-config/service.d.ts +27 -0
  30. package/dist/product-config/service.js +74 -0
  31. package/dist/product-config/service.test.d.ts +1 -0
  32. package/dist/product-config/service.test.js +107 -0
  33. package/dist/trpc/index.d.ts +1 -0
  34. package/dist/trpc/index.js +1 -0
  35. package/dist/trpc/product-config-router.d.ts +117 -0
  36. package/dist/trpc/product-config-router.js +137 -0
  37. package/docs/specs/2026-03-23-product-config-db-migration-plan.md +2260 -0
  38. package/docs/specs/2026-03-23-product-config-db-migration.md +371 -0
  39. package/drizzle/migrations/0020_product_config_tables.sql +109 -0
  40. package/drizzle/migrations/0021_watcher_type_column.sql +3 -0
  41. package/drizzle/migrations/meta/_journal.json +7 -0
  42. package/package.json +1 -1
  43. package/scripts/seed-products.ts +268 -0
  44. package/src/billing/crypto/__tests__/key-server.test.ts +3 -0
  45. package/src/billing/crypto/key-server.ts +2 -0
  46. package/src/billing/crypto/oracle/coingecko.ts +1 -0
  47. package/src/billing/crypto/payment-method-store.ts +4 -0
  48. package/src/billing/crypto/tron/__tests__/address-convert.test.ts +67 -0
  49. package/src/billing/crypto/tron/address-convert.ts +80 -0
  50. package/src/billing/crypto/watcher-service.ts +24 -16
  51. package/src/db/schema/crypto.ts +1 -0
  52. package/src/db/schema/index.ts +2 -0
  53. package/src/db/schema/product-config.ts +56 -0
  54. package/src/db/schema/products.ts +58 -0
  55. package/src/product-config/boot.ts +57 -0
  56. package/src/product-config/drizzle-product-config-repository.test.ts +132 -0
  57. package/src/product-config/drizzle-product-config-repository.ts +229 -0
  58. package/src/product-config/index.ts +62 -0
  59. package/src/product-config/repository-types.ts +222 -0
  60. package/src/product-config/service.test.ts +127 -0
  61. package/src/product-config/service.ts +105 -0
  62. package/src/trpc/index.ts +1 -0
  63. package/src/trpc/product-config-router.ts +161 -0
@@ -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
+ }
@@ -0,0 +1,74 @@
1
+ import { toBrandConfig } 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 class ProductConfigService {
11
+ repo;
12
+ cache = new Map();
13
+ ttlMs;
14
+ constructor(repo, opts = {}) {
15
+ this.repo = repo;
16
+ this.ttlMs = opts.ttlMs ?? 60_000;
17
+ }
18
+ // ---------------------------------------------------------------------------
19
+ // Reads (cached)
20
+ // ---------------------------------------------------------------------------
21
+ async getBySlug(slug) {
22
+ const entry = this.cache.get(slug);
23
+ if (entry && Date.now() < entry.expiresAt) {
24
+ return entry.config;
25
+ }
26
+ const config = await this.repo.getBySlug(slug);
27
+ if (config) {
28
+ this.cache.set(slug, { config, expiresAt: Date.now() + this.ttlMs });
29
+ }
30
+ return config;
31
+ }
32
+ async getBrandConfig(slug) {
33
+ const config = await this.getBySlug(slug);
34
+ if (!config)
35
+ return null;
36
+ return toBrandConfig(config);
37
+ }
38
+ async listAll() {
39
+ return this.repo.listAll();
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Writes (auto-invalidate)
43
+ // ---------------------------------------------------------------------------
44
+ async upsertProduct(slug, data) {
45
+ const result = await this.repo.upsertProduct(slug, data);
46
+ this.invalidate(slug);
47
+ return result;
48
+ }
49
+ async replaceNavItems(slug, productId, items) {
50
+ await this.repo.replaceNavItems(productId, items);
51
+ this.invalidate(slug);
52
+ }
53
+ async upsertFeatures(slug, productId, data) {
54
+ await this.repo.upsertFeatures(productId, data);
55
+ this.invalidate(slug);
56
+ }
57
+ async upsertFleetConfig(slug, productId, data) {
58
+ await this.repo.upsertFleetConfig(productId, data);
59
+ this.invalidate(slug);
60
+ }
61
+ async upsertBillingConfig(slug, productId, data) {
62
+ await this.repo.upsertBillingConfig(productId, data);
63
+ this.invalidate(slug);
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // Cache management
67
+ // ---------------------------------------------------------------------------
68
+ invalidate(slug) {
69
+ this.cache.delete(slug);
70
+ }
71
+ invalidateAll() {
72
+ this.cache.clear();
73
+ }
74
+ }
@@ -0,0 +1 @@
1
+ export {};