feat: Concurrent SQLite Session Manager #2
@@ -11,7 +11,7 @@
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
.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; }
|
||||
.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.err { background: #7f1d1d; color: #f87171; }
|
||||
.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; }
|
||||
.clear-btn { background: #374151; padding: 0.25rem 0.5rem; font-size: 0.7rem; margin: 0; }
|
||||
.clear-btn:hover { background: #4b5563; }
|
||||
.status-row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -40,8 +41,10 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>Status</h2>
|
||||
<span id="status" class="status wait">Loading...</span>
|
||||
<button onclick="runAllTests()" style="margin-left: 1rem;">Run All Tests</button>
|
||||
<div class="status-row">
|
||||
<span id="status" class="status wait">Loading...</span>
|
||||
<button onclick="runAllTests()" style="margin-left: 0.5rem;">Run All Tests</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -71,8 +74,9 @@
|
||||
<h2>load(database)</h2>
|
||||
<button class="clear-btn" onclick="clearCardLog('load')">Clear</button>
|
||||
</div>
|
||||
<input type="text" id="database" placeholder="Base64 database (auto-filled after generate)">
|
||||
<button onclick="testLoad()">Run</button>
|
||||
<div class="actions">
|
||||
<button onclick="testLoadFromBytes()">Load from Bytes</button>
|
||||
</div>
|
||||
<div id="log-load" class="log"></div>
|
||||
</div>
|
||||
|
||||
@@ -85,6 +89,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>
|
||||
|
||||
@@ -25,10 +25,12 @@ function log(card, level, message, data = null) {
|
||||
console.log(`[${time}] [${card}] ${message}`, data ?? '');
|
||||
}
|
||||
|
||||
function setStatus(ok, message) {
|
||||
const el = document.getElementById('status');
|
||||
el.textContent = message;
|
||||
el.className = `status ${ok ? 'ok' : 'err'}`;
|
||||
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 arrayBufferToBase64(buffer) {
|
||||
@@ -40,15 +42,6 @@ function arrayBufferToBase64(buffer) {
|
||||
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() {
|
||||
const userId = crypto.getRandomValues(new Uint8Array(16));
|
||||
const challenge = crypto.getRandomValues(new Uint8Array(32));
|
||||
@@ -95,11 +88,13 @@ async function createWebAuthnCredential() {
|
||||
async function init() {
|
||||
try {
|
||||
log('generate', LogLevel.INFO, 'Loading enclave.wasm...');
|
||||
|
||||
enclave = await createEnclave('./enclave.wasm', { debug: true });
|
||||
setStatus(true, 'Ready');
|
||||
|
||||
setStatus('status', true, 'Ready');
|
||||
log('generate', LogLevel.OK, 'Plugin loaded');
|
||||
} catch (err) {
|
||||
setStatus(false, 'Failed');
|
||||
setStatus('status', false, 'Failed');
|
||||
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()...');
|
||||
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) {
|
||||
lastDatabase = result.database;
|
||||
document.getElementById('database').value = btoa(String.fromCharCode(...result.database));
|
||||
log('generate', LogLevel.INFO, 'Database saved for load() test');
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
@@ -178,12 +173,12 @@ window.testGenerateMock = async function() {
|
||||
|
||||
try {
|
||||
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) {
|
||||
lastDatabase = result.database;
|
||||
document.getElementById('database').value = btoa(String.fromCharCode(...result.database));
|
||||
log('generate', LogLevel.INFO, 'Database saved for load() test');
|
||||
}
|
||||
return result;
|
||||
} 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');
|
||||
|
||||
const b64 = document.getElementById('database').value;
|
||||
if (!b64) return log('load', LogLevel.ERR, 'No database - run generate first');
|
||||
if (!lastDatabase) {
|
||||
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 {
|
||||
const database = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||||
const result = await enclave.load(database);
|
||||
const result = await enclave.load(new Uint8Array(lastDatabase));
|
||||
|
||||
if (result.success) {
|
||||
log('load', LogLevel.OK, `Loaded DID: ${result.did}`, result);
|
||||
@@ -273,7 +268,7 @@ window.runAllTests = async function() {
|
||||
try {
|
||||
await testPing();
|
||||
await testGenerateMock();
|
||||
await testLoad();
|
||||
await testLoadFromBytes();
|
||||
await testExec();
|
||||
await testQuery();
|
||||
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) {
|
||||
am, err := keybase.NewActionManager()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("action manager: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
switch params.Action {
|
||||
case "list":
|
||||
accounts := []types.Account{
|
||||
{
|
||||
Address: "sonr1abc123...",
|
||||
ChainID: "sonr-mainnet-1",
|
||||
CoinType: 118,
|
||||
AccountIndex: 0,
|
||||
AddressIndex: 0,
|
||||
Label: "Primary",
|
||||
IsDefault: true,
|
||||
},
|
||||
accounts, err := am.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list accounts: %w", err)
|
||||
}
|
||||
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":
|
||||
return json.Marshal(map[string]string{"signature": "placeholder"})
|
||||
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) {
|
||||
am, err := keybase.NewActionManager()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("action manager: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
switch params.Action {
|
||||
case "list":
|
||||
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",
|
||||
},
|
||||
credentials, err := am.ListCredentials(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list credentials: %w", err)
|
||||
}
|
||||
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:
|
||||
return nil, fmt.Errorf("unknown action for credentials: %s", params.Action)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
case "list":
|
||||
return json.Marshal([]map[string]any{})
|
||||
case "create":
|
||||
return json.Marshal(map[string]string{"session_id": "sess_placeholder"})
|
||||
sessions, err := am.ListSessions(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
return json.Marshal(sessions)
|
||||
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})
|
||||
default:
|
||||
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) {
|
||||
am, err := keybase.NewActionManager()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("action manager: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
switch params.Action {
|
||||
case "list":
|
||||
return json.Marshal([]map[string]any{})
|
||||
case "create":
|
||||
return json.Marshal(map[string]string{"grant_id": "grant_placeholder"})
|
||||
grants, err := am.ListGrants(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list grants: %w", err)
|
||||
}
|
||||
return json.Marshal(grants)
|
||||
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})
|
||||
default:
|
||||
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) {
|
||||
output := &types.QueryOutput{
|
||||
DID: did,
|
||||
Controller: did,
|
||||
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",
|
||||
},
|
||||
},
|
||||
am, err := keybase.NewActionManager()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("action manager: %w", err)
|
||||
}
|
||||
|
||||
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,
|
||||
} 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 {
|
||||
private plugin: Plugin;
|
||||
private logger: EnclaveOptions['logger'];
|
||||
@@ -30,12 +19,6 @@ export class Enclave {
|
||||
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(
|
||||
wasm: string | Uint8Array,
|
||||
options: EnclaveOptions = {}
|
||||
@@ -54,12 +37,6 @@ export class Enclave {
|
||||
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> {
|
||||
this.log('generate: starting with credential');
|
||||
|
||||
@@ -72,17 +49,20 @@ export class Enclave {
|
||||
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: 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 (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 +76,6 @@ 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 exec(filter: string, token?: string): Promise<ExecOutput> {
|
||||
this.log(`exec: executing filter "${filter}"`);
|
||||
|
||||
@@ -120,13 +93,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 +105,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,17 +129,11 @@ 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();
|
||||
@@ -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(
|
||||
wasm: string | Uint8Array,
|
||||
options: EnclaveOptions = {}
|
||||
|
||||
@@ -105,9 +105,7 @@ export interface Credential {
|
||||
// ============================================================================
|
||||
|
||||
export interface EnclaveOptions {
|
||||
/** Custom logger (defaults to console) */
|
||||
logger?: Pick<Console, 'log' | 'error' | 'warn' | 'info' | 'debug'>;
|
||||
/** Enable debug logging */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user