Merge pull request 'feat: Concurrent SQLite Session Manager' (#2) from feat/ipfs-helia into main
This commit was merged in pull request #2.
This commit is contained in:
@@ -11,7 +11,7 @@
|
|||||||
.container { max-width: 800px; margin: 0 auto; }
|
.container { max-width: 800px; margin: 0 auto; }
|
||||||
.card { background: #171717; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
|
.card { background: #171717; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
|
||||||
.card h2 { font-size: 0.875rem; color: #a3a3a3; margin-bottom: 0.5rem; font-weight: 500; }
|
.card h2 { font-size: 0.875rem; color: #a3a3a3; margin-bottom: 0.5rem; font-weight: 500; }
|
||||||
.status { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; }
|
.status { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; margin-right: 0.5rem; }
|
||||||
.status.ok { background: #14532d; color: #4ade80; }
|
.status.ok { background: #14532d; color: #4ade80; }
|
||||||
.status.err { background: #7f1d1d; color: #f87171; }
|
.status.err { background: #7f1d1d; color: #f87171; }
|
||||||
.status.wait { background: #422006; color: #fbbf24; }
|
.status.wait { background: #422006; color: #fbbf24; }
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
|
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
|
||||||
.clear-btn { background: #374151; padding: 0.25rem 0.5rem; font-size: 0.7rem; margin: 0; }
|
.clear-btn { background: #374151; padding: 0.25rem 0.5rem; font-size: 0.7rem; margin: 0; }
|
||||||
.clear-btn:hover { background: #4b5563; }
|
.clear-btn:hover { background: #4b5563; }
|
||||||
|
.status-row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -40,8 +41,10 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Status</h2>
|
<h2>Status</h2>
|
||||||
<span id="status" class="status wait">Loading...</span>
|
<div class="status-row">
|
||||||
<button onclick="runAllTests()" style="margin-left: 1rem;">Run All Tests</button>
|
<span id="status" class="status wait">Loading...</span>
|
||||||
|
<button onclick="runAllTests()" style="margin-left: 0.5rem;">Run All Tests</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -71,8 +74,9 @@
|
|||||||
<h2>load(database)</h2>
|
<h2>load(database)</h2>
|
||||||
<button class="clear-btn" onclick="clearCardLog('load')">Clear</button>
|
<button class="clear-btn" onclick="clearCardLog('load')">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="database" placeholder="Base64 database (auto-filled after generate)">
|
<div class="actions">
|
||||||
<button onclick="testLoad()">Run</button>
|
<button onclick="testLoadFromBytes()">Load from Bytes</button>
|
||||||
|
</div>
|
||||||
<div id="log-load" class="log"></div>
|
<div id="log-load" class="log"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,6 +89,7 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="testExec()">Run</button>
|
<button onclick="testExec()">Run</button>
|
||||||
<button onclick="setFilter('resource:accounts action:list')">Accounts</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:credentials action:list')">Credentials</button>
|
||||||
<button onclick="setFilter('resource:sessions action:list')">Sessions</button>
|
<button onclick="setFilter('resource:sessions action:list')">Sessions</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ function log(card, level, message, data = null) {
|
|||||||
console.log(`[${time}] [${card}] ${message}`, data ?? '');
|
console.log(`[${time}] [${card}] ${message}`, data ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(ok, message) {
|
function setStatus(id, ok, message) {
|
||||||
const el = document.getElementById('status');
|
const el = document.getElementById(id);
|
||||||
el.textContent = message;
|
if (el) {
|
||||||
el.className = `status ${ok ? 'ok' : 'err'}`;
|
el.textContent = message;
|
||||||
|
el.className = `status ${ok ? 'ok' : ok === false ? 'err' : 'wait'}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function arrayBufferToBase64(buffer) {
|
function arrayBufferToBase64(buffer) {
|
||||||
@@ -40,15 +42,6 @@ function arrayBufferToBase64(buffer) {
|
|||||||
return btoa(binary);
|
return btoa(binary);
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64ToArrayBuffer(base64) {
|
|
||||||
const binary = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let i = 0; i < binary.length; i++) {
|
|
||||||
bytes[i] = binary.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return bytes.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createWebAuthnCredential() {
|
async function createWebAuthnCredential() {
|
||||||
const userId = crypto.getRandomValues(new Uint8Array(16));
|
const userId = crypto.getRandomValues(new Uint8Array(16));
|
||||||
const challenge = crypto.getRandomValues(new Uint8Array(32));
|
const challenge = crypto.getRandomValues(new Uint8Array(32));
|
||||||
@@ -95,11 +88,13 @@ async function createWebAuthnCredential() {
|
|||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
log('generate', LogLevel.INFO, 'Loading enclave.wasm...');
|
log('generate', LogLevel.INFO, 'Loading enclave.wasm...');
|
||||||
|
|
||||||
enclave = await createEnclave('./enclave.wasm', { debug: true });
|
enclave = await createEnclave('./enclave.wasm', { debug: true });
|
||||||
setStatus(true, 'Ready');
|
|
||||||
|
setStatus('status', true, 'Ready');
|
||||||
log('generate', LogLevel.OK, 'Plugin loaded');
|
log('generate', LogLevel.OK, 'Plugin loaded');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(false, 'Failed');
|
setStatus('status', false, 'Failed');
|
||||||
log('generate', LogLevel.ERR, `Load failed: ${err?.message || String(err)}`);
|
log('generate', LogLevel.ERR, `Load failed: ${err?.message || String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,12 +138,12 @@ window.testGenerate = async function() {
|
|||||||
|
|
||||||
log('generate', LogLevel.INFO, 'Calling enclave.generate()...');
|
log('generate', LogLevel.INFO, 'Calling enclave.generate()...');
|
||||||
const result = await enclave.generate(credentialBase64);
|
const result = await enclave.generate(credentialBase64);
|
||||||
log('generate', LogLevel.OK, `DID created: ${result.did}`, { did: result.did, dbSize: result.database?.length });
|
|
||||||
|
const logData = { did: result.did, dbSize: result.database?.length };
|
||||||
|
log('generate', LogLevel.OK, `DID created: ${result.did}`, logData);
|
||||||
|
|
||||||
if (result.database) {
|
if (result.database) {
|
||||||
lastDatabase = result.database;
|
lastDatabase = result.database;
|
||||||
document.getElementById('database').value = btoa(String.fromCharCode(...result.database));
|
|
||||||
log('generate', LogLevel.INFO, 'Database saved for load() test');
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -178,12 +173,12 @@ window.testGenerateMock = async function() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await enclave.generate(mockCredential);
|
const result = await enclave.generate(mockCredential);
|
||||||
log('generate', LogLevel.OK, `DID created: ${result.did}`, { did: result.did, dbSize: result.database?.length });
|
|
||||||
|
const logData = { did: result.did, dbSize: result.database?.length };
|
||||||
|
log('generate', LogLevel.OK, `DID created: ${result.did}`, logData);
|
||||||
|
|
||||||
if (result.database) {
|
if (result.database) {
|
||||||
lastDatabase = result.database;
|
lastDatabase = result.database;
|
||||||
document.getElementById('database').value = btoa(String.fromCharCode(...result.database));
|
|
||||||
log('generate', LogLevel.INFO, 'Database saved for load() test');
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -192,17 +187,17 @@ window.testGenerateMock = async function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.testLoad = async function() {
|
window.testLoadFromBytes = async function() {
|
||||||
if (!enclave) return log('load', LogLevel.ERR, 'Plugin not loaded');
|
if (!enclave) return log('load', LogLevel.ERR, 'Plugin not loaded');
|
||||||
|
|
||||||
const b64 = document.getElementById('database').value;
|
if (!lastDatabase) {
|
||||||
if (!b64) return log('load', LogLevel.ERR, 'No database - run generate first');
|
return log('load', LogLevel.ERR, 'No database in memory - run generate first');
|
||||||
|
}
|
||||||
|
|
||||||
log('load', LogLevel.INFO, `Loading database (${b64.length} chars)...`);
|
log('load', LogLevel.INFO, `Loading from bytes (${lastDatabase.length} bytes)...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const database = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
const result = await enclave.load(new Uint8Array(lastDatabase));
|
||||||
const result = await enclave.load(database);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
log('load', LogLevel.OK, `Loaded DID: ${result.did}`, result);
|
log('load', LogLevel.OK, `Loaded DID: ${result.did}`, result);
|
||||||
@@ -273,7 +268,7 @@ window.runAllTests = async function() {
|
|||||||
try {
|
try {
|
||||||
await testPing();
|
await testPing();
|
||||||
await testGenerateMock();
|
await testGenerateMock();
|
||||||
await testLoad();
|
await testLoadFromBytes();
|
||||||
await testExec();
|
await testExec();
|
||||||
await testQuery();
|
await testQuery();
|
||||||
log('query', LogLevel.OK, '=== All tests passed ===');
|
log('query', LogLevel.OK, '=== All tests passed ===');
|
||||||
|
|||||||
543
internal/keybase/actions.go
Normal file
543
internal/keybase/actions.go
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
// Package keybase provides action handlers for concurrent database operations.
|
||||||
|
package keybase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActionManager provides thread-safe database operations with concurrent read support.
|
||||||
|
type ActionManager struct {
|
||||||
|
kb *Keybase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewActionManager creates an ActionManager from the current keybase instance.
|
||||||
|
func NewActionManager() (*ActionManager, error) {
|
||||||
|
kb := Get()
|
||||||
|
if kb == nil {
|
||||||
|
return nil, fmt.Errorf("keybase not initialized")
|
||||||
|
}
|
||||||
|
return &ActionManager{kb: kb}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ACCOUNT ACTIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// AccountResult represents an account in API responses.
|
||||||
|
type AccountResult struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
ChainID string `json:"chain_id"`
|
||||||
|
CoinType int64 `json:"coin_type"`
|
||||||
|
AccountIndex int64 `json:"account_index"`
|
||||||
|
AddressIndex int64 `json:"address_index"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
PublicKey string `json:"public_key,omitempty"`
|
||||||
|
Curve string `json:"curve,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAccounts returns all accounts for the current DID.
|
||||||
|
func (am *ActionManager) ListAccounts(ctx context.Context) ([]AccountResult, error) {
|
||||||
|
am.kb.mu.RLock()
|
||||||
|
defer am.kb.mu.RUnlock()
|
||||||
|
|
||||||
|
if am.kb.didID == 0 {
|
||||||
|
return []AccountResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := am.kb.queries.ListAccountsByDID(ctx, am.kb.didID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list accounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]AccountResult, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
label := ""
|
||||||
|
if row.Label != nil {
|
||||||
|
label = *row.Label
|
||||||
|
}
|
||||||
|
results[i] = AccountResult{
|
||||||
|
ID: row.ID,
|
||||||
|
Address: row.Address,
|
||||||
|
ChainID: row.ChainID,
|
||||||
|
CoinType: row.CoinType,
|
||||||
|
AccountIndex: row.AccountIndex,
|
||||||
|
AddressIndex: row.AddressIndex,
|
||||||
|
Label: label,
|
||||||
|
IsDefault: row.IsDefault == 1,
|
||||||
|
PublicKey: row.PublicKey,
|
||||||
|
Curve: row.Curve,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccountByAddress retrieves an account by its blockchain address.
|
||||||
|
func (am *ActionManager) GetAccountByAddress(ctx context.Context, address string) (*AccountResult, error) {
|
||||||
|
am.kb.mu.RLock()
|
||||||
|
defer am.kb.mu.RUnlock()
|
||||||
|
|
||||||
|
acc, err := am.kb.queries.GetAccountByAddress(ctx, address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
label := ""
|
||||||
|
if acc.Label != nil {
|
||||||
|
label = *acc.Label
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AccountResult{
|
||||||
|
ID: acc.ID,
|
||||||
|
Address: acc.Address,
|
||||||
|
ChainID: acc.ChainID,
|
||||||
|
CoinType: acc.CoinType,
|
||||||
|
AccountIndex: acc.AccountIndex,
|
||||||
|
AddressIndex: acc.AddressIndex,
|
||||||
|
Label: label,
|
||||||
|
IsDefault: acc.IsDefault == 1,
|
||||||
|
CreatedAt: acc.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CREDENTIAL ACTIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// CredentialResult represents a credential in API responses.
|
||||||
|
type CredentialResult struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
CredentialID string `json:"credential_id"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
DeviceType string `json:"device_type"`
|
||||||
|
Authenticator string `json:"authenticator"`
|
||||||
|
Transports []string `json:"transports"`
|
||||||
|
SignCount int64 `json:"sign_count"`
|
||||||
|
IsDiscoverable bool `json:"is_discoverable"`
|
||||||
|
BackedUp bool `json:"backed_up"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
LastUsed string `json:"last_used"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCredentials returns all credentials for the current DID.
|
||||||
|
func (am *ActionManager) ListCredentials(ctx context.Context) ([]CredentialResult, error) {
|
||||||
|
am.kb.mu.RLock()
|
||||||
|
defer am.kb.mu.RUnlock()
|
||||||
|
|
||||||
|
if am.kb.didID == 0 {
|
||||||
|
return []CredentialResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := am.kb.queries.ListCredentialsByDID(ctx, am.kb.didID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]CredentialResult, len(creds))
|
||||||
|
for i, cred := range creds {
|
||||||
|
var transports []string
|
||||||
|
if err := json.Unmarshal(cred.Transports, &transports); err != nil {
|
||||||
|
transports = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticator := ""
|
||||||
|
if cred.Authenticator != nil {
|
||||||
|
authenticator = *cred.Authenticator
|
||||||
|
}
|
||||||
|
|
||||||
|
results[i] = CredentialResult{
|
||||||
|
ID: cred.ID,
|
||||||
|
CredentialID: cred.CredentialID,
|
||||||
|
DeviceName: cred.DeviceName,
|
||||||
|
DeviceType: cred.DeviceType,
|
||||||
|
Authenticator: authenticator,
|
||||||
|
Transports: transports,
|
||||||
|
SignCount: cred.SignCount,
|
||||||
|
IsDiscoverable: cred.IsDiscoverable == 1,
|
||||||
|
BackedUp: cred.BackedUp == 1,
|
||||||
|
CreatedAt: cred.CreatedAt,
|
||||||
|
LastUsed: cred.LastUsed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCredentialByID retrieves a credential by its WebAuthn credential ID.
|
||||||
|
func (am *ActionManager) GetCredentialByID(ctx context.Context, credentialID string) (*CredentialResult, error) {
|
||||||
|
am.kb.mu.RLock()
|
||||||
|
defer am.kb.mu.RUnlock()
|
||||||
|
|
||||||
|
cred, err := am.kb.queries.GetCredentialByID(ctx, credentialID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get credential: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var transports []string
|
||||||
|
if err := json.Unmarshal(cred.Transports, &transports); err != nil {
|
||||||
|
transports = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticator := ""
|
||||||
|
if cred.Authenticator != nil {
|
||||||
|
authenticator = *cred.Authenticator
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CredentialResult{
|
||||||
|
ID: cred.ID,
|
||||||
|
CredentialID: cred.CredentialID,
|
||||||
|
DeviceName: cred.DeviceName,
|
||||||
|
DeviceType: cred.DeviceType,
|
||||||
|
Authenticator: authenticator,
|
||||||
|
Transports: transports,
|
||||||
|
SignCount: cred.SignCount,
|
||||||
|
IsDiscoverable: cred.IsDiscoverable == 1,
|
||||||
|
BackedUp: cred.BackedUp == 1,
|
||||||
|
CreatedAt: cred.CreatedAt,
|
||||||
|
LastUsed: cred.LastUsed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SESSION ACTIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// SessionResult represents a session in API responses.
|
||||||
|
type SessionResult struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
Authenticator string `json:"authenticator"`
|
||||||
|
DeviceInfo json.RawMessage `json:"device_info"`
|
||||||
|
IsCurrent bool `json:"is_current"`
|
||||||
|
LastActivity string `json:"last_activity"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessions returns all active sessions for the current DID.
|
||||||
|
func (am *ActionManager) ListSessions(ctx context.Context) ([]SessionResult, error) {
|
||||||
|
am.kb.mu.RLock()
|
||||||
|
defer am.kb.mu.RUnlock()
|
||||||
|
|
||||||
|
if am.kb.didID == 0 {
|
||||||
|
return []SessionResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := am.kb.queries.ListSessionsByDID(ctx, am.kb.didID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list sessions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]SessionResult, len(sessions))
|
||||||
|
for i, sess := range sessions {
|
||||||
|
authenticator := ""
|
||||||
|
if sess.Authenticator != nil {
|
||||||
|
authenticator = *sess.Authenticator
|
||||||
|
}
|
||||||
|
|
||||||
|
results[i] = SessionResult{
|
||||||
|
ID: sess.ID,
|
||||||
|
SessionID: sess.SessionID,
|
||||||
|
DeviceName: sess.DeviceName,
|
||||||
|
Authenticator: authenticator,
|
||||||
|
DeviceInfo: sess.DeviceInfo,
|
||||||
|
IsCurrent: sess.IsCurrent == 1,
|
||||||
|
LastActivity: sess.LastActivity,
|
||||||
|
ExpiresAt: sess.ExpiresAt,
|
||||||
|
CreatedAt: sess.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionParams contains parameters for creating a session.
|
||||||
|
type NewSessionParams struct {
|
||||||
|
CredentialID int64 `json:"credential_id"`
|
||||||
|
DeviceInfo json.RawMessage `json:"device_info"`
|
||||||
|
ExpiresIn time.Duration `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession creates a new session for the current DID.
|
||||||
|
func (am *ActionManager) CreateSession(ctx context.Context, params NewSessionParams) (*SessionResult, error) {
|
||||||
|
am.kb.mu.Lock()
|
||||||
|
defer am.kb.mu.Unlock()
|
||||||
|
|
||||||
|
if am.kb.didID == 0 {
|
||||||
|
return nil, fmt.Errorf("DID not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := generateSessionID()
|
||||||
|
expiresAt := time.Now().Add(params.ExpiresIn).UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
deviceInfo := params.DeviceInfo
|
||||||
|
if deviceInfo == nil {
|
||||||
|
deviceInfo = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := am.kb.queries.CreateSession(ctx, CreateSessionParams{
|
||||||
|
DidID: am.kb.didID,
|
||||||
|
CredentialID: params.CredentialID,
|
||||||
|
SessionID: sessionID,
|
||||||
|
DeviceInfo: deviceInfo,
|
||||||
|
IsCurrent: 1,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SessionResult{
|
||||||
|
ID: sess.ID,
|
||||||
|
SessionID: sess.SessionID,
|
||||||
|
DeviceInfo: sess.DeviceInfo,
|
||||||
|
IsCurrent: sess.IsCurrent == 1,
|
||||||
|
LastActivity: sess.LastActivity,
|
||||||
|
ExpiresAt: sess.ExpiresAt,
|
||||||
|
CreatedAt: sess.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSessionID creates a random session identifier.
|
||||||
|
func generateSessionID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
return "sess_" + hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeSession deletes a session by its ID.
|
||||||
|
func (am *ActionManager) RevokeSession(ctx context.Context, sessionID string) error {
|
||||||
|
am.kb.mu.Lock()
|
||||||
|
defer am.kb.mu.Unlock()
|
||||||
|
|
||||||
|
sess, err := am.kb.queries.GetSessionByID(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := am.kb.queries.DeleteSession(ctx, sess.ID); err != nil {
|
||||||
|
return fmt.Errorf("delete session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GRANT ACTIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// GrantResult represents a grant in API responses.
|
||||||
|
type GrantResult struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ServiceName string `json:"service_name"`
|
||||||
|
ServiceOrigin string `json:"service_origin"`
|
||||||
|
ServiceLogo string `json:"service_logo,omitempty"`
|
||||||
|
Scopes json.RawMessage `json:"scopes"`
|
||||||
|
Accounts json.RawMessage `json:"accounts"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
GrantedAt string `json:"granted_at"`
|
||||||
|
LastUsed string `json:"last_used,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGrants returns all active grants for the current DID.
|
||||||
|
func (am *ActionManager) ListGrants(ctx context.Context) ([]GrantResult, error) {
|
||||||
|
am.kb.mu.RLock()
|
||||||
|
defer am.kb.mu.RUnlock()
|
||||||
|
|
||||||
|
if am.kb.didID == 0 {
|
||||||
|
return []GrantResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
grants, err := am.kb.queries.ListGrantsByDID(ctx, am.kb.didID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list grants: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]GrantResult, len(grants))
|
||||||
|
for i, g := range grants {
|
||||||
|
serviceLogo := ""
|
||||||
|
if g.ServiceLogo != nil {
|
||||||
|
serviceLogo = *g.ServiceLogo
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUsed := ""
|
||||||
|
if g.LastUsed != nil {
|
||||||
|
lastUsed = *g.LastUsed
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := ""
|
||||||
|
if g.ExpiresAt != nil {
|
||||||
|
expiresAt = *g.ExpiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
results[i] = GrantResult{
|
||||||
|
ID: g.ID,
|
||||||
|
ServiceName: g.ServiceName,
|
||||||
|
ServiceOrigin: g.ServiceOrigin,
|
||||||
|
ServiceLogo: serviceLogo,
|
||||||
|
Scopes: g.Scopes,
|
||||||
|
Accounts: g.Accounts,
|
||||||
|
Status: g.Status,
|
||||||
|
GrantedAt: g.GrantedAt,
|
||||||
|
LastUsed: lastUsed,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeGrant revokes a grant by its ID.
|
||||||
|
func (am *ActionManager) RevokeGrant(ctx context.Context, grantID int64) error {
|
||||||
|
am.kb.mu.Lock()
|
||||||
|
defer am.kb.mu.Unlock()
|
||||||
|
|
||||||
|
if err := am.kb.queries.RevokeGrant(ctx, grantID); err != nil {
|
||||||
|
return fmt.Errorf("revoke grant: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// DID RESOLUTION
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// VerificationMethodResult represents a verification method in API responses.
|
||||||
|
type VerificationMethodResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Controller string `json:"controller"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DIDDocumentResult represents a resolved DID document.
|
||||||
|
type DIDDocumentResult struct {
|
||||||
|
DID string `json:"did"`
|
||||||
|
Controller string `json:"controller"`
|
||||||
|
VerificationMethods []VerificationMethodResult `json:"verification_methods"`
|
||||||
|
Accounts []AccountResult `json:"accounts"`
|
||||||
|
Credentials []CredentialResult `json:"credentials"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveDID resolves a DID document with all associated data.
|
||||||
|
func (am *ActionManager) ResolveDID(ctx context.Context, did string) (*DIDDocumentResult, error) {
|
||||||
|
am.kb.mu.RLock()
|
||||||
|
defer am.kb.mu.RUnlock()
|
||||||
|
|
||||||
|
// Get DID document
|
||||||
|
doc, err := am.kb.queries.GetDIDByDID(ctx, did)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get DID document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get verification methods
|
||||||
|
vms, err := am.kb.queries.ListVerificationMethods(ctx, doc.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list verification methods: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vmResults := make([]VerificationMethodResult, len(vms))
|
||||||
|
for i, vm := range vms {
|
||||||
|
vmResults[i] = VerificationMethodResult{
|
||||||
|
ID: vm.MethodID,
|
||||||
|
Type: vm.MethodType,
|
||||||
|
Controller: vm.Controller,
|
||||||
|
PublicKey: vm.PublicKey,
|
||||||
|
Purpose: vm.Purpose,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get accounts
|
||||||
|
accountRows, err := am.kb.queries.ListAccountsByDID(ctx, doc.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list accounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountResults := make([]AccountResult, len(accountRows))
|
||||||
|
for i, row := range accountRows {
|
||||||
|
label := ""
|
||||||
|
if row.Label != nil {
|
||||||
|
label = *row.Label
|
||||||
|
}
|
||||||
|
accountResults[i] = AccountResult{
|
||||||
|
ID: row.ID,
|
||||||
|
Address: row.Address,
|
||||||
|
ChainID: row.ChainID,
|
||||||
|
CoinType: row.CoinType,
|
||||||
|
AccountIndex: row.AccountIndex,
|
||||||
|
AddressIndex: row.AddressIndex,
|
||||||
|
Label: label,
|
||||||
|
IsDefault: row.IsDefault == 1,
|
||||||
|
PublicKey: row.PublicKey,
|
||||||
|
Curve: row.Curve,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get credentials
|
||||||
|
creds, err := am.kb.queries.ListCredentialsByDID(ctx, doc.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
credResults := make([]CredentialResult, len(creds))
|
||||||
|
for i, cred := range creds {
|
||||||
|
var transports []string
|
||||||
|
if err := json.Unmarshal(cred.Transports, &transports); err != nil {
|
||||||
|
transports = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticator := ""
|
||||||
|
if cred.Authenticator != nil {
|
||||||
|
authenticator = *cred.Authenticator
|
||||||
|
}
|
||||||
|
|
||||||
|
credResults[i] = CredentialResult{
|
||||||
|
ID: cred.ID,
|
||||||
|
CredentialID: cred.CredentialID,
|
||||||
|
DeviceName: cred.DeviceName,
|
||||||
|
DeviceType: cred.DeviceType,
|
||||||
|
Authenticator: authenticator,
|
||||||
|
Transports: transports,
|
||||||
|
SignCount: cred.SignCount,
|
||||||
|
IsDiscoverable: cred.IsDiscoverable == 1,
|
||||||
|
BackedUp: cred.BackedUp == 1,
|
||||||
|
CreatedAt: cred.CreatedAt,
|
||||||
|
LastUsed: cred.LastUsed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DIDDocumentResult{
|
||||||
|
DID: doc.Did,
|
||||||
|
Controller: doc.Controller,
|
||||||
|
VerificationMethods: vmResults,
|
||||||
|
Accounts: accountResults,
|
||||||
|
Credentials: credResults,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveCurrentDID resolves the current DID document.
|
||||||
|
func (am *ActionManager) ResolveCurrentDID(ctx context.Context) (*DIDDocumentResult, error) {
|
||||||
|
am.kb.mu.RLock()
|
||||||
|
did := am.kb.did
|
||||||
|
am.kb.mu.RUnlock()
|
||||||
|
|
||||||
|
if did == "" {
|
||||||
|
return nil, fmt.Errorf("no DID initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
return am.ResolveDID(ctx, did)
|
||||||
|
}
|
||||||
228
main.go
228
main.go
@@ -338,20 +338,31 @@ func executeAction(params *types.FilterParams) (json.RawMessage, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func executeAccountAction(params *types.FilterParams) (json.RawMessage, error) {
|
func executeAccountAction(params *types.FilterParams) (json.RawMessage, error) {
|
||||||
|
am, err := keybase.NewActionManager()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("action manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
switch params.Action {
|
switch params.Action {
|
||||||
case "list":
|
case "list":
|
||||||
accounts := []types.Account{
|
accounts, err := am.ListAccounts(ctx)
|
||||||
{
|
if err != nil {
|
||||||
Address: "sonr1abc123...",
|
return nil, fmt.Errorf("list accounts: %w", err)
|
||||||
ChainID: "sonr-mainnet-1",
|
|
||||||
CoinType: 118,
|
|
||||||
AccountIndex: 0,
|
|
||||||
AddressIndex: 0,
|
|
||||||
Label: "Primary",
|
|
||||||
IsDefault: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return json.Marshal(accounts)
|
return json.Marshal(accounts)
|
||||||
|
case "get":
|
||||||
|
if params.Subject == "" {
|
||||||
|
return nil, errors.New("subject (address) required for get action")
|
||||||
|
}
|
||||||
|
account, err := am.GetAccountByAddress(ctx, params.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get account: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(account)
|
||||||
|
case "balances":
|
||||||
|
return fetchAccountBalances(params.Subject)
|
||||||
case "sign":
|
case "sign":
|
||||||
return json.Marshal(map[string]string{"signature": "placeholder"})
|
return json.Marshal(map[string]string{"signature": "placeholder"})
|
||||||
default:
|
default:
|
||||||
@@ -359,33 +370,91 @@ 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) {
|
func executeCredentialAction(params *types.FilterParams) (json.RawMessage, error) {
|
||||||
|
am, err := keybase.NewActionManager()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("action manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
switch params.Action {
|
switch params.Action {
|
||||||
case "list":
|
case "list":
|
||||||
credentials := []types.Credential{
|
credentials, err := am.ListCredentials(ctx)
|
||||||
{
|
if err != nil {
|
||||||
CredentialID: "cred_abc123",
|
return nil, fmt.Errorf("list credentials: %w", err)
|
||||||
DeviceName: "MacBook Pro",
|
|
||||||
DeviceType: "platform",
|
|
||||||
Authenticator: "Touch ID",
|
|
||||||
Transports: []string{"internal"},
|
|
||||||
CreatedAt: "2025-01-01T00:00:00Z",
|
|
||||||
LastUsed: "2025-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return json.Marshal(credentials)
|
return json.Marshal(credentials)
|
||||||
|
case "get":
|
||||||
|
if params.Subject == "" {
|
||||||
|
return nil, errors.New("subject (credential_id) required for get action")
|
||||||
|
}
|
||||||
|
credential, err := am.GetCredentialByID(ctx, params.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get credential: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(credential)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown action for credentials: %s", params.Action)
|
return nil, fmt.Errorf("unknown action for credentials: %s", params.Action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeSessionAction(params *types.FilterParams) (json.RawMessage, error) {
|
func executeSessionAction(params *types.FilterParams) (json.RawMessage, error) {
|
||||||
|
am, err := keybase.NewActionManager()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("action manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
switch params.Action {
|
switch params.Action {
|
||||||
case "list":
|
case "list":
|
||||||
return json.Marshal([]map[string]any{})
|
sessions, err := am.ListSessions(ctx)
|
||||||
case "create":
|
if err != nil {
|
||||||
return json.Marshal(map[string]string{"session_id": "sess_placeholder"})
|
return nil, fmt.Errorf("list sessions: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(sessions)
|
||||||
case "revoke":
|
case "revoke":
|
||||||
|
if params.Subject == "" {
|
||||||
|
return nil, errors.New("subject (session_id) required for revoke action")
|
||||||
|
}
|
||||||
|
if err := am.RevokeSession(ctx, params.Subject); err != nil {
|
||||||
|
return nil, fmt.Errorf("revoke session: %w", err)
|
||||||
|
}
|
||||||
return json.Marshal(map[string]bool{"revoked": true})
|
return json.Marshal(map[string]bool{"revoked": true})
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown action for sessions: %s", params.Action)
|
return nil, fmt.Errorf("unknown action for sessions: %s", params.Action)
|
||||||
@@ -393,12 +462,31 @@ func executeSessionAction(params *types.FilterParams) (json.RawMessage, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func executeGrantAction(params *types.FilterParams) (json.RawMessage, error) {
|
func executeGrantAction(params *types.FilterParams) (json.RawMessage, error) {
|
||||||
|
am, err := keybase.NewActionManager()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("action manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
switch params.Action {
|
switch params.Action {
|
||||||
case "list":
|
case "list":
|
||||||
return json.Marshal([]map[string]any{})
|
grants, err := am.ListGrants(ctx)
|
||||||
case "create":
|
if err != nil {
|
||||||
return json.Marshal(map[string]string{"grant_id": "grant_placeholder"})
|
return nil, fmt.Errorf("list grants: %w", err)
|
||||||
|
}
|
||||||
|
return json.Marshal(grants)
|
||||||
case "revoke":
|
case "revoke":
|
||||||
|
if params.Subject == "" {
|
||||||
|
return nil, errors.New("subject (grant_id) required for revoke action")
|
||||||
|
}
|
||||||
|
var grantID int64
|
||||||
|
if _, err := fmt.Sscanf(params.Subject, "%d", &grantID); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid grant_id: %w", err)
|
||||||
|
}
|
||||||
|
if err := am.RevokeGrant(ctx, grantID); err != nil {
|
||||||
|
return nil, fmt.Errorf("revoke grant: %w", err)
|
||||||
|
}
|
||||||
return json.Marshal(map[string]bool{"revoked": true})
|
return json.Marshal(map[string]bool{"revoked": true})
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown action for grants: %s", params.Action)
|
return nil, fmt.Errorf("unknown action for grants: %s", params.Action)
|
||||||
@@ -406,41 +494,59 @@ func executeGrantAction(params *types.FilterParams) (json.RawMessage, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolveDID(did string) (*types.QueryOutput, error) {
|
func resolveDID(did string) (*types.QueryOutput, error) {
|
||||||
output := &types.QueryOutput{
|
am, err := keybase.NewActionManager()
|
||||||
DID: did,
|
if err != nil {
|
||||||
Controller: did,
|
return nil, fmt.Errorf("action manager: %w", err)
|
||||||
VerificationMethods: []types.VerificationMethod{
|
|
||||||
{
|
|
||||||
ID: did + "#key-1",
|
|
||||||
Type: "Ed25519VerificationKey2020",
|
|
||||||
Controller: did,
|
|
||||||
PublicKey: "placeholder_public_key",
|
|
||||||
Purpose: "authentication",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Accounts: []types.Account{
|
|
||||||
{
|
|
||||||
Address: "sonr1abc123...",
|
|
||||||
ChainID: "sonr-mainnet-1",
|
|
||||||
CoinType: 118,
|
|
||||||
AccountIndex: 0,
|
|
||||||
AddressIndex: 0,
|
|
||||||
Label: "Primary",
|
|
||||||
IsDefault: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Credentials: []types.Credential{
|
|
||||||
{
|
|
||||||
CredentialID: "cred_abc123",
|
|
||||||
DeviceName: "MacBook Pro",
|
|
||||||
DeviceType: "platform",
|
|
||||||
Authenticator: "Touch ID",
|
|
||||||
Transports: []string{"internal"},
|
|
||||||
CreatedAt: "2025-01-01T00:00:00Z",
|
|
||||||
LastUsed: "2025-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return output, nil
|
ctx := context.Background()
|
||||||
|
doc, err := am.ResolveDID(ctx, did)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve DID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vms := make([]types.VerificationMethod, len(doc.VerificationMethods))
|
||||||
|
for i, vm := range doc.VerificationMethods {
|
||||||
|
vms[i] = types.VerificationMethod{
|
||||||
|
ID: vm.ID,
|
||||||
|
Type: vm.Type,
|
||||||
|
Controller: vm.Controller,
|
||||||
|
PublicKey: vm.PublicKey,
|
||||||
|
Purpose: vm.Purpose,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts := make([]types.Account, len(doc.Accounts))
|
||||||
|
for i, acc := range doc.Accounts {
|
||||||
|
accounts[i] = types.Account{
|
||||||
|
Address: acc.Address,
|
||||||
|
ChainID: acc.ChainID,
|
||||||
|
CoinType: int(acc.CoinType),
|
||||||
|
AccountIndex: int(acc.AccountIndex),
|
||||||
|
AddressIndex: int(acc.AddressIndex),
|
||||||
|
Label: acc.Label,
|
||||||
|
IsDefault: acc.IsDefault,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials := make([]types.Credential, len(doc.Credentials))
|
||||||
|
for i, cred := range doc.Credentials {
|
||||||
|
credentials[i] = types.Credential{
|
||||||
|
CredentialID: cred.CredentialID,
|
||||||
|
DeviceName: cred.DeviceName,
|
||||||
|
DeviceType: cred.DeviceType,
|
||||||
|
Authenticator: cred.Authenticator,
|
||||||
|
Transports: cred.Transports,
|
||||||
|
CreatedAt: cred.CreatedAt,
|
||||||
|
LastUsed: cred.LastUsed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.QueryOutput{
|
||||||
|
DID: doc.DID,
|
||||||
|
Controller: doc.Controller,
|
||||||
|
VerificationMethods: vms,
|
||||||
|
Accounts: accounts,
|
||||||
|
Credentials: credentials,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,6 @@ import type {
|
|||||||
Resource,
|
Resource,
|
||||||
} from './types';
|
} 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);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class Enclave {
|
export class Enclave {
|
||||||
private plugin: Plugin;
|
private plugin: Plugin;
|
||||||
private logger: EnclaveOptions['logger'];
|
private logger: EnclaveOptions['logger'];
|
||||||
@@ -30,12 +19,6 @@ export class Enclave {
|
|||||||
this.debug = options.debug ?? false;
|
this.debug = options.debug ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(
|
static async create(
|
||||||
wasm: string | Uint8Array,
|
wasm: string | Uint8Array,
|
||||||
options: EnclaveOptions = {}
|
options: EnclaveOptions = {}
|
||||||
@@ -54,12 +37,6 @@ export class Enclave {
|
|||||||
return new Enclave(plugin, options);
|
return new Enclave(plugin, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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> {
|
async generate(credential: string): Promise<GenerateOutput> {
|
||||||
this.log('generate: starting with credential');
|
this.log('generate: starting with credential');
|
||||||
|
|
||||||
@@ -72,17 +49,20 @@ export class Enclave {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async load(source: Uint8Array | number[]): Promise<LoadOutput> {
|
||||||
* Load database from serialized buffer
|
this.log('load: loading database');
|
||||||
*
|
|
||||||
* @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');
|
|
||||||
|
|
||||||
const dbArray = database instanceof Uint8Array ? Array.from(database) : database;
|
let database: number[];
|
||||||
const input = JSON.stringify({ database: dbArray });
|
|
||||||
|
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);
|
const result = await this.plugin.call('load', input);
|
||||||
if (!result) throw new Error('load: plugin returned no output');
|
if (!result) throw new Error('load: plugin returned no output');
|
||||||
const output = result.json() as LoadOutput;
|
const output = result.json() as LoadOutput;
|
||||||
@@ -96,13 +76,6 @@ export class Enclave {
|
|||||||
return output;
|
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 exec(filter: string, token?: string): Promise<ExecOutput> {
|
async exec(filter: string, token?: string): Promise<ExecOutput> {
|
||||||
this.log(`exec: executing filter "${filter}"`);
|
this.log(`exec: executing filter "${filter}"`);
|
||||||
|
|
||||||
@@ -120,13 +93,6 @@ export class Enclave {
|
|||||||
return output;
|
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(
|
async execute(
|
||||||
resource: Resource,
|
resource: Resource,
|
||||||
action: string,
|
action: string,
|
||||||
@@ -139,12 +105,6 @@ export class Enclave {
|
|||||||
return this.exec(filter, options.token);
|
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> {
|
async query(did: string = ''): Promise<QueryOutput> {
|
||||||
this.log(`query: resolving DID ${did || '(current)'}`);
|
this.log(`query: resolving DID ${did || '(current)'}`);
|
||||||
|
|
||||||
@@ -169,17 +129,11 @@ export class Enclave {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset plugin state
|
|
||||||
*/
|
|
||||||
async reset(): Promise<void> {
|
async reset(): Promise<void> {
|
||||||
this.log('reset: clearing plugin state');
|
this.log('reset: clearing plugin state');
|
||||||
await this.plugin.reset();
|
await this.plugin.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Close and cleanup plugin resources
|
|
||||||
*/
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
this.log('close: releasing plugin resources');
|
this.log('close: releasing plugin resources');
|
||||||
await this.plugin.close();
|
await this.plugin.close();
|
||||||
@@ -192,22 +146,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(
|
export async function createEnclave(
|
||||||
wasm: string | Uint8Array,
|
wasm: string | Uint8Array,
|
||||||
options: EnclaveOptions = {}
|
options: EnclaveOptions = {}
|
||||||
|
|||||||
@@ -105,9 +105,7 @@ export interface Credential {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface EnclaveOptions {
|
export interface EnclaveOptions {
|
||||||
/** Custom logger (defaults to console) */
|
|
||||||
logger?: Pick<Console, 'log' | 'error' | 'warn' | 'info' | 'debug'>;
|
logger?: Pick<Console, 'log' | 'error' | 'warn' | 'info' | 'debug'>;
|
||||||
/** Enable debug logging */
|
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user