diff --git a/.env.example b/.env.example index aba75ac..0020b8e 100644 --- a/.env.example +++ b/.env.example @@ -23,15 +23,16 @@ # Parsed by @solana-developers/helpers `getKeypairFromEnvironment`. FAUCET_KEYPAIR_NEW= -# [SECRET] [optional] Solana RPC endpoint. Defaults to the public -# https://api.devnet.solana.com when unset. -# In practice, the only reason to set this is to point at a private provider +# [SECRET] [optional] Solana RPC endpoints, per network. Default to the public +# https://api.devnet.solana.com / https://api.testnet.solana.com when unset. +# In practice, the only reason to set these is to point at a private provider # (Helius, QuickNode, Triton, Alchemy, etc.) — and those URLs typically embed # an API key directly in the path or query string, e.g. # https://devnet.helius-rpc.com/?api-key=... # https://solana-devnet.g.alchemy.com/v2/ -# So treat the value as a credential: don't log, don't commit, rotate if leaked. -RPC_URL= +# So treat the values as credentials: don't log, don't commit, rotate if leaked. +RPC_URL_DEVNET= +RPC_URL_TESTNET= # ----------------------------------------------------------------------------- # Cloudflare Turnstile (CAPTCHA) @@ -99,3 +100,19 @@ IP_ALLOW_LIST= # rate limits — treat exactly like an API key and rotate if leaked. # Example: [{"name":"hackathon","token":"abc123","startDate":"2026-04-01","endDate":"2026-04-30"}] AUTH_TOKENS_ALLOW_LIST= + +# ----------------------------------------------------------------------------- +# Analytics (GA4 Measurement Protocol, server-side) +# ----------------------------------------------------------------------------- + +# [PUBLIC] [optional] GA4 Measurement ID for server-side event tracking from +# API routes (lib/analytics.ts). Format: "G-XXXXXXXXXX". Already exposed to +# clients on any site with GA4 web tracking, so not sensitive. +# When unset, trackEvent() is a no-op — the airdrop path still works. +GA4_MEASUREMENT_ID= + +# [SECRET] [optional] GA4 Measurement Protocol API secret. Pairs with +# GA4_MEASUREMENT_ID. Created in GA4 Admin › Data Streams › Measurement +# Protocol API secrets. Leaking it lets anyone forge events into your GA4 +# property. Required only if GA4_MEASUREMENT_ID is set. +GA4_API_SECRET= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 077df53..441927e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: jobs: lint: - name: Install & Lint + name: Install, Lint & Test runs-on: ubuntu-latest timeout-minutes: 5 env: @@ -21,3 +21,4 @@ jobs: - run: corepack enable npm - run: npm ci - run: npm run lint + - run: npm test diff --git a/app/api/monitorfaucet/route.ts b/app/api/monitorfaucet/route.ts index 0242b90..073c8d1 100644 --- a/app/api/monitorfaucet/route.ts +++ b/app/api/monitorfaucet/route.ts @@ -1,6 +1,7 @@ -import { LAMPORTS_PER_SOL, PublicKey, Connection } from "@solana/web3.js"; +import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"; import { solanaBalancesAPI } from "@/lib/backend"; import { FAUCET_ACCOUNTS } from "@/lib/constants"; +import { getConnection } from "@/lib/rpc"; export const dynamic = "force-dynamic"; // defaults to auto @@ -9,10 +10,7 @@ export const dynamic = "force-dynamic"; // defaults to auto */ export const GET = async (_req: Request) => { try { - // connect to the desired solana rpc - const connection = new Connection( - process.env.RPC_URL ?? "https://api.devnet.solana.com", - ); + const connection = getConnection("devnet"); const accounts = FAUCET_ACCOUNTS.map(acc => new PublicKey(acc)); diff --git a/app/api/request/__tests__/airdrop-error.test.ts b/app/api/request/__tests__/airdrop-error.test.ts new file mode 100644 index 0000000..46539e6 --- /dev/null +++ b/app/api/request/__tests__/airdrop-error.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from "vitest"; +import { AirdropError, AirdropErrorCode } from "../airdrop-error"; + +vi.spyOn(console, "error").mockImplementation(() => {}); + +describe("AirdropError", () => { + it("should set code, status, and message from error def", () => { + const err = new AirdropError(AirdropErrorCode.INVALID_BODY); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe("invalid_body"); + expect(err.status).toBe(400); + expect(err.message).toBe("Invalid request body"); + }); + + it("should allow overriding the message", () => { + const err = new AirdropError(AirdropErrorCode.RATE_LIMITED, { + message: "Too fast", + }); + expect(err.code).toBe("rate_limited"); + expect(err.message).toBe("Too fast"); + }); + + it("should preserve the cause", () => { + const cause = new Error("upstream"); + const err = new AirdropError(AirdropErrorCode.TX_FAILED, { cause }); + expect(err.cause).toBe(cause); + }); +}); diff --git a/app/api/request/__tests__/auth-bypass.test.ts b/app/api/request/__tests__/auth-bypass.test.ts new file mode 100644 index 0000000..8a1fd20 --- /dev/null +++ b/app/api/request/__tests__/auth-bypass.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { isAuthorizedToBypass } from "../auth-bypass"; + + +describe("isAuthorizedToBypass", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("should return false for undefined token", () => { + setAllowList([{ token: "abc" }]); + expect(isAuthorizedToBypass(undefined)).toBe(false); + }); + + it("should return false when env var is missing", () => { + expect(isAuthorizedToBypass("abc")).toBe(false); + }); + + it("should return false when token is not in the list", () => { + setAllowList([{ token: "abc" }]); + expect(isAuthorizedToBypass("xyz")).toBe(false); + }); + + it("should return true for a matching token", () => { + setAllowList([{ token: "abc" }]); + expect(isAuthorizedToBypass("abc")).toBe(true); + }); + + it("should return true for a matching token with name", () => { + setAllowList([{ token: "abc", name: "CI" }]); + expect(isAuthorizedToBypass("abc")).toBe(true); + }); + + it("should return false when token is not yet active", () => { + const future = new Date(Date.now() + 86400000).toISOString(); + setAllowList([{ token: "abc", startDate: future }]); + expect(isAuthorizedToBypass("abc")).toBe(false); + }); + + it("should return false when token has expired", () => { + const past = new Date(Date.now() - 86400000).toISOString(); + setAllowList([{ token: "abc", endDate: past }]); + expect(isAuthorizedToBypass("abc")).toBe(false); + }); + + it("should return true when within time window", () => { + const past = new Date(Date.now() - 86400000).toISOString(); + const future = new Date(Date.now() + 86400000).toISOString(); + setAllowList([{ token: "abc", startDate: past, endDate: future }]); + expect(isAuthorizedToBypass("abc")).toBe(true); + }); + + it("should handle invalid JSON in env var gracefully", () => { + vi.stubEnv("AUTH_TOKENS_ALLOW_LIST", "not json"); + expect(isAuthorizedToBypass("abc")).toBe(false); + }); + + it("should handle non-array JSON gracefully", () => { + vi.stubEnv("AUTH_TOKENS_ALLOW_LIST", '{"token": "abc"}'); + expect(isAuthorizedToBypass("abc")).toBe(false); + }); + + it("should skip malformed entries missing token field", () => { + setAllowList([{ name: "no-token" }, { token: "abc" }]); + expect(isAuthorizedToBypass("abc")).toBe(true); + }); + + it("should skip entries where token is not a string", () => { + setAllowList([{ token: 123 }, { token: "abc" }]); + expect(isAuthorizedToBypass("abc")).toBe(true); + }); +}); + +function setAllowList(list: unknown) { + vi.stubEnv("AUTH_TOKENS_ALLOW_LIST", JSON.stringify(list)); +} \ No newline at end of file diff --git a/app/api/request/__tests__/handler.test.ts b/app/api/request/__tests__/handler.test.ts new file mode 100644 index 0000000..4f6c36b --- /dev/null +++ b/app/api/request/__tests__/handler.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Keypair } from "@solana/web3.js"; + +import { AIRDROP_TIERS } from "@/lib/airdrop"; + +vi.mock("@/lib/cloudflare", () => ({ + checkCloudflare: vi.fn().mockResolvedValue(true), +})); + +vi.mock("@/lib/backend", () => ({ + transactionsAPI: { + create: vi.fn().mockResolvedValue({}), + getLastTransactions: vi.fn().mockResolvedValue([]), + }, + validationAPI: { + validate: vi.fn().mockResolvedValue({ valid: true }), + }, +})); + +vi.mock("@solana-developers/helpers", () => ({ + sendTransaction: vi.fn().mockResolvedValue("mock-sig"), +})); + +vi.mock("@/lib/analytics", () => ({ + trackEvent: vi.fn(), +})); + +import { handleAirdrop } from "../handler"; +import type { RequestContext } from "../types"; +import { transactionsAPI, validationAPI } from "@/lib/backend"; +import { checkCloudflare } from "@/lib/cloudflare"; +import { sendTransaction } from "@solana-developers/helpers"; +import { trackEvent } from "@/lib/analytics"; + +const WALLET = Keypair.generate().publicKey.toBase58(); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(validationAPI.validate).mockResolvedValue({ valid: true }); + vi.mocked(transactionsAPI.getLastTransactions).mockResolvedValue([]); + vi.mocked(transactionsAPI.create).mockResolvedValue({}); + vi.mocked(checkCloudflare).mockResolvedValue(true); + vi.mocked(sendTransaction).mockResolvedValue("mock-sig"); +}); + +describe("handleAirdrop", () => { + it("should succeed on happy path", async () => { + const result = await handleAirdrop(buildCtx()); + + expect(result).toEqual({ success: true, signature: "mock-sig" }); + }); + + it("should record the transaction", async () => { + await handleAirdrop(buildCtx()); + + expect(transactionsAPI.create).toHaveBeenCalledWith( + "mock-sig", + expect.any(String), + WALLET, + "user-123", + expect.any(Number), + ); + }); + + describe("GitHub auth", () => { + it("should fail when no GitHub session and not bypassed", async () => { + const result = await handleAirdrop( + buildCtx({ githubUserId: undefined }), + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("github_auth_required"); + } + }); + }); + + describe("captcha", () => { + it("should skip captcha when skipCaptcha is true", async () => { + await handleAirdrop(buildCtx({ skipCaptcha: true })); + + expect(checkCloudflare).not.toHaveBeenCalled(); + }); + + it("should fail when captcha token is missing", async () => { + const result = await handleAirdrop( + buildCtx({ + skipCaptcha: false, + body: { recipientAddress: WALLET, amount: 1, network: "devnet", captchaToken: undefined }, + }), + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("captcha_missing"); + } + }); + + it("should fail when captcha verification fails", async () => { + vi.mocked(checkCloudflare).mockResolvedValue(false); + + const result = await handleAirdrop( + buildCtx({ skipCaptcha: false }), + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("captcha_failed"); + } + }); + }); + + describe("rate limiting", () => { + it("should fail when backend blocklist rejects", async () => { + vi.mocked(validationAPI.validate).mockResolvedValue({ + valid: false, + reason: "blocked", + }); + + const result = await handleAirdrop(buildCtx()); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("validation_rejected"); + } + }); + + it("should fail when rate limited", async () => { + const MS_PER_HOUR = 60 * 60 * 1000; + vi.mocked(transactionsAPI.getLastTransactions).mockResolvedValue([ + { signature: "s1", ip_address: "1234", wallet_address: WALLET, timestamp: Date.now() - MS_PER_HOUR }, + { signature: "s2", ip_address: "1234", wallet_address: WALLET, timestamp: Date.now() - 2 * MS_PER_HOUR }, + ]); + + const result = await handleAirdrop(buildCtx()); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("rate_limited"); + } + }); + }); + + describe("transaction failure", () => { + it("should fail when sendTransaction throws", async () => { + vi.mocked(sendTransaction).mockRejectedValue(new Error("tx failed")); + + const result = await handleAirdrop(buildCtx()); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("tx_failed"); + } + }); + + it("should fail when sendTransaction returns empty signature", async () => { + vi.mocked(sendTransaction).mockResolvedValue(""); + + const result = await handleAirdrop(buildCtx()); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("tx_no_signature"); + } + }); + + it("should still succeed when recording the transaction fails", async () => { + vi.mocked(transactionsAPI.create).mockRejectedValue( + new Error("db error"), + ); + + const result = await handleAirdrop(buildCtx()); + + expect(result).toEqual({ success: true, signature: "mock-sig" }); + expect(console.error).toHaveBeenCalledWith( + "[AIRDROP] Failed to record transaction:", + expect.any(Error), + ); + }); + }); + + describe("auth bypass", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("with allow-listed IP", () => { + beforeEach(() => { + vi.stubEnv("IP_ALLOW_LIST", JSON.stringify(["1.2.3.4"])); + }); + + it("should skip rate limits but still require GitHub auth and captcha", async () => { + const result = await handleAirdrop( + buildCtx({ skipCaptcha: false }), + ); + + expect(result.success).toBe(true); + expect(checkCloudflare).toHaveBeenCalled(); + expect(validationAPI.validate).not.toHaveBeenCalled(); + expect(trackEvent).toHaveBeenCalledWith( + "airdrop_bypass_requested", + expect.objectContaining({ bypass_reason: "allow_listed_ip" }), + "1234", + ); + }); + + // Regression: a spoofed cf-connecting-ip must not unlock GitHub-auth + // and captcha gates, only the rate-limit gate. + it("should reject when GitHub session is missing", async () => { + const result = await handleAirdrop( + buildCtx({ + githubUserId: undefined, + skipCaptcha: false, + body: { recipientAddress: WALLET, amount: 1, network: "devnet", captchaToken: undefined }, + }), + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("github_auth_required"); + } + expect(checkCloudflare).not.toHaveBeenCalled(); + }); + + it("should reject when captcha fails", async () => { + vi.mocked(checkCloudflare).mockResolvedValue(false); + + const result = await handleAirdrop( + buildCtx({ skipCaptcha: false }), + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("captcha_failed"); + } + }); + }); + + describe("with allow-listed auth token", () => { + beforeEach(() => { + vi.stubEnv( + "AUTH_TOKENS_ALLOW_LIST", + JSON.stringify([{ token: "ci-token", name: "CI" }]), + ); + }); + + it("should skip GitHub auth, captcha, and rate limits", async () => { + const result = await handleAirdrop( + buildCtx({ + authToken: "ci-token", + githubUserId: undefined, + skipCaptcha: false, + body: { recipientAddress: WALLET, amount: 1, network: "devnet", captchaToken: undefined }, + }), + ); + + expect(result.success).toBe(true); + expect(checkCloudflare).not.toHaveBeenCalled(); + expect(validationAPI.validate).not.toHaveBeenCalled(); + expect(trackEvent).toHaveBeenCalledWith( + "airdrop_bypass_requested", + expect.objectContaining({ bypass_reason: "auth_token" }), + "1234", + ); + }); + + it("should pass undefined github_id to recordTransaction for token bypass callers", async () => { + // Backend's githubIdSchema is `^\d+$` + .optional(): empty string fails + // the regex but `undefined` (omitted by JSON.stringify) is accepted. + await handleAirdrop( + buildCtx({ + authToken: "ci-token", + githubUserId: undefined, + skipCaptcha: false, + body: { recipientAddress: WALLET, amount: 1, network: "devnet", captchaToken: undefined }, + }), + ); + + expect(transactionsAPI.create).toHaveBeenCalledWith( + "mock-sig", + expect.any(String), + WALLET, + undefined, + expect.any(Number), + ); + }); + }); + + it("should not emit bypass event when not bypassed", async () => { + await handleAirdrop(buildCtx()); + + expect(trackEvent).not.toHaveBeenCalledWith( + "airdrop_bypass_requested", + expect.anything(), + expect.anything(), + ); + }); + }); +}); + +function buildCtx(overrides: Partial = {}): RequestContext { + return { + ip: "1.2.3.4", + sanitizedIp: "1234", + faucetKeypair: Keypair.generate(), + authToken: undefined, + githubUserId: "user-123", + tier: AIRDROP_TIERS.default, + skipCaptcha: true, + body: { + recipientAddress: WALLET, + amount: 1, + network: "devnet", + captchaToken: "token", + }, + ...overrides, + }; +} diff --git a/app/api/request/__tests__/ip.test.ts b/app/api/request/__tests__/ip.test.ts new file mode 100644 index 0000000..244c207 --- /dev/null +++ b/app/api/request/__tests__/ip.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { sanitizeIp, isAllowListedIp } from "../ip"; + +vi.spyOn(console, "error").mockImplementation(() => {}); + +describe("sanitizeIp", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it("should strip colons from IPv6", () => { + expect(sanitizeIp("::1")).toBe("1"); + }); + + it("should strip colons from full IPv6", () => { + expect(sanitizeIp("2001:0db8:85a3:0000:0000:8a2e:0370:7334")).toBe( + "20010db885a3000000008a2e03707334", + ); + }); + + it("should strip dots from IPv4", () => { + expect(sanitizeIp("192.168.1.1")).toBe("19216811"); + }); +}); + +describe("isAllowListedIp", () => { + it("should return false when env var is missing", () => { + expect(isAllowListedIp("1.2.3.4")).toBe(false); + }); + + it("should return true when IP is in the list", () => { + vi.stubEnv("IP_ALLOW_LIST", '["1.2.3.4", "5.6.7.8"]'); + expect(isAllowListedIp("1.2.3.4")).toBe(true); + }); + + it("should return false when IP is not in the list", () => { + vi.stubEnv("IP_ALLOW_LIST", '["1.2.3.4"]'); + expect(isAllowListedIp("9.9.9.9")).toBe(false); + }); + + it("should handle invalid JSON gracefully", () => { + vi.stubEnv("IP_ALLOW_LIST", "not json"); + expect(isAllowListedIp("1.2.3.4")).toBe(false); + }); + + it("should handle empty array", () => { + vi.stubEnv("IP_ALLOW_LIST", "[]"); + expect(isAllowListedIp("1.2.3.4")).toBe(false); + }); +}); diff --git a/app/api/request/__tests__/rate-limiting.test.ts b/app/api/request/__tests__/rate-limiting.test.ts new file mode 100644 index 0000000..1da7f18 --- /dev/null +++ b/app/api/request/__tests__/rate-limiting.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Keypair } from "@solana/web3.js"; + +import { AIRDROP_TIERS, type FaucetTransaction } from "@/lib/airdrop"; + +import { enforceRateLimits } from "../rate-limiting"; +import { AirdropError, AirdropErrorCode } from "../airdrop-error"; +import type { AuthenticatedRequestContext } from "../types"; + +vi.mock("@/lib/backend", () => ({ + validationAPI: { + validate: vi.fn(), + }, + transactionsAPI: { + getLastTransactions: vi.fn(), + }, +})); + +import { validationAPI, transactionsAPI } from "@/lib/backend"; + +const VALID_WALLET = Keypair.generate().publicKey.toBase58(); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +describe("enforceRateLimits", () => { + describe("blocklist", () => { + it("should pass when backend validates the identity", async () => { + vi.mocked(validationAPI.validate).mockResolvedValue({ valid: true }); + vi.mocked(transactionsAPI.getLastTransactions).mockResolvedValue([]); + + await expect(enforceRateLimits(buildCtx())).resolves.toBeUndefined(); + }); + + it("should throw VALIDATION_REJECTED when backend rejects the identity", async () => { + vi.mocked(validationAPI.validate).mockResolvedValue({ + valid: false, + reason: "blocked wallet", + }); + + await expectAirdropError( + () => enforceRateLimits(buildCtx()), + AirdropErrorCode.VALIDATION_REJECTED.code, + ); + }); + + it("should include backend reason in error message", async () => { + vi.mocked(validationAPI.validate).mockResolvedValue({ + valid: false, + reason: "known abuser", + }); + + try { + await enforceRateLimits(buildCtx()); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(AirdropError); + expect((err as AirdropError).message).toBe("known abuser"); + } + }); + }); + + describe("frequency limit", () => { + beforeEach(() => { + vi.mocked(validationAPI.validate).mockResolvedValue({ valid: true }); + }); + + it("should pass with no previous transactions", async () => { + vi.mocked(transactionsAPI.getLastTransactions).mockResolvedValue([]); + + await expect(enforceRateLimits(buildCtx())).resolves.toBeUndefined(); + }); + + it("should pass when recent transactions are below the limit", async () => { + // default tier: allowedRequests=2, coveredHours=8 + // 1 recent tx is below the limit of 2 + vi.mocked(transactionsAPI.getLastTransactions).mockResolvedValue([ + makeTx(1), // 1 hour ago — within window + ]); + + await expect(enforceRateLimits(buildCtx())).resolves.toBeUndefined(); + }); + + it("should throw RATE_LIMITED when recent transactions meet the limit", async () => { + // 2 transactions within the window = meets allowedRequests of 2 + vi.mocked(transactionsAPI.getLastTransactions).mockResolvedValue([ + makeTx(1), + makeTx(2), + ]); + + await expectAirdropError( + () => enforceRateLimits(buildCtx()), + AirdropErrorCode.RATE_LIMITED.code, + ); + }); + + it("should not count transactions outside the covered window", async () => { + // Both transactions are older than 8 hours — outside window + vi.mocked(transactionsAPI.getLastTransactions).mockResolvedValue([ + makeTx(9), + makeTx(10), + ]); + + await expect(enforceRateLimits(buildCtx())).resolves.toBeUndefined(); + }); + + it("should only count transactions within the window against the limit", async () => { + // 1 within window, 1 outside — only 1 counts, below limit of 2 + vi.mocked(transactionsAPI.getLastTransactions).mockResolvedValue([ + makeTx(1), // within 8hr window + makeTx(10), // outside 8hr window + ]); + + await expect(enforceRateLimits(buildCtx())).resolves.toBeUndefined(); + }); + + it("should pass correct parameters to getLastTransactions", async () => { + vi.mocked(transactionsAPI.getLastTransactions).mockResolvedValue([]); + const ctx = buildCtx(); + + await enforceRateLimits(ctx); + + expect(transactionsAPI.getLastTransactions).toHaveBeenCalledWith( + ctx.body.recipientAddress, + ctx.githubUserId, + ctx.sanitizedIp, + ctx.tier.allowedRequests, + ); + }); + }); +}); + +function buildCtx( + overrides: Partial = {}, +): AuthenticatedRequestContext { + return { + ip: "1.2.3.4", + sanitizedIp: "1234", + faucetKeypair: Keypair.generate(), + authToken: undefined, + githubUserId: "user-123", + tier: AIRDROP_TIERS.default, + skipCaptcha: true, + body: { + recipientAddress: VALID_WALLET, + amount: 1, + network: "devnet", + captchaToken: "tok", + }, + ...overrides, + }; +} + +function makeTx(ageHours: number): FaucetTransaction { + const MS_PER_HOUR = 60 * 60 * 1000; + return { + signature: "sig-" + Math.random().toString(36).slice(2), + ip_address: "1234", + wallet_address: VALID_WALLET, + github_id: "user-123", + timestamp: Date.now() - ageHours * MS_PER_HOUR, + }; +} + +async function expectAirdropError( + fn: () => Promise, + expectedCode: string, +) { + try { + await fn(); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(AirdropError); + expect((err as AirdropError).code).toBe(expectedCode); + } +} diff --git a/app/api/request/__tests__/route.test.ts b/app/api/request/__tests__/route.test.ts new file mode 100644 index 0000000..ce480fb --- /dev/null +++ b/app/api/request/__tests__/route.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { AirdropError, AirdropErrorCode } from "../airdrop-error"; + +vi.mock("@/lib/auth", () => ({ + withOptionalUserSession: (handler: Function) => { + return (req: Request, _opts?: { params?: Record }) => { + const sessionHeader = req.headers.get("x-test-session"); + const session = sessionHeader ? JSON.parse(sessionHeader) : null; + return handler({ req, session }); + }; + }, +})); + +vi.mock("@solana-developers/helpers", () => ({ + getKeypairFromEnvironment: vi.fn().mockReturnValue( + (() => { + const { Keypair } = require("@solana/web3.js"); + return Keypair.generate(); + })(), + ), +})); + +vi.mock("../handler", () => ({ + handleAirdrop: vi.fn().mockResolvedValue({ success: true, signature: "sig" }), +})); + +import { POST as _POST } from "../route"; +import { handleAirdrop } from "../handler"; + +const POST = (req: Request) => _POST(req, { params: {} }); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(handleAirdrop).mockResolvedValue({ success: true, signature: "sig" }); +}); + +describe("POST /api/request", () => { + describe("response mapping", () => { + it("should return 200 when handler succeeds", async () => { + const res = await POST(buildRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ success: true, message: "Airdrop successful", signature: "sig" }); + }); + + it("should return error status when handler fails", async () => { + vi.mocked(handleAirdrop).mockResolvedValue({ + success: false, + error: new AirdropError(AirdropErrorCode.RATE_LIMITED), + }); + + const res = await POST(buildRequest()); + expect(res.status).toBe(429); + }); + + it("should return error message in response body", async () => { + vi.mocked(handleAirdrop).mockResolvedValue({ + success: false, + error: new AirdropError(AirdropErrorCode.TX_FAILED), + }); + + const res = await POST(buildRequest()); + expect(await res.text()).toBe("Faucet is empty, ping @solana_devs on Twitter"); + }); + }); + + describe("buildContext errors", () => { + it("should return 400 for non-JSON body", async () => { + const req = new Request("http://localhost/api/request", { + method: "POST", + headers: { + "cf-connecting-ip": "1.2.3.4", + "x-test-session": JSON.stringify({ + user: { githubUserId: "user-123" }, + }), + }, + body: "not-json", + }); + + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("should return 400 for null body", async () => { + const req = new Request("http://localhost/api/request", { + method: "POST", + headers: { + "cf-connecting-ip": "1.2.3.4", + "content-type": "application/json", + "x-test-session": JSON.stringify({ + user: { githubUserId: "user-123" }, + }), + }, + body: "null", + }); + + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("should return 400 for array body", async () => { + const req = new Request("http://localhost/api/request", { + method: "POST", + headers: { + "cf-connecting-ip": "1.2.3.4", + "content-type": "application/json", + "x-test-session": JSON.stringify({ + user: { githubUserId: "user-123" }, + }), + }, + body: "[]", + }); + + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("should return 400 for invalid wallet in body", async () => { + const res = await POST(buildRequest({ walletAddress: "bad" })); + expect(res.status).toBe(400); + }); + + it("should return 400 for invalid amount", async () => { + const res = await POST(buildRequest({ amount: 999 })); + expect(res.status).toBe(400); + }); + + it("should return 400 for invalid network", async () => { + const res = await POST(buildRequest({ network: "mainnet" })); + expect(res.status).toBe(400); + }); + }); + + describe("context passed to handler", () => { + it("should pass parsed session githubUserId", async () => { + await POST(buildRequest()); + + expect(handleAirdrop).toHaveBeenCalledWith( + expect.objectContaining({ githubUserId: "user-123" }), + ); + }); + + it("should pass undefined githubUserId when no session", async () => { + await POST(buildRequest({}, { "x-test-session": "null" })); + + expect(handleAirdrop).toHaveBeenCalledWith( + expect.objectContaining({ githubUserId: undefined }), + ); + }); + + it("should pass sanitized IP", async () => { + await POST(buildRequest()); + + expect(handleAirdrop).toHaveBeenCalledWith( + expect.objectContaining({ ip: "1.2.3.4", sanitizedIp: "1234" }), + ); + }); + }); + + describe("sensitive field redaction", () => { + it("should keep faucetKeypair and authToken accessible to consumers", async () => { + await POST(buildRequest({}, { authorization: "Bearer secret-token" })); + + const ctx = vi.mocked(handleAirdrop).mock.calls[0][0]; + expect(ctx.faucetKeypair).toBeDefined(); + expect(ctx.authToken).toBe("secret-token"); + }); + + it("should hide sensitive fields from Object.keys and spread", async () => { + await POST(buildRequest({}, { authorization: "Bearer secret-token" })); + + const ctx = vi.mocked(handleAirdrop).mock.calls[0][0]; + expect(Object.keys(ctx)).not.toContain("faucetKeypair"); + expect(Object.keys(ctx)).not.toContain("authToken"); + expect({ ...ctx }).not.toHaveProperty("faucetKeypair"); + expect({ ...ctx }).not.toHaveProperty("authToken"); + }); + + it("should redact sensitive fields in JSON.stringify", async () => { + await POST(buildRequest({}, { authorization: "Bearer secret-token" })); + + const ctx = vi.mocked(handleAirdrop).mock.calls[0][0]; + const json = JSON.stringify(ctx); + expect(json).not.toContain("secret-token"); + expect(JSON.parse(json)).toMatchObject({ + faucetKeypair: "", + authToken: "", + }); + }); + + it("should omit authToken redaction placeholder when not present", async () => { + await POST(buildRequest()); + + const ctx = vi.mocked(handleAirdrop).mock.calls[0][0]; + const parsed = JSON.parse(JSON.stringify(ctx)); + expect(parsed).toMatchObject({ faucetKeypair: "" }); + expect(parsed).not.toHaveProperty("authToken"); + }); + }); +}); + +function buildRequest( + bodyOverrides: Record = {}, + headerOverrides: Record = {}, +): Request { + const body = { + walletAddress: "GFNcycM4KJBibmCGMNyCt9ywUERQBgJ9AMqVuRw7HbZU", + amount: 1, + network: "devnet", + cloudflareCallback: "captcha-token", + ...bodyOverrides, + }; + + const headers: Record = { + "cf-connecting-ip": "1.2.3.4", + "content-type": "application/json", + "x-test-session": JSON.stringify({ + user: { githubUserId: "user-123", githubUsername: "testuser" }, + }), + ...headerOverrides, + }; + + return new Request("http://localhost/api/request", { + method: "POST", + headers, + body: JSON.stringify(body), + }); +} diff --git a/app/api/request/__tests__/tracking.test.ts b/app/api/request/__tests__/tracking.test.ts new file mode 100644 index 0000000..be46216 --- /dev/null +++ b/app/api/request/__tests__/tracking.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Keypair } from "@solana/web3.js"; + +import { AIRDROP_TIERS } from "@/lib/airdrop"; + +vi.mock("@/lib/analytics", () => ({ + trackEvent: vi.fn(), +})); + +import { trackSuccess, trackError, trackBypass } from "../tracking"; +import { AirdropError, AirdropErrorCode } from "../airdrop-error"; +import { trackEvent } from "@/lib/analytics"; +import type { RequestContext } from "../types"; + +const WALLET = "8rWi9H6wcZVPSA3A8LnE7KVvUnPnPnX1zvWnbGN66bBi"; + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +describe("trackSuccess", () => { + it("should send airdrop_success event with correct params", () => { + const ctx = buildCtx(); + + trackSuccess("client-1", ctx, "sig-abc"); + + expect(trackEvent).toHaveBeenCalledWith( + "airdrop_success", + { + network: "devnet", + amount: 1, + has_github: "yes", + wallet: "8rWi...6bBi", + signature: "sig-abc", + }, + "client-1", + ); + }); + + it("should report has_github as 'no' when githubUserId is undefined", () => { + const ctx = buildCtx({ githubUserId: undefined }); + + trackSuccess("client-1", ctx, "sig-abc"); + + expect(trackEvent).toHaveBeenCalledWith( + "airdrop_success", + expect.objectContaining({ has_github: "no" }), + "client-1", + ); + }); +}); + +describe("trackBypass", () => { + it("should send airdrop_bypass_requested with auth_token reason", () => { + const ctx = buildCtx({ authToken: "secret-token" }); + + trackBypass("client-1", ctx, "auth_token"); + + expect(trackEvent).toHaveBeenCalledWith( + "airdrop_bypass_requested", + { + network: "devnet", + amount: 1, + has_github: "yes", + wallet: "8rWi...6bBi", + bypass_reason: "auth_token", + }, + "client-1", + ); + }); + + it("should send airdrop_bypass_requested with allow_listed_ip reason", () => { + const ctx = buildCtx(); + + trackBypass("client-1", ctx, "allow_listed_ip"); + + expect(trackEvent).toHaveBeenCalledWith( + "airdrop_bypass_requested", + expect.objectContaining({ bypass_reason: "allow_listed_ip" }), + "client-1", + ); + }); + + it("should not include the raw auth token in the payload", () => { + const ctx = buildCtx({ authToken: "super-secret" }); + + trackBypass("client-1", ctx, "auth_token"); + + const payload = (trackEvent as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][1]; + expect(JSON.stringify(payload)).not.toContain("super-secret"); + }); +}); + +describe("trackError", () => { + it("should send airdrop_failed event for AirdropError", () => { + const ctx = buildCtx(); + const err = new AirdropError(AirdropErrorCode.RATE_LIMITED); + + trackError("client-1", ctx, err); + + expect(trackEvent).toHaveBeenCalledWith( + "airdrop_failed", + { + reason: "rate_limited", + error_message: "Rate limit exceeded", + network: "devnet", + amount: 1, + has_github: "yes", + wallet: "8rWi...6bBi", + }, + "client-1", + ); + }); + + it("should include cause message when present", () => { + const ctx = buildCtx(); + const cause = new Error("connection refused"); + const err = new AirdropError(AirdropErrorCode.TX_FAILED, { cause }); + + trackError("client-1", ctx, err); + + expect(trackEvent).toHaveBeenCalledWith( + "airdrop_failed", + expect.objectContaining({ cause: "connection refused" }), + "client-1", + ); + }); + + it("should handle non-AirdropError with reason 'unhandled'", () => { + const ctx = buildCtx(); + + trackError("client-1", ctx, new Error("something broke")); + + expect(trackEvent).toHaveBeenCalledWith( + "airdrop_failed", + expect.objectContaining({ + reason: "unhandled", + error_message: "something broke", + }), + "client-1", + ); + }); + + it("should handle non-Error values", () => { + const ctx = buildCtx(); + + trackError("client-1", ctx, "string error"); + + expect(trackEvent).toHaveBeenCalledWith( + "airdrop_failed", + expect.objectContaining({ + reason: "unhandled", + error_message: "unknown", + }), + "client-1", + ); + }); +}); + + +function buildCtx(overrides: Partial = {}): RequestContext { + return { + ip: "1.2.3.4", + sanitizedIp: "1234", + faucetKeypair: Keypair.generate(), + authToken: undefined, + githubUserId: "user-123", + tier: AIRDROP_TIERS.default, + skipCaptcha: true, + body: { + recipientAddress: WALLET, + amount: 1, + network: "devnet", + captchaToken: "token", + }, + ...overrides, + }; +} \ No newline at end of file diff --git a/app/api/request/__tests__/validation.test.ts b/app/api/request/__tests__/validation.test.ts new file mode 100644 index 0000000..f8e9224 --- /dev/null +++ b/app/api/request/__tests__/validation.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import { Keypair } from "@solana/web3.js"; +import { validateBody } from "../validation"; +import { AirdropError, AirdropErrorCode } from "../airdrop-error"; +import { AIRDROP_TIERS, VALID_AMOUNTS } from "@/lib/airdrop"; + +const VALID_WALLET = Keypair.generate().publicKey.toBase58(); +const DEFAULT_TIER = AIRDROP_TIERS.default; + + +describe("validateBody", () => { + it("should accept a valid request", () => { + const result = validateBody(validInput(), DEFAULT_TIER); + expect(result).toEqual({ + recipientAddress: VALID_WALLET, + amount: 1, + network: "devnet", + captchaToken: "captcha-token", + }); + }); + + it("should accept testnet", () => { + const result = validateBody(validInput({ network: "testnet" }), DEFAULT_TIER); + expect(result.network).toBe("testnet"); + }); + + it("should accept all valid amounts", () => { + for (const amount of VALID_AMOUNTS) { + const result = validateBody(validInput({ amount }), DEFAULT_TIER); + expect(result.amount).toBe(amount); + } + }); + + it("should set captchaToken to undefined when cloudflareCallback is missing", () => { + const result = validateBody(validInput({ cloudflareCallback: undefined }), DEFAULT_TIER); + expect(result.captchaToken).toBeUndefined(); + }); + + it("should set captchaToken to undefined when cloudflareCallback is not a string", () => { + const result = validateBody(validInput({ cloudflareCallback: 123 }), DEFAULT_TIER); + expect(result.captchaToken).toBeUndefined(); + }); + + describe("wallet validation", () => { + it("should throw MISSING_WALLET when walletAddress is missing", () => { + expectAirdropError( + () => validateBody(validInput({ walletAddress: undefined }), DEFAULT_TIER), + AirdropErrorCode.MISSING_WALLET.code, + ); + }); + + it("should throw MISSING_WALLET when walletAddress is empty", () => { + expectAirdropError( + () => validateBody(validInput({ walletAddress: "" }), DEFAULT_TIER), + AirdropErrorCode.MISSING_WALLET.code, + ); + }); + + it("should throw INVALID_WALLET for a non-base58 address", () => { + expectAirdropError( + () => validateBody(validInput({ walletAddress: "not-a-wallet" }), DEFAULT_TIER), + AirdropErrorCode.INVALID_WALLET.code, + ); + }); + }); + + describe("amount validation", () => { + it("should throw INVALID_AMOUNT when amount is missing", () => { + expectAirdropError( + () => validateBody(validInput({ amount: undefined }), DEFAULT_TIER), + AirdropErrorCode.INVALID_AMOUNT.code, + ); + }); + + it("should throw INVALID_AMOUNT for non-number", () => { + expectAirdropError( + () => validateBody(validInput({ amount: "1" }), DEFAULT_TIER), + AirdropErrorCode.INVALID_AMOUNT.code, + ); + }); + + it("should throw INVALID_AMOUNT for disallowed value", () => { + expectAirdropError( + () => validateBody(validInput({ amount: 10 }), DEFAULT_TIER), + AirdropErrorCode.INVALID_AMOUNT.code, + ); + }); + }); + + describe("network validation", () => { + it("should throw INVALID_NETWORK when network is missing", () => { + expectAirdropError( + () => validateBody(validInput({ network: undefined }), DEFAULT_TIER), + AirdropErrorCode.INVALID_NETWORK.code, + ); + }); + + it("should throw INVALID_NETWORK for unknown network", () => { + expectAirdropError( + () => validateBody(validInput({ network: "mainnet" }), DEFAULT_TIER), + AirdropErrorCode.INVALID_NETWORK.code, + ); + }); + }); +}); + +function validInput(overrides: Record = {}): Record { + return { + walletAddress: VALID_WALLET, + amount: 1, + network: "devnet", + cloudflareCallback: "captcha-token", + ...overrides, + }; +} + +function expectAirdropError(fn: () => unknown, expectedCode: string) { + try { + fn(); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(AirdropError); + expect((err as AirdropError).code).toBe(expectedCode); + } +} \ No newline at end of file diff --git a/app/api/request/airdrop-error.ts b/app/api/request/airdrop-error.ts new file mode 100644 index 0000000..48e1d2f --- /dev/null +++ b/app/api/request/airdrop-error.ts @@ -0,0 +1,41 @@ +export const AirdropErrorCode = { + // Request parsing + NO_IP: { code: "no_ip", status: 400, message: "Could not determine client IP address" }, + NO_KEYPAIR: { code: "no_keypair", status: 500, message: "Internal server error" }, + INVALID_BODY: { code: "invalid_body", status: 400, message: "Invalid request body" }, + + // Validation + MISSING_WALLET: { code: "missing_wallet", status: 400, message: "Missing wallet address" }, + INVALID_WALLET: { code: "invalid_wallet", status: 400, message: "Invalid wallet address" }, + INVALID_AMOUNT: { code: "invalid_amount", status: 400, message: "Invalid SOL amount" }, + AMOUNT_TOO_LARGE: { code: "amount_too_large", status: 400, message: "Requested SOL amount too large" }, + INVALID_NETWORK: { code: "invalid_network", status: 400, message: "Invalid network" }, + + // Auth + GITHUB_AUTH_REQUIRED: { code: "github_auth_required", status: 400, message: "GitHub authentication is required. Please sign in with GitHub to use the faucet." }, + + // Captcha + CAPTCHA_MISSING: { code: "captcha_missing", status: 400, message: "Missing CAPTCHA token" }, + CAPTCHA_FAILED: { code: "captcha_failed", status: 400, message: "Invalid CAPTCHA" }, + + // Rate limiting + VALIDATION_REJECTED: { code: "validation_rejected", status: 429, message: "Request rejected by validation" }, + RATE_LIMITED: { code: "rate_limited", status: 429, message: "Rate limit exceeded" }, + + // Transaction + TX_FAILED: { code: "tx_failed", status: 400, message: "Faucet is empty, ping @solana_devs on Twitter" }, + TX_NO_SIGNATURE: { code: "tx_no_signature", status: 400, message: "Transaction failed" }, +} as const; + +type ErrorDef = typeof AirdropErrorCode[keyof typeof AirdropErrorCode]; + +export class AirdropError extends Error { + readonly status: number; + readonly code: string; + + constructor(def: ErrorDef, options?: ErrorOptions & { message?: string }) { + super(options?.message ?? def.message, options); + this.status = def.status; + this.code = def.code; + } +} diff --git a/app/api/request/auth-bypass.ts b/app/api/request/auth-bypass.ts new file mode 100644 index 0000000..3ac8f47 --- /dev/null +++ b/app/api/request/auth-bypass.ts @@ -0,0 +1,60 @@ +/** + * Auth token bypass for the airdrop endpoint. + * + * Tokens in the AUTH_TOKENS_ALLOW_LIST env var can skip captcha and rate + * limits. Each token may have an optional time window (startDate/endDate). + * + * Expected env format: + * [{ "token": "abc", "name": "CI", "startDate": "...", "endDate": "..." }] + */ + +import { timingSafeEqual } from "crypto"; + +interface AuthToken { + token: string; + name?: string; + startDate?: string; + endDate?: string; +} + +function parseAuthTokenAllowList(): AuthToken[] { + let raw: unknown; + try { + raw = JSON.parse(process.env.AUTH_TOKENS_ALLOW_LIST || "[]"); + } catch { + // Don't log the parse error — its message can echo bytes from the env var. + console.error("[AUTH-BYPASS] AUTH_TOKENS_ALLOW_LIST is not valid JSON; bypass disabled"); + return []; + } + + if (!Array.isArray(raw)) return []; + + return raw.filter( + (item): item is AuthToken => + typeof item === "object" && + item !== null && + typeof item.token === "string", + ); +} + +export function isAuthorizedToBypass(authToken: string | undefined): boolean { + if (!authToken) return false; + + const match = parseAuthTokenAllowList().find(item => safeEqual(item.token, authToken)); + if (!match) return false; + + const now = new Date(); + if (match.startDate && new Date(match.startDate) >= now) return false; + if (match.endDate && new Date(match.endDate) <= now) return false; + + return true; +} + +function safeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return timingSafeEqual( + new Uint8Array(new TextEncoder().encode(a)), + new Uint8Array(new TextEncoder().encode(b)), + ); +} + diff --git a/app/api/request/execute-airdrop.ts b/app/api/request/execute-airdrop.ts new file mode 100644 index 0000000..3092a2f --- /dev/null +++ b/app/api/request/execute-airdrop.ts @@ -0,0 +1,47 @@ +import { + LAMPORTS_PER_SOL, + PublicKey, + Transaction, + SystemProgram, +} from "@solana/web3.js"; +import { sendTransaction } from "@solana-developers/helpers"; + +import { getConnection } from "@/lib/rpc"; + +import { AirdropError, AirdropErrorCode } from "./airdrop-error"; +import type { RequestContext } from "./types"; + +const PRIORITY_FEE_LAMPORTS = 1_000_000; + +export async function executeAirdrop(ctx: RequestContext): Promise { + const { network, amount } = ctx.body; + + const connection = getConnection(network); + const lamports = Math.round(amount * LAMPORTS_PER_SOL); + + const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: ctx.faucetKeypair.publicKey, + toPubkey: new PublicKey(ctx.body.recipientAddress), + lamports, + }), + ); + + let signature: string; + try { + signature = await sendTransaction( + connection, + transaction, + [ctx.faucetKeypair], + PRIORITY_FEE_LAMPORTS, + ); + } catch (error) { + throw new AirdropError(AirdropErrorCode.TX_FAILED, { cause: error }); + } + + if (!signature) { + throw new AirdropError(AirdropErrorCode.TX_NO_SIGNATURE); + } + + return signature; +} diff --git a/app/api/request/handler.ts b/app/api/request/handler.ts new file mode 100644 index 0000000..dcfd1cc --- /dev/null +++ b/app/api/request/handler.ts @@ -0,0 +1,73 @@ +import { transactionsAPI } from "@/lib/backend"; + +import { AirdropError, AirdropErrorCode } from "./airdrop-error"; +import { isAuthorizedToBypass } from "./auth-bypass"; +import { isAllowListedIp } from "./ip"; +import { enforceRateLimits } from "./rate-limiting"; +import { verifyCaptcha } from "./verify-captcha"; +import { executeAirdrop } from "./execute-airdrop"; +import { trackBypass } from "./tracking"; +import type { RequestContext } from "./types"; + +export type AirdropResult = + | { success: true; signature: string } + | { success: false; error: AirdropError }; + +export async function handleAirdrop( + ctx: RequestContext, +): Promise { + try { + const tokenBypass = isAuthorizedToBypass(ctx.authToken); + const ipBypass = isAllowListedIp(ctx.ip); + + if (!tokenBypass) { + const { githubUserId } = ctx; + if (!githubUserId) { + throw new AirdropError(AirdropErrorCode.GITHUB_AUTH_REQUIRED); + } + await verifyCaptcha(ctx); + + if (!ipBypass) { + await enforceRateLimits({ ...ctx, githubUserId }); + } + } + + if (tokenBypass) { + trackBypass(ctx.sanitizedIp, ctx, "auth_token"); + } else if (ipBypass) { + trackBypass(ctx.sanitizedIp, ctx, "allow_listed_ip"); + } + + const signature = await executeAirdrop(ctx); + + try { + await recordTransaction(ctx, signature); + } catch (err) { + console.error("[AIRDROP] Failed to record transaction:", err); + } + + return { success: true, signature }; + } catch (err) { + if (err instanceof AirdropError) { + return { success: false, error: err }; + } + + return { + success: false, + error: new AirdropError(AirdropErrorCode.TX_FAILED, { cause: err }), + }; + } +} + +async function recordTransaction( + ctx: RequestContext, + signature: string, +): Promise { + await transactionsAPI.create( + signature, + ctx.sanitizedIp, + ctx.body.recipientAddress, + ctx.githubUserId, + Date.now(), + ); +} diff --git a/app/api/request/ip.ts b/app/api/request/ip.ts new file mode 100644 index 0000000..51709b7 --- /dev/null +++ b/app/api/request/ip.ts @@ -0,0 +1,42 @@ +/** + * Client IP utilities for the airdrop endpoint. + * + * Handles IP extraction (Cloudflare headers), sanitization for use as + * database/analytics keys, and allow-list bypass via IP_ALLOW_LIST env var. + */ + +/** + * Resolve client IP from Cloudflare headers, falling back to + * localhost in development. + */ +export function getClientIp(req: Request): string | undefined { + return ( + req.headers.get("cf-connecting-ipv6") || + req.headers.get("cf-connecting-ip") || + (process.env.NODE_ENV === "development" ? "::1" : undefined) + ); +} + +/** + * Strip delimiters from an IP address to produce a stable identifier + * suitable for use as a database key or analytics client ID. + */ +export function sanitizeIp(ip: string): string { + return ip.includes(":") ? ip.replace(/:/g, "") : ip.replace(/\./g, ""); +} + +/** + * Check if an IP is on the allow list defined by IP_ALLOW_LIST env var. + */ +export function isAllowListedIp(ip: string): boolean { + return parseAllowList().includes(ip); +} + +function parseAllowList(): string[] { + try { + return JSON.parse(process.env.IP_ALLOW_LIST || "[]"); + } catch { + console.error("[CONFIG] Invalid IP_ALLOW_LIST JSON, using empty list"); + return []; + } +} diff --git a/app/api/request/rate-limiting.ts b/app/api/request/rate-limiting.ts new file mode 100644 index 0000000..913048d --- /dev/null +++ b/app/api/request/rate-limiting.ts @@ -0,0 +1,87 @@ +/** + * Rate limiting for the airdrop endpoint. + * + * Two layers of enforcement, both delegated to the backend service: + * 1. Blocklist — the backend validates the IP/wallet/GitHub identity and may + * reject it outright (e.g. known abusers, flagged addresses). + * 2. Frequency limit — recent transactions for the same identity are counted + * against the tier's allowedRequests / coveredHours window. + * + * Throws AirdropError (VALIDATION_REJECTED or RATE_LIMITED) on failure. + */ + +import { type FaucetTransaction, type AirdropTier } from "@/lib/airdrop"; +import { transactionsAPI, validationAPI } from "@/lib/backend"; +import { AirdropError, AirdropErrorCode } from "./airdrop-error"; +import type { AuthenticatedRequestContext } from "./types"; + +// NOTE (pre-existing): There is a TOCTOU gap between checking rate limits +// here and recording the transaction after executeAirdrop() in the route +// handler. In theory concurrent requests could both pass before either is +// recorded. In practice this is mitigated by Turnstile (each request needs a +// fresh captcha token) and possibly by the backend DB itself. Fully closing +// the gap would require an atomic check-and-reserve at the backend level. +export async function enforceRateLimits( + ctx: AuthenticatedRequestContext, +): Promise { + await enforceBlocklist(ctx); + await enforceFrequencyLimit(ctx, ctx.tier); +} + +async function enforceBlocklist( + ctx: AuthenticatedRequestContext, +): Promise { + const { valid, reason } = await validationAPI.validate( + ctx.sanitizedIp, + ctx.body.recipientAddress, + ctx.githubUserId, + ); + + if (!valid) { + console.warn( + `[RATE LIMIT] backend rejected: ip=${ctx.sanitizedIp} wallet=${ctx.body.recipientAddress} github=${ctx.githubUserId} reason="${reason}"`, + ); + throw new AirdropError(AirdropErrorCode.VALIDATION_REJECTED, { + message: reason, + }); + } +} + +async function enforceFrequencyLimit( + ctx: AuthenticatedRequestContext, + tier: AirdropTier, +): Promise { + const lastTransactions = await transactionsAPI.getLastTransactions( + ctx.body.recipientAddress, + ctx.githubUserId, + ctx.sanitizedIp, + tier.allowedRequests, + ); + + if (!isWithinRateLimit(lastTransactions, tier)) { + console.warn( + `[RATE LIMIT] exceeded: ip=${ctx.sanitizedIp} wallet=${ctx.body.recipientAddress} github=${ctx.githubUserId} recent=${lastTransactions.length} allowed=${tier.allowedRequests} window=${tier.coveredHours}h`, + ); + throw new AirdropError(AirdropErrorCode.RATE_LIMITED, { + message: + `You have exceeded the ${tier.allowedRequests} airdrops limit ` + + `in the past ${tier.coveredHours} hour(s)`, + }); + } +} + +function isWithinRateLimit( + lastTransactions: FaucetTransaction[], + tier: AirdropTier, +): boolean { + const MS_PER_HOUR = 60 * 60 * 1000; + + if (lastTransactions.length === 0) return true; + + const windowStart = Date.now() - tier.coveredHours * MS_PER_HOUR; + const recentCount = lastTransactions.filter( + tx => tx.timestamp > windowStart, + ).length; + + return recentCount < tier.allowedRequests; +} diff --git a/app/api/request/route.ts b/app/api/request/route.ts index 0b07c24..a197d53 100644 --- a/app/api/request/route.ts +++ b/app/api/request/route.ts @@ -1,286 +1,126 @@ /** - * `/api/request` endpoint + * POST /api/request — Airdrop SOL on devnet or testnet. * - * allows people to request devnet and testnet sol be airdropped to their wallet + * Thin HTTP adapter: parses the request into a RequestContext, + * delegates to handleAirdrop, and maps the result to an HTTP response. */ +import type { Session } from "next-auth"; +import { NextResponse } from "next/server"; +import { Keypair } from "@solana/web3.js"; +import { getKeypairFromEnvironment } from "@solana-developers/helpers"; -import { - LAMPORTS_PER_SOL, - Keypair, - PublicKey, - Connection, - Transaction, - SystemProgram, -} from "@solana/web3.js"; -import { checkCloudflare } from "@/lib/cloudflare"; -import { - getKeypairFromEnvironment, - sendTransaction, -} from "@solana-developers/helpers"; import { withOptionalUserSession } from "@/lib/auth"; -import { AirdropRateLimit, FaucetTransaction } from "@/lib/constants"; -import { - getAirdropRateLimitForSession, - isAuthorizedToBypass, -} from "@/lib/utils"; -import { transactionsAPI, validationAPI } from "@/lib/backend"; -import { headers } from "next/headers"; +import { resolveTier } from "@/lib/airdrop/server"; -export const dynamic = "force-dynamic"; // defaults to auto -const GITHUB_LOGIN_REQUIRED = true; +import { trackEvent } from "@/lib/analytics"; -export const GET = () => { - return new Response("Nothing to see here"); -}; +import { getClientIp, sanitizeIp } from "./ip"; +import { AirdropError, AirdropErrorCode } from "./airdrop-error"; +import { trackError, trackSuccess } from "./tracking"; +import { validateBody } from "./validation"; +import { handleAirdrop } from "./handler"; +import type { AirdropResponse, RequestContext } from "./types"; -/** - * Define the handler function for POST requests to this endpoint - */ +export const dynamic = "force-dynamic"; export const POST = withOptionalUserSession(async ({ req, session }) => { - try { - const headersList = headers(); - - // Get the real IP address from Cloudflare headers - const ip = - headersList.get("cf-connecting-ipv6") || - headersList.get("cf-connecting-ip") || - (process.env.NODE_ENV === "development" ? "::1" : null); - - if (!ip) { - return new Response("Could not determine client IP address", { - status: 400, - }); - } - - let serverKeypair: Keypair; - try { - serverKeypair = await getKeypairFromEnvironment("FAUCET_KEYPAIR_NEW"); - } catch (err) { - throw Error("Internal error. No faucet keypair found"); - } - - const authToken = (req.headers.get("authorization") || "") - .split(/^Bearer:?/gi) - .at(1) - ?.trim(); - - console.log("airdrop request received", { - ip, - authBypassRequested: !!authToken, - }); - - // get the required data submitted from the client - const { walletAddress, amount, network, cloudflareCallback } = - await req.json(); - - // GitHub auth is required - if (GITHUB_LOGIN_REQUIRED) { - if (!session?.user?.githubUserId) { - throw Error( - "GitHub authentication is required. Please sign in with GitHub to use the faucet.", - ); - } - } - - // get the desired rate limit for the current requestor - const rateLimit = await getAirdropRateLimitForSession(session); - - // validate the user provided wallet address is a valid solana wallet address - let userWallet: PublicKey; - try { - if (!walletAddress) throw Error("Missing wallet address"); - - userWallet = new PublicKey(walletAddress); - - // when here, the user provided wallet is considered valid - } catch (err) { - throw Error("Invalid wallet address"); - } - - if (!amount) { - throw Error("Missing SOL amount"); - } - - if (amount > rateLimit.maxAmountPerRequest) { - throw Error("Requested SOL amount too large"); - } - - if (!ip) { - // Not sure we'd ever be in a situation where we don't have an IP - throw Error("Could not determine IP"); - } - - // enable the some `authTokens` - if (!isAuthorizedToBypass(authToken)) { - // skip the cloudflare check when working on localhost - if (process.env.NODE_ENV != "development" && ip != "::1") { - const isCloudflareApproved = await checkCloudflare(cloudflareCallback); - - if (!isCloudflareApproved) { - throw Error("Invalid CAPTCHA"); - } - } else { - console.warn("SKIP CLOUDFLARE CHECK IN DEVELOPMENT MODE ON LOCALHOST"); - } - - // load the ip address whitelist from the env - const IP_ALLOW_LIST: Array = JSON.parse( - process.env.IP_ALLOW_LIST || "[]", - ); + const ip = getClientIp(req); + const clientId = sanitizeIp(ip ?? "unknown"); - // attempt to rate limit a request (for non-whitelisted ip's) - if (!IP_ALLOW_LIST?.includes(ip)) { - const ipAddressWithoutDots = getCleanIp(ip); - try { - const { valid, reason } = await validationAPI.validate( - ipAddressWithoutDots, - userWallet.toBase58(), - session!.user.githubUserId!); - if (!valid) { - throw Error(reason); - } - - // Fetch last transaction for any of the three identifiers - const lastTransactions = await transactionsAPI.getLastTransactions( - userWallet.toBase58(), - session?.user?.githubUserId!, - ipAddressWithoutDots, - rateLimit.allowedRequests, - ); - - // Check if the request exceeds rate limits - const isWithinRateLimit = checkRateLimit(lastTransactions, rateLimit); - - console.log( - `network: ${network}, requested: ${amount}, ip: ${ipAddressWithoutDots}, ` + - `wallet: ${walletAddress}, github: ${session?.user?.githubUserId}, ` + - `isWithinRateLimit: ${isWithinRateLimit}`, - ); - - if (!isWithinRateLimit) { - throw Error( - `You have exceeded the ${rateLimit.allowedRequests} airdrops limit ` + - `in the past ${rateLimit.coveredHours} hour(s)`, - ); - } - - /** - * when here, we assume the request is not rate limited - * since none of the above Promises will have throw an error - */ - } catch (err) { - // set the default error message - let errorMessage = "Rate limit exceeded"; - - // handle custom error and their messages - if (err instanceof Error) { - errorMessage = err.message; - } - - return new Response(errorMessage, { - status: 429, // too many requests - }); - } - } + let ctx: RequestContext; + try { + ctx = await buildContext(req, session ?? undefined, ip); + } catch (err) { + console.error("[AIRDROP ERROR] buildContext failed:", err); + trackEvent("airdrop_failed", { + reason: err instanceof AirdropError ? err.code : "unhandled", + error_message: err instanceof Error ? err.message : "unknown", + }, clientId); + if (err instanceof AirdropError) { + return new Response(err.message, { status: err.status }); } + return new Response("Unable to complete airdrop", { status: 500 }); + } - const rpc_url = - network == "testnet" - ? "https://api.testnet.solana.com" - : process.env.RPC_URL ?? "https://api.devnet.solana.com"; - - const connection = new Connection(rpc_url, "confirmed"); - - const transaction = new Transaction().add( - SystemProgram.transfer({ - fromPubkey: serverKeypair.publicKey, - toPubkey: userWallet, - lamports: amount * LAMPORTS_PER_SOL, - }), - ); - - try { - // send and confirm the transaction - const signature = await sendTransaction( - connection, - transaction, - [serverKeypair], - 1000000, - ); - - if (!signature) { - throw Error("Transaction failed"); - } + const result = await handleAirdrop(ctx); - try { - await transactionsAPI.create( - signature, - getCleanIp(ip), - userWallet.toBase58(), - session?.user?.githubUserId ?? "", - Date.now(), - ); - console.log(`Transaction recorded: ${signature}`); - } catch (error) { - console.error("Failed to record transaction in database:", error); - } + if (result.success) { + trackSuccess(clientId, ctx, result.signature); + const body: AirdropResponse = { + success: true, + message: "Airdrop successful", + signature: result.signature, + }; + return NextResponse.json(body, { status: 200 }); + } - // finally return a success 200 message when the transaction was successful - return new Response( - JSON.stringify({ - success: true, - message: "Airdrop successful", - signature, - }), - { - status: 200, // success - }, - ); - } catch (error) { - console.error("[TRANSACTION FAILED]"); - console.error(error); - throw Error("Faucet is empty, ping @solana_devs on Twitter"); - } + trackError(clientId, ctx, result.error); + return new Response(result.error.message, { status: result.error.status }); +}); - // set a final catching error to handle if an unknown/unhandled thing happen - throw Error("Unknown completion error"); - } catch (err) { - console.warn("[FAUCET ERROR]"); - console.warn(err); +async function buildContext( + req: Request, + session: Session | undefined, + ip: string | undefined, +): Promise { + const githubUserId = session?.user?.githubUserId; - // set the default error message - let errorMessage = "Unable to complete airdrop"; + if (!ip) { + throw new AirdropError(AirdropErrorCode.NO_IP); + } - // handle custom error and their messages - if (err instanceof Error) { - errorMessage = err.message; - } + const sanitizedIp = sanitizeIp(ip); - return new Response(errorMessage, { - status: 400, // bad request - }); + let faucetKeypair: Keypair; + try { + faucetKeypair = getKeypairFromEnvironment("FAUCET_KEYPAIR_NEW"); + } catch { + throw new AirdropError(AirdropErrorCode.NO_KEYPAIR); } -}); - -const checkRateLimit = ( - lastTransactions: FaucetTransaction[], - rateLimit: AirdropRateLimit, -) => { - if (lastTransactions.length === 0) return true; // No previous transactions → allow request - const rateLimitThreshold = - Date.now() - rateLimit.coveredHours * (60 * 60 * 1000); + const authToken = req.headers + .get("authorization") + ?.match(/^Bearer:?\s*(.+)/i)?.[1]; - // Count how many transactions are within the rate limit window - const recentTransactions = lastTransactions.filter( - transaction => transaction.timestamp > rateLimitThreshold, - ); + let raw: unknown; + try { + raw = await req.json(); + } catch { + throw new AirdropError(AirdropErrorCode.INVALID_BODY); + } - // Allow if number of recent transactions is less than allowed requests - return recentTransactions.length < rateLimit.allowedRequests; -}; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new AirdropError(AirdropErrorCode.INVALID_BODY); + } -const getCleanIp = (ip: string) => { - return ip.includes(":") ? ip.replace(/:/g, "") : ip.replace(/\./g, ""); -}; + const tier = resolveTier(githubUserId); + const body = validateBody(raw as Record, tier); + + const ctx = { + ip, + sanitizedIp, + githubUserId, + tier, + skipCaptcha: process.env.NODE_ENV === "development", + body, + } as RequestContext; + + // Sensitive fields are non-enumerable (skipped by spread/Object.keys/default console.log); toJSON emits "" placeholders so logs show presence without leaking values. + Object.defineProperties(ctx, { + faucetKeypair: { value: faucetKeypair, enumerable: false }, + authToken: { value: authToken, enumerable: false }, + toJSON: { + value(this: RequestContext) { + const { faucetKeypair: kp, authToken: tok, ...safe } = this; + return { + ...safe, + ...(kp && { faucetKeypair: "" }), + ...(tok && { authToken: "" }), + }; + }, + enumerable: false, + }, + }); + + return ctx; +} diff --git a/app/api/request/tracking.ts b/app/api/request/tracking.ts new file mode 100644 index 0000000..54bfbcb --- /dev/null +++ b/app/api/request/tracking.ts @@ -0,0 +1,82 @@ +import { trackEvent } from "@/lib/analytics"; + +import { AirdropError } from "./airdrop-error"; +import type { RequestContext } from "./types"; + +export function trackSuccess( + clientId: string, + ctx: RequestContext, + signature: string, +): void { + trackEvent( + "airdrop_success", + { + ...extractTrackingParams(ctx), + signature, + }, + clientId, + ); +} + +export type BypassReason = "auth_token" | "allow_listed_ip"; + +export function trackBypass( + clientId: string, + ctx: RequestContext, + reason: BypassReason, +): void { + trackEvent( + "airdrop_bypass_requested", + { + ...extractTrackingParams(ctx), + bypass_reason: reason, + }, + clientId, + ); +} + +export function trackError( + clientId: string, + ctx: RequestContext, + err: unknown, +): void { + const ctxParams = extractTrackingParams(ctx); + + if (!(err instanceof AirdropError)) { + console.error("[AIRDROP ERROR] unhandled:", err); + trackEvent("airdrop_failed", { + reason: "unhandled", + error_message: err instanceof Error ? err.message : "unknown", + ...ctxParams, + }, clientId); + return; + } + + const causeMessage = + err.cause instanceof Error ? err.cause.message : undefined; + + console.error(`[AIRDROP ERROR] ${err.code}: ${err.message}`, err.cause ?? ""); + trackEvent( + "airdrop_failed", + { + reason: err.code, + error_message: err.message, + ...(causeMessage && { cause: causeMessage }), + ...ctxParams, + }, + clientId, + ); +} + +function shortenAddress(address: string, chars = 4): string { + return `${address.slice(0, chars)}...${address.slice(-chars)}`; +} + +function extractTrackingParams(ctx: RequestContext) { + return { + network: ctx.body.network, + amount: ctx.body.amount, + has_github: ctx.githubUserId ? "yes" : "no", + wallet: shortenAddress(ctx.body.recipientAddress), + }; +} diff --git a/app/api/request/types.ts b/app/api/request/types.ts new file mode 100644 index 0000000..20b7aa4 --- /dev/null +++ b/app/api/request/types.ts @@ -0,0 +1,43 @@ +import type { Keypair } from "@solana/web3.js"; +import type { Network } from "@/lib/rpc"; +import type { AirdropTier } from "@/lib/airdrop"; + +export type RequestContext = { + /** Client IP from Cloudflare headers (cf-connecting-ip / cf-connecting-ipv6) */ + ip: string; + /** IP with delimiters stripped, used as a stable client identifier */ + sanitizedIp: string; + /** Keypair that signs airdrop transactions */ + faucetKeypair: Keypair; + /** Bearer token from Authorization header, if provided */ + authToken: string | undefined; + /** GitHub user ID from the authenticated session (undefined for bypass callers) */ + githubUserId: string | undefined; + /** Resolved airdrop tier for this user */ + tier: AirdropTier; + /** When true, captcha verification is skipped (dev mode or bypass callers) */ + skipCaptcha: boolean; + /** Validated request body */ + body: { + /** Base58-encoded Solana wallet address of the recipient */ + recipientAddress: string; + /** SOL amount to airdrop (must be in VALID_AMOUNTS) */ + amount: number; + /** Target Solana network */ + network: Network; + /** Cloudflare Turnstile verification token */ + captchaToken: string | undefined; + }; +}; + +/** JSON body returned on a successful airdrop (HTTP 200). */ +export type AirdropResponse = { + success: true; + message: string; + signature: string; +}; + +/** RequestContext after GitHub authentication has been verified */ +export type AuthenticatedRequestContext = RequestContext & { + githubUserId: string; +}; \ No newline at end of file diff --git a/app/api/request/validation.ts b/app/api/request/validation.ts new file mode 100644 index 0000000..301d4a1 --- /dev/null +++ b/app/api/request/validation.ts @@ -0,0 +1,67 @@ +/** + * Request body validation for the airdrop endpoint. + * + * Parses the raw JSON payload and validates each field: + * - recipientAddress — must be a valid Solana public key + * - amount — must be in the VALID_AMOUNTS list and within the tier's max + * - network — must be a supported network (devnet/testnet) + * - captchaToken — optional Cloudflare Turnstile token (string or undefined) + * + * Throws AirdropError on any invalid input. + */ + +import { PublicKey } from "@solana/web3.js"; + +import { isNetwork } from "@/lib/rpc"; +import { type AirdropTier, VALID_AMOUNTS } from "@/lib/airdrop"; + +import { AirdropError, AirdropErrorCode } from "./airdrop-error"; +import type { RequestContext } from "./types"; + +export function validateBody( + raw: Record, + tier: AirdropTier, +): RequestContext["body"] { + return { + recipientAddress: validateRecipientAddress(raw.walletAddress), + amount: validateAmount(raw.amount, tier), + network: validateNetwork(raw.network), + captchaToken: typeof raw.cloudflareCallback === "string" + ? raw.cloudflareCallback + : undefined, + }; +} + +function validateRecipientAddress(value: unknown): string { + if (typeof value !== "string" || value.length === 0) { + throw new AirdropError(AirdropErrorCode.MISSING_WALLET); + } + + try { + new PublicKey(value); + } catch { + throw new AirdropError(AirdropErrorCode.INVALID_WALLET); + } + + return value; +} + +function validateAmount(value: unknown, tier: AirdropTier): number { + if (typeof value !== "number" || !VALID_AMOUNTS.includes(value)) { + throw new AirdropError(AirdropErrorCode.INVALID_AMOUNT); + } + + if (value > tier.maxAmountPerRequest) { + throw new AirdropError(AirdropErrorCode.AMOUNT_TOO_LARGE); + } + + return value; +} + +function validateNetwork(value: unknown): RequestContext["body"]["network"] { + if (!isNetwork(value)) { + throw new AirdropError(AirdropErrorCode.INVALID_NETWORK); + } + + return value; +} diff --git a/app/api/request/verify-captcha.ts b/app/api/request/verify-captcha.ts new file mode 100644 index 0000000..60e0192 --- /dev/null +++ b/app/api/request/verify-captcha.ts @@ -0,0 +1,17 @@ +import { checkCloudflare } from "@/lib/cloudflare"; + +import { AirdropError, AirdropErrorCode } from "./airdrop-error"; +import type { RequestContext } from "./types"; + +export async function verifyCaptcha(ctx: RequestContext): Promise { + if (ctx.skipCaptcha) return; + + if (!ctx.body.captchaToken) { + throw new AirdropError(AirdropErrorCode.CAPTCHA_MISSING); + } + + const approved = await checkCloudflare(ctx.body.captchaToken); + if (!approved) { + throw new AirdropError(AirdropErrorCode.CAPTCHA_FAILED); + } +} diff --git a/app/page.tsx b/app/page.tsx index b7e9e00..3e6ae63 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,7 +3,7 @@ import { GradientBlob } from "@/components/GradientBlob"; import { AirdropForm } from "@/components/AirdropForm"; import { GitHubConnectForm } from "@/components/GitHubConnectForm"; import { getUserSession } from "@/lib/auth"; -import { getAirdropRateLimitForSession } from "@/lib/utils"; +import { getTierForSession } from "@/lib/airdrop/server"; /** * Set the custom metadata for this specific page @@ -16,12 +16,12 @@ export const metadata: Metadata = { export default async function Page() { const session = await getUserSession(); - const rateLimit = await getAirdropRateLimitForSession(session); + const tier = await getTierForSession(session); return (
diff --git a/components/AirdropForm.tsx b/components/AirdropForm.tsx index fda5b4e..a6dafed 100644 --- a/components/AirdropForm.tsx +++ b/components/AirdropForm.tsx @@ -31,16 +31,17 @@ import { useToast } from "@/components/ui/use-toast"; import Image from "next/image"; import svgLoader from "@/public/svgLoader.svg"; -import { AirdropRateLimit } from "@/lib/constants"; +import { VALID_AMOUNTS, type AirdropTier } from "@/lib/airdrop"; +import type { AirdropResponse } from "@/app/api/request/types"; type AirdropFormProps = { className?: string; - rateLimit: AirdropRateLimit; + tier: AirdropTier; }; -export const AirdropForm = ({ className, rateLimit }: AirdropFormProps) => { +export const AirdropForm = ({ className, tier }: AirdropFormProps) => { const toaster = useToast(); - const amountOptions = [0.5, 1, 2.5, 5]; + const amountOptions = [...VALID_AMOUNTS]; const [loading, setLoading] = useState(false); const [walletAddress, setWalletAddress] = useState(""); @@ -53,8 +54,8 @@ export const AirdropForm = ({ className, rateLimit }: AirdropFormProps) => { const [network, setSelectedNetwork] = useState("devnet"); const [isFormValid, setIsFormValid] = useState(false); - if (rateLimit.maxAmountPerRequest > 5) { - amountOptions.push(rateLimit.maxAmountPerRequest); + if (tier.maxAmountPerRequest > 5) { + amountOptions.push(tier.maxAmountPerRequest); } const validateWallet = (address: string): boolean => { @@ -66,10 +67,6 @@ export const AirdropForm = ({ className, rateLimit }: AirdropFormProps) => { } }; - const validateAmount = (value: number): boolean => { - return amountOptions.includes(value); - }; - const handleWalletChange = (event: ChangeEvent) => { const address = event.target.value; setWalletAddress(address); @@ -93,20 +90,20 @@ export const AirdropForm = ({ className, rateLimit }: AirdropFormProps) => { const requestAirdrop = useCallback( async (cloudflareCallback: string | null = null) => { - try { - if ( - cloudflareCallback === null && - process.env.NODE_ENV != "development" - ) { - return toaster.toast({ - title: "Error!", - description: "Please complete the captcha.", - }); - } + if ( + cloudflareCallback === null && + process.env.NODE_ENV !== "development" + ) { + return toaster.toast({ + title: "Error!", + description: "Please complete the captcha.", + }); + } - setLoading(true); + setLoading(true); - await fetch("/api/request", { + try { + const res = await fetch("/api/request", { method: "POST", headers: { "Content-Type": "application/json", @@ -117,36 +114,25 @@ export const AirdropForm = ({ className, rateLimit }: AirdropFormProps) => { cloudflareCallback, network, }), - }) - .then(async res => { - if (res.ok) { - return toaster.toast({ - title: "Success!", - description: "Airdrop was successful.", - }); - } else throw await res.text(); - }) - .catch(err => { - console.error(err); - - let errorMessage = "Airdrop request failed"; - - if (typeof err == "string") errorMessage = err; - else if (err instanceof Error) errorMessage = err.message; + }); - toaster.toast({ - title: "Error!", - description: errorMessage, - }); + if (res.ok) { + const data: AirdropResponse = await res.json(); + toaster.toast({ + title: "Success!", + description: data.message, }); + } else { + toaster.toast({ + title: "Error!", + description: await res.text(), + }); + } } catch (err) { console.error(err); - toaster.toast({ title: "Error!", - description: `Failed to request airdrop, error: ${ - err instanceof Error ? err.message : err - }`, + description: "Airdrop request failed", }); } @@ -166,19 +152,13 @@ export const AirdropForm = ({ className, rateLimit }: AirdropFormProps) => { } if (amountFromUrl) { const amountValue = parseFloat(amountFromUrl); - if (validateAmount(amountValue)) { + if (VALID_AMOUNTS.includes(amountValue) || amountValue === tier.maxAmountPerRequest) { setAmount(amountValue); } } }, []); // eslint-disable-line react-hooks/exhaustive-deps -- mount-only URL hydration; re-running on validator identity changes would clobber user input useEffect(() => { - // console.log({ - // walletAddress, - // amount, - // errorsWallet: errors.wallet, - // errorsAmount: errors.amount, - // }); setIsFormValid( walletAddress !== "" && amount !== null && @@ -223,10 +203,10 @@ export const AirdropForm = ({ className, rateLimit }: AirdropFormProps) => { - Maximum of {rateLimit.allowedRequests} requests{" "} - {rateLimit.coveredHours == 1 + Maximum of {tier.allowedRequests} requests{" "} + {tier.coveredHours == 1 ? "per hour" - : `every ${rateLimit.coveredHours} hours`} + : `every ${tier.coveredHours} hours`} diff --git a/lib/__tests__/airdrop.test.ts b/lib/__tests__/airdrop.test.ts new file mode 100644 index 0000000..d97c297 --- /dev/null +++ b/lib/__tests__/airdrop.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { VALID_AMOUNTS, AIRDROP_TIERS } from "../airdrop"; +import { resolveTier } from "../airdrop/server"; + +describe("VALID_AMOUNTS", () => { + it("should contain expected amounts", () => { + expect(VALID_AMOUNTS).toEqual([0.5, 1, 2.5, 5]); + }); +}); + +describe("AIRDROP_TIERS", () => { + it("should have default and github tiers", () => { + expect(AIRDROP_TIERS.default).toBeDefined(); + expect(AIRDROP_TIERS.github).toBeDefined(); + }); + + it("each tier should have required fields", () => { + for (const tier of Object.values(AIRDROP_TIERS)) { + expect(tier.coveredHours).toBeGreaterThan(0); + expect(tier.allowedRequests).toBeGreaterThan(0); + expect(tier.maxAmountPerRequest).toBeGreaterThan(0); + } + }); +}); + +describe("resolveTier", () => { + it("should return github tier when githubId is provided", () => { + expect(resolveTier("user-123")).toBe(AIRDROP_TIERS.github); + }); + + it("should return default tier when githubId is undefined", () => { + expect(resolveTier(undefined)).toBe(AIRDROP_TIERS.default); + }); + + it("should return github tier for any truthy string", () => { + expect(resolveTier("abc")).toBe(AIRDROP_TIERS.github); + }); +}); diff --git a/lib/__tests__/rpc.test.ts b/lib/__tests__/rpc.test.ts new file mode 100644 index 0000000..6922dfa --- /dev/null +++ b/lib/__tests__/rpc.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { isNetwork, getRpcUrl, getConnection, VALID_NETWORKS } from "../rpc"; + +describe("isNetwork", () => { + it("should return true for devnet", () => { + expect(isNetwork("devnet")).toBe(true); + }); + + it("should return true for testnet", () => { + expect(isNetwork("testnet")).toBe(true); + }); + + it("should return false for mainnet", () => { + expect(isNetwork("mainnet")).toBe(false); + }); + + it("should return false for non-string values", () => { + expect(isNetwork(123)).toBe(false); + expect(isNetwork(null)).toBe(false); + expect(isNetwork(undefined)).toBe(false); + }); + + it("should return false for empty string", () => { + expect(isNetwork("")).toBe(false); + }); +}); + +describe("VALID_NETWORKS", () => { + it("should contain devnet and testnet", () => { + expect(VALID_NETWORKS).toContain("devnet"); + expect(VALID_NETWORKS).toContain("testnet"); + expect(VALID_NETWORKS).toHaveLength(2); + }); +}); + +describe("getRpcUrl", () => { + // RPC_URLS is captured at module-import time, so each test must reset + // modules and re-import after stubbing env to actually exercise the + // fallback / override logic. + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("should fall back to default devnet URL when RPC_URL_DEVNET is unset", async () => { + vi.stubEnv("RPC_URL_DEVNET", undefined as unknown as string); + const { getRpcUrl } = await import("../rpc"); + expect(getRpcUrl("devnet")).toBe("https://api.devnet.solana.com"); + }); + + it("should fall back to default testnet URL when RPC_URL_TESTNET is unset", async () => { + vi.stubEnv("RPC_URL_TESTNET", undefined as unknown as string); + const { getRpcUrl } = await import("../rpc"); + expect(getRpcUrl("testnet")).toBe("https://api.testnet.solana.com"); + }); + + it("should use RPC_URL_DEVNET when set", async () => { + vi.stubEnv("RPC_URL_DEVNET", "https://custom.devnet.example/"); + const { getRpcUrl } = await import("../rpc"); + expect(getRpcUrl("devnet")).toBe("https://custom.devnet.example/"); + }); + + it("should use RPC_URL_TESTNET when set", async () => { + vi.stubEnv("RPC_URL_TESTNET", "https://custom.testnet.example/"); + const { getRpcUrl } = await import("../rpc"); + expect(getRpcUrl("testnet")).toBe("https://custom.testnet.example/"); + }); +}); + +describe("getConnection", () => { + it("should return a Connection object for devnet", () => { + const conn = getConnection("devnet"); + expect(conn).toBeDefined(); + expect(conn.rpcEndpoint).toMatch(/^https?:\/\//); + }); + + it("should return a Connection object for testnet", () => { + const conn = getConnection("testnet"); + expect(conn).toBeDefined(); + expect(conn.rpcEndpoint).toMatch(/^https?:\/\//); + }); +}); diff --git a/lib/airdrop/index.ts b/lib/airdrop/index.ts new file mode 100644 index 0000000..ca47057 --- /dev/null +++ b/lib/airdrop/index.ts @@ -0,0 +1,52 @@ +/** + * Client-safe airdrop constants and types. + * + * This barrel is safe to import from client components — it has no + * server-only dependencies (next-auth, etc.). For server-only helpers + * like `resolveTier` and `getTierForSession`, import from + * `@/lib/airdrop/server`. + */ + +export const VALID_AMOUNTS = [0.5, 1, 2.5, 5]; + +export type AirdropTier = { + /** number of previous hours covered by the rate limit, in a rolling period */ + coveredHours: number; + /** max number of requests to allow per `coveredHours` time period */ + allowedRequests: number; + /** max amount of SOL allowed per individual request */ + maxAmountPerRequest: number; +}; + +type AirdropTierName = "default" | "github"; + +export const AIRDROP_TIERS: { + [key in AirdropTierName]: AirdropTier; +} = { + default: { + coveredHours: 8, + allowedRequests: 2, + maxAmountPerRequest: 5, + }, + github: { + coveredHours: 8, + allowedRequests: 2, + maxAmountPerRequest: 5, + }, +}; + +/** + * Represents a record in the `faucet.transactions` table. + */ +export type FaucetTransaction = { + /** Unique signature of the Solana transaction */ + signature: string; + /** Requestor's IP address with delimiters stripped (dots/colons removed) */ + ip_address: string; + /** Requestor's Solana wallet address (base58) */ + wallet_address: string; + /** Requestor's GitHub user ID (omitted for bypass callers) */ + github_id?: string; + /** Timestamp of the transaction */ + timestamp: number; +}; diff --git a/lib/airdrop/server.ts b/lib/airdrop/server.ts new file mode 100644 index 0000000..c84c47e --- /dev/null +++ b/lib/airdrop/server.ts @@ -0,0 +1,27 @@ +/** + * Server-only airdrop helpers. + * + * These depend on next-auth and must NOT be imported from client components. + * For client-safe exports, import from `@/lib/airdrop`. + */ + +import type { Session } from "next-auth"; + +import { AIRDROP_TIERS, type AirdropTier } from "./index"; + +/** + * Resolve the airdrop tier: GitHub-authenticated users get the `github` tier, + * everyone else gets `default`. + */ +export function resolveTier(githubId: string | undefined): AirdropTier { + return githubId ? AIRDROP_TIERS.github : AIRDROP_TIERS.default; +} + +/** + * Get the airdrop tier for a given session. + */ +export async function getTierForSession( + session: Session | null, +): Promise { + return resolveTier(session?.user.githubUserId); +} diff --git a/lib/analytics.ts b/lib/analytics.ts new file mode 100644 index 0000000..17e9a8b --- /dev/null +++ b/lib/analytics.ts @@ -0,0 +1,54 @@ +/** + * GA4 Measurement Protocol - server-side event tracking + * + * Fires events to Google Analytics 4 from API routes so we can track + * airdrop outcomes (success, each failure reason) without relying on + * client-side JS. + * + * Required env vars: + * GA4_MEASUREMENT_ID – e.g. "G-XXXXXXXXXX" + * GA4_API_SECRET – created in GA4 Admin › Data Streams › Measurement Protocol API secrets + */ + +const GA4_ENDPOINT = "https://www.google-analytics.com/mp/collect"; + +type EventParams = Record; + +/** + * Send a single event to GA4 via the Measurement Protocol. + * + * `clientId` should be a stable identifier for the request — we use the + * cleaned IP so GA4 can group events per visitor without cookies. + * + * This function is fire-and-forget: it never throws and never blocks the + * airdrop response. + */ +export function trackEvent( + name: string, + params: EventParams, + clientId: string, +): void { + const measurementId = process.env.GA4_MEASUREMENT_ID; + const apiSecret = process.env.GA4_API_SECRET; + + if (!measurementId || !apiSecret) return; + + const url = `${GA4_ENDPOINT}?measurement_id=${measurementId}&api_secret=${apiSecret}`; + + // Strip undefined values so GA4 doesn't receive "undefined" strings + const cleanParams: Record = {}; + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) cleanParams[k] = v; + } + + fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: clientId, + events: [{ name, params: cleanParams }], + }), + }).catch((err) => { + console.error(`[ANALYTICS] Failed to send event "${name}":`, err); + }); +} diff --git a/lib/backend.ts b/lib/backend.ts index 97a29c8..7e8f8fc 100644 --- a/lib/backend.ts +++ b/lib/backend.ts @@ -63,11 +63,6 @@ const solanaBalancesAPI = { body: JSON.stringify({ account, balance }), }); }, - getAllForAccount: async (account: string) => { - return fetchRequest(`${BASE_URL}/solana-balances/account/${account}`, { - method: 'GET', - }); - }, getRecent: async () => { return fetchRequest(`${BASE_URL}/solana-balances/recent`, { method: 'GET', @@ -76,19 +71,23 @@ const solanaBalancesAPI = { }; const transactionsAPI = { - create: async (signature: string, ip_address: string, wallet_address: string, github_id: string, timestamp: number) => { + create: async (signature: string, ip_address: string, wallet_address: string, github_id: string | undefined, timestamp: number) => { return fetchRequest(`${BASE_URL}/transactions`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, + // github_id intentionally omitted from JSON when undefined: backend + // schema is .strict() with githubIdSchema.optional(), and `""` would + // fail the `^\d+$` regex. JSON.stringify drops keys whose value is + // undefined. body: JSON.stringify({ signature, ip_address, wallet_address, github_id, timestamp }), }); }, - getLastTransactions: async (wallet_address: string, github_username:string, ip_address:string, count: number) => { + getLastTransactions: async (wallet_address: string, github_id: string, ip_address: string, count: number) => { const queryParams = new URLSearchParams(); queryParams.append('wallet_address', wallet_address); - queryParams.append('github_id', github_username); + queryParams.append('github_id', github_id); queryParams.append('ip_address', ip_address); queryParams.append('count', count.toString()); @@ -114,20 +113,8 @@ const validationAPI = { } }; -// Github Validation API -const githubValidationAPI = { - ghValidation: async (userId: string) => { - return fetchRequest(`${BASE_URL}/gh-validation/${userId}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - }, -}; - // Export the API objects -export { solanaBalancesAPI, transactionsAPI, githubValidationAPI, validationAPI }; +export { solanaBalancesAPI, transactionsAPI, validationAPI }; diff --git a/lib/constants.ts b/lib/constants.ts index 18b4dee..d3feb84 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -19,65 +19,3 @@ export const FAUCET_ACCOUNTS = [ "2pekXzx7WRPtdj4Gvtif1mzmHfc21zpNx2AvW9r4g7bo", "dev2JBjyB5CshoGsiJCwzdmJYiEUwAXMdqDR7txoFBJ", ] as const; - -/** - * Airdrop rate limit controls - * - * Example rate limits: - * - if `coveredHours` = 1, `allowedRequests` = 2, `maxAmountPerRequest` = 5, - * + a user could make `2` airdrop requests per `1` hour - * + each airdrop could be up to `5` SOL each - * + for a total of `10` SOL per hour max - * - if `coveredHours` = 1, `allowedRequests` = 4, `maxAmountPerRequest` = 10, - * + a user could make `4` airdrop requests per `1` hour - * + each airdrop could be up to `10` SOL each - * + for a total of `40` SOL per hour max - */ -export type AirdropRateLimit = { - /** number of previous hours covered by the rate limit, in a rolling period */ - coveredHours: number; - /** max number of requests to allow per `coveredHours` time period */ - allowedRequests: number; - /** max amount of SOl allowed per individual request */ - maxAmountPerRequest: number; -}; - -/** - * Unique keys used to identify a specific airdrop limit - */ -export type AirdropLimitKeys = "default" | "github"; - -/** - * Define the standard airdrop limits for requesting users - * (including the base and elevated) - */ -export const AIRDROP_LIMITS: { - [key in AirdropLimitKeys]: AirdropRateLimit; -} = { - default: { - coveredHours: 8, - allowedRequests: 2, - maxAmountPerRequest: 5, - }, - github: { - coveredHours: 8, - allowedRequests: 2, - maxAmountPerRequest: 5, - }, -}; - -/** - * Represents a record in the `faucet.transactions` table. - */ -export type FaucetTransaction = { - /** Unique signature of the Solana transaction */ - signature: string; - /** Requestor's IP address */ - ip_address: string; - /** Requestor's wallet address */ - wallet_address: string; - /** Requestor's GitHub userId (may become optional) */ - github_username?: string; - /** Timestamp of the transaction */ - timestamp: number; -}; diff --git a/lib/rpc.ts b/lib/rpc.ts new file mode 100644 index 0000000..a3d9ba1 --- /dev/null +++ b/lib/rpc.ts @@ -0,0 +1,21 @@ +import { Connection } from "@solana/web3.js"; + +const RPC_URLS = { + devnet: process.env.RPC_URL_DEVNET ?? "https://api.devnet.solana.com", + testnet: process.env.RPC_URL_TESTNET ?? "https://api.testnet.solana.com", +} as const; + +export type Network = keyof typeof RPC_URLS; +export const VALID_NETWORKS = Object.keys(RPC_URLS) as Network[]; + +export function isNetwork(value: unknown): value is Network { + return typeof value === "string" && value in RPC_URLS; +} + +export function getRpcUrl(network: Network): string { + return RPC_URLS[network]; +} + +export function getConnection(network: Network): Connection { + return new Connection(getRpcUrl(network), "confirmed"); +} diff --git a/lib/utils.ts b/lib/utils.ts index 683d641..365058c 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,74 +1,6 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; -import type { Session } from "next-auth"; -import { AIRDROP_LIMITS, type AirdropRateLimit } from "./constants"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } - -/** - * Get the desired airdrop rate limit to be applied, based on a given requestor's session - */ -export async function getAirdropRateLimitForSession( - session: Session | null, -): Promise { - // always initialize with the default rate limit - let rateLimit = AIRDROP_LIMITS.default; - - // when a user has authed with github, we will raise their rate limit - if (!!session?.user.githubUsername) { - rateLimit = AIRDROP_LIMITS.github; - } - - return rateLimit; -} - -/** - * Check if the given `authToken` should be allowed to bypass auth checks - */ -export function isAuthorizedToBypass(authToken: string = ""): boolean { - let bypassChecksWithAuth = false; - - try { - // load the ip address whitelist from the env - const AUTH_TOKENS_ALLOW_LIST: Array<{ - name?: string; - token: string; - startDate?: string; - endDate?: string; - }> = JSON.parse(process.env.AUTH_TOKENS_ALLOW_LIST || "[]"); - - const authCheck = AUTH_TOKENS_ALLOW_LIST.find( - item => item.token == authToken, - ); - - if (!!authCheck) { - try { - let currentDate = new Date(); - - // is this authToken activated within its time window - if ( - authCheck.startDate && - new Date(authCheck.startDate) >= currentDate - ) { - throw Error("Not authorized: token not activated"); - } - // is this authToken activated within its time window - - if (authCheck.endDate && new Date(authCheck.endDate) <= currentDate) { - throw Error("Not authorized: token expired"); - } - - // when here, we have performed all auth checks. let them pass - bypassChecksWithAuth = true; - } catch (err) { - if (err instanceof Error) console.warn("[authCheck]", err.message); - // do nothing. the `authToken` is not authorized - } - } - } catch (err) { - // - } - return bypassChecksWithAuth; -} diff --git a/package-lock.json b/package-lock.json index 07bfa4e..9da48fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@radix-ui/react-toast": "^1.1.4", "@solana-developers/helpers": "=2.7.0", "@solana/web3.js": "^1.78.0", - "@types/node": "20.4.1", + "@types/node": "^22.12.0", "@types/react": "18.2.14", "@types/react-dom": "18.2.6", "@vercel/edge-config": "^0.2.1", @@ -25,7 +25,7 @@ "fs": "^0.0.1-security", "google-auth-library": "^9.14.2", "lucide-react": "^0.263.1", - "next": "^14.0.0", + "next": "14.2.35", "next-auth": "^4.24.5", "path": "^0.12.7", "pg": "^8.11.1", @@ -45,7 +45,8 @@ "vitest": "^4.1.5" }, "engines": { - "node": ">=22" + "node": ">=22", + "npm": ">=11.6" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -360,9 +361,10 @@ } }, "node_modules/@next/env": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.0.tgz", - "integrity": "sha512-cIKhxkfVELB6hFjYsbtEeTus2mwrTC+JissfZYM0n+8Fv+g8ucUfOlm3VEDtwtwydZ0Nuauv3bl0qF82nnCAqA==" + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", + "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", + "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { "version": "13.4.9", @@ -394,12 +396,13 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.0.tgz", - "integrity": "sha512-HQKi159jCz4SRsPesVCiNN6tPSAFUkOuSkpJsqYTIlbHLKr1mD6be/J0TvWV6fwJekj81bZV9V/Tgx3C2HO9lA==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -409,12 +412,13 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.0.tgz", - "integrity": "sha512-4YyQLMSaCgX/kgC1jjF3s3xSoBnwHuDhnF6WA1DWNEYRsbOOPWjcYhv8TKhRe2ApdOam+VfQSffC4ZD+X4u1Cg==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -424,12 +428,16 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.0.tgz", - "integrity": "sha512-io7fMkJ28Glj7SH8yvnlD6naIhRDnDxeE55CmpQkj3+uaA2Hko6WGY2pT5SzpQLTnGGnviK85cy8EJ2qsETj/g==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -439,12 +447,16 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.0.tgz", - "integrity": "sha512-nC2h0l1Jt8LEzyQeSs/BKpXAMe0mnHIMykYALWaeddTqCv5UEN8nGO3BG8JAqW/Y8iutqJsaMe2A9itS0d/r8w==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -454,12 +466,16 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.0.tgz", - "integrity": "sha512-Wf+WjXibJQ7hHXOdNOmSMW5bxeJHVf46Pwb3eLSD2L76NrytQlif9NH7JpHuFlYKCQGfKfgSYYre5rIfmnSwQw==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -469,12 +485,16 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.0.tgz", - "integrity": "sha512-WTZb2G7B+CTsdigcJVkRxfcAIQj7Lf0ipPNRJ3vlSadU8f0CFGv/ST+sJwF5eSwIe6dxKoX0DG6OljDBaad+rg==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -484,12 +504,13 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.0.tgz", - "integrity": "sha512-7R8/x6oQODmNpnWVW00rlWX90sIlwluJwcvMT6GXNIBOvEf01t3fBg0AGURNKdTJg2xNuP7TyLchCL7Lh2DTiw==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -499,12 +520,13 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.0.tgz", - "integrity": "sha512-RLK1nELvhCnxaWPF07jGU4x3tjbyx2319q43loZELqF0+iJtKutZ+Lk8SVmf/KiJkYBc7Cragadz7hb3uQvz4g==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", "cpu": [ "ia32" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -514,12 +536,13 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.0.tgz", - "integrity": "sha512-g6hLf1SUko+hnnaywQQZzzb3BRecQsoKkF3o/C+F+dOA4w/noVAJngUVkfwF0+2/8FzNznM7ofM6TGZO9svn7w==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -1845,11 +1868,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", - "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", "dependencies": { + "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, @@ -1967,10 +1998,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.1.tgz", - "integrity": "sha512-JIzsAvJeA/5iY6Y/OxZbv1lUcc8dNSE77lb2gnBH+/PJ3lFR1Ccvgwl5JWnHAkNHcRsT0TbpVOsiMKZ1F/yyJg==", - "license": "MIT" + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } }, "node_modules/@types/pg": { "version": "8.10.7", @@ -1984,13 +2018,6 @@ "pg-types": "^4.0.1" } }, - "node_modules/@types/pg/node_modules/@types/node": { - "version": "20.4.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz", - "integrity": "sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/pg/node_modules/pg-types": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.1.tgz", @@ -2988,9 +3015,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001515", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001515.tgz", - "integrity": "sha512-eEFDwUOZbE24sb+Ecsx3+OvNETqjWIdabMy52oOkIgcUtAsQifjUG9q4U9dgTHJM2mfk4uEPxc0+xuFdJ629QA==", + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", "funding": [ { "type": "opencollective", @@ -4507,12 +4534,6 @@ "node": ">= 6" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause" - }, "node_modules/globals": { "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", @@ -5203,18 +5224,6 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/jose": { "version": "4.15.4", "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", @@ -5809,17 +5818,18 @@ "license": "MIT" }, "node_modules/next": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/next/-/next-14.0.0.tgz", - "integrity": "sha512-J0jHKBJpB9zd4+c153sair0sz44mbaCHxggs8ryVXSFBuBqJ8XdE9/ozoV85xGh2VnSjahwntBZZgsihL9QznA==", + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "license": "MIT", "dependencies": { - "@next/env": "14.0.0", - "@swc/helpers": "0.5.2", + "@next/env": "14.2.35", + "@swc/helpers": "0.5.5", "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001406", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", "postcss": "8.4.31", - "styled-jsx": "5.1.1", - "watchpack": "2.4.0" + "styled-jsx": "5.1.1" }, "bin": { "next": "dist/bin/next" @@ -5828,18 +5838,19 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.0.0", - "@next/swc-darwin-x64": "14.0.0", - "@next/swc-linux-arm64-gnu": "14.0.0", - "@next/swc-linux-arm64-musl": "14.0.0", - "@next/swc-linux-x64-gnu": "14.0.0", - "@next/swc-linux-x64-musl": "14.0.0", - "@next/swc-win32-arm64-msvc": "14.0.0", - "@next/swc-win32-ia32-msvc": "14.0.0", - "@next/swc-win32-x64-msvc": "14.0.0" + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", "react": "^18.2.0", "react-dom": "^18.2.0", "sass": "^1.3.0" @@ -5848,6 +5859,9 @@ "@opentelemetry/api": { "optional": true }, + "@playwright/test": { + "optional": true + }, "sass": { "optional": true } @@ -8019,6 +8033,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -8343,19 +8363,6 @@ } } }, - "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", - "license": "MIT", - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -8726,9 +8733,9 @@ } }, "@next/env": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.0.tgz", - "integrity": "sha512-cIKhxkfVELB6hFjYsbtEeTus2mwrTC+JissfZYM0n+8Fv+g8ucUfOlm3VEDtwtwydZ0Nuauv3bl0qF82nnCAqA==" + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", + "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==" }, "@next/eslint-plugin-next": { "version": "13.4.9", @@ -8754,57 +8761,57 @@ } }, "@next/swc-darwin-arm64": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.0.tgz", - "integrity": "sha512-HQKi159jCz4SRsPesVCiNN6tPSAFUkOuSkpJsqYTIlbHLKr1mD6be/J0TvWV6fwJekj81bZV9V/Tgx3C2HO9lA==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", "optional": true }, "@next/swc-darwin-x64": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.0.tgz", - "integrity": "sha512-4YyQLMSaCgX/kgC1jjF3s3xSoBnwHuDhnF6WA1DWNEYRsbOOPWjcYhv8TKhRe2ApdOam+VfQSffC4ZD+X4u1Cg==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", "optional": true }, "@next/swc-linux-arm64-gnu": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.0.tgz", - "integrity": "sha512-io7fMkJ28Glj7SH8yvnlD6naIhRDnDxeE55CmpQkj3+uaA2Hko6WGY2pT5SzpQLTnGGnviK85cy8EJ2qsETj/g==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", "optional": true }, "@next/swc-linux-arm64-musl": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.0.tgz", - "integrity": "sha512-nC2h0l1Jt8LEzyQeSs/BKpXAMe0mnHIMykYALWaeddTqCv5UEN8nGO3BG8JAqW/Y8iutqJsaMe2A9itS0d/r8w==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", "optional": true }, "@next/swc-linux-x64-gnu": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.0.tgz", - "integrity": "sha512-Wf+WjXibJQ7hHXOdNOmSMW5bxeJHVf46Pwb3eLSD2L76NrytQlif9NH7JpHuFlYKCQGfKfgSYYre5rIfmnSwQw==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", "optional": true }, "@next/swc-linux-x64-musl": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.0.tgz", - "integrity": "sha512-WTZb2G7B+CTsdigcJVkRxfcAIQj7Lf0ipPNRJ3vlSadU8f0CFGv/ST+sJwF5eSwIe6dxKoX0DG6OljDBaad+rg==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", "optional": true }, "@next/swc-win32-arm64-msvc": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.0.tgz", - "integrity": "sha512-7R8/x6oQODmNpnWVW00rlWX90sIlwluJwcvMT6GXNIBOvEf01t3fBg0AGURNKdTJg2xNuP7TyLchCL7Lh2DTiw==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", "optional": true }, "@next/swc-win32-ia32-msvc": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.0.tgz", - "integrity": "sha512-RLK1nELvhCnxaWPF07jGU4x3tjbyx2319q43loZELqF0+iJtKutZ+Lk8SVmf/KiJkYBc7Cragadz7hb3uQvz4g==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", "optional": true }, "@next/swc-win32-x64-msvc": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.0.tgz", - "integrity": "sha512-g6hLf1SUko+hnnaywQQZzzb3BRecQsoKkF3o/C+F+dOA4w/noVAJngUVkfwF0+2/8FzNznM7ofM6TGZO9svn7w==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", "optional": true }, "@noble/curves": { @@ -9521,11 +9528,17 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true }, + "@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" + }, "@swc/helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", - "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", "requires": { + "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, @@ -9629,9 +9642,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" }, "@types/node": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.1.tgz", - "integrity": "sha512-JIzsAvJeA/5iY6Y/OxZbv1lUcc8dNSE77lb2gnBH+/PJ3lFR1Ccvgwl5JWnHAkNHcRsT0TbpVOsiMKZ1F/yyJg==" + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "requires": { + "undici-types": "~6.21.0" + } }, "@types/pg": { "version": "8.10.7", @@ -9644,12 +9660,6 @@ "pg-types": "^4.0.1" }, "dependencies": { - "@types/node": { - "version": "20.4.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz", - "integrity": "sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw==", - "dev": true - }, "pg-types": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.1.tgz", @@ -10276,9 +10286,9 @@ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==" }, "caniuse-lite": { - "version": "1.0.30001515", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001515.tgz", - "integrity": "sha512-eEFDwUOZbE24sb+Ecsx3+OvNETqjWIdabMy52oOkIgcUtAsQifjUG9q4U9dgTHJM2mfk4uEPxc0+xuFdJ629QA==" + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==" }, "chai": { "version": "6.2.2", @@ -11305,11 +11315,6 @@ "is-glob": "^4.0.1" } }, - "glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" - }, "globals": { "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", @@ -11730,14 +11735,6 @@ } } }, - "jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "optional": true, - "peer": true - }, "jose": { "version": "4.15.4", "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", @@ -12073,26 +12070,26 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, "next": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/next/-/next-14.0.0.tgz", - "integrity": "sha512-J0jHKBJpB9zd4+c153sair0sz44mbaCHxggs8ryVXSFBuBqJ8XdE9/ozoV85xGh2VnSjahwntBZZgsihL9QznA==", - "requires": { - "@next/env": "14.0.0", - "@next/swc-darwin-arm64": "14.0.0", - "@next/swc-darwin-x64": "14.0.0", - "@next/swc-linux-arm64-gnu": "14.0.0", - "@next/swc-linux-arm64-musl": "14.0.0", - "@next/swc-linux-x64-gnu": "14.0.0", - "@next/swc-linux-x64-musl": "14.0.0", - "@next/swc-win32-arm64-msvc": "14.0.0", - "@next/swc-win32-ia32-msvc": "14.0.0", - "@next/swc-win32-x64-msvc": "14.0.0", - "@swc/helpers": "0.5.2", + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "requires": { + "@next/env": "14.2.35", + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33", + "@swc/helpers": "0.5.5", "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001406", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", "postcss": "8.4.31", - "styled-jsx": "5.1.1", - "watchpack": "2.4.0" + "styled-jsx": "5.1.1" }, "dependencies": { "postcss": { @@ -13447,6 +13444,11 @@ "which-boxed-primitive": "^1.0.2" } }, + "undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, "untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -13583,15 +13585,6 @@ "why-is-node-running": "^2.3.0" } }, - "watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", - "requires": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - } - }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index e0c54a7..deeb2b3 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@radix-ui/react-toast": "^1.1.4", "@solana-developers/helpers": "=2.7.0", "@solana/web3.js": "^1.78.0", - "@types/node": "20.4.1", + "@types/node": "^22.12.0", "@types/react": "18.2.14", "@types/react-dom": "18.2.6", "@vercel/edge-config": "^0.2.1", @@ -28,7 +28,7 @@ "fs": "^0.0.1-security", "google-auth-library": "^9.14.2", "lucide-react": "^0.263.1", - "next": "^14.0.0", + "next": "14.2.35", "next-auth": "^4.24.5", "path": "^0.12.7", "pg": "^8.11.1", diff --git a/types.d.ts b/types.d.ts index 8b1e732..43e2ba4 100644 --- a/types.d.ts +++ b/types.d.ts @@ -22,7 +22,8 @@ declare namespace NodeJS { /** * General variables and settings */ - RPC_URL: string; + RPC_URL_DEVNET: string; + RPC_URL_TESTNET: string; FAUCET_KEYPAIR_NEW: string; CLOUDFLARE_SECRET: string; @@ -35,6 +36,13 @@ declare namespace NodeJS { BE_TOKEN: string; BE_SERVICE_ACCOUNT_KEY: string; + /** + * Server-side analytics (GA4 Measurement Protocol). Optional — + * trackEvent() is a no-op when either is unset. + */ + GA4_MEASUREMENT_ID: string; + GA4_API_SECRET: string; + /** * Auth related variables */ diff --git a/vitest.config.ts b/vitest.config.ts index e3f7f70..276825f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,11 +1,14 @@ import { defineConfig } from "vitest/config"; +import path from "path"; export default defineConfig({ resolve: { - tsconfigPaths: true, + alias: { + "@": path.resolve(__dirname, "."), + }, }, test: { - include: ["lib/**/*.test.ts"], + include: ["app/**/__tests__/**/*.test.ts", "lib/**/__tests__/**/*.test.ts"], environment: "node", }, });