SOURCE / PINNED RELEASE
Made of little things.
Body Journal
- Release
- e8049f8da4e2…
- Author-recorded commit
- 61c04bf0e8aa…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6c4554844c7
Archive hash verified: 4369d778da046a8b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { fs, storage, type CvmServerRef } from '@napplet/sdk';
import QRCode from 'qrcode';
import jsQR from 'jsqr';
import { escape as esc } from './case.js';
import { runtimeHasDomain } from './domain-availability.js';
import { createPrivateKey, parsePrivateKey, privateText, publicKey, fingerprint, digest, signChallenge, type Challenge } from './report-identity.js';
import { recoveryPdf } from './identity-backup.js';
import { PaidClient, type PaidOrder } from './inference-adapter.js';
const KEY = 'body-journal:report-identity:v1';
type Ui = { modal: (html: string) => void; toast: (text: string) => void; ref: () => CvmServerRef; recover: (order: PaidOrder) => Promise<void> };
type Entry = { orderId: string; status: string; createdAt: number; amountMsat: number };
export class IdentityUi {
private key?: Uint8Array;
private candidate?: Uint8Array;
private after?: () => void;
private client = new PaidClient();
private busy = false;
constructor(private ui: Ui) { document.addEventListener('click',this.click); }
get ready() { return !!this.key; }
async init() {
if (!runtimeHasDomain('storage')) return;
try { const saved=await storage.getItem(KEY); if(saved) this.key=parsePrivateKey(saved); }
catch { this.ui.toast('The report identity could not be restored. Import your recovery key to continue.'); }
}
open(after?: () => void) {
this.after=after; this.candidate?.fill(0); this.candidate=undefined;
this.ui.modal(`<span class="section-caption">YOUR PRIVATE REPORT IDENTITY</span><h2>${this.key ? 'Your reports, together.' : 'One key for your reports.'}</h2><p>A dedicated identity for AI Doc, separate from your shell login. Use the same key for multiple reports and restore them on another device.</p>${this.key ? `<p>Identity <strong>${esc(fingerprint(this.key))}</strong></p><div class="dialog-actions"><button class="primary-button" data-identity="history">View report history</button><button class="outline-button" data-identity="backup">Recovery key & PDF</button></div><button class="text-button" data-identity="confirm-forget">Switch / forget identity…</button>` : `<button class="primary-button" data-identity="create">Create private identity</button><details open><summary>Restore identity</summary><label class="field">Private recovery key<input id="restoreIdentity" type="password" autocomplete="off" spellcheck="false" placeholder="nsec or hex private key" /></label><div class="dialog-actions"><button class="outline-button" data-identity="restore">Restore key</button><button class="outline-button" data-identity="scan" ${runtimeHasDomain('fs') ? '' : 'disabled'}>Read QR from photo</button></div><p class="field-hint">Photograph your printed QR, then choose the image. It is decoded here; no image or private key is sent to the service. Use a dedicated key, never your main Nostr account key.</p></details>`}<details><summary>Privacy & recovery</summary><p>Reports under this identity are linkable at the service. The service and inference provider receive your reviewed medical input. Wallet and network records can still be correlated. This is not a guarantee of anonymity.</p><p>Your key recovers reports still retained by the selected service, not deleted reports or unsaved drafts. Existing recovery receipts continue to work.</p></details><p id="identityStatus" role="status" aria-live="polite"></p>`);
}
private async backup(candidate: Uint8Array, setup: boolean) {
const text=privateText(candidate), qr=await QRCode.toDataURL(text,{width:220,margin:4});
this.ui.modal(`<span class="section-caption">KEEP THIS PRIVATE</span><h2>${setup ? 'Save your recovery key.' : 'Your recovery key.'}</h2><p>Anyone with this key can access your retained reports. Keep the PDF offline; do not share it or send it to support.</p><img src="${qr}" alt="Private recovery key QR" style="display:block;width:180px;max-width:100%;margin:auto"/><label class="field">Private key<textarea id="identitySecret" readonly data-napplet-select="text" style="overflow-wrap:anywhere">${esc(text)}</textarea></label><p class="field-hint">Public identity ${esc(fingerprint(candidate))}</p><button class="outline-button" data-identity="pdf" ${runtimeHasDomain('fs') ? '' : 'disabled'}>Download printable PDF</button><p class="field-hint">Print the downloaded PDF using your PDF viewer. You can also copy the key above.</p>${setup ? `<label class="checkbox-field"><input id="rememberIdentity" type="checkbox" ${runtimeHasDomain('storage') ? '' : 'disabled'}/> Remember this identity in shell storage</label><p class="field-hint">Otherwise it lasts for this session. Shell storage is accessible to your host and has no encryption guarantee.</p><div class="dialog-actions"><button class="primary-button" data-identity="use">I saved my key — continue</button><button class="text-button" data-identity="unbacked">Continue without a backup…</button></div>` : '<button class="primary-button" data-identity="home">Done</button>'}<p id="identityStatus" role="status" aria-live="polite"></p>`);
}
async perform(action: string, args: Record<string,unknown>, ref=this.ui.ref()) {
if(!this.key) throw new Error('Restore your report identity first.');
const key=new Uint8Array(this.key);
try {
const response=await this.client.call(ref,'body_journal_identity_challenge',{pubkey:publicKey(key),action,digest:await digest(args)});
const challenge=response.challenge as Challenge;
const signature=await signChallenge(key,challenge,ref.pubkey,action,args);
return await this.client.call(ref,'body_journal_identity_execute',{challenge,signature,args});
} finally { key.fill(0); }
}
async attach(order: PaidOrder) {
await this.perform('attach',{receipt:order.receipt,caseText:order.caseText,imageCount:order.imageCount,photoCount:order.photoCount},order.ref);
}
private async history() {
const ref=structuredClone(this.ui.ref());
const result=await this.perform('list',{},ref);
const reports=result.reports as Entry[];
if(!Array.isArray(reports) || reports.some(r=>!r || !/^[a-f0-9]{64}$/.test(r.orderId) || !Number.isFinite(r.createdAt))) throw new Error('The service returned an invalid report list.');
this.ui.modal(`<span class="section-caption">YOUR PRIVATE REPORT IDENTITY</span><h2>Your reports.</h2><p>${esc(fingerprint(this.key!))} · ${reports.length} retained report${reports.length===1?'':'s'}</p>${reports.map(r=>`<article class="quiet-note"><div><strong>${esc(new Date(r.createdAt).toLocaleString())}</strong><p>${esc(r.status)} · ${Number(r.amountMsat)/1000} sats</p><button class="outline-button" data-identity="read:${r.orderId}">Open / recover</button>${r.status==='complete'?`<button class="text-button" data-identity="delete:${r.orderId}">Delete from service…</button>`:''}</div></article>`).join('') || '<p>No reports attached to this identity at this service yet.</p>'}<button class="text-button" data-identity="home">Back to identity</button><p id="identityStatus" role="status"></p>`);
}
private async scan() {
const picked=await fs.pickFile({accept:[{extension:'.png'},{extension:'.jpg'},{extension:'.jpeg'},{extension:'.webp'}],description:'Choose a photo of your private recovery QR'});
const entry=picked.entries[0]; if(!entry || entry.kind!=='file') throw new Error('Choose a recovery QR image.');
try {
const parts:Uint8Array<ArrayBuffer>[]=[];let offset=0;
for(;;) { const part=await fs.read(entry.path,{offset,length:262144}); const bytes=Uint8Array.from(atob(part.data),c=>c.charCodeAt(0));if(part.offset!==offset || bytes.length!==part.bytesRead || (!bytes.length&&!part.eof)) throw new Error('QR image could not be read.');offset+=bytes.length;if(offset>10*1024*1024) throw new Error('Choose a QR image smaller than 10 MiB.');parts.push(bytes);if(part.eof)break; }
const bitmap=await createImageBitmap(new Blob(parts));
try {
const scale=Math.min(1,1800/Math.max(bitmap.width,bitmap.height));const canvas=document.createElement('canvas');canvas.width=Math.round(bitmap.width*scale);canvas.height=Math.round(bitmap.height*scale);const ctx=canvas.getContext('2d');if(!ctx)throw new Error('Image decoding unavailable. Paste the key instead.');ctx.drawImage(bitmap,0,0,canvas.width,canvas.height);
const data=ctx.getImageData(0,0,canvas.width,canvas.height), decoded=jsQR(data.data,data.width,data.height);
if(!decoded)throw new Error('No recovery QR found. Use a sharper photo or paste the key.');
this.candidate=parsePrivateKey(decoded.data);await this.backup(this.candidate,true);
} finally { bitmap.close(); }
} finally { try { await fs.remove(entry.path); } catch { /* host temporary import expires at teardown */ } }
}
private click=(event: Event)=>{
const button=(event.target as Element).closest<HTMLButtonElement>('[data-identity]');if(!button || this.busy)return;
this.busy=true;button.disabled=true;
const status=document.querySelector('#identityStatus');if(status)status.textContent='Working…';
void this.action(button.dataset.identity!).catch(()=>{const el=document.querySelector('#identityStatus');if(el)el.textContent='This operation could not be completed. Check your recovery key and service connection, then try again. Your existing key and reports were kept.';}).finally(()=>{this.busy=false;if(button.isConnected)button.disabled=false;if(status?.isConnected && status.textContent==='Working…')status.textContent='';});
};
private async action(action:string) {
if(action==='home')this.open();
if(action==='create'){this.candidate=createPrivateKey();await this.backup(this.candidate,true);}
if(action==='restore'){this.candidate=parsePrivateKey((document.querySelector('#restoreIdentity') as HTMLInputElement).value);await this.backup(this.candidate,true);}
if(action==='backup')await this.backup(this.key!,false);
if(action==='scan')await this.scan();
if(action==='pdf'){
const bytes=recoveryPdf(this.candidate??this.key!,this.ui.ref().pubkey);
const picked=await fs.pickSaveFile({suggestedName:'ai-doc-private-recovery.pdf',description:'Save a private recovery PDF. Anyone with this file can access your reports.'});const file=picked.entries[0];if(!file)throw new Error('No destination.');
let binary='';for(const b of bytes)binary+=String.fromCharCode(b);
const result=await fs.write(file.path,btoa(binary));if(result.bytesWritten!==bytes.length)throw new Error('Backup write incomplete.');
this.ui.toast('Recovery PDF saved. Keep it private; print it from your PDF viewer.');
}
if(action==='unbacked'){
const remember=(document.querySelector('#rememberIdentity') as HTMLInputElement).checked;
this.ui.modal(`<h2>Continue without a backup?</h2><p>If you lose this key, you cannot restore your report collection. You can still back it up later during this session.</p><input id="rememberIdentity" type="checkbox" hidden ${remember?'checked':''}/><button class="primary-button" data-identity="use">Continue anyway</button><button class="outline-button" data-identity="candidate-backup">Back to backup</button><p id="identityStatus" role="status"></p>`);
}
if(action==='candidate-backup')await this.backup(this.candidate!,true);
if(action==='use'){
const remember=(document.querySelector('#rememberIdentity') as HTMLInputElement).checked;
if(runtimeHasDomain('storage')){if(remember)await storage.setItem(KEY,privateText(this.candidate!));else await storage.removeItem(KEY);}
this.key?.fill(0);this.key=this.candidate;this.candidate=undefined;
const after=this.after;this.after=undefined;if(after)after();else await this.history();
}
if(action==='history')await this.history();
if(action.startsWith('read:')){const ref=structuredClone(this.ui.ref());const result=await this.perform('read',{orderId:action.slice(5)},ref);await this.ui.recover({...result.recovery as PaidOrder,ref});}
if(action.startsWith('delete:'))this.ui.modal(`<h2>Delete this report from the service?</h2><p>This removes the stored report and its recovery data. Payment records and existing backups may remain. Your local draft is unchanged. This cannot be undone.</p><button class="primary-button" data-identity="erase:${esc(action.slice(7))}">Delete report</button><button class="outline-button" data-identity="history">Cancel</button><p id="identityStatus" role="status"></p>`);
if(action.startsWith('erase:')){await this.perform('delete',{orderId:action.slice(6)});await this.history();}
if(action==='confirm-forget')this.ui.modal('<h2>Forget this identity here?</h2><p>Make sure you have its recovery key. This removes the remembered key, but does not delete reports on the service or local drafts and receipts.</p><button class="primary-button" data-identity="forget">Forget identity</button><button class="outline-button" data-identity="home">Cancel</button><p id="identityStatus" role="status"></p>');
if(action==='forget'){if(runtimeHasDomain('storage'))await storage.removeItem(KEY);this.key?.fill(0);this.key=undefined;this.open();}
}
dispose(){this.key?.fill(0);this.candidate?.fill(0);document.removeEventListener('click',this.click);}
}
