@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,65 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createTestContainer } from "../../test-container.js";
4
+ import { createStripeWebhookRoutes } from "../stripe-webhook.js";
5
+ function makeApp(stripeOverride) {
6
+ const container = createTestContainer(stripeOverride !== undefined ? { stripe: stripeOverride } : {});
7
+ const app = new Hono();
8
+ app.route("/api/webhooks/stripe", createStripeWebhookRoutes(container));
9
+ return app;
10
+ }
11
+ describe("stripe webhook route", () => {
12
+ it("returns 501 when stripe not configured", async () => {
13
+ const app = makeApp(null);
14
+ const res = await app.request("/api/webhooks/stripe", { method: "POST" });
15
+ expect(res.status).toBe(501);
16
+ });
17
+ it("returns 400 when stripe-signature header missing", async () => {
18
+ const app = makeApp({
19
+ stripe: {},
20
+ webhookSecret: "whsec_test",
21
+ customerRepo: {},
22
+ processor: { handleWebhook: vi.fn() },
23
+ });
24
+ const res = await app.request("/api/webhooks/stripe", {
25
+ method: "POST",
26
+ body: "{}",
27
+ });
28
+ expect(res.status).toBe(400);
29
+ const body = await res.json();
30
+ expect(body.error).toBe("Missing stripe-signature header");
31
+ });
32
+ it("returns 200 on valid webhook", async () => {
33
+ const handleWebhook = vi.fn().mockResolvedValue({ handled: true, event_type: "checkout.session.completed" });
34
+ const app = makeApp({
35
+ stripe: {},
36
+ webhookSecret: "whsec_test",
37
+ customerRepo: {},
38
+ processor: { handleWebhook },
39
+ });
40
+ const res = await app.request("/api/webhooks/stripe", {
41
+ method: "POST",
42
+ headers: { "stripe-signature": "t=123,v1=abc" },
43
+ body: '{"type":"checkout.session.completed"}',
44
+ });
45
+ expect(res.status).toBe(200);
46
+ expect(handleWebhook).toHaveBeenCalledOnce();
47
+ });
48
+ it("returns 400 when processor throws (bad signature)", async () => {
49
+ const handleWebhook = vi.fn().mockRejectedValue(new Error("Signature verification failed"));
50
+ const app = makeApp({
51
+ stripe: {},
52
+ webhookSecret: "whsec_test",
53
+ customerRepo: {},
54
+ processor: { handleWebhook },
55
+ });
56
+ const res = await app.request("/api/webhooks/stripe", {
57
+ method: "POST",
58
+ headers: { "stripe-signature": "t=123,v1=bad" },
59
+ body: "invalid",
60
+ });
61
+ expect(res.status).toBe(400);
62
+ const body = await res.json();
63
+ expect(body.error).toBe("Webhook processing failed");
64
+ });
65
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Admin tRPC router factory — platform-wide settings for the operator.
3
+ *
4
+ * All endpoints require platform_admin role (via adminProcedure).
5
+ * Dependencies are injected via PlatformContainer rather than module-level
6
+ * singletons, enabling clean testing and per-product composition.
7
+ */
8
+ import type { PlatformContainer } from "../container.js";
9
+ type CachedModel = {
10
+ id: string;
11
+ name: string;
12
+ contextLength: number;
13
+ promptPrice: string;
14
+ completionPrice: string;
15
+ };
16
+ /**
17
+ * Synchronous model resolver for the gateway proxy.
18
+ * Returns the cached DB value, or null to fall back to env var.
19
+ * The cache is refreshed asynchronously every 5 seconds.
20
+ */
21
+ export declare function resolveGatewayModel(): string | null;
22
+ /** Seed the cache on startup so the first request doesn't miss. */
23
+ export declare function warmModelCache(container: PlatformContainer): Promise<void>;
24
+ export interface AdminRouterConfig {
25
+ openRouterApiKey?: string;
26
+ }
27
+ export declare function createAdminRouter(container: PlatformContainer, config?: AdminRouterConfig): import("@trpc/server").TRPCBuiltRouter<{
28
+ ctx: import("../../trpc/init.js").TRPCContext;
29
+ meta: object;
30
+ errorShape: import("@trpc/server").TRPCDefaultErrorShape;
31
+ transformer: false;
32
+ }, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
33
+ /** Get the current gateway model setting. */
34
+ getGatewayModel: import("@trpc/server").TRPCQueryProcedure<{
35
+ input: void;
36
+ output: {
37
+ model: string;
38
+ updatedAt: string;
39
+ };
40
+ meta: object;
41
+ }>;
42
+ /** Set the gateway model. Takes effect within 5 seconds. */
43
+ setGatewayModel: import("@trpc/server").TRPCMutationProcedure<{
44
+ input: {
45
+ model: string;
46
+ };
47
+ output: {
48
+ ok: boolean;
49
+ model: string;
50
+ };
51
+ meta: object;
52
+ }>;
53
+ /** List available OpenRouter models for the gateway model dropdown. */
54
+ listAvailableModels: import("@trpc/server").TRPCQueryProcedure<{
55
+ input: void;
56
+ output: {
57
+ models: CachedModel[];
58
+ };
59
+ meta: object;
60
+ }>;
61
+ /** List ALL instances across all tenants with health status. */
62
+ listAllInstances: import("@trpc/server").TRPCQueryProcedure<{
63
+ input: void;
64
+ output: {
65
+ instances: never[];
66
+ error: string;
67
+ } | {
68
+ instances: {
69
+ id: string;
70
+ name: string;
71
+ tenantId: string;
72
+ image: string;
73
+ state: "paused" | "error" | "running" | "stopped" | "pulling" | "created" | "restarting" | "exited" | "dead";
74
+ health: string | null;
75
+ uptime: string | null;
76
+ containerId: string | null;
77
+ startedAt: string | null;
78
+ }[];
79
+ error?: undefined;
80
+ };
81
+ meta: object;
82
+ }>;
83
+ /** List all organizations with member counts and instance counts. */
84
+ listAllOrgs: import("@trpc/server").TRPCQueryProcedure<{
85
+ input: void;
86
+ output: {
87
+ orgs: {
88
+ id: string;
89
+ name: string;
90
+ slug: string | null;
91
+ createdAt: string;
92
+ memberCount: number;
93
+ instanceCount: number;
94
+ balanceCents: number;
95
+ }[];
96
+ };
97
+ meta: object;
98
+ }>;
99
+ /** Get platform billing summary: total credits, active service keys, payment method count. */
100
+ billingOverview: import("@trpc/server").TRPCQueryProcedure<{
101
+ input: void;
102
+ output: {
103
+ totalBalanceCents: number;
104
+ activeKeyCount: number;
105
+ paymentMethodCount: number;
106
+ orgCount: number;
107
+ };
108
+ meta: object;
109
+ }>;
110
+ }>>;
111
+ export {};
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Admin tRPC router factory — platform-wide settings for the operator.
3
+ *
4
+ * All endpoints require platform_admin role (via adminProcedure).
5
+ * Dependencies are injected via PlatformContainer rather than module-level
6
+ * singletons, enabling clean testing and per-product composition.
7
+ */
8
+ import { eq } from "drizzle-orm";
9
+ import { z } from "zod";
10
+ import { tenantModelSelection } from "../../db/schema/tenant-model-selection.js";
11
+ import { adminProcedure, router } from "../../trpc/init.js";
12
+ let modelListCache = null;
13
+ let modelListCacheExpiry = 0;
14
+ /** Well-known tenant ID for the global platform model setting. */
15
+ const GLOBAL_TENANT_ID = "__platform__";
16
+ // ---------------------------------------------------------------------------
17
+ // Gateway model cache (short-TTL, refreshed per-request for the proxy)
18
+ // ---------------------------------------------------------------------------
19
+ const CACHE_TTL_MS = 5_000;
20
+ let cachedModel = null;
21
+ let modelCacheExpiry = 0;
22
+ /** Container ref stashed by `warmModelCache` so the background refresh can use it. */
23
+ let _container = null;
24
+ /**
25
+ * Synchronous model resolver for the gateway proxy.
26
+ * Returns the cached DB value, or null to fall back to env var.
27
+ * The cache is refreshed asynchronously every 5 seconds.
28
+ */
29
+ export function resolveGatewayModel() {
30
+ const now = Date.now();
31
+ if (now > modelCacheExpiry && _container) {
32
+ // Refresh cache in the background — don't block the request
33
+ refreshModelCache(_container).catch(() => { });
34
+ }
35
+ return cachedModel;
36
+ }
37
+ async function refreshModelCache(container) {
38
+ try {
39
+ const row = await container.db
40
+ .select({ defaultModel: tenantModelSelection.defaultModel })
41
+ .from(tenantModelSelection)
42
+ .where(eq(tenantModelSelection.tenantId, GLOBAL_TENANT_ID))
43
+ .then((rows) => rows[0] ?? null);
44
+ cachedModel = row?.defaultModel ?? null;
45
+ modelCacheExpiry = Date.now() + CACHE_TTL_MS;
46
+ }
47
+ catch {
48
+ // DB error — keep stale cache, retry next time
49
+ }
50
+ }
51
+ /** Seed the cache on startup so the first request doesn't miss. */
52
+ export async function warmModelCache(container) {
53
+ _container = container;
54
+ await refreshModelCache(container);
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // Router factory
58
+ // ---------------------------------------------------------------------------
59
+ export function createAdminRouter(container, config) {
60
+ return router({
61
+ /** Get the current gateway model setting. */
62
+ getGatewayModel: adminProcedure.query(async () => {
63
+ const row = await container.db
64
+ .select({
65
+ defaultModel: tenantModelSelection.defaultModel,
66
+ updatedAt: tenantModelSelection.updatedAt,
67
+ })
68
+ .from(tenantModelSelection)
69
+ .where(eq(tenantModelSelection.tenantId, GLOBAL_TENANT_ID))
70
+ .then((rows) => rows[0] ?? null);
71
+ return {
72
+ model: row?.defaultModel ?? null,
73
+ updatedAt: row?.updatedAt ?? null,
74
+ };
75
+ }),
76
+ /** Set the gateway model. Takes effect within 5 seconds. */
77
+ setGatewayModel: adminProcedure
78
+ .input(z.object({ model: z.string().min(1).max(200) }))
79
+ .mutation(async ({ input }) => {
80
+ const now = new Date().toISOString();
81
+ await container.db
82
+ .insert(tenantModelSelection)
83
+ .values({
84
+ tenantId: GLOBAL_TENANT_ID,
85
+ defaultModel: input.model,
86
+ updatedAt: now,
87
+ })
88
+ .onConflictDoUpdate({
89
+ target: tenantModelSelection.tenantId,
90
+ set: { defaultModel: input.model, updatedAt: now },
91
+ });
92
+ // Immediately update the in-memory cache.
93
+ cachedModel = input.model;
94
+ modelCacheExpiry = Date.now() + CACHE_TTL_MS;
95
+ return { ok: true, model: input.model };
96
+ }),
97
+ /** List available OpenRouter models for the gateway model dropdown. */
98
+ listAvailableModels: adminProcedure.query(async () => {
99
+ const apiKey = config?.openRouterApiKey;
100
+ if (!apiKey)
101
+ return { models: [] };
102
+ const now = Date.now();
103
+ if (modelListCache && modelListCacheExpiry > now)
104
+ return { models: modelListCache };
105
+ try {
106
+ const res = await fetch("https://openrouter.ai/api/v1/models", {
107
+ headers: { Authorization: `Bearer ${apiKey}` },
108
+ signal: AbortSignal.timeout(10_000),
109
+ });
110
+ if (!res.ok)
111
+ return { models: modelListCache ?? [] };
112
+ const json = (await res.json());
113
+ const models = json.data
114
+ .map((m) => ({
115
+ id: m.id,
116
+ name: m.name,
117
+ contextLength: m.context_length ?? 0,
118
+ promptPrice: m.pricing?.prompt ?? "0",
119
+ completionPrice: m.pricing?.completion ?? "0",
120
+ }))
121
+ .sort((a, b) => a.id.localeCompare(b.id));
122
+ modelListCache = models;
123
+ modelListCacheExpiry = now + 60_000;
124
+ return { models };
125
+ }
126
+ catch {
127
+ return { models: modelListCache ?? [] };
128
+ }
129
+ }),
130
+ // -----------------------------------------------------------------------
131
+ // Platform-wide instance overview (all tenants)
132
+ // -----------------------------------------------------------------------
133
+ /** List ALL instances across all tenants with health status. */
134
+ listAllInstances: adminProcedure.query(async () => {
135
+ if (!container.fleet) {
136
+ return { instances: [], error: "Fleet not configured" };
137
+ }
138
+ const fleet = container.fleet;
139
+ const profiles = await fleet.profileStore.list();
140
+ const instances = await Promise.all(profiles.map(async (profile) => {
141
+ try {
142
+ const status = await fleet.manager.status(profile.id);
143
+ return {
144
+ id: profile.id,
145
+ name: profile.name,
146
+ tenantId: profile.tenantId,
147
+ image: profile.image,
148
+ state: status.state,
149
+ health: status.health,
150
+ uptime: status.uptime,
151
+ containerId: status.containerId ?? null,
152
+ startedAt: status.startedAt ?? null,
153
+ };
154
+ }
155
+ catch {
156
+ return {
157
+ id: profile.id,
158
+ name: profile.name,
159
+ tenantId: profile.tenantId,
160
+ image: profile.image,
161
+ state: "error",
162
+ health: null,
163
+ uptime: null,
164
+ containerId: null,
165
+ startedAt: null,
166
+ };
167
+ }
168
+ }));
169
+ return { instances };
170
+ }),
171
+ // -----------------------------------------------------------------------
172
+ // Platform-wide tenant/org overview
173
+ // -----------------------------------------------------------------------
174
+ /** List all organizations with member counts and instance counts. */
175
+ listAllOrgs: adminProcedure.query(async () => {
176
+ const orgs = await container.pool.query(`
177
+ SELECT
178
+ o.id,
179
+ o.name,
180
+ o.slug,
181
+ o.created_at as "createdAt",
182
+ (SELECT COUNT(*) FROM org_member om WHERE om.org_id = o.id) as "memberCount"
183
+ FROM organization o
184
+ ORDER BY o.created_at DESC
185
+ `);
186
+ // Count instances per tenant from fleet profiles
187
+ const instanceCountByTenant = new Map();
188
+ if (container.fleet) {
189
+ const profiles = await container.fleet.profileStore.list();
190
+ for (const p of profiles) {
191
+ instanceCountByTenant.set(p.tenantId, (instanceCountByTenant.get(p.tenantId) ?? 0) + 1);
192
+ }
193
+ }
194
+ const result = await Promise.all(orgs.rows.map(async (org) => {
195
+ let balanceCents = 0;
196
+ try {
197
+ const balance = await container.creditLedger.balance(org.id);
198
+ balanceCents = balance.toCentsRounded();
199
+ }
200
+ catch {
201
+ // Ledger may not have an entry for this org
202
+ }
203
+ return {
204
+ id: org.id,
205
+ name: org.name,
206
+ slug: org.slug,
207
+ createdAt: org.createdAt,
208
+ memberCount: Number(org.memberCount),
209
+ instanceCount: instanceCountByTenant.get(org.id) ?? 0,
210
+ balanceCents,
211
+ };
212
+ }));
213
+ return { orgs: result };
214
+ }),
215
+ // -----------------------------------------------------------------------
216
+ // Platform-wide billing summary
217
+ // -----------------------------------------------------------------------
218
+ /** Get platform billing summary: total credits, active service keys, payment method count. */
219
+ billingOverview: adminProcedure.query(async () => {
220
+ // Total credit balance across all tenants
221
+ let totalBalanceCents = 0;
222
+ try {
223
+ const balanceResult = await container.pool.query(`
224
+ SELECT COALESCE(SUM(amount), 0) as "totalRaw"
225
+ FROM credit_entry
226
+ `);
227
+ const rawTotal = Number(balanceResult.rows[0]?.totalRaw ?? 0);
228
+ // credit_entry.amount is in microdollars (10^-6), convert to cents
229
+ totalBalanceCents = Math.round(rawTotal / 10_000);
230
+ }
231
+ catch {
232
+ // Table may not exist yet
233
+ }
234
+ // Count active service keys
235
+ let activeKeyCount = 0;
236
+ if (container.gateway) {
237
+ try {
238
+ const keyResult = await container.pool.query(`SELECT COUNT(*) as "count" FROM service_keys WHERE revoked_at IS NULL`);
239
+ activeKeyCount = Number(keyResult.rows[0]?.count ?? 0);
240
+ }
241
+ catch {
242
+ // Table may not exist
243
+ }
244
+ }
245
+ // Count payment methods across all tenants
246
+ let paymentMethodCount = 0;
247
+ try {
248
+ const pmResult = await container.pool.query(`
249
+ SELECT COUNT(*) as "count" FROM payment_methods WHERE enabled = true
250
+ `);
251
+ paymentMethodCount = Number(pmResult.rows[0]?.count ?? 0);
252
+ }
253
+ catch {
254
+ // Table may not exist
255
+ }
256
+ // Count total orgs
257
+ let orgCount = 0;
258
+ try {
259
+ const orgCountResult = await container.pool.query(`SELECT COUNT(*) as "count" FROM organization`);
260
+ orgCount = Number(orgCountResult.rows[0]?.count ?? 0);
261
+ }
262
+ catch {
263
+ // Table may not exist
264
+ }
265
+ return {
266
+ totalBalanceCents,
267
+ activeKeyCount,
268
+ paymentMethodCount,
269
+ orgCount,
270
+ };
271
+ }),
272
+ });
273
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Crypto webhook route — accepts payment confirmations from the key server.
3
+ *
4
+ * Extracted from paperclip-platform into platform-core so every product
5
+ * gets the same timing-safe auth, Zod validation, and idempotent handler
6
+ * without copy-pasting.
7
+ */
8
+ import { Hono } from "hono";
9
+ import type { PlatformContainer } from "../container.js";
10
+ export interface CryptoWebhookConfig {
11
+ provisionSecret: string;
12
+ cryptoServiceKey?: string;
13
+ }
14
+ /**
15
+ * Create the crypto webhook Hono sub-app.
16
+ *
17
+ * Mount it at `/api/webhooks/crypto` (or wherever the product prefers).
18
+ *
19
+ * ```ts
20
+ * app.route("/api/webhooks/crypto", createCryptoWebhookRoutes(container, config));
21
+ * ```
22
+ */
23
+ export declare function createCryptoWebhookRoutes(container: PlatformContainer, config: CryptoWebhookConfig): Hono;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Crypto webhook route — accepts payment confirmations from the key server.
3
+ *
4
+ * Extracted from paperclip-platform into platform-core so every product
5
+ * gets the same timing-safe auth, Zod validation, and idempotent handler
6
+ * without copy-pasting.
7
+ */
8
+ import { timingSafeEqual } from "node:crypto";
9
+ import { Hono } from "hono";
10
+ import { z } from "zod";
11
+ import { handleKeyServerWebhook } from "../../billing/crypto/key-server-webhook.js";
12
+ // ---------------------------------------------------------------------------
13
+ // Zod schema for incoming webhook payloads
14
+ // ---------------------------------------------------------------------------
15
+ const cryptoWebhookSchema = z.object({
16
+ chargeId: z.string().min(1),
17
+ chain: z.string().min(1),
18
+ address: z.string().min(1),
19
+ amountUsdCents: z.number().optional(),
20
+ amountReceivedCents: z.number().optional(),
21
+ status: z.string().min(1),
22
+ txHash: z.string().optional(),
23
+ amountReceived: z.string().optional(),
24
+ confirmations: z.number().optional(),
25
+ confirmationsRequired: z.number().optional(),
26
+ });
27
+ // ---------------------------------------------------------------------------
28
+ // Timing-safe secret validation
29
+ // ---------------------------------------------------------------------------
30
+ function assertSecret(authHeader, config) {
31
+ if (!authHeader?.startsWith("Bearer "))
32
+ return false;
33
+ const token = authHeader.slice("Bearer ".length).trim();
34
+ const secrets = [config.provisionSecret, config.cryptoServiceKey].filter((s) => !!s);
35
+ for (const secret of secrets) {
36
+ if (token.length === secret.length && timingSafeEqual(Buffer.from(token), Buffer.from(secret))) {
37
+ return true;
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+ // ---------------------------------------------------------------------------
43
+ // Route factory
44
+ // ---------------------------------------------------------------------------
45
+ /**
46
+ * Create the crypto webhook Hono sub-app.
47
+ *
48
+ * Mount it at `/api/webhooks/crypto` (or wherever the product prefers).
49
+ *
50
+ * ```ts
51
+ * app.route("/api/webhooks/crypto", createCryptoWebhookRoutes(container, config));
52
+ * ```
53
+ */
54
+ export function createCryptoWebhookRoutes(container, config) {
55
+ const app = new Hono();
56
+ app.post("/", async (c) => {
57
+ if (!assertSecret(c.req.header("authorization"), config)) {
58
+ return c.json({ error: "Unauthorized" }, 401);
59
+ }
60
+ if (!container.crypto) {
61
+ return c.json({ error: "Crypto payments not configured" }, 501);
62
+ }
63
+ let payload;
64
+ try {
65
+ const raw = await c.req.json();
66
+ payload = cryptoWebhookSchema.parse(raw);
67
+ }
68
+ catch (err) {
69
+ if (err instanceof z.ZodError) {
70
+ return c.json({ error: "Invalid payload", issues: err.issues }, 400);
71
+ }
72
+ return c.json({ error: "Invalid JSON" }, 400);
73
+ }
74
+ const result = await handleKeyServerWebhook({
75
+ chargeStore: container.crypto.chargeRepo,
76
+ creditLedger: container.creditLedger,
77
+ replayGuard: container.crypto.webhookSeenRepo,
78
+ }, payload);
79
+ return c.json(result, 200);
80
+ });
81
+ return app;
82
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Provision webhook routes — instance lifecycle management.
3
+ *
4
+ * POST /create — spin up a new container and configure it
5
+ * POST /destroy — tear down a container
6
+ * PUT /budget — update a container's spending budget
7
+ *
8
+ * Extracted from product-specific implementations into platform-core so
9
+ * every product gets the same timing-safe auth and DI-based fleet access
10
+ * without copy-pasting.
11
+ *
12
+ * All env var names are generic (no product-specific prefixes).
13
+ */
14
+ import { Hono } from "hono";
15
+ import type { PlatformContainer } from "../container.js";
16
+ export interface ProvisionWebhookConfig {
17
+ provisionSecret: string;
18
+ /** Docker image to provision for new instances. */
19
+ instanceImage: string;
20
+ /** Port the provisioned container listens on. */
21
+ containerPort: number;
22
+ /** Maximum instances per tenant (0 = unlimited). */
23
+ maxInstancesPerTenant: number;
24
+ /** URL of the metered inference gateway (passed to provisioned containers). */
25
+ gatewayUrl?: string;
26
+ /** Container prefix for naming (e.g. "wopr" → "wopr-<subdomain>"). */
27
+ containerPrefix?: string;
28
+ }
29
+ /**
30
+ * Create the provision webhook Hono sub-app.
31
+ *
32
+ * Mount it at `/api/provision` (or wherever the product prefers).
33
+ *
34
+ * ```ts
35
+ * app.route("/api/provision", createProvisionWebhookRoutes(container, config));
36
+ * ```
37
+ */
38
+ export declare function createProvisionWebhookRoutes(container: PlatformContainer, config: ProvisionWebhookConfig): Hono;