@wopr-network/platform-core 1.58.0 → 1.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/billing/crypto/key-server-entry.js +1 -0
- package/dist/billing/crypto/watcher-service.d.ts +2 -0
- package/dist/billing/crypto/watcher-service.js +7 -3
- 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/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/scripts/seed-products.ts +268 -0
- package/src/billing/crypto/key-server-entry.ts +1 -0
- package/src/billing/crypto/watcher-service.ts +8 -2
- 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,105 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IProductConfigRepository,
|
|
3
|
+
NavItemInput,
|
|
4
|
+
Product,
|
|
5
|
+
ProductBillingConfig,
|
|
6
|
+
ProductBrandConfig,
|
|
7
|
+
ProductBrandUpdate,
|
|
8
|
+
ProductConfig,
|
|
9
|
+
ProductFeatures,
|
|
10
|
+
ProductFleetConfig,
|
|
11
|
+
} from "./repository-types.js";
|
|
12
|
+
import { toBrandConfig } from "./repository-types.js";
|
|
13
|
+
|
|
14
|
+
interface CacheEntry {
|
|
15
|
+
config: ProductConfig;
|
|
16
|
+
expiresAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Single point of access for product configuration.
|
|
21
|
+
*
|
|
22
|
+
* Wraps IProductConfigRepository with an in-memory cache.
|
|
23
|
+
* All mutations automatically invalidate the cache — no caller
|
|
24
|
+
* needs to remember to invalidate. This is the ONLY public
|
|
25
|
+
* interface to product config; consumers never touch the repo directly.
|
|
26
|
+
*/
|
|
27
|
+
export class ProductConfigService {
|
|
28
|
+
private cache = new Map<string, CacheEntry>();
|
|
29
|
+
private ttlMs: number;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
private repo: IProductConfigRepository,
|
|
33
|
+
opts: { ttlMs?: number } = {},
|
|
34
|
+
) {
|
|
35
|
+
this.ttlMs = opts.ttlMs ?? 60_000;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Reads (cached)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
async getBySlug(slug: string): Promise<ProductConfig | null> {
|
|
43
|
+
const entry = this.cache.get(slug);
|
|
44
|
+
if (entry && Date.now() < entry.expiresAt) {
|
|
45
|
+
return entry.config;
|
|
46
|
+
}
|
|
47
|
+
const config = await this.repo.getBySlug(slug);
|
|
48
|
+
if (config) {
|
|
49
|
+
this.cache.set(slug, { config, expiresAt: Date.now() + this.ttlMs });
|
|
50
|
+
}
|
|
51
|
+
return config;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async getBrandConfig(slug: string): Promise<ProductBrandConfig | null> {
|
|
55
|
+
const config = await this.getBySlug(slug);
|
|
56
|
+
if (!config) return null;
|
|
57
|
+
return toBrandConfig(config);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async listAll(): Promise<ProductConfig[]> {
|
|
61
|
+
return this.repo.listAll();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Writes (auto-invalidate)
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
async upsertProduct(slug: string, data: ProductBrandUpdate): Promise<Product> {
|
|
69
|
+
const result = await this.repo.upsertProduct(slug, data);
|
|
70
|
+
this.invalidate(slug);
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async replaceNavItems(slug: string, productId: string, items: NavItemInput[]): Promise<void> {
|
|
75
|
+
await this.repo.replaceNavItems(productId, items);
|
|
76
|
+
this.invalidate(slug);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async upsertFeatures(slug: string, productId: string, data: Partial<ProductFeatures>): Promise<void> {
|
|
80
|
+
await this.repo.upsertFeatures(productId, data);
|
|
81
|
+
this.invalidate(slug);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async upsertFleetConfig(slug: string, productId: string, data: Partial<ProductFleetConfig>): Promise<void> {
|
|
85
|
+
await this.repo.upsertFleetConfig(productId, data);
|
|
86
|
+
this.invalidate(slug);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async upsertBillingConfig(slug: string, productId: string, data: Partial<ProductBillingConfig>): Promise<void> {
|
|
90
|
+
await this.repo.upsertBillingConfig(productId, data);
|
|
91
|
+
this.invalidate(slug);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Cache management
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
invalidate(slug: string): void {
|
|
99
|
+
this.cache.delete(slug);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
invalidateAll(): void {
|
|
103
|
+
this.cache.clear();
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/trpc/index.ts
CHANGED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { TRPCError } from "@trpc/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { ProductConfig } from "../product-config/repository-types.js";
|
|
4
|
+
import type { ProductConfigService } from "../product-config/service.js";
|
|
5
|
+
import { adminProcedure, publicProcedure, router } from "./init.js";
|
|
6
|
+
|
|
7
|
+
/** Mask Stripe secrets in admin responses — never send raw keys over the wire. */
|
|
8
|
+
function redactSecrets(config: ProductConfig): ProductConfig {
|
|
9
|
+
if (!config.billing) return config;
|
|
10
|
+
return {
|
|
11
|
+
...config,
|
|
12
|
+
billing: {
|
|
13
|
+
...config.billing,
|
|
14
|
+
stripeSecretKey: config.billing.stripeSecretKey ? "sk_...redacted" : null,
|
|
15
|
+
stripeWebhookSecret: config.billing.stripeWebhookSecret ? "whsec_...redacted" : null,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createProductConfigRouter(getService: () => ProductConfigService, productSlug: string) {
|
|
21
|
+
/** Resolve product id, throwing NOT_FOUND if the product doesn't exist. */
|
|
22
|
+
async function resolveProductId(): Promise<string> {
|
|
23
|
+
const config = await getService().getBySlug(productSlug);
|
|
24
|
+
if (!config) {
|
|
25
|
+
throw new TRPCError({ code: "NOT_FOUND", message: `Product not found: ${productSlug}` });
|
|
26
|
+
}
|
|
27
|
+
return config.product.id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return router({
|
|
31
|
+
// -----------------------------------------------------------------------
|
|
32
|
+
// Public endpoints
|
|
33
|
+
// -----------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
getBrandConfig: publicProcedure.query(async () => {
|
|
36
|
+
return getService().getBrandConfig(productSlug);
|
|
37
|
+
}),
|
|
38
|
+
|
|
39
|
+
getNavItems: publicProcedure.query(async () => {
|
|
40
|
+
const config = await getService().getBySlug(productSlug);
|
|
41
|
+
if (!config) return [];
|
|
42
|
+
return config.navItems.filter((n) => n.enabled).map((n) => ({ label: n.label, href: n.href }));
|
|
43
|
+
}),
|
|
44
|
+
|
|
45
|
+
// -----------------------------------------------------------------------
|
|
46
|
+
// Admin endpoints
|
|
47
|
+
// -----------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
admin: router({
|
|
50
|
+
get: adminProcedure.query(async () => {
|
|
51
|
+
const config = await getService().getBySlug(productSlug);
|
|
52
|
+
if (!config) return null;
|
|
53
|
+
return redactSecrets(config);
|
|
54
|
+
}),
|
|
55
|
+
|
|
56
|
+
listAll: adminProcedure.query(async () => {
|
|
57
|
+
const configs = await getService().listAll();
|
|
58
|
+
return configs.map(redactSecrets);
|
|
59
|
+
}),
|
|
60
|
+
|
|
61
|
+
updateBrand: adminProcedure
|
|
62
|
+
.input(
|
|
63
|
+
z.object({
|
|
64
|
+
brandName: z.string().min(1).optional(),
|
|
65
|
+
productName: z.string().min(1).optional(),
|
|
66
|
+
tagline: z.string().optional(),
|
|
67
|
+
domain: z.string().min(1).optional(),
|
|
68
|
+
appDomain: z.string().min(1).optional(),
|
|
69
|
+
cookieDomain: z.string().optional(),
|
|
70
|
+
companyLegal: z.string().optional(),
|
|
71
|
+
priceLabel: z.string().optional(),
|
|
72
|
+
defaultImage: z.string().optional(),
|
|
73
|
+
emailSupport: z.string().optional(),
|
|
74
|
+
emailPrivacy: z.string().optional(),
|
|
75
|
+
emailLegal: z.string().optional(),
|
|
76
|
+
fromEmail: z.string().optional(),
|
|
77
|
+
homePath: z.string().optional(),
|
|
78
|
+
storagePrefix: z.string().min(1).optional(),
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
.mutation(async ({ input }) => {
|
|
82
|
+
await getService().upsertProduct(productSlug, input);
|
|
83
|
+
}),
|
|
84
|
+
|
|
85
|
+
updateNavItems: adminProcedure
|
|
86
|
+
.input(
|
|
87
|
+
z.array(
|
|
88
|
+
z.object({
|
|
89
|
+
label: z.string().min(1),
|
|
90
|
+
href: z.string().min(1),
|
|
91
|
+
icon: z.string().optional(),
|
|
92
|
+
sortOrder: z.number().int().min(0),
|
|
93
|
+
requiresRole: z.string().optional(),
|
|
94
|
+
enabled: z.boolean().optional(),
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
.mutation(async ({ input }) => {
|
|
99
|
+
const productId = await resolveProductId();
|
|
100
|
+
await getService().replaceNavItems(productSlug, productId, input);
|
|
101
|
+
}),
|
|
102
|
+
|
|
103
|
+
updateFeatures: adminProcedure
|
|
104
|
+
.input(
|
|
105
|
+
z.object({
|
|
106
|
+
chatEnabled: z.boolean().optional(),
|
|
107
|
+
onboardingEnabled: z.boolean().optional(),
|
|
108
|
+
onboardingDefaultModel: z.string().optional(),
|
|
109
|
+
onboardingSystemPrompt: z.string().optional(),
|
|
110
|
+
onboardingMaxCredits: z.number().int().min(0).optional(),
|
|
111
|
+
onboardingWelcomeMsg: z.string().optional(),
|
|
112
|
+
sharedModuleBilling: z.boolean().optional(),
|
|
113
|
+
sharedModuleMonitoring: z.boolean().optional(),
|
|
114
|
+
sharedModuleAnalytics: z.boolean().optional(),
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
.mutation(async ({ input }) => {
|
|
118
|
+
const productId = await resolveProductId();
|
|
119
|
+
await getService().upsertFeatures(productSlug, productId, input);
|
|
120
|
+
}),
|
|
121
|
+
|
|
122
|
+
updateFleet: adminProcedure
|
|
123
|
+
.input(
|
|
124
|
+
z.object({
|
|
125
|
+
containerImage: z.string().optional(),
|
|
126
|
+
containerPort: z.number().int().optional(),
|
|
127
|
+
lifecycle: z.enum(["managed", "ephemeral"]).optional(),
|
|
128
|
+
billingModel: z.enum(["monthly", "per_use", "none"]).optional(),
|
|
129
|
+
maxInstances: z.number().int().min(1).optional(),
|
|
130
|
+
imageAllowlist: z.array(z.string()).optional(),
|
|
131
|
+
dockerNetwork: z.string().optional(),
|
|
132
|
+
placementStrategy: z.string().optional(),
|
|
133
|
+
fleetDataDir: z.string().optional(),
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
.mutation(async ({ input }) => {
|
|
137
|
+
const productId = await resolveProductId();
|
|
138
|
+
await getService().upsertFleetConfig(productSlug, productId, input);
|
|
139
|
+
}),
|
|
140
|
+
|
|
141
|
+
updateBilling: adminProcedure
|
|
142
|
+
.input(
|
|
143
|
+
z.object({
|
|
144
|
+
stripePublishableKey: z.string().optional(),
|
|
145
|
+
stripeSecretKey: z.string().optional(),
|
|
146
|
+
stripeWebhookSecret: z.string().optional(),
|
|
147
|
+
creditPrices: z.record(z.string(), z.number()).optional(),
|
|
148
|
+
affiliateBaseUrl: z.string().optional(),
|
|
149
|
+
affiliateMatchRate: z.number().min(0).optional(),
|
|
150
|
+
affiliateMaxCap: z.number().int().min(0).optional(),
|
|
151
|
+
dividendRate: z.number().min(0).optional(),
|
|
152
|
+
marginConfig: z.unknown().optional(),
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
.mutation(async ({ input }) => {
|
|
156
|
+
const productId = await resolveProductId();
|
|
157
|
+
await getService().upsertBillingConfig(productSlug, productId, input);
|
|
158
|
+
}),
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
}
|