@wopr-network/platform-core 1.68.0 → 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 (77) hide show
  1. package/dist/backup/types.d.ts +1 -1
  2. package/dist/server/__tests__/build-container.test.d.ts +1 -0
  3. package/dist/server/__tests__/build-container.test.js +339 -0
  4. package/dist/server/__tests__/container.test.d.ts +1 -0
  5. package/dist/server/__tests__/container.test.js +170 -0
  6. package/dist/server/__tests__/lifecycle.test.d.ts +1 -0
  7. package/dist/server/__tests__/lifecycle.test.js +90 -0
  8. package/dist/server/__tests__/mount-routes.test.d.ts +1 -0
  9. package/dist/server/__tests__/mount-routes.test.js +151 -0
  10. package/dist/server/boot-config.d.ts +51 -0
  11. package/dist/server/boot-config.js +7 -0
  12. package/dist/server/container.d.ts +81 -0
  13. package/dist/server/container.js +134 -0
  14. package/dist/server/index.d.ts +33 -0
  15. package/dist/server/index.js +66 -0
  16. package/dist/server/lifecycle.d.ts +25 -0
  17. package/dist/server/lifecycle.js +46 -0
  18. package/dist/server/middleware/__tests__/admin-auth.test.d.ts +1 -0
  19. package/dist/server/middleware/__tests__/admin-auth.test.js +59 -0
  20. package/dist/server/middleware/__tests__/tenant-proxy.test.d.ts +1 -0
  21. package/dist/server/middleware/__tests__/tenant-proxy.test.js +268 -0
  22. package/dist/server/middleware/admin-auth.d.ts +18 -0
  23. package/dist/server/middleware/admin-auth.js +38 -0
  24. package/dist/server/middleware/tenant-proxy.d.ts +56 -0
  25. package/dist/server/middleware/tenant-proxy.js +162 -0
  26. package/dist/server/mount-routes.d.ts +30 -0
  27. package/dist/server/mount-routes.js +74 -0
  28. package/dist/server/routes/__tests__/admin.test.d.ts +1 -0
  29. package/dist/server/routes/__tests__/admin.test.js +267 -0
  30. package/dist/server/routes/__tests__/crypto-webhook.test.d.ts +1 -0
  31. package/dist/server/routes/__tests__/crypto-webhook.test.js +137 -0
  32. package/dist/server/routes/__tests__/provision-webhook.test.d.ts +1 -0
  33. package/dist/server/routes/__tests__/provision-webhook.test.js +212 -0
  34. package/dist/server/routes/__tests__/stripe-webhook.test.d.ts +1 -0
  35. package/dist/server/routes/__tests__/stripe-webhook.test.js +65 -0
  36. package/dist/server/routes/admin.d.ts +111 -0
  37. package/dist/server/routes/admin.js +273 -0
  38. package/dist/server/routes/crypto-webhook.d.ts +23 -0
  39. package/dist/server/routes/crypto-webhook.js +82 -0
  40. package/dist/server/routes/provision-webhook.d.ts +38 -0
  41. package/dist/server/routes/provision-webhook.js +160 -0
  42. package/dist/server/routes/stripe-webhook.d.ts +10 -0
  43. package/dist/server/routes/stripe-webhook.js +29 -0
  44. package/dist/server/test-container.d.ts +15 -0
  45. package/dist/server/test-container.js +103 -0
  46. package/dist/trpc/auth-helpers.d.ts +17 -0
  47. package/dist/trpc/auth-helpers.js +26 -0
  48. package/dist/trpc/container-factories.d.ts +300 -0
  49. package/dist/trpc/container-factories.js +80 -0
  50. package/dist/trpc/index.d.ts +2 -0
  51. package/dist/trpc/index.js +2 -0
  52. package/package.json +5 -1
  53. package/src/server/__tests__/build-container.test.ts +402 -0
  54. package/src/server/__tests__/container.test.ts +204 -0
  55. package/src/server/__tests__/lifecycle.test.ts +106 -0
  56. package/src/server/__tests__/mount-routes.test.ts +169 -0
  57. package/src/server/boot-config.ts +84 -0
  58. package/src/server/container.ts +237 -0
  59. package/src/server/index.ts +92 -0
  60. package/src/server/lifecycle.ts +62 -0
  61. package/src/server/middleware/__tests__/admin-auth.test.ts +67 -0
  62. package/src/server/middleware/__tests__/tenant-proxy.test.ts +308 -0
  63. package/src/server/middleware/admin-auth.ts +51 -0
  64. package/src/server/middleware/tenant-proxy.ts +192 -0
  65. package/src/server/mount-routes.ts +113 -0
  66. package/src/server/routes/__tests__/admin.test.ts +320 -0
  67. package/src/server/routes/__tests__/crypto-webhook.test.ts +167 -0
  68. package/src/server/routes/__tests__/provision-webhook.test.ts +323 -0
  69. package/src/server/routes/__tests__/stripe-webhook.test.ts +73 -0
  70. package/src/server/routes/admin.ts +334 -0
  71. package/src/server/routes/crypto-webhook.ts +110 -0
  72. package/src/server/routes/provision-webhook.ts +212 -0
  73. package/src/server/routes/stripe-webhook.ts +36 -0
  74. package/src/server/test-container.ts +120 -0
  75. package/src/trpc/auth-helpers.ts +28 -0
  76. package/src/trpc/container-factories.ts +114 -0
  77. package/src/trpc/index.ts +9 -0
@@ -0,0 +1,320 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { IOrgMemberRepository } from "../../../tenancy/org-member-repository.js";
3
+ import { createCallerFactory, router, setTrpcOrgMemberRepo } from "../../../trpc/init.js";
4
+ import type { PlatformContainer } from "../../container.js";
5
+ import { createTestContainer } from "../../test-container.js";
6
+ import type { AdminRouterConfig } from "../admin.js";
7
+ import { createAdminRouter } from "../admin.js";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Mocks
11
+ // ---------------------------------------------------------------------------
12
+
13
+ // Mock global fetch for OpenRouter API calls
14
+ const mockFetch = vi.fn();
15
+ vi.stubGlobal("fetch", mockFetch);
16
+
17
+ function makeMockOrgRepo(): IOrgMemberRepository {
18
+ return {
19
+ listMembers: vi.fn().mockResolvedValue([]),
20
+ addMember: vi.fn().mockResolvedValue(undefined),
21
+ updateMemberRole: vi.fn().mockResolvedValue(undefined),
22
+ removeMember: vi.fn().mockResolvedValue(undefined),
23
+ findMember: vi.fn().mockResolvedValue(null),
24
+ countAdminsAndOwners: vi.fn().mockResolvedValue(0),
25
+ listInvites: vi.fn().mockResolvedValue([]),
26
+ createInvite: vi.fn().mockResolvedValue(undefined),
27
+ findInviteById: vi.fn().mockResolvedValue(null),
28
+ findInviteByToken: vi.fn().mockResolvedValue(null),
29
+ deleteInvite: vi.fn().mockResolvedValue(undefined),
30
+ deleteAllMembers: vi.fn().mockResolvedValue(undefined),
31
+ deleteAllInvites: vi.fn().mockResolvedValue(undefined),
32
+ listOrgsByUser: vi.fn().mockResolvedValue([]),
33
+ markInviteAccepted: vi.fn().mockResolvedValue(undefined),
34
+ } as unknown as IOrgMemberRepository;
35
+ }
36
+
37
+ const adminCtx = {
38
+ user: { id: "admin-1", roles: ["platform_admin"] },
39
+ tenantId: undefined,
40
+ };
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Helpers
44
+ // ---------------------------------------------------------------------------
45
+
46
+ function makeCaller(container: PlatformContainer, config?: AdminRouterConfig) {
47
+ const adminRouter = createAdminRouter(container, config);
48
+ const appRouter = router({ admin: adminRouter });
49
+ const createCaller = createCallerFactory(appRouter);
50
+ return createCaller(adminCtx);
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Tests
55
+ // ---------------------------------------------------------------------------
56
+
57
+ describe("createAdminRouter", () => {
58
+ beforeEach(() => {
59
+ setTrpcOrgMemberRepo(makeMockOrgRepo());
60
+ mockFetch.mockReset();
61
+ });
62
+
63
+ // -------------------------------------------------------------------------
64
+ // Model list endpoint
65
+ // -------------------------------------------------------------------------
66
+
67
+ describe("listAvailableModels", () => {
68
+ it("returns cached models from OpenRouter API", async () => {
69
+ const container = createTestContainer();
70
+ const models = [
71
+ {
72
+ id: "openai/gpt-4",
73
+ name: "GPT-4",
74
+ context_length: 8192,
75
+ pricing: { prompt: "0.03", completion: "0.06" },
76
+ },
77
+ {
78
+ id: "anthropic/claude-3",
79
+ name: "Claude 3",
80
+ context_length: 200000,
81
+ pricing: { prompt: "0.015", completion: "0.075" },
82
+ },
83
+ ];
84
+
85
+ mockFetch.mockResolvedValueOnce({
86
+ ok: true,
87
+ json: async () => ({ data: models }),
88
+ });
89
+
90
+ const caller = makeCaller(container, { openRouterApiKey: "sk-test-key" });
91
+ const result = await caller.admin.listAvailableModels();
92
+
93
+ expect(result.models).toHaveLength(2);
94
+ expect(result.models[0].id).toBe("anthropic/claude-3");
95
+ expect(result.models[1].id).toBe("openai/gpt-4");
96
+ expect(mockFetch).toHaveBeenCalledOnce();
97
+
98
+ // Second call should use cache (no additional fetch)
99
+ const result2 = await caller.admin.listAvailableModels();
100
+ expect(result2.models).toHaveLength(2);
101
+ expect(mockFetch).toHaveBeenCalledOnce();
102
+ });
103
+
104
+ it("returns empty list when no API key configured", async () => {
105
+ const container = createTestContainer();
106
+ const caller = makeCaller(container); // no config = no API key
107
+
108
+ const result = await caller.admin.listAvailableModels();
109
+
110
+ expect(result.models).toEqual([]);
111
+ expect(mockFetch).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it("returns stale cache on fetch failure", async () => {
115
+ // First call seeds the cache
116
+ const container = createTestContainer();
117
+ mockFetch.mockResolvedValueOnce({
118
+ ok: true,
119
+ json: async () => ({
120
+ data: [{ id: "model/a", name: "A", context_length: 100, pricing: { prompt: "1", completion: "2" } }],
121
+ }),
122
+ });
123
+ const caller = makeCaller(container, { openRouterApiKey: "sk-test-key" });
124
+ await caller.admin.listAvailableModels();
125
+
126
+ // Advance past 60s cache TTL so the next call triggers a fetch
127
+ vi.useFakeTimers();
128
+ vi.advanceTimersByTime(61_000);
129
+
130
+ mockFetch.mockRejectedValueOnce(new Error("Network error"));
131
+ const result = await caller.admin.listAvailableModels();
132
+
133
+ // On failure, falls back to stale cache
134
+ expect(result.models).toHaveLength(1);
135
+ expect(result.models[0].id).toBe("model/a");
136
+
137
+ vi.useRealTimers();
138
+ });
139
+ });
140
+
141
+ // -------------------------------------------------------------------------
142
+ // Fleet instance listing
143
+ // -------------------------------------------------------------------------
144
+
145
+ describe("listAllInstances", () => {
146
+ it("lists instances using container.fleet", async () => {
147
+ const mockProfiles = [
148
+ { id: "inst-1", name: "Bot A", tenantId: "t1", image: "img:latest" },
149
+ { id: "inst-2", name: "Bot B", tenantId: "t2", image: "img:v2" },
150
+ ];
151
+
152
+ const mockStatus = {
153
+ state: "running",
154
+ health: "healthy",
155
+ uptime: 3600,
156
+ containerId: "abc123",
157
+ startedAt: "2026-01-01T00:00:00Z",
158
+ };
159
+
160
+ const container = createTestContainer({
161
+ fleet: {
162
+ manager: {
163
+ status: vi.fn().mockResolvedValue(mockStatus),
164
+ } as never,
165
+ docker: {} as never,
166
+ proxy: {} as never,
167
+ profileStore: {
168
+ list: vi.fn().mockResolvedValue(mockProfiles),
169
+ init: vi.fn(),
170
+ save: vi.fn(),
171
+ get: vi.fn(),
172
+ delete: vi.fn(),
173
+ },
174
+ serviceKeyRepo: {} as never,
175
+ },
176
+ });
177
+
178
+ const caller = makeCaller(container);
179
+ const result = await caller.admin.listAllInstances();
180
+
181
+ expect(result.instances).toHaveLength(2);
182
+ expect(result.instances[0]).toEqual({
183
+ id: "inst-1",
184
+ name: "Bot A",
185
+ tenantId: "t1",
186
+ image: "img:latest",
187
+ state: "running",
188
+ health: "healthy",
189
+ uptime: 3600,
190
+ containerId: "abc123",
191
+ startedAt: "2026-01-01T00:00:00Z",
192
+ });
193
+ });
194
+
195
+ it("returns error when fleet not configured", async () => {
196
+ const container = createTestContainer({ fleet: null });
197
+ const caller = makeCaller(container);
198
+ const result = await caller.admin.listAllInstances();
199
+
200
+ expect(result.instances).toEqual([]);
201
+ expect(result.error).toBe("Fleet not configured");
202
+ });
203
+
204
+ it("returns error state for instances that fail status check", async () => {
205
+ const mockProfiles = [{ id: "inst-bad", name: "Bad Bot", tenantId: "t1", image: "img:latest" }];
206
+
207
+ const container = createTestContainer({
208
+ fleet: {
209
+ manager: {
210
+ status: vi.fn().mockRejectedValue(new Error("container not found")),
211
+ } as never,
212
+ docker: {} as never,
213
+ proxy: {} as never,
214
+ profileStore: {
215
+ list: vi.fn().mockResolvedValue(mockProfiles),
216
+ init: vi.fn(),
217
+ save: vi.fn(),
218
+ get: vi.fn(),
219
+ delete: vi.fn(),
220
+ },
221
+ serviceKeyRepo: {} as never,
222
+ },
223
+ });
224
+
225
+ const caller = makeCaller(container);
226
+ const result = await caller.admin.listAllInstances();
227
+
228
+ expect(result.instances).toHaveLength(1);
229
+ expect(result.instances[0].state).toBe("error");
230
+ expect(result.instances[0].health).toBeNull();
231
+ });
232
+ });
233
+
234
+ // -------------------------------------------------------------------------
235
+ // Credit balance query
236
+ // -------------------------------------------------------------------------
237
+
238
+ describe("billingOverview", () => {
239
+ it("queries credit balance via container pool", async () => {
240
+ const mockPool = {
241
+ query: vi
242
+ .fn()
243
+ .mockResolvedValueOnce({ rows: [{ totalRaw: "50000000" }] }) // credit_entry sum (50M microdollars = 5000 cents)
244
+ .mockResolvedValueOnce({ rows: [{ count: "3" }] }) // payment methods
245
+ .mockResolvedValueOnce({ rows: [{ count: "5" }] }), // orgs
246
+ end: vi.fn(),
247
+ };
248
+
249
+ const container = createTestContainer({
250
+ pool: mockPool as never,
251
+ gateway: null, // no gateway = skip service key count
252
+ });
253
+
254
+ const caller = makeCaller(container);
255
+ const result = await caller.admin.billingOverview();
256
+
257
+ expect(result.totalBalanceCents).toBe(5000);
258
+ expect(result.activeKeyCount).toBe(0); // gateway not configured
259
+ expect(result.paymentMethodCount).toBe(3);
260
+ expect(result.orgCount).toBe(5);
261
+ });
262
+
263
+ it("counts active service keys when gateway is configured", async () => {
264
+ const mockPool = {
265
+ query: vi
266
+ .fn()
267
+ .mockResolvedValueOnce({ rows: [{ totalRaw: "0" }] }) // credit_entry
268
+ .mockResolvedValueOnce({ rows: [{ count: "7" }] }) // service_keys
269
+ .mockResolvedValueOnce({ rows: [{ count: "0" }] }) // payment methods
270
+ .mockResolvedValueOnce({ rows: [{ count: "0" }] }), // orgs
271
+ end: vi.fn(),
272
+ };
273
+
274
+ const container = createTestContainer({
275
+ pool: mockPool as never,
276
+ gateway: {
277
+ serviceKeyRepo: {} as never,
278
+ },
279
+ });
280
+
281
+ const caller = makeCaller(container);
282
+ const result = await caller.admin.billingOverview();
283
+
284
+ expect(result.activeKeyCount).toBe(7);
285
+ });
286
+
287
+ it("returns zeros when tables do not exist", async () => {
288
+ const mockPool = {
289
+ query: vi.fn().mockRejectedValue(new Error('relation "credit_entry" does not exist')),
290
+ end: vi.fn(),
291
+ };
292
+
293
+ const container = createTestContainer({
294
+ pool: mockPool as never,
295
+ });
296
+
297
+ const caller = makeCaller(container);
298
+ const result = await caller.admin.billingOverview();
299
+
300
+ expect(result.totalBalanceCents).toBe(0);
301
+ expect(result.activeKeyCount).toBe(0);
302
+ expect(result.paymentMethodCount).toBe(0);
303
+ expect(result.orgCount).toBe(0);
304
+ });
305
+ });
306
+
307
+ // -------------------------------------------------------------------------
308
+ // Auth guard
309
+ // -------------------------------------------------------------------------
310
+
311
+ it("rejects non-admin users", async () => {
312
+ const container = createTestContainer();
313
+ const adminRouter = createAdminRouter(container);
314
+ const appRouter = router({ admin: adminRouter });
315
+ const createCaller = createCallerFactory(appRouter);
316
+ const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
317
+
318
+ await expect(caller.admin.listAvailableModels()).rejects.toThrow("Platform admin role required");
319
+ });
320
+ });
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { CryptoServices } from "../../container.js";
3
+ import { createTestContainer } from "../../test-container.js";
4
+ import { type CryptoWebhookConfig, createCryptoWebhookRoutes } from "../crypto-webhook.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Mock the key-server-webhook handler so we never hit real billing logic
8
+ // ---------------------------------------------------------------------------
9
+
10
+ vi.mock("../../../billing/crypto/key-server-webhook.js", () => ({
11
+ handleKeyServerWebhook: vi.fn().mockResolvedValue({
12
+ handled: true,
13
+ creditedCents: 500,
14
+ }),
15
+ }));
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const SECRET = "test-provision-secret";
22
+ const CRYPTO_KEY = "test-crypto-service-key";
23
+
24
+ function makeConfig(overrides?: Partial<CryptoWebhookConfig>): CryptoWebhookConfig {
25
+ return {
26
+ provisionSecret: SECRET,
27
+ cryptoServiceKey: CRYPTO_KEY,
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ function makeCrypto(): CryptoServices {
33
+ return {
34
+ chargeRepo: {} as never,
35
+ webhookSeenRepo: {} as never,
36
+ };
37
+ }
38
+
39
+ const validPayload = {
40
+ chargeId: "ch_123",
41
+ chain: "ETH",
42
+ address: "0xabc",
43
+ status: "confirmed",
44
+ };
45
+
46
+ function buildApp(opts?: { crypto?: CryptoServices | null; config?: Partial<CryptoWebhookConfig> }) {
47
+ const container = createTestContainer({
48
+ crypto: opts?.crypto !== undefined ? opts.crypto : makeCrypto(),
49
+ });
50
+ const config = makeConfig(opts?.config);
51
+ return createCryptoWebhookRoutes(container, config);
52
+ }
53
+
54
+ async function post(app: ReturnType<typeof buildApp>, body: unknown, headers?: Record<string, string>) {
55
+ const init: RequestInit = {
56
+ method: "POST",
57
+ headers: {
58
+ "Content-Type": "application/json",
59
+ ...headers,
60
+ },
61
+ };
62
+
63
+ if (body !== undefined) {
64
+ init.body = typeof body === "string" ? body : JSON.stringify(body);
65
+ }
66
+
67
+ return app.request("/", init);
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Tests
72
+ // ---------------------------------------------------------------------------
73
+
74
+ describe("createCryptoWebhookRoutes", () => {
75
+ // 1. No auth header
76
+ it("returns 401 without auth header", async () => {
77
+ const app = buildApp();
78
+ const res = await post(app, validPayload);
79
+
80
+ expect(res.status).toBe(401);
81
+ const json = await res.json();
82
+ expect(json.error).toBe("Unauthorized");
83
+ });
84
+
85
+ // 2. Wrong Bearer token
86
+ it("returns 401 with wrong Bearer token", async () => {
87
+ const app = buildApp();
88
+ const res = await post(app, validPayload, {
89
+ Authorization: "Bearer wrong-token",
90
+ });
91
+
92
+ expect(res.status).toBe(401);
93
+ const json = await res.json();
94
+ expect(json.error).toBe("Unauthorized");
95
+ });
96
+
97
+ // 3. Invalid JSON
98
+ it("returns 400 on invalid JSON", async () => {
99
+ const app = buildApp();
100
+ const res = await app.request("/", {
101
+ method: "POST",
102
+ headers: {
103
+ "Content-Type": "application/json",
104
+ Authorization: `Bearer ${SECRET}`,
105
+ },
106
+ body: "not-valid-json{{{",
107
+ });
108
+
109
+ expect(res.status).toBe(400);
110
+ const json = await res.json();
111
+ expect(json.error).toBe("Invalid JSON");
112
+ });
113
+
114
+ // 4. Zod validation failure (missing required fields)
115
+ it("returns 400 on payload that fails Zod validation", async () => {
116
+ const app = buildApp();
117
+ const res = await post(
118
+ app,
119
+ { chargeId: "ch_123" }, // missing chain, address, status
120
+ { Authorization: `Bearer ${SECRET}` },
121
+ );
122
+
123
+ expect(res.status).toBe(400);
124
+ const json = await res.json();
125
+ expect(json.error).toBe("Invalid payload");
126
+ expect(json.issues).toBeDefined();
127
+ expect(json.issues.length).toBeGreaterThan(0);
128
+ });
129
+
130
+ // 5. Container crypto is null -> 501
131
+ it("returns 501 when container.crypto is null", async () => {
132
+ const app = buildApp({ crypto: null });
133
+ const res = await post(app, validPayload, {
134
+ Authorization: `Bearer ${SECRET}`,
135
+ });
136
+
137
+ expect(res.status).toBe(501);
138
+ const json = await res.json();
139
+ expect(json.error).toBe("Crypto payments not configured");
140
+ });
141
+
142
+ // 6. Valid Bearer matching provision secret
143
+ it("accepts valid Bearer token matching provision secret", async () => {
144
+ const app = buildApp();
145
+ const res = await post(app, validPayload, {
146
+ Authorization: `Bearer ${SECRET}`,
147
+ });
148
+
149
+ expect(res.status).toBe(200);
150
+ const json = await res.json();
151
+ expect(json.handled).toBe(true);
152
+ expect(json.creditedCents).toBe(500);
153
+ });
154
+
155
+ // 7. Valid Bearer matching crypto service key
156
+ it("accepts valid Bearer token matching crypto service key", async () => {
157
+ const app = buildApp();
158
+ const res = await post(app, validPayload, {
159
+ Authorization: `Bearer ${CRYPTO_KEY}`,
160
+ });
161
+
162
+ expect(res.status).toBe(200);
163
+ const json = await res.json();
164
+ expect(json.handled).toBe(true);
165
+ expect(json.creditedCents).toBe(500);
166
+ });
167
+ });