v2.0.0: claims model - signed MMUR2 votes in a truth DB, merged view in a rebuildable cache DB; POST /api/claims, claims and merge routes, unsigned writes answer 410
Build and Push Docker Image / build (push) Successful in 5m1s

This commit is contained in:
Worph
2026-10-25 17:36:00 +00:00
parent b0adefb97b
commit 84af298792
78 changed files with 7203 additions and 5617 deletions
+73 -7
View File
@@ -4,11 +4,14 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/storage"
)
@@ -120,6 +123,9 @@ type identityDeleteResponse struct {
type identitySignRequest struct {
BytesB64 string `json:"bytesB64"`
// Format is "der" or "compact" (64-byte r||s, low-S). Default: compact for
// MMUR2 claim payloads ("MMUR2\n" prefix), DER otherwise (MMUR1 back-compat).
Format string `json:"format,omitempty"`
// 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"`
@@ -143,6 +149,8 @@ type identitySignBatchRequest struct {
// UID selects which account signs — same optional single-identity
// back-compat as identitySignRequest.
UID string `json:"uid,omitempty"`
// Format: see identitySignRequest.Format; applies to every payload.
Format string `json:"format,omitempty"`
}
type identitySignBatchResponse struct {
@@ -445,10 +453,10 @@ func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
"storage is not connected; refusing to delete the key while its data cannot be purged", true)
return
}
purged, err := s.storage.UDLPurgeUser(id.UID)
purged, err := s.retractAllClaims(r, id)
if err != nil {
writeErrorSlug(w, http.StatusInternalServerError, "purge_failed",
"could not purge this account's data; the account was NOT deleted: "+err.Error(), true)
"could not retract this account's claims; the account was NOT deleted: "+err.Error(), true)
return
}
@@ -456,7 +464,7 @@ func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
writeErrorSlug(w, http.StatusInternalServerError, "delete_failed", err.Error(), false)
return
}
log.Printf("[API] Deleted account %s and purged %d User Data Layer records across %d cids",
log.Printf("[API] Deleted account %s after retracting %d claims across %d cids",
id.UID, purged.Records, purged.Cids)
writeJSON(w, http.StatusOK, identityDeleteResponse{UID: id.UID, Purged: purged})
}
@@ -485,9 +493,9 @@ func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
writeErrorSlug(w, http.StatusForbidden, "reserved_payload", reservedPayloadMessage, false)
return
}
sig, err := identity.Sign(id, raw)
sig, err := signPayload(id, raw, body.Format)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, identitySignResponse{
@@ -537,9 +545,9 @@ func (s *Server) handleIdentitySignBatch(w http.ResponseWriter, r *http.Request)
"payloadsB64["+strconv.Itoa(i)+"]: "+reservedPayloadMessage, false)
return
}
sig, err := identity.Sign(id, raw)
sig, err := signPayload(id, raw, body.Format)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
writeError(w, http.StatusBadRequest, "payloadsB64["+strconv.Itoa(i)+"]: "+err.Error())
return
}
sigs = append(sigs, base64.StdEncoding.EncodeToString(sig))
@@ -562,3 +570,61 @@ func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) {
AEADKeyB64: base64.StdEncoding.EncodeToString(key),
})
}
// signPayload signs raw with the account key in the requested encoding.
func signPayload(id *identity.Identity, raw []byte, format string) ([]byte, error) {
switch format {
case "compact":
return identity.SignCompact(id, raw)
case "der":
return identity.Sign(id, raw)
case "":
if strings.HasPrefix(string(raw), claim.Domain) {
return identity.SignCompact(id, raw)
}
return identity.Sign(id, raw)
}
return nil, fmt.Errorf("format must be \"compact\" or \"der\"")
}
// retractAllClaims signs a retraction for every live claim the account holds
// and stores them (G11): the tombstones stay, so the votes cannot be replayed.
func (s *Server) retractAllClaims(r *http.Request, id *identity.Identity) (storage.UDLUserStats, error) {
refs, err := s.storage.LiveClaimsBySigner(id.UID)
if err != nil {
return storage.UDLUserStats{}, err
}
cids, keys := map[string]bool{}, map[string]bool{}
now := uint64(time.Now().UnixMilli())
batch := make([]claim.Claim, 0, len(refs))
for _, ref := range refs {
cur, ok, err := s.storage.UDLGetClaim(id.UID, ref[0], ref[1])
if err != nil {
return storage.UDLUserStats{}, err
}
ts := now
if ok && cur.TS >= ts {
ts = cur.TS + 1
}
tomb := claim.Claim{CID: ref[0], UID: id.UID, Key: ref[1], Value: claim.Null(), TS: ts}
if err := tomb.Sign(id); err != nil {
return storage.UDLUserStats{}, err
}
batch = append(batch, tomb)
cids[ref[0]], keys[ref[1]] = true, true
}
for start := 0; start < len(batch); start += storage.MaxClaimsPerBatch {
end := min(start+storage.MaxClaimsPerBatch, len(batch))
results, err := s.storage.WriteClaims(r.Context(), batch[start:end])
if err != nil {
return storage.UDLUserStats{}, err
}
for i, res := range results {
if res.Status != storage.StatusAccepted && res.Status != storage.StatusUnchanged {
return storage.UDLUserStats{}, fmt.Errorf("retraction of %s/%s refused: %s %s",
batch[start+i].CID, batch[start+i].Key, res.Status, res.Reason)
}
}
}
return storage.UDLUserStats{Records: len(batch), Cids: len(cids), Keys: len(keys)}, nil
}