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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions docs/feature-flags-deployment.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
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;
Expand Down Expand Up @@ -270,6 +275,23 @@
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(),

Check failure on line 281 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Replace `.enum(['true',·'false']).transform((value)·=>·value·===·'true')` with `⏎······.enum(['true',·'false'])⏎······.transform((value)·=>·value·===·'true')⏎······`
),
FEATURE_DELIVERY_POOLING_ENABLED: z.preprocess(
blankToUndefined,
z.enum(['true', 'false']).transform((value) => value === 'true').optional(),

Check failure on line 285 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Replace `.enum(['true',·'false']).transform((value)·=>·value·===·'true')` with `⏎······.enum(['true',·'false'])⏎······.transform((value)·=>·value·===·'true')⏎······`
),
FEATURE_CHAT_ENABLED: z.preprocess(
blankToUndefined,
z.enum(['true', 'false']).transform((value) => value === 'true').optional(),

Check failure on line 289 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Replace `.enum(['true',·'false']).transform((value)·=>·value·===·'true')` with `⏎······.enum(['true',·'false'])⏎······.transform((value)·=>·value·===·'true')⏎······`
),
FEATURE_MILESTONES_ENABLED: z.preprocess(
blankToUndefined,
z.enum(['true', 'false']).transform((value) => value === 'true').optional(),

Check failure on line 293 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Replace `.enum(['true',·'false']).transform((value)·=>·value·===·'true')` with `⏎······.enum(['true',·'false'])⏎······.transform((value)·=>·value·===·'true')⏎······`
),

// ── Uploads / storage ──────────────────────────────────────────────────────
UPLOAD_STORAGE_DRIVER: z.enum(['local', 's3']).default('local'),
Expand Down Expand Up @@ -434,18 +456,18 @@
env = envSchema.parse(process.env);
} catch (error) {
if (error instanceof z.ZodError) {
console.error('❌ Invalid environment variables:');

Check warning on line 459 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Unexpected console statement
error.issues.forEach((issue) => {
console.error(` - ${issue.path.join('.')}: ${issue.message}`);

Check warning on line 461 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Unexpected console statement
});
} else {
console.error('❌ Failed to parse environment variables:', error);

Check warning on line 464 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Unexpected console statement
}
process.exit(1);
}

if (env.UPLOAD_STORAGE_DRIVER === 's3' && !env.AWS_S3_BUCKET) {
console.error('❌ AWS_S3_BUCKET is required when UPLOAD_STORAGE_DRIVER=s3');

Check warning on line 470 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Unexpected console statement
process.exit(1);
}

Expand Down
73 changes: 73 additions & 0 deletions src/config/flags.ts
Original file line number Diff line number Diff line change
@@ -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<FeatureFlag, boolean>;
export type FeatureFlagOverrides = Partial<Record<FeatureFlag, boolean>>;

/**
* 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]>) {

Check failure on line 53 in src/config/flags.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Replace `[FeatureFlag,·boolean·|·undefined]` with `⏎····[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 });
10 changes: 10 additions & 0 deletions src/services/poolingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -76,6 +77,11 @@ export class PoolingService {
* Find pending/funded deliveries and group them into proximity-based pools.
*/
async createPools(options: PoolingOptions = {}): Promise<DeliveryPool[]> {
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];
Expand Down Expand Up @@ -152,6 +158,10 @@ export class PoolingService {
* deliveries in the pool.
*/
async assignPoolToNearestDriver(pool: DeliveryPool): Promise<AssignPoolResult> {
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' };
}
Expand Down
6 changes: 6 additions & 0 deletions src/services/webhookService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -558,6 +559,11 @@ export class WebhookService {
* @returns Number of attempts processed in this sweep.
*/
async retryDueAttempts(): Promise<number> {
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() },
Expand Down
38 changes: 31 additions & 7 deletions src/sockets/chatMessage.service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.
*
Expand All @@ -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<IChatMessage[]> {
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') {
Expand Down Expand Up @@ -123,6 +145,8 @@ export class ChatMessageService {
* @throws {InvalidChatMessageError} If the payload is unusable.
*/
async createMessage(payload: IncomingChatMessage): Promise<IChatMessage> {
if (!isEnabled('chat')) throw new ChatFeatureDisabledError();

const validated = this.validate(payload);

let recipients: Types.ObjectId[] = validated.recipients ?? [];
Expand Down
15 changes: 14 additions & 1 deletion src/sockets/socketService.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<void> {
if (!isEnabled('chat')) return;

try {
const recent = await this.chat.getRecentTranscript();
socket.emit('recentMessages', recent);
Expand All @@ -57,6 +60,11 @@ export class SocketService {
payload: IncomingChatMessage,
socket?: Socket,
): Promise<void> {
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') {
Expand All @@ -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 });
Expand Down
34 changes: 34 additions & 0 deletions tests/chatFeatureFlag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
jest.mock('../src/config/flags', () => ({
isEnabled: (flag: string) => flag !== 'chat',
}));

import {

Check failure on line 5 in tests/chatFeatureFlag.test.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Replace `⏎··ChatFeatureDisabledError,⏎··ChatMessageService,⏎` with `·ChatFeatureDisabledError,·ChatMessageService·`
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();
});
});
Loading
Loading