118 lines
3.3 KiB
TypeScript
118 lines
3.3 KiB
TypeScript
import { z } from "zod";
|
|
|
|
const WINDOW_MILLISECONDS = 28 * 24 * 60 * 60 * 1000;
|
|
const nonEmptyString = z.string().trim().min(1);
|
|
const timestamp = nonEmptyString.refine(
|
|
(value) => Number.isFinite(Date.parse(value)),
|
|
"must be an RFC 3339 timestamp",
|
|
);
|
|
const sampleSchema = z
|
|
.object({
|
|
timestamp,
|
|
consent: z.boolean(),
|
|
releaseId: nonEmptyString,
|
|
routeId: nonEmptyString.regex(/^[A-Z][A-Z0-9_]*$/),
|
|
lcpMs: z.number().finite().nonnegative(),
|
|
cls: z.number().finite().nonnegative(),
|
|
inpMs: z.number().finite().nonnegative(),
|
|
})
|
|
.strict();
|
|
|
|
const fieldEvidenceInputSchema = z
|
|
.object({
|
|
schemaVersion: z.literal(1),
|
|
environment: z.literal("production"),
|
|
releaseId: nonEmptyString.refine(
|
|
(value) => value !== "local-release",
|
|
"must identify an immutable production release",
|
|
),
|
|
source: z
|
|
.object({
|
|
system: nonEmptyString,
|
|
exportId: nonEmptyString,
|
|
})
|
|
.strict(),
|
|
privacy: z
|
|
.object({
|
|
approved: z.literal(true),
|
|
approvalRef: nonEmptyString,
|
|
})
|
|
.strict(),
|
|
window: z
|
|
.object({
|
|
start: timestamp,
|
|
end: timestamp,
|
|
})
|
|
.strict(),
|
|
thresholdDecision: z
|
|
.object({
|
|
status: z.literal("approved"),
|
|
minimumEligibleSamples: z.number().int().positive(),
|
|
owner: nonEmptyString,
|
|
reviewedAt: timestamp,
|
|
evidenceRef: nonEmptyString,
|
|
})
|
|
.strict(),
|
|
samples: z.array(sampleSchema),
|
|
})
|
|
.strict()
|
|
.superRefine((input, context) => {
|
|
const start = Date.parse(input.window.start);
|
|
const end = Date.parse(input.window.end);
|
|
if (end - start !== WINDOW_MILLISECONDS) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
path: ["window"],
|
|
message: "must cover exactly 28 days",
|
|
});
|
|
}
|
|
});
|
|
|
|
export function validateFieldEvidenceInput(
|
|
input: unknown,
|
|
configuredMinimum: string | undefined,
|
|
now: Date = new Date(),
|
|
) {
|
|
const parsed = fieldEvidenceInputSchema.safeParse(input);
|
|
const failures = parsed.success
|
|
? []
|
|
: parsed.error.issues.map(
|
|
(issue) => `${issue.path.join(".") || "input"}: ${issue.message}`,
|
|
);
|
|
const minimumEligibleSamples = Number(configuredMinimum);
|
|
if (
|
|
configuredMinimum === undefined ||
|
|
!Number.isInteger(minimumEligibleSamples) ||
|
|
minimumEligibleSamples <= 0
|
|
) {
|
|
failures.push("MIN_ELIGIBLE_SAMPLES: must be a positive integer");
|
|
}
|
|
|
|
if (parsed.success) {
|
|
if (
|
|
parsed.data.thresholdDecision.minimumEligibleSamples !==
|
|
minimumEligibleSamples
|
|
) {
|
|
failures.push(
|
|
"MIN_ELIGIBLE_SAMPLES: does not match the approved threshold decision",
|
|
);
|
|
}
|
|
if (Date.parse(parsed.data.window.end) > now.getTime()) {
|
|
failures.push("window.end: must not be in the future");
|
|
}
|
|
if (Date.parse(parsed.data.thresholdDecision.reviewedAt) > now.getTime()) {
|
|
failures.push("thresholdDecision.reviewedAt: must not be in the future");
|
|
}
|
|
}
|
|
|
|
return Object.freeze({
|
|
data: parsed.success ? parsed.data : null,
|
|
failures: Object.freeze(failures),
|
|
minimumEligibleSamples:
|
|
Number.isInteger(minimumEligibleSamples) && minimumEligibleSamples > 0
|
|
? minimumEligibleSamples
|
|
: null,
|
|
passed: parsed.success && failures.length === 0,
|
|
});
|
|
}
|