@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.
- package/dist/backup/types.d.ts +1 -1
- package/dist/server/__tests__/build-container.test.d.ts +1 -0
- package/dist/server/__tests__/build-container.test.js +339 -0
- package/dist/server/__tests__/container.test.d.ts +1 -0
- package/dist/server/__tests__/container.test.js +170 -0
- package/dist/server/__tests__/lifecycle.test.d.ts +1 -0
- package/dist/server/__tests__/lifecycle.test.js +90 -0
- package/dist/server/__tests__/mount-routes.test.d.ts +1 -0
- package/dist/server/__tests__/mount-routes.test.js +151 -0
- package/dist/server/boot-config.d.ts +51 -0
- package/dist/server/boot-config.js +7 -0
- package/dist/server/container.d.ts +81 -0
- package/dist/server/container.js +134 -0
- package/dist/server/index.d.ts +33 -0
- package/dist/server/index.js +66 -0
- package/dist/server/lifecycle.d.ts +25 -0
- package/dist/server/lifecycle.js +46 -0
- package/dist/server/middleware/__tests__/admin-auth.test.d.ts +1 -0
- package/dist/server/middleware/__tests__/admin-auth.test.js +59 -0
- package/dist/server/middleware/__tests__/tenant-proxy.test.d.ts +1 -0
- package/dist/server/middleware/__tests__/tenant-proxy.test.js +268 -0
- package/dist/server/middleware/admin-auth.d.ts +18 -0
- package/dist/server/middleware/admin-auth.js +38 -0
- package/dist/server/middleware/tenant-proxy.d.ts +56 -0
- package/dist/server/middleware/tenant-proxy.js +162 -0
- package/dist/server/mount-routes.d.ts +30 -0
- package/dist/server/mount-routes.js +74 -0
- package/dist/server/routes/__tests__/admin.test.d.ts +1 -0
- package/dist/server/routes/__tests__/admin.test.js +267 -0
- package/dist/server/routes/__tests__/crypto-webhook.test.d.ts +1 -0
- package/dist/server/routes/__tests__/crypto-webhook.test.js +137 -0
- package/dist/server/routes/__tests__/provision-webhook.test.d.ts +1 -0
- package/dist/server/routes/__tests__/provision-webhook.test.js +212 -0
- package/dist/server/routes/__tests__/stripe-webhook.test.d.ts +1 -0
- package/dist/server/routes/__tests__/stripe-webhook.test.js +65 -0
- package/dist/server/routes/admin.d.ts +111 -0
- package/dist/server/routes/admin.js +273 -0
- package/dist/server/routes/crypto-webhook.d.ts +23 -0
- package/dist/server/routes/crypto-webhook.js +82 -0
- package/dist/server/routes/provision-webhook.d.ts +38 -0
- package/dist/server/routes/provision-webhook.js +160 -0
- package/dist/server/routes/stripe-webhook.d.ts +10 -0
- package/dist/server/routes/stripe-webhook.js +29 -0
- package/dist/server/test-container.d.ts +15 -0
- package/dist/server/test-container.js +103 -0
- package/dist/trpc/auth-helpers.d.ts +17 -0
- package/dist/trpc/auth-helpers.js +26 -0
- package/dist/trpc/container-factories.d.ts +300 -0
- package/dist/trpc/container-factories.js +80 -0
- package/dist/trpc/index.d.ts +2 -0
- package/dist/trpc/index.js +2 -0
- package/package.json +5 -1
- package/src/server/__tests__/build-container.test.ts +402 -0
- package/src/server/__tests__/container.test.ts +204 -0
- package/src/server/__tests__/lifecycle.test.ts +106 -0
- package/src/server/__tests__/mount-routes.test.ts +169 -0
- package/src/server/boot-config.ts +84 -0
- package/src/server/container.ts +237 -0
- package/src/server/index.ts +92 -0
- package/src/server/lifecycle.ts +62 -0
- package/src/server/middleware/__tests__/admin-auth.test.ts +67 -0
- package/src/server/middleware/__tests__/tenant-proxy.test.ts +308 -0
- package/src/server/middleware/admin-auth.ts +51 -0
- package/src/server/middleware/tenant-proxy.ts +192 -0
- package/src/server/mount-routes.ts +113 -0
- package/src/server/routes/__tests__/admin.test.ts +320 -0
- package/src/server/routes/__tests__/crypto-webhook.test.ts +167 -0
- package/src/server/routes/__tests__/provision-webhook.test.ts +323 -0
- package/src/server/routes/__tests__/stripe-webhook.test.ts +73 -0
- package/src/server/routes/admin.ts +334 -0
- package/src/server/routes/crypto-webhook.ts +110 -0
- package/src/server/routes/provision-webhook.ts +212 -0
- package/src/server/routes/stripe-webhook.ts +36 -0
- package/src/server/test-container.ts +120 -0
- package/src/trpc/auth-helpers.ts +28 -0
- package/src/trpc/container-factories.ts +114 -0
- package/src/trpc/index.ts +9 -0
|
@@ -0,0 +1,334 @@
|
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
|
|
3
|
+
import type { PlatformContainer } from "../container.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stripe webhook route factory.
|
|
7
|
+
*
|
|
8
|
+
* Delegates to `container.stripe.processor.handleWebhook()` which
|
|
9
|
+
* calls `stripe.webhooks.constructEvent()` internally for signature
|
|
10
|
+
* verification.
|
|
11
|
+
*/
|
|
12
|
+
export function createStripeWebhookRoutes(container: PlatformContainer): Hono {
|
|
13
|
+
const routes = new Hono();
|
|
14
|
+
|
|
15
|
+
routes.post("/", async (c) => {
|
|
16
|
+
if (!container.stripe) {
|
|
17
|
+
return c.json({ error: "Stripe not configured" }, 501);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const rawBody = Buffer.from(await c.req.arrayBuffer());
|
|
21
|
+
const sig = c.req.header("stripe-signature");
|
|
22
|
+
|
|
23
|
+
if (!sig) {
|
|
24
|
+
return c.json({ error: "Missing stripe-signature header" }, 400);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const result = await container.stripe.processor.handleWebhook(rawBody, sig);
|
|
29
|
+
return c.json({ ok: true, result }, 200);
|
|
30
|
+
} catch {
|
|
31
|
+
return c.json({ error: "Webhook processing failed" }, 400);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return routes;
|
|
36
|
+
}
|