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:
chick
2026-09-05 18:52:54 +08:00
parent 03fa2e6c7b
commit f11877f13e
19 changed files with 15535 additions and 11768 deletions
@@ -6,35 +6,123 @@ import { upstream58e2204Operations } from '../../operation-registry/src/upstream
import { normalizeConfig } from '../../../server/config/schema.js';
import { collectOne, selectReadonlyOperations, validateInstanceOrigin } from './collector.ts';
const here=path.dirname(fileURLToPath(import.meta.url));const pkg=path.resolve(here,'..');const repo=path.resolve(pkg,'../..');
export function loadInstances(config:any){
if(!config||!Array.isArray(config.instances)||config.instances.length!==2)throw new Error('config must contain exactly two instances');
for(const raw of config.instances){
if(!raw||typeof raw!=='object'||typeof raw.url!=='string')throw new Error('invalid collector instance configuration');
validateInstanceOrigin(raw.url);
if(raw.password||raw.auth?.password||!['none',undefined].includes(raw.auth?.mode))throw new Error('collector instances must be password-free');
}
const normalized=normalizeConfig(config,{HOST:'127.0.0.1',PORT:'8788'});
if(normalized.instances.length!==2)throw new Error('config must contain exactly two instances');
return normalized.instances.map((raw:any,i:number)=>{
if(raw.auth.mode!=='none'||raw.auth.password)throw new Error(`instance-${i+1} must be password-free`);
return {origin:validateInstanceOrigin(raw.url),alias:`instance-${i+1}`};
});
const here = path.dirname(fileURLToPath(import.meta.url));
const pkg = path.resolve(here, '..');
const repo = path.resolve(pkg, '../..');
export function loadInstances(config: any) {
if (!config || !Array.isArray(config.instances) || config.instances.length !== 2)
throw new Error('config must contain exactly two instances');
for (const raw of config.instances) {
if (!raw || typeof raw !== 'object' || typeof raw.url !== 'string')
throw new Error('invalid collector instance configuration');
validateInstanceOrigin(raw.url);
if (raw.password || raw.auth?.password || !['none', undefined].includes(raw.auth?.mode))
throw new Error('collector instances must be password-free');
}
const normalized = normalizeConfig(config, { HOST: '127.0.0.1', PORT: '8788' });
if (normalized.instances.length !== 2)
throw new Error('config must contain exactly two instances');
return normalized.instances.map((raw: any, i: number) => {
if (raw.auth.mode !== 'none' || raw.auth.password)
throw new Error(`instance-${i + 1} must be password-free`);
return { origin: validateInstanceOrigin(raw.url), alias: `instance-${i + 1}` };
});
}
async function mapLimit<T,R>(xs:T[],limit:number,fn:(x:T)=>Promise<R>){const out:R[]=[];let n=0;async function worker(){while(n<xs.length){const i=n++;out[i]=await fn(xs[i]);}}await Promise.all(Array.from({length:Math.min(limit,xs.length)},worker));return out;}
async function allJson(dir:string):Promise<string[]>{const out:string[]=[];for(const e of await readdir(dir,{withFileTypes:true})){const p=path.join(dir,e.name);if(e.isDirectory())out.push(...await allJson(p));else if(e.name.endsWith('.json'))out.push(p);}return out;}
function safeName(s:string){return s.replace(/[^a-zA-Z0-9_-]/g,'-');}
export async function main(argv=process.argv.slice(2)){
if(argv.length!==1||!['--dry-run','--capture'].includes(argv[0]))throw new Error('usage: collect-readonly-fixtures.ts --dry-run|--capture');
const config=JSON.parse(await readFile(path.join(repo,'config.json'),'utf8'));const instances=loadInstances(config);const {selected,denied}=selectReadonlyOperations(upstream58e2204Operations);
if(argv[0]==='--dry-run'){for(const op of selected)console.log(`registry ${op.operationId}`);console.log(`selected=${selected.length} denied=${denied.length}`);return;}
const shapePath=path.join(pkg,'src/response-shapes-58e2204.json');
// Separately reviewed: capture attests this baseline but never creates it.
const shapeBytes=await readFile(shapePath);
const root=path.join(pkg,'src/simadmin');await rm(root,{recursive:true,force:true});await rm(path.join(pkg,'src/manifest.json'),{force:true});await mkdir(root,{recursive:true});
const jobs=instances.flatMap(instance=>selected.map(op=>({instance,op})));const fixtures=await mapLimit(jobs,2,async({instance,op})=>{const f=await collectOne(instance,op);console.log(`${instance.alias} ${op.operationId} ${f.statusCategory}`);return {f,domain:op.upstreamDomain};});
for(const {f,domain} of fixtures){const dir=path.join(root,safeName(domain));await mkdir(dir,{recursive:true});await writeFile(path.join(dir,`${f.sourceInstanceAlias}--${f.operationId}.json`),JSON.stringify(f,null,2)+'\n',{flag:'wx'});}
const files=[];const coverage:Record<string,number>={};for(const full of await allJson(root)){const text=await readFile(full);const rel=path.relative(pkg,full);const domain=path.basename(path.dirname(full));coverage[domain]=(coverage[domain]||0)+1;files.push({path:rel,size:(await stat(full)).size,sha256:createHash('sha256').update(text).digest('hex')});}
files.sort((a,b)=>a.path.localeCompare(b.path));const shapeBaseline={path:'src/response-shapes-58e2204.json',size:shapeBytes.length,sha256:createHash('sha256').update(shapeBytes).digest('hex')};await writeFile(path.join(pkg,'src/manifest.json'),JSON.stringify({schemaVersion:1,upstreamBaseline:'58e2204',realFixtureCount:files.length,syntheticFixtureCount:0,domainCoverage:Object.fromEntries(Object.entries(coverage).sort()),shapeBaseline,files},null,2)+'\n');
async function mapLimit<T, R>(xs: T[], limit: number, fn: (x: T) => Promise<R>) {
const out: R[] = [];
let n = 0;
async function worker() {
while (n < xs.length) {
const i = n++;
out[i] = await fn(xs[i]);
}
}
await Promise.all(Array.from({ length: Math.min(limit, xs.length) }, worker));
return out;
}
if(process.argv[1]===fileURLToPath(import.meta.url))main().catch(()=>{console.error('collector failed');process.exitCode=1;});
async function allJson(dir: string): Promise<string[]> {
const out: string[] = [];
for (const e of await readdir(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await allJson(p)));
else if (e.name.endsWith('.json')) out.push(p);
}
return out;
}
function safeName(s: string) {
return s.replace(/[^a-zA-Z0-9_-]/g, '-');
}
export async function main(argv = process.argv.slice(2)) {
if (argv.length !== 1 || !['--dry-run', '--capture'].includes(argv[0]))
throw new Error('usage: collect-readonly-fixtures.ts --dry-run|--capture');
const config = JSON.parse(await readFile(path.join(repo, 'config.json'), 'utf8'));
const instances = loadInstances(config);
const { selected, denied } = selectReadonlyOperations(upstream58e2204Operations);
if (argv[0] === '--dry-run') {
for (const op of selected) console.log(`registry ${op.operationId}`);
console.log(`selected=${selected.length} denied=${denied.length}`);
return;
}
const shapePath = path.join(pkg, 'src/response-shapes-58e2204.json');
// Separately reviewed: capture attests this baseline but never creates it.
const shapeBytes = await readFile(shapePath);
const root = path.join(pkg, 'src/simadmin');
await rm(root, { recursive: true, force: true });
await rm(path.join(pkg, 'src/manifest.json'), { force: true });
await mkdir(root, { recursive: true });
const jobs = instances.flatMap((instance) => selected.map((op) => ({ instance, op })));
const fixtures = await mapLimit(jobs, 2, async ({ instance, op }) => {
const f = await collectOne(instance, op);
console.log(`${instance.alias} ${op.operationId} ${f.statusCategory}`);
return { f, domain: op.upstreamDomain };
});
for (const { f, domain } of fixtures) {
const dir = path.join(root, safeName(domain));
await mkdir(dir, { recursive: true });
await writeFile(
path.join(dir, `${f.sourceInstanceAlias}--${f.operationId}.json`),
JSON.stringify(f, null, 2) + '\n',
{ flag: 'wx' },
);
}
const files = [];
const coverage: Record<string, number> = {};
for (const full of await allJson(root)) {
const text = await readFile(full);
const rel = path.relative(pkg, full);
const domain = path.basename(path.dirname(full));
coverage[domain] = (coverage[domain] || 0) + 1;
files.push({
path: rel,
size: (await stat(full)).size,
sha256: createHash('sha256').update(text).digest('hex'),
});
}
files.sort((a, b) => a.path.localeCompare(b.path));
const shapeBaseline = {
path: 'src/response-shapes-58e2204.json',
size: shapeBytes.length,
sha256: createHash('sha256').update(shapeBytes).digest('hex'),
};
await writeFile(
path.join(pkg, 'src/manifest.json'),
JSON.stringify(
{
schemaVersion: 1,
upstreamBaseline: '58e2204',
realFixtureCount: files.length,
syntheticFixtureCount: 0,
domainCoverage: Object.fromEntries(Object.entries(coverage).sort()),
shapeBaseline,
files,
},
null,
2,
) + '\n',
);
}
if (process.argv[1] === fileURLToPath(import.meta.url))
main().catch(() => {
console.error('collector failed');
process.exitCode = 1;
});
+178 -52
View File
@@ -1,64 +1,190 @@
import { redactOperationPayload } from '../src/redactor.ts';
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
export const UPSTREAM_BASELINE='58e2204';
export const DENY_REASONS:Record<string,string>={
'/api/network/operators/scan':'active radio/network scan',
'/api/connectivity':'handler performs active connectivity ping',
'/api/sms/list':'message list can expose body; intentionally not collected',
'/api/sms/conversation':'requires correspondent query and exposes message bodies',
'/api/call/history':'query limit is not encoded in Registry path contract',
'/api/notifications/logs':'query limit is not encoded in Registry path contract',
'/api/notifications/queue':'query limit is not encoded in Registry path contract',
'/api/automation/logs':'query limit is not encoded in Registry path contract',
'/api/device-network/ddns/logs':'no safe limit contract',
'/api/esim/euicc':'query-bearing endpoint omitted',
'/api/esim/profiles':'query-bearing endpoint omitted',
export const UPSTREAM_BASELINE = '58e2204';
export const DENY_REASONS: Record<string, string> = {
'/api/network/operators/scan': 'active radio/network scan',
'/api/connectivity': 'handler performs active connectivity ping',
'/api/sms/list': 'message list can expose body; intentionally not collected',
'/api/sms/conversation': 'requires correspondent query and exposes message bodies',
'/api/call/history': 'query limit is not encoded in Registry path contract',
'/api/notifications/logs': 'query limit is not encoded in Registry path contract',
'/api/notifications/queue': 'query limit is not encoded in Registry path contract',
'/api/automation/logs': 'query limit is not encoded in Registry path contract',
'/api/device-network/ddns/logs': 'no safe limit contract',
'/api/esim/euicc': 'query-bearing endpoint omitted',
'/api/esim/profiles': 'query-bearing endpoint omitted',
};
const registryById=new Map(upstream58e2204Operations.map((op:any)=>[op.operationId,op]));
const registryById = new Map(upstream58e2204Operations.map((op: any) => [op.operationId, op]));
// Capture the complete imported JSON contract before any caller can mutate it.
// Exact identity alone does not protect mutable Registry objects.
const registrySignatures=new Map(upstream58e2204Operations.map((op:any)=>[op.operationId,JSON.stringify(op)]));
function isPrivateLan(host:string){const p=host.split('.').map(Number);return p.length===4&&p.every(n=>Number.isInteger(n)&&n>=0&&n<=255)&&(p[0]===10||(p[0]===172&&p[1]>=16&&p[1]<=31)||(p[0]===192&&p[1]===168));}
export function validateInstanceOrigin(origin:string){
let u:URL;try{u=new URL(origin);}catch{throw new Error('invalid collector instance origin');}
if(!['http:','https:'].includes(u.protocol)||!isPrivateLan(u.hostname)||u.pathname!=='/'||u.search||u.hash||u.username||u.password)throw new Error('invalid collector instance origin');
return u.origin;
const registrySignatures = new Map(
upstream58e2204Operations.map((op: any) => [op.operationId, JSON.stringify(op)]),
);
function isPrivateLan(host: string) {
const p = host.split('.').map(Number);
return (
p.length === 4 &&
p.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) &&
(p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168))
);
}
function validatePath(path:string){
if(typeof path!=='string'||!path.startsWith('/api/')||path.startsWith('//')||path.includes('\\')||/[?#%]/.test(path)||path.includes('//')) throw new Error('unsafe operation path');
let decoded=path; for(let i=0;i<2;i++){const next=decodeURIComponent(decoded);if(next!==decoded)throw new Error('encoded operation path');decoded=next;}
if(path.split('/').some(x=>x==='.'||x==='..'))throw new Error('dot segment operation path');
return path;
export function validateInstanceOrigin(origin: string) {
let u: URL;
try {
u = new URL(origin);
} catch {
throw new Error('invalid collector instance origin');
}
if (
!['http:', 'https:'].includes(u.protocol) ||
!isPrivateLan(u.hostname) ||
u.pathname !== '/' ||
u.search ||
u.hash ||
u.username ||
u.password
)
throw new Error('invalid collector instance origin');
return u.origin;
}
export function validateReadonlyOperation(op:any){
const exact=registryById.get(op?.operationId);
if(!exact||exact!==op||registrySignatures.get(op?.operationId)!==JSON.stringify(op))throw new Error('operation violates Registry integrity');
validatePath(op.pathTemplate);
if(op.method!=='GET'||op.riskLevel!=='R0'||/[{}]/.test(op.pathTemplate))throw new Error('operation is not readonly R0');
if(DENY_REASONS[op.pathTemplate])throw new Error('operation is explicitly denied');
return op;
function validatePath(path: string) {
if (
typeof path !== 'string' ||
!path.startsWith('/api/') ||
path.startsWith('//') ||
path.includes('\\') ||
/[?#%]/.test(path) ||
path.includes('//')
)
throw new Error('unsafe operation path');
let decoded = path;
for (let i = 0; i < 2; i++) {
const next = decodeURIComponent(decoded);
if (next !== decoded) throw new Error('encoded operation path');
decoded = next;
}
if (path.split('/').some((x) => x === '.' || x === '..'))
throw new Error('dot segment operation path');
return path;
}
export function selectReadonlyOperations(registry:any[]){
const candidates=registry.filter(o=>o.method==='GET'&&o.riskLevel==='R0'&&!/[{}]/.test(o.pathTemplate));
for(const op of candidates) validatePath(op.pathTemplate);
return {selected:candidates.filter(o=>!DENY_REASONS[o.pathTemplate]).map(validateReadonlyOperation),denied:candidates.filter(o=>DENY_REASONS[o.pathTemplate]).map(o=>({...o,denyReason:DENY_REASONS[o.pathTemplate]}))};
export function validateReadonlyOperation(op: any) {
const exact = registryById.get(op?.operationId);
if (!exact || exact !== op || registrySignatures.get(op?.operationId) !== JSON.stringify(op))
throw new Error('operation violates Registry integrity');
validatePath(op.pathTemplate);
if (op.method !== 'GET' || op.riskLevel !== 'R0' || /[{}]/.test(op.pathTemplate))
throw new Error('operation is not readonly R0');
if (DENY_REASONS[op.pathTemplate]) throw new Error('operation is explicitly denied');
return op;
}
function latency(ms:number,timedOut=false){if(timedOut)return 'timeout';if(ms<250)return '<250ms';if(ms<1000)return '250ms-1s';if(ms<3000)return '1-3s';return '>3s';}
function statusCategory(status:number,isJson:boolean){if(status===401||status===403)return 'auth-required';if(status===404||status===405||status===501)return 'unsupported';if(status>=200&&status<300)return isJson?'success':'non-json';return 'http-error';}
export async function collectOne(instance:{origin:string,alias:string},op:any,transport:any=fetch){
validateReadonlyOperation(op);
if(!['instance-1','instance-2'].includes(instance?.alias))throw new Error('invalid instance alias');
const origin=validateInstanceOrigin(instance?.origin);
const requestUrl=new URL(op.pathTemplate,origin);
if(requestUrl.origin!==origin||requestUrl.pathname!==op.pathTemplate||requestUrl.search||requestUrl.hash)throw new Error('unsafe collector request URL');
const started=Date.now();const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),6000);
const base={schemaVersion:1,upstreamBaseline:UPSTREAM_BASELINE,capturedAt:new Date().toISOString().slice(0,10),sourceInstanceAlias:instance.alias,operationId:op.operationId,method:'GET',pathTemplate:op.pathTemplate,redacted:true};
try{
export function selectReadonlyOperations(registry: any[]) {
const candidates = registry.filter(
(o) => o.method === 'GET' && o.riskLevel === 'R0' && !/[{}]/.test(o.pathTemplate),
);
for (const op of candidates) validatePath(op.pathTemplate);
return {
selected: candidates
.filter((o) => !DENY_REASONS[o.pathTemplate])
.map(validateReadonlyOperation),
denied: candidates
.filter((o) => DENY_REASONS[o.pathTemplate])
.map((o) => ({ ...o, denyReason: DENY_REASONS[o.pathTemplate] })),
};
}
function latency(ms: number, timedOut = false) {
if (timedOut) return 'timeout';
if (ms < 250) return '<250ms';
if (ms < 1000) return '250ms-1s';
if (ms < 3000) return '1-3s';
return '>3s';
}
function statusCategory(status: number, isJson: boolean) {
if (status === 401 || status === 403) return 'auth-required';
if (status === 404 || status === 405 || status === 501) return 'unsupported';
if (status >= 200 && status < 300) return isJson ? 'success' : 'non-json';
return 'http-error';
}
export async function collectOne(
instance: { origin: string; alias: string },
op: any,
transport: any = fetch,
) {
validateReadonlyOperation(op);
const response=await transport(requestUrl,{method:'GET',headers:{accept:'application/json'},body:undefined,credentials:'omit',redirect:'manual',signal:controller.signal});
const contentType=(response.headers.get('content-type')||'').split(';')[0].trim().toLowerCase()||null;const claimsJson=contentType==='application/json'||contentType?.endsWith('+json');
let parsedJson=false;let payload:any;if(claimsJson){try{payload=await response.json();parsedJson=true;}catch{payload={error:'invalid-json'};}}else{await response.text();payload={text:'[REDACTED]'};}
return {...base,statusCategory:statusCategory(response.status,parsedJson),httpStatus:response.status,contentType,latencyBucket:latency(Date.now()-started),payload:redactOperationPayload(payload,op)};
}catch(error:any){if(String(error?.message||'').includes('Registry')||String(error?.message||'').includes('operation'))throw error;const timeout=error?.name==='AbortError';return {...base,statusCategory:timeout?'timeout':'network-error',httpStatus:null,contentType:null,latencyBucket:latency(Date.now()-started,timeout),payload:{error:timeout?'timeout':'network-error'}};}finally{clearTimeout(timer);}
if (!['instance-1', 'instance-2'].includes(instance?.alias))
throw new Error('invalid instance alias');
const origin = validateInstanceOrigin(instance?.origin);
const requestUrl = new URL(op.pathTemplate, origin);
if (
requestUrl.origin !== origin ||
requestUrl.pathname !== op.pathTemplate ||
requestUrl.search ||
requestUrl.hash
)
throw new Error('unsafe collector request URL');
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 6000);
const base = {
schemaVersion: 1,
upstreamBaseline: UPSTREAM_BASELINE,
capturedAt: new Date().toISOString().slice(0, 10),
sourceInstanceAlias: instance.alias,
operationId: op.operationId,
method: 'GET',
pathTemplate: op.pathTemplate,
redacted: true,
};
try {
validateReadonlyOperation(op);
const response = await transport(requestUrl, {
method: 'GET',
headers: { accept: 'application/json' },
body: undefined,
credentials: 'omit',
redirect: 'manual',
signal: controller.signal,
});
const contentType =
(response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase() || null;
const claimsJson = contentType === 'application/json' || contentType?.endsWith('+json');
let parsedJson = false;
let payload: any;
if (claimsJson) {
try {
payload = await response.json();
parsedJson = true;
} catch {
payload = { error: 'invalid-json' };
}
} else {
await response.text();
payload = { text: '[REDACTED]' };
}
return {
...base,
statusCategory: statusCategory(response.status, parsedJson),
httpStatus: response.status,
contentType,
latencyBucket: latency(Date.now() - started),
payload: redactOperationPayload(payload, op),
};
} catch (error: any) {
if (
String(error?.message || '').includes('Registry') ||
String(error?.message || '').includes('operation')
)
throw error;
const timeout = error?.name === 'AbortError';
return {
...base,
statusCategory: timeout ? 'timeout' : 'network-error',
httpStatus: null,
contentType: null,
latencyBucket: latency(Date.now() - started, timeout),
payload: { error: timeout ? 'timeout' : 'network-error' },
};
} finally {
clearTimeout(timer);
}
}