@wopr-network/platform-core 1.58.1 → 1.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/billing/crypto/__tests__/key-server.test.js +3 -0
  2. package/dist/billing/crypto/key-server.js +1 -0
  3. package/dist/billing/crypto/oracle/coingecko.js +1 -0
  4. package/dist/billing/crypto/payment-method-store.d.ts +1 -0
  5. package/dist/billing/crypto/payment-method-store.js +3 -0
  6. package/dist/billing/crypto/tron/__tests__/address-convert.test.d.ts +1 -0
  7. package/dist/billing/crypto/tron/__tests__/address-convert.test.js +55 -0
  8. package/dist/billing/crypto/tron/address-convert.d.ts +14 -0
  9. package/dist/billing/crypto/tron/address-convert.js +83 -0
  10. package/dist/billing/crypto/watcher-service.js +21 -11
  11. package/dist/db/schema/crypto.d.ts +17 -0
  12. package/dist/db/schema/crypto.js +1 -0
  13. package/dist/db/schema/index.d.ts +2 -0
  14. package/dist/db/schema/index.js +2 -0
  15. package/dist/db/schema/product-config.d.ts +610 -0
  16. package/dist/db/schema/product-config.js +51 -0
  17. package/dist/db/schema/products.d.ts +565 -0
  18. package/dist/db/schema/products.js +43 -0
  19. package/dist/product-config/boot.d.ts +36 -0
  20. package/dist/product-config/boot.js +30 -0
  21. package/dist/product-config/drizzle-product-config-repository.d.ts +19 -0
  22. package/dist/product-config/drizzle-product-config-repository.js +200 -0
  23. package/dist/product-config/drizzle-product-config-repository.test.d.ts +1 -0
  24. package/dist/product-config/drizzle-product-config-repository.test.js +114 -0
  25. package/dist/product-config/index.d.ts +24 -0
  26. package/dist/product-config/index.js +37 -0
  27. package/dist/product-config/repository-types.d.ts +143 -0
  28. package/dist/product-config/repository-types.js +53 -0
  29. package/dist/product-config/service.d.ts +27 -0
  30. package/dist/product-config/service.js +74 -0
  31. package/dist/product-config/service.test.d.ts +1 -0
  32. package/dist/product-config/service.test.js +107 -0
  33. package/dist/trpc/index.d.ts +1 -0
  34. package/dist/trpc/index.js +1 -0
  35. package/dist/trpc/product-config-router.d.ts +117 -0
  36. package/dist/trpc/product-config-router.js +137 -0
  37. package/docs/specs/2026-03-23-product-config-db-migration-plan.md +2260 -0
  38. package/docs/specs/2026-03-23-product-config-db-migration.md +371 -0
  39. package/drizzle/migrations/0020_product_config_tables.sql +109 -0
  40. package/drizzle/migrations/0021_watcher_type_column.sql +3 -0
  41. package/drizzle/migrations/meta/_journal.json +7 -0
  42. package/package.json +1 -1
  43. package/scripts/seed-products.ts +268 -0
  44. package/src/billing/crypto/__tests__/key-server.test.ts +3 -0
  45. package/src/billing/crypto/key-server.ts +2 -0
  46. package/src/billing/crypto/oracle/coingecko.ts +1 -0
  47. package/src/billing/crypto/payment-method-store.ts +4 -0
  48. package/src/billing/crypto/tron/__tests__/address-convert.test.ts +67 -0
  49. package/src/billing/crypto/tron/address-convert.ts +80 -0
  50. package/src/billing/crypto/watcher-service.ts +24 -16
  51. package/src/db/schema/crypto.ts +1 -0
  52. package/src/db/schema/index.ts +2 -0
  53. package/src/db/schema/product-config.ts +56 -0
  54. package/src/db/schema/products.ts +58 -0
  55. package/src/product-config/boot.ts +57 -0
  56. package/src/product-config/drizzle-product-config-repository.test.ts +132 -0
  57. package/src/product-config/drizzle-product-config-repository.ts +229 -0
  58. package/src/product-config/index.ts +62 -0
  59. package/src/product-config/repository-types.ts +222 -0
  60. package/src/product-config/service.test.ts +127 -0
  61. package/src/product-config/service.ts +105 -0
  62. package/src/trpc/index.ts +1 -0
  63. package/src/trpc/product-config-router.ts +161 -0
@@ -0,0 +1,371 @@
1
+ # Product Configuration Database Migration
2
+
3
+ **Date:** 2026-03-23
4
+ **Status:** Draft
5
+ **Scope:** platform-core, platform-ui-core, all 4 product backends + UIs
6
+
7
+ ## Problem
8
+
9
+ 4 products (WOPR, Paperclip, Holy Ship, NemoClaw) are configured via environment variables. This worked at 1-2 products but is now unwieldy:
10
+
11
+ - **Adding a product** requires creating ~30 env vars across `.env` files, Dockerfiles, and docker-compose build args
12
+ - **Changing config** (routes, nav, pricing) requires a rebuild/redeploy
13
+ - **Complex data** is crammed into env vars (JSON nav items, multi-tier pricing)
14
+ - **Per-tenant overrides** are impossible without code changes
15
+ - **Backend repos get forked** per product with hardcoded domain strings
16
+
17
+ ### Current State
18
+
19
+ | Repo | Product-Configurable Env Vars | Pattern |
20
+ |------|------------------------------|---------|
21
+ | platform-core | ~28 | Zod schema reads `process.env` |
22
+ | platform-ui-core | ~18 | `NEXT_PUBLIC_BRAND_*` → `envDefaults()` → `setBrandConfig()` |
23
+ | paperclip-platform | ~30 | Own `config.ts` with Zod, imports platform-core |
24
+ | nemoclaw-platform | ~25 | Forked backend, hardcoded `nemopod.com` in source |
25
+ | holyship | ~15 | Hardcoded `holyship.wtf` in config, reimplements fleet for ephemeral containers |
26
+
27
+ **Total:** ~46 unique product-configurable vars, duplicated 4x across products = ~184 env var entries maintained.
28
+
29
+ ### Pain Points by Product
30
+
31
+ - **WOPR:** Low pain (it's the default everything was built for)
32
+ - **Paperclip:** Medium — clean thin shell pattern, but 19 `NEXT_PUBLIC_BRAND_*` vars + build-time injection
33
+ - **NemoClaw:** High — forked backend with hardcoded `nemopod.com` strings in source
34
+ - **Holy Ship:** Highest — reimplements 631 lines of fleet management because platform-core's fleet assumes persistent, monthly-billed containers
35
+
36
+ ## Design
37
+
38
+ ### Principle
39
+
40
+ **Config in DB, custom behavior in code.** The database eliminates env var sprawl and unlocks behavioral knobs. Product backends still own their specific provisioning logic (e.g., Holy Ship's 7-step ephemeral lifecycle).
41
+
42
+ ### What Moves to DB (~46 vars)
43
+
44
+ Product identity, branding, navigation, feature flags, fleet configuration, billing configuration.
45
+
46
+ ### What Stays as Env Vars (~12 per product)
47
+
48
+ Infrastructure: `DATABASE_URL`, `PORT`, `HOST`, `NODE_ENV`, `PRODUCT_SLUG`, `PROVISION_SECRET`, `GATEWAY_URL`, `GATEWAY_API_KEY`, `OPENROUTER_API_KEY`, `CADDY_ADMIN_URL`, `CLOUDFLARE_API_TOKEN`, `ADMIN_API_KEY`.
49
+
50
+ These are deployment-specific, secret, or infrastructure-wiring concerns that don't belong in a shared database.
51
+
52
+ Additionally, `DEV_ORIGINS` (local dev CORS origins) stays as an env var — it's per-developer, not per-product.
53
+
54
+ ### New Env Var: `PRODUCT_SLUG`
55
+
56
+ Each product backend sets one env var that identifies which DB row to read:
57
+
58
+ ```
59
+ PRODUCT_SLUG=paperclip # everything else comes from DB
60
+ ```
61
+
62
+ ## Schema
63
+
64
+ ### `products`
65
+
66
+ The anchor table. One row per product deployment.
67
+
68
+ ```sql
69
+ CREATE TABLE products (
70
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
71
+ slug TEXT NOT NULL UNIQUE, -- 'wopr', 'paperclip', 'holyship', 'nemoclaw'
72
+ brand_name TEXT NOT NULL, -- 'Paperclip'
73
+ product_name TEXT NOT NULL, -- 'Paperclip'
74
+ tagline TEXT NOT NULL DEFAULT '',
75
+ domain TEXT NOT NULL, -- 'runpaperclip.com'
76
+ app_domain TEXT NOT NULL, -- 'app.runpaperclip.com'
77
+ cookie_domain TEXT NOT NULL, -- '.runpaperclip.com'
78
+ company_legal TEXT NOT NULL DEFAULT '',
79
+ price_label TEXT NOT NULL DEFAULT '',
80
+ default_image TEXT NOT NULL DEFAULT '',
81
+ email_support TEXT NOT NULL DEFAULT '',
82
+ email_privacy TEXT NOT NULL DEFAULT '',
83
+ email_legal TEXT NOT NULL DEFAULT '',
84
+ from_email TEXT NOT NULL DEFAULT '', -- noreply@runpaperclip.com
85
+ home_path TEXT NOT NULL DEFAULT '/marketplace',
86
+ storage_prefix TEXT NOT NULL, -- 'paperclip' (derives envVarPrefix, toolPrefix, eventPrefix, tenantCookieName)
87
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
88
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
89
+ );
90
+ ```
91
+
92
+ **Replaces:** all 18 `NEXT_PUBLIC_BRAND_*` env vars + `BRAND_NAME`, `SUPPORT_EMAIL`, `PLATFORM_DOMAIN`, `FROM_EMAIL`, `APP_BASE_URL` from backend configs.
93
+
94
+ **CORS derivation:** `UI_ORIGIN` is no longer an env var. The backend computes allowed origins from `products.domain` + `products.app_domain` + `product_domains` entries + `DEV_ORIGINS` env var (local dev only). Example: product with `domain=runpaperclip.com`, `app_domain=app.runpaperclip.com` → CORS allows `https://runpaperclip.com`, `https://app.runpaperclip.com`.
95
+
96
+ **Derived fields (computed in code, not stored):**
97
+ - `envVarPrefix` = `storage_prefix.toUpperCase()` (e.g., `PAPERCLIP`)
98
+ - `toolPrefix` = `storage_prefix` (e.g., `paperclip`)
99
+ - `eventPrefix` = `storage_prefix` (e.g., `paperclip`)
100
+ - `tenantCookieName` = `${storage_prefix}_tenant_id`
101
+
102
+ Same derivation logic already in `setBrandConfig()`.
103
+
104
+ ### `product_nav_items`
105
+
106
+ Replaces `NEXT_PUBLIC_BRAND_NAV_ITEMS` JSON blob and thin shell `setBrandConfig({ navItems })` overrides.
107
+
108
+ ```sql
109
+ CREATE TABLE product_nav_items (
110
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
111
+ product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
112
+ label TEXT NOT NULL, -- 'Ship'
113
+ href TEXT NOT NULL, -- '/ship'
114
+ icon TEXT, -- optional icon identifier
115
+ sort_order INTEGER NOT NULL, -- display order
116
+ requires_role TEXT, -- null = everyone, 'platform_admin' = admin only
117
+ enabled BOOLEAN NOT NULL DEFAULT true,
118
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
119
+ );
120
+
121
+ CREATE INDEX idx_product_nav_items_product ON product_nav_items(product_id, sort_order);
122
+ ```
123
+
124
+ **Admin UI:** drag-and-drop reorder, toggle visibility, add/remove items per product. No code changes to modify navigation.
125
+
126
+ ### `product_features`
127
+
128
+ Replaces `NEXT_PUBLIC_BRAND_CHAT_ENABLED`, `ONBOARDING_*` env vars, `SHARED_MODULE_*` flags.
129
+
130
+ ```sql
131
+ CREATE TABLE product_features (
132
+ product_id UUID PRIMARY KEY REFERENCES products(id) ON DELETE CASCADE,
133
+ chat_enabled BOOLEAN NOT NULL DEFAULT true,
134
+ onboarding_enabled BOOLEAN NOT NULL DEFAULT true,
135
+ onboarding_default_model TEXT,
136
+ onboarding_system_prompt TEXT,
137
+ onboarding_max_credits INTEGER NOT NULL DEFAULT 100,
138
+ onboarding_welcome_msg TEXT,
139
+ shared_module_billing BOOLEAN NOT NULL DEFAULT true,
140
+ shared_module_monitoring BOOLEAN NOT NULL DEFAULT true,
141
+ shared_module_analytics BOOLEAN NOT NULL DEFAULT true,
142
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
143
+ );
144
+ ```
145
+
146
+ ### `product_fleet_config`
147
+
148
+ Replaces `PAPERCLIP_IMAGE`, `FLEET_*`, `MAX_INSTANCES_PER_TENANT`, `NEMOCLAW_IMAGE`, `FLEET_IMAGE_ALLOWLIST` env vars. **Solves the Holy Ship ephemeral container problem** by making lifecycle and billing model explicit knobs.
149
+
150
+ ```sql
151
+ CREATE TYPE fleet_lifecycle AS ENUM ('managed', 'ephemeral');
152
+ CREATE TYPE fleet_billing_model AS ENUM ('monthly', 'per_use', 'none');
153
+
154
+ CREATE TABLE product_fleet_config (
155
+ product_id UUID PRIMARY KEY REFERENCES products(id) ON DELETE CASCADE,
156
+ container_image TEXT NOT NULL, -- 'ghcr.io/wopr-network/paperclip:managed'
157
+ container_port INTEGER NOT NULL DEFAULT 3100,
158
+ lifecycle fleet_lifecycle NOT NULL DEFAULT 'managed',
159
+ billing_model fleet_billing_model NOT NULL DEFAULT 'monthly',
160
+ max_instances INTEGER NOT NULL DEFAULT 5,
161
+ image_allowlist TEXT[], -- null = any, or ['ghcr.io/wopr-network/nemoclaw:*']
162
+ docker_network TEXT NOT NULL DEFAULT '',
163
+ placement_strategy TEXT NOT NULL DEFAULT 'least-loaded',
164
+ fleet_data_dir TEXT NOT NULL DEFAULT '/data/fleet',
165
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
166
+ );
167
+ ```
168
+
169
+ **How fleet code uses this:**
170
+ - `lifecycle = 'ephemeral'` → skip persistent instance tracking, auto-teardown on completion
171
+ - `billing_model = 'none'` → skip runtime billing cron for this product's containers
172
+ - `billing_model = 'per_use'` → bill at gateway layer (credit-gate), not per-container
173
+
174
+ Product backends still own their specific provisioning logic. Holy Ship's `HolyshipperFleetManager` (credential injection, repo checkout, worker pool) stays in the holyship repo. It just reads fleet config from DB instead of env vars.
175
+
176
+ ### `product_billing_config`
177
+
178
+ Replaces `STRIPE_*`, `AFFILIATE_*`, `MARGIN_CONFIG_JSON` env vars.
179
+
180
+ ```sql
181
+ CREATE TABLE product_billing_config (
182
+ product_id UUID PRIMARY KEY REFERENCES products(id) ON DELETE CASCADE,
183
+ stripe_publishable_key TEXT,
184
+ stripe_secret_key TEXT, -- encrypted at rest
185
+ stripe_webhook_secret TEXT, -- encrypted at rest
186
+ credit_prices JSONB NOT NULL DEFAULT '{}', -- {"5": 500, "20": 2000, "50": 5000, "100": 10000, "500": 50000}
187
+ affiliate_base_url TEXT,
188
+ affiliate_match_rate NUMERIC NOT NULL DEFAULT 1.0,
189
+ affiliate_max_cap INTEGER NOT NULL DEFAULT 20000,
190
+ dividend_rate NUMERIC NOT NULL DEFAULT 1.0,
191
+ margin_config JSONB, -- arbitrage rules
192
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
193
+ );
194
+ ```
195
+
196
+ **Note on Stripe secrets:** These are per-product (each product has its own Stripe account). Encrypted using the existing `CRYPTO_SERVICE_KEY` envelope encryption in platform-core's credential vault.
197
+
198
+ ### `product_domains`
199
+
200
+ Optional multi-domain support (Holy Ship uses holyship.wtf canonical + holyship.dev redirect).
201
+
202
+ ```sql
203
+ CREATE TABLE product_domains (
204
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
205
+ product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
206
+ host TEXT NOT NULL, -- 'holyship.wtf'
207
+ role TEXT NOT NULL DEFAULT 'canonical', -- 'canonical' | 'redirect'
208
+ UNIQUE(product_id, host)
209
+ );
210
+ ```
211
+
212
+ Maps directly to the existing `BrandDomain` interface in `brand-config.ts`.
213
+
214
+ ## tRPC Endpoints
215
+
216
+ New router: `product` (in platform-core, composed into each product's appRouter).
217
+
218
+ ### Public (cached aggressively)
219
+
220
+ ```typescript
221
+ product.getBrandConfig // → BrandConfig (full UI config including nav, features)
222
+ product.getNavItems // → NavItem[] (ordered, filtered by role)
223
+ product.getFeatures // → FeatureFlags
224
+ ```
225
+
226
+ ### Admin (platform_admin role)
227
+
228
+ ```typescript
229
+ product.admin.get // → full ProductConfig
230
+ product.admin.updateBrand // → update products table
231
+ product.admin.updateNavItems // → replace product_nav_items rows
232
+ product.admin.updateFeatures // → update product_features row
233
+ product.admin.updateFleet // → update product_fleet_config row
234
+ product.admin.updateBilling // → update product_billing_config row
235
+ ```
236
+
237
+ ### Backend (internal)
238
+
239
+ ```typescript
240
+ product.internal.getFleetConfig // → FleetConfig (for fleet module)
241
+ product.internal.getBillingConfig // → BillingConfig (for billing module)
242
+ product.internal.getFullConfig // → everything (startup cache)
243
+ ```
244
+
245
+ ## UI Integration
246
+
247
+ ### How `brand-config.ts` Changes
248
+
249
+ ```typescript
250
+ // BEFORE: env vars at build time
251
+ let _config: BrandConfig = envDefaults();
252
+
253
+ // AFTER: env vars as fallback, DB via tRPC as source of truth
254
+ let _config: BrandConfig = envDefaults(); // still works for local dev
255
+
256
+ export async function initBrandConfig(): Promise<void> {
257
+ try {
258
+ const dbConfig = await trpc.product.getBrandConfig.query();
259
+ setBrandConfig(dbConfig);
260
+ } catch {
261
+ // Fall back to env vars — local dev or tRPC unavailable
262
+ console.warn('Failed to fetch brand config from API, using env defaults');
263
+ }
264
+ }
265
+ ```
266
+
267
+ Called once in the root layout's server component. Cached aggressively (revalidate every 60s or on admin mutation).
268
+
269
+ ### Thin Shells After Migration
270
+
271
+ Each product's UI shell shrinks from "19 env vars + setBrandConfig override" to nearly nothing:
272
+
273
+ ```typescript
274
+ // paperclip-platform-ui/src/app/layout.tsx — AFTER
275
+ import { initBrandConfig } from "@core/lib/brand-config";
276
+
277
+ export default async function RootLayout({ children }) {
278
+ await initBrandConfig(); // fetches from DB via tRPC
279
+ return <html>...</html>;
280
+ }
281
+ ```
282
+
283
+ No `.env.paperclip`, no `.env.brand`, no `setBrandConfig({ navItems: [...] })`. The thin shell just needs `NEXT_PUBLIC_API_URL` pointing at the right backend — which already knows its `PRODUCT_SLUG`.
284
+
285
+ ### Admin UI
286
+
287
+ New `/admin/products` page (added to platform-ui-core):
288
+
289
+ **Sections:**
290
+ 1. **Brand Identity** — product name, tagline, domain, emails, legal name, pricing label
291
+ 2. **Navigation** — drag-and-drop nav item editor, per-item role gating, enable/disable
292
+ 3. **Features** — toggle switches for chat, onboarding, shared modules
293
+ 4. **Fleet** — lifecycle dropdown (managed/ephemeral), billing model, container image, limits
294
+ 5. **Billing** — Stripe keys (masked), credit price tiers, affiliate config, margin rules
295
+
296
+ Each section saves independently via the admin tRPC mutations.
297
+
298
+ ## Migration Path
299
+
300
+ ### Phase 1: Tables + Seed (no behavior change)
301
+
302
+ 1. Add Drizzle schema for all 6 tables
303
+ 2. Run `drizzle-kit generate` for migration
304
+ 3. Seed script reads current env vars and populates DB rows for all 4 products
305
+ 4. All existing behavior unchanged — modules still read env vars
306
+
307
+ ### Phase 2: tRPC Endpoints + Admin UI
308
+
309
+ 1. Add `product` tRPC router to platform-core
310
+ 2. Add `/admin/products` page to platform-ui-core
311
+ 3. Admin can now view/edit product config in DB
312
+ 4. Modules still read env vars (DB is source of truth for admin, not yet for runtime)
313
+
314
+ ### Phase 3: Backend Migration (one module at a time)
315
+
316
+ 1. Add `getProductConfig(slug)` to platform-core — reads from DB, caches in memory
317
+ 2. Migrate fleet module: read `container_image`, `lifecycle`, `billing_model` from DB
318
+ 3. Migrate billing module: read `credit_prices`, `stripe_*`, `affiliate_*` from DB
319
+ 4. Migrate auth/CORS: read `domain`, `app_domain`, `cookie_domain` from DB
320
+ 5. Migrate email: read `from_email`, `brand_name`, `support_email` from DB
321
+ 6. Each product backend's `config.ts` shrinks as env vars get absorbed
322
+
323
+ ### Phase 4: Frontend Migration
324
+
325
+ 1. Add `initBrandConfig()` that fetches from tRPC
326
+ 2. Call in root layout server component
327
+ 3. Remove `NEXT_PUBLIC_BRAND_*` from `.env` files
328
+ 4. Remove `setBrandConfig()` overrides from thin shells
329
+ 5. Thin shells reduce to just `NEXT_PUBLIC_API_URL`
330
+
331
+ ### Phase 5: Cleanup
332
+
333
+ 1. Remove absorbed env vars from `.env.example` files
334
+ 2. Remove absorbed Zod schema fields from product `config.ts` files
335
+ 3. Remove `.env.wopr`, `.env.paperclip`, `.env.holyship` preset files
336
+ 4. Update Dockerfiles to remove build args that are no longer needed
337
+ 5. Update docker-compose files to remove env var forwarding
338
+
339
+ ## Per-Tenant Overrides (Future)
340
+
341
+ Once products are in DB, per-tenant overrides become natural:
342
+
343
+ ```sql
344
+ CREATE TABLE tenant_config_overrides (
345
+ tenant_id UUID NOT NULL REFERENCES tenants(id),
346
+ product_id UUID NOT NULL REFERENCES products(id),
347
+ key TEXT NOT NULL, -- 'nav_items', 'chat_enabled', 'max_instances'
348
+ value JSONB NOT NULL,
349
+ PRIMARY KEY (tenant_id, product_id, key)
350
+ );
351
+ ```
352
+
353
+ Resolution order: tenant override → product config → platform defaults.
354
+
355
+ Not in scope for this migration, but the schema is designed to support it.
356
+
357
+ ## Risks
358
+
359
+ 1. **Cache invalidation** — admin changes brand config, UI serves stale config. Mitigation: 60s TTL on tRPC cache + cache-bust on admin mutation.
360
+ 2. **Cold start** — first request after deploy hits DB for config. Mitigation: backend pre-fetches config on startup and caches in memory.
361
+ 3. **Stripe secrets in DB** — must be encrypted at rest. Mitigation: use existing platform-core credential vault (`CRYPTO_SERVICE_KEY` envelope encryption).
362
+ 4. **Next.js middleware** — `proxy.ts` reads `NEXT_PUBLIC_BRAND_HOME_PATH` at build time for auth redirects. A tRPC fetch per middleware invocation is too expensive. **Solution:** the product backend exposes a `GET /api/product-config` endpoint that the UI fetches once at server startup and caches in a module-level variable. Middleware reads from this in-memory cache. Cache is refreshed on admin mutation via a webhook or on a 60s interval. `NEXT_PUBLIC_BRAND_HOME_PATH` env var remains as a build-time fallback for the edge case where the API is unreachable at startup.
363
+
364
+ ## Success Criteria
365
+
366
+ - [ ] Adding a 5th product requires: 1 DB seed script, 1 new `PRODUCT_SLUG` env var, 0 new `.env` files
367
+ - [ ] Changing nav items, feature flags, or pricing requires: admin UI click, 0 redeploys
368
+ - [ ] Holy Ship fleet config reads `lifecycle: ephemeral` + `billing_model: none` from DB
369
+ - [ ] Each product backend's `config.ts` has <=12 env vars (down from 25-30)
370
+ - [ ] Brand config served via tRPC, not baked into Next.js bundle
371
+ - [ ] Per-tenant overrides are structurally possible (table exists or can be added trivially)
@@ -0,0 +1,109 @@
1
+ -- Product configuration tables.
2
+ -- Moves ~46 product-configurable env vars into DB.
3
+ -- See docs/specs/2026-03-23-product-config-db-migration.md
4
+
5
+ -- Enums
6
+ CREATE TYPE "fleet_lifecycle" AS ENUM ('managed', 'ephemeral');
7
+ --> statement-breakpoint
8
+ CREATE TYPE "fleet_billing_model" AS ENUM ('monthly', 'per_use', 'none');
9
+ --> statement-breakpoint
10
+
11
+ -- Products (anchor table)
12
+ CREATE TABLE IF NOT EXISTS "products" (
13
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
14
+ "slug" text NOT NULL,
15
+ "brand_name" text NOT NULL,
16
+ "product_name" text NOT NULL,
17
+ "tagline" text NOT NULL DEFAULT '',
18
+ "domain" text NOT NULL,
19
+ "app_domain" text NOT NULL,
20
+ "cookie_domain" text NOT NULL,
21
+ "company_legal" text NOT NULL DEFAULT '',
22
+ "price_label" text NOT NULL DEFAULT '',
23
+ "default_image" text NOT NULL DEFAULT '',
24
+ "email_support" text NOT NULL DEFAULT '',
25
+ "email_privacy" text NOT NULL DEFAULT '',
26
+ "email_legal" text NOT NULL DEFAULT '',
27
+ "from_email" text NOT NULL DEFAULT '',
28
+ "home_path" text NOT NULL DEFAULT '/marketplace',
29
+ "storage_prefix" text NOT NULL,
30
+ "created_at" timestamptz NOT NULL DEFAULT now(),
31
+ "updated_at" timestamptz NOT NULL DEFAULT now()
32
+ );
33
+ --> statement-breakpoint
34
+ CREATE UNIQUE INDEX IF NOT EXISTS "products_slug_idx" ON "products" ("slug");
35
+ --> statement-breakpoint
36
+
37
+ -- Product navigation items
38
+ CREATE TABLE IF NOT EXISTS "product_nav_items" (
39
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
40
+ "product_id" uuid NOT NULL REFERENCES "products"("id") ON DELETE CASCADE,
41
+ "label" text NOT NULL,
42
+ "href" text NOT NULL,
43
+ "icon" text,
44
+ "sort_order" integer NOT NULL,
45
+ "requires_role" text,
46
+ "enabled" boolean NOT NULL DEFAULT true,
47
+ "created_at" timestamptz NOT NULL DEFAULT now()
48
+ );
49
+ --> statement-breakpoint
50
+ CREATE INDEX IF NOT EXISTS "product_nav_items_product_sort_idx" ON "product_nav_items" ("product_id", "sort_order");
51
+ --> statement-breakpoint
52
+
53
+ -- Product domains (multi-domain support)
54
+ CREATE TABLE IF NOT EXISTS "product_domains" (
55
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
56
+ "product_id" uuid NOT NULL REFERENCES "products"("id") ON DELETE CASCADE,
57
+ "host" text NOT NULL,
58
+ "role" text NOT NULL DEFAULT 'canonical'
59
+ );
60
+ --> statement-breakpoint
61
+ CREATE UNIQUE INDEX IF NOT EXISTS "product_domains_product_host_idx" ON "product_domains" ("product_id", "host");
62
+ --> statement-breakpoint
63
+
64
+ -- Product feature flags
65
+ CREATE TABLE IF NOT EXISTS "product_features" (
66
+ "product_id" uuid PRIMARY KEY REFERENCES "products"("id") ON DELETE CASCADE,
67
+ "chat_enabled" boolean NOT NULL DEFAULT true,
68
+ "onboarding_enabled" boolean NOT NULL DEFAULT true,
69
+ "onboarding_default_model" text,
70
+ "onboarding_system_prompt" text,
71
+ "onboarding_max_credits" integer NOT NULL DEFAULT 100,
72
+ "onboarding_welcome_msg" text,
73
+ "shared_module_billing" boolean NOT NULL DEFAULT true,
74
+ "shared_module_monitoring" boolean NOT NULL DEFAULT true,
75
+ "shared_module_analytics" boolean NOT NULL DEFAULT true,
76
+ "updated_at" timestamptz NOT NULL DEFAULT now()
77
+ );
78
+ --> statement-breakpoint
79
+
80
+ -- Product fleet configuration
81
+ CREATE TABLE IF NOT EXISTS "product_fleet_config" (
82
+ "product_id" uuid PRIMARY KEY REFERENCES "products"("id") ON DELETE CASCADE,
83
+ "container_image" text NOT NULL,
84
+ "container_port" integer NOT NULL DEFAULT 3100,
85
+ "lifecycle" "fleet_lifecycle" NOT NULL DEFAULT 'managed',
86
+ "billing_model" "fleet_billing_model" NOT NULL DEFAULT 'monthly',
87
+ "max_instances" integer NOT NULL DEFAULT 5,
88
+ "image_allowlist" text[],
89
+ "docker_network" text NOT NULL DEFAULT '',
90
+ "placement_strategy" text NOT NULL DEFAULT 'least-loaded',
91
+ "fleet_data_dir" text NOT NULL DEFAULT '/data/fleet',
92
+ "updated_at" timestamptz NOT NULL DEFAULT now()
93
+ );
94
+ --> statement-breakpoint
95
+
96
+ -- Product billing configuration
97
+ CREATE TABLE IF NOT EXISTS "product_billing_config" (
98
+ "product_id" uuid PRIMARY KEY REFERENCES "products"("id") ON DELETE CASCADE,
99
+ "stripe_publishable_key" text,
100
+ "stripe_secret_key" text,
101
+ "stripe_webhook_secret" text,
102
+ "credit_prices" jsonb NOT NULL DEFAULT '{}',
103
+ "affiliate_base_url" text,
104
+ "affiliate_match_rate" numeric NOT NULL DEFAULT 1.0,
105
+ "affiliate_max_cap" integer NOT NULL DEFAULT 20000,
106
+ "dividend_rate" numeric NOT NULL DEFAULT 1.0,
107
+ "margin_config" jsonb,
108
+ "updated_at" timestamptz NOT NULL DEFAULT now()
109
+ );
@@ -0,0 +1,3 @@
1
+ ALTER TABLE "payment_methods" ADD COLUMN "watcher_type" text DEFAULT 'evm' NOT NULL;
2
+ --> statement-breakpoint
3
+ UPDATE "payment_methods" SET "watcher_type" = 'utxo' WHERE "chain" IN ('bitcoin', 'litecoin', 'dogecoin');
@@ -141,6 +141,13 @@
141
141
  "when": 1743264000000,
142
142
  "tag": "0019_icon_url_column",
143
143
  "breakpoints": true
144
+ },
145
+ {
146
+ "idx": 20,
147
+ "version": "7",
148
+ "when": 1743350400000,
149
+ "tag": "0020_product_config_tables",
150
+ "breakpoints": true
144
151
  }
145
152
  ]
146
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.58.1",
3
+ "version": "1.60.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",