feat(enclave): add ipfs support
This commit is contained in:
@@ -85,6 +85,7 @@
|
||||
<div class="actions">
|
||||
<button onclick="testExec()">Run</button>
|
||||
<button onclick="setFilter('resource:accounts action:list')">Accounts</button>
|
||||
<button onclick="setFilter('resource:accounts action:balances subject:sonr1example')">Balances</button>
|
||||
<button onclick="setFilter('resource:credentials action:list')">Credentials</button>
|
||||
<button onclick="setFilter('resource:sessions action:list')">Sessions</button>
|
||||
</div>
|
||||
|
||||
36
main.go
36
main.go
@@ -352,6 +352,8 @@ func executeAccountAction(params *types.FilterParams) (json.RawMessage, error) {
|
||||
},
|
||||
}
|
||||
return json.Marshal(accounts)
|
||||
case "balances":
|
||||
return fetchAccountBalances(params.Subject)
|
||||
case "sign":
|
||||
return json.Marshal(map[string]string{"signature": "placeholder"})
|
||||
default:
|
||||
@@ -359,6 +361,40 @@ func executeAccountAction(params *types.FilterParams) (json.RawMessage, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAccountBalances(address string) (json.RawMessage, error) {
|
||||
if address == "" {
|
||||
address = state.GetDID()
|
||||
}
|
||||
|
||||
apiBase, ok := state.GetConfig("api_endpoint")
|
||||
if !ok {
|
||||
apiBase = "https://api.sonr.io"
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/cosmos/bank/v1beta1/balances/%s", apiBase, address)
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("fetchAccountBalances: GET %s", url))
|
||||
|
||||
req := pdk.NewHTTPRequest(pdk.MethodGet, url)
|
||||
req.SetHeader("Accept", "application/json")
|
||||
|
||||
res := req.Send()
|
||||
status := res.Status()
|
||||
|
||||
if status < 200 || status >= 300 {
|
||||
pdk.Log(pdk.LogError, fmt.Sprintf("fetchAccountBalances: HTTP %d", status))
|
||||
return json.Marshal(map[string]any{
|
||||
"error": "failed to fetch balances",
|
||||
"status": status,
|
||||
"address": address,
|
||||
})
|
||||
}
|
||||
|
||||
body := res.Body()
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("fetchAccountBalances: received %d bytes", len(body)))
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func executeCredentialAction(params *types.FilterParams) (json.RawMessage, error) {
|
||||
switch params.Action {
|
||||
case "list":
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bun build ./src/index.ts --outdir ./dist --format esm --target browser --sourcemap=external --external @extism/extism --entry-naming enclave.js && bun run tsc --emitDeclarationOnly --declaration -p src/tsconfig.json --outDir dist",
|
||||
"build": "bun build ./src/index.ts --outdir ./dist --format esm --target browser --sourcemap=external --external @extism/extism --external @helia/unixfs --external multiformats/cid --external helia --entry-naming enclave.js && bun run tsc --emitDeclarationOnly --declaration -p src/tsconfig.json --outDir dist",
|
||||
"typecheck": "tsc --noEmit -p src/tsconfig.json",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
@@ -28,5 +28,10 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@extism/extism": "^2.0.0-rc13"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@helia/unixfs": "^4.0.0",
|
||||
"helia": "^5.0.0",
|
||||
"multiformats": "^13.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
183
src/enclave.ts
183
src/enclave.ts
@@ -6,36 +6,28 @@ import type {
|
||||
ExecOutput,
|
||||
QueryOutput,
|
||||
Resource,
|
||||
HeliaInstance,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Motr Enclave - WebAssembly plugin wrapper for encrypted key storage
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createEnclave } from '@sonr/motr-enclave';
|
||||
*
|
||||
* const enclave = await createEnclave('/enclave.wasm');
|
||||
* const { did, database } = await enclave.generate(credential);
|
||||
* ```
|
||||
*/
|
||||
type UnixFS = {
|
||||
addBytes(bytes: Uint8Array): Promise<{ toString(): string }>;
|
||||
cat(cid: { toString(): string }): AsyncIterable<Uint8Array>;
|
||||
};
|
||||
|
||||
export class Enclave {
|
||||
private plugin: Plugin;
|
||||
private logger: EnclaveOptions['logger'];
|
||||
private debug: boolean;
|
||||
private helia: HeliaInstance | null;
|
||||
private unixfs: UnixFS | null = null;
|
||||
|
||||
private constructor(plugin: Plugin, options: EnclaveOptions = {}) {
|
||||
this.plugin = plugin;
|
||||
this.logger = options.logger ?? console;
|
||||
this.debug = options.debug ?? false;
|
||||
this.helia = options.ipfs ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Enclave instance from a WASM source
|
||||
*
|
||||
* @param wasm - URL string, file path, or Uint8Array of WASM bytes
|
||||
* @param options - Configuration options
|
||||
*/
|
||||
static async create(
|
||||
wasm: string | Uint8Array,
|
||||
options: EnclaveOptions = {}
|
||||
@@ -51,15 +43,23 @@ export class Enclave {
|
||||
logger: options.debug ? (options.logger as Console) : undefined,
|
||||
});
|
||||
|
||||
return new Enclave(plugin, options);
|
||||
const enclave = new Enclave(plugin, options);
|
||||
|
||||
if (options.ipfs) {
|
||||
await enclave.initIPFS();
|
||||
}
|
||||
|
||||
return enclave;
|
||||
}
|
||||
|
||||
private async initIPFS(): Promise<void> {
|
||||
if (!this.helia) return;
|
||||
|
||||
const { unixfs } = await import('@helia/unixfs');
|
||||
this.unixfs = unixfs(this.helia as Parameters<typeof unixfs>[0]);
|
||||
this.log('IPFS initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database with WebAuthn credential
|
||||
*
|
||||
* @param credential - Base64-encoded PublicKeyCredential from WebAuthn registration
|
||||
* @returns DID and serialized database buffer
|
||||
*/
|
||||
async generate(credential: string): Promise<GenerateOutput> {
|
||||
this.log('generate: starting with credential');
|
||||
|
||||
@@ -68,21 +68,55 @@ export class Enclave {
|
||||
if (!result) throw new Error('generate: plugin returned no output');
|
||||
const output = result.json() as GenerateOutput;
|
||||
|
||||
if (this.unixfs && output.database) {
|
||||
const bytes = new Uint8Array(output.database);
|
||||
const cid = await this.unixfs.addBytes(bytes);
|
||||
output.cid = cid.toString();
|
||||
this.log(`generate: stored to IPFS ${output.cid}`);
|
||||
}
|
||||
|
||||
this.log(`generate: created DID ${output.did}`);
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load database from serialized buffer
|
||||
*
|
||||
* @param database - Raw database bytes (from IPFS or storage)
|
||||
* @returns Success status and loaded DID
|
||||
*/
|
||||
async load(database: Uint8Array | number[]): Promise<LoadOutput> {
|
||||
this.log('load: loading database from buffer');
|
||||
async load(source: string | Uint8Array | number[]): Promise<LoadOutput> {
|
||||
this.log('load: loading database');
|
||||
|
||||
const dbArray = database instanceof Uint8Array ? Array.from(database) : database;
|
||||
const input = JSON.stringify({ database: dbArray });
|
||||
let database: number[];
|
||||
|
||||
if (typeof source === 'string' && source.startsWith('bafy')) {
|
||||
if (!this.unixfs) {
|
||||
throw new Error('load: IPFS not configured, cannot resolve CID');
|
||||
}
|
||||
|
||||
this.log(`load: fetching from IPFS ${source}`);
|
||||
const { CID } = await import('multiformats/cid');
|
||||
const cid = CID.parse(source);
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of this.unixfs.cat(cid)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((acc, c) => acc + c.length, 0);
|
||||
const bytes = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
database = Array.from(bytes);
|
||||
this.log(`load: fetched ${database.length} bytes from IPFS`);
|
||||
} else if (source instanceof Uint8Array) {
|
||||
database = Array.from(source);
|
||||
} else if (Array.isArray(source)) {
|
||||
database = source;
|
||||
} else {
|
||||
throw new Error('load: 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;
|
||||
@@ -96,13 +130,41 @@ export class Enclave {
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute action with filter syntax
|
||||
*
|
||||
* @param filter - GitHub-style filter (e.g., "resource:accounts action:list")
|
||||
* @param token - Optional UCAN token for authorization
|
||||
* @returns Action result
|
||||
*/
|
||||
async storeToIPFS(data: Uint8Array): Promise<string> {
|
||||
if (!this.unixfs) {
|
||||
throw new Error('storeToIPFS: IPFS not configured');
|
||||
}
|
||||
|
||||
const cid = await this.unixfs.addBytes(data);
|
||||
this.log(`storeToIPFS: stored ${data.length} bytes as ${cid.toString()}`);
|
||||
return cid.toString();
|
||||
}
|
||||
|
||||
async fetchFromIPFS(cidString: string): Promise<Uint8Array> {
|
||||
if (!this.unixfs) {
|
||||
throw new Error('fetchFromIPFS: IPFS not configured');
|
||||
}
|
||||
|
||||
const { CID } = await import('multiformats/cid');
|
||||
const cid = CID.parse(cidString);
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of this.unixfs.cat(cid)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((acc, c) => acc + c.length, 0);
|
||||
const bytes = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
this.log(`fetchFromIPFS: fetched ${bytes.length} bytes from ${cidString}`);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async exec(filter: string, token?: string): Promise<ExecOutput> {
|
||||
this.log(`exec: executing filter "${filter}"`);
|
||||
|
||||
@@ -120,13 +182,6 @@ export class Enclave {
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute action with typed parameters
|
||||
*
|
||||
* @param resource - Resource type (accounts, credentials, sessions, grants)
|
||||
* @param action - Action to perform
|
||||
* @param options - Additional options
|
||||
*/
|
||||
async execute(
|
||||
resource: Resource,
|
||||
action: string,
|
||||
@@ -139,12 +194,6 @@ export class Enclave {
|
||||
return this.exec(filter, options.token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query DID document and associated resources
|
||||
*
|
||||
* @param did - DID to resolve (empty for current DID)
|
||||
* @returns Resolved DID document with resources
|
||||
*/
|
||||
async query(did: string = ''): Promise<QueryOutput> {
|
||||
this.log(`query: resolving DID ${did || '(current)'}`);
|
||||
|
||||
@@ -169,22 +218,20 @@ export class Enclave {
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset plugin state
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
this.log('reset: clearing plugin state');
|
||||
await this.plugin.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and cleanup plugin resources
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.log('close: releasing plugin resources');
|
||||
await this.plugin.close();
|
||||
}
|
||||
|
||||
get ipfsEnabled(): boolean {
|
||||
return this.unixfs !== null;
|
||||
}
|
||||
|
||||
private log(message: string, level: 'log' | 'error' | 'warn' | 'info' | 'debug' = 'debug'): void {
|
||||
if (this.debug && this.logger) {
|
||||
this.logger[level](`[Enclave] ${message}`);
|
||||
@@ -192,22 +239,6 @@ export class Enclave {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Enclave instance
|
||||
*
|
||||
* @param wasm - URL string, file path, or Uint8Array of WASM bytes
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // From URL
|
||||
* const enclave = await createEnclave('/enclave.wasm');
|
||||
*
|
||||
* // From bytes
|
||||
* const wasmBytes = await fetch('/enclave.wasm').then(r => r.arrayBuffer());
|
||||
* const enclave = await createEnclave(new Uint8Array(wasmBytes));
|
||||
* ```
|
||||
*/
|
||||
export async function createEnclave(
|
||||
wasm: string | Uint8Array,
|
||||
options: EnclaveOptions = {}
|
||||
|
||||
12
src/types.ts
12
src/types.ts
@@ -17,6 +17,8 @@ export interface GenerateOutput {
|
||||
did: string;
|
||||
/** Serialized database buffer for storage */
|
||||
database: number[];
|
||||
/** IPFS CID of stored database (if IPFS enabled) */
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -105,10 +107,16 @@ export interface Credential {
|
||||
// ============================================================================
|
||||
|
||||
export interface EnclaveOptions {
|
||||
/** Custom logger (defaults to console) */
|
||||
logger?: Pick<Console, 'log' | 'error' | 'warn' | 'info' | 'debug'>;
|
||||
/** Enable debug logging */
|
||||
debug?: boolean;
|
||||
ipfs?: HeliaInstance;
|
||||
}
|
||||
|
||||
export interface HeliaInstance {
|
||||
blockstore: unknown;
|
||||
datastore: unknown;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user