diff --git a/cmd/cloudyd/main.go b/cmd/cloudyd/main.go index b8737be..6ac7dfb 100644 --- a/cmd/cloudyd/main.go +++ b/cmd/cloudyd/main.go @@ -1,13 +1,18 @@ // Command cloudyd is Cloudy's member-facing daemon: it serves the consumer // JSON API (internal/consumerapi) that the React Native app talks to. This is // the member-facing ingress cmd/cloudy's composition root said did not yet -// exist — slice 1: onboarding, the Technology Tree, and the hardware Market. +// exist. Slice 1: onboarding, the Technology Tree, and the hardware Market. +// Slice 2: the four-leaf member economy — Drops (dialog-sealed record with +// honestly stand-in-labeled checkpoints), LBTAS assessments, credit spends +// (409 while the governed policy is escrow), and dispute filing/withdrawal. // // Honest about what it is (like cmd/cloudy): in-memory stores, an ephemeral // platform, no durable persistence and no live coordination loop. Member keys // never reach this process — every member artifact arrives client-signed and is -// only validated here. Durable persistence, the record witnessing, and the -// economy/settlement + disputes + contribution slices are the named follow-ups. +// only validated here; the steward and adjudicator private keys are discarded +// at startup, so no escrow->credit PolicyChange and no ruling is signable. +// Durable persistence, witness federation (lifecycle witnessing included), +// and the settlement + contribution slices are the named follow-ups. package main import ( @@ -28,7 +33,7 @@ func main() { log.Fatalf("cloudyd: constructing server: %v", err) } - log.Printf("cloudyd: consumer JSON API (slice 1: members, techtree, market) listening on %s for platform %q; in-memory stores, member keys never touch this process (client-signed artifacts only)", *addr, *platform) + log.Printf("cloudyd: consumer JSON API (slices 1+2: members, techtree, market, drops, covenant, credit, disputes) listening on %s for platform %q; in-memory stores, member keys never touch this process (client-signed artifacts only); credit policy is escrow (spends 409 until the governed switch); checkpoints carry the single-witness stand-in label", *addr, *platform) if err := http.ListenAndServe(*addr, srv.Handler()); err != nil { log.Fatalf("cloudyd: server exited: %v", err) } diff --git a/internal/consumerapi/api_slice2_test.go b/internal/consumerapi/api_slice2_test.go new file mode 100644 index 0000000..1beea40 --- /dev/null +++ b/internal/consumerapi/api_slice2_test.go @@ -0,0 +1,337 @@ +package consumerapi + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "net/http" + "testing" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/covenant" + "github.com/NTARI-RAND/Cloudy/internal/dispute" + "github.com/NTARI-RAND/Cloudy/internal/economy" + "github.com/NTARI-RAND/Cloudy/internal/record" +) + +// sealExchange drives the full client-side dialog-seal flow over the API: +// fetch the operator's log identity, build the entry, seal it with BOTH +// member keys locally (keys never touch the server), and post it. It returns +// the leaf ID hex — THE cross-layer exchange reference. +func sealExchange(t *testing.T, h http.Handler, pubA ed25519.PublicKey, privA ed25519.PrivateKey, pubB ed25519.PublicKey, privB ed25519.PrivateKey) string { + t.Helper() + rec, body := do(t, h, "GET", "/api/v1/drops/log", nil) + if rec.Code != http.StatusOK { + t.Fatalf("drops/log: code %d body %s", rec.Code, rec.Body.String()) + } + logHex, _ := body["log_id"].(string) + logRaw, err := hex.DecodeString(logHex) + if err != nil || len(logRaw) != 32 { + t.Fatalf("drops/log returned unusable log_id %q", logHex) + } + var logID record.Hash + copy(logID[:], logRaw) + + e, err := record.NewEntry(logID, pubA, pubB, record.HashContent([]byte("member-local narrative")), record.Hash{}, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if err := e.Seal(privA); err != nil { + t.Fatal(err) + } + if err := e.Seal(privB); err != nil { + t.Fatal(err) + } + rec, body = do(t, h, "POST", "/api/v1/drops", dropRequest{ + Log: logHex, + Proposer: hex.EncodeToString(pubA), + Acceptor: hex.EncodeToString(pubB), + Content: hex.EncodeToString(e.Content[:]), + Nonce: hex.EncodeToString(e.Nonce[:]), + SealedAt: e.SealedAt.Format(time.RFC3339Nano), + ProposerSeal: hex.EncodeToString(e.ProposerSeal), + AcceptorSeal: hex.EncodeToString(e.AcceptorSeal), + }) + if rec.Code != http.StatusOK { + t.Fatalf("POST drops: code %d body %s", rec.Code, rec.Body.String()) + } + id, _ := body["id"].(string) + want := e.ID() + if id != hex.EncodeToString(want[:]) { + t.Fatalf("POST drops returned id %q, want the entry's leaf ID %q", id, hex.EncodeToString(want[:])) + } + return id +} + +func TestDropsAppendReadAndStandInLabel(t *testing.T) { + h := newTestServer(t) + pubA, privA := key(1) + pubB, privB := key(2) + registerMember(t, h, pubA, privA) + registerMember(t, h, pubB, privB) + + id := sealExchange(t, h, pubA, privA, pubB, privB) + + rec, body := do(t, h, "GET", "/api/v1/drops/"+id, nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET drop: code %d body %s", rec.Code, rec.Body.String()) + } + if body["proposer"] != hex.EncodeToString(pubA) || body["acceptor"] != hex.EncodeToString(pubB) { + t.Fatalf("GET drop returned wrong parties: %v", body) + } + if _, hasCorrects := body["corrects"]; hasCorrects { + t.Fatalf("plain covenant must omit corrects, got %v", body["corrects"]) + } + + // The checkpoint MUST carry the honest single-witness stand-in label, and + // its signature must verify under the operator key the API itself serves. + rec, body = do(t, h, "GET", "/api/v1/drops/checkpoints", nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET checkpoints: code %d", rec.Code) + } + if body["stand_in"] != true { + t.Fatal("zero-witness deployment did not label itself a stand-in; misrepresenting federation is forbidden") + } + if body["size"].(float64) != 1 { + t.Fatalf("checkpoint size = %v, want 1", body["size"]) + } + _, lg := do(t, h, "GET", "/api/v1/drops/log", nil) + opKey, err := hex.DecodeString(lg["operator_key"].(string)) + if err != nil { + t.Fatal(err) + } + var cp record.Checkpoint + logRaw, _ := hex.DecodeString(body["log"].(string)) + headRaw, _ := hex.DecodeString(body["head"].(string)) + copy(cp.Log[:], logRaw) + copy(cp.Head[:], headRaw) + cp.Size = uint64(body["size"].(float64)) + cp.IssuedAt, err = time.Parse(time.RFC3339Nano, body["issued_at"].(string)) + if err != nil { + t.Fatal(err) + } + cp.Signature, _ = hex.DecodeString(body["signature"].(string)) + if !cp.Verify(ed25519.PublicKey(opKey)) { + t.Fatal("served checkpoint does not verify under the served operator key") + } + + // Unregistered parties cannot enter the log through this ingress. + pubC, privC := key(3) + e, err := record.NewEntry(cp.Log, pubA, pubC, record.HashContent([]byte("n")), record.Hash{}, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + _ = e.Seal(privA) + _ = e.Seal(privC) + rec, _ = do(t, h, "POST", "/api/v1/drops", dropRequest{ + Log: hex.EncodeToString(cp.Log[:]), + Proposer: hex.EncodeToString(pubA), + Acceptor: hex.EncodeToString(pubC), + Content: hex.EncodeToString(e.Content[:]), + Nonce: hex.EncodeToString(e.Nonce[:]), + SealedAt: e.SealedAt.Format(time.RFC3339Nano), + ProposerSeal: hex.EncodeToString(e.ProposerSeal), + AcceptorSeal: hex.EncodeToString(e.AcceptorSeal), + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("unregistered acceptor: code %d, want 400", rec.Code) + } +} + +func TestAssessmentsAnchorToSealedDialogs(t *testing.T) { + h := newTestServer(t) + pubA, privA := key(1) + pubB, privB := key(2) + registerMember(t, h, pubA, privA) + registerMember(t, h, pubB, privB) + id := sealExchange(t, h, pubA, privA, pubB, privB) + + assess := func(exchangeHex string, level int8, commentHash string) (*int, string) { + exRaw, _ := hex.DecodeString(exchangeHex) + var ex covenant.ExchangeRef + copy(ex[:], exRaw) + a := covenant.Assessment{ + Assessor: covenant.MemberIDFor(platform, pubA), + Subject: covenant.MemberIDFor(platform, pubB), + Exchange: ex, + Category: "reliability", + Level: covenant.Level(level), + IssuedAt: time.Now().UTC(), + } + if commentHash != "" { + raw, _ := hex.DecodeString(commentHash) + copy(a.CommentHash[:], raw) + } + a.Sign(privA) + rec, body := do(t, h, "POST", "/api/v1/assessments", assessmentRequest{ + Assessor: string(a.Assessor), + Subject: string(a.Subject), + Exchange: exchangeHex, + Category: a.Category, + Level: level, + CommentHash: commentHash, + IssuedAt: a.IssuedAt.Format(time.RFC3339Nano), + Signature: hex.EncodeToString(a.Signature), + }) + errMsg, _ := body["error"].(string) + return &rec.Code, errMsg + } + + if code, msg := assess(id, 3, ""); *code != http.StatusOK { + t.Fatalf("anchored assessment refused: %d %s", *code, msg) + } + // The same assessor re-assessing the same exchange under the same + // category is a duplicate, reported as a conflict. + if code, _ := assess(id, 2, ""); *code != http.StatusConflict { + t.Fatalf("duplicate assessment: code %d, want 409", *code) + } + // An exchange that never sealed anchors nothing. + fake := sha256.Sum256([]byte("no such exchange")) + if code, _ := assess(hex.EncodeToString(fake[:]), 3, ""); *code != http.StatusBadRequest { + t.Fatalf("unanchored assessment: code %d, want 400", *code) + } + + // Standing serves distributions and the harm count — never an average. + rec, body := do(t, h, "GET", "/api/v1/members/"+string(covenant.MemberIDFor(platform, pubB))+"/standing", nil) + if rec.Code != http.StatusOK { + t.Fatalf("standing: code %d", rec.Code) + } + overall := body["overall"].(map[string]any) + if overall["total"].(float64) != 1 { + t.Fatalf("standing total = %v, want 1", overall["total"]) + } + counts := overall["counts"].(map[string]any) + if counts[covenant.Level(3).String()].(float64) != 1 { + t.Fatalf("standing counts = %v, want one at %q", counts, covenant.Level(3).String()) + } + for k := range body { + if k == "average" || k == "score" || k == "rating" { + t.Fatalf("standing response leaked a scalar %q — the covenant forbids the average", k) + } + } +} + +func TestSpendsRefusedWhileEscrow(t *testing.T) { + h := newTestServer(t) + pubA, privA := key(1) + pubB, privB := key(2) + registerMember(t, h, pubA, privA) + registerMember(t, h, pubB, privB) + id := sealExchange(t, h, pubA, privA, pubB, privB) + + rec, body := do(t, h, "GET", "/api/v1/credit/policy", nil) + if rec.Code != http.StatusOK || body["mode"] != "escrow" { + t.Fatalf("policy: code %d body %v, want escrow mode", rec.Code, body) + } + + exRaw, _ := hex.DecodeString(id) + var ex [32]byte + copy(ex[:], exRaw) + sp := economy.Spend{ + Platform: platform, + From: economy.AccountIDFor(platform, pubA), + To: economy.AccountIDFor(platform, pubB), + Amount: 5, + ExchangeHash: ex, + IssuedAt: time.Now().UTC(), + Nonce: 1, + } + sp.Sign(privA) + rec, body = do(t, h, "POST", "/api/v1/credit/spends", spendRequest{ + From: hex.EncodeToString(sp.From[:]), + To: hex.EncodeToString(sp.To[:]), + Amount: uint64(sp.Amount), + ExchangeHash: id, + IssuedAt: sp.IssuedAt.Format(time.RFC3339Nano), + Nonce: sp.Nonce, + Signature: hex.EncodeToString(sp.Signature), + }) + if rec.Code != http.StatusConflict { + t.Fatalf("spend in escrow mode: code %d body %v, want 409 — the escrow wall must hold", rec.Code, body) + } + + // Balances and history stay a deterministic function of the (empty) sealed + // spend record. + acctA := hex.EncodeToString(sp.From[:]) + rec, body = do(t, h, "GET", "/api/v1/credit/accounts/"+acctA+"/balance", nil) + if rec.Code != http.StatusOK || body["balance"].(float64) != 0 { + t.Fatalf("balance: code %d body %v, want 0", rec.Code, body) + } + rec, body = do(t, h, "GET", "/api/v1/credit/accounts/"+acctA+"/history", nil) + if rec.Code != http.StatusOK || len(body["spends"].([]any)) != 0 { + t.Fatalf("history: code %d body %v, want no spends", rec.Code, body) + } +} + +func TestDisputeFileWithdrawLifecycle(t *testing.T) { + h := newTestServer(t) + pubA, privA := key(1) + pubB, privB := key(2) + registerMember(t, h, pubA, privA) + registerMember(t, h, pubB, privB) + id := sealExchange(t, h, pubA, privA, pubB, privB) + + exRaw, _ := hex.DecodeString(id) + var ex dispute.ExchangeRef + copy(ex[:], exRaw) + reason := sha256.Sum256([]byte("member-local grievance narrative")) + o, err := dispute.NewOpening(platform, pubA, pubB, ex, reason, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if err := o.Sign(privA); err != nil { + t.Fatal(err) + } + rec, body := do(t, h, "POST", "/api/v1/disputes", openDisputeRequest{ + Complainant: hex.EncodeToString(pubA), + Respondent: hex.EncodeToString(pubB), + Exchange: id, + ReasonHash: hex.EncodeToString(o.ReasonHash[:]), + Nonce: hex.EncodeToString(o.Nonce[:]), + OpenedAt: o.OpenedAt.Format(time.RFC3339Nano), + Signature: hex.EncodeToString(o.Signature), + }) + if rec.Code != http.StatusOK { + t.Fatalf("open dispute: code %d body %s", rec.Code, rec.Body.String()) + } + dID, _ := body["dispute_id"].(string) + wantID := o.ID() + if dID != hex.EncodeToString(wantID[:]) { + t.Fatalf("dispute_id %q, want Opening.ID() %q", dID, hex.EncodeToString(wantID[:])) + } + + rec, body = do(t, h, "GET", "/api/v1/disputes/"+dID, nil) + if rec.Code != http.StatusOK || body["state"] != "open" { + t.Fatalf("case: code %d state %v, want open", rec.Code, body["state"]) + } + + // Only the complainant can withdraw: the respondent's signature is refused. + wd := dispute.Withdrawal{Platform: platform, Dispute: dispute.DisputeID(wantID), WithdrawnAt: time.Now().UTC()} + wd.Sign(privB) + rec, _ = do(t, h, "POST", "/api/v1/disputes/"+dID+"/withdraw", withdrawRequest{ + WithdrawnAt: wd.WithdrawnAt.Format(time.RFC3339Nano), + Signature: hex.EncodeToString(wd.Signature), + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("respondent withdrawal: code %d, want 400", rec.Code) + } + + wd.Sign(privA) + rec, _ = do(t, h, "POST", "/api/v1/disputes/"+dID+"/withdraw", withdrawRequest{ + WithdrawnAt: wd.WithdrawnAt.Format(time.RFC3339Nano), + Signature: hex.EncodeToString(wd.Signature), + }) + if rec.Code != http.StatusOK { + t.Fatalf("complainant withdrawal: code %d body %s", rec.Code, rec.Body.String()) + } + rec, body = do(t, h, "GET", "/api/v1/disputes/"+dID, nil) + if rec.Code != http.StatusOK || body["state"] != "withdrawn" { + t.Fatalf("case after withdrawal: code %d state %v, want withdrawn", rec.Code, body["state"]) + } + + unknown := sha256.Sum256([]byte("no such case")) + rec, _ = do(t, h, "GET", "/api/v1/disputes/"+hex.EncodeToString(unknown[:]), nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("unknown dispute: code %d, want 404", rec.Code) + } +} diff --git a/internal/consumerapi/assessments.go b/internal/consumerapi/assessments.go new file mode 100644 index 0000000..919b7fc --- /dev/null +++ b/internal/consumerapi/assessments.go @@ -0,0 +1,82 @@ +package consumerapi + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/NTARI-RAND/Cloudy/internal/covenant" +) + +type assessmentRequest struct { + Assessor string `json:"assessor"` // MemberID of the assessing member; their key signs + Subject string `json:"subject"` // MemberID whose standing this shapes + Exchange string `json:"exchange"` // hex leaf ID of the sealed dialog both took part in + Category string `json:"category"` // one of the Book's closed vocabulary + Level int8 `json:"level"` // LBTAS level, -1 (No Trust) .. +4 + CommentHash string `json:"comment_hash,omitempty"` // hex SHA-256 of the member-local justification; REQUIRED at No Trust + IssuedAt string `json:"issued_at"` + Signature string `json:"signature"` // hex ed25519 by the assessor over the canonical bytes +} + +// handleRecordAssessment admits one member-signed LBTAS assessment. The Book +// is the only admission path and re-verifies everything: the assessor's +// signature against the directory, the anchor (the named exchange is a sealed +// dialog between exactly these two members — recAnchors joins it to the +// operator's Drops log), the closed category vocabulary, and the No-Trust +// comment-hash rule. The server adds nothing and can forge nothing. +// +// What is deliberately absent, per the covenant invariants: any endpoint that +// averages, ranks, or scores. Standing is served as distributions only +// (GET /api/v1/members/{id}/standing) and a harm stays visible beside the +// volume permanently. +func (s *Server) handleRecordAssessment(w http.ResponseWriter, r *http.Request) { + var req assessmentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + exchange, ok := decodeHex32(req.Exchange) + if !ok { + writeErr(w, http.StatusBadRequest, "exchange must be a 32-byte hex leaf ID") + return + } + var commentHash [32]byte + if req.CommentHash != "" { + if commentHash, ok = decodeHex32(req.CommentHash); !ok { + writeErr(w, http.StatusBadRequest, "comment_hash must be a 32-byte hex digest when present") + return + } + } + issuedAt, ok := parseUTC(req.IssuedAt) + if !ok { + writeErr(w, http.StatusBadRequest, "issued_at must be RFC3339") + return + } + sig, ok := decodeSig(req.Signature) + if !ok { + writeErr(w, http.StatusBadRequest, "signature must be a 64-byte hex signature") + return + } + a := covenant.Assessment{ + Assessor: covenant.MemberID(req.Assessor), + Subject: covenant.MemberID(req.Subject), + Exchange: covenant.ExchangeRef(exchange), + Category: req.Category, + Level: covenant.Level(req.Level), + CommentHash: commentHash, + IssuedAt: issuedAt, + Signature: sig, + } + s.mu.Lock() + err := s.book.Record(a) + s.mu.Unlock() + switch { + case err == nil: + writeJSON(w, http.StatusOK, map[string]string{"status": "recorded"}) + case errors.Is(err, covenant.ErrDuplicate): + writeErr(w, http.StatusConflict, err.Error()) + default: + writeErr(w, http.StatusBadRequest, err.Error()) + } +} diff --git a/internal/consumerapi/disputes.go b/internal/consumerapi/disputes.go new file mode 100644 index 0000000..2d2f9fb --- /dev/null +++ b/internal/consumerapi/disputes.go @@ -0,0 +1,172 @@ +package consumerapi + +import ( + "encoding/json" + "net/http" + + "github.com/NTARI-RAND/Cloudy/internal/dispute" +) + +type openDisputeRequest struct { + Complainant string `json:"complainant"` // hex ed25519 key; signs the opening + Respondent string `json:"respondent"` // hex ed25519 key of the counterparty + Exchange string `json:"exchange"` // hex leaf ID of the disputed sealed dialog + ReasonHash string `json:"reason_hash"` // hex SHA-256 of the member-local reason; the text never enters the commons + Nonce string `json:"nonce"` // hex 32-byte client-drawn nonce + OpenedAt string `json:"opened_at"` + Signature string `json:"signature"` // hex ed25519 by the complainant +} + +// handleOpenDispute admits one complainant-signed Opening. The Registry is +// the only admission path and re-verifies the signature, the anchor (the +// named exchange is a sealed dialog between exactly these two members), and +// the one-live-case rule. A dismissal, when adjudication exists, will be an +// annotation — never an erasure; the case index is append-only underneath. +// +// Honest scope note, labeled the stand-in it is: this process holds no +// adjudicator key, so no ruling can be produced here — filed cases stay open +// (visible, with their dwell readable) until they are withdrawn. A real +// adjudication surface arrives with a staff panel, and its lifecycle +// witnessing (file → adjudicate → resolve → seal, filing at an INDEPENDENT +// witness upstream of this operator) is Phase-3 record-federation work; this +// endpoint is the operator-local half, not a substitute for it. +func (s *Server) handleOpenDispute(w http.ResponseWriter, r *http.Request) { + var req openDisputeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + complainant, ok := decodeKey(req.Complainant) + if !ok { + writeErr(w, http.StatusBadRequest, "complainant must be a 32-byte hex ed25519 key") + return + } + respondent, ok := decodeKey(req.Respondent) + if !ok { + writeErr(w, http.StatusBadRequest, "respondent must be a 32-byte hex ed25519 key") + return + } + exchange, ok := decodeHex32(req.Exchange) + if !ok { + writeErr(w, http.StatusBadRequest, "exchange must be a 32-byte hex leaf ID") + return + } + reasonHash, ok := decodeHex32(req.ReasonHash) + if !ok { + writeErr(w, http.StatusBadRequest, "reason_hash must be a 32-byte hex digest") + return + } + nonce, ok := decodeHex32(req.Nonce) + if !ok { + writeErr(w, http.StatusBadRequest, "nonce must be 32 hex bytes") + return + } + openedAt, ok := parseUTC(req.OpenedAt) + if !ok { + writeErr(w, http.StatusBadRequest, "opened_at must be RFC3339") + return + } + sig, ok := decodeSig(req.Signature) + if !ok { + writeErr(w, http.StatusBadRequest, "signature must be a 64-byte hex signature") + return + } + o := dispute.Opening{ + Platform: s.platform, + Complainant: complainant, + Respondent: respondent, + Exchange: dispute.ExchangeRef(exchange), + ReasonHash: reasonHash, + Nonce: nonce, + OpenedAt: openedAt, + Signature: sig, + } + s.mu.Lock() + id, err := s.registry.Open(o) + s.mu.Unlock() + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"dispute_id": hx(id[:])}) +} + +type disputeView struct { + DisputeID string `json:"dispute_id"` + State string `json:"state"` // open | resolved | withdrawn + Complainant string `json:"complainant"` + Respondent string `json:"respondent"` + Exchange string `json:"exchange"` +} + +// handleGetDispute serves one case's read model, folded from the ordered +// artifacts — never a stored scalar. Public read: a case is keys, hashes, and +// state; the grievance narrative lives member-local behind ReasonHash. +func (s *Server) handleGetDispute(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHex32(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusBadRequest, "id must be a 32-byte hex dispute ID") + return + } + s.mu.Lock() + c, err := s.registry.Case(dispute.DisputeID(id)) + s.mu.Unlock() + if err != nil { + writeErr(w, http.StatusNotFound, "no case with this dispute ID") + return + } + exchange := c.Exchange() + writeJSON(w, http.StatusOK, disputeView{ + DisputeID: r.PathValue("id"), + State: c.State().String(), + Complainant: hx(c.Complainant()), + Respondent: hx(c.Respondent()), + Exchange: hx(exchange[:]), + }) +} + +type withdrawRequest struct { + WithdrawnAt string `json:"withdrawn_at"` + Signature string `json:"signature"` // hex ed25519 by the complainant over the withdrawal's canonical bytes +} + +// handleWithdrawDispute admits the complainant's signed retraction of their +// own open case. The registry resolves the complainant's key from the stored +// opening — only the member who opened a case can withdraw it, and only while +// it is open. +func (s *Server) handleWithdrawDispute(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHex32(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusBadRequest, "id must be a 32-byte hex dispute ID") + return + } + var req withdrawRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + withdrawnAt, ok := parseUTC(req.WithdrawnAt) + if !ok { + writeErr(w, http.StatusBadRequest, "withdrawn_at must be RFC3339") + return + } + sig, ok := decodeSig(req.Signature) + if !ok { + writeErr(w, http.StatusBadRequest, "signature must be a 64-byte hex signature") + return + } + wd := dispute.Withdrawal{ + Platform: s.platform, + Dispute: dispute.DisputeID(id), + WithdrawnAt: withdrawnAt, + Signature: sig, + } + s.mu.Lock() + err := s.registry.Withdraw(wd) + s.mu.Unlock() + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "withdrawn"}) +} diff --git a/internal/consumerapi/drops.go b/internal/consumerapi/drops.go new file mode 100644 index 0000000..9efba8c --- /dev/null +++ b/internal/consumerapi/drops.go @@ -0,0 +1,239 @@ +package consumerapi + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/record" +) + +// parseUTC parses an RFC3339 instant and normalizes it to UTC — the same +// normalization canon applies at signing time, so a client-signed artifact +// round-trips to identical canonical bytes. +func parseUTC(s string) (time.Time, bool) { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return time.Time{}, false + } + return t.UTC(), true +} + +type dropsLogResponse struct { + LogID string `json:"log_id"` // LogID of this operator's Drops log + OperatorKey string `json:"operator_key"` // the operator's public key (checkpoint signer) + Size uint64 `json:"size"` // entries committed so far +} + +// handleDropsLog serves what a client needs before it can seal a dialog: the +// operator log's identity (inside every entry's signed bytes, so cross-log +// replay is dead) and the operator key checkpoints verify under. Public read. +func (s *Server) handleDropsLog(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + cp := s.opLog.Checkpoint(time.Now().UTC()) + s.mu.Unlock() + writeJSON(w, http.StatusOK, dropsLogResponse{ + LogID: hx(s.logID[:]), + OperatorKey: hx(s.operatorPub), + Size: cp.Size, + }) +} + +type dropRequest struct { + Log string `json:"log"` // hex LogID; must be THIS operator's log + Proposer string `json:"proposer"` // hex ed25519 member key + Acceptor string `json:"acceptor"` // hex ed25519 member key + Content string `json:"content"` // hex HashContent digest of the member-local narrative + Corrects string `json:"corrects"` // hex leaf ID of the entry corrected; empty or zero for a plain covenant + Nonce string `json:"nonce"` // hex 32-byte client-drawn nonce + SealedAt string `json:"sealed_at"` + ProposerSeal string `json:"proposer_seal"` // hex ed25519 seal over the entry's canonical bytes + AcceptorSeal string `json:"acceptor_seal"` +} + +type dropResponse struct { + ID string `json:"id"` // the entry's leaf hash: THE cross-layer exchange reference + Seq uint64 `json:"seq"` +} + +// handleAppendDrop accepts one fully dialog-sealed entry. The trust model is +// slice 1's: both seals are produced CLIENT-SIDE by the two members; the +// server holds no key that can produce a member seal, so its entire power is +// assigning the sequence number. Log.Append re-verifies everything (distinct +// well-formed keys, both seals over the same canonical bytes, log binding); +// this handler adds only the ingress policy that both parties are registered +// members — the composition root is the one place member IDs are minted, and +// an unregistered party's dialog could never anchor an assessment or dispute. +func (s *Server) handleAppendDrop(w http.ResponseWriter, r *http.Request) { + var req dropRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + logID, ok := decodeHex32(req.Log) + if !ok { + writeErr(w, http.StatusBadRequest, "log must be a 32-byte hex LogID") + return + } + if record.Hash(logID) != s.logID { + writeErr(w, http.StatusBadRequest, "log does not name this operator's Drops log (GET /api/v1/drops/log)") + return + } + proposer, ok := decodeKey(req.Proposer) + if !ok { + writeErr(w, http.StatusBadRequest, "proposer must be a 32-byte hex ed25519 key") + return + } + acceptor, ok := decodeKey(req.Acceptor) + if !ok { + writeErr(w, http.StatusBadRequest, "acceptor must be a 32-byte hex ed25519 key") + return + } + content, ok := decodeHex32(req.Content) + if !ok { + writeErr(w, http.StatusBadRequest, "content must be a 32-byte hex digest") + return + } + var corrects [32]byte + if req.Corrects != "" { + if corrects, ok = decodeHex32(req.Corrects); !ok { + writeErr(w, http.StatusBadRequest, "corrects must be a 32-byte hex leaf ID when present") + return + } + } + nonce, ok := decodeHex32(req.Nonce) + if !ok { + writeErr(w, http.StatusBadRequest, "nonce must be 32 hex bytes") + return + } + sealedAt, ok := parseUTC(req.SealedAt) + if !ok { + writeErr(w, http.StatusBadRequest, "sealed_at must be RFC3339") + return + } + pSeal, ok := decodeSig(req.ProposerSeal) + if !ok { + writeErr(w, http.StatusBadRequest, "proposer_seal must be a 64-byte hex signature") + return + } + aSeal, ok := decodeSig(req.AcceptorSeal) + if !ok { + writeErr(w, http.StatusBadRequest, "acceptor_seal must be a 64-byte hex signature") + return + } + + e := record.Entry{ + Log: record.Hash(logID), + Proposer: proposer, + Acceptor: acceptor, + Content: record.Hash(content), + Corrects: record.Hash(corrects), + Nonce: nonce, + SealedAt: sealedAt, + ProposerSeal: pSeal, + AcceptorSeal: aSeal, + } + + s.mu.Lock() + defer s.mu.Unlock() + if !s.registered(proposer) || !s.registered(acceptor) { + writeErr(w, http.StatusBadRequest, "both parties must be registered members") + return + } + seq, err := s.appendEntry(e) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + id := e.ID() + writeJSON(w, http.StatusOK, dropResponse{ID: hx(id[:]), Seq: seq}) +} + +type dropView struct { + ID string `json:"id"` + Seq uint64 `json:"seq"` + Log string `json:"log"` + Proposer string `json:"proposer"` + Acceptor string `json:"acceptor"` + Content string `json:"content"` + Corrects string `json:"corrects,omitempty"` + SealedAt string `json:"sealed_at"` + ProposerSeal string `json:"proposer_seal"` + AcceptorSeal string `json:"acceptor_seal"` +} + +// handleGetDrop serves one sealed entry by its leaf ID. Public read: the +// commons carries keys, hashes, and instants — no PII — so there is nothing +// to gate. The member-local narrative behind Content is NOT here and never +// will be; it lives in the erasable Locker on the member's own device. +func (s *Server) handleGetDrop(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHex32(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusBadRequest, "id must be a 32-byte hex leaf ID") + return + } + s.mu.Lock() + seq, found := s.index[record.Hash(id)] + var e record.Entry + var err error + if found { + e, err = s.recStore.At(seq) + } + s.mu.Unlock() + if !found || err != nil { + writeErr(w, http.StatusNotFound, "no entry with this leaf ID") + return + } + v := dropView{ + ID: hx(id[:]), + Seq: seq, + Log: hx(e.Log[:]), + Proposer: hx(e.Proposer), + Acceptor: hx(e.Acceptor), + Content: hx(e.Content[:]), + SealedAt: e.SealedAt.UTC().Format(time.RFC3339Nano), + ProposerSeal: hx(e.ProposerSeal), + AcceptorSeal: hx(e.AcceptorSeal), + } + var zero record.Hash + if e.Corrects != zero { + v.Corrects = hx(e.Corrects[:]) + } + writeJSON(w, http.StatusOK, v) +} + +type checkpointResponse struct { + Log string `json:"log"` + Size uint64 `json:"size"` + Head string `json:"head"` + IssuedAt string `json:"issued_at"` + Signature string `json:"signature"` // operator's; verify against operator_key from /drops/log + Countersignatures []string `json:"countersignatures"` + // StandIn is the honest label the record layer REQUIRES a surface to carry: + // true whenever fewer than two verified, operator-independent witnesses + // have countersigned. A single-witness (or zero-witness) deployment must + // present itself as the stand-in it is, never as federation. + StandIn bool `json:"stand_in"` +} + +// handleDropsCheckpoints serves the operator's current signed checkpoint as +// the publishable witnessed unit. Slice 2 runs with NO independent witnesses, +// so Countersignatures is empty and StandIn is true — the label is the API +// contract, not a debug field. Independent witnesses federate by appending +// countersignatures (Phase 3); nothing about this surface changes when they do. +func (s *Server) handleDropsCheckpoints(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + cp := s.opLog.Checkpoint(time.Now().UTC()) + cp.Sign(s.operatorPriv) + s.mu.Unlock() + wc := record.WitnessedCheckpoint{Checkpoint: cp} + writeJSON(w, http.StatusOK, checkpointResponse{ + Log: hx(cp.Log[:]), + Size: cp.Size, + Head: hx(cp.Head[:]), + IssuedAt: cp.IssuedAt.UTC().Format(time.RFC3339Nano), + Signature: hx(cp.Signature), + Countersignatures: []string{}, + StandIn: wc.StandIn(s.operatorPub), + }) +} diff --git a/internal/consumerapi/economy.go b/internal/consumerapi/economy.go new file mode 100644 index 0000000..450412b --- /dev/null +++ b/internal/consumerapi/economy.go @@ -0,0 +1,171 @@ +package consumerapi + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/economy" +) + +type policyResponse struct { + Mode string `json:"mode"` // "escrow" or "credit" — the one governed switch + DebitCap uint64 `json:"debit_cap"` // uniform issuance limit; no account gets a deeper well +} + +// handleCreditPolicy serves the ledger's governed policy. In escrow mode the +// API says so plainly — the fee-declaration pattern applied to the credit +// switch: a member reads the rule that governs them at the point of use. +func (s *Server) handleCreditPolicy(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + pol := s.ledger.Policy() + s.mu.Unlock() + writeJSON(w, http.StatusOK, policyResponse{Mode: string(pol.Mode), DebitCap: uint64(pol.DebitCap)}) +} + +type balanceResponse struct { + AccountID string `json:"account_id"` + Balance int64 `json:"balance"` +} + +// handleBalance serves one account's balance, derived by folding the sealed +// ledger — never a stored scalar. Public read: balances are keyed by opaque +// derived AccountIDs and carry no PII. +func (s *Server) handleBalance(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHex32(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusBadRequest, "id must be a 32-byte hex account ID") + return + } + s.mu.Lock() + bal := s.ledger.Balance(economy.AccountID(id)) + s.mu.Unlock() + writeJSON(w, http.StatusOK, balanceResponse{AccountID: r.PathValue("id"), Balance: int64(bal)}) +} + +type spendView struct { + Position int `json:"position"` // append position in the ledger + From string `json:"from"` + To string `json:"to"` + Amount uint64 `json:"amount"` + ExchangeHash string `json:"exchange_hash"` // leaf ID of the sealed dialog this spend commits to + IssuedAt string `json:"issued_at"` + Nonce uint64 `json:"nonce"` +} + +type historyResponse struct { + AccountID string `json:"account_id"` + Spends []spendView `json:"spends"` +} + +// handleHistory serves the spends touching one account, in ledger order. +// Balances and history answer to the same fold, so what this returns is by +// construction consistent with handleBalance — one deterministic function of +// the sealed record, per the economy invariant. +func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHex32(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusBadRequest, "id must be a 32-byte hex account ID") + return + } + acct := economy.AccountID(id) + s.mu.Lock() + records, err := s.ecoStore.All() + s.mu.Unlock() + if err != nil { + writeErr(w, http.StatusInternalServerError, "reading ledger") + return + } + out := historyResponse{AccountID: r.PathValue("id"), Spends: []spendView{}} + for i, rec := range records { + sp, isSpend := rec.(economy.Spend) + if !isSpend || (sp.From != acct && sp.To != acct) { + continue + } + out.Spends = append(out.Spends, spendView{ + Position: i, + From: hx(sp.From[:]), + To: hx(sp.To[:]), + Amount: uint64(sp.Amount), + ExchangeHash: hx(sp.ExchangeHash[:]), + IssuedAt: sp.IssuedAt.UTC().Format(time.RFC3339Nano), + Nonce: sp.Nonce, + }) + } + writeJSON(w, http.StatusOK, out) +} + +type spendRequest struct { + From string `json:"from"` // hex account ID of the payer; its member key signs + To string `json:"to"` + Amount uint64 `json:"amount"` + ExchangeHash string `json:"exchange_hash"` // hex leaf ID of the sealed dialog + IssuedAt string `json:"issued_at"` + Nonce uint64 `json:"nonce"` // strictly monotonic per payer account + Signature string `json:"signature"` // hex ed25519 by the payer over the spend's canonical bytes +} + +// handlePostSpend accepts one payer-signed spend. The server signs nothing: +// the spend arrives sealed by the payer's own key (client-side, like every +// member artifact here) and the ledger re-verifies signature, nonce, parties, +// and the debit cap on admission. +// +// While the governed policy is escrow mode, the ledger refuses every spend +// with ErrCreditDisabled and this endpoint reports that honestly as 409 — +// the escrow→credit transition is a governed PolicyChange (bylaws §5.7: +// Board approval, membership notice, completed regulatory review), never an +// API affordance. There is deliberately no endpoint that could flip it. +func (s *Server) handlePostSpend(w http.ResponseWriter, r *http.Request) { + var req spendRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + from, ok := decodeHex32(req.From) + if !ok { + writeErr(w, http.StatusBadRequest, "from must be a 32-byte hex account ID") + return + } + to, ok := decodeHex32(req.To) + if !ok { + writeErr(w, http.StatusBadRequest, "to must be a 32-byte hex account ID") + return + } + exchange, ok := decodeHex32(req.ExchangeHash) + if !ok { + writeErr(w, http.StatusBadRequest, "exchange_hash must be a 32-byte hex leaf ID") + return + } + issuedAt, ok := parseUTC(req.IssuedAt) + if !ok { + writeErr(w, http.StatusBadRequest, "issued_at must be RFC3339") + return + } + sig, ok := decodeSig(req.Signature) + if !ok { + writeErr(w, http.StatusBadRequest, "signature must be a 64-byte hex signature") + return + } + sp := economy.Spend{ + Platform: s.platform, + From: economy.AccountID(from), + To: economy.AccountID(to), + Amount: economy.Amount(req.Amount), + ExchangeHash: exchange, + IssuedAt: issuedAt, + Nonce: req.Nonce, + Signature: sig, + } + s.mu.Lock() + err := s.ledger.Post(sp) + s.mu.Unlock() + switch { + case err == nil: + writeJSON(w, http.StatusOK, map[string]string{"status": "posted"}) + case errors.Is(err, economy.ErrCreditDisabled): + writeErr(w, http.StatusConflict, err.Error()) + default: + writeErr(w, http.StatusBadRequest, err.Error()) + } +} diff --git a/internal/consumerapi/members.go b/internal/consumerapi/members.go index 5a9cd36..54d00e1 100644 --- a/internal/consumerapi/members.go +++ b/internal/consumerapi/members.go @@ -48,8 +48,12 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { member := covenant.MemberIDFor(s.platform, pub) account := economy.AccountIDFor(s.platform, pub) + // One key, two platform-scoped IDs, ONE owned copy in the shared registry: + // the covenant and economy views resolve the same registered key. + owned := append(ed25519.PublicKey(nil), pub...) s.mu.Lock() - s.byMember[member] = append(ed25519.PublicKey(nil), pub...) + s.byMember[member] = owned + s.byAccount[account] = owned s.mu.Unlock() writeJSON(w, http.StatusOK, registerResponse{ diff --git a/internal/consumerapi/server.go b/internal/consumerapi/server.go index c5732ec..8bea3d1 100644 --- a/internal/consumerapi/server.go +++ b/internal/consumerapi/server.go @@ -1,9 +1,15 @@ // Package consumerapi is Cloudy's member-facing JSON API — the surface the // React Native app talks to, and the first member-facing ingress the headless -// composition root (cmd/cloudy) said did not yet exist. It is slice 1: -// onboarding, the Technology Tree (techtree), and the hardware Market. Economy -// spends, disputes, settlement, contribution control, and storage are later -// slices. +// composition root (cmd/cloudy) said did not yet exist. Slice 1 delivered +// onboarding, the Technology Tree (techtree), and the hardware Market; slice 2 +// adds the four-leaf member economy: Drops (the dialog-sealed record, with the +// operator's signed checkpoint honestly labeled a single-witness stand-in), +// LBTAS assessments anchored to sealed dialogs, payer-signed credit spends +// (refused with a plain 409 while the governed policy is escrow mode), and +// dispute filing/withdrawal (no adjudicator key exists in-process, so no +// ruling is producible here). Settlement, contribution control, and storage +// are later slices; lifecycle witnessing of disputes is Phase-3 federation +// work and is named, not simulated. // // Trust model (the load-bearing decision, from the Phase-1 design §7.1): // member keys NEVER leave the member's device. Every member-authored artifact — @@ -26,7 +32,9 @@ package consumerapi import ( + "bytes" "crypto/ed25519" + "crypto/rand" "encoding/hex" "encoding/json" "errors" @@ -36,6 +44,8 @@ import ( "github.com/NTARI-RAND/sohocloud-protocol/canon" "github.com/NTARI-RAND/Cloudy/internal/covenant" + "github.com/NTARI-RAND/Cloudy/internal/dispute" + "github.com/NTARI-RAND/Cloudy/internal/economy" "github.com/NTARI-RAND/Cloudy/internal/market" "github.com/NTARI-RAND/Cloudy/internal/record" "github.com/NTARI-RAND/Cloudy/internal/techtree" @@ -52,12 +62,33 @@ const domainRegister = "cloudy/consumerapi/register/v0" type Server struct { platform string - mu sync.Mutex - byMember map[covenant.MemberID]ed25519.PublicKey - tree *techtree.Tree - catalog *market.Catalog - book *covenant.Book - locker record.Locker + // The operator's ephemeral record-log keypair. The operator legitimately + // holds its own checkpoint-signing key — what it can never hold is a key + // that produces a member seal, a steward PolicyChange, or an adjudicator + // ruling; those private keys are discarded at construction, so none of + // those artifacts are signable in this process. + operatorPub ed25519.PublicKey + operatorPriv ed25519.PrivateKey + + mu sync.Mutex + byMember map[covenant.MemberID]ed25519.PublicKey + byAccount map[economy.AccountID]ed25519.PublicKey + tree *techtree.Tree + catalog *market.Catalog + book *covenant.Book + locker record.Locker + + // The four-leaf member economy, composed here exactly as cmd/cloudy + // composes it: one shared member directory, one operator Drops log, one + // Entry.ID()->seq index serving both cross-layer anchor joins. + recStore record.Store + opLog *record.Log + logID record.Hash + index map[record.Hash]uint64 + ledger *economy.Ledger + ecoStore *economy.MemStore + registry *dispute.Registry + // manifest maps a commons artifact id (hex) to the Locker hashes of its // member-local narrative — the front-end-local index that lets a reader // fetch narrative the commons deliberately does not carry. @@ -70,7 +101,11 @@ type narrativeRefs struct { Result record.Hash } -// directory is the covenant Directory view over the server's member map. +// directory is the covenant Directory view over the server's member map, and +// accountDirectory the economy view over the account map — two typed views of +// the ONE shared registry (Go method sets cannot overload PublicKey by ID +// type). Every view returns a COPY of the registered key, so no caller can +// corrupt the registry through a returned slice. type directory struct{ s *Server } func (d directory) PublicKey(m covenant.MemberID) (ed25519.PublicKey, bool) { @@ -81,18 +116,87 @@ func (d directory) PublicKey(m covenant.MemberID) (ed25519.PublicKey, bool) { return append(ed25519.PublicKey(nil), pub...), true } -// noAnchors admits no assessments: slice 1 has no sealed-exchange ingress yet, -// so no assessment can be recorded and every Standing is empty-but-valid. When -// the economy/record exchange ingress lands, this is replaced by the -// record-log-backed anchors the cmd/cloudy composition root already implements. -type noAnchors struct{} +type accountDirectory struct{ s *Server } + +func (d accountDirectory) PublicKey(a economy.AccountID) (ed25519.PublicKey, bool) { + pub, ok := d.s.byAccount[a] + if !ok { + return nil, false + } + return append(ed25519.PublicKey(nil), pub...), true +} + +// recAnchors implements covenant.Anchors against the operator's Drops log: +// resolve both MemberIDs through the shared directory, find the entry whose +// ID() equals the ExchangeRef via the server's ID->seq index, and demand an +// entry bound to THIS operator log that fully verifies and is sealed by +// exactly the resolved pair, in either order. Same join as cmd/cloudy's. +// Callers hold s.mu (the Book calls Sealed inside Record, which handlers +// already serialize), so the anchor reads take no lock of their own. +type recAnchors struct{ s *Server } + +func (a recAnchors) Sealed(exchange covenant.ExchangeRef, assessor, subject covenant.MemberID) bool { + assessorKey, ok := a.s.byMember[assessor] + if !ok { + return false + } + subjectKey, ok := a.s.byMember[subject] + if !ok { + return false + } + id := record.Hash(exchange) + seq, ok := a.s.index[id] + if !ok { + return false + } + e, err := a.s.recStore.At(seq) + if err != nil || e.ID() != id || e.Log != a.s.logID || !e.Verify() { + return false + } + return (bytes.Equal(e.Proposer, assessorKey) && bytes.Equal(e.Acceptor, subjectKey)) || + (bytes.Equal(e.Proposer, subjectKey) && bytes.Equal(e.Acceptor, assessorKey)) +} + +// dispAnchors is the dispute-side twin: the same join on the same index, but +// the dispute port speaks raw ed25519 keys, so the party match compares keys +// directly. The [32]byte conversion between record.Hash and the two layers' +// ExchangeRef types happens in these two views and nowhere else. +type dispAnchors struct{ s *Server } + +func (a dispAnchors) Sealed(exchange dispute.ExchangeRef, complainant, respondent ed25519.PublicKey) bool { + id := record.Hash(exchange) + seq, ok := a.s.index[id] + if !ok { + return false + } + e, err := a.s.recStore.At(seq) + if err != nil || e.ID() != id || e.Log != a.s.logID || !e.Verify() { + return false + } + return (bytes.Equal(e.Proposer, complainant) && bytes.Equal(e.Acceptor, respondent)) || + (bytes.Equal(e.Proposer, respondent) && bytes.Equal(e.Acceptor, complainant)) +} -func (noAnchors) Sealed(covenant.ExchangeRef, covenant.MemberID, covenant.MemberID) bool { - return false +// appendEntry is the ONLY append path into the operator log: it appends and +// records the entry's ID->seq mapping, so every assessment or dispute of that +// exchange can anchor to it. Appending via s.opLog directly would strand the +// entry unanchorable forever. Caller holds s.mu. +func (s *Server) appendEntry(e record.Entry) (uint64, error) { + seq, err := s.opLog.Append(e) + if err != nil { + return 0, err + } + s.index[e.ID()] = seq + return seq, nil } -// NewServer constructs the slice-1 composition for a platform with in-memory -// stores. +// NewServer constructs the member-facing composition for a platform with +// in-memory stores and ephemeral keys, exactly as honest about what it is as +// cmd/cloudy: nothing here is a durable deployment. The steward and +// adjudicator PRIVATE keys are discarded at construction — no PolicyChange +// (so no escrow->credit transition) and no ruling is signable in this +// process. Only the operator's checkpoint-signing key is held, because +// serving signed checkpoints is the operator's own job. func NewServer(platform string) (*Server, error) { if platform == "" { return nil, errors.New("consumerapi: platform must be set") @@ -105,19 +209,58 @@ func NewServer(platform string) (*Server, error) { if err != nil { return nil, err } + operatorPub, operatorPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + stewardPub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + adjudicatorPub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } s := &Server{ - platform: platform, - byMember: make(map[covenant.MemberID]ed25519.PublicKey), - tree: tree, - catalog: catalog, - locker: record.NewMemLocker(), - manifest: make(map[string]narrativeRefs), - } - book, err := covenant.NewBook(platform, nil, directory{s}, noAnchors{}, covenant.NewMemStore()) + platform: platform, + operatorPub: operatorPub, + operatorPriv: operatorPriv, + byMember: make(map[covenant.MemberID]ed25519.PublicKey), + byAccount: make(map[economy.AccountID]ed25519.PublicKey), + tree: tree, + catalog: catalog, + locker: record.NewMemLocker(), + index: make(map[record.Hash]uint64), + manifest: make(map[string]narrativeRefs), + } + s.recStore = record.NewMemStore() + s.opLog, err = record.OpenLog(operatorPub, s.recStore) + if err != nil { + return nil, err + } + s.logID = record.LogID(operatorPub) + s.ecoStore = economy.NewMemStore() + s.ledger, err = economy.Open(economy.Genesis{ + Platform: platform, + Stewards: []ed25519.PublicKey{stewardPub}, + Threshold: 1, + Policy: economy.Policy{Mode: economy.ModeEscrow}, + }, accountDirectory{s}, s.ecoStore) + if err != nil { + return nil, err + } + s.book, err = covenant.NewBook(platform, nil, directory{s}, recAnchors{s}, covenant.NewMemStore()) + if err != nil { + return nil, err + } + s.registry, err = dispute.NewRegistry(dispute.Charter{ + Platform: platform, + Adjudicators: []ed25519.PublicKey{adjudicatorPub}, + Threshold: 1, + }, dispAnchors{s}, dispute.NewMemStore()) if err != nil { return nil, err } - s.book = book return s, nil } @@ -133,6 +276,18 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/v1/market/listings", s.handleCreateListing) mux.HandleFunc("GET /api/v1/market/listings", s.handleBrowseListings) mux.HandleFunc("GET /api/v1/market/listings/{id}", s.handleGetListing) + mux.HandleFunc("GET /api/v1/drops/log", s.handleDropsLog) + mux.HandleFunc("GET /api/v1/drops/checkpoints", s.handleDropsCheckpoints) + mux.HandleFunc("POST /api/v1/drops", s.handleAppendDrop) + mux.HandleFunc("GET /api/v1/drops/{id}", s.handleGetDrop) + mux.HandleFunc("GET /api/v1/credit/policy", s.handleCreditPolicy) + mux.HandleFunc("GET /api/v1/credit/accounts/{id}/balance", s.handleBalance) + mux.HandleFunc("GET /api/v1/credit/accounts/{id}/history", s.handleHistory) + mux.HandleFunc("POST /api/v1/credit/spends", s.handlePostSpend) + mux.HandleFunc("POST /api/v1/assessments", s.handleRecordAssessment) + mux.HandleFunc("POST /api/v1/disputes", s.handleOpenDispute) + mux.HandleFunc("GET /api/v1/disputes/{id}", s.handleGetDispute) + mux.HandleFunc("POST /api/v1/disputes/{id}/withdraw", s.handleWithdrawDispute) return mux } diff --git a/internal/record/checkpoint.go b/internal/record/checkpoint.go index 4847d84..94c9d0f 100644 --- a/internal/record/checkpoint.go +++ b/internal/record/checkpoint.go @@ -10,7 +10,7 @@ import ( // domainCheckpoint tags checkpoint signing payloads; an operator's // checkpoint signature is never valid under any other tag, so it can never // pose as a member seal or a witness countersignature. -const domainCheckpoint = "cloudy/record/checkpoint/v0" +const domainCheckpoint = "drops/checkpoint/v0" // Checkpoint is an operator's signed, monotonic commitment to its log head — // the Certificate Transparency signed-tree-head analogue for a linear chain. diff --git a/internal/record/doc.go b/internal/record/doc.go index 380b029..7b11fd8 100644 --- a/internal/record/doc.go +++ b/internal/record/doc.go @@ -1,6 +1,8 @@ -// Package record is Cloudy's dialog-sealed, append-only, witnessed record: -// the durable account of what two members agreed to, kept as per-operator -// hash-chained logs whose integrity any member can verify offline. It is a +// Package record is Drops — Cloudy's dialog-sealed, append-only, witnessed +// record: the durable account of what two members agreed to, kept as +// per-operator hash-chained logs whose integrity any member can verify +// offline. Drops is the product name (and the domain-tag namespace, drops/*); +// record stays the Go package name per the 2026-07-09 naming decision. It is a // JFA member-economy layer the substrate coordination protocol does not // define, and the member-facing analogue of the protocol's anchor/ package: // anchor/ reserves witnessing of substrate EMPLOYMENT claims; this package @@ -117,12 +119,12 @@ // Every hash and signature in the package is computed over canonical bytes // beginning with one of six distinct, unexported domain tags: // -// cloudy/record/content/v0 HashContent digests -// cloudy/record/entry/v0 Entry signing payloads (both seals) -// cloudy/record/leaf/v0 Entry.ID leaf hashes -// cloudy/record/chain/v0 the chain derivation: LogID seed and fold steps -// cloudy/record/checkpoint/v0 Checkpoint signing payloads -// cloudy/record/witness/v0 witness countersignature payloads +// drops/content/v0 HashContent digests +// drops/entry/v0 Entry signing payloads (both seals) +// drops/leaf/v0 Entry.ID leaf hashes +// drops/chain/v0 the chain derivation: LogID seed and fold steps +// drops/checkpoint/v0 Checkpoint signing payloads +// drops/witness/v0 witness countersignature payloads // // so no artifact is transferable between message types, between a hash role // and a signature role, or back into sohocloud-protocol messages. The chain diff --git a/internal/record/entry.go b/internal/record/entry.go index 12fcfa4..2bb4944 100644 --- a/internal/record/entry.go +++ b/internal/record/entry.go @@ -17,9 +17,9 @@ import ( // begins with exactly one tag, so no artifact is transferable between roles // or message types (see doc.go for the full table). const ( - domainContent = "cloudy/record/content/v0" - domainEntry = "cloudy/record/entry/v0" - domainLeaf = "cloudy/record/leaf/v0" + domainContent = "drops/content/v0" + domainEntry = "drops/entry/v0" + domainLeaf = "drops/leaf/v0" ) // Hash is an opaque 32-byte SHA-256 digest: the only value other Cloudy diff --git a/internal/record/log.go b/internal/record/log.go index 313469b..482fd04 100644 --- a/internal/record/log.go +++ b/internal/record/log.go @@ -14,7 +14,7 @@ import ( // single-field seed (LogID over the operator key) and the two-field fold // step (previous head, leaf). Canon's length prefixes keep the two shapes // disjoint, and neither is a signature payload. -const domainChain = "cloudy/record/chain/v0" +const domainChain = "drops/chain/v0" // chainStep folds one leaf hash into the running chain head. func chainStep(prev, leaf Hash) Hash { diff --git a/internal/record/witness.go b/internal/record/witness.go index cd1b6f9..a07783c 100644 --- a/internal/record/witness.go +++ b/internal/record/witness.go @@ -11,7 +11,7 @@ import ( // domainWitness tags witness countersignature payloads, distinct from the // checkpoint tag, so an operator signature can never pose as a witness // cosignature or vice versa. -const domainWitness = "cloudy/record/witness/v0" +const domainWitness = "drops/witness/v0" // witnessPayload is the canonical payload a witness countersigns: the // checkpoint's canonical bytes wrapped under the witness domain tag. diff --git a/internal/storage/audit.go b/internal/storage/audit.go index 262e773..f48b93f 100644 --- a/internal/storage/audit.go +++ b/internal/storage/audit.go @@ -13,7 +13,7 @@ import ( // shard, salted by a nonce so the answer cannot be precomputed or replayed. // The wire envelope that will carry challenges (and the node signature over // responses that makes them payable, non-repudiable metering facts) is SCP -// v0.3 scope — see Development/SCP-completion-roadmap.md. This file is the +// v0.3 scope — see the SCP completion roadmap (sohocloud-protocol repo; NTARI-internal copy: Development/Planning/SCP-completion-roadmap.md). This file is the // member-side substance: what to ask and how to check the answer. type Challenge struct { Offset int64 diff --git a/internal/storage/doc.go b/internal/storage/doc.go index 1ff4bc1..d63bf80 100644 --- a/internal/storage/doc.go +++ b/internal/storage/doc.go @@ -42,7 +42,7 @@ // named follow-up), and the challenge table is a finite-budget // proof-of-storage (homomorphic-tag PoR is the named follow-up). The wire // messages that will carry challenges and lease terms are SCP v0.3 scope — -// see Development/SCP-completion-roadmap.md. +// see the SCP completion roadmap (sohocloud-protocol repo; NTARI-internal copy: Development/Planning/SCP-completion-roadmap.md). // // Residual leaks, named honestly: shard traffic volume per host is still // observable (bounded by class sizes); urgent reads that cannot wait for a diff --git a/test/composition/imports_test.go b/test/composition/imports_test.go index c87e537..a7e1ab2 100644 --- a/test/composition/imports_test.go +++ b/test/composition/imports_test.go @@ -127,8 +127,12 @@ func TestImportGraph(t *testing.T) { } } - // (b) Only the two composition roots may import more than one JFA package. - allowedRoots := map[string]bool{"cmd/cloudy": true, "test/composition": true} + // (b) Only the composition roots may import more than one JFA package. + // internal/consumerapi is the member-facing composition root by design + // (Phase-1 design §2: cloudyd = composition root + consumer JSON API; the + // cmd/cloudyd main stays a thin flag-parsing shell over it, importing no + // JFA package itself). Everything else composes nothing. + allowedRoots := map[string]bool{"cmd/cloudy": true, "internal/consumerapi": true, "test/composition": true} var roots []string for dir, set := range imports { n := 0 @@ -145,15 +149,20 @@ func TestImportGraph(t *testing.T) { } } - // Positive control proving the scan has teeth: both known composition - // roots must be found, each importing all three JFA packages — if the + // Positive control proving the scan has teeth: every known composition + // root must be found, each importing all four JFA packages — if the // walk or the parse ever silently skipped them, this fails rather than // the tripwire going green on an empty graph. sort.Strings(roots) - want := []string{"cmd/cloudy", "test/composition"} - if len(roots) != len(want) || roots[0] != want[0] || roots[1] != want[1] { + want := []string{"cmd/cloudy", "internal/consumerapi", "test/composition"} + if len(roots) != len(want) { t.Fatalf("composition roots found: %v, want exactly %v", roots, want) } + for i := range want { + if roots[i] != want[i] { + t.Fatalf("composition roots found: %v, want exactly %v", roots, want) + } + } for _, dir := range want { for _, jfa := range jfaImportPaths { if !imports[dir][jfa] {