// Credential views use only the host's JSON API. Secrets leave the browser encrypted. function credentialReview(request,items){ const e=escapeHTML,p=request.payload,grant=p.grant_create||p; const list=values=>values.length?'':'

None.

'; const config=item=>'

'+e(item.name)+'

Access rules

'+list((item.rules||[]).map(rule=>e(rule.scheme)+' · '+e(rule.hosts.join(', '))+' · port '+e(rule.port??({https:443,http:80,ssh:22}[rule.scheme.toLowerCase()]||'default'))+' · paths '+e(rule.path_prefixes.join(', ')||'None')+' · methods '+e(rule.methods==null?'All':rule.methods.join(', ')||'None')))+'

Fields

'+list((item.fields||[]).map(field=>e(field.name)+' — '+(field.public?'Public':'Secret')+', '+e(field.field_type.replaceAll('_',' '))))+'

Transformation script: '+(item.transform_js?'Present':'None')+'.

'; let requested=''; if(grant.items)requested='

Grant access to:

'+grant.items.map(id=>{const item=items.find(item=>item.id===id);return item?config(item):'

Item '+e(id)+'

';}).join(''); else if(p.create||p.edit?.config)requested=config(p.create||p.edit.config); else if(p.old)requested=config(p.old); else requested=list((p.requested?.change||[]).map(change=>e(change.op.replaceAll('_',' '))+(change.config?config(change.config):''))); const subject=request.subject; return '

'+e(requestSummary(request.summary))+'

Requested by

'+(subject.kind==='conversation'?'Conversation #':'Program ')+e(subject.id)+'

Requested change

'+requested+'
Technical details
'+e(JSON.stringify(request,null,2))+'
'; } const credentialView=document.getElementById('credential-view'); const credentialNotice=document.getElementById('credential-notice'); document.getElementById('register-first').onclick=()=>{location.hash='passkeys';}; async function credentialGet(path){ const response=await fetch('/api/cred/'+path); const value=await response.json(); if(!response.ok)throw new Error(value.message||value.error); return value; } function credentialError(error){(document.getElementById('credential-status')||document.getElementById('error')).textContent=error.message;} function credentialForm(action){ credentialView.querySelector('form').onsubmit=async event=>{ event.preventDefault(); document.getElementById('credential-status').textContent=''; const buttons=[...event.target.querySelectorAll('button')]; buttons.forEach(button=>button.disabled=true); try{const status=await action(new FormData(event.target),event.submitter.value);document.getElementById('credential-status').textContent=status;await credentialState();} catch(error){credentialError(error);} finally{buttons.forEach(button=>button.disabled=false);} }; } async function credentialState(){ const passkeys=await credentialGet('passkeys'); credentialNotice.hidden=passkeys.length>0; return passkeys; } async function credentialNavigate(){ const parts=location.hash.slice(1).split('/').map(decodeURIComponent); credentialView.hidden=!['passkeys','approve','entry'].includes(parts[0]); if(credentialView.hidden)return; credentialView.innerHTML='

'; document.getElementById('credential-close').onclick=()=>{location.hash='';}; const content=document.getElementById('credential-content'); try{ if(parts[0]==='passkeys'){ credentialView.querySelector('h2').textContent='Passkeys'; const passkeys=await credentialState(); const list=document.createElement('ul'); if(!passkeys.length){const empty=document.createElement('p');empty.textContent='No passkeys registered.';content.append(empty);} for(const key of passkeys){const row=document.createElement('li');row.textContent=key.label;list.append(row);} content.append(list); const form=document.createElement('form');form.innerHTML='';content.append(form); let challenge; credentialForm(async data=>{ if(!challenge){ const label=data.get('label');let gate; if(passkeys.length){const proof=await assertion(await post('/cred/passkeys/register/challenge',{label}));gate={id:proof.challenge_id,response:proof.assertion};} challenge=await post('/cred/passkeys/register/start',{label,gate}); if(passkeys.length){form.elements.label.readOnly=true;form.querySelector('button').textContent='Register new passkey';return 'Existing passkey verified. Choose a new passkey or device.';} } const credential=await navigator.credentials.create(options(structuredClone(challenge))); await post('/cred/passkeys/register/finish',{challenge_id:challenge.id,response:serialize(credential)}); await credentialNavigate(); return 'Passkey registered.'; }); }else if(parts[0]==='approve'){ credentialView.querySelector('h2').textContent='Review credential request'; const id=parts[1],request=await credentialGet('requests/'+encodeURIComponent(id)); content.innerHTML=credentialReview(request,await credentialGet('items')); if(request.state!=='pending'){document.getElementById('credential-status').textContent='Request '+request.state+'.';return;} const grant=request.payload.grant_create||request.payload; const limits=!!request.payload.grant_create||!!request.payload.items?.length; const form=document.createElement('form'); form.innerHTML='
Limits
'; form.elements.duration.value=grant.duration_secs||(grant.exp?Math.max(1,grant.exp-Math.floor(Date.now()/1000)):3600);form.elements.uses.value=grant.max_uses||'';content.append(form); credentialForm(async(data,decision)=>{ const path='/cred/requests/'+encodeURIComponent(id); const overrides=decision==='deny'||!limits?{}:{duration_secs:Number(data.get('duration')),max_uses:data.get('uses')?Number(data.get('uses')):null}; const challenge=await post(path+'/challenge',{decision,overrides}); await post(path+'/decide',challenge.unlocked?{decision,overrides}:await assertion(challenge)); dismissCredentialRequest(id); await credentialNavigate(); return decision==='approve'?'Request approved.':'Request denied.'; }); }else{ credentialView.querySelector('h2').textContent='Enter a credential secret'; const items=await credentialGet('items'); const form=document.createElement('form');form.innerHTML='';content.append(form); for(const item of items)for(const field of item.fields.filter(field=>!field.public)){ const option=document.createElement('option');option.value=JSON.stringify([item.id,field.field_id]);option.textContent=item.name+' / '+field.name;form.elements.field.append(option); if(item.id===parts[1]&&field.field_id===parts[2])option.selected=true; } if(!form.elements.field.options.length){form.hidden=true;content.append(document.createTextNode('No secret fields exist. Create a credential item with a secret field first.'));return;} credentialForm(async data=>{ const [item,field]=JSON.parse(data.get('field')); const path='/cred/items/'+encodeURIComponent(item)+'/fields/'+encodeURIComponent(field); const {public_key}=await credentialGet('entry/key'); const encrypted=await encrypt(public_key,data.get('secret'));form.elements.secret.value=''; const proof=await assertion(await post(path+'/challenge',{ciphertext_sha256:encrypted.ciphertext_sha256})); await post(path,{...proof,envelope:encrypted.envelope}); return 'Secret saved.'; }); } }catch(error){credentialError(error);} } window.addEventListener('hashchange',credentialNavigate); credentialNavigate(); credentialState().catch(credentialError);