Add client engine and desktop CLI that mirror KDBX files

Why: a KeePass application must see an ordinary .kdbx file. A mirror
directory of real files gives that on every desktop platform without FUSE,
WinFsp or macFUSE, and without cgo.

What: internal/client holds the API wrapper, an SSE reader, and a mirror
agent whose entire policy is one pure decide() table: adopt, pull, push,
force-push, server-wins, restore or manual. Saves are detected by rescan,
remote changes arrive over the event stream, and every overwrite of local
bytes leaves a conflict copy. cmd/keeporg drives it: login (password, TOTP or
browser/OIDC device flow), run, sync, status, plus the group, grant, database
and invite administration.

Proved: go test ./internal/client covers the decision table and runs a real
server in-process for adopt, follow, server-wins, client-wins, manual,
read-only restore, server-side rename and event-driven update. Also smoke
tested live: built both binaries, bootstrapped a server, logged in, created a
database and adopted a local file.
This commit is contained in:
2026-10-29 16:01:35 +01:00
parent 8bbb3179a1
commit 4d0d53ff04
10 changed files with 2288 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
package client
import (
"context"
"net/http"
"net/url"
"time"
)
// Group is a node of the server's group tree as the client sees it.
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID string `json:"parent_id"`
Admin bool `json:"admin"`
Members int `json:"members"`
}
// Invite is a registration link.
type Invite struct {
Token string `json:"token"`
URL string `json:"url"`
GroupID string `json:"group_id"`
Role string `json:"role"`
Uses int `json:"uses"`
MaxUses int `json:"max_uses"`
ExpiresAt time.Time `json:"expires_at"`
}
// User is an account as an administrator sees it.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
ServerAdmin bool `json:"server_admin"`
TOTPEnabled bool `json:"totp_enabled"`
}
// Identity is the answer to "who am I and where do I belong".
type Identity struct {
User
Groups []struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
} `json:"groups"`
}
func (a *API) Whoami(ctx context.Context) (*Identity, error) {
var out Identity
return &out, a.do(ctx, http.MethodGet, "/api/v1/auth/whoami", nil, &out)
}
func (a *API) Groups(ctx context.Context) ([]Group, error) {
var out struct {
Groups []Group `json:"groups"`
}
err := a.do(ctx, http.MethodGet, "/api/v1/groups", nil, &out)
return out.Groups, err
}
func (a *API) CreateGroup(ctx context.Context, name, parentID string) (*Group, error) {
var out Group
return &out, a.do(ctx, http.MethodPost, "/api/v1/groups",
map[string]string{"name": name, "parent_id": parentID}, &out)
}
func (a *API) DeleteGroup(ctx context.Context, id string) error {
return a.do(ctx, http.MethodDelete, "/api/v1/groups/"+url.PathEscape(id), nil, nil)
}
func (a *API) SetMember(ctx context.Context, groupID, username, role string) error {
return a.do(ctx, http.MethodPost,
"/api/v1/groups/"+url.PathEscape(groupID)+"/members",
map[string]string{"username": username, "role": role}, nil)
}
func (a *API) RemoveMember(ctx context.Context, groupID, user string) error {
return a.do(ctx, http.MethodDelete,
"/api/v1/groups/"+url.PathEscape(groupID)+"/members/"+url.PathEscape(user), nil, nil)
}
func (a *API) CreateDatabase(ctx context.Context, name, groupID, strategy string) (*Database, error) {
body := map[string]string{"name": name, "owner_group_id": groupID}
if strategy != "" {
body["merge_strategy"] = strategy
}
var out Database
return &out, a.do(ctx, http.MethodPost, "/api/v1/databases", body, &out)
}
// SetStrategy changes the merge strategy of a database. Only an
// administrator of the owning group may do this.
func (a *API) SetStrategy(ctx context.Context, dbID, strategy string) (*Database, error) {
var out Database
return &out, a.do(ctx, http.MethodPatch, "/api/v1/databases/"+url.PathEscape(dbID),
map[string]string{"merge_strategy": strategy}, &out)
}
func (a *API) RenameDatabase(ctx context.Context, dbID, name string) (*Database, error) {
var out Database
return &out, a.do(ctx, http.MethodPatch, "/api/v1/databases/"+url.PathEscape(dbID),
map[string]string{"name": name}, &out)
}
func (a *API) DeleteDatabase(ctx context.Context, dbID string) error {
return a.do(ctx, http.MethodDelete, "/api/v1/databases/"+url.PathEscape(dbID), nil, nil)
}
func (a *API) SetGrant(ctx context.Context, groupID, dbID, perm string) error {
return a.do(ctx, http.MethodPost, "/api/v1/grants",
map[string]string{"group_id": groupID, "database_id": dbID, "perm": perm}, nil)
}
func (a *API) DeleteGrant(ctx context.Context, groupID, dbID string) error {
q := url.Values{"group_id": {groupID}, "database_id": {dbID}}
return a.do(ctx, http.MethodDelete, "/api/v1/grants?"+q.Encode(), nil, nil)
}
func (a *API) CreateInvite(ctx context.Context, groupID, role string, ttlHours, maxUses int) (*Invite, error) {
var out Invite
return &out, a.do(ctx, http.MethodPost, "/api/v1/invites", map[string]any{
"group_id": groupID, "role": role, "ttl_hours": ttlHours, "max_uses": maxUses}, &out)
}
func (a *API) Invites(ctx context.Context) ([]Invite, error) {
var out struct {
Invites []Invite `json:"invites"`
}
err := a.do(ctx, http.MethodGet, "/api/v1/invites", nil, &out)
return out.Invites, err
}
func (a *API) Users(ctx context.Context) ([]User, error) {
var out struct {
Users []User `json:"users"`
}
err := a.do(ctx, http.MethodGet, "/api/v1/users", nil, &out)
return out.Users, err
}
// EnrollTOTP starts second-factor enrolment and returns the shared secret
// and its otpauth URI.
func (a *API) EnrollTOTP(ctx context.Context) (secret, uri string, err error) {
var out struct {
Secret string `json:"secret"`
URI string `json:"uri"`
}
err = a.do(ctx, http.MethodPost, "/api/v1/auth/totp/enroll", nil, &out)
return out.Secret, out.URI, err
}
// ConfirmTOTP completes enrolment by proving the secret was stored.
func (a *API) ConfirmTOTP(ctx context.Context, code string) error {
return a.do(ctx, http.MethodPost, "/api/v1/auth/totp/confirm",
map[string]string{"code": code}, nil)
}