diff --git a/example/index.html b/example/index.html index b6b10fd..fc308fd 100644 --- a/example/index.html +++ b/example/index.html @@ -3,108 +3,497 @@ - Motr Enclave + Motr Enclave Demo -
-

Motr Enclave

+
+ -
-

Status

-
- Loading... - -
-
- -
-
-

ping(message)

- -
- - -
-
- -
-
-

generate(credential)

- -
-
- - -
-
-
- -
-
-

load(database)

- -
-
- -
-
-
- -
-
-

exec(filter)

- -
- -
- - - - - -
-
-
- -
-
-

query(did)

- -
- - -
-
+
+
+

Setup & Initialize

+ +
+
+

ping() health check

+ +
+ +
+
+
+ +
+

generate() create wallet

+
+ + +
+
+
+
+ +
+

load() restore wallet

+
+ + + +
+
+
+
+ +
+

Resource Explorer

+ +
+

Select Resource

+
+ + + + + + + + +
+
+ +
+

Actions for accounts

+
+ + +
+ + + +
+ + +
+
+ +
+

Result

+
+ Execute an action to see results +
+
+
+ +
+

DID Query

+ +
+

query() resolve DID document

+ +
+ +
+
+ +
+

DID Document

+
+ Query a DID to see the document +
+
+
+
diff --git a/example/main.js b/example/main.js index 115ced2..97b527f 100644 --- a/example/main.js +++ b/example/main.js @@ -2,8 +2,22 @@ import { createEnclave } from '../dist/enclave.js'; let enclave = null; let lastDatabase = null; +let currentResource = 'accounts'; +let currentAction = 'list'; -const LogLevel = { INFO: 'info', OK: 'ok', ERR: 'err', DATA: 'data' }; +const RESOURCE_ACTIONS = { + accounts: ['list', 'get'], + enclaves: ['list', 'get', 'rotate', 'archive', 'delete'], + credentials: ['list', 'get'], + sessions: ['list', 'revoke'], + grants: ['list', 'revoke'], + delegations: ['list', 'list_received', 'list_command', 'get', 'revoke', 'verify', 'cleanup'], + ucans: ['list', 'get', 'revoke', 'verify', 'cleanup'], + verification_methods: ['list', 'get', 'delete'], + services: ['list', 'get', 'get_by_id'], +}; + +const ACTIONS_REQUIRING_SUBJECT = ['get', 'revoke', 'delete', 'verify', 'rotate', 'archive', 'list_received', 'list_command', 'get_by_id']; function log(card, level, message, data = null) { const el = document.getElementById(`log-${card}`); @@ -14,22 +28,30 @@ function log(card, level, message, data = null) { let entry = `
${time} ${message}`; if (data !== null) { const json = typeof data === 'string' ? data : JSON.stringify(data, null, 2); - entry += `${json}`; + entry += `${escapeHtml(json)}`; } entry += '
'; el.innerHTML += entry; - el.classList.add('has-content'); + el.classList.add('visible'); el.scrollTop = el.scrollHeight; console.log(`[${time}] [${card}] ${message}`, data ?? ''); } -function setStatus(id, ok, message) { - const el = document.getElementById(id); - if (el) { - el.textContent = message; - el.className = `status ${ok ? 'ok' : ok === false ? 'err' : 'wait'}`; +function escapeHtml(str) { + return str.replace(/&/g, '&').replace(//g, '>'); +} + +function setStatus(ready, message) { + const dot = document.getElementById('status-dot'); + const text = document.getElementById('status-text'); + + if (dot) { + dot.className = 'status-dot' + (ready === true ? ' ready' : ready === false ? ' error' : ''); + } + if (text) { + text.textContent = message; } } @@ -46,16 +68,16 @@ async function createWebAuthnCredential() { const userId = crypto.getRandomValues(new Uint8Array(16)); const challenge = crypto.getRandomValues(new Uint8Array(32)); - const publicKeyCredentialCreationOptions = { + const options = { challenge, rp: { - name: "Motr Enclave", + name: "Motr Enclave Demo", id: window.location.hostname, }, user: { id: userId, name: `user-${Date.now()}@motr.local`, - displayName: "Motr User", + displayName: "Motr Demo User", }, pubKeyCredParams: [ { alg: -7, type: "public-key" }, @@ -70,9 +92,7 @@ async function createWebAuthnCredential() { attestation: "none", }; - const credential = await navigator.credentials.create({ - publicKey: publicKeyCredentialCreationOptions, - }); + const credential = await navigator.credentials.create({ publicKey: options }); return { id: credential.id, @@ -87,60 +107,71 @@ async function createWebAuthnCredential() { async function init() { try { - log('generate', LogLevel.INFO, 'Loading enclave.wasm...'); - + setStatus(null, 'Loading...'); enclave = await createEnclave('./enclave.wasm', { debug: true }); - - setStatus('status', true, 'Ready'); - log('generate', LogLevel.OK, 'Plugin loaded'); + setStatus(true, 'Ready'); + log('generate', 'ok', 'Plugin loaded successfully'); } catch (err) { - setStatus('status', false, 'Failed'); - log('generate', LogLevel.ERR, `Load failed: ${err?.message || String(err)}`); + setStatus(false, 'Failed'); + log('generate', 'err', `Load failed: ${err?.message || String(err)}`); } } -window.testPing = async function() { - if (!enclave) return log('ping', LogLevel.ERR, 'Plugin not loaded'); +window.showSection = function(section) { + document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active')); + document.querySelectorAll('.nav-btn').forEach(el => el.classList.remove('active')); - const message = document.getElementById('ping-msg').value || 'hello'; - log('ping', LogLevel.INFO, `Sending: "${message}"`); + const sectionEl = document.getElementById(`section-${section}`); + if (sectionEl) sectionEl.classList.add('active'); + + event?.target?.classList.add('active'); +}; + +window.testPing = async function() { + if (!enclave) return log('ping', 'err', 'Plugin not loaded'); + + const message = document.getElementById('ping-msg')?.value || 'hello'; + log('ping', 'info', `Sending: "${message}"`); try { const result = await enclave.ping(message); if (result.success) { - log('ping', LogLevel.OK, `Response: "${result.echo}"`, result); + log('ping', 'ok', `Response: "${result.echo}"`, result); } else { - log('ping', LogLevel.ERR, result.message, result); + log('ping', 'err', result.message, result); } return result; } catch (err) { - log('ping', LogLevel.ERR, err?.message || String(err)); + log('ping', 'err', err?.message || String(err)); throw err; } }; window.testGenerate = async function() { - if (!enclave) return log('generate', LogLevel.ERR, 'Plugin not loaded'); + if (!enclave) return log('generate', 'err', 'Plugin not loaded'); if (!window.PublicKeyCredential) { - log('generate', LogLevel.ERR, 'WebAuthn not supported in this browser'); + log('generate', 'err', 'WebAuthn not supported in this browser'); return; } try { - log('generate', LogLevel.INFO, 'Requesting WebAuthn credential...'); - + log('generate', 'info', 'Requesting WebAuthn credential...'); const credential = await createWebAuthnCredential(); - log('generate', LogLevel.OK, `Credential created: ${credential.id.slice(0, 20)}...`); + log('generate', 'ok', `Credential created: ${credential.id.slice(0, 20)}...`); - const credentialJson = JSON.stringify(credential); - const credentialBase64 = btoa(credentialJson); + const credentialBase64 = btoa(JSON.stringify(credential)); - log('generate', LogLevel.INFO, 'Calling enclave.generate()...'); + log('generate', 'info', 'Calling enclave.generate()...'); const result = await enclave.generate(credentialBase64); - const logData = { did: result.did, dbSize: result.database?.length }; - log('generate', LogLevel.OK, `DID created: ${result.did}`, logData); + log('generate', 'ok', `DID created: ${result.did}`, { + did: result.did, + enclaveId: result.enclave_id, + publicKey: result.public_key?.slice(0, 20) + '...', + accounts: result.accounts?.length ?? 0, + dbSize: result.database?.length ?? 0, + }); if (result.database) { lastDatabase = result.database; @@ -148,16 +179,16 @@ window.testGenerate = async function() { return result; } catch (err) { if (err.name === 'NotAllowedError') { - log('generate', LogLevel.ERR, 'User cancelled or WebAuthn not allowed'); + log('generate', 'err', 'User cancelled or WebAuthn not allowed'); } else { - log('generate', LogLevel.ERR, err?.message || String(err)); + log('generate', 'err', err?.message || String(err)); } throw err; } }; window.testGenerateMock = async function() { - if (!enclave) return log('generate', LogLevel.ERR, 'Plugin not loaded'); + if (!enclave) return log('generate', 'err', 'Plugin not loaded'); const mockCredential = btoa(JSON.stringify({ id: `mock-${Date.now()}`, @@ -169,111 +200,246 @@ window.testGenerateMock = async function() { }, })); - log('generate', LogLevel.INFO, 'Using mock credential...'); + log('generate', 'info', 'Using mock credential...'); try { const result = await enclave.generate(mockCredential); - const logData = { did: result.did, dbSize: result.database?.length }; - log('generate', LogLevel.OK, `DID created: ${result.did}`, logData); + log('generate', 'ok', `DID created: ${result.did}`, { + did: result.did, + enclaveId: result.enclave_id, + publicKey: result.public_key?.slice(0, 20) + '...', + accounts: result.accounts?.length ?? 0, + dbSize: result.database?.length ?? 0, + }); if (result.database) { lastDatabase = result.database; } return result; } catch (err) { - log('generate', LogLevel.ERR, err?.message || String(err)); + log('generate', 'err', err?.message || String(err)); throw err; } }; window.testLoadFromBytes = async function() { - if (!enclave) return log('load', LogLevel.ERR, 'Plugin not loaded'); + if (!enclave) return log('load', 'err', 'Plugin not loaded'); if (!lastDatabase) { - return log('load', LogLevel.ERR, 'No database in memory - run generate first'); + return log('load', 'err', 'No database in memory - run generate first'); } - log('load', LogLevel.INFO, `Loading from bytes (${lastDatabase.length} bytes)...`); + log('load', 'info', `Loading from bytes (${lastDatabase.length} bytes)...`); try { const result = await enclave.load(new Uint8Array(lastDatabase)); if (result.success) { - log('load', LogLevel.OK, `Loaded DID: ${result.did}`, result); + log('load', 'ok', `Loaded DID: ${result.did}`, result); } else { - log('load', LogLevel.ERR, result.error, result); + log('load', 'err', result.error, result); } return result; } catch (err) { - log('load', LogLevel.ERR, err?.message || String(err)); + log('load', 'err', err?.message || String(err)); throw err; } }; -window.testExec = async function() { - if (!enclave) return log('exec', LogLevel.ERR, 'Plugin not loaded'); +window.downloadDatabase = function() { + if (!lastDatabase) { + alert('No database in memory. Run generate first.'); + return; + } - const filter = document.getElementById('filter').value; - if (!filter) return log('exec', LogLevel.ERR, 'Filter required'); + const blob = new Blob([new Uint8Array(lastDatabase)], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `enclave-${Date.now()}.db`; + a.click(); + URL.revokeObjectURL(url); - log('exec', LogLevel.INFO, `Executing: ${filter}`); + log('load', 'ok', `Downloaded database (${lastDatabase.length} bytes)`); +}; + +window.uploadDatabase = async function(event) { + const file = event.target.files?.[0]; + if (!file) return; + + const buffer = await file.arrayBuffer(); + lastDatabase = Array.from(new Uint8Array(buffer)); + + log('load', 'info', `Uploaded ${file.name} (${lastDatabase.length} bytes)`); + + if (enclave) { + await testLoadFromBytes(); + } +}; + +window.selectResource = function(resource) { + currentResource = resource; + currentAction = 'list'; + + document.querySelectorAll('.resource-btn').forEach(el => el.classList.remove('active')); + event?.target?.closest('.resource-btn')?.classList.add('active'); + + document.getElementById('selected-resource').textContent = resource; + + const actions = RESOURCE_ACTIONS[resource] || ['list']; + const pillsContainer = document.getElementById('action-pills'); + pillsContainer.innerHTML = actions.map((action, i) => + `` + ).join(''); + + updateSubjectRow(); +}; + +window.selectAction = function(action) { + currentAction = action; + + document.querySelectorAll('.action-pill').forEach(el => el.classList.remove('active')); + event?.target?.classList.add('active'); + + updateSubjectRow(); +}; + +function updateSubjectRow() { + const subjectRow = document.getElementById('subject-row'); + const subjectInput = document.getElementById('subject-input'); + + if (ACTIONS_REQUIRING_SUBJECT.includes(currentAction)) { + subjectRow.style.display = 'block'; + + const placeholders = { + get: 'ID or address', + revoke: 'ID to revoke', + delete: 'ID to delete', + verify: 'CID to verify', + rotate: 'Enclave ID', + archive: 'Enclave ID', + list_received: 'Audience DID', + list_command: 'Command (e.g., msg/send)', + get_by_id: 'Service ID (number)', + }; + + subjectInput.placeholder = placeholders[currentAction] || 'Subject'; + } else { + subjectRow.style.display = 'none'; + subjectInput.value = ''; + } +} + +window.executeAction = async function() { + if (!enclave) { + showResult('Plugin not loaded', true); + return; + } + + const subject = document.getElementById('subject-input')?.value || ''; + + if (ACTIONS_REQUIRING_SUBJECT.includes(currentAction) && !subject) { + showResult(`Subject is required for action: ${currentAction}`, true); + return; + } + + let filter = `resource:${currentResource} action:${currentAction}`; + if (subject) { + filter += ` subject:${subject}`; + } try { const result = await enclave.exec(filter); if (result.success) { - log('exec', LogLevel.OK, 'Success', result); + showResult(JSON.stringify(result.result, null, 2), false); } else { - log('exec', LogLevel.ERR, result.error, result); + showResult(result.error, true); } - return result; } catch (err) { - log('exec', LogLevel.ERR, err?.message || String(err)); - throw err; + showResult(err?.message || String(err), true); } }; +window.quickExec = async function(resource, action) { + if (!enclave) { + alert('Plugin not loaded'); + return; + } + + showSection('explorer'); + selectResource(resource); + selectAction(action); + await executeAction(); +}; + +function showResult(content, isError = false) { + const resultEl = document.getElementById('exec-result'); + resultEl.textContent = content; + resultEl.className = 'result-panel' + (isError ? ' error' : ' success'); +} + +window.clearResult = function() { + const resultEl = document.getElementById('exec-result'); + resultEl.innerHTML = 'Execute an action to see results'; + resultEl.className = 'result-panel'; +}; + window.testQuery = async function() { - if (!enclave) return log('query', LogLevel.ERR, 'Plugin not loaded'); + if (!enclave) { + showQueryResult('Plugin not loaded', true); + return; + } - const did = document.getElementById('did').value; - log('query', LogLevel.INFO, did ? `Querying: ${did}` : 'Querying current DID...'); + const did = document.getElementById('did-input')?.value || ''; try { const result = await enclave.query(did); - log('query', LogLevel.OK, `Resolved: ${result.did}`, result); - return result; + showQueryResult(JSON.stringify(result, null, 2), false); } catch (err) { - log('query', LogLevel.ERR, err?.message || String(err)); - throw err; + showQueryResult(err?.message || String(err), true); } }; -window.setFilter = function(filter) { - document.getElementById('filter').value = filter; -}; +function showQueryResult(content, isError = false) { + const resultEl = document.getElementById('query-result'); + resultEl.textContent = content; + resultEl.className = 'result-panel' + (isError ? ' error' : ' success'); +} -window.clearCardLog = function(card) { - const el = document.getElementById(`log-${card}`); - if (el) { - el.innerHTML = ''; - el.classList.remove('has-content'); +window.resetEnclave = async function() { + if (!enclave) return; + + try { + await enclave.reset(); + lastDatabase = null; + log('generate', 'info', 'Enclave state reset'); + clearResult(); + document.getElementById('query-result').innerHTML = 'Query a DID to see the document'; + } catch (err) { + log('generate', 'err', `Reset failed: ${err?.message || String(err)}`); } }; window.runAllTests = async function() { - log('ping', LogLevel.INFO, '=== Running all tests ==='); + log('generate', 'info', '=== Running all tests ==='); try { await testPing(); await testGenerateMock(); await testLoadFromBytes(); - await testExec(); + + showSection('explorer'); + await quickExec('accounts', 'list'); + await quickExec('enclaves', 'list'); + + showSection('query'); await testQuery(); - log('query', LogLevel.OK, '=== All tests passed ==='); + + log('generate', 'ok', '=== All tests completed ==='); } catch (err) { - log('query', LogLevel.ERR, `Tests failed: ${err?.message || String(err)}`); + log('generate', 'err', `Tests failed: ${err?.message || String(err)}`); } }; diff --git a/src/enclave.ts b/src/enclave.ts index b9e4b56..7d6d2fc 100644 --- a/src/enclave.ts +++ b/src/enclave.ts @@ -8,6 +8,24 @@ import type { Resource, } from './types'; +function extractErrorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + if (typeof err === 'object' && err !== null) { + const obj = err as Record; + if (typeof obj.message === 'string') return obj.message; + if (typeof obj.error === 'string') return obj.error; + if (typeof obj.msg === 'string') return obj.msg; + try { + return JSON.stringify(err); + } catch { + return '[unknown error object]'; + } + } + return String(err); +} + export class Enclave { private plugin: Plugin; private logger: EnclaveOptions['logger']; @@ -41,12 +59,20 @@ export class Enclave { this.log('generate: starting with credential'); const input = JSON.stringify({ credential }); - const result = await this.plugin.call('generate', input); - if (!result) throw new Error('generate: plugin returned no output'); - const output = result.json() as GenerateOutput; - - this.log(`generate: created DID ${output.did}`); - return output; + + try { + const result = await this.plugin.call('generate', input); + if (!result) { + throw new Error('generate: plugin returned no output'); + } + const output = result.json() as GenerateOutput; + this.log(`generate: created DID ${output.did}`); + return output; + } catch (err) { + const errMsg = extractErrorMessage(err); + this.log(`generate: failed - ${errMsg}`, 'error'); + throw new Error(`generate: ${errMsg}`); + } } async load(source: Uint8Array | number[]): Promise { @@ -59,38 +85,52 @@ export class Enclave { } else if (Array.isArray(source)) { database = source; } else { - throw new Error('load: invalid source type'); + return { success: false, error: 'invalid source type' }; } const input = JSON.stringify({ database }); - const result = await this.plugin.call('load', input); - if (!result) throw new Error('load: plugin returned no output'); - const output = result.json() as LoadOutput; - - if (output.success) { - this.log(`load: loaded database for DID ${output.did}`); - } else { - this.log(`load: failed - ${output.error}`, 'error'); + + try { + const result = await this.plugin.call('load', input); + if (!result) { + return { success: false, error: 'plugin returned no output' }; + } + const output = result.json() as LoadOutput; + if (output.success) { + this.log(`load: loaded database for DID ${output.did}`); + } else { + this.log(`load: failed - ${output.error}`, 'error'); + } + return output; + } catch (err) { + const errMsg = extractErrorMessage(err); + this.log(`load: failed - ${errMsg}`, 'error'); + return { success: false, error: errMsg }; } - - return output; } async exec(filter: string, token?: string): Promise { this.log(`exec: executing filter "${filter}"`); const input = JSON.stringify({ filter, token }); - const result = await this.plugin.call('exec', input); - if (!result) throw new Error('exec: plugin returned no output'); - const output = result.json() as ExecOutput; - - if (output.success) { - this.log('exec: completed successfully'); - } else { - this.log(`exec: failed - ${output.error}`, 'error'); + + try { + const result = await this.plugin.call('exec', input); + if (!result) { + return { success: false, error: 'plugin returned no output' }; + } + const output = result.json() as ExecOutput; + if (output.success) { + this.log('exec: completed successfully'); + } else { + this.log(`exec: failed - ${output.error}`, 'error'); + } + return output; + } catch (err) { + const errMsg = extractErrorMessage(err); + this.log(`exec: failed - ${errMsg}`, 'error'); + return { success: false, error: errMsg }; } - - return output; } async execute( @@ -109,24 +149,40 @@ export class Enclave { this.log(`query: resolving DID ${did || '(current)'}`); const input = JSON.stringify({ did }); - const result = await this.plugin.call('query', input); - if (!result) throw new Error('query: plugin returned no output'); - const output = result.json() as QueryOutput; - - this.log(`query: resolved DID ${output.did}`); - return output; + + try { + const result = await this.plugin.call('query', input); + if (!result) { + throw new Error('query: plugin returned no output'); + } + const output = result.json() as QueryOutput; + this.log(`query: resolved DID ${output.did}`); + return output; + } catch (err) { + const errMsg = extractErrorMessage(err); + this.log(`query: failed - ${errMsg}`, 'error'); + throw new Error(`query: ${errMsg}`); + } } async ping(message: string = 'hello'): Promise<{ success: boolean; message: string; echo: string }> { this.log(`ping: sending "${message}"`); const input = JSON.stringify({ message }); - const result = await this.plugin.call('ping', input); - if (!result) throw new Error('ping: plugin returned no output'); - const output = result.json() as { success: boolean; message: string; echo: string }; - - this.log(`ping: received ${output.success ? 'pong' : 'error'}`); - return output; + + try { + const result = await this.plugin.call('ping', input); + if (!result) { + throw new Error('ping: plugin returned no output'); + } + const output = result.json() as { success: boolean; message: string; echo: string }; + this.log(`ping: received ${output.success ? 'pong' : 'error'}`); + return output; + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + this.log(`ping: failed - ${errMsg}`, 'error'); + throw new Error(`ping: ${errMsg}`); + } } async reset(): Promise { diff --git a/src/types.ts b/src/types.ts index 5e0088c..e1c02ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,18 @@ export interface GenerateOutput { did: string; /** Serialized database buffer for storage */ database: number[]; + /** The MPC enclave ID */ + enclave_id?: string; + /** The public key hex */ + public_key?: string; + /** Default accounts created */ + accounts?: AccountInfo[]; +} + +export interface AccountInfo { + address: string; + chain_id: string; + coin_type: number; } // ============================================================================