import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; const require=createRequire(new URL('../web/package.json',import.meta.url)); const yaml=require('js-yaml'); const root=path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/,'$1')),'..'); const source=fs.readdirSync(path.join(root,'internal/onboarding')).filter(x=>x.endsWith('.go')&&!x.endsWith('_test.go')).map(x=>fs.readFileSync(path.join(root,'internal/onboarding',x),'utf8')).join('\n'); const descriptions={device_id:'生产编号,KP01-加12位大写MAC;不是平台UUID',platform_device_id:'平台公开UUID,沿用现有设备管理接口',factory_mac:'12位大写十六进制MAC;相同MAC不能创建第二台物理设备',hardware_model:'生产登记型号,当前为KP01',hardware_revision:'硬件修订号,注册必须与库存一致',batch_no:'生产批次,方便追溯',key_version:'设备认证密钥版本',expected_key_version:'交付放行目标版本;轮换时为当前生效版本',enrollment_job_id:'生产登记或密钥轮换任务UUID',receiver_public_key_pem:'工装RSA公钥,PKIX PEM,2048至4096位,指数65537',evidence_ref:'烧录读回或人工对账的证据引用,不放密钥',boot_id:'每次启动生成并保持的UUID v4',client_nonce:'设备生成的16字节随机数,Base64URL无填充',challenge_id:'C01签名校时返回的一次性挑战',firmware_version:'固件版本,用于兼容诊断',sdk_version:'SDK版本,用于兼容诊断',setup_id:'云端15分钟临时设置会话UUID',local_setup_id:'设备本次设置入口UUID v4;重试保持不变',transport_profile:'固定LOCAL_WEB,表示设备提供本地网页入口',code_challenge:'SHA256(ASCII verifier)的Base64URL无填充值',token:'90秒内只能兑换一次的handoff令牌',verifier:'设备交给手机的PKCE校验值;32字节随机数的Base64URL无填充字符串',known_revision:'设备已知状态版本;允许0',claim_id:'待物理确认的绑定申请UUID',challenge:'C05给设备的短时按键挑战,手机不能获取',binding_digest:'云端生成的绑定摘要,按键回报原值',expected_binding_generation:'提交时预期绑定代数,防止跨解绑或重新绑定重放',press_event_id:'该次真实物理按键事件UUID v4,重试保持不变',binding_generation:'绑定代数;解绑、重新绑定后旧代数无效',device_revision:'云端设备状态版本号',local_generation:'本次启动的本地持久化序号,不能倒退',state_hash:'STATE1规范字符串的SHA256小写十六进制摘要',reason:'关闭、轮换或人工处理原因,见对应枚举',final_revision:'设备关闭设置入口前看到的最新状态版本',elder_id:'使用者公开UUID,复用subject_profile',household_id:'家庭公开UUID;省略时须提供新家庭名称',household_name:'新建家庭的名称',preferred_name:'使用者称呼,用于设备人设和家庭展示',phone_e164:'含国家区号的手机号,例如+8613800000000',purpose:'固定LOGIN',sms_challenge_id:'发送短信得到的挑战UUID',code:'6位短信验证码;5分钟有效,最多验证5次',binding_id:'当前已生效的绑定公开UUID',received_revision:'网页已收到的设备状态版本',activation_id:'该物理设备唯一的激活任务UUID',config_version:'配置版本,必须与当前云端版本一致',config_digest:'config_b64解码后的原始字节SHA256;不能重序列化再计算',attempt_id:'设备生成的UUID v4,表示一次引擎初始化或首通执行',stage:'SDK_REGISTER或CONVERSATION_TEST;与C08平台当前阶段一致',user_initiated:'CONVERSATION_TEST必须为true,代表用户主动发起',sdk_result:'SDK执行返回码;SUCCESS_OBSERVED时必须为0',outcome:'SUCCESS_OBSERVED、DEFINITE_FAILURE或UNKNOWN;未知结果不得盲目重试',evidence:'首通验收使用的真实会话和事件引用',conversation_id:'测试会话公开UUID',started_event_id:'已入库session_started事件ID',first_audio_event_id:'已入库first_audio_received事件ID',ended_event_id:'已入库session_ended事件ID',expected_version:'运维列表中的激活任务版本,防止覆盖并发处理',conclusion:'OBSERVED_REGISTERED、CONFIRMED_NOT_REGISTERED或DEVICE_ATTEMPT_STOPPED',device_secret:'已核实的供应商单设备密钥,仅人工对账写入,加密保存',rtc_app_id:'供应商RTC应用ID;不填则使用已保存值'}; const schemas={}; for(const [,name,body] of source.matchAll(/type (\w+) struct \{([\s\S]*?)\n\}/g)){ if(!name.endsWith('Input')&&name!=='ActivationEvidence')continue; const props={},required=[]; for(const [,type,field,omit] of body.matchAll(/\w+\s+(\w+)\s+`json:"([^",]+)(,omitempty)?"`/g)){ props[field]=type==='ActivationEvidence'?{$ref:'#/components/schemas/ActivationEvidence'}:{type:type==='bool'?'boolean':/^int/.test(type)?'integer':'string'}; if(/^int/.test(type))props[field].format='int64'; props[field].description=descriptions[field]??field; if(!omit)required.push(field); } schemas[name]={type:'object',additionalProperties:false,properties:props,...(required.length?{required}:{})}; } const set=(n,f,v)=>Object.assign(schemas[n].properties[f],v); set('TimeChallengeInput','device_id',{pattern:'^KP01-[0-9A-F]{12}$'});set('InventoryInput','factory_mac',{pattern:'^[0-9A-F]{12}$'}); set('SetupInput','transport_profile',{enum:['LOCAL_WEB']});set('CloseInput','reason',{enum:['SUCCESS','TIMEOUT','CANCELLED','NETWORK_SWITCH']});set('SMSSendInput','purpose',{enum:['LOGIN']});set('SMSSendInput','phone_e164',{pattern:'^\\+[1-9][0-9]{7,14}$'});set('SMSVerifyInput','code',{pattern:'^[0-9]{6}$'}); set('ResultInput','stage',{enum:['SDK_REGISTER','CONVERSATION_TEST']});set('ResultInput','outcome',{enum:['SUCCESS_OBSERVED','DEFINITE_FAILURE','UNKNOWN']});set('ReconcileInput','conclusion',{enum:['OBSERVED_REGISTERED','CONFIRMED_NOT_REGISTERED','DEVICE_ATTEMPT_STOPPED']}); schemas.PollInput.required=['setup_id','boot_id','known_revision']; // Closed sessions remain usable for activation polling until boot/generation changes. schemas.Empty={type:'object',additionalProperties:false}; const str=(description)=>({type:'string',description}),int=(description)=>({type:'integer',format:'int64',description}),bool={type:'boolean'},ref=n=>({$ref:'#/components/schemas/'+n}); const object=properties=>({type:'object',properties}); schemas.State=object({device_id:str(descriptions.device_id),device_revision:int(descriptions.device_revision),binding_generation:int(descriptions.binding_generation),binding_state:{type:'string',enum:['UNBOUND','CLAIM_PENDING','BOUND','TRANSFER_PENDING']},activation_state:{type:'string',enum:['NOT_STARTED','RESERVED','CONTACT_POSSIBLE','REGISTERED_OBSERVED','READY','RECONCILE_REQUIRED','BLOCKED']},state_hash:str(descriptions.state_hash)}); schemas.Command=object({command_id:str('命令标识'),type:{type:'string',enum:['CONFIRM_CLAIM','APPLY_ACTIVATION']},expires_at_ms:int('命令到期UTC毫秒'),payload:{oneOf:[object({claim_id:str('申请编号'),challenge:str(descriptions.challenge),binding_digest:str(descriptions.binding_digest),expected_binding_generation:int(descriptions.expected_binding_generation),masked_account:str('脱敏的申请账号')}),object({config_b64:str('原始配置JSON的Base64URL无填充编码'),config_mac:str('HMAC-SHA256(raw设备密钥, CFG1换行config_b64)')})]}}); schemas.ActivationConfig=object({contract_version:str('ONBOARDING-1.1'),device_id:str(descriptions.device_id),activation_id:str('任务编号'),config_version:int('版本'),binding_generation:int('当前绑定代数'),expires_at_ms:int('24小时配置有效期'),iot:object({instance_id:str('供应商实例'),product_key:str('产品编号'),device_name:str('冻结的物理设备名'),product_secret:str('供应商产品注册密钥,只在设备认证后的HTTPS激活配置中下发')}),bot_id:str('智能体ID')}); schemas.WebStatus=object({setup_id:str('临时会话UUID'),state:ref('State'),binding_id:str(descriptions.binding_id),claim_id:str(descriptions.claim_id),device_saved:bool,authenticated:bool,closed:bool,sms_enabled:bool,expires_at_ms:int('设置入口到期时间')}); schemas.Browser=object({csrf_token:str('保存在当前标签页sessionStorage;写请求放X-CSRF-Token'),data:ref('WebStatus')}); schemas.Failure=object({ok:{type:'boolean',const:false},code:str('稳定业务错误码'),message:str('用户可读说明'),request_id:str('请求追踪UUID')}); const data={TimeChallenge:object({payload_b64:str('TIME1校时JSON的Base64URL无填充'),mac_hex:str('HMAC-SHA256(raw32设备密钥, TIME1换行payload_b64)')}),Register:object({state:ref('State'),contract_version:str('ONBOARDING-1.1')}),Setup:object({setup_id:str('设置UUID'),expires_at_ms:int('15分钟到期'),poll_after_ms:int('首次2000'),state:ref('State')}),Handoff:object({handoff_token:str('90秒一次性令牌'),expires_at_ms:int('到期毫秒'),entry_url:str('固定HTTPS入口;设备将token和verifier放fragment')}),Poll:object({state:ref('State'),command:{oneOf:[ref('Command'),{type:'null'}]},poll_after_ms:int('下一次轮询间隔')}),Confirmation:object({claim_id:str('申请UUID'),state:ref('State'),already_applied:bool}),StateAck:object({accepted_revision:int('已接受版本'),setup_can_close:bool}),Attempt:object({allowed:bool,attempt_id:str('执行UUID'),permit_expires_at_ms:int('30秒有效期'),state:ref('State')}),Result:object({activation_id:str('任务UUID'),state:ref('State'),reconcile_required:bool}),ActivationAck:object({accepted:bool,state:ref('State')}),Close:object({close_allowed:bool,state:ref('State')}),Claim:object({claim_id:str('申请UUID'),expires_at_ms:int('120秒有效期'),state:ref('State')}),StateOnly:object({state:ref('State')}),Ack:object({accepted:bool}),SMS:object({sms_challenge_id:str('验证码挑战UUID'),expires_at_ms:int('5分钟'),resend_after_ms:int('60000')}),Profile:object({elder_id:str('使用者公开UUID'),household_id:str('家庭公开UUID')}),Profiles:object({items:{type:'array',items:object({household_id:str('家庭公开UUID'),household_name:str('家庭名'),elder_id:str('使用者UUID,空值表示家庭无使用者'),preferred_name:str('称呼')})}}),Devices:object({items:{type:'array',items:{type:'object',additionalProperties:true}},limit:int('运维列表最多200条;按设备创建时间倒序')}),Inventory:object({device_id:str(descriptions.device_id),platform_device_id:str(descriptions.platform_device_id),enrollment_job_id:str('生产或轮换任务UUID'),key_version:int('目标凭据版本')}),Delivery:object({algorithm:str('RSA-OAEP-SHA256+A256GCM'),enrollment_job_id:str('任务UUID'),wrapped_key_b64:str('RSA包装的AES数据密钥'),ciphertext_b64:str('前12字节为nonce,后接AES-GCM密文和16字节认证标签;AAD为任务UUID'),expires_at_ms:int('交付包到期时间')}),Release:object({released:bool,device_id:str(descriptions.device_id),key_version:int('生效版本')}),Logout:object({signed_out:bool}),Account:object({csrf_token:str('账号会话写请求CSRF'),authenticated:bool})}; data.Browser=ref('Browser');data.WebStatus=ref('WebStatus'); data.Inventory.properties.contract_version=str('ONBOARDING-1.1');data.Inventory.properties.released=bool;data.Release=data.Inventory; schemas.DevicesItem=object({platform_device_id:str('平台设备UUID'),device_id:str('物理设备编号'),model:str('型号'),binding_generation:int('当前绑定代数'),device_revision:int('状态版本'),released:bool,activation_id:str('激活任务UUID'),activation_state:str('激活状态'),activation_version:int('对账使用的预期任务版本'),key_version:int('当前生效设备认证密钥版本'),pending_key_version:int('待交付或放行的版本,没有则0'),current_attempt_id:str('当前供应商注册尝试UUID'),enrollment_job_id:str('最新生产或轮换任务UUID'),last_error_code:str('故障原因码'),provider_device_name:str('冻结的供应商物理名'),household_name:str('家庭名,仅我的设备'),preferred_name:str('使用者称呼,仅我的设备')});data.Devices.properties.items.items=ref('DevicesItem'); for(const [name,schema]of Object.entries(data))schemas[name+'Response']=object({ok:{type:'boolean',const:true},code:{type:'string',const:'OK'},request_id:{type:'string',format:'uuid'},server_time_ms:int('UTC毫秒'),data:schema}); schemas.TimeChallengeResponse=data.TimeChallenge; const endpoints=[ ['POST','/api/v1/device/time-challenges','C01 校时挑战','TimeChallengeInput','TimeChallenge','public'],['POST','/api/v1/device/register','C02 首次认证和启动注册','RegisterInput','Register','device'],['POST','/api/v1/device/setup-sessions','C03 创建临时设置会话','SetupInput','Setup','device'],['POST','/api/v1/device/handoffs','C04 获取一次性手机入口','HandoffInput','Handoff','device'],['POST','/api/v1/device/poll','C05 查询状态和命令','PollInput','Poll','device'],['POST','/api/v1/device/claim-confirmations','C06 物理按键确认绑定','ConfirmationInput','Confirmation','device'],['POST','/api/v1/device/state-acks','C07 绑定状态持久化回执','StateAckInput','StateAck','device'],['POST','/api/v1/device/activation-attempts','C08 申请设备执行许可','AttemptInput','Attempt','device'],['POST','/api/v1/device/activation-results','C09 回报设备执行结果','ResultInput','Result','device'],['POST','/api/v1/device/activation-acks','C10 激活结果保存回执','ActivationAckInput','ActivationAck','device'],['POST','/api/v1/device/setup-close','C11 关闭临时设置入口','CloseInput','Close','device'], ['POST','/api/v1/web/handoffs/exchange','手机兑换一次性入口','ExchangeInput','Browser','exchange'],['GET','/api/v1/web/setup/session','恢复手机设置会话',null,'Browser','web'],['POST','/api/v1/web/setup/auth-link','关联OIDC登录','Empty','Browser','oidc'],['POST','/api/v1/web/auth/sms/send','发送登录短信','SMSSendInput','SMS','web'],['POST','/api/v1/web/auth/sms/verify','验证短信并登录','SMSVerifyInput','Browser','web'],['POST','/api/v1/web/profiles','查询可选择的家庭使用者','Empty','Profiles','web'],['POST','/api/v1/web/profiles/create','创建家庭或使用者','ProfileInput','Profile','web'],['POST','/api/v1/web/claims','提交绑定申请','ClaimInput','Claim','web'],['POST','/api/v1/web/claims/cancel','取消自己的绑定申请','CancelInput','StateOnly','web'],['POST','/api/v1/web/setup/status','查询手机绑定进度','Empty','WebStatus','web'],['POST','/api/v1/web/setup/ack','网页确认收到绑定结果','WebAckInput','Ack','web'],['GET','/api/v1/web/onboarding/devices','设置结束后查询我的设备',null,'Devices','account'],['GET','/api/v1/web/account/session','恢复账号会话及CSRF',null,'Account','account'],['POST','/api/v1/web/auth/logout','退出本浏览器登录','Empty','Logout','account'], ['GET','/api/v1/ops/device-inventory','查询生产设备和激活状态',null,'Devices','ops'],['POST','/api/v1/ops/device-inventory','登记生产设备','InventoryInput','Inventory','ops'],['POST','/api/v1/ops/device-inventory/{id}/credential-deliveries','向工装交付加密密钥','DeliveryInput','Delivery','ops'],['POST','/api/v1/ops/device-inventory/{id}/release','确认烧录读回并放行','ReleaseInput','Release','ops'],['POST','/api/v1/ops/device-inventory/{id}/credential-rotations','准备轮换凭据','RotationInput','Inventory','ops'],['POST','/api/v1/ops/onboarding-activations/{id}/reconcile','人工记录激活对账结论','ReconcileInput','StateOnly','ops']]; const spec={openapi:'3.1.0',info:{title:'SaaS 首次认证、绑定和激活接口',version:'0.78.0',description:'ONBOARDING-1.1。仅用于生产登记为physical_confirm的设备;旧设备维持原协议。详细规则见docs/onboarding-implementation.md。'},paths:{},components:{securitySchemes:{DeviceRequest:{type:'apiKey',in:'header',name:'Authorization',description:'Device-HMAC-SHA256小写十六进制签名;DEVREQ1九行规范;其余必填签名头见操作参数'},Bearer:{type:'http',scheme:'bearer'},SetupCookie:{type:'apiKey',in:'cookie',name:'__Host-onboarding'},AccountCookie:{type:'apiKey',in:'cookie',name:'__Host-platform-onboarding'}},schemas}}; function header(name,required=true,description=name){return{in:'header',name,required,schema:{type:'string'},description}} for(const [method,url,title,input,output,auth]of endpoints){ const params=[header('X-Request-ID',method==='POST'&&auth!=='public','UUID v4;业务重试保持相同值和原始正文')]; if(url.includes('{id}'))params.push({in:'path',name:'id',required:true,schema:{type:'string',format:'uuid'},description:url.includes('onboarding-activations')?'激活任务UUID':'平台设备公开UUID'}); if(auth==='device')for(const name of ['X-Device-ID','X-Key-Version','X-Timestamp-Ms','X-Nonce'])params.push(header(name)); if(['web','oidc','account'].includes(auth)&&method==='POST')params.push(header('X-CSRF-Token'),header('Origin',true,'必须与ONBOARDING_ENTRY_URL同源')); if(auth==='exchange')params.push(header('Origin',true,'必须与ONBOARDING_ENTRY_URL同源')); if(auth==='ops'){params.push(header('X-Tenant-ID',true,'租户公开UUID;服务端核验运维角色'));if(input==='InventoryInput'||input==='RotationInput')params.push(header('Idempotency-Key',true,'16至160字符,重试保持相同值'));} const security=auth==='device'?[{DeviceRequest:[]}]:auth==='ops'?[{Bearer:[]}]:auth==='oidc'?[{SetupCookie:[],Bearer:[]}]:auth==='web'?[{SetupCookie:[]}]:auth==='account'?[{AccountCookie:[]},{Bearer:[]}]:[]; const operation={operationId:'onboarding'+title.replace(/[^a-zA-Z0-9]/g,'')+method+url.replace(/[^a-zA-Z0-9]/g,''),summary:title,tags:['Onboarding'],description:auth==='device'?'HTTPS,无query参数;正文不超过8KiB。随机nonce每次发送更新,X-Request-ID在重试时保持。':auth==='public'?'专用HTTP或HTTPS校时;无需时间戳及请求签名;成功直接返回payload_b64和mac_hex,设备必须核验TIME1。':auth==='ops'?'必须使用运维OIDC身份。对账仅ops_admin;其它写操作ops_admin或ops_operator。':'HTTPS,同源浏览器,Cookie不可读,CSRF仅保存在当前标签页。',security,parameters:params,responses:{'200':{description:'成功;业务状态请读取data',content:{'application/json':{schema:ref(output+'Response')}}},default:{description:'失败;400/401/403/409/410/413/415/429/500/503,见code恢复;禁止根据超时推断供应商没有执行',content:{'application/json':{schema:ref('Failure')}}}}}; if(input)operation.requestBody={required:true,content:{'application/json':{schema:ref(input)}}}; (spec.paths[url]??={})[method.toLowerCase()]=operation; } fs.writeFileSync(path.join(root,'openapi/onboarding.yaml'),yaml.dump(spec,{lineWidth:140,noRefs:true})); const mainPath=path.join(root,'openapi/openapi.yaml');let main=fs.readFileSync(mainPath,'utf8').replace(/\r\n/g,'\n').replace('version: 0.77.0','version: 0.78.0'); if(!main.includes('./onboarding.yaml#/paths/'))main=main.replace('paths:\n','paths:\n'+Object.keys(spec.paths).map(url=>` ${url}:\n $ref: './onboarding.yaml#/paths/${url.replaceAll('~','~0').replaceAll('/','~1')}'\n`).join('')); if(!main.includes('intent: { type: string, enum: [activation_test]'))main=main.replace(' HardwareAgentCreateRequest:\n',' HardwareAgentCreateRequest:\n').replace(/( HardwareAgentCreateRequest:[\s\S]*? properties:\n)/,'$1 intent: { type: string, enum: [activation_test], description: 仅physical_confirm首通测试填写;普通会话省略。 }\n activation_id: { type: string, format: uuid }\n attempt_id: { type: string, format: uuid }\n permit_token: { type: string, description: 历史接口兼容字段;ONBOARDING-1.1不下发且无需填写。 }\n'); main=main.replace('enum: [session_started, session_updated, session_ended, session_failed','enum: [session_started, first_audio_received, session_updated, session_ended, session_failed'); main=main.replace('config_revision, claim, local_actions','config_revision, local_actions'); if(!main.includes('maximum_duration_seconds:'))main=main.replace(/( ConversationSession:[\s\S]*? properties:\n)/,'$1 maximum_duration_seconds: { type: integer, minimum: 1, description: 首通测试固定60秒;普通会话省略。 }\n'); fs.writeFileSync(mainPath,main); const doc=['# 首次接入接口字段清单','', '由 `node scripts/generate-onboarding-contract.mjs` 根据实际Go请求结构生成。共 '+endpoints.length+' 项操作。完整响应结构见 [OpenAPI](../openapi/onboarding.yaml),流程、签名、错误恢复见 [实施说明](onboarding-implementation.md)。','']; for(const [method,url,title,input,output,auth]of endpoints){doc.push('## '+title,'', '`'+method+' '+url+'`','', '调用身份:'+({device:'设备DEVREQ1签名',public:'校时请求,无请求签名;设备必须核验TIME1响应MAC',web:'设置Cookie + 写请求CSRF',oidc:'设置Cookie + CSRF + OIDC Bearer',exchange:'同源网页;一次性token和verifier',account:'账号Cookie;设备列表也支持OIDC Bearer',ops:'运维OIDC Bearer + X-Tenant-ID'}[auth]),'');if(input&&input!=='Empty'){doc.push('| 字段 | 类型 | 必填 | 用途 |','|---|---|---|---|');for(const [name,p]of Object.entries(schemas[input].properties))doc.push(`| ${name} | ${p.type??'object'} | ${schemas[input].required?.includes(name)?'是':'条件或可选'} | ${p.description} |`);}else doc.push(method==='POST'?'正文:`{}`。':'无请求正文。');doc.push('', output==='TimeChallenge'?'响应:根对象直接返回 `{payload_b64,mac_hex}`。':'响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `'+output+'Response.data`。','');} fs.writeFileSync(path.join(root,'docs/onboarding-api-fields.md'),doc.join('\n')); console.log('Generated onboarding OpenAPI and field reference: '+endpoints.length+' operations');