diff --git a/cmd/cloudyd/main.go b/cmd/cloudyd/main.go new file mode 100644 index 0000000..b8737be --- /dev/null +++ b/cmd/cloudyd/main.go @@ -0,0 +1,35 @@ +// 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. +// +// 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. +package main + +import ( + "flag" + "log" + "net/http" + + "github.com/NTARI-RAND/Cloudy/internal/consumerapi" +) + +func main() { + addr := flag.String("addr", ":8088", "listen address for the consumer JSON API") + platform := flag.String("platform", "cloudy", "platform identity (scopes member ids and every artifact)") + flag.Parse() + + srv, err := consumerapi.NewServer(*platform) + if err != nil { + 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) + if err := http.ListenAndServe(*addr, srv.Handler()); err != nil { + log.Fatalf("cloudyd: server exited: %v", err) + } +} diff --git a/internal/consumerapi/api_test.go b/internal/consumerapi/api_test.go new file mode 100644 index 0000000..32bfb95 --- /dev/null +++ b/internal/consumerapi/api_test.go @@ -0,0 +1,255 @@ +package consumerapi + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/covenant" + "github.com/NTARI-RAND/Cloudy/internal/market" + "github.com/NTARI-RAND/Cloudy/internal/techtree" +) + +const platform = "cloudy-test" + +func newTestServer(t *testing.T) http.Handler { + t.Helper() + s, err := NewServer(platform) + if err != nil { + t.Fatal(err) + } + return s.Handler() +} + +func key(seed byte) (ed25519.PublicKey, ed25519.PrivateKey) { + s := make([]byte, ed25519.SeedSize) + for i := range s { + s[i] = seed + } + priv := ed25519.NewKeyFromSeed(s) + return priv.Public().(ed25519.PublicKey), priv +} + +func do(t *testing.T, h http.Handler, method, path string, body any) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + var buf bytes.Buffer + if body != nil { + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatal(err) + } + } + req := httptest.NewRequest(method, path, &buf) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var out map[string]any + if rec.Body.Len() > 0 { + _ = json.Unmarshal(rec.Body.Bytes(), &out) + } + return rec, out +} + +// registerMember drives the real self-signed registration flow. +func registerMember(t *testing.T, h http.Handler, pub ed25519.PublicKey, priv ed25519.PrivateKey) { + t.Helper() + // Reproduce the server's register challenge to sign it. + s, _ := NewServer(platform) + sig := ed25519.Sign(priv, s.registerChallenge(pub)) + rec, _ := do(t, h, "POST", "/api/v1/members", registerRequest{ + PublicKey: hex.EncodeToString(pub), + Signature: hex.EncodeToString(sig), + }) + if rec.Code != http.StatusOK { + t.Fatalf("register: code %d body %s", rec.Code, rec.Body.String()) + } +} + +// anchorClaim builds, signs, and posts a claim; returns its id hex. +func anchorClaim(t *testing.T, h http.Handler, pub ed25519.PublicKey, priv ed25519.PrivateKey, kind techtree.ClaimKind, inputs, method, result string) string { + t.Helper() + c, err := techtree.NewClaim(platform, pub, kind, + techtree.HashNarrative([]byte(inputs)), + techtree.HashNarrative([]byte(method)), + techtree.HashNarrative([]byte(result)), + time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if err := c.Sign(priv); err != nil { + t.Fatal(err) + } + rec, out := do(t, h, "POST", "/api/v1/claims", claimDTO{ + Platform: c.Platform, Claimant: hex.EncodeToString(c.Claimant), Kind: string(c.Kind), + InputsHash: hex.EncodeToString(c.InputsHash[:]), MethodHash: hex.EncodeToString(c.MethodHash[:]), + ResultHash: hex.EncodeToString(c.ResultHash[:]), Nonce: hex.EncodeToString(c.Nonce[:]), + AssertedAtNs: c.AssertedAt.UnixNano(), Signature: hex.EncodeToString(c.Signature), + Inputs: inputs, Method: method, Result: result, + }) + if rec.Code != http.StatusCreated { + t.Fatalf("anchor claim: code %d body %s", rec.Code, rec.Body.String()) + } + return out["claim_id"].(string) +} + +func TestRegisterRequiresProofOfKey(t *testing.T) { + h := newTestServer(t) + pub, _ := key(1) + _, wrongPriv := key(2) + // Sign the challenge with the WRONG key → rejected. + s, _ := NewServer(platform) + badSig := ed25519.Sign(wrongPriv, s.registerChallenge(pub)) + rec, _ := do(t, h, "POST", "/api/v1/members", registerRequest{ + PublicKey: hex.EncodeToString(pub), + Signature: hex.EncodeToString(badSig), + }) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("register with wrong-key signature: code %d, want 401", rec.Code) + } +} + +func TestFullFlow(t *testing.T) { + h := newTestServer(t) + makerPub, makerPriv := key(1) + buyerPub, buyerPriv := key(2) + registerMember(t, h, makerPub, makerPriv) + registerMember(t, h, buyerPub, buyerPriv) + + // Maker anchors a product_spec claim. + specID := anchorClaim(t, h, makerPub, makerPriv, techtree.KindProductSpec, + "NTARI Node One", "8-core ARM, 32GB, 2TB NVMe", "idle draw 6W") + + // GET the claim: structural + narrative from the Locker + zero weight. + rec, out := do(t, h, "GET", "/api/v1/claims/"+specID, nil) + if rec.Code != http.StatusOK { + t.Fatalf("get claim: %d %s", rec.Code, rec.Body.String()) + } + if out["kind"] != "product_spec" { + t.Fatalf("claim kind = %v, want product_spec", out["kind"]) + } + narr, _ := out["narrative"].(map[string]any) + if narr == nil || narr["result"] != "idle draw 6W" { + t.Fatalf("narrative not returned from Locker: %v", out["narrative"]) + } + + // Buyer authors their own fact claim, then CITES the maker's spec. + buyerClaim := anchorClaim(t, h, buyerPub, buyerPriv, techtree.KindFact, + "bought and measured", "kill-a-watt over 24h", "idle draw measured 6.2W") + ref, _ := techtree.NewReference(platform, buyerPub, techtree.RefReproduces, + decodeClaimID(t, buyerClaim), decodeClaimID(t, specID), time.Now().UTC()) + if err := ref.Sign(buyerPriv); err != nil { + t.Fatal(err) + } + rrec, _ := do(t, h, "POST", "/api/v1/references", referenceDTO{ + Platform: ref.Platform, Asserter: hex.EncodeToString(ref.Asserter), Kind: string(ref.Kind), + From: buyerClaim, To: specID, Nonce: hex.EncodeToString(ref.Nonce[:]), + AssertedAtNs: ref.AssertedAt.UnixNano(), Signature: hex.EncodeToString(ref.Signature), + }) + if rrec.Code != http.StatusCreated { + t.Fatalf("add reference: %d %s", rrec.Code, rrec.Body.String()) + } + + // The spec claim now shows one reproduce in its citation weight. + _, cout := do(t, h, "GET", "/api/v1/claims/"+specID, nil) + wt, _ := cout["citation_weight"].(map[string]any) + if wt == nil || wt["reproduces"].(float64) != 1 { + t.Fatalf("citation weight reproduces = %v, want 1", wt) + } + + // Maker lists the product (fiat + member credit), pointing at the spec claim. + l, err := market.NewListing(platform, makerPub, market.CategoryComputer, + market.SpecRef(decodeClaimID(t, specID)), + market.AcceptedRails{Fiat: true, MemberCredit: true}, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if err := l.Sign(makerPriv); err != nil { + t.Fatal(err) + } + lrec, lout := do(t, h, "POST", "/api/v1/market/listings", listingDTO{ + Platform: l.Platform, Maker: hex.EncodeToString(l.Maker), Category: string(l.Category), + Spec: specID, AcceptFiat: true, AcceptCredit: true, Nonce: hex.EncodeToString(l.Nonce[:]), + ListedAtNs: l.ListedAt.UnixNano(), Signature: hex.EncodeToString(l.Signature), + }) + if lrec.Code != http.StatusCreated { + t.Fatalf("create listing: %d %s", lrec.Code, lrec.Body.String()) + } + listingID := lout["listing_id"].(string) + + // Browse the computer category → the listing is there. + _, bout := do(t, h, "GET", "/api/v1/market/listings?category=computer", nil) + listings, _ := bout["listings"].([]any) + if len(listings) != 1 { + t.Fatalf("browse computer: got %d listings, want 1", len(listings)) + } + first, _ := listings[0].(map[string]any) + if first["listing_id"] != listingID || first["accept_member_credit"] != true { + t.Fatalf("listing view mismatch: %v", first) + } + + // Standing endpoint returns an empty-but-valid distribution for the maker + // (registered above; standing is empty — no sealed exchanges yet). + makerMember := string(covenant.MemberIDFor(platform, makerPub)) + srec, sout := do(t, h, "GET", "/api/v1/members/"+makerMember+"/standing", nil) + if srec.Code != http.StatusOK { + t.Fatalf("standing: %d %s", srec.Code, srec.Body.String()) + } + if sout["harm"].(float64) != 0 { + t.Fatalf("fresh member harm = %v, want 0", sout["harm"]) + } +} + +func TestUnregisteredClaimantRejected(t *testing.T) { + h := newTestServer(t) + pub, priv := key(9) // never registered + c, _ := techtree.NewClaim(platform, pub, techtree.KindFact, + techtree.HashNarrative([]byte("a")), techtree.HashNarrative([]byte("b")), + techtree.HashNarrative([]byte("c")), time.Now().UTC()) + _ = c.Sign(priv) + rec, _ := do(t, h, "POST", "/api/v1/claims", claimDTO{ + Platform: c.Platform, Claimant: hex.EncodeToString(c.Claimant), Kind: string(c.Kind), + InputsHash: hex.EncodeToString(c.InputsHash[:]), MethodHash: hex.EncodeToString(c.MethodHash[:]), + ResultHash: hex.EncodeToString(c.ResultHash[:]), Nonce: hex.EncodeToString(c.Nonce[:]), + AssertedAtNs: c.AssertedAt.UnixNano(), Signature: hex.EncodeToString(c.Signature), + Inputs: "a", Method: "b", Result: "c", + }) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("unregistered claimant: code %d, want 401", rec.Code) + } +} + +func TestNarrativeMustMatchSignedHash(t *testing.T) { + h := newTestServer(t) + pub, priv := key(3) + registerMember(t, h, pub, priv) + c, _ := techtree.NewClaim(platform, pub, techtree.KindFact, + techtree.HashNarrative([]byte("real inputs")), techtree.HashNarrative([]byte("m")), + techtree.HashNarrative([]byte("r")), time.Now().UTC()) + _ = c.Sign(priv) + rec, _ := do(t, h, "POST", "/api/v1/claims", claimDTO{ + Platform: c.Platform, Claimant: hex.EncodeToString(c.Claimant), Kind: string(c.Kind), + InputsHash: hex.EncodeToString(c.InputsHash[:]), MethodHash: hex.EncodeToString(c.MethodHash[:]), + ResultHash: hex.EncodeToString(c.ResultHash[:]), Nonce: hex.EncodeToString(c.Nonce[:]), + AssertedAtNs: c.AssertedAt.UnixNano(), Signature: hex.EncodeToString(c.Signature), + Inputs: "TAMPERED inputs", Method: "m", Result: "r", // does not hash to the signed InputsHash + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("mismatched narrative: code %d, want 400", rec.Code) + } +} + +// helpers + +func decodeClaimID(t *testing.T, h string) techtree.ClaimID { + t.Helper() + b, err := hex.DecodeString(h) + if err != nil || len(b) != 32 { + t.Fatalf("bad claim id hex %q", h) + } + var id techtree.ClaimID + copy(id[:], b) + return id +} diff --git a/internal/consumerapi/market.go b/internal/consumerapi/market.go new file mode 100644 index 0000000..daf70aa --- /dev/null +++ b/internal/consumerapi/market.go @@ -0,0 +1,181 @@ +package consumerapi + +import ( + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/market" + "github.com/NTARI-RAND/Cloudy/internal/techtree" +) + +// handleCategories lists the hardware category allowlist (the node-class +// taxonomy). Public. +func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) { + cats := market.Categories() + out := make([]string, len(cats)) + for i, c := range cats { + out[i] = string(c) + } + writeJSON(w, http.StatusOK, map[string][]string{"categories": out}) +} + +type listingDTO struct { + Platform string `json:"platform"` + Maker string `json:"maker"` // hex ed25519 public key + Category string `json:"category"` // must be an allowlisted hardware category + Spec string `json:"spec"` // hex techtree claim id (must be an existing product_spec claim) + AcceptFiat bool `json:"accept_fiat"` + AcceptCredit bool `json:"accept_member_credit"` + Nonce string `json:"nonce"` // hex 32 + ListedAtNs int64 `json:"listed_at_ns"` + Signature string `json:"signature"` // hex ed25519 +} + +func (dto listingDTO) toListing() (market.Listing, bool) { + maker, ok := decodeKey(dto.Maker) + if !ok { + return market.Listing{}, false + } + spec, ok := decodeHex32(dto.Spec) + if !ok { + return market.Listing{}, false + } + nonce, ok := decodeHex32(dto.Nonce) + if !ok { + return market.Listing{}, false + } + sig, ok := decodeSig(dto.Signature) + if !ok { + return market.Listing{}, false + } + return market.Listing{ + Platform: dto.Platform, + Maker: maker, + Category: market.Category(dto.Category), + Spec: market.SpecRef(spec), + Rails: market.AcceptedRails{Fiat: dto.AcceptFiat, MemberCredit: dto.AcceptCredit}, + Nonce: nonce, + ListedAt: time.Unix(0, dto.ListedAtNs).UTC(), + Signature: sig, + }, true +} + +// handleCreateListing ingests a client-signed listing. Beyond the maker +// signature and category allowlist (enforced by market.Listing), it requires +// the Spec to be an existing techtree claim of Kind product_spec authored by +// the same maker — so a listing always points at a real, contestable product +// claim, and a maker cannot list against someone else's spec. +func (s *Server) handleCreateListing(w http.ResponseWriter, r *http.Request) { + var dto listingDTO + if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + l, ok := dto.toListing() + if !ok { + writeErr(w, http.StatusBadRequest, "malformed listing fields") + return + } + s.mu.Lock() + defer s.mu.Unlock() + if !s.registered(l.Maker) { + writeErr(w, http.StatusUnauthorized, "maker is not a registered member") + return + } + // The spec must be an anchored product_spec claim, authored by this maker. + specClaim, ok := s.tree.Claim(techtree.ClaimID(l.Spec)) + if !ok { + writeErr(w, http.StatusConflict, "spec must reference an anchored product-spec claim") + return + } + if specClaim.Kind != techtree.KindProductSpec { + writeErr(w, http.StatusConflict, "spec claim must be of kind product_spec") + return + } + if !specClaim.Claimant.Equal(l.Maker) { + writeErr(w, http.StatusConflict, "a listing's spec claim must be authored by the maker") + return + } + id, err := s.catalog.AddListing(l) + if err != nil { + writeErr(w, listingStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"listing_id": hex.EncodeToString(id[:])}) +} + +func listingStatus(err error) int { + switch { + case errors.Is(err, market.ErrDuplicate): + return http.StatusConflict + default: + return http.StatusBadRequest + } +} + +type listingView struct { + ListingID string `json:"listing_id"` + Platform string `json:"platform"` + Maker string `json:"maker"` + Category string `json:"category"` + Spec string `json:"spec"` + AcceptFiat bool `json:"accept_fiat"` + AcceptCredit bool `json:"accept_member_credit"` + ListedAt string `json:"listed_at"` + SalesCount int `json:"sales_count"` // number of recorded exchanges (provenance depth) +} + +func (s *Server) listingViewLocked(id market.ListingID, l market.Listing) listingView { + return listingView{ + ListingID: hex.EncodeToString(id[:]), + Platform: l.Platform, + Maker: hx(l.Maker), + Category: string(l.Category), + Spec: hex.EncodeToString(l.Spec[:]), + AcceptFiat: l.Rails.Fiat, + AcceptCredit: l.Rails.MemberCredit, + ListedAt: l.ListedAt.UTC().Format(time.RFC3339Nano), + SalesCount: len(s.catalog.SalesOf(id)), + } +} + +// handleBrowseListings lists listings in a category (query ?category=), in +// append order — never ranked or promoted. Public. +func (s *Server) handleBrowseListings(w http.ResponseWriter, r *http.Request) { + cat := market.Category(r.URL.Query().Get("category")) + s.mu.Lock() + defer s.mu.Unlock() + ids := s.catalog.ByCategory(cat) + out := make([]listingView, 0, len(ids)) + for _, id := range ids { + l, ok := s.catalog.Listing(id) + if !ok { + continue + } + out = append(out, s.listingViewLocked(id, l)) + } + writeJSON(w, http.StatusOK, map[string][]listingView{"listings": out}) +} + +// handleGetListing serves one listing. Public. +func (s *Server) handleGetListing(w http.ResponseWriter, r *http.Request) { + idHex := r.PathValue("id") + idBytes, err := hex.DecodeString(idHex) + if err != nil || len(idBytes) != 32 { + writeErr(w, http.StatusBadRequest, "listing id must be 32-byte hex") + return + } + var id market.ListingID + copy(id[:], idBytes) + s.mu.Lock() + defer s.mu.Unlock() + l, ok := s.catalog.Listing(id) + if !ok { + writeErr(w, http.StatusNotFound, "listing not found") + return + } + writeJSON(w, http.StatusOK, s.listingViewLocked(id, l)) +} diff --git a/internal/consumerapi/members.go b/internal/consumerapi/members.go new file mode 100644 index 0000000..5a9cd36 --- /dev/null +++ b/internal/consumerapi/members.go @@ -0,0 +1,116 @@ +package consumerapi + +import ( + "crypto/ed25519" + "encoding/json" + "net/http" + + "github.com/NTARI-RAND/Cloudy/internal/covenant" + "github.com/NTARI-RAND/Cloudy/internal/economy" +) + +type registerRequest struct { + PublicKey string `json:"public_key"` // hex ed25519 public key + Signature string `json:"signature"` // hex ed25519 signature over the register challenge, proving key possession +} + +type registerResponse struct { + AccountID string `json:"account_id"` + MemberID string `json:"member_id"` +} + +// handleRegister registers a member's public key. It is self-authenticating: +// the caller proves possession of the private key by signing the register +// challenge, so no one can register a key that is not theirs. Registration is +// open (zero-cost) — the JFA onboarding stance — and idempotent: re-registering +// the same key returns the same ids. +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + var req registerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + pub, ok := decodeKey(req.PublicKey) + if !ok { + writeErr(w, http.StatusBadRequest, "public_key must be a 32-byte hex ed25519 key") + return + } + sig, ok := decodeSig(req.Signature) + if !ok { + writeErr(w, http.StatusBadRequest, "signature must be a 64-byte hex ed25519 signature") + return + } + if !ed25519.Verify(pub, s.registerChallenge(pub), sig) { + writeErr(w, http.StatusUnauthorized, "signature does not prove possession of this key") + return + } + + member := covenant.MemberIDFor(s.platform, pub) + account := economy.AccountIDFor(s.platform, pub) + + s.mu.Lock() + s.byMember[member] = append(ed25519.PublicKey(nil), pub...) + s.mu.Unlock() + + writeJSON(w, http.StatusOK, registerResponse{ + AccountID: hx(account[:]), + MemberID: string(member), + }) +} + +type distributionDTO struct { + // Counts per LBTAS level, keyed by the level's name — never averaged into a + // score. Total is transaction volume, not a denominator. + Counts map[string]int `json:"counts"` + Total int `json:"total"` +} + +type standingResponse struct { + MemberID string `json:"member_id"` + Overall distributionDTO `json:"overall"` + ByCategory map[string]distributionDTO `json:"by_category"` + Harm int `json:"harm"` // count of No Trust (-1); surfaced by name, never diluted +} + +func distToDTO(d covenant.Distribution) distributionDTO { + counts := map[string]int{} + for _, lvl := range covenant.Levels() { + counts[lvl.String()] = d.Count(lvl) + } + return distributionDTO{Counts: counts, Total: d.Total()} +} + +// handleStanding serves a member's LBTAS standing as distributions — per +// category, pooled overall, and the harm count — never a single score. Public: +// the covenant carries no PII and standing is world-readable (no information +// asymmetry). An unknown member yields an empty-but-valid standing. +func (s *Server) handleStanding(w http.ResponseWriter, r *http.Request) { + member := covenant.MemberID(r.PathValue("id")) + + s.mu.Lock() + standing, err := s.book.Standing(member) + categories := s.categoryNames() + s.mu.Unlock() + if err != nil { + writeErr(w, http.StatusInternalServerError, "reading standing") + return + } + + byCat := map[string]distributionDTO{} + for _, name := range categories { + byCat[name] = distToDTO(standing.Category(name)) + } + writeJSON(w, http.StatusOK, standingResponse{ + MemberID: string(member), + Overall: distToDTO(standing.Overall()), + ByCategory: byCat, + Harm: standing.Harm(), + }) +} + +// categoryNames returns the covenant's category vocabulary. Slice 1 uses the +// LBTAS defaults (the nil categories NewBook was given); surfaced here so the +// standing response enumerates them. +func (s *Server) categoryNames() []string { + return []string{"reliability", "usability", "performance", "support"} +} diff --git a/internal/consumerapi/server.go b/internal/consumerapi/server.go new file mode 100644 index 0000000..c5732ec --- /dev/null +++ b/internal/consumerapi/server.go @@ -0,0 +1,190 @@ +// 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. +// +// 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 — +// a claim, a reference, a listing — is signed CLIENT-SIDE and arrives here +// already sealed; the server VALIDATES (signature + invariants), stores, and +// routes the member-local narrative to the erasable Locker. The server holds no +// member private key and mints nothing a member could forge. +// +// Reads are public: the commons (claims, listings, standing) carries no PII and +// is world-readable by the JFA "no information asymmetry" principle, so GET +// endpoints need no auth. Writes are authenticated by the embedded artifact +// signature itself — a claim is accepted only if it verifies under a registered +// member key — so slice 1 needs no session tokens; those are a later refinement +// for rate-limiting and non-artifact actions. +// +// Slice 1 uses in-memory stores and an ephemeral operator key, exactly like +// cmd/cloudy: it proves the surface end to end without pretending to be a +// durable deployment. Durable persistence and the record witnessing are the +// named follow-ups. +package consumerapi + +import ( + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "sync" + + "github.com/NTARI-RAND/sohocloud-protocol/canon" + + "github.com/NTARI-RAND/Cloudy/internal/covenant" + "github.com/NTARI-RAND/Cloudy/internal/market" + "github.com/NTARI-RAND/Cloudy/internal/record" + "github.com/NTARI-RAND/Cloudy/internal/techtree" +) + +// domainRegister is the message a member signs to prove key possession at +// registration. +const domainRegister = "cloudy/consumerapi/register/v0" + +// Server is the composition root for the member-facing surface: it owns the +// member directory and the layer stores, and mounts the HTTP handlers. It is +// safe for concurrent use — a single mutex guards the in-memory stores and the +// member-local narrative manifest, which is sufficient for slice 1. +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 + // 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. + manifest map[string]narrativeRefs +} + +type narrativeRefs struct { + Inputs record.Hash + Method record.Hash + Result record.Hash +} + +// directory is the covenant Directory view over the server's member map. +type directory struct{ s *Server } + +func (d directory) PublicKey(m covenant.MemberID) (ed25519.PublicKey, bool) { + pub, ok := d.s.byMember[m] + if !ok { + return nil, false + } + 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{} + +func (noAnchors) Sealed(covenant.ExchangeRef, covenant.MemberID, covenant.MemberID) bool { + return false +} + +// NewServer constructs the slice-1 composition for a platform with in-memory +// stores. +func NewServer(platform string) (*Server, error) { + if platform == "" { + return nil, errors.New("consumerapi: platform must be set") + } + tree, err := techtree.NewTree(platform) + if err != nil { + return nil, err + } + catalog, err := market.NewCatalog(platform) + 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()) + if err != nil { + return nil, err + } + s.book = book + return s, nil +} + +// Handler returns the mounted HTTP router for the API. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v1/members", s.handleRegister) + mux.HandleFunc("GET /api/v1/members/{id}/standing", s.handleStanding) + mux.HandleFunc("POST /api/v1/claims", s.handleAnchorClaim) + mux.HandleFunc("GET /api/v1/claims/{id}", s.handleGetClaim) + mux.HandleFunc("POST /api/v1/references", s.handleAddReference) + mux.HandleFunc("GET /api/v1/market/categories", s.handleCategories) + 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) + return mux +} + +// registered reports whether pub is a registered member key (caller holds mu). +func (s *Server) registered(pub ed25519.PublicKey) bool { + m := covenant.MemberIDFor(s.platform, pub) + stored, ok := s.byMember[m] + return ok && stored.Equal(pub) +} + +// registerChallenge is the canonical message a member signs to prove key +// possession at registration. +func (s *Server) registerChallenge(pub ed25519.PublicKey) []byte { + return canon.New(domainRegister).String(s.platform).Bytes(pub).Sum() +} + +// --- JSON + hex helpers --- + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} + +func decodeHex32(s string) ([32]byte, bool) { + var out [32]byte + b, err := hex.DecodeString(s) + if err != nil || len(b) != 32 { + return out, false + } + copy(out[:], b) + return out, true +} + +func decodeKey(s string) (ed25519.PublicKey, bool) { + b, err := hex.DecodeString(s) + if err != nil || len(b) != ed25519.PublicKeySize { + return nil, false + } + return ed25519.PublicKey(b), true +} + +func decodeSig(s string) ([]byte, bool) { + b, err := hex.DecodeString(s) + if err != nil || len(b) != ed25519.SignatureSize { + return nil, false + } + return b, true +} + +func hx(b []byte) string { return hex.EncodeToString(b) } diff --git a/internal/consumerapi/techtree.go b/internal/consumerapi/techtree.go new file mode 100644 index 0000000..1b4aac2 --- /dev/null +++ b/internal/consumerapi/techtree.go @@ -0,0 +1,278 @@ +package consumerapi + +import ( + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/techtree" +) + +// claimDTO is the wire form of a techtree.Claim: every signed field, hex-coded, +// with the timestamp as unix nanoseconds so it round-trips to the exact +// time.Time the client signed (canon.Time uses UTC UnixNano, which is +// location-independent). The three narrative texts travel ALONGSIDE the signed +// claim; the server checks each hashes to the signed hash, then stores the +// text member-local in the Locker and keeps only the hash in the commons. +type claimDTO struct { + Platform string `json:"platform"` + Claimant string `json:"claimant"` // hex ed25519 public key + Kind string `json:"kind"` // fact | technique | product_spec + InputsHash string `json:"inputs_hash"` // hex; must equal HashNarrative(inputs) + MethodHash string `json:"method_hash"` // hex; must equal HashNarrative(method) + ResultHash string `json:"result_hash"` // hex; must equal HashNarrative(result) + Nonce string `json:"nonce"` // hex 32 + AssertedAtNs int64 `json:"asserted_at_ns"` + Signature string `json:"signature"` // hex ed25519 + + // Member-local narrative — never enters the commons; routed to the Locker. + Inputs string `json:"inputs"` + Method string `json:"method"` + Result string `json:"result"` +} + +func (dto claimDTO) toClaim() (techtree.Claim, bool) { + claimant, ok := decodeKey(dto.Claimant) + if !ok { + return techtree.Claim{}, false + } + inputsHash, ok := decodeHex32(dto.InputsHash) + if !ok { + return techtree.Claim{}, false + } + methodHash, ok := decodeHex32(dto.MethodHash) + if !ok { + return techtree.Claim{}, false + } + resultHash, ok := decodeHex32(dto.ResultHash) + if !ok { + return techtree.Claim{}, false + } + nonce, ok := decodeHex32(dto.Nonce) + if !ok { + return techtree.Claim{}, false + } + sig, ok := decodeSig(dto.Signature) + if !ok { + return techtree.Claim{}, false + } + return techtree.Claim{ + Platform: dto.Platform, + Claimant: claimant, + Kind: techtree.ClaimKind(dto.Kind), + InputsHash: inputsHash, + MethodHash: methodHash, + ResultHash: resultHash, + Nonce: nonce, + AssertedAt: time.Unix(0, dto.AssertedAtNs).UTC(), + Signature: sig, + }, true +} + +// handleAnchorClaim ingests a client-signed claim plus its narrative. It +// verifies the narrative matches the signed hashes (so the commons hash and the +// Locker content correspond), that the claim is signed by a REGISTERED member, +// and that the claim itself verifies; then it appends to the tree and stores +// the narrative member-local. +func (s *Server) handleAnchorClaim(w http.ResponseWriter, r *http.Request) { + var dto claimDTO + if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + c, ok := dto.toClaim() + if !ok { + writeErr(w, http.StatusBadRequest, "malformed claim fields") + return + } + // The narrative the client sends must hash to the hashes it signed. + if techtree.HashNarrative([]byte(dto.Inputs)) != c.InputsHash || + techtree.HashNarrative([]byte(dto.Method)) != c.MethodHash || + techtree.HashNarrative([]byte(dto.Result)) != c.ResultHash { + writeErr(w, http.StatusBadRequest, "narrative does not match the signed hashes") + return + } + + s.mu.Lock() + defer s.mu.Unlock() + if !s.registered(c.Claimant) { + writeErr(w, http.StatusUnauthorized, "claimant is not a registered member") + return + } + id, err := s.tree.AddClaim(c) + if err != nil { + writeErr(w, addClaimStatus(err), err.Error()) + return + } + // Store the narrative member-local (erasable Locker) and index it. + s.manifest[hex.EncodeToString(id[:])] = narrativeRefs{ + Inputs: s.locker.Put([]byte(dto.Inputs)), + Method: s.locker.Put([]byte(dto.Method)), + Result: s.locker.Put([]byte(dto.Result)), + } + writeJSON(w, http.StatusCreated, map[string]string{"claim_id": hex.EncodeToString(id[:])}) +} + +func addClaimStatus(err error) int { + switch { + case errors.Is(err, techtree.ErrDuplicate): + return http.StatusConflict + case errors.Is(err, techtree.ErrWrongPlatform), errors.Is(err, techtree.ErrBadSignature): + return http.StatusBadRequest + default: + return http.StatusBadRequest + } +} + +type claimView struct { + ClaimID string `json:"claim_id"` + Platform string `json:"platform"` + Claimant string `json:"claimant"` + Kind string `json:"kind"` + AssertedAt string `json:"asserted_at"` + Weight weightDTO `json:"citation_weight"` + Narrative *narrativeView `json:"narrative,omitempty"` +} + +type narrativeView struct { + Inputs string `json:"inputs"` + Method string `json:"method"` + Result string `json:"result"` +} + +type weightDTO struct { + Cites int `json:"cites"` + BuildsOn int `json:"builds_on"` + Reproduces int `json:"reproduces"` + Refutes int `json:"refutes"` + Contests int `json:"contests"` +} + +// handleGetClaim serves a claim's structural facts, its legible citation-weight +// breakdown (never a score), and — if the narrative is held locally — the +// inputs/method/result text from the Locker. Public read. +func (s *Server) handleGetClaim(w http.ResponseWriter, r *http.Request) { + idHex := r.PathValue("id") + idBytes, err := hex.DecodeString(idHex) + if err != nil || len(idBytes) != 32 { + writeErr(w, http.StatusBadRequest, "claim id must be 32-byte hex") + return + } + var id techtree.ClaimID + copy(id[:], idBytes) + + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.tree.Claim(id) + if !ok { + writeErr(w, http.StatusNotFound, "claim not found") + return + } + w0 := s.tree.CitationWeight(id) + view := claimView{ + ClaimID: idHex, + Platform: c.Platform, + Claimant: hx(c.Claimant), + Kind: string(c.Kind), + AssertedAt: c.AssertedAt.UTC().Format(time.RFC3339Nano), + Weight: weightDTO{ + Cites: w0.Cites, BuildsOn: w0.BuildsOn, Reproduces: w0.Reproduces, + Refutes: w0.Refutes, Contests: w0.Contests, + }, + } + if refs, ok := s.manifest[idHex]; ok { + in, iok := s.locker.Get(refs.Inputs) + me, mok := s.locker.Get(refs.Method) + re, rok := s.locker.Get(refs.Result) + if iok && mok && rok { + view.Narrative = &narrativeView{Inputs: string(in), Method: string(me), Result: string(re)} + } + } + writeJSON(w, http.StatusOK, view) +} + +type referenceDTO struct { + Platform string `json:"platform"` + Asserter string `json:"asserter"` // hex ed25519 public key + Kind string `json:"kind"` // builds_on | cites | contests | reproduces | refutes + From string `json:"from"` // hex claim id + To string `json:"to"` // hex claim id + Nonce string `json:"nonce"` // hex 32 + AssertedAtNs int64 `json:"asserted_at_ns"` + Signature string `json:"signature"` // hex ed25519 +} + +func (dto referenceDTO) toReference() (techtree.Reference, bool) { + asserter, ok := decodeKey(dto.Asserter) + if !ok { + return techtree.Reference{}, false + } + from, ok := decodeHex32(dto.From) + if !ok { + return techtree.Reference{}, false + } + to, ok := decodeHex32(dto.To) + if !ok { + return techtree.Reference{}, false + } + nonce, ok := decodeHex32(dto.Nonce) + if !ok { + return techtree.Reference{}, false + } + sig, ok := decodeSig(dto.Signature) + if !ok { + return techtree.Reference{}, false + } + return techtree.Reference{ + Platform: dto.Platform, + Asserter: asserter, + Kind: techtree.RefKind(dto.Kind), + From: techtree.ClaimID(from), + To: techtree.ClaimID(to), + Nonce: nonce, + AssertedAt: time.Unix(0, dto.AssertedAtNs).UTC(), + Signature: sig, + }, true +} + +// handleAddReference ingests a client-signed edge (cite/contest/reproduce/etc.) +// between two claims. The tree enforces that From and To exist and that the +// asserter owns From; a contest is a new edge, never an erasure. +func (s *Server) handleAddReference(w http.ResponseWriter, r *http.Request) { + var dto referenceDTO + if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + ref, ok := dto.toReference() + if !ok { + writeErr(w, http.StatusBadRequest, "malformed reference fields") + return + } + s.mu.Lock() + defer s.mu.Unlock() + if !s.registered(ref.Asserter) { + writeErr(w, http.StatusUnauthorized, "asserter is not a registered member") + return + } + id, err := s.tree.AddReference(ref) + if err != nil { + writeErr(w, referenceStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"reference_id": hex.EncodeToString(id[:])}) +} + +func referenceStatus(err error) int { + switch { + case errors.Is(err, techtree.ErrDuplicate): + return http.StatusConflict + case errors.Is(err, techtree.ErrUnknownClaim), errors.Is(err, techtree.ErrNotAsserter), + errors.Is(err, techtree.ErrBuildsOnCycle): + return http.StatusConflict + default: + return http.StatusBadRequest + } +}