@wopr-network/platform-core 1.67.1 → 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 (81) hide show
  1. package/dist/auth/better-auth.js +7 -0
  2. package/dist/backup/types.d.ts +1 -1
  3. package/dist/email/client.js +16 -0
  4. package/dist/server/__tests__/build-container.test.d.ts +1 -0
  5. package/dist/server/__tests__/build-container.test.js +339 -0
  6. package/dist/server/__tests__/container.test.d.ts +1 -0
  7. package/dist/server/__tests__/container.test.js +170 -0
  8. package/dist/server/__tests__/lifecycle.test.d.ts +1 -0
  9. package/dist/server/__tests__/lifecycle.test.js +90 -0
  10. package/dist/server/__tests__/mount-routes.test.d.ts +1 -0
  11. package/dist/server/__tests__/mount-routes.test.js +151 -0
  12. package/dist/server/boot-config.d.ts +51 -0
  13. package/dist/server/boot-config.js +7 -0
  14. package/dist/server/container.d.ts +81 -0
  15. package/dist/server/container.js +134 -0
  16. package/dist/server/index.d.ts +33 -0
  17. package/dist/server/index.js +66 -0
  18. package/dist/server/lifecycle.d.ts +25 -0
  19. package/dist/server/lifecycle.js +46 -0
  20. package/dist/server/middleware/__tests__/admin-auth.test.d.ts +1 -0
  21. package/dist/server/middleware/__tests__/admin-auth.test.js +59 -0
  22. package/dist/server/middleware/__tests__/tenant-proxy.test.d.ts +1 -0
  23. package/dist/server/middleware/__tests__/tenant-proxy.test.js +268 -0
  24. package/dist/server/middleware/admin-auth.d.ts +18 -0
  25. package/dist/server/middleware/admin-auth.js +38 -0
  26. package/dist/server/middleware/tenant-proxy.d.ts +56 -0
  27. package/dist/server/middleware/tenant-proxy.js +162 -0
  28. package/dist/server/mount-routes.d.ts +30 -0
  29. package/dist/server/mount-routes.js +74 -0
  30. package/dist/server/routes/__tests__/admin.test.d.ts +1 -0
  31. package/dist/server/routes/__tests__/admin.test.js +267 -0
  32. package/dist/server/routes/__tests__/crypto-webhook.test.d.ts +1 -0
  33. package/dist/server/routes/__tests__/crypto-webhook.test.js +137 -0
  34. package/dist/server/routes/__tests__/provision-webhook.test.d.ts +1 -0
  35. package/dist/server/routes/__tests__/provision-webhook.test.js +212 -0
  36. package/dist/server/routes/__tests__/stripe-webhook.test.d.ts +1 -0
  37. package/dist/server/routes/__tests__/stripe-webhook.test.js +65 -0
  38. package/dist/server/routes/admin.d.ts +111 -0
  39. package/dist/server/routes/admin.js +273 -0
  40. package/dist/server/routes/crypto-webhook.d.ts +23 -0
  41. package/dist/server/routes/crypto-webhook.js +82 -0
  42. package/dist/server/routes/provision-webhook.d.ts +38 -0
  43. package/dist/server/routes/provision-webhook.js +160 -0
  44. package/dist/server/routes/stripe-webhook.d.ts +10 -0
  45. package/dist/server/routes/stripe-webhook.js +29 -0
  46. package/dist/server/test-container.d.ts +15 -0
  47. package/dist/server/test-container.js +103 -0
  48. package/dist/trpc/auth-helpers.d.ts +17 -0
  49. package/dist/trpc/auth-helpers.js +26 -0
  50. package/dist/trpc/container-factories.d.ts +300 -0
  51. package/dist/trpc/container-factories.js +80 -0
  52. package/dist/trpc/index.d.ts +2 -0
  53. package/dist/trpc/index.js +2 -0
  54. package/package.json +8 -3
  55. package/src/auth/better-auth.ts +8 -0
  56. package/src/email/client.ts +18 -0
  57. package/src/server/__tests__/build-container.test.ts +402 -0
  58. package/src/server/__tests__/container.test.ts +204 -0
  59. package/src/server/__tests__/lifecycle.test.ts +106 -0
  60. package/src/server/__tests__/mount-routes.test.ts +169 -0
  61. package/src/server/boot-config.ts +84 -0
  62. package/src/server/container.ts +237 -0
  63. package/src/server/index.ts +92 -0
  64. package/src/server/lifecycle.ts +62 -0
  65. package/src/server/middleware/__tests__/admin-auth.test.ts +67 -0
  66. package/src/server/middleware/__tests__/tenant-proxy.test.ts +308 -0
  67. package/src/server/middleware/admin-auth.ts +51 -0
  68. package/src/server/middleware/tenant-proxy.ts +192 -0
  69. package/src/server/mount-routes.ts +113 -0
  70. package/src/server/routes/__tests__/admin.test.ts +320 -0
  71. package/src/server/routes/__tests__/crypto-webhook.test.ts +167 -0
  72. package/src/server/routes/__tests__/provision-webhook.test.ts +323 -0
  73. package/src/server/routes/__tests__/stripe-webhook.test.ts +73 -0
  74. package/src/server/routes/admin.ts +334 -0
  75. package/src/server/routes/crypto-webhook.ts +110 -0
  76. package/src/server/routes/provision-webhook.ts +212 -0
  77. package/src/server/routes/stripe-webhook.ts +36 -0
  78. package/src/server/test-container.ts +120 -0
  79. package/src/trpc/auth-helpers.ts +28 -0
  80. package/src/trpc/container-factories.ts +114 -0
  81. package/src/trpc/index.ts +9 -0
@@ -0,0 +1,268 @@
1
+ import { Hono } from "hono";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { createTestContainer } from "../../test-container.js";
4
+ import { buildUpstreamHeaders, createTenantProxyMiddleware, extractTenantSubdomain, } from "../tenant-proxy.js";
5
+ // ---------------------------------------------------------------------------
6
+ // extractTenantSubdomain unit tests
7
+ // ---------------------------------------------------------------------------
8
+ describe("extractTenantSubdomain", () => {
9
+ const domain = "example.com";
10
+ it("extracts a valid subdomain", () => {
11
+ expect(extractTenantSubdomain("alice.example.com", domain)).toBe("alice");
12
+ });
13
+ it("returns null for the root domain", () => {
14
+ expect(extractTenantSubdomain("example.com", domain)).toBeNull();
15
+ });
16
+ it("returns null for reserved subdomains", () => {
17
+ expect(extractTenantSubdomain("app.example.com", domain)).toBeNull();
18
+ expect(extractTenantSubdomain("api.example.com", domain)).toBeNull();
19
+ expect(extractTenantSubdomain("admin.example.com", domain)).toBeNull();
20
+ });
21
+ it("returns null for nested subdomains", () => {
22
+ expect(extractTenantSubdomain("deep.alice.example.com", domain)).toBeNull();
23
+ });
24
+ it("strips port before matching", () => {
25
+ expect(extractTenantSubdomain("alice.example.com:3000", domain)).toBe("alice");
26
+ });
27
+ it("returns null for non-matching domain", () => {
28
+ expect(extractTenantSubdomain("alice.other.com", domain)).toBeNull();
29
+ });
30
+ it("returns null for invalid subdomain characters", () => {
31
+ expect(extractTenantSubdomain("al!ce.example.com", domain)).toBeNull();
32
+ });
33
+ });
34
+ // ---------------------------------------------------------------------------
35
+ // buildUpstreamHeaders unit tests
36
+ // ---------------------------------------------------------------------------
37
+ describe("buildUpstreamHeaders", () => {
38
+ it("copies only allowlisted headers and injects platform headers", () => {
39
+ const incoming = new Headers({
40
+ "content-type": "application/json",
41
+ "x-evil-header": "should-not-pass",
42
+ host: "alice.example.com",
43
+ });
44
+ const user = { id: "u1", email: "a@b.com", name: "Alice" };
45
+ const result = buildUpstreamHeaders(incoming, user, "alice");
46
+ expect(result.get("content-type")).toBe("application/json");
47
+ expect(result.get("x-evil-header")).toBeNull();
48
+ expect(result.get("x-platform-user-id")).toBe("u1");
49
+ expect(result.get("x-platform-tenant")).toBe("alice");
50
+ expect(result.get("x-platform-user-email")).toBe("a@b.com");
51
+ expect(result.get("x-platform-user-name")).toBe("Alice");
52
+ expect(result.get("host")).toBe("alice.example.com");
53
+ });
54
+ });
55
+ // ---------------------------------------------------------------------------
56
+ // createTenantProxyMiddleware integration tests
57
+ // ---------------------------------------------------------------------------
58
+ describe("createTenantProxyMiddleware", () => {
59
+ const DOMAIN = "example.com";
60
+ let resolveUser;
61
+ let config;
62
+ beforeEach(() => {
63
+ resolveUser = vi.fn();
64
+ config = { platformDomain: DOMAIN, resolveUser };
65
+ });
66
+ function createApp(container) {
67
+ const app = new Hono();
68
+ app.use("/*", createTenantProxyMiddleware(container, config));
69
+ app.get("/fallthrough", (c) => c.json({ fallthrough: true }));
70
+ return app;
71
+ }
72
+ it("passes through non-tenant requests (no subdomain)", async () => {
73
+ const container = createTestContainer();
74
+ const app = createApp(container);
75
+ const res = await app.request("http://example.com/fallthrough");
76
+ expect(res.status).toBe(200);
77
+ const body = await res.json();
78
+ expect(body.fallthrough).toBe(true);
79
+ // resolveUser should not be called for non-tenant requests
80
+ expect(resolveUser).not.toHaveBeenCalled();
81
+ });
82
+ it("returns 401 for unauthenticated tenant requests", async () => {
83
+ const container = createTestContainer({
84
+ fleet: {
85
+ profileStore: { list: vi.fn().mockResolvedValue([]) },
86
+ proxy: { getRoutes: vi.fn().mockReturnValue([]) },
87
+ manager: {},
88
+ docker: {},
89
+ serviceKeyRepo: {},
90
+ },
91
+ });
92
+ resolveUser.mockResolvedValue(undefined);
93
+ const app = createApp(container);
94
+ const res = await app.request("http://alice.example.com/dashboard", {
95
+ headers: { host: "alice.example.com" },
96
+ });
97
+ expect(res.status).toBe(401);
98
+ const body = await res.json();
99
+ expect(body.error).toContain("Authentication required");
100
+ });
101
+ it("returns 503 when fleet services are not available", async () => {
102
+ const container = createTestContainer({ fleet: null });
103
+ const app = createApp(container);
104
+ const res = await app.request("http://alice.example.com/dashboard", {
105
+ headers: { host: "alice.example.com" },
106
+ });
107
+ expect(res.status).toBe(503);
108
+ const body = await res.json();
109
+ expect(body.error).toContain("Fleet services unavailable");
110
+ });
111
+ it("returns 403 when user is not a member of the tenant", async () => {
112
+ const mockProfile = { name: "alice", tenantId: "tenant-1" };
113
+ const container = createTestContainer({
114
+ fleet: {
115
+ profileStore: { list: vi.fn().mockResolvedValue([mockProfile]) },
116
+ proxy: {
117
+ getRoutes: vi
118
+ .fn()
119
+ .mockReturnValue([{ subdomain: "alice", upstreamHost: "127.0.0.1", upstreamPort: 4000, healthy: true }]),
120
+ },
121
+ manager: {},
122
+ docker: {},
123
+ serviceKeyRepo: {},
124
+ },
125
+ orgMemberRepo: {
126
+ findMember: vi.fn().mockResolvedValue(null),
127
+ listMembers: vi.fn(),
128
+ addMember: vi.fn(),
129
+ updateMemberRole: vi.fn(),
130
+ removeMember: vi.fn(),
131
+ countAdminsAndOwners: vi.fn(),
132
+ listInvites: vi.fn(),
133
+ createInvite: vi.fn(),
134
+ findInviteById: vi.fn(),
135
+ findInviteByToken: vi.fn(),
136
+ deleteInvite: vi.fn(),
137
+ deleteAllMembers: vi.fn(),
138
+ deleteAllInvites: vi.fn(),
139
+ listOrgsByUser: vi.fn(),
140
+ markInviteAccepted: vi.fn(),
141
+ },
142
+ });
143
+ resolveUser.mockResolvedValue({ id: "user-99", email: "user@test.com" });
144
+ const app = createApp(container);
145
+ const res = await app.request("http://alice.example.com/dashboard", {
146
+ headers: { host: "alice.example.com" },
147
+ });
148
+ expect(res.status).toBe(403);
149
+ const body = await res.json();
150
+ expect(body.error).toContain("not a member");
151
+ });
152
+ it("proxies correctly when authorized", async () => {
153
+ const mockProfile = { name: "alice", tenantId: "tenant-1" };
154
+ const upstreamRoute = {
155
+ instanceId: "inst-1",
156
+ subdomain: "alice",
157
+ upstreamHost: "127.0.0.1",
158
+ upstreamPort: 4000,
159
+ healthy: true,
160
+ };
161
+ const container = createTestContainer({
162
+ fleet: {
163
+ profileStore: { list: vi.fn().mockResolvedValue([mockProfile]) },
164
+ proxy: { getRoutes: vi.fn().mockReturnValue([upstreamRoute]) },
165
+ manager: {},
166
+ docker: {},
167
+ serviceKeyRepo: {},
168
+ },
169
+ orgMemberRepo: {
170
+ findMember: vi.fn().mockResolvedValue({ orgId: "tenant-1", userId: "user-1", role: "member" }),
171
+ listMembers: vi.fn(),
172
+ addMember: vi.fn(),
173
+ updateMemberRole: vi.fn(),
174
+ removeMember: vi.fn(),
175
+ countAdminsAndOwners: vi.fn(),
176
+ listInvites: vi.fn(),
177
+ createInvite: vi.fn(),
178
+ findInviteById: vi.fn(),
179
+ findInviteByToken: vi.fn(),
180
+ deleteInvite: vi.fn(),
181
+ deleteAllMembers: vi.fn(),
182
+ deleteAllInvites: vi.fn(),
183
+ listOrgsByUser: vi.fn(),
184
+ markInviteAccepted: vi.fn(),
185
+ },
186
+ });
187
+ resolveUser.mockResolvedValue({ id: "user-1", email: "user@test.com" });
188
+ // Mock global fetch to simulate upstream response
189
+ const originalFetch = globalThis.fetch;
190
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ upstream: true }), {
191
+ status: 200,
192
+ headers: { "content-type": "application/json" },
193
+ }));
194
+ try {
195
+ const app = createApp(container);
196
+ const res = await app.request("http://alice.example.com/api/data?q=1", {
197
+ headers: { host: "alice.example.com" },
198
+ });
199
+ expect(res.status).toBe(200);
200
+ const body = await res.json();
201
+ expect(body.upstream).toBe(true);
202
+ // Verify fetch was called with the correct upstream URL
203
+ const fetchCall = globalThis.fetch.mock.calls[0];
204
+ expect(fetchCall[0]).toBe("http://127.0.0.1:4000/api/data?q=1");
205
+ // Verify platform headers were injected
206
+ const headers = fetchCall[1].headers;
207
+ expect(headers.get("x-platform-user-id")).toBe("user-1");
208
+ expect(headers.get("x-platform-tenant")).toBe("alice");
209
+ }
210
+ finally {
211
+ globalThis.fetch = originalFetch;
212
+ }
213
+ });
214
+ it("returns 502 when upstream fetch fails", async () => {
215
+ const mockProfile = { name: "bob", tenantId: "user-1" };
216
+ const upstreamRoute = {
217
+ instanceId: "inst-2",
218
+ subdomain: "bob",
219
+ upstreamHost: "127.0.0.1",
220
+ upstreamPort: 4001,
221
+ healthy: true,
222
+ };
223
+ const container = createTestContainer({
224
+ fleet: {
225
+ profileStore: { list: vi.fn().mockResolvedValue([mockProfile]) },
226
+ proxy: { getRoutes: vi.fn().mockReturnValue([upstreamRoute]) },
227
+ manager: {},
228
+ docker: {},
229
+ serviceKeyRepo: {},
230
+ },
231
+ });
232
+ // tenantId === userId means personal tenant, so validateTenantAccess returns true
233
+ resolveUser.mockResolvedValue({ id: "user-1" });
234
+ const originalFetch = globalThis.fetch;
235
+ globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
236
+ try {
237
+ const app = createApp(container);
238
+ const res = await app.request("http://bob.example.com/test", {
239
+ headers: { host: "bob.example.com" },
240
+ });
241
+ expect(res.status).toBe(502);
242
+ const body = await res.json();
243
+ expect(body.error).toContain("Bad Gateway");
244
+ }
245
+ finally {
246
+ globalThis.fetch = originalFetch;
247
+ }
248
+ });
249
+ it("returns 404 when subdomain has no upstream route", async () => {
250
+ const container = createTestContainer({
251
+ fleet: {
252
+ profileStore: { list: vi.fn().mockResolvedValue([]) },
253
+ proxy: { getRoutes: vi.fn().mockReturnValue([]) },
254
+ manager: {},
255
+ docker: {},
256
+ serviceKeyRepo: {},
257
+ },
258
+ });
259
+ resolveUser.mockResolvedValue({ id: "user-1" });
260
+ const app = createApp(container);
261
+ const res = await app.request("http://ghost.example.com/test", {
262
+ headers: { host: "ghost.example.com" },
263
+ });
264
+ expect(res.status).toBe(404);
265
+ const body = await res.json();
266
+ expect(body.error).toContain("Tenant not found");
267
+ });
268
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Admin auth middleware — timing-safe API key verification.
3
+ *
4
+ * Factory function that creates a Hono middleware handler requiring
5
+ * a valid admin API key in the Authorization header. Uses
6
+ * `crypto.timingSafeEqual` to prevent timing side-channel attacks.
7
+ */
8
+ import type { MiddlewareHandler } from "hono";
9
+ export interface AdminAuthConfig {
10
+ adminApiKey: string;
11
+ }
12
+ /**
13
+ * Create an admin auth middleware that validates Bearer tokens
14
+ * against the configured admin API key using timing-safe comparison.
15
+ *
16
+ * Fail-closed: if the key is empty or missing, all requests are rejected.
17
+ */
18
+ export declare function createAdminAuthMiddleware(config: AdminAuthConfig): MiddlewareHandler;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Admin auth middleware — timing-safe API key verification.
3
+ *
4
+ * Factory function that creates a Hono middleware handler requiring
5
+ * a valid admin API key in the Authorization header. Uses
6
+ * `crypto.timingSafeEqual` to prevent timing side-channel attacks.
7
+ */
8
+ import { timingSafeEqual } from "node:crypto";
9
+ /**
10
+ * Create an admin auth middleware that validates Bearer tokens
11
+ * against the configured admin API key using timing-safe comparison.
12
+ *
13
+ * Fail-closed: if the key is empty or missing, all requests are rejected.
14
+ */
15
+ export function createAdminAuthMiddleware(config) {
16
+ const { adminApiKey } = config;
17
+ return async (c, next) => {
18
+ // Fail closed: if no admin key is configured, reject everything
19
+ if (!adminApiKey) {
20
+ return c.json({ error: "Admin authentication not configured" }, 503);
21
+ }
22
+ const authHeader = c.req.header("authorization");
23
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
24
+ return c.json({ error: "Unauthorized: admin authentication required" }, 401);
25
+ }
26
+ const token = authHeader.slice("Bearer ".length).trim();
27
+ if (!token) {
28
+ return c.json({ error: "Unauthorized: admin authentication required" }, 401);
29
+ }
30
+ // Timing-safe comparison: both buffers must be the same length
31
+ const tokenBuf = Buffer.from(token);
32
+ const keyBuf = Buffer.from(adminApiKey);
33
+ if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) {
34
+ return c.json({ error: "Unauthorized: invalid admin credentials" }, 401);
35
+ }
36
+ return next();
37
+ };
38
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Tenant subdomain proxy middleware.
3
+ *
4
+ * Extracts the tenant subdomain from the Host header, authenticates
5
+ * the user, verifies tenant membership via orgMemberRepo, resolves
6
+ * the fleet container URL, and proxies the request upstream.
7
+ *
8
+ * Ported from paperclip-platform with fail-closed semantics:
9
+ * - If fleet services are unavailable, returns 503 (not silent skip)
10
+ * - Auth check runs before tenant ownership check
11
+ * - Upstream headers are sanitized via allowlist
12
+ */
13
+ import type { MiddlewareHandler } from "hono";
14
+ import type { PlatformContainer } from "../container.js";
15
+ /** Resolved user identity for upstream header injection. */
16
+ export interface ProxyUserInfo {
17
+ id: string;
18
+ email?: string;
19
+ name?: string;
20
+ }
21
+ export interface TenantProxyConfig {
22
+ /** The platform root domain (e.g. "runpaperclip.com"). */
23
+ platformDomain: string;
24
+ /**
25
+ * Resolve the authenticated user from the request.
26
+ * Products wire this to their auth system (BetterAuth, etc.).
27
+ */
28
+ resolveUser: (req: Request) => Promise<ProxyUserInfo | undefined>;
29
+ }
30
+ /**
31
+ * Extract the tenant subdomain from a Host header value.
32
+ *
33
+ * "alice.example.com" -> "alice"
34
+ * "example.com" -> null (root domain)
35
+ * "app.example.com" -> null (reserved)
36
+ */
37
+ export declare function extractTenantSubdomain(host: string, platformDomain: string): string | null;
38
+ /**
39
+ * Build sanitized headers for upstream requests.
40
+ *
41
+ * Only allowlisted headers are forwarded. All x-platform-* headers are
42
+ * injected server-side from the authenticated session -- never copied from
43
+ * the incoming request -- to prevent spoofing.
44
+ */
45
+ export declare function buildUpstreamHeaders(incoming: Headers, user: ProxyUserInfo, tenantSubdomain: string): Headers;
46
+ /**
47
+ * Create a tenant subdomain proxy middleware.
48
+ *
49
+ * If the request Host identifies a tenant subdomain, authenticates the user,
50
+ * resolves the fleet container URL, and proxies the request. Non-tenant
51
+ * requests (root domain, reserved subdomains) pass through to next().
52
+ *
53
+ * Fail-closed: if fleet services or orgMemberRepo are unavailable, returns
54
+ * 503 instead of silently skipping checks.
55
+ */
56
+ export declare function createTenantProxyMiddleware(container: PlatformContainer, config: TenantProxyConfig): MiddlewareHandler;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Tenant subdomain proxy middleware.
3
+ *
4
+ * Extracts the tenant subdomain from the Host header, authenticates
5
+ * the user, verifies tenant membership via orgMemberRepo, resolves
6
+ * the fleet container URL, and proxies the request upstream.
7
+ *
8
+ * Ported from paperclip-platform with fail-closed semantics:
9
+ * - If fleet services are unavailable, returns 503 (not silent skip)
10
+ * - Auth check runs before tenant ownership check
11
+ * - Upstream headers are sanitized via allowlist
12
+ */
13
+ /** Reserved subdomains that should never resolve to a tenant. */
14
+ const RESERVED_SUBDOMAINS = new Set(["app", "api", "staging", "www", "mail", "admin", "dashboard", "status", "docs"]);
15
+ /** DNS label rules (RFC 1123). */
16
+ const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
17
+ /**
18
+ * Headers safe to forward to upstream containers.
19
+ *
20
+ * This is an allowlist -- only these headers are copied from the incoming
21
+ * request. All x-platform-* headers are injected server-side after auth
22
+ * resolution, preventing client-side spoofing.
23
+ */
24
+ const FORWARDED_HEADERS = [
25
+ "content-type",
26
+ "accept",
27
+ "accept-language",
28
+ "accept-encoding",
29
+ "content-length",
30
+ "x-request-id",
31
+ "user-agent",
32
+ "origin",
33
+ "referer",
34
+ "cookie",
35
+ ];
36
+ /**
37
+ * Extract the tenant subdomain from a Host header value.
38
+ *
39
+ * "alice.example.com" -> "alice"
40
+ * "example.com" -> null (root domain)
41
+ * "app.example.com" -> null (reserved)
42
+ */
43
+ export function extractTenantSubdomain(host, platformDomain) {
44
+ const hostname = host.split(":")[0].toLowerCase();
45
+ const suffix = `.${platformDomain}`;
46
+ if (!hostname.endsWith(suffix))
47
+ return null;
48
+ const subdomain = hostname.slice(0, -suffix.length);
49
+ if (!subdomain || subdomain.includes("."))
50
+ return null;
51
+ if (RESERVED_SUBDOMAINS.has(subdomain))
52
+ return null;
53
+ if (!SUBDOMAIN_RE.test(subdomain))
54
+ return null;
55
+ return subdomain;
56
+ }
57
+ /**
58
+ * Build sanitized headers for upstream requests.
59
+ *
60
+ * Only allowlisted headers are forwarded. All x-platform-* headers are
61
+ * injected server-side from the authenticated session -- never copied from
62
+ * the incoming request -- to prevent spoofing.
63
+ */
64
+ export function buildUpstreamHeaders(incoming, user, tenantSubdomain) {
65
+ const headers = new Headers();
66
+ for (const key of FORWARDED_HEADERS) {
67
+ const val = incoming.get(key);
68
+ if (val)
69
+ headers.set(key, val);
70
+ }
71
+ // Forward original Host so upstream hostname allowlist doesn't reject
72
+ const host = incoming.get("host");
73
+ if (host)
74
+ headers.set("host", host);
75
+ headers.set("x-platform-user-id", user.id);
76
+ headers.set("x-platform-tenant", tenantSubdomain);
77
+ if (user.email)
78
+ headers.set("x-platform-user-email", user.email);
79
+ if (user.name)
80
+ headers.set("x-platform-user-name", user.name);
81
+ return headers;
82
+ }
83
+ /**
84
+ * Resolve the upstream container URL for a tenant subdomain from the
85
+ * proxy route table. Returns null if no route exists or is unhealthy.
86
+ */
87
+ function resolveContainerUrl(container, subdomain) {
88
+ if (!container.fleet)
89
+ return null;
90
+ const routes = container.fleet.proxy.getRoutes();
91
+ const route = routes.find((r) => r.subdomain === subdomain);
92
+ if (!route || !route.healthy)
93
+ return null;
94
+ return `http://${route.upstreamHost}:${route.upstreamPort}`;
95
+ }
96
+ /**
97
+ * Create a tenant subdomain proxy middleware.
98
+ *
99
+ * If the request Host identifies a tenant subdomain, authenticates the user,
100
+ * resolves the fleet container URL, and proxies the request. Non-tenant
101
+ * requests (root domain, reserved subdomains) pass through to next().
102
+ *
103
+ * Fail-closed: if fleet services or orgMemberRepo are unavailable, returns
104
+ * 503 instead of silently skipping checks.
105
+ */
106
+ export function createTenantProxyMiddleware(container, config) {
107
+ const { platformDomain, resolveUser } = config;
108
+ return async (c, next) => {
109
+ const host = c.req.header("host");
110
+ if (!host)
111
+ return next();
112
+ const subdomain = extractTenantSubdomain(host, platformDomain);
113
+ if (!subdomain)
114
+ return next();
115
+ // --- Fail-closed checks ---
116
+ // Fleet services must be available for tenant proxying
117
+ if (!container.fleet) {
118
+ return c.json({ error: "Fleet services unavailable" }, 503);
119
+ }
120
+ // Authenticate -- reject unauthenticated requests
121
+ const user = await resolveUser(c.req.raw);
122
+ if (!user) {
123
+ return c.json({ error: "Authentication required" }, 401);
124
+ }
125
+ // Verify tenant ownership -- user must belong to the org that owns this subdomain
126
+ const profiles = await container.fleet.profileStore.list();
127
+ const profile = profiles.find((p) => p.name === subdomain);
128
+ if (!profile) {
129
+ return c.json({ error: "Tenant not found" }, 404);
130
+ }
131
+ const { validateTenantAccess } = await import("../../auth/index.js");
132
+ const hasAccess = await validateTenantAccess(user.id, profile.tenantId, container.orgMemberRepo);
133
+ if (!hasAccess) {
134
+ return c.json({ error: "Forbidden: not a member of this tenant" }, 403);
135
+ }
136
+ // Resolve fleet container URL via proxy route table
137
+ const upstream = resolveContainerUrl(container, subdomain);
138
+ if (!upstream) {
139
+ return c.json({ error: "Tenant not found" }, 404);
140
+ }
141
+ const url = new URL(c.req.url);
142
+ const targetUrl = `${upstream}${url.pathname}${url.search}`;
143
+ const upstreamHeaders = buildUpstreamHeaders(c.req.raw.headers, user, subdomain);
144
+ let response;
145
+ try {
146
+ response = await fetch(targetUrl, {
147
+ method: c.req.method,
148
+ headers: upstreamHeaders,
149
+ body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
150
+ // @ts-expect-error duplex needed for streaming request bodies
151
+ duplex: "half",
152
+ });
153
+ }
154
+ catch {
155
+ return c.json({ error: "Bad Gateway: upstream container unavailable" }, 502);
156
+ }
157
+ return new Response(response.body, {
158
+ status: response.status,
159
+ headers: response.headers,
160
+ });
161
+ };
162
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * mountRoutes — wire shared HTTP routes and middleware onto a Hono app.
3
+ *
4
+ * Mounts routes conditionally based on which feature sub-containers are
5
+ * present on the PlatformContainer. Products call this after building the
6
+ * container; tRPC routers (admin, fleet-update, etc.) are mounted
7
+ * separately by products since they need product-specific auth context.
8
+ */
9
+ import type { Hono } from "hono";
10
+ import type { RoutePlugin } from "./boot-config.js";
11
+ import type { PlatformContainer } from "./container.js";
12
+ export interface MountConfig {
13
+ provisionSecret: string;
14
+ cryptoServiceKey?: string;
15
+ platformDomain: string;
16
+ }
17
+ /**
18
+ * Mount all shared routes and middleware onto a Hono app based on the
19
+ * container's enabled feature slices.
20
+ *
21
+ * Mount order:
22
+ * 1. CORS middleware (from productConfig domain list)
23
+ * 2. Health endpoint (always)
24
+ * 3. Crypto webhook (if crypto enabled)
25
+ * 4. Stripe webhook (if stripe enabled)
26
+ * 5. Provision webhook (if fleet enabled)
27
+ * 6. Product-specific route plugins
28
+ * 7. Tenant proxy middleware (catch-all — must be last)
29
+ */
30
+ export declare function mountRoutes(app: Hono, container: PlatformContainer, config: MountConfig, plugins?: RoutePlugin[]): void;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * mountRoutes — wire shared HTTP routes and middleware onto a Hono app.
3
+ *
4
+ * Mounts routes conditionally based on which feature sub-containers are
5
+ * present on the PlatformContainer. Products call this after building the
6
+ * container; tRPC routers (admin, fleet-update, etc.) are mounted
7
+ * separately by products since they need product-specific auth context.
8
+ */
9
+ import { cors } from "hono/cors";
10
+ import { deriveCorsOrigins } from "../product-config/repository-types.js";
11
+ import { createTenantProxyMiddleware } from "./middleware/tenant-proxy.js";
12
+ import { createCryptoWebhookRoutes } from "./routes/crypto-webhook.js";
13
+ import { createProvisionWebhookRoutes } from "./routes/provision-webhook.js";
14
+ import { createStripeWebhookRoutes } from "./routes/stripe-webhook.js";
15
+ // ---------------------------------------------------------------------------
16
+ // mountRoutes
17
+ // ---------------------------------------------------------------------------
18
+ /**
19
+ * Mount all shared routes and middleware onto a Hono app based on the
20
+ * container's enabled feature slices.
21
+ *
22
+ * Mount order:
23
+ * 1. CORS middleware (from productConfig domain list)
24
+ * 2. Health endpoint (always)
25
+ * 3. Crypto webhook (if crypto enabled)
26
+ * 4. Stripe webhook (if stripe enabled)
27
+ * 5. Provision webhook (if fleet enabled)
28
+ * 6. Product-specific route plugins
29
+ * 7. Tenant proxy middleware (catch-all — must be last)
30
+ */
31
+ export function mountRoutes(app, container, config, plugins = []) {
32
+ // 1. CORS middleware
33
+ const origins = deriveCorsOrigins(container.productConfig.product, container.productConfig.domains);
34
+ app.use("*", cors({
35
+ origin: origins,
36
+ allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
37
+ allowHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
38
+ credentials: true,
39
+ }));
40
+ // 2. Health endpoint (always available)
41
+ app.get("/health", (c) => c.json({ ok: true }));
42
+ // 3. Crypto webhook (when crypto payments are enabled)
43
+ if (container.crypto) {
44
+ app.route("/api/webhooks/crypto", createCryptoWebhookRoutes(container, {
45
+ provisionSecret: config.provisionSecret,
46
+ cryptoServiceKey: config.cryptoServiceKey,
47
+ }));
48
+ }
49
+ // 4. Stripe webhook (when stripe billing is enabled)
50
+ if (container.stripe) {
51
+ app.route("/api/webhooks/stripe", createStripeWebhookRoutes(container));
52
+ }
53
+ // 5. Provision webhook (when fleet management is enabled)
54
+ if (container.fleet) {
55
+ const fleetConfig = container.productConfig.fleet;
56
+ app.route("/api/provision", createProvisionWebhookRoutes(container, {
57
+ provisionSecret: config.provisionSecret,
58
+ instanceImage: fleetConfig?.containerImage ?? "ghcr.io/default:latest",
59
+ containerPort: fleetConfig?.containerPort ?? 3000,
60
+ maxInstancesPerTenant: fleetConfig?.maxInstances ?? 5,
61
+ }));
62
+ }
63
+ // 6. Product-specific route plugins
64
+ for (const plugin of plugins) {
65
+ app.route(plugin.path, plugin.handler(container));
66
+ }
67
+ // 7. Tenant proxy middleware (catch-all — MUST be last)
68
+ if (container.fleet) {
69
+ app.use("*", createTenantProxyMiddleware(container, {
70
+ platformDomain: config.platformDomain,
71
+ resolveUser: async () => undefined, // Products override via plugin or direct middleware
72
+ }));
73
+ }
74
+ }
@@ -0,0 +1 @@
1
+ export {};