@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,107 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { ProductConfigService } from "./service.js";
3
+ function makeConfig(slug) {
4
+ const product = {
5
+ id: "test-id",
6
+ slug,
7
+ brandName: "Test",
8
+ productName: "Test",
9
+ tagline: "",
10
+ domain: "test.com",
11
+ appDomain: "app.test.com",
12
+ cookieDomain: ".test.com",
13
+ companyLegal: "",
14
+ priceLabel: "",
15
+ defaultImage: "",
16
+ emailSupport: "",
17
+ emailPrivacy: "",
18
+ emailLegal: "",
19
+ fromEmail: "",
20
+ homePath: "/dashboard",
21
+ storagePrefix: "test",
22
+ createdAt: new Date(),
23
+ updatedAt: new Date(),
24
+ };
25
+ return { product, navItems: [], domains: [], features: null, fleet: null, billing: null };
26
+ }
27
+ function mockRepo() {
28
+ return {
29
+ getBySlug: vi.fn().mockResolvedValue(makeConfig("test")),
30
+ listAll: vi.fn().mockResolvedValue([makeConfig("test")]),
31
+ upsertProduct: vi.fn().mockResolvedValue(makeConfig("test").product),
32
+ replaceNavItems: vi.fn().mockResolvedValue(undefined),
33
+ upsertFeatures: vi.fn().mockResolvedValue(undefined),
34
+ upsertFleetConfig: vi.fn().mockResolvedValue(undefined),
35
+ upsertBillingConfig: vi.fn().mockResolvedValue(undefined),
36
+ };
37
+ }
38
+ describe("ProductConfigService", () => {
39
+ let repo;
40
+ let service;
41
+ beforeEach(() => {
42
+ repo = mockRepo();
43
+ service = new ProductConfigService(repo, { ttlMs: 100 });
44
+ });
45
+ // --- Cache behavior ---
46
+ it("caches getBySlug results", async () => {
47
+ await service.getBySlug("test");
48
+ await service.getBySlug("test");
49
+ expect(repo.getBySlug).toHaveBeenCalledOnce();
50
+ });
51
+ it("refetches after TTL expires", async () => {
52
+ await service.getBySlug("test");
53
+ await new Promise((r) => setTimeout(r, 150));
54
+ await service.getBySlug("test");
55
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
56
+ });
57
+ it("does not cache null results", async () => {
58
+ repo.getBySlug.mockResolvedValue(null);
59
+ await service.getBySlug("missing");
60
+ await service.getBySlug("missing");
61
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
62
+ });
63
+ // --- Auto-invalidation ---
64
+ it("upsertProduct invalidates cache", async () => {
65
+ await service.getBySlug("test");
66
+ expect(repo.getBySlug).toHaveBeenCalledOnce();
67
+ await service.upsertProduct("test", { brandName: "Updated" });
68
+ await service.getBySlug("test");
69
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
70
+ });
71
+ it("replaceNavItems invalidates cache", async () => {
72
+ await service.getBySlug("test");
73
+ await service.replaceNavItems("test", "test-id", []);
74
+ await service.getBySlug("test");
75
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
76
+ });
77
+ it("upsertFeatures invalidates cache", async () => {
78
+ await service.getBySlug("test");
79
+ await service.upsertFeatures("test", "test-id", { chatEnabled: false });
80
+ await service.getBySlug("test");
81
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
82
+ });
83
+ it("upsertFleetConfig invalidates cache", async () => {
84
+ await service.getBySlug("test");
85
+ await service.upsertFleetConfig("test", "test-id", { lifecycle: "ephemeral" });
86
+ await service.getBySlug("test");
87
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
88
+ });
89
+ it("upsertBillingConfig invalidates cache", async () => {
90
+ await service.getBySlug("test");
91
+ await service.upsertBillingConfig("test", "test-id", { affiliateMaxCap: 100 });
92
+ await service.getBySlug("test");
93
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
94
+ });
95
+ // --- Brand config derivation ---
96
+ it("getBrandConfig returns derived brand config", async () => {
97
+ const brand = await service.getBrandConfig("test");
98
+ expect(brand).not.toBeNull();
99
+ expect(brand?.brandName).toBe("Test");
100
+ expect(brand?.domain).toBe("test.com");
101
+ });
102
+ it("getBrandConfig returns null for missing product", async () => {
103
+ repo.getBySlug.mockResolvedValue(null);
104
+ const brand = await service.getBrandConfig("missing");
105
+ expect(brand).toBeNull();
106
+ });
107
+ });
@@ -4,3 +4,4 @@ export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
4
4
  export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, type TRPCContext, tenantProcedure, } from "./init.js";
5
5
  export { createNotificationTemplateRouter } from "./notification-template-router.js";
6
6
  export { createOrgRemovePaymentMethodRouter, type OrgRemovePaymentMethodDeps, } from "./org-remove-payment-method-router.js";
7
+ export { createProductConfigRouter } from "./product-config-router.js";
@@ -4,3 +4,4 @@ export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
4
4
  export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, tenantProcedure, } from "./init.js";
5
5
  export { createNotificationTemplateRouter } from "./notification-template-router.js";
6
6
  export { createOrgRemovePaymentMethodRouter, } from "./org-remove-payment-method-router.js";
7
+ export { createProductConfigRouter } from "./product-config-router.js";
@@ -0,0 +1,117 @@
1
+ import type { ProductConfig } from "../product-config/repository-types.js";
2
+ import type { ProductConfigService } from "../product-config/service.js";
3
+ export declare function createProductConfigRouter(getService: () => ProductConfigService, productSlug: string): import("@trpc/server").TRPCBuiltRouter<{
4
+ ctx: import("./init.js").TRPCContext;
5
+ meta: object;
6
+ errorShape: import("@trpc/server").TRPCDefaultErrorShape;
7
+ transformer: false;
8
+ }, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
9
+ getBrandConfig: import("@trpc/server").TRPCQueryProcedure<{
10
+ input: void;
11
+ output: import("../product-config/repository-types.js").ProductBrandConfig | null;
12
+ meta: object;
13
+ }>;
14
+ getNavItems: import("@trpc/server").TRPCQueryProcedure<{
15
+ input: void;
16
+ output: {
17
+ label: string;
18
+ href: string;
19
+ }[];
20
+ meta: object;
21
+ }>;
22
+ admin: import("@trpc/server").TRPCBuiltRouter<{
23
+ ctx: import("./init.js").TRPCContext;
24
+ meta: object;
25
+ errorShape: import("@trpc/server").TRPCDefaultErrorShape;
26
+ transformer: false;
27
+ }, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
28
+ get: import("@trpc/server").TRPCQueryProcedure<{
29
+ input: void;
30
+ output: ProductConfig | null;
31
+ meta: object;
32
+ }>;
33
+ listAll: import("@trpc/server").TRPCQueryProcedure<{
34
+ input: void;
35
+ output: ProductConfig[];
36
+ meta: object;
37
+ }>;
38
+ updateBrand: import("@trpc/server").TRPCMutationProcedure<{
39
+ input: {
40
+ brandName?: string | undefined;
41
+ productName?: string | undefined;
42
+ tagline?: string | undefined;
43
+ domain?: string | undefined;
44
+ appDomain?: string | undefined;
45
+ cookieDomain?: string | undefined;
46
+ companyLegal?: string | undefined;
47
+ priceLabel?: string | undefined;
48
+ defaultImage?: string | undefined;
49
+ emailSupport?: string | undefined;
50
+ emailPrivacy?: string | undefined;
51
+ emailLegal?: string | undefined;
52
+ fromEmail?: string | undefined;
53
+ homePath?: string | undefined;
54
+ storagePrefix?: string | undefined;
55
+ };
56
+ output: void;
57
+ meta: object;
58
+ }>;
59
+ updateNavItems: import("@trpc/server").TRPCMutationProcedure<{
60
+ input: {
61
+ label: string;
62
+ href: string;
63
+ sortOrder: number;
64
+ icon?: string | undefined;
65
+ requiresRole?: string | undefined;
66
+ enabled?: boolean | undefined;
67
+ }[];
68
+ output: void;
69
+ meta: object;
70
+ }>;
71
+ updateFeatures: import("@trpc/server").TRPCMutationProcedure<{
72
+ input: {
73
+ chatEnabled?: boolean | undefined;
74
+ onboardingEnabled?: boolean | undefined;
75
+ onboardingDefaultModel?: string | undefined;
76
+ onboardingSystemPrompt?: string | undefined;
77
+ onboardingMaxCredits?: number | undefined;
78
+ onboardingWelcomeMsg?: string | undefined;
79
+ sharedModuleBilling?: boolean | undefined;
80
+ sharedModuleMonitoring?: boolean | undefined;
81
+ sharedModuleAnalytics?: boolean | undefined;
82
+ };
83
+ output: void;
84
+ meta: object;
85
+ }>;
86
+ updateFleet: import("@trpc/server").TRPCMutationProcedure<{
87
+ input: {
88
+ containerImage?: string | undefined;
89
+ containerPort?: number | undefined;
90
+ lifecycle?: "managed" | "ephemeral" | undefined;
91
+ billingModel?: "monthly" | "per_use" | "none" | undefined;
92
+ maxInstances?: number | undefined;
93
+ imageAllowlist?: string[] | undefined;
94
+ dockerNetwork?: string | undefined;
95
+ placementStrategy?: string | undefined;
96
+ fleetDataDir?: string | undefined;
97
+ };
98
+ output: void;
99
+ meta: object;
100
+ }>;
101
+ updateBilling: import("@trpc/server").TRPCMutationProcedure<{
102
+ input: {
103
+ stripePublishableKey?: string | undefined;
104
+ stripeSecretKey?: string | undefined;
105
+ stripeWebhookSecret?: string | undefined;
106
+ creditPrices?: Record<string, number> | undefined;
107
+ affiliateBaseUrl?: string | undefined;
108
+ affiliateMatchRate?: number | undefined;
109
+ affiliateMaxCap?: number | undefined;
110
+ dividendRate?: number | undefined;
111
+ marginConfig?: unknown;
112
+ };
113
+ output: void;
114
+ meta: object;
115
+ }>;
116
+ }>>;
117
+ }>>;
@@ -0,0 +1,137 @@
1
+ import { TRPCError } from "@trpc/server";
2
+ import { z } from "zod";
3
+ import { adminProcedure, publicProcedure, router } from "./init.js";
4
+ /** Mask Stripe secrets in admin responses — never send raw keys over the wire. */
5
+ function redactSecrets(config) {
6
+ if (!config.billing)
7
+ return config;
8
+ return {
9
+ ...config,
10
+ billing: {
11
+ ...config.billing,
12
+ stripeSecretKey: config.billing.stripeSecretKey ? "sk_...redacted" : null,
13
+ stripeWebhookSecret: config.billing.stripeWebhookSecret ? "whsec_...redacted" : null,
14
+ },
15
+ };
16
+ }
17
+ export function createProductConfigRouter(getService, productSlug) {
18
+ /** Resolve product id, throwing NOT_FOUND if the product doesn't exist. */
19
+ async function resolveProductId() {
20
+ const config = await getService().getBySlug(productSlug);
21
+ if (!config) {
22
+ throw new TRPCError({ code: "NOT_FOUND", message: `Product not found: ${productSlug}` });
23
+ }
24
+ return config.product.id;
25
+ }
26
+ return router({
27
+ // -----------------------------------------------------------------------
28
+ // Public endpoints
29
+ // -----------------------------------------------------------------------
30
+ getBrandConfig: publicProcedure.query(async () => {
31
+ return getService().getBrandConfig(productSlug);
32
+ }),
33
+ getNavItems: publicProcedure.query(async () => {
34
+ const config = await getService().getBySlug(productSlug);
35
+ if (!config)
36
+ return [];
37
+ return config.navItems.filter((n) => n.enabled).map((n) => ({ label: n.label, href: n.href }));
38
+ }),
39
+ // -----------------------------------------------------------------------
40
+ // Admin endpoints
41
+ // -----------------------------------------------------------------------
42
+ admin: router({
43
+ get: adminProcedure.query(async () => {
44
+ const config = await getService().getBySlug(productSlug);
45
+ if (!config)
46
+ return null;
47
+ return redactSecrets(config);
48
+ }),
49
+ listAll: adminProcedure.query(async () => {
50
+ const configs = await getService().listAll();
51
+ return configs.map(redactSecrets);
52
+ }),
53
+ updateBrand: adminProcedure
54
+ .input(z.object({
55
+ brandName: z.string().min(1).optional(),
56
+ productName: z.string().min(1).optional(),
57
+ tagline: z.string().optional(),
58
+ domain: z.string().min(1).optional(),
59
+ appDomain: z.string().min(1).optional(),
60
+ cookieDomain: z.string().optional(),
61
+ companyLegal: z.string().optional(),
62
+ priceLabel: z.string().optional(),
63
+ defaultImage: z.string().optional(),
64
+ emailSupport: z.string().optional(),
65
+ emailPrivacy: z.string().optional(),
66
+ emailLegal: z.string().optional(),
67
+ fromEmail: z.string().optional(),
68
+ homePath: z.string().optional(),
69
+ storagePrefix: z.string().min(1).optional(),
70
+ }))
71
+ .mutation(async ({ input }) => {
72
+ await getService().upsertProduct(productSlug, input);
73
+ }),
74
+ updateNavItems: adminProcedure
75
+ .input(z.array(z.object({
76
+ label: z.string().min(1),
77
+ href: z.string().min(1),
78
+ icon: z.string().optional(),
79
+ sortOrder: z.number().int().min(0),
80
+ requiresRole: z.string().optional(),
81
+ enabled: z.boolean().optional(),
82
+ })))
83
+ .mutation(async ({ input }) => {
84
+ const productId = await resolveProductId();
85
+ await getService().replaceNavItems(productSlug, productId, input);
86
+ }),
87
+ updateFeatures: adminProcedure
88
+ .input(z.object({
89
+ chatEnabled: z.boolean().optional(),
90
+ onboardingEnabled: z.boolean().optional(),
91
+ onboardingDefaultModel: z.string().optional(),
92
+ onboardingSystemPrompt: z.string().optional(),
93
+ onboardingMaxCredits: z.number().int().min(0).optional(),
94
+ onboardingWelcomeMsg: z.string().optional(),
95
+ sharedModuleBilling: z.boolean().optional(),
96
+ sharedModuleMonitoring: z.boolean().optional(),
97
+ sharedModuleAnalytics: z.boolean().optional(),
98
+ }))
99
+ .mutation(async ({ input }) => {
100
+ const productId = await resolveProductId();
101
+ await getService().upsertFeatures(productSlug, productId, input);
102
+ }),
103
+ updateFleet: adminProcedure
104
+ .input(z.object({
105
+ containerImage: z.string().optional(),
106
+ containerPort: z.number().int().optional(),
107
+ lifecycle: z.enum(["managed", "ephemeral"]).optional(),
108
+ billingModel: z.enum(["monthly", "per_use", "none"]).optional(),
109
+ maxInstances: z.number().int().min(1).optional(),
110
+ imageAllowlist: z.array(z.string()).optional(),
111
+ dockerNetwork: z.string().optional(),
112
+ placementStrategy: z.string().optional(),
113
+ fleetDataDir: z.string().optional(),
114
+ }))
115
+ .mutation(async ({ input }) => {
116
+ const productId = await resolveProductId();
117
+ await getService().upsertFleetConfig(productSlug, productId, input);
118
+ }),
119
+ updateBilling: adminProcedure
120
+ .input(z.object({
121
+ stripePublishableKey: z.string().optional(),
122
+ stripeSecretKey: z.string().optional(),
123
+ stripeWebhookSecret: z.string().optional(),
124
+ creditPrices: z.record(z.string(), z.number()).optional(),
125
+ affiliateBaseUrl: z.string().optional(),
126
+ affiliateMatchRate: z.number().min(0).optional(),
127
+ affiliateMaxCap: z.number().int().min(0).optional(),
128
+ dividendRate: z.number().min(0).optional(),
129
+ marginConfig: z.unknown().optional(),
130
+ }))
131
+ .mutation(async ({ input }) => {
132
+ const productId = await resolveProductId();
133
+ await getService().upsertBillingConfig(productSlug, productId, input);
134
+ }),
135
+ }),
136
+ });
137
+ }