@wopr-network/platform-ui-core 1.1.7 → 1.1.9
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 +1 -1
- package/src/__tests__/auto-topup-card.test.tsx +10 -2
- package/src/__tests__/buy-crypto-credits-panel.test.tsx +5 -5
- package/src/__tests__/dividend-calculator.test.tsx +15 -0
- package/src/__tests__/dividend-stats.test.tsx +54 -12
- package/src/__tests__/onboarding-store-urls.test.ts +24 -0
- package/src/__tests__/provider-docs.test.ts +35 -0
- package/src/__tests__/suspension-banner.test.tsx +20 -4
- package/src/__tests__/validate-redirect-url.test.ts +6 -11
- package/src/components/billing/buy-crypto-credits-panel.tsx +2 -2
- package/src/config/provider-docs.ts +12 -1
- package/src/lib/onboarding-store.ts +6 -5
- package/src/lib/validate-redirect-url.ts +25 -7
package/package.json
CHANGED
|
@@ -35,6 +35,14 @@ vi.mock("better-auth/react", () => ({
|
|
|
35
35
|
}),
|
|
36
36
|
}));
|
|
37
37
|
|
|
38
|
+
/** Return an ISO-8601 UTC date string N days from now, truncated to midnight. */
|
|
39
|
+
function daysFromNow(n: number): string {
|
|
40
|
+
const d = new Date();
|
|
41
|
+
d.setUTCDate(d.getUTCDate() + n);
|
|
42
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
43
|
+
return d.toISOString();
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
const MOCK_SETTINGS: AutoTopupSettings = {
|
|
39
47
|
usageBased: {
|
|
40
48
|
enabled: false,
|
|
@@ -61,7 +69,7 @@ const MOCK_SETTINGS_ENABLED: AutoTopupSettings = {
|
|
|
61
69
|
enabled: true,
|
|
62
70
|
amountCents: 2000,
|
|
63
71
|
interval: "weekly",
|
|
64
|
-
nextChargeDate:
|
|
72
|
+
nextChargeDate: daysFromNow(7),
|
|
65
73
|
},
|
|
66
74
|
paymentMethodLast4: "4242",
|
|
67
75
|
paymentMethodBrand: "Visa",
|
|
@@ -89,7 +97,7 @@ const MOCK_SETTINGS_MONTHLY: AutoTopupSettings = {
|
|
|
89
97
|
enabled: true,
|
|
90
98
|
amountCents: 2000,
|
|
91
99
|
interval: "monthly",
|
|
92
|
-
nextChargeDate:
|
|
100
|
+
nextChargeDate: daysFromNow(14),
|
|
93
101
|
},
|
|
94
102
|
};
|
|
95
103
|
|
|
@@ -103,7 +103,7 @@ describe("BuyCryptoCreditPanel", () => {
|
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
mockCreateCryptoCheckout.mockResolvedValue({
|
|
106
|
-
url: "https://
|
|
106
|
+
url: "https://btcpay.example.com/i/abc123",
|
|
107
107
|
referenceId: "ref-abc123",
|
|
108
108
|
});
|
|
109
109
|
mockIsAllowedRedirectUrl.mockReturnValue(true);
|
|
@@ -116,8 +116,8 @@ describe("BuyCryptoCreditPanel", () => {
|
|
|
116
116
|
await user.click(screen.getByRole("button", { name: "Pay with crypto" }));
|
|
117
117
|
|
|
118
118
|
expect(mockCreateCryptoCheckout).toHaveBeenCalledWith(50);
|
|
119
|
-
expect(mockIsAllowedRedirectUrl).toHaveBeenCalledWith("https://
|
|
120
|
-
expect(hrefSetter).toHaveBeenCalledWith("https://
|
|
119
|
+
expect(mockIsAllowedRedirectUrl).toHaveBeenCalledWith("https://btcpay.example.com/i/abc123");
|
|
120
|
+
expect(hrefSetter).toHaveBeenCalledWith("https://btcpay.example.com/i/abc123");
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
it("shows error when redirect URL is not allowed", async () => {
|
|
@@ -139,7 +139,7 @@ describe("BuyCryptoCreditPanel", () => {
|
|
|
139
139
|
).toBeInTheDocument();
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
-
it("shows Opening
|
|
142
|
+
it("shows Opening checkout... while checkout is in progress", async () => {
|
|
143
143
|
mockCreateCryptoCheckout.mockReturnValue(
|
|
144
144
|
new Promise(() => {
|
|
145
145
|
/* intentionally pending */
|
|
@@ -153,7 +153,7 @@ describe("BuyCryptoCreditPanel", () => {
|
|
|
153
153
|
await user.click(screen.getByText("$100"));
|
|
154
154
|
await user.click(screen.getByRole("button", { name: "Pay with crypto" }));
|
|
155
155
|
|
|
156
|
-
expect(await screen.findByText("Opening
|
|
156
|
+
expect(await screen.findByText("Opening checkout...")).toBeInTheDocument();
|
|
157
157
|
});
|
|
158
158
|
|
|
159
159
|
it("shows error when crypto checkout API call fails", async () => {
|
|
@@ -17,4 +17,19 @@ describe("DividendCalculator", () => {
|
|
|
17
17
|
|
|
18
18
|
expect(screen.getByText(/the earlier you join/i)).toBeInTheDocument();
|
|
19
19
|
});
|
|
20
|
+
|
|
21
|
+
it("renders without crash when no API context is available (unauthenticated)", async () => {
|
|
22
|
+
// DividendCalculator is a static component with no API dependencies.
|
|
23
|
+
// This test verifies it renders completely in an environment where
|
|
24
|
+
// fetch is stubbed to reject (simulating unauthenticated pricing page).
|
|
25
|
+
// If a future refactor adds data fetching, this test catches regressions.
|
|
26
|
+
const { DividendCalculator } = await import("@/components/pricing/dividend-calculator");
|
|
27
|
+
const { container } = render(<DividendCalculator />);
|
|
28
|
+
|
|
29
|
+
// Component should render two Card sections
|
|
30
|
+
expect(screen.getByTestId("net-cost")).toBeInTheDocument();
|
|
31
|
+
expect(screen.getByText(/early adopter advantage/i)).toBeInTheDocument();
|
|
32
|
+
// No error text or crash indicators
|
|
33
|
+
expect(container.querySelector(".text-red-500")).toBeNull();
|
|
34
|
+
});
|
|
20
35
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
|
|
4
4
|
// Stub fetch globally before any module imports
|
|
@@ -13,9 +13,10 @@ vi.mock("@/lib/api-config", () => ({
|
|
|
13
13
|
describe("DividendStats", () => {
|
|
14
14
|
beforeEach(() => {
|
|
15
15
|
mockFetch.mockReset();
|
|
16
|
+
vi.resetModules();
|
|
16
17
|
});
|
|
17
18
|
|
|
18
|
-
it("renders fallback
|
|
19
|
+
it("renders fallback dashes when API returns non-ok response (no error message shown)", async () => {
|
|
19
20
|
mockFetch.mockResolvedValue({
|
|
20
21
|
ok: false,
|
|
21
22
|
status: 500,
|
|
@@ -26,9 +27,14 @@ describe("DividendStats", () => {
|
|
|
26
27
|
const { DividendStats } = await import("@/components/pricing/dividend-stats");
|
|
27
28
|
render(<DividendStats />);
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
// Wait for fetch to complete (loaded=true), then verify fallback dashes
|
|
31
|
+
await waitFor(() => {
|
|
32
|
+
expect(screen.getByTestId("pool-amount")).toHaveTextContent("--");
|
|
33
|
+
});
|
|
34
|
+
expect(screen.getByTestId("active-users")).toHaveTextContent("--");
|
|
35
|
+
expect(screen.getByTestId("projected-dividend")).toHaveTextContent("--");
|
|
36
|
+
// fetchDividendStats returns null (no throw) → .catch() never runs → no red error paragraph
|
|
37
|
+
expect(screen.queryByText(/failed to load/i)).not.toBeInTheDocument();
|
|
32
38
|
});
|
|
33
39
|
|
|
34
40
|
it("renders live data when API succeeds", async () => {
|
|
@@ -45,9 +51,12 @@ describe("DividendStats", () => {
|
|
|
45
51
|
const { DividendStats } = await import("@/components/pricing/dividend-stats");
|
|
46
52
|
render(<DividendStats />);
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
// useCountUp with prefers-reduced-motion: true sets value immediately
|
|
55
|
+
await waitFor(() => {
|
|
56
|
+
expect(screen.getByTestId("pool-amount")).toHaveTextContent("$2500.00");
|
|
57
|
+
});
|
|
58
|
+
expect(screen.getByTestId("active-users")).toHaveTextContent("8,000");
|
|
59
|
+
expect(screen.getByTestId("projected-dividend")).toHaveTextContent("~$0.31");
|
|
51
60
|
});
|
|
52
61
|
|
|
53
62
|
it("renders fallback dashes when fetch rejects (network error)", async () => {
|
|
@@ -56,9 +65,42 @@ describe("DividendStats", () => {
|
|
|
56
65
|
const { DividendStats } = await import("@/components/pricing/dividend-stats");
|
|
57
66
|
render(<DividendStats />);
|
|
58
67
|
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
// Wait for fetch to complete, then verify fallback dashes
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
// fetchDividendStats catches network errors and returns null → component shows "--"
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
expect(screen.getByTestId("pool-amount")).toHaveTextContent("--");
|
|
75
|
+
});
|
|
76
|
+
expect(screen.getByTestId("active-users")).toHaveTextContent("--");
|
|
77
|
+
expect(screen.getByTestId("projected-dividend")).toHaveTextContent("--");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("renders error message when fetchDividendStats throws", async () => {
|
|
81
|
+
// To hit the component's .catch() branch, mock the API module directly
|
|
82
|
+
// so fetchDividendStats rejects instead of catching internally
|
|
83
|
+
vi.doMock("@/lib/api", async (importOriginal) => {
|
|
84
|
+
const orig = await importOriginal<typeof import("@/lib/api")>();
|
|
85
|
+
return {
|
|
86
|
+
...orig,
|
|
87
|
+
fetchDividendStats: vi.fn().mockRejectedValue(new Error("Unexpected failure")),
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const { DividendStats } = await import("@/components/pricing/dividend-stats");
|
|
92
|
+
render(<DividendStats />);
|
|
93
|
+
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
expect(screen.getByText("Unexpected failure")).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
// Error text should be red
|
|
98
|
+
expect(screen.getByText("Unexpected failure")).toHaveClass("text-red-500");
|
|
99
|
+
// Data fields should show "--" (never populated)
|
|
100
|
+
expect(screen.getByTestId("pool-amount")).toHaveTextContent("--");
|
|
101
|
+
expect(screen.getByTestId("active-users")).toHaveTextContent("--");
|
|
102
|
+
expect(screen.getByTestId("projected-dividend")).toHaveTextContent("--");
|
|
103
|
+
|
|
104
|
+
vi.unmock("@/lib/api"); // clean up factory registration so future tests are unaffected
|
|
63
105
|
});
|
|
64
106
|
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { PROVIDER_DOC_URLS } from "../config/provider-docs";
|
|
3
|
+
|
|
4
|
+
describe("AI_PROVIDERS keyHelpUrl consolidation", () => {
|
|
5
|
+
it("all keyHelpUrl values match PROVIDER_DOC_URLS entries", async () => {
|
|
6
|
+
const { AI_PROVIDERS } = await import("../lib/onboarding-store");
|
|
7
|
+
|
|
8
|
+
const urlToProviderDocKey: Record<string, string> = {
|
|
9
|
+
[PROVIDER_DOC_URLS.anthropic]: "anthropic",
|
|
10
|
+
[PROVIDER_DOC_URLS.openai]: "openai",
|
|
11
|
+
[PROVIDER_DOC_URLS.google]: "google",
|
|
12
|
+
[PROVIDER_DOC_URLS.xai]: "xai",
|
|
13
|
+
[PROVIDER_DOC_URLS.local]: "local",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
for (const provider of AI_PROVIDERS) {
|
|
17
|
+
expect(
|
|
18
|
+
provider.keyHelpUrl in urlToProviderDocKey ||
|
|
19
|
+
Object.values(PROVIDER_DOC_URLS).includes(provider.keyHelpUrl as never),
|
|
20
|
+
`${provider.id} keyHelpUrl "${provider.keyHelpUrl}" should come from PROVIDER_DOC_URLS`,
|
|
21
|
+
).toBe(true);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { PROVIDER_DOC_URLS } from "../config/provider-docs";
|
|
3
|
+
|
|
4
|
+
describe("PROVIDER_DOC_URLS", () => {
|
|
5
|
+
it("contains all expected provider keys", () => {
|
|
6
|
+
const expectedKeys = [
|
|
7
|
+
"openai",
|
|
8
|
+
"openrouter",
|
|
9
|
+
"anthropic",
|
|
10
|
+
"replicate",
|
|
11
|
+
"elevenlabs",
|
|
12
|
+
"elevenlabsHome",
|
|
13
|
+
"deepgram",
|
|
14
|
+
"discord",
|
|
15
|
+
"slack",
|
|
16
|
+
"telegram",
|
|
17
|
+
"whatsapp",
|
|
18
|
+
"msTeams",
|
|
19
|
+
"moonshot",
|
|
20
|
+
"github",
|
|
21
|
+
"google",
|
|
22
|
+
"xai",
|
|
23
|
+
"local",
|
|
24
|
+
];
|
|
25
|
+
for (const key of expectedKeys) {
|
|
26
|
+
expect(PROVIDER_DOC_URLS).toHaveProperty(key);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("all values are valid https or http URLs", () => {
|
|
31
|
+
for (const [key, url] of Object.entries(PROVIDER_DOC_URLS)) {
|
|
32
|
+
expect(url, `${key} should be a valid URL`).toMatch(/^https?:\/\//);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -19,6 +19,22 @@ vi.mock("better-auth/react", () => ({
|
|
|
19
19
|
}),
|
|
20
20
|
}));
|
|
21
21
|
|
|
22
|
+
/** Return an ISO-8601 UTC date string N days from now, truncated to midnight. */
|
|
23
|
+
function daysFromNow(n: number): string {
|
|
24
|
+
const d = new Date();
|
|
25
|
+
d.setUTCDate(d.getUTCDate() + n);
|
|
26
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
27
|
+
return d.toISOString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Return an ISO-8601 UTC date string N days from now, at end-of-day. */
|
|
31
|
+
function endOfDayFromNow(n: number): string {
|
|
32
|
+
const d = new Date();
|
|
33
|
+
d.setUTCDate(d.getUTCDate() + n);
|
|
34
|
+
d.setUTCHours(23, 59, 59, 0);
|
|
35
|
+
return d.toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
const MOCK_BALANCE: CreditBalance = {
|
|
23
39
|
balance: 0.5,
|
|
24
40
|
dailyBurn: 0.33,
|
|
@@ -32,8 +48,8 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
|
|
32
48
|
getCreditBalance: vi.fn().mockResolvedValue(MOCK_BALANCE),
|
|
33
49
|
getAccountStatus: vi.fn().mockResolvedValue(null),
|
|
34
50
|
getBillingUsageSummary: vi.fn().mockResolvedValue({
|
|
35
|
-
periodStart:
|
|
36
|
-
periodEnd:
|
|
51
|
+
periodStart: daysFromNow(-30),
|
|
52
|
+
periodEnd: endOfDayFromNow(0),
|
|
37
53
|
totalSpend: 30,
|
|
38
54
|
includedCredit: 50,
|
|
39
55
|
amountDue: 0,
|
|
@@ -105,8 +121,8 @@ describe("SuspensionBanner", () => {
|
|
|
105
121
|
runway: 5,
|
|
106
122
|
});
|
|
107
123
|
vi.mocked(api.getBillingUsageSummary).mockResolvedValueOnce({
|
|
108
|
-
periodStart:
|
|
109
|
-
periodEnd:
|
|
124
|
+
periodStart: daysFromNow(-30),
|
|
125
|
+
periodEnd: endOfDayFromNow(0),
|
|
110
126
|
totalSpend: 120,
|
|
111
127
|
includedCredit: 50,
|
|
112
128
|
amountDue: 70,
|
|
@@ -19,18 +19,9 @@ describe("isAllowedRedirectUrl", () => {
|
|
|
19
19
|
expect(isAllowedRedirectUrl("https://billing.stripe.com/p/session/test_abc")).toBe(true);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
it("allows
|
|
23
|
-
expect(isAllowedRedirectUrl("https://commerce.coinbase.com/charges/ABC123")).toBe(true);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("allows PayRam URLs", () => {
|
|
27
|
-
expect(isAllowedRedirectUrl("https://payram.io/checkout/abc")).toBe(true);
|
|
28
|
-
expect(isAllowedRedirectUrl("https://app.payram.io/pay/abc")).toBe(true);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("allows same-origin URLs", () => {
|
|
32
|
-
// jsdom defaults to http://localhost
|
|
22
|
+
it("allows same-origin URLs (BTCPay checkout is same-origin in local dev)", () => {
|
|
33
23
|
expect(isAllowedRedirectUrl("http://localhost/billing/success")).toBe(true);
|
|
24
|
+
expect(isAllowedRedirectUrl("http://localhost/i/invoice123")).toBe(true);
|
|
34
25
|
expect(isAllowedRedirectUrl("/billing/success")).toBe(true);
|
|
35
26
|
});
|
|
36
27
|
|
|
@@ -58,4 +49,8 @@ describe("isAllowedRedirectUrl", () => {
|
|
|
58
49
|
expect(ALLOWED_REDIRECT_ORIGINS).toBeInstanceOf(Set);
|
|
59
50
|
expect(ALLOWED_REDIRECT_ORIGINS.has("https://checkout.stripe.com")).toBe(true);
|
|
60
51
|
});
|
|
52
|
+
|
|
53
|
+
it("does not include defunct payment providers", () => {
|
|
54
|
+
expect(ALLOWED_REDIRECT_ORIGINS.has("https://payram.io")).toBe(false);
|
|
55
|
+
});
|
|
61
56
|
});
|
|
@@ -52,7 +52,7 @@ export function BuyCryptoCreditPanel() {
|
|
|
52
52
|
Pay with Crypto
|
|
53
53
|
</CardTitle>
|
|
54
54
|
<p className="text-xs text-muted-foreground">
|
|
55
|
-
Pay with
|
|
55
|
+
Pay with BTC or other cryptocurrencies. Minimum $10.
|
|
56
56
|
</p>
|
|
57
57
|
</CardHeader>
|
|
58
58
|
<CardContent className="space-y-4">
|
|
@@ -87,7 +87,7 @@ export function BuyCryptoCreditPanel() {
|
|
|
87
87
|
variant="outline"
|
|
88
88
|
className="w-full sm:w-auto"
|
|
89
89
|
>
|
|
90
|
-
{loading ? "Opening
|
|
90
|
+
{loading ? "Opening checkout..." : "Pay with crypto"}
|
|
91
91
|
</Button>
|
|
92
92
|
</CardContent>
|
|
93
93
|
</Card>
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* External provider documentation URLs used in onboarding config fields.
|
|
3
|
+
*
|
|
4
|
+
* These are intentional external links to provider API key dashboards —
|
|
5
|
+
* no secret material. They live in code (not fetched from the API) because
|
|
6
|
+
* major provider dashboard URLs change extremely rarely (years, not weeks).
|
|
7
|
+
* The cost of an API round-trip, loading state, and DB migration is not
|
|
8
|
+
* justified. If a URL changes, update this map and publish a new version.
|
|
9
|
+
*/
|
|
2
10
|
export const PROVIDER_DOC_URLS = {
|
|
3
11
|
openai: "https://platform.openai.com/api-keys",
|
|
4
12
|
openrouter: "https://openrouter.ai/keys",
|
|
@@ -14,4 +22,7 @@ export const PROVIDER_DOC_URLS = {
|
|
|
14
22
|
msTeams: "https://dev.teams.microsoft.com/",
|
|
15
23
|
moonshot: "https://platform.moonshot.cn/",
|
|
16
24
|
github: "https://github.com/settings/tokens",
|
|
25
|
+
google: "https://aistudio.google.com/apikey",
|
|
26
|
+
xai: "https://console.x.ai/",
|
|
27
|
+
local: "https://ollama.com/download",
|
|
17
28
|
} as const;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Client-side onboarding state with localStorage persistence.
|
|
3
3
|
* Enables resume from last completed step.
|
|
4
4
|
*/
|
|
5
|
+
import { PROVIDER_DOC_URLS } from "../config/provider-docs";
|
|
5
6
|
import { storageKey } from "./brand-config";
|
|
6
7
|
|
|
7
8
|
export interface ProviderConfig {
|
|
@@ -137,7 +138,7 @@ export const AI_PROVIDERS = [
|
|
|
137
138
|
recommended: true,
|
|
138
139
|
keyPattern: "^sk-ant-[a-zA-Z0-9_-]+$",
|
|
139
140
|
keyPlaceholder: "sk-ant-api03-...",
|
|
140
|
-
keyHelpUrl:
|
|
141
|
+
keyHelpUrl: PROVIDER_DOC_URLS.anthropic,
|
|
141
142
|
},
|
|
142
143
|
{
|
|
143
144
|
id: "openai",
|
|
@@ -148,7 +149,7 @@ export const AI_PROVIDERS = [
|
|
|
148
149
|
recommended: false,
|
|
149
150
|
keyPattern: "^sk-[a-zA-Z0-9_-]+$",
|
|
150
151
|
keyPlaceholder: "sk-proj-...",
|
|
151
|
-
keyHelpUrl:
|
|
152
|
+
keyHelpUrl: PROVIDER_DOC_URLS.openai,
|
|
152
153
|
},
|
|
153
154
|
{
|
|
154
155
|
id: "google",
|
|
@@ -159,7 +160,7 @@ export const AI_PROVIDERS = [
|
|
|
159
160
|
recommended: false,
|
|
160
161
|
keyPattern: "^AIza[a-zA-Z0-9_-]+$",
|
|
161
162
|
keyPlaceholder: "AIzaSy...",
|
|
162
|
-
keyHelpUrl:
|
|
163
|
+
keyHelpUrl: PROVIDER_DOC_URLS.google,
|
|
163
164
|
},
|
|
164
165
|
{
|
|
165
166
|
id: "xai",
|
|
@@ -170,7 +171,7 @@ export const AI_PROVIDERS = [
|
|
|
170
171
|
recommended: false,
|
|
171
172
|
keyPattern: "^xai-[a-zA-Z0-9_-]+$",
|
|
172
173
|
keyPlaceholder: "xai-...",
|
|
173
|
-
keyHelpUrl:
|
|
174
|
+
keyHelpUrl: PROVIDER_DOC_URLS.xai,
|
|
174
175
|
},
|
|
175
176
|
{
|
|
176
177
|
id: "local",
|
|
@@ -181,7 +182,7 @@ export const AI_PROVIDERS = [
|
|
|
181
182
|
recommended: false,
|
|
182
183
|
keyPattern: ".*",
|
|
183
184
|
keyPlaceholder: "http://localhost:11434",
|
|
184
|
-
keyHelpUrl:
|
|
185
|
+
keyHelpUrl: PROVIDER_DOC_URLS.local,
|
|
185
186
|
},
|
|
186
187
|
] as const;
|
|
187
188
|
|
|
@@ -1,12 +1,30 @@
|
|
|
1
|
-
/** Origins that are allowed as redirect targets from
|
|
2
|
-
|
|
1
|
+
/** Origins that are always allowed as redirect targets from checkout responses. */
|
|
2
|
+
const STATIC_ALLOWED_ORIGINS: readonly string[] = [
|
|
3
3
|
"https://checkout.stripe.com",
|
|
4
4
|
"https://billing.stripe.com",
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build the full allowed origins set, including any configured BTCPay Server origin.
|
|
9
|
+
* BTCPay is self-hosted so its URL varies per deployment — read from env var.
|
|
10
|
+
*/
|
|
11
|
+
function getAllowedOrigins(): ReadonlySet<string> {
|
|
12
|
+
const origins = new Set(STATIC_ALLOWED_ORIGINS);
|
|
13
|
+
|
|
14
|
+
// BTCPay Server (self-hosted, URL varies per deployment)
|
|
15
|
+
const btcpayUrl = process.env.NEXT_PUBLIC_BTCPAY_URL?.trim();
|
|
16
|
+
if (btcpayUrl) {
|
|
17
|
+
try {
|
|
18
|
+
origins.add(new URL(btcpayUrl).origin);
|
|
19
|
+
} catch (_err) {
|
|
20
|
+
// Invalid URL — skip silently; BTCPay checkout will fall back to same-origin
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return origins;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const ALLOWED_REDIRECT_ORIGINS: ReadonlySet<string> = getAllowedOrigins();
|
|
10
28
|
|
|
11
29
|
/**
|
|
12
30
|
* Returns true if `url` is safe to navigate to.
|