refactor(infra): split de drivers pesados a subpaquetes + fix TestSSEHandler
Mueve duckdb_open, clickhouse_open, postgres_open, matrix_* y keyring_token_store
del paquete monolitico functions/infra a subpaquetes propios
(functions/infra/{duckdb,clickhouse,postgres,matrix,keyring}). El paquete infra ya
no importa los drivers (go-duckdb, clickhouse-go, pgx, mautrix, go-keyring), por lo
que las apps que solo usan funciones ligeras (process, cron, http, sqlite) dejan de
arrastrarlos. Reduccion de binarios: dag_engine 72->10MB, registry_api 70->8.7MB,
services_api 70->9MB, call_monitor 68->6.6MB, sqlite_api 70->8.9MB.
Los IDs del registry se mantienen estables (domain: infra en frontmatter). Se
preservan los build tags goolm/libolm de matrix_crypto_init.
Tambien corrige TestSSEHandler: el test leia el body con un unico Read() que con
HTTP chunked solo capturaba el primer evento; ahora usa io.ReadAll hasta EOF.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package keyring
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
keyring "github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned by Load when no token exists for the given account.
|
||||
var ErrNotFound = errors.New("token not found in keyring")
|
||||
|
||||
// Token holds OAuth/OIDC credentials that need to survive app restarts.
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never expires
|
||||
UserID string `json:"user_id"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
HomeserverURL string `json:"homeserver_url"`
|
||||
Issuer string `json:"issuer,omitempty"` // MAS/OIDC issuer URL
|
||||
ClientID string `json:"client_id,omitempty"` // MAS client_id used
|
||||
}
|
||||
|
||||
// KeyringTokenStore persists tokens in the OS keyring (Secret Service on Linux,
|
||||
// Keychain on macOS, Credential Manager on Windows).
|
||||
type KeyringTokenStore struct {
|
||||
// Service is the keyring namespace. Keep it stable across app versions.
|
||||
// Example: "fn_registry.matrix_client_pc"
|
||||
Service string
|
||||
}
|
||||
|
||||
// NewKeyringTokenStore returns a store scoped to the given service name.
|
||||
func NewKeyringTokenStore(service string) *KeyringTokenStore {
|
||||
return &KeyringTokenStore{Service: service}
|
||||
}
|
||||
|
||||
// Save serialises t to JSON and writes it to the keyring under (service, account).
|
||||
// Overwrites silently if an entry already exists.
|
||||
// account is typically the user ID, e.g. "@user:homeserver.example.com".
|
||||
func (s *KeyringTokenStore) Save(account string, t Token) error {
|
||||
b, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keyring save: marshal: %w", err)
|
||||
}
|
||||
if err := keyring.Set(s.Service, account, string(b)); err != nil {
|
||||
return fmt.Errorf("keyring save: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load retrieves and deserialises the token stored under (service, account).
|
||||
// Returns ErrNotFound if no entry exists. Callers should check with errors.Is.
|
||||
func (s *KeyringTokenStore) Load(account string) (*Token, error) {
|
||||
raw, err := keyring.Get(s.Service, account)
|
||||
if err != nil {
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("keyring load: %w", err)
|
||||
}
|
||||
var t Token
|
||||
if err := json.Unmarshal([]byte(raw), &t); err != nil {
|
||||
return nil, fmt.Errorf("keyring load: unmarshal: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// Delete removes the token for account from the keyring.
|
||||
// Idempotent: if no entry exists, returns nil.
|
||||
func (s *KeyringTokenStore) Delete(account string) error {
|
||||
err := keyring.Delete(s.Service, account)
|
||||
if err != nil && !errors.Is(err, keyring.ErrNotFound) {
|
||||
return fmt.Errorf("keyring delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: keyring_token_store
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: |
|
||||
type KeyringTokenStore struct { Service string }
|
||||
func NewKeyringTokenStore(service string) *KeyringTokenStore
|
||||
func (s *KeyringTokenStore) Save(account string, t Token) error
|
||||
func (s *KeyringTokenStore) Load(account string) (*Token, error)
|
||||
func (s *KeyringTokenStore) Delete(account string) error
|
||||
var ErrNotFound = errors.New("token not found in keyring")
|
||||
description: "Persiste tokens OAuth/OIDC entre arranques usando el keyring del SO (Secret Service en Linux, Keychain en macOS, Credential Manager en Windows). Serializa Token a JSON cifrado at-rest por el OS."
|
||||
tags: [security, keyring, tokens, oauth, persistence, infra, matrix-mas]
|
||||
params:
|
||||
- name: service
|
||||
desc: "Namespace estable del keyring para esta app. Ej: 'fn_registry.matrix_client_pc'. No compartir entre apps."
|
||||
- name: account
|
||||
desc: "Identificador unico del token. Ej: user_id '@egutierrez:matrix-af2f3d.organic-machine.com'."
|
||||
- name: Token.AccessToken
|
||||
desc: "Access token OAuth/OIDC. Campo obligatorio."
|
||||
- name: Token.RefreshToken
|
||||
desc: "Refresh token. Omitido en JSON si vacio."
|
||||
- name: Token.ExpiresAt
|
||||
desc: "Momento de expiracion del access token. Zero = nunca expira."
|
||||
- name: Token.UserID
|
||||
desc: "Identificador del usuario, ej. '@user:homeserver'. Obligatorio."
|
||||
- name: Token.DeviceID
|
||||
desc: "Device ID asignado por el homeserver Matrix."
|
||||
- name: Token.HomeserverURL
|
||||
desc: "URL base del homeserver Matrix. Ej: 'https://matrix-af2f3d.organic-machine.com'."
|
||||
- name: Token.Issuer
|
||||
desc: "URL del emisor OIDC/MAS. Requerido para flows OIDC."
|
||||
- name: Token.ClientID
|
||||
desc: "Client ID usado en el flow MAS/OIDC."
|
||||
output: "Save/Delete retornan error envuelto con contexto. Load retorna *Token o ErrNotFound (chequear con errors.Is)."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: true
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- "encoding/json"
|
||||
- "errors"
|
||||
- "fmt"
|
||||
- "time"
|
||||
- "github.com/zalando/go-keyring"
|
||||
tested: true
|
||||
tests:
|
||||
- "Save then Load returns matching token"
|
||||
- "Load nonexistent returns ErrNotFound"
|
||||
- "Save then Delete then Load returns ErrNotFound"
|
||||
- "Delete nonexistent is idempotent"
|
||||
- "Save twice overwrites with second token"
|
||||
test_file_path: "functions/infra/keyring/keyring_token_store_test.go"
|
||||
file_path: "functions/infra/keyring/keyring_token_store.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
store := NewKeyringTokenStore("fn_registry.matrix_client_pc")
|
||||
|
||||
tok := Token{
|
||||
AccessToken: "mxat_abc123...",
|
||||
RefreshToken: "mxrt_xyz789...",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
UserID: "@egutierrez:matrix-af2f3d.organic-machine.com",
|
||||
DeviceID: "ABCDEF1234",
|
||||
HomeserverURL: "https://matrix-af2f3d.organic-machine.com",
|
||||
Issuer: "https://auth-af2f3d.organic-machine.com/",
|
||||
ClientID: "VDC4XQ2ZKN2TJ0BYVJ54FK7M6Y",
|
||||
}
|
||||
|
||||
if err := store.Save(tok.UserID, tok); err != nil {
|
||||
log.Fatalf("save token: %v", err)
|
||||
}
|
||||
|
||||
// En otro arranque de la app:
|
||||
got, err := store.Load("@egutierrez:matrix-af2f3d.organic-machine.com")
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// primer login — arrancar flujo OAuth
|
||||
}
|
||||
|
||||
// Logout o revocacion:
|
||||
_ = store.Delete(tok.UserID)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usar cuando una app desktop Go necesite persistir tokens OAuth/OIDC entre arranques sin escribirlos en disco en texto plano. Ideal para matrix_client_pc (tokens MAS + Matrix), admin_panel, cualquier CLI Go con login interactivo. Usar en el path de callback OAuth justo despues de recibir el token, y en el arranque para recuperarlo antes de pedir credenciales.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Linux requiere D-Bus session activa.** En servidores headless sin GUI, `keyring.Set` falla. En contenedores Docker sin Secret Service (GNOME Keyring / KWallet): NO funciona salvo montaje de socket D-Bus. Para apps que corren en servidor headless, usar un fallback de archivo cifrado (fuera del scope de esta funcion).
|
||||
- **Windows Credential Manager** tiene limite de ~2500 chars por entrada. Tokens JWT largos (base64 > 2KB) pueden cortarse. Mantener tokens < 2KB o comprimir/dividir.
|
||||
- **macOS Keychain** vincula el entry al binario que lo creo. Si el ejecutable cambia de firma (recompilacion con nuevo cert), el sistema pide permiso al usuario otra vez. Es comportamiento esperado de macOS, no un bug.
|
||||
- **Snap / Flatpak** en Linux pueden bloquear el acceso a Secret Service por sandboxing. Documentar en el README de apps distribuidas via estos formatos.
|
||||
- El JSON serializado almacena el AccessToken en texto plano dentro del entry del keyring. El keyring del SO cifra at-rest, pero no usar esto para secretos de alta seguridad (claves privadas, PINs bancarios, etc.).
|
||||
- **Nunca loggear** el valor de AccessToken/RefreshToken. Los errores solo incluyen la descripcion del fallo, nunca el valor.
|
||||
- `List()` no esta implementada en v0.1.0 porque `go-keyring` no expone listado de accounts. TODO: implementar via index local en `os.UserConfigDir()/service/accounts.json` si se necesita en el futuro.
|
||||
|
||||
## Notas
|
||||
|
||||
Depende de `github.com/zalando/go-keyring` (v0.2.x). En Linux usa `github.com/godbus/dbus/v5` para comunicarse con Secret Service. En Windows usa `github.com/danieljoos/wincred`. Ambas dependencias se agregan transitivamente.
|
||||
|
||||
El campo `Token.ExpiresAt` se redondea a segundos al serializar/deserializar con `time.Time` en JSON. El caller debe comparar con `time.Now().Before(t.ExpiresAt)` para saber si el token ha expirado.
|
||||
@@ -0,0 +1,126 @@
|
||||
package keyring
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
keyring "github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func TestKeyringTokenStore(t *testing.T) {
|
||||
// Probe whether the OS keyring is available. If not, skip gracefully
|
||||
// (CI Linux headless, Docker containers without Secret Service).
|
||||
probeService := fmt.Sprintf("fn_registry.test.probe.%d", time.Now().UnixNano())
|
||||
probeErr := keyring.Set(probeService, "probe", "ok")
|
||||
if probeErr != nil {
|
||||
t.Skipf("keyring not available (headless/CI): %v", probeErr)
|
||||
}
|
||||
// Clean up the probe entry.
|
||||
_ = keyring.Delete(probeService, "probe")
|
||||
|
||||
// Use a timestamped service name so parallel test runs don't collide.
|
||||
service := fmt.Sprintf("fn_registry.test.%d", time.Now().UnixNano())
|
||||
store := NewKeyringTokenStore(service)
|
||||
|
||||
sampleToken := Token{
|
||||
AccessToken: "mxat_test_access",
|
||||
RefreshToken: "mxrt_test_refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour).UTC().Truncate(time.Second),
|
||||
UserID: "@testuser:matrix.example.com",
|
||||
DeviceID: "TESTDEV01",
|
||||
HomeserverURL: "https://matrix.example.com",
|
||||
Issuer: "https://auth.example.com/",
|
||||
ClientID: "TESTCLIENT123",
|
||||
}
|
||||
|
||||
t.Run("Save then Load returns matching token", func(t *testing.T) {
|
||||
account := sampleToken.UserID
|
||||
t.Cleanup(func() { _ = store.Delete(account) })
|
||||
|
||||
if err := store.Save(account, sampleToken); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
got, err := store.Load(account)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.AccessToken != sampleToken.AccessToken {
|
||||
t.Errorf("AccessToken: got %q, want %q", got.AccessToken, sampleToken.AccessToken)
|
||||
}
|
||||
if got.RefreshToken != sampleToken.RefreshToken {
|
||||
t.Errorf("RefreshToken: got %q, want %q", got.RefreshToken, sampleToken.RefreshToken)
|
||||
}
|
||||
if !got.ExpiresAt.Equal(sampleToken.ExpiresAt) {
|
||||
t.Errorf("ExpiresAt: got %v, want %v", got.ExpiresAt, sampleToken.ExpiresAt)
|
||||
}
|
||||
if got.UserID != sampleToken.UserID {
|
||||
t.Errorf("UserID: got %q, want %q", got.UserID, sampleToken.UserID)
|
||||
}
|
||||
if got.DeviceID != sampleToken.DeviceID {
|
||||
t.Errorf("DeviceID: got %q, want %q", got.DeviceID, sampleToken.DeviceID)
|
||||
}
|
||||
if got.HomeserverURL != sampleToken.HomeserverURL {
|
||||
t.Errorf("HomeserverURL: got %q, want %q", got.HomeserverURL, sampleToken.HomeserverURL)
|
||||
}
|
||||
if got.Issuer != sampleToken.Issuer {
|
||||
t.Errorf("Issuer: got %q, want %q", got.Issuer, sampleToken.Issuer)
|
||||
}
|
||||
if got.ClientID != sampleToken.ClientID {
|
||||
t.Errorf("ClientID: got %q, want %q", got.ClientID, sampleToken.ClientID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Load nonexistent returns ErrNotFound", func(t *testing.T) {
|
||||
_, err := store.Load("@nobody:missing.example.com")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Save then Delete then Load returns ErrNotFound", func(t *testing.T) {
|
||||
account := "@delete_me:matrix.example.com"
|
||||
if err := store.Save(account, sampleToken); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if err := store.Delete(account); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
_, err := store.Load(account)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound after Delete, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Delete nonexistent is idempotent", func(t *testing.T) {
|
||||
if err := store.Delete("@nonexistent:matrix.example.com"); err != nil {
|
||||
t.Errorf("Delete of nonexistent should not error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Save twice overwrites with second token", func(t *testing.T) {
|
||||
account := "@overwrite_me:matrix.example.com"
|
||||
t.Cleanup(func() { _ = store.Delete(account) })
|
||||
|
||||
first := sampleToken
|
||||
first.AccessToken = "mxat_first_version"
|
||||
if err := store.Save(account, first); err != nil {
|
||||
t.Fatalf("Save (first): %v", err)
|
||||
}
|
||||
|
||||
second := sampleToken
|
||||
second.AccessToken = "mxat_second_version"
|
||||
if err := store.Save(account, second); err != nil {
|
||||
t.Fatalf("Save (second): %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Load(account)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.AccessToken != second.AccessToken {
|
||||
t.Errorf("overwrite: got AccessToken %q, want %q", got.AccessToken, second.AccessToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user