@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.
- package/dist/backup/types.d.ts +1 -1
- package/dist/db/schema/pool-config.d.ts +41 -0
- package/dist/db/schema/pool-config.js +5 -0
- package/dist/db/schema/pool-instances.d.ts +126 -0
- package/dist/db/schema/pool-instances.js +10 -0
- 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 +173 -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 +97 -0
- package/dist/server/container.js +148 -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 +56 -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 +129 -0
- package/dist/server/routes/admin.js +294 -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/services/hot-pool-claim.d.ts +30 -0
- package/dist/server/services/hot-pool-claim.js +92 -0
- package/dist/server/services/hot-pool.d.ts +25 -0
- package/dist/server/services/hot-pool.js +129 -0
- package/dist/server/services/pool-repository.d.ts +44 -0
- package/dist/server/services/pool-repository.js +72 -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/drizzle/migrations/0025_hot_pool_tables.sql +29 -0
- package/package.json +5 -1
- package/src/db/schema/pool-config.ts +6 -0
- package/src/db/schema/pool-instances.ts +11 -0
- package/src/server/__tests__/build-container.test.ts +402 -0
- package/src/server/__tests__/container.test.ts +207 -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 +264 -0
- package/src/server/index.ts +92 -0
- package/src/server/lifecycle.ts +72 -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 +360 -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/services/hot-pool-claim.ts +130 -0
- package/src/server/services/hot-pool.ts +174 -0
- package/src/server/services/pool-repository.ts +107 -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,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant subdomain proxy middleware.
|
|
3
|
+
*
|
|
4
|
+
* Extracts the tenant subdomain from the Host header, authenticates
|
|
5
|
+
* the user, verifies tenant membership via orgMemberRepo, resolves
|
|
6
|
+
* the fleet container URL, and proxies the request upstream.
|
|
7
|
+
*
|
|
8
|
+
* Ported from paperclip-platform with fail-closed semantics:
|
|
9
|
+
* - If fleet services are unavailable, returns 503 (not silent skip)
|
|
10
|
+
* - Auth check runs before tenant ownership check
|
|
11
|
+
* - Upstream headers are sanitized via allowlist
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { MiddlewareHandler } from "hono";
|
|
15
|
+
import type { PlatformContainer } from "../container.js";
|
|
16
|
+
|
|
17
|
+
/** Reserved subdomains that should never resolve to a tenant. */
|
|
18
|
+
const RESERVED_SUBDOMAINS = new Set(["app", "api", "staging", "www", "mail", "admin", "dashboard", "status", "docs"]);
|
|
19
|
+
|
|
20
|
+
/** DNS label rules (RFC 1123). */
|
|
21
|
+
const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Headers safe to forward to upstream containers.
|
|
25
|
+
*
|
|
26
|
+
* This is an allowlist -- only these headers are copied from the incoming
|
|
27
|
+
* request. All x-platform-* headers are injected server-side after auth
|
|
28
|
+
* resolution, preventing client-side spoofing.
|
|
29
|
+
*/
|
|
30
|
+
const FORWARDED_HEADERS = [
|
|
31
|
+
"content-type",
|
|
32
|
+
"accept",
|
|
33
|
+
"accept-language",
|
|
34
|
+
"accept-encoding",
|
|
35
|
+
"content-length",
|
|
36
|
+
"x-request-id",
|
|
37
|
+
"user-agent",
|
|
38
|
+
"origin",
|
|
39
|
+
"referer",
|
|
40
|
+
"cookie",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Resolved user identity for upstream header injection. */
|
|
44
|
+
export interface ProxyUserInfo {
|
|
45
|
+
id: string;
|
|
46
|
+
email?: string;
|
|
47
|
+
name?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TenantProxyConfig {
|
|
51
|
+
/** The platform root domain (e.g. "runpaperclip.com"). */
|
|
52
|
+
platformDomain: string;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the authenticated user from the request.
|
|
56
|
+
* Products wire this to their auth system (BetterAuth, etc.).
|
|
57
|
+
*/
|
|
58
|
+
resolveUser: (req: Request) => Promise<ProxyUserInfo | undefined>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Extract the tenant subdomain from a Host header value.
|
|
63
|
+
*
|
|
64
|
+
* "alice.example.com" -> "alice"
|
|
65
|
+
* "example.com" -> null (root domain)
|
|
66
|
+
* "app.example.com" -> null (reserved)
|
|
67
|
+
*/
|
|
68
|
+
export function extractTenantSubdomain(host: string, platformDomain: string): string | null {
|
|
69
|
+
const hostname = host.split(":")[0].toLowerCase();
|
|
70
|
+
const suffix = `.${platformDomain}`;
|
|
71
|
+
if (!hostname.endsWith(suffix)) return null;
|
|
72
|
+
|
|
73
|
+
const subdomain = hostname.slice(0, -suffix.length);
|
|
74
|
+
if (!subdomain || subdomain.includes(".")) return null;
|
|
75
|
+
if (RESERVED_SUBDOMAINS.has(subdomain)) return null;
|
|
76
|
+
if (!SUBDOMAIN_RE.test(subdomain)) return null;
|
|
77
|
+
|
|
78
|
+
return subdomain;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build sanitized headers for upstream requests.
|
|
83
|
+
*
|
|
84
|
+
* Only allowlisted headers are forwarded. All x-platform-* headers are
|
|
85
|
+
* injected server-side from the authenticated session -- never copied from
|
|
86
|
+
* the incoming request -- to prevent spoofing.
|
|
87
|
+
*/
|
|
88
|
+
export function buildUpstreamHeaders(incoming: Headers, user: ProxyUserInfo, tenantSubdomain: string): Headers {
|
|
89
|
+
const headers = new Headers();
|
|
90
|
+
for (const key of FORWARDED_HEADERS) {
|
|
91
|
+
const val = incoming.get(key);
|
|
92
|
+
if (val) headers.set(key, val);
|
|
93
|
+
}
|
|
94
|
+
// Forward original Host so upstream hostname allowlist doesn't reject
|
|
95
|
+
const host = incoming.get("host");
|
|
96
|
+
if (host) headers.set("host", host);
|
|
97
|
+
headers.set("x-platform-user-id", user.id);
|
|
98
|
+
headers.set("x-platform-tenant", tenantSubdomain);
|
|
99
|
+
if (user.email) headers.set("x-platform-user-email", user.email);
|
|
100
|
+
if (user.name) headers.set("x-platform-user-name", user.name);
|
|
101
|
+
return headers;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the upstream container URL for a tenant subdomain from the
|
|
106
|
+
* proxy route table. Returns null if no route exists or is unhealthy.
|
|
107
|
+
*/
|
|
108
|
+
function resolveContainerUrl(container: PlatformContainer, subdomain: string): string | null {
|
|
109
|
+
if (!container.fleet) return null;
|
|
110
|
+
const routes = container.fleet.proxy.getRoutes();
|
|
111
|
+
const route = routes.find((r) => r.subdomain === subdomain);
|
|
112
|
+
if (!route || !route.healthy) return null;
|
|
113
|
+
return `http://${route.upstreamHost}:${route.upstreamPort}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a tenant subdomain proxy middleware.
|
|
118
|
+
*
|
|
119
|
+
* If the request Host identifies a tenant subdomain, authenticates the user,
|
|
120
|
+
* resolves the fleet container URL, and proxies the request. Non-tenant
|
|
121
|
+
* requests (root domain, reserved subdomains) pass through to next().
|
|
122
|
+
*
|
|
123
|
+
* Fail-closed: if fleet services or orgMemberRepo are unavailable, returns
|
|
124
|
+
* 503 instead of silently skipping checks.
|
|
125
|
+
*/
|
|
126
|
+
export function createTenantProxyMiddleware(
|
|
127
|
+
container: PlatformContainer,
|
|
128
|
+
config: TenantProxyConfig,
|
|
129
|
+
): MiddlewareHandler {
|
|
130
|
+
const { platformDomain, resolveUser } = config;
|
|
131
|
+
|
|
132
|
+
return async (c, next) => {
|
|
133
|
+
const host = c.req.header("host");
|
|
134
|
+
if (!host) return next();
|
|
135
|
+
|
|
136
|
+
const subdomain = extractTenantSubdomain(host, platformDomain);
|
|
137
|
+
if (!subdomain) return next();
|
|
138
|
+
|
|
139
|
+
// --- Fail-closed checks ---
|
|
140
|
+
|
|
141
|
+
// Fleet services must be available for tenant proxying
|
|
142
|
+
if (!container.fleet) {
|
|
143
|
+
return c.json({ error: "Fleet services unavailable" }, 503);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Authenticate -- reject unauthenticated requests
|
|
147
|
+
const user = await resolveUser(c.req.raw);
|
|
148
|
+
if (!user) {
|
|
149
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Verify tenant ownership -- user must belong to the org that owns this subdomain
|
|
153
|
+
const profiles = await container.fleet.profileStore.list();
|
|
154
|
+
const profile = profiles.find((p) => p.name === subdomain);
|
|
155
|
+
if (!profile) {
|
|
156
|
+
return c.json({ error: "Tenant not found" }, 404);
|
|
157
|
+
}
|
|
158
|
+
const { validateTenantAccess } = await import("../../auth/index.js");
|
|
159
|
+
const hasAccess = await validateTenantAccess(user.id, profile.tenantId, container.orgMemberRepo);
|
|
160
|
+
if (!hasAccess) {
|
|
161
|
+
return c.json({ error: "Forbidden: not a member of this tenant" }, 403);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Resolve fleet container URL via proxy route table
|
|
165
|
+
const upstream = resolveContainerUrl(container, subdomain);
|
|
166
|
+
if (!upstream) {
|
|
167
|
+
return c.json({ error: "Tenant not found" }, 404);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const url = new URL(c.req.url);
|
|
171
|
+
const targetUrl = `${upstream}${url.pathname}${url.search}`;
|
|
172
|
+
const upstreamHeaders = buildUpstreamHeaders(c.req.raw.headers, user, subdomain);
|
|
173
|
+
|
|
174
|
+
let response: Response;
|
|
175
|
+
try {
|
|
176
|
+
response = await fetch(targetUrl, {
|
|
177
|
+
method: c.req.method,
|
|
178
|
+
headers: upstreamHeaders,
|
|
179
|
+
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
|
|
180
|
+
// @ts-expect-error duplex needed for streaming request bodies
|
|
181
|
+
duplex: "half",
|
|
182
|
+
});
|
|
183
|
+
} catch {
|
|
184
|
+
return c.json({ error: "Bad Gateway: upstream container unavailable" }, 502);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return new Response(response.body, {
|
|
188
|
+
status: response.status,
|
|
189
|
+
headers: response.headers,
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mountRoutes — wire shared HTTP routes and middleware onto a Hono app.
|
|
3
|
+
*
|
|
4
|
+
* Mounts routes conditionally based on which feature sub-containers are
|
|
5
|
+
* present on the PlatformContainer. Products call this after building the
|
|
6
|
+
* container; tRPC routers (admin, fleet-update, etc.) are mounted
|
|
7
|
+
* separately by products since they need product-specific auth context.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Hono } from "hono";
|
|
11
|
+
import { cors } from "hono/cors";
|
|
12
|
+
import { deriveCorsOrigins } from "../product-config/repository-types.js";
|
|
13
|
+
import type { RoutePlugin } from "./boot-config.js";
|
|
14
|
+
import type { PlatformContainer } from "./container.js";
|
|
15
|
+
import { createTenantProxyMiddleware } from "./middleware/tenant-proxy.js";
|
|
16
|
+
import { createCryptoWebhookRoutes } from "./routes/crypto-webhook.js";
|
|
17
|
+
import { createProvisionWebhookRoutes } from "./routes/provision-webhook.js";
|
|
18
|
+
import { createStripeWebhookRoutes } from "./routes/stripe-webhook.js";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Config accepted at mount time
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export interface MountConfig {
|
|
25
|
+
provisionSecret: string;
|
|
26
|
+
cryptoServiceKey?: string;
|
|
27
|
+
platformDomain: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// mountRoutes
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mount all shared routes and middleware onto a Hono app based on the
|
|
36
|
+
* container's enabled feature slices.
|
|
37
|
+
*
|
|
38
|
+
* Mount order:
|
|
39
|
+
* 1. CORS middleware (from productConfig domain list)
|
|
40
|
+
* 2. Health endpoint (always)
|
|
41
|
+
* 3. Crypto webhook (if crypto enabled)
|
|
42
|
+
* 4. Stripe webhook (if stripe enabled)
|
|
43
|
+
* 5. Provision webhook (if fleet enabled)
|
|
44
|
+
* 6. Product-specific route plugins
|
|
45
|
+
* 7. Tenant proxy middleware (catch-all — must be last)
|
|
46
|
+
*/
|
|
47
|
+
export function mountRoutes(
|
|
48
|
+
app: Hono,
|
|
49
|
+
container: PlatformContainer,
|
|
50
|
+
config: MountConfig,
|
|
51
|
+
plugins: RoutePlugin[] = [],
|
|
52
|
+
): void {
|
|
53
|
+
// 1. CORS middleware
|
|
54
|
+
const origins = deriveCorsOrigins(container.productConfig.product, container.productConfig.domains);
|
|
55
|
+
app.use(
|
|
56
|
+
"*",
|
|
57
|
+
cors({
|
|
58
|
+
origin: origins,
|
|
59
|
+
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
60
|
+
allowHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
|
|
61
|
+
credentials: true,
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// 2. Health endpoint (always available)
|
|
66
|
+
app.get("/health", (c) => c.json({ ok: true }));
|
|
67
|
+
|
|
68
|
+
// 3. Crypto webhook (when crypto payments are enabled)
|
|
69
|
+
if (container.crypto) {
|
|
70
|
+
app.route(
|
|
71
|
+
"/api/webhooks/crypto",
|
|
72
|
+
createCryptoWebhookRoutes(container, {
|
|
73
|
+
provisionSecret: config.provisionSecret,
|
|
74
|
+
cryptoServiceKey: config.cryptoServiceKey,
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 4. Stripe webhook (when stripe billing is enabled)
|
|
80
|
+
if (container.stripe) {
|
|
81
|
+
app.route("/api/webhooks/stripe", createStripeWebhookRoutes(container));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 5. Provision webhook (when fleet management is enabled)
|
|
85
|
+
if (container.fleet) {
|
|
86
|
+
const fleetConfig = container.productConfig.fleet;
|
|
87
|
+
app.route(
|
|
88
|
+
"/api/provision",
|
|
89
|
+
createProvisionWebhookRoutes(container, {
|
|
90
|
+
provisionSecret: config.provisionSecret,
|
|
91
|
+
instanceImage: fleetConfig?.containerImage ?? "ghcr.io/default:latest",
|
|
92
|
+
containerPort: fleetConfig?.containerPort ?? 3000,
|
|
93
|
+
maxInstancesPerTenant: fleetConfig?.maxInstances ?? 5,
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 6. Product-specific route plugins
|
|
99
|
+
for (const plugin of plugins) {
|
|
100
|
+
app.route(plugin.path, plugin.handler(container));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 7. Tenant proxy middleware (catch-all — MUST be last)
|
|
104
|
+
if (container.fleet) {
|
|
105
|
+
app.use(
|
|
106
|
+
"*",
|
|
107
|
+
createTenantProxyMiddleware(container, {
|
|
108
|
+
platformDomain: config.platformDomain,
|
|
109
|
+
resolveUser: async () => undefined, // Products override via plugin or direct middleware
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { IOrgMemberRepository } from "../../../tenancy/org-member-repository.js";
|
|
3
|
+
import { createCallerFactory, router, setTrpcOrgMemberRepo } from "../../../trpc/init.js";
|
|
4
|
+
import type { PlatformContainer } from "../../container.js";
|
|
5
|
+
import { createTestContainer } from "../../test-container.js";
|
|
6
|
+
import type { AdminRouterConfig } from "../admin.js";
|
|
7
|
+
import { createAdminRouter } from "../admin.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Mocks
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
// Mock global fetch for OpenRouter API calls
|
|
14
|
+
const mockFetch = vi.fn();
|
|
15
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
16
|
+
|
|
17
|
+
function makeMockOrgRepo(): IOrgMemberRepository {
|
|
18
|
+
return {
|
|
19
|
+
listMembers: vi.fn().mockResolvedValue([]),
|
|
20
|
+
addMember: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
updateMemberRole: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
removeMember: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
findMember: vi.fn().mockResolvedValue(null),
|
|
24
|
+
countAdminsAndOwners: vi.fn().mockResolvedValue(0),
|
|
25
|
+
listInvites: vi.fn().mockResolvedValue([]),
|
|
26
|
+
createInvite: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
findInviteById: vi.fn().mockResolvedValue(null),
|
|
28
|
+
findInviteByToken: vi.fn().mockResolvedValue(null),
|
|
29
|
+
deleteInvite: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
deleteAllMembers: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
deleteAllInvites: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
listOrgsByUser: vi.fn().mockResolvedValue([]),
|
|
33
|
+
markInviteAccepted: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
} as unknown as IOrgMemberRepository;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const adminCtx = {
|
|
38
|
+
user: { id: "admin-1", roles: ["platform_admin"] },
|
|
39
|
+
tenantId: undefined,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Helpers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function makeCaller(container: PlatformContainer, config?: AdminRouterConfig) {
|
|
47
|
+
const adminRouter = createAdminRouter(container, config);
|
|
48
|
+
const appRouter = router({ admin: adminRouter });
|
|
49
|
+
const createCaller = createCallerFactory(appRouter);
|
|
50
|
+
return createCaller(adminCtx);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Tests
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
describe("createAdminRouter", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
setTrpcOrgMemberRepo(makeMockOrgRepo());
|
|
60
|
+
mockFetch.mockReset();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// -------------------------------------------------------------------------
|
|
64
|
+
// Model list endpoint
|
|
65
|
+
// -------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe("listAvailableModels", () => {
|
|
68
|
+
it("returns cached models from OpenRouter API", async () => {
|
|
69
|
+
const container = createTestContainer();
|
|
70
|
+
const models = [
|
|
71
|
+
{
|
|
72
|
+
id: "openai/gpt-4",
|
|
73
|
+
name: "GPT-4",
|
|
74
|
+
context_length: 8192,
|
|
75
|
+
pricing: { prompt: "0.03", completion: "0.06" },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "anthropic/claude-3",
|
|
79
|
+
name: "Claude 3",
|
|
80
|
+
context_length: 200000,
|
|
81
|
+
pricing: { prompt: "0.015", completion: "0.075" },
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
mockFetch.mockResolvedValueOnce({
|
|
86
|
+
ok: true,
|
|
87
|
+
json: async () => ({ data: models }),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const caller = makeCaller(container, { openRouterApiKey: "sk-test-key" });
|
|
91
|
+
const result = await caller.admin.listAvailableModels();
|
|
92
|
+
|
|
93
|
+
expect(result.models).toHaveLength(2);
|
|
94
|
+
expect(result.models[0].id).toBe("anthropic/claude-3");
|
|
95
|
+
expect(result.models[1].id).toBe("openai/gpt-4");
|
|
96
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
97
|
+
|
|
98
|
+
// Second call should use cache (no additional fetch)
|
|
99
|
+
const result2 = await caller.admin.listAvailableModels();
|
|
100
|
+
expect(result2.models).toHaveLength(2);
|
|
101
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns empty list when no API key configured", async () => {
|
|
105
|
+
const container = createTestContainer();
|
|
106
|
+
const caller = makeCaller(container); // no config = no API key
|
|
107
|
+
|
|
108
|
+
const result = await caller.admin.listAvailableModels();
|
|
109
|
+
|
|
110
|
+
expect(result.models).toEqual([]);
|
|
111
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns stale cache on fetch failure", async () => {
|
|
115
|
+
// First call seeds the cache
|
|
116
|
+
const container = createTestContainer();
|
|
117
|
+
mockFetch.mockResolvedValueOnce({
|
|
118
|
+
ok: true,
|
|
119
|
+
json: async () => ({
|
|
120
|
+
data: [{ id: "model/a", name: "A", context_length: 100, pricing: { prompt: "1", completion: "2" } }],
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
const caller = makeCaller(container, { openRouterApiKey: "sk-test-key" });
|
|
124
|
+
await caller.admin.listAvailableModels();
|
|
125
|
+
|
|
126
|
+
// Advance past 60s cache TTL so the next call triggers a fetch
|
|
127
|
+
vi.useFakeTimers();
|
|
128
|
+
vi.advanceTimersByTime(61_000);
|
|
129
|
+
|
|
130
|
+
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
|
131
|
+
const result = await caller.admin.listAvailableModels();
|
|
132
|
+
|
|
133
|
+
// On failure, falls back to stale cache
|
|
134
|
+
expect(result.models).toHaveLength(1);
|
|
135
|
+
expect(result.models[0].id).toBe("model/a");
|
|
136
|
+
|
|
137
|
+
vi.useRealTimers();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// -------------------------------------------------------------------------
|
|
142
|
+
// Fleet instance listing
|
|
143
|
+
// -------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
describe("listAllInstances", () => {
|
|
146
|
+
it("lists instances using container.fleet", async () => {
|
|
147
|
+
const mockProfiles = [
|
|
148
|
+
{ id: "inst-1", name: "Bot A", tenantId: "t1", image: "img:latest" },
|
|
149
|
+
{ id: "inst-2", name: "Bot B", tenantId: "t2", image: "img:v2" },
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
const mockStatus = {
|
|
153
|
+
state: "running",
|
|
154
|
+
health: "healthy",
|
|
155
|
+
uptime: 3600,
|
|
156
|
+
containerId: "abc123",
|
|
157
|
+
startedAt: "2026-01-01T00:00:00Z",
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const container = createTestContainer({
|
|
161
|
+
fleet: {
|
|
162
|
+
manager: {
|
|
163
|
+
status: vi.fn().mockResolvedValue(mockStatus),
|
|
164
|
+
} as never,
|
|
165
|
+
docker: {} as never,
|
|
166
|
+
proxy: {} as never,
|
|
167
|
+
profileStore: {
|
|
168
|
+
list: vi.fn().mockResolvedValue(mockProfiles),
|
|
169
|
+
init: vi.fn(),
|
|
170
|
+
save: vi.fn(),
|
|
171
|
+
get: vi.fn(),
|
|
172
|
+
delete: vi.fn(),
|
|
173
|
+
},
|
|
174
|
+
serviceKeyRepo: {} as never,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const caller = makeCaller(container);
|
|
179
|
+
const result = await caller.admin.listAllInstances();
|
|
180
|
+
|
|
181
|
+
expect(result.instances).toHaveLength(2);
|
|
182
|
+
expect(result.instances[0]).toEqual({
|
|
183
|
+
id: "inst-1",
|
|
184
|
+
name: "Bot A",
|
|
185
|
+
tenantId: "t1",
|
|
186
|
+
image: "img:latest",
|
|
187
|
+
state: "running",
|
|
188
|
+
health: "healthy",
|
|
189
|
+
uptime: 3600,
|
|
190
|
+
containerId: "abc123",
|
|
191
|
+
startedAt: "2026-01-01T00:00:00Z",
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("returns error when fleet not configured", async () => {
|
|
196
|
+
const container = createTestContainer({ fleet: null });
|
|
197
|
+
const caller = makeCaller(container);
|
|
198
|
+
const result = await caller.admin.listAllInstances();
|
|
199
|
+
|
|
200
|
+
expect(result.instances).toEqual([]);
|
|
201
|
+
expect(result.error).toBe("Fleet not configured");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("returns error state for instances that fail status check", async () => {
|
|
205
|
+
const mockProfiles = [{ id: "inst-bad", name: "Bad Bot", tenantId: "t1", image: "img:latest" }];
|
|
206
|
+
|
|
207
|
+
const container = createTestContainer({
|
|
208
|
+
fleet: {
|
|
209
|
+
manager: {
|
|
210
|
+
status: vi.fn().mockRejectedValue(new Error("container not found")),
|
|
211
|
+
} as never,
|
|
212
|
+
docker: {} as never,
|
|
213
|
+
proxy: {} as never,
|
|
214
|
+
profileStore: {
|
|
215
|
+
list: vi.fn().mockResolvedValue(mockProfiles),
|
|
216
|
+
init: vi.fn(),
|
|
217
|
+
save: vi.fn(),
|
|
218
|
+
get: vi.fn(),
|
|
219
|
+
delete: vi.fn(),
|
|
220
|
+
},
|
|
221
|
+
serviceKeyRepo: {} as never,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const caller = makeCaller(container);
|
|
226
|
+
const result = await caller.admin.listAllInstances();
|
|
227
|
+
|
|
228
|
+
expect(result.instances).toHaveLength(1);
|
|
229
|
+
expect(result.instances[0].state).toBe("error");
|
|
230
|
+
expect(result.instances[0].health).toBeNull();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// -------------------------------------------------------------------------
|
|
235
|
+
// Credit balance query
|
|
236
|
+
// -------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
describe("billingOverview", () => {
|
|
239
|
+
it("queries credit balance via container pool", async () => {
|
|
240
|
+
const mockPool = {
|
|
241
|
+
query: vi
|
|
242
|
+
.fn()
|
|
243
|
+
.mockResolvedValueOnce({ rows: [{ totalRaw: "50000000" }] }) // credit_entry sum (50M microdollars = 5000 cents)
|
|
244
|
+
.mockResolvedValueOnce({ rows: [{ count: "3" }] }) // payment methods
|
|
245
|
+
.mockResolvedValueOnce({ rows: [{ count: "5" }] }), // orgs
|
|
246
|
+
end: vi.fn(),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const container = createTestContainer({
|
|
250
|
+
pool: mockPool as never,
|
|
251
|
+
gateway: null, // no gateway = skip service key count
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const caller = makeCaller(container);
|
|
255
|
+
const result = await caller.admin.billingOverview();
|
|
256
|
+
|
|
257
|
+
expect(result.totalBalanceCents).toBe(5000);
|
|
258
|
+
expect(result.activeKeyCount).toBe(0); // gateway not configured
|
|
259
|
+
expect(result.paymentMethodCount).toBe(3);
|
|
260
|
+
expect(result.orgCount).toBe(5);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("counts active service keys when gateway is configured", async () => {
|
|
264
|
+
const mockPool = {
|
|
265
|
+
query: vi
|
|
266
|
+
.fn()
|
|
267
|
+
.mockResolvedValueOnce({ rows: [{ totalRaw: "0" }] }) // credit_entry
|
|
268
|
+
.mockResolvedValueOnce({ rows: [{ count: "7" }] }) // service_keys
|
|
269
|
+
.mockResolvedValueOnce({ rows: [{ count: "0" }] }) // payment methods
|
|
270
|
+
.mockResolvedValueOnce({ rows: [{ count: "0" }] }), // orgs
|
|
271
|
+
end: vi.fn(),
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const container = createTestContainer({
|
|
275
|
+
pool: mockPool as never,
|
|
276
|
+
gateway: {
|
|
277
|
+
serviceKeyRepo: {} as never,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const caller = makeCaller(container);
|
|
282
|
+
const result = await caller.admin.billingOverview();
|
|
283
|
+
|
|
284
|
+
expect(result.activeKeyCount).toBe(7);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("returns zeros when tables do not exist", async () => {
|
|
288
|
+
const mockPool = {
|
|
289
|
+
query: vi.fn().mockRejectedValue(new Error('relation "credit_entry" does not exist')),
|
|
290
|
+
end: vi.fn(),
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const container = createTestContainer({
|
|
294
|
+
pool: mockPool as never,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const caller = makeCaller(container);
|
|
298
|
+
const result = await caller.admin.billingOverview();
|
|
299
|
+
|
|
300
|
+
expect(result.totalBalanceCents).toBe(0);
|
|
301
|
+
expect(result.activeKeyCount).toBe(0);
|
|
302
|
+
expect(result.paymentMethodCount).toBe(0);
|
|
303
|
+
expect(result.orgCount).toBe(0);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// -------------------------------------------------------------------------
|
|
308
|
+
// Auth guard
|
|
309
|
+
// -------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
it("rejects non-admin users", async () => {
|
|
312
|
+
const container = createTestContainer();
|
|
313
|
+
const adminRouter = createAdminRouter(container);
|
|
314
|
+
const appRouter = router({ admin: adminRouter });
|
|
315
|
+
const createCaller = createCallerFactory(appRouter);
|
|
316
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
317
|
+
|
|
318
|
+
await expect(caller.admin.listAvailableModels()).rejects.toThrow("Platform admin role required");
|
|
319
|
+
});
|
|
320
|
+
});
|