multi identity support for metacore

This commit is contained in:
Pierre
2026-06-06 18:44:52 +02:00
parent f8a10091b8
commit 52f5b5cedc
5 changed files with 375 additions and 38 deletions
+120 -37
View File
@@ -17,6 +17,22 @@ type identityStatusResponse struct {
UID string `json:"uid,omitempty"`
Curve string `json:"curve,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
// AccountCount is the total number of accounts in the keystore. Lets
// multi-account clients (meta-watch) tell "single identity" from "pick a
// profile" without a second call. Older single-identity clients ignore it.
AccountCount int `json:"accountCount,omitempty"`
}
// identityAccountInfo is one entry in GET /api/identity/accounts. Never carries
// the private key — that is only ever returned once, by generate.
type identityAccountInfo struct {
UID string `json:"uid"`
Curve string `json:"curve"`
CreatedAt int64 `json:"createdAt"`
}
type identityAccountsResponse struct {
Accounts []identityAccountInfo `json:"accounts"`
}
type identityGenerateResponse struct {
@@ -37,11 +53,15 @@ type identityImportResponse struct {
}
type identityDeleteRequest struct {
Confirm bool `json:"confirm"`
Confirm bool `json:"confirm"`
UID string `json:"uid"`
}
type identitySignRequest struct {
BytesB64 string `json:"bytesB64"`
// UID selects which account signs. Optional — when omitted and exactly one
// account exists, that account is used (single-identity back-compat).
UID string `json:"uid,omitempty"`
}
type identitySignResponse struct {
@@ -52,36 +72,79 @@ type identityAEADKeyResponse struct {
AEADKeyB64 string `json:"aeadKeyB64"`
}
// resolveAccount picks the account an operation targets. A non-empty uid must
// match an existing account. An empty uid falls back to the sole account
// (single-identity back-compat); with zero or many accounts and no uid it
// returns a non-zero status for the caller to surface. status == 0 means ok.
func (s *Server) resolveAccount(uid string) (id *identity.Identity, status int, slug, msg string) {
dir := s.config.IdentityAccountsDir()
uid = strings.TrimSpace(uid)
if uid != "" {
got, err := identity.LoadByUID(dir, uid)
if err != nil {
return nil, http.StatusBadRequest, "bad_uid", err.Error()
}
if got == nil {
return nil, http.StatusNotFound, "no_identity", "no identity for the given uid"
}
return got, 0, "", ""
}
list, err := identity.List(dir)
if err != nil {
return nil, http.StatusInternalServerError, "list_failed", err.Error()
}
switch len(list) {
case 0:
return nil, http.StatusNotFound, "no_identity", "no identity is configured"
case 1:
return list[0], 0, "", ""
default:
return nil, http.StatusBadRequest, "uid_required", "multiple accounts exist; specify uid"
}
}
func (s *Server) handleIdentityGet(w http.ResponseWriter, r *http.Request) {
id, err := identity.Load(s.config.IdentityFilePath())
list, err := identity.List(s.config.IdentityAccountsDir())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if id == nil {
if len(list) == 0 {
writeJSON(w, http.StatusOK, identityStatusResponse{HasIdentity: false})
return
}
// Back-compat: report the oldest account as "the" identity. Multi-account
// clients enumerate via GET /api/identity/accounts instead.
def := list[0]
writeJSON(w, http.StatusOK, identityStatusResponse{
HasIdentity: true,
UID: id.UID,
Curve: id.Curve,
CreatedAt: id.CreatedAt,
HasIdentity: true,
UID: def.UID,
Curve: def.Curve,
CreatedAt: def.CreatedAt,
AccountCount: len(list),
})
}
func (s *Server) handleIdentityGenerate(w http.ResponseWriter, r *http.Request) {
if identity.Exists(s.config.IdentityFilePath()) {
writeErrorSlug(w, http.StatusConflict, "identity_exists",
"identity already exists; delete it first to generate a new one", false)
func (s *Server) handleIdentityAccounts(w http.ResponseWriter, r *http.Request) {
list, err := identity.List(s.config.IdentityAccountsDir())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]identityAccountInfo, 0, len(list))
for _, id := range list {
out = append(out, identityAccountInfo{UID: id.UID, Curve: id.Curve, CreatedAt: id.CreatedAt})
}
writeJSON(w, http.StatusOK, identityAccountsResponse{Accounts: out})
}
func (s *Server) handleIdentityGenerate(w http.ResponseWriter, r *http.Request) {
id, err := identity.Generate()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := identity.Save(s.config.IdentityFilePath(), id); err != nil {
if err := identity.SaveAccount(s.config.IdentityAccountsDir(), id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -94,11 +157,6 @@ func (s *Server) handleIdentityGenerate(w http.ResponseWriter, r *http.Request)
}
func (s *Server) handleIdentityImport(w http.ResponseWriter, r *http.Request) {
if identity.Exists(s.config.IdentityFilePath()) {
writeErrorSlug(w, http.StatusConflict, "identity_exists",
"identity already exists; delete it first to import a new one", false)
return
}
var body identityImportRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
@@ -113,7 +171,13 @@ func (s *Server) handleIdentityImport(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := identity.Save(s.config.IdentityFilePath(), id); err != nil {
dir := s.config.IdentityAccountsDir()
if identity.ExistsByUID(dir, id.UID) {
writeErrorSlug(w, http.StatusConflict, "identity_exists",
"an account with this key already exists", false)
return
}
if err := identity.SaveAccount(dir, id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -134,7 +198,32 @@ func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
"identity deletion requires {\"confirm\": true} or ?confirm=true", false)
return
}
if err := identity.Delete(s.config.IdentityFilePath()); err != nil {
dir := s.config.IdentityAccountsDir()
uid := strings.TrimSpace(body.UID)
if uid == "" {
uid = strings.TrimSpace(r.URL.Query().Get("uid"))
}
if uid == "" {
// Back-compat: with no uid, delete the sole account. Ambiguous (and so
// refused) once more than one account exists.
list, err := identity.List(dir)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
switch len(list) {
case 0:
w.WriteHeader(http.StatusNoContent) // idempotent
return
case 1:
uid = list[0].UID
default:
writeErrorSlug(w, http.StatusBadRequest, "uid_required",
"multiple accounts exist; specify uid to delete", false)
return
}
}
if err := identity.DeleteByUID(dir, uid); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -142,21 +231,20 @@ func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
id, err := identity.Load(s.config.IdentityFilePath())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if id == nil {
writeErrorSlug(w, http.StatusNotFound, "no_identity",
"no identity is configured", false)
return
}
var body identitySignRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
uid := body.UID
if strings.TrimSpace(uid) == "" {
uid = r.URL.Query().Get("uid")
}
id, status, slug, msg := s.resolveAccount(uid)
if status != 0 {
writeErrorSlug(w, status, slug, msg, false)
return
}
raw, err := base64.StdEncoding.DecodeString(body.BytesB64)
if err != nil {
writeError(w, http.StatusBadRequest, "bytesB64 is not valid base64")
@@ -173,14 +261,9 @@ func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) {
id, err := identity.Load(s.config.IdentityFilePath())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if id == nil {
writeErrorSlug(w, http.StatusNotFound, "no_identity",
"no identity is configured", false)
id, status, slug, msg := s.resolveAccount(r.URL.Query().Get("uid"))
if status != 0 {
writeErrorSlug(w, status, slug, msg, false)
return
}
key, err := identity.DeriveAEADKey(id)
+10
View File
@@ -11,6 +11,7 @@ import (
"github.com/metazla/meta-core/internal/config"
"github.com/metazla/meta-core/internal/discovery"
"github.com/metazla/meta-core/internal/events"
"github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/leader"
"github.com/metazla/meta-core/internal/mounts"
"github.com/metazla/meta-core/internal/schema"
@@ -136,6 +137,14 @@ func NewServer(
// Initialize WebDAV handler (caching is handled by nginx proxy_cache)
s.webdavHandler = webdav.NewHandler(cfg)
// One-time migration: fold a pre-multi-account identity.json into the
// per-account keystore so existing single-identity hosts keep their uid.
if migrated, err := identity.MigrateLegacy(cfg.IdentityFilePath(), cfg.IdentityAccountsDir()); err != nil {
log.Printf("[API] Warning: identity migration failed: %v", err)
} else if migrated {
log.Printf("[API] Migrated legacy identity.json into multi-account keystore")
}
s.setupRoutes()
return s
}
@@ -158,6 +167,7 @@ func (s *Server) setupRoutes() {
// User Data Layer signing identity (see internal/identity). Auth-gated
// at the perimeter — do NOT add to nginx-hash-lock unauth bypass.
s.router.HandleFunc("/api/identity", s.handleIdentityGet).Methods("GET")
s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET")
s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).Methods("POST")
s.router.HandleFunc("/api/identity/import", s.handleIdentityImport).Methods("POST")
s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE")