From 5c9dc12cbd676a07c4a67e8134230bb196b78be6 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Tue, 11 Aug 2026 14:53:26 -0500 Subject: [PATCH 1/4] refactor: extract host middleware/routes, add api test suite - host: extract security middleware (cors/csrf/rate-limit/csp), static asset proxy, and API oRPC route setup out of program.ts into dedicated modules composed via SecurityMiddleware Live layer - host: emit site.webmanifest manifest link in renderClientShell and fix runtime-remote test to assert it (drop stale BOS iframe assertion) - host: dedupe normalizeDomain/normalizeUrl/extractErrorDetails into utils, remove inline duplicates from program/plugins/tenant-runtime - api: add vitest config, HTTP integration harness with typed oRPC clients and injectable auth/org context, integration tests for ping/ authHealth/resolveTenant/preflight/testError, and PGlite unit tests for TenantsService; fixes 'no test files found' in test:api --- .changeset/lazy-otters-shave.md | 5 + api/tests/integration/plugin.test.ts | 117 ++++ api/tests/setup.ts | 119 ++++ api/tests/unit/tenants-service.test.ts | 222 +++++++ api/vitest.config.ts | 19 + host/src/middleware/security.ts | 186 ++++++ host/src/middleware/static-proxy.ts | 85 +++ host/src/program.ts | 575 +----------------- host/src/routes/api.ts | 221 +++++++ host/src/services/plugins.ts | 17 +- host/src/services/tenant-runtime.ts | 22 +- host/src/types.ts | 4 + host/src/utils/errors.ts | 56 ++ host/src/utils/normalize.ts | 37 ++ host/tests/integration/proxy.test.ts | 3 +- host/tests/integration/runtime-remote.test.ts | 3 +- 16 files changed, 1107 insertions(+), 584 deletions(-) create mode 100644 .changeset/lazy-otters-shave.md create mode 100644 api/tests/integration/plugin.test.ts create mode 100644 api/tests/setup.ts create mode 100644 api/tests/unit/tenants-service.test.ts create mode 100644 api/vitest.config.ts create mode 100644 host/src/middleware/security.ts create mode 100644 host/src/middleware/static-proxy.ts create mode 100644 host/src/routes/api.ts create mode 100644 host/src/utils/errors.ts create mode 100644 host/src/utils/normalize.ts diff --git a/.changeset/lazy-otters-shave.md b/.changeset/lazy-otters-shave.md new file mode 100644 index 00000000..781c9312 --- /dev/null +++ b/.changeset/lazy-otters-shave.md @@ -0,0 +1,5 @@ +--- +"api": patch +--- + +Add a starter test suite for the API plugin: vitest config, an integration harness (`tests/setup.ts`) that boots the plugin runtime over HTTP with typed oRPC clients and injectable auth/org context, integration tests for public and authenticated routes, and PGlite-backed unit tests for `TenantsService`. The root `test:api` script now runs real tests instead of erroring with "no test files found". diff --git a/api/tests/integration/plugin.test.ts b/api/tests/integration/plugin.test.ts new file mode 100644 index 00000000..235a81f0 --- /dev/null +++ b/api/tests/integration/plugin.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { authedContext, getPluginClient, orgContext } from "../setup"; + +describe("API Plugin Integration Tests", () => { + describe("ping", () => { + it("returns healthy status", async () => { + const client = await getPluginClient(); + const result = await client.ping(); + + expect(result).toEqual({ + status: "ok", + timestamp: expect.any(String), + }); + }); + }); + + describe("authHealth", () => { + it("rejects unauthenticated requests", async () => { + const client = await getPluginClient(); + await expect(client.authHealth()).rejects.toThrow("Authentication required"); + }); + + it("returns status when authenticated", async () => { + const client = await getPluginClient(authedContext()); + const result = await client.authHealth(); + + expect(result.status).toBe("ok"); + expect(result.emailConfigured).toEqual(expect.any(Boolean)); + expect(result.smsConfigured).toEqual(expect.any(Boolean)); + }); + }); + + describe("resolveTenant", () => { + it("returns null for an unknown account", async () => { + const client = await getPluginClient(); + const result = await client.resolveTenant({ accountId: "nobody.near" }); + expect(result).toBeNull(); + }); + + it("resolves a tenant created by its owning organization", async () => { + const client = await getPluginClient(orgContext()); + + const created = await client.createTenant({ + subdomain: "acme", + name: "Acme Corp", + accountId: "acme.example.near", + status: "active", + }); + expect(created).toMatchObject({ + subdomain: "acme", + name: "Acme Corp", + accountId: "acme.example.near", + orgId: "org-1", + status: "active", + }); + + const resolved = await client.resolveTenant({ accountId: "acme.example.near" }); + expect(resolved?.id).toBe(created.id); + }); + + it("rejects invalid accountId format on create", async () => { + const client = await getPluginClient(orgContext()); + await expect( + client.createTenant({ + subdomain: "acme", + name: "Acme Corp", + accountId: "NOT-A-VALID-ACCOUNT", + }), + ).rejects.toThrow("Invalid accountId format"); + }); + }); + + describe("tenantPreflight", () => { + it("reports availability for a fresh subdomain", async () => { + const client = await getPluginClient(authedContext()); + const result = await client.tenantPreflight({ + subdomain: "acmename", + parentAccount: "example.near", + }); + + expect(result.subdomain.available).toBe(true); + expect(result.subdomain.reserved).toBe(false); + expect(result.accountId.format).toBe("valid"); + expect(result.accountId.available).toBe(true); + }); + + it("flags reserved subdomains", async () => { + const client = await getPluginClient(authedContext()); + const result = await client.tenantPreflight({ + subdomain: "admin", + parentAccount: "example.near", + }); + + expect(result.subdomain.reserved).toBe(true); + expect(result.subdomain.available).toBe(false); + }); + }); + + describe("testError", () => { + it("maps error kinds to client-visible failures", async () => { + const client = await getPluginClient(); + + await expect(client.testError({ kind: "unauthorized" })).rejects.toThrow( + "test unauthorized error", + ); + await expect(client.testError({ kind: "forbidden" })).rejects.toThrow("test forbidden error"); + await expect(client.testError({ kind: "not_found" })).rejects.toThrow("test not found error"); + await expect(client.testError({ kind: "conflict" })).rejects.toThrow("test conflict error"); + await expect(client.testError({ kind: "bad_request" })).rejects.toThrow( + "test bad request error", + ); + await expect(client.testError({ kind: "internal" as never })).rejects.toThrow( + "Internal server error", + ); + }); + }); +}); diff --git a/api/tests/setup.ts b/api/tests/setup.ts new file mode 100644 index 00000000..bbdd669d --- /dev/null +++ b/api/tests/setup.ts @@ -0,0 +1,119 @@ +import { createServer } from "node:http"; +import { createORPCClient } from "@orpc/client"; +import { RPCLink } from "@orpc/client/fetch"; +import type { ContractRouterClient } from "@orpc/contract"; +import { RPCHandler } from "@orpc/server/node"; +import { createPluginRuntime } from "every-plugin"; +import type { contract } from "@/contract"; +import Plugin from "@/index"; +import pluginDevConfig from "../plugin.dev"; + +const TEST_PLUGIN_ID = pluginDevConfig.pluginId; +const TEST_CONFIG = pluginDevConfig.config; + +const TEST_REGISTRY = { + [TEST_PLUGIN_ID]: { + module: Plugin, + description: "API integration test runtime", + }, +} as const; + +export const runtime = createPluginRuntime({ + registry: TEST_REGISTRY, + secrets: {}, +}); + +let server: ReturnType | null = null; +let baseUrl = ""; +let port = 0; + +export async function getPluginClient(context?: Record) { + if (!server) { + const { router } = await runtime.usePlugin(TEST_PLUGIN_ID, TEST_CONFIG); + const rpcHandler = new RPCHandler(router); + + // Find an available port + const testPort = 3000 + Math.floor(Math.random() * 1000); + port = testPort; + baseUrl = `http://localhost:${port}`; + + server = createServer(async (req, res) => { + const url = new URL(req.url!, baseUrl); + + if (url.pathname.startsWith("/rpc")) { + // Initialize empty context for each request to prevent closure capture + let requestContext = {}; + + // Allow overriding context via headers for flexibility + if (req.headers["x-test-context"]) { + requestContext = JSON.parse(req.headers["x-test-context"] as string); + } + + const result = await rpcHandler.handle(req, res, { + prefix: "/rpc", + context: requestContext, + }); + if (result.matched) return; + } + + res.statusCode = 404; + res.end("Route not found"); + }); + + await new Promise((resolve, reject) => { + server?.listen(port, "127.0.0.1", () => resolve()); + server?.on("error", reject); + }); + } + + const link = new RPCLink({ + url: `${baseUrl}/rpc`, + fetch: globalThis.fetch, + headers: context + ? { + "x-test-context": JSON.stringify(context), + } + : {}, + }); + + const client: ContractRouterClient = createORPCClient(link); + return client; +} + +export function authedContext(userId = "user-1"): Record { + return { + userId, + user: { + id: userId, + email: `${userId}@example.com`, + name: "Test User", + }, + }; +} + +export function orgContext( + userId = "user-1", + activeOrganizationId = "org-1", +): Record { + return { + ...authedContext(userId), + organization: { + activeOrganizationId, + organization: { + id: activeOrganizationId, + slug: activeOrganizationId, + metadata: null, + }, + }, + }; +} + +export async function teardown() { + if (server) { + await new Promise((resolve) => { + server?.close(() => resolve()); + }); + server = null; + } + await runtime.shutdown(); +} diff --git a/api/tests/unit/tenants-service.test.ts b/api/tests/unit/tenants-service.test.ts new file mode 100644 index 00000000..427ad07f --- /dev/null +++ b/api/tests/unit/tenants-service.test.ts @@ -0,0 +1,222 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PluginIdTag } from "every-plugin"; +import { Cause, Effect, Exit, Layer } from "every-plugin/effect"; +import { ORPCError } from "every-plugin/orpc"; +import { afterEach, describe, expect, it } from "vitest"; +import { DatabaseLive } from "@/db/layer"; +import { TenantsLive, type TenantsService, TenantsTag } from "@/services/tenants"; + +let activeDir: string | null = null; + +afterEach(() => { + if (activeDir) { + rmSync(activeDir, { recursive: true, force: true }); + activeDir = null; + } +}); + +function freshLayer() { + const dir = mkdtempSync(join(tmpdir(), "api-tenants-")); + activeDir = dir; + return TenantsLive.pipe( + Layer.provide(DatabaseLive(`pglite:${dir}`)), + Layer.provide(Layer.succeed(PluginIdTag, "api")), + ); +} + +const MISSING_ID = "00000000-0000-0000-0000-000000000000"; + +async function runService( + layer: Layer.Layer, + fn: (svc: TenantsService) => Promise, +): Promise { + const effect = Effect.gen(function* () { + const svc = yield* TenantsTag; + return yield* Effect.tryPromise({ try: () => fn(svc), catch: (error) => error }); + }); + return Effect.runPromise(Effect.provide(effect, layer)); +} + +async function squashServiceError( + layer: Layer.Layer, + fn: (svc: TenantsService) => Promise, +): Promise { + const effect = Effect.gen(function* () { + const svc = yield* TenantsTag; + return yield* Effect.tryPromise({ try: () => fn(svc), catch: (error) => error }); + }); + const exit = await Effect.runPromiseExit(Effect.provide(effect, layer)); + if (Exit.isSuccess(exit)) { + throw new Error("Expected effect to fail"); + } + return Cause.squash(exit.cause); +} + +const baseInput = { + subdomain: "acme", + name: "Acme Corp", + accountId: "acme.example.near", + orgId: "org-1", +}; + +describe("TenantsService", () => { + it("creates and resolves a tenant by id", async () => { + const layer = freshLayer(); + const created = await runService(layer, (svc) => svc.createTenant(baseInput)); + + expect(created).toMatchObject({ + subdomain: "acme", + accountId: "acme.example.near", + orgId: "org-1", + name: "Acme Corp", + status: "active", + }); + expect(created.id).toEqual(expect.any(String)); + expect(created.createdAt).toEqual(expect.any(String)); + expect(created.deletedAt).toBeNull(); + + const resolved = await runService(layer, (svc) => svc.resolveTenantById(created.id)); + expect(resolved?.id).toBe(created.id); + }); + + it("fails with BAD_REQUEST when creating a duplicate", async () => { + const layer = freshLayer(); + await runService(layer, (svc) => svc.createTenant(baseInput)); + + const error = await squashServiceError(layer, (svc) => svc.createTenant(baseInput)); + expect(error).toBeInstanceOf(ORPCError); + expect((error as ORPCError).code).toBe("BAD_REQUEST"); + }); + + it("resolves by accountId, subdomain, and orgId", async () => { + const layer = freshLayer(); + const created = await runService(layer, (svc) => svc.createTenant(baseInput)); + + const byAccount = await runService(layer, (svc) => + svc.resolveTenantByAccountId("acme.example.near"), + ); + const bySubdomain = await runService(layer, (svc) => svc.resolveTenantBySubdomain("acme")); + const byOrg = await runService(layer, (svc) => svc.resolveTenantByOrgId("org-1")); + + expect(byAccount?.id).toBe(created.id); + expect(bySubdomain?.id).toBe(created.id); + expect(byOrg?.id).toBe(created.id); + }); + + it("returns null for unknown resolvers", async () => { + const layer = freshLayer(); + expect(await runService(layer, (svc) => svc.resolveTenantById(MISSING_ID))).toBeNull(); + expect(await runService(layer, (svc) => svc.resolveTenantByAccountId("nope"))).toBeNull(); + expect(await runService(layer, (svc) => svc.resolveTenantBySubdomain("nope"))).toBeNull(); + expect(await runService(layer, (svc) => svc.resolveTenantByOrgId("nope"))).toBeNull(); + }); + + it("lists tenants by orgId", async () => { + const layer = freshLayer(); + await runService(layer, (svc) => svc.createTenant({ ...baseInput, orgId: "org-1" })); + await runService(layer, (svc) => + svc.createTenant({ + ...baseInput, + subdomain: "beta", + accountId: "beta.example.near", + orgId: "org-2", + }), + ); + await runService(layer, (svc) => + svc.createTenant({ + ...baseInput, + subdomain: "gamma", + accountId: "gamma.example.near", + orgId: "org-3", + }), + ); + + const forOrg1 = await runService(layer, (svc) => svc.listTenantsByOrgIds(["org-1"])); + expect(forOrg1.map((t) => t.orgId)).toEqual(["org-1"]); + + const forOrg2 = await runService(layer, (svc) => svc.listTenantsByOrgIds(["org-2"])); + expect(forOrg2.map((t) => t.orgId)).toEqual(["org-2"]); + + const forAll = await runService(layer, (svc) => + svc.listTenantsByOrgIds(["org-1", "org-2", "org-3"]), + ); + expect(forAll).toHaveLength(3); + + expect(await runService(layer, (svc) => svc.listTenantsByOrgIds([]))).toEqual([]); + }); + + it("updates a tenant name", async () => { + const layer = freshLayer(); + const created = await runService(layer, (svc) => svc.createTenant(baseInput)); + + const updated = await runService(layer, (svc) => + svc.updateTenant(created.id, { name: "Renamed Corp" }), + ); + expect(updated.name).toBe("Renamed Corp"); + + const fetched = await runService(layer, (svc) => svc.resolveTenantById(created.id)); + expect(fetched?.name).toBe("Renamed Corp"); + }); + + it("fails with BAD_REQUEST when updating to a conflicting subdomain", async () => { + const layer = freshLayer(); + await runService(layer, (svc) => svc.createTenant(baseInput)); + const other = await runService(layer, (svc) => + svc.createTenant({ + ...baseInput, + subdomain: "beta", + accountId: "beta.example.near", + orgId: "org-2", + }), + ); + + const error = await squashServiceError(layer, (svc) => + svc.updateTenant(other.id, { subdomain: "acme" }), + ); + expect(error).toBeInstanceOf(ORPCError); + expect((error as ORPCError).code).toBe("BAD_REQUEST"); + }); + + it("fails with NOT_FOUND when updating a missing tenant", async () => { + const layer = freshLayer(); + const error = await squashServiceError(layer, (svc) => + svc.updateTenant(MISSING_ID, { name: "x" }), + ); + expect(error).toBeInstanceOf(ORPCError); + expect((error as ORPCError).code).toBe("NOT_FOUND"); + }); + + it("soft-deletes and reactivates a tenant", async () => { + const layer = freshLayer(); + const created = await runService(layer, (svc) => svc.createTenant(baseInput)); + + const suspended = await runService(layer, (svc) => svc.suspendTenant(created.id)); + expect(suspended?.status).toBe("suspended"); + + const reactivated = await runService(layer, (svc) => svc.reactivateTenant(created.id)); + expect(reactivated?.status).toBe("active"); + + const deleted = await runService(layer, (svc) => svc.softDeleteTenant(created.id)); + expect(deleted).not.toBeNull(); + expect(deleted?.status).toBe("pending_deletion"); + expect(deleted?.deletedAt).toEqual(expect.any(String)); + }); + + it("returns null for status transitions on a missing tenant", async () => { + const layer = freshLayer(); + expect(await runService(layer, (svc) => svc.suspendTenant(MISSING_ID))).toBeNull(); + expect(await runService(layer, (svc) => svc.reactivateTenant(MISSING_ID))).toBeNull(); + expect(await runService(layer, (svc) => svc.softDeleteTenant(MISSING_ID))).toBeNull(); + }); + + it("hard-deletes a tenant and reports existence", async () => { + const layer = freshLayer(); + const created = await runService(layer, (svc) => svc.createTenant(baseInput)); + + expect(await runService(layer, (svc) => svc.deleteTenantById(created.id))).toBe(true); + expect(await runService(layer, (svc) => svc.deleteTenantById(created.id))).toBe(false); + expect(await runService(layer, (svc) => svc.resolveTenantById(created.id))).toBeNull(); + }); +}); diff --git a/api/vitest.config.ts b/api/vitest.config.ts new file mode 100644 index 00000000..f252254c --- /dev/null +++ b/api/vitest.config.ts @@ -0,0 +1,19 @@ +import DrizzleORMMigrations from "@proj-airi/unplugin-drizzle-orm-migrations/vite"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["tests/**/*.test.ts"], + exclude: ["node_modules/**", "dist/**"], + testTimeout: 30000, + }, + plugins: [ + tsconfigPaths({ + projects: ["./tsconfig.json"], + }), + DrizzleORMMigrations(), + ], +}); diff --git a/host/src/middleware/security.ts b/host/src/middleware/security.ts new file mode 100644 index 00000000..4a4cc90c --- /dev/null +++ b/host/src/middleware/security.ts @@ -0,0 +1,186 @@ +import { getConnInfo } from "@hono/node-server/conninfo"; +import { Context, Effect, Layer } from "every-plugin/effect"; +import type { MiddlewareHandler } from "hono"; +import { cors } from "hono/cors"; +import { NONCE, secureHeaders } from "hono/secure-headers"; +import { rateLimiter } from "hono-rate-limiter"; +import { ConfigService, readCorsOrigins } from "../services/config"; +import type { RuntimePlugin } from "../types"; +import { logger } from "../utils/logger"; + +export const RATE_LIMIT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS) || 900_000; +export const RATE_LIMIT_MAX = Number(process.env.RATE_LIMIT_MAX) || 300; +export const BODY_LIMIT_MAX = Number(process.env.BODY_LIMIT_MAX) || 10 * 1024 * 1024; +export const API_TIMEOUT_MS = Number(process.env.API_TIMEOUT_MS) || 30_000; + +export const STATIC_ASSET_PATTERN = + /\.(js|css|png|jpg|jpeg|gif|svg|ico|json|md|webmanifest|woff2?|ttf|eot|webp|avif|map|txt|xml)$/i; + +export function getCspStrict(isDev: boolean): boolean { + return process.env.CSP_STRICT === "false" ? false : !isDev; +} + +export class SecurityMiddleware extends Context.Tag("host/SecurityMiddleware")< + SecurityMiddleware, + { + cors: MiddlewareHandler; + csrf: MiddlewareHandler; + rateLimit: MiddlewareHandler; + csp: MiddlewareHandler; + } +>() { + static Live = Layer.effect( + SecurityMiddleware, + Effect.gen(function* () { + const config = yield* ConfigService; + const isDev = process.env.NODE_ENV !== "production"; + const corsOrigins = yield* readCorsOrigins(); + const uiConfig = config.ui!; + + if (corsOrigins.length === 0 && !isDev) { + logger.warn( + "[Security] CORS_ORIGIN is not set in production. Auth endpoints will reject cross-origin requests.", + ); + logger.warn( + "[Security] Set CORS_ORIGIN to your allowed origins (comma-separated), e.g.: CORS_ORIGIN=https://yourdomain.com,https://app.yourdomain.com", + ); + } + + const allowedOrigins = + corsOrigins.length > 0 + ? corsOrigins + : [config.host?.url ?? "", ...(uiConfig.url ? [uiConfig.url] : [])]; + + const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + + const csrf: MiddlewareHandler = async (c, next) => { + if (SAFE_METHODS.has(c.req.method)) { + return next(); + } + + const origin = c.req.header("origin"); + + if (!origin) { + return next(); + } + + const host = c.req.header("host"); + if (host && host.split(":")[0] === new URL(origin).hostname) { + return next(); + } + + logger.warn(`[CSRF] Blocked ${c.req.method} ${c.req.path} from origin=${origin}`); + return c.json({ error: "CSRF validation failed: request origin is not allowed" }, 403); + }; + + const corsHandler: MiddlewareHandler = cors({ + origin: (origin) => { + if (!origin) return "*"; + if (allowedOrigins.includes(origin)) return origin; + if (origin.startsWith("https://")) return origin; + if (isDev && origin.startsWith("http://")) return origin; + return null; + }, + credentials: true, + }); + + const rateLimit: MiddlewareHandler = rateLimiter({ + windowMs: RATE_LIMIT_WINDOW_MS, + limit: RATE_LIMIT_MAX, + keyGenerator: (c) => { + const forwarded = c.req.header("x-forwarded-for"); + if (forwarded) { + return forwarded.split(",")[0]!.trim(); + } + try { + const info = getConnInfo(c); + return info.remote.address ?? "unknown"; + } catch { + return "unknown"; + } + }, + skip: (c) => { + const { pathname } = new URL(c.req.url); + const lastSegment = pathname.split("/").pop() ?? ""; + return STATIC_ASSET_PATTERN.test(lastSegment); + }, + message: { error: "Too many requests, please try again later." }, + }); + + const remoteOrigins = [ + ...(uiConfig.url ? [new URL(uiConfig.url).origin] : []), + ...(config.api?.url ? [new URL(config.api.url).origin] : []), + ...(config.auth?.url ? [new URL(config.auth.url).origin] : []), + ...Object.values(config.plugins ?? {}).flatMap((p: RuntimePlugin) => { + if (p.url) return [new URL(p.url).origin]; + return []; + }), + ]; + + const uniqueOrigins = [...new Set(remoteOrigins)]; + + const pluginConnectSrcs = [ + ...new Set( + Object.values(config.plugins ?? {}).flatMap((p: RuntimePlugin) => p.connectSrc ?? []), + ), + ]; + + const wsOrigins = isDev + ? uniqueOrigins.filter((o) => o.startsWith("http:")).map((o) => o.replace(/^http:/, "ws:")) + : []; + + const CSP_STRICT = getCspStrict(isDev); + + const cdnOrigins = ["https://cdn.jsdelivr.net", "https://unpkg.com"]; + + const cspScriptSrc = CSP_STRICT + ? [NONCE, "'strict-dynamic'", "'unsafe-eval'"] + : ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https:", ...uniqueOrigins, ...cdnOrigins]; + + const csp: MiddlewareHandler = (c, next) => { + const frameAncestors = ["'none'"]; + + const lastSegment = c.req.path.split("/").pop() ?? ""; + const isStaticAsset = STATIC_ASSET_PATTERN.test(lastSegment); + + return secureHeaders({ + crossOriginOpenerPolicy: "same-origin-allow-popups", + crossOriginResourcePolicy: isStaticAsset ? "cross-origin" : "same-origin", + contentSecurityPolicy: { + defaultSrc: ["'self'"], + scriptSrc: cspScriptSrc, + styleSrc: ["'self'", "'unsafe-inline'", "https:", ...uniqueOrigins, ...cdnOrigins], + imgSrc: [ + "'self'", + "data:", + ...(isDev ? ["http:"] : ["https:"]), + ...(uiConfig.url ? [new URL(uiConfig.url).origin] : []), + ], + connectSrc: [ + "'self'", + "https:", + ...uniqueOrigins, + ...wsOrigins, + ...cdnOrigins, + ...pluginConnectSrcs, + ], + fontSrc: ["'self'", "https:", ...uniqueOrigins], + manifestSrc: [ + "'self'", + "https:", + ...(uiConfig.url ? [new URL(uiConfig.url).origin] : []), + ], + frameSrc: ["'self'", "https:", ...uniqueOrigins], + objectSrc: ["'none'"], + baseUri: ["'self'"], + formAction: ["'self'"], + frameAncestors, + workerSrc: ["'self'", "https:", ...uniqueOrigins], + }, + })(c, next); + }; + + return { cors: corsHandler, csrf, rateLimit, csp }; + }), + ); +} diff --git a/host/src/middleware/static-proxy.ts b/host/src/middleware/static-proxy.ts new file mode 100644 index 00000000..2cbb2b38 --- /dev/null +++ b/host/src/middleware/static-proxy.ts @@ -0,0 +1,85 @@ +import { proxy } from "hono/proxy"; + +export async function proxyRequest( + req: Request, + targetBase: string, + rewriteCookies = false, +): Promise { + const url = new URL(req.url); + const targetUrl = `${targetBase}${url.pathname}${url.search}`; + + const headers = new Headers(req.headers); + headers.delete("host"); + headers.set("accept-encoding", "identity"); + + if (rewriteCookies) { + const cookieHeader = headers.get("cookie"); + if (cookieHeader) { + const rewrittenCookies = cookieHeader.replace(/\bbetter-auth\./g, "__Secure-better-auth."); + headers.set("cookie", rewrittenCookies); + } + } + + const proxyReq = new Request(targetUrl, { + method: req.method, + headers, + body: req.body, + duplex: "half", + } as RequestInit); + + const response = await fetch(proxyReq); + + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("content-encoding"); + responseHeaders.delete("content-length"); + + if (rewriteCookies) { + responseHeaders.delete("set-cookie"); + const setCookies = + typeof response.headers.getSetCookie === "function" + ? response.headers.getSetCookie() + : (response.headers.get("set-cookie")?.split(/,(?=\s*(?:__Secure-|__Host-)?\w+=)/) ?? []); + for (const cookie of setCookies) { + const rewritten = cookie + .replace(/^(__Secure-|__Host-)/i, "") + .replace(/;\s*Domain=[^;]*/gi, "") + .replace(/;\s*Secure/gi, ""); + responseHeaders.append("set-cookie", rewritten); + } + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); +} + +function buildStaticAssetProxyHeaders(req: Request) { + const headers = new Headers(); + + for (const name of ["accept", "accept-language"]) { + const value = req.headers.get(name); + if (value) { + headers.set(name, value); + } + } + + return headers; +} + +export async function proxyStaticAssetRequest(req: Request, targetBase: string): Promise { + const url = new URL(req.url); + const targetUrl = `${targetBase}${url.pathname}${url.search}`; + + const response = await proxy(targetUrl, { + raw: req, + headers: buildStaticAssetProxyHeaders(req), + }); + + response.headers.delete("etag"); + response.headers.delete("last-modified"); + response.headers.set("cache-control", "public, max-age=14400, s-maxage=300"); + + return response; +} diff --git a/host/src/program.ts b/host/src/program.ts index 8db0648a..da9644d4 100644 --- a/host/src/program.ts +++ b/host/src/program.ts @@ -1,10 +1,4 @@ import { serve } from "@hono/node-server"; -import { getConnInfo } from "@hono/node-server/conninfo"; -import { OpenAPIHandler } from "@orpc/openapi/fetch"; -import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"; -import { RPCHandler } from "@orpc/server/fetch"; -import { BatchHandlerPlugin } from "@orpc/server/plugins"; -import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; import { Cause, Effect, @@ -14,67 +8,31 @@ import { Layer, ManagedRuntime, } from "every-plugin/effect"; -import { formatORPCError } from "every-plugin/errors"; -import { onError } from "every-plugin/orpc"; import { getBaseStyles, getHydrateScript, getThemeInitScript } from "everything-dev/ui/head"; -import { type Context, Hono, type Next } from "hono"; -import { bodyLimit } from "hono/body-limit"; -import { cors } from "hono/cors"; -import { HTTPException } from "hono/http-exception"; -import { proxy } from "hono/proxy"; -import { NONCE, secureHeaders } from "hono/secure-headers"; -import { timeout } from "hono/timeout"; -import { rateLimiter } from "hono-rate-limiter"; +import { type Context, Hono } from "hono"; import type { AuthVariables } from "./lib/auth"; +import { getCspStrict, SecurityMiddleware, STATIC_ASSET_PATTERN } from "./middleware/security"; +import { proxyStaticAssetRequest } from "./middleware/static-proxy"; +import { setupApiRoutes } from "./routes/api"; +import type { HealthLoadingState } from "./routes/health"; import { buildPluginContext, createSessionMiddleware, registerAuthHandler } from "./services/auth"; -import { - type ClientRuntimeConfig, - ConfigService, - type RuntimeConfig, - readCorsOrigins, -} from "./services/config"; -import { closeMcpServer, mountMcpRoute } from "./services/mcp"; -import type { RouterModule } from "./types"; - -type HonoEnv = { Variables: AuthVariables }; - -import { - getHealthStatus, - getMemorySnapshot, - HEALTH_PATH, - MEMORY_PATH, - tryGc, -} from "./routes/health"; +import { type ClientRuntimeConfig, ConfigService, type RuntimeConfig } from "./services/config"; import { loadRouterModule, resetFederationInstance } from "./services/federation.server"; import { startIntegrityMonitor } from "./services/integrity-monitor"; +import { closeMcpServer } from "./services/mcp"; import { createPluginsClient, type PluginResult, PluginsService } from "./services/plugins"; - import { getTenantRuntimeErrorResponse, resolveRequestRuntime } from "./services/tenant-runtime"; +import type { RouterModule, RuntimePlugin } from "./types"; +import { extractErrorDetails } from "./utils/errors"; import { logger } from "./utils/logger"; +import { normalizeUrl } from "./utils/normalize"; + +type HonoEnv = { Variables: AuthVariables }; type ActiveRuntimeState = NonNullable; type RuntimeClientConfig = ClientRuntimeConfig & { runtime?: ActiveRuntimeState }; -type RuntimePlugin = NonNullable[string]; - -const RATE_LIMIT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS) || 900_000; -const RATE_LIMIT_MAX = Number(process.env.RATE_LIMIT_MAX) || 300; -const BODY_LIMIT_MAX = Number(process.env.BODY_LIMIT_MAX) || 10 * 1024 * 1024; -const API_TIMEOUT_MS = Number(process.env.API_TIMEOUT_MS) || 30_000; - -function normalizeUrl(url?: string | null) { - if (!url) { - return null; - } - - try { - return new URL(url).toString().replace(/\/$/, ""); - } catch { - return url.replace(/\/$/, ""); - } -} - function getFallbackGatewayId(config: RuntimeConfig) { if (config.domain) { return config.domain; @@ -172,357 +130,16 @@ function buildRuntimeClientConfig( } as RuntimeClientConfig; } -function extractErrorDetails(error: unknown): { - message: string; - stack?: string; - cause?: string; -} { - if (!error) return { message: "Unknown error (null/undefined)" }; - - if (error instanceof Error) { - const details: { message: string; stack?: string; cause?: string } = { - message: error.message || error.name || "Error", - stack: error.stack, - }; - - if (error.cause) { - if (error.cause instanceof Error) { - details.cause = `${error.cause.name}: ${error.cause.message}`; - } else if (typeof error.cause === "object" && "_tag" in (error.cause as object)) { - try { - const squashed = Cause.squash(error.cause as Cause.Cause); - if (squashed instanceof Error) { - details.cause = `[Effect] ${squashed.name}: ${squashed.message}`; - } else { - details.cause = `[Effect] ${String(squashed)}`; - } - } catch { - details.cause = `[Effect Cause] ${JSON.stringify(error.cause)}`; - } - } else { - details.cause = String(error.cause); - } - } - - return details; - } - - if (typeof error === "object" && error !== null) { - if ("_tag" in error) { - try { - const squashed = Cause.squash(error as Cause.Cause); - return extractErrorDetails(squashed); - } catch { - return { message: `[Effect] ${JSON.stringify(error)}` }; - } - } - - if ("message" in error) { - return { message: String((error as { message: unknown }).message) }; - } - - return { message: JSON.stringify(error) }; - } - - return { message: String(error) }; -} - -export async function proxyRequest( - req: Request, - targetBase: string, - rewriteCookies = false, -): Promise { - const url = new URL(req.url); - const targetUrl = `${targetBase}${url.pathname}${url.search}`; - - const headers = new Headers(req.headers); - headers.delete("host"); - headers.set("accept-encoding", "identity"); - - if (rewriteCookies) { - const cookieHeader = headers.get("cookie"); - if (cookieHeader) { - const rewrittenCookies = cookieHeader.replace(/\bbetter-auth\./g, "__Secure-better-auth."); - headers.set("cookie", rewrittenCookies); - } - } - - const proxyReq = new Request(targetUrl, { - method: req.method, - headers, - body: req.body, - duplex: "half", - } as RequestInit); - - const response = await fetch(proxyReq); - - const responseHeaders = new Headers(response.headers); - responseHeaders.delete("content-encoding"); - responseHeaders.delete("content-length"); - - if (rewriteCookies) { - responseHeaders.delete("set-cookie"); - const setCookies = - typeof response.headers.getSetCookie === "function" - ? response.headers.getSetCookie() - : (response.headers.get("set-cookie")?.split(/,(?=\s*(?:__Secure-|__Host-)?\w+=)/) ?? []); - for (const cookie of setCookies) { - const rewritten = cookie - .replace(/^(__Secure-|__Host-)/i, "") - .replace(/;\s*Domain=[^;]*/gi, "") - .replace(/;\s*Secure/gi, ""); - responseHeaders.append("set-cookie", rewritten); - } - } - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: responseHeaders, - }); -} - -function buildStaticAssetProxyHeaders(req: Request) { - const headers = new Headers(); - - for (const name of ["accept", "accept-language"]) { - const value = req.headers.get(name); - if (value) { - headers.set(name, value); - } - } - - return headers; -} - -async function proxyStaticAssetRequest(req: Request, targetBase: string): Promise { - const url = new URL(req.url); - const targetUrl = `${targetBase}${url.pathname}${url.search}`; - - const response = await proxy(targetUrl, { - raw: req, - headers: buildStaticAssetProxyHeaders(req), - }); - - response.headers.delete("etag"); - response.headers.delete("last-modified"); - response.headers.set("cache-control", "public, max-age=14400, s-maxage=300"); - - return response; -} - -export async function setupApiRoutes( - app: Hono, - config: RuntimeConfig, - plugins: PluginResult, - sessionMiddleware: ReturnType, - loadingState: { - status: string; - startTime: number; - milestones: string[]; - error: Error | null; - ssrEnabled: boolean; - }, -) { - const apiConfig = config.api; - - if (!apiConfig) { - throw new Error("API config is required to start the host"); - } - - const isProxyMode = process.argv.includes("--proxy"); - - const publicRpcRouters = new Map>(); - - const registerPublicRpcRouter = (prefix: string, router: unknown) => { - publicRpcRouters.set( - prefix, - new RPCHandler(router as any, { - plugins: [new BatchHandlerPlugin()], - interceptors: [ - onError((error: unknown) => { - const formatted = formatORPCError(error); - if (formatted) console.error(formatted); - throw error; - }), - ], - }), - ); - }; - - if (plugins.auth?.router) { - registerPublicRpcRouter("/api/rpc/auth", plugins.auth.router); - } - - for (const [pluginKey, plugin] of Object.entries(plugins.plugins)) { - registerPublicRpcRouter(`/api/rpc/${pluginKey}`, plugin.router); - } - - const getPublicRpcRoute = (pathname: string) => { - for (const [prefix, handler] of publicRpcRouters.entries()) { - if (pathname === prefix || pathname.startsWith(`${prefix}/`)) { - return { prefix, handler }; - } - } - - return null; - }; - - if (isProxyMode) { - const proxyTarget = apiConfig.proxy!; - logger.info(`[API] Proxy mode enabled → ${proxyTarget}`); - - app.all("/api/*", async (c: Context) => { - if (c.req.path === HEALTH_PATH) { - return c.json(getHealthStatus(plugins, loadingState)); - } - if (c.req.path === MEMORY_PATH) { - const gcRan = c.req.query("gc") === "true" && tryGc(); - return c.json({ memory: getMemorySnapshot(), gc: gcRan }); - } - const response = await proxyRequest(c.req.raw, proxyTarget, true); - return response; - }); - - return; - } - - app.get(HEALTH_PATH, (c: Context) => { - return c.json(getHealthStatus(plugins, loadingState)); - }); - - app.get(MEMORY_PATH, (c: Context) => { - const gcRan = c.req.query("gc") === "true" && tryGc(); - return c.json({ memory: getMemorySnapshot(), gc: gcRan }); - }); - - app.use( - "/api/*", - bodyLimit({ - maxSize: BODY_LIMIT_MAX, - onError: (c) => c.json({ error: "Request body too large" }, 413), - }), - ); - - app.use( - "/api/*", - timeout(API_TIMEOUT_MS, () => { - return new HTTPException(408, { message: "Request timeout" }); - }), - ); - - app.use("/api/*", sessionMiddleware); - - const handleOrpc = async ( - c: Context, - handler: RPCHandler | OpenAPIHandler, - prefix: `/${string}`, - ) => { - const context = buildPluginContext(c); - - const result = await handler.handle(c.req.raw, { prefix, context }); - if (!result.response) { - return c.text("Not Found", 404); - } - - const contentType = result.response.headers.get("content-type") ?? ""; - if (contentType.includes("text/html")) { - const nonce = c.get("secureHeadersNonce") as string | undefined; - if (nonce) { - const body = await result.response.text(); - const injected = body.replace(/`; - - const shellBody = `
${ - error - ? `

SSR unavailable, showing client app.

${error.message}

` - : `

Loading...

` - }
`; - - const title = - runtimeConfig.runtime?.title ?? runtimeSourceConfig.title ?? runtimeSourceConfig.account; - const hydrateScript = - ( - getHydrateScript( - runtimeConfig as Partial, - undefined, - undefined, - nonce, - ) as { children?: string } - ).children ?? ""; - - return ctx.html( - ` - - - - - ${title} - - - - ${themeScript} - - ${hydrateScript} - - ${shellBody} - `, - 200, - ); - }; - const proxyUiAssetRequest = async (c: Context) => { const runtime = await resolveRequestRuntime(config, c.req.raw, { verification: "stale-while-revalidate", @@ -355,7 +294,7 @@ export const createStartServer = (onReady?: () => void) => } } - return renderClientShell(c, effectiveConfig, runtimeConfig, moduleLoadError); + return renderClientShell(c, nonce, effectiveConfig, runtimeConfig, moduleLoadError); }); const startHttpServer = () => { diff --git a/host/src/routes/html.ts b/host/src/routes/html.ts new file mode 100644 index 00000000..a1b82347 --- /dev/null +++ b/host/src/routes/html.ts @@ -0,0 +1,67 @@ +import { getBaseStyles, getHydrateScript, getThemeInitScript } from "everything-dev/ui/head"; +import type { Context } from "hono"; +import type { AuthVariables } from "../lib/auth"; +import type { ClientRuntimeConfig, RuntimeConfig } from "../services/config"; + +type HonoEnv = { Variables: AuthVariables }; + +export function renderClientShell( + ctx: Context, + nonce: string | undefined, + runtimeSourceConfig: RuntimeConfig, + runtimeConfig: ClientRuntimeConfig, + error?: Error | null, +) { + const uiIntegrity = runtimeSourceConfig.ui.integrity; + const assetsUrl = runtimeConfig.assetsUrl.replace(/\/$/, ""); + const nonceAttr = nonce ? ` nonce="${nonce}"` : ""; + const sriAttr = uiIntegrity ? ` integrity="${uiIntegrity}" crossorigin="anonymous"` : ""; + const uiVersion = uiIntegrity ? `?v=${encodeURIComponent(uiIntegrity)}` : ""; + + const baseStyles = ` + ${getBaseStyles()} + .shell { min-height: 100vh; min-height: 100dvh; display: flex; align-items: center; justify-content: center; } + .fade { animation: fadeIn 0.3s ease-in; } + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + .error { color: #fca5a5; } + `.trim(); + + const themeScript = `${(getThemeInitScript() as { children?: string }).children ?? ""}`; + + const shellBody = `
${ + error + ? `

SSR unavailable, showing client app.

${error.message}

` + : `

Loading...

` + }
`; + + const title = + runtimeConfig.runtime?.title ?? runtimeSourceConfig.title ?? runtimeSourceConfig.account; + const hydrateScript = + ( + getHydrateScript( + runtimeConfig as Partial, + undefined, + undefined, + nonce, + ) as { children?: string } + ).children ?? ""; + + return ctx.html( + ` + + + + + ${title} + + + + ${themeScript} + + ${hydrateScript} + + ${shellBody} + `, + 200, + ); +} diff --git a/host/tests/e2e/runtime-remote.spec.ts b/host/tests/e2e/runtime-remote.spec.ts index 70fd99e1..05aac168 100644 --- a/host/tests/e2e/runtime-remote.spec.ts +++ b/host/tests/e2e/runtime-remote.spec.ts @@ -148,7 +148,15 @@ for (const scenario of scenarios) { () => performance.getEntriesByType("navigation").length, ); - await expect(page.getByRole("link", { name: /^Skill$/ })).toBeVisible(); + try { + await expect(page.getByRole("link", { name: /^Skill$/ })).toBeVisible(); + } catch (e) { + const html = await page.content(); + console.error("[Diagnostic] Page HTML (first 3000 chars):", html.slice(0, 3000)); + console.error("[Diagnostic] Page errors:", JSON.stringify(pageErrors)); + console.error("[Diagnostic] Console errors:", JSON.stringify(consoleErrors)); + throw e; + } await page.getByRole("link", { name: /^Skill$/ }).dispatchEvent("click"); await page.waitForURL(/\/skill$/); @@ -176,7 +184,10 @@ for (const scenario of scenarios) { document.documentElement.classList.contains("dark"), ); - await page.getByRole("button", { name: "Toggle theme" }).click(); + await page + .getByRole("button", { name: /Switch to .* theme/ }) + .first() + .click(); await expect .poll(async () => diff --git a/host/tests/helpers/json-proxy-target.ts b/host/tests/helpers/json-proxy-target.ts index 83975486..50137ccf 100644 --- a/host/tests/helpers/json-proxy-target.ts +++ b/host/tests/helpers/json-proxy-target.ts @@ -23,6 +23,20 @@ export async function startJsonProxyTarget(): Promise { return; } + if (req.url?.startsWith("/api/auth/")) { + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(null)); + return; + } + + if (req.method === "POST" && req.url?.startsWith("/api/rpc/")) { + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ json: null, meta: [] })); + return; + } + res.statusCode = 404; res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ error: "Not Found" })); diff --git a/host/tests/helpers/runtime-config.ts b/host/tests/helpers/runtime-config.ts index 55b7110c..9abd6774 100644 --- a/host/tests/helpers/runtime-config.ts +++ b/host/tests/helpers/runtime-config.ts @@ -25,6 +25,21 @@ export async function loadTestRuntimeConfig(): Promise { return config; } +export function createMockSession() { + return { + user: { + id: "test-user-id", + name: "Test User", + email: "test@everything.dev", + role: "user", + }, + session: { + id: "test-session-id", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }; +} + export function createMockAuthClient(): AuthClient { const noOp = (): Promise<{ data: null; error: null }> => Promise.resolve({ data: null, error: null }); @@ -111,11 +126,12 @@ export function buildTestRenderOptions( config: RuntimeConfig, apiClient: ApiClient, authClient?: AuthClient, + session = createMockSession(), ): RenderOptionsWithApi { return { runtimeConfig: buildTestClientRuntimeConfig(config), apiClient, - session: null, + session, authClient, } satisfies RenderOptionsWithApi; } diff --git a/host/tests/integration/csp-nonce.test.ts b/host/tests/integration/csp-nonce.test.ts index dcad904b..7dfe0e29 100644 --- a/host/tests/integration/csp-nonce.test.ts +++ b/host/tests/integration/csp-nonce.test.ts @@ -6,6 +6,7 @@ import { loadBundledRouterModule } from "../helpers/bundled-ssr-module"; import { buildTestClientRuntimeConfig, createMockAuthClient, + createMockSession, loadTestRuntimeConfig, } from "../helpers/runtime-config"; @@ -24,6 +25,7 @@ async function consumeStream(stream: ReadableStream): Promise { const mockApiClient = createTestApiClient({}); const mockAuthClient = createMockAuthClient(); +const mockSession = createMockSession(); describe("CSP Nonce Regression Tests", () => { let routerModule: RouterModule; @@ -49,7 +51,7 @@ describe("CSP Nonce Regression Tests", () => { const renderOptions: RenderOptionsWithApi = { runtimeConfig: buildTestClientRuntimeConfig(config), apiClient: mockApiClient, - session: null, + session: mockSession, authClient: mockAuthClient, cspNonce: testNonce, }; @@ -77,7 +79,7 @@ describe("CSP Nonce Regression Tests", () => { const renderOptions: RenderOptionsWithApi = { runtimeConfig: buildTestClientRuntimeConfig(config), apiClient: mockApiClient, - session: null, + session: mockSession, authClient: mockAuthClient, cspNonce: testNonce, }; @@ -105,7 +107,7 @@ describe("CSP Nonce Regression Tests", () => { const renderOptions: RenderOptionsWithApi = { runtimeConfig: buildTestClientRuntimeConfig(config), apiClient: mockApiClient, - session: null, + session: mockSession, authClient: mockAuthClient, cspNonce: testNonce, }; @@ -126,7 +128,7 @@ describe("CSP Nonce Regression Tests", () => { const renderOptions: RenderOptionsWithApi = { runtimeConfig: buildTestClientRuntimeConfig(config), apiClient: mockApiClient, - session: null, + session: mockSession, authClient: mockAuthClient, }; @@ -161,7 +163,7 @@ describe("CSP Nonce Regression Tests", () => { rpcBase: "/rpc", }, apiClient: mockApiClient, - session: null, + session: mockSession, authClient: mockAuthClient, cspNonce: "typed-nonce-without-cast", }; @@ -180,7 +182,7 @@ describe("CSP Nonce Regression Tests", () => { rpcBase: "/rpc", }, apiClient: mockApiClient, - session: null, + session: mockSession, authClient: mockAuthClient, }; diff --git a/host/tests/integration/ssr-bundled-runtime.test.ts b/host/tests/integration/ssr-bundled-runtime.test.ts index 1f16465c..d26b3177 100644 --- a/host/tests/integration/ssr-bundled-runtime.test.ts +++ b/host/tests/integration/ssr-bundled-runtime.test.ts @@ -57,7 +57,7 @@ describe("bundled host SSR runtime", () => { const html = await response.text(); expect(response.status).toBe(200); - expect(html).toContain('