@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,294 @@
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
+ // Hot pool config (DB-driven, admin-only)
274
+ // -----------------------------------------------------------------------
275
+ getPoolConfig: adminProcedure.query(async () => {
276
+ if (!container.hotPool) {
277
+ return { enabled: false, poolSize: 0, warmCount: 0 };
278
+ }
279
+ const poolSize = await container.hotPool.getPoolSize();
280
+ const warmRes = await container.pool.query("SELECT COUNT(*)::int AS count FROM pool_instances WHERE status = 'warm'");
281
+ const warmCount = Number(warmRes.rows[0]?.count ?? 0);
282
+ return { enabled: true, poolSize, warmCount };
283
+ }),
284
+ setPoolSize: adminProcedure
285
+ .input(z.object({ size: z.number().int().min(0).max(50) }))
286
+ .mutation(async ({ input }) => {
287
+ if (!container.hotPool) {
288
+ throw new Error("Hot pool not enabled");
289
+ }
290
+ await container.hotPool.setPoolSize(input.size);
291
+ return { poolSize: input.size };
292
+ }),
293
+ });
294
+ }
@@ -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;
@@ -0,0 +1,160 @@
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 { timingSafeEqual } from "node:crypto";
15
+ import { Hono } from "hono";
16
+ // ---------------------------------------------------------------------------
17
+ // Timing-safe secret validation (same pattern as crypto-webhook)
18
+ // ---------------------------------------------------------------------------
19
+ function assertSecret(authHeader, secret) {
20
+ if (!authHeader?.startsWith("Bearer "))
21
+ return false;
22
+ const token = authHeader.slice("Bearer ".length).trim();
23
+ if (token.length !== secret.length)
24
+ return false;
25
+ return timingSafeEqual(Buffer.from(token), Buffer.from(secret));
26
+ }
27
+ // ---------------------------------------------------------------------------
28
+ // Route factory
29
+ // ---------------------------------------------------------------------------
30
+ /**
31
+ * Create the provision webhook Hono sub-app.
32
+ *
33
+ * Mount it at `/api/provision` (or wherever the product prefers).
34
+ *
35
+ * ```ts
36
+ * app.route("/api/provision", createProvisionWebhookRoutes(container, config));
37
+ * ```
38
+ */
39
+ export function createProvisionWebhookRoutes(container, config) {
40
+ const app = new Hono();
41
+ // ------------------------------------------------------------------
42
+ // POST /create — create a new managed instance
43
+ // ------------------------------------------------------------------
44
+ app.post("/create", async (c) => {
45
+ if (!assertSecret(c.req.header("authorization"), config.provisionSecret)) {
46
+ return c.json({ error: "Unauthorized" }, 401);
47
+ }
48
+ if (!container.fleet) {
49
+ return c.json({ error: "Fleet management not configured" }, 501);
50
+ }
51
+ const body = await c.req.json();
52
+ const { tenantId, subdomain } = body;
53
+ if (!tenantId || !subdomain) {
54
+ return c.json({ error: "Missing required fields: tenantId, subdomain" }, 422);
55
+ }
56
+ // Billing gate — require positive credit balance before provisioning
57
+ const balance = await container.creditLedger.balance(tenantId);
58
+ if (typeof balance === "object" && "isZero" in balance) {
59
+ const bal = balance;
60
+ if (bal.isZero() || bal.isNegative()) {
61
+ return c.json({ error: "Insufficient credits: add funds before creating an instance" }, 402);
62
+ }
63
+ }
64
+ // Instance limit gate
65
+ const { profileStore, manager: fleet, proxy } = container.fleet;
66
+ if (config.maxInstancesPerTenant > 0) {
67
+ const profiles = await profileStore.list();
68
+ const tenantInstances = profiles.filter((p) => p.tenantId === tenantId);
69
+ if (tenantInstances.length >= config.maxInstancesPerTenant) {
70
+ return c.json({ error: `Instance limit reached: maximum ${config.maxInstancesPerTenant} per tenant` }, 403);
71
+ }
72
+ }
73
+ // Create the Docker container
74
+ const instance = await fleet.create({
75
+ tenantId,
76
+ name: subdomain,
77
+ description: `Managed instance for ${subdomain}`,
78
+ image: config.instanceImage,
79
+ env: {
80
+ PORT: String(config.containerPort),
81
+ PROVISION_SECRET: config.provisionSecret,
82
+ HOSTED_MODE: "true",
83
+ DEPLOYMENT_MODE: "hosted_proxy",
84
+ DEPLOYMENT_EXPOSURE: "private",
85
+ MIGRATION_AUTO_APPLY: "true",
86
+ },
87
+ restartPolicy: "unless-stopped",
88
+ releaseChannel: "stable",
89
+ updatePolicy: "manual",
90
+ });
91
+ // Register proxy route — container name must match FleetManager naming convention
92
+ const prefix = config.containerPrefix ?? "wopr";
93
+ const containerName = `${prefix}-${subdomain}`;
94
+ await proxy.addRoute({
95
+ instanceId: instance.id,
96
+ subdomain,
97
+ upstreamHost: containerName,
98
+ upstreamPort: config.containerPort,
99
+ healthy: true,
100
+ });
101
+ return c.json({
102
+ ok: true,
103
+ instanceId: instance.id,
104
+ subdomain,
105
+ containerUrl: `http://${containerName}:${config.containerPort}`,
106
+ }, 201);
107
+ });
108
+ // ------------------------------------------------------------------
109
+ // POST /destroy — tear down a managed instance
110
+ // ------------------------------------------------------------------
111
+ app.post("/destroy", async (c) => {
112
+ if (!assertSecret(c.req.header("authorization"), config.provisionSecret)) {
113
+ return c.json({ error: "Unauthorized" }, 401);
114
+ }
115
+ if (!container.fleet) {
116
+ return c.json({ error: "Fleet management not configured" }, 501);
117
+ }
118
+ const body = await c.req.json();
119
+ const { instanceId } = body;
120
+ if (!instanceId) {
121
+ return c.json({ error: "Missing required field: instanceId" }, 422);
122
+ }
123
+ const { manager: fleet, proxy, serviceKeyRepo } = container.fleet;
124
+ // Revoke gateway service key
125
+ await serviceKeyRepo.revokeByInstance(instanceId);
126
+ // Remove the Docker container
127
+ try {
128
+ await fleet.remove(instanceId);
129
+ }
130
+ catch {
131
+ // Container may already be gone — continue cleanup
132
+ }
133
+ // Remove proxy route
134
+ proxy.removeRoute(instanceId);
135
+ return c.json({ ok: true });
136
+ });
137
+ // ------------------------------------------------------------------
138
+ // PUT /budget — update a container's spending budget
139
+ // ------------------------------------------------------------------
140
+ app.put("/budget", async (c) => {
141
+ if (!assertSecret(c.req.header("authorization"), config.provisionSecret)) {
142
+ return c.json({ error: "Unauthorized" }, 401);
143
+ }
144
+ if (!container.fleet) {
145
+ return c.json({ error: "Fleet management not configured" }, 501);
146
+ }
147
+ const body = await c.req.json();
148
+ const { instanceId, tenantEntityId, budgetCents } = body;
149
+ if (!instanceId || !tenantEntityId || budgetCents === undefined) {
150
+ return c.json({ error: "Missing required fields: instanceId, tenantEntityId, budgetCents" }, 422);
151
+ }
152
+ const { manager: fleet } = container.fleet;
153
+ const status = await fleet.status(instanceId);
154
+ if (status.state !== "running") {
155
+ return c.json({ error: "Instance not running" }, 503);
156
+ }
157
+ return c.json({ ok: true, instanceId, budgetCents });
158
+ });
159
+ return app;
160
+ }
@@ -0,0 +1,10 @@
1
+ import { Hono } from "hono";
2
+ import type { PlatformContainer } from "../container.js";
3
+ /**
4
+ * Stripe webhook route factory.
5
+ *
6
+ * Delegates to `container.stripe.processor.handleWebhook()` which
7
+ * calls `stripe.webhooks.constructEvent()` internally for signature
8
+ * verification.
9
+ */
10
+ export declare function createStripeWebhookRoutes(container: PlatformContainer): Hono;
@@ -0,0 +1,29 @@
1
+ import { Hono } from "hono";
2
+ /**
3
+ * Stripe webhook route factory.
4
+ *
5
+ * Delegates to `container.stripe.processor.handleWebhook()` which
6
+ * calls `stripe.webhooks.constructEvent()` internally for signature
7
+ * verification.
8
+ */
9
+ export function createStripeWebhookRoutes(container) {
10
+ const routes = new Hono();
11
+ routes.post("/", async (c) => {
12
+ if (!container.stripe) {
13
+ return c.json({ error: "Stripe not configured" }, 501);
14
+ }
15
+ const rawBody = Buffer.from(await c.req.arrayBuffer());
16
+ const sig = c.req.header("stripe-signature");
17
+ if (!sig) {
18
+ return c.json({ error: "Missing stripe-signature header" }, 400);
19
+ }
20
+ try {
21
+ const result = await container.stripe.processor.handleWebhook(rawBody, sig);
22
+ return c.json({ ok: true, result }, 200);
23
+ }
24
+ catch {
25
+ return c.json({ error: "Webhook processing failed" }, 400);
26
+ }
27
+ });
28
+ return routes;
29
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Atomic hot pool claiming.
3
+ *
4
+ * Uses IPoolRepository for all DB operations. No raw pool.query().
5
+ */
6
+ import type { PlatformContainer } from "../container.js";
7
+ import type { IPoolRepository } from "./pool-repository.js";
8
+ export interface ClaimConfig {
9
+ /** Shared secret for provision auth. */
10
+ provisionSecret: string;
11
+ /** Container name prefix. Default: "wopr". */
12
+ containerPrefix?: string;
13
+ }
14
+ export interface ClaimAdminUser {
15
+ id: string;
16
+ email: string;
17
+ name: string;
18
+ }
19
+ export interface ClaimResult {
20
+ id: string;
21
+ name: string;
22
+ subdomain: string;
23
+ }
24
+ /**
25
+ * Claim a warm pool instance, rename it, create a fleet profile,
26
+ * and register the proxy route.
27
+ *
28
+ * Returns the claim result on success, or null if the pool is empty.
29
+ */
30
+ export declare function claimPoolInstance(container: PlatformContainer, repo: IPoolRepository, name: string, tenantId: string, _adminUser: ClaimAdminUser, config?: ClaimConfig): Promise<ClaimResult | null>;