@wopr-network/platform-core 1.68.0 → 1.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/backup/types.d.ts +1 -1
  2. package/dist/server/__tests__/build-container.test.d.ts +1 -0
  3. package/dist/server/__tests__/build-container.test.js +339 -0
  4. package/dist/server/__tests__/container.test.d.ts +1 -0
  5. package/dist/server/__tests__/container.test.js +170 -0
  6. package/dist/server/__tests__/lifecycle.test.d.ts +1 -0
  7. package/dist/server/__tests__/lifecycle.test.js +90 -0
  8. package/dist/server/__tests__/mount-routes.test.d.ts +1 -0
  9. package/dist/server/__tests__/mount-routes.test.js +151 -0
  10. package/dist/server/boot-config.d.ts +51 -0
  11. package/dist/server/boot-config.js +7 -0
  12. package/dist/server/container.d.ts +81 -0
  13. package/dist/server/container.js +134 -0
  14. package/dist/server/index.d.ts +33 -0
  15. package/dist/server/index.js +66 -0
  16. package/dist/server/lifecycle.d.ts +25 -0
  17. package/dist/server/lifecycle.js +46 -0
  18. package/dist/server/middleware/__tests__/admin-auth.test.d.ts +1 -0
  19. package/dist/server/middleware/__tests__/admin-auth.test.js +59 -0
  20. package/dist/server/middleware/__tests__/tenant-proxy.test.d.ts +1 -0
  21. package/dist/server/middleware/__tests__/tenant-proxy.test.js +268 -0
  22. package/dist/server/middleware/admin-auth.d.ts +18 -0
  23. package/dist/server/middleware/admin-auth.js +38 -0
  24. package/dist/server/middleware/tenant-proxy.d.ts +56 -0
  25. package/dist/server/middleware/tenant-proxy.js +162 -0
  26. package/dist/server/mount-routes.d.ts +30 -0
  27. package/dist/server/mount-routes.js +74 -0
  28. package/dist/server/routes/__tests__/admin.test.d.ts +1 -0
  29. package/dist/server/routes/__tests__/admin.test.js +267 -0
  30. package/dist/server/routes/__tests__/crypto-webhook.test.d.ts +1 -0
  31. package/dist/server/routes/__tests__/crypto-webhook.test.js +137 -0
  32. package/dist/server/routes/__tests__/provision-webhook.test.d.ts +1 -0
  33. package/dist/server/routes/__tests__/provision-webhook.test.js +212 -0
  34. package/dist/server/routes/__tests__/stripe-webhook.test.d.ts +1 -0
  35. package/dist/server/routes/__tests__/stripe-webhook.test.js +65 -0
  36. package/dist/server/routes/admin.d.ts +111 -0
  37. package/dist/server/routes/admin.js +273 -0
  38. package/dist/server/routes/crypto-webhook.d.ts +23 -0
  39. package/dist/server/routes/crypto-webhook.js +82 -0
  40. package/dist/server/routes/provision-webhook.d.ts +38 -0
  41. package/dist/server/routes/provision-webhook.js +160 -0
  42. package/dist/server/routes/stripe-webhook.d.ts +10 -0
  43. package/dist/server/routes/stripe-webhook.js +29 -0
  44. package/dist/server/test-container.d.ts +15 -0
  45. package/dist/server/test-container.js +103 -0
  46. package/dist/trpc/auth-helpers.d.ts +17 -0
  47. package/dist/trpc/auth-helpers.js +26 -0
  48. package/dist/trpc/container-factories.d.ts +300 -0
  49. package/dist/trpc/container-factories.js +80 -0
  50. package/dist/trpc/index.d.ts +2 -0
  51. package/dist/trpc/index.js +2 -0
  52. package/package.json +5 -1
  53. package/src/server/__tests__/build-container.test.ts +402 -0
  54. package/src/server/__tests__/container.test.ts +204 -0
  55. package/src/server/__tests__/lifecycle.test.ts +106 -0
  56. package/src/server/__tests__/mount-routes.test.ts +169 -0
  57. package/src/server/boot-config.ts +84 -0
  58. package/src/server/container.ts +237 -0
  59. package/src/server/index.ts +92 -0
  60. package/src/server/lifecycle.ts +62 -0
  61. package/src/server/middleware/__tests__/admin-auth.test.ts +67 -0
  62. package/src/server/middleware/__tests__/tenant-proxy.test.ts +308 -0
  63. package/src/server/middleware/admin-auth.ts +51 -0
  64. package/src/server/middleware/tenant-proxy.ts +192 -0
  65. package/src/server/mount-routes.ts +113 -0
  66. package/src/server/routes/__tests__/admin.test.ts +320 -0
  67. package/src/server/routes/__tests__/crypto-webhook.test.ts +167 -0
  68. package/src/server/routes/__tests__/provision-webhook.test.ts +323 -0
  69. package/src/server/routes/__tests__/stripe-webhook.test.ts +73 -0
  70. package/src/server/routes/admin.ts +334 -0
  71. package/src/server/routes/crypto-webhook.ts +110 -0
  72. package/src/server/routes/provision-webhook.ts +212 -0
  73. package/src/server/routes/stripe-webhook.ts +36 -0
  74. package/src/server/test-container.ts +120 -0
  75. package/src/trpc/auth-helpers.ts +28 -0
  76. package/src/trpc/container-factories.ts +114 -0
  77. package/src/trpc/index.ts +9 -0
@@ -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
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Tenant subdomain proxy middleware.
3
+ *
4
+ * Extracts the tenant subdomain from the Host header, authenticates
5
+ * the user, verifies tenant membership via orgMemberRepo, resolves
6
+ * the fleet container URL, and proxies the request upstream.
7
+ *
8
+ * Ported from paperclip-platform with fail-closed semantics:
9
+ * - If fleet services are unavailable, returns 503 (not silent skip)
10
+ * - Auth check runs before tenant ownership check
11
+ * - Upstream headers are sanitized via allowlist
12
+ */
13
+
14
+ import type { MiddlewareHandler } from "hono";
15
+ import type { PlatformContainer } from "../container.js";
16
+
17
+ /** Reserved subdomains that should never resolve to a tenant. */
18
+ const RESERVED_SUBDOMAINS = new Set(["app", "api", "staging", "www", "mail", "admin", "dashboard", "status", "docs"]);
19
+
20
+ /** DNS label rules (RFC 1123). */
21
+ const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
22
+
23
+ /**
24
+ * Headers safe to forward to upstream containers.
25
+ *
26
+ * This is an allowlist -- only these headers are copied from the incoming
27
+ * request. All x-platform-* headers are injected server-side after auth
28
+ * resolution, preventing client-side spoofing.
29
+ */
30
+ const FORWARDED_HEADERS = [
31
+ "content-type",
32
+ "accept",
33
+ "accept-language",
34
+ "accept-encoding",
35
+ "content-length",
36
+ "x-request-id",
37
+ "user-agent",
38
+ "origin",
39
+ "referer",
40
+ "cookie",
41
+ ];
42
+
43
+ /** Resolved user identity for upstream header injection. */
44
+ export interface ProxyUserInfo {
45
+ id: string;
46
+ email?: string;
47
+ name?: string;
48
+ }
49
+
50
+ export interface TenantProxyConfig {
51
+ /** The platform root domain (e.g. "runpaperclip.com"). */
52
+ platformDomain: string;
53
+
54
+ /**
55
+ * Resolve the authenticated user from the request.
56
+ * Products wire this to their auth system (BetterAuth, etc.).
57
+ */
58
+ resolveUser: (req: Request) => Promise<ProxyUserInfo | undefined>;
59
+ }
60
+
61
+ /**
62
+ * Extract the tenant subdomain from a Host header value.
63
+ *
64
+ * "alice.example.com" -> "alice"
65
+ * "example.com" -> null (root domain)
66
+ * "app.example.com" -> null (reserved)
67
+ */
68
+ export function extractTenantSubdomain(host: string, platformDomain: string): string | null {
69
+ const hostname = host.split(":")[0].toLowerCase();
70
+ const suffix = `.${platformDomain}`;
71
+ if (!hostname.endsWith(suffix)) return null;
72
+
73
+ const subdomain = hostname.slice(0, -suffix.length);
74
+ if (!subdomain || subdomain.includes(".")) return null;
75
+ if (RESERVED_SUBDOMAINS.has(subdomain)) return null;
76
+ if (!SUBDOMAIN_RE.test(subdomain)) return null;
77
+
78
+ return subdomain;
79
+ }
80
+
81
+ /**
82
+ * Build sanitized headers for upstream requests.
83
+ *
84
+ * Only allowlisted headers are forwarded. All x-platform-* headers are
85
+ * injected server-side from the authenticated session -- never copied from
86
+ * the incoming request -- to prevent spoofing.
87
+ */
88
+ export function buildUpstreamHeaders(incoming: Headers, user: ProxyUserInfo, tenantSubdomain: string): Headers {
89
+ const headers = new Headers();
90
+ for (const key of FORWARDED_HEADERS) {
91
+ const val = incoming.get(key);
92
+ if (val) headers.set(key, val);
93
+ }
94
+ // Forward original Host so upstream hostname allowlist doesn't reject
95
+ const host = incoming.get("host");
96
+ if (host) headers.set("host", host);
97
+ headers.set("x-platform-user-id", user.id);
98
+ headers.set("x-platform-tenant", tenantSubdomain);
99
+ if (user.email) headers.set("x-platform-user-email", user.email);
100
+ if (user.name) headers.set("x-platform-user-name", user.name);
101
+ return headers;
102
+ }
103
+
104
+ /**
105
+ * Resolve the upstream container URL for a tenant subdomain from the
106
+ * proxy route table. Returns null if no route exists or is unhealthy.
107
+ */
108
+ function resolveContainerUrl(container: PlatformContainer, subdomain: string): string | null {
109
+ if (!container.fleet) return null;
110
+ const routes = container.fleet.proxy.getRoutes();
111
+ const route = routes.find((r) => r.subdomain === subdomain);
112
+ if (!route || !route.healthy) return null;
113
+ return `http://${route.upstreamHost}:${route.upstreamPort}`;
114
+ }
115
+
116
+ /**
117
+ * Create a tenant subdomain proxy middleware.
118
+ *
119
+ * If the request Host identifies a tenant subdomain, authenticates the user,
120
+ * resolves the fleet container URL, and proxies the request. Non-tenant
121
+ * requests (root domain, reserved subdomains) pass through to next().
122
+ *
123
+ * Fail-closed: if fleet services or orgMemberRepo are unavailable, returns
124
+ * 503 instead of silently skipping checks.
125
+ */
126
+ export function createTenantProxyMiddleware(
127
+ container: PlatformContainer,
128
+ config: TenantProxyConfig,
129
+ ): MiddlewareHandler {
130
+ const { platformDomain, resolveUser } = config;
131
+
132
+ return async (c, next) => {
133
+ const host = c.req.header("host");
134
+ if (!host) return next();
135
+
136
+ const subdomain = extractTenantSubdomain(host, platformDomain);
137
+ if (!subdomain) return next();
138
+
139
+ // --- Fail-closed checks ---
140
+
141
+ // Fleet services must be available for tenant proxying
142
+ if (!container.fleet) {
143
+ return c.json({ error: "Fleet services unavailable" }, 503);
144
+ }
145
+
146
+ // Authenticate -- reject unauthenticated requests
147
+ const user = await resolveUser(c.req.raw);
148
+ if (!user) {
149
+ return c.json({ error: "Authentication required" }, 401);
150
+ }
151
+
152
+ // Verify tenant ownership -- user must belong to the org that owns this subdomain
153
+ const profiles = await container.fleet.profileStore.list();
154
+ const profile = profiles.find((p) => p.name === subdomain);
155
+ if (!profile) {
156
+ return c.json({ error: "Tenant not found" }, 404);
157
+ }
158
+ const { validateTenantAccess } = await import("../../auth/index.js");
159
+ const hasAccess = await validateTenantAccess(user.id, profile.tenantId, container.orgMemberRepo);
160
+ if (!hasAccess) {
161
+ return c.json({ error: "Forbidden: not a member of this tenant" }, 403);
162
+ }
163
+
164
+ // Resolve fleet container URL via proxy route table
165
+ const upstream = resolveContainerUrl(container, subdomain);
166
+ if (!upstream) {
167
+ return c.json({ error: "Tenant not found" }, 404);
168
+ }
169
+
170
+ const url = new URL(c.req.url);
171
+ const targetUrl = `${upstream}${url.pathname}${url.search}`;
172
+ const upstreamHeaders = buildUpstreamHeaders(c.req.raw.headers, user, subdomain);
173
+
174
+ let response: Response;
175
+ try {
176
+ response = await fetch(targetUrl, {
177
+ method: c.req.method,
178
+ headers: upstreamHeaders,
179
+ body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
180
+ // @ts-expect-error duplex needed for streaming request bodies
181
+ duplex: "half",
182
+ });
183
+ } catch {
184
+ return c.json({ error: "Bad Gateway: upstream container unavailable" }, 502);
185
+ }
186
+
187
+ return new Response(response.body, {
188
+ status: response.status,
189
+ headers: response.headers,
190
+ });
191
+ };
192
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * mountRoutes — wire shared HTTP routes and middleware onto a Hono app.
3
+ *
4
+ * Mounts routes conditionally based on which feature sub-containers are
5
+ * present on the PlatformContainer. Products call this after building the
6
+ * container; tRPC routers (admin, fleet-update, etc.) are mounted
7
+ * separately by products since they need product-specific auth context.
8
+ */
9
+
10
+ import type { Hono } from "hono";
11
+ import { cors } from "hono/cors";
12
+ import { deriveCorsOrigins } from "../product-config/repository-types.js";
13
+ import type { RoutePlugin } from "./boot-config.js";
14
+ import type { PlatformContainer } from "./container.js";
15
+ import { createTenantProxyMiddleware } from "./middleware/tenant-proxy.js";
16
+ import { createCryptoWebhookRoutes } from "./routes/crypto-webhook.js";
17
+ import { createProvisionWebhookRoutes } from "./routes/provision-webhook.js";
18
+ import { createStripeWebhookRoutes } from "./routes/stripe-webhook.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Config accepted at mount time
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface MountConfig {
25
+ provisionSecret: string;
26
+ cryptoServiceKey?: string;
27
+ platformDomain: string;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // mountRoutes
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Mount all shared routes and middleware onto a Hono app based on the
36
+ * container's enabled feature slices.
37
+ *
38
+ * Mount order:
39
+ * 1. CORS middleware (from productConfig domain list)
40
+ * 2. Health endpoint (always)
41
+ * 3. Crypto webhook (if crypto enabled)
42
+ * 4. Stripe webhook (if stripe enabled)
43
+ * 5. Provision webhook (if fleet enabled)
44
+ * 6. Product-specific route plugins
45
+ * 7. Tenant proxy middleware (catch-all — must be last)
46
+ */
47
+ export function mountRoutes(
48
+ app: Hono,
49
+ container: PlatformContainer,
50
+ config: MountConfig,
51
+ plugins: RoutePlugin[] = [],
52
+ ): void {
53
+ // 1. CORS middleware
54
+ const origins = deriveCorsOrigins(container.productConfig.product, container.productConfig.domains);
55
+ app.use(
56
+ "*",
57
+ cors({
58
+ origin: origins,
59
+ allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
60
+ allowHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
61
+ credentials: true,
62
+ }),
63
+ );
64
+
65
+ // 2. Health endpoint (always available)
66
+ app.get("/health", (c) => c.json({ ok: true }));
67
+
68
+ // 3. Crypto webhook (when crypto payments are enabled)
69
+ if (container.crypto) {
70
+ app.route(
71
+ "/api/webhooks/crypto",
72
+ createCryptoWebhookRoutes(container, {
73
+ provisionSecret: config.provisionSecret,
74
+ cryptoServiceKey: config.cryptoServiceKey,
75
+ }),
76
+ );
77
+ }
78
+
79
+ // 4. Stripe webhook (when stripe billing is enabled)
80
+ if (container.stripe) {
81
+ app.route("/api/webhooks/stripe", createStripeWebhookRoutes(container));
82
+ }
83
+
84
+ // 5. Provision webhook (when fleet management is enabled)
85
+ if (container.fleet) {
86
+ const fleetConfig = container.productConfig.fleet;
87
+ app.route(
88
+ "/api/provision",
89
+ createProvisionWebhookRoutes(container, {
90
+ provisionSecret: config.provisionSecret,
91
+ instanceImage: fleetConfig?.containerImage ?? "ghcr.io/default:latest",
92
+ containerPort: fleetConfig?.containerPort ?? 3000,
93
+ maxInstancesPerTenant: fleetConfig?.maxInstances ?? 5,
94
+ }),
95
+ );
96
+ }
97
+
98
+ // 6. Product-specific route plugins
99
+ for (const plugin of plugins) {
100
+ app.route(plugin.path, plugin.handler(container));
101
+ }
102
+
103
+ // 7. Tenant proxy middleware (catch-all — MUST be last)
104
+ if (container.fleet) {
105
+ app.use(
106
+ "*",
107
+ createTenantProxyMiddleware(container, {
108
+ platformDomain: config.platformDomain,
109
+ resolveUser: async () => undefined, // Products override via plugin or direct middleware
110
+ }),
111
+ );
112
+ }
113
+ }