diff --git a/.github/workflows/pr-version-check.yml b/.github/workflows/pr-version-check.yml new file mode 100644 index 0000000..1cbf871 --- /dev/null +++ b/.github/workflows/pr-version-check.yml @@ -0,0 +1,43 @@ +name: PR Version Check + +on: + pull_request: + branches: + - main + types: [opened, synchronize] + +jobs: + version-check: + runs-on: ubuntu-latest + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get PR branch package.json version + id: pr-version + run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + + - name: Get main branch package.json version + id: main-version + run: | + git checkout main + echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + + - name: Compare versions + run: | + PR_VERSION="${{ steps.pr-version.outputs.version }}" + MAIN_VERSION="${{ steps.main-version.outputs.version }}" + + echo "PR branch version: $PR_VERSION" + echo "Main branch version: $MAIN_VERSION" + + if [ "$PR_VERSION" = "$MAIN_VERSION" ]; then + echo "❌ Version must be updated in package.json" + echo "Current version: $MAIN_VERSION" + echo "Please increment the version number before merging to main" + exit 1 + else + echo "✅ Version has been updated from $MAIN_VERSION to $PR_VERSION" + fi \ No newline at end of file diff --git a/.github/workflows/version-and-publish.yml b/.github/workflows/version-and-publish.yml new file mode 100644 index 0000000..7666f01 --- /dev/null +++ b/.github/workflows/version-and-publish.yml @@ -0,0 +1,49 @@ +name: Publish to NPM + +on: + push: + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test + + - name: Run build + run: pnpm build + + - name: Get package version + id: package-version + run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + + - name: Create and push git tag + run: | + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + git tag -a "v${{ steps.package-version.outputs.version }}" -m "Release v${{ steps.package-version.outputs.version }}" + git push origin "v${{ steps.package-version.outputs.version }}" + + - name: Publish to NPM + run: pnpm publish --access public --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index 2d8d48b..6a321a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,10 +56,10 @@ The plugin follows Payload's plugin architecture with multiple exports: - `getUserVariant()`: A/B testing variant selection - `getFeatureFlagsByTag()`: Query flags by tags -#### API Endpoints (`src/endpoints/customEndpointHandler.ts`) -- `GET /api/feature-flags` - List all active feature flags -- `GET /api/feature-flags/:flag` - Get specific flag data -- Only created when `enableApi: true` +#### API Access +- The plugin uses Payload's native REST API for the feature-flags collection +- Standard Payload query syntax is supported +- Collection access controls are enforced ### Collection Schema The plugin creates a feature flags collection with these key fields: @@ -93,9 +93,9 @@ The plugin integrates with Payload by: 4. Supporting full collection customization through `collectionOverrides` ### Security Considerations -- REST API endpoints are disabled by default (`enableApi: false`) - Server-side hooks are the preferred method for accessing feature flags - Collection access can be restricted through `collectionOverrides.access` +- API access follows standard Payload authentication and authorization ### Testing Setup The development configuration (`dev/payload.config.ts`) includes: diff --git a/README.md b/README.md index f0ca57d..b76eb39 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,6 @@ export default buildConfig({ defaultValue: false, // New flags start disabled enableRollouts: true, // Allow percentage rollouts enableVariants: true, // Allow A/B testing - enableApi: false, // REST API endpoints disabled: false, // Plugin enabled }), ], @@ -82,12 +81,6 @@ export type PayloadFeatureFlagsConfig = { */ enableVariants?: boolean - /** - * Enable REST API endpoints for feature flags - * @default false - */ - enableApi?: boolean - /** * Disable the plugin while keeping the database schema intact * @default false @@ -166,14 +159,14 @@ payloadFeatureFlags({ ### Security Considerations -**API Access Control:** When `enableApi: true`, the REST endpoints respect your collection access controls: +**Collection Access Control:** The feature flags collection uses Payload's standard access control system: ```typescript -// Example: Secure API access +// Example: Secure collection access access: { // Option 1: Simple authentication check read: ({ req: { user } }) => !!user, // Only authenticated users - + // Option 2: More granular control read: ({ req: { user } }) => { if (!user) return false // No anonymous access @@ -183,7 +176,45 @@ access: { } ``` -**Important:** The plugin does not implement separate API authentication - it uses Payload's collection access system for security. +**Production Security Best Practices:** + +For production environments, consider implementing these additional security measures: + +```typescript +// Example: API key authentication for external services +collectionOverrides: { + access: { + read: ({ req }) => { + // Check for API key in headers for service-to-service calls + const apiKey = req.headers['x-api-key'] + if (apiKey && apiKey === process.env.FEATURE_FLAGS_API_KEY) { + return true + } + // Fall back to user authentication + return !!req.user + } + } +} +``` + +**Rate Limiting:** Use Payload's built-in rate limiting or implement middleware: + +```typescript +// Example with express-rate-limit +import rateLimit from 'express-rate-limit' + +const featureFlagLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // Limit each IP to 100 requests per windowMs + standardHeaders: true, + legacyHeaders: false, +}) + +// Apply to your API routes +app.use('/api/feature-flags', featureFlagLimiter) +``` + +**Important:** The plugin uses Payload's native REST API for the collection, which respects all access control rules. ## Usage @@ -259,75 +290,109 @@ export default async function ProductPage({ userId }: { userId: string }) { ### Using Feature Flags via REST API -If you have `enableApi: true`, you can use the REST API endpoints: +The plugin uses Payload's native REST API for the collection. You can access feature flags through the standard Payload REST endpoints: ```typescript -// Check if a specific feature is enabled -const response = await fetch('/api/feature-flags/new-dashboard') -const flag = await response.json() +// Get all feature flags +const response = await fetch('/api/feature-flags') +const result = await response.json() +// result.docs contains the array of feature flags -if (flag.enabled) { +// Query specific feature flags +const response = await fetch('/api/feature-flags?where[name][equals]=new-dashboard') +const result = await response.json() +if (result.docs.length > 0 && result.docs[0].enabled) { // Show new dashboard } -// Get all active feature flags -const allFlags = await fetch('/api/feature-flags') -const flags = await allFlags.json() +// Get only enabled flags +const response = await fetch('/api/feature-flags?where[enabled][equals]=true') +const result = await response.json() ``` **Important Security Notes:** -- REST API endpoints are disabled by default (`enableApi: false`) - **API endpoints respect your collection access controls** - they don't bypass security - Configure access permissions using `collectionOverrides.access` (see example above) - Anonymous users can only access flags if you explicitly allow it in access controls ### API Endpoints -When `enableApi: true`, the plugin exposes the following endpoints: +The plugin uses Payload's standard REST API endpoints for the feature-flags collection: -#### Get All Active Feature Flags +#### Get All Feature Flags ```http GET /api/feature-flags ``` -Returns all enabled feature flags: +Returns paginated feature flags: ```json { - "new-dashboard": { - "enabled": true, - "rolloutPercentage": 50, - "variants": null, - "metadata": {} - }, - "beta-feature": { - "enabled": true, - "rolloutPercentage": 100, - "variants": [ - { "name": "control", "weight": 50, "metadata": {} }, - { "name": "variant-a", "weight": 50, "metadata": {} } - ], - "metadata": {} - } + "docs": [ + { + "id": "...", + "name": "new-dashboard", + "enabled": true, + "rolloutPercentage": 50, + "variants": null, + "metadata": {}, + "createdAt": "...", + "updatedAt": "..." + }, + { + "id": "...", + "name": "beta-feature", + "enabled": true, + "rolloutPercentage": 100, + "variants": [ + { "name": "control", "weight": 50, "metadata": {} }, + { "name": "variant-a", "weight": 50, "metadata": {} } + ], + "metadata": {}, + "createdAt": "...", + "updatedAt": "..." + } + ], + "totalDocs": 2, + "limit": 10, + "page": 1, + "totalPages": 1, + "pagingCounter": 1, + "hasPrevPage": false, + "hasNextPage": false } ``` -#### Get Specific Feature Flag +#### Query Specific Feature Flag ```http -GET /api/feature-flags/:flagName +GET /api/feature-flags?where[name][equals]=new-dashboard ``` -Returns a specific feature flag: +Returns matching feature flags: ```json { - "name": "new-dashboard", - "enabled": true, - "rolloutPercentage": 50, - "variants": null, - "metadata": {} + "docs": [ + { + "id": "...", + "name": "new-dashboard", + "enabled": true, + "rolloutPercentage": 50, + "variants": null, + "metadata": {}, + "createdAt": "...", + "updatedAt": "..." + } + ], + "totalDocs": 1, + "limit": 10, + "page": 1, + "totalPages": 1, + "pagingCounter": 1, + "hasPrevPage": false, + "hasNextPage": false } ``` @@ -421,6 +486,38 @@ if (flag.enabled && flag.variants) { } ``` +## Performance Considerations + +### Client-Side Caching + +For improved performance, consider implementing client-side caching when fetching feature flags: + +```typescript +// Example: Simple cache with TTL +class FeatureFlagCache { + private cache = new Map() + private ttl = 5 * 60 * 1000 // 5 minutes + + async get(key: string, fetcher: () => Promise) { + const cached = this.cache.get(key) + if (cached && cached.expiry > Date.now()) { + return cached.data + } + + const data = await fetcher() + this.cache.set(key, { data, expiry: Date.now() + this.ttl }) + return data + } +} + +const flagCache = new FeatureFlagCache() + +// Use with the hooks +const flags = await flagCache.get('all-flags', () => + fetch('/api/feature-flags?limit=1000').then(r => r.json()) +) +``` + ## Migration ### Disabling the Plugin diff --git a/dev/README.md b/dev/README.md index 3ad57d1..98bcc40 100644 --- a/dev/README.md +++ b/dev/README.md @@ -38,9 +38,8 @@ pnpm dev ```typescript payloadFeatureFlags({ - enableRollouts: true, // Percentage rollouts + enableRollouts: true, // Percentage rollouts enableVariants: true, // A/B testing - enableApi: true, // REST endpoints defaultValue: false, // New flags start disabled // + custom fields and permissions }) @@ -48,12 +47,17 @@ payloadFeatureFlags({ ## API Testing +The plugin uses Payload's native REST API: + ```bash # Get all flags curl http://localhost:3000/api/feature-flags -# Get specific flag -curl http://localhost:3000/api/feature-flags/new-feature +# Query specific flag +curl 'http://localhost:3000/api/feature-flags?where[name][equals]=new-feature' + +# Get only enabled flags +curl 'http://localhost:3000/api/feature-flags?where[enabled][equals]=true' ``` diff --git a/dev/int.spec.ts b/dev/int.spec.ts index 04e6982..d8771bf 100644 --- a/dev/int.spec.ts +++ b/dev/int.spec.ts @@ -1,11 +1,9 @@ import type { Payload } from 'payload' import config from '@payload-config' -import { createPayloadRequest, getPayload } from 'payload' +import { getPayload } from 'payload' import { afterAll, beforeAll, describe, expect, test } from 'vitest' -import { customEndpointHandler } from '../src/endpoints/customEndpointHandler.js' - let payload: Payload afterAll(async () => { @@ -17,21 +15,6 @@ beforeAll(async () => { }) describe('Plugin integration tests', () => { - test('should query custom endpoint added by plugin', async () => { - const request = new Request('http://localhost:3000/api/my-plugin-endpoint', { - method: 'GET', - }) - - const payloadRequest = await createPayloadRequest({ config, request }) - const response = await customEndpointHandler(payloadRequest) - expect(response.status).toBe(200) - - const data = await response.json() - expect(data).toMatchObject({ - message: 'Hello from custom endpoint', - }) - }) - test('can create post with custom text field added by plugin', async () => { const post = await payload.create({ collection: 'posts', diff --git a/dev/payload.config.ts b/dev/payload.config.ts index 88692e6..f25147f 100644 --- a/dev/payload.config.ts +++ b/dev/payload.config.ts @@ -127,7 +127,6 @@ export default buildConfig({ // Enable all features enableRollouts: true, enableVariants: true, - enableApi: true, defaultValue: false, // Custom collection configuration diff --git a/package.json b/package.json index 7f8dfa8..afb0b60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-feature-flags", - "version": "0.0.7", + "version": "0.0.9", "description": "Feature flags plugin for Payload CMS - manage feature toggles, A/B tests, and gradual rollouts", "license": "MIT", "type": "module", diff --git a/src/endpoints/customEndpointHandler.ts b/src/endpoints/customEndpointHandler.ts deleted file mode 100644 index 83db3d0..0000000 --- a/src/endpoints/customEndpointHandler.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { PayloadHandler } from 'payload' - -export const customEndpointHandler = (collectionSlug: string): PayloadHandler => - async (req) => { - const { payload } = req - const url = new URL(req.url || '') - const pathParts = url.pathname.split('/').filter(Boolean) - const flagName = pathParts[pathParts.length - 1] - - // Check if we're fetching a specific flag - if (flagName && flagName !== 'feature-flags') { - try { - const result = await payload.find({ - collection: collectionSlug, - where: { - name: { - equals: flagName, - }, - }, - limit: 1, - }) - - if (result.docs.length === 0) { - return Response.json( - { error: 'Feature flag not found' }, - { status: 404 } - ) - } - - const flag = result.docs[0] - - // Return simplified flag data - return Response.json({ - name: flag.name, - enabled: flag.enabled, - rolloutPercentage: flag.rolloutPercentage, - variants: flag.variants, - metadata: flag.metadata, - }) - } catch (error) { - return Response.json( - { error: 'Failed to fetch feature flag' }, - { status: 500 } - ) - } - } - - // Fetch all feature flags - try { - const result = await payload.find({ - collection: collectionSlug, - limit: 1000, // Adjust as needed - where: { - enabled: { - equals: true, - }, - }, - }) - - // Return simplified flag data - const flags = result.docs.reduce((acc: any, flag: any) => { - acc[flag.name] = { - enabled: flag.enabled, - rolloutPercentage: flag.rolloutPercentage, - variants: flag.variants, - metadata: flag.metadata, - } - return acc - }, {}) - - return Response.json(flags) - } catch (error) { - return Response.json( - { error: 'Failed to fetch feature flags' }, - { status: 500 } - ) - } - } \ No newline at end of file diff --git a/src/hooks/client.ts b/src/hooks/client.ts index 75aa807..da0b323 100644 --- a/src/hooks/client.ts +++ b/src/hooks/client.ts @@ -17,14 +17,16 @@ export interface FeatureFlag { /** * Hook to fetch all active feature flags from the API */ -export function useFeatureFlags(): { - flags: Record | null +export function useFeatureFlags( + initialFlags: Partial[] +): { + flags: Partial[] loading: boolean error: string | null refetch: () => Promise } { const { config } = useConfig() - const [flags, setFlags] = useState | null>(null) + const [flags, setFlags] = useState[]>(initialFlags) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -33,25 +35,49 @@ export function useFeatureFlags(): { setLoading(true) setError(null) - const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags`) - + // Use Payload's native collection API + const names = initialFlags.map(f => f.name).filter(Boolean) + const query = names.length > 0 + ? `?where[name][in]=${names.join(',')}&limit=1000` + : '?limit=1000' + + const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags${query}`) + if (!response.ok) { - if (response.status === 404) { - throw new Error('Feature flags API not enabled. Set enableApi: true in plugin config.') - } throw new Error(`Failed to fetch feature flags: ${response.statusText}`) } const result = await response.json() - setFlags(result) + + // Create a map of fetched flags by name for quick lookup + const fetchedFlagsMap = new Map>() + if (result.docs && Array.isArray(result.docs)) { + result.docs.forEach((doc: any) => { + fetchedFlagsMap.set(doc.name, { + name: doc.name, + enabled: doc.enabled, + rolloutPercentage: doc.rolloutPercentage, + variants: doc.variants, + metadata: doc.metadata, + }) + }) + } + + // Sort flags based on the order of names in initialFlags + const sortedFlags = initialFlags.map(initialFlag => { + const fetchedFlag = fetchedFlagsMap.get(initialFlag.name!) + // Use fetched flag if available, otherwise keep the initial flag + return fetchedFlag || initialFlag + }) + + setFlags(sortedFlags) } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred' setError(errorMessage) - setFlags(null) } finally { setLoading(false) } - }, [config.serverURL, config.routes.api]) + }, [config.serverURL, config.routes.api, initialFlags]) useEffect(() => { void fetchFlags() @@ -65,13 +91,13 @@ export function useFeatureFlags(): { */ export function useFeatureFlag(flagName: string): { isEnabled: boolean - flag: FeatureFlag | null + flag: Partial | null loading: boolean error: string | null } { - const { flags, loading, error } = useFeatureFlags() - - const flag = flags?.[flagName] || null + const { flags, loading, error } = useFeatureFlags([{ name: flagName }]) + + const flag = flags.find(f => f.name === flagName) || null const isEnabled = flag?.enabled || false return { isEnabled, flag, loading, error } @@ -96,19 +122,30 @@ export function useSpecificFeatureFlag(flagName: string): { setLoading(true) setError(null) - const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags/${flagName}`) - + // Use Payload's native collection API with query filter + const response = await fetch( + `${config.serverURL}${config.routes.api}/feature-flags?where[name][equals]=${flagName}&limit=1` + ) + if (!response.ok) { - if (response.status === 404) { - setFlag(null) - setError(`Feature flag '${flagName}' not found`) - return - } throw new Error(`Failed to fetch feature flag: ${response.statusText}`) } const result = await response.json() - setFlag(result) + + if (result.docs && result.docs.length > 0) { + const doc = result.docs[0] + setFlag({ + name: doc.name, + enabled: doc.enabled, + rolloutPercentage: doc.rolloutPercentage, + variants: doc.variants, + metadata: doc.metadata, + }) + } else { + setFlag(null) + setError(`Feature flag '${flagName}' not found`) + } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred' setError(errorMessage) @@ -129,7 +166,7 @@ export function useSpecificFeatureFlag(flagName: string): { * Utility hook for A/B testing - selects a variant based on user ID */ export function useVariantSelection( - flagName: string, + flagName: string, userId: string ): { variant: string | null @@ -138,8 +175,8 @@ export function useVariantSelection( error: string | null } { const { flag, loading, error } = useSpecificFeatureFlag(flagName) - - const variant = flag?.enabled && flag.variants + + const variant = flag?.enabled && flag.variants ? selectVariantForUser(userId, flag.variants) : null @@ -150,7 +187,7 @@ export function useVariantSelection( * Utility hook to check if user is in rollout percentage */ export function useRolloutCheck( - flagName: string, + flagName: string, userId: string ): { isInRollout: boolean @@ -159,8 +196,8 @@ export function useRolloutCheck( error: string | null } { const { flag, loading, error } = useSpecificFeatureFlag(flagName) - - const isInRollout = flag?.enabled + + const isInRollout = flag?.enabled ? checkUserInRollout(userId, flag.rolloutPercentage || 100) : false @@ -173,26 +210,26 @@ export function useRolloutCheck( * Select variant for a user based on consistent hashing */ function selectVariantForUser( - userId: string, + userId: string, variants: Array<{ name: string; weight: number }> ): string | null { if (variants.length === 0) return null - + // Simple hash function for consistent user bucketing const hash = Math.abs(userId.split('').reduce((acc, char) => { return ((acc << 5) - acc) + char.charCodeAt(0) }, 0)) - + const bucket = hash % 100 let cumulative = 0 - + for (const variant of variants) { cumulative += variant.weight if (bucket < cumulative) { return variant.name } } - + return variants[0]?.name || null } @@ -202,12 +239,12 @@ function selectVariantForUser( function checkUserInRollout(userId: string, percentage: number): boolean { if (percentage >= 100) return true if (percentage <= 0) return false - + // Simple hash function for consistent user bucketing const hash = userId.split('').reduce((acc, char) => { return ((acc << 5) - acc) + char.charCodeAt(0) }, 0) - + return (Math.abs(hash) % 100) < percentage } @@ -223,16 +260,16 @@ export function withFeatureFlag

>( ): React.ComponentType

{ return function WithFeatureFlagComponent(props: P): React.ReactElement | null { const { isEnabled, loading } = useFeatureFlag(flagName) - + if (loading) { return null // or a loading spinner } - + if (!isEnabled) { return FallbackComponent ? React.createElement(FallbackComponent, props) : null } - + return React.createElement(WrappedComponent, props) } } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 2285841..3d83577 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,5 @@ import type { Config, CollectionConfig, Field } from 'payload' -import { customEndpointHandler } from './endpoints/customEndpointHandler.js' - export type CollectionOverrides = Partial< Omit > & { @@ -29,11 +27,6 @@ export type PayloadFeatureFlagsConfig = { * @default true */ enableVariants?: boolean - /** - * Enable REST API endpoints for feature flags - * @default false - */ - enableApi?: boolean /** * Override collection configuration */ @@ -48,7 +41,6 @@ export const payloadFeatureFlags = defaultValue = false, enableRollouts = true, enableVariants = true, - enableApi = false, collectionOverrides, } = pluginOptions @@ -207,23 +199,5 @@ export const payloadFeatureFlags = path: '/feature-flags-overview', } - - // Add API endpoints if enabled - if (enableApi) { - // Add API endpoint for fetching feature flags - config.endpoints.push({ - handler: customEndpointHandler(collectionSlug), - method: 'get', - path: '/feature-flags', - }) - - // Add endpoint for checking a specific feature flag - config.endpoints.push({ - handler: customEndpointHandler(collectionSlug), - method: 'get', - path: '/feature-flags/:flag', - }) - } - return config } diff --git a/src/views/FeatureFlagsView.tsx b/src/views/FeatureFlagsView.tsx index 75fea5a..f52aa24 100644 --- a/src/views/FeatureFlagsView.tsx +++ b/src/views/FeatureFlagsView.tsx @@ -53,17 +53,13 @@ const FeatureFlagsViewComponent = () => { }) if (!response.ok) { - if (response.status === 404) { - setError('Feature flags API not enabled. Set enableApi: true in plugin config.') - return - } throw new Error(`Failed to fetch feature flags: ${response.statusText}`) } - + const result = await response.json() - - // Convert the result object to an array if it's not already - const flagsArray = Array.isArray(result) ? result : Object.values(result || {}) + + // Extract docs array from Payload API response + const flagsArray = result.docs || [] // Only update state if the component is still mounted (signal not aborted) if (!signal?.aborted) {