@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,56 @@
1
+ import { boolean, integer, jsonb, numeric, pgEnum, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
2
+
3
+ import { products } from "./products.js";
4
+
5
+ export const fleetLifecycleEnum = pgEnum("fleet_lifecycle", ["managed", "ephemeral"]);
6
+ export const fleetBillingModelEnum = pgEnum("fleet_billing_model", ["monthly", "per_use", "none"]);
7
+
8
+ export const productFeatures = pgTable("product_features", {
9
+ productId: uuid("product_id")
10
+ .primaryKey()
11
+ .references(() => products.id, { onDelete: "cascade" }),
12
+ chatEnabled: boolean("chat_enabled").notNull().default(true),
13
+ onboardingEnabled: boolean("onboarding_enabled").notNull().default(true),
14
+ onboardingDefaultModel: text("onboarding_default_model"),
15
+ onboardingSystemPrompt: text("onboarding_system_prompt"),
16
+ onboardingMaxCredits: integer("onboarding_max_credits").notNull().default(100),
17
+ onboardingWelcomeMsg: text("onboarding_welcome_msg"),
18
+ sharedModuleBilling: boolean("shared_module_billing").notNull().default(true),
19
+ sharedModuleMonitoring: boolean("shared_module_monitoring").notNull().default(true),
20
+ sharedModuleAnalytics: boolean("shared_module_analytics").notNull().default(true),
21
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
22
+ });
23
+
24
+ export const productFleetConfig = pgTable("product_fleet_config", {
25
+ productId: uuid("product_id")
26
+ .primaryKey()
27
+ .references(() => products.id, { onDelete: "cascade" }),
28
+ containerImage: text("container_image").notNull(),
29
+ containerPort: integer("container_port").notNull().default(3100),
30
+ lifecycle: fleetLifecycleEnum("lifecycle").notNull().default("managed"),
31
+ billingModel: fleetBillingModelEnum("billing_model").notNull().default("monthly"),
32
+ maxInstances: integer("max_instances").notNull().default(5),
33
+ imageAllowlist: text("image_allowlist").array(),
34
+ dockerNetwork: text("docker_network").notNull().default(""),
35
+ placementStrategy: text("placement_strategy").notNull().default("least-loaded"),
36
+ fleetDataDir: text("fleet_data_dir").notNull().default("/data/fleet"),
37
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
38
+ });
39
+
40
+ export const productBillingConfig = pgTable("product_billing_config", {
41
+ productId: uuid("product_id")
42
+ .primaryKey()
43
+ .references(() => products.id, { onDelete: "cascade" }),
44
+ stripePublishableKey: text("stripe_publishable_key"),
45
+ /** Encrypted via credential vault (CRYPTO_SERVICE_KEY) before storage. */
46
+ stripeSecretKey: text("stripe_secret_key"),
47
+ /** Encrypted via credential vault (CRYPTO_SERVICE_KEY) before storage. */
48
+ stripeWebhookSecret: text("stripe_webhook_secret"),
49
+ creditPrices: jsonb("credit_prices").notNull().default({}),
50
+ affiliateBaseUrl: text("affiliate_base_url"),
51
+ affiliateMatchRate: numeric("affiliate_match_rate").notNull().default("1.0"),
52
+ affiliateMaxCap: integer("affiliate_max_cap").notNull().default(20000),
53
+ dividendRate: numeric("dividend_rate").notNull().default("1.0"),
54
+ marginConfig: jsonb("margin_config"),
55
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
56
+ });
@@ -0,0 +1,58 @@
1
+ import { boolean, index, integer, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
2
+
3
+ export const products = pgTable(
4
+ "products",
5
+ {
6
+ id: uuid("id").primaryKey().defaultRandom(),
7
+ slug: text("slug").notNull(),
8
+ brandName: text("brand_name").notNull(),
9
+ productName: text("product_name").notNull(),
10
+ tagline: text("tagline").notNull().default(""),
11
+ domain: text("domain").notNull(),
12
+ appDomain: text("app_domain").notNull(),
13
+ cookieDomain: text("cookie_domain").notNull(),
14
+ companyLegal: text("company_legal").notNull().default(""),
15
+ priceLabel: text("price_label").notNull().default(""),
16
+ defaultImage: text("default_image").notNull().default(""),
17
+ emailSupport: text("email_support").notNull().default(""),
18
+ emailPrivacy: text("email_privacy").notNull().default(""),
19
+ emailLegal: text("email_legal").notNull().default(""),
20
+ fromEmail: text("from_email").notNull().default(""),
21
+ homePath: text("home_path").notNull().default("/marketplace"),
22
+ storagePrefix: text("storage_prefix").notNull(),
23
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
24
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
25
+ },
26
+ (t) => [uniqueIndex("products_slug_idx").on(t.slug)],
27
+ );
28
+
29
+ export const productNavItems = pgTable(
30
+ "product_nav_items",
31
+ {
32
+ id: uuid("id").primaryKey().defaultRandom(),
33
+ productId: uuid("product_id")
34
+ .notNull()
35
+ .references(() => products.id, { onDelete: "cascade" }),
36
+ label: text("label").notNull(),
37
+ href: text("href").notNull(),
38
+ icon: text("icon"),
39
+ sortOrder: integer("sort_order").notNull(),
40
+ requiresRole: text("requires_role"),
41
+ enabled: boolean("enabled").notNull().default(true),
42
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
43
+ },
44
+ (t) => [index("product_nav_items_product_sort_idx").on(t.productId, t.sortOrder)],
45
+ );
46
+
47
+ export const productDomains = pgTable(
48
+ "product_domains",
49
+ {
50
+ id: uuid("id").primaryKey().defaultRandom(),
51
+ productId: uuid("product_id")
52
+ .notNull()
53
+ .references(() => products.id, { onDelete: "cascade" }),
54
+ host: text("host").notNull(),
55
+ role: text("role").notNull().default("canonical"),
56
+ },
57
+ (t) => [uniqueIndex("product_domains_product_host_idx").on(t.productId, t.host)],
58
+ );
@@ -0,0 +1,57 @@
1
+ import type { DrizzleDb } from "../db/index.js";
2
+ import { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
3
+ import type { ProductConfig } from "./repository-types.js";
4
+ import { deriveCorsOrigins } from "./repository-types.js";
5
+ import { ProductConfigService } from "./service.js";
6
+
7
+ export interface PlatformBootResult {
8
+ /** The product config service — single point of access for config reads/writes. */
9
+ service: ProductConfigService;
10
+ /** The resolved product config (cached). */
11
+ config: ProductConfig;
12
+ /** CORS origins derived from product domains + optional dev origins. */
13
+ corsOrigins: string[];
14
+ }
15
+
16
+ export interface PlatformBootOptions {
17
+ /** Product slug (e.g. "paperclip", "wopr", "holyship", "nemoclaw"). */
18
+ slug: string;
19
+ /** Drizzle database instance. */
20
+ db: DrizzleDb;
21
+ /** Additional CORS origins for local dev (from DEV_ORIGINS env var). */
22
+ devOrigins?: string[];
23
+ }
24
+
25
+ /**
26
+ * Bootstrap product configuration from DB.
27
+ *
28
+ * Call once at startup, after DB + migrations, before route registration.
29
+ * Returns the service (for tRPC router wiring) and the resolved config
30
+ * (for CORS, email, fleet, auth initialization).
31
+ *
32
+ * This replaces: BRAND_NAME, PLATFORM_DOMAIN, UI_ORIGIN, FROM_EMAIL,
33
+ * SUPPORT_EMAIL, COOKIE_DOMAIN, APP_BASE_URL, and all other product-
34
+ * specific env vars that platform-core modules previously read from
35
+ * process.env.
36
+ *
37
+ * Product backends still own their specific wiring (crypto watchers,
38
+ * fleet updaters, notification pipelines). platformBoot handles the
39
+ * config-driven parts that are identical across products.
40
+ */
41
+ export async function platformBoot(opts: PlatformBootOptions): Promise<PlatformBootResult> {
42
+ const { slug, db, devOrigins = [] } = opts;
43
+
44
+ const repo = new DrizzleProductConfigRepository(db);
45
+ const service = new ProductConfigService(repo);
46
+
47
+ const config = await service.getBySlug(slug);
48
+ if (!config) {
49
+ throw new Error(
50
+ `Product "${slug}" not found in database. Run the seed script: DATABASE_URL=... npx tsx scripts/seed-products.ts`,
51
+ );
52
+ }
53
+
54
+ const corsOrigins = [...deriveCorsOrigins(config.product, config.domains), ...devOrigins];
55
+
56
+ return { service, config, corsOrigins };
57
+ }
@@ -0,0 +1,132 @@
1
+ import type { PGlite } from "@electric-sql/pglite";
2
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
+ import type { DrizzleDb } from "../db/index.js";
4
+ import { products } from "../db/schema/products.js";
5
+ import { createTestDb } from "../test/db.js";
6
+ import { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
7
+
8
+ const PRODUCT_SEED = {
9
+ slug: "test-product",
10
+ brandName: "Test Brand",
11
+ productName: "Test Product",
12
+ domain: "test.example.com",
13
+ appDomain: "app.test.example.com",
14
+ cookieDomain: ".test.example.com",
15
+ storagePrefix: "test",
16
+ };
17
+
18
+ describe("DrizzleProductConfigRepository", () => {
19
+ let db: DrizzleDb;
20
+ let pool: PGlite;
21
+ let repo: DrizzleProductConfigRepository;
22
+
23
+ beforeAll(async () => {
24
+ const result = await createTestDb();
25
+ db = result.db as unknown as DrizzleDb;
26
+ pool = result.pool;
27
+ repo = new DrizzleProductConfigRepository(db);
28
+ });
29
+
30
+ afterAll(async () => {
31
+ await pool.close();
32
+ });
33
+
34
+ it("getBySlug returns null for unknown slug", async () => {
35
+ const result = await repo.getBySlug("no-such-slug");
36
+ expect(result).toBeNull();
37
+ });
38
+
39
+ it("getBySlug returns full config for seeded product with empty related tables", async () => {
40
+ await db.insert(products).values(PRODUCT_SEED);
41
+
42
+ const config = await repo.getBySlug("test-product");
43
+
44
+ expect(config).not.toBeNull();
45
+ expect(config?.product.slug).toBe("test-product");
46
+ expect(config?.product.brandName).toBe("Test Brand");
47
+ expect(config?.navItems).toHaveLength(0);
48
+ expect(config?.domains).toHaveLength(0);
49
+ expect(config?.features).toBeNull();
50
+ expect(config?.fleet).toBeNull();
51
+ expect(config?.billing).toBeNull();
52
+ });
53
+
54
+ it("replaceNavItems then getBySlug returns sorted nav items", async () => {
55
+ const config = await repo.getBySlug("test-product");
56
+ if (!config) throw new Error("product not found");
57
+ const productId = config.product.id;
58
+
59
+ await repo.replaceNavItems(productId, [
60
+ { label: "Beta", href: "/beta", sortOrder: 2 },
61
+ { label: "Alpha", href: "/alpha", sortOrder: 1 },
62
+ { label: "Gamma", href: "/gamma", sortOrder: 3, requiresRole: "admin" },
63
+ ]);
64
+
65
+ const updated = await repo.getBySlug("test-product");
66
+ expect(updated?.navItems).toHaveLength(3);
67
+ expect(updated?.navItems[0].label).toBe("Alpha");
68
+ expect(updated?.navItems[1].label).toBe("Beta");
69
+ expect(updated?.navItems[2].label).toBe("Gamma");
70
+ expect(updated?.navItems[2].requiresRole).toBe("admin");
71
+ });
72
+
73
+ it("upsertFeatures then getBySlug returns features", async () => {
74
+ const config = await repo.getBySlug("test-product");
75
+ if (!config) throw new Error("product not found");
76
+ const productId = config.product.id;
77
+
78
+ await repo.upsertFeatures(productId, {
79
+ chatEnabled: false,
80
+ onboardingEnabled: true,
81
+ onboardingDefaultModel: "gpt-4o",
82
+ onboardingMaxCredits: 50,
83
+ });
84
+
85
+ const updated = await repo.getBySlug("test-product");
86
+ expect(updated?.features).not.toBeNull();
87
+ expect(updated?.features?.chatEnabled).toBe(false);
88
+ expect(updated?.features?.onboardingDefaultModel).toBe("gpt-4o");
89
+ expect(updated?.features?.onboardingMaxCredits).toBe(50);
90
+ });
91
+
92
+ it("upsertFleetConfig with lifecycle ephemeral and billingModel none", async () => {
93
+ const config = await repo.getBySlug("test-product");
94
+ if (!config) throw new Error("product not found");
95
+ const productId = config.product.id;
96
+
97
+ await repo.upsertFleetConfig(productId, {
98
+ containerImage: "ghcr.io/example/app:latest",
99
+ lifecycle: "ephemeral",
100
+ billingModel: "none",
101
+ containerPort: 8080,
102
+ maxInstances: 10,
103
+ });
104
+
105
+ const updated = await repo.getBySlug("test-product");
106
+ expect(updated?.fleet).not.toBeNull();
107
+ expect(updated?.fleet?.containerImage).toBe("ghcr.io/example/app:latest");
108
+ expect(updated?.fleet?.lifecycle).toBe("ephemeral");
109
+ expect(updated?.fleet?.billingModel).toBe("none");
110
+ expect(updated?.fleet?.containerPort).toBe(8080);
111
+ expect(updated?.fleet?.maxInstances).toBe(10);
112
+ });
113
+
114
+ it("listAll returns all products", async () => {
115
+ // Insert a second product
116
+ await db.insert(products).values({
117
+ slug: "second-product",
118
+ brandName: "Second Brand",
119
+ productName: "Second Product",
120
+ domain: "second.example.com",
121
+ appDomain: "app.second.example.com",
122
+ cookieDomain: ".second.example.com",
123
+ storagePrefix: "second",
124
+ });
125
+
126
+ const all = await repo.listAll();
127
+ const slugs = all.map((c) => c.product.slug);
128
+ expect(slugs).toContain("test-product");
129
+ expect(slugs).toContain("second-product");
130
+ expect(all.length).toBeGreaterThanOrEqual(2);
131
+ });
132
+ });
@@ -0,0 +1,229 @@
1
+ import { asc, eq } from "drizzle-orm";
2
+ import type { DrizzleDb } from "../db/index.js";
3
+ import { productBillingConfig, productFeatures, productFleetConfig } from "../db/schema/product-config.js";
4
+ import { productDomains, productNavItems, products } from "../db/schema/products.js";
5
+ import type {
6
+ IProductConfigRepository,
7
+ NavItemInput,
8
+ Product,
9
+ ProductBillingConfig as ProductBillingConfigType,
10
+ ProductBrandUpdate,
11
+ ProductConfig,
12
+ ProductDomain,
13
+ ProductFeatures as ProductFeaturesType,
14
+ ProductFleetConfig as ProductFleetConfigType,
15
+ ProductNavItem,
16
+ } from "./repository-types.js";
17
+
18
+ export class DrizzleProductConfigRepository implements IProductConfigRepository {
19
+ constructor(private db: DrizzleDb) {}
20
+
21
+ async getBySlug(slug: string): Promise<ProductConfig | null> {
22
+ const [product] = await this.db.select().from(products).where(eq(products.slug, slug)).limit(1);
23
+
24
+ if (!product) return null;
25
+
26
+ const [navItems, domains, featuresRows, fleetRows, billingRows] = await Promise.all([
27
+ this.db
28
+ .select()
29
+ .from(productNavItems)
30
+ .where(eq(productNavItems.productId, product.id))
31
+ .orderBy(asc(productNavItems.sortOrder)),
32
+ this.db.select().from(productDomains).where(eq(productDomains.productId, product.id)),
33
+ this.db.select().from(productFeatures).where(eq(productFeatures.productId, product.id)).limit(1),
34
+ this.db.select().from(productFleetConfig).where(eq(productFleetConfig.productId, product.id)).limit(1),
35
+ this.db.select().from(productBillingConfig).where(eq(productBillingConfig.productId, product.id)).limit(1),
36
+ ]);
37
+
38
+ return {
39
+ product: this.mapProduct(product),
40
+ navItems: navItems.map((n) => this.mapNavItem(n)),
41
+ domains: domains.map((d) => this.mapDomain(d)),
42
+ features: featuresRows[0] ? this.mapFeatures(featuresRows[0]) : null,
43
+ fleet: fleetRows[0] ? this.mapFleet(fleetRows[0]) : null,
44
+ billing: billingRows[0] ? this.mapBilling(billingRows[0]) : null,
45
+ };
46
+ }
47
+
48
+ async listAll(): Promise<ProductConfig[]> {
49
+ const allProducts = await this.db.select().from(products);
50
+ const configs = await Promise.all(allProducts.map((p) => this.getBySlug(p.slug)));
51
+ return configs.filter((c): c is ProductConfig => c !== null);
52
+ }
53
+
54
+ async upsertProduct(slug: string, data: ProductBrandUpdate): Promise<Product> {
55
+ const base = {
56
+ slug,
57
+ storagePrefix: slug,
58
+ domain: "",
59
+ appDomain: "",
60
+ cookieDomain: "",
61
+ brandName: "",
62
+ productName: "",
63
+ };
64
+ const [result] = await this.db
65
+ .insert(products)
66
+ .values({ ...base, ...data })
67
+ .onConflictDoUpdate({
68
+ target: products.slug,
69
+ set: { ...(data as Record<string, unknown>), updatedAt: new Date() },
70
+ })
71
+ .returning();
72
+ return this.mapProduct(result);
73
+ }
74
+
75
+ async replaceNavItems(productId: string, items: NavItemInput[]): Promise<void> {
76
+ await this.db.transaction(async (tx) => {
77
+ await tx.delete(productNavItems).where(eq(productNavItems.productId, productId));
78
+ if (items.length > 0) {
79
+ await tx.insert(productNavItems).values(
80
+ items.map((item) => ({
81
+ productId,
82
+ label: item.label,
83
+ href: item.href,
84
+ icon: item.icon ?? null,
85
+ sortOrder: item.sortOrder,
86
+ requiresRole: item.requiresRole ?? null,
87
+ enabled: item.enabled !== false,
88
+ })),
89
+ );
90
+ }
91
+ });
92
+ }
93
+
94
+ async upsertFeatures(productId: string, data: Partial<ProductFeaturesType>): Promise<void> {
95
+ const { productId: _, ...rest } = data as Record<string, unknown>;
96
+ await this.db
97
+ .insert(productFeatures)
98
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
99
+ .values({ productId, ...rest } as any)
100
+ .onConflictDoUpdate({
101
+ target: productFeatures.productId,
102
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
103
+ set: { ...rest, updatedAt: new Date() } as any,
104
+ });
105
+ }
106
+
107
+ async upsertFleetConfig(productId: string, data: Partial<ProductFleetConfigType>): Promise<void> {
108
+ const { productId: _, ...rest } = data as Record<string, unknown>;
109
+ await this.db
110
+ .insert(productFleetConfig)
111
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
112
+ .values({ productId, containerImage: "", ...rest } as any)
113
+ .onConflictDoUpdate({
114
+ target: productFleetConfig.productId,
115
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
116
+ set: { ...rest, updatedAt: new Date() } as any,
117
+ });
118
+ }
119
+
120
+ async upsertBillingConfig(productId: string, data: Partial<ProductBillingConfigType>): Promise<void> {
121
+ // TODO: stripeSecretKey and stripeWebhookSecret must be encrypted via the credential vault
122
+ // (CRYPTO_SERVICE_KEY) before reaching this method. The schema stores encrypted ciphertext.
123
+ const { productId: _, ...rest } = data as Record<string, unknown>;
124
+ await this.db
125
+ .insert(productBillingConfig)
126
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
127
+ .values({ productId, ...rest } as any)
128
+ .onConflictDoUpdate({
129
+ target: productBillingConfig.productId,
130
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
131
+ set: { ...rest, updatedAt: new Date() } as any,
132
+ });
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Mappers
137
+ // ---------------------------------------------------------------------------
138
+
139
+ private mapProduct(row: typeof products.$inferSelect): Product {
140
+ return {
141
+ id: row.id,
142
+ slug: row.slug,
143
+ brandName: row.brandName,
144
+ productName: row.productName,
145
+ tagline: row.tagline,
146
+ domain: row.domain,
147
+ appDomain: row.appDomain,
148
+ cookieDomain: row.cookieDomain,
149
+ companyLegal: row.companyLegal,
150
+ priceLabel: row.priceLabel,
151
+ defaultImage: row.defaultImage,
152
+ emailSupport: row.emailSupport,
153
+ emailPrivacy: row.emailPrivacy,
154
+ emailLegal: row.emailLegal,
155
+ fromEmail: row.fromEmail,
156
+ homePath: row.homePath,
157
+ storagePrefix: row.storagePrefix,
158
+ createdAt: row.createdAt,
159
+ updatedAt: row.updatedAt,
160
+ };
161
+ }
162
+
163
+ private mapNavItem(row: typeof productNavItems.$inferSelect): ProductNavItem {
164
+ return {
165
+ id: row.id,
166
+ productId: row.productId,
167
+ label: row.label,
168
+ href: row.href,
169
+ icon: row.icon ?? null,
170
+ sortOrder: row.sortOrder,
171
+ requiresRole: row.requiresRole ?? null,
172
+ enabled: row.enabled,
173
+ };
174
+ }
175
+
176
+ private mapDomain(row: typeof productDomains.$inferSelect): ProductDomain {
177
+ return {
178
+ id: row.id,
179
+ productId: row.productId,
180
+ host: row.host,
181
+ role: row.role as "canonical" | "redirect",
182
+ };
183
+ }
184
+
185
+ private mapFeatures(row: typeof productFeatures.$inferSelect): ProductFeaturesType {
186
+ return {
187
+ productId: row.productId,
188
+ chatEnabled: row.chatEnabled,
189
+ onboardingEnabled: row.onboardingEnabled,
190
+ onboardingDefaultModel: row.onboardingDefaultModel ?? null,
191
+ onboardingSystemPrompt: row.onboardingSystemPrompt ?? null,
192
+ onboardingMaxCredits: row.onboardingMaxCredits,
193
+ onboardingWelcomeMsg: row.onboardingWelcomeMsg ?? null,
194
+ sharedModuleBilling: row.sharedModuleBilling,
195
+ sharedModuleMonitoring: row.sharedModuleMonitoring,
196
+ sharedModuleAnalytics: row.sharedModuleAnalytics,
197
+ };
198
+ }
199
+
200
+ private mapFleet(row: typeof productFleetConfig.$inferSelect): ProductFleetConfigType {
201
+ return {
202
+ productId: row.productId,
203
+ containerImage: row.containerImage,
204
+ containerPort: row.containerPort,
205
+ lifecycle: row.lifecycle as ProductFleetConfigType["lifecycle"],
206
+ billingModel: row.billingModel as ProductFleetConfigType["billingModel"],
207
+ maxInstances: row.maxInstances,
208
+ imageAllowlist: row.imageAllowlist ?? null,
209
+ dockerNetwork: row.dockerNetwork,
210
+ placementStrategy: row.placementStrategy,
211
+ fleetDataDir: row.fleetDataDir,
212
+ };
213
+ }
214
+
215
+ private mapBilling(row: typeof productBillingConfig.$inferSelect): ProductBillingConfigType {
216
+ return {
217
+ productId: row.productId,
218
+ stripePublishableKey: row.stripePublishableKey ?? null,
219
+ stripeSecretKey: row.stripeSecretKey ?? null,
220
+ stripeWebhookSecret: row.stripeWebhookSecret ?? null,
221
+ creditPrices: (row.creditPrices ?? {}) as Record<string, number>,
222
+ affiliateBaseUrl: row.affiliateBaseUrl ?? null,
223
+ affiliateMatchRate: Number(row.affiliateMatchRate),
224
+ affiliateMaxCap: row.affiliateMaxCap,
225
+ dividendRate: Number(row.dividendRate),
226
+ marginConfig: row.marginConfig ?? null,
227
+ };
228
+ }
229
+ }
@@ -0,0 +1,62 @@
1
+ import type { DrizzleDb } from "../db/index.js";
2
+ import type { PlatformBootOptions, PlatformBootResult } from "./boot.js";
3
+ import { platformBoot as rawPlatformBoot } from "./boot.js";
4
+ import { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
5
+ import type { IProductConfigRepository } from "./repository-types.js";
6
+ import { ProductConfigService } from "./service.js";
7
+
8
+ export type { PlatformBootOptions, PlatformBootResult } from "./boot.js";
9
+
10
+ /**
11
+ * Bootstrap product config from DB and register the service globally.
12
+ * Wraps the raw boot to ensure getProductConfigService() works after calling this.
13
+ */
14
+ export async function platformBoot(opts: PlatformBootOptions): Promise<PlatformBootResult> {
15
+ const result = await rawPlatformBoot(opts);
16
+ _service = result.service;
17
+ return result;
18
+ }
19
+ export { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
20
+ // Re-exports for consumers
21
+ export type {
22
+ FleetBillingModel,
23
+ FleetLifecycle,
24
+ IProductConfigRepository,
25
+ NavItemInput,
26
+ Product,
27
+ ProductBillingConfig,
28
+ ProductBrandConfig,
29
+ ProductBrandUpdate,
30
+ ProductConfig,
31
+ ProductDomain,
32
+ ProductFeatures,
33
+ ProductFleetConfig,
34
+ ProductNavItem,
35
+ } from "./repository-types.js";
36
+ export { deriveCorsOrigins, toBrandConfig } from "./repository-types.js";
37
+ export { ProductConfigService } from "./service.js";
38
+
39
+ let _service: ProductConfigService | null = null;
40
+
41
+ /** Initialize the product config system. Call once at startup. */
42
+ export function initProductConfig(db: DrizzleDb): ProductConfigService {
43
+ const repo = new DrizzleProductConfigRepository(db);
44
+ _service = new ProductConfigService(repo);
45
+ return _service;
46
+ }
47
+
48
+ /** Initialize with a custom repository (for testing). */
49
+ export function initProductConfigWithRepo(repo: IProductConfigRepository): ProductConfigService {
50
+ _service = new ProductConfigService(repo);
51
+ return _service;
52
+ }
53
+
54
+ /**
55
+ * Get the product config service.
56
+ * This is the ONLY way to access product config — reads are cached,
57
+ * writes auto-invalidate the cache.
58
+ */
59
+ export function getProductConfigService(): ProductConfigService {
60
+ if (!_service) throw new Error("Product config not initialized. Call initProductConfig() first.");
61
+ return _service;
62
+ }