feat(access-intelligence): expand type guard infrastructure with EnhancedGuard pattern and V2 model validators (#19621)

This commit is contained in:
Leslie Tilton 2026-03-18 09:27:05 -05:00 committed by GitHub
parent c73f1307a3
commit e4dd535e4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1252 additions and 36 deletions

View File

@ -0,0 +1,2 @@
// Abstractions
export * from "./abstractions/access-report-encryption.service";

View File

@ -1 +1,3 @@
export * from "./risk-insights-data-mappers";
export * from "./type-guards/basic-type-guards";
export * from "./type-guards/risk-insights-type-guards";

View File

@ -0,0 +1,199 @@
# Type Guards
**Purpose:** Reference guide for the type guard infrastructure used to validate decrypted Access Intelligence report data.
---
## Overview
All data arriving from decrypted blobs must pass through these guards before use.
Two categories of building blocks work together:
| File | What it contains |
| ------------------------------ | ------------------------------------------------------- |
| `basic-type-guards.ts` | Primitive guards and factory functions |
| `risk-insights-type-guards.ts` | Access Intelligence validators composed from primitives |
---
## Factory Functions
### `createValidator<T>(validators)`
Creates a type guard for a **fixed-schema plain object** from a map of per-field guards.
Use this for every object validator — never write manual object validators.
```typescript
// ✅ DO — compose via createValidator
const isMyModel = createValidator<MyModel>({
id: isBoundedString, // required — must be present and non-empty
label: isBoundedStringOrNull, // required — must be present, may be null
reportedAt: isDateStringOrUndefined, // optional — key may be absent from JSON
});
// ❌ DON'T — write manual field-by-field checks
function isMyModel(value: unknown): value is MyModel {
if (typeof value !== "object" || value === null) return false;
const obj = value as Record<string, unknown>;
return isBoundedString(obj["id"]) && ...;
}
```
**Built-in protections:**
- Rejects non-plain-objects (arrays, class instances, `null`)
- Blocks prototype pollution (`__proto__`, `constructor`, `prototype`)
- Absent keys pass `undefined` to the guard — required guards reject it naturally;
optional guards (e.g. `isBoundedStringOrUndefined`) accept it
#### `.explain()` — Diagnostic Mode
Every validator returned by `createValidator` exposes an `.explain(value)` method.
It returns `string[]` — empty if validation passes, otherwise one message per failing field.
Use `.explain()` only in error messages and logging. Never use it in hot data paths —
it iterates all fields even after the boolean guard has already returned `false`.
```typescript
const errors = isMyModel.explain(suspectValue);
if (errors.length > 0) {
throw new Error(`Validation failed:\n ${errors.join("\n ")}`);
}
```
**Error message format:**
| Situation | Example message |
| ---------------------------- | ---------------------------------------------------------------------- |
| Not a plain object | `"expected plain object, got Array(3)"` |
| Class instance or null-proto | `"rejected: non-plain object (class instance or Object.create(null))"` |
| Prototype pollution attempt | `"rejected: prototype pollution attempt via key \"__proto__\""` |
| Field fails its guard | `"field 'email': guard isBoundedString failed — got null"` |
| Required field absent | `"field 'id': guard isBoundedString failed — got undefined"` |
**Security note:** Messages describe types only (`"null"`, `"string"`, `"Array(3)"`) —
never actual values. Safe for production logs (no PII or vault data exposed).
**Constraint:** `.explain()` reports top-level field failures only. It does not recurse into
why a nested guard (e.g. `isMemberDetailsArray`) failed element-by-element.
---
### `createBoundedArrayGuard<T>(isType)`
Creates a type guard for an array of `T`, bounded to `BOUNDED_ARRAY_MAX_LENGTH` (50 000 items).
```typescript
const isMyModelArray = createBoundedArrayGuard(isMyModel);
```
---
## Primitive Guards
Primitives are `(value: unknown) => value is T` functions that test a single value.
They are the building blocks passed to `createValidator`.
### Choosing the Right Guard for a Field
| TypeScript field type | Guard to use |
| ------------------------------------------ | ---------------------------- |
| `string` (required) | `isBoundedString` |
| `string \| null` | `isBoundedStringOrNull` |
| `string \| undefined` (optional field) | `isBoundedStringOrUndefined` |
| `number` (count, non-negative) | `isBoundedPositiveNumber` |
| `boolean` | `isBoolean` |
| `Record<string, boolean>` | `isBooleanRecord` |
| `Date` | `isDate` |
| `Date \| null` | `isDateOrNull` |
| `string` (ISO date, required) | `isDateString` |
| `string \| null` (ISO date, nullable) | `isDateStringOrNull` |
| `string \| undefined` (ISO date, optional) | `isDateStringOrUndefined` |
> ⚠️ **Optional vs. nullable:** Use `*OrUndefined` guards for fields that may be absent from
> JSON-parsed objects (`JSON.stringify` drops `undefined` values, so the key won't exist after
> `JSON.parse`). Use `*OrNull` guards for fields that are always present but may be `null`.
---
## Adding a New Validator
### Step 1 — Do you need a new primitive?
If the field type is not covered by the table above, add it to `basic-type-guards.ts`.
Keep each primitive focused on one type.
```typescript
// basic-type-guards.ts
export function isMyNewType(value: unknown): value is MyNewType {
// single-type check only
}
```
### Step 2 — Compose the object validator
```typescript
// risk-insights-type-guards.ts
const isMyModel = createValidator<MyModel>({
id: isBoundedString,
count: isBoundedPositiveNumber,
reportedAt: isDateStringOrUndefined,
});
const isMyModelArray = createBoundedArrayGuard(isMyModel);
```
### Step 3 — Add a validate function for blob entry points
Validate functions are called at the decryption boundary and throw on invalid data.
```typescript
export function validateMyModelArray(data: unknown): MyModel[] {
if (!Array.isArray(data)) {
throw new Error("Invalid data: expected array, received non-array");
}
const invalidItems = data
.map((item, index) => ({ item, index }))
.filter(({ item }) => !isMyModel(item));
if (invalidItems.length > 0) {
const elementMessages = invalidItems.map(({ item, index }) => {
const fieldErrors = isMyModel.explain(item).join("; ");
return ` element[${index}]: ${fieldErrors}`;
});
throw new Error(
`Invalid data: ${invalidItems.length} invalid element(s)\n` + elementMessages.join("\n"),
);
}
if (!isMyModelArray(data)) {
throw new Error("Invalid data");
}
return data;
}
```
---
## When Manual Validation Is Acceptable
`createValidator` handles fixed-schema objects. Two cases require hand-written logic:
1. **Dynamic-key dictionaries** (e.g. `Record<string, SomeType>`) — use a manual loop:
```typescript
// validateAccessReportPayload does this for memberRegistry
for (const [key, entry] of Object.entries(registry)) {
if (!isBoundedString(key) || !isMemberRegistryEntryData(entry)) { ... }
}
```
2. **Custom primitive guards** for field types not in `basic-type-guards.ts` (e.g. `isCipherId`,
`isValidDateOrNull`) — these are single-value primitives, not object validators.
> **Rule:** Never write a manual multi-field object validator. If `createValidator` can't
> express the validation, add the missing primitive to `basic-type-guards.ts` and compose.
> If the shape is truly dynamic, use a manual loop with existing primitives.
---
**Document Version:** 1.0
**Last Updated:** 2026-03-06
**Maintainer:** DIRT Team

View File

@ -0,0 +1,337 @@
import {
createBoundedArrayGuard,
createValidator,
isBoolean,
isBooleanRecord,
isBoundedArray,
isBoundedPositiveNumber,
isBoundedString,
isBoundedStringOrNull,
isBoundedStringOrUndefined,
isDate,
isDateOrNull,
isDateString,
isDateStringOrNull,
isDateStringOrUndefined,
BOUNDED_ARRAY_MAX_LENGTH,
BOUNDED_STRING_MAX_LENGTH,
} from "./basic-type-guards";
describe("basic-type-guards", () => {
describe("isBoolean", () => {
it("should return true for true", () => expect(isBoolean(true)).toBe(true));
it("should return true for false", () => expect(isBoolean(false)).toBe(true));
it("should return false for 1", () => expect(isBoolean(1)).toBe(false));
it("should return false for string", () => expect(isBoolean("true")).toBe(false));
it("should return false for null", () => expect(isBoolean(null)).toBe(false));
});
describe("isBoundedPositiveNumber", () => {
it("should return true for 0", () => expect(isBoundedPositiveNumber(0)).toBe(true));
it("should return true for positive integer", () =>
expect(isBoundedPositiveNumber(42)).toBe(true));
it("should return false for negative number", () =>
expect(isBoundedPositiveNumber(-1)).toBe(false));
it("should return false for NaN", () => expect(isBoundedPositiveNumber(NaN)).toBe(false));
it("should return false for Infinity", () =>
expect(isBoundedPositiveNumber(Infinity)).toBe(false));
it("should return false for float", () => expect(isBoundedPositiveNumber(1.5)).toBe(false));
it("should return false for string", () => expect(isBoundedPositiveNumber("5")).toBe(false));
it("should return false for number exceeding max", () => {
expect(isBoundedPositiveNumber(10_000_001)).toBe(false);
});
});
describe("isBoundedString", () => {
it("should return true for a normal string", () => expect(isBoundedString("hello")).toBe(true));
it("should return false for empty string", () => expect(isBoundedString("")).toBe(false));
it("should return false for null", () => expect(isBoundedString(null)).toBe(false));
it("should return false for undefined", () => expect(isBoundedString(undefined)).toBe(false));
it("should return false for number", () => expect(isBoundedString(42)).toBe(false));
it("should return false for string exceeding max length", () => {
expect(isBoundedString("a".repeat(BOUNDED_STRING_MAX_LENGTH + 1))).toBe(false);
});
it("should return true for string at max length", () => {
expect(isBoundedString("a".repeat(BOUNDED_STRING_MAX_LENGTH))).toBe(true);
});
});
describe("isBoundedStringOrNull", () => {
it("should return true for valid string", () =>
expect(isBoundedStringOrNull("hello")).toBe(true));
it("should return true for null", () => expect(isBoundedStringOrNull(null)).toBe(true));
it("should return true for undefined (null-ish)", () =>
expect(isBoundedStringOrNull(undefined)).toBe(true));
it("should return false for empty string", () => expect(isBoundedStringOrNull("")).toBe(false));
it("should return false for number", () => expect(isBoundedStringOrNull(42)).toBe(false));
});
describe("isBoundedStringOrUndefined", () => {
it("should return true for valid string", () =>
expect(isBoundedStringOrUndefined("hello")).toBe(true));
it("should return true for undefined", () =>
expect(isBoundedStringOrUndefined(undefined)).toBe(true));
it("should return false for null", () => expect(isBoundedStringOrUndefined(null)).toBe(false));
it("should return false for empty string", () =>
expect(isBoundedStringOrUndefined("")).toBe(false));
it("should return false for number", () => expect(isBoundedStringOrUndefined(42)).toBe(false));
});
describe("isBooleanRecord", () => {
it("should return true for valid Record<string, boolean>", () => {
expect(isBooleanRecord({ a: true, b: false })).toBe(true);
});
it("should return true for empty object", () => expect(isBooleanRecord({})).toBe(true));
it("should return false for object with non-boolean values", () => {
expect(isBooleanRecord({ a: "true" })).toBe(false);
});
it("should return false for array", () => expect(isBooleanRecord([true])).toBe(false));
it("should return false for null", () => expect(isBooleanRecord(null)).toBe(false));
});
describe("isDate", () => {
it("should return true for a valid Date", () => expect(isDate(new Date())).toBe(true));
it("should return false for an invalid Date", () =>
expect(isDate(new Date("invalid"))).toBe(false));
it("should return false for a date string", () => expect(isDate("2024-01-01")).toBe(false));
it("should return false for null", () => expect(isDate(null)).toBe(false));
});
describe("isDateOrNull", () => {
it("should return true for a valid Date", () => expect(isDateOrNull(new Date())).toBe(true));
it("should return true for null", () => expect(isDateOrNull(null)).toBe(true));
it("should return false for undefined", () => expect(isDateOrNull(undefined)).toBe(false));
it("should return false for a date string", () =>
expect(isDateOrNull("2024-01-01")).toBe(false));
});
describe("isDateString", () => {
it("should return true for ISO date string", () => {
expect(isDateString("2024-01-15T10:30:00.000Z")).toBe(true);
});
it("should return true for simple date string", () => {
expect(isDateString("2024-01-01")).toBe(true);
});
it("should return false for invalid date string", () => {
expect(isDateString("not-a-date")).toBe(false);
});
it("should return false for null", () => expect(isDateString(null)).toBe(false));
it("should return false for Date object", () => expect(isDateString(new Date())).toBe(false));
});
describe("isDateStringOrNull", () => {
it("should return true for valid date string", () => {
expect(isDateStringOrNull("2024-01-01")).toBe(true);
});
it("should return true for null", () => expect(isDateStringOrNull(null)).toBe(true));
it("should return false for undefined", () =>
expect(isDateStringOrNull(undefined)).toBe(false));
it("should return false for invalid string", () => {
expect(isDateStringOrNull("not-a-date")).toBe(false);
});
});
describe("isDateStringOrUndefined", () => {
it("should return true for valid date string", () => {
expect(isDateStringOrUndefined("2024-01-15T10:30:00.000Z")).toBe(true);
});
it("should return true for undefined", () => {
expect(isDateStringOrUndefined(undefined)).toBe(true);
});
it("should return false for null", () => expect(isDateStringOrUndefined(null)).toBe(false));
it("should return false for invalid string", () => {
expect(isDateStringOrUndefined("not-a-date")).toBe(false);
});
it("should return false for Date object", () => {
expect(isDateStringOrUndefined(new Date())).toBe(false);
});
});
describe("isBoundedArray", () => {
it("should return true for an empty array", () => expect(isBoundedArray([])).toBe(true));
it("should return true for a small array", () => expect(isBoundedArray([1, 2, 3])).toBe(true));
it("should return false for a non-array", () => expect(isBoundedArray("array")).toBe(false));
it("should return false for array exceeding max length", () => {
const oversized = new Array(BOUNDED_ARRAY_MAX_LENGTH + 1);
expect(isBoundedArray(oversized)).toBe(false);
});
});
describe("createBoundedArrayGuard", () => {
const isNumberArray = createBoundedArrayGuard(
(v: unknown): v is number => typeof v === "number",
);
it("should return true for an array of valid items", () => {
expect(isNumberArray([1, 2, 3])).toBe(true);
});
it("should return true for an empty array", () => expect(isNumberArray([])).toBe(true));
it("should return false if any item fails the guard", () => {
expect(isNumberArray([1, "two", 3])).toBe(false);
});
it("should return false for a non-array", () => expect(isNumberArray("not-array")).toBe(false));
});
describe("createValidator", () => {
interface TestModel {
id: string;
label: string | null;
tag?: string;
}
const isTestModel = createValidator<TestModel>({
id: isBoundedString,
label: isBoundedStringOrNull,
tag: isBoundedStringOrUndefined,
});
it("should return true for a valid object with all fields present", () => {
expect(isTestModel({ id: "abc", label: "name", tag: "optional" })).toBe(true);
});
it("should return true when optional field key is absent from object", () => {
expect(isTestModel({ id: "abc", label: null })).toBe(true);
});
it("should return false when required field is missing", () => {
expect(isTestModel({ label: "name" })).toBe(false);
});
it("should return false when required field fails its guard", () => {
expect(isTestModel({ id: "", label: "name" })).toBe(false);
});
it("should return false for null", () => expect(isTestModel(null)).toBe(false));
it("should return false for array", () => expect(isTestModel([])).toBe(false));
it("should return false for string", () => expect(isTestModel("string")).toBe(false));
it("should return false for class instances (non-plain-object)", () => {
class Foo {
id = "abc";
label = "name";
}
expect(isTestModel(new Foo())).toBe(false);
});
it("should return false for prototype pollution via __proto__", () => {
const obj = { id: "abc", label: "name", __proto__: { polluted: true } };
expect(isTestModel(obj)).toBe(false);
});
it("should return false for prototype pollution via constructor property", () => {
const obj = Object.create(null) as Record<string, unknown>;
obj["id"] = "abc";
obj["label"] = "name";
// Object.create(null) has no prototype — getPrototypeOf returns null ≠ Object.prototype
expect(isTestModel(obj)).toBe(false);
});
});
describe("createValidator — EnhancedGuard (.explain())", () => {
interface TestModel {
id: string;
label: string | null;
tag?: string;
}
const isTestModel = createValidator<TestModel>({
id: isBoundedString,
label: isBoundedStringOrNull,
tag: isBoundedStringOrUndefined,
});
describe("structural failures", () => {
it("should return a structural error for null", () => {
const result = isTestModel.explain(null);
expect(result).toHaveLength(1);
expect(result[0]).toContain("expected plain object");
expect(result[0]).toContain("null");
});
it("should return a structural error for an array", () => {
const result = isTestModel.explain([]);
expect(result).toHaveLength(1);
expect(result[0]).toContain("expected plain object");
expect(result[0]).toContain("Array(0)");
});
it("should return a structural error for a string", () => {
const result = isTestModel.explain("hello");
expect(result).toHaveLength(1);
expect(result[0]).toContain("expected plain object");
expect(result[0]).toContain("string");
});
it("should return a prototype pollution error for a class instance", () => {
class Foo {
id = "abc";
label = "name";
}
const result = isTestModel.explain(new Foo());
expect(result).toHaveLength(1);
expect(result[0]).toContain("non-plain object");
});
it("should return a prototype pollution error for Object.create(null)", () => {
const obj = Object.create(null) as Record<string, unknown>;
obj["id"] = "abc";
obj["label"] = "name";
const result = isTestModel.explain(obj);
expect(result).toHaveLength(1);
expect(result[0]).toContain("non-plain object");
});
it("should return a prototype pollution error for __proto__ key", () => {
const obj = JSON.parse(
'{"id":"abc","label":"name","__proto__":{"polluted":true}}',
) as unknown;
const result = isTestModel.explain(obj);
expect(result).toHaveLength(1);
expect(result[0]).toContain("prototype pollution");
});
});
describe("field-level failures", () => {
it("should return empty array for a valid object", () => {
expect(isTestModel.explain({ id: "abc", label: "name", tag: "opt" })).toHaveLength(0);
});
it("should return empty array when optional field is absent", () => {
expect(isTestModel.explain({ id: "abc", label: null })).toHaveLength(0);
});
it("should identify a single failing required field", () => {
const result = isTestModel.explain({ id: "", label: "name" });
expect(result).toHaveLength(1);
expect(result[0]).toContain("field 'id'");
expect(result[0]).toContain("isBoundedString");
});
it("should identify multiple failing fields", () => {
const result = isTestModel.explain({ id: "", label: 42 });
expect(result).toHaveLength(2);
expect(result.some((m) => m.includes("field 'id'"))).toBe(true);
expect(result.some((m) => m.includes("field 'label'"))).toBe(true);
});
it("should report undefined for a missing required field", () => {
const result = isTestModel.explain({ label: "name" });
expect(result).toHaveLength(1);
expect(result[0]).toContain("field 'id'");
expect(result[0]).toContain("undefined");
});
it("should include the guard function name in the message", () => {
const result = isTestModel.explain({ id: null, label: "name" });
expect(result[0]).toContain("isBoundedString");
});
it("should describe the received type, not the actual value", () => {
const result = isTestModel.explain({ id: 42, label: "name" });
expect(result[0]).toContain("number");
// Should NOT include the actual numeric value as a data point
expect(result[0]).not.toContain("42");
});
});
});
});

View File

@ -7,11 +7,13 @@
// Basic types
export type BoundedString = string;
export type BoundedStringOrNull = BoundedString | null;
export type BoundedStringOrUndefined = BoundedString | undefined;
export type PositiveSafeNumber = number;
export type BoundedArray<T> = T[];
export type DateOrNull = Date | null;
export type DateString = string;
export type DateStringOrNull = DateString | null;
export type DateStringOrUndefined = DateString | undefined;
// Constants
/**
@ -44,12 +46,32 @@ export function isBoundedStringOrNull(value: unknown): value is BoundedStringOrN
return value == null || isBoundedString(value);
}
export function isBoundedStringOrUndefined(value: unknown): value is BoundedStringOrUndefined {
return value === undefined || isBoundedString(value);
}
export const isBoundedStringArray = createBoundedArrayGuard(isBoundedString);
export function isBoundedArray<T>(arr: unknown): arr is BoundedArray<T> {
return Array.isArray(arr) && arr.length < BOUNDED_ARRAY_MAX_LENGTH;
}
/**
* A type guard to check if a value is a plain object with string keys and boolean values
* @param value The value to check
* @returns True if the value is a Record<string, boolean>, false otherwise
*/
export function isBooleanRecord(value: unknown): value is Record<string, boolean> {
if (value == null || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const entries = Object.entries(value as object);
if (entries.length > BOUNDED_ARRAY_MAX_LENGTH) {
return false;
}
return entries.every(([k, v]) => isBoundedString(k) && isBoolean(v));
}
/**
* A type guard to check if a value is a valid Date object
* @param value The value to check
@ -97,6 +119,16 @@ export function isDateStringOrNull(value: unknown): value is DateStringOrNull {
return value === null || isDateString(value);
}
/**
* A type guard to check if a value is a valid date string or undefined (absent field).
* Use this for optional date fields where the key may be absent from JSON-parsed objects.
* @param value The value to check
* @returns True if the value is undefined or a valid date string, false otherwise
*/
export function isDateStringOrUndefined(value: unknown): value is DateStringOrUndefined {
return value === undefined || isDateString(value);
}
/**
* A higher-order function that takes a type guard for T and returns a
* new type guard for an array of T.
@ -110,33 +142,102 @@ export function createBoundedArrayGuard<T>(isType: (item: unknown) => item is T)
type TempObject = Record<PropertyKey, unknown>;
/**
* Describes the type/shape of a value without exposing actual content.
* Safe to include in logs never reveals PII or vault data.
*/
function describeType(value: unknown): string {
if (value === null) {
return "null";
}
if (value === undefined) {
return "undefined";
}
if (Array.isArray(value)) {
return `Array(${value.length})`;
}
if (typeof value === "object") {
return "object";
}
return typeof value; // "string", "number", "boolean"
}
/**
* Checks that obj is a plain JSON-parsed object (not null, not a class instance,
* not a prototype-polluted object).
* Returns null if the structure is valid, or a diagnostic string if not.
*/
function checkStructure(obj: unknown): string | null {
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
return `expected plain object, got ${describeType(obj)}`;
}
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return "rejected: non-plain object (class instance or Object.create(null))";
}
// Prevent dangerous properties that could be used for prototype pollution
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return `rejected: prototype pollution attempt via key "${key}"`;
}
}
return null;
}
/**
* A type guard function with an attached `.explain()` method.
* The guard itself behaves identically to a standard type predicate.
* `.explain()` returns an array of diagnostic messages describing which fields
* failed validation safe to include in logs (never exposes actual values, only types).
*/
export type EnhancedGuard<T> = ((obj: unknown) => obj is T) & {
explain: (obj: unknown) => string[];
};
/**
* Factory that creates a type-safe object validator from a map of per-field guards.
*
* @param validators
* @returns
* Pass a `{ fieldName: typeGuardFn }` map and receive a single function that validates
* a plain JSON-parsed object against all listed fields. The returned validator:
* - Rejects non-plain-objects (arrays, class instances, null)
* - Blocks prototype pollution via `__proto__` / `constructor` / `prototype`
* - For each field in the map: if the field is **absent** from the object, passes
* `undefined` to the guard required fields fail naturally (e.g. `isBoundedString(undefined)
* false`), optional fields pass if their guard accepts `undefined`
* (e.g. `isDateStringOrUndefined(undefined) → true`)
*
* The returned `EnhancedGuard<T>` also exposes an `.explain(value)` method that returns
* field-level diagnostic messages (which fields failed and what type was received).
*
* @example
* ```typescript
* // All required fields — key must be present and pass its guard
* const isMemberEntry = createValidator<MemberRegistryEntryData>({
* id: isBoundedString,
* userName: isBoundedStringOrNull, // present, but may be null
* email: isBoundedString,
* });
*
* // Mixed required and optional fields — optional fields use an *OrUndefined guard
* const isReportEntry = createValidator<ApplicationHealthData>({
* applicationName: isBoundedString, // required
* memberRefs: isBooleanRecord, // required
* iconUri: isBoundedStringOrUndefined, // optional — key may be absent
* });
* ```
*/
export function createValidator<T>(validators: {
[K in keyof T]: (value: unknown) => value is T[K];
}): (obj: unknown) => obj is T {
}): EnhancedGuard<T> {
const keys = Object.keys(validators) as (keyof T)[];
return function (obj: unknown): obj is T {
if (typeof obj !== "object" || obj === null) {
const guard = function (obj: unknown): obj is T {
if (checkStructure(obj) !== null) {
return false;
}
if (Object.getPrototypeOf(obj) !== Object.prototype) {
return false;
}
// Prevent dangerous properties that could be used for prototype pollution
// Check for __proto__, constructor, and prototype as own properties
const dangerousKeys = ["__proto__", "constructor", "prototype"];
for (const key of dangerousKeys) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
// Type cast to TempObject for key checks
const tempObj = obj as TempObject;
@ -149,14 +250,109 @@ export function createValidator<T>(validators: {
// return false;
// }
// Check for each property's existence and type
// For each field in the validator map, pass the value (or `undefined` if absent) to
// its guard. Required guards (e.g. isBoundedString) reject undefined naturally;
// optional guards (e.g. isBoundedStringOrUndefined) accept it.
return keys.every((key) => {
// Use 'in' to check for property existence before accessing it
if (!(key in tempObj)) {
return false;
}
// Pass the value to its specific validator
return validators[key](tempObj[key]);
const value = key in tempObj ? tempObj[key] : undefined;
return validators[key](value);
});
};
guard.explain = function (obj: unknown): string[] {
const structuralError = checkStructure(obj);
if (structuralError !== null) {
return [structuralError];
}
const tempObj = obj as TempObject;
const failures: string[] = [];
for (const key of keys) {
const value = key in tempObj ? tempObj[key] : undefined;
if (!validators[key](value)) {
const nestedExplain = (validators[key] as EnhancedGuard<unknown>).explain;
if (typeof nestedExplain === "function") {
const nested = nestedExplain(value);
failures.push(...nested.map((e) => `field '${String(key)}': ${e}`));
} else {
failures.push(
`field '${String(key)}': guard ${validators[key].name || "(anonymous)"} failed — got ${describeType(value)}`,
);
}
}
}
return failures;
};
return guard;
}
/**
* A higher-order function that takes an EnhancedGuard for T and returns an
* EnhancedGuard for T[], with element-level diagnostics in `.explain()`.
* Use this instead of createBoundedArrayGuard when you need recursive diagnostics.
*/
export function createEnhancedBoundedArrayGuard<T>(isType: EnhancedGuard<T>): EnhancedGuard<T[]> {
const guard = function (value: unknown): value is T[] {
return Array.isArray(value) && value.length <= BOUNDED_ARRAY_MAX_LENGTH && value.every(isType);
};
guard.explain = function (value: unknown): string[] {
if (!Array.isArray(value)) {
return [`expected array, got ${describeType(value)}`];
}
if (value.length > BOUNDED_ARRAY_MAX_LENGTH) {
return [`array length ${value.length} exceeds max ${BOUNDED_ARRAY_MAX_LENGTH}`];
}
const errors: string[] = [];
value.forEach((item, index) => {
if (!isType(item)) {
const fieldErrors = isType.explain(item);
errors.push(...fieldErrors.map((e) => `[${index}]: ${e}`));
}
});
return errors;
};
return guard;
}
/**
* A higher-order function that takes an EnhancedGuard for T and returns an
* EnhancedGuard for Record<string, T>, with entry-level diagnostics in `.explain()`.
* Use this for dynamic-key dictionaries (e.g. member registries keyed by user GUID).
*/
export function createBoundedRecordGuard<T>(
isValue: EnhancedGuard<T>,
): EnhancedGuard<Record<string, T>> {
const guard = function (value: unknown): value is Record<string, T> {
if (value == null || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const entries = Object.entries(value as object);
if (entries.length > BOUNDED_ARRAY_MAX_LENGTH) {
return false;
}
return entries.every(([k, v]) => isBoundedString(k) && isValue(v));
};
guard.explain = function (value: unknown): string[] {
if (value == null || typeof value !== "object" || Array.isArray(value)) {
return [`expected plain object, got ${describeType(value)}`];
}
const entries = Object.entries(value as object);
if (entries.length > BOUNDED_ARRAY_MAX_LENGTH) {
return [`record length ${entries.length} exceeds max ${BOUNDED_ARRAY_MAX_LENGTH}`];
}
const errors: string[] = [];
for (const [k, v] of entries) {
if (!isBoundedString(k)) {
errors.push(`key: invalid — got ${describeType(k)}`);
} else if (!isValue(v)) {
const fieldErrors = isValue.explain(v);
errors.push(...fieldErrors.map((e) => `["${k}"]: ${e}`));
}
}
return errors;
};
return guard;
}

View File

@ -3,11 +3,15 @@ import { MemberDetails } from "../../models";
import {
isApplicationHealthReportDetail,
isMemberDetails,
isMemberRegistryEntryData,
isOrganizationReportApplication,
isOrganizationReportSummary,
validateApplicationHealthReportDetailArray,
validateOrganizationReportApplicationArray,
validateOrganizationReportSummary,
validateAccessReportSettingsDataArray,
validateAccessReportSummaryData,
validateAccessReportPayload,
} from "./risk-insights-type-guards";
describe("Risk Insights Type Guards", () => {
@ -60,7 +64,7 @@ describe("Risk Insights Type Guards", () => {
];
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
/Invalid report data: array contains 1 invalid ApplicationHealthReportDetail element\(s\) at indices: 0/,
/Invalid report data: array contains 1 invalid ApplicationHealthReportDetail element\(s\)/,
);
});
@ -82,7 +86,7 @@ describe("Risk Insights Type Guards", () => {
];
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
/Invalid report data: array contains 2 invalid ApplicationHealthReportDetail element\(s\) at indices: 0, 2/,
/Invalid report data: array contains 2 invalid ApplicationHealthReportDetail element\(s\)/,
);
});
@ -227,7 +231,7 @@ describe("Risk Insights Type Guards", () => {
];
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
"Invalid application data: array contains 1 invalid OrganizationReportApplication element(s) at indices: 0",
/Invalid application data: array contains 1 invalid OrganizationReportApplication element\(s\)/,
);
});
@ -247,7 +251,7 @@ describe("Risk Insights Type Guards", () => {
];
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
/Invalid application data: array contains 1 invalid OrganizationReportApplication element\(s\) at indices: 0/,
/Invalid application data: array contains 1 invalid OrganizationReportApplication element\(s\)/,
);
});
@ -551,4 +555,324 @@ describe("Risk Insights Type Guards", () => {
expect(isOrganizationReportApplication(invalidData)).toBe(false);
});
});
describe("isMemberRegistryEntryData", () => {
it("should return true for valid MemberRegistryEntryData", () => {
const validData = { id: "u1", userName: "Alice", email: "alice@example.com" };
expect(isMemberRegistryEntryData(validData)).toBe(true);
});
it("should return false for empty id", () => {
const invalidData = { id: "", userName: "Alice", email: "alice@example.com" };
expect(isMemberRegistryEntryData(invalidData)).toBe(false);
});
it("should return false for empty userName (empty string is not a valid non-empty string)", () => {
const invalidData = { id: "u1", userName: "", email: "alice@example.com" };
expect(isMemberRegistryEntryData(invalidData)).toBe(false);
});
it("should return true when userName is absent (userName is optional)", () => {
const dataWithoutUserName = { id: "u1", email: "alice@example.com" };
expect(isMemberRegistryEntryData(dataWithoutUserName)).toBe(true);
});
it("should return true when userName is undefined (userName is optional)", () => {
const dataWithUndefinedUserName: unknown = {
id: "u1",
userName: undefined,
email: "alice@example.com",
};
expect(isMemberRegistryEntryData(dataWithUndefinedUserName)).toBe(true);
});
it("should return false for empty email", () => {
const invalidData = { id: "u1", userName: "Alice", email: "" };
expect(isMemberRegistryEntryData(invalidData)).toBe(false);
});
it("should return false for missing field", () => {
const invalidData = { id: "u1", userName: "Alice" };
expect(isMemberRegistryEntryData(invalidData)).toBe(false);
});
it("should return false for non-object", () => {
expect(isMemberRegistryEntryData("not an object")).toBe(false);
expect(isMemberRegistryEntryData(null)).toBe(false);
});
it("should return false for prototype pollution attempts", () => {
const invalidData = {
id: "u1",
userName: "Alice",
email: "alice@example.com",
__proto__: { malicious: true },
};
expect(isMemberRegistryEntryData(invalidData)).toBe(false);
});
});
describe("validateAccessReportPayload", () => {
const validV2Data = {
version: 2,
reports: [
{
applicationName: "github.com",
passwordCount: 2,
atRiskPasswordCount: 1,
memberRefs: { u1: true, u2: false },
cipherRefs: { c1: true, c2: false },
memberCount: 2,
atRiskMemberCount: 1,
},
],
memberRegistry: {
u1: { id: "u1", userName: "Alice", email: "alice@example.com" },
u2: { id: "u2", userName: "Bob", email: "bob@example.com" },
},
};
it("should validate valid V2 report data", () => {
expect(() => validateAccessReportPayload(validV2Data)).not.toThrow();
const result = validateAccessReportPayload(validV2Data);
expect(result.reports).toHaveLength(1);
expect(Object.keys(result.memberRegistry)).toHaveLength(2);
});
it("should validate V2 data with empty reports and registry", () => {
const emptyV2Data: unknown = { version: 2, reports: [], memberRegistry: {} };
expect(() => validateAccessReportPayload(emptyV2Data)).not.toThrow();
});
it("should throw for non-object input", () => {
expect(() => validateAccessReportPayload("not an object")).toThrow(
/expected object, received non-object/,
);
expect(() => validateAccessReportPayload(null)).toThrow(
/expected object, received non-object/,
);
expect(() => validateAccessReportPayload([1, 2, 3])).toThrow(
/expected object, received non-object/,
);
});
it("should throw for invalid reports array", () => {
const invalidReports = { ...validV2Data, reports: "not an array" };
expect(() => validateAccessReportPayload(invalidReports)).toThrow(
/reports array failed validation/,
);
});
it("should throw for missing memberRegistry", () => {
const noRegistry: unknown = { version: 2, reports: [] };
expect(() => validateAccessReportPayload(noRegistry)).toThrow(
/memberRegistry failed validation/,
);
});
it("should throw for invalid memberRegistry entry", () => {
const invalidEntry = {
...validV2Data,
memberRegistry: {
u1: { id: "u1", userName: "Alice" }, // missing email
},
};
expect(() => validateAccessReportPayload(invalidEntry)).toThrow(
/memberRegistry failed validation/,
);
});
it("should normalize empty userName to undefined for backwards compatibility", () => {
const dataWithEmptyUserName: unknown = {
version: 2,
reports: [],
memberRegistry: {
u1: { id: "u1", userName: "", email: "alice@example.com" },
},
};
const result = validateAccessReportPayload(dataWithEmptyUserName);
expect(result.memberRegistry["u1"].userName).toBeUndefined();
});
});
describe("validateAccessReportSummaryData", () => {
const validSummary = {
totalMemberCount: 10,
totalApplicationCount: 5,
totalAtRiskMemberCount: 2,
totalAtRiskApplicationCount: 1,
totalCriticalApplicationCount: 3,
totalCriticalMemberCount: 4,
totalCriticalAtRiskMemberCount: 1,
totalCriticalAtRiskApplicationCount: 1,
};
it("should validate valid summary data", () => {
expect(() => validateAccessReportSummaryData(validSummary)).not.toThrow();
const result = validateAccessReportSummaryData(validSummary);
expect(result.totalMemberCount).toBe(10);
expect(result.totalApplicationCount).toBe(5);
});
it("should throw for invalid field types", () => {
const invalid = { ...validSummary, totalMemberCount: "10" };
expect(() => validateAccessReportSummaryData(invalid)).toThrow(/Invalid report summary/);
});
it("should throw for non-object input", () => {
expect(() => validateAccessReportSummaryData(null)).toThrow(/Invalid report summary/);
expect(() => validateAccessReportSummaryData("string")).toThrow(/Invalid report summary/);
});
});
describe("validateAccessReportSettingsDataArray", () => {
it("should validate valid V2 application data array", () => {
const validData = [
{ applicationName: "app.com", isCritical: true, reviewedDate: "2024-01-15T10:30:00.000Z" },
{ applicationName: "other.com", isCritical: false },
];
expect(() => validateAccessReportSettingsDataArray(validData)).not.toThrow();
const result = validateAccessReportSettingsDataArray(validData);
expect(result).toHaveLength(2);
expect(result[0].reviewedDate).toBe("2024-01-15T10:30:00.000Z");
expect(result[1].reviewedDate).toBeUndefined();
});
it("should accept missing reviewedDate (undefined)", () => {
const validData = [{ applicationName: "app.com", isCritical: false }];
const result = validateAccessReportSettingsDataArray(validData);
expect(result[0].reviewedDate).toBeUndefined();
});
it("should throw for non-array input", () => {
expect(() => validateAccessReportSettingsDataArray("not an array")).toThrow(
"Invalid application data: expected array of AccessReportSettingsData, received non-array",
);
});
it("should throw for array with invalid elements", () => {
const invalidData = [{ applicationName: "app.com" }]; // missing isCritical
expect(() => validateAccessReportSettingsDataArray(invalidData)).toThrow(
/Invalid application data: array contains 1 invalid AccessReportSettingsData element\(s\)/,
);
});
it("should throw for null reviewedDate", () => {
const invalidData: unknown = [
{ applicationName: "app.com", isCritical: true, reviewedDate: null },
];
expect(() => validateAccessReportSettingsDataArray(invalidData)).toThrow(
/Invalid application data/,
);
});
it("should throw for invalid date string in reviewedDate", () => {
const invalidData = [
{ applicationName: "app.com", isCritical: true, reviewedDate: "not-a-date" },
];
expect(() => validateAccessReportSettingsDataArray(invalidData)).toThrow(
/Invalid application data/,
);
});
});
describe("field-level diagnostics", () => {
describe("validateApplicationHealthReportDetailArray", () => {
it("should include element index and field name in error message", () => {
const invalidData = [{ applicationName: "Test App" }]; // missing many required fields
let caught: Error | null = null;
try {
validateApplicationHealthReportDetailArray(invalidData);
} catch (e) {
caught = e as Error;
}
expect(caught).not.toBeNull();
expect(caught!.message).toMatch(/element\[0\]/);
expect(caught!.message).toMatch(/field '/);
});
it("should identify memberDetails field failure when memberDetails has wrong shape", () => {
const invalidData = [
{
applicationName: "Test App",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [{ userGuid: "user-1" }] as any, // missing required fields
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
},
];
let caught: Error | null = null;
try {
validateApplicationHealthReportDetailArray(invalidData);
} catch (e) {
caught = e as Error;
}
expect(caught).not.toBeNull();
expect(caught!.message).toContain("element[0]");
expect(caught!.message).toContain("memberDetails");
});
it("should list each failing element by index, skipping valid ones", () => {
const invalidData = [
{ applicationName: "App 1" }, // invalid
{
applicationName: "App 2",
passwordCount: 10,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cipher-1"],
memberCount: 5,
atRiskMemberCount: 1,
memberDetails: [] as MemberDetails[],
atRiskMemberDetails: [] as MemberDetails[],
cipherIds: ["cipher-1"],
}, // valid
{ applicationName: "App 3" }, // invalid
];
let caught: Error | null = null;
try {
validateApplicationHealthReportDetailArray(invalidData);
} catch (e) {
caught = e as Error;
}
expect(caught).not.toBeNull();
expect(caught!.message).toContain("element[0]");
expect(caught!.message).toContain("element[2]");
expect(caught!.message).not.toContain("element[1]");
});
});
describe("validateAccessReportPayload — memberRegistry diagnostics", () => {
it("should include the registry key and failing field name when a registry entry is invalid", () => {
const invalidPayload = {
version: 2,
reports: [] as unknown[],
memberRegistry: {
u1: { id: "u1", userName: "Alice" }, // missing email
},
};
let caught: Error | null = null;
try {
validateAccessReportPayload(invalidPayload);
} catch (e) {
caught = e as Error;
}
expect(caught).not.toBeNull();
expect(caught!.message).toContain('"u1"');
expect(caught!.message).toContain("email");
});
});
});
});

View File

@ -1,5 +1,12 @@
import { CipherId } from "@bitwarden/common/types/guid";
import {
MemberRegistryEntryData,
AccessReportSettingsData,
ApplicationHealthData,
AccessReportSummaryData,
} from "../../../../access-intelligence/models";
import { AccessReportPayload } from "../../../../access-intelligence/services";
import {
ApplicationHealthReportDetail,
MemberDetails,
@ -9,17 +16,22 @@ import {
import {
createBoundedArrayGuard,
createBoundedRecordGuard,
createEnhancedBoundedArrayGuard,
createValidator,
isBoolean,
isBooleanRecord,
isBoundedString,
isBoundedStringOrNull,
isBoundedStringOrUndefined,
isBoundedPositiveNumber,
BOUNDED_ARRAY_MAX_LENGTH,
isDate,
isDateString,
isDateStringOrUndefined,
} from "./basic-type-guards";
// Risk Insights specific type guards
// === Type Guards for Access Intelligence ===
/**
* Type guard to validate MemberDetails structure
@ -30,14 +42,27 @@ export const isMemberDetails = createValidator<MemberDetails>({
userGuid: isBoundedString,
userName: isBoundedStringOrNull,
email: isBoundedString,
cipherId: isBoundedString,
cipherId: isBoundedString, // TODO is isBoundedStringOrNull for backwards compatibility
});
export const isMemberDetailsArray = createBoundedArrayGuard(isMemberDetails);
export const isMemberDetailsArray = createEnhancedBoundedArrayGuard(isMemberDetails);
/**
* Type guard to validate MemberRegistryEntryData structure
* Exported for testability
* Strict validation: rejects objects with unexpected properties and prototype pollution
*/
export const isMemberRegistryEntryData = createValidator<MemberRegistryEntryData>({
id: isBoundedString,
userName: isBoundedStringOrUndefined,
email: isBoundedString,
});
const isMemberRegistry = createBoundedRecordGuard(isMemberRegistryEntryData);
export function isCipherId(value: unknown): value is CipherId {
return value == null || isBoundedString(value);
}
export const isCipherIdArray = createBoundedArrayGuard(isCipherId);
/**
* Type guard to validate ApplicationHealthReportDetail structure
* Exported for testability
@ -58,6 +83,19 @@ export const isApplicationHealthReportDetailArray = createBoundedArrayGuard(
isApplicationHealthReportDetail,
);
const isApplicationHealthData = createValidator<ApplicationHealthData>({
applicationName: isBoundedString,
passwordCount: isBoundedPositiveNumber,
atRiskPasswordCount: isBoundedPositiveNumber,
memberRefs: isBooleanRecord,
cipherRefs: isBooleanRecord,
memberCount: isBoundedPositiveNumber,
atRiskMemberCount: isBoundedPositiveNumber,
iconUri: isBoundedStringOrUndefined,
iconCipherId: isBoundedStringOrUndefined,
});
const isApplicationHealthDataArray = createBoundedArrayGuard(isApplicationHealthData);
/**
* Type guard to validate OrganizationReportSummary structure
* Exported for testability
@ -98,6 +136,8 @@ export const isOrganizationReportApplicationArray = createBoundedArrayGuard(
isOrganizationReportApplication,
);
// === Validate Functions ===
/**
* Validates and returns an array of ApplicationHealthReportDetail
* @throws Error if validation fails
@ -122,9 +162,13 @@ export function validateApplicationHealthReportDetailArray(
.filter(({ item }) => !isApplicationHealthReportDetail(item));
if (invalidItems.length > 0) {
const invalidIndices = invalidItems.map(({ index }) => index).join(", ");
const elementMessages = invalidItems.map(({ item, index }) => {
const fieldErrors = isApplicationHealthReportDetail.explain(item).join("; ");
return ` element[${index}]: ${fieldErrors}`;
});
throw new Error(
`Invalid report data: array contains ${invalidItems.length} invalid ApplicationHealthReportDetail element(s) at indices: ${invalidIndices}`,
`Invalid report data: array contains ${invalidItems.length} invalid ApplicationHealthReportDetail element(s)\n` +
elementMessages.join("\n"),
);
}
@ -149,6 +193,28 @@ export function validateOrganizationReportSummary(data: unknown): OrganizationRe
return data;
}
export const isAccessReportSummaryData = createValidator<AccessReportSummaryData>({
totalMemberCount: isBoundedPositiveNumber,
totalApplicationCount: isBoundedPositiveNumber,
totalAtRiskMemberCount: isBoundedPositiveNumber,
totalAtRiskApplicationCount: isBoundedPositiveNumber,
totalCriticalApplicationCount: isBoundedPositiveNumber,
totalCriticalMemberCount: isBoundedPositiveNumber,
totalCriticalAtRiskMemberCount: isBoundedPositiveNumber,
totalCriticalAtRiskApplicationCount: isBoundedPositiveNumber,
});
/**
* Validates and returns AccessReportSummaryData
* @throws Error if validation fails
*/
export function validateAccessReportSummaryData(data: unknown): AccessReportSummaryData {
if (!isAccessReportSummaryData(data)) {
throw new Error("Invalid report summary");
}
return data;
}
/**
* Validates and returns an array of OrganizationReportApplication
* @throws Error if validation fails
@ -173,9 +239,13 @@ export function validateOrganizationReportApplicationArray(
.filter(({ item }) => !isOrganizationReportApplication(item));
if (invalidItems.length > 0) {
const invalidIndices = invalidItems.map(({ index }) => index).join(", ");
const elementMessages = invalidItems.map(({ item, index }) => {
const fieldErrors = isOrganizationReportApplication.explain(item).join("; ");
return ` element[${index}]: ${fieldErrors}`;
});
throw new Error(
`Invalid application data: array contains ${invalidItems.length} invalid OrganizationReportApplication element(s) at indices: ${invalidIndices}`,
`Invalid application data: array contains ${invalidItems.length} invalid OrganizationReportApplication element(s)\n` +
elementMessages.join("\n"),
);
}
@ -203,3 +273,89 @@ export function validateOrganizationReportApplicationArray(
// Convert string dates to Date objects for reviewedDate
return mappedData;
}
const isAccessReportSettingsData = createValidator<AccessReportSettingsData>({
applicationName: isBoundedString,
isCritical: isBoolean,
reviewedDate: isDateStringOrUndefined,
});
export const isAccessReportSettingsDataArray = createBoundedArrayGuard(isAccessReportSettingsData);
/**
* Validates and returns an array of AccessReportSettingsData
* @throws Error if validation fails
*/
export function validateAccessReportSettingsDataArray(data: unknown): AccessReportSettingsData[] {
if (!Array.isArray(data)) {
throw new Error(
"Invalid application data: expected array of AccessReportSettingsData, received non-array",
);
}
if (data.length > BOUNDED_ARRAY_MAX_LENGTH) {
throw new Error(
`Invalid application data: array length ${data.length} exceeds maximum allowed length ${BOUNDED_ARRAY_MAX_LENGTH}`,
);
}
const invalidItems = data
.map((item, index) => ({ item, index }))
.filter(({ item }) => !isAccessReportSettingsData(item));
if (invalidItems.length > 0) {
const elementMessages = invalidItems.map(({ item, index }) => {
const fieldErrors = isAccessReportSettingsData.explain(item).join("; ");
return ` element[${index}]: ${fieldErrors}`;
});
throw new Error(
`Invalid application data: array contains ${invalidItems.length} invalid AccessReportSettingsData element(s)\n` +
elementMessages.join("\n"),
);
}
if (!isAccessReportSettingsDataArray(data)) {
// Throw for type casting return
// Should never get here
throw new Error("Invalid application data");
}
return data;
}
/**
* Validates and returns AccessReportPayload
* @throws Error if validation fails
*/
export function validateAccessReportPayload(data: unknown): AccessReportPayload {
if (data == null || typeof data !== "object" || Array.isArray(data)) {
throw new Error("Invalid report payload: expected object, received non-object");
}
const obj = data as Record<string, unknown>;
if (!isApplicationHealthDataArray(obj["reports"])) {
throw new Error("Invalid report payload: reports array failed validation");
}
// Pre-normalize "" → undefined before validation for backwards compatibility with
// blobs that stored empty string. The guard uses isBoundedStringOrUndefined which
// rejects "", so normalization must happen before the guard runs.
if (typeof obj["memberRegistry"] === "object" && obj["memberRegistry"] !== null) {
for (const entry of Object.values(obj["memberRegistry"] as Record<string, unknown>)) {
if (
typeof entry === "object" &&
entry !== null &&
(entry as Record<string, unknown>)["userName"] === ""
) {
(entry as Record<string, unknown>)["userName"] = undefined;
}
}
}
if (!isMemberRegistry(obj["memberRegistry"])) {
const errors = isMemberRegistry.explain(obj["memberRegistry"]).join("; ");
throw new Error(`Invalid report payload: memberRegistry failed validation: ${errors}`);
}
return data as AccessReportPayload;
}