feat: complete device onboarding and mobile binding workflows
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-12 08:57:29 +08:00
parent 7846f30d0b
commit 98fb2965d0
116 changed files with 15669 additions and 64 deletions
+3 -3
View File
@@ -22,8 +22,8 @@ $payloadRoot = Join-Path $stagingRoot $bundleName
New-Item -ItemType Directory -Force -Path $payloadRoot | Out-Null
try {
$candidatePaths = @(& git ls-files --cached --others --exclude-standard -- `
cmd internal migrations openapi web deploy compose.yaml go.mod go.sum README.md CHANGELOG.md .env.example)
$candidatePaths = @(& git -c core.quotePath=false ls-files --cached --others --exclude-standard -- `
cmd internal migrations openapi web deploy scripts test docs compose.yaml go.mod go.sum README.md CHANGELOG.md .env.example .dockerignore)
if ($LASTEXITCODE -ne 0) { throw 'git file enumeration failed' }
$candidatePaths = $candidatePaths | Where-Object {
$_ -and
@@ -60,7 +60,7 @@ try {
generated_at = [DateTime]::UtcNow.ToString('o')
source_commit = $sourceCommit
working_tree_dirty = $statusLines.Count -gt 0
schema_migration_target = 52
schema_migration_target = 58
hardware_runtime = 'volc_hardware_agent'
files = @($files)
}
+70
View File
@@ -0,0 +1,70 @@
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');
+71
View File
@@ -0,0 +1,71 @@
"""DEVREQ1 call helper. Keys stay in a user-specified local file, never argv.
Prints signed headers by default; --send explicitly submits one HTTPS request.
The caller keeps request-id + original body on retries and changes nonce/time.
"""
import argparse
import base64
import hashlib
import hmac
import json
import secrets
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
def sign(key, method, path, device_id, key_version, timestamp, nonce, request_id, body):
canonical = '\n'.join(['DEVREQ1', method.upper(), path, device_id, str(key_version), str(timestamp), nonce, request_id, hashlib.sha256(body).hexdigest()])
return hmac.new(key, canonical.encode('utf-8'), hashlib.sha256).hexdigest(), canonical
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--device-id', required=True)
parser.add_argument('--key-file', type=Path, required=True, help='file containing Base64URL (unpadded) encoded raw32 key')
parser.add_argument('--key-version', type=int, default=1)
parser.add_argument('--path', required=True)
parser.add_argument('--body-file', type=Path, required=True)
parser.add_argument('--request-id', required=True)
parser.add_argument('--method', default='POST', choices=['POST', 'GET'])
parser.add_argument('--send', help='explicit HTTPS origin, e.g. https://saas.example')
args = parser.parse_args()
if not args.path.startswith('/') or '?' in args.path or '#' in args.path or '\n' in args.path:
parser.error('path must be absolute without query or fragment')
if str(uuid.UUID(args.request_id)) != args.request_id or uuid.UUID(args.request_id).version != 4:
parser.error('request-id must be canonical UUID v4')
encoded = args.key_file.read_text(encoding='utf-8').strip()
key = base64.urlsafe_b64decode(encoded + '=' * (-len(encoded) % 4))
if len(key) != 32:
parser.error('device key must decode to 32 bytes')
body = args.body_file.read_bytes()
if len(body) > 8192 or body.startswith(b'\xef\xbb\xbf'):
parser.error('body must be <=8KiB UTF-8 without BOM')
json.loads(body.decode('utf-8'))
nonce = base64.urlsafe_b64encode(secrets.token_bytes(16)).decode().rstrip('=')
timestamp = int(time.time() * 1000)
signature, _ = sign(key, args.method, args.path, args.device_id, args.key_version, timestamp, nonce, args.request_id, body)
headers = {'Authorization': 'Device-HMAC-SHA256 ' + signature, 'X-Device-ID': args.device_id, 'X-Key-Version': str(args.key_version), 'X-Timestamp-Ms': str(timestamp), 'X-Nonce': nonce, 'X-Request-ID': args.request_id, 'Content-Type': 'application/json'}
if not args.send:
print(json.dumps({'method': args.method, 'path': args.path, 'headers': headers}, ensure_ascii=False, indent=2))
return
origin = urllib.parse.urlsplit(args.send)
if origin.scheme != 'https' or not origin.netloc or origin.username or origin.query or origin.fragment or origin.path not in ('', '/'):
parser.error('--send must be a fixed HTTPS origin')
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *unused):
return None
request = urllib.request.Request(args.send.rstrip('/') + args.path, data=body, headers=headers, method=args.method)
try:
with urllib.request.build_opener(NoRedirect).open(request, timeout=30) as response:
print(response.read(65536).decode('utf-8'))
except urllib.error.HTTPError as error:
print(error.read(65536).decode('utf-8'))
raise SystemExit(error.code)
if __name__ == '__main__':
main()
+34
View File
@@ -0,0 +1,34 @@
param([string[]]$Packages = @('onboarding','devicereset','opsdevice','hardwareprovision','idempotency','httpapi','orchestration','saas','outbox','privacy'))
$ErrorActionPreference='Continue'
Set-Location (Split-Path $PSScriptRoot -Parent)
$taskTemplate='codex_onboarding_template_'+[Guid]::NewGuid().ToString('N').Substring(0,12)
$taskDatabases=[System.Collections.Generic.List[string]]::new()
New-Item -ItemType Directory -Force -Path '.tmp-onboarding' | Out-Null
try {
docker compose exec -T postgres psql -U voice -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE $taskTemplate" | Out-Null
if($LASTEXITCODE -ne 0){throw 'Test database creation failed'}
$taskDatabases.Add($taskTemplate)
docker compose run --rm --no-deps migrate -path=/migrations "-database=postgres://voice:voice_dev_only@postgres:5432/$taskTemplate`?sslmode=disable" up 2>&1 | Out-File '.tmp-onboarding/migration-validation.log'
if($LASTEXITCODE -ne 0){throw 'Migration validation failed'}
docker compose run --rm --no-deps -e PGPASSWORD=voice_dev_only seed psql -h postgres -U voice -d $taskTemplate -v ON_ERROR_STOP=1 -f /fixtures/local_seed.sql 2>&1 | Out-File '.tmp-onboarding/seed-validation.log'
if($LASTEXITCODE -ne 0){throw 'Seed validation failed'}
foreach($taskPackage in $Packages) {
if($taskPackage -notmatch '^[a-z]+$'){throw 'Invalid package'}
$taskDatabase='codex_onboarding_test_'+[Guid]::NewGuid().ToString('N').Substring(0,12)
docker compose exec -T postgres psql -U voice -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE $taskDatabase TEMPLATE $taskTemplate" | Out-Null
if($LASTEXITCODE -ne 0){throw 'Package database creation failed'}
$taskDatabases.Add($taskDatabase)
$env:TEST_DATABASE_URL="postgres://voice:voice_dev_only@127.0.0.1:55432/$taskDatabase`?sslmode=disable"
$env:TEST_REDIS_ADDR='127.0.0.1:56379'
go test -count=1 -p 1 "./internal/$taskPackage" 2>&1 | Tee-Object ".tmp-onboarding/integration-$taskPackage.log"
if($LASTEXITCODE -ne 0){throw "Integration failed: $taskPackage"}
}
} finally {
Remove-Item Env:TEST_DATABASE_URL -ErrorAction SilentlyContinue
foreach($taskDatabase in $taskDatabases) {
# Only databases created by this invocation can be removed.
if($taskDatabase -match '^codex_onboarding_(template|test)_[0-9a-f]{12}$'){
docker compose exec -T postgres psql -U voice -d postgres -c "DROP DATABASE $taskDatabase WITH (FORCE)" | Out-Null
}
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ try {
$manifestPath = Join-Path $payload[0].FullName 'deploy-manifest.json'
$checksumsPath = Join-Path $payload[0].FullName 'SHA256SUMS'
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
if ($manifest.schema_version -ne 'server-deployment-bundle-v1' -or $manifest.schema_migration_target -ne 52 -or $manifest.hardware_runtime -ne 'volc_hardware_agent') {
if ($manifest.schema_version -ne 'server-deployment-bundle-v1' -or $manifest.schema_migration_target -ne 58 -or $manifest.hardware_runtime -ne 'volc_hardware_agent') {
throw 'Deployment manifest metadata is invalid'
}
$verifiedFiles = 0