exportconstCONNECT_STEPS=[{id:'ownership',title:'소유 확인'},{id:'dns',title:'DNS 반영 확인'},{id:'cert',title:'HTTPS 인증서 발급'},{id:'external_check',title:'외부 접속 확인'},{id:'primary',title:'대표 도메인 지정'}];
constbyName=(name)=>row(store.get('SELECT * FROM domains WHERE name=?',normalize(name)));
constsave=(id,patch)=>{constsets=[];constvals=[];for(const[k,v]ofObject.entries(patch)){sets.push(`${k}=?`);vals.push(v&&typeofv==='object'?toJson(v):v);}sets.push('updated_at=?');vals.push(nowIso());store.run(`UPDATE domains SET ${sets.join(', ')} WHERE id=?`,...vals,id);returnget(id);};
constrecord=(domainId,kind,ok,detail)=>store.run('INSERT INTO domain_checks(id, domain_id, kind, ok, detail_json, checked_at) VALUES(?,?,?,?,?,?)',newId('dc_'),domainId,kind,ok?1:0,toJson(detail||null),nowIso());
constrows=store.all(`SELECT * FROM domains WHERE group_id=? ${siteId?'AND site_id=?':''}${mailServiceId?'AND mail_service_id=?':''} ORDER BY is_primary DESC, name`,...[groupId,...(siteId?[siteId]:[]),...(mailServiceId?[mailServiceId]:[])]).map(row);
returnrows.map(summarize);
}
functionsummarize(d){
constsite=d.site_id?store.get('SELECT id, name, deploy_mode, lifecycle FROM sites WHERE id=?',d.site_id):null;constms=d.mail_service_id?store.get('SELECT id, name, status FROM mail_services WHERE id=?',d.mail_service_id):null;
constfails=store.get('SELECT COUNT(*) AS n FROM domain_checks WHERE domain_id=? AND ok=0 AND checked_at>?',d.id,newDate(Date.now()-7*86400000).toISOString()).n;
letip=d.expected_ip;if(!ip&&d.site_id){consts=store.get('SELECT server_id, deploy_mode FROM sites WHERE id=?',d.site_id);if(s&&s.deploy_mode==='managed'&&s.server_id){try{constsrv=(awaithal().listServers({role:'web'})).find((x)=>x.id===s.server_id);ip=srv?srv.publicIp:null;if(ip)save(d.id,{expected_ip:ip});}catch(_){}}elseif(s&&s.deploy_mode==='external')ip=null;}
constsiteExt=d.site_id?store.get('SELECT deploy_mode FROM sites WHERE id=?',d.site_id)?.deploy_mode==='external':false;
recs.push({type:'A',name:d.name,value:ip||null,purpose:'웹 접속',note:siteExt?'외부 배포: 고객 서버의 공인 IP 를 넣으세요(포털이 값을 확인하지 않습니다)':ip?null:'관리형 배포를 시작하면 서버 IP 가 정해져 표시됩니다',state:d.dns_state});
if(['mail','both'].includes(d.purpose)){letmail=null;try{mail=deps.mailDns&&deps.mailDns.recordsFor?awaitdeps.mailDns.recordsFor(d.name,{groupId:d.group_id,mailServiceId:d.mail_service_id}):null;}catch(_){}if(mail&&mail.length)recs.push(...mail.map((r)=>({...r,purpose:r.purpose||'메일'})));elserecs.push({type:'MX/SPF/DKIM/DMARC',name:d.name,value:null,purpose:'메일',note:'메일 서비스 화면의 DNS 설정에서 정확한 값을 확인하세요',state:d.mail_dns_state||'unknown'});}
name=normalize(name);if(!NAME_RE.test(name))thrownewHttpError(400,'bad_domain','도메인 형식이 올바르지 않습니다 (예: example.com)',{field:'name'});
if(!['web','mail','both'].includes(purpose))thrownewHttpError(400,'bad_purpose','용도는 웹·메일·둘 다 중 하나입니다');
if(byName(name)){constex=byName(name);thrownewHttpError(409,'domain_taken',ex.group_id===group.id?'이미 등록한 도메인입니다':'이 도메인은 이미 다른 고객에게 연결되어 있어 활성화할 수 없습니다. 소유자라면 문의하세요',{href:ex.group_id===group.id?`/domains/${ex.id}`:null});}
if(['web','both'].includes(purpose)&&siteId){consts=store.get('SELECT id, group_id, lifecycle FROM sites WHERE id=?',siteId);if(!s||s.group_id!==group.id)thrownewHttpError(404,'not_found','사이트를 찾을 수 없습니다');if(s.lifecycle==='deleted')thrownewHttpError(409,'site_deleted','삭제된 사이트에는 연결할 수 없습니다');}
if(['mail','both'].includes(purpose)&&mailServiceId){constm=store.get('SELECT id, group_id FROM mail_services WHERE id=?',mailServiceId);if(!m||m.group_id!==group.id)thrownewHttpError(404,'not_found','메일 서비스를 찾을 수 없습니다');}
if(active){if(['awaiting_external','needs_user'].includes(active.status)){if(makePrimary&&!active.subject.makePrimary)store.run('UPDATE jobs SET subject_json=? WHERE id=?',toJson({...active.subject,makePrimary:true}),active.id);jobs.resume(active.id,user?.id);}returndescribeJob(jobs.get(active.id));}
const{job}=jobs.enqueue({kind:'domain.connect',subject:{domainId:d.id,makePrimary},idempotencyKey:`domain.connect:${d.id}:${jobs.list({subjectType:'domain',subjectId:d.id,kind:'domain.connect',limit:1000}).length}`,createdBy:user?.id,groupId:d.group_id,subjectType:'domain',subjectId:d.id,label:`도메인 연결 확인 (${d.name})`,maxAttempts:3});
asyncfunctionprobeTarget(d){if(d.site_id){consts=store.get('SELECT server_id, deploy_mode, external_agent_id FROM sites WHERE id=?',d.site_id);if(s&&s.deploy_mode==='managed'&&s.server_id)return{target:s.server_id,external:false,agent:null};consta=s&&s.deploy_mode==='external'?store.get('SELECT hal_agent_id FROM external_agents WHERE site_id=? AND revoked_at IS NULL AND status=\'connected\'',d.site_id):null;constsrv=(awaithal().listServers({role:'web'}))[0];return{target:srv?srv.id:null,external:s&&s.deploy_mode==='external',agent:a?a.hal_agent_id:null};}constsrv=(awaithal().listServers({role:'web'}))[0];return{target:srv?srv.id:null,external:false,agent:null};}
/** 단계에 묶인 HAL 작업 보장(portal/sites/service.mjs 의 halJobFor 와 같은 규칙): 재진입 때 같은 작업, 실패 뒤 재시도 때만 새 작업 */
if(prev&&!fresh){lethj;try{hj=awaithal().getJob(prev);}catch(e){if(e.code==='service_unreachable')ctx.retryLater(15000,'HAL 연결 대기(정보 확인 지연)');throwe;}returnprev;}// [E2E 결함 수정] 실패·취소된 HAL 작업도 돌려줘 awaitHal 이 실패를 드러낸다(새 작업은 단계 실패 뒤 재시도 때만)
/** [E2E 결함 수정] 외부 대기(사용자 DNS 변경) 직전에 단계에 적어 둔 탐침 작업 id 를 지워, 재개 때 새 탐침을 만들게 한다(옛 결과 재사용 금지). 단계가 없으면 만들어 둔다 */
functionprobeReset(ctx,stepId){constst=ctx.steps().find((x)=>x.id===stepId);if(st&&st.result){st.result={...st.result,halJobId:null,seq:(st.result.seq||0)+1};ctx.flush();}}// seq 로 멱등 키를 바꿔 HAL 이 옛 탐침을 돌려주지 않게 한다
asyncfunctionawaitHal(ctx,halJobId){lethj;try{hj=awaithal().getJob(halJobId);}catch(e){if(e.code==='service_unreachable')ctx.retryLater(15000,'HAL 연결 대기(정보 확인 지연)');throwe;}if(hj.status==='done')returnhj;if(['failed','cancelled'].includes(hj.status))thrownewJobError(hj.errorCode||'hal_job_failed',hj.error||'서버 작업 실패');ctx.retryLater(5000,'서버 작업 진행 중');}
jobs.register('domain.connect',{title:'도메인 연결 확인',steps:CONNECT_STEPS,maxAttempts:3,retryable:(e)=>e&&e.code==='service_unreachable',
asyncrun(job,ctx){
constd0=get(job.subject.domainId);if(!d0)thrownewJobError('domain_missing','도메인이 없습니다');constpt=awaitprobeTarget(d0);if(!pt.target)thrownewJobError('no_server','확인에 쓸 서버가 없습니다',{retryable:true});
// 1 소유 확인 — TXT 값이 보일 때까지 외부 작업 대기(사용자가 DNS 를 넣는다)
if(d1.ownership_state!=='verified'){consthid=awaithalJobFor(ctx,job,'ownership',(key)=>hal().createJob({kind:'domain.probe',target:pt.target,idempotencyKey:key,payload:{name:`_ariweb-verify.${d1.name}`,expectTxt:{name:`_ariweb-verify.${d1.name}`,value:`ariweb-verify=${d1.ownership_token}`}}}));consthj=awaitawaitHal(ctx,hid);constok=!!(hj.result&&hj.result.txtOk);record(d1.id,'ownership',ok,hj.result);if(!ok){save(d1.id,{last_checked_at:nowIso(),check_json:{stage:'ownership',detail:hj.result&&hj.result.txt?`TXT 값이 다릅니다(지금 값: ${String(hj.result.txt).slice(0,60)})`:'TXT 레코드가 아직 보이지 않습니다'}});probeReset(ctx,'ownership');ctx.waitExternal(hj.result&&hj.result.txt?`소유 확인 TXT 값이 다릅니다 — 안내된 값으로 수정한 뒤 "다시 확인"`:`소유 확인 TXT 레코드가 아직 반영되지 않았습니다 — DNS 에 넣은 뒤 "다시 확인"`);}save(d1.id,{ownership_state:'verified',last_checked_at:nowIso()});}
if(pt.external){save(d2.id,{dns_state:'customer',dns_detail_json:toJson({note:'외부 배포: DNS 값은 고객이 관리하며 포털이 검증하지 않습니다'})});}
elseif(!a.value){save(d2.id,{dns_state:'pending',check_json:{stage:'dns',detail:'관리형 배포를 시작해 서버가 정해진 뒤 A 레코드 값을 안내합니다'}});ctx.waitExternal('사이트를 먼저 관리형으로 배포하면 A 레코드 값을 안내합니다');}
else{consthid=awaithalJobFor(ctx,job,'dns',(key)=>hal().createJob({kind:'domain.probe',target:pt.target,idempotencyKey:key,payload:{name:d2.name,expectA:[a.value]}}));consthj=awaitawaitHal(ctx,hid);constgot=(hj.result&&hj.result.a)||[];conststate=!got.length?'pending':got.includes(a.value)?'ok':'wrong';record(d2.id,'dns',state==='ok',{expected:a.value,got});save(d2.id,{dns_state:state,dns_detail_json:toJson({expected:a.value,got,resolvedAt:hj.result&&hj.result.resolvedAt}),last_checked_at:nowIso()});if(state!=='ok'){probeReset(ctx,'dns');ctx.waitExternal(state==='wrong'?`A 레코드 값이 다릅니다 (지금 ${got.join(', ')} / 필요 ${a.value}) — 수정 뒤 "다시 확인"`:`A 레코드가 아직 반영되지 않았습니다 (필요 ${a.value}) — 반영 뒤 "다시 확인"`);}}
awaitctx.step('cert',async()=>{save(d3.id,{cert_state:d3.cert_state==='valid'?'valid':'issuing'});constemail=deps.config.operatorEmail||deps.config.mailFrom;consthid=awaithalJobFor(ctx,job,'cert',(key)=>hal().createJob({kind:'domain.cert',target:pt.target,idempotencyKey:key,payload:{siteId:d3.site_id,domains:[d3.name],email}}));consthj=awaitawaitHal(ctx,hid);constres=hj.result||{};constfailed=(res.failed||[]).find((f)=>f.domain===d3.name);if(failed||!(res.issued||[]).includes(d3.name)){record(d3.id,'cert',false,res);save(d3.id,{cert_state:'failed',cert_error:failed?failed.reason:'발급 결과에 도메인이 없습니다'});thrownewJobError('cert_failed',`인증서 발급 실패: ${failed?failed.reason:'알 수 없음'}`);}record(d3.id,'cert',true,{expiresAt:res.expiresAt});save(d3.id,{cert_state:'valid',cert_expires_at:res.expiresAt||null,cert_error:null});constsite=store.get('SELECT * FROM sites WHERE id=?',d3.site_id);constall=store.all('SELECT name FROM domains WHERE site_id=? AND ownership_state=\'verified\' AND purpose IN (\'web\',\'both\')',d3.site_id).map((x)=>x.name);constv=awaithal().createJob({kind:'domain.vhost',target:pt.target,idempotencyKey:`portal:${job.id}:vhost:${hid}`,payload:{siteId:d3.site_id,domains:all,primaryDomain:site.primary_domain,redirectOld:false,maintenance:!!site.maintenance}});awaitawaitHal(ctx,v.halJobId);return{issued:true,expiresAt:res.expiresAt};});
if(['web','both'].includes(d4.purpose)&&d4.site_id&&store.get('SELECT current_version FROM sites WHERE id=?',d4.site_id)?.current_version){
awaitctx.step('external_check',async()=>{consttarget=pt.external?pt.agent:pt.target;if(!target)thrownewJobError('agent_not_connected','외부 서버 Agent 가 연결되어 있지 않아 외부 확인을 할 수 없습니다');constcid=awaithalJobFor(ctx,job,'external_check',(key)=>hal().createJob({kind:'site.check',target,idempotencyKey:key,payload:{siteId:d4.site_id,urls:[`https://${d4.name}/`]}}));consthj=awaitawaitHal(ctx,cid);constc=((hj.result&&hj.result.checks)||[])[0]||null;record(d4.id,'external',!!(c&&c.ok),c);consttlsOk=!!(c&&c.tls&&c.tls.valid);constpatch={external_check_json:toJson(c),last_checked_at:nowIso()};if(!pt.external){if(!tlsOk){patch.cert_state='failed';patch.cert_error='외부 확인에서 인증서가 유효하지 않습니다';}}save(d4.id,patch);if(!(c&&c.ok))thrownewJobError('external_check_failed',`외부 접속 확인 실패 (${c?c.status||'응답 없음':'결과 없음'})`);if(!pt.external&&!tlsOk)thrownewJobError('tls_invalid','인증서가 유효하지 않습니다');returnc;});
}elseawaitctx.step('external_check',()=>({skipped:'배포된 사이트가 없어 외부 확인을 건너뜀(배포 후 다시 확인)'}));
constfin=get(d0.id);notify(store,{groupId:fin.group_id,category:'domain',severity:'info',incidentKey:`domain:${fin.id}`,title:`도메인 ${fin.name} 연결 확인 완료`,body:`소유 확인 ${fin.ownership_state} · DNS ${fin.dns_state} · 인증서 ${fin.cert_state}`,link:`/domains/${fin.id}`,subjectType:'domain',subjectId:fin.id});
jobs.onChange((j)=>{if(j.kind!=='domain.connect'||j.status!=='failed')return;constd=get(j.subject.domainId);if(!d)return;notify(store,{groupId:d.group_id,category:'domain',severity:'warning',incidentKey:`domain:${d.id}`,title:`도메인 ${d.name} 연결 확인 실패`,body:`${j.error||''} (문의 번호 ${j.id})`,link:`/domains/${d.id}`,subjectType:'domain',subjectId:d.id,actionRequired:'안내 값 확인 후 다시 시도'});});
/** 대표 도메인 지정(§12.3). DNS·HTTPS 정상(또는 외부 배포의 고객 관리) 조건 */
if(!ready)thrownewHttpError(409,'domain_not_ready','대표 도메인은 소유 확인·DNS·HTTPS 가 정상일 때 지정할 수 있습니다',{ownership:d.ownership_state,dns:d.dns_state,cert:d.cert_state});
constprev=store.get('SELECT id, name FROM domains WHERE site_id=? AND is_primary=1 AND id<>?',d.site_id,d.id);
store.tx(()=>{store.run('UPDATE domains SET is_primary=0, updated_at=? WHERE site_id=?',nowIso(),d.site_id);store.run('UPDATE domains SET is_primary=1, redirect_from=?, updated_at=? WHERE id=?',redirectOld&&prev?prev.name:null,nowIso(),d.id);});
if(siteExt.deploy_mode==='managed'&&siteExt.server_id){constall=store.all('SELECT name FROM domains WHERE site_id=? AND ownership_state=\'verified\' AND purpose IN (\'web\',\'both\')',d.site_id).map((x)=>x.name);jobs.enqueue({kind:'domain.vhost_apply',subject:{siteId:d.site_id,serverId:siteExt.server_id,domains:all,primaryDomain:d.name,redirectOld:!!redirectOld,maintenance:!!siteExt.maintenance},idempotencyKey:`vhost:${d.site_id}:${d.name}:${redirectOld}:${Date.now()/60000|0}`,createdBy:actor?.id,groupId:d.group_id,subjectType:'site',subjectId:d.site_id,label:`대표 도메인 적용 (${d.name})`});}
jobs.register('domain.vhost_apply',{title:'vhost 적용',steps:[{id:'vhost',title:'웹서버 설정 적용'}],maxAttempts:3,retryable:(e)=>e&&e.code==='service_unreachable',asyncrun(job,ctx){returnctx.step('vhost',async()=>{consts=job.subject;constid=awaithalJobFor(ctx,job,'vhost',(key)=>hal().createJob({kind:'domain.vhost',target:s.serverId,idempotencyKey:key,payload:{siteId:s.siteId,domains:s.domains,primaryDomain:s.primaryDomain,redirectOld:s.redirectOld,maintenance:s.maintenance}}));return(awaitawaitHal(ctx,id)).result;});}});
/** 연결 해제(§12.1). 대표였으면 사이트는 '도메인 설정 필요' 로 */
functionrelease(d,actor){
constactive=jobs.list({subjectType:'domain',subjectId:d.id,status:['queued','running'],limit:3});if(active.length)thrownewHttpError(409,'job_running','확인 작업이 진행 중입니다. 끝난 뒤 해제하세요');
consts=d.site_id?store.get('SELECT deploy_mode, server_id, maintenance, primary_domain FROM sites WHERE id=?',d.site_id):null;
if(s&&s.deploy_mode==='managed'&&s.server_id){constall=store.all('SELECT name FROM domains WHERE site_id=? AND ownership_state=\'verified\' AND purpose IN (\'web\',\'both\')',d.site_id).map((x)=>x.name);jobs.enqueue({kind:'domain.vhost_apply',subject:{siteId:d.site_id,serverId:s.server_id,domains:all,primaryDomain:wasPrimary?null:s.primary_domain,redirectOld:false,maintenance:!!s.maintenance},idempotencyKey:`vhost:${d.site_id}:release:${d.name}:${Date.now()/60000|0}`,createdBy:actor?.id,groupId:d.group_id,subjectType:'site',subjectId:d.site_id,label:`도메인 해제 적용 (${d.name})`});}
functionupdate(d,patch,actor){constp={};if(patch.purpose&&['web','mail','both'].includes(patch.purpose))p.purpose=patch.purpose;if(patch.siteId!==undefined){if(patch.siteId){consts=store.get('SELECT id, group_id FROM sites WHERE id=?',patch.siteId);if(!s||s.group_id!==d.group_id)thrownewHttpError(404,'not_found','사이트를 찾을 수 없습니다');}if(d.is_primary&&patch.siteId!==d.site_id){p.is_primary=0;if(deps.sites&&d.site_id)deps.sites.onPrimaryDomainLost(d.site_id);}p.site_id=patch.siteId||null;p.dns_state='pending';p.cert_state='none';p.expected_ip=null;}if(patch.mailServiceId!==undefined)p.mail_service_id=patch.mailServiceId||null;if(Object.keys(p).length){save(d.id,p);audit(store,{actorType:'user',actorId:actor?.id,groupId:d.group_id,subjectType:'domain',subjectId:d.id,action:'domain.update',detail:p});}returnget(d.id);}
for(constdofstore.all('SELECT * FROM domains WHERE cert_state IN (\'valid\',\'expiring_soon\') AND cert_expires_at IS NOT NULL AND cert_expires_at<=?',soon).map(row)){
if(Date.parse(d.cert_expires_at)<=now){save(d.id,{cert_state:'failed',cert_error:'인증서가 만료되었습니다'});notify(store,{groupId:d.group_id,category:'domain',severity:'critical',mandatory:true,incidentKey:`cert:${d.id}`,title:`도메인 ${d.name} 인증서 만료`,body:'갱신 작업을 다시 시도합니다. 실패가 계속되면 DNS 값을 확인하세요',link:`/domains/${d.id}`,subjectType:'domain',subjectId:d.id,actionRequired:'DNS 확인 후 다시 확인'});}
store.run('DELETE FROM domain_checks WHERE checked_at<?',newDate(now-90*86400000).toISOString());
return{renew};
}
functiondetail(d){return{...summarize(d),ownershipToken:d.ownership_token,dnsDetail:d.dnsDetail,externalCheck:d.externalCheck,check:d.check,redirectFrom:d.redirect_from,checks:store.all('SELECT kind, ok, detail_json, checked_at FROM domain_checks WHERE domain_id=? ORDER BY checked_at DESC LIMIT 30',d.id).map((c)=>({...c,detail:parseJson(c.detail_json,null)})),job:d.job_id?describeJob(jobs.get(d.job_id)):null};}