@wopr-network/platform-core 1.67.1 → 1.69.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 (81) hide show
  1. package/dist/auth/better-auth.js +7 -0
  2. package/dist/backup/types.d.ts +1 -1
  3. package/dist/email/client.js +16 -0
  4. package/dist/server/__tests__/build-container.test.d.ts +1 -0
  5. package/dist/server/__tests__/build-container.test.js +339 -0
  6. package/dist/server/__tests__/container.test.d.ts +1 -0
  7. package/dist/server/__tests__/container.test.js +170 -0
  8. package/dist/server/__tests__/lifecycle.test.d.ts +1 -0
  9. package/dist/server/__tests__/lifecycle.test.js +90 -0
  10. package/dist/server/__tests__/mount-routes.test.d.ts +1 -0
  11. package/dist/server/__tests__/mount-routes.test.js +151 -0
  12. package/dist/server/boot-config.d.ts +51 -0
  13. package/dist/server/boot-config.js +7 -0
  14. package/dist/server/container.d.ts +81 -0
  15. package/dist/server/container.js +134 -0
  16. package/dist/server/index.d.ts +33 -0
  17. package/dist/server/index.js +66 -0
  18. package/dist/server/lifecycle.d.ts +25 -0
  19. package/dist/server/lifecycle.js +46 -0
  20. package/dist/server/middleware/__tests__/admin-auth.test.d.ts +1 -0
  21. package/dist/server/middleware/__tests__/admin-auth.test.js +59 -0
  22. package/dist/server/middleware/__tests__/tenant-proxy.test.d.ts +1 -0
  23. package/dist/server/middleware/__tests__/tenant-proxy.test.js +268 -0
  24. package/dist/server/middleware/admin-auth.d.ts +18 -0
  25. package/dist/server/middleware/admin-auth.js +38 -0
  26. package/dist/server/middleware/tenant-proxy.d.ts +56 -0
  27. package/dist/server/middleware/tenant-proxy.js +162 -0
  28. package/dist/server/mount-routes.d.ts +30 -0
  29. package/dist/server/mount-routes.js +74 -0
  30. package/dist/server/routes/__tests__/admin.test.d.ts +1 -0
  31. package/dist/server/routes/__tests__/admin.test.js +267 -0
  32. package/dist/server/routes/__tests__/crypto-webhook.test.d.ts +1 -0
  33. package/dist/server/routes/__tests__/crypto-webhook.test.js +137 -0
  34. package/dist/server/routes/__tests__/provision-webhook.test.d.ts +1 -0
  35. package/dist/server/routes/__tests__/provision-webhook.test.js +212 -0
  36. package/dist/server/routes/__tests__/stripe-webhook.test.d.ts +1 -0
  37. package/dist/server/routes/__tests__/stripe-webhook.test.js +65 -0
  38. package/dist/server/routes/admin.d.ts +111 -0
  39. package/dist/server/routes/admin.js +273 -0
  40. package/dist/server/routes/crypto-webhook.d.ts +23 -0
  41. package/dist/server/routes/crypto-webhook.js +82 -0
  42. package/dist/server/routes/provision-webhook.d.ts +38 -0
  43. package/dist/server/routes/provision-webhook.js +160 -0
  44. package/dist/server/routes/stripe-webhook.d.ts +10 -0
  45. package/dist/server/routes/stripe-webhook.js +29 -0
  46. package/dist/server/test-container.d.ts +15 -0
  47. package/dist/server/test-container.js +103 -0
  48. package/dist/trpc/auth-helpers.d.ts +17 -0
  49. package/dist/trpc/auth-helpers.js +26 -0
  50. package/dist/trpc/container-factories.d.ts +300 -0
  51. package/dist/trpc/container-factories.js +80 -0
  52. package/dist/trpc/index.d.ts +2 -0
  53. package/dist/trpc/index.js +2 -0
  54. package/package.json +8 -3
  55. package/src/auth/better-auth.ts +8 -0
  56. package/src/email/client.ts +18 -0
  57. package/src/server/__tests__/build-container.test.ts +402 -0
  58. package/src/server/__tests__/container.test.ts +204 -0
  59. package/src/server/__tests__/lifecycle.test.ts +106 -0
  60. package/src/server/__tests__/mount-routes.test.ts +169 -0
  61. package/src/server/boot-config.ts +84 -0
  62. package/src/server/container.ts +237 -0
  63. package/src/server/index.ts +92 -0
  64. package/src/server/lifecycle.ts +62 -0
  65. package/src/server/middleware/__tests__/admin-auth.test.ts +67 -0
  66. package/src/server/middleware/__tests__/tenant-proxy.test.ts +308 -0
  67. package/src/server/middleware/admin-auth.ts +51 -0
  68. package/src/server/middleware/tenant-proxy.ts +192 -0
  69. package/src/server/mount-routes.ts +113 -0
  70. package/src/server/routes/__tests__/admin.test.ts +320 -0
  71. package/src/server/routes/__tests__/crypto-webhook.test.ts +167 -0
  72. package/src/server/routes/__tests__/provision-webhook.test.ts +323 -0
  73. package/src/server/routes/__tests__/stripe-webhook.test.ts +73 -0
  74. package/src/server/routes/admin.ts +334 -0
  75. package/src/server/routes/crypto-webhook.ts +110 -0
  76. package/src/server/routes/provision-webhook.ts +212 -0
  77. package/src/server/routes/stripe-webhook.ts +36 -0
  78. package/src/server/test-container.ts +120 -0
  79. package/src/trpc/auth-helpers.ts +28 -0
  80. package/src/trpc/container-factories.ts +114 -0
  81. package/src/trpc/index.ts +9 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Container-based tRPC router factories.
3
+ *
4
+ * Each function accepts a PlatformContainer (plus any extra deps not yet on
5
+ * the container) and returns a tRPC router by delegating to the existing
6
+ * factory functions. This provides a single-call DI entry point for products
7
+ * that have migrated to the container pattern, while the existing factory
8
+ * functions and setter-based API remain fully functional for products that
9
+ * haven't migrated yet.
10
+ */
11
+ import { createAdminFleetUpdateRouter } from "./admin-fleet-update-router.js";
12
+ import { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
13
+ import { setTrpcOrgMemberRepo } from "./init.js";
14
+ import { createNotificationTemplateRouter } from "./notification-template-router.js";
15
+ import { createOrgRemovePaymentMethodRouter, } from "./org-remove-payment-method-router.js";
16
+ import { createProductConfigRouter } from "./product-config-router.js";
17
+ // ---------------------------------------------------------------------------
18
+ // Init / middleware wiring
19
+ // ---------------------------------------------------------------------------
20
+ /**
21
+ * Wire the PlatformContainer's orgMemberRepo into the tRPC middleware layer.
22
+ *
23
+ * This replaces the manual `setTrpcOrgMemberRepo()` call. Products using the
24
+ * container call this once at boot; the setter-based API remains for products
25
+ * that haven't migrated yet.
26
+ */
27
+ export function initTrpcFromContainer(container) {
28
+ setTrpcOrgMemberRepo(container.orgMemberRepo);
29
+ }
30
+ // ---------------------------------------------------------------------------
31
+ // Router factories — thin wrappers over existing factory functions
32
+ // ---------------------------------------------------------------------------
33
+ /**
34
+ * Create the admin fleet-update router from a container.
35
+ *
36
+ * Requires additional fleet-specific deps (orchestrator + config repo) that
37
+ * are constructed at boot when fleet is enabled and are not yet on the
38
+ * PlatformContainer itself.
39
+ */
40
+ export function createAdminFleetUpdateRouterFromContainer(_container, getOrchestrator, getConfigRepo) {
41
+ return createAdminFleetUpdateRouter(getOrchestrator, getConfigRepo);
42
+ }
43
+ /**
44
+ * Create the fleet-update-config router from a container.
45
+ *
46
+ * Requires the tenant-update-config repository getter (fleet-specific dep
47
+ * not yet on PlatformContainer).
48
+ */
49
+ export function createFleetUpdateConfigRouterFromContainer(_container, getConfigRepo) {
50
+ return createFleetUpdateConfigRouter(getConfigRepo);
51
+ }
52
+ /**
53
+ * Create the notification-template router from a container.
54
+ *
55
+ * Requires a getter for the notification-template repository (not yet on
56
+ * PlatformContainer).
57
+ */
58
+ export function createNotificationTemplateRouterFromContainer(_container, getRepo) {
59
+ return createNotificationTemplateRouter(getRepo);
60
+ }
61
+ /**
62
+ * Create the org-remove-payment-method router from a container.
63
+ *
64
+ * The container's StripeServices.processor is typed narrowly (webhook-only),
65
+ * so this still requires the full OrgRemovePaymentMethodDeps to be supplied
66
+ * until the container's Stripe sub-container is widened to include
67
+ * IPaymentProcessor.
68
+ */
69
+ export function createOrgRemovePaymentMethodRouterFromContainer(_container, getDeps) {
70
+ return createOrgRemovePaymentMethodRouter(getDeps);
71
+ }
72
+ /**
73
+ * Create the product-config router from a container.
74
+ *
75
+ * Requires a getter for ProductConfigService and the product slug, both of
76
+ * which are product-specific and not on PlatformContainer.
77
+ */
78
+ export function createProductConfigRouterFromContainer(_container, getService, productSlug) {
79
+ return createProductConfigRouter(getService, productSlug);
80
+ }
@@ -1,5 +1,7 @@
1
1
  export { createAdminFleetUpdateRouter } from "./admin-fleet-update-router.js";
2
+ export { createAssertOrgAdminOrOwner } from "./auth-helpers.js";
2
3
  export { authSocialRouter } from "./auth-social-router.js";
4
+ export { createAdminFleetUpdateRouterFromContainer, createFleetUpdateConfigRouterFromContainer, createNotificationTemplateRouterFromContainer, createOrgRemovePaymentMethodRouterFromContainer, createProductConfigRouterFromContainer, initTrpcFromContainer, } from "./container-factories.js";
3
5
  export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
4
6
  export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, type TRPCContext, tenantProcedure, } from "./init.js";
5
7
  export { createNotificationTemplateRouter } from "./notification-template-router.js";
@@ -1,5 +1,7 @@
1
1
  export { createAdminFleetUpdateRouter } from "./admin-fleet-update-router.js";
2
+ export { createAssertOrgAdminOrOwner } from "./auth-helpers.js";
2
3
  export { authSocialRouter } from "./auth-social-router.js";
4
+ export { createAdminFleetUpdateRouterFromContainer, createFleetUpdateConfigRouterFromContainer, createNotificationTemplateRouterFromContainer, createOrgRemovePaymentMethodRouterFromContainer, createProductConfigRouterFromContainer, initTrpcFromContainer, } from "./container-factories.js";
3
5
  export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
4
6
  export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, tenantProcedure, } from "./init.js";
5
7
  export { createNotificationTemplateRouter } from "./notification-template-router.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.67.1",
3
+ "version": "1.69.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -36,6 +36,10 @@
36
36
  "./product-config": "./dist/product-config/index.js",
37
37
  "./proxy": "./dist/proxy/index.js",
38
38
  "./security": "./dist/security/index.js",
39
+ "./server": {
40
+ "import": "./dist/server/index.js",
41
+ "types": "./dist/server/index.d.ts"
42
+ },
39
43
  "./setup": "./dist/setup/index.js",
40
44
  "./tenancy": "./dist/tenancy/index.js",
41
45
  "./api/routes/audit": "./dist/api/routes/audit.js",
@@ -83,6 +87,7 @@
83
87
  "format": "biome format src/ --write"
84
88
  },
85
89
  "peerDependencies": {
90
+ "@aws-sdk/client-ses": ">=3",
86
91
  "@trpc/server": ">=11",
87
92
  "better-auth": ">=1.5",
88
93
  "dockerode": ">=4",
@@ -95,6 +100,7 @@
95
100
  "zod": ">=3"
96
101
  },
97
102
  "devDependencies": {
103
+ "@aws-sdk/client-ses": "^3.1014.0",
98
104
  "@biomejs/biome": "^2.4.6",
99
105
  "@electric-sql/pglite": "^0.3.16",
100
106
  "@sentry/node": "^10.43.0",
@@ -129,14 +135,13 @@
129
135
  },
130
136
  "packageManager": "pnpm@10.31.0",
131
137
  "dependencies": {
132
- "@aws-sdk/client-ses": "^3.1014.0",
133
138
  "@hono/node-server": "^1.19.11",
134
139
  "@noble/curves": "^2.0.1",
135
140
  "@noble/hashes": "^2.0.1",
136
141
  "@scure/base": "^2.0.0",
137
142
  "@scure/bip32": "^2.0.1",
138
143
  "@scure/bip39": "^2.0.1",
139
- "@wopr-network/crypto-plugins": "^1.0.1",
144
+ "@wopr-network/crypto-plugins": "^1.1.0",
140
145
  "handlebars": "^4.7.8",
141
146
  "js-yaml": "^4.1.1",
142
147
  "postmark": "^4.0.7",
@@ -265,6 +265,14 @@ function authOptions(cfg: BetterAuthConfig): BetterAuthOptions {
265
265
 
266
266
  if (user.emailVerified) return;
267
267
 
268
+ if (process.env.SKIP_EMAIL_VERIFICATION === "true") {
269
+ // raw SQL: better-auth manages the "user" table schema; Drizzle schema is not available in this auth hook context
270
+ await pool.query('UPDATE "user" SET "emailVerified" = true WHERE id = $1', [user.id]);
271
+ user.emailVerified = true;
272
+ logger.info("Email verification skipped (SKIP_EMAIL_VERIFICATION=true)", { userId: user.id });
273
+ return;
274
+ }
275
+
268
276
  try {
269
277
  await initVerificationSchema(pool);
270
278
  const { token } = await generateVerificationToken(pool, user.id);
@@ -129,6 +129,18 @@ class ResendTransport implements EmailTransport {
129
129
  }
130
130
  }
131
131
 
132
+ /** No-op transport that logs but does not send. Used when EMAIL_DISABLED=true. */
133
+ class NoopTransport implements EmailTransport {
134
+ async send(opts: SendTemplateEmailOpts): Promise<EmailSendResult> {
135
+ logger.info("Email suppressed (EMAIL_DISABLED)", {
136
+ to: opts.to,
137
+ template: opts.templateName,
138
+ userId: opts.userId,
139
+ });
140
+ return { id: "noop", success: true };
141
+ }
142
+ }
143
+
132
144
  /**
133
145
  * Create a lazily-initialized singleton EmailClient from environment variables.
134
146
  *
@@ -174,6 +186,12 @@ export interface EmailClientOverrides {
174
186
  */
175
187
  export function getEmailClient(overrides?: EmailClientOverrides): EmailClient {
176
188
  if (!_client) {
189
+ if (process.env.EMAIL_DISABLED === "true") {
190
+ _client = new EmailClient(new NoopTransport());
191
+ logger.info("Email client disabled (EMAIL_DISABLED=true)");
192
+ return _client;
193
+ }
194
+
177
195
  const from = overrides?.from || process.env.EMAIL_FROM || process.env.RESEND_FROM || "noreply@wopr.bot";
178
196
  const replyTo =
179
197
  overrides?.replyTo || process.env.EMAIL_REPLY_TO || process.env.RESEND_REPLY_TO || "support@wopr.bot";
@@ -0,0 +1,402 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { BootConfig } from "../boot-config.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Mocks — all class mocks use real classes (not arrow fns) so `new` works.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ // Mock pg.Pool
9
+ const mockPoolEnd = vi.fn().mockResolvedValue(undefined);
10
+ const poolConstructorCalls: Array<{ connectionString: string }> = [];
11
+ class MockPoolClass {
12
+ connectionString: string;
13
+ end = mockPoolEnd;
14
+ query = vi.fn().mockResolvedValue({ rows: [] });
15
+ constructor(opts: { connectionString: string }) {
16
+ this.connectionString = opts.connectionString;
17
+ poolConstructorCalls.push(opts);
18
+ }
19
+ }
20
+ vi.mock("pg", () => ({ Pool: MockPoolClass }));
21
+
22
+ // Mock drizzle db creation
23
+ const mockDb = { __brand: "drizzle-db" } as never;
24
+ vi.mock("../../db/index.js", () => ({
25
+ createDb: vi.fn(() => mockDb),
26
+ }));
27
+
28
+ // Mock drizzle migrator
29
+ const mockMigrate = vi.fn().mockResolvedValue(undefined);
30
+ vi.mock("drizzle-orm/node-postgres/migrator", () => ({
31
+ migrate: mockMigrate,
32
+ }));
33
+
34
+ // Mock platformBoot
35
+ const mockProductConfig = {
36
+ product: {
37
+ slug: "test",
38
+ brandName: "Test",
39
+ domain: "test.dev",
40
+ appDomain: "app.test.dev",
41
+ fromEmail: "hi@test.dev",
42
+ emailSupport: "support@test.dev",
43
+ },
44
+ navItems: [],
45
+ domains: [],
46
+ features: null,
47
+ fleet: null,
48
+ billing: null,
49
+ };
50
+ const mockPlatformBoot = vi.fn().mockResolvedValue({
51
+ service: {},
52
+ config: mockProductConfig,
53
+ corsOrigins: ["https://test.dev"],
54
+ seeded: false,
55
+ });
56
+ vi.mock("../../product-config/boot.js", () => ({
57
+ platformBoot: (...args: unknown[]) => mockPlatformBoot(...args),
58
+ }));
59
+
60
+ // Mock DrizzleLedger
61
+ const mockSeedSystemAccounts = vi.fn().mockResolvedValue(undefined);
62
+ class MockDrizzleLedgerClass {
63
+ seedSystemAccounts = mockSeedSystemAccounts;
64
+ balance = vi.fn().mockResolvedValue(0);
65
+ credit = vi.fn();
66
+ debit = vi.fn();
67
+ post = vi.fn();
68
+ hasReferenceId = vi.fn().mockResolvedValue(false);
69
+ history = vi.fn().mockResolvedValue([]);
70
+ tenantsWithBalance = vi.fn().mockResolvedValue([]);
71
+ memberUsage = vi.fn().mockResolvedValue([]);
72
+ lifetimeSpend = vi.fn().mockResolvedValue(0);
73
+ lifetimeSpendBatch = vi.fn().mockResolvedValue(new Map());
74
+ expiredCredits = vi.fn().mockResolvedValue([]);
75
+ trialBalance = vi.fn().mockResolvedValue({ balanced: true });
76
+ accountBalance = vi.fn().mockResolvedValue(0);
77
+ existsByReferenceIdLike = vi.fn().mockResolvedValue(false);
78
+ sumPurchasesForPeriod = vi.fn().mockResolvedValue(0);
79
+ getActiveTenantIdsInWindow = vi.fn().mockResolvedValue([]);
80
+ debitCapped = vi.fn().mockResolvedValue(null);
81
+ }
82
+ vi.mock("../../credits/ledger.js", async (importOriginal) => {
83
+ const orig = await importOriginal<Record<string, unknown>>();
84
+ return { ...orig, DrizzleLedger: MockDrizzleLedgerClass };
85
+ });
86
+
87
+ // Mock org/tenancy
88
+ class MockDrizzleOrgMemberRepositoryClass {
89
+ listMembers = vi.fn().mockResolvedValue([]);
90
+ addMember = vi.fn();
91
+ }
92
+ vi.mock("../../tenancy/org-member-repository.js", () => ({
93
+ DrizzleOrgMemberRepository: MockDrizzleOrgMemberRepositoryClass,
94
+ }));
95
+
96
+ class MockDrizzleOrgRepositoryClass {}
97
+ vi.mock("../../tenancy/drizzle-org-repository.js", () => ({
98
+ DrizzleOrgRepository: MockDrizzleOrgRepositoryClass,
99
+ }));
100
+
101
+ const orgServiceConstructorCalls: unknown[][] = [];
102
+ class MockOrgServiceClass {
103
+ getOrCreatePersonalOrg = vi.fn();
104
+ constructor(...args: unknown[]) {
105
+ orgServiceConstructorCalls.push(args);
106
+ }
107
+ }
108
+ vi.mock("../../tenancy/org-service.js", () => ({
109
+ OrgService: MockOrgServiceClass,
110
+ }));
111
+
112
+ // Mock auth
113
+ class MockBetterAuthUserRepositoryClass {}
114
+ vi.mock("../../db/auth-user-repository.js", () => ({
115
+ BetterAuthUserRepository: MockBetterAuthUserRepositoryClass,
116
+ }));
117
+
118
+ class MockDrizzleUserRoleRepositoryClass {
119
+ isPlatformAdmin = vi.fn().mockResolvedValue(false);
120
+ listRolesByUser = vi.fn().mockResolvedValue([]);
121
+ getTenantIdByUserId = vi.fn().mockResolvedValue(null);
122
+ grantRole = vi.fn();
123
+ revokeRole = vi.fn().mockResolvedValue(false);
124
+ listUsersByRole = vi.fn().mockResolvedValue([]);
125
+ }
126
+ vi.mock("../../auth/user-role-repository.js", () => ({
127
+ DrizzleUserRoleRepository: MockDrizzleUserRoleRepositoryClass,
128
+ }));
129
+
130
+ // Mock fleet deps (only imported when fleet is enabled)
131
+ class MockFleetManagerClass {
132
+ __brand = "fleet-manager";
133
+ }
134
+ vi.mock("../../fleet/fleet-manager.js", () => ({
135
+ FleetManager: MockFleetManagerClass,
136
+ }));
137
+
138
+ class MockProfileStoreClass {
139
+ __brand = "profile-store";
140
+ }
141
+ vi.mock("../../fleet/profile-store.js", () => ({
142
+ ProfileStore: MockProfileStoreClass,
143
+ }));
144
+
145
+ class MockProxyManagerClass {
146
+ __brand = "proxy-manager";
147
+ }
148
+ vi.mock("../../proxy/manager.js", () => ({
149
+ ProxyManager: MockProxyManagerClass,
150
+ }));
151
+
152
+ class MockDrizzleServiceKeyRepositoryClass {
153
+ __brand = "service-key-repo";
154
+ }
155
+ vi.mock("../../gateway/service-key-repository.js", () => ({
156
+ DrizzleServiceKeyRepository: MockDrizzleServiceKeyRepositoryClass,
157
+ }));
158
+
159
+ class MockDockerClass {
160
+ __brand = "docker";
161
+ }
162
+ vi.mock("dockerode", () => ({
163
+ default: MockDockerClass,
164
+ }));
165
+
166
+ // Mock crypto deps
167
+ class MockDrizzleCryptoChargeRepositoryClass {
168
+ __brand = "charge-repo";
169
+ }
170
+ vi.mock("../../billing/crypto/charge-store.js", () => ({
171
+ DrizzleCryptoChargeRepository: MockDrizzleCryptoChargeRepositoryClass,
172
+ }));
173
+
174
+ class MockDrizzleWebhookSeenRepositoryClass {
175
+ __brand = "webhook-seen-repo";
176
+ }
177
+ vi.mock("../../billing/drizzle-webhook-seen-repository.js", () => ({
178
+ DrizzleWebhookSeenRepository: MockDrizzleWebhookSeenRepositoryClass,
179
+ }));
180
+
181
+ // Mock stripe deps
182
+ class MockStripeClass {
183
+ __brand = "stripe-client";
184
+ }
185
+ vi.mock("stripe", () => ({
186
+ default: MockStripeClass,
187
+ }));
188
+
189
+ class MockDrizzleTenantCustomerRepositoryClass {
190
+ __brand = "tenant-customer-repo";
191
+ }
192
+ vi.mock("../../billing/stripe/tenant-store.js", () => ({
193
+ DrizzleTenantCustomerRepository: MockDrizzleTenantCustomerRepositoryClass,
194
+ }));
195
+
196
+ vi.mock("../../billing/stripe/credit-prices.js", () => ({
197
+ loadCreditPriceMap: vi.fn(() => new Map()),
198
+ }));
199
+
200
+ class MockStripePaymentProcessorClass {
201
+ __brand = "stripe-processor";
202
+ handleWebhook = vi.fn();
203
+ }
204
+ vi.mock("../../billing/stripe/stripe-payment-processor.js", () => ({
205
+ StripePaymentProcessor: MockStripePaymentProcessorClass,
206
+ }));
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Helpers
210
+ // ---------------------------------------------------------------------------
211
+
212
+ function baseBootConfig(overrides?: Partial<BootConfig>): BootConfig {
213
+ return {
214
+ slug: "test-product",
215
+ databaseUrl: "postgres://localhost:5432/testdb",
216
+ provisionSecret: "test-secret",
217
+ features: {
218
+ fleet: false,
219
+ crypto: false,
220
+ stripe: false,
221
+ gateway: false,
222
+ hotPool: false,
223
+ },
224
+ ...overrides,
225
+ };
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Tests
230
+ // ---------------------------------------------------------------------------
231
+
232
+ describe("buildContainer", () => {
233
+ // Dynamic import so mocks are registered before the module loads
234
+ async function loadBuildContainer() {
235
+ const mod = await import("../container.js");
236
+ return mod.buildContainer;
237
+ }
238
+
239
+ it("throws on empty databaseUrl", async () => {
240
+ const buildContainer = await loadBuildContainer();
241
+ const config = baseBootConfig({ databaseUrl: "" });
242
+
243
+ await expect(buildContainer(config)).rejects.toThrow("databaseUrl is required");
244
+ });
245
+
246
+ it("creates pool with correct connectionString", async () => {
247
+ const buildContainer = await loadBuildContainer();
248
+ const config = baseBootConfig();
249
+ poolConstructorCalls.length = 0;
250
+
251
+ const container = await buildContainer(config);
252
+
253
+ expect(poolConstructorCalls).toContainEqual({
254
+ connectionString: "postgres://localhost:5432/testdb",
255
+ });
256
+ expect(container.pool).toBeDefined();
257
+ });
258
+
259
+ it("calls platformBoot with correct slug", async () => {
260
+ const buildContainer = await loadBuildContainer();
261
+ const config = baseBootConfig({ slug: "paperclip" });
262
+
263
+ await buildContainer(config);
264
+
265
+ expect(mockPlatformBoot).toHaveBeenCalledWith(expect.objectContaining({ slug: "paperclip" }));
266
+ });
267
+
268
+ it("seeds system accounts after creating ledger", async () => {
269
+ const buildContainer = await loadBuildContainer();
270
+ const config = baseBootConfig();
271
+
272
+ await buildContainer(config);
273
+
274
+ expect(mockSeedSystemAccounts).toHaveBeenCalled();
275
+ });
276
+
277
+ it("core services are always present", async () => {
278
+ const buildContainer = await loadBuildContainer();
279
+ const config = baseBootConfig();
280
+
281
+ const container = await buildContainer(config);
282
+
283
+ expect(container.db).toBeDefined();
284
+ expect(container.pool).toBeDefined();
285
+ expect(container.productConfig).toBeDefined();
286
+ expect(container.creditLedger).toBeDefined();
287
+ expect(container.orgMemberRepo).toBeDefined();
288
+ expect(container.orgService).toBeDefined();
289
+ expect(container.userRoleRepo).toBeDefined();
290
+ });
291
+
292
+ it("returns null for all disabled features", async () => {
293
+ const buildContainer = await loadBuildContainer();
294
+ const config = baseBootConfig();
295
+
296
+ const container = await buildContainer(config);
297
+
298
+ expect(container.fleet).toBeNull();
299
+ expect(container.crypto).toBeNull();
300
+ expect(container.stripe).toBeNull();
301
+ expect(container.gateway).toBeNull();
302
+ expect(container.hotPool).toBeNull();
303
+ });
304
+
305
+ it("builds fleet services when feature is enabled", async () => {
306
+ const buildContainer = await loadBuildContainer();
307
+ const config = baseBootConfig({
308
+ features: { fleet: true, crypto: false, stripe: false, gateway: false, hotPool: false },
309
+ });
310
+
311
+ const container = await buildContainer(config);
312
+
313
+ expect(container.fleet).not.toBeNull();
314
+ expect(container.fleet?.manager).toBeDefined();
315
+ expect(container.fleet?.docker).toBeDefined();
316
+ expect(container.fleet?.proxy).toBeDefined();
317
+ expect(container.fleet?.profileStore).toBeDefined();
318
+ expect(container.fleet?.serviceKeyRepo).toBeDefined();
319
+ });
320
+
321
+ it("builds crypto services when feature is enabled", async () => {
322
+ const buildContainer = await loadBuildContainer();
323
+ const config = baseBootConfig({
324
+ features: { fleet: false, crypto: true, stripe: false, gateway: false, hotPool: false },
325
+ });
326
+
327
+ const container = await buildContainer(config);
328
+
329
+ expect(container.crypto).not.toBeNull();
330
+ expect(container.crypto?.chargeRepo).toBeDefined();
331
+ expect(container.crypto?.webhookSeenRepo).toBeDefined();
332
+ });
333
+
334
+ it("builds stripe services when feature is enabled and key provided", async () => {
335
+ const buildContainer = await loadBuildContainer();
336
+ const config = baseBootConfig({
337
+ features: { fleet: false, crypto: false, stripe: true, gateway: false, hotPool: false },
338
+ stripeSecretKey: "sk_test_123",
339
+ stripeWebhookSecret: "whsec_test_456",
340
+ });
341
+
342
+ const container = await buildContainer(config);
343
+
344
+ expect(container.stripe).not.toBeNull();
345
+ expect(container.stripe?.stripe).toBeDefined();
346
+ expect(container.stripe?.webhookSecret).toBe("whsec_test_456");
347
+ expect(container.stripe?.customerRepo).toBeDefined();
348
+ expect(container.stripe?.processor).toBeDefined();
349
+ });
350
+
351
+ it("returns null stripe when feature enabled but no secret key", async () => {
352
+ const buildContainer = await loadBuildContainer();
353
+ const config = baseBootConfig({
354
+ features: { fleet: false, crypto: false, stripe: true, gateway: false, hotPool: false },
355
+ // stripeSecretKey intentionally omitted
356
+ });
357
+
358
+ const container = await buildContainer(config);
359
+
360
+ expect(container.stripe).toBeNull();
361
+ });
362
+
363
+ it("builds gateway services when feature is enabled", async () => {
364
+ const buildContainer = await loadBuildContainer();
365
+ const config = baseBootConfig({
366
+ features: { fleet: false, crypto: false, stripe: false, gateway: true, hotPool: false },
367
+ });
368
+
369
+ const container = await buildContainer(config);
370
+
371
+ expect(container.gateway).not.toBeNull();
372
+ expect(container.gateway?.serviceKeyRepo).toBeDefined();
373
+ });
374
+
375
+ it("runs migrations before building services", async () => {
376
+ const buildContainer = await loadBuildContainer();
377
+ const config = baseBootConfig();
378
+
379
+ await buildContainer(config);
380
+
381
+ expect(mockMigrate).toHaveBeenCalled();
382
+ // Migration should be called before platformBoot
383
+ const migrateOrder = mockMigrate.mock.invocationCallOrder[0];
384
+ const bootOrder = mockPlatformBoot.mock.invocationCallOrder[0];
385
+ expect(migrateOrder).toBeLessThan(bootOrder);
386
+ });
387
+
388
+ it("constructs OrgService with orgRepo, memberRepo, db, and authUserRepo", async () => {
389
+ const buildContainer = await loadBuildContainer();
390
+ const config = baseBootConfig();
391
+ orgServiceConstructorCalls.length = 0;
392
+
393
+ await buildContainer(config);
394
+
395
+ expect(orgServiceConstructorCalls.length).toBeGreaterThan(0);
396
+ const [orgRepo, memberRepo, db, options] = orgServiceConstructorCalls[0];
397
+ expect(orgRepo).toBeInstanceOf(MockDrizzleOrgRepositoryClass);
398
+ expect(memberRepo).toBeInstanceOf(MockDrizzleOrgMemberRepositoryClass);
399
+ expect(db).toBe(mockDb);
400
+ expect(options).toEqual(expect.objectContaining({ userRepo: expect.any(MockBetterAuthUserRepositoryClass) }));
401
+ });
402
+ });