@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,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* platform-core server entry point.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports types, the test helper, and provides bootPlatformServer() —
|
|
5
|
+
* the single-call boot function products use to go from a declarative
|
|
6
|
+
* BootConfig to a running Hono server with DI container.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import type { BootConfig, BootResult } from "./boot-config.js";
|
|
11
|
+
import { buildContainer } from "./container.js";
|
|
12
|
+
import { type BackgroundHandles, gracefulShutdown, startBackgroundServices } from "./lifecycle.js";
|
|
13
|
+
import { mountRoutes } from "./mount-routes.js";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Re-exports
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export type { BootConfig, BootResult, FeatureFlags, RoutePlugin } from "./boot-config.js";
|
|
20
|
+
export type {
|
|
21
|
+
CryptoServices,
|
|
22
|
+
FleetServices,
|
|
23
|
+
GatewayServices,
|
|
24
|
+
HotPoolServices,
|
|
25
|
+
PlatformContainer,
|
|
26
|
+
StripeServices,
|
|
27
|
+
} from "./container.js";
|
|
28
|
+
export { buildContainer } from "./container.js";
|
|
29
|
+
export { type BackgroundHandles, gracefulShutdown, startBackgroundServices } from "./lifecycle.js";
|
|
30
|
+
export { type MountConfig, mountRoutes } from "./mount-routes.js";
|
|
31
|
+
export { createTestContainer } from "./test-container.js";
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// bootPlatformServer
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Boot a fully-wired platform server from a declarative config.
|
|
39
|
+
*
|
|
40
|
+
* 1. Builds the DI container (DB, migrations, product config, feature slices)
|
|
41
|
+
* 2. Creates a Hono app and mounts shared routes
|
|
42
|
+
* 3. Returns start/stop lifecycle hooks
|
|
43
|
+
*
|
|
44
|
+
* Products call this from their index.ts:
|
|
45
|
+
* ```ts
|
|
46
|
+
* const { app, container, start, stop } = await bootPlatformServer({
|
|
47
|
+
* slug: "paperclip",
|
|
48
|
+
* databaseUrl: process.env.DATABASE_URL!,
|
|
49
|
+
* provisionSecret: process.env.PROVISION_SECRET!,
|
|
50
|
+
* features: { fleet: true, crypto: true, stripe: true, gateway: true, hotPool: false },
|
|
51
|
+
* });
|
|
52
|
+
* await start();
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export async function bootPlatformServer(config: BootConfig): Promise<BootResult> {
|
|
56
|
+
const container = await buildContainer(config);
|
|
57
|
+
const app = new Hono();
|
|
58
|
+
|
|
59
|
+
mountRoutes(
|
|
60
|
+
app,
|
|
61
|
+
container,
|
|
62
|
+
{
|
|
63
|
+
provisionSecret: config.provisionSecret,
|
|
64
|
+
cryptoServiceKey: config.cryptoServiceKey,
|
|
65
|
+
platformDomain: container.productConfig.product?.domain ?? "localhost",
|
|
66
|
+
},
|
|
67
|
+
config.routes,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
let handles: BackgroundHandles | null = null;
|
|
71
|
+
let server: { close: () => void } | null = null;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
app,
|
|
75
|
+
container,
|
|
76
|
+
start: async (port?: number) => {
|
|
77
|
+
const { serve } = await import("@hono/node-server");
|
|
78
|
+
const listenPort = port ?? config.port ?? 3001;
|
|
79
|
+
const hostname = config.host ?? "0.0.0.0";
|
|
80
|
+
|
|
81
|
+
server = serve({ fetch: app.fetch, hostname, port: listenPort }, async () => {
|
|
82
|
+
handles = await startBackgroundServices(container);
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
stop: async () => {
|
|
86
|
+
if (server) server.close();
|
|
87
|
+
if (handles) {
|
|
88
|
+
await gracefulShutdown(container, handles);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle management — background services and graceful shutdown.
|
|
3
|
+
*
|
|
4
|
+
* Products currently handle background tasks in their serve() callbacks.
|
|
5
|
+
* This module provides a standard interface for starting and stopping
|
|
6
|
+
* those tasks so bootPlatformServer can manage them uniformly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PlatformContainer } from "./container.js";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface BackgroundHandles {
|
|
16
|
+
intervals: ReturnType<typeof setInterval>[];
|
|
17
|
+
unsubscribes: (() => void)[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// startBackgroundServices
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Start background services that run after the server is listening.
|
|
26
|
+
*
|
|
27
|
+
* Currently a thin scaffold — the hooks exist so products can migrate their
|
|
28
|
+
* background tasks (fleet updater, notification worker, caddy hydration,
|
|
29
|
+
* health monitor) incrementally without changing the boot contract.
|
|
30
|
+
*/
|
|
31
|
+
export async function startBackgroundServices(container: PlatformContainer): Promise<BackgroundHandles> {
|
|
32
|
+
const handles: BackgroundHandles = { intervals: [], unsubscribes: [] };
|
|
33
|
+
|
|
34
|
+
// Caddy proxy hydration (if fleet + proxy are enabled)
|
|
35
|
+
if (container.fleet?.proxy) {
|
|
36
|
+
try {
|
|
37
|
+
await container.fleet.proxy.start?.();
|
|
38
|
+
} catch {
|
|
39
|
+
// Non-fatal — proxy sync will retry on next health tick
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Hot pool manager (if enabled)
|
|
44
|
+
if (container.hotPool) {
|
|
45
|
+
try {
|
|
46
|
+
const poolHandles = await container.hotPool.start();
|
|
47
|
+
handles.unsubscribes.push(poolHandles.stop);
|
|
48
|
+
} catch {
|
|
49
|
+
// Non-fatal — pool will be empty but claiming falls back to cold create
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return handles;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// gracefulShutdown
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Graceful shutdown: clear intervals, call unsubscribe hooks, close the
|
|
62
|
+
* database connection pool.
|
|
63
|
+
*/
|
|
64
|
+
export async function gracefulShutdown(container: PlatformContainer, handles: BackgroundHandles): Promise<void> {
|
|
65
|
+
for (const interval of handles.intervals) {
|
|
66
|
+
clearInterval(interval);
|
|
67
|
+
}
|
|
68
|
+
for (const unsub of handles.unsubscribes) {
|
|
69
|
+
unsub();
|
|
70
|
+
}
|
|
71
|
+
await container.pool.end();
|
|
72
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createAdminAuthMiddleware } from "../admin-auth.js";
|
|
4
|
+
|
|
5
|
+
function createApp(adminApiKey: string) {
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
app.use("/admin/*", createAdminAuthMiddleware({ adminApiKey }));
|
|
8
|
+
app.get("/admin/status", (c) => c.json({ ok: true }));
|
|
9
|
+
return app;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("createAdminAuthMiddleware", () => {
|
|
13
|
+
const API_KEY = "test-admin-key-abc123";
|
|
14
|
+
|
|
15
|
+
it("returns 401 without Authorization header", async () => {
|
|
16
|
+
const app = createApp(API_KEY);
|
|
17
|
+
const res = await app.request("/admin/status");
|
|
18
|
+
expect(res.status).toBe(401);
|
|
19
|
+
const body = await res.json();
|
|
20
|
+
expect(body.error).toContain("Unauthorized");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns 401 with wrong API key", async () => {
|
|
24
|
+
const app = createApp(API_KEY);
|
|
25
|
+
const res = await app.request("/admin/status", {
|
|
26
|
+
headers: { authorization: "Bearer wrong-key" },
|
|
27
|
+
});
|
|
28
|
+
expect(res.status).toBe(401);
|
|
29
|
+
const body = await res.json();
|
|
30
|
+
expect(body.error).toContain("invalid admin credentials");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("passes through with correct API key (timing-safe)", async () => {
|
|
34
|
+
const app = createApp(API_KEY);
|
|
35
|
+
const res = await app.request("/admin/status", {
|
|
36
|
+
headers: { authorization: `Bearer ${API_KEY}` },
|
|
37
|
+
});
|
|
38
|
+
expect(res.status).toBe(200);
|
|
39
|
+
const body = await res.json();
|
|
40
|
+
expect(body.ok).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects keys of different length (timing-safe length check)", async () => {
|
|
44
|
+
const app = createApp(API_KEY);
|
|
45
|
+
// Key with same prefix but different length
|
|
46
|
+
const res = await app.request("/admin/status", {
|
|
47
|
+
headers: { authorization: `Bearer ${API_KEY}extra` },
|
|
48
|
+
});
|
|
49
|
+
expect(res.status).toBe(401);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns 503 when admin key is not configured (fail-closed)", async () => {
|
|
53
|
+
const app = createApp("");
|
|
54
|
+
const res = await app.request("/admin/status", {
|
|
55
|
+
headers: { authorization: "Bearer anything" },
|
|
56
|
+
});
|
|
57
|
+
expect(res.status).toBe(503);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rejects non-Bearer auth schemes", async () => {
|
|
61
|
+
const app = createApp(API_KEY);
|
|
62
|
+
const res = await app.request("/admin/status", {
|
|
63
|
+
headers: { authorization: `Basic ${API_KEY}` },
|
|
64
|
+
});
|
|
65
|
+
expect(res.status).toBe(401);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";
|
|
3
|
+
import type { ProxyRoute } from "../../../proxy/types.js";
|
|
4
|
+
import { createTestContainer } from "../../test-container.js";
|
|
5
|
+
import {
|
|
6
|
+
buildUpstreamHeaders,
|
|
7
|
+
createTenantProxyMiddleware,
|
|
8
|
+
extractTenantSubdomain,
|
|
9
|
+
type ProxyUserInfo,
|
|
10
|
+
type TenantProxyConfig,
|
|
11
|
+
} from "../tenant-proxy.js";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// extractTenantSubdomain unit tests
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
describe("extractTenantSubdomain", () => {
|
|
18
|
+
const domain = "example.com";
|
|
19
|
+
|
|
20
|
+
it("extracts a valid subdomain", () => {
|
|
21
|
+
expect(extractTenantSubdomain("alice.example.com", domain)).toBe("alice");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns null for the root domain", () => {
|
|
25
|
+
expect(extractTenantSubdomain("example.com", domain)).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns null for reserved subdomains", () => {
|
|
29
|
+
expect(extractTenantSubdomain("app.example.com", domain)).toBeNull();
|
|
30
|
+
expect(extractTenantSubdomain("api.example.com", domain)).toBeNull();
|
|
31
|
+
expect(extractTenantSubdomain("admin.example.com", domain)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns null for nested subdomains", () => {
|
|
35
|
+
expect(extractTenantSubdomain("deep.alice.example.com", domain)).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("strips port before matching", () => {
|
|
39
|
+
expect(extractTenantSubdomain("alice.example.com:3000", domain)).toBe("alice");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns null for non-matching domain", () => {
|
|
43
|
+
expect(extractTenantSubdomain("alice.other.com", domain)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns null for invalid subdomain characters", () => {
|
|
47
|
+
expect(extractTenantSubdomain("al!ce.example.com", domain)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// buildUpstreamHeaders unit tests
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
describe("buildUpstreamHeaders", () => {
|
|
56
|
+
it("copies only allowlisted headers and injects platform headers", () => {
|
|
57
|
+
const incoming = new Headers({
|
|
58
|
+
"content-type": "application/json",
|
|
59
|
+
"x-evil-header": "should-not-pass",
|
|
60
|
+
host: "alice.example.com",
|
|
61
|
+
});
|
|
62
|
+
const user: ProxyUserInfo = { id: "u1", email: "a@b.com", name: "Alice" };
|
|
63
|
+
const result = buildUpstreamHeaders(incoming, user, "alice");
|
|
64
|
+
|
|
65
|
+
expect(result.get("content-type")).toBe("application/json");
|
|
66
|
+
expect(result.get("x-evil-header")).toBeNull();
|
|
67
|
+
expect(result.get("x-platform-user-id")).toBe("u1");
|
|
68
|
+
expect(result.get("x-platform-tenant")).toBe("alice");
|
|
69
|
+
expect(result.get("x-platform-user-email")).toBe("a@b.com");
|
|
70
|
+
expect(result.get("x-platform-user-name")).toBe("Alice");
|
|
71
|
+
expect(result.get("host")).toBe("alice.example.com");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// createTenantProxyMiddleware integration tests
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
describe("createTenantProxyMiddleware", () => {
|
|
80
|
+
const DOMAIN = "example.com";
|
|
81
|
+
let resolveUser: Mock<(req: Request) => Promise<ProxyUserInfo | undefined>>;
|
|
82
|
+
let config: TenantProxyConfig;
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
resolveUser = vi.fn<(req: Request) => Promise<ProxyUserInfo | undefined>>();
|
|
86
|
+
config = { platformDomain: DOMAIN, resolveUser };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function createApp(container: ReturnType<typeof createTestContainer>) {
|
|
90
|
+
const app = new Hono();
|
|
91
|
+
app.use("/*", createTenantProxyMiddleware(container, config));
|
|
92
|
+
app.get("/fallthrough", (c) => c.json({ fallthrough: true }));
|
|
93
|
+
return app;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
it("passes through non-tenant requests (no subdomain)", async () => {
|
|
97
|
+
const container = createTestContainer();
|
|
98
|
+
const app = createApp(container);
|
|
99
|
+
const res = await app.request("http://example.com/fallthrough");
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
const body = await res.json();
|
|
102
|
+
expect(body.fallthrough).toBe(true);
|
|
103
|
+
// resolveUser should not be called for non-tenant requests
|
|
104
|
+
expect(resolveUser).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns 401 for unauthenticated tenant requests", async () => {
|
|
108
|
+
const container = createTestContainer({
|
|
109
|
+
fleet: {
|
|
110
|
+
profileStore: { list: vi.fn().mockResolvedValue([]) } as never,
|
|
111
|
+
proxy: { getRoutes: vi.fn().mockReturnValue([]) } as never,
|
|
112
|
+
manager: {} as never,
|
|
113
|
+
docker: {} as never,
|
|
114
|
+
serviceKeyRepo: {} as never,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
resolveUser.mockResolvedValue(undefined);
|
|
118
|
+
const app = createApp(container);
|
|
119
|
+
|
|
120
|
+
const res = await app.request("http://alice.example.com/dashboard", {
|
|
121
|
+
headers: { host: "alice.example.com" },
|
|
122
|
+
});
|
|
123
|
+
expect(res.status).toBe(401);
|
|
124
|
+
const body = await res.json();
|
|
125
|
+
expect(body.error).toContain("Authentication required");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns 503 when fleet services are not available", async () => {
|
|
129
|
+
const container = createTestContainer({ fleet: null });
|
|
130
|
+
const app = createApp(container);
|
|
131
|
+
|
|
132
|
+
const res = await app.request("http://alice.example.com/dashboard", {
|
|
133
|
+
headers: { host: "alice.example.com" },
|
|
134
|
+
});
|
|
135
|
+
expect(res.status).toBe(503);
|
|
136
|
+
const body = await res.json();
|
|
137
|
+
expect(body.error).toContain("Fleet services unavailable");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("returns 403 when user is not a member of the tenant", async () => {
|
|
141
|
+
const mockProfile = { name: "alice", tenantId: "tenant-1" };
|
|
142
|
+
const container = createTestContainer({
|
|
143
|
+
fleet: {
|
|
144
|
+
profileStore: { list: vi.fn().mockResolvedValue([mockProfile]) } as never,
|
|
145
|
+
proxy: {
|
|
146
|
+
getRoutes: vi
|
|
147
|
+
.fn()
|
|
148
|
+
.mockReturnValue([{ subdomain: "alice", upstreamHost: "127.0.0.1", upstreamPort: 4000, healthy: true }]),
|
|
149
|
+
} as never,
|
|
150
|
+
manager: {} as never,
|
|
151
|
+
docker: {} as never,
|
|
152
|
+
serviceKeyRepo: {} as never,
|
|
153
|
+
},
|
|
154
|
+
orgMemberRepo: {
|
|
155
|
+
findMember: vi.fn().mockResolvedValue(null),
|
|
156
|
+
listMembers: vi.fn(),
|
|
157
|
+
addMember: vi.fn(),
|
|
158
|
+
updateMemberRole: vi.fn(),
|
|
159
|
+
removeMember: vi.fn(),
|
|
160
|
+
countAdminsAndOwners: vi.fn(),
|
|
161
|
+
listInvites: vi.fn(),
|
|
162
|
+
createInvite: vi.fn(),
|
|
163
|
+
findInviteById: vi.fn(),
|
|
164
|
+
findInviteByToken: vi.fn(),
|
|
165
|
+
deleteInvite: vi.fn(),
|
|
166
|
+
deleteAllMembers: vi.fn(),
|
|
167
|
+
deleteAllInvites: vi.fn(),
|
|
168
|
+
listOrgsByUser: vi.fn(),
|
|
169
|
+
markInviteAccepted: vi.fn(),
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
resolveUser.mockResolvedValue({ id: "user-99", email: "user@test.com" });
|
|
173
|
+
const app = createApp(container);
|
|
174
|
+
|
|
175
|
+
const res = await app.request("http://alice.example.com/dashboard", {
|
|
176
|
+
headers: { host: "alice.example.com" },
|
|
177
|
+
});
|
|
178
|
+
expect(res.status).toBe(403);
|
|
179
|
+
const body = await res.json();
|
|
180
|
+
expect(body.error).toContain("not a member");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("proxies correctly when authorized", async () => {
|
|
184
|
+
const mockProfile = { name: "alice", tenantId: "tenant-1" };
|
|
185
|
+
const upstreamRoute: ProxyRoute = {
|
|
186
|
+
instanceId: "inst-1",
|
|
187
|
+
subdomain: "alice",
|
|
188
|
+
upstreamHost: "127.0.0.1",
|
|
189
|
+
upstreamPort: 4000,
|
|
190
|
+
healthy: true,
|
|
191
|
+
};
|
|
192
|
+
const container = createTestContainer({
|
|
193
|
+
fleet: {
|
|
194
|
+
profileStore: { list: vi.fn().mockResolvedValue([mockProfile]) } as never,
|
|
195
|
+
proxy: { getRoutes: vi.fn().mockReturnValue([upstreamRoute]) } as never,
|
|
196
|
+
manager: {} as never,
|
|
197
|
+
docker: {} as never,
|
|
198
|
+
serviceKeyRepo: {} as never,
|
|
199
|
+
},
|
|
200
|
+
orgMemberRepo: {
|
|
201
|
+
findMember: vi.fn().mockResolvedValue({ orgId: "tenant-1", userId: "user-1", role: "member" }),
|
|
202
|
+
listMembers: vi.fn(),
|
|
203
|
+
addMember: vi.fn(),
|
|
204
|
+
updateMemberRole: vi.fn(),
|
|
205
|
+
removeMember: vi.fn(),
|
|
206
|
+
countAdminsAndOwners: vi.fn(),
|
|
207
|
+
listInvites: vi.fn(),
|
|
208
|
+
createInvite: vi.fn(),
|
|
209
|
+
findInviteById: vi.fn(),
|
|
210
|
+
findInviteByToken: vi.fn(),
|
|
211
|
+
deleteInvite: vi.fn(),
|
|
212
|
+
deleteAllMembers: vi.fn(),
|
|
213
|
+
deleteAllInvites: vi.fn(),
|
|
214
|
+
listOrgsByUser: vi.fn(),
|
|
215
|
+
markInviteAccepted: vi.fn(),
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
resolveUser.mockResolvedValue({ id: "user-1", email: "user@test.com" });
|
|
219
|
+
|
|
220
|
+
// Mock global fetch to simulate upstream response
|
|
221
|
+
const originalFetch = globalThis.fetch;
|
|
222
|
+
globalThis.fetch = vi.fn().mockResolvedValue(
|
|
223
|
+
new Response(JSON.stringify({ upstream: true }), {
|
|
224
|
+
status: 200,
|
|
225
|
+
headers: { "content-type": "application/json" },
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const app = createApp(container);
|
|
231
|
+
const res = await app.request("http://alice.example.com/api/data?q=1", {
|
|
232
|
+
headers: { host: "alice.example.com" },
|
|
233
|
+
});
|
|
234
|
+
expect(res.status).toBe(200);
|
|
235
|
+
const body = await res.json();
|
|
236
|
+
expect(body.upstream).toBe(true);
|
|
237
|
+
|
|
238
|
+
// Verify fetch was called with the correct upstream URL
|
|
239
|
+
const fetchCall = (globalThis.fetch as Mock).mock.calls[0];
|
|
240
|
+
expect(fetchCall[0]).toBe("http://127.0.0.1:4000/api/data?q=1");
|
|
241
|
+
|
|
242
|
+
// Verify platform headers were injected
|
|
243
|
+
const headers = fetchCall[1].headers as Headers;
|
|
244
|
+
expect(headers.get("x-platform-user-id")).toBe("user-1");
|
|
245
|
+
expect(headers.get("x-platform-tenant")).toBe("alice");
|
|
246
|
+
} finally {
|
|
247
|
+
globalThis.fetch = originalFetch;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("returns 502 when upstream fetch fails", async () => {
|
|
252
|
+
const mockProfile = { name: "bob", tenantId: "user-1" };
|
|
253
|
+
const upstreamRoute: ProxyRoute = {
|
|
254
|
+
instanceId: "inst-2",
|
|
255
|
+
subdomain: "bob",
|
|
256
|
+
upstreamHost: "127.0.0.1",
|
|
257
|
+
upstreamPort: 4001,
|
|
258
|
+
healthy: true,
|
|
259
|
+
};
|
|
260
|
+
const container = createTestContainer({
|
|
261
|
+
fleet: {
|
|
262
|
+
profileStore: { list: vi.fn().mockResolvedValue([mockProfile]) } as never,
|
|
263
|
+
proxy: { getRoutes: vi.fn().mockReturnValue([upstreamRoute]) } as never,
|
|
264
|
+
manager: {} as never,
|
|
265
|
+
docker: {} as never,
|
|
266
|
+
serviceKeyRepo: {} as never,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
// tenantId === userId means personal tenant, so validateTenantAccess returns true
|
|
270
|
+
resolveUser.mockResolvedValue({ id: "user-1" });
|
|
271
|
+
|
|
272
|
+
const originalFetch = globalThis.fetch;
|
|
273
|
+
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const app = createApp(container);
|
|
277
|
+
const res = await app.request("http://bob.example.com/test", {
|
|
278
|
+
headers: { host: "bob.example.com" },
|
|
279
|
+
});
|
|
280
|
+
expect(res.status).toBe(502);
|
|
281
|
+
const body = await res.json();
|
|
282
|
+
expect(body.error).toContain("Bad Gateway");
|
|
283
|
+
} finally {
|
|
284
|
+
globalThis.fetch = originalFetch;
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("returns 404 when subdomain has no upstream route", async () => {
|
|
289
|
+
const container = createTestContainer({
|
|
290
|
+
fleet: {
|
|
291
|
+
profileStore: { list: vi.fn().mockResolvedValue([]) } as never,
|
|
292
|
+
proxy: { getRoutes: vi.fn().mockReturnValue([]) } as never,
|
|
293
|
+
manager: {} as never,
|
|
294
|
+
docker: {} as never,
|
|
295
|
+
serviceKeyRepo: {} as never,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
resolveUser.mockResolvedValue({ id: "user-1" });
|
|
299
|
+
const app = createApp(container);
|
|
300
|
+
|
|
301
|
+
const res = await app.request("http://ghost.example.com/test", {
|
|
302
|
+
headers: { host: "ghost.example.com" },
|
|
303
|
+
});
|
|
304
|
+
expect(res.status).toBe(404);
|
|
305
|
+
const body = await res.json();
|
|
306
|
+
expect(body.error).toContain("Tenant not found");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
|
|
9
|
+
import { timingSafeEqual } from "node:crypto";
|
|
10
|
+
import type { MiddlewareHandler } from "hono";
|
|
11
|
+
|
|
12
|
+
export interface AdminAuthConfig {
|
|
13
|
+
adminApiKey: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create an admin auth middleware that validates Bearer tokens
|
|
18
|
+
* against the configured admin API key using timing-safe comparison.
|
|
19
|
+
*
|
|
20
|
+
* Fail-closed: if the key is empty or missing, all requests are rejected.
|
|
21
|
+
*/
|
|
22
|
+
export function createAdminAuthMiddleware(config: AdminAuthConfig): MiddlewareHandler {
|
|
23
|
+
const { adminApiKey } = config;
|
|
24
|
+
|
|
25
|
+
return async (c, next) => {
|
|
26
|
+
// Fail closed: if no admin key is configured, reject everything
|
|
27
|
+
if (!adminApiKey) {
|
|
28
|
+
return c.json({ error: "Admin authentication not configured" }, 503);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const authHeader = c.req.header("authorization");
|
|
32
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
33
|
+
return c.json({ error: "Unauthorized: admin authentication required" }, 401);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const token = authHeader.slice("Bearer ".length).trim();
|
|
37
|
+
if (!token) {
|
|
38
|
+
return c.json({ error: "Unauthorized: admin authentication required" }, 401);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Timing-safe comparison: both buffers must be the same length
|
|
42
|
+
const tokenBuf = Buffer.from(token);
|
|
43
|
+
const keyBuf = Buffer.from(adminApiKey);
|
|
44
|
+
|
|
45
|
+
if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) {
|
|
46
|
+
return c.json({ error: "Unauthorized: invalid admin credentials" }, 401);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return next();
|
|
50
|
+
};
|
|
51
|
+
}
|