@wopr-network/platform-core 1.68.0 → 1.70.0

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