@wopr-network/platform-core 1.58.1 → 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 (42) hide show
  1. package/dist/db/schema/index.d.ts +2 -0
  2. package/dist/db/schema/index.js +2 -0
  3. package/dist/db/schema/product-config.d.ts +610 -0
  4. package/dist/db/schema/product-config.js +51 -0
  5. package/dist/db/schema/products.d.ts +565 -0
  6. package/dist/db/schema/products.js +43 -0
  7. package/dist/product-config/boot.d.ts +36 -0
  8. package/dist/product-config/boot.js +30 -0
  9. package/dist/product-config/drizzle-product-config-repository.d.ts +19 -0
  10. package/dist/product-config/drizzle-product-config-repository.js +200 -0
  11. package/dist/product-config/drizzle-product-config-repository.test.d.ts +1 -0
  12. package/dist/product-config/drizzle-product-config-repository.test.js +114 -0
  13. package/dist/product-config/index.d.ts +24 -0
  14. package/dist/product-config/index.js +37 -0
  15. package/dist/product-config/repository-types.d.ts +143 -0
  16. package/dist/product-config/repository-types.js +53 -0
  17. package/dist/product-config/service.d.ts +27 -0
  18. package/dist/product-config/service.js +74 -0
  19. package/dist/product-config/service.test.d.ts +1 -0
  20. package/dist/product-config/service.test.js +107 -0
  21. package/dist/trpc/index.d.ts +1 -0
  22. package/dist/trpc/index.js +1 -0
  23. package/dist/trpc/product-config-router.d.ts +117 -0
  24. package/dist/trpc/product-config-router.js +137 -0
  25. package/docs/specs/2026-03-23-product-config-db-migration-plan.md +2260 -0
  26. package/docs/specs/2026-03-23-product-config-db-migration.md +371 -0
  27. package/drizzle/migrations/0020_product_config_tables.sql +109 -0
  28. package/drizzle/migrations/meta/_journal.json +7 -0
  29. package/package.json +1 -1
  30. package/scripts/seed-products.ts +268 -0
  31. package/src/db/schema/index.ts +2 -0
  32. package/src/db/schema/product-config.ts +56 -0
  33. package/src/db/schema/products.ts +58 -0
  34. package/src/product-config/boot.ts +57 -0
  35. package/src/product-config/drizzle-product-config-repository.test.ts +132 -0
  36. package/src/product-config/drizzle-product-config-repository.ts +229 -0
  37. package/src/product-config/index.ts +62 -0
  38. package/src/product-config/repository-types.ts +222 -0
  39. package/src/product-config/service.test.ts +127 -0
  40. package/src/product-config/service.ts +105 -0
  41. package/src/trpc/index.ts +1 -0
  42. package/src/trpc/product-config-router.ts +161 -0
@@ -0,0 +1,2260 @@
1
+ # Product Config DB Migration — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Move ~46 product-configurable environment variables into database tables, served via tRPC, managed via admin UI.
6
+
7
+ **Architecture:** New Drizzle schema tables in platform-core, product config repository with in-memory cache, tRPC router (public + admin), admin UI pages in platform-ui-core. Existing modules migrate one at a time from `process.env` reads to `getProductConfig()` calls.
8
+
9
+ **Tech Stack:** Drizzle ORM, tRPC, Zod, PGlite (tests), Next.js App Router, shadcn/ui, Biome
10
+
11
+ **Spec:** `docs/specs/2026-03-23-product-config-db-migration.md`
12
+
13
+ ---
14
+
15
+ ## Design Principle: Eliminate Code in Products
16
+
17
+ **The goal is NOT just swapping env vars for DB reads.** The goal is that platform-core does the heavy lifting so product backends shrink dramatically.
18
+
19
+ Before: each product backend has its own `config.ts` (30+ env vars), CORS setup, email config, fleet wiring.
20
+ After: each product backend passes `PRODUCT_SLUG` to `platformBoot()` and platform-core auto-configures everything from DB.
21
+
22
+ ```typescript
23
+ // paperclip-platform/src/index.ts — AFTER (the dream)
24
+ import { platformBoot } from "@wopr-network/platform-core";
25
+
26
+ const app = await platformBoot({
27
+ slug: "paperclip",
28
+ db: getDb(),
29
+ // product-specific route additions only:
30
+ extraRoutes: (hono) => {
31
+ // any paperclip-only routes
32
+ },
33
+ });
34
+ ```
35
+
36
+ ## File Map
37
+
38
+ ### platform-core (new files)
39
+
40
+ | File | Responsibility |
41
+ |------|---------------|
42
+ | `src/db/schema/products.ts` | Drizzle schema: products, product_nav_items, product_domains |
43
+ | `src/db/schema/product-config.ts` | Drizzle schema: product_features, product_fleet_config, product_billing_config |
44
+ | `src/product-config/repository-types.ts` | Plain TS interfaces: `IProductConfigRepository`, domain objects (no Drizzle types) |
45
+ | `src/product-config/drizzle-product-config-repository.ts` | Drizzle implementation of `IProductConfigRepository` |
46
+ | `src/product-config/drizzle-product-config-repository.test.ts` | Repository tests (PGlite) |
47
+ | `src/product-config/cache.ts` | In-memory cache with TTL + invalidation |
48
+ | `src/product-config/cache.test.ts` | Cache tests |
49
+ | `src/product-config/index.ts` | Public API: `getProductConfig()`, `getProductBrand()`, `deriveCorsOrigins()` |
50
+ | `src/trpc/product-config-router.ts` | tRPC router: public + admin endpoints |
51
+ | `src/trpc/product-config-router.test.ts` | Router tests |
52
+ | `scripts/seed-products.ts` | One-time seed script to populate tables from current env vars |
53
+
54
+ ### platform-core (modified files)
55
+
56
+ | File | Change |
57
+ |------|--------|
58
+ | `src/db/schema/index.ts` | Export new schema tables |
59
+ | `src/trpc/index.ts` | Export product config router |
60
+ | `drizzle/migrations/XXXX_*.sql` | Generated migration |
61
+
62
+ ### platform-ui-core (new files)
63
+
64
+ | File | Responsibility |
65
+ |------|---------------|
66
+ | `src/app/admin/products/page.tsx` | Admin product config page (tabs per domain) |
67
+ | `src/app/admin/products/loading.tsx` | Loading skeleton |
68
+ | `src/app/admin/products/error.tsx` | Error boundary |
69
+ | `src/components/admin/products/brand-form.tsx` | Brand identity form |
70
+ | `src/components/admin/products/nav-editor.tsx` | Navigation item editor (reorder, toggle, add/remove) |
71
+ | `src/components/admin/products/features-form.tsx` | Feature flags form |
72
+ | `src/components/admin/products/fleet-form.tsx` | Fleet config form |
73
+ | `src/components/admin/products/billing-form.tsx` | Billing config form |
74
+
75
+ ### platform-ui-core (modified files)
76
+
77
+ | File | Change |
78
+ |------|--------|
79
+ | `src/lib/brand-config.ts` | Add `initBrandConfig()` that fetches from tRPC |
80
+ | `src/components/sidebar.tsx` | Read nav items from brand config (already does, but confirm) |
81
+
82
+ ---
83
+
84
+ ## Phase 1: Schema + Repository
85
+
86
+ ### Task 1: Product Schema Tables
87
+
88
+ **Files:**
89
+ - Create: `src/db/schema/products.ts`
90
+ - Create: `src/db/schema/product-config.ts`
91
+ - Modify: `src/db/schema/index.ts`
92
+
93
+ - [ ] **Step 1: Write products schema**
94
+
95
+ ```typescript
96
+ // src/db/schema/products.ts
97
+ import { index, pgTable, text, timestamp, unique, uuid } from "drizzle-orm/pg-core";
98
+
99
+ export const products = pgTable(
100
+ "products",
101
+ {
102
+ id: uuid("id").primaryKey().defaultRandom(),
103
+ slug: text("slug").notNull(),
104
+ brandName: text("brand_name").notNull(),
105
+ productName: text("product_name").notNull(),
106
+ tagline: text("tagline").notNull().default(""),
107
+ domain: text("domain").notNull(),
108
+ appDomain: text("app_domain").notNull(),
109
+ cookieDomain: text("cookie_domain").notNull(),
110
+ companyLegal: text("company_legal").notNull().default(""),
111
+ priceLabel: text("price_label").notNull().default(""),
112
+ defaultImage: text("default_image").notNull().default(""),
113
+ emailSupport: text("email_support").notNull().default(""),
114
+ emailPrivacy: text("email_privacy").notNull().default(""),
115
+ emailLegal: text("email_legal").notNull().default(""),
116
+ fromEmail: text("from_email").notNull().default(""),
117
+ homePath: text("home_path").notNull().default("/marketplace"),
118
+ storagePrefix: text("storage_prefix").notNull(),
119
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
120
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
121
+ },
122
+ (t) => [unique("products_slug_uniq").on(t.slug)],
123
+ );
124
+
125
+ export const productNavItems = pgTable(
126
+ "product_nav_items",
127
+ {
128
+ id: uuid("id").primaryKey().defaultRandom(),
129
+ productId: uuid("product_id")
130
+ .notNull()
131
+ .references(() => products.id, { onDelete: "cascade" }),
132
+ label: text("label").notNull(),
133
+ href: text("href").notNull(),
134
+ icon: text("icon"),
135
+ sortOrder: uuid("sort_order").notNull().$type<number>(),
136
+ requiresRole: text("requires_role"),
137
+ enabled: text("enabled").notNull().default("true").$type<"true" | "false">(),
138
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
139
+ },
140
+ (t) => [index("idx_product_nav_items_product").on(t.productId, t.sortOrder)],
141
+ );
142
+
143
+ export const productDomains = pgTable(
144
+ "product_domains",
145
+ {
146
+ id: uuid("id").primaryKey().defaultRandom(),
147
+ productId: uuid("product_id")
148
+ .notNull()
149
+ .references(() => products.id, { onDelete: "cascade" }),
150
+ host: text("host").notNull(),
151
+ role: text("role").notNull().default("canonical"),
152
+ },
153
+ (t) => [unique("product_domains_product_host_uniq").on(t.productId, t.host)],
154
+ );
155
+ ```
156
+
157
+ - [ ] **Step 2: Write product config schema**
158
+
159
+ ```typescript
160
+ // src/db/schema/product-config.ts
161
+ import { boolean, integer, jsonb, numeric, pgEnum, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
162
+ import { products } from "./products.js";
163
+
164
+ export const fleetLifecycleEnum = pgEnum("fleet_lifecycle", ["managed", "ephemeral"]);
165
+ export const fleetBillingModelEnum = pgEnum("fleet_billing_model", ["monthly", "per_use", "none"]);
166
+
167
+ export const productFeatures = pgTable("product_features", {
168
+ productId: uuid("product_id")
169
+ .primaryKey()
170
+ .references(() => products.id, { onDelete: "cascade" }),
171
+ chatEnabled: boolean("chat_enabled").notNull().default(true),
172
+ onboardingEnabled: boolean("onboarding_enabled").notNull().default(true),
173
+ onboardingDefaultModel: text("onboarding_default_model"),
174
+ onboardingSystemPrompt: text("onboarding_system_prompt"),
175
+ onboardingMaxCredits: integer("onboarding_max_credits").notNull().default(100),
176
+ onboardingWelcomeMsg: text("onboarding_welcome_msg"),
177
+ sharedModuleBilling: boolean("shared_module_billing").notNull().default(true),
178
+ sharedModuleMonitoring: boolean("shared_module_monitoring").notNull().default(true),
179
+ sharedModuleAnalytics: boolean("shared_module_analytics").notNull().default(true),
180
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
181
+ });
182
+
183
+ export const productFleetConfig = pgTable("product_fleet_config", {
184
+ productId: uuid("product_id")
185
+ .primaryKey()
186
+ .references(() => products.id, { onDelete: "cascade" }),
187
+ containerImage: text("container_image").notNull(),
188
+ containerPort: integer("container_port").notNull().default(3100),
189
+ lifecycle: fleetLifecycleEnum("lifecycle").notNull().default("managed"),
190
+ billingModel: fleetBillingModelEnum("billing_model").notNull().default("monthly"),
191
+ maxInstances: integer("max_instances").notNull().default(5),
192
+ imageAllowlist: text("image_allowlist").array(),
193
+ dockerNetwork: text("docker_network").notNull().default(""),
194
+ placementStrategy: text("placement_strategy").notNull().default("least-loaded"),
195
+ fleetDataDir: text("fleet_data_dir").notNull().default("/data/fleet"),
196
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
197
+ });
198
+
199
+ export const productBillingConfig = pgTable("product_billing_config", {
200
+ productId: uuid("product_id")
201
+ .primaryKey()
202
+ .references(() => products.id, { onDelete: "cascade" }),
203
+ stripePublishableKey: text("stripe_publishable_key"),
204
+ stripeSecretKey: text("stripe_secret_key"),
205
+ stripeWebhookSecret: text("stripe_webhook_secret"),
206
+ creditPrices: jsonb("credit_prices").notNull().default({}),
207
+ affiliateBaseUrl: text("affiliate_base_url"),
208
+ affiliateMatchRate: numeric("affiliate_match_rate").notNull().default("1.0"),
209
+ affiliateMaxCap: integer("affiliate_max_cap").notNull().default(20000),
210
+ dividendRate: numeric("dividend_rate").notNull().default("1.0"),
211
+ marginConfig: jsonb("margin_config"),
212
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
213
+ });
214
+ ```
215
+
216
+ - [ ] **Step 3: Export from schema index**
217
+
218
+ Add to `src/db/schema/index.ts`:
219
+
220
+ ```typescript
221
+ export * from "./products.js";
222
+ export * from "./product-config.js";
223
+ ```
224
+
225
+ - [ ] **Step 4: Generate migration**
226
+
227
+ Run: `npx drizzle-kit generate`
228
+ Expected: New migration file in `drizzle/migrations/`
229
+
230
+ - [ ] **Step 5: Verify migration applies**
231
+
232
+ Run: `npx vitest run src/product-config/repository.test.ts` (will create in next task — for now just verify the schema compiles)
233
+ Run: `npm run check`
234
+ Expected: No type errors
235
+
236
+ - [ ] **Step 6: Commit**
237
+
238
+ ```bash
239
+ git add src/db/schema/products.ts src/db/schema/product-config.ts src/db/schema/index.ts drizzle/migrations/
240
+ git commit -m "feat: add product config Drizzle schema tables"
241
+ ```
242
+
243
+ ---
244
+
245
+ ### Task 2: Repository Types (IProductConfigRepository)
246
+
247
+ **Files:**
248
+ - Create: `src/product-config/repository-types.ts`
249
+
250
+ - [ ] **Step 1: Write plain TS interfaces (no Drizzle types)**
251
+
252
+ Following the `fleet/repository-types.ts` pattern: plain domain objects + repository interface.
253
+
254
+ ```typescript
255
+ // src/product-config/repository-types.ts
256
+ //
257
+ // Plain TypeScript interfaces for product configuration domain.
258
+ // No Drizzle types. These are the contract all consumers work against.
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Product
262
+ // ---------------------------------------------------------------------------
263
+
264
+ /** Plain domain object representing a product — mirrors `products` table. */
265
+ export interface Product {
266
+ id: string;
267
+ slug: string;
268
+ brandName: string;
269
+ productName: string;
270
+ tagline: string;
271
+ domain: string;
272
+ appDomain: string;
273
+ cookieDomain: string;
274
+ companyLegal: string;
275
+ priceLabel: string;
276
+ defaultImage: string;
277
+ emailSupport: string;
278
+ emailPrivacy: string;
279
+ emailLegal: string;
280
+ fromEmail: string;
281
+ homePath: string;
282
+ storagePrefix: string;
283
+ createdAt: Date;
284
+ updatedAt: Date;
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // ProductNavItem
289
+ // ---------------------------------------------------------------------------
290
+
291
+ export interface ProductNavItem {
292
+ id: string;
293
+ productId: string;
294
+ label: string;
295
+ href: string;
296
+ icon: string | null;
297
+ sortOrder: number;
298
+ requiresRole: string | null;
299
+ enabled: boolean;
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // ProductDomain
304
+ // ---------------------------------------------------------------------------
305
+
306
+ export interface ProductDomain {
307
+ id: string;
308
+ productId: string;
309
+ host: string;
310
+ role: "canonical" | "redirect";
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // ProductFeatures
315
+ // ---------------------------------------------------------------------------
316
+
317
+ export interface ProductFeatures {
318
+ productId: string;
319
+ chatEnabled: boolean;
320
+ onboardingEnabled: boolean;
321
+ onboardingDefaultModel: string | null;
322
+ onboardingSystemPrompt: string | null;
323
+ onboardingMaxCredits: number;
324
+ onboardingWelcomeMsg: string | null;
325
+ sharedModuleBilling: boolean;
326
+ sharedModuleMonitoring: boolean;
327
+ sharedModuleAnalytics: boolean;
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // ProductFleetConfig
332
+ // ---------------------------------------------------------------------------
333
+
334
+ export type FleetLifecycle = "managed" | "ephemeral";
335
+ export type FleetBillingModel = "monthly" | "per_use" | "none";
336
+
337
+ export interface ProductFleetConfig {
338
+ productId: string;
339
+ containerImage: string;
340
+ containerPort: number;
341
+ lifecycle: FleetLifecycle;
342
+ billingModel: FleetBillingModel;
343
+ maxInstances: number;
344
+ imageAllowlist: string[] | null;
345
+ dockerNetwork: string;
346
+ placementStrategy: string;
347
+ fleetDataDir: string;
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // ProductBillingConfig
352
+ // ---------------------------------------------------------------------------
353
+
354
+ export interface ProductBillingConfig {
355
+ productId: string;
356
+ stripePublishableKey: string | null;
357
+ stripeSecretKey: string | null;
358
+ stripeWebhookSecret: string | null;
359
+ creditPrices: Record<string, number>;
360
+ affiliateBaseUrl: string | null;
361
+ affiliateMatchRate: number;
362
+ affiliateMaxCap: number;
363
+ dividendRate: number;
364
+ marginConfig: unknown;
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Aggregates
369
+ // ---------------------------------------------------------------------------
370
+
371
+ /** Full product config resolved from all tables. */
372
+ export interface ProductConfig {
373
+ product: Product;
374
+ navItems: ProductNavItem[];
375
+ domains: ProductDomain[];
376
+ features: ProductFeatures | null;
377
+ fleet: ProductFleetConfig | null;
378
+ billing: ProductBillingConfig | null;
379
+ }
380
+
381
+ /** Brand config shape served to UI (matches BrandConfig in platform-ui-core). */
382
+ export interface ProductBrandConfig {
383
+ productName: string;
384
+ brandName: string;
385
+ domain: string;
386
+ appDomain: string;
387
+ tagline: string;
388
+ emails: { privacy: string; legal: string; support: string };
389
+ defaultImage: string;
390
+ storagePrefix: string;
391
+ companyLegalName: string;
392
+ price: string;
393
+ homePath: string;
394
+ chatEnabled: boolean;
395
+ navItems: Array<{ label: string; href: string }>;
396
+ domains?: Array<{ host: string; role: string }>;
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Repository Interface
401
+ // ---------------------------------------------------------------------------
402
+
403
+ /** Upsert payload for product brand fields. */
404
+ export type ProductBrandUpdate = Partial<Omit<Product, "id" | "slug" | "createdAt" | "updatedAt">>;
405
+
406
+ /** Upsert payload for a nav item (no id — replaced in bulk). */
407
+ export interface NavItemInput {
408
+ label: string;
409
+ href: string;
410
+ icon?: string;
411
+ sortOrder: number;
412
+ requiresRole?: string;
413
+ enabled?: boolean;
414
+ }
415
+
416
+ export interface IProductConfigRepository {
417
+ getBySlug(slug: string): Promise<ProductConfig | null>;
418
+ listAll(): Promise<ProductConfig[]>;
419
+ upsertProduct(slug: string, data: ProductBrandUpdate): Promise<Product>;
420
+ replaceNavItems(productId: string, items: NavItemInput[]): Promise<void>;
421
+ upsertFeatures(productId: string, data: Partial<ProductFeatures>): Promise<void>;
422
+ upsertFleetConfig(productId: string, data: Partial<ProductFleetConfig>): Promise<void>;
423
+ upsertBillingConfig(productId: string, data: Partial<ProductBillingConfig>): Promise<void>;
424
+ }
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // Helpers
428
+ // ---------------------------------------------------------------------------
429
+
430
+ /** Derive CORS origins from product config. */
431
+ export function deriveCorsOrigins(product: Product, domains: ProductDomain[]): string[] {
432
+ const origins = new Set<string>();
433
+ origins.add(`https://${product.domain}`);
434
+ origins.add(`https://${product.appDomain}`);
435
+ for (const d of domains) {
436
+ origins.add(`https://${d.host}`);
437
+ }
438
+ return [...origins];
439
+ }
440
+
441
+ /** Derive brand config for UI from full product config. */
442
+ export function toBrandConfig(config: ProductConfig): ProductBrandConfig {
443
+ const { product, navItems, domains, features } = config;
444
+ return {
445
+ productName: product.productName,
446
+ brandName: product.brandName,
447
+ domain: product.domain,
448
+ appDomain: product.appDomain,
449
+ tagline: product.tagline,
450
+ emails: {
451
+ privacy: product.emailPrivacy,
452
+ legal: product.emailLegal,
453
+ support: product.emailSupport,
454
+ },
455
+ defaultImage: product.defaultImage,
456
+ storagePrefix: product.storagePrefix,
457
+ companyLegalName: product.companyLegal,
458
+ price: product.priceLabel,
459
+ homePath: product.homePath,
460
+ chatEnabled: features?.chatEnabled ?? true,
461
+ navItems: navItems
462
+ .filter((n) => n.enabled)
463
+ .map((n) => ({ label: n.label, href: n.href })),
464
+ domains: domains.length > 0 ? domains.map((d) => ({ host: d.host, role: d.role })) : undefined,
465
+ };
466
+ }
467
+ ```
468
+
469
+ - [ ] **Step 2: Commit**
470
+
471
+ ```bash
472
+ git add src/product-config/repository-types.ts
473
+ git commit -m "feat: add product config repository types (IProductConfigRepository)"
474
+ ```
475
+
476
+ ---
477
+
478
+ ### Task 3: Drizzle Product Config Repository
479
+
480
+ **Files:**
481
+ - Create: `src/product-config/drizzle-product-config-repository.ts`
482
+ - Create: `src/product-config/drizzle-product-config-repository.test.ts`
483
+
484
+ - [ ] **Step 1: Write failing test — getBySlug**
485
+
486
+ ```typescript
487
+ // src/product-config/drizzle-product-config-repository.test.ts
488
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
489
+ import type { PGlite } from "@electric-sql/pglite";
490
+ import { createTestDb } from "../test/db.js";
491
+ import { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
492
+ import { products } from "../db/schema/products.js";
493
+ import type { DrizzleDb } from "../db/index.js";
494
+
495
+ describe("DrizzleProductConfigRepository", () => {
496
+ let db: DrizzleDb;
497
+ let pool: PGlite;
498
+ let repo: ProductConfigRepository;
499
+
500
+ beforeAll(async () => {
501
+ const result = await createTestDb();
502
+ db = result.db;
503
+ pool = result.pool;
504
+ repo = new DrizzleProductConfigRepository(db);
505
+ });
506
+
507
+ afterAll(async () => {
508
+ await pool.close();
509
+ });
510
+
511
+ it("returns null for unknown slug", async () => {
512
+ const result = await repo.getBySlug("nonexistent");
513
+ expect(result).toBeNull();
514
+ });
515
+
516
+ it("returns full config for seeded product", async () => {
517
+ // Seed a product
518
+ const [inserted] = await db
519
+ .insert(products)
520
+ .values({
521
+ slug: "test-product",
522
+ brandName: "Test",
523
+ productName: "Test Product",
524
+ domain: "test.com",
525
+ appDomain: "app.test.com",
526
+ cookieDomain: ".test.com",
527
+ storagePrefix: "test",
528
+ })
529
+ .returning();
530
+
531
+ const config = await repo.getBySlug("test-product");
532
+ expect(config).not.toBeNull();
533
+ expect(config!.product.slug).toBe("test-product");
534
+ expect(config!.product.brandName).toBe("Test");
535
+ expect(config!.navItems).toEqual([]);
536
+ expect(config!.domains).toEqual([]);
537
+ expect(config!.features).toBeNull();
538
+ expect(config!.fleet).toBeNull();
539
+ expect(config!.billing).toBeNull();
540
+ });
541
+
542
+ it("returns nav items sorted by sortOrder", async () => {
543
+ const [product] = await db
544
+ .insert(products)
545
+ .values({
546
+ slug: "nav-test",
547
+ brandName: "Nav",
548
+ productName: "Nav Test",
549
+ domain: "nav.com",
550
+ appDomain: "app.nav.com",
551
+ cookieDomain: ".nav.com",
552
+ storagePrefix: "nav",
553
+ })
554
+ .returning();
555
+
556
+ const { productNavItems } = await import("../db/schema/products.js");
557
+ await db.insert(productNavItems).values([
558
+ { productId: product.id, label: "Second", href: "/second", sortOrder: 2 },
559
+ { productId: product.id, label: "First", href: "/first", sortOrder: 1 },
560
+ ]);
561
+
562
+ const config = await repo.getBySlug("nav-test");
563
+ expect(config!.navItems).toHaveLength(2);
564
+ expect(config!.navItems[0].label).toBe("First");
565
+ expect(config!.navItems[1].label).toBe("Second");
566
+ });
567
+ });
568
+ ```
569
+
570
+ - [ ] **Step 2: Run test to verify it fails**
571
+
572
+ Run: `npx vitest run src/product-config/repository.test.ts`
573
+ Expected: FAIL — module `./repository.js` not found
574
+
575
+ - [ ] **Step 3: Write Drizzle repository implementation**
576
+
577
+ ```typescript
578
+ // src/product-config/drizzle-product-config-repository.ts
579
+ import { eq, asc } from "drizzle-orm";
580
+ import type { DrizzleDb } from "../db/index.js";
581
+ import { products, productNavItems, productDomains } from "../db/schema/products.js";
582
+ import {
583
+ productFeatures,
584
+ productFleetConfig,
585
+ productBillingConfig,
586
+ } from "../db/schema/product-config.js";
587
+ import type { IProductConfigRepository, ProductConfig, ProductBrandUpdate, NavItemInput } from "./repository-types.js";
588
+
589
+ export class DrizzleProductConfigRepository implements IProductConfigRepository {
590
+ constructor(private db: DrizzleDb) {}
591
+
592
+ async getBySlug(slug: string): Promise<ProductConfig | null> {
593
+ const [product] = await this.db
594
+ .select()
595
+ .from(products)
596
+ .where(eq(products.slug, slug))
597
+ .limit(1);
598
+
599
+ if (!product) return null;
600
+
601
+ const [navItems, domains, features, fleet, billing] = await Promise.all([
602
+ this.db
603
+ .select()
604
+ .from(productNavItems)
605
+ .where(eq(productNavItems.productId, product.id))
606
+ .orderBy(asc(productNavItems.sortOrder)),
607
+ this.db
608
+ .select()
609
+ .from(productDomains)
610
+ .where(eq(productDomains.productId, product.id)),
611
+ this.db
612
+ .select()
613
+ .from(productFeatures)
614
+ .where(eq(productFeatures.productId, product.id))
615
+ .limit(1)
616
+ .then((rows) => rows[0] ?? null),
617
+ this.db
618
+ .select()
619
+ .from(productFleetConfig)
620
+ .where(eq(productFleetConfig.productId, product.id))
621
+ .limit(1)
622
+ .then((rows) => rows[0] ?? null),
623
+ this.db
624
+ .select()
625
+ .from(productBillingConfig)
626
+ .where(eq(productBillingConfig.productId, product.id))
627
+ .limit(1)
628
+ .then((rows) => rows[0] ?? null),
629
+ ]);
630
+
631
+ return { product, navItems, domains, features, fleet, billing };
632
+ }
633
+
634
+ async listAll(): Promise<ProductConfig[]> {
635
+ const allProducts = await this.db.select().from(products);
636
+ return Promise.all(allProducts.map((p) => this.getBySlug(p.slug).then((c) => c!)));
637
+ }
638
+
639
+ async upsertProduct(
640
+ slug: string,
641
+ data: Partial<typeof products.$inferInsert>,
642
+ ): Promise<typeof products.$inferSelect> {
643
+ const [result] = await this.db
644
+ .insert(products)
645
+ .values({ slug, ...data } as typeof products.$inferInsert)
646
+ .onConflictDoUpdate({
647
+ target: products.slug,
648
+ set: { ...data, updatedAt: new Date() },
649
+ })
650
+ .returning();
651
+ return result;
652
+ }
653
+
654
+ async replaceNavItems(
655
+ productId: string,
656
+ items: Array<{ label: string; href: string; icon?: string; sortOrder: number; requiresRole?: string; enabled?: boolean }>,
657
+ ): Promise<void> {
658
+ await this.db.delete(productNavItems).where(eq(productNavItems.productId, productId));
659
+ if (items.length > 0) {
660
+ await this.db.insert(productNavItems).values(
661
+ items.map((item) => ({
662
+ productId,
663
+ label: item.label,
664
+ href: item.href,
665
+ icon: item.icon ?? null,
666
+ sortOrder: item.sortOrder,
667
+ requiresRole: item.requiresRole ?? null,
668
+ enabled: item.enabled === false ? "false" : "true",
669
+ })),
670
+ );
671
+ }
672
+ }
673
+
674
+ async upsertFeatures(
675
+ productId: string,
676
+ data: Partial<typeof productFeatures.$inferInsert>,
677
+ ): Promise<void> {
678
+ await this.db
679
+ .insert(productFeatures)
680
+ .values({ productId, ...data } as typeof productFeatures.$inferInsert)
681
+ .onConflictDoUpdate({
682
+ target: productFeatures.productId,
683
+ set: { ...data, updatedAt: new Date() },
684
+ });
685
+ }
686
+
687
+ async upsertFleetConfig(
688
+ productId: string,
689
+ data: Partial<typeof productFleetConfig.$inferInsert>,
690
+ ): Promise<void> {
691
+ await this.db
692
+ .insert(productFleetConfig)
693
+ .values({ productId, ...data } as typeof productFleetConfig.$inferInsert)
694
+ .onConflictDoUpdate({
695
+ target: productFleetConfig.productId,
696
+ set: { ...data, updatedAt: new Date() },
697
+ });
698
+ }
699
+
700
+ async upsertBillingConfig(
701
+ productId: string,
702
+ data: Partial<typeof productBillingConfig.$inferInsert>,
703
+ ): Promise<void> {
704
+ await this.db
705
+ .insert(productBillingConfig)
706
+ .values({ productId, ...data } as typeof productBillingConfig.$inferInsert)
707
+ .onConflictDoUpdate({
708
+ target: productBillingConfig.productId,
709
+ set: { ...data, updatedAt: new Date() },
710
+ });
711
+ }
712
+ }
713
+ ```
714
+
715
+ - [ ] **Step 4: Run tests**
716
+
717
+ Run: `npx vitest run src/product-config/repository.test.ts`
718
+ Expected: PASS
719
+
720
+ - [ ] **Step 5: Commit**
721
+
722
+ ```bash
723
+ git add src/product-config/
724
+ git commit -m "feat: add product config repository with tests"
725
+ ```
726
+
727
+ ---
728
+
729
+ ### Task 4: In-Memory Cache
730
+
731
+ **Files:**
732
+ - Create: `src/product-config/cache.ts`
733
+ - Create: `src/product-config/cache.test.ts`
734
+
735
+ - [ ] **Step 1: Write failing test**
736
+
737
+ ```typescript
738
+ // src/product-config/cache.test.ts
739
+ import { describe, it, expect, vi, beforeEach } from "vitest";
740
+ import { ProductConfigCache } from "./cache.js";
741
+ import type { ProductConfig } from "./types.js";
742
+
743
+ const mockConfig: ProductConfig = {
744
+ product: {
745
+ id: "test-id",
746
+ slug: "test",
747
+ brandName: "Test",
748
+ productName: "Test",
749
+ tagline: "",
750
+ domain: "test.com",
751
+ appDomain: "app.test.com",
752
+ cookieDomain: ".test.com",
753
+ companyLegal: "",
754
+ priceLabel: "",
755
+ defaultImage: "",
756
+ emailSupport: "",
757
+ emailPrivacy: "",
758
+ emailLegal: "",
759
+ fromEmail: "",
760
+ homePath: "/dashboard",
761
+ storagePrefix: "test",
762
+ createdAt: new Date(),
763
+ updatedAt: new Date(),
764
+ },
765
+ navItems: [],
766
+ domains: [],
767
+ features: null,
768
+ fleet: null,
769
+ billing: null,
770
+ };
771
+
772
+ describe("ProductConfigCache", () => {
773
+ let fetcher: ReturnType<typeof vi.fn>;
774
+ let cache: ProductConfigCache;
775
+
776
+ beforeEach(() => {
777
+ fetcher = vi.fn().mockResolvedValue(mockConfig);
778
+ cache = new ProductConfigCache(fetcher, { ttlMs: 100 });
779
+ });
780
+
781
+ it("calls fetcher on first get", async () => {
782
+ const result = await cache.get("test");
783
+ expect(result).toEqual(mockConfig);
784
+ expect(fetcher).toHaveBeenCalledOnce();
785
+ });
786
+
787
+ it("returns cached value on second get", async () => {
788
+ await cache.get("test");
789
+ await cache.get("test");
790
+ expect(fetcher).toHaveBeenCalledOnce();
791
+ });
792
+
793
+ it("refetches after TTL expires", async () => {
794
+ await cache.get("test");
795
+ await new Promise((r) => setTimeout(r, 150));
796
+ await cache.get("test");
797
+ expect(fetcher).toHaveBeenCalledTimes(2);
798
+ });
799
+
800
+ it("invalidate forces refetch", async () => {
801
+ await cache.get("test");
802
+ cache.invalidate("test");
803
+ await cache.get("test");
804
+ expect(fetcher).toHaveBeenCalledTimes(2);
805
+ });
806
+ });
807
+ ```
808
+
809
+ - [ ] **Step 2: Run test to verify it fails**
810
+
811
+ Run: `npx vitest run src/product-config/cache.test.ts`
812
+ Expected: FAIL
813
+
814
+ - [ ] **Step 3: Write cache implementation**
815
+
816
+ ```typescript
817
+ // src/product-config/cache.ts
818
+ import type { ProductConfig } from "./types.js";
819
+
820
+ interface CacheEntry {
821
+ config: ProductConfig;
822
+ expiresAt: number;
823
+ }
824
+
825
+ export class ProductConfigCache {
826
+ private cache = new Map<string, CacheEntry>();
827
+ private ttlMs: number;
828
+ private fetcher: (slug: string) => Promise<ProductConfig | null>;
829
+
830
+ constructor(
831
+ fetcher: (slug: string) => Promise<ProductConfig | null>,
832
+ opts: { ttlMs?: number } = {},
833
+ ) {
834
+ this.fetcher = fetcher;
835
+ this.ttlMs = opts.ttlMs ?? 60_000;
836
+ }
837
+
838
+ async get(slug: string): Promise<ProductConfig | null> {
839
+ const entry = this.cache.get(slug);
840
+ if (entry && Date.now() < entry.expiresAt) {
841
+ return entry.config;
842
+ }
843
+ const config = await this.fetcher(slug);
844
+ if (config) {
845
+ this.cache.set(slug, { config, expiresAt: Date.now() + this.ttlMs });
846
+ }
847
+ return config;
848
+ }
849
+
850
+ invalidate(slug: string): void {
851
+ this.cache.delete(slug);
852
+ }
853
+
854
+ invalidateAll(): void {
855
+ this.cache.clear();
856
+ }
857
+ }
858
+ ```
859
+
860
+ - [ ] **Step 4: Run tests**
861
+
862
+ Run: `npx vitest run src/product-config/cache.test.ts`
863
+ Expected: PASS
864
+
865
+ - [ ] **Step 5: Commit**
866
+
867
+ ```bash
868
+ git add src/product-config/cache.ts src/product-config/cache.test.ts
869
+ git commit -m "feat: add product config in-memory cache with TTL"
870
+ ```
871
+
872
+ ---
873
+
874
+ ### Task 5: Public API (index.ts)
875
+
876
+ **Files:**
877
+ - Create: `src/product-config/index.ts`
878
+
879
+ - [ ] **Step 1: Write public API module**
880
+
881
+ ```typescript
882
+ // src/product-config/index.ts
883
+ import type { DrizzleDb } from "../db/index.js";
884
+ import { ProductConfigCache } from "./cache.js";
885
+ import { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
886
+ import type { IProductConfigRepository, ProductBrandConfig, ProductConfig } from "./repository-types.js";
887
+ import { deriveCorsOrigins, toBrandConfig } from "./repository-types.js";
888
+
889
+ export type { ProductConfig, ProductBrandConfig, IProductConfigRepository, ProductFeatures, ProductFleetConfig, ProductBillingConfig, FleetLifecycle, FleetBillingModel } from "./repository-types.js";
890
+ export { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
891
+ export { ProductConfigCache } from "./cache.js";
892
+ export { deriveCorsOrigins, toBrandConfig } from "./repository-types.js";
893
+
894
+ let _repo: IProductConfigRepository | null = null;
895
+ let _cache: ProductConfigCache | null = null;
896
+
897
+ /** Initialize the product config system. Call once at startup. */
898
+ export function initProductConfig(db: DrizzleDb): void {
899
+ _repo = new DrizzleProductConfigRepository(db);
900
+ _cache = new ProductConfigCache((slug) => _repo!.getBySlug(slug));
901
+ }
902
+
903
+ /** Initialize with a custom repository (for testing or alternative backends). */
904
+ export function initProductConfigWithRepo(repo: IProductConfigRepository): void {
905
+ _repo = repo;
906
+ _cache = new ProductConfigCache((slug) => _repo!.getBySlug(slug));
907
+ }
908
+
909
+ /** Get full product config by slug. Cached. */
910
+ export async function getProductConfig(slug: string): Promise<ProductConfig | null> {
911
+ if (!_cache) throw new Error("Product config not initialized. Call initProductConfig() first.");
912
+ return _cache.get(slug);
913
+ }
914
+
915
+ /** Get brand config formatted for UI consumption. */
916
+ export async function getProductBrand(slug: string): Promise<ProductBrandConfig | null> {
917
+ const config = await getProductConfig(slug);
918
+ if (!config) return null;
919
+ return toBrandConfig(config);
920
+ }
921
+
922
+ /** Get the repository for admin mutations. */
923
+ export function getProductConfigRepo(): IProductConfigRepository {
924
+ if (!_repo) throw new Error("Product config not initialized.");
925
+ return _repo;
926
+ }
927
+
928
+ /** Invalidate cache for a product (call after admin mutations). */
929
+ export function invalidateProductConfig(slug: string): void {
930
+ _cache?.invalidate(slug);
931
+ }
932
+ ```
933
+
934
+ - [ ] **Step 2: Commit**
935
+
936
+ ```bash
937
+ git add src/product-config/index.ts
938
+ git commit -m "feat: add product config public API with cache"
939
+ ```
940
+
941
+ ---
942
+
943
+ ## Phase 2: tRPC Router
944
+
945
+ ### Task 6: Product Config tRPC Router
946
+
947
+ **Files:**
948
+ - Create: `src/trpc/product-config-router.ts`
949
+ - Modify: `src/trpc/index.ts`
950
+
951
+ - [ ] **Step 1: Write the router**
952
+
953
+ ```typescript
954
+ // src/trpc/product-config-router.ts
955
+ import { z } from "zod";
956
+ import { adminProcedure, publicProcedure, router } from "./init.js";
957
+ import type { IProductConfigRepository } from "../product-config/repository-types.js";
958
+ import type { ProductConfigCache } from "../product-config/cache.js";
959
+ import { getProductBrand } from "../product-config/index.js";
960
+
961
+ export function createProductConfigRouter(
962
+ getRepo: () => IProductConfigRepository,
963
+ getCache: () => ProductConfigCache,
964
+ productSlug: string,
965
+ ) {
966
+ return router({
967
+ // --- Public ---
968
+ getBrandConfig: publicProcedure.query(async () => {
969
+ return getProductBrand(productSlug);
970
+ }),
971
+
972
+ getNavItems: publicProcedure.query(async () => {
973
+ const brand = await getProductBrand(productSlug);
974
+ return brand?.navItems ?? [];
975
+ }),
976
+
977
+ // --- Admin ---
978
+ admin: router({
979
+ get: adminProcedure.query(async () => {
980
+ return getRepo().getBySlug(productSlug);
981
+ }),
982
+
983
+ listAll: adminProcedure.query(async () => {
984
+ return getRepo().listAll();
985
+ }),
986
+
987
+ updateBrand: adminProcedure
988
+ .input(
989
+ z.object({
990
+ brandName: z.string().min(1).optional(),
991
+ productName: z.string().min(1).optional(),
992
+ tagline: z.string().optional(),
993
+ domain: z.string().min(1).optional(),
994
+ appDomain: z.string().min(1).optional(),
995
+ cookieDomain: z.string().optional(),
996
+ companyLegal: z.string().optional(),
997
+ priceLabel: z.string().optional(),
998
+ defaultImage: z.string().optional(),
999
+ emailSupport: z.string().email().optional(),
1000
+ emailPrivacy: z.string().email().optional(),
1001
+ emailLegal: z.string().email().optional(),
1002
+ fromEmail: z.string().email().optional(),
1003
+ homePath: z.string().optional(),
1004
+ storagePrefix: z.string().min(1).optional(),
1005
+ }),
1006
+ )
1007
+ .mutation(async ({ input }) => {
1008
+ const result = await getRepo().upsertProduct(productSlug, input);
1009
+ getCache().invalidate(productSlug);
1010
+ return result;
1011
+ }),
1012
+
1013
+ updateNavItems: adminProcedure
1014
+ .input(
1015
+ z.array(
1016
+ z.object({
1017
+ label: z.string().min(1),
1018
+ href: z.string().min(1),
1019
+ icon: z.string().optional(),
1020
+ sortOrder: z.number().int().min(0),
1021
+ requiresRole: z.string().optional(),
1022
+ enabled: z.boolean().optional(),
1023
+ }),
1024
+ ),
1025
+ )
1026
+ .mutation(async ({ input }) => {
1027
+ const config = await getRepo().getBySlug(productSlug);
1028
+ if (!config) throw new Error("Product not found");
1029
+ await getRepo().replaceNavItems(config.product.id, input);
1030
+ getCache().invalidate(productSlug);
1031
+ }),
1032
+
1033
+ updateFeatures: adminProcedure
1034
+ .input(
1035
+ z.object({
1036
+ chatEnabled: z.boolean().optional(),
1037
+ onboardingEnabled: z.boolean().optional(),
1038
+ onboardingDefaultModel: z.string().optional(),
1039
+ onboardingSystemPrompt: z.string().optional(),
1040
+ onboardingMaxCredits: z.number().int().min(0).optional(),
1041
+ onboardingWelcomeMsg: z.string().optional(),
1042
+ sharedModuleBilling: z.boolean().optional(),
1043
+ sharedModuleMonitoring: z.boolean().optional(),
1044
+ sharedModuleAnalytics: z.boolean().optional(),
1045
+ }),
1046
+ )
1047
+ .mutation(async ({ input }) => {
1048
+ const config = await getRepo().getBySlug(productSlug);
1049
+ if (!config) throw new Error("Product not found");
1050
+ await getRepo().upsertFeatures(config.product.id, input);
1051
+ getCache().invalidate(productSlug);
1052
+ }),
1053
+
1054
+ updateFleet: adminProcedure
1055
+ .input(
1056
+ z.object({
1057
+ containerImage: z.string().optional(),
1058
+ containerPort: z.number().int().optional(),
1059
+ lifecycle: z.enum(["managed", "ephemeral"]).optional(),
1060
+ billingModel: z.enum(["monthly", "per_use", "none"]).optional(),
1061
+ maxInstances: z.number().int().min(1).optional(),
1062
+ imageAllowlist: z.array(z.string()).optional(),
1063
+ dockerNetwork: z.string().optional(),
1064
+ placementStrategy: z.string().optional(),
1065
+ fleetDataDir: z.string().optional(),
1066
+ }),
1067
+ )
1068
+ .mutation(async ({ input }) => {
1069
+ const config = await getRepo().getBySlug(productSlug);
1070
+ if (!config) throw new Error("Product not found");
1071
+ await getRepo().upsertFleetConfig(config.product.id, input);
1072
+ getCache().invalidate(productSlug);
1073
+ }),
1074
+
1075
+ updateBilling: adminProcedure
1076
+ .input(
1077
+ z.object({
1078
+ stripePublishableKey: z.string().optional(),
1079
+ stripeSecretKey: z.string().optional(),
1080
+ stripeWebhookSecret: z.string().optional(),
1081
+ creditPrices: z.record(z.string(), z.number()).optional(),
1082
+ affiliateBaseUrl: z.string().url().optional(),
1083
+ affiliateMatchRate: z.number().min(0).optional(),
1084
+ affiliateMaxCap: z.number().int().min(0).optional(),
1085
+ dividendRate: z.number().min(0).optional(),
1086
+ marginConfig: z.unknown().optional(),
1087
+ }),
1088
+ )
1089
+ .mutation(async ({ input }) => {
1090
+ const config = await getRepo().getBySlug(productSlug);
1091
+ if (!config) throw new Error("Product not found");
1092
+ await getRepo().upsertBillingConfig(config.product.id, input);
1093
+ getCache().invalidate(productSlug);
1094
+ }),
1095
+ }),
1096
+ });
1097
+ }
1098
+ ```
1099
+
1100
+ - [ ] **Step 2: Export from tRPC index**
1101
+
1102
+ Add to `src/trpc/index.ts`:
1103
+
1104
+ ```typescript
1105
+ export { createProductConfigRouter } from "./product-config-router.js";
1106
+ ```
1107
+
1108
+ - [ ] **Step 3: Run type check**
1109
+
1110
+ Run: `npm run check` (in platform-core)
1111
+ Expected: No type errors
1112
+
1113
+ - [ ] **Step 4: Commit**
1114
+
1115
+ ```bash
1116
+ git add src/trpc/product-config-router.ts src/trpc/index.ts
1117
+ git commit -m "feat: add product config tRPC router (public + admin)"
1118
+ ```
1119
+
1120
+ ---
1121
+
1122
+ ### Task 7: Seed Script
1123
+
1124
+ **Files:**
1125
+ - Create: `scripts/seed-products.ts`
1126
+
1127
+ - [ ] **Step 1: Write seed script**
1128
+
1129
+ This script reads current env var values and populates the product tables. Run once per product deployment to migrate existing config into the database.
1130
+
1131
+ ```typescript
1132
+ // scripts/seed-products.ts
1133
+ /**
1134
+ * Seed product config tables from current environment.
1135
+ *
1136
+ * Usage:
1137
+ * PRODUCT_SLUG=paperclip npx tsx scripts/seed-products.ts
1138
+ *
1139
+ * Or seed all 4 products at once:
1140
+ * npx tsx scripts/seed-products.ts --all
1141
+ */
1142
+ import { drizzle } from "drizzle-orm/node-postgres";
1143
+ import pg from "pg";
1144
+ import * as schema from "../src/db/schema/index.js";
1145
+
1146
+ const PRODUCT_PRESETS: Record<string, {
1147
+ brandName: string;
1148
+ productName: string;
1149
+ tagline: string;
1150
+ domain: string;
1151
+ appDomain: string;
1152
+ cookieDomain: string;
1153
+ companyLegal: string;
1154
+ priceLabel: string;
1155
+ defaultImage: string;
1156
+ emailSupport: string;
1157
+ emailPrivacy: string;
1158
+ emailLegal: string;
1159
+ fromEmail: string;
1160
+ homePath: string;
1161
+ storagePrefix: string;
1162
+ navItems: Array<{ label: string; href: string; sortOrder: number }>;
1163
+ fleet: { containerImage: string; lifecycle: "managed" | "ephemeral"; billingModel: "monthly" | "per_use" | "none"; maxInstances: number };
1164
+ }> = {
1165
+ wopr: {
1166
+ brandName: "WOPR",
1167
+ productName: "WOPR Bot",
1168
+ tagline: "A $5/month supercomputer that manages your business.",
1169
+ domain: "wopr.bot",
1170
+ appDomain: "app.wopr.bot",
1171
+ cookieDomain: ".wopr.bot",
1172
+ companyLegal: "WOPR Network Inc.",
1173
+ priceLabel: "$5/month",
1174
+ defaultImage: "ghcr.io/wopr-network/wopr:latest",
1175
+ emailSupport: "support@wopr.bot",
1176
+ emailPrivacy: "privacy@wopr.bot",
1177
+ emailLegal: "legal@wopr.bot",
1178
+ fromEmail: "noreply@wopr.bot",
1179
+ homePath: "/marketplace",
1180
+ storagePrefix: "wopr",
1181
+ navItems: [
1182
+ { label: "Dashboard", href: "/dashboard", sortOrder: 0 },
1183
+ { label: "Chat", href: "/chat", sortOrder: 1 },
1184
+ { label: "Marketplace", href: "/marketplace", sortOrder: 2 },
1185
+ { label: "Channels", href: "/channels", sortOrder: 3 },
1186
+ { label: "Plugins", href: "/plugins", sortOrder: 4 },
1187
+ { label: "Instances", href: "/instances", sortOrder: 5 },
1188
+ { label: "Changesets", href: "/changesets", sortOrder: 6 },
1189
+ { label: "Network", href: "/dashboard/network", sortOrder: 7 },
1190
+ { label: "Fleet Health", href: "/fleet/health", sortOrder: 8 },
1191
+ { label: "Credits", href: "/billing/credits", sortOrder: 9 },
1192
+ { label: "Billing", href: "/billing/plans", sortOrder: 10 },
1193
+ { label: "Settings", href: "/settings/profile", sortOrder: 11 },
1194
+ { label: "Admin", href: "/admin/tenants", sortOrder: 12 },
1195
+ ],
1196
+ fleet: { containerImage: "ghcr.io/wopr-network/wopr:latest", lifecycle: "managed", billingModel: "monthly", maxInstances: 5 },
1197
+ },
1198
+ paperclip: {
1199
+ brandName: "Paperclip",
1200
+ productName: "Paperclip",
1201
+ tagline: "AI agents that run your business.",
1202
+ domain: "runpaperclip.com",
1203
+ appDomain: "app.runpaperclip.com",
1204
+ cookieDomain: ".runpaperclip.com",
1205
+ companyLegal: "Paperclip AI Inc.",
1206
+ priceLabel: "$5/month",
1207
+ defaultImage: "ghcr.io/wopr-network/paperclip:managed",
1208
+ emailSupport: "support@runpaperclip.com",
1209
+ emailPrivacy: "privacy@runpaperclip.com",
1210
+ emailLegal: "legal@runpaperclip.com",
1211
+ fromEmail: "noreply@runpaperclip.com",
1212
+ homePath: "/instances",
1213
+ storagePrefix: "paperclip",
1214
+ navItems: [
1215
+ { label: "Instances", href: "/instances", sortOrder: 0 },
1216
+ { label: "Credits", href: "/billing/credits", sortOrder: 1 },
1217
+ { label: "Settings", href: "/settings/profile", sortOrder: 2 },
1218
+ { label: "Admin", href: "/admin/tenants", sortOrder: 3 },
1219
+ ],
1220
+ fleet: { containerImage: "ghcr.io/wopr-network/paperclip:managed", lifecycle: "managed", billingModel: "monthly", maxInstances: 5 },
1221
+ },
1222
+ holyship: {
1223
+ brandName: "Holy Ship",
1224
+ productName: "Holy Ship",
1225
+ tagline: "Ship it.",
1226
+ domain: "holyship.wtf",
1227
+ appDomain: "app.holyship.wtf",
1228
+ cookieDomain: ".holyship.wtf",
1229
+ companyLegal: "WOPR Network Inc.",
1230
+ priceLabel: "",
1231
+ defaultImage: "ghcr.io/wopr-network/holyship:latest",
1232
+ emailSupport: "support@holyship.wtf",
1233
+ emailPrivacy: "privacy@holyship.wtf",
1234
+ emailLegal: "legal@holyship.wtf",
1235
+ fromEmail: "noreply@holyship.wtf",
1236
+ homePath: "/dashboard",
1237
+ storagePrefix: "holyship",
1238
+ navItems: [
1239
+ { label: "Dashboard", href: "/dashboard", sortOrder: 0 },
1240
+ { label: "Ship", href: "/ship", sortOrder: 1 },
1241
+ { label: "Approvals", href: "/approvals", sortOrder: 2 },
1242
+ { label: "Connect", href: "/connect", sortOrder: 3 },
1243
+ { label: "Credits", href: "/billing/credits", sortOrder: 4 },
1244
+ { label: "Settings", href: "/settings/profile", sortOrder: 5 },
1245
+ { label: "Admin", href: "/admin/tenants", sortOrder: 6 },
1246
+ ],
1247
+ fleet: { containerImage: "ghcr.io/wopr-network/holyship:latest", lifecycle: "ephemeral", billingModel: "none", maxInstances: 50 },
1248
+ },
1249
+ nemoclaw: {
1250
+ brandName: "NemoPod",
1251
+ productName: "NemoPod",
1252
+ tagline: "NVIDIA NeMo, one click away",
1253
+ domain: "nemopod.com",
1254
+ appDomain: "app.nemopod.com",
1255
+ cookieDomain: ".nemopod.com",
1256
+ companyLegal: "WOPR Network Inc.",
1257
+ priceLabel: "$5 free credits",
1258
+ defaultImage: "ghcr.io/wopr-network/nemoclaw:latest",
1259
+ emailSupport: "support@nemopod.com",
1260
+ emailPrivacy: "privacy@nemopod.com",
1261
+ emailLegal: "legal@nemopod.com",
1262
+ fromEmail: "noreply@nemopod.com",
1263
+ homePath: "/instances",
1264
+ storagePrefix: "nemopod",
1265
+ navItems: [
1266
+ { label: "NemoClaws", href: "/instances", sortOrder: 0 },
1267
+ { label: "Credits", href: "/billing/credits", sortOrder: 1 },
1268
+ { label: "Settings", href: "/settings/profile", sortOrder: 2 },
1269
+ { label: "Admin", href: "/admin/tenants", sortOrder: 3 },
1270
+ ],
1271
+ fleet: { containerImage: "ghcr.io/wopr-network/nemoclaw:latest", lifecycle: "managed", billingModel: "monthly", maxInstances: 5 },
1272
+ },
1273
+ };
1274
+
1275
+ async function seed() {
1276
+ const dbUrl = process.env.DATABASE_URL;
1277
+ if (!dbUrl) {
1278
+ console.error("DATABASE_URL required");
1279
+ process.exit(1);
1280
+ }
1281
+
1282
+ const pool = new pg.Pool({ connectionString: dbUrl });
1283
+ const db = drizzle(pool, { schema });
1284
+
1285
+ const slugs = process.argv.includes("--all")
1286
+ ? Object.keys(PRODUCT_PRESETS)
1287
+ : [process.env.PRODUCT_SLUG ?? "wopr"];
1288
+
1289
+ for (const slug of slugs) {
1290
+ const preset = PRODUCT_PRESETS[slug];
1291
+ if (!preset) {
1292
+ console.error(`Unknown product slug: ${slug}`);
1293
+ continue;
1294
+ }
1295
+
1296
+ console.log(`Seeding ${slug}...`);
1297
+
1298
+ const { navItems, fleet, ...productData } = preset;
1299
+
1300
+ // Upsert product
1301
+ const [product] = await db
1302
+ .insert(schema.products)
1303
+ .values({ slug, ...productData })
1304
+ .onConflictDoUpdate({ target: schema.products.slug, set: productData })
1305
+ .returning();
1306
+
1307
+ // Replace nav items
1308
+ await db.delete(schema.productNavItems).where(
1309
+ require("drizzle-orm").eq(schema.productNavItems.productId, product.id),
1310
+ );
1311
+ if (navItems.length > 0) {
1312
+ await db.insert(schema.productNavItems).values(
1313
+ navItems.map((item) => ({ productId: product.id, ...item })),
1314
+ );
1315
+ }
1316
+
1317
+ // Upsert fleet config
1318
+ await db
1319
+ .insert(schema.productFleetConfig)
1320
+ .values({ productId: product.id, ...fleet })
1321
+ .onConflictDoUpdate({ target: schema.productFleetConfig.productId, set: fleet });
1322
+
1323
+ // Features with defaults
1324
+ await db
1325
+ .insert(schema.productFeatures)
1326
+ .values({ productId: product.id })
1327
+ .onConflictDoNothing();
1328
+
1329
+ console.log(` ✓ ${slug} seeded (${navItems.length} nav items)`);
1330
+ }
1331
+
1332
+ await pool.end();
1333
+ console.log("Done.");
1334
+ }
1335
+
1336
+ seed().catch(console.error);
1337
+ ```
1338
+
1339
+ - [ ] **Step 2: Commit**
1340
+
1341
+ ```bash
1342
+ git add scripts/seed-products.ts
1343
+ git commit -m "feat: add product config seed script for all 4 products"
1344
+ ```
1345
+
1346
+ ---
1347
+
1348
+ ## Phase 3: Admin UI (platform-ui-core)
1349
+
1350
+ ### Task 8: Admin Products Page Shell
1351
+
1352
+ **Files:**
1353
+ - Create: `src/app/admin/products/page.tsx`
1354
+ - Create: `src/app/admin/products/loading.tsx`
1355
+ - Create: `src/app/admin/products/error.tsx`
1356
+
1357
+ - [ ] **Step 1: Create page with tab layout**
1358
+
1359
+ The page loads the current product config via tRPC admin endpoint, then renders tabs for each config domain (Brand, Navigation, Features, Fleet, Billing). Each tab contains a form component.
1360
+
1361
+ ```typescript
1362
+ // src/app/admin/products/page.tsx
1363
+ "use client";
1364
+
1365
+ import { useCallback, useEffect, useState } from "react";
1366
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1367
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
1368
+ import { trpcVanilla } from "@/lib/trpc";
1369
+ import { BrandForm } from "@/components/admin/products/brand-form";
1370
+ import { NavEditor } from "@/components/admin/products/nav-editor";
1371
+ import { FeaturesForm } from "@/components/admin/products/features-form";
1372
+ import { FleetForm } from "@/components/admin/products/fleet-form";
1373
+ import { BillingForm } from "@/components/admin/products/billing-form";
1374
+
1375
+ export default function AdminProductsPage() {
1376
+ const [config, setConfig] = useState<Awaited<ReturnType<typeof trpcVanilla.product.admin.get.query>> | null>(null);
1377
+ const [error, setError] = useState<string | null>(null);
1378
+
1379
+ const load = useCallback(async () => {
1380
+ try {
1381
+ const result = await trpcVanilla.product.admin.get.query();
1382
+ setConfig(result);
1383
+ } catch (err) {
1384
+ setError(err instanceof Error ? err.message : "Failed to load config");
1385
+ }
1386
+ }, []);
1387
+
1388
+ useEffect(() => { load(); }, [load]);
1389
+
1390
+ if (error) return <div className="p-6 text-red-400">{error}</div>;
1391
+ if (!config) return null;
1392
+
1393
+ return (
1394
+ <div className="space-y-6 p-6">
1395
+ <h1 className="text-2xl font-bold">Product Configuration</h1>
1396
+ <p className="text-muted-foreground">
1397
+ {config.product.productName} ({config.product.slug})
1398
+ </p>
1399
+
1400
+ <Tabs defaultValue="brand">
1401
+ <TabsList>
1402
+ <TabsTrigger value="brand">Brand</TabsTrigger>
1403
+ <TabsTrigger value="navigation">Navigation</TabsTrigger>
1404
+ <TabsTrigger value="features">Features</TabsTrigger>
1405
+ <TabsTrigger value="fleet">Fleet</TabsTrigger>
1406
+ <TabsTrigger value="billing">Billing</TabsTrigger>
1407
+ </TabsList>
1408
+
1409
+ <TabsContent value="brand">
1410
+ <BrandForm product={config.product} onSaved={load} />
1411
+ </TabsContent>
1412
+ <TabsContent value="navigation">
1413
+ <NavEditor items={config.navItems} onSaved={load} />
1414
+ </TabsContent>
1415
+ <TabsContent value="features">
1416
+ <FeaturesForm features={config.features} onSaved={load} />
1417
+ </TabsContent>
1418
+ <TabsContent value="fleet">
1419
+ <FleetForm fleet={config.fleet} onSaved={load} />
1420
+ </TabsContent>
1421
+ <TabsContent value="billing">
1422
+ <BillingForm billing={config.billing} onSaved={load} />
1423
+ </TabsContent>
1424
+ </Tabs>
1425
+ </div>
1426
+ );
1427
+ }
1428
+ ```
1429
+
1430
+ - [ ] **Step 2: Create loading and error boundaries**
1431
+
1432
+ ```typescript
1433
+ // src/app/admin/products/loading.tsx
1434
+ export default function Loading() {
1435
+ return <div className="p-6 animate-pulse">Loading product configuration...</div>;
1436
+ }
1437
+ ```
1438
+
1439
+ ```typescript
1440
+ // src/app/admin/products/error.tsx
1441
+ "use client";
1442
+
1443
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1444
+ import { Button } from "@/components/ui/button";
1445
+
1446
+ export default function Error({ error, reset }: { error: Error; reset: () => void }) {
1447
+ return (
1448
+ <div className="p-6">
1449
+ <Card>
1450
+ <CardHeader>
1451
+ <CardTitle>Error loading product config</CardTitle>
1452
+ </CardHeader>
1453
+ <CardContent className="space-y-4">
1454
+ <p className="text-muted-foreground">{error.message}</p>
1455
+ <Button onClick={reset}>Retry</Button>
1456
+ </CardContent>
1457
+ </Card>
1458
+ </div>
1459
+ );
1460
+ }
1461
+ ```
1462
+
1463
+ - [ ] **Step 3: Commit**
1464
+
1465
+ ```bash
1466
+ git add src/app/admin/products/
1467
+ git commit -m "feat: add admin products page shell with tabs"
1468
+ ```
1469
+
1470
+ ---
1471
+
1472
+ ### Task 9: Brand Form Component
1473
+
1474
+ **Files:**
1475
+ - Create: `src/components/admin/products/brand-form.tsx`
1476
+
1477
+ - [ ] **Step 1: Write brand form**
1478
+
1479
+ Follow the existing promotion-form.tsx pattern: state-based, shadcn components, tRPC mutations.
1480
+
1481
+ ```typescript
1482
+ // src/components/admin/products/brand-form.tsx
1483
+ "use client";
1484
+
1485
+ import { useState } from "react";
1486
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1487
+ import { Input } from "@/components/ui/input";
1488
+ import { Label } from "@/components/ui/label";
1489
+ import { Button } from "@/components/ui/button";
1490
+ import { trpcVanilla } from "@/lib/trpc";
1491
+ import { toast } from "sonner";
1492
+
1493
+ interface Props {
1494
+ product: {
1495
+ brandName: string;
1496
+ productName: string;
1497
+ tagline: string;
1498
+ domain: string;
1499
+ appDomain: string;
1500
+ cookieDomain: string;
1501
+ companyLegal: string;
1502
+ priceLabel: string;
1503
+ defaultImage: string;
1504
+ emailSupport: string;
1505
+ emailPrivacy: string;
1506
+ emailLegal: string;
1507
+ fromEmail: string;
1508
+ homePath: string;
1509
+ storagePrefix: string;
1510
+ };
1511
+ onSaved: () => void;
1512
+ }
1513
+
1514
+ export function BrandForm({ product, onSaved }: Props) {
1515
+ const [form, setForm] = useState(product);
1516
+ const [saving, setSaving] = useState(false);
1517
+
1518
+ const update = (field: keyof typeof form, value: string) =>
1519
+ setForm((prev) => ({ ...prev, [field]: value }));
1520
+
1521
+ const save = async () => {
1522
+ setSaving(true);
1523
+ try {
1524
+ await trpcVanilla.product.admin.updateBrand.mutate(form);
1525
+ toast.success("Brand config saved");
1526
+ onSaved();
1527
+ } catch (err) {
1528
+ toast.error(err instanceof Error ? err.message : "Save failed");
1529
+ } finally {
1530
+ setSaving(false);
1531
+ }
1532
+ };
1533
+
1534
+ const fields: Array<{ key: keyof typeof form; label: string }> = [
1535
+ { key: "brandName", label: "Brand Name" },
1536
+ { key: "productName", label: "Product Name" },
1537
+ { key: "tagline", label: "Tagline" },
1538
+ { key: "domain", label: "Domain" },
1539
+ { key: "appDomain", label: "App Domain" },
1540
+ { key: "cookieDomain", label: "Cookie Domain" },
1541
+ { key: "companyLegal", label: "Company Legal Name" },
1542
+ { key: "priceLabel", label: "Price Label" },
1543
+ { key: "defaultImage", label: "Default Container Image" },
1544
+ { key: "emailSupport", label: "Support Email" },
1545
+ { key: "emailPrivacy", label: "Privacy Email" },
1546
+ { key: "emailLegal", label: "Legal Email" },
1547
+ { key: "fromEmail", label: "From Email (notifications)" },
1548
+ { key: "homePath", label: "Home Path (post-login redirect)" },
1549
+ { key: "storagePrefix", label: "Storage Prefix" },
1550
+ ];
1551
+
1552
+ return (
1553
+ <Card>
1554
+ <CardHeader>
1555
+ <CardTitle>Brand Identity</CardTitle>
1556
+ </CardHeader>
1557
+ <CardContent className="space-y-4">
1558
+ {fields.map(({ key, label }) => (
1559
+ <div key={key} className="space-y-1">
1560
+ <Label htmlFor={key}>{label}</Label>
1561
+ <Input
1562
+ id={key}
1563
+ value={form[key]}
1564
+ onChange={(e) => update(key, e.target.value)}
1565
+ />
1566
+ </div>
1567
+ ))}
1568
+ <Button onClick={save} disabled={saving}>
1569
+ {saving ? "Saving..." : "Save Brand Config"}
1570
+ </Button>
1571
+ </CardContent>
1572
+ </Card>
1573
+ );
1574
+ }
1575
+ ```
1576
+
1577
+ - [ ] **Step 2: Commit**
1578
+
1579
+ ```bash
1580
+ git add src/components/admin/products/brand-form.tsx
1581
+ git commit -m "feat: add brand identity admin form"
1582
+ ```
1583
+
1584
+ ---
1585
+
1586
+ ### Task 10: Navigation Editor Component
1587
+
1588
+ **Files:**
1589
+ - Create: `src/components/admin/products/nav-editor.tsx`
1590
+
1591
+ - [ ] **Step 1: Write nav editor**
1592
+
1593
+ List of nav items with add/remove/reorder/toggle. No drag-and-drop library needed for v1 — use up/down buttons.
1594
+
1595
+ ```typescript
1596
+ // src/components/admin/products/nav-editor.tsx
1597
+ "use client";
1598
+
1599
+ import { useState } from "react";
1600
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1601
+ import { Input } from "@/components/ui/input";
1602
+ import { Button } from "@/components/ui/button";
1603
+ import { trpcVanilla } from "@/lib/trpc";
1604
+ import { toast } from "sonner";
1605
+
1606
+ interface NavItem {
1607
+ label: string;
1608
+ href: string;
1609
+ icon?: string | null;
1610
+ sortOrder: number;
1611
+ requiresRole?: string | null;
1612
+ enabled: string;
1613
+ }
1614
+
1615
+ interface Props {
1616
+ items: NavItem[];
1617
+ onSaved: () => void;
1618
+ }
1619
+
1620
+ export function NavEditor({ items: initialItems, onSaved }: Props) {
1621
+ const [items, setItems] = useState<NavItem[]>(
1622
+ [...initialItems].sort((a, b) => a.sortOrder - b.sortOrder),
1623
+ );
1624
+ const [saving, setSaving] = useState(false);
1625
+
1626
+ const moveUp = (idx: number) => {
1627
+ if (idx === 0) return;
1628
+ const next = [...items];
1629
+ [next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
1630
+ setItems(next.map((item, i) => ({ ...item, sortOrder: i })));
1631
+ };
1632
+
1633
+ const moveDown = (idx: number) => {
1634
+ if (idx === items.length - 1) return;
1635
+ const next = [...items];
1636
+ [next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
1637
+ setItems(next.map((item, i) => ({ ...item, sortOrder: i })));
1638
+ };
1639
+
1640
+ const toggle = (idx: number) => {
1641
+ const next = [...items];
1642
+ next[idx] = { ...next[idx], enabled: next[idx].enabled === "true" ? "false" : "true" };
1643
+ setItems(next);
1644
+ };
1645
+
1646
+ const remove = (idx: number) => {
1647
+ setItems(items.filter((_, i) => i !== idx).map((item, i) => ({ ...item, sortOrder: i })));
1648
+ };
1649
+
1650
+ const add = () => {
1651
+ setItems([...items, { label: "", href: "/", sortOrder: items.length, enabled: "true" }]);
1652
+ };
1653
+
1654
+ const updateField = (idx: number, field: "label" | "href", value: string) => {
1655
+ const next = [...items];
1656
+ next[idx] = { ...next[idx], [field]: value };
1657
+ setItems(next);
1658
+ };
1659
+
1660
+ const save = async () => {
1661
+ setSaving(true);
1662
+ try {
1663
+ await trpcVanilla.product.admin.updateNavItems.mutate(
1664
+ items.map((item) => ({
1665
+ label: item.label,
1666
+ href: item.href,
1667
+ icon: item.icon ?? undefined,
1668
+ sortOrder: item.sortOrder,
1669
+ requiresRole: item.requiresRole ?? undefined,
1670
+ enabled: item.enabled !== "false",
1671
+ })),
1672
+ );
1673
+ toast.success("Navigation saved");
1674
+ onSaved();
1675
+ } catch (err) {
1676
+ toast.error(err instanceof Error ? err.message : "Save failed");
1677
+ } finally {
1678
+ setSaving(false);
1679
+ }
1680
+ };
1681
+
1682
+ return (
1683
+ <Card>
1684
+ <CardHeader>
1685
+ <CardTitle>Navigation Items</CardTitle>
1686
+ </CardHeader>
1687
+ <CardContent className="space-y-3">
1688
+ {items.map((item, idx) => (
1689
+ <div key={idx} className="flex items-center gap-2">
1690
+ <span className="text-xs text-muted-foreground w-6">{idx}</span>
1691
+ <Input
1692
+ className="flex-1"
1693
+ placeholder="Label"
1694
+ value={item.label}
1695
+ onChange={(e) => updateField(idx, "label", e.target.value)}
1696
+ />
1697
+ <Input
1698
+ className="flex-1"
1699
+ placeholder="/path"
1700
+ value={item.href}
1701
+ onChange={(e) => updateField(idx, "href", e.target.value)}
1702
+ />
1703
+ <Button variant="ghost" size="sm" onClick={() => moveUp(idx)} disabled={idx === 0}>
1704
+
1705
+ </Button>
1706
+ <Button variant="ghost" size="sm" onClick={() => moveDown(idx)} disabled={idx === items.length - 1}>
1707
+
1708
+ </Button>
1709
+ <Button variant="ghost" size="sm" onClick={() => toggle(idx)}>
1710
+ {item.enabled === "true" ? "On" : "Off"}
1711
+ </Button>
1712
+ <Button variant="ghost" size="sm" onClick={() => remove(idx)}>
1713
+
1714
+ </Button>
1715
+ </div>
1716
+ ))}
1717
+ <div className="flex gap-2">
1718
+ <Button variant="outline" onClick={add}>Add Item</Button>
1719
+ <Button onClick={save} disabled={saving}>
1720
+ {saving ? "Saving..." : "Save Navigation"}
1721
+ </Button>
1722
+ </div>
1723
+ </CardContent>
1724
+ </Card>
1725
+ );
1726
+ }
1727
+ ```
1728
+
1729
+ - [ ] **Step 2: Commit**
1730
+
1731
+ ```bash
1732
+ git add src/components/admin/products/nav-editor.tsx
1733
+ git commit -m "feat: add navigation item editor with reorder/toggle"
1734
+ ```
1735
+
1736
+ ---
1737
+
1738
+ ### Task 11: Features, Fleet, and Billing Forms
1739
+
1740
+ **Files:**
1741
+ - Create: `src/components/admin/products/features-form.tsx`
1742
+ - Create: `src/components/admin/products/fleet-form.tsx`
1743
+ - Create: `src/components/admin/products/billing-form.tsx`
1744
+
1745
+ - [ ] **Step 1: Write features form**
1746
+
1747
+ Toggle switches for boolean flags, text inputs for string fields.
1748
+
1749
+ Pattern: same as brand-form but with `Checkbox` components for booleans.
1750
+
1751
+ ```typescript
1752
+ // src/components/admin/products/features-form.tsx
1753
+ "use client";
1754
+
1755
+ import { useState } from "react";
1756
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1757
+ import { Input } from "@/components/ui/input";
1758
+ import { Label } from "@/components/ui/label";
1759
+ import { Checkbox } from "@/components/ui/checkbox";
1760
+ import { Button } from "@/components/ui/button";
1761
+ import { trpcVanilla } from "@/lib/trpc";
1762
+ import { toast } from "sonner";
1763
+
1764
+ interface Props {
1765
+ features: {
1766
+ chatEnabled: boolean;
1767
+ onboardingEnabled: boolean;
1768
+ onboardingDefaultModel: string | null;
1769
+ onboardingMaxCredits: number;
1770
+ onboardingWelcomeMsg: string | null;
1771
+ sharedModuleBilling: boolean;
1772
+ sharedModuleMonitoring: boolean;
1773
+ sharedModuleAnalytics: boolean;
1774
+ } | null;
1775
+ onSaved: () => void;
1776
+ }
1777
+
1778
+ export function FeaturesForm({ features, onSaved }: Props) {
1779
+ const defaults = {
1780
+ chatEnabled: true,
1781
+ onboardingEnabled: true,
1782
+ onboardingDefaultModel: "",
1783
+ onboardingMaxCredits: 100,
1784
+ onboardingWelcomeMsg: "",
1785
+ sharedModuleBilling: true,
1786
+ sharedModuleMonitoring: true,
1787
+ sharedModuleAnalytics: true,
1788
+ };
1789
+ const [form, setForm] = useState({
1790
+ ...defaults,
1791
+ ...features,
1792
+ onboardingDefaultModel: features?.onboardingDefaultModel ?? "",
1793
+ onboardingWelcomeMsg: features?.onboardingWelcomeMsg ?? "",
1794
+ });
1795
+ const [saving, setSaving] = useState(false);
1796
+
1797
+ const save = async () => {
1798
+ setSaving(true);
1799
+ try {
1800
+ await trpcVanilla.product.admin.updateFeatures.mutate(form);
1801
+ toast.success("Features saved");
1802
+ onSaved();
1803
+ } catch (err) {
1804
+ toast.error(err instanceof Error ? err.message : "Save failed");
1805
+ } finally {
1806
+ setSaving(false);
1807
+ }
1808
+ };
1809
+
1810
+ const toggles: Array<{ key: keyof typeof form; label: string }> = [
1811
+ { key: "chatEnabled", label: "Chat Widget" },
1812
+ { key: "onboardingEnabled", label: "Onboarding Flow" },
1813
+ { key: "sharedModuleBilling", label: "Billing Module" },
1814
+ { key: "sharedModuleMonitoring", label: "Monitoring Module" },
1815
+ { key: "sharedModuleAnalytics", label: "Analytics Module" },
1816
+ ];
1817
+
1818
+ return (
1819
+ <Card>
1820
+ <CardHeader><CardTitle>Feature Flags</CardTitle></CardHeader>
1821
+ <CardContent className="space-y-4">
1822
+ {toggles.map(({ key, label }) => (
1823
+ <div key={key} className="flex items-center gap-2">
1824
+ <Checkbox
1825
+ checked={form[key] as boolean}
1826
+ onCheckedChange={(checked) => setForm((prev) => ({ ...prev, [key]: !!checked }))}
1827
+ />
1828
+ <Label>{label}</Label>
1829
+ </div>
1830
+ ))}
1831
+ <div className="space-y-1">
1832
+ <Label>Onboarding Default Model</Label>
1833
+ <Input value={form.onboardingDefaultModel} onChange={(e) => setForm((prev) => ({ ...prev, onboardingDefaultModel: e.target.value }))} />
1834
+ </div>
1835
+ <div className="space-y-1">
1836
+ <Label>Onboarding Max Credits</Label>
1837
+ <Input type="number" value={form.onboardingMaxCredits} onChange={(e) => setForm((prev) => ({ ...prev, onboardingMaxCredits: Number(e.target.value) }))} />
1838
+ </div>
1839
+ <Button onClick={save} disabled={saving}>{saving ? "Saving..." : "Save Features"}</Button>
1840
+ </CardContent>
1841
+ </Card>
1842
+ );
1843
+ }
1844
+ ```
1845
+
1846
+ - [ ] **Step 2: Write fleet form**
1847
+
1848
+ Dropdowns for lifecycle/billing model enums, number inputs for limits.
1849
+
1850
+ ```typescript
1851
+ // src/components/admin/products/fleet-form.tsx
1852
+ "use client";
1853
+
1854
+ import { useState } from "react";
1855
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1856
+ import { Input } from "@/components/ui/input";
1857
+ import { Label } from "@/components/ui/label";
1858
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
1859
+ import { Button } from "@/components/ui/button";
1860
+ import { trpcVanilla } from "@/lib/trpc";
1861
+ import { toast } from "sonner";
1862
+
1863
+ interface Props {
1864
+ fleet: {
1865
+ containerImage: string;
1866
+ containerPort: number;
1867
+ lifecycle: string;
1868
+ billingModel: string;
1869
+ maxInstances: number;
1870
+ dockerNetwork: string;
1871
+ placementStrategy: string;
1872
+ fleetDataDir: string;
1873
+ } | null;
1874
+ onSaved: () => void;
1875
+ }
1876
+
1877
+ export function FleetForm({ fleet, onSaved }: Props) {
1878
+ const defaults = {
1879
+ containerImage: "",
1880
+ containerPort: 3100,
1881
+ lifecycle: "managed" as const,
1882
+ billingModel: "monthly" as const,
1883
+ maxInstances: 5,
1884
+ dockerNetwork: "",
1885
+ placementStrategy: "least-loaded",
1886
+ fleetDataDir: "/data/fleet",
1887
+ };
1888
+ const [form, setForm] = useState({ ...defaults, ...fleet });
1889
+ const [saving, setSaving] = useState(false);
1890
+
1891
+ const save = async () => {
1892
+ setSaving(true);
1893
+ try {
1894
+ await trpcVanilla.product.admin.updateFleet.mutate(form);
1895
+ toast.success("Fleet config saved");
1896
+ onSaved();
1897
+ } catch (err) {
1898
+ toast.error(err instanceof Error ? err.message : "Save failed");
1899
+ } finally {
1900
+ setSaving(false);
1901
+ }
1902
+ };
1903
+
1904
+ return (
1905
+ <Card>
1906
+ <CardHeader><CardTitle>Fleet Configuration</CardTitle></CardHeader>
1907
+ <CardContent className="space-y-4">
1908
+ <div className="space-y-1">
1909
+ <Label>Container Image</Label>
1910
+ <Input value={form.containerImage} onChange={(e) => setForm((prev) => ({ ...prev, containerImage: e.target.value }))} />
1911
+ </div>
1912
+ <div className="space-y-1">
1913
+ <Label>Container Port</Label>
1914
+ <Input type="number" value={form.containerPort} onChange={(e) => setForm((prev) => ({ ...prev, containerPort: Number(e.target.value) }))} />
1915
+ </div>
1916
+ <div className="space-y-1">
1917
+ <Label>Lifecycle</Label>
1918
+ <Select value={form.lifecycle} onValueChange={(v) => setForm((prev) => ({ ...prev, lifecycle: v }))}>
1919
+ <SelectTrigger><SelectValue /></SelectTrigger>
1920
+ <SelectContent>
1921
+ <SelectItem value="managed">Managed (persistent)</SelectItem>
1922
+ <SelectItem value="ephemeral">Ephemeral (teardown on completion)</SelectItem>
1923
+ </SelectContent>
1924
+ </Select>
1925
+ </div>
1926
+ <div className="space-y-1">
1927
+ <Label>Billing Model</Label>
1928
+ <Select value={form.billingModel} onValueChange={(v) => setForm((prev) => ({ ...prev, billingModel: v }))}>
1929
+ <SelectTrigger><SelectValue /></SelectTrigger>
1930
+ <SelectContent>
1931
+ <SelectItem value="monthly">Monthly subscription</SelectItem>
1932
+ <SelectItem value="per_use">Per-use (credit gate)</SelectItem>
1933
+ <SelectItem value="none">None</SelectItem>
1934
+ </SelectContent>
1935
+ </Select>
1936
+ </div>
1937
+ <div className="space-y-1">
1938
+ <Label>Max Instances Per Tenant</Label>
1939
+ <Input type="number" value={form.maxInstances} onChange={(e) => setForm((prev) => ({ ...prev, maxInstances: Number(e.target.value) }))} />
1940
+ </div>
1941
+ <div className="space-y-1">
1942
+ <Label>Docker Network</Label>
1943
+ <Input value={form.dockerNetwork} onChange={(e) => setForm((prev) => ({ ...prev, dockerNetwork: e.target.value }))} />
1944
+ </div>
1945
+ <Button onClick={save} disabled={saving}>{saving ? "Saving..." : "Save Fleet Config"}</Button>
1946
+ </CardContent>
1947
+ </Card>
1948
+ );
1949
+ }
1950
+ ```
1951
+
1952
+ - [ ] **Step 3: Write billing form**
1953
+
1954
+ Stripe keys (masked), credit price tiers, affiliate config.
1955
+
1956
+ ```typescript
1957
+ // src/components/admin/products/billing-form.tsx
1958
+ "use client";
1959
+
1960
+ import { useState } from "react";
1961
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1962
+ import { Input } from "@/components/ui/input";
1963
+ import { Label } from "@/components/ui/label";
1964
+ import { Button } from "@/components/ui/button";
1965
+ import { trpcVanilla } from "@/lib/trpc";
1966
+ import { toast } from "sonner";
1967
+
1968
+ interface Props {
1969
+ billing: {
1970
+ stripePublishableKey: string | null;
1971
+ creditPrices: Record<string, number>;
1972
+ affiliateBaseUrl: string | null;
1973
+ affiliateMatchRate: string;
1974
+ affiliateMaxCap: number;
1975
+ dividendRate: string;
1976
+ } | null;
1977
+ onSaved: () => void;
1978
+ }
1979
+
1980
+ export function BillingForm({ billing, onSaved }: Props) {
1981
+ const defaults = {
1982
+ stripePublishableKey: "",
1983
+ creditPrices: { "5": 500, "20": 2000, "50": 5000, "100": 10000, "500": 50000 },
1984
+ affiliateBaseUrl: "",
1985
+ affiliateMatchRate: 1.0,
1986
+ affiliateMaxCap: 20000,
1987
+ dividendRate: 1.0,
1988
+ };
1989
+ const [form, setForm] = useState({
1990
+ ...defaults,
1991
+ ...billing,
1992
+ stripePublishableKey: billing?.stripePublishableKey ?? "",
1993
+ affiliateBaseUrl: billing?.affiliateBaseUrl ?? "",
1994
+ affiliateMatchRate: Number(billing?.affiliateMatchRate ?? 1.0),
1995
+ dividendRate: Number(billing?.dividendRate ?? 1.0),
1996
+ creditPrices: (billing?.creditPrices as Record<string, number>) ?? defaults.creditPrices,
1997
+ });
1998
+ const [saving, setSaving] = useState(false);
1999
+
2000
+ const save = async () => {
2001
+ setSaving(true);
2002
+ try {
2003
+ await trpcVanilla.product.admin.updateBilling.mutate(form);
2004
+ toast.success("Billing config saved");
2005
+ onSaved();
2006
+ } catch (err) {
2007
+ toast.error(err instanceof Error ? err.message : "Save failed");
2008
+ } finally {
2009
+ setSaving(false);
2010
+ }
2011
+ };
2012
+
2013
+ const priceTiers = ["5", "20", "50", "100", "500"];
2014
+
2015
+ return (
2016
+ <Card>
2017
+ <CardHeader><CardTitle>Billing Configuration</CardTitle></CardHeader>
2018
+ <CardContent className="space-y-4">
2019
+ <div className="space-y-1">
2020
+ <Label>Stripe Publishable Key</Label>
2021
+ <Input value={form.stripePublishableKey} onChange={(e) => setForm((prev) => ({ ...prev, stripePublishableKey: e.target.value }))} />
2022
+ </div>
2023
+ <div className="space-y-2">
2024
+ <Label>Credit Prices (cents)</Label>
2025
+ {priceTiers.map((tier) => (
2026
+ <div key={tier} className="flex items-center gap-2">
2027
+ <span className="w-16 text-sm text-muted-foreground">${tier}:</span>
2028
+ <Input
2029
+ type="number"
2030
+ value={form.creditPrices[tier] ?? 0}
2031
+ onChange={(e) => setForm((prev) => ({
2032
+ ...prev,
2033
+ creditPrices: { ...prev.creditPrices, [tier]: Number(e.target.value) },
2034
+ }))}
2035
+ />
2036
+ </div>
2037
+ ))}
2038
+ </div>
2039
+ <div className="space-y-1">
2040
+ <Label>Affiliate Match Rate</Label>
2041
+ <Input type="number" step="0.1" value={form.affiliateMatchRate} onChange={(e) => setForm((prev) => ({ ...prev, affiliateMatchRate: Number(e.target.value) }))} />
2042
+ </div>
2043
+ <div className="space-y-1">
2044
+ <Label>Affiliate Max Cap (cents)</Label>
2045
+ <Input type="number" value={form.affiliateMaxCap} onChange={(e) => setForm((prev) => ({ ...prev, affiliateMaxCap: Number(e.target.value) }))} />
2046
+ </div>
2047
+ <div className="space-y-1">
2048
+ <Label>Dividend Rate</Label>
2049
+ <Input type="number" step="0.1" value={form.dividendRate} onChange={(e) => setForm((prev) => ({ ...prev, dividendRate: Number(e.target.value) }))} />
2050
+ </div>
2051
+ <Button onClick={save} disabled={saving}>{saving ? "Saving..." : "Save Billing Config"}</Button>
2052
+ </CardContent>
2053
+ </Card>
2054
+ );
2055
+ }
2056
+ ```
2057
+
2058
+ - [ ] **Step 4: Commit**
2059
+
2060
+ ```bash
2061
+ git add src/components/admin/products/
2062
+ git commit -m "feat: add features, fleet, and billing admin forms"
2063
+ ```
2064
+
2065
+ ---
2066
+
2067
+ ## Phase 4: Frontend Brand Config Migration
2068
+
2069
+ ### Task 12: initBrandConfig() via tRPC
2070
+
2071
+ **Files:**
2072
+ - Modify: `src/lib/brand-config.ts` (in platform-ui-core)
2073
+
2074
+ - [ ] **Step 1: Add initBrandConfig function**
2075
+
2076
+ Add after the existing `setBrandConfig` function:
2077
+
2078
+ ```typescript
2079
+ /**
2080
+ * Fetch brand config from the platform API and apply it.
2081
+ * Call once in root layout server component.
2082
+ * Falls back to env var defaults if API unavailable.
2083
+ */
2084
+ export async function initBrandConfig(apiBaseUrl: string): Promise<void> {
2085
+ try {
2086
+ const res = await fetch(`${apiBaseUrl}/trpc/product.getBrandConfig`, {
2087
+ next: { revalidate: 60 },
2088
+ });
2089
+ if (!res.ok) return; // fall back to env defaults
2090
+ const json = await res.json();
2091
+ const data = json?.result?.data;
2092
+ if (data) {
2093
+ setBrandConfig(data);
2094
+ }
2095
+ } catch {
2096
+ // API unavailable — env var defaults remain active
2097
+ }
2098
+ }
2099
+ ```
2100
+
2101
+ - [ ] **Step 2: Run type check**
2102
+
2103
+ Run: `npm run check` (in platform-ui-core)
2104
+ Expected: No type errors
2105
+
2106
+ - [ ] **Step 3: Commit**
2107
+
2108
+ ```bash
2109
+ git add src/lib/brand-config.ts
2110
+ git commit -m "feat: add initBrandConfig() for tRPC-based brand config loading"
2111
+ ```
2112
+
2113
+ ---
2114
+
2115
+ ## Phase 5: Backend Module Migration (platform-core)
2116
+
2117
+ > **Note:** These tasks modify how existing platform-core modules read config. Each task is a standalone migration of one module — they can be done in any order. Each product backend adopts `PRODUCT_SLUG` + `initProductConfig()` at its own pace.
2118
+
2119
+ ### Task 13: Platform Boot Function (eliminate code in products)
2120
+
2121
+ **Files:**
2122
+ - Create: `src/boot.ts` (in platform-core)
2123
+ - Modify: each product backend's `src/index.ts`
2124
+
2125
+ The goal: product backends call `platformBoot()` and platform-core auto-configures CORS, email, fleet defaults, brand config endpoint — all from DB. Products only add their own custom routes.
2126
+
2127
+ - [ ] **Step 1: Write platformBoot in platform-core**
2128
+
2129
+ ```typescript
2130
+ // src/boot.ts
2131
+ import type { Hono } from "hono";
2132
+ import type { DrizzleDb } from "./db/index.js";
2133
+ import { initProductConfig, getProductConfig, deriveCorsOrigins } from "./product-config/index.js";
2134
+
2135
+ export interface PlatformBootOptions {
2136
+ slug: string;
2137
+ db: DrizzleDb;
2138
+ app: Hono;
2139
+ /** Additional CORS origins (e.g. DEV_ORIGINS from env). */
2140
+ devOrigins?: string[];
2141
+ }
2142
+
2143
+ /**
2144
+ * Initialize platform-core modules from DB-driven product config.
2145
+ * Call once at startup, before serve().
2146
+ *
2147
+ * This replaces: BRAND_NAME, PLATFORM_DOMAIN, UI_ORIGIN, FROM_EMAIL,
2148
+ * SUPPORT_EMAIL, COOKIE_DOMAIN, and all other product-specific env vars
2149
+ * that platform-core modules previously read from process.env.
2150
+ */
2151
+ export async function platformBoot(opts: PlatformBootOptions): Promise<void> {
2152
+ const { slug, db, app, devOrigins = [] } = opts;
2153
+
2154
+ // 1. Initialize product config from DB
2155
+ initProductConfig(db);
2156
+ const config = await getProductConfig(slug);
2157
+ if (!config) throw new Error(`Product "${slug}" not found in DB. Run seed script.`);
2158
+
2159
+ // 2. Auto-configure CORS from product domains
2160
+ const origins = [...deriveCorsOrigins(config.product, config.domains), ...devOrigins];
2161
+ // Wire into existing CORS middleware (implementation depends on current CORS setup)
2162
+
2163
+ // 3. Auto-configure email (brand name, from email, support email)
2164
+ // Wire into existing notification service
2165
+
2166
+ // 4. Auto-configure fleet defaults (lifecycle, billing model, container image)
2167
+ // Wire into fleet manager initialization
2168
+
2169
+ // 5. Register product config tRPC endpoints
2170
+ // Already handled by router composition
2171
+ }
2172
+ ```
2173
+
2174
+ - [ ] **Step 2: Migrate product backends one at a time**
2175
+
2176
+ Each product backend shrinks its `config.ts` to just infrastructure vars and calls `platformBoot()`:
2177
+
2178
+ ```typescript
2179
+ // paperclip-platform/src/index.ts — AFTER
2180
+ import { platformBoot } from "@wopr-network/platform-core";
2181
+
2182
+ // After DB init:
2183
+ await platformBoot({
2184
+ slug: process.env.PRODUCT_SLUG ?? "paperclip",
2185
+ db,
2186
+ app,
2187
+ devOrigins: process.env.DEV_ORIGINS?.split(","),
2188
+ });
2189
+ ```
2190
+
2191
+ - [ ] **Step 3: Migrate platform-core modules to read from product config**
2192
+
2193
+ Each module migration is a separate commit. Priority:
2194
+
2195
+ 1. **Email templates** — `brand_name`, `from_email`, `support_email` read from `getProductConfig()`
2196
+ 2. **CORS middleware** — origins derived from `product.domain` + `product.appDomain` + `product_domains`
2197
+ 3. **Fleet manager** — `container_image`, `lifecycle`, `billing_model` from `product_fleet_config`
2198
+ 4. **Billing module** — `credit_prices`, `affiliate_*` from `product_billing_config`
2199
+ 5. **Onboarding** — feature flags from `product_features`
2200
+ 6. **Auth/cookie** — `cookie_domain` from `product.cookieDomain`
2201
+
2202
+ - [ ] **Step 4: Each product backend removes absorbed env vars from its config.ts**
2203
+
2204
+ Paperclip's `config.ts` goes from ~30 env vars to ~12. The removed ones are now in DB, accessed via `getProductConfig()` inside platform-core modules.
2205
+
2206
+ - [ ] **Step 5: Commit per module**
2207
+
2208
+ ```bash
2209
+ git commit -m "feat: add platformBoot() to auto-configure core modules from DB"
2210
+ git commit -m "refactor: migrate email templates to read from product config DB"
2211
+ git commit -m "refactor: migrate CORS to derive origins from product config DB"
2212
+ ```
2213
+
2214
+ ---
2215
+
2216
+ ## Phase 6: Cleanup
2217
+
2218
+ ### Task 14: Remove Absorbed Env Vars
2219
+
2220
+ - [ ] **Step 1: Remove from .env files**
2221
+
2222
+ After all modules read from DB, remove the absorbed env vars from:
2223
+ - `platform-ui-core/.env.wopr`
2224
+ - `platform-ui-core/.env.paperclip`
2225
+ - Each product's `.env.example`
2226
+ - Each product's `docker-compose.yml` build args
2227
+
2228
+ - [ ] **Step 2: Remove from Zod schemas**
2229
+
2230
+ Remove absorbed fields from each product's `src/config.ts` Zod schema.
2231
+
2232
+ - [ ] **Step 3: Remove setBrandConfig overrides from thin shells**
2233
+
2234
+ Each product UI's `src/lib/brand-config.ts` override becomes empty or deleted — `initBrandConfig()` handles everything.
2235
+
2236
+ - [ ] **Step 4: Remove Dockerfile build args**
2237
+
2238
+ Remove `ARG NEXT_PUBLIC_BRAND_*` lines from each product UI's Dockerfile.
2239
+
2240
+ - [ ] **Step 5: Final commit**
2241
+
2242
+ ```bash
2243
+ git commit -m "chore: remove absorbed env vars, Zod fields, and Dockerfile build args"
2244
+ ```
2245
+
2246
+ ---
2247
+
2248
+ ## Verification Checklist
2249
+
2250
+ After all phases:
2251
+
2252
+ - [ ] `npm run check` passes in platform-core
2253
+ - [ ] `npm run check` passes in platform-ui-core
2254
+ - [ ] `npx vitest run src/product-config/` passes in platform-core
2255
+ - [ ] Seed script populates all 4 products: `npx tsx scripts/seed-products.ts --all`
2256
+ - [ ] Admin UI at `/admin/products` shows brand/nav/features/fleet/billing tabs
2257
+ - [ ] Changing nav items in admin UI reflects immediately on next page load
2258
+ - [ ] Each product backend starts with just `PRODUCT_SLUG` + infrastructure env vars
2259
+ - [ ] Holy Ship fleet config shows `lifecycle: ephemeral`, `billingModel: none`
2260
+ - [ ] Adding a 5th product = 1 seed script entry + 1 `PRODUCT_SLUG` env var