@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 CHANGED
@@ -1,12 +1,21 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-ui-core",
3
- "version": "1.0.0",
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 { TEST_PLUGINS, mockInstallPlugin, mockListBots, mockListInstalledPlugins } = vi.hoisted(
47
- () => {
48
- const mockInstallPlugin = vi.fn();
49
- const mockListBots = vi
50
- .fn()
51
- .mockResolvedValue([{ id: "bot-001", name: "My Bot", state: "running" }]);
52
- const mockListInstalledPlugins = vi.fn().mockResolvedValue([]);
53
- // eslint-disable-next-line @typescript-eslint/no-require-imports
54
- const { INSTALL_FLOW_TEST_PLUGINS } =
55
- require("./fixtures/mock-manifests-data") as typeof import("./fixtures/mock-manifests");
56
- return {
57
- TEST_PLUGINS: INSTALL_FLOW_TEST_PLUGINS,
58
- mockInstallPlugin,
59
- mockListBots,
60
- mockListInstalledPlugins,
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 { getActiveTenantId, TenantProvider, useTenant } from "@/lib/tenant-context";
20
-
21
- function createWrapper() {
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
- clearTenantCookie();
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("includes orgs when listMyOrganizations returns results", async () => {
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.tenants).toHaveLength(2);
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("persists tenant switch to cookie", async () => {
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(document.cookie).toContain("platform_tenant_id=org-1");
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
- clearTenantCookie();
130
+ setServerTenantId("");
136
131
  });
137
132
 
138
- it("returns stored tenant ID from cookie", () => {
139
- setTenantCookie("org-1");
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 stored", () => {
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("/marketplace");
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("/marketplace");
89
+ router.push(homePath());
89
90
  }
90
91
 
91
92
  function handleSkip() {
92
93
  markOnboardingComplete();
93
- router.push("/marketplace");
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 */}
@@ -57,7 +57,9 @@ export default async function RootLayout({
57
57
  }: Readonly<{
58
58
  children: React.ReactNode;
59
59
  }>) {
60
- const nonce = (await headers()).get("x-nonce") ?? undefined;
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("/marketplace");
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
- const key = issue.path[0] as string;
215
- stepErrors[key] = issue.message;
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
- const navItems = [
25
- { label: "Dashboard", href: "/dashboard" },
26
- { label: "Chat", href: "/chat" },
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
- {navItems
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}`, {
@@ -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
- const COOKIE_NAME = getBrandConfig().tenantCookieName;
10
-
11
- function readTenantCookie(): string {
12
- if (typeof document === "undefined") return "";
13
- const match = document.cookie.split("; ").find((row) => row.startsWith(`${COOKIE_NAME}=`));
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
- function writeTenantCookie(tenantId: string): void {
18
- // biome-ignore lint/suspicious/noDocumentCookie: intentional session cookie write (security: tenant ID moved out of localStorage)
19
- document.cookie = `${COOKIE_NAME}=${encodeURIComponent(tenantId)}; path=/; SameSite=Lax; Secure`;
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 from a session cookie.
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 readTenantCookie();
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
- export function TenantProvider({ children }: { children: React.ReactNode }) {
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
- writeTenantCookie(tenantId);
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({ children }: Readonly<{ children: React.ReactNode }>) {
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
  );
@@ -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
- * Accepts paths starting with "/" but rejects protocol-relative URLs like "//evil.com".
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("/") && !raw.startsWith("//")) {
15
- return raw;
15
+ if (!raw?.startsWith("/")) {
16
+ return "/";
16
17
  }
17
- return "/";
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 /marketplace. On the base domain, redirect to app subdomain.
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
- return withCsp(NextResponse.redirect(new URL(`https://${appDomain}/marketplace`)));
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 /marketplace
201
- return withCsp(NextResponse.redirect(new URL("/marketplace", request.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("/marketplace", request.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"