@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,268 @@
1
+ /**
2
+ * Seed product config tables with data for all 4 products.
3
+ *
4
+ * Usage:
5
+ * DATABASE_URL=postgres://... npx tsx scripts/seed-products.ts
6
+ * DATABASE_URL=postgres://... npx tsx scripts/seed-products.ts --slug=paperclip
7
+ */
8
+ import { eq } from "drizzle-orm";
9
+ import { drizzle } from "drizzle-orm/node-postgres";
10
+ import pg from "pg";
11
+ import { productFeatures, productFleetConfig } from "../src/db/schema/product-config.js";
12
+ import { productNavItems, products } from "../src/db/schema/products.js";
13
+
14
+ interface NavItem {
15
+ label: string;
16
+ href: string;
17
+ sortOrder: number;
18
+ requiresRole?: string;
19
+ }
20
+
21
+ interface FleetPreset {
22
+ containerImage: string;
23
+ lifecycle: "managed" | "ephemeral";
24
+ billingModel: "monthly" | "per_use" | "none";
25
+ maxInstances: number;
26
+ }
27
+
28
+ interface ProductPreset {
29
+ brandName: string;
30
+ productName: string;
31
+ tagline: string;
32
+ domain: string;
33
+ appDomain: string;
34
+ cookieDomain: string;
35
+ companyLegal: string;
36
+ priceLabel: string;
37
+ defaultImage: string;
38
+ emailSupport: string;
39
+ emailPrivacy: string;
40
+ emailLegal: string;
41
+ fromEmail: string;
42
+ homePath: string;
43
+ storagePrefix: string;
44
+ navItems: NavItem[];
45
+ fleet: FleetPreset;
46
+ }
47
+
48
+ const PRESETS: Record<string, ProductPreset> = {
49
+ wopr: {
50
+ brandName: "WOPR",
51
+ productName: "WOPR Bot",
52
+ tagline: "A $5/month supercomputer that manages your business.",
53
+ domain: "wopr.bot",
54
+ appDomain: "app.wopr.bot",
55
+ cookieDomain: ".wopr.bot",
56
+ companyLegal: "WOPR Network Inc.",
57
+ priceLabel: "$5/month",
58
+ defaultImage: "ghcr.io/wopr-network/wopr:latest",
59
+ emailSupport: "support@wopr.bot",
60
+ emailPrivacy: "privacy@wopr.bot",
61
+ emailLegal: "legal@wopr.bot",
62
+ fromEmail: "noreply@wopr.bot",
63
+ homePath: "/marketplace",
64
+ storagePrefix: "wopr",
65
+ navItems: [
66
+ { label: "Dashboard", href: "/dashboard", sortOrder: 0 },
67
+ { label: "Chat", href: "/chat", sortOrder: 1 },
68
+ { label: "Marketplace", href: "/marketplace", sortOrder: 2 },
69
+ { label: "Channels", href: "/channels", sortOrder: 3 },
70
+ { label: "Plugins", href: "/plugins", sortOrder: 4 },
71
+ { label: "Instances", href: "/instances", sortOrder: 5 },
72
+ { label: "Changesets", href: "/changesets", sortOrder: 6 },
73
+ { label: "Network", href: "/dashboard/network", sortOrder: 7 },
74
+ { label: "Fleet Health", href: "/fleet/health", sortOrder: 8 },
75
+ { label: "Credits", href: "/billing/credits", sortOrder: 9 },
76
+ { label: "Billing", href: "/billing/plans", sortOrder: 10 },
77
+ { label: "Settings", href: "/settings/profile", sortOrder: 11 },
78
+ { label: "Admin", href: "/admin/tenants", sortOrder: 12, requiresRole: "platform_admin" },
79
+ ],
80
+ fleet: {
81
+ containerImage: "ghcr.io/wopr-network/wopr:latest",
82
+ lifecycle: "managed",
83
+ billingModel: "monthly",
84
+ maxInstances: 5,
85
+ },
86
+ },
87
+ paperclip: {
88
+ brandName: "Paperclip",
89
+ productName: "Paperclip",
90
+ tagline: "AI agents that run your business.",
91
+ domain: "runpaperclip.com",
92
+ appDomain: "app.runpaperclip.com",
93
+ cookieDomain: ".runpaperclip.com",
94
+ companyLegal: "Paperclip AI Inc.",
95
+ priceLabel: "$5/month",
96
+ defaultImage: "ghcr.io/wopr-network/paperclip:managed",
97
+ emailSupport: "support@runpaperclip.com",
98
+ emailPrivacy: "privacy@runpaperclip.com",
99
+ emailLegal: "legal@runpaperclip.com",
100
+ fromEmail: "noreply@runpaperclip.com",
101
+ homePath: "/instances",
102
+ storagePrefix: "paperclip",
103
+ navItems: [
104
+ { label: "Instances", href: "/instances", sortOrder: 0 },
105
+ { label: "Credits", href: "/billing/credits", sortOrder: 1 },
106
+ { label: "Settings", href: "/settings/profile", sortOrder: 2 },
107
+ { label: "Admin", href: "/admin/tenants", sortOrder: 3, requiresRole: "platform_admin" },
108
+ ],
109
+ fleet: {
110
+ containerImage: "ghcr.io/wopr-network/paperclip:managed",
111
+ lifecycle: "managed",
112
+ billingModel: "monthly",
113
+ maxInstances: 5,
114
+ },
115
+ },
116
+ holyship: {
117
+ brandName: "Holy Ship",
118
+ productName: "Holy Ship",
119
+ tagline: "Ship it.",
120
+ domain: "holyship.wtf",
121
+ appDomain: "app.holyship.wtf",
122
+ cookieDomain: ".holyship.wtf",
123
+ companyLegal: "WOPR Network Inc.",
124
+ priceLabel: "",
125
+ defaultImage: "ghcr.io/wopr-network/holyship:latest",
126
+ emailSupport: "support@holyship.wtf",
127
+ emailPrivacy: "privacy@holyship.wtf",
128
+ emailLegal: "legal@holyship.wtf",
129
+ fromEmail: "noreply@holyship.wtf",
130
+ homePath: "/dashboard",
131
+ storagePrefix: "holyship",
132
+ navItems: [
133
+ { label: "Dashboard", href: "/dashboard", sortOrder: 0 },
134
+ { label: "Ship", href: "/ship", sortOrder: 1 },
135
+ { label: "Approvals", href: "/approvals", sortOrder: 2 },
136
+ { label: "Connect", href: "/connect", sortOrder: 3 },
137
+ { label: "Credits", href: "/billing/credits", sortOrder: 4 },
138
+ { label: "Settings", href: "/settings/profile", sortOrder: 5 },
139
+ { label: "Admin", href: "/admin/tenants", sortOrder: 6, requiresRole: "platform_admin" },
140
+ ],
141
+ fleet: {
142
+ containerImage: "ghcr.io/wopr-network/holyship:latest",
143
+ lifecycle: "ephemeral",
144
+ billingModel: "none",
145
+ maxInstances: 50,
146
+ },
147
+ },
148
+ nemoclaw: {
149
+ brandName: "NemoPod",
150
+ productName: "NemoPod",
151
+ tagline: "NVIDIA NeMo, one click away",
152
+ domain: "nemopod.com",
153
+ appDomain: "app.nemopod.com",
154
+ cookieDomain: ".nemopod.com",
155
+ companyLegal: "WOPR Network Inc.",
156
+ priceLabel: "$5 free credits",
157
+ defaultImage: "ghcr.io/wopr-network/nemoclaw:latest",
158
+ emailSupport: "support@nemopod.com",
159
+ emailPrivacy: "privacy@nemopod.com",
160
+ emailLegal: "legal@nemopod.com",
161
+ fromEmail: "noreply@nemopod.com",
162
+ homePath: "/instances",
163
+ storagePrefix: "nemopod",
164
+ navItems: [
165
+ { label: "NemoClaws", href: "/instances", sortOrder: 0 },
166
+ { label: "Credits", href: "/billing/credits", sortOrder: 1 },
167
+ { label: "Settings", href: "/settings/profile", sortOrder: 2 },
168
+ { label: "Admin", href: "/admin/tenants", sortOrder: 3, requiresRole: "platform_admin" },
169
+ ],
170
+ fleet: {
171
+ containerImage: "ghcr.io/wopr-network/nemoclaw:latest",
172
+ lifecycle: "managed",
173
+ billingModel: "monthly",
174
+ maxInstances: 5,
175
+ },
176
+ },
177
+ };
178
+
179
+ async function seed(): Promise<void> {
180
+ const dbUrl = process.env.DATABASE_URL;
181
+ if (!dbUrl) {
182
+ console.error("DATABASE_URL is required");
183
+ process.exit(1);
184
+ }
185
+
186
+ const slugArg = process.argv.find((a) => a.startsWith("--slug="))?.split("=")[1];
187
+ const slugs = slugArg ? [slugArg] : Object.keys(PRESETS);
188
+
189
+ const pool = new pg.Pool({ connectionString: dbUrl });
190
+ const db = drizzle(pool);
191
+
192
+ for (const slug of slugs) {
193
+ const preset = PRESETS[slug];
194
+ if (!preset) {
195
+ console.error(`Unknown product: ${slug}. Valid: ${Object.keys(PRESETS).join(", ")}`);
196
+ continue;
197
+ }
198
+
199
+ console.log(`Seeding ${slug}...`);
200
+ const { navItems, fleet, ...productData } = preset;
201
+
202
+ // Upsert product
203
+ const [product] = await db
204
+ .insert(products)
205
+ .values({ slug, ...productData })
206
+ .onConflictDoUpdate({
207
+ target: products.slug,
208
+ set: { ...productData, updatedAt: new Date() },
209
+ })
210
+ .returning();
211
+
212
+ if (!product) {
213
+ throw new Error(`Failed to upsert product: ${slug}`);
214
+ }
215
+
216
+ // Replace nav items
217
+ await db.delete(productNavItems).where(eq(productNavItems.productId, product.id));
218
+ if (navItems.length > 0) {
219
+ await db.insert(productNavItems).values(
220
+ navItems.map((item) => ({
221
+ productId: product.id,
222
+ label: item.label,
223
+ href: item.href,
224
+ sortOrder: item.sortOrder,
225
+ requiresRole: item.requiresRole ?? null,
226
+ enabled: true,
227
+ })),
228
+ );
229
+ }
230
+
231
+ // Upsert fleet config
232
+ await db
233
+ .insert(productFleetConfig)
234
+ .values({
235
+ productId: product.id,
236
+ containerImage: fleet.containerImage,
237
+ lifecycle: fleet.lifecycle,
238
+ billingModel: fleet.billingModel,
239
+ maxInstances: fleet.maxInstances,
240
+ })
241
+ .onConflictDoUpdate({
242
+ target: productFleetConfig.productId,
243
+ set: {
244
+ containerImage: fleet.containerImage,
245
+ lifecycle: fleet.lifecycle,
246
+ billingModel: fleet.billingModel,
247
+ maxInstances: fleet.maxInstances,
248
+ updatedAt: new Date(),
249
+ },
250
+ });
251
+
252
+ // Upsert default features (no-op if already exists)
253
+ await db
254
+ .insert(productFeatures)
255
+ .values({ productId: product.id })
256
+ .onConflictDoNothing();
257
+
258
+ console.log(` done (${navItems.length} nav items, fleet: ${fleet.lifecycle}/${fleet.billingModel})`);
259
+ }
260
+
261
+ await pool.end();
262
+ console.log("Seed complete.");
263
+ }
264
+
265
+ seed().catch((err: unknown) => {
266
+ console.error("Seed failed:", err);
267
+ process.exit(1);
268
+ });
@@ -77,6 +77,7 @@ async function main(): Promise<void> {
77
77
  oracle,
78
78
  bitcoindUser: BITCOIND_USER,
79
79
  bitcoindPassword: BITCOIND_PASSWORD,
80
+ serviceKey: SERVICE_KEY,
80
81
  log: (msg, meta) => console.log(`[watcher] ${msg}`, meta ?? ""),
81
82
  });
82
83
 
@@ -43,6 +43,8 @@ export interface WatcherServiceOpts {
43
43
  log?: (msg: string, meta?: Record<string, unknown>) => void;
44
44
  /** Allowed callback URL prefixes. Default: ["https://"] — enforces HTTPS. */
45
45
  allowedCallbackPrefixes?: string[];
46
+ /** Service key sent as Bearer token in webhook deliveries. */
47
+ serviceKey?: string;
46
48
  }
47
49
 
48
50
  // --- SSRF validation ---
@@ -79,6 +81,7 @@ async function processDeliveries(
79
81
  db: DrizzleDb,
80
82
  allowedPrefixes: string[],
81
83
  log: (msg: string, meta?: Record<string, unknown>) => void,
84
+ serviceKey?: string,
82
85
  ): Promise<number> {
83
86
  const now = new Date().toISOString();
84
87
  const pending = await db
@@ -103,9 +106,11 @@ async function processDeliveries(
103
106
  }
104
107
 
105
108
  try {
109
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
110
+ if (serviceKey) headers.Authorization = `Bearer ${serviceKey}`;
106
111
  const res = await fetch(row.callbackUrl, {
107
112
  method: "POST",
108
- headers: { "Content-Type": "application/json" },
113
+ headers,
109
114
  body: row.payload,
110
115
  });
111
116
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -242,6 +247,7 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
242
247
  const deliveryMs = opts.deliveryIntervalMs ?? 10_000;
243
248
  const log = opts.log ?? (() => {});
244
249
  const allowedPrefixes = opts.allowedCallbackPrefixes ?? ["https://"];
250
+ const serviceKey = opts.serviceKey;
245
251
  const timers: ReturnType<typeof setInterval>[] = [];
246
252
 
247
253
  const methods = await methodStore.listEnabled();
@@ -500,7 +506,7 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
500
506
  timers.push(
501
507
  setInterval(async () => {
502
508
  try {
503
- const count = await processDeliveries(db, allowedPrefixes, log);
509
+ const count = await processDeliveries(db, allowedPrefixes, log, serviceKey);
504
510
  if (count > 0) log("Webhooks delivered", { count });
505
511
  } catch (err) {
506
512
  log("Delivery loop error", { error: String(err) });
@@ -44,6 +44,8 @@ export * from "./page-contexts.js";
44
44
  export * from "./platform-api-keys.js";
45
45
  export * from "./plugin-configs.js";
46
46
  export * from "./plugin-marketplace-content.js";
47
+ export * from "./product-config.js";
48
+ export * from "./products.js";
47
49
  export * from "./promotion-redemptions.js";
48
50
  export * from "./promotions.js";
49
51
  export * from "./provider-credentials.js";
@@ -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
+ });