Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/<key>
# 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)
Expand Down Expand Up @@ -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=
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:

jobs:
lint:
name: Install & Lint
name: Install, Lint & Test
runs-on: ubuntu-latest
timeout-minutes: 5
env:
Expand All @@ -21,3 +21,4 @@ jobs:
- run: corepack enable npm
- run: npm ci
- run: npm run lint
- run: npm test
8 changes: 3 additions & 5 deletions app/api/monitorfaucet/route.ts
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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));

Expand Down
28 changes: 28 additions & 0 deletions app/api/request/__tests__/airdrop-error.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
77 changes: 77 additions & 0 deletions app/api/request/__tests__/auth-bypass.test.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Loading
Loading