Skip to content

feat: set up configuration management and constants #32

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 6, 2025
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
31 changes: 30 additions & 1 deletion lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,40 @@ This directory contains utility functions, business logic, and service modules.

```
lib/
├── config.ts # Configuration management
├── constants.ts # Application constants and limits
├── feature-flags.ts # Feature flag system
├── utils.ts # General utility functions (cn, etc.)
├── config.test.ts # Configuration tests
├── constants.test.ts # Constants tests
└── [future modules will be added here]
```

## Implemented Modules

### Configuration (`config.ts`)

- Type-safe access to Cloudflare environment variables
- Feature flags based on available services
- Environment detection (development/production)
- Application URL helpers

### Constants (`constants.ts`)

- File size limits (500KB/file, 5MB/gist, 20 files)
- Expiry durations for gists
- HTTP status codes
- Validation helpers for files, PINs, and expiry options
- Binary format constants
- Cache control headers

### Feature Flags (`feature-flags.ts`)

- Advanced feature flag system with gradual rollout
- Percentage-based feature rollouts
- User-specific feature targeting
- Runtime feature toggling without deployments

## Planned Modules (to be implemented)

### Core Modules
Expand All @@ -24,7 +54,6 @@ lib/
- `logger.ts` - Structured logging utility
- `errors.ts` - Custom error classes and handling
- `validation.ts` - Input validation helpers
- `constants.ts` - Application constants and limits

## Conventions

Expand Down
92 changes: 92 additions & 0 deletions lib/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
getConfig,
isFeatureEnabled,
isDevelopment,
isProduction,
} from "./config";
import type { Env } from "@/types";

// Mock the Cloudflare context
vi.mock("@opennextjs/cloudflare", () => ({
getCloudflareContext: vi.fn(),
}));

describe("Configuration Management", () => {
const mockEnv: Env = {
GHOSTPASTE_BUCKET: {} as R2Bucket,
NEXT_PUBLIC_APP_URL: "https://ghostpaste.dev",
ENVIRONMENT: "production",
};

beforeEach(async () => {
vi.clearAllMocks();
const { getCloudflareContext } = vi.mocked(
await import("@opennextjs/cloudflare")
);
getCloudflareContext.mockResolvedValue({
env: mockEnv,
ctx: {} as ExecutionContext,
cf: {},
});
});

describe("getConfig", () => {
it("should return configuration with all required fields", async () => {
const config = await getConfig();

expect(config).toMatchObject({
appUrl: "https://ghostpaste.dev",
environment: "production",
r2Bucket: mockEnv.GHOSTPASTE_BUCKET,
rateLimitKV: undefined,
analytics: undefined,
});
});

it("should enable core features by default", async () => {
const config = await getConfig();

expect(config.features.rateLimit).toBe(false); // Not yet enabled
expect(config.features.analytics).toBe(false); // Not yet enabled
expect(config.features.oneTimeView).toBe(true);
expect(config.features.editPin).toBe(true);
expect(config.features.expiry).toBe(true);
});
});

describe("Feature checks", () => {
it("should correctly identify enabled features", async () => {
const config = await getConfig();

expect(isFeatureEnabled(config, "rateLimit")).toBe(false);
expect(isFeatureEnabled(config, "analytics")).toBe(false);
expect(isFeatureEnabled(config, "oneTimeView")).toBe(true);
});
});

describe("Environment checks", () => {
it("should correctly identify production environment", async () => {
const config = await getConfig();

expect(isDevelopment(config)).toBe(false);
expect(isProduction(config)).toBe(true);
});

it("should correctly identify development environment", async () => {
const { getCloudflareContext } = vi.mocked(
await import("@opennextjs/cloudflare")
);
getCloudflareContext.mockResolvedValue({
env: { ...mockEnv, ENVIRONMENT: "development" },
ctx: {} as ExecutionContext,
cf: {},
});

const config = await getConfig();

expect(isDevelopment(config)).toBe(true);
expect(isProduction(config)).toBe(false);
});
});
});
120 changes: 120 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Configuration management for GhostPaste
* Provides type-safe access to environment variables and settings
*/

import { getCloudflareContext } from "@opennextjs/cloudflare";

/**
* Application configuration interface
*/
export interface AppConfig {
// Application
appUrl: string;
environment: "development" | "production";

// Storage
r2Bucket: R2Bucket;

// Optional services
rateLimitKV?: KVNamespace;
analytics?: AnalyticsEngineDataset;

// Feature flags
features: {
rateLimit: boolean;
analytics: boolean;
oneTimeView: boolean;
editPin: boolean;
expiry: boolean;
};
}

/**
* Get the current environment
*/
function getEnvironment(): "development" | "production" {
if (process.env.NODE_ENV === "development") {
return "development";
}
// In Cloudflare Workers, we check the URL
if (typeof globalThis !== "undefined" && "location" in globalThis) {
const hostname = globalThis.location?.hostname || "";
if (hostname.includes("localhost") || hostname.includes("127.0.0.1")) {
return "development";
}
}
return "production";
}

/**
* Get application configuration from Cloudflare environment
* This function must be called within a request context
*/
export async function getConfig(): Promise<AppConfig> {
const { env } = await getCloudflareContext();

const environment = env.ENVIRONMENT || getEnvironment();

return {
// Application
appUrl: env.NEXT_PUBLIC_APP_URL || "https://ghostpaste.dev",
environment,

// Storage
r2Bucket: env.GHOSTPASTE_BUCKET,

// Optional services - will be undefined until added to wrangler.toml
rateLimitKV: undefined, // env.RATE_LIMIT_KV when enabled
analytics: undefined, // env.ANALYTICS when enabled

// Feature flags - can be toggled based on environment
features: {
rateLimit: false, // Will be !!env.RATE_LIMIT_KV when enabled
analytics: false, // Will be !!env.ANALYTICS when enabled
oneTimeView: true, // Always enabled
editPin: true, // Always enabled
expiry: true, // Always enabled
},
};
}

/**
* Type guard to check if a feature is enabled
*/
export function isFeatureEnabled(
config: AppConfig,
feature: keyof AppConfig["features"]
): boolean {
return config.features[feature];
}

/**
* Get the base URL for the application
*/
export function getBaseUrl(config: AppConfig): string {
return config.appUrl;
}

/**
* Get the API URL for the application
*/
export function getApiUrl(config: AppConfig, path: string): string {
const baseUrl = getBaseUrl(config);
const cleanPath = path.startsWith("/") ? path : `/${path}`;
return `${baseUrl}/api${cleanPath}`;
}

/**
* Check if running in development
*/
export function isDevelopment(config: AppConfig): boolean {
return config.environment === "development";
}

/**
* Check if running in production
*/
export function isProduction(config: AppConfig): boolean {
return config.environment === "production";
}
120 changes: 120 additions & 0 deletions lib/constants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, it, expect } from "vitest";
import {
FILE_LIMITS,
GIST_LIMITS,
EXPIRY_DURATIONS,
isValidFileSize,
isValidTotalSize,
isValidFileCount,
isValidFilename,
isValidPin,
isValidExpiryOption,
getExpiryDate,
isExpired,
} from "./constants";

describe("Constants and Validation", () => {
describe("File validation", () => {
it("should validate file sizes correctly", () => {
expect(isValidFileSize(100)).toBe(true);
expect(isValidFileSize(FILE_LIMITS.MAX_FILE_SIZE)).toBe(true);
expect(isValidFileSize(FILE_LIMITS.MAX_FILE_SIZE + 1)).toBe(false);
expect(isValidFileSize(0)).toBe(false);
expect(isValidFileSize(-1)).toBe(false);
});

it("should validate total sizes correctly", () => {
expect(isValidTotalSize(1000)).toBe(true);
expect(isValidTotalSize(FILE_LIMITS.MAX_TOTAL_SIZE)).toBe(true);
expect(isValidTotalSize(FILE_LIMITS.MAX_TOTAL_SIZE + 1)).toBe(false);
expect(isValidTotalSize(0)).toBe(false);
});

it("should validate file counts correctly", () => {
expect(isValidFileCount(1)).toBe(true);
expect(isValidFileCount(FILE_LIMITS.MAX_FILE_COUNT)).toBe(true);
expect(isValidFileCount(FILE_LIMITS.MAX_FILE_COUNT + 1)).toBe(false);
expect(isValidFileCount(0)).toBe(false);
});

it("should validate filenames correctly", () => {
expect(isValidFilename("test.js")).toBe(true);
expect(isValidFilename("a".repeat(FILE_LIMITS.MAX_FILENAME_LENGTH))).toBe(
true
);
expect(
isValidFilename("a".repeat(FILE_LIMITS.MAX_FILENAME_LENGTH + 1))
).toBe(false);
expect(isValidFilename("")).toBe(false);
expect(isValidFilename("test/file.js")).toBe(false);
expect(isValidFilename("test\\file.js")).toBe(false);
});
});

describe("PIN validation", () => {
it("should validate PINs correctly", () => {
expect(isValidPin("1234")).toBe(true);
expect(isValidPin("a".repeat(GIST_LIMITS.MIN_PIN_LENGTH))).toBe(true);
expect(isValidPin("a".repeat(GIST_LIMITS.MAX_PIN_LENGTH))).toBe(true);
expect(isValidPin("123")).toBe(false);
expect(isValidPin("a".repeat(GIST_LIMITS.MAX_PIN_LENGTH + 1))).toBe(
false
);
});
});

describe("Expiry validation", () => {
it("should validate expiry options correctly", () => {
expect(isValidExpiryOption("never")).toBe(true);
expect(isValidExpiryOption("1hour")).toBe(true);
expect(isValidExpiryOption("24hours")).toBe(true);
expect(isValidExpiryOption("7days")).toBe(true);
expect(isValidExpiryOption("30days")).toBe(true);
expect(isValidExpiryOption("invalid")).toBe(false);
});

it("should calculate expiry dates correctly", () => {
const now = Date.now();

expect(getExpiryDate("never")).toBe(null);

const oneHour = getExpiryDate("1hour");
expect(oneHour).toBeInstanceOf(Date);
expect(oneHour!.getTime() - now).toBeCloseTo(
EXPIRY_DURATIONS["1hour"]!,
-3
);

const sevenDays = getExpiryDate("7days");
expect(sevenDays).toBeInstanceOf(Date);
expect(sevenDays!.getTime() - now).toBeCloseTo(
EXPIRY_DURATIONS["7days"]!,
-3
);
});

it("should check expiry correctly", () => {
const future = new Date(Date.now() + 1000).toISOString();
const past = new Date(Date.now() - 1000).toISOString();

expect(isExpired(null)).toBe(false);
expect(isExpired(undefined)).toBe(false);
expect(isExpired(future)).toBe(false);
expect(isExpired(past)).toBe(true);
});
});

describe("Constants values", () => {
it("should have correct file limits", () => {
expect(FILE_LIMITS.MAX_FILE_SIZE).toBe(500 * 1024);
expect(FILE_LIMITS.MAX_TOTAL_SIZE).toBe(5 * 1024 * 1024);
expect(FILE_LIMITS.MAX_FILE_COUNT).toBe(20);
});

it("should have correct expiry durations", () => {
expect(EXPIRY_DURATIONS.never).toBe(null);
expect(EXPIRY_DURATIONS["1hour"]).toBe(60 * 60 * 1000);
expect(EXPIRY_DURATIONS["24hours"]).toBe(24 * 60 * 60 * 1000);
});
});
});
Loading