diff --git a/system/manifest.go b/system/manifest.go index 497b71a..6264ca7 100644 --- a/system/manifest.go +++ b/system/manifest.go @@ -1,6 +1,10 @@ package system import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" "io/fs" "log" "net/http" @@ -12,6 +16,14 @@ import ( "github.com/mirrorstack-ai/app-module-sdk/internal/registry" ) +// manifestHashHeader carries hex(sha256(exact served manifest body bytes)). +// The platform and CLI READ this header rather than recomputing the hash, so +// they can never disagree with the module about its declared surface. The hash +// covers the Go-declared manifest (id/slug/pages/permissions/schedules/ +// migrations/...) and is stable across esbuild JS rebuilds, because the +// manifest body carries no JS. +const manifestHashHeader = "X-MS-Manifest-Hash" + // ManifestPayload is the JSON shape returned by GET /__mirrorstack/platform/manifest. // The platform reads this on deploy to discover module identity, capabilities, // migration versions, and the semver→migration mapping it needs to translate @@ -183,7 +195,7 @@ func ManifestHandler(id, slug, name, icon string, tags []string, sqlFS fs.FS, ve contribSlots = []contributions.SlotInfo{} } - httputil.JSON(w, http.StatusOK, ManifestPayload{ + payload := ManifestPayload{ ID: id, Slug: slug, Defaults: ManifestDefaults{Name: name, Icon: icon, Tags: tags, NameLabels: i18n.Lookup("module.name"), TagLabels: i18n.LookupList(tagCatalogKey, tagLabelSep)}, @@ -203,6 +215,26 @@ func ManifestHandler(id, slug, name, icon string, tags []string, sqlFS fs.FS, ve UI: reg.UI(), Provides: contribSlots, ContributesTo: reg.OutboundContributions(), - }) + } + + // Marshal the body ONCE, hash exactly those bytes, advertise the hash + // in X-MS-Manifest-Hash, then write the SAME bytes. Encoding must be + // byte-identical to httputil.JSON (json.Encoder: HTML escaping + a + // trailing newline) so the served body is unchanged and the hash the + // platform/CLI read matches sha256 over what they actually receive. + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(payload); err != nil { + log.Printf("mirrorstack: manifest marshal error: %v", err) + httputil.JSON(w, http.StatusInternalServerError, httputil.ErrorResponse{Error: "manifest unavailable"}) + return + } + body := buf.Bytes() + sum := sha256.Sum256(body) + w.Header().Set("Content-Type", "application/json") + w.Header().Set(manifestHashHeader, hex.EncodeToString(sum[:])) + w.WriteHeader(http.StatusOK) + if _, err := w.Write(body); err != nil { + log.Printf("mirrorstack: manifest write error: %v", err) + } } } diff --git a/system/manifest_test.go b/system/manifest_test.go index b0a55c3..fe034e1 100644 --- a/system/manifest_test.go +++ b/system/manifest_test.go @@ -1,6 +1,8 @@ package system import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "net/http" "net/http/httptest" @@ -369,6 +371,105 @@ func TestManifest_UIPopulatedWhenRegistered(t *testing.T) { } } +// manifestResponse serves the handler once and returns the recorder so tests +// can inspect both the X-MS-Manifest-Hash header and the exact body bytes. +func manifestResponse(t *testing.T, h http.HandlerFunc) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest("GET", "/__mirrorstack/platform/manifest", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + return rec +} + +func TestManifest_HashHeaderMatchesBody(t *testing.T) { + t.Parallel() + + rec := manifestResponse(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, nil, registry.New(), nil)) + + got := rec.Header().Get("X-MS-Manifest-Hash") + if len(got) != 64 { + t.Fatalf("X-MS-Manifest-Hash = %q, want 64-char hex", got) + } + if got != strings.ToLower(got) { + t.Errorf("X-MS-Manifest-Hash = %q, want lowercase hex", got) + } + + // The header MUST be sha256 over the EXACT served body bytes (including the + // json.Encoder trailing newline), never a separate re-encode. + sum := sha256.Sum256(rec.Body.Bytes()) + if want := hex.EncodeToString(sum[:]); got != want { + t.Errorf("X-MS-Manifest-Hash = %q, want %q (sha256 of served body)", got, want) + } + + // Body contract is unchanged: still valid JSON carrying id/slug fields. + var payload ManifestPayload + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("body is not valid JSON: %v", err) + } + if payload.ID != "media" { + t.Errorf("body id = %q, want media", payload.ID) + } +} + +func TestManifest_HashStableAcrossCalls(t *testing.T) { + t.Parallel() + + // Same id/slug/registry/versions → identical body → identical hash. The + // header is the single source of truth, so two serves must never diverge. + newHandler := func() http.HandlerFunc { + reg := registry.New() + reg.AddPermission("media.view", []string{"admin"}) + reg.AddSchedule("cleanup", "0 3 * * *", "/crons/cleanup") + return ManifestHandler("media", "media", "Media", "perm_media", nil, nil, nil, reg, nil) + } + first := manifestResponse(t, newHandler()).Header().Get("X-MS-Manifest-Hash") + second := manifestResponse(t, newHandler()).Header().Get("X-MS-Manifest-Hash") + if first == "" { + t.Fatal("X-MS-Manifest-Hash missing") + } + if first != second { + t.Errorf("hash not stable: %q != %q", first, second) + } +} + +func TestManifest_HashChangesWithDeclaration(t *testing.T) { + t.Parallel() + + base := registry.New() + baseHash := manifestResponse(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, nil, base, nil)). + Header().Get("X-MS-Manifest-Hash") + + // Adding a declared permission changes the served surface → new hash. + withPerm := registry.New() + withPerm.AddPermission("media.view", []string{"admin"}) + permHash := manifestResponse(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, nil, withPerm, nil)). + Header().Get("X-MS-Manifest-Hash") + if baseHash == permHash { + t.Errorf("hash unchanged after adding a permission: %q", baseHash) + } + + // Adding a schedule likewise moves the hash. + withSched := registry.New() + withSched.AddSchedule("cleanup", "0 3 * * *", "/crons/cleanup") + schedHash := manifestResponse(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, nil, withSched, nil)). + Header().Get("X-MS-Manifest-Hash") + if baseHash == schedHash { + t.Errorf("hash unchanged after adding a schedule: %q", baseHash) + } +} + +func TestManifest_ContentTypeJSON(t *testing.T) { + t.Parallel() + + rec := manifestResponse(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, nil, registry.New(), nil)) + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } +} + func TestManifest_UIOmittedWhenNotRegistered(t *testing.T) { t.Parallel()