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