@wopr-network/platform-ui-core 1.0.0 → 1.1.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/package.json +11 -3
- package/src/__tests__/api-tenant-route.test.ts +65 -0
- package/src/__tests__/middleware.test.ts +86 -0
- package/src/__tests__/plugin-install-flow.test.tsx +91 -18
- package/src/__tests__/sanitize-redirect-url.test.ts +27 -0
- package/src/__tests__/tenant-context.test.tsx +31 -36
- package/src/app/(dashboard)/onboarding/page.tsx +4 -3
- package/src/app/api/tenant/route.ts +50 -0
- package/src/app/instances/[id]/instance-detail-client.tsx +0 -72
- package/src/app/layout.tsx +4 -2
- package/src/components/auth/auth-redirect.tsx +2 -1
- package/src/components/chat/chat-panel.tsx +1 -1
- package/src/components/marketplace/install-wizard.tsx +12 -2
- package/src/components/sidebar.tsx +5 -17
- package/src/lib/api.ts +0 -21
- package/src/lib/brand-config.ts +29 -0
- package/src/lib/tenant-context.tsx +48 -18
- package/src/lib/trpc.tsx +5 -2
- package/src/lib/utils.test.ts +9 -0
- package/src/lib/utils.ts +35 -4
- package/src/proxy.ts +31 -6
- package/.env.paperclip +0 -18
package/package.json
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wopr-network/platform-ui-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Brand-agnostic AI agent platform UI — deploy as any brand via env vars",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/wopr-network/platform-ui-core.git"
|
|
8
8
|
},
|
|
9
9
|
"license": "UNLICENSED",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/lib/index.ts",
|
|
12
|
+
"./app/*": "./src/app/*",
|
|
13
|
+
"./components/*": "./src/components/*",
|
|
14
|
+
"./hooks/*": "./src/hooks/*",
|
|
15
|
+
"./lib/*": "./src/lib/*",
|
|
16
|
+
"./globals.css": "./src/app/globals.css",
|
|
17
|
+
"./proxy": "./src/proxy.ts"
|
|
18
|
+
},
|
|
10
19
|
"publishConfig": {
|
|
11
20
|
"registry": "https://registry.npmjs.org",
|
|
12
21
|
"access": "public"
|
|
@@ -20,8 +29,7 @@
|
|
|
20
29
|
"tailwind.config.ts",
|
|
21
30
|
"biome.json",
|
|
22
31
|
"vitest.config.ts",
|
|
23
|
-
".env.wopr"
|
|
24
|
-
".env.paperclip"
|
|
32
|
+
".env.wopr"
|
|
25
33
|
],
|
|
26
34
|
"dependencies": {
|
|
27
35
|
"@hookform/resolvers": "^5.2.2",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock brand-config before importing route
|
|
4
|
+
vi.mock("@/lib/brand-config", () => ({
|
|
5
|
+
getBrandConfig: vi.fn(() => ({
|
|
6
|
+
tenantCookieName: "platform_tenant_id",
|
|
7
|
+
})),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
// We test the route handler directly
|
|
11
|
+
import { DELETE, POST } from "@/app/api/tenant/route";
|
|
12
|
+
|
|
13
|
+
function makeRequest(method: string, body?: Record<string, unknown>): Request {
|
|
14
|
+
return new Request("http://localhost:3000/api/tenant", {
|
|
15
|
+
method,
|
|
16
|
+
headers: { "Content-Type": "application/json" },
|
|
17
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("POST /api/tenant", () => {
|
|
22
|
+
it("sets HttpOnly cookie with the provided tenantId", async () => {
|
|
23
|
+
const req = makeRequest("POST", { tenantId: "org-123" });
|
|
24
|
+
const res = await POST(req);
|
|
25
|
+
|
|
26
|
+
expect(res.status).toBe(200);
|
|
27
|
+
const setCookie = res.headers.get("set-cookie");
|
|
28
|
+
expect(setCookie).not.toBeNull();
|
|
29
|
+
expect(setCookie).toContain("platform_tenant_id=org-123");
|
|
30
|
+
expect(setCookie).toContain("HttpOnly");
|
|
31
|
+
expect(setCookie).toContain("Secure");
|
|
32
|
+
expect(setCookie).toMatch(/SameSite=lax/i);
|
|
33
|
+
expect(setCookie).toContain("Max-Age=2592000");
|
|
34
|
+
expect(setCookie).toContain("Path=/");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects missing tenantId", async () => {
|
|
38
|
+
const req = makeRequest("POST", {});
|
|
39
|
+
const res = await POST(req);
|
|
40
|
+
expect(res.status).toBe(400);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects empty tenantId", async () => {
|
|
44
|
+
const req = makeRequest("POST", { tenantId: "" });
|
|
45
|
+
const res = await POST(req);
|
|
46
|
+
expect(res.status).toBe(400);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects tenantId with invalid characters", async () => {
|
|
50
|
+
const req = makeRequest("POST", { tenantId: "org;evil=true" });
|
|
51
|
+
const res = await POST(req);
|
|
52
|
+
expect(res.status).toBe(400);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("DELETE /api/tenant", () => {
|
|
57
|
+
it("clears the tenant cookie", async () => {
|
|
58
|
+
const res = await DELETE();
|
|
59
|
+
|
|
60
|
+
expect(res.status).toBe(200);
|
|
61
|
+
const setCookie = res.headers.get("set-cookie");
|
|
62
|
+
expect(setCookie).toContain("platform_tenant_id=");
|
|
63
|
+
expect(setCookie).toContain("Max-Age=0");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -118,6 +118,41 @@ describe("middleware", () => {
|
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Open redirect prevention — callbackUrl sanitization
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
describe("callbackUrl sanitization (open redirect prevention)", () => {
|
|
125
|
+
it("sanitizes percent-encoded protocol-relative pathname in callbackUrl", async () => {
|
|
126
|
+
// /%2F%2Fevil-redirect decodes to //evil-redirect — must be rejected.
|
|
127
|
+
// Path has no dot so it reaches the session check (not static-file bypass).
|
|
128
|
+
const req = buildRequest("/%2F%2Fevil-redirect");
|
|
129
|
+
const res = await middleware(req);
|
|
130
|
+
expect(isRedirect(res)).toBe(true);
|
|
131
|
+
const loc = redirectPath(res);
|
|
132
|
+
expect(loc).toContain("/login");
|
|
133
|
+
// callbackUrl must be "/" (sanitized), not the malicious path
|
|
134
|
+
expect(loc).toContain("callbackUrl=%2F");
|
|
135
|
+
expect(loc).not.toContain("evil-redirect");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("preserves legitimate callbackUrl for normal paths", async () => {
|
|
139
|
+
const req = buildRequest("/settings/profile");
|
|
140
|
+
const res = await middleware(req);
|
|
141
|
+
expect(isRedirect(res)).toBe(true);
|
|
142
|
+
const loc = redirectPath(res);
|
|
143
|
+
expect(loc).toContain("callbackUrl=%2Fsettings%2Fprofile");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("sanitizes mixed-case percent-encoded bypass attempts", async () => {
|
|
147
|
+
// /%2f%2Fevil-redirect also decodes to //evil-redirect — path has no dot.
|
|
148
|
+
const req = buildRequest("/%2f%2Fevil-redirect");
|
|
149
|
+
const res = await middleware(req);
|
|
150
|
+
expect(isRedirect(res)).toBe(true);
|
|
151
|
+
const loc = redirectPath(res);
|
|
152
|
+
expect(loc).not.toContain("evil-redirect");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
121
156
|
// ---------------------------------------------------------------------------
|
|
122
157
|
// Authenticated pass-through
|
|
123
158
|
// ---------------------------------------------------------------------------
|
|
@@ -678,6 +713,57 @@ describe("CSP style-src directive", () => {
|
|
|
678
713
|
});
|
|
679
714
|
});
|
|
680
715
|
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
// Tenant cookie forwarding (WOP-2114)
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
describe("tenant cookie forwarding", () => {
|
|
720
|
+
it("forwards tenant cookie as x-tenant-id request header for authenticated users", async () => {
|
|
721
|
+
const req = buildRequest("/marketplace", {
|
|
722
|
+
cookies: {
|
|
723
|
+
"better-auth.session_token": "valid-token",
|
|
724
|
+
platform_tenant_id: "org-456",
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
const res = await middleware(req);
|
|
728
|
+
expect(isPassThrough(res)).toBe(true);
|
|
729
|
+
expect(res.headers.get("x-middleware-request-x-tenant-id")).toBe("org-456");
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("does not set x-tenant-id header when no tenant cookie present", async () => {
|
|
733
|
+
const req = buildRequest("/marketplace", {
|
|
734
|
+
cookies: { "better-auth.session_token": "valid-token" },
|
|
735
|
+
});
|
|
736
|
+
const res = await middleware(req);
|
|
737
|
+
expect(isPassThrough(res)).toBe(true);
|
|
738
|
+
expect(res.headers.get("x-middleware-request-x-tenant-id")).toBeNull();
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("strips client-supplied x-tenant-id header when no tenant cookie is set (spoofing prevention)", async () => {
|
|
742
|
+
const req = buildRequest("/marketplace", {
|
|
743
|
+
cookies: { "better-auth.session_token": "valid-token" },
|
|
744
|
+
headers: { "x-tenant-id": "attacker-tenant" },
|
|
745
|
+
});
|
|
746
|
+
const res = await middleware(req);
|
|
747
|
+
expect(isPassThrough(res)).toBe(true);
|
|
748
|
+
// The attacker's header must not be forwarded to server components
|
|
749
|
+
expect(res.headers.get("x-middleware-request-x-tenant-id")).toBeNull();
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it("replaces client-supplied x-tenant-id with the trusted cookie value when cookie is set", async () => {
|
|
753
|
+
const req = buildRequest("/marketplace", {
|
|
754
|
+
cookies: {
|
|
755
|
+
"better-auth.session_token": "valid-token",
|
|
756
|
+
platform_tenant_id: "real-tenant",
|
|
757
|
+
},
|
|
758
|
+
headers: { "x-tenant-id": "attacker-tenant" },
|
|
759
|
+
});
|
|
760
|
+
const res = await middleware(req);
|
|
761
|
+
expect(isPassThrough(res)).toBe(true);
|
|
762
|
+
// Must be the cookie value, not the client-supplied value
|
|
763
|
+
expect(res.headers.get("x-middleware-request-x-tenant-id")).toBe("real-tenant");
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
681
767
|
// ---------------------------------------------------------------------------
|
|
682
768
|
// config export
|
|
683
769
|
// ---------------------------------------------------------------------------
|
|
@@ -43,24 +43,57 @@ vi.mock("framer-motion", () => {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
// vi.hoisted runs before module imports so TEST_PLUGINS and mocks are available in vi.mock factories
|
|
46
|
-
const {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
46
|
+
const {
|
|
47
|
+
TEST_PLUGINS,
|
|
48
|
+
ALL_PLUGINS,
|
|
49
|
+
mockInstallPlugin,
|
|
50
|
+
mockListBots,
|
|
51
|
+
mockListInstalledPlugins,
|
|
52
|
+
injectPathlessZodError,
|
|
53
|
+
} = vi.hoisted(() => {
|
|
54
|
+
const mockInstallPlugin = vi.fn();
|
|
55
|
+
const mockListBots = vi
|
|
56
|
+
.fn()
|
|
57
|
+
.mockResolvedValue([{ id: "bot-001", name: "My Bot", state: "running" }]);
|
|
58
|
+
const mockListInstalledPlugins = vi.fn().mockResolvedValue([]);
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
60
|
+
const { INSTALL_FLOW_TEST_PLUGINS, MARKETPLACE_TEST_PLUGINS } =
|
|
61
|
+
require("./fixtures/mock-manifests-data") as typeof import("./fixtures/mock-manifests");
|
|
62
|
+
// Flag to conditionally inject a path-less Zod error in the mocked z.object
|
|
63
|
+
const injectPathlessZodError = { value: false };
|
|
64
|
+
return {
|
|
65
|
+
TEST_PLUGINS: INSTALL_FLOW_TEST_PLUGINS,
|
|
66
|
+
ALL_PLUGINS: MARKETPLACE_TEST_PLUGINS,
|
|
67
|
+
mockInstallPlugin,
|
|
68
|
+
mockListBots,
|
|
69
|
+
mockListInstalledPlugins,
|
|
70
|
+
injectPathlessZodError,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Mock zod with a conditional wrapper: when injectPathlessZodError.value is true,
|
|
75
|
+
// z.object() adds a superRefine that produces a path-less ZodIssue.
|
|
76
|
+
// Only inject on schemas with fields (Object.keys(shape).length > 0) so empty
|
|
77
|
+
// setup steps (no fields) pass through without triggering the fake error.
|
|
78
|
+
vi.mock("zod", async () => {
|
|
79
|
+
const realZod = await vi.importActual<typeof import("zod")>("zod");
|
|
80
|
+
const originalObject = realZod.z.object.bind(realZod.z);
|
|
81
|
+
const wrappedObject = (
|
|
82
|
+
shape?: Record<string, unknown>,
|
|
83
|
+
params?: string | Record<string, unknown>,
|
|
84
|
+
) => {
|
|
85
|
+
const schema = originalObject(shape, params as undefined);
|
|
86
|
+
if (!injectPathlessZodError.value || !shape || Object.keys(shape).length === 0) return schema;
|
|
87
|
+
return schema.superRefine((_val, ctx) => {
|
|
88
|
+
ctx.addIssue({
|
|
89
|
+
code: realZod.z.ZodIssueCode.custom,
|
|
90
|
+
message: "Cross-field validation failed",
|
|
91
|
+
path: [],
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
return { ...realZod, z: { ...realZod.z, object: wrappedObject } };
|
|
96
|
+
});
|
|
64
97
|
|
|
65
98
|
const mockPush = vi.fn();
|
|
66
99
|
const mockParams: { plugin?: string } = {};
|
|
@@ -441,6 +474,46 @@ describe("Plugin Toggle (Enable/Disable)", () => {
|
|
|
441
474
|
});
|
|
442
475
|
expect(screen.getByText("Create a Bot")).toBeInTheDocument();
|
|
443
476
|
});
|
|
477
|
+
|
|
478
|
+
it("validateFields surfaces path-less Zod errors visibly instead of swallowing them", async () => {
|
|
479
|
+
// Enable the path-less error injection on z.object (mocked at file level)
|
|
480
|
+
injectPathlessZodError.value = true;
|
|
481
|
+
|
|
482
|
+
const user = userEvent.setup();
|
|
483
|
+
const { InstallWizard } = await import("../components/marketplace/install-wizard");
|
|
484
|
+
|
|
485
|
+
// Use the Discord plugin which has a setup step with fields
|
|
486
|
+
const discordPlugin = ALL_PLUGINS.find(
|
|
487
|
+
(p: Record<string, unknown>) => p.id === "discord",
|
|
488
|
+
) as unknown as PluginManifest;
|
|
489
|
+
|
|
490
|
+
render(<InstallWizard plugin={discordPlugin} onComplete={vi.fn()} onCancel={vi.fn()} />);
|
|
491
|
+
|
|
492
|
+
// Select bot and advance to setup
|
|
493
|
+
const botButton = await screen.findByText("My Bot");
|
|
494
|
+
await user.click(botButton);
|
|
495
|
+
await user.click(screen.getByText("Continue"));
|
|
496
|
+
|
|
497
|
+
// Skip the first setup step (no fields — "Create a Discord Bot")
|
|
498
|
+
await user.click(screen.getByText("Continue"));
|
|
499
|
+
|
|
500
|
+
// Now on "Enter Bot Token" step — fill in a valid token so the only
|
|
501
|
+
// error left is the path-less superRefine issue we injected.
|
|
502
|
+
const tokenInput = screen.getByPlaceholderText("Paste your Discord bot token");
|
|
503
|
+
await user.type(tokenInput, "valid-token-123");
|
|
504
|
+
await user.click(screen.getByText("Continue"));
|
|
505
|
+
|
|
506
|
+
// The path-less error should surface as a _form-level banner, not be silently dropped
|
|
507
|
+
await waitFor(() => {
|
|
508
|
+
expect(screen.getByText("Cross-field validation failed")).toBeInTheDocument();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// After typing in any field the _form banner should clear immediately
|
|
512
|
+
await user.type(tokenInput, "x");
|
|
513
|
+
expect(screen.queryByText("Cross-field validation failed")).not.toBeInTheDocument();
|
|
514
|
+
|
|
515
|
+
injectPathlessZodError.value = false;
|
|
516
|
+
});
|
|
444
517
|
});
|
|
445
518
|
|
|
446
519
|
describe("Install Wizard Navigation", () => {
|
|
@@ -18,6 +18,33 @@ describe("sanitizeRedirectUrl", () => {
|
|
|
18
18
|
expect(sanitizeRedirectUrl("//evil.com/path")).toBe("/");
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
+
it("rejects percent-encoded protocol-relative URLs (bypass attempt)", () => {
|
|
22
|
+
expect(sanitizeRedirectUrl("/%2F%2Fevil.com")).toBe("/");
|
|
23
|
+
expect(sanitizeRedirectUrl("/%2f%2fevil.com")).toBe("/");
|
|
24
|
+
expect(sanitizeRedirectUrl("/%2F/evil.com")).toBe("/");
|
|
25
|
+
expect(sanitizeRedirectUrl("/%252F%252Fevil.com")).toBe("/");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("rejects backslash-relative URLs", () => {
|
|
29
|
+
expect(sanitizeRedirectUrl("/\\evil.com")).toBe("/");
|
|
30
|
+
expect(sanitizeRedirectUrl("/%5Cevil.com")).toBe("/");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns / when decode loop exceeds max iterations", () => {
|
|
34
|
+
// Build a string with deeply nested encoding that requires >5 rounds to decode
|
|
35
|
+
let tail = "//evil.com";
|
|
36
|
+
for (let i = 0; i < 10; i++) {
|
|
37
|
+
tail = encodeURIComponent(tail);
|
|
38
|
+
}
|
|
39
|
+
const deeplyEncoded = `/${tail}`;
|
|
40
|
+
expect(sanitizeRedirectUrl(deeplyEncoded)).toBe("/");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("allows valid paths with percent-encoded characters (no false rejection)", () => {
|
|
44
|
+
// /100%25 decodes to /100% — a literal % sign, not a bypass — must not be rejected
|
|
45
|
+
expect(sanitizeRedirectUrl("/100%25")).toBe("/100%25");
|
|
46
|
+
});
|
|
47
|
+
|
|
21
48
|
it("falls back to / for null and undefined", () => {
|
|
22
49
|
expect(sanitizeRedirectUrl(null)).toBe("/");
|
|
23
50
|
expect(sanitizeRedirectUrl(undefined)).toBe("/");
|
|
@@ -16,36 +16,32 @@ vi.mock("@/lib/trpc", () => ({
|
|
|
16
16
|
},
|
|
17
17
|
}));
|
|
18
18
|
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
import {
|
|
20
|
+
getActiveTenantId,
|
|
21
|
+
setServerTenantId,
|
|
22
|
+
TenantProvider,
|
|
23
|
+
useTenant,
|
|
24
|
+
} from "@/lib/tenant-context";
|
|
25
|
+
|
|
26
|
+
function createWrapper(initialTenantId?: string) {
|
|
22
27
|
const queryClient = new QueryClient({
|
|
23
28
|
defaultOptions: { queries: { retry: false } },
|
|
24
29
|
});
|
|
25
30
|
return ({ children }: { children: React.ReactNode }) => (
|
|
26
31
|
<QueryClientProvider client={queryClient}>
|
|
27
|
-
<TenantProvider>{children}</TenantProvider>
|
|
32
|
+
<TenantProvider initialTenantId={initialTenantId}>{children}</TenantProvider>
|
|
28
33
|
</QueryClientProvider>
|
|
29
34
|
);
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
function clearTenantCookie() {
|
|
33
|
-
// biome-ignore lint/suspicious/noDocumentCookie: test helper — mirrors production writeTenantCookie pattern
|
|
34
|
-
document.cookie = "platform_tenant_id=; path=/; max-age=0";
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function setTenantCookie(tenantId: string) {
|
|
38
|
-
// biome-ignore lint/suspicious/noDocumentCookie: test helper — mirrors production writeTenantCookie pattern
|
|
39
|
-
document.cookie = `platform_tenant_id=${encodeURIComponent(tenantId)}; path=/`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
37
|
describe("useTenant", () => {
|
|
43
38
|
beforeEach(() => {
|
|
44
|
-
|
|
39
|
+
setServerTenantId("");
|
|
45
40
|
mockQuery.mockResolvedValue([]);
|
|
41
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }))));
|
|
46
42
|
});
|
|
47
43
|
|
|
48
|
-
it("defaults to the user's personal account", async () => {
|
|
44
|
+
it("defaults to the user's personal account when no initial tenant", async () => {
|
|
49
45
|
const { result } = renderHook(() => useTenant(), {
|
|
50
46
|
wrapper: createWrapper(),
|
|
51
47
|
});
|
|
@@ -55,32 +51,26 @@ describe("useTenant", () => {
|
|
|
55
51
|
});
|
|
56
52
|
|
|
57
53
|
expect(result.current.activeTenantId).toBe("user-1");
|
|
58
|
-
expect(result.current.tenants).toEqual([
|
|
59
|
-
{ id: "user-1", name: "Test User", type: "personal", image: null },
|
|
60
|
-
]);
|
|
61
54
|
});
|
|
62
55
|
|
|
63
|
-
it("
|
|
56
|
+
it("uses initialTenantId from server when provided", async () => {
|
|
64
57
|
mockQuery.mockResolvedValue([{ id: "org-1", name: "My Team", image: null }]);
|
|
65
58
|
|
|
66
59
|
const { result } = renderHook(() => useTenant(), {
|
|
67
|
-
wrapper: createWrapper(),
|
|
60
|
+
wrapper: createWrapper("org-1"),
|
|
68
61
|
});
|
|
69
62
|
|
|
70
63
|
await vi.waitFor(() => {
|
|
71
64
|
expect(result.current.isLoading).toBe(false);
|
|
72
65
|
});
|
|
73
66
|
|
|
74
|
-
expect(result.current.
|
|
75
|
-
expect(result.current.tenants[1]).toEqual({
|
|
76
|
-
id: "org-1",
|
|
77
|
-
name: "My Team",
|
|
78
|
-
type: "org",
|
|
79
|
-
image: null,
|
|
80
|
-
});
|
|
67
|
+
expect(result.current.activeTenantId).toBe("org-1");
|
|
81
68
|
});
|
|
82
69
|
|
|
83
|
-
it("
|
|
70
|
+
it("calls /api/tenant on switchTenant instead of writing document.cookie", async () => {
|
|
71
|
+
const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true })));
|
|
72
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
73
|
+
|
|
84
74
|
mockQuery.mockResolvedValue([{ id: "org-1", name: "My Team", image: null }]);
|
|
85
75
|
|
|
86
76
|
const { result } = renderHook(() => useTenant(), {
|
|
@@ -96,15 +86,20 @@ describe("useTenant", () => {
|
|
|
96
86
|
});
|
|
97
87
|
|
|
98
88
|
expect(result.current.activeTenantId).toBe("org-1");
|
|
99
|
-
expect(
|
|
89
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
90
|
+
"/api/tenant",
|
|
91
|
+
expect.objectContaining({
|
|
92
|
+
method: "POST",
|
|
93
|
+
body: JSON.stringify({ tenantId: "org-1" }),
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
100
96
|
});
|
|
101
97
|
|
|
102
98
|
it("falls back to personal if stored tenant is not in list", async () => {
|
|
103
|
-
setTenantCookie("org-deleted");
|
|
104
99
|
mockQuery.mockResolvedValue([]);
|
|
105
100
|
|
|
106
101
|
const { result } = renderHook(() => useTenant(), {
|
|
107
|
-
wrapper: createWrapper(),
|
|
102
|
+
wrapper: createWrapper("org-deleted"),
|
|
108
103
|
});
|
|
109
104
|
|
|
110
105
|
await vi.waitFor(() => {
|
|
@@ -132,15 +127,15 @@ describe("useTenant", () => {
|
|
|
132
127
|
|
|
133
128
|
describe("getActiveTenantId", () => {
|
|
134
129
|
beforeEach(() => {
|
|
135
|
-
|
|
130
|
+
setServerTenantId("");
|
|
136
131
|
});
|
|
137
132
|
|
|
138
|
-
it("returns
|
|
139
|
-
|
|
133
|
+
it("returns the server-injected tenant ID", () => {
|
|
134
|
+
setServerTenantId("org-1");
|
|
140
135
|
expect(getActiveTenantId()).toBe("org-1");
|
|
141
136
|
});
|
|
142
137
|
|
|
143
|
-
it("returns empty string when nothing
|
|
138
|
+
it("returns empty string when nothing set", () => {
|
|
144
139
|
expect(getActiveTenantId()).toBe("");
|
|
145
140
|
});
|
|
146
141
|
});
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
saveOnboardingState,
|
|
24
24
|
} from "@/lib/onboarding-store";
|
|
25
25
|
|
|
26
|
+
const homePath = () => getBrandConfig().homePath;
|
|
26
27
|
const MAX_STEP = 2;
|
|
27
28
|
|
|
28
29
|
export default function OnboardingPage() {
|
|
@@ -34,7 +35,7 @@ export default function OnboardingPage() {
|
|
|
34
35
|
// biome-ignore lint/correctness/useExhaustiveDependencies: router.push is stable; using [router] causes infinite re-renders
|
|
35
36
|
useEffect(() => {
|
|
36
37
|
if (isOnboardingComplete()) {
|
|
37
|
-
router.push(
|
|
38
|
+
router.push(homePath());
|
|
38
39
|
return;
|
|
39
40
|
}
|
|
40
41
|
const saved = loadOnboardingState();
|
|
@@ -85,12 +86,12 @@ export default function OnboardingPage() {
|
|
|
85
86
|
plugins: preset?.plugins ?? state.plugins,
|
|
86
87
|
});
|
|
87
88
|
markOnboardingComplete();
|
|
88
|
-
router.push(
|
|
89
|
+
router.push(homePath());
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
function handleSkip() {
|
|
92
93
|
markOnboardingComplete();
|
|
93
|
-
router.push(
|
|
94
|
+
router.push(homePath());
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
const selectedPresetData = presets.find((p) => p.id === selectedPreset);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { getBrandConfig } from "@/lib/brand-config";
|
|
3
|
+
|
|
4
|
+
/** Tenant IDs are UUIDs or alphanumeric identifiers — reject anything suspicious. */
|
|
5
|
+
const TENANT_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
6
|
+
const MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
|
7
|
+
|
|
8
|
+
function cookieName(): string {
|
|
9
|
+
return getBrandConfig().tenantCookieName;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function POST(request: Request): Promise<NextResponse> {
|
|
13
|
+
let body: unknown;
|
|
14
|
+
try {
|
|
15
|
+
body = await request.json();
|
|
16
|
+
} catch {
|
|
17
|
+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const tenantId =
|
|
21
|
+
typeof body === "object" && body !== null && "tenantId" in body
|
|
22
|
+
? (body as Record<string, unknown>).tenantId
|
|
23
|
+
: undefined;
|
|
24
|
+
|
|
25
|
+
if (typeof tenantId !== "string" || !tenantId || !TENANT_ID_PATTERN.test(tenantId)) {
|
|
26
|
+
return NextResponse.json({ error: "Invalid tenantId" }, { status: 400 });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const response = NextResponse.json({ ok: true });
|
|
30
|
+
response.cookies.set(cookieName(), tenantId, {
|
|
31
|
+
httpOnly: true,
|
|
32
|
+
secure: true,
|
|
33
|
+
sameSite: "lax",
|
|
34
|
+
path: "/",
|
|
35
|
+
maxAge: MAX_AGE_SECONDS,
|
|
36
|
+
});
|
|
37
|
+
return response;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function DELETE(): Promise<NextResponse> {
|
|
41
|
+
const response = NextResponse.json({ ok: true });
|
|
42
|
+
response.cookies.set(cookieName(), "", {
|
|
43
|
+
httpOnly: true,
|
|
44
|
+
secure: true,
|
|
45
|
+
sameSite: "lax",
|
|
46
|
+
path: "/",
|
|
47
|
+
maxAge: 0,
|
|
48
|
+
});
|
|
49
|
+
return response;
|
|
50
|
+
}
|
|
@@ -67,11 +67,9 @@ import {
|
|
|
67
67
|
renameInstance,
|
|
68
68
|
restoreSnapshot,
|
|
69
69
|
toggleInstancePlugin,
|
|
70
|
-
updateInstanceBudget,
|
|
71
70
|
updateInstanceConfig,
|
|
72
71
|
updateInstanceSecrets,
|
|
73
72
|
} from "@/lib/api";
|
|
74
|
-
import { getBrandConfig } from "@/lib/brand-config";
|
|
75
73
|
import { toUserMessage } from "@/lib/errors";
|
|
76
74
|
import { cn } from "@/lib/utils";
|
|
77
75
|
|
|
@@ -118,10 +116,6 @@ export function InstanceDetailClient({ instanceId }: { instanceId: string }) {
|
|
|
118
116
|
const [renaming, setRenaming] = useState(false);
|
|
119
117
|
const [renameValue, setRenameValue] = useState("");
|
|
120
118
|
const [renameSaving, setRenameSaving] = useState(false);
|
|
121
|
-
const [budgetCents, setBudgetCents] = useState<number>(0);
|
|
122
|
-
const [budgetSaving, setBudgetSaving] = useState(false);
|
|
123
|
-
const [budgetError, setBudgetError] = useState<string | null>(null);
|
|
124
|
-
|
|
125
119
|
useEffect(() => {
|
|
126
120
|
if (!configText.trim()) return;
|
|
127
121
|
try {
|
|
@@ -193,7 +187,6 @@ export function InstanceDetailClient({ instanceId }: { instanceId: string }) {
|
|
|
193
187
|
const data = await getInstance(instanceId);
|
|
194
188
|
setInstance(data);
|
|
195
189
|
setConfigText(JSON.stringify(data.config, null, 2));
|
|
196
|
-
if (data.budgetCents !== undefined) setBudgetCents(data.budgetCents);
|
|
197
190
|
} catch (err) {
|
|
198
191
|
setError(toUserMessage(err, "Failed to load instance"));
|
|
199
192
|
} finally {
|
|
@@ -306,19 +299,6 @@ export function InstanceDetailClient({ instanceId }: { instanceId: string }) {
|
|
|
306
299
|
}
|
|
307
300
|
}
|
|
308
301
|
|
|
309
|
-
async function handleSaveBudget() {
|
|
310
|
-
setBudgetSaving(true);
|
|
311
|
-
setBudgetError(null);
|
|
312
|
-
try {
|
|
313
|
-
await updateInstanceBudget(instanceId, budgetCents);
|
|
314
|
-
await load();
|
|
315
|
-
} catch (err) {
|
|
316
|
-
setBudgetError(toUserMessage(err, "Failed to update budget"));
|
|
317
|
-
} finally {
|
|
318
|
-
setBudgetSaving(false);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
302
|
async function handleCreateSnapshot() {
|
|
323
303
|
setSnapshotsError(null);
|
|
324
304
|
setCreating(true);
|
|
@@ -496,16 +476,6 @@ export function InstanceDetailClient({ instanceId }: { instanceId: string }) {
|
|
|
496
476
|
</Badge>
|
|
497
477
|
)}
|
|
498
478
|
<span>{instance.provider}</span>
|
|
499
|
-
{instance.subdomain && (
|
|
500
|
-
<a
|
|
501
|
-
href={`https://${instance.subdomain}.${getBrandConfig().domain}`}
|
|
502
|
-
target="_blank"
|
|
503
|
-
rel="noopener noreferrer"
|
|
504
|
-
className="font-mono text-xs text-terminal hover:underline"
|
|
505
|
-
>
|
|
506
|
-
{instance.subdomain}.{getBrandConfig().domain}
|
|
507
|
-
</a>
|
|
508
|
-
)}
|
|
509
479
|
</div>
|
|
510
480
|
</div>
|
|
511
481
|
<div className="flex gap-2">
|
|
@@ -599,48 +569,6 @@ export function InstanceDetailClient({ instanceId }: { instanceId: string }) {
|
|
|
599
569
|
<MetricCard title="Active Sessions" value={String(instance.sessions.length)} />
|
|
600
570
|
<MetricCard title="Created" value={new Date(instance.createdAt).toLocaleDateString()} />
|
|
601
571
|
</div>
|
|
602
|
-
|
|
603
|
-
{/* Budget management — shown when backend provides budget data */}
|
|
604
|
-
{instance.budgetCents !== undefined && (
|
|
605
|
-
<Card className="mt-4">
|
|
606
|
-
<CardHeader className="pb-3">
|
|
607
|
-
<CardTitle className="text-sm font-medium">Spending Budget</CardTitle>
|
|
608
|
-
</CardHeader>
|
|
609
|
-
<CardContent className="space-y-4">
|
|
610
|
-
<div className="flex items-center justify-between">
|
|
611
|
-
<span className="text-2xl font-bold font-mono">
|
|
612
|
-
${(budgetCents / 100).toFixed(2)}
|
|
613
|
-
</span>
|
|
614
|
-
<span className="text-xs text-muted-foreground">per month</span>
|
|
615
|
-
</div>
|
|
616
|
-
<input
|
|
617
|
-
type="range"
|
|
618
|
-
min={0}
|
|
619
|
-
max={10000}
|
|
620
|
-
step={100}
|
|
621
|
-
value={budgetCents}
|
|
622
|
-
onChange={(e) => setBudgetCents(Number(e.target.value))}
|
|
623
|
-
className="w-full accent-terminal"
|
|
624
|
-
aria-label="Budget slider"
|
|
625
|
-
/>
|
|
626
|
-
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
627
|
-
<span>$0</span>
|
|
628
|
-
<span>$100</span>
|
|
629
|
-
</div>
|
|
630
|
-
{budgetError && <p className="text-sm text-destructive">{budgetError}</p>}
|
|
631
|
-
<div className="flex justify-end">
|
|
632
|
-
<Button
|
|
633
|
-
size="sm"
|
|
634
|
-
variant="terminal"
|
|
635
|
-
onClick={handleSaveBudget}
|
|
636
|
-
disabled={budgetSaving || budgetCents === instance.budgetCents}
|
|
637
|
-
>
|
|
638
|
-
{budgetSaving ? "Saving..." : "Update Budget"}
|
|
639
|
-
</Button>
|
|
640
|
-
</div>
|
|
641
|
-
</CardContent>
|
|
642
|
-
</Card>
|
|
643
|
-
)}
|
|
644
572
|
</TabsContent>
|
|
645
573
|
|
|
646
574
|
{/* Health Tab */}
|
package/src/app/layout.tsx
CHANGED
|
@@ -57,7 +57,9 @@ export default async function RootLayout({
|
|
|
57
57
|
}: Readonly<{
|
|
58
58
|
children: React.ReactNode;
|
|
59
59
|
}>) {
|
|
60
|
-
const
|
|
60
|
+
const headersList = await headers();
|
|
61
|
+
const nonce = headersList.get("x-nonce") ?? undefined;
|
|
62
|
+
const initialTenantId = headersList.get("x-tenant-id") ?? "";
|
|
61
63
|
|
|
62
64
|
return (
|
|
63
65
|
<html lang="en" suppressHydrationWarning>
|
|
@@ -71,7 +73,7 @@ export default async function RootLayout({
|
|
|
71
73
|
disableTransitionOnChange
|
|
72
74
|
nonce={nonce}
|
|
73
75
|
>
|
|
74
|
-
<TRPCProvider>
|
|
76
|
+
<TRPCProvider initialTenantId={initialTenantId}>
|
|
75
77
|
{children}
|
|
76
78
|
<Toaster theme="dark" richColors />
|
|
77
79
|
</TRPCProvider>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useRouter } from "next/navigation";
|
|
4
4
|
import { useEffect } from "react";
|
|
5
5
|
import { useSession } from "@/lib/auth-client";
|
|
6
|
+
import { getBrandConfig } from "@/lib/brand-config";
|
|
6
7
|
|
|
7
8
|
export function AuthRedirect() {
|
|
8
9
|
const { data: session, isPending } = useSession();
|
|
@@ -10,7 +11,7 @@ export function AuthRedirect() {
|
|
|
10
11
|
|
|
11
12
|
useEffect(() => {
|
|
12
13
|
if (!isPending && session) {
|
|
13
|
-
router.replace(
|
|
14
|
+
router.replace(getBrandConfig().homePath);
|
|
14
15
|
}
|
|
15
16
|
}, [isPending, session, router]);
|
|
16
17
|
|
|
@@ -25,7 +25,7 @@ const panelVariants = {
|
|
|
25
25
|
opacity: 1,
|
|
26
26
|
y: 0,
|
|
27
27
|
scale: 1,
|
|
28
|
-
transition: { type: "spring", damping: 25, stiffness: 300 },
|
|
28
|
+
transition: { type: "spring" as const, damping: 25, stiffness: 300 },
|
|
29
29
|
},
|
|
30
30
|
exit: { opacity: 0, y: 20, scale: 0.95, transition: { duration: 0.15 } },
|
|
31
31
|
};
|
|
@@ -174,6 +174,7 @@ export function InstallWizard({ plugin, onComplete, onCancel }: InstallWizardPro
|
|
|
174
174
|
setErrors((prev) => {
|
|
175
175
|
const next = { ...prev };
|
|
176
176
|
delete next[key];
|
|
177
|
+
delete next._form;
|
|
177
178
|
return next;
|
|
178
179
|
});
|
|
179
180
|
}, []);
|
|
@@ -211,8 +212,16 @@ export function InstallWizard({ plugin, onComplete, onCancel }: InstallWizardPro
|
|
|
211
212
|
}
|
|
212
213
|
const stepErrors: Record<string, string> = {};
|
|
213
214
|
for (const issue of result.error.issues) {
|
|
214
|
-
|
|
215
|
-
|
|
215
|
+
if (issue.path.length > 0) {
|
|
216
|
+
const key = issue.path[0] as string;
|
|
217
|
+
stepErrors[key] = issue.message;
|
|
218
|
+
} else {
|
|
219
|
+
// Object-level errors (refinements, superRefine, union discriminants) have empty path.
|
|
220
|
+
// Collect under _form so they render in a general error area.
|
|
221
|
+
stepErrors._form = stepErrors._form
|
|
222
|
+
? `${stepErrors._form}. ${issue.message}`
|
|
223
|
+
: issue.message;
|
|
224
|
+
}
|
|
216
225
|
}
|
|
217
226
|
setErrors(stepErrors);
|
|
218
227
|
return false;
|
|
@@ -608,6 +617,7 @@ function SetupStepForm({
|
|
|
608
617
|
|
|
609
618
|
return (
|
|
610
619
|
<div className="space-y-4">
|
|
620
|
+
{errors._form && <p className="text-xs text-destructive">{errors._form}</p>}
|
|
611
621
|
{step.instruction && <p className="text-sm text-muted-foreground">{step.instruction}</p>}
|
|
612
622
|
{step.externalUrl && (
|
|
613
623
|
<a
|
|
@@ -17,25 +17,13 @@ import {
|
|
|
17
17
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
18
18
|
import { getCreditBalance } from "@/lib/api";
|
|
19
19
|
import { signOut, useSession } from "@/lib/auth-client";
|
|
20
|
-
import { productName } from "@/lib/brand-config";
|
|
20
|
+
import { getBrandConfig, productName } from "@/lib/brand-config";
|
|
21
21
|
import { formatCreditStandard } from "@/lib/format-credit";
|
|
22
22
|
import { cn } from "@/lib/utils";
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{ label: "Marketplace", href: "/marketplace" },
|
|
28
|
-
{ label: "Channels", href: "/channels" },
|
|
29
|
-
{ label: "Plugins", href: "/plugins" },
|
|
30
|
-
{ label: "Instances", href: "/instances" },
|
|
31
|
-
{ label: "Changesets", href: "/changesets" },
|
|
32
|
-
{ label: "Network", href: "/dashboard/network" },
|
|
33
|
-
{ label: "Fleet Health", href: "/fleet/health" },
|
|
34
|
-
{ label: "Credits", href: "/billing/credits" },
|
|
35
|
-
{ label: "Billing", href: "/billing/plans" },
|
|
36
|
-
{ label: "Settings", href: "/settings/profile" },
|
|
37
|
-
{ label: "Admin", href: "/admin/tenants" },
|
|
38
|
-
];
|
|
24
|
+
function getNavItems() {
|
|
25
|
+
return getBrandConfig().navItems;
|
|
26
|
+
}
|
|
39
27
|
|
|
40
28
|
function isNavActive(href: string, pathname: string): boolean {
|
|
41
29
|
if (href === "/dashboard") return pathname === "/dashboard";
|
|
@@ -104,7 +92,7 @@ export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
|
|
|
104
92
|
</div>
|
|
105
93
|
<AccountSwitcher />
|
|
106
94
|
<nav className="flex-1 space-y-1 px-3 py-4">
|
|
107
|
-
{
|
|
95
|
+
{getNavItems()
|
|
108
96
|
.filter(
|
|
109
97
|
(item) =>
|
|
110
98
|
item.href !== "/admin/tenants" ||
|
package/src/lib/api.ts
CHANGED
|
@@ -61,8 +61,6 @@ export interface Instance {
|
|
|
61
61
|
plugins: PluginInfo[];
|
|
62
62
|
uptime: number | null;
|
|
63
63
|
createdAt: string;
|
|
64
|
-
subdomain?: string;
|
|
65
|
-
nodeId?: string;
|
|
66
64
|
}
|
|
67
65
|
|
|
68
66
|
export interface PluginInfo {
|
|
@@ -95,8 +93,6 @@ export interface InstanceDetail extends Instance {
|
|
|
95
93
|
memoryMb: number;
|
|
96
94
|
cpuPercent: number;
|
|
97
95
|
};
|
|
98
|
-
budgetCents?: number;
|
|
99
|
-
perAgentCents?: number;
|
|
100
96
|
}
|
|
101
97
|
|
|
102
98
|
// --- API client ---
|
|
@@ -323,7 +319,6 @@ export async function getInstance(id: string): Promise<InstanceDetail> {
|
|
|
323
319
|
id,
|
|
324
320
|
})) as BotStatusResponse;
|
|
325
321
|
const uptimeMs = bot.uptime ? new Date(bot.uptime).getTime() : NaN;
|
|
326
|
-
const extra = bot as Record<string, unknown>;
|
|
327
322
|
return {
|
|
328
323
|
id: bot.id,
|
|
329
324
|
name: bot.name,
|
|
@@ -333,8 +328,6 @@ export async function getInstance(id: string): Promise<InstanceDetail> {
|
|
|
333
328
|
plugins: parsePluginsFromEnv(bot.env as Record<string, string> | undefined),
|
|
334
329
|
uptime: Number.isNaN(uptimeMs) ? null : Math.floor((Date.now() - uptimeMs) / 1000),
|
|
335
330
|
createdAt: (bot.createdAt as string | undefined) ?? new Date().toISOString(),
|
|
336
|
-
subdomain: (extra.subdomain as string | undefined) ?? undefined,
|
|
337
|
-
nodeId: (extra.nodeId as string | undefined) ?? undefined,
|
|
338
331
|
config: bot.env ?? {},
|
|
339
332
|
channelDetails: [],
|
|
340
333
|
sessions: [],
|
|
@@ -342,8 +335,6 @@ export async function getInstance(id: string): Promise<InstanceDetail> {
|
|
|
342
335
|
memoryMb: bot.stats?.memoryUsageMb ?? 0,
|
|
343
336
|
cpuPercent: bot.stats?.cpuPercent ?? 0,
|
|
344
337
|
},
|
|
345
|
-
budgetCents: typeof extra.budgetCents === "number" ? extra.budgetCents : undefined,
|
|
346
|
-
perAgentCents: typeof extra.perAgentCents === "number" ? extra.perAgentCents : undefined,
|
|
347
338
|
};
|
|
348
339
|
}
|
|
349
340
|
|
|
@@ -471,18 +462,6 @@ export async function updateInstanceConfig(id: string, env: Record<string, strin
|
|
|
471
462
|
});
|
|
472
463
|
}
|
|
473
464
|
|
|
474
|
-
/** PUT /api/provision/budget — Update instance spending budget. */
|
|
475
|
-
export async function updateInstanceBudget(
|
|
476
|
-
id: string,
|
|
477
|
-
budgetCents: number,
|
|
478
|
-
perAgentCents?: number,
|
|
479
|
-
): Promise<void> {
|
|
480
|
-
await apiFetch("/provision/budget", {
|
|
481
|
-
method: "PUT",
|
|
482
|
-
body: JSON.stringify({ instanceId: id, budgetCents, perAgentCents }),
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
|
|
486
465
|
/** PATCH /fleet/bots/:id — Rename a bot instance. */
|
|
487
466
|
export async function renameInstance(id: string, name: string): Promise<void> {
|
|
488
467
|
await fleetFetch(`/bots/${id}`, {
|
package/src/lib/brand-config.ts
CHANGED
|
@@ -53,6 +53,19 @@ export interface BrandConfig {
|
|
|
53
53
|
|
|
54
54
|
/** Base pricing display string (e.g. "$5/month") */
|
|
55
55
|
price: string;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Post-auth redirect path (default "/marketplace").
|
|
59
|
+
*
|
|
60
|
+
* The Next.js middleware reads this from NEXT_PUBLIC_BRAND_HOME_PATH
|
|
61
|
+
* at build time. setBrandConfig({ homePath }) only affects client-side
|
|
62
|
+
* redirects. Brand shells must set the env var AND call setBrandConfig
|
|
63
|
+
* to keep both paths in sync.
|
|
64
|
+
*/
|
|
65
|
+
homePath: string;
|
|
66
|
+
|
|
67
|
+
/** Sidebar navigation items. Each has a label and href. */
|
|
68
|
+
navItems: Array<{ label: string; href: string }>;
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
/**
|
|
@@ -87,6 +100,22 @@ function envDefaults(): BrandConfig {
|
|
|
87
100
|
tenantCookieName: env("NEXT_PUBLIC_BRAND_TENANT_COOKIE") || `${storagePrefix}_tenant_id`,
|
|
88
101
|
companyLegalName: env("NEXT_PUBLIC_BRAND_COMPANY_LEGAL") || "Platform Inc.",
|
|
89
102
|
price: env("NEXT_PUBLIC_BRAND_PRICE"),
|
|
103
|
+
homePath: env("NEXT_PUBLIC_BRAND_HOME_PATH") || "/marketplace",
|
|
104
|
+
navItems: [
|
|
105
|
+
{ label: "Dashboard", href: "/dashboard" },
|
|
106
|
+
{ label: "Chat", href: "/chat" },
|
|
107
|
+
{ label: "Marketplace", href: "/marketplace" },
|
|
108
|
+
{ label: "Channels", href: "/channels" },
|
|
109
|
+
{ label: "Plugins", href: "/plugins" },
|
|
110
|
+
{ label: "Instances", href: "/instances" },
|
|
111
|
+
{ label: "Changesets", href: "/changesets" },
|
|
112
|
+
{ label: "Network", href: "/dashboard/network" },
|
|
113
|
+
{ label: "Fleet Health", href: "/fleet/health" },
|
|
114
|
+
{ label: "Credits", href: "/billing/credits" },
|
|
115
|
+
{ label: "Billing", href: "/billing/plans" },
|
|
116
|
+
{ label: "Settings", href: "/settings/profile" },
|
|
117
|
+
{ label: "Admin", href: "/admin/tenants" },
|
|
118
|
+
],
|
|
90
119
|
};
|
|
91
120
|
}
|
|
92
121
|
|
|
@@ -3,28 +3,44 @@
|
|
|
3
3
|
import { useQueryClient } from "@tanstack/react-query";
|
|
4
4
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
5
5
|
import { useSession } from "@/lib/auth-client";
|
|
6
|
-
import { getBrandConfig } from "@/lib/brand-config";
|
|
7
6
|
import { trpcVanilla } from "@/lib/trpc";
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return match ? decodeURIComponent(match.split("=")[1]) : "";
|
|
15
|
-
}
|
|
8
|
+
/**
|
|
9
|
+
* Module-level tenant ID, set by TenantProvider from the server-injected value.
|
|
10
|
+
* Read by getActiveTenantId() for non-React callers (apiFetch, tRPC, SSE hooks).
|
|
11
|
+
*/
|
|
12
|
+
let _activeTenantId = "";
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Set the active tenant ID from server context (called by TenantProvider on mount
|
|
16
|
+
* and on tenant switch). Also used by tests.
|
|
17
|
+
*
|
|
18
|
+
* @internal Not for production code. Import in tests only.
|
|
19
|
+
*/
|
|
20
|
+
export function setServerTenantId(tenantId: string): void {
|
|
21
|
+
_activeTenantId = tenantId;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
|
-
* Read the active tenant ID
|
|
25
|
+
* Read the active tenant ID.
|
|
24
26
|
* Used by non-React code (apiFetch, trpc client) to inject X-Tenant-Id headers.
|
|
25
27
|
*/
|
|
26
28
|
export function getActiveTenantId(): string {
|
|
27
|
-
return
|
|
29
|
+
return _activeTenantId;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Persist tenant selection via HttpOnly cookie (server-side). */
|
|
33
|
+
async function persistTenantSelection(tenantId: string): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
await fetch("/api/tenant", {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify({ tenantId }),
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
// Best-effort — cookie will be stale on next hard navigation but
|
|
42
|
+
// in-memory state is already updated for the current session.
|
|
43
|
+
}
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
export interface TenantOption {
|
|
@@ -43,16 +59,20 @@ export interface TenantContextValue {
|
|
|
43
59
|
|
|
44
60
|
const TenantContext = createContext<TenantContextValue | null>(null);
|
|
45
61
|
|
|
46
|
-
|
|
62
|
+
interface TenantProviderProps {
|
|
63
|
+
children: React.ReactNode;
|
|
64
|
+
/** Server-injected tenant ID from the HttpOnly cookie (read in middleware → layout). */
|
|
65
|
+
initialTenantId?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function TenantProvider({ children, initialTenantId = "" }: TenantProviderProps) {
|
|
47
69
|
const { data: session, isPending: sessionPending } = useSession();
|
|
48
70
|
const queryClient = useQueryClient();
|
|
49
71
|
const user = session?.user;
|
|
50
72
|
|
|
51
73
|
const [orgs, setOrgs] = useState<Array<{ id: string; name: string; image?: string | null }>>([]);
|
|
52
74
|
const [orgsLoaded, setOrgsLoaded] = useState(false);
|
|
53
|
-
const [activeTenantId, setActiveTenantId] = useState<string>(
|
|
54
|
-
return readTenantCookie();
|
|
55
|
-
});
|
|
75
|
+
const [activeTenantId, setActiveTenantId] = useState<string>(initialTenantId);
|
|
56
76
|
|
|
57
77
|
// Fetch orgs once user is available
|
|
58
78
|
useEffect(() => {
|
|
@@ -101,10 +121,20 @@ export function TenantProvider({ children }: { children: React.ReactNode }) {
|
|
|
101
121
|
return user.id;
|
|
102
122
|
}, [user, activeTenantId, tenants]);
|
|
103
123
|
|
|
124
|
+
// Keep module-level var in sync with resolved value.
|
|
125
|
+
// Guard on truthy value so an initial render with user=null (resolvedTenantId="")
|
|
126
|
+
// does not overwrite a value already set by switchTenant or SSR hydration.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (resolvedTenantId) {
|
|
129
|
+
_activeTenantId = resolvedTenantId;
|
|
130
|
+
}
|
|
131
|
+
}, [resolvedTenantId]);
|
|
132
|
+
|
|
104
133
|
const switchTenant = useCallback(
|
|
105
134
|
(tenantId: string) => {
|
|
106
135
|
setActiveTenantId(tenantId);
|
|
107
|
-
|
|
136
|
+
_activeTenantId = tenantId;
|
|
137
|
+
persistTenantSelection(tenantId);
|
|
108
138
|
queryClient.invalidateQueries();
|
|
109
139
|
},
|
|
110
140
|
[queryClient],
|
package/src/lib/trpc.tsx
CHANGED
|
@@ -59,7 +59,10 @@ function getQueryClient(): QueryClient {
|
|
|
59
59
|
return browserQueryClient;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
export function TRPCProvider({
|
|
62
|
+
export function TRPCProvider({
|
|
63
|
+
children,
|
|
64
|
+
initialTenantId,
|
|
65
|
+
}: Readonly<{ children: React.ReactNode; initialTenantId?: string }>) {
|
|
63
66
|
const queryClient = getQueryClient();
|
|
64
67
|
const [trpcClient] = useState(() =>
|
|
65
68
|
trpc.createClient({
|
|
@@ -79,7 +82,7 @@ export function TRPCProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
79
82
|
return (
|
|
80
83
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
|
81
84
|
<QueryClientProvider client={queryClient}>
|
|
82
|
-
<TenantProvider>{children}</TenantProvider>
|
|
85
|
+
<TenantProvider initialTenantId={initialTenantId}>{children}</TenantProvider>
|
|
83
86
|
</QueryClientProvider>
|
|
84
87
|
</trpc.Provider>
|
|
85
88
|
);
|
package/src/lib/utils.test.ts
CHANGED
|
@@ -33,6 +33,15 @@ describe("sanitizeRedirectUrl", () => {
|
|
|
33
33
|
expect(sanitizeRedirectUrl("//evil.com")).toBe("/");
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
it("rejects percent-encoded protocol-relative URLs", () => {
|
|
37
|
+
expect(sanitizeRedirectUrl("/%2F%2Fevil.com")).toBe("/");
|
|
38
|
+
expect(sanitizeRedirectUrl("/%2f%2fevil.com")).toBe("/");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("rejects double-encoded protocol-relative URLs", () => {
|
|
42
|
+
expect(sanitizeRedirectUrl("/%252F%252Fevil.com")).toBe("/");
|
|
43
|
+
});
|
|
44
|
+
|
|
36
45
|
it("rejects absolute URLs", () => {
|
|
37
46
|
expect(sanitizeRedirectUrl("https://evil.com")).toBe("/");
|
|
38
47
|
});
|
package/src/lib/utils.ts
CHANGED
|
@@ -7,12 +7,43 @@ export function cn(...inputs: ClassValue[]) {
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Validate a redirect URL is a safe relative path.
|
|
10
|
-
*
|
|
10
|
+
* Fully decodes percent-encoding (up to 5 iterations) then rejects
|
|
11
|
+
* protocol-relative URLs ("//evil.com") and backslash-relative URLs ("/\evil.com").
|
|
11
12
|
* Falls back to "/" for any unsafe value.
|
|
12
13
|
*/
|
|
13
14
|
export function sanitizeRedirectUrl(raw: string | null | undefined): string {
|
|
14
|
-
if (raw?.startsWith("/")
|
|
15
|
-
return
|
|
15
|
+
if (!raw?.startsWith("/")) {
|
|
16
|
+
return "/";
|
|
16
17
|
}
|
|
17
|
-
|
|
18
|
+
|
|
19
|
+
// Decode percent-encoding to catch bypass attempts like /%2F%2Fevil.com or /%5Cevil.com.
|
|
20
|
+
// Loop because double-encoding is possible (/%252F → /%2F → //).
|
|
21
|
+
// Cap iterations to prevent abuse via deeply nested encoding.
|
|
22
|
+
// On URIError mid-loop, stop at the last stable decode — e.g. /100%25 → /100% is valid.
|
|
23
|
+
const MAX_DECODE_ITERATIONS = 5;
|
|
24
|
+
let decoded = raw;
|
|
25
|
+
let iterations = 0;
|
|
26
|
+
while (iterations < MAX_DECODE_ITERATIONS) {
|
|
27
|
+
let next: string;
|
|
28
|
+
try {
|
|
29
|
+
next = decodeURIComponent(decoded);
|
|
30
|
+
} catch {
|
|
31
|
+
// Malformed percent-encoding in remainder — stop at current stable value
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
if (next === decoded) {
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
decoded = next;
|
|
38
|
+
iterations++;
|
|
39
|
+
}
|
|
40
|
+
if (iterations >= MAX_DECODE_ITERATIONS) {
|
|
41
|
+
return "/";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (decoded.startsWith("//") || decoded.startsWith("/\\")) {
|
|
45
|
+
return "/";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return raw;
|
|
18
49
|
}
|
package/src/proxy.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getBrandConfig } from "@/lib/brand-config";
|
|
2
3
|
import { logger } from "@/lib/logger";
|
|
4
|
+
import { sanitizeRedirectUrl } from "@/lib/utils";
|
|
3
5
|
|
|
4
6
|
const log = logger("middleware");
|
|
5
7
|
|
|
@@ -78,6 +80,15 @@ const CSRF_EXEMPT_AUTH_PATHS = [
|
|
|
78
80
|
|
|
79
81
|
const PLATFORM_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
|
|
80
82
|
|
|
83
|
+
const TENANT_COOKIE_NAME = getBrandConfig().tenantCookieName;
|
|
84
|
+
|
|
85
|
+
/** Post-auth landing page — configurable per brand (default: /marketplace). */
|
|
86
|
+
const HOME_PATH = (() => {
|
|
87
|
+
const p = (process.env.NEXT_PUBLIC_BRAND_HOME_PATH || "/marketplace").trim();
|
|
88
|
+
if (!p || /^https?:\/\//i.test(p)) return "/marketplace";
|
|
89
|
+
return p.startsWith("/") ? p : `/${p}`;
|
|
90
|
+
})();
|
|
91
|
+
|
|
81
92
|
/**
|
|
82
93
|
* Validate that a state-changing request originates from this application.
|
|
83
94
|
* Checks the Origin header (preferred) with Referer as fallback.
|
|
@@ -167,6 +178,18 @@ export default async function middleware(request: NextRequest) {
|
|
|
167
178
|
function nextWithNonce(): NextResponse {
|
|
168
179
|
const requestHeaders = new Headers(request.headers);
|
|
169
180
|
requestHeaders.set("x-nonce", nonce);
|
|
181
|
+
|
|
182
|
+
// Strip any client-supplied x-tenant-id before conditionally setting from the
|
|
183
|
+
// trusted HttpOnly cookie. Without this delete, a client that sends their own
|
|
184
|
+
// x-tenant-id header could spoof a tenant when no cookie is present.
|
|
185
|
+
requestHeaders.delete("x-tenant-id");
|
|
186
|
+
|
|
187
|
+
// Forward HttpOnly tenant cookie as request header for server components
|
|
188
|
+
const tenantCookie = request.cookies.get(TENANT_COOKIE_NAME);
|
|
189
|
+
if (tenantCookie?.value) {
|
|
190
|
+
requestHeaders.set("x-tenant-id", tenantCookie.value);
|
|
191
|
+
}
|
|
192
|
+
|
|
170
193
|
return NextResponse.next({ request: { headers: requestHeaders } });
|
|
171
194
|
}
|
|
172
195
|
|
|
@@ -182,7 +205,7 @@ export default async function middleware(request: NextRequest) {
|
|
|
182
205
|
}
|
|
183
206
|
|
|
184
207
|
// Redirect authenticated users from "/" to the app subdomain if on the marketing domain.
|
|
185
|
-
// On the app subdomain, redirect to
|
|
208
|
+
// On the app subdomain, redirect to HOME_PATH. On the base domain, redirect to app subdomain.
|
|
186
209
|
// NOTE: This check requires the Better Auth server to set the session cookie with
|
|
187
210
|
// domain=".<base-domain>" so it is visible on both the app and marketing subdomains.
|
|
188
211
|
// See: wopr-platform/src/auth/better-auth.ts advanced.cookies.session_token.attributes.domain
|
|
@@ -195,10 +218,12 @@ export default async function middleware(request: NextRequest) {
|
|
|
195
218
|
process.env.NEXT_PUBLIC_BRAND_APP_DOMAIN || process.env.NEXT_PUBLIC_APP_DOMAIN;
|
|
196
219
|
if (appDomain && !host.startsWith("app.")) {
|
|
197
220
|
// On marketing domain — redirect to the app subdomain
|
|
198
|
-
|
|
221
|
+
const appUrl = new URL(`https://${appDomain}`);
|
|
222
|
+
appUrl.pathname = HOME_PATH;
|
|
223
|
+
return withCsp(NextResponse.redirect(appUrl));
|
|
199
224
|
}
|
|
200
|
-
// On app subdomain (or no configured app domain) — redirect to
|
|
201
|
-
return withCsp(NextResponse.redirect(new URL(
|
|
225
|
+
// On app subdomain (or no configured app domain) — redirect to home
|
|
226
|
+
return withCsp(NextResponse.redirect(new URL(HOME_PATH, request.url)));
|
|
202
227
|
}
|
|
203
228
|
}
|
|
204
229
|
|
|
@@ -212,7 +237,7 @@ export default async function middleware(request: NextRequest) {
|
|
|
212
237
|
if (sessionCookie?.value.trim()) {
|
|
213
238
|
const role = await getSessionRole(request);
|
|
214
239
|
if (role !== "platform_admin") {
|
|
215
|
-
return withCsp(NextResponse.redirect(new URL(
|
|
240
|
+
return withCsp(NextResponse.redirect(new URL(HOME_PATH, request.url)));
|
|
216
241
|
}
|
|
217
242
|
// Admin confirmed — serve page with anti-cache headers so revocation
|
|
218
243
|
// is detected on the very next navigation (browser must revalidate).
|
|
@@ -246,7 +271,7 @@ export default async function middleware(request: NextRequest) {
|
|
|
246
271
|
|
|
247
272
|
if (!sessionToken || !sessionToken.value.trim()) {
|
|
248
273
|
const loginUrl = new URL("/login", request.url);
|
|
249
|
-
loginUrl.searchParams.set("callbackUrl", pathname);
|
|
274
|
+
loginUrl.searchParams.set("callbackUrl", sanitizeRedirectUrl(pathname));
|
|
250
275
|
return withCsp(NextResponse.redirect(loginUrl));
|
|
251
276
|
}
|
|
252
277
|
|
package/.env.paperclip
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
# Paperclip Brand Configuration
|
|
2
|
-
# Copy to .env.local to deploy as Paperclip
|
|
3
|
-
NEXT_PUBLIC_BRAND_PRODUCT_NAME="Paperclip"
|
|
4
|
-
NEXT_PUBLIC_BRAND_NAME="Paperclip"
|
|
5
|
-
NEXT_PUBLIC_BRAND_DOMAIN="runpaperclip.com"
|
|
6
|
-
NEXT_PUBLIC_BRAND_APP_DOMAIN="app.runpaperclip.com"
|
|
7
|
-
NEXT_PUBLIC_BRAND_TAGLINE="AI agents that run your business."
|
|
8
|
-
NEXT_PUBLIC_BRAND_EMAIL_PRIVACY="privacy@runpaperclip.com"
|
|
9
|
-
NEXT_PUBLIC_BRAND_EMAIL_LEGAL="legal@runpaperclip.com"
|
|
10
|
-
NEXT_PUBLIC_BRAND_EMAIL_SUPPORT="support@runpaperclip.com"
|
|
11
|
-
NEXT_PUBLIC_BRAND_DEFAULT_IMAGE="ghcr.io/wopr-network/wopr:latest"
|
|
12
|
-
NEXT_PUBLIC_BRAND_STORAGE_PREFIX="paperclip"
|
|
13
|
-
NEXT_PUBLIC_BRAND_EVENT_PREFIX="paperclip"
|
|
14
|
-
NEXT_PUBLIC_BRAND_ENV_PREFIX="PAPERCLIP"
|
|
15
|
-
NEXT_PUBLIC_BRAND_TOOL_PREFIX="paperclip"
|
|
16
|
-
NEXT_PUBLIC_BRAND_TENANT_COOKIE="paperclip_tenant_id"
|
|
17
|
-
NEXT_PUBLIC_BRAND_COMPANY_LEGAL="Paperclip AI Inc."
|
|
18
|
-
NEXT_PUBLIC_BRAND_PRICE="$5/month"
|