From c59b0a88db37eb3b952e6d01c900d5e45192a9d2 Mon Sep 17 00:00:00 2001 From: caballeto Date: Mon, 11 May 2026 18:32:47 +0200 Subject: [PATCH] feat: spec-level Postel's-Law tolerant readers via shared preprocessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendored `scripts/lib/preprocess.mjs` is in lockstep with mono's `@devhelm/openapi-tools` and runs `relaxResponseEnumsInSpec` before openapi-zod-client + openapi-typescript consume the spec. Response-DTO multi-value enums collapse to `z.string()` in `schemas.ts` and `string` in `api.ts` — so `MonitorDto.type` decodes any string, including future values added by the API after this SDK version was built. Request DTOs (CreateMonitorRequest.type, etc.) keep their literal unions for strict client-side validation. Negative tests around response-DTO enum rejection are flipped to assert tolerance (Postel's Law). Negative tests around request-DTO enum rejection are kept as-is. See `mini/runbooks/api-contract.md` § 3.1 for the cross-surface design. Coverage: 1321 / 1321 tests pass; lint + tsc clean. Co-authored-by: Cursor --- scripts/generate-schemas.js | 13 +- scripts/lib/preprocess.mjs | 81 +++++++++- src/generated/api.ts | 255 +++++++++---------------------- test/negative-validation.test.ts | 111 ++++++++------ test/schemas.test.ts | 19 +-- 5 files changed, 240 insertions(+), 239 deletions(-) diff --git a/scripts/generate-schemas.js b/scripts/generate-schemas.js index 30791d7..b637763 100644 --- a/scripts/generate-schemas.js +++ b/scripts/generate-schemas.js @@ -113,8 +113,12 @@ async function main() { const spec = JSON.parse(readFileSync(SPEC_PATH, 'utf8')) console.log('Preprocessing (via @devhelm/openapi-tools)...') - const { flattened, inlinedDiscriminators, inlinedNullableDeductions } = - preprocessSpec(spec) + const { + flattened, + inlinedDiscriminators, + inlinedNullableDeductions, + relaxedEnums, + } = preprocessSpec(spec) if (flattened.length > 0) { console.log(` Flattened circular oneOf: ${flattened.join(', ')}`) } @@ -130,6 +134,11 @@ async function main() { ` Inlined nullable deduction refs for: ${inlinedNullableDeductions.join(', ')}`, ) } + if (relaxedEnums && relaxedEnums.length > 0) { + console.log( + ` Relaxed response-DTO enums (Postel's Law): ${relaxedEnums.length} fields`, + ) + } const tempSpec = join(ROOT, '.openapi-preprocessed.json') const tempGenerated = join(ROOT, '.schemas-raw.ts') diff --git a/scripts/lib/preprocess.mjs b/scripts/lib/preprocess.mjs index 0b6fd80..6193148 100644 --- a/scripts/lib/preprocess.mjs +++ b/scripts/lib/preprocess.mjs @@ -314,6 +314,75 @@ export function inlineNullableDeductionRefs(spec) { return Array.from(rewritten); } +/** + * Drop `enum` constraints from response-shape DTO properties so the + * generated Zod schemas decode unknown future enum values as plain + * strings (Postel's Law contract — see + * `mini/runbooks/api-contract.md` § 2.2 + § 3). + * + * Selection rules — must match the request-vs-response naming convention + * shared with mini's `relaxResponseEnums` post-processor: + * + * - Walks `components.schemas`. A schema is "response-shape" if its + * name matches `*Dto`, `*Response`, `SingleValueResponse*`, + * `TableValueResult*`, or `CursorPage*`. + * - A schema is "request-shape" (left alone) if its name matches + * `*Request` / `*Params` or starts lower-case (helpers). + * - Inside a response-shape schema, every property whose schema + * carries a multi-value `enum` (length ≥ 2) gets the `enum` key + * dropped so codegens emit `z.string()` / `str` / `string`. + * - SINGLE-VALUE enums are PRESERVED — those are the discriminator + * tags installed by `inlineDiscriminatorSubtypesWithInfo`. + * - Array-typed properties get the same treatment on `items.enum`. + * + * Idempotent: re-running on a relaxed spec is a no-op. Returns the list + * of `Schema.field` paths that were relaxed. + */ +export function relaxResponseEnumsInSpec(spec) { + const schemas = getSchemas(spec); + const relaxed = []; + + function isResponseShape(name) { + if (/^[a-z]/.test(name)) return false; + if (/(Request|Params)$/.test(name)) return false; + return ( + /(Dto|Response)$/.test(name) || + /^(SingleValueResponse|TableValueResult|CursorPage)/.test(name) + ); + } + + function relaxProps(schemaName, properties) { + if (!properties) return; + for (const [propName, raw] of Object.entries(properties)) { + if (!isSchemaObj(raw)) continue; + if (Array.isArray(raw.enum) && raw.enum.length >= 2) { + delete raw.enum; + relaxed.push(`${schemaName}.${propName}`); + } + if (raw.items && isSchemaObj(raw.items)) { + if (Array.isArray(raw.items.enum) && raw.items.enum.length >= 2) { + delete raw.items.enum; + relaxed.push(`${schemaName}.${propName}[]`); + } + } + } + } + + for (const [schemaName, schema] of Object.entries(schemas)) { + if (!isResponseShape(schemaName)) continue; + relaxProps(schemaName, schema.properties); + if (Array.isArray(schema.allOf)) { + for (const member of schema.allOf) { + if (isSchemaObj(member)) { + relaxProps(schemaName, member.properties); + } + } + } + } + + return relaxed; +} + export function preprocessSpec(spec) { setRequiredFields(spec); setRequiredOnAllOfMembers(spec); @@ -326,7 +395,17 @@ export function preprocessSpec(spec) { // discriminator-based parents as abstract/empty ones. const inlinedNullableDeductions = inlineNullableDeductionRefs(spec); const flattened = flattenCircularOneOf(spec); - return { flattened, inlinedDiscriminators, inlinedNullableDeductions }; + // Postel's Law: drop multi-value enums on response-shape DTOs so all + // codegens (Zod, Pydantic, Go) emit tolerant readers. MUST run AFTER + // discriminator inlining so we don't accidentally relax single-value + // discriminator tags (those are length-1 enums and skipped by design). + const relaxedEnums = relaxResponseEnumsInSpec(spec); + return { + flattened, + inlinedDiscriminators, + inlinedNullableDeductions, + relaxedEnums, + }; } /** diff --git a/src/generated/api.ts b/src/generated/api.ts index ba8d61a..62f0032 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -2490,11 +2490,8 @@ export interface components { id: string; /** @description Human-readable channel name */ name: string; - /** - * @description Channel integration type (e.g. SLACK, PAGERDUTY, EMAIL) - * @enum {string} - */ - channelType: "email" | "webhook" | "slack" | "pagerduty" | "opsgenie" | "teams" | "discord"; + /** @description Channel integration type (e.g. SLACK, PAGERDUTY, EMAIL) */ + channelType: string; displayConfig?: components["schemas"]["AlertChannelDisplayConfig"] | null; /** * Format: date-time @@ -2508,11 +2505,8 @@ export interface components { updatedAt: string; /** @description SHA-256 hash of the channel config; use for change detection */ configHash?: string | null; - /** - * @description Source that created/owns this channel: DASHBOARD, CLI, TERRAFORM, MCP, or API. Null on channels created before this attribution column existed. - * @enum {string|null} - */ - managedBy?: "DASHBOARD" | "CLI" | "TERRAFORM" | "MCP" | "API" | null; + /** @description Source that created/owns this channel: DASHBOARD, CLI, TERRAFORM, MCP, or API. Null on channels created before this attribution column existed. */ + managedBy?: string | null; /** * Format: date-time * @description Timestamp of the most recent delivery attempt @@ -2544,16 +2538,10 @@ export interface components { channel: string; /** @description Alert channel type (e.g. slack, email, webhook) */ channelType: string; - /** - * @description Current delivery status - * @enum {string} - */ - status: "PENDING" | "DELIVERED" | "RETRY_PENDING" | "FAILED" | "CANCELLED"; - /** - * @description Incident lifecycle event that triggered this delivery - * @enum {string} - */ - eventType: "INCIDENT_CREATED" | "INCIDENT_RESOLVED" | "INCIDENT_REOPENED"; + /** @description Current delivery status */ + status: string; + /** @description Incident lifecycle event that triggered this delivery */ + eventType: string; /** * Format: int32 * @description 1-based escalation step this delivery belongs to @@ -2671,11 +2659,8 @@ export interface components { type: string; /** @description Whether the assertion passed */ passed: boolean; - /** - * @description Assertion severity - * @enum {string} - */ - severity: "fail" | "warn"; + /** @description Assertion severity */ + severity: string; /** @description Human-readable result message */ message?: string | null; /** @@ -2690,18 +2675,12 @@ export interface components { actual?: string | null; }; AssertionTestResultDto: { - /** - * @description Assertion type evaluated - * @enum {string} - */ - assertionType: "status_code" | "response_time" | "body_contains" | "json_path" | "header_value" | "regex_body" | "dns_resolves" | "dns_response_time" | "dns_expected_ips" | "dns_expected_cname" | "dns_record_contains" | "dns_record_equals" | "dns_txt_contains" | "dns_min_answers" | "dns_max_answers" | "dns_response_time_warn" | "dns_ttl_low" | "dns_ttl_high" | "mcp_connects" | "mcp_response_time" | "mcp_has_capability" | "mcp_tool_available" | "mcp_min_tools" | "mcp_protocol_version" | "mcp_response_time_warn" | "mcp_tool_count_changed" | "ssl_expiry" | "response_size" | "redirect_count" | "redirect_target" | "response_time_warn" | "tcp_connects" | "tcp_response_time" | "tcp_response_time_warn" | "icmp_reachable" | "icmp_response_time" | "icmp_response_time_warn" | "icmp_packet_loss" | "heartbeat_received" | "heartbeat_max_interval" | "heartbeat_interval_drift" | "heartbeat_payload_contains"; + /** @description Assertion type evaluated */ + assertionType: string; /** @description Whether the assertion passed */ passed: boolean; - /** - * @description Assertion severity: FAIL or WARN - * @enum {string} - */ - severity: "fail" | "warn"; + /** @description Assertion severity: FAIL or WARN */ + severity: string; /** @description Human-readable result description */ message: string; /** @description Expected value */ @@ -4170,21 +4149,12 @@ export interface components { * @description Organization this incident belongs to */ organizationId: number; - /** - * @description Incident origin: MONITOR, SERVICE, or MANUAL - * @enum {string} - */ - source: "AUTOMATIC" | "MANUAL" | "MONITORS" | "STATUS_DATA" | "RESOURCE_GROUP"; - /** - * @description Current lifecycle status (OPEN, RESOLVED, etc.) - * @enum {string} - */ - status: "WATCHING" | "TRIGGERED" | "CONFIRMED" | "RESOLVED"; - /** - * @description Severity level: DOWN, DEGRADED, or MAINTENANCE - * @enum {string} - */ - severity: "DOWN" | "DEGRADED" | "MAINTENANCE"; + /** @description Incident origin: MONITOR, SERVICE, or MANUAL */ + source: string; + /** @description Current lifecycle status (OPEN, RESOLVED, etc.) */ + status: string; + /** @description Severity level: DOWN, DEGRADED, or MAINTENANCE */ + severity: string; /** @description Short summary of the incident; null for auto-generated incidents */ title?: string | null; /** @description Human-readable description of the trigger rule that fired */ @@ -4219,11 +4189,8 @@ export interface components { affectedComponents?: string[] | null; /** @description Short URL linking to the incident details */ shortlink?: string | null; - /** - * @description How the incident was resolved (AUTO_RECOVERED, MANUAL, etc.) - * @enum {string|null} - */ - resolutionReason?: "MANUAL" | "AUTO_RECOVERED" | "AUTO_RESOLVED" | null; + /** @description How the incident was resolved (AUTO_RECOVERED, MANUAL, etc.) */ + resolutionReason?: string | null; /** * Format: date-time * @description Timestamp when the incident was detected or created @@ -4461,13 +4428,10 @@ export interface components { id: string; /** Format: uuid */ incidentId: string; - /** @enum {string|null} */ - oldStatus?: "WATCHING" | "TRIGGERED" | "CONFIRMED" | "RESOLVED" | null; - /** @enum {string|null} */ - newStatus?: "WATCHING" | "TRIGGERED" | "CONFIRMED" | "RESOLVED" | null; + oldStatus?: string | null; + newStatus?: string | null; body?: string | null; - /** @enum {string|null} */ - createdBy?: "SYSTEM" | "USER" | null; + createdBy?: string | null; notifySubscribers: boolean; /** Format: date-time */ createdAt: string; @@ -4482,8 +4446,7 @@ export interface components { description: string; logoUrl: string; authType: string; - /** @enum {string} */ - tierAvailability: "FREE" | "STARTER" | "PRO" | "TEAM" | "BUSINESS" | "ENTERPRISE"; + tierAvailability: string; lifecycle: string; setupGuideUrl: string; configSchema: components["schemas"]["IntegrationConfigSchemaDto"]; @@ -4508,11 +4471,8 @@ export interface components { inviteId: number; /** @description Email address the invite was sent to */ email: string; - /** - * @description Role that will be assigned to the invitee on acceptance - * @enum {string} - */ - roleOffered: "OWNER" | "ADMIN" | "MEMBER"; + /** @description Role that will be assigned to the invitee on acceptance */ + roleOffered: string; /** * Format: date-time * @description Timestamp when the invite expires @@ -4575,10 +4535,8 @@ export interface components { statusPageName: string; statusPageSlug: string; title: string; - /** @enum {string} */ - status: "INVESTIGATING" | "IDENTIFIED" | "MONITORING" | "RESOLVED"; - /** @enum {string} */ - impact: "NONE" | "MINOR" | "MAJOR" | "CRITICAL"; + status: string; + impact: string; scheduled: boolean; /** Format: date-time */ publishedAt?: string | null; @@ -4777,16 +4735,10 @@ export interface components { email: string; /** @description Member display name; null if not set */ name?: string | null; - /** - * @description Member role within this organization (OWNER, ADMIN, MEMBER) - * @enum {string} - */ - orgRole: "OWNER" | "ADMIN" | "MEMBER"; - /** - * @description Membership status (ACTIVE, PENDING, SUSPENDED) - * @enum {string} - */ - status: "INVITED" | "ACTIVE" | "SUSPENDED" | "LEFT" | "REMOVED" | "DECLINED"; + /** @description Member role within this organization (OWNER, ADMIN, MEMBER) */ + orgRole: string; + /** @description Membership status (ACTIVE, PENDING, SUSPENDED) */ + status: string; /** * Format: date-time * @description Timestamp when the member was added to the organization @@ -4816,11 +4768,9 @@ export interface components { id: string; /** Format: uuid */ monitorId: string; - /** @enum {string} */ - assertionType: "status_code" | "response_time" | "body_contains" | "json_path" | "header_value" | "regex_body" | "dns_resolves" | "dns_response_time" | "dns_expected_ips" | "dns_expected_cname" | "dns_record_contains" | "dns_record_equals" | "dns_txt_contains" | "dns_min_answers" | "dns_max_answers" | "dns_response_time_warn" | "dns_ttl_low" | "dns_ttl_high" | "mcp_connects" | "mcp_response_time" | "mcp_has_capability" | "mcp_tool_available" | "mcp_min_tools" | "mcp_protocol_version" | "mcp_response_time_warn" | "mcp_tool_count_changed" | "ssl_expiry" | "response_size" | "redirect_count" | "redirect_target" | "response_time_warn" | "tcp_connects" | "tcp_response_time" | "tcp_response_time_warn" | "icmp_reachable" | "icmp_response_time" | "icmp_response_time_warn" | "icmp_packet_loss" | "heartbeat_received" | "heartbeat_max_interval" | "heartbeat_interval_drift" | "heartbeat_payload_contains"; + assertionType: string; config: components["schemas"]["BodyContainsAssertion"] | components["schemas"]["DnsExpectedCnameAssertion"] | components["schemas"]["DnsExpectedIpsAssertion"] | components["schemas"]["DnsMaxAnswersAssertion"] | components["schemas"]["DnsMinAnswersAssertion"] | components["schemas"]["DnsRecordContainsAssertion"] | components["schemas"]["DnsRecordEqualsAssertion"] | components["schemas"]["DnsResolvesAssertion"] | components["schemas"]["DnsResponseTimeAssertion"] | components["schemas"]["DnsResponseTimeWarnAssertion"] | components["schemas"]["DnsTtlHighAssertion"] | components["schemas"]["DnsTtlLowAssertion"] | components["schemas"]["DnsTxtContainsAssertion"] | components["schemas"]["HeaderValueAssertion"] | components["schemas"]["HeartbeatIntervalDriftAssertion"] | components["schemas"]["HeartbeatMaxIntervalAssertion"] | components["schemas"]["HeartbeatPayloadContainsAssertion"] | components["schemas"]["HeartbeatReceivedAssertion"] | components["schemas"]["IcmpPacketLossAssertion"] | components["schemas"]["IcmpReachableAssertion"] | components["schemas"]["IcmpResponseTimeAssertion"] | components["schemas"]["IcmpResponseTimeWarnAssertion"] | components["schemas"]["JsonPathAssertion"] | components["schemas"]["McpConnectsAssertion"] | components["schemas"]["McpHasCapabilityAssertion"] | components["schemas"]["McpMinToolsAssertion"] | components["schemas"]["McpProtocolVersionAssertion"] | components["schemas"]["McpResponseTimeAssertion"] | components["schemas"]["McpResponseTimeWarnAssertion"] | components["schemas"]["McpToolAvailableAssertion"] | components["schemas"]["McpToolCountChangedAssertion"] | components["schemas"]["RedirectCountAssertion"] | components["schemas"]["RedirectTargetAssertion"] | components["schemas"]["RegexBodyAssertion"] | components["schemas"]["ResponseSizeAssertion"] | components["schemas"]["ResponseTimeAssertion"] | components["schemas"]["ResponseTimeWarnAssertion"] | components["schemas"]["SslExpiryAssertion"] | components["schemas"]["StatusCodeAssertion"] | components["schemas"]["TcpConnectsAssertion"] | components["schemas"]["TcpResponseTimeAssertion"] | components["schemas"]["TcpResponseTimeWarnAssertion"]; - /** @enum {string} */ - severity: "fail" | "warn"; + severity: string; }; /** @description New authentication configuration (full replacement) */ MonitorAuthConfig: components["schemas"]["BearerAuthConfig"] | components["schemas"]["BasicAuthConfig"] | components["schemas"]["HeaderAuthConfig"] | components["schemas"]["ApiKeyAuthConfig"]; @@ -4829,8 +4779,7 @@ export interface components { id: string; /** Format: uuid */ monitorId: string; - /** @enum {string} */ - authType: "bearer" | "basic" | "header" | "api_key"; + authType: string; config: components["schemas"]["ApiKeyAuthConfig"] | components["schemas"]["BasicAuthConfig"] | components["schemas"]["BearerAuthConfig"] | components["schemas"]["HeaderAuthConfig"]; }; /** @description Full monitor representation */ @@ -4847,8 +4796,7 @@ export interface components { organizationId: number; /** @description Human-readable name for this monitor */ name: string; - /** @enum {string} */ - type: "HTTP" | "DNS" | "MCP_SERVER" | "TCP" | "ICMP" | "HEARTBEAT"; + type: string; config: components["schemas"]["DnsMonitorConfig"] | components["schemas"]["HeartbeatMonitorConfig"] | components["schemas"]["HttpMonitorConfig"] | components["schemas"]["IcmpMonitorConfig"] | components["schemas"]["McpServerMonitorConfig"] | components["schemas"]["TcpMonitorConfig"]; /** * Format: int32 @@ -4859,11 +4807,8 @@ export interface components { enabled: boolean; /** @description Probe regions where checks are executed */ regions: string[]; - /** - * @description Source that created/owns this monitor: DASHBOARD, CLI, TERRAFORM, MCP, or API - * @enum {string} - */ - managedBy: "DASHBOARD" | "CLI" | "TERRAFORM" | "MCP" | "API"; + /** @description Source that created/owns this monitor: DASHBOARD, CLI, TERRAFORM, MCP, or API */ + managedBy: string; /** * Format: date-time * @description Timestamp when the monitor was created @@ -4885,11 +4830,8 @@ export interface components { incidentPolicy?: components["schemas"]["IncidentPolicyDto"] | null; /** @description Alert channel IDs linked to this monitor; populated on single-monitor responses */ alertChannelIds?: string[] | null; - /** - * @description Current operational state — UP, DOWN, DEGRADED, PAUSED, or UNKNOWN if no probe data yet - * @enum {string|null} - */ - currentStatus?: "up" | "degraded" | "down" | "paused" | "unknown" | null; + /** @description Current operational state — UP, DOWN, DEGRADED, PAUSED, or UNKNOWN if no probe data yet */ + currentStatus?: string | null; }; /** @description Monitors that reference this secret; null on create/update responses */ MonitorReference: { @@ -4991,11 +4933,8 @@ export interface components { * @description User ID who made the change; null for automated changes */ changedById?: number | null; - /** - * @description Change source (DASHBOARD, CLI, API) - * @enum {string} - */ - changedVia: "API" | "DASHBOARD" | "CLI" | "TERRAFORM"; + /** @description Change source (DASHBOARD, CLI, API) */ + changedVia: string; /** @description Human-readable description of what changed */ changeSummary?: string | null; /** @@ -5030,16 +4969,10 @@ export interface components { policyId: string; /** @description Human-readable name of the matched policy (null if policy has been deleted) */ policyName?: string | null; - /** - * @description Current dispatch state - * @enum {string} - */ - status: "PENDING" | "DISPATCHING" | "DELIVERED" | "ESCALATING" | "ACKNOWLEDGED" | "COMPLETED"; - /** - * @description Why the dispatch reached COMPLETED: EXHAUSTED (all steps ran, no ack), RESOLVED (incident resolved), NO_STEPS (policy had no steps). Null for non-terminal states. - * @enum {string|null} - */ - completionReason?: "EXHAUSTED" | "RESOLVED" | "NO_STEPS" | null; + /** @description Current dispatch state */ + status: string; + /** @description Why the dispatch reached COMPLETED: EXHAUSTED (all steps ran, no ack), RESOLVED (incident resolved), NO_STEPS (policy had no steps). Null for non-terminal states. */ + completionReason?: string | null; /** * Format: int32 * @description 1-based index of the currently active escalation step @@ -5446,11 +5379,8 @@ export interface components { * @description Default environment ID for member monitors */ defaultEnvironmentId?: string | null; - /** - * @description Health threshold type: COUNT or PERCENTAGE - * @enum {string|null} - */ - healthThresholdType?: "COUNT" | "PERCENTAGE" | null; + /** @description Health threshold type: COUNT or PERCENTAGE */ + healthThresholdType?: string | null; /** @description Health threshold value */ healthThresholdValue?: number | null; /** @description When true, member-level incidents skip notification dispatch; only group alerts fire */ @@ -5468,11 +5398,8 @@ export interface components { health: components["schemas"]["ResourceGroupHealthDto"]; /** @description Member list with individual statuses; populated on detail GET only */ members?: components["schemas"]["ResourceGroupMemberDto"][] | null; - /** - * @description Source that created/owns this group: DASHBOARD, CLI, TERRAFORM, MCP, or API. Null on groups created before this attribution column existed. - * @enum {string|null} - */ - managedBy?: "DASHBOARD" | "CLI" | "TERRAFORM" | "MCP" | "API" | null; + /** @description Source that created/owns this group: DASHBOARD, CLI, TERRAFORM, MCP, or API. Null on groups created before this attribution column existed. */ + managedBy?: string | null; /** * Format: date-time * @description Timestamp when the group was created @@ -5486,11 +5413,8 @@ export interface components { }; /** @description Aggregated health summary for a resource group */ ResourceGroupHealthDto: { - /** - * @description Worst-of health status across all members - * @enum {string} - */ - status: "operational" | "maintenance" | "degraded" | "down"; + /** @description Worst-of health status across all members */ + status: string; /** * Format: int32 * @description Total number of members in the group @@ -5506,11 +5430,8 @@ export interface components { * @description Number of members with an active incident or non-operational status */ activeIncidents: number; - /** - * @description Computed group health status based on threshold: 'healthy', 'degraded', or 'down'. Null when no health threshold is configured. - * @enum {string|null} - */ - thresholdStatus?: "healthy" | "degraded" | "down" | null; + /** @description Computed group health status based on threshold: 'healthy', 'degraded', or 'down'. Null when no health threshold is configured. */ + thresholdStatus?: string | null; /** * Format: int32 * @description Number of failing members at time of last evaluation @@ -5550,11 +5471,8 @@ export interface components { * @description Subscription ID for the service (services only); used to link to the dependency detail page */ subscriptionId?: string | null; - /** - * @description Computed health status for this member - * @enum {string} - */ - status: "operational" | "maintenance" | "degraded" | "down"; + /** @description Computed health status for this member */ + status: string; /** @description Effective check frequency label showing the group default when the monitor inherits it; null for services or when no group default is configured */ effectiveFrequency?: string | null; /** @@ -5618,11 +5536,8 @@ export interface components { }; /** @description Dashboard summary: current status, per-region latest results, and chart data */ ResultSummaryDto: { - /** - * @description Derived current status across all regions - * @enum {string} - */ - currentStatus: "up" | "degraded" | "down" | "paused" | "unknown"; + /** @description Derived current status across all regions */ + currentStatus: string; /** @description Latest check result per region */ latestPerRegion: components["schemas"]["RegionStatusDto"][]; /** @description Time-bucketed chart data for the requested window */ @@ -6114,11 +6029,8 @@ export interface components { */ componentId?: string | null; component?: components["schemas"]["ServiceComponentDto"] | null; - /** - * @description Alert sensitivity: ALL (synthetic + real incidents), INCIDENTS_ONLY (real vendor incidents, default), MAJOR_ONLY (real + DOWN severity) - * @enum {string} - */ - alertSensitivity: "ALL" | "INCIDENTS_ONLY" | "MAJOR_ONLY"; + /** @description Alert sensitivity: ALL (synthetic + real incidents), INCIDENTS_ONLY (real vendor incidents, default), MAJOR_ONLY (real + DOWN severity) */ + alertSensitivity: string; /** * Format: date-time * @description When the organization subscribed to this service @@ -6405,14 +6317,12 @@ export interface components { groupId?: string | null; name: string; description?: string | null; - /** @enum {string} */ - type: "MONITOR" | "GROUP" | "STATIC"; + type: string; /** Format: uuid */ monitorId?: string | null; /** Format: uuid */ resourceGroupId?: string | null; - /** @enum {string} */ - currentStatus: "OPERATIONAL" | "DEGRADED_PERFORMANCE" | "PARTIAL_OUTAGE" | "MAJOR_OUTAGE" | "UNDER_MAINTENANCE"; + currentStatus: string; showUptime: boolean; /** Format: int32 */ displayOrder: number; @@ -6448,10 +6358,8 @@ export interface components { /** Format: uuid */ id: string; hostname: string; - /** @enum {string} */ - status: "PENDING_VERIFICATION" | "VERIFICATION_FAILED" | "VERIFIED" | "SSL_PENDING" | "ACTIVE" | "FAILED" | "REMOVED"; - /** @enum {string} */ - verificationMethod: "CNAME" | "TXT"; + status: string; + verificationMethod: string; verificationToken: string; verificationCnameTarget: string; /** Format: date-time */ @@ -6478,22 +6386,16 @@ export interface components { slug: string; description?: string | null; branding: components["schemas"]["StatusPageBranding"]; - /** @enum {string} */ - visibility: "PUBLIC" | "PASSWORD" | "IP_RESTRICTED"; + visibility: string; enabled: boolean; - /** @enum {string} */ - incidentMode: "MANUAL" | "REVIEW" | "AUTOMATIC"; + incidentMode: string; /** Format: int32 */ componentCount?: number | null; /** Format: int64 */ subscriberCount?: number | null; - /** @enum {string|null} */ - overallStatus?: "OPERATIONAL" | "DEGRADED_PERFORMANCE" | "PARTIAL_OUTAGE" | "MAJOR_OUTAGE" | "UNDER_MAINTENANCE" | null; - /** - * @description Source that created/owns this status page: DASHBOARD, CLI, TERRAFORM, MCP, or API. Null on pages created before this attribution column existed. - * @enum {string|null} - */ - managedBy?: "DASHBOARD" | "CLI" | "TERRAFORM" | "MCP" | "API" | null; + overallStatus?: string | null; + /** @description Source that created/owns this status page: DASHBOARD, CLI, TERRAFORM, MCP, or API. Null on pages created before this attribution column existed. */ + managedBy?: string | null; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -6502,8 +6404,7 @@ export interface components { StatusPageIncidentComponentDto: { /** Format: uuid */ statusPageComponentId: string; - /** @enum {string} */ - componentStatus: "OPERATIONAL" | "DEGRADED_PERFORMANCE" | "PARTIAL_OUTAGE" | "MAJOR_OUTAGE" | "UNDER_MAINTENANCE"; + componentStatus: string; componentName: string; }; StatusPageIncidentDto: { @@ -6512,10 +6413,8 @@ export interface components { /** Format: uuid */ statusPageId: string; title: string; - /** @enum {string} */ - status: "INVESTIGATING" | "IDENTIFIED" | "MONITORING" | "RESOLVED"; - /** @enum {string} */ - impact: "NONE" | "MINOR" | "MAJOR" | "CRITICAL"; + status: string; + impact: string; scheduled: boolean; /** Format: date-time */ scheduledFor?: string | null; @@ -6544,11 +6443,9 @@ export interface components { StatusPageIncidentUpdateDto: { /** Format: uuid */ id: string; - /** @enum {string} */ - status: "INVESTIGATING" | "IDENTIFIED" | "MONITORING" | "RESOLVED"; + status: string; body: string; - /** @enum {string|null} */ - createdBy?: "USER" | "SYSTEM" | null; + createdBy?: string | null; /** Format: int32 */ createdByUserId?: number | null; notifySubscribers: boolean; diff --git a/test/negative-validation.test.ts b/test/negative-validation.test.ts index 7af63ea..1334482 100644 --- a/test/negative-validation.test.ts +++ b/test/negative-validation.test.ts @@ -178,8 +178,10 @@ describe('MonitorDto negative validation', () => { fail(s, rest) }) - it('rejects invalid type enum', () => fail(s, {...validMonitorDto, type: 'WEBSOCKET'})) - it('rejects lowercase type enum', () => fail(s, {...validMonitorDto, type: 'http'})) + it('accepts unknown type (Postel tolerant reader)', () => + pass(s, {...validMonitorDto, type: 'WEBSOCKET'})) + it('accepts unknown type (Postel tolerant reader)', () => + pass(s, {...validMonitorDto, type: 'http'})) it('rejects missing config', () => { const {config: _, ...rest} = validMonitorDto @@ -218,8 +220,8 @@ describe('MonitorDto negative validation', () => { fail(s, rest) }) - it('rejects invalid managedBy enum', () => - fail(s, {...validMonitorDto, managedBy: 'GITHUB_ACTIONS'})) + it('accepts unknown managedBy (Postel tolerant reader)', () => + pass(s, {...validMonitorDto, managedBy: 'GITHUB_ACTIONS'})) it('rejects missing createdAt', () => { const {createdAt: _, ...rest} = validMonitorDto @@ -373,22 +375,26 @@ describe('IncidentDto negative validation', () => { fail(s, rest) }) - it('rejects invalid source enum', () => fail(s, {...validIncidentDto, source: 'WEBHOOK'})) + it('accepts unknown source (Postel tolerant reader)', () => + pass(s, {...validIncidentDto, source: 'WEBHOOK'})) it('rejects missing status', () => { const {status: _, ...rest} = validIncidentDto fail(s, rest) }) - it('rejects invalid status enum', () => fail(s, {...validIncidentDto, status: 'OPEN'})) - it('rejects lowercase status enum', () => fail(s, {...validIncidentDto, status: 'triggered'})) + it('accepts unknown status (Postel tolerant reader)', () => + pass(s, {...validIncidentDto, status: 'OPEN'})) + it('accepts unknown status (Postel tolerant reader)', () => + pass(s, {...validIncidentDto, status: 'triggered'})) it('rejects missing severity', () => { const {severity: _, ...rest} = validIncidentDto fail(s, rest) }) - it('rejects invalid severity enum', () => fail(s, {...validIncidentDto, severity: 'CRITICAL'})) + it('accepts unknown severity (Postel tolerant reader)', () => + pass(s, {...validIncidentDto, severity: 'CRITICAL'})) it('rejects missing affectedRegions', () => { const {affectedRegions: _, ...rest} = validIncidentDto @@ -434,8 +440,8 @@ describe('IncidentDto negative validation', () => { it('rejects null for non-nullable severity', () => fail(s, {...validIncidentDto, severity: null})) - it('rejects invalid resolutionReason enum', () => - fail(s, {...validIncidentDto, resolutionReason: 'TIMEOUT'})) + it('accepts unknown resolutionReason (Postel tolerant reader)', () => + pass(s, {...validIncidentDto, resolutionReason: 'TIMEOUT'})) it('rejects non-UUID monitorId', () => fail(s, {...validIncidentDto, monitorId: 'mon-123'})) @@ -499,8 +505,10 @@ describe('AlertChannelDto negative validation', () => { fail(s, rest) }) - it('rejects invalid channelType enum', () => fail(s, {...validAlertChannelDto, channelType: 'sms'})) - it('rejects uppercase channelType', () => fail(s, {...validAlertChannelDto, channelType: 'SLACK'})) + it('accepts unknown channelType (Postel tolerant reader)', () => + pass(s, {...validAlertChannelDto, channelType: 'sms'})) + it('accepts unknown channelType (Postel tolerant reader)', () => + pass(s, {...validAlertChannelDto, channelType: 'SLACK'})) it('rejects missing createdAt', () => { const {createdAt: _, ...rest} = validAlertChannelDto @@ -1046,8 +1054,8 @@ describe('ResourceGroupDto negative validation', () => { it('rejects wrong type for health (string)', () => fail(s, {...validResourceGroupDto, health: 'good'})) - it('rejects health with invalid status enum', () => - fail(s, {...validResourceGroupDto, health: {status: 'critical', totalMembers: 0, operationalCount: 0, activeIncidents: 0}})) + it('accepts health with unknown status enum (Postel)', () => + pass(s, {...validResourceGroupDto, health: {status: 'critical', totalMembers: 0, operationalCount: 0, activeIncidents: 0}})) it('rejects non-UUID alertPolicyId', () => fail(s, {...validResourceGroupDto, alertPolicyId: 'policy-1'})) @@ -1055,8 +1063,8 @@ describe('ResourceGroupDto negative validation', () => { it('rejects non-UUID defaultEnvironmentId', () => fail(s, {...validResourceGroupDto, defaultEnvironmentId: 'env-1'})) - it('rejects invalid healthThresholdType enum', () => - fail(s, {...validResourceGroupDto, healthThresholdType: 'ABSOLUTE'})) + it('accepts unknown healthThresholdType (Postel tolerant reader)', () => + pass(s, {...validResourceGroupDto, healthThresholdType: 'ABSOLUTE'})) it('rejects null for non-nullable id', () => fail(s, {...validResourceGroupDto, id: null})) it('rejects null for non-nullable name', () => fail(s, {...validResourceGroupDto, name: null})) @@ -1485,8 +1493,10 @@ describe('StatusPageDto negative validation', () => { fail(s, rest) }) - it('rejects invalid visibility enum', () => fail(s, {...validStatusPageDto, visibility: 'PRIVATE'})) - it('rejects lowercase visibility', () => fail(s, {...validStatusPageDto, visibility: 'public'})) + it('accepts unknown visibility (Postel tolerant reader)', () => + pass(s, {...validStatusPageDto, visibility: 'PRIVATE'})) + it('accepts unknown visibility (Postel tolerant reader)', () => + pass(s, {...validStatusPageDto, visibility: 'public'})) it('rejects missing enabled', () => { const {enabled: _, ...rest} = validStatusPageDto @@ -1501,10 +1511,11 @@ describe('StatusPageDto negative validation', () => { fail(s, rest) }) - it('rejects invalid incidentMode', () => fail(s, {...validStatusPageDto, incidentMode: 'AUTO'})) + it('accepts unknown incidentMode (Postel tolerant reader)', () => + pass(s, {...validStatusPageDto, incidentMode: 'AUTO'})) - it('rejects invalid overallStatus', () => - fail(s, {...validStatusPageDto, overallStatus: 'BROKEN'})) + it('accepts unknown overallStatus (Postel tolerant reader)', () => + pass(s, {...validStatusPageDto, overallStatus: 'BROKEN'})) it('rejects null for non-nullable id', () => fail(s, {...validStatusPageDto, id: null})) it('rejects null for non-nullable name', () => fail(s, {...validStatusPageDto, name: null})) @@ -1565,15 +1576,16 @@ describe('StatusPageComponentDto negative validation', () => { fail(s, rest) }) - it('rejects invalid type enum', () => fail(s, {...validStatusPageComponentDto, type: 'SERVICE'})) + it('accepts unknown type (Postel tolerant reader)', () => + pass(s, {...validStatusPageComponentDto, type: 'SERVICE'})) it('rejects missing currentStatus', () => { const {currentStatus: _, ...rest} = validStatusPageComponentDto fail(s, rest) }) - it('rejects invalid currentStatus', () => - fail(s, {...validStatusPageComponentDto, currentStatus: 'BROKEN'})) + it('accepts unknown currentStatus (Postel tolerant reader)', () => + pass(s, {...validStatusPageComponentDto, currentStatus: 'BROKEN'})) it('rejects missing showUptime', () => { const {showUptime: _, ...rest} = validStatusPageComponentDto @@ -1667,14 +1679,16 @@ describe('StatusPageIncidentDto negative validation', () => { fail(s, rest) }) - it('rejects invalid status', () => fail(s, {...validStatusPageIncidentDto, status: 'OPEN'})) + it('accepts unknown status (Postel tolerant reader)', () => + pass(s, {...validStatusPageIncidentDto, status: 'OPEN'})) it('rejects missing impact', () => { const {impact: _, ...rest} = validStatusPageIncidentDto fail(s, rest) }) - it('rejects invalid impact', () => fail(s, {...validStatusPageIncidentDto, impact: 'APOCALYPTIC'})) + it('accepts unknown impact (Postel tolerant reader)', () => + pass(s, {...validStatusPageIncidentDto, impact: 'APOCALYPTIC'})) it('rejects missing scheduled', () => { const {scheduled: _, ...rest} = validStatusPageIncidentDto @@ -1719,7 +1733,8 @@ describe('StatusPageIncidentUpdateDto negative validation', () => { fail(s, rest) }) - it('rejects invalid status', () => fail(s, {...validStatusPageIncidentUpdateDto, status: 'PANICKING'})) + it('accepts unknown status (Postel tolerant reader)', () => + pass(s, {...validStatusPageIncidentUpdateDto, status: 'PANICKING'})) it('rejects missing body', () => { const {body: _, ...rest} = validStatusPageIncidentUpdateDto @@ -1792,16 +1807,16 @@ describe('StatusPageCustomDomainDto negative validation', () => { fail(s, rest) }) - it('rejects invalid status enum', () => - fail(s, {...validStatusPageCustomDomainDto, status: 'READY'})) + it('accepts unknown status (Postel tolerant reader)', () => + pass(s, {...validStatusPageCustomDomainDto, status: 'READY'})) it('rejects missing verificationMethod', () => { const {verificationMethod: _, ...rest} = validStatusPageCustomDomainDto fail(s, rest) }) - it('rejects invalid verificationMethod', () => - fail(s, {...validStatusPageCustomDomainDto, verificationMethod: 'DNS'})) + it('accepts unknown verificationMethod (Postel tolerant reader)', () => + pass(s, {...validStatusPageCustomDomainDto, verificationMethod: 'DNS'})) it('rejects missing verificationToken', () => { const {verificationToken: _, ...rest} = validStatusPageCustomDomainDto @@ -2078,7 +2093,7 @@ describe('MonitorVersionDto negative validation', () => { it('rejects wrong type for snapshot (string)', () => fail(s, {...validMonitorVersionDto, snapshot: 'snapshot-data'})) - it('rejects snapshot with invalid MonitorDto', () => + it('rejects snapshot with malformed nested fields', () => fail(s, {...validMonitorVersionDto, snapshot: {id: 'not-uuid', name: 123}})) it('rejects missing changedVia', () => { @@ -2086,8 +2101,8 @@ describe('MonitorVersionDto negative validation', () => { fail(s, rest) }) - it('rejects invalid changedVia enum', () => - fail(s, {...validMonitorVersionDto, changedVia: 'GITHUB'})) + it('accepts unknown changedVia (Postel tolerant reader)', () => + pass(s, {...validMonitorVersionDto, changedVia: 'GITHUB'})) it('rejects missing createdAt', () => { const {createdAt: _, ...rest} = validMonitorVersionDto @@ -2172,8 +2187,8 @@ describe('ServiceSubscriptionDto negative validation', () => { fail(s, rest) }) - it('rejects invalid alertSensitivity enum', () => - fail(s, {...validServiceSubscriptionDto, alertSensitivity: 'NONE'})) + it('accepts unknown alertSensitivity (Postel tolerant reader)', () => + pass(s, {...validServiceSubscriptionDto, alertSensitivity: 'NONE'})) it('rejects missing subscribedAt', () => { const {subscribedAt: _, ...rest} = validServiceSubscriptionDto @@ -2482,10 +2497,10 @@ describe('parseCursorPage() validation layer', () => { // ===================================================================== describe('cross-schema negative validation', () => { - it('MonitorVersionDto rejects snapshot with invalid type enum', () => - fail(schemas.MonitorVersionDto, { + it('MonitorVersionDto accepts snapshot with unknown type enum (Postel tolerant reader)', () => + pass(schemas.MonitorVersionDto, { ...validMonitorVersionDto, - snapshot: {...validMonitorDto, type: 'INVALID'}, + snapshot: {...validMonitorDto, type: 'GRPC_FUTURE'}, })) it('ResourceGroupDto rejects health with missing totalMembers', () => { @@ -2493,10 +2508,10 @@ describe('cross-schema negative validation', () => { fail(schemas.ResourceGroupDto, {...validResourceGroupDto, health: badHealth}) }) - it('StatusPageComponentGroupDto rejects components with invalid currentStatus', () => - fail(schemas.StatusPageComponentGroupDto, { + it('StatusPageComponentGroupDto accepts components with unknown currentStatus (Postel tolerant reader)', () => + pass(schemas.StatusPageComponentGroupDto, { ...validStatusPageComponentGroupDto, - components: [{...validStatusPageComponentDto, currentStatus: 'BROKEN'}], + components: [{...validStatusPageComponentDto, currentStatus: 'FUTURE_STATUS'}], })) it('DashboardOverviewDto rejects monitors with string total', () => @@ -2505,16 +2520,16 @@ describe('cross-schema negative validation', () => { monitors: {...validDashboardOverviewDto.monitors, total: 'ten'}, })) - it('StatusPageIncidentDto rejects updates with invalid status', () => - fail(schemas.StatusPageIncidentDto, { + it('StatusPageIncidentDto accepts updates with unknown status (Postel tolerant reader)', () => + pass(schemas.StatusPageIncidentDto, { ...validStatusPageIncidentDto, - updates: [{...validStatusPageIncidentUpdateDto, status: 'PANICKING'}], + updates: [{...validStatusPageIncidentUpdateDto, status: 'FUTURE_STATUS'}], })) - it('StatusPageIncidentDto rejects affectedComponents with invalid componentStatus', () => - fail(schemas.StatusPageIncidentDto, { + it('StatusPageIncidentDto accepts affectedComponents with unknown componentStatus (Postel tolerant reader)', () => + pass(schemas.StatusPageIncidentDto, { ...validStatusPageIncidentDto, - affectedComponents: [{statusPageComponentId: UUID, componentStatus: 'BROKEN', componentName: 'API'}], + affectedComponents: [{statusPageComponentId: UUID, componentStatus: 'FUTURE_STATUS', componentName: 'API'}], })) }) diff --git a/test/schemas.test.ts b/test/schemas.test.ts index e66f234..541e5fa 100644 --- a/test/schemas.test.ts +++ b/test/schemas.test.ts @@ -295,8 +295,8 @@ describe('StatusPageDto response validation', () => { expect(schema.safeParse({...validStatusPageDto, id: 'not-uuid'}).success).toBe(false) }) - it('rejects invalid visibility enum in response', () => { - expect(schema.safeParse({...validStatusPageDto, visibility: 'HIDDEN'}).success).toBe(false) + it('accepts unknown visibility in response (Postel tolerant reader)', () => { + expect(schema.safeParse({...validStatusPageDto, visibility: 'INTERNAL_FUTURE'}).success).toBe(true) }) it('rejects missing name', () => { @@ -337,11 +337,11 @@ describe('StatusPageComponentDto response validation', () => { expect(schema.safeParse(validComponentDto).success).toBe(true) }) - it('rejects invalid currentStatus', () => { - expect(schema.safeParse({...validComponentDto, currentStatus: 'BROKEN'}).success).toBe(false) + it('accepts unknown currentStatus (Postel tolerant reader)', () => { + expect(schema.safeParse({...validComponentDto, currentStatus: 'SCHEDULED_MAINTENANCE_FUTURE'}).success).toBe(true) }) - it('validates all currentStatus enum values', () => { + it('accepts all known currentStatus values', () => { for (const status of ['OPERATIONAL', 'DEGRADED_PERFORMANCE', 'PARTIAL_OUTAGE', 'MAJOR_OUTAGE', 'UNDER_MAINTENANCE']) { expect(schema.safeParse({...validComponentDto, currentStatus: status}).success).toBe(true) } @@ -360,8 +360,8 @@ describe('StatusPageIncidentDto response validation', () => { expect(schema.safeParse(noScheduled).success).toBe(false) }) - it('rejects incident with invalid status', () => { - expect(schema.safeParse({...validIncidentDto, status: 'OPEN'}).success).toBe(false) + it('accepts incident with unknown status (Postel tolerant reader)', () => { + expect(schema.safeParse({...validIncidentDto, status: 'OPEN'}).success).toBe(true) }) it('accepts incident with all nullable fields null', () => { @@ -412,12 +412,13 @@ describe('parsePage with real schemas', () => { expect(result.totalElements).toBe(1) }) - it('throws when a page item has invalid impact enum', () => { + it('passes through page items with unknown impact (Postel tolerant reader)', () => { const raw = { data: [{...validIncidentDto, impact: 'APOCALYPTIC'}], hasNext: false, hasPrev: false, } - expect(() => parsePage(schemas.StatusPageIncidentDto, raw)).toThrow(DevhelmError) + const page = parsePage(schemas.StatusPageIncidentDto, raw) + expect(page.data[0].impact).toBe('APOCALYPTIC') }) })