diff --git a/.env.example b/.env.example index df8f639..02e2a69 100644 --- a/.env.example +++ b/.env.example @@ -338,6 +338,21 @@ SHUTDOWN_TIMEOUT_MS=30000 # silly. [default: info] LOG_LEVEL=info +# ─── Optional feature flags ────────────────────────────────────────────────── + +# Comma-separated flag names or name=true/false pairs. Bare names enable a +# flag; explicit values allow an individual flag to be disabled. Unknown names +# or invalid boolean values fail application startup. Defaults preserve the +# existing webhook retry, delivery pooling, and chat behavior; milestones are +# disabled because no milestone service is currently implemented. +FEATURE_FLAGS= + +# Optional per-flag overrides. Leave blank to use FEATURE_FLAGS/registry defaults. +FEATURE_WEBHOOK_BATCHING_ENABLED= +FEATURE_DELIVERY_POOLING_ENABLED= +FEATURE_CHAT_ENABLED= +FEATURE_MILESTONES_ENABLED= + # ─── Notes on variables that are NOT part of the app schema ────────────────── # JEST_WORKER_ID is read by Jest itself to detect the test runner, and is diff --git a/docs/feature-flags-deployment.md b/docs/feature-flags-deployment.md new file mode 100644 index 0000000..cd9d7c6 --- /dev/null +++ b/docs/feature-flags-deployment.md @@ -0,0 +1,46 @@ +# Feature flag deployment guide + +The feature flag registry is defined in src/config/flags.ts and validated environment variables are declared in src/config/env.ts. + +## Known flags and defaults + +| Flag | Default | Gated behavior | +| --- | --- | --- | +| webhookBatching | true | Retrying due webhook delivery attempts in batches | +| deliveryPooling | true | Creating delivery pools and assigning a pool to a driver | +| chat | true | Replaying chat history and persisting/broadcasting chat messages | +| milestones | false | Reserved for milestone behavior; no milestone service or entry point currently exists in this repository | + +Defaults preserve the existing webhook retry, pooling, and chat behavior. The milestones flag remains off until a milestone implementation is introduced. + +## Configure flags + +FEATURE_FLAGS accepts comma-separated flag names or name=value pairs. A bare name enables that flag; a value must be true or false. Examples: + + FEATURE_FLAGS=chat=false,deliveryPooling=false + +Individual variables override values supplied through FEATURE_FLAGS: + + FEATURE_WEBHOOK_BATCHING_ENABLED=false + FEATURE_DELIVERY_POOLING_ENABLED=true + FEATURE_CHAT_ENABLED=false + FEATURE_MILESTONES_ENABLED=false + +Leave individual variables unset or blank to use the registry default or the value supplied in FEATURE_FLAGS. Unknown names and invalid boolean values cause startup to fail with a clear configuration error rather than silently enabling or disabling behavior. + +Flags are resolved at process startup. Updating environment configuration does not require a code change or new image, but the process must be restarted to load the new values. + +## Operational behavior + +- With webhookBatching disabled, the retry sweep returns zero processed attempts without querying due attempts. New webhook dispatch behavior is unchanged. +- With deliveryPooling disabled, pool creation returns an empty list and direct pool assignment returns an unassigned result. Ordinary one-to-one driver assignment is unchanged. +- With chat disabled, recent transcript reads return an empty list and incoming messages are not persisted or broadcast. Socket clients receive a clear disabled response when attempting to send a message. +- The milestones flag is registered and configurable, but cannot gate a milestone flow until that flow exists. Do not treat enabling the flag as enabling milestone functionality. + +## Tests + +Run the feature flag parsing and chat gate tests: + + pnpm test -- tests/featureFlags.test.ts tests/chatFeatureFlag.test.ts + +Then run the full suite, type check/build, and lint before merging. Do not commit secrets or production credentials in this configuration. diff --git a/src/config/env.ts b/src/config/env.ts index 6f1616e..1cfeafb 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -19,6 +19,11 @@ interface EnvConfig { RATE_LIMIT_WINDOW_MS: number; RATE_LIMIT_MAX_REQUESTS: number; DISPUTE_NOTIFICATION_WEBHOOK_URL: string; + FEATURE_FLAGS: string; + FEATURE_WEBHOOK_BATCHING_ENABLED?: boolean; + FEATURE_DELIVERY_POOLING_ENABLED?: boolean; + FEATURE_CHAT_ENABLED?: boolean; + FEATURE_MILESTONES_ENABLED?: boolean; UPLOAD_STORAGE_DRIVER: string; UPLOAD_LOCAL_DIR: string; UPLOAD_MAX_FILE_SIZE_MB: number; @@ -270,6 +275,23 @@ const envSchema = z.object({ RATE_LIMIT_WINDOW_MS: numeric(z.coerce.number().int().min(1000).default(900000)), RATE_LIMIT_MAX_REQUESTS: numeric(z.coerce.number().int().min(1).default(100)), DISPUTE_NOTIFICATION_WEBHOOK_URL: z.string().default(''), + FEATURE_FLAGS: z.string().trim().default(''), + FEATURE_WEBHOOK_BATCHING_ENABLED: z.preprocess( + blankToUndefined, + z.enum(['true', 'false']).transform((value) => value === 'true').optional(), + ), + FEATURE_DELIVERY_POOLING_ENABLED: z.preprocess( + blankToUndefined, + z.enum(['true', 'false']).transform((value) => value === 'true').optional(), + ), + FEATURE_CHAT_ENABLED: z.preprocess( + blankToUndefined, + z.enum(['true', 'false']).transform((value) => value === 'true').optional(), + ), + FEATURE_MILESTONES_ENABLED: z.preprocess( + blankToUndefined, + z.enum(['true', 'false']).transform((value) => value === 'true').optional(), + ), // ── Uploads / storage ────────────────────────────────────────────────────── UPLOAD_STORAGE_DRIVER: z.enum(['local', 's3']).default('local'), diff --git a/src/config/flags.ts b/src/config/flags.ts new file mode 100644 index 0000000..199ad34 --- /dev/null +++ b/src/config/flags.ts @@ -0,0 +1,73 @@ +import env from './env'; + +export const FEATURE_FLAG_DEFAULTS = { + webhookBatching: true, + deliveryPooling: true, + chat: true, + milestones: false, +} as const; + +export type FeatureFlag = keyof typeof FEATURE_FLAG_DEFAULTS; +export type FeatureFlagValues = Record; +export type FeatureFlagOverrides = Partial>; + +/** + * Parse a comma-separated list of feature names or name=value pairs. + * A bare name enables that flag; an explicit value must be true or false. + * Unknown names and malformed values fail fast during application startup. + */ +export const parseFeatureFlags = ( + input: string, + overrides: FeatureFlagOverrides = {}, +): FeatureFlagValues => { + const flags: FeatureFlagValues = { ...FEATURE_FLAG_DEFAULTS }; + + if (input.trim()) { + for (const entry of input.split(',')) { + const token = entry.trim(); + if (!token) continue; + + const parts = token.split('='); + if (parts.length > 2) { + throw new Error('Invalid FEATURE_FLAGS entry: ' + token); + } + + const name = parts[0].trim(); + if (!Object.prototype.hasOwnProperty.call(FEATURE_FLAG_DEFAULTS, name)) { + throw new Error('Unknown feature flag: ' + name); + } + + let enabled = true; + if (parts.length === 2) { + const value = parts[1].trim().toLowerCase(); + if (value !== 'true' && value !== 'false') { + throw new Error('Invalid boolean for feature flag ' + name + ': ' + parts[1]); + } + enabled = value === 'true'; + } + + flags[name as FeatureFlag] = enabled; + } + } + + for (const [name, value] of Object.entries(overrides) as Array<[FeatureFlag, boolean | undefined]>) { + if (value !== undefined) { + flags[name] = value; + } + } + + return flags; +}; + +const featureFlags = parseFeatureFlags(env.FEATURE_FLAGS, { + webhookBatching: env.FEATURE_WEBHOOK_BATCHING_ENABLED, + deliveryPooling: env.FEATURE_DELIVERY_POOLING_ENABLED, + chat: env.FEATURE_CHAT_ENABLED, + milestones: env.FEATURE_MILESTONES_ENABLED, +}); + +/** Return whether a known optional feature is enabled in this process. */ +export const isEnabled = (flag: FeatureFlag): boolean => featureFlags[flag]; + +/** Return a defensive copy of the resolved feature-flag registry. */ +export const getFeatureFlags = (): FeatureFlagValues => ({ ...featureFlags }); diff --git a/src/services/poolingService.ts b/src/services/poolingService.ts index 15599c6..1a41b31 100644 --- a/src/services/poolingService.ts +++ b/src/services/poolingService.ts @@ -17,6 +17,7 @@ import type { IRoutingProvider } from './providers/routingProvider'; import { encodeGeohash } from '../utils/geohash'; import logger from '../config/logger'; import env from '../config/env'; +import { isEnabled } from '../config/flags'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -76,6 +77,11 @@ export class PoolingService { * Find pending/funded deliveries and group them into proximity-based pools. */ async createPools(options: PoolingOptions = {}): Promise { + if (!isEnabled('deliveryPooling')) { + logger.debug('[PoolingService] Delivery pooling is disabled by feature flag.'); + return []; + } + const proximity = options.maxProximityMeters ?? this.DEFAULT_PROXIMITY_M; const maxSize = options.maxPoolSize ?? this.DEFAULT_MAX_POOL_SIZE; const statuses = options.statuses ?? [DeliveryStatus.PENDING, DeliveryStatus.FUNDED]; @@ -152,6 +158,10 @@ export class PoolingService { * deliveries in the pool. */ async assignPoolToNearestDriver(pool: DeliveryPool): Promise { + if (!isEnabled('deliveryPooling')) { + return { assigned: false, reason: 'Delivery pooling is disabled by feature flag' }; + } + if (pool.deliveries.length === 0) { return { assigned: false, reason: 'Empty pool' }; } diff --git a/src/services/webhookService.ts b/src/services/webhookService.ts index d299cd4..fb3f38b 100644 --- a/src/services/webhookService.ts +++ b/src/services/webhookService.ts @@ -41,6 +41,7 @@ import { import { IDelivery, DeliveryStatus } from '../models/Delivery'; import AppError from '../utils/AppError'; import env from '../config/env'; +import { isEnabled } from '../config/flags'; import logger from '../config/logger'; // ─── DTOs ────────────────────────────────────────────────────────────────────── @@ -558,6 +559,11 @@ export class WebhookService { * @returns Number of attempts processed in this sweep. */ async retryDueAttempts(): Promise { + if (!isEnabled('webhookBatching')) { + logger.debug('[WebhookService] Webhook retry batching is disabled by feature flag.'); + return 0; + } + const due = await WebhookDeliveryAttempt.find({ status: WebhookDeliveryStatus.FAILED, nextRetryAt: { $lte: new Date() }, diff --git a/src/sockets/chatMessage.service.ts b/src/sockets/chatMessage.service.ts index 60dd839..c9ce499 100644 --- a/src/sockets/chatMessage.service.ts +++ b/src/sockets/chatMessage.service.ts @@ -1,8 +1,5 @@ -import { Types } from 'mongoose'; -import { IChatMessage, IReadReceipt } from '../models/ChatMessage'; -import Delivery from '../models/Delivery'; -import User from '../models/User'; -import { UserRole } from '../interfaces/IUser'; +import { IChatMessage } from '../models/ChatMessage'; +import { isEnabled } from '../config/flags'; import { ChatMessageRepository, chatMessageRepository, @@ -40,6 +37,14 @@ export class InvalidChatMessageError extends Error { } } +/** Raised when chat is disabled and a caller attempts to persist a message. */ +export class ChatFeatureDisabledError extends Error { + constructor() { + super('Chat is disabled by feature flag'); + this.name = 'ChatFeatureDisabledError'; + } +} + /** * Chat domain logic, decoupled from Socket.IO transport. * @@ -53,8 +58,25 @@ export class ChatMessageService { /** * Validate an incoming raw payload. * - * @throws {InvalidChatMessageError} If content is missing, the wrong type, - * empty, or exceeds the length limit; or if sender is provided but not a string. + * The query returns newest-first to use the descending `createdAt` index; + * the reversal to reading order happens here rather than in the handler so + * every caller gets the same ordering. + */ + async getRecentTranscript(limit: number = RECENT_MESSAGE_LIMIT): Promise { + if (!isEnabled('chat')) return []; + + const recent = await this.messages.findRecent(limit); + return recent.reverse(); + } + + /** + * Validate an untrusted payload from a client. + * + * Socket payloads bypass Express middleware entirely, so this is the only + * validation boundary for realtime input — it must not assume any prior + * checking. + * + * @throws {InvalidChatMessageError} If the payload is unusable. */ validate(payload: IncomingChatMessage): ValidatedChatMessage { if (payload === null || typeof payload !== 'object') { @@ -123,6 +145,8 @@ export class ChatMessageService { * @throws {InvalidChatMessageError} If the payload is unusable. */ async createMessage(payload: IncomingChatMessage): Promise { + if (!isEnabled('chat')) throw new ChatFeatureDisabledError(); + const validated = this.validate(payload); let recipients: Types.ObjectId[] = validated.recipients ?? []; diff --git a/src/sockets/socketService.ts b/src/sockets/socketService.ts index 37c5d9a..1871555 100644 --- a/src/sockets/socketService.ts +++ b/src/sockets/socketService.ts @@ -1,10 +1,11 @@ import { Namespace, Socket } from 'socket.io'; import { IChatMessage } from '../models/ChatMessage'; import logger from '../config/logger'; -import { deliveryRoom } from './location.service'; +import { isEnabled } from '../config/flags'; import { ChatMessageService, IncomingChatMessage, + ChatFeatureDisabledError, InvalidChatMessageError, chatMessageService, } from './chatMessage.service'; @@ -34,6 +35,8 @@ export class SocketService { * client's failed backlog must not tear down the connection handler. */ public async handleConnection(socket: Socket, _nsp: Namespace): Promise { + if (!isEnabled('chat')) return; + try { const recent = await this.chat.getRecentTranscript(); socket.emit('recentMessages', recent); @@ -57,6 +60,11 @@ export class SocketService { payload: IncomingChatMessage, socket?: Socket, ): Promise { + if (!isEnabled('chat')) { + socket?.emit('error', { message: 'Chat is disabled' }); + return; + } + try { const message = await this.chat.createMessage(payload); if (message.deliveryId && typeof nsp.to === 'function') { @@ -66,6 +74,11 @@ export class SocketService { nsp.emit('message', message); } } catch (error) { + if (error instanceof ChatFeatureDisabledError) { + socket?.emit('error', { message: 'Chat is disabled' }); + return; + } + if (error instanceof InvalidChatMessageError) { logger.warn(`[SocketService] Rejected invalid message: ${error.message}`); socket?.emit('error', { message: error.message }); diff --git a/tests/chatFeatureFlag.test.ts b/tests/chatFeatureFlag.test.ts new file mode 100644 index 0000000..a3b5d2f --- /dev/null +++ b/tests/chatFeatureFlag.test.ts @@ -0,0 +1,34 @@ +jest.mock('../src/config/flags', () => ({ + isEnabled: (flag: string) => flag !== 'chat', +})); + +import { + ChatFeatureDisabledError, + ChatMessageService, +} from '../src/sockets/chatMessage.service'; +import type { ChatMessageRepository } from '../src/repositories/ChatMessageRepository'; + +describe('chat feature flag', () => { + const repository = { + findRecent: jest.fn(), + create: jest.fn(), + }; + + const service = new ChatMessageService(repository as unknown as ChatMessageRepository); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not read chat history while chat is disabled', async () => { + await expect(service.getRecentTranscript()).resolves.toEqual([]); + expect(repository.findRecent).not.toHaveBeenCalled(); + }); + + it('rejects message persistence while chat is disabled', async () => { + await expect(service.createMessage({ content: 'hello' })).rejects.toBeInstanceOf( + ChatFeatureDisabledError, + ); + expect(repository.create).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/featureFlagServiceGates.test.ts b/tests/featureFlagServiceGates.test.ts new file mode 100644 index 0000000..2b5bbf7 --- /dev/null +++ b/tests/featureFlagServiceGates.test.ts @@ -0,0 +1,30 @@ +jest.mock('../src/config/flags', () => ({ + isEnabled: (flag: string) => flag !== 'deliveryPooling' && flag !== 'webhookBatching', +})); + +jest.mock('../src/models/WebhookDeliveryAttempt', () => ({ + WebhookDeliveryAttempt: { + find: jest.fn(), + }, +})); + +import poolingService, { DeliveryPool } from '../src/services/poolingService'; +import webhookService from '../src/services/webhookService'; +import { WebhookDeliveryAttempt } from '../src/models/WebhookDeliveryAttempt'; + +describe('feature-flag service gates', () => { + it('does not query or create delivery pools when pooling is disabled', async () => { + await expect(poolingService.createPools()).resolves.toEqual([]); + await expect( + poolingService.assignPoolToNearestDriver({} as DeliveryPool), + ).resolves.toEqual({ + assigned: false, + reason: 'Delivery pooling is disabled by feature flag', + }); + }); + + it('does not query due webhook attempts when webhook batching is disabled', async () => { + await expect(webhookService.retryDueAttempts()).resolves.toBe(0); + expect(WebhookDeliveryAttempt.find).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/featureFlags.test.ts b/tests/featureFlags.test.ts new file mode 100644 index 0000000..e8c5d92 --- /dev/null +++ b/tests/featureFlags.test.ts @@ -0,0 +1,45 @@ +import { + FEATURE_FLAG_DEFAULTS, + parseFeatureFlags, +} from '../src/config/flags'; + +describe('feature flag configuration', () => { + it('preserves existing optional behavior by default and leaves milestones disabled', () => { + expect(FEATURE_FLAG_DEFAULTS).toEqual({ + webhookBatching: true, + deliveryPooling: true, + chat: true, + milestones: false, + }); + }); + + it('parses bare names and explicit boolean values', () => { + expect(parseFeatureFlags('chat=false, deliveryPooling=true')).toEqual({ + webhookBatching: true, + deliveryPooling: true, + chat: false, + milestones: false, + }); + expect(parseFeatureFlags('milestones')).toEqual({ + webhookBatching: true, + deliveryPooling: true, + chat: true, + milestones: true, + }); + }); + + it('lets individual environment overrides take precedence over FEATURE_FLAGS', () => { + expect(parseFeatureFlags('chat=false', { chat: true, deliveryPooling: false })).toEqual({ + webhookBatching: true, + deliveryPooling: false, + chat: true, + milestones: false, + }); + }); + + it('rejects unknown flag names and invalid boolean values', () => { + expect(() => parseFeatureFlags('unknownFeature=true')).toThrow('Unknown feature flag'); + expect(() => parseFeatureFlags('chat=maybe')).toThrow('Invalid boolean'); + expect(() => parseFeatureFlags('chat=true=false')).toThrow('Invalid FEATURE_FLAGS entry'); + }); +});