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
+
+
-
-
Status
-
- Loading...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ 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;
}
// ============================================================================