Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions system/manifest.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package system

import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io/fs"
"log"
"net/http"
Expand All @@ -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
Expand Down Expand Up @@ -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)},
Expand All @@ -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)
}
}
}
101 changes: 101 additions & 0 deletions system/manifest_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package system

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -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()

Expand Down
Loading