@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.
- package/dist/billing/crypto/__tests__/key-server.test.js +3 -0
- package/dist/billing/crypto/key-server.js +1 -0
- package/dist/billing/crypto/oracle/coingecko.js +1 -0
- package/dist/billing/crypto/payment-method-store.d.ts +1 -0
- package/dist/billing/crypto/payment-method-store.js +3 -0
- package/dist/billing/crypto/tron/__tests__/address-convert.test.d.ts +1 -0
- package/dist/billing/crypto/tron/__tests__/address-convert.test.js +55 -0
- package/dist/billing/crypto/tron/address-convert.d.ts +14 -0
- package/dist/billing/crypto/tron/address-convert.js +83 -0
- package/dist/billing/crypto/watcher-service.js +21 -11
- package/dist/db/schema/crypto.d.ts +17 -0
- package/dist/db/schema/crypto.js +1 -0
- package/dist/db/schema/index.d.ts +2 -0
- package/dist/db/schema/index.js +2 -0
- package/dist/db/schema/product-config.d.ts +610 -0
- package/dist/db/schema/product-config.js +51 -0
- package/dist/db/schema/products.d.ts +565 -0
- package/dist/db/schema/products.js +43 -0
- package/dist/product-config/boot.d.ts +36 -0
- package/dist/product-config/boot.js +30 -0
- package/dist/product-config/drizzle-product-config-repository.d.ts +19 -0
- package/dist/product-config/drizzle-product-config-repository.js +200 -0
- package/dist/product-config/drizzle-product-config-repository.test.d.ts +1 -0
- package/dist/product-config/drizzle-product-config-repository.test.js +114 -0
- package/dist/product-config/index.d.ts +24 -0
- package/dist/product-config/index.js +37 -0
- package/dist/product-config/repository-types.d.ts +143 -0
- package/dist/product-config/repository-types.js +53 -0
- package/dist/product-config/service.d.ts +27 -0
- package/dist/product-config/service.js +74 -0
- package/dist/product-config/service.test.d.ts +1 -0
- package/dist/product-config/service.test.js +107 -0
- package/dist/trpc/index.d.ts +1 -0
- package/dist/trpc/index.js +1 -0
- package/dist/trpc/product-config-router.d.ts +117 -0
- package/dist/trpc/product-config-router.js +137 -0
- package/docs/specs/2026-03-23-product-config-db-migration-plan.md +2260 -0
- package/docs/specs/2026-03-23-product-config-db-migration.md +371 -0
- package/drizzle/migrations/0020_product_config_tables.sql +109 -0
- package/drizzle/migrations/0021_watcher_type_column.sql +3 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/scripts/seed-products.ts +268 -0
- package/src/billing/crypto/__tests__/key-server.test.ts +3 -0
- package/src/billing/crypto/key-server.ts +2 -0
- package/src/billing/crypto/oracle/coingecko.ts +1 -0
- package/src/billing/crypto/payment-method-store.ts +4 -0
- package/src/billing/crypto/tron/__tests__/address-convert.test.ts +67 -0
- package/src/billing/crypto/tron/address-convert.ts +80 -0
- package/src/billing/crypto/watcher-service.ts +24 -16
- package/src/db/schema/crypto.ts +1 -0
- package/src/db/schema/index.ts +2 -0
- package/src/db/schema/product-config.ts +56 -0
- package/src/db/schema/products.ts +58 -0
- package/src/product-config/boot.ts +57 -0
- package/src/product-config/drizzle-product-config-repository.test.ts +132 -0
- package/src/product-config/drizzle-product-config-repository.ts +229 -0
- package/src/product-config/index.ts +62 -0
- package/src/product-config/repository-types.ts +222 -0
- package/src/product-config/service.test.ts +127 -0
- package/src/product-config/service.ts +105 -0
- package/src/trpc/index.ts +1 -0
- 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
|
+
);
|
|
@@ -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
|
}
|