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:
parent
ca6a4d41d8
commit
dd113b96c9
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user