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');
}
// 10. Default (no userId, full rollout)
return variationResult(flag, flag.defaultVariation, 'default');
// 10. No userId with partial rollout — can't hash, return off
return offResult(flag, 'off');
}
/**

View File

@ -35,10 +35,11 @@ export async function create(doc: FeatureFlagDoc): Promise<FeatureFlagDoc> {
export async function update(
id: string,
productId: string,
updates: Partial<FeatureFlagDoc>
): Promise<FeatureFlagDoc | null> {
try {
return await flagCollection().update(id, id, {
return await flagCollection().update(id, productId, {
...updates,
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 {
await flagCollection().delete(id, id);
await flagCollection().delete(id, productId);
return true;
} catch {
return false;
@ -81,10 +82,11 @@ export async function createSegment(doc: SegmentDoc): Promise<SegmentDoc> {
export async function updateSegment(
id: string,
productId: string,
updates: Partial<SegmentDoc>
): Promise<SegmentDoc | null> {
try {
return await segmentCollection().update(id, id, {
return await segmentCollection().update(id, productId, {
...updates,
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 {
await segmentCollection().delete(id, id);
await segmentCollection().delete(id, productId);
return true;
} catch {
return false;

View File

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