fix(flags): critical partition key bug + audit snapshot integrity + anonymous rollout

- repository.ts: update() and remove() now require productId as partition key
  (was passing 'id' as both params — works with memory provider but fails on Cosmos DB)
- repository.ts: updateSegment() and removeSegment() also fixed
- routes.ts: all repo.update/remove calls updated to pass productId
- routes.ts: audit 'before' snapshots now use JSON deep copy instead of shallow spread
  (prevents nested object mutation from corrupting audit trail)
- routes.ts: kill switch audit now uses repo.update() return value for 'after' snapshot
- evaluator.ts: anonymous users (no userId) with partial percentage (0 < pct < 100)
  now correctly return 'off' instead of falling through to default variation
  (can't deterministically hash without a userId)
This commit is contained in:
saravanakumardb1 2026-03-21 11:50:08 -07:00
parent ca6a4d41d8
commit dd113b96c9
3 changed files with 33 additions and 20 deletions

View File

@ -352,8 +352,8 @@ export function evaluateFlag(opts: EvaluateFlagOptions): EvaluationResult {
return offResult(flag, 'off'); return offResult(flag, 'off');
} }
// 10. Default (no userId, full rollout) // 10. No userId with partial rollout — can't hash, return off
return variationResult(flag, flag.defaultVariation, 'default'); return offResult(flag, 'off');
} }
/** /**

View File

@ -35,10 +35,11 @@ export async function create(doc: FeatureFlagDoc): Promise<FeatureFlagDoc> {
export async function update( export async function update(
id: string, id: string,
productId: string,
updates: Partial<FeatureFlagDoc> updates: Partial<FeatureFlagDoc>
): Promise<FeatureFlagDoc | null> { ): Promise<FeatureFlagDoc | null> {
try { try {
return await flagCollection().update(id, id, { return await flagCollection().update(id, productId, {
...updates, ...updates,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
@ -47,9 +48,9 @@ export async function update(
} }
} }
export async function remove(id: string): Promise<boolean> { export async function remove(id: string, productId: string): Promise<boolean> {
try { try {
await flagCollection().delete(id, id); await flagCollection().delete(id, productId);
return true; return true;
} catch { } catch {
return false; return false;
@ -81,10 +82,11 @@ export async function createSegment(doc: SegmentDoc): Promise<SegmentDoc> {
export async function updateSegment( export async function updateSegment(
id: string, id: string,
productId: string,
updates: Partial<SegmentDoc> updates: Partial<SegmentDoc>
): Promise<SegmentDoc | null> { ): Promise<SegmentDoc | null> {
try { try {
return await segmentCollection().update(id, id, { return await segmentCollection().update(id, productId, {
...updates, ...updates,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
@ -93,9 +95,9 @@ export async function updateSegment(
} }
} }
export async function removeSegment(id: string): Promise<boolean> { export async function removeSegment(id: string, productId: string): Promise<boolean> {
try { try {
await segmentCollection().delete(id, id); await segmentCollection().delete(id, productId);
return true; return true;
} catch { } catch {
return false; return false;

View File

@ -292,9 +292,9 @@ export async function flagRoutes(app: FastifyInstance) {
} }
const actor = getActor(req); const actor = getActor(req);
const before = { ...flag }; const before = JSON.parse(JSON.stringify(flag)) as FeatureFlagDoc;
const updates = { ...parsed.data, updatedBy: actor, version: flag.version + 1 }; const updates = { ...parsed.data, updatedBy: actor, version: flag.version + 1 };
const updated = await repo.update(flag.id, updates); const updated = await repo.update(flag.id, productId, updates);
if (!updated) throw new NotFoundError('Flag update failed'); if (!updated) throw new NotFoundError('Flag update failed');
await recordAudit(productId, key, 'updated', actor, before, updated); await recordAudit(productId, key, 'updated', actor, before, updated);
@ -322,8 +322,8 @@ export async function flagRoutes(app: FastifyInstance) {
if (!flag) throw new NotFoundError('Flag not found'); if (!flag) throw new NotFoundError('Flag not found');
const actor = getActor(req); const actor = getActor(req);
const before = { ...flag }; const before = JSON.parse(JSON.stringify(flag)) as FeatureFlagDoc;
const updated = await repo.update(flag.id, { const updated = await repo.update(flag.id, productId, {
enabled: !flag.enabled, enabled: !flag.enabled,
updatedBy: actor, updatedBy: actor,
version: flag.version + 1, version: flag.version + 1,
@ -358,8 +358,8 @@ export async function flagRoutes(app: FastifyInstance) {
const newArchived = archived ?? !flag.archived; const newArchived = archived ?? !flag.archived;
const actor = getActor(req); const actor = getActor(req);
const before = { ...flag }; const before = JSON.parse(JSON.stringify(flag)) as FeatureFlagDoc;
const updated = await repo.update(flag.id, { const updated = await repo.update(flag.id, productId, {
archived: newArchived, archived: newArchived,
enabled: newArchived ? false : flag.enabled, enabled: newArchived ? false : flag.enabled,
updatedBy: actor, updatedBy: actor,
@ -381,7 +381,7 @@ export async function flagRoutes(app: FastifyInstance) {
if (!flag) throw new NotFoundError('Flag not found'); if (!flag) throw new NotFoundError('Flag not found');
const actor = getActor(req); const actor = getActor(req);
await repo.remove(flag.id); await repo.remove(flag.id, productId);
await recordAudit(productId, key, 'deleted', actor, flag, undefined); await recordAudit(productId, key, 'deleted', actor, flag, undefined);
broadcastFlagChange(productId, key, 'deleted'); broadcastFlagChange(productId, key, 'deleted');
@ -414,9 +414,20 @@ export async function flagRoutes(app: FastifyInstance) {
const disabled: string[] = []; const disabled: string[] = [];
for (const f of toDisable) { for (const f of toDisable) {
const before = { ...f }; const before = JSON.parse(JSON.stringify(f)) as FeatureFlagDoc;
await repo.update(f.id, { enabled: false, updatedBy: actor, version: f.version + 1 }); const afterDoc = await repo.update(f.id, productId, {
await recordAudit(productId, f.key, 'kill_switch', actor, before, { ...f, enabled: false }); enabled: false,
updatedBy: actor,
version: f.version + 1,
});
await recordAudit(
productId,
f.key,
'kill_switch',
actor,
before,
afterDoc ?? { ...f, enabled: false }
);
broadcastFlagChange(productId, f.key, 'kill_switch'); broadcastFlagChange(productId, f.key, 'kill_switch');
disabled.push(f.key); disabled.push(f.key);
} }
@ -479,7 +490,7 @@ export async function flagRoutes(app: FastifyInstance) {
if (!parsed.success) { if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
} }
const updated = await repo.updateSegment(segment.id, parsed.data); const updated = await repo.updateSegment(segment.id, productId, parsed.data);
if (!updated) throw new NotFoundError('Segment update failed'); if (!updated) throw new NotFoundError('Segment update failed');
return updated; return updated;
}); });
@ -489,7 +500,7 @@ export async function flagRoutes(app: FastifyInstance) {
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
const segment = await repo.getSegmentByKey(key, productId); const segment = await repo.getSegmentByKey(key, productId);
if (!segment) throw new NotFoundError('Segment not found'); if (!segment) throw new NotFoundError('Segment not found');
await repo.removeSegment(segment.id); await repo.removeSegment(segment.id, productId);
return { success: true }; return { success: true };
}); });