Setup wallet model
CI / build (push) Successful in 1m25s

This commit is contained in:
2026-05-25 14:15:13 +02:00
parent ae311f474b
commit e6d29e9710
16 changed files with 638 additions and 355 deletions
+33 -9
View File
@@ -7,15 +7,15 @@ How sherlock persists secrets and runtime state.
| Topic | Decision |
|---|---|
| Token storage | OS keyring via [`github.com/zalando/go-keyring`](https://github.com/zalando/go-keyring). No on-disk credential files, no `age` blobs, no plaintext. |
| Wallet shape | One `TokenSet` per service (`gitea`, `grafana`, `miniflux`, …). Tracked via a sidecar `services-index` entry so `Store.List()` works on every backend without OS-specific search APIs. |
| Pre-flight | The only constructor is `keyring.Open()`, which probes the keyring before returning a Store. There is no probe-less escape hatch. A missing/locked keyring returns `*UnavailableError` (with a populated `Hint` field) and exits 3. |
| Service name | `sherlock` for real tokens; `sherlock-preflight` for the probe sentinel. |
| Account key | The Authentik `sub` claim once logged in, `default` before. |
| Runtime files | `$XDG_RUNTIME_DIR/sherlock.sock`, `sherlock.pid`, `sherlock.log`, `sherlock/<agent>.mcp.json`. All `0600` (files) / `0700` (dirs). |
| Keyring service ns | `sherlock` for all real entries; `sherlock-preflight` for the probe sentinel. Per-service tokens are accounts of the form `service:<name>`; the index is account `services-index`. |
| Runtime files | `$XDG_RUNTIME_DIR/sherlock/<agent>.mcp.json` (0600), `$XDG_RUNTIME_DIR/sherlock.refresh.lock` (0600, flock anchor for cross-process refreshes). No socket, no PID file, no daemon log. |
| Config files | `$XDG_CONFIG_HOME/sherlock/services.d/*.toml` (operator-registered services, Phase 2+). Agent integrations are compiled in — see [agents.md](agents.md). |
## TokenSet
What we store under the `sherlock` keyring service:
What sherlock stores per service entry:
```go
type TokenSet struct {
@@ -24,16 +24,39 @@ type TokenSet struct {
RefreshToken string
IDExpiresAt time.Time
RefreshExpAt time.Time
Issuer string
Issuer string // for refresh: full OIDC issuer URL
ClientID string // for refresh: OAuth client ID
Scopes []string // for refresh: scopes to re-request
Subject string
Email string
Name string
}
```
Serialized as a single JSON blob — the keyring exposes one secret per
`(service, account)` pair and we don't want to round-trip multiple
secrets.
Serialized as a single JSON blob per service entry — the keyring
exposes one secret per `(service, account)` pair and we don't want to
round-trip multiple secrets per session.
`Issuer` + `ClientID` + `Scopes` are persisted alongside the tokens so
refresh and re-login work from any process, with no dependency on the
shell environment that originally created the entry.
## Wallet API
`keyring.Store` is service-keyed:
```go
type Store interface {
Get(service string) (TokenSet, error) // ErrNoTokens if missing
Set(service string, ts TokenSet) error // also updates the index
Clear(service string) error // also updates the index
List() ([]string, error) // names sorted
}
```
`Get` / `Set` / `Clear` go straight to the OS keyring under
`(service="sherlock", account="service:<name>")`. `List` reads the
sidecar `services-index` entry; `Set`/`Clear` keep it in sync.
## Pre-flight semantics
@@ -49,7 +72,8 @@ secrets.
is the type-predicate for branching.
CLI behaviour: any failure prints the error (which already includes
the hint) and exits with code `3`.
the hint) and exits with code `3`. MCPs inherit the same behaviour
because they construct their Store via the same `keyring.Open()`.
## Platform notes