feat(contracts): share notification, organization and device capability models
Move the Hub channel table, organization tags and device capability definitions into the workspace packages so the control plane and the console validate the same contract, and refresh the frozen upstream evidence for the new paths.
This commit is contained in:
@@ -1,82 +1,113 @@
|
||||
const SECRET_KEY = /(?:password|passwd|credential|api[_-]?key|access[_-]?(?:key|token|secret|id)|device[_-]?key|private[_-]?key|pin|puk|username|(?:^|[_-])user(?:$|[_-])|account|serial|revision|(?:^|[_-])path(?:$|[_-])|template|token|secret|cookie|authorization|content|body|confirmation[_-]?code)/i;
|
||||
const SECRET_KEY =
|
||||
/(?:password|passwd|credential|api[_-]?key|access[_-]?(?:key|token|secret|id)|device[_-]?key|private[_-]?key|pin|puk|username|(?:^|[_-])user(?:$|[_-])|account|serial|revision|(?:^|[_-])path(?:$|[_-])|template|token|secret|cookie|authorization|content|body|confirmation[_-]?code)/i;
|
||||
const STRUCTURED_KEY = /^(?:config)$/i;
|
||||
const LOCATION_KEY = /^(?:lat(?:itude)?|lon(?:gitude)?|cell_?id|cid|lac|tac|pci|e?nb|gnb)$/i;
|
||||
const FINGERPRINT_KEY = /^(?:uptime|traffic|temperature|.*(?:rx|tx)[_-]?(?:bytes|packets)|.*percent|(?:created|updated|captured)?_?at|timestamp|time)$/i;
|
||||
const FINGERPRINT_KEY =
|
||||
/^(?:uptime|traffic|temperature|.*(?:rx|tx)[_-]?(?:bytes|packets)|.*percent|(?:created|updated|captured)?_?at|timestamp|time)$/i;
|
||||
const SAFE_BOOLEAN_KEY = /(?:phone_number|sms_center)_is_manual$/i;
|
||||
const PHONE_KEY = /(?:phone|msisdn|recipient|number|smsc|sms_center)/i;
|
||||
const IDENTIFIER_KEY = /(?:iccid|imsi|imei|eid|matching_id)/i;
|
||||
const ADDRESS_KEY = /(?:ssid|bssid|mac|(?:^|_)(?:ip|address)(?:_|$)|hostname|host|url|webhook)/i;
|
||||
|
||||
export function typedRedact(value:any):any {
|
||||
if(Array.isArray(value)) return value.map(typedRedact);
|
||||
if(value && typeof value==='object') return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,typedRedact(v)]));
|
||||
if(typeof value==='string') return '[REDACTED]';
|
||||
if(typeof value==='number') return 0;
|
||||
if(typeof value==='boolean') return false;
|
||||
return value;
|
||||
export function typedRedact(value: any): any {
|
||||
if (Array.isArray(value)) return value.map(typedRedact);
|
||||
if (value && typeof value === 'object')
|
||||
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, typedRedact(v)]));
|
||||
if (typeof value === 'string') return '[REDACTED]';
|
||||
if (typeof value === 'number') return 0;
|
||||
if (typeof value === 'boolean') return false;
|
||||
return value;
|
||||
}
|
||||
|
||||
function looksHighEntropy(s:string){
|
||||
if(s.length<16 || /\s/.test(s)) return false;
|
||||
const classes=[/[a-z]/,/[A-Z]/,/\d/,/[^A-Za-z0-9]/].filter(r=>r.test(s)).length;
|
||||
return classes>=3 || (classes>=2 && new Set(s).size>=12);
|
||||
function looksHighEntropy(s: string) {
|
||||
if (s.length < 16 || /\s/.test(s)) return false;
|
||||
const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((r) => r.test(s)).length;
|
||||
return classes >= 3 || (classes >= 2 && new Set(s).size >= 12);
|
||||
}
|
||||
function unsafeString(s:string){
|
||||
return /https?:\/\//i.test(s) || /(?:^|\s|["'=])(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?/i.test(s) ||
|
||||
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i.test(s) || /(?:[0-9a-f]{2}:){5}[0-9a-f]{2}/i.test(s) ||
|
||||
/(?:^|[^\w])(?:[0-9a-f]{0,4}:){2,}[0-9a-f:%]+(?:$|[^\w])/i.test(s) || /\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(s) ||
|
||||
/(?:^|\D)\+?\d(?:[ ()-]*\d){7,}(?:$|\D)/.test(s) || /(?:\d[ -]){6,}\d/.test(s) ||
|
||||
/(?:^|\s)(?:\/Users\/|\/home\/|\/var\/|\/etc\/|[A-Za-z]:\\)\S+/i.test(s) ||
|
||||
/\b(?:token|user(?:name)?|account|path|url|host)\s*[=:]\s*\S+/i.test(s) || /\b(?:AKIA|ASIA)[A-Z0-9]{12,}\b/.test(s) || looksHighEntropy(s);
|
||||
function unsafeString(s: string) {
|
||||
return (
|
||||
/https?:\/\//i.test(s) ||
|
||||
/(?:^|\s|["'=])(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?/i.test(s) ||
|
||||
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i.test(s) ||
|
||||
/(?:[0-9a-f]{2}:){5}[0-9a-f]{2}/i.test(s) ||
|
||||
/(?:^|[^\w])(?:[0-9a-f]{0,4}:){2,}[0-9a-f:%]+(?:$|[^\w])/i.test(s) ||
|
||||
/\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(s) ||
|
||||
/(?:^|\D)\+?\d(?:[ ()-]*\d){7,}(?:$|\D)/.test(s) ||
|
||||
/(?:\d[ -]){6,}\d/.test(s) ||
|
||||
/(?:^|\s)(?:\/Users\/|\/home\/|\/var\/|\/etc\/|[A-Za-z]:\\)\S+/i.test(s) ||
|
||||
/\b(?:token|user(?:name)?|account|path|url|host)\s*[=:]\s*\S+/i.test(s) ||
|
||||
/\b(?:AKIA|ASIA)[A-Z0-9]{12,}\b/.test(s) ||
|
||||
looksHighEntropy(s)
|
||||
);
|
||||
}
|
||||
|
||||
class Context {
|
||||
value(value:any,key='',force=false):any {
|
||||
if(SAFE_BOOLEAN_KEY.test(key) && typeof value==='boolean') return value;
|
||||
if(SECRET_KEY.test(key) || LOCATION_KEY.test(key) || FINGERPRINT_KEY.test(key)) return typedRedact(value);
|
||||
if(STRUCTURED_KEY.test(key)) force=true;
|
||||
if(PHONE_KEY.test(key)||IDENTIFIER_KEY.test(key)||ADDRESS_KEY.test(key)) return typedRedact(value);
|
||||
if(Array.isArray(value)) return value.map(v=>this.value(v,'',force));
|
||||
if(value && typeof value==='object') return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,this.value(v,k,force)]));
|
||||
if(force) return typedRedact(value);
|
||||
if(typeof value==='string'){
|
||||
const t=value.trim();
|
||||
if((t.startsWith('{')&&t.endsWith('}'))||(t.startsWith('[')&&t.endsWith(']'))){try{return JSON.stringify(this.value(JSON.parse(value)));}catch{}}
|
||||
return unsafeString(value)?'[REDACTED]':value;
|
||||
value(value: any, key = '', force = false): any {
|
||||
if (SAFE_BOOLEAN_KEY.test(key) && typeof value === 'boolean') return value;
|
||||
if (SECRET_KEY.test(key) || LOCATION_KEY.test(key) || FINGERPRINT_KEY.test(key))
|
||||
return typedRedact(value);
|
||||
if (STRUCTURED_KEY.test(key)) force = true;
|
||||
if (PHONE_KEY.test(key) || IDENTIFIER_KEY.test(key) || ADDRESS_KEY.test(key))
|
||||
return typedRedact(value);
|
||||
if (Array.isArray(value)) return value.map((v) => this.value(v, '', force));
|
||||
if (value && typeof value === 'object')
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([k, v]) => [k, this.value(v, k, force)]),
|
||||
);
|
||||
if (force) return typedRedact(value);
|
||||
if (typeof value === 'string') {
|
||||
const t = value.trim();
|
||||
if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) {
|
||||
try {
|
||||
return JSON.stringify(this.value(JSON.parse(value)));
|
||||
} catch {}
|
||||
}
|
||||
return unsafeString(value) ? '[REDACTED]' : value;
|
||||
}
|
||||
if (typeof value === 'number' && Number.isInteger(value) && Math.abs(value) >= 100_000_000)
|
||||
return 0;
|
||||
return value;
|
||||
}
|
||||
if(typeof value==='number' && Number.isInteger(value) && Math.abs(value)>=100_000_000) return 0;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseResponseSensitivePath(path:string):string[] {
|
||||
if(typeof path!=='string'||!path.startsWith('$.response')) throw new Error('unsupported response sensitive path');
|
||||
const rest=path.slice('$.response'.length);
|
||||
if(!rest) return [];
|
||||
if(!/^(?:\.[A-Za-z0-9_-]+|\[\*\])+$/.test(rest)) throw new Error('unsupported response sensitive path');
|
||||
return [...rest.matchAll(/\.([A-Za-z0-9_-]+)|\[\*\]/g)].map(m=>m[1]||'*');
|
||||
export function parseResponseSensitivePath(path: string): string[] {
|
||||
if (typeof path !== 'string' || !path.startsWith('$.response'))
|
||||
throw new Error('unsupported response sensitive path');
|
||||
const rest = path.slice('$.response'.length);
|
||||
if (!rest) return [];
|
||||
if (!/^(?:\.[A-Za-z0-9_-]+|\[\*\])+$/.test(rest))
|
||||
throw new Error('unsupported response sensitive path');
|
||||
return [...rest.matchAll(/\.([A-Za-z0-9_-]+)|\[\*\]/g)].map((m) => m[1] || '*');
|
||||
}
|
||||
export function applySensitivePath(node:any,parts:string[],index=0):number {
|
||||
if(index===parts.length) return 0;
|
||||
const part=parts[index];
|
||||
if(part==='*'){
|
||||
if(!Array.isArray(node)) return 0;
|
||||
if(index===parts.length-1){for(let i=0;i<node.length;i++)node[i]=typedRedact(node[i]);return node.length;}
|
||||
return node.reduce((n,item)=>n+applySensitivePath(item,parts,index+1),0);
|
||||
}
|
||||
if(!node||typeof node!=='object'||!(part in node)) return 0;
|
||||
if(index===parts.length-1){node[part]=typedRedact(node[part]);return 1;}
|
||||
return applySensitivePath(node[part],parts,index+1);
|
||||
export function applySensitivePath(node: any, parts: string[], index = 0): number {
|
||||
if (index === parts.length) return 0;
|
||||
const part = parts[index];
|
||||
if (part === '*') {
|
||||
if (!Array.isArray(node)) return 0;
|
||||
if (index === parts.length - 1) {
|
||||
for (let i = 0; i < node.length; i++) node[i] = typedRedact(node[i]);
|
||||
return node.length;
|
||||
}
|
||||
return node.reduce((n, item) => n + applySensitivePath(item, parts, index + 1), 0);
|
||||
}
|
||||
if (!node || typeof node !== 'object' || !(part in node)) return 0;
|
||||
if (index === parts.length - 1) {
|
||||
node[part] = typedRedact(node[part]);
|
||||
return 1;
|
||||
}
|
||||
return applySensitivePath(node[part], parts, index + 1);
|
||||
}
|
||||
export function redactOperationPayload<T>(value:T,operation:any):T {
|
||||
const clone=structuredClone(value);
|
||||
for(const field of operation?.sensitiveFields||[]){
|
||||
if(field?.direction!=='response') continue;
|
||||
const parts=parseResponseSensitivePath(field.path);
|
||||
// The decoded HTTP body corresponds to $.response; parts are body-relative
|
||||
// (normally beginning with `data`), so do not add another response wrapper.
|
||||
applySensitivePath(clone,parts);
|
||||
}
|
||||
return new Context().value(clone) as T;
|
||||
export function redactOperationPayload<T>(value: T, operation: any): T {
|
||||
const clone = structuredClone(value);
|
||||
for (const field of operation?.sensitiveFields || []) {
|
||||
if (field?.direction !== 'response') continue;
|
||||
const parts = parseResponseSensitivePath(field.path);
|
||||
// The decoded HTTP body corresponds to $.response; parts are body-relative
|
||||
// (normally beginning with `data`), so do not add another response wrapper.
|
||||
applySensitivePath(clone, parts);
|
||||
}
|
||||
return new Context().value(clone) as T;
|
||||
}
|
||||
export function redact<T>(value: T): T {
|
||||
return new Context().value(value) as T;
|
||||
}
|
||||
export function redact<T>(value:T):T { return new Context().value(value) as T; }
|
||||
|
||||
@@ -1,73 +1,144 @@
|
||||
import { parseResponseSensitivePath } from './redactor.ts';
|
||||
|
||||
export type Shape={type:string,keys?:Record<string,Shape>,items?:Shape[]};
|
||||
export type Shape = { type: string; keys?: Record<string, Shape>; items?: Shape[] };
|
||||
|
||||
export function payloadShape(value:any):Shape {
|
||||
if(value===null)return {type:'null'};
|
||||
if(Array.isArray(value))return {type:'array',items:value.map(payloadShape)};
|
||||
if(typeof value==='object')return {type:'object',keys:Object.fromEntries(Object.keys(value).sort().map(k=>[k,payloadShape(value[k])]))};
|
||||
return {type:typeof value};
|
||||
export function payloadShape(value: any): Shape {
|
||||
if (value === null) return { type: 'null' };
|
||||
if (Array.isArray(value)) return { type: 'array', items: value.map(payloadShape) };
|
||||
if (typeof value === 'object')
|
||||
return {
|
||||
type: 'object',
|
||||
keys: Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((k) => [k, payloadShape(value[k])]),
|
||||
),
|
||||
};
|
||||
return { type: typeof value };
|
||||
}
|
||||
|
||||
export function validateShapeNode(node:any):void {
|
||||
if(!node||typeof node!=='object'||Array.isArray(node))throw new Error('invalid shape node');
|
||||
const allowed=node.type==='object'?['keys','type']:node.type==='array'?['items','type']:['type'];
|
||||
if(!['object','array','string','number','boolean','null'].includes(node.type)||JSON.stringify(Object.keys(node).sort())!==JSON.stringify(allowed))throw new Error('invalid shape node');
|
||||
if(node.type==='object'){
|
||||
if(!node.keys||typeof node.keys!=='object'||Array.isArray(node.keys))throw new Error('invalid shape keys');
|
||||
for(const child of Object.values(node.keys))validateShapeNode(child);
|
||||
} else if(node.type==='array'){
|
||||
if(!Array.isArray(node.items))throw new Error('invalid shape items');
|
||||
for(const child of node.items)validateShapeNode(child);
|
||||
}
|
||||
export function validateShapeNode(node: any): void {
|
||||
if (!node || typeof node !== 'object' || Array.isArray(node))
|
||||
throw new Error('invalid shape node');
|
||||
const allowed =
|
||||
node.type === 'object'
|
||||
? ['keys', 'type']
|
||||
: node.type === 'array'
|
||||
? ['items', 'type']
|
||||
: ['type'];
|
||||
if (
|
||||
!['object', 'array', 'string', 'number', 'boolean', 'null'].includes(node.type) ||
|
||||
JSON.stringify(Object.keys(node).sort()) !== JSON.stringify(allowed)
|
||||
)
|
||||
throw new Error('invalid shape node');
|
||||
if (node.type === 'object') {
|
||||
if (!node.keys || typeof node.keys !== 'object' || Array.isArray(node.keys))
|
||||
throw new Error('invalid shape keys');
|
||||
for (const child of Object.values(node.keys)) validateShapeNode(child);
|
||||
} else if (node.type === 'array') {
|
||||
if (!Array.isArray(node.items)) throw new Error('invalid shape items');
|
||||
for (const child of node.items) validateShapeNode(child);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalPrimitive(value:any){
|
||||
return value===null||value==='[REDACTED]'||(typeof value==='number'&&value===0)||(typeof value==='boolean'&&value===false);
|
||||
function canonicalPrimitive(value: any) {
|
||||
return (
|
||||
value === null ||
|
||||
value === '[REDACTED]' ||
|
||||
(typeof value === 'number' && value === 0) ||
|
||||
(typeof value === 'boolean' && value === false)
|
||||
);
|
||||
}
|
||||
function assertCanonicalTree(value:any):void {
|
||||
if(Array.isArray(value)){for(const item of value)assertCanonicalTree(item);return;}
|
||||
if(value&&typeof value==='object'){for(const item of Object.values(value))assertCanonicalTree(item);return;}
|
||||
if(!canonicalPrimitive(value))throw new Error('non-canonical sensitive leaf');
|
||||
function assertCanonicalTree(value: any): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) assertCanonicalTree(item);
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const item of Object.values(value)) assertCanonicalTree(item);
|
||||
return;
|
||||
}
|
||||
if (!canonicalPrimitive(value)) throw new Error('non-canonical sensitive leaf');
|
||||
}
|
||||
function matchedNodes(node:any,parts:string[],i=0):any[]{
|
||||
if(i===parts.length)return [node];
|
||||
if(parts[i]==='*')return Array.isArray(node)?node.flatMap(x=>matchedNodes(x,parts,i+1)):[];
|
||||
return node&&typeof node==='object'&&parts[i] in node?matchedNodes(node[parts[i]],parts,i+1):[];
|
||||
function matchedNodes(node: any, parts: string[], i = 0): any[] {
|
||||
if (i === parts.length) return [node];
|
||||
if (parts[i] === '*')
|
||||
return Array.isArray(node) ? node.flatMap((x) => matchedNodes(x, parts, i + 1)) : [];
|
||||
return node && typeof node === 'object' && parts[i] in node
|
||||
? matchedNodes(node[parts[i]], parts, i + 1)
|
||||
: [];
|
||||
}
|
||||
// Deliberately excludes ordinary `message`; only credential-like generic keys belong here.
|
||||
const GENERIC_SENSITIVE_KEY=/(?:password|passwd|credential|api[_-]?key|access[_-]?(?:key|token|secret|id)|device[_-]?key|private[_-]?key|(?:^|[_-])pin(?:$|[_-])|puk|token|secret|cookie|authorization|confirmation[_-]?code)/i;
|
||||
export function validateSensitivePayload(payload:any,operation:any):void {
|
||||
for(const field of operation?.sensitiveFields||[]){
|
||||
if(field?.direction!=='response')continue;
|
||||
for(const node of matchedNodes(payload,parseResponseSensitivePath(field.path)))assertCanonicalTree(node);
|
||||
}
|
||||
function walk(node:any):void{
|
||||
if(Array.isArray(node)){for(const x of node)walk(x);return;}
|
||||
if(node&&typeof node==='object')for(const [key,value] of Object.entries(node)){if(GENERIC_SENSITIVE_KEY.test(key))assertCanonicalTree(value);else walk(value);}
|
||||
}
|
||||
walk(payload);
|
||||
const GENERIC_SENSITIVE_KEY =
|
||||
/(?:password|passwd|credential|api[_-]?key|access[_-]?(?:key|token|secret|id)|device[_-]?key|private[_-]?key|(?:^|[_-])pin(?:$|[_-])|puk|token|secret|cookie|authorization|confirmation[_-]?code)/i;
|
||||
export function validateSensitivePayload(payload: any, operation: any): void {
|
||||
for (const field of operation?.sensitiveFields || []) {
|
||||
if (field?.direction !== 'response') continue;
|
||||
for (const node of matchedNodes(payload, parseResponseSensitivePath(field.path)))
|
||||
assertCanonicalTree(node);
|
||||
}
|
||||
function walk(node: any): void {
|
||||
if (Array.isArray(node)) {
|
||||
for (const x of node) walk(x);
|
||||
return;
|
||||
}
|
||||
if (node && typeof node === 'object')
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (GENERIC_SENSITIVE_KEY.test(key)) assertCanonicalTree(value);
|
||||
else walk(value);
|
||||
}
|
||||
}
|
||||
walk(payload);
|
||||
}
|
||||
|
||||
const isJson=(s:any)=>typeof s==='string'&&(s==='application/json'||s.endsWith('+json'));
|
||||
const exactPayload=(actual:any,expected:any)=>JSON.stringify(actual)===JSON.stringify(expected);
|
||||
export function validateStatusContract(f:any):void {
|
||||
const status=f.httpStatus,category=f.statusCategory;
|
||||
if(category==='success'){
|
||||
if(!(Number.isInteger(status)&&status>=200&&status<300&&isJson(f.contentType)))throw new Error('inconsistent success status');
|
||||
}else if(category==='auth-required'){
|
||||
if(![401,403].includes(status))throw new Error('inconsistent auth status');
|
||||
}else if(category==='unsupported'){
|
||||
if(![404,405,501].includes(status))throw new Error('inconsistent unsupported status');
|
||||
}else if(category==='non-json'){
|
||||
const valid=Number.isInteger(status)&&status>=200&&status<300&&((!isJson(f.contentType)&&exactPayload(f.payload,{text:'[REDACTED]'}))||(isJson(f.contentType)&&exactPayload(f.payload,{error:'invalid-json'})));
|
||||
if(!valid)throw new Error('inconsistent non-json status');
|
||||
}else if(category==='http-error'){
|
||||
// Redirects are observed with redirect:'manual' and are non-success HTTP outcomes.
|
||||
if(!(Number.isInteger(status)&&status>=300&&status<600&&![401,403,404,405,501].includes(status)))throw new Error('inconsistent http error status');
|
||||
}else if(category==='timeout'){
|
||||
if(status!==null||f.contentType!==null||f.latencyBucket!=='timeout'||!exactPayload(f.payload,{error:'timeout'}))throw new Error('inconsistent timeout status');
|
||||
}else if(category==='network-error'){
|
||||
if(status!==null||f.contentType!==null||f.latencyBucket==='timeout'||!exactPayload(f.payload,{error:'network-error'}))throw new Error('inconsistent network status');
|
||||
}else throw new Error('unknown status category');
|
||||
}
|
||||
const isJson = (s: any) =>
|
||||
typeof s === 'string' && (s === 'application/json' || s.endsWith('+json'));
|
||||
const exactPayload = (actual: any, expected: any) =>
|
||||
JSON.stringify(actual) === JSON.stringify(expected);
|
||||
export function validateStatusContract(f: any): void {
|
||||
const status = f.httpStatus,
|
||||
category = f.statusCategory;
|
||||
if (category === 'success') {
|
||||
if (!(Number.isInteger(status) && status >= 200 && status < 300 && isJson(f.contentType)))
|
||||
throw new Error('inconsistent success status');
|
||||
} else if (category === 'auth-required') {
|
||||
if (![401, 403].includes(status)) throw new Error('inconsistent auth status');
|
||||
} else if (category === 'unsupported') {
|
||||
if (![404, 405, 501].includes(status)) throw new Error('inconsistent unsupported status');
|
||||
} else if (category === 'non-json') {
|
||||
const valid =
|
||||
Number.isInteger(status) &&
|
||||
status >= 200 &&
|
||||
status < 300 &&
|
||||
((!isJson(f.contentType) && exactPayload(f.payload, { text: '[REDACTED]' })) ||
|
||||
(isJson(f.contentType) && exactPayload(f.payload, { error: 'invalid-json' })));
|
||||
if (!valid) throw new Error('inconsistent non-json status');
|
||||
} else if (category === 'http-error') {
|
||||
// Redirects are observed with redirect:'manual' and are non-success HTTP outcomes.
|
||||
if (
|
||||
!(
|
||||
Number.isInteger(status) &&
|
||||
status >= 300 &&
|
||||
status < 600 &&
|
||||
![401, 403, 404, 405, 501].includes(status)
|
||||
)
|
||||
)
|
||||
throw new Error('inconsistent http error status');
|
||||
} else if (category === 'timeout') {
|
||||
if (
|
||||
status !== null ||
|
||||
f.contentType !== null ||
|
||||
f.latencyBucket !== 'timeout' ||
|
||||
!exactPayload(f.payload, { error: 'timeout' })
|
||||
)
|
||||
throw new Error('inconsistent timeout status');
|
||||
} else if (category === 'network-error') {
|
||||
if (
|
||||
status !== null ||
|
||||
f.contentType !== null ||
|
||||
f.latencyBucket === 'timeout' ||
|
||||
!exactPayload(f.payload, { error: 'network-error' })
|
||||
)
|
||||
throw new Error('inconsistent network status');
|
||||
} else throw new Error('unknown status category');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user