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