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;
});