@wopr-network/platform-core 1.68.0 → 1.70.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 (93) hide show
  1. package/dist/backup/types.d.ts +1 -1
  2. package/dist/db/schema/pool-config.d.ts +41 -0
  3. package/dist/db/schema/pool-config.js +5 -0
  4. package/dist/db/schema/pool-instances.d.ts +126 -0
  5. package/dist/db/schema/pool-instances.js +10 -0
  6. package/dist/server/__tests__/build-container.test.d.ts +1 -0
  7. package/dist/server/__tests__/build-container.test.js +339 -0
  8. package/dist/server/__tests__/container.test.d.ts +1 -0
  9. package/dist/server/__tests__/container.test.js +173 -0
  10. package/dist/server/__tests__/lifecycle.test.d.ts +1 -0
  11. package/dist/server/__tests__/lifecycle.test.js +90 -0
  12. package/dist/server/__tests__/mount-routes.test.d.ts +1 -0
  13. package/dist/server/__tests__/mount-routes.test.js +151 -0
  14. package/dist/server/boot-config.d.ts +51 -0
  15. package/dist/server/boot-config.js +7 -0
  16. package/dist/server/container.d.ts +97 -0
  17. package/dist/server/container.js +148 -0
  18. package/dist/server/index.d.ts +33 -0
  19. package/dist/server/index.js +66 -0
  20. package/dist/server/lifecycle.d.ts +25 -0
  21. package/dist/server/lifecycle.js +56 -0
  22. package/dist/server/middleware/__tests__/admin-auth.test.d.ts +1 -0
  23. package/dist/server/middleware/__tests__/admin-auth.test.js +59 -0
  24. package/dist/server/middleware/__tests__/tenant-proxy.test.d.ts +1 -0
  25. package/dist/server/middleware/__tests__/tenant-proxy.test.js +268 -0
  26. package/dist/server/middleware/admin-auth.d.ts +18 -0
  27. package/dist/server/middleware/admin-auth.js +38 -0
  28. package/dist/server/middleware/tenant-proxy.d.ts +56 -0
  29. package/dist/server/middleware/tenant-proxy.js +162 -0
  30. package/dist/server/mount-routes.d.ts +30 -0
  31. package/dist/server/mount-routes.js +74 -0
  32. package/dist/server/routes/__tests__/admin.test.d.ts +1 -0
  33. package/dist/server/routes/__tests__/admin.test.js +267 -0
  34. package/dist/server/routes/__tests__/crypto-webhook.test.d.ts +1 -0
  35. package/dist/server/routes/__tests__/crypto-webhook.test.js +137 -0
  36. package/dist/server/routes/__tests__/provision-webhook.test.d.ts +1 -0
  37. package/dist/server/routes/__tests__/provision-webhook.test.js +212 -0
  38. package/dist/server/routes/__tests__/stripe-webhook.test.d.ts +1 -0
  39. package/dist/server/routes/__tests__/stripe-webhook.test.js +65 -0
  40. package/dist/server/routes/admin.d.ts +129 -0
  41. package/dist/server/routes/admin.js +294 -0
  42. package/dist/server/routes/crypto-webhook.d.ts +23 -0
  43. package/dist/server/routes/crypto-webhook.js +82 -0
  44. package/dist/server/routes/provision-webhook.d.ts +38 -0
  45. package/dist/server/routes/provision-webhook.js +160 -0
  46. package/dist/server/routes/stripe-webhook.d.ts +10 -0
  47. package/dist/server/routes/stripe-webhook.js +29 -0
  48. package/dist/server/services/hot-pool-claim.d.ts +30 -0
  49. package/dist/server/services/hot-pool-claim.js +92 -0
  50. package/dist/server/services/hot-pool.d.ts +25 -0
  51. package/dist/server/services/hot-pool.js +129 -0
  52. package/dist/server/services/pool-repository.d.ts +44 -0
  53. package/dist/server/services/pool-repository.js +72 -0
  54. package/dist/server/test-container.d.ts +15 -0
  55. package/dist/server/test-container.js +103 -0
  56. package/dist/trpc/auth-helpers.d.ts +17 -0
  57. package/dist/trpc/auth-helpers.js +26 -0
  58. package/dist/trpc/container-factories.d.ts +300 -0
  59. package/dist/trpc/container-factories.js +80 -0
  60. package/dist/trpc/index.d.ts +2 -0
  61. package/dist/trpc/index.js +2 -0
  62. package/drizzle/migrations/0025_hot_pool_tables.sql +29 -0
  63. package/package.json +5 -1
  64. package/src/db/schema/pool-config.ts +6 -0
  65. package/src/db/schema/pool-instances.ts +11 -0
  66. package/src/server/__tests__/build-container.test.ts +402 -0
  67. package/src/server/__tests__/container.test.ts +207 -0
  68. package/src/server/__tests__/lifecycle.test.ts +106 -0
  69. package/src/server/__tests__/mount-routes.test.ts +169 -0
  70. package/src/server/boot-config.ts +84 -0
  71. package/src/server/container.ts +264 -0
  72. package/src/server/index.ts +92 -0
  73. package/src/server/lifecycle.ts +72 -0
  74. package/src/server/middleware/__tests__/admin-auth.test.ts +67 -0
  75. package/src/server/middleware/__tests__/tenant-proxy.test.ts +308 -0
  76. package/src/server/middleware/admin-auth.ts +51 -0
  77. package/src/server/middleware/tenant-proxy.ts +192 -0
  78. package/src/server/mount-routes.ts +113 -0
  79. package/src/server/routes/__tests__/admin.test.ts +320 -0
  80. package/src/server/routes/__tests__/crypto-webhook.test.ts +167 -0
  81. package/src/server/routes/__tests__/provision-webhook.test.ts +323 -0
  82. package/src/server/routes/__tests__/stripe-webhook.test.ts +73 -0
  83. package/src/server/routes/admin.ts +360 -0
  84. package/src/server/routes/crypto-webhook.ts +110 -0
  85. package/src/server/routes/provision-webhook.ts +212 -0
  86. package/src/server/routes/stripe-webhook.ts +36 -0
  87. package/src/server/services/hot-pool-claim.ts +130 -0
  88. package/src/server/services/hot-pool.ts +174 -0
  89. package/src/server/services/pool-repository.ts +107 -0
  90. package/src/server/test-container.ts +120 -0
  91. package/src/trpc/auth-helpers.ts +28 -0
  92. package/src/trpc/container-factories.ts +114 -0
  93. package/src/trpc/index.ts +9 -0
@@ -0,0 +1,360 @@
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
+
9
+ import { eq } from "drizzle-orm";
10
+ import { z } from "zod";
11
+ import { tenantModelSelection } from "../../db/schema/tenant-model-selection.js";
12
+ import { adminProcedure, router } from "../../trpc/init.js";
13
+ import type { PlatformContainer } from "../container.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // OpenRouter model list cache (module-level — safe for a cache)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ type CachedModel = {
20
+ id: string;
21
+ name: string;
22
+ contextLength: number;
23
+ promptPrice: string;
24
+ completionPrice: string;
25
+ };
26
+ let modelListCache: CachedModel[] | null = null;
27
+ let modelListCacheExpiry = 0;
28
+
29
+ /** Well-known tenant ID for the global platform model setting. */
30
+ const GLOBAL_TENANT_ID = "__platform__";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Gateway model cache (short-TTL, refreshed per-request for the proxy)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const CACHE_TTL_MS = 5_000;
37
+ let cachedModel: string | null = null;
38
+ let modelCacheExpiry = 0;
39
+
40
+ /** Container ref stashed by `warmModelCache` so the background refresh can use it. */
41
+ let _container: PlatformContainer | null = null;
42
+
43
+ /**
44
+ * Synchronous model resolver for the gateway proxy.
45
+ * Returns the cached DB value, or null to fall back to env var.
46
+ * The cache is refreshed asynchronously every 5 seconds.
47
+ */
48
+ export function resolveGatewayModel(): string | null {
49
+ const now = Date.now();
50
+ if (now > modelCacheExpiry && _container) {
51
+ // Refresh cache in the background — don't block the request
52
+ refreshModelCache(_container).catch(() => {});
53
+ }
54
+ return cachedModel;
55
+ }
56
+
57
+ async function refreshModelCache(container: PlatformContainer): Promise<void> {
58
+ try {
59
+ const row = await container.db
60
+ .select({ defaultModel: tenantModelSelection.defaultModel })
61
+ .from(tenantModelSelection)
62
+ .where(eq(tenantModelSelection.tenantId, GLOBAL_TENANT_ID))
63
+ .then((rows) => rows[0] ?? null);
64
+ cachedModel = row?.defaultModel ?? null;
65
+ modelCacheExpiry = Date.now() + CACHE_TTL_MS;
66
+ } catch {
67
+ // DB error — keep stale cache, retry next time
68
+ }
69
+ }
70
+
71
+ /** Seed the cache on startup so the first request doesn't miss. */
72
+ export async function warmModelCache(container: PlatformContainer): Promise<void> {
73
+ _container = container;
74
+ await refreshModelCache(container);
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Config shape needed by the OpenRouter model listing
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export interface AdminRouterConfig {
82
+ openRouterApiKey?: string;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Router factory
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export function createAdminRouter(container: PlatformContainer, config?: AdminRouterConfig) {
90
+ return router({
91
+ /** Get the current gateway model setting. */
92
+ getGatewayModel: adminProcedure.query(async () => {
93
+ const row = await container.db
94
+ .select({
95
+ defaultModel: tenantModelSelection.defaultModel,
96
+ updatedAt: tenantModelSelection.updatedAt,
97
+ })
98
+ .from(tenantModelSelection)
99
+ .where(eq(tenantModelSelection.tenantId, GLOBAL_TENANT_ID))
100
+ .then((rows) => rows[0] ?? null);
101
+ return {
102
+ model: row?.defaultModel ?? null,
103
+ updatedAt: row?.updatedAt ?? null,
104
+ };
105
+ }),
106
+
107
+ /** Set the gateway model. Takes effect within 5 seconds. */
108
+ setGatewayModel: adminProcedure
109
+ .input(z.object({ model: z.string().min(1).max(200) }))
110
+ .mutation(async ({ input }) => {
111
+ const now = new Date().toISOString();
112
+ await container.db
113
+ .insert(tenantModelSelection)
114
+ .values({
115
+ tenantId: GLOBAL_TENANT_ID,
116
+ defaultModel: input.model,
117
+ updatedAt: now,
118
+ })
119
+ .onConflictDoUpdate({
120
+ target: tenantModelSelection.tenantId,
121
+ set: { defaultModel: input.model, updatedAt: now },
122
+ });
123
+ // Immediately update the in-memory cache.
124
+ cachedModel = input.model;
125
+ modelCacheExpiry = Date.now() + CACHE_TTL_MS;
126
+ return { ok: true, model: input.model };
127
+ }),
128
+
129
+ /** List available OpenRouter models for the gateway model dropdown. */
130
+ listAvailableModels: adminProcedure.query(async () => {
131
+ const apiKey = config?.openRouterApiKey;
132
+ if (!apiKey) return { models: [] };
133
+
134
+ const now = Date.now();
135
+ if (modelListCache && modelListCacheExpiry > now) return { models: modelListCache };
136
+
137
+ try {
138
+ const res = await fetch("https://openrouter.ai/api/v1/models", {
139
+ headers: { Authorization: `Bearer ${apiKey}` },
140
+ signal: AbortSignal.timeout(10_000),
141
+ });
142
+ if (!res.ok) return { models: modelListCache ?? [] };
143
+ const json = (await res.json()) as {
144
+ data: Array<{
145
+ id: string;
146
+ name: string;
147
+ context_length?: number;
148
+ pricing?: { prompt?: string; completion?: string };
149
+ }>;
150
+ };
151
+ const models = json.data
152
+ .map((m) => ({
153
+ id: m.id,
154
+ name: m.name,
155
+ contextLength: m.context_length ?? 0,
156
+ promptPrice: m.pricing?.prompt ?? "0",
157
+ completionPrice: m.pricing?.completion ?? "0",
158
+ }))
159
+ .sort((a, b) => a.id.localeCompare(b.id));
160
+ modelListCache = models;
161
+ modelListCacheExpiry = now + 60_000;
162
+ return { models };
163
+ } catch {
164
+ return { models: modelListCache ?? [] };
165
+ }
166
+ }),
167
+
168
+ // -----------------------------------------------------------------------
169
+ // Platform-wide instance overview (all tenants)
170
+ // -----------------------------------------------------------------------
171
+
172
+ /** List ALL instances across all tenants with health status. */
173
+ listAllInstances: adminProcedure.query(async () => {
174
+ if (!container.fleet) {
175
+ return { instances: [], error: "Fleet not configured" };
176
+ }
177
+
178
+ const fleet = container.fleet;
179
+ const profiles = await fleet.profileStore.list();
180
+
181
+ const instances = await Promise.all(
182
+ profiles.map(async (profile) => {
183
+ try {
184
+ const status = await fleet.manager.status(profile.id);
185
+ return {
186
+ id: profile.id,
187
+ name: profile.name,
188
+ tenantId: profile.tenantId,
189
+ image: profile.image,
190
+ state: status.state,
191
+ health: status.health,
192
+ uptime: status.uptime,
193
+ containerId: status.containerId ?? null,
194
+ startedAt: status.startedAt ?? null,
195
+ };
196
+ } catch {
197
+ return {
198
+ id: profile.id,
199
+ name: profile.name,
200
+ tenantId: profile.tenantId,
201
+ image: profile.image,
202
+ state: "error" as const,
203
+ health: null,
204
+ uptime: null,
205
+ containerId: null,
206
+ startedAt: null,
207
+ };
208
+ }
209
+ }),
210
+ );
211
+
212
+ return { instances };
213
+ }),
214
+
215
+ // -----------------------------------------------------------------------
216
+ // Platform-wide tenant/org overview
217
+ // -----------------------------------------------------------------------
218
+
219
+ /** List all organizations with member counts and instance counts. */
220
+ listAllOrgs: adminProcedure.query(async () => {
221
+ const orgs = await container.pool.query<{
222
+ id: string;
223
+ name: string;
224
+ slug: string | null;
225
+ createdAt: string;
226
+ memberCount: string;
227
+ }>(`
228
+ SELECT
229
+ o.id,
230
+ o.name,
231
+ o.slug,
232
+ o.created_at as "createdAt",
233
+ (SELECT COUNT(*) FROM org_member om WHERE om.org_id = o.id) as "memberCount"
234
+ FROM organization o
235
+ ORDER BY o.created_at DESC
236
+ `);
237
+
238
+ // Count instances per tenant from fleet profiles
239
+ const instanceCountByTenant = new Map<string, number>();
240
+ if (container.fleet) {
241
+ const profiles = await container.fleet.profileStore.list();
242
+ for (const p of profiles) {
243
+ instanceCountByTenant.set(p.tenantId, (instanceCountByTenant.get(p.tenantId) ?? 0) + 1);
244
+ }
245
+ }
246
+
247
+ const result = await Promise.all(
248
+ orgs.rows.map(async (org) => {
249
+ let balanceCents = 0;
250
+ try {
251
+ const balance = await container.creditLedger.balance(org.id);
252
+ balanceCents = (balance as { toCentsRounded(): number }).toCentsRounded();
253
+ } catch {
254
+ // Ledger may not have an entry for this org
255
+ }
256
+ return {
257
+ id: org.id,
258
+ name: org.name,
259
+ slug: org.slug,
260
+ createdAt: org.createdAt,
261
+ memberCount: Number(org.memberCount),
262
+ instanceCount: instanceCountByTenant.get(org.id) ?? 0,
263
+ balanceCents,
264
+ };
265
+ }),
266
+ );
267
+
268
+ return { orgs: result };
269
+ }),
270
+
271
+ // -----------------------------------------------------------------------
272
+ // Platform-wide billing summary
273
+ // -----------------------------------------------------------------------
274
+
275
+ /** Get platform billing summary: total credits, active service keys, payment method count. */
276
+ billingOverview: adminProcedure.query(async () => {
277
+ // Total credit balance across all tenants
278
+ let totalBalanceCents = 0;
279
+ try {
280
+ const balanceResult = await container.pool.query<{ totalRaw: string }>(`
281
+ SELECT COALESCE(SUM(amount), 0) as "totalRaw"
282
+ FROM credit_entry
283
+ `);
284
+ const rawTotal = Number(balanceResult.rows[0]?.totalRaw ?? 0);
285
+ // credit_entry.amount is in microdollars (10^-6), convert to cents
286
+ totalBalanceCents = Math.round(rawTotal / 10_000);
287
+ } catch {
288
+ // Table may not exist yet
289
+ }
290
+
291
+ // Count active service keys
292
+ let activeKeyCount = 0;
293
+ if (container.gateway) {
294
+ try {
295
+ const keyResult = await container.pool.query<{ count: string }>(
296
+ `SELECT COUNT(*) as "count" FROM service_keys WHERE revoked_at IS NULL`,
297
+ );
298
+ activeKeyCount = Number(keyResult.rows[0]?.count ?? 0);
299
+ } catch {
300
+ // Table may not exist
301
+ }
302
+ }
303
+
304
+ // Count payment methods across all tenants
305
+ let paymentMethodCount = 0;
306
+ try {
307
+ const pmResult = await container.pool.query<{ count: string }>(`
308
+ SELECT COUNT(*) as "count" FROM payment_methods WHERE enabled = true
309
+ `);
310
+ paymentMethodCount = Number(pmResult.rows[0]?.count ?? 0);
311
+ } catch {
312
+ // Table may not exist
313
+ }
314
+
315
+ // Count total orgs
316
+ let orgCount = 0;
317
+ try {
318
+ const orgCountResult = await container.pool.query<{ count: string }>(
319
+ `SELECT COUNT(*) as "count" FROM organization`,
320
+ );
321
+ orgCount = Number(orgCountResult.rows[0]?.count ?? 0);
322
+ } catch {
323
+ // Table may not exist
324
+ }
325
+
326
+ return {
327
+ totalBalanceCents,
328
+ activeKeyCount,
329
+ paymentMethodCount,
330
+ orgCount,
331
+ };
332
+ }),
333
+
334
+ // -----------------------------------------------------------------------
335
+ // Hot pool config (DB-driven, admin-only)
336
+ // -----------------------------------------------------------------------
337
+
338
+ getPoolConfig: adminProcedure.query(async () => {
339
+ if (!container.hotPool) {
340
+ return { enabled: false, poolSize: 0, warmCount: 0 };
341
+ }
342
+ const poolSize = await container.hotPool.getPoolSize();
343
+ const warmRes = await container.pool.query<{ count: string }>(
344
+ "SELECT COUNT(*)::int AS count FROM pool_instances WHERE status = 'warm'",
345
+ );
346
+ const warmCount = Number(warmRes.rows[0]?.count ?? 0);
347
+ return { enabled: true, poolSize, warmCount };
348
+ }),
349
+
350
+ setPoolSize: adminProcedure
351
+ .input(z.object({ size: z.number().int().min(0).max(50) }))
352
+ .mutation(async ({ input }) => {
353
+ if (!container.hotPool) {
354
+ throw new Error("Hot pool not enabled");
355
+ }
356
+ await container.hotPool.setPoolSize(input.size);
357
+ return { poolSize: input.size };
358
+ }),
359
+ });
360
+ }
@@ -0,0 +1,110 @@
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
+
9
+ import { timingSafeEqual } from "node:crypto";
10
+ import { Hono } from "hono";
11
+ import { z } from "zod";
12
+ import type { CryptoWebhookPayload } from "../../billing/crypto/index.js";
13
+ import { handleKeyServerWebhook } from "../../billing/crypto/key-server-webhook.js";
14
+
15
+ import type { PlatformContainer } from "../container.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Zod schema for incoming webhook payloads
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const cryptoWebhookSchema = z.object({
22
+ chargeId: z.string().min(1),
23
+ chain: z.string().min(1),
24
+ address: z.string().min(1),
25
+ amountUsdCents: z.number().optional(),
26
+ amountReceivedCents: z.number().optional(),
27
+ status: z.string().min(1),
28
+ txHash: z.string().optional(),
29
+ amountReceived: z.string().optional(),
30
+ confirmations: z.number().optional(),
31
+ confirmationsRequired: z.number().optional(),
32
+ });
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Config accepted at mount time
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export interface CryptoWebhookConfig {
39
+ provisionSecret: string;
40
+ cryptoServiceKey?: string;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Timing-safe secret validation
45
+ // ---------------------------------------------------------------------------
46
+
47
+ function assertSecret(authHeader: string | undefined, config: CryptoWebhookConfig): boolean {
48
+ if (!authHeader?.startsWith("Bearer ")) return false;
49
+ const token = authHeader.slice("Bearer ".length).trim();
50
+
51
+ const secrets = [config.provisionSecret, config.cryptoServiceKey].filter((s): s is string => !!s);
52
+
53
+ for (const secret of secrets) {
54
+ if (token.length === secret.length && timingSafeEqual(Buffer.from(token), Buffer.from(secret))) {
55
+ return true;
56
+ }
57
+ }
58
+ return false;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Route factory
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Create the crypto webhook Hono sub-app.
67
+ *
68
+ * Mount it at `/api/webhooks/crypto` (or wherever the product prefers).
69
+ *
70
+ * ```ts
71
+ * app.route("/api/webhooks/crypto", createCryptoWebhookRoutes(container, config));
72
+ * ```
73
+ */
74
+ export function createCryptoWebhookRoutes(container: PlatformContainer, config: CryptoWebhookConfig): Hono {
75
+ const app = new Hono();
76
+
77
+ app.post("/", async (c) => {
78
+ if (!assertSecret(c.req.header("authorization"), config)) {
79
+ return c.json({ error: "Unauthorized" }, 401);
80
+ }
81
+
82
+ if (!container.crypto) {
83
+ return c.json({ error: "Crypto payments not configured" }, 501);
84
+ }
85
+
86
+ let payload: CryptoWebhookPayload;
87
+ try {
88
+ const raw = await c.req.json();
89
+ payload = cryptoWebhookSchema.parse(raw) as CryptoWebhookPayload;
90
+ } catch (err) {
91
+ if (err instanceof z.ZodError) {
92
+ return c.json({ error: "Invalid payload", issues: err.issues }, 400);
93
+ }
94
+ return c.json({ error: "Invalid JSON" }, 400);
95
+ }
96
+
97
+ const result = await handleKeyServerWebhook(
98
+ {
99
+ chargeStore: container.crypto.chargeRepo,
100
+ creditLedger: container.creditLedger,
101
+ replayGuard: container.crypto.webhookSeenRepo,
102
+ },
103
+ payload,
104
+ );
105
+
106
+ return c.json(result, 200);
107
+ });
108
+
109
+ return app;
110
+ }
@@ -0,0 +1,212 @@
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
+
15
+ import { timingSafeEqual } from "node:crypto";
16
+ import { Hono } from "hono";
17
+
18
+ import type { PlatformContainer } from "../container.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Config accepted at mount time
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface ProvisionWebhookConfig {
25
+ provisionSecret: string;
26
+ /** Docker image to provision for new instances. */
27
+ instanceImage: string;
28
+ /** Port the provisioned container listens on. */
29
+ containerPort: number;
30
+ /** Maximum instances per tenant (0 = unlimited). */
31
+ maxInstancesPerTenant: number;
32
+ /** URL of the metered inference gateway (passed to provisioned containers). */
33
+ gatewayUrl?: string;
34
+ /** Container prefix for naming (e.g. "wopr" → "wopr-<subdomain>"). */
35
+ containerPrefix?: string;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Timing-safe secret validation (same pattern as crypto-webhook)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function assertSecret(authHeader: string | undefined, secret: string): boolean {
43
+ if (!authHeader?.startsWith("Bearer ")) return false;
44
+ const token = authHeader.slice("Bearer ".length).trim();
45
+ if (token.length !== secret.length) return false;
46
+ return timingSafeEqual(Buffer.from(token), Buffer.from(secret));
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Route factory
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Create the provision webhook Hono sub-app.
55
+ *
56
+ * Mount it at `/api/provision` (or wherever the product prefers).
57
+ *
58
+ * ```ts
59
+ * app.route("/api/provision", createProvisionWebhookRoutes(container, config));
60
+ * ```
61
+ */
62
+ export function createProvisionWebhookRoutes(container: PlatformContainer, config: ProvisionWebhookConfig): Hono {
63
+ const app = new Hono();
64
+
65
+ // ------------------------------------------------------------------
66
+ // POST /create — create a new managed instance
67
+ // ------------------------------------------------------------------
68
+ app.post("/create", async (c) => {
69
+ if (!assertSecret(c.req.header("authorization"), config.provisionSecret)) {
70
+ return c.json({ error: "Unauthorized" }, 401);
71
+ }
72
+
73
+ if (!container.fleet) {
74
+ return c.json({ error: "Fleet management not configured" }, 501);
75
+ }
76
+
77
+ const body = await c.req.json();
78
+ const { tenantId, subdomain } = body;
79
+
80
+ if (!tenantId || !subdomain) {
81
+ return c.json({ error: "Missing required fields: tenantId, subdomain" }, 422);
82
+ }
83
+
84
+ // Billing gate — require positive credit balance before provisioning
85
+ const balance = await container.creditLedger.balance(tenantId);
86
+ if (typeof balance === "object" && "isZero" in balance) {
87
+ const bal = balance as { isZero(): boolean; isNegative(): boolean };
88
+ if (bal.isZero() || bal.isNegative()) {
89
+ return c.json({ error: "Insufficient credits: add funds before creating an instance" }, 402);
90
+ }
91
+ }
92
+
93
+ // Instance limit gate
94
+ const { profileStore, manager: fleet, proxy } = container.fleet;
95
+
96
+ if (config.maxInstancesPerTenant > 0) {
97
+ const profiles = await profileStore.list();
98
+ const tenantInstances = profiles.filter((p) => p.tenantId === tenantId);
99
+ if (tenantInstances.length >= config.maxInstancesPerTenant) {
100
+ return c.json({ error: `Instance limit reached: maximum ${config.maxInstancesPerTenant} per tenant` }, 403);
101
+ }
102
+ }
103
+
104
+ // Create the Docker container
105
+ const instance = await fleet.create({
106
+ tenantId,
107
+ name: subdomain,
108
+ description: `Managed instance for ${subdomain}`,
109
+ image: config.instanceImage,
110
+ env: {
111
+ PORT: String(config.containerPort),
112
+ PROVISION_SECRET: config.provisionSecret,
113
+ HOSTED_MODE: "true",
114
+ DEPLOYMENT_MODE: "hosted_proxy",
115
+ DEPLOYMENT_EXPOSURE: "private",
116
+ MIGRATION_AUTO_APPLY: "true",
117
+ },
118
+ restartPolicy: "unless-stopped",
119
+ releaseChannel: "stable",
120
+ updatePolicy: "manual",
121
+ });
122
+
123
+ // Register proxy route — container name must match FleetManager naming convention
124
+ const prefix = config.containerPrefix ?? "wopr";
125
+ const containerName = `${prefix}-${subdomain}`;
126
+ await proxy.addRoute({
127
+ instanceId: instance.id,
128
+ subdomain,
129
+ upstreamHost: containerName,
130
+ upstreamPort: config.containerPort,
131
+ healthy: true,
132
+ });
133
+
134
+ return c.json(
135
+ {
136
+ ok: true,
137
+ instanceId: instance.id,
138
+ subdomain,
139
+ containerUrl: `http://${containerName}:${config.containerPort}`,
140
+ },
141
+ 201,
142
+ );
143
+ });
144
+
145
+ // ------------------------------------------------------------------
146
+ // POST /destroy — tear down a managed instance
147
+ // ------------------------------------------------------------------
148
+ app.post("/destroy", async (c) => {
149
+ if (!assertSecret(c.req.header("authorization"), config.provisionSecret)) {
150
+ return c.json({ error: "Unauthorized" }, 401);
151
+ }
152
+
153
+ if (!container.fleet) {
154
+ return c.json({ error: "Fleet management not configured" }, 501);
155
+ }
156
+
157
+ const body = await c.req.json();
158
+ const { instanceId } = body;
159
+
160
+ if (!instanceId) {
161
+ return c.json({ error: "Missing required field: instanceId" }, 422);
162
+ }
163
+
164
+ const { manager: fleet, proxy, serviceKeyRepo } = container.fleet;
165
+
166
+ // Revoke gateway service key
167
+ await serviceKeyRepo.revokeByInstance(instanceId);
168
+
169
+ // Remove the Docker container
170
+ try {
171
+ await fleet.remove(instanceId);
172
+ } catch {
173
+ // Container may already be gone — continue cleanup
174
+ }
175
+
176
+ // Remove proxy route
177
+ proxy.removeRoute(instanceId);
178
+
179
+ return c.json({ ok: true });
180
+ });
181
+
182
+ // ------------------------------------------------------------------
183
+ // PUT /budget — update a container's spending budget
184
+ // ------------------------------------------------------------------
185
+ app.put("/budget", async (c) => {
186
+ if (!assertSecret(c.req.header("authorization"), config.provisionSecret)) {
187
+ return c.json({ error: "Unauthorized" }, 401);
188
+ }
189
+
190
+ if (!container.fleet) {
191
+ return c.json({ error: "Fleet management not configured" }, 501);
192
+ }
193
+
194
+ const body = await c.req.json();
195
+ const { instanceId, tenantEntityId, budgetCents } = body;
196
+
197
+ if (!instanceId || !tenantEntityId || budgetCents === undefined) {
198
+ return c.json({ error: "Missing required fields: instanceId, tenantEntityId, budgetCents" }, 422);
199
+ }
200
+
201
+ const { manager: fleet } = container.fleet;
202
+
203
+ const status = await fleet.status(instanceId);
204
+ if (status.state !== "running") {
205
+ return c.json({ error: "Instance not running" }, 503);
206
+ }
207
+
208
+ return c.json({ ok: true, instanceId, budgetCents });
209
+ });
210
+
211
+ return app;
212
+ }