feat(keybase): add action manager for concurrent database operations

This commit is contained in:
2026-01-07 21:00:22 -05:00
parent 7187743e83
commit f3a372123f

543
internal/keybase/actions.go Normal file
View 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)
}