@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.
- package/dist/auth/better-auth.js +7 -0
- package/dist/backup/types.d.ts +1 -1
- package/dist/email/client.js +16 -0
- package/dist/server/__tests__/build-container.test.d.ts +1 -0
- package/dist/server/__tests__/build-container.test.js +339 -0
- package/dist/server/__tests__/container.test.d.ts +1 -0
- package/dist/server/__tests__/container.test.js +170 -0
- package/dist/server/__tests__/lifecycle.test.d.ts +1 -0
- package/dist/server/__tests__/lifecycle.test.js +90 -0
- package/dist/server/__tests__/mount-routes.test.d.ts +1 -0
- package/dist/server/__tests__/mount-routes.test.js +151 -0
- package/dist/server/boot-config.d.ts +51 -0
- package/dist/server/boot-config.js +7 -0
- package/dist/server/container.d.ts +81 -0
- package/dist/server/container.js +134 -0
- package/dist/server/index.d.ts +33 -0
- package/dist/server/index.js +66 -0
- package/dist/server/lifecycle.d.ts +25 -0
- package/dist/server/lifecycle.js +46 -0
- package/dist/server/middleware/__tests__/admin-auth.test.d.ts +1 -0
- package/dist/server/middleware/__tests__/admin-auth.test.js +59 -0
- package/dist/server/middleware/__tests__/tenant-proxy.test.d.ts +1 -0
- package/dist/server/middleware/__tests__/tenant-proxy.test.js +268 -0
- package/dist/server/middleware/admin-auth.d.ts +18 -0
- package/dist/server/middleware/admin-auth.js +38 -0
- package/dist/server/middleware/tenant-proxy.d.ts +56 -0
- package/dist/server/middleware/tenant-proxy.js +162 -0
- package/dist/server/mount-routes.d.ts +30 -0
- package/dist/server/mount-routes.js +74 -0
- package/dist/server/routes/__tests__/admin.test.d.ts +1 -0
- package/dist/server/routes/__tests__/admin.test.js +267 -0
- package/dist/server/routes/__tests__/crypto-webhook.test.d.ts +1 -0
- package/dist/server/routes/__tests__/crypto-webhook.test.js +137 -0
- package/dist/server/routes/__tests__/provision-webhook.test.d.ts +1 -0
- package/dist/server/routes/__tests__/provision-webhook.test.js +212 -0
- package/dist/server/routes/__tests__/stripe-webhook.test.d.ts +1 -0
- package/dist/server/routes/__tests__/stripe-webhook.test.js +65 -0
- package/dist/server/routes/admin.d.ts +111 -0
- package/dist/server/routes/admin.js +273 -0
- package/dist/server/routes/crypto-webhook.d.ts +23 -0
- package/dist/server/routes/crypto-webhook.js +82 -0
- package/dist/server/routes/provision-webhook.d.ts +38 -0
- package/dist/server/routes/provision-webhook.js +160 -0
- package/dist/server/routes/stripe-webhook.d.ts +10 -0
- package/dist/server/routes/stripe-webhook.js +29 -0
- package/dist/server/test-container.d.ts +15 -0
- package/dist/server/test-container.js +103 -0
- package/dist/trpc/auth-helpers.d.ts +17 -0
- package/dist/trpc/auth-helpers.js +26 -0
- package/dist/trpc/container-factories.d.ts +300 -0
- package/dist/trpc/container-factories.js +80 -0
- package/dist/trpc/index.d.ts +2 -0
- package/dist/trpc/index.js +2 -0
- package/package.json +8 -3
- package/src/auth/better-auth.ts +8 -0
- package/src/email/client.ts +18 -0
- package/src/server/__tests__/build-container.test.ts +402 -0
- package/src/server/__tests__/container.test.ts +204 -0
- package/src/server/__tests__/lifecycle.test.ts +106 -0
- package/src/server/__tests__/mount-routes.test.ts +169 -0
- package/src/server/boot-config.ts +84 -0
- package/src/server/container.ts +237 -0
- package/src/server/index.ts +92 -0
- package/src/server/lifecycle.ts +62 -0
- package/src/server/middleware/__tests__/admin-auth.test.ts +67 -0
- package/src/server/middleware/__tests__/tenant-proxy.test.ts +308 -0
- package/src/server/middleware/admin-auth.ts +51 -0
- package/src/server/middleware/tenant-proxy.ts +192 -0
- package/src/server/mount-routes.ts +113 -0
- package/src/server/routes/__tests__/admin.test.ts +320 -0
- package/src/server/routes/__tests__/crypto-webhook.test.ts +167 -0
- package/src/server/routes/__tests__/provision-webhook.test.ts +323 -0
- package/src/server/routes/__tests__/stripe-webhook.test.ts +73 -0
- package/src/server/routes/admin.ts +334 -0
- package/src/server/routes/crypto-webhook.ts +110 -0
- package/src/server/routes/provision-webhook.ts +212 -0
- package/src/server/routes/stripe-webhook.ts +36 -0
- package/src/server/test-container.ts +120 -0
- package/src/trpc/auth-helpers.ts +28 -0
- package/src/trpc/container-factories.ts +114 -0
- package/src/trpc/index.ts +9 -0
|
@@ -0,0 +1,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
|
+
}
|