Merge pull request #37 from carlosframework/ledger-primitive
Open-ledgers primitive: hash-chained ledger streams, console API writes, fail-closed edge serving
25 files changed,
+4333
−19
apimode.go+119 −0apimode_test.go+600 −0cli.go+331 −0docs/superpowers/specs/2026-08-03-open-ledgers-design.md+70 −11internal/console/apiledger.go+381 −0internal/console/apiledger_test.go+728 −0internal/console/authcli.go+9 −0internal/edge/edge.go+18 −1internal/edge/status.go+260 −5internal/edge/status_test.go+678 −0internal/ledger/append.go+109 −0internal/ledger/append_test.go+169 −0internal/ledger/blob.go+28 −0internal/ledger/blob_test.go+37 −0internal/ledger/ledger.go+84 −0internal/ledger/ledger_test.go+37 −0internal/ledger/manifest.go+98 −0internal/ledger/manifest_test.go+168 −0internal/ledger/verify.go+99 −0internal/ledger/verify_test.go+136 −0internal/release/qualified_test.go+3 −2internal/release/scopedpromote_test.go+5 −0internal/release/store.go+71 −0internal/release/store_test.go+87 −0main.go+8 −0
diff --git a/apimode.go b/apimode.go| index 6145af533..0f22b2679 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -29,6 +29,8 @@ import ( |
| "path/filepath" |
| "sort" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/ledger" |
| ) |
| // apiSession is the resolved API mode: which console, as whom. console and |
| @@ -359,6 +361,123 @@ func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiCh |
| return out, nil |
| } |
| +// ledgerAppend asks the console to CAS-append one entry to name's chain, |
| +// stamping writer from the caller's own authenticated identity server-side |
| +// (never client-supplied — apiledger.go). A 503 here is contention (too many |
| +// writers accruing the same chain at once, ledger.ErrBusy) — retry-worthy, |
| +// which is exactly what apiErrorFromBody's json-first decode surfaces, since |
| +// ledgerStoreError answers it with a real "{"error": "..."}" body, not a bare |
| +// status. |
| +func (s *apiSession) ledgerAppend(ctx context.Context, account, app, name string, payload json.RawMessage) (ledger.Appended, error) { |
| + var out ledger.Appended |
| + status, body, err := s.postJSON(ctx, apiPath(account, app, "/ledgers/"+url.PathEscape(name)+"/append"), |
| + map[string]json.RawMessage{"payload": payload}) |
| + if err != nil { |
| + return out, err |
| + } |
| + if status != http.StatusOK { |
| + return out, apiErrorFromBody(status, body) |
| + } |
| + return out, json.Unmarshal(body, &out) |
| +} |
| + |
| +// ledgerHead reads name's HEAD.json through the console. Its 404 is answered |
| +// with a dedicated message rather than apiErrorFromBody's generic membership |
| +// text: an unknown LEDGER under an app the caller does belong to is a |
| +// different, much more common case ("nothing appended yet") than "check |
| +// -account and -app", and deserves to say so. |
| +func (s *apiSession) ledgerHead(ctx context.Context, account, app, name string) (ledger.Head, error) { |
| + var out ledger.Head |
| + status, body, err := s.getJSON(ctx, apiPath(account, app, "/ledgers/"+url.PathEscape(name)+"/head")) |
| + if err != nil { |
| + return out, err |
| + } |
| + if status == http.StatusNotFound { |
| + return out, fmt.Errorf("no %q ledger yet for this app", name) |
| + } |
| + if status != http.StatusOK { |
| + return out, apiErrorFromBody(status, body) |
| + } |
| + return out, json.Unmarshal(body, &out) |
| +} |
| + |
| +// ledgerList reads an app's ledger names and its publish manifest through |
| +// the console. The raw body is handed back rather than a decoded struct: |
| +// {"manifest": Manifest|null, "ledgers": [...]} is the caller's shape to |
| +// interpret (a null manifest means "never published" — a caller-side zero |
| +// value would erase that distinction), and the CLI print path decodes it |
| +// itself right where it decides what to show a human. |
| +func (s *apiSession) ledgerList(ctx context.Context, account, app string) ([]byte, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(account, app, "/ledgers")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + return body, nil |
| +} |
| + |
| +// ledgerPublish adds (or, with public:false, removes) named ledgers from the |
| +// set an app serves publicly. The console applies it as a read-modify-write |
| +// on the stored manifest, so the request says what to CHANGE and only the |
| +// response says what is public NOW — a set that may well name ledgers this |
| +// call never mentioned. That resulting manifest is the return value: without |
| +// it the CLI could only echo the caller's own argument back at them, which |
| +// is exactly how someone comes to believe they unpublished a chain they |
| +// didn't. |
| +func (s *apiSession) ledgerPublish(ctx context.Context, account, app string, m ledger.Manifest) (ledger.Manifest, error) { |
| + status, body, err := s.postJSON(ctx, apiPath(account, app, "/ledgers/publish"), m) |
| + if err != nil { |
| + return ledger.Manifest{}, err |
| + } |
| + if status != http.StatusOK { |
| + return ledger.Manifest{}, apiErrorFromBody(status, body) |
| + } |
| + var out ledger.Manifest |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return ledger.Manifest{}, fmt.Errorf("ledger publish: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| +// ledgerBlob posts raw bytes (not JSON) to the ledger's blobs door — a |
| +// content-addressed, create-only upload (ledger.PutBlob; a re-post of the |
| +// same bytes is a no-op, matching ship's artifact semantics). It follows |
| +// getJSON's own request plumbing exactly — same base URL field, same bearer |
| +// header (apimode.go:212-230) — but with http.MethodPost and a |
| +// bytes.Reader body in place of GET/nil, since the wire body here is the |
| +// blob's raw bytes, never JSON. |
| +func (s *apiSession) ledgerBlob(ctx context.Context, account, app, name string, body []byte) (string, error) { |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, |
| + s.c.base+apiPath(account, app, "/ledgers/"+url.PathEscape(name)+"/blobs"), bytes.NewReader(body)) |
| + if err != nil { |
| + return "", err |
| + } |
| + if s.c.token != "" { |
| + req.Header.Set("Authorization", "Bearer "+s.c.token) |
| + } |
| + res, err := s.c.httpc.Do(req) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer res.Body.Close() |
| + respBody, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) |
| + if err != nil { |
| + return "", err |
| + } |
| + if res.StatusCode != http.StatusOK { |
| + return "", apiErrorFromBody(res.StatusCode, respBody) |
| + } |
| + var out struct { |
| + SHA256 string `json:"sha256"` |
| + } |
| + if err := json.Unmarshal(respBody, &out); err != nil { |
| + return "", fmt.Errorf("ledger blob: unreadable response: %w", err) |
| + } |
| + return out.SHA256, nil |
| +} |
| + |
| // shipTimeout bounds ship's own context — generous on purpose. The console |
| // gives itself up to 10 minutes to read the request body and 2 minutes to |
| // store it (internal/console/apicli.go), well past the 30s absolute Timeout |
diff --git a/apimode_test.go b/apimode_test.go| index 88d5c7cb0..8a05cecfe 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -20,6 +20,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/ledger" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -1241,3 +1242,602 @@ func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) { |
| } |
| checkNoRegistry() |
| } |
| + |
| +// ---- Task 6: carlos ledger — apiSession methods and the CLI verbs -------- |
| + |
| +// TestAPISessionLedgerAppend pins the wire shape handleAPILedgerAppend |
| +// (internal/console/apiledger.go) requires: bearer auth, POST to |
| +// .../ledgers/<name>/append, body {"payload": <verbatim>}. |
| +func TestAPISessionLedgerAppend(t *testing.T) { |
| + var gotAuth, gotPath, gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotAuth = r.Header.Get("Authorization") |
| + gotPath = r.URL.Path |
| + b, _ := io.ReadAll(r.Body) |
| + gotBody = string(b) |
| + json.NewEncoder(w).Encode(ledger.Appended{Key: "ledgers/acct/app/carbon/entries/x.json", SHA256: strings.Repeat("a", 64), Count: 3}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + a, err := s.ledgerAppend(context.Background(), "acct", "app", "carbon", json.RawMessage(`{"n":1}`)) |
| + if err != nil { |
| + t.Fatalf("ledgerAppend: %v", err) |
| + } |
| + if gotAuth != "Bearer cli-token" { |
| + t.Fatalf("Authorization = %q, want Bearer cli-token", gotAuth) |
| + } |
| + if gotPath != "/api/cli/apps/acct/app/ledgers/carbon/append" { |
| + t.Fatalf("path = %q", gotPath) |
| + } |
| + if !strings.Contains(gotBody, `"payload":{"n":1}`) { |
| + t.Fatalf("request body = %q, want the payload carried verbatim", gotBody) |
| + } |
| + if a.Count != 3 { |
| + t.Fatalf("Count = %d, want 3", a.Count) |
| + } |
| +} |
| + |
| +// TestAPISessionLedgerAppendBusy pins ledgerStoreError's contention status: |
| +// a 503 with a JSON "error" body must surface that text, not a generic |
| +// status-only message — it is what tells a caller "retry", not "stop". |
| +func TestAPISessionLedgerAppendBusy(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusServiceUnavailable) |
| + json.NewEncoder(w).Encode(map[string]string{"error": "the ledger is busy right now — try again in a moment"}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.ledgerAppend(context.Background(), "acct", "app", "carbon", json.RawMessage(`{"n":1}`)) |
| + if err == nil || !strings.Contains(err.Error(), "busy") { |
| + t.Fatalf("ledgerAppend 503 error = %v, want it to surface the busy message", err) |
| + } |
| +} |
| + |
| +// TestAPISessionLedgerHeadNotFound pins ledgerHead's DEDICATED 404 message — |
| +// deliberately not apiErrorFromBody's generic "-account/-app" text, since an |
| +// unknown LEDGER under an app the caller does belong to is a different, far |
| +// more common case ("nothing appended yet"). |
| +func TestAPISessionLedgerHeadNotFound(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.ledgerHead(context.Background(), "acct", "app", "carbon") |
| + if err == nil { |
| + t.Fatal("ledgerHead against a 404 must error") |
| + } |
| + if !strings.Contains(err.Error(), `"carbon"`) { |
| + t.Fatalf("error = %q, want it to name the ledger", err) |
| + } |
| + if strings.Contains(err.Error(), "-account") { |
| + t.Fatalf("error = %q, must NOT fall back to the generic membership message", err) |
| + } |
| +} |
| + |
| +func TestAPISessionLedgerHead(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(ledger.Head{Schema: 1, UpdatedAt: "2026-08-03T00:00:00Z", HeadKey: "k", HeadSHA256: strings.Repeat("b", 64), Count: 5}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + h, err := s.ledgerHead(context.Background(), "acct", "app", "carbon") |
| + if err != nil { |
| + t.Fatalf("ledgerHead: %v", err) |
| + } |
| + if h.Count != 5 || h.HeadKey != "k" { |
| + t.Fatalf("head = %+v", h) |
| + } |
| +} |
| + |
| +// TestAPISessionLedgerList pins ledgerList's raw-body contract: the caller |
| +// (runLedgerList) decodes {"manifest","ledgers"} itself, so a null manifest |
| +// survives round-trip as nil rather than a caller-side zero value that would |
| +// erase the "never published" distinction. |
| +func TestAPISessionLedgerList(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"manifest": nil, "ledgers": []string{"carbon"}}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + body, err := s.ledgerList(context.Background(), "acct", "app") |
| + if err != nil { |
| + t.Fatalf("ledgerList: %v", err) |
| + } |
| + var res struct { |
| + Manifest *ledger.Manifest `json:"manifest"` |
| + Ledgers []string `json:"ledgers"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + t.Fatalf("unmarshal: %v", err) |
| + } |
| + if res.Manifest != nil { |
| + t.Fatalf("manifest = %+v, want nil", res.Manifest) |
| + } |
| + if len(res.Ledgers) != 1 || res.Ledgers[0] != "carbon" { |
| + t.Fatalf("ledgers = %v, want [carbon]", res.Ledgers) |
| + } |
| +} |
| + |
| +// TestAPISessionLedgerPublish: publish is read-modify-write server-side, so |
| +// the answer is the RESULTING manifest — the set that is public now, which |
| +// may name ledgers this request never mentioned. A method that discarded the |
| +// body would leave the CLI with nothing honest to print. |
| +func TestAPISessionLedgerPublish(t *testing.T) { |
| + var gotPath, gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.Path |
| + b, _ := io.ReadAll(r.Body) |
| + gotBody = string(b) |
| + json.NewEncoder(w).Encode(ledger.Manifest{ |
| + Schema: 1, Public: true, Ledgers: []string{"prompts", "carbon"}, Title: "T"}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + m, err := s.ledgerPublish(context.Background(), "acct", "app", |
| + ledger.Manifest{Public: true, Ledgers: []string{"carbon"}, Title: "T"}) |
| + if err != nil { |
| + t.Fatalf("ledgerPublish: %v", err) |
| + } |
| + if !m.Public || len(m.Ledgers) != 2 || m.Ledgers[1] != "carbon" { |
| + t.Fatalf("ledgerPublish = %+v, want the server's RESULTING manifest, not the request", m) |
| + } |
| + if gotPath != "/api/cli/apps/acct/app/ledgers/publish" { |
| + t.Fatalf("path = %q", gotPath) |
| + } |
| + if !strings.Contains(gotBody, `"public":true`) || !strings.Contains(gotBody, `"carbon"`) { |
| + t.Fatalf("body = %q", gotBody) |
| + } |
| +} |
| + |
| +// TestAPISessionLedgerBlob pins ledgerBlob's request plumbing — the one |
| +// method that hand-builds its request rather than going through |
| +// getJSON/postJSON, because the wire body is raw bytes, not JSON. Same base |
| +// URL and bearer header as getJSON, POST instead of GET, a bytes.Reader body |
| +// instead of nil. |
| +func TestAPISessionLedgerBlob(t *testing.T) { |
| + var gotAuth, gotPath string |
| + var gotBody []byte |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotAuth = r.Header.Get("Authorization") |
| + gotPath = r.URL.Path |
| + gotBody, _ = io.ReadAll(r.Body) |
| + json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(gotBody)}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + sha, err := s.ledgerBlob(context.Background(), "acct", "app", "carbon", []byte("corpus")) |
| + if err != nil { |
| + t.Fatalf("ledgerBlob: %v", err) |
| + } |
| + if gotAuth != "Bearer cli-token" { |
| + t.Fatalf("Authorization = %q, want Bearer cli-token", gotAuth) |
| + } |
| + if gotPath != "/api/cli/apps/acct/app/ledgers/carbon/blobs" { |
| + t.Fatalf("path = %q", gotPath) |
| + } |
| + if string(gotBody) != "corpus" { |
| + t.Fatalf("request body = %q, want the raw bytes verbatim", gotBody) |
| + } |
| + if want := ledger.SHA256Hex([]byte("corpus")); sha != want { |
| + t.Fatalf("sha256 = %q, want %q", sha, want) |
| + } |
| +} |
| + |
| +// TestAPISessionLedgerBlobError pins ledgerBlob's error path — a non-200 |
| +// status decodes through apiErrorFromBody exactly like every other method |
| +// here, even though the request itself was raw bytes, not JSON. |
| +func TestAPISessionLedgerBlobError(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusRequestEntityTooLarge) |
| + json.NewEncoder(w).Encode(map[string]string{"error": "blob exceeds the 4 MiB cap"}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.ledgerBlob(context.Background(), "acct", "app", "carbon", []byte("x")) |
| + if err == nil || !strings.Contains(err.Error(), "4 MiB cap") { |
| + t.Fatalf("ledgerBlob error = %v, want the server's 413 message surfaced", err) |
| + } |
| +} |
| + |
| +// TestLedgerVerbsOfferNoRegistryFlag: -registry names the local database |
| +// that resolves an account in DIRECT-bucket mode, and ledgerAPISession |
| +// refuses direct mode outright (spec §2.3). A ledger verb that advertised |
| +// -registry would be offering a mode it will then refuse — and the flag |
| +// would be dead besides, since nothing on this path opens a registry (the |
| +// requireNoRegistryTouch assertions elsewhere in this file are the other |
| +// half of that proof). |
| +func TestLedgerVerbsOfferNoRegistryFlag(t *testing.T) { |
| + fs := flag.NewFlagSet("ledger", flag.ContinueOnError) |
| + ledgerAccountFlag(fs) |
| + if fs.Lookup("account") == nil { |
| + t.Fatal("ledger verbs must still take -account") |
| + } |
| + if f := fs.Lookup("registry"); f != nil { |
| + t.Fatalf("ledger verbs register -registry (%q) — direct-bucket mode is refused, not configured", f.Usage) |
| + } |
| +} |
| + |
| +// ---- Task 6: the ledger verbs wired to resolveMode / public HTTPS -------- |
| + |
| +// TestLedgerAppendVerbAPIMode pins runLedgerAppend's console wiring: it |
| +// reads the entry from a file argument, validates it client-side (already |
| +// smoke-tested via `go run . ledger append` — invalid JSON never reaches |
| +// this server, see the task report), and posts through the console. |
| +func TestLedgerAppendVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + var reqCount int32 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/carbon/append" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + var in struct { |
| + Payload json.RawMessage `json:"payload"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&in) |
| + if string(in.Payload) != `{"n":1}` { |
| + t.Errorf("payload = %s, want {\"n\":1}", in.Payload) |
| + } |
| + json.NewEncoder(w).Encode(ledger.Appended{Key: "k", SHA256: strings.Repeat("c", 64), Count: 7}) |
| + })) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + dir := t.TempDir() |
| + entryPath := filepath.Join(dir, "entry.json") |
| + if err := os.WriteFile(entryPath, []byte(`{"n":1}`), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runLedgerAppend([]string{"-app", "titogo", "-account", apiTestAccount, "carbon", entryPath}) |
| + |
| + out := buf.String() |
| + if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want it to contain %q", out, want) |
| + } |
| + if !strings.Contains(out, "appended to carbon") || !strings.Contains(out, "#7") { |
| + t.Fatalf("stdout = %q, want the appended line naming the ledger and count 7", out) |
| + } |
| + if !strings.Contains(out, strings.Repeat("c", 12)) { |
| + t.Fatalf("stdout = %q, want the sha's first 12 hex chars", out) |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 1 { |
| + t.Fatalf("server got %d requests, want 1", got) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestLedgerAppendVerbReadsStdin pins the "-" convention: the entry argument |
| +// "-" reads the payload from stdin rather than treating "-" as a filename. |
| +func TestLedgerAppendVerbReadsStdin(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + var gotPayload string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + var in struct { |
| + Payload json.RawMessage `json:"payload"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&in) |
| + gotPayload = string(in.Payload) |
| + json.NewEncoder(w).Encode(ledger.Appended{Key: "k", SHA256: strings.Repeat("d", 64), Count: 1}) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + r, w, err := os.Pipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := w.WriteString(`{"n":2}`); err != nil { |
| + t.Fatal(err) |
| + } |
| + w.Close() |
| + origStdin := os.Stdin |
| + os.Stdin = r |
| + t.Cleanup(func() { os.Stdin = origStdin }) |
| + |
| + buf := captureStdout(t) |
| + runLedgerAppend([]string{"-app", "titogo", "-account", apiTestAccount, "carbon", "-"}) |
| + |
| + if gotPayload != `{"n":2}` { |
| + t.Fatalf("payload the server received = %q, want the stdin content", gotPayload) |
| + } |
| + if !strings.Contains(buf.String(), "appended to carbon") { |
| + t.Fatalf("stdout = %q", buf.String()) |
| + } |
| +} |
| + |
| +// TestLedgerHeadVerbAPIMode pins the one deliberately-different output shape |
| +// in this file: head prints ONLY the HEAD JSON (no "using ... as ..." |
| +// banner line) so a script can pipe it straight into `jq`. |
| +func TestLedgerHeadVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/carbon/head" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + json.NewEncoder(w).Encode(ledger.Head{ |
| + Schema: 1, UpdatedAt: "2026-08-03T00:00:00Z", |
| + HeadKey: "ledgers/x/HEAD.json", HeadSHA256: strings.Repeat("e", 64), Count: 9, |
| + }) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runLedgerHead([]string{"-app", "titogo", "-account", apiTestAccount, "carbon"}) |
| + |
| + out := buf.String() |
| + if strings.Contains(out, "using ") { |
| + t.Fatalf("stdout = %q, head must print ONLY the HEAD JSON, no banner line", out) |
| + } |
| + var h ledger.Head |
| + if err := json.Unmarshal([]byte(out), &h); err != nil { |
| + t.Fatalf("head output is not valid JSON: %v (%q)", err, out) |
| + } |
| + if h.Count != 9 { |
| + t.Fatalf("head.Count = %d, want 9", h.Count) |
| + } |
| +} |
| + |
| +// Note: runLedgerHead's 404 path ends in log.Fatal(err), which exits the |
| +// process — untestable in-process, matching this file's existing convention |
| +// (captureStdout's own doc comment above: "log.Fatal paths are untouched by |
| +// this seam on purpose"). The error text itself, which is what a human |
| +// actually reads, is pinned at the apiSession layer instead, by |
| +// TestAPISessionLedgerHeadNotFound above. |
| + |
| +func TestLedgerListVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "manifest": map[string]any{"schema": 1, "public": true, "ledgers": []string{"carbon"}, "title": "Titogo"}, |
| + "ledgers": []string{"carbon", "prompts"}, |
| + }) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runLedgerList([]string{"-app", "titogo", "-account", apiTestAccount}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, `published "Titogo"`) || !strings.Contains(out, "carbon") { |
| + t.Fatalf("stdout = %q, want the published-manifest line naming the title and ledgers", out) |
| + } |
| + if !strings.Contains(out, "prompts") { |
| + t.Fatalf("stdout = %q, want the prompts ledger listed even though it isn't in the manifest", out) |
| + } |
| +} |
| + |
| +// TestLedgerListVerbUnpublished pins the null-manifest case: "never |
| +// published" must read as its own message, not an empty/zero-value table. |
| +func TestLedgerListVerbUnpublished(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"manifest": nil, "ledgers": []string{"carbon"}}) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runLedgerList([]string{"-app", "titogo", "-account", apiTestAccount}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, "not published") { |
| + t.Fatalf("stdout = %q, want a not-published message for a null manifest", out) |
| + } |
| + if !strings.Contains(out, "carbon") { |
| + t.Fatalf("stdout = %q, want the carbon ledger still listed", out) |
| + } |
| +} |
| + |
| +func TestLedgerPublishVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + var gotPath, gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.Path |
| + b, _ := io.ReadAll(r.Body) |
| + gotBody = string(b) |
| + // The server's answer names a ledger this call never mentioned: |
| + // publish is additive, so the resulting set is bigger than the ask. |
| + json.NewEncoder(w).Encode(ledger.Manifest{ |
| + Schema: 1, Public: true, Ledgers: []string{"prompts", "carbon"}, Title: "Titogo"}) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-title", "Titogo", "carbon"}) |
| + |
| + if gotPath != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/publish" { |
| + t.Fatalf("path = %q", gotPath) |
| + } |
| + if !strings.Contains(gotBody, `"public":true`) || !strings.Contains(gotBody, `"carbon"`) || !strings.Contains(gotBody, `"Titogo"`) { |
| + t.Fatalf("publish body = %q", gotBody) |
| + } |
| + // The line reports the RESULTING public set, not the request's argument |
| + // — printing "carbon now public" while prompts is also public is how a |
| + // caller ends up believing they unpublished something they didn't. |
| + if !strings.Contains(buf.String(), "published titogo: public ledgers now [prompts carbon]") { |
| + t.Fatalf("stdout = %q, want the resulting public set", buf.String()) |
| + } |
| +} |
| + |
| +// TestLedgerPublishVerbUnpublish pins -public=false's own message (an |
| +// unpublish is not the same act as a publish, and must not read like one), |
| +// and that it too reports what is left public rather than what was removed. |
| +func TestLedgerPublishVerbUnpublish(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + var gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + b, _ := io.ReadAll(r.Body) |
| + gotBody = string(b) |
| + json.NewEncoder(w).Encode(ledger.Manifest{ |
| + Schema: 1, Public: true, Ledgers: []string{"prompts"}, Title: "Titogo"}) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-public=false", "carbon"}) |
| + |
| + if !strings.Contains(gotBody, `"public":false`) { |
| + t.Fatalf("publish body = %q, want public:false", gotBody) |
| + } |
| + if !strings.Contains(buf.String(), "unpublished titogo: public ledgers now [prompts]") { |
| + t.Fatalf("stdout = %q, want what is still public", buf.String()) |
| + } |
| +} |
| + |
| +// TestLedgerPublishVerbUnpublishLast: when the last ledger goes, the line |
| +// says so plainly rather than printing an empty bracket. |
| +func TestLedgerPublishVerbUnpublishLast(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(ledger.Manifest{Schema: 1, Public: false, Title: "Titogo"}) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runLedgerPublish([]string{"-app", "titogo", "-account", apiTestAccount, "-public=false", "carbon"}) |
| + |
| + if !strings.Contains(buf.String(), "unpublished titogo: no public ledgers") { |
| + t.Fatalf("stdout = %q, want the emptied-set message", buf.String()) |
| + } |
| +} |
| + |
| +func TestLedgerBlobVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/ledgers/prompts/blobs" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + b, _ := io.ReadAll(r.Body) |
| + json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(b)}) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "transcript.txt") |
| + if err := os.WriteFile(path, []byte("corpus bytes"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runLedgerBlob([]string{"-app", "titogo", "-account", apiTestAccount, "prompts", path}) |
| + |
| + want := ledger.SHA256Hex([]byte("corpus bytes")) |
| + if !strings.Contains(buf.String(), want) { |
| + t.Fatalf("stdout = %q, want it to contain the uploaded blob's sha256 %q", buf.String(), want) |
| + } |
| +} |
| + |
| +// TestLedgerBlobVerbMultipleFiles pins that "blob <ledger> <file>..." takes |
| +// every trailing file argument, one upload per file. |
| +func TestLedgerBlobVerbMultipleFiles(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + var reqCount int32 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + b, _ := io.ReadAll(r.Body) |
| + json.NewEncoder(w).Encode(map[string]string{"sha256": ledger.SHA256Hex(b)}) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + dir := t.TempDir() |
| + pathA := filepath.Join(dir, "a.txt") |
| + pathB := filepath.Join(dir, "b.txt") |
| + if err := os.WriteFile(pathA, []byte("aaa"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(pathB, []byte("bbb"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runLedgerBlob([]string{"-app", "titogo", "-account", apiTestAccount, "prompts", pathA, pathB}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, ledger.SHA256Hex([]byte("aaa"))) || !strings.Contains(out, ledger.SHA256Hex([]byte("bbb"))) { |
| + t.Fatalf("stdout = %q, want both files' sha256", out) |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 2 { |
| + t.Fatalf("server got %d requests, want 2 (one per file)", got) |
| + } |
| +} |
| + |
| +// TestLedgerVerifyVerb pins runLedgerVerify's own wiring: -url trimmed and |
| +// handed to ledger.HTTPGetter, -account treated as a bare sqid (never |
| +// resolved), and the printed message. Deliberately does NOT call |
| +// useTempCredentials/clearDeploymentEnv/loginTo — the whole point of |
| +// "verify -url" is that it needs no credential and no console session at |
| +// all, and this test proves it by never configuring either. |
| +func TestLedgerVerifyVerb(t *testing.T) { |
| + dir := t.TempDir() |
| + st := release.NewDirStore(dir) |
| + fixedNow := func(i int) func() time.Time { |
| + return func() time.Time { return time.Date(2026, 8, 3, 0, i, 0, 0, time.UTC) } |
| + } |
| + for i := range 2 { |
| + if _, err := ledger.Append(context.Background(), st, "bab", "platform", "carbon", "w", |
| + json.RawMessage(`{"i":1}`), fixedNow(i)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + b, _, err := st.Get(r.Context(), strings.TrimPrefix(r.URL.Path, "/")) |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Write(b) |
| + })) |
| + defer ts.Close() |
| + |
| + buf := captureStdout(t) |
| + // Trailing slash on -url, deliberately, to pin strings.TrimRight. |
| + runLedgerVerify([]string{"-app", "platform", "-account", "bab", "-url", ts.URL + "/", "carbon"}) |
| + |
| + if !strings.Contains(buf.String(), "verified: 2 entries") { |
| + t.Fatalf("stdout = %q, want the verified line", buf.String()) |
| + } |
| +} |
diff --git a/cli.go b/cli.go| index fdccb2676..3594d88b7 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -9,11 +9,13 @@ import ( |
| "crypto/ed25519" |
| "crypto/sha256" |
| "encoding/hex" |
| + "encoding/json" |
| "errors" |
| "flag" |
| "fmt" |
| "io" |
| "log" |
| + "net/http" |
| "os" |
| "path/filepath" |
| "regexp" |
| @@ -23,6 +25,7 @@ import ( |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/ledger" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| @@ -1129,6 +1132,334 @@ func runRollback(args []string) { |
| fmt.Fprintf(stdout, "rolled back %s %s\n", *app, fs.Arg(0)) |
| } |
| +// ledgerUsage lists every `carlos ledger` sub-verb — printed for a bare |
| +// `carlos ledger`, an unrecognized sub, and `-h`, matching RunAuth/ |
| +// RunAccounts/RunEnv's own bare/unknown/-h behaviour (auth.go, accounts.go, |
| +// appconfig_cli.go): "-h" is never special-cased inside a sub's own |
| +// flag.FlagSet, it just isn't a recognized args[0] here either, so it falls |
| +// through to this same text. |
| +const ledgerUsage = `usage: carlos ledger <command> |
| + |
| +commands: |
| + append -app <a> [-account <acct>] <ledger> <entry.json|-> |
| + append one JSON entry to the chain — |
| + reads the file, or stdin with "-"; |
| + validated as JSON before it is sent |
| + head -app <a> [-account <acct>] <ledger> |
| + print the chain's HEAD.json verbatim |
| + list -app <a> [-account <acct>] |
| + list this app's ledger names and its |
| + publish manifest (what's public, if |
| + anything) |
| + publish -app <a> [-account <acct>] [-public=true|false] [-title <t>] <ledger>... |
| + add these ledgers to what this app |
| + serves publicly (additive: the ones |
| + already public stay public, and an |
| + omitted -title keeps the stored one); |
| + -public=false removes just these, |
| + taking the app private only when |
| + nothing is left |
| + blob -app <a> [-account <acct>] <ledger> <file>... |
| + upload one or more content-addressed |
| + blobs (create-only; re-upload of |
| + identical bytes is a no-op) |
| + verify -app <a> -account <sqid> -url <https://status-host> <ledger> |
| + walk a PUBLISHED chain over plain |
| + HTTPS and re-hash every entry — needs |
| + NO credentials, so anyone can check a |
| + published ledger. -account here is the |
| + account SQID, not a name: the public |
| + status-host path is keyed by sqid. |
| + |
| +Every sub above except "verify -url" goes through the console API — this |
| +terminal never touches bucket credentials directly (spec §2.3, |
| +docs/superpowers/specs/2026-08-03-open-ledgers-design.md). [-console |
| +<origin>] picks which logged-in console when more than one is configured. |
| +` |
| + |
| +// RunLedger dispatches the ledger sub-verbs. |
| +func RunLedger(args []string) { |
| + if len(args) == 0 { |
| + fmt.Fprint(os.Stderr, ledgerUsage) |
| + os.Exit(1) |
| + } |
| + switch args[0] { |
| + case "append": |
| + runLedgerAppend(args[1:]) |
| + case "head": |
| + runLedgerHead(args[1:]) |
| + case "list": |
| + runLedgerList(args[1:]) |
| + case "publish": |
| + runLedgerPublish(args[1:]) |
| + case "blob": |
| + runLedgerBlob(args[1:]) |
| + case "verify": |
| + runLedgerVerify(args[1:]) |
| + default: |
| + fmt.Fprint(os.Stderr, ledgerUsage) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// ledgerAPISession resolves this terminal's console session for a ledger |
| +// sub-verb that writes or reads through the console (every sub except |
| +// `verify -url`, which never calls this at all). Unlike promote/channels/ |
| +// rollback/ship, ledgers have no direct-bucket mode to fall back to — spec |
| +// §2.3 is unconditional: a contributor machine gets no bucket credentials, |
| +// full stop. resolveMode returning (nil, nil) means CARLOS_DEPLOYMENT_BUCKET |
| +// or _DIR is set, i.e. direct mode is what this terminal is configured for — |
| +// which for every OTHER release verb is a valid path to take, but for ledger |
| +// is refused here rather than silently reinterpreted or left for a caller |
| +// to nil-dereference. |
| +func ledgerAPISession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("carlos ledger always goes through the console API — no bucket credentials in a terminal " + |
| + "(spec §2.3); run carlos auth login -console <url>, or unset CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR") |
| + } |
| + return sess |
| +} |
| + |
| +// ledgerAccountFlag registers the ONE account flag a ledger verb has, in |
| +// place of accountFlags. accountFlags also registers -registry, the local |
| +// registry database that resolves an account name in DIRECT-bucket mode — |
| +// and ledgerAPISession refuses direct mode outright (spec §2.3: no bucket |
| +// credentials in a terminal). A -registry flag on these verbs is therefore |
| +// dead: nothing here ever opens a registry, and offering the flag advertises |
| +// a mode that does not exist. The account is resolved server-side, by the |
| +// console, against the caller's own memberships — which is why -account |
| +// takes a sqid OR a name here and needs no local database to do it. |
| +func ledgerAccountFlag(fs *flag.FlagSet) *string { |
| + return fs.String("account", "ops", "account (sqid or name) the app belongs to — resolved by the console") |
| +} |
| + |
| +func runLedgerAppend(args []string) { |
| + fs := flag.NewFlagSet("ledger append", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctRaw := ledgerAccountFlag(fs) |
| + consoleFlag := fs.String("console", "", "console origin to append through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + if *app == "" || fs.NArg() != 2 { |
| + log.Fatal("usage: carlos ledger append -app <a> [-account <acct>] <ledger> <entry.json|->") |
| + } |
| + name, src := fs.Arg(0), fs.Arg(1) |
| + if !ledger.ValidName(name) { |
| + log.Fatalf("ledger append: invalid ledger name %q", name) |
| + } |
| + |
| + // Read and validate the payload BEFORE ever dialing the console — a |
| + // malformed entry is the caller's mistake to see immediately, not a |
| + // round trip and a server-side 400. |
| + var raw []byte |
| + var err error |
| + if src == "-" { |
| + raw, err = io.ReadAll(os.Stdin) |
| + } else { |
| + raw, err = os.ReadFile(src) |
| + } |
| + if err != nil { |
| + log.Fatalf("ledger append: %v", err) |
| + } |
| + if !json.Valid(raw) { |
| + log.Fatalf("ledger append: %s is not valid JSON", src) |
| + } |
| + |
| + sess := ledgerAPISession(*consoleFlag) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + a, err := sess.ledgerAppend(context.Background(), *acctRaw, *app, name, json.RawMessage(raw)) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Fprintf(stdout, "appended to %s: entry #%d (sha %s)\n", name, a.Count, a.SHA256[:12]) |
| +} |
| + |
| +// runLedgerHead prints HEAD.json verbatim — no "using ... as ..." banner |
| +// line first, unlike every other sub here, so the output stays valid JSON a |
| +// script can pipe straight into `jq` rather than a human-readable line plus |
| +// a JSON blob. |
| +func runLedgerHead(args []string) { |
| + fs := flag.NewFlagSet("ledger head", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctRaw := ledgerAccountFlag(fs) |
| + consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + if *app == "" || fs.NArg() != 1 { |
| + log.Fatal("usage: carlos ledger head -app <a> [-account <acct>] <ledger>") |
| + } |
| + name := fs.Arg(0) |
| + if !ledger.ValidName(name) { |
| + log.Fatalf("ledger head: invalid ledger name %q", name) |
| + } |
| + |
| + sess := ledgerAPISession(*consoleFlag) |
| + h, err := sess.ledgerHead(context.Background(), *acctRaw, *app, name) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + b, err := json.MarshalIndent(h, "", " ") |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Fprintln(stdout, string(b)) |
| +} |
| + |
| +func runLedgerList(args []string) { |
| + fs := flag.NewFlagSet("ledger list", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctRaw := ledgerAccountFlag(fs) |
| + consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + if *app == "" { |
| + log.Fatal("usage: carlos ledger list -app <a> [-account <acct>]") |
| + } |
| + |
| + sess := ledgerAPISession(*consoleFlag) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + body, err := sess.ledgerList(context.Background(), *acctRaw, *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + var res struct { |
| + Manifest *ledger.Manifest `json:"manifest"` |
| + Ledgers []string `json:"ledgers"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + log.Fatalf("ledger list: unreadable response: %v", err) |
| + } |
| + |
| + switch { |
| + case res.Manifest == nil: |
| + fmt.Fprintln(stdout, "not published — nothing is public for this app yet") |
| + case res.Manifest.Public: |
| + title := res.Manifest.Title |
| + if title == "" { |
| + title = *app |
| + } |
| + fmt.Fprintf(stdout, "published %q: %s\n", title, strings.Join(res.Manifest.Ledgers, ", ")) |
| + default: |
| + fmt.Fprintln(stdout, "unpublished (public=false)") |
| + } |
| + if len(res.Ledgers) == 0 { |
| + fmt.Fprintln(stdout, "no ledgers yet") |
| + return |
| + } |
| + w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| + fmt.Fprintln(w, "LEDGER") |
| + for _, n := range res.Ledgers { |
| + fmt.Fprintln(w, n) |
| + } |
| + w.Flush() |
| +} |
| + |
| +func runLedgerPublish(args []string) { |
| + fs := flag.NewFlagSet("ledger publish", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctRaw := ledgerAccountFlag(fs) |
| + public := fs.Bool("public", true, "serve these ledgers publicly (-public=false unpublishes)") |
| + title := fs.String("title", "", "display title for the published set") |
| + consoleFlag := fs.String("console", "", "console origin to publish through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + if *app == "" || fs.NArg() == 0 { |
| + log.Fatal("usage: carlos ledger publish -app <a> [-account <acct>] [-public=true|false] [-title <t>] <ledger>...") |
| + } |
| + names := fs.Args() |
| + for _, n := range names { |
| + if !ledger.ValidName(n) { |
| + log.Fatalf("ledger publish: invalid ledger name %q", n) |
| + } |
| + } |
| + |
| + sess := ledgerAPISession(*consoleFlag) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + m := ledger.Manifest{Public: *public, Ledgers: names, Title: *title} |
| + got, err := sess.ledgerPublish(context.Background(), *acctRaw, *app, m) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + // Report the RESULTING public set, never the argument: publish is |
| + // additive and unpublish removes by name, so the app's public set after |
| + // this call is something only the server can say. Echoing `names` here |
| + // would tell a caller who published `prompts` that prompts is what is |
| + // public — when carbon is public too, or, on an unpublish, when carbon is |
| + // what is left. |
| + verb := "published" |
| + if !*public { |
| + verb = "unpublished" |
| + } |
| + if len(got.Ledgers) == 0 { |
| + fmt.Fprintf(stdout, "%s %s: no public ledgers\n", verb, *app) |
| + return |
| + } |
| + fmt.Fprintf(stdout, "%s %s: public ledgers now [%s]\n", verb, *app, strings.Join(got.Ledgers, " ")) |
| +} |
| + |
| +func runLedgerBlob(args []string) { |
| + fs := flag.NewFlagSet("ledger blob", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctRaw := ledgerAccountFlag(fs) |
| + consoleFlag := fs.String("console", "", "console origin to upload through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + if *app == "" || fs.NArg() < 2 { |
| + log.Fatal("usage: carlos ledger blob -app <a> [-account <acct>] <ledger> <file>...") |
| + } |
| + name := fs.Arg(0) |
| + if !ledger.ValidName(name) { |
| + log.Fatalf("ledger blob: invalid ledger name %q", name) |
| + } |
| + files := fs.Args()[1:] |
| + |
| + sess := ledgerAPISession(*consoleFlag) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + for _, path := range files { |
| + data, err := os.ReadFile(path) |
| + if err != nil { |
| + log.Fatalf("ledger blob: %v", err) |
| + } |
| + if len(data) > ledger.MaxBlob { |
| + log.Fatalf("ledger blob: %s is %d bytes, exceeds the %d byte cap", path, len(data), ledger.MaxBlob) |
| + } |
| + sha, err := sess.ledgerBlob(context.Background(), *acctRaw, *app, name, data) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Fprintf(stdout, "%s: %s\n", path, sha) |
| + } |
| +} |
| + |
| +// runLedgerVerify never calls resolveMode/ledgerAPISession — verifying a |
| +// published chain needs no console session and no credential at all (spec |
| +// §2.5): it is a plain HTTPS walk against the public status-host, which is |
| +// the entire point of publishing one. -account here is the account SQID, |
| +// not a name — the public path is keyed by sqid (apimode.go's apiPath, by |
| +// contrast, accepts either, because the console resolves a name against the |
| +// caller's own memberships; there is no caller and no membership check |
| +// here). |
| +func runLedgerVerify(args []string) { |
| + fs := flag.NewFlagSet("ledger verify", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + account := fs.String("account", "", "account SQID (not name) — the public status-host path is keyed by sqid") |
| + urlBase := fs.String("url", "", "public status-host base URL, e.g. https://status.example.com — no credentials needed") |
| + fs.Parse(args) |
| + if *app == "" || *account == "" || *urlBase == "" || fs.NArg() != 1 { |
| + log.Fatal("usage: carlos ledger verify -app <a> -account <sqid> -url <https://status-host> <ledger>") |
| + } |
| + name := fs.Arg(0) |
| + if !ledger.ValidName(name) { |
| + log.Fatalf("ledger verify: invalid ledger name %q", name) |
| + } |
| + |
| + g := ledger.HTTPGetter(http.DefaultClient, strings.TrimRight(*urlBase, "/")) |
| + res, err := ledger.Verify(context.Background(), g, *account, *app, name) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Fprintf(stdout, "verified: %d entries, head %s\n", res.Count, res.HeadSHA256[:12]) |
| +} |
| + |
| // defaultGrantTTL is how long a scoped key lives when the operator does not |
| // say: a quarter. Short enough that a forgotten builder key stops working |
| // while someone still remembers what it was for, long enough that rotating it |
diff --git a/docs/superpowers/specs/2026-08-03-open-ledgers-design.md b/docs/superpowers/specs/2026-08-03-open-ledgers-design.md| index 329ca6329..ada8a5bc9 100644 |
| --- a/docs/superpowers/specs/2026-08-03-open-ledgers-design.md |
| +++ b/docs/superpowers/specs/2026-08-03-open-ledgers-design.md |
| @@ -56,7 +56,7 @@ two tenants can both run a `blog`). |
| ``` |
| ledgers/<account-sqid>/<app>/<ledger>/entries/<ts-compact>-<sha12>.json |
| ledgers/<account-sqid>/<app>/<ledger>/HEAD.json |
| -ledgers/<account-sqid>/<app>/<ledger>/blobs/<sha256>[.<ext>] (§2.4) |
| +ledgers/<account-sqid>/<app>/<ledger>/blobs/<sha256> (§2.4) |
| ledgers/<account-sqid>/<app>/ledgers.json (§4.1) |
| ``` |
| @@ -76,8 +76,21 @@ Entry wire shape (adopted from Eleven's stream design, one addition): |
| `HEAD.json`: `{"updated_at", "head_key", "head_sha256", "count"}`. |
| -The chain hashes canonicalized JSON (sorted keys, no insignificant |
| -whitespace) — same canonicalization both when writing and when verifying. |
| +The chain hashes the **stored entry bytes verbatim** — no canonicalization, |
| +at write or at verify. The writer marshals the entry once, hashes exactly |
| +those bytes, and stores exactly those bytes; a verifier re-hashes the bytes |
| +exactly as fetched and compares. This is deliberate, and it is a change from |
| +the design's first draft (which specified sorted-key canonical JSON both |
| +sides): a canonicalization bug is a hash-break bug, and every verifier in |
| +every language would have to reproduce the same canonical form to agree. |
| +Verbatim bytes make the contract trivially reimplementable — `sha256(the |
| +bytes you downloaded)` — and remove the whole class. |
| + |
| +The blob key carries no extension: the edge derives content type from the |
| +object's SHAPE, never from a suffix (`internal/edge/status.go`'s |
| +`ledgerShape`), because a tenant can name a blob whose sha happens to end |
| +`.json`. |
| + |
| The first entry has `prev_key: ""`, `prev_sha256: ""`. `schema` is new |
| relative to Eleven's shape and follows this repo's wire-shape rule: **all |
| future changes are additive**; anything written before a change must still |
| @@ -100,10 +113,28 @@ Contributor machines get **no bucket credentials**. `carlos ledger append` |
| calls the console's CLI API (the PR #25 pattern, `internal/console/apicli.go`): |
| ``` |
| -POST /api/cli/ledger/append {account, app, ledger, payload} |
| -GET /api/cli/ledger/head ?account=&app=&ledger= |
| +POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append {payload} |
| +GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head |
| +POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs (raw bytes) |
| +GET /api/cli/apps/{account}/{app}/ledgers |
| +POST /api/cli/apps/{account}/{app}/ledgers/publish {public, ledgers, title} |
| ``` |
| +The account/app pair is in the PATH, not the body — the shape every other |
| +CLI API route in `internal/console/apicli.go` already uses, so |
| +`apiAccountApp` is the one gate and the byte-identical 404 comes for free. |
| +Consequence: **`publish` and `head` are effectively reserved ledger names**, |
| +being literal segments in this URL space (Go 1.22's mux prefers a literal |
| +over a `{ledger}` wildcard). Both remain valid ledger names and are writable |
| +and readable at their own sub-routes; no new route may ever be added where |
| +the two would collide. |
| + |
| +`publish` is **additive per ledger**: it adds the named ledgers to the app's |
| +published set and leaves the rest alone, and `-public=false` removes the |
| +named ones, flipping `public` off only when the set empties (§4.1). The |
| +response is the RESULTING manifest, because after a read-modify-write only |
| +the server can say what is public now. |
| + |
| Auth is the same session/token the CLI already holds for ship/promote; |
| account/app resolution goes through the existing `apiAccountApp` check — |
| one auth+membership gate, byte-identical 404 for "doesn't exist" and "not |
| @@ -137,8 +168,8 @@ repo tamper-evidence mechanism — the chain *is* the tamper evidence. |
| carlos ledger append -app <a> [-account <acct>] <ledger> <entry.json|-> |
| carlos ledger blob -app <a> <ledger> <file>... |
| carlos ledger head -app <a> <ledger> |
| -carlos ledger list [-app <a>] |
| -carlos ledger verify -app <a> <ledger> # walks the chain, re-hashes |
| +carlos ledger list -app <a> # v1: -app required |
| +carlos ledger verify -app <a> -account <sqid> -url <base> <ledger> |
| carlos ledger check -app <a> # freshness/owed report, §7 |
| ``` |
| @@ -147,14 +178,28 @@ base URL (`-url https://status.carlosframework.com`) so **anyone** can verify |
| a published chain without CARLOS credentials — an open ledger you cannot |
| independently verify is a press release. |
| +**v1 narrowing (plan-sanctioned):** `verify` is `-url`-only — there is no |
| +bucket-reading mode, matching §2.3's "no credentials in a terminal", and its |
| +`-account` takes a SQID because the public path is keyed by sqid with no |
| +console to resolve a name against. `list` requires `-app`; the |
| +account-wide listing is deferred. `check` (§7) is not in the first slice. |
| +Verification also pins the walk to the ledger under test: every entry must |
| +name that ledger and every key followed must live under its own `entries/` |
| +prefix, so a chain cannot graft another ledger's history onto itself and |
| +still verify clean. |
| + |
| ### 2.6 Serving |
| The edge's `Kind=="status"` route (`internal/edge/status.go`) currently |
| serves a fixed allowlisted key→path map from the `status/` prefix with a 60s |
| cache and stale-serves-while-store-hiccups. It gains one prefix rule: |
| `GET /ledgers/<sqid>/<app>/...` maps to the same bucket key **iff** the |
| -ledger opt-in manifest (§4.1) marks that app public — checked from a cached |
| -copy on the same 60s tick, fail-closed (no manifest → 404). JSON and blobs |
| +ledger opt-in manifest (§4.1) marks that app public AND, for everything |
| +below the app level, names that ledger in its `ledgers` array — the array is |
| +the per-ledger allowlist, not merely an index, because an account accrues |
| +chains privately and publishing is per ledger by name (an empty array |
| +publishes nothing but `ledgers.json` itself). Checked from a cached copy on |
| +the same 60s tick, fail-closed (no manifest → 404). JSON and blobs |
| only, plus the static ledger pages (§4.3) which ship inside the status |
| site's existing page set. No new timers, no publish train: an appended entry |
| is publicly readable on the next cache tick. The fleet renderer's lease and |
| @@ -279,13 +324,27 @@ explicit choice, recorded where the publisher reads. A per-app manifest at |
| {"schema": 1, "public": true, "ledgers": ["carbon", "changelog"], "title": "Kass"} |
| ``` |
| -written via `carlos ledger publish -app <a>` (and unpublished by flipping |
| -`public`). No manifest, or `public: false` → the edge serves nothing for |
| +written via `carlos ledger publish -app <a> <ledger>...`, which is |
| +**additive**: the named ledgers join the set, the rest stay as they were, and |
| +an omitted `-title` keeps the stored title. `-public=false <ledger>...` |
| +removes just those ledgers, flipping `public` off only when the set empties. |
| +The manifest is a set accrued across separate publish calls, so the write is |
| +a read-modify-write with CAS on the manifest's etag — two contributors |
| +publishing two ledgers at once end with both published, not with whichever |
| +was slower. No manifest, or `public: false` → the edge serves nothing for |
| that app (§2.6), and writes still work (an account can accrue privately |
| before going public — but §6's rule stands: once public, history is public; |
| going private again stops serving but does not unpublish the versioned |
| bucket's past). |
| +**Unpublishing is not a recall.** Flipping `public` off stops the edge |
| +serving within ≤60s (one cache tick), but every entry and blob already |
| +fetched went out with `Cache-Control: public, max-age=31536000, immutable` — |
| +browsers and CDNs may hold it for a year, and anyone who walked the chain |
| +has the bytes. Publication of chain content is effectively irreversible; |
| +§6's posture ("over-withholding is recoverable; publication is not") is the |
| +operative rule, and `public: false` is a tap, not an eraser. |
| + |
| ### 4.2 Authz |
| Member-level, via the console API only (§2.3). The `ops` account's ledgers |
diff --git a/internal/console/apiledger.go b/internal/console/apiledger.go| new file mode 100644 |
| index 000000000..d6f32a48e |
| --- /dev/null |
| +++ b/internal/console/apiledger.go |
| @@ -0,0 +1,381 @@ |
| +// The CLI's ledger API: the ONLY write path to ledgers/ from outside a |
| +// box. Contributor machines hold no bucket credentials — the console |
| +// performs the CAS append server-side and stamps the writer from the |
| +// authenticated identity. Member-level, same gate as promote: publishing |
| +// an app's ledger is ordinary member work. |
| +// |
| +// Every route here begins with apiAccountApp, so an unknown account and one |
| +// the caller doesn't belong to are the same 404 on the WRITE doors too, and |
| +// none of them consult the manifest's public flag — opt-in gates serving, |
| +// not writing (spec §2.3). |
| +// |
| +// `publish` and `head` are effectively reserved ledger names: they are |
| +// literal path segments in this API's URL space, and Go 1.22's mux prefers a |
| +// literal segment over a {ledger} wildcard. ValidName allows both, so a |
| +// ledger so named can still be written and read at its own sub-routes, but |
| +// nothing in this API should ever grow a route where the two would collide. |
| +// Acceptable: they are our own API's words. |
| +// |
| +// Spec: docs/superpowers/specs/2026-08-03-open-ledgers-design.md §2.3. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net/http" |
| + "os" |
| + "slices" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/ledger" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ledgerStoreTimeout bounds every store round trip these handlers make, the |
| +// same 15s its siblings in apicli.go use: a wedged bucket must cost one |
| +// request 15 seconds, not a console goroutine forever. Each handler derives |
| +// its own context from the request's, so a client that hangs up still |
| +// cancels immediately — this only adds a ceiling. |
| +const ledgerStoreTimeout = 15 * time.Second |
| + |
| +// ledgerWriteAllowed is the write budget every ledger WRITE door passes, in |
| +// the same shape and with the same key as promote/rollback/new-account: the |
| +// principal a write budget belongs to is the PERSON, not the terminal they |
| +// happen to be typing on. The two GET doors below are unlimited, matching |
| +// their read-only siblings (channels, apps). |
| +func (s *Server) ledgerWriteAllowed(w http.ResponseWriter, addr string) bool { |
| + if !s.cli.write.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// ledgerStoreError answers a failure that is NOT the caller's fault. Two |
| +// things matter here. First, contention is retry-worthy and a broken bucket |
| +// is not, so they must not share a status: a 503 tells a CLI to try again, |
| +// a 500 tells it to stop. Second, the store's own error text — bucket names, |
| +// key paths, AWS request ids — never reaches the client; it goes to the log, |
| +// where the operator who can act on it will see it. |
| +func (s *Server) ledgerStoreError(w http.ResponseWriter, what string, err error) { |
| + log.Printf("console: %s: %v", what, err) |
| + if errors.Is(err, ledger.ErrBusy) || errors.Is(err, release.ErrCASConflict) { |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "the ledger is busy right now — try again in a moment") |
| + return |
| + } |
| + jsonError(w, http.StatusInternalServerError, "could not reach the ledger store") |
| +} |
| + |
| +func (s *Server) handleAPILedgerAppend(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.ledgerWriteAllowed(w, addr) { |
| + return |
| + } |
| + name := r.PathValue("ledger") |
| + if !ledger.ValidName(name) { |
| + jsonError(w, http.StatusBadRequest, "invalid ledger name") |
| + return |
| + } |
| + // Only the payload is read off the wire: there is no field on this |
| + // shape by which a caller could name a writer other than themselves. |
| + var in struct { |
| + Payload json.RawMessage `json:"payload"` |
| + } |
| + r.Body = http.MaxBytesReader(w, r.Body, ledger.MaxPayload+8<<10) |
| + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { |
| + var tooBig *http.MaxBytesError |
| + if errors.As(err, &tooBig) { |
| + jsonError(w, http.StatusRequestEntityTooLarge, "payload exceeds the 64 KiB cap") |
| + return |
| + } |
| + jsonError(w, http.StatusBadRequest, "malformed body — want {\"payload\": ...}") |
| + return |
| + } |
| + if len(in.Payload) == 0 { |
| + jsonError(w, http.StatusBadRequest, "missing payload") |
| + return |
| + } |
| + // The cap, checked HERE so an over-cap payload is the caller's 413 and |
| + // not swept into ledger.Append's generic error return below. A body |
| + // under MaxBytesReader's allowance can still carry an over-cap payload: |
| + // the reader bounds the request, this bounds the entry. |
| + if len(in.Payload) > ledger.MaxPayload { |
| + jsonError(w, http.StatusRequestEntityTooLarge, "payload exceeds the 64 KiB cap") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) |
| + defer cancel() |
| + a, err := ledger.Append(ctx, s.cfg.Releases, acct.Sqid, app, name, addr, |
| + in.Payload, time.Now) |
| + if err != nil { |
| + // Every caller-caused failure has been answered above — an invalid |
| + // name, a payload over the cap, a body that isn't JSON. What's left |
| + // is contention or a broken store, neither of which is the caller's |
| + // fault and neither of which they should read raw. |
| + s.ledgerStoreError(w, fmt.Sprintf("ledger append %s/%s/%s", acct.Sqid, app, name), err) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(a) |
| +} |
| + |
| +func (s *Server) handleAPILedgerHead(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + name := r.PathValue("ledger") |
| + if !ledger.ValidName(name) { |
| + jsonError(w, http.StatusBadRequest, "invalid ledger name") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) |
| + defer cancel() |
| + b, _, err := s.cfg.Releases.Get(ctx, ledger.HeadKey(acct.Sqid, app, name)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if err != nil { |
| + s.ledgerStoreError(w, fmt.Sprintf("ledger head %s/%s/%s", acct.Sqid, app, name), err) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + w.Write(b) |
| +} |
| + |
| +// handleAPILedgerBlob takes raw bytes. It is rate-limited on the write |
| +// budget and hard-bounded at 4 MiB by MaxBytesReader, and deliberately does |
| +// NOT take ship's semaphore: a blob is 64× smaller than a ship's cap, so a |
| +// bounded 4 MiB read is not the unbounded-buffer hazard shipSem exists for, |
| +// and sharing that two-slot semaphore would let blob traffic starve the |
| +// deploy path. |
| +func (s *Server) handleAPILedgerBlob(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.ledgerWriteAllowed(w, addr) { |
| + return |
| + } |
| + name := r.PathValue("ledger") |
| + if !ledger.ValidName(name) { |
| + jsonError(w, http.StatusBadRequest, "invalid ledger name") |
| + return |
| + } |
| + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, ledger.MaxBlob)) |
| + if err != nil { |
| + var tooBig *http.MaxBytesError |
| + if errors.As(err, &tooBig) { |
| + jsonError(w, http.StatusRequestEntityTooLarge, "blob exceeds the 4 MiB cap") |
| + return |
| + } |
| + jsonError(w, http.StatusBadRequest, "could not read the request body") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) |
| + defer cancel() |
| + sha, err := ledger.PutBlob(ctx, s.cfg.Releases, acct.Sqid, app, name, body) |
| + if err != nil { |
| + s.ledgerStoreError(w, fmt.Sprintf("ledger blob %s/%s/%s", acct.Sqid, app, name), err) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(map[string]string{"sha256": sha}) |
| +} |
| + |
| +func (s *Server) handleAPILedgerList(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) |
| + defer cancel() |
| + m, err := ledger.ReadManifest(ctx, s.cfg.Releases, acct.Sqid, app) |
| + var manifest *ledger.Manifest |
| + switch { |
| + case err == nil: |
| + manifest = &m |
| + case errors.Is(err, os.ErrNotExist): |
| + // no manifest yet — null in the response |
| + default: |
| + s.ledgerStoreError(w, fmt.Sprintf("ledger manifest %s/%s", acct.Sqid, app), err) |
| + return |
| + } |
| + // The ledger names are the immediate child directories of the app's |
| + // prefix — one delimiter-aware listing, whose cost is the number of |
| + // chains rather than the total length of their histories. A flat List |
| + // here would walk every entry and every blob ever written under this |
| + // app just to learn a handful of names. Blobs live INSIDE a chain's |
| + // directory, so the children are exactly the chain names; ledgers.json |
| + // is a file at this level, and ListDirs never returns files. |
| + prefix := "ledgers/" + acct.Sqid + "/" + app + "/" |
| + dirs, err := s.cfg.Releases.ListDirs(ctx, prefix) |
| + if err != nil { |
| + s.ledgerStoreError(w, fmt.Sprintf("ledger list %s/%s", acct.Sqid, app), err) |
| + return |
| + } |
| + var names []string |
| + for _, d := range dirs { |
| + n := strings.TrimSuffix(strings.TrimPrefix(d, prefix), "/") |
| + if n == "" { |
| + continue |
| + } |
| + names = append(names, n) |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(map[string]any{"manifest": manifest, "ledgers": names}) |
| +} |
| + |
| +// maxLedgerTitle bounds Manifest.Title, the one member-supplied string on |
| +// this API that is served verbatim to the public (ledgers.json, and from |
| +// there whatever page renders it). 120 bytes is a display title's worth; the |
| +// cap is here, at the write door, because that is the only place a bound |
| +// holds for every reader that will ever fetch the object. |
| +const maxLedgerTitle = 120 |
| + |
| +// maxLedgerCount bounds Manifest.Ledgers. Publishing is additive |
| +// read-modify-write, which removed the implicit bound the 8 KiB request-body |
| +// cap used to give when a publish stored the request's manifest wholesale: |
| +// names now ACCUMULATE across calls, and ledgers.json is member-supplied |
| +// content that is served publicly at the edge, held in the edge's in-memory |
| +// cache, and scanned linearly on every single ledger request (the allowlist |
| +// gate). 64 published chains per app is far past any real app and keeps that |
| +// per-request scan trivially short. Like the title's cap, it lives at the |
| +// write door, because that is the only place a bound holds for every reader |
| +// that will ever fetch the object. |
| +const maxLedgerCount = 64 |
| + |
| +// errTooManyLedgers is how the publish apply below REFUSES from inside |
| +// ledger.UpdateManifest's CAS loop: the cap can only be judged against the |
| +// stored set, which the loop may re-read, so the check has to live where the |
| +// decision is made and travel back out as an error the handler recognises. |
| +// Its text is the client-facing message — it names the cap and nothing else, |
| +// so there is no path by which store internals reach a caller through it. |
| +var errTooManyLedgers = fmt.Errorf("too many public ledgers (max %d)", maxLedgerCount) |
| + |
| +// handleAPILedgerPublish is READ-MODIFY-WRITE, per ledger. The manifest is a |
| +// SET of published ledgers accrued across separate publish calls, and the |
| +// edge reads that set as an ALLOWLIST (internal/edge/status.go) — so a |
| +// handler that stored the request's manifest wholesale would silently |
| +// unpublish every chain the caller didn't name in this one call, while |
| +// answering success. Hence: |
| +// |
| +// - public:true — add the named ledgers to the stored set (order kept, no |
| +// duplicates), set public, and replace the title only if the request |
| +// carries one. Publishing a second chain is not a request to erase the |
| +// app's title. |
| +// - public:false — remove the named ledgers. public flips off only when |
| +// the set empties, so unpublishing one of two chains leaves the other |
| +// serving. The title survives: unpublishing is not forgetting. |
| +// |
| +// The CAS is ledger.UpdateManifest's, which re-reads and re-applies on a |
| +// lost race rather than re-putting a stale copy — two contributors |
| +// publishing two chains at once must end with both published. |
| +// |
| +// The response is the RESULTING manifest, not an empty ack: the caller's |
| +// request said what to change, and only the answer can say what is public |
| +// now. |
| +func (s *Server) handleAPILedgerPublish(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.ledgerWriteAllowed(w, addr) { |
| + return |
| + } |
| + var in ledger.Manifest |
| + r.Body = http.MaxBytesReader(w, r.Body, 8<<10) |
| + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { |
| + jsonError(w, http.StatusBadRequest, "malformed manifest") |
| + return |
| + } |
| + for _, n := range in.Ledgers { |
| + if !ledger.ValidName(n) { |
| + jsonError(w, http.StatusBadRequest, "invalid ledger name "+n) |
| + return |
| + } |
| + } |
| + title := strings.TrimSpace(in.Title) |
| + if len(title) > maxLedgerTitle { |
| + jsonError(w, http.StatusBadRequest, |
| + fmt.Sprintf("title too long (max %d bytes)", maxLedgerTitle)) |
| + return |
| + } |
| + |
| + // An unpublish against an app with no manifest is a no-op success: there |
| + // is nothing to take private, and conjuring a public:false manifest for |
| + // an app that never published one would be a write with no meaning. |
| + ctx, cancel := context.WithTimeout(r.Context(), ledgerStoreTimeout) |
| + defer cancel() |
| + if !in.Public { |
| + if _, err := ledger.ReadManifest(ctx, s.cfg.Releases, acct.Sqid, app); errors.Is(err, os.ErrNotExist) { |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(ledger.Manifest{}) |
| + return |
| + } |
| + } |
| + |
| + m, err := ledger.UpdateManifest(ctx, s.cfg.Releases, acct.Sqid, app, func(m *ledger.Manifest) error { |
| + if in.Public { |
| + m.Public = true |
| + for _, n := range in.Ledgers { |
| + if !slices.Contains(m.Ledgers, n) { |
| + m.Ledgers = append(m.Ledgers, n) |
| + } |
| + } |
| + // The cap is checked HERE, on the manifest this attempt read, and |
| + // on the RESULT — not on the request. Only the stored set knows |
| + // how many names are already there, and UpdateManifest re-reads |
| + // and re-applies on a lost race, so a check made once outside |
| + // this closure could be stepped over by two publishers racing. |
| + // Refusing from inside aborts before the Put: nothing is written. |
| + if len(m.Ledgers) > maxLedgerCount { |
| + return errTooManyLedgers |
| + } |
| + if title != "" { |
| + m.Title = title |
| + } |
| + return nil |
| + } |
| + m.Ledgers = slices.DeleteFunc(m.Ledgers, func(n string) bool { |
| + return slices.Contains(in.Ledgers, n) |
| + }) |
| + if len(m.Ledgers) == 0 { |
| + m.Public = false |
| + } |
| + return nil |
| + }) |
| + // A refusal is the CALLER's fault and must not be laundered through |
| + // ledgerStoreError, which would log it and answer 500: this is the one |
| + // error out of UpdateManifest that a client can act on, and the only one |
| + // whose text is ours to show. |
| + if errors.Is(err, errTooManyLedgers) { |
| + jsonError(w, http.StatusBadRequest, errTooManyLedgers.Error()) |
| + return |
| + } |
| + if err != nil { |
| + s.ledgerStoreError(w, fmt.Sprintf("ledger publish %s/%s", acct.Sqid, app), err) |
| + return |
| + } |
| + // Flipping an app's ledgers public is exactly the kind of act the audit |
| + // trail exists for, and the ONLY one on this API that changes what the |
| + // world can see — the same trail promote, rollback and ship write to. |
| + // The line records the RESULTING state, which is what a reader of the |
| + // trail needs to know; the request alone would not say it. |
| + s.audit(acct, app, addr, "ledger-publish", |
| + "set ledgers public="+strconv.FormatBool(m.Public)+ |
| + " ["+strings.Join(m.Ledgers, " ")+"] (cli)") |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(m) |
| +} |
diff --git a/internal/console/apiledger_test.go b/internal/console/apiledger_test.go| new file mode 100644 |
| index 000000000..bdd218192 |
| --- /dev/null |
| +++ b/internal/console/apiledger_test.go |
| @@ -0,0 +1,728 @@ |
| +package console |
| + |
| +// The ledger CLI API (apiledger.go): the ONLY write path to ledgers/ from |
| +// outside a box. The properties that matter, in the order they'd hurt if |
| +// lost: the writer stamped on an entry is the AUTHENTICATED identity and |
| +// nothing a client sent, every route is member-gated through apiAccountApp |
| +// (so an unknown account and someone else's are the identical 404), and the |
| +// caps — 64 KiB payload, 4 MiB blob, a validated ledger name — are enforced |
| +// before anything touches the store. |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "slices" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/ledger" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// postBody is postJSON's raw-body cousin: these tests send bodies that are |
| +// deliberately not round-trippable through json.Marshal (an oversize |
| +// payload, a body carrying a field the server must ignore), and read the |
| +// response as bytes so it can be decoded into a typed struct. |
| +func postBody(t *testing.T, u, bearer, body string) (int, []byte) { |
| + t.Helper() |
| + req, err := http.NewRequest(http.MethodPost, u, strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + if bearer != "" { |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + out, err := io.ReadAll(res.Body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return res.StatusCode, out |
| +} |
| + |
| +// ledgerFixture is a console with titogo claimed in the ops account and a |
| +// MEMBER (not owner) token for it — member-level is exactly the gate this |
| +// API grants, the same one promote uses. |
| +func ledgerFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "alice@keymail.dev", code) |
| +} |
| + |
| +// TestAPILedgerAppendAndHead: an append round-trips through HEAD, and the |
| +// writer on the stored entry is the token's identity — never anything the |
| +// client put in the body (the request below tries to set one). |
| +func TestAPILedgerAppendAndHead(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-append") |
| + |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, |
| + `{"payload":{"kg_central":1},"writer":"attacker@example.com"}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("append = %d %s", status, out) |
| + } |
| + var a ledger.Appended |
| + if err := json.Unmarshal(out, &a); err != nil { |
| + t.Fatalf("append body %s: %v", out, err) |
| + } |
| + if a.Count != 1 || a.Key == "" || a.SHA256 == "" { |
| + t.Fatalf("append = %+v, want count 1 with a key and sha", a) |
| + } |
| + |
| + status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("head = %d %s", status, body) |
| + } |
| + var h ledger.Head |
| + if err := json.Unmarshal([]byte(body), &h); err != nil { |
| + t.Fatalf("head body %s: %v", body, err) |
| + } |
| + if h.HeadKey != a.Key || h.HeadSHA256 != a.SHA256 || h.Count != 1 { |
| + t.Fatalf("head = %+v, want it to name the appended entry %+v", h, a) |
| + } |
| + |
| + // The writer is the token's identity, stamped server-side. The body's |
| + // own "writer" field must have gone nowhere. |
| + eb, _, err := fx.rel.Get(context.Background(), a.Key) |
| + if err != nil { |
| + t.Fatalf("stored entry %s: %v", a.Key, err) |
| + } |
| + var e ledger.Entry |
| + if err := json.Unmarshal(eb, &e); err != nil { |
| + t.Fatal(err) |
| + } |
| + if e.Writer != "alice@keymail.dev" { |
| + t.Fatalf("entry writer = %q, want the authenticated identity alice@keymail.dev (%+v)", e.Writer, e) |
| + } |
| + if bytes.Contains(eb, []byte("attacker@example.com")) { |
| + t.Fatalf("stored entry carries a client-supplied writer: %s", eb) |
| + } |
| +} |
| + |
| +// TestAPILedgerAuthz: no token is the same 401 every CLI door gives, and a |
| +// non-member — whether the account is one they don't belong to or an app it |
| +// never claimed — gets the byte-identical 404 apiAccountApp already |
| +// produces, on the WRITE routes too. |
| +func TestAPILedgerAuthz(t *testing.T) { |
| + fx, _ := ledgerFixture(t, "code-ledger-authz") |
| + |
| + status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", "", |
| + `{"payload":1}`) |
| + if status != http.StatusUnauthorized { |
| + t.Fatalf("append with no token = %d, want 401", status) |
| + } |
| + |
| + // (a) member of nothing, probing a real account+app. |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-ledger-eve") |
| + statusA, bodyA := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", eve, |
| + `{"payload":1}`) |
| + if statusA != http.StatusNotFound { |
| + t.Fatalf("append as non-member = %d, want 404", statusA) |
| + } |
| + // (b) member of bab, but an app it never claimed. |
| + if err := fx.st.AddMember(OpsAccountID, "frank@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + frank := cliToken(t, fx, "frank@keymail.dev", "code-ledger-frank") |
| + statusB, bodyB := postBody(t, fx.ts.URL+"/api/cli/apps/bab/ghost-app/ledgers/carbon/append", frank, |
| + `{"payload":1}`) |
| + if statusB != http.StatusNotFound { |
| + t.Fatalf("append against an unclaimed app = %d, want 404", statusB) |
| + } |
| + if !bytes.Equal(bodyA, bodyB) { |
| + t.Fatalf("the two refusals are not byte-identical:\n(a) %q\n(b) %q", bodyA, bodyB) |
| + } |
| + if string(bodyA) != "404 page not found\n" { |
| + t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA) |
| + } |
| + |
| + // Nothing was written by any of the refused calls. |
| + if _, _, err := fx.rel.Get(context.Background(), ledger.HeadKey("bab", "titogo", "carbon")); err == nil { |
| + t.Fatalf("a ledger HEAD exists after only refused appends, want none") |
| + } |
| + |
| + // The read routes refuse identically. |
| + if st, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", eve); st != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Fatalf("list as non-member = %d %q, want the identical 404", st, body) |
| + } |
| + if st, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", eve); st != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Fatalf("head as non-member = %d %q, want the identical 404", st, body) |
| + } |
| +} |
| + |
| +// TestAPILedgerRejects: a name that isn't a ledger name, and a payload over |
| +// the 64 KiB cap, are both refused before anything lands in the store. |
| +func TestAPILedgerRejects(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-rejects") |
| + |
| + // Bad ledger name in the path — uppercase and a space. |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/Not%20Valid/append", token, |
| + `{"payload":1}`) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("bad ledger name = %d %s, want 400", status, out) |
| + } |
| + // A traversal attempt in the name segment, which would otherwise become |
| + // bucket key segments. |
| + status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/..%2f..%2fetc/append", token, |
| + `{"payload":1}`) |
| + if status != http.StatusBadRequest && status != http.StatusNotFound { |
| + t.Fatalf("traversal ledger name = %d %s, want a refusal", status, out) |
| + } |
| + |
| + // Oversized payload: over the cap, but under MaxBytesReader's limit, so |
| + // this is the handler's own arithmetic refusing it, not the reader's. |
| + big := `{"payload":"` + strings.Repeat("x", ledger.MaxPayload) + `"}` |
| + status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, big) |
| + if status != http.StatusBadRequest && status != http.StatusRequestEntityTooLarge { |
| + t.Fatalf("oversize payload = %d %s, want 400 or 413", status, out) |
| + } |
| + // Far oversized: MaxBytesReader cuts this one off. |
| + huge := `{"payload":"` + strings.Repeat("x", ledger.MaxPayload+64<<10) + `"}` |
| + status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, huge) |
| + if status != http.StatusBadRequest && status != http.StatusRequestEntityTooLarge { |
| + t.Fatalf("far-oversize payload = %d %s, want 400 or 413", status, out) |
| + } |
| + // A body with no payload at all. |
| + status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{}`) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("missing payload = %d %s, want 400", status, out) |
| + } |
| + |
| + // None of the refusals created a chain. |
| + if _, _, err := fx.rel.Get(context.Background(), ledger.HeadKey("bab", "titogo", "carbon")); err == nil { |
| + t.Fatalf("carbon HEAD exists after only refused appends, want none") |
| + } |
| +} |
| + |
| +// TestAPILedgerPublishAndList: the opt-in manifest round-trips, and the |
| +// listing names both the manifest and the ledgers that actually exist — |
| +// derived from the HEAD keys in the bucket, not from the manifest. |
| +func TestAPILedgerPublishAndList(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-publish") |
| + |
| + // A write happens with NO manifest present: opt-in gates serving, not |
| + // writing (spec §2.3). |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, |
| + `{"payload":{"n":1}}`); status != http.StatusOK { |
| + t.Fatalf("append before any publish = %d %s, want 200 (opt-in gates serving, not writing)", status, out) |
| + } |
| + |
| + // Before publishing, the manifest reads back null and the ledger is |
| + // still listed. |
| + status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("list before publish = %d %s", status, body) |
| + } |
| + var pre struct { |
| + Manifest *ledger.Manifest `json:"manifest"` |
| + Ledgers []string `json:"ledgers"` |
| + } |
| + if err := json.Unmarshal([]byte(body), &pre); err != nil { |
| + t.Fatalf("list body %s: %v", body, err) |
| + } |
| + if pre.Manifest != nil { |
| + t.Fatalf("manifest before publish = %+v, want null", pre.Manifest) |
| + } |
| + if len(pre.Ledgers) != 1 || pre.Ledgers[0] != "carbon" { |
| + t.Fatalf("ledgers before publish = %v, want [carbon]", pre.Ledgers) |
| + } |
| + |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, |
| + `{"public":true,"ledgers":["carbon"],"title":"Titogo"}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("publish = %d %s", status, out) |
| + } |
| + |
| + // A second entry and a blob under the same chain: the listing is |
| + // delimiter-aware, so neither the entries/ nor the blobs/ directory may |
| + // surface as a ledger name, however much history accrues. |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, |
| + `{"payload":{"n":2}}`); status != http.StatusOK { |
| + t.Fatalf("second append = %d %s", status, out) |
| + } |
| + if _, err := fx.rel.Put(context.Background(), |
| + ledger.BlobKey("bab", "titogo", "carbon", strings.Repeat("a", 64)), []byte("x"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + status, body = getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token) |
| + if status != http.StatusOK || !strings.Contains(body, `"carbon"`) { |
| + t.Fatalf("list = %d %s, want carbon among the ledgers", status, body) |
| + } |
| + var post struct { |
| + Manifest *ledger.Manifest `json:"manifest"` |
| + Ledgers []string `json:"ledgers"` |
| + } |
| + if err := json.Unmarshal([]byte(body), &post); err != nil { |
| + t.Fatalf("list body %s: %v", body, err) |
| + } |
| + if post.Manifest == nil || !post.Manifest.Public || post.Manifest.Title != "Titogo" || |
| + len(post.Manifest.Ledgers) != 1 || post.Manifest.Ledgers[0] != "carbon" { |
| + t.Fatalf("manifest after publish = %+v, want the published one", post.Manifest) |
| + } |
| + if post.Manifest.Schema != ledger.Schema { |
| + t.Fatalf("manifest schema = %d, want %d (stamped server-side)", post.Manifest.Schema, ledger.Schema) |
| + } |
| + if len(post.Ledgers) != 1 || post.Ledgers[0] != "carbon" { |
| + t.Fatalf("ledgers = %v, want exactly [carbon] — entries/ and blobs/ are not ledgers", post.Ledgers) |
| + } |
| + |
| + // Publishing is a visibility change, so it leaves an audit line naming |
| + // who did it, the flag, and the ledgers — the same trail promote, |
| + // rollback and ship write to. |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var pub *Event |
| + for i := range events { |
| + if events[i].Action == "ledger-publish" { |
| + pub = &events[i] |
| + } |
| + } |
| + if pub == nil { |
| + t.Fatalf("events = %v, want a ledger-publish row", events) |
| + } |
| + if pub.Address != "alice@keymail.dev" { |
| + t.Fatalf("ledger-publish address = %q, want the authenticated identity", pub.Address) |
| + } |
| + if !strings.Contains(pub.Detail, "public=true") || !strings.Contains(pub.Detail, "carbon") || |
| + !strings.Contains(pub.Detail, "(cli)") { |
| + t.Fatalf("ledger-publish detail = %q, want the flag, the ledgers and the CLI marker", pub.Detail) |
| + } |
| + |
| + // A manifest naming a ledger that isn't a ledger name is refused. |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, |
| + `{"public":true,"ledgers":["Not Valid"]}`); status != http.StatusBadRequest { |
| + t.Fatalf("publish with a bad ledger name = %d %s, want 400", status, out) |
| + } |
| +} |
| + |
| +// publishLedgers posts one publish call and decodes the RESULTING manifest |
| +// the handler answers with — the shape a CLI needs in order to tell the user |
| +// what is public now rather than what they just asked for. |
| +func publishLedgers(t *testing.T, fx *consoleFixture, token, body string) (int, ledger.Manifest) { |
| + t.Helper() |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, body) |
| + var m ledger.Manifest |
| + if status == http.StatusOK { |
| + if err := json.Unmarshal(out, &m); err != nil { |
| + t.Fatalf("publish body %s: %v", out, err) |
| + } |
| + } |
| + return status, m |
| +} |
| + |
| +// TestAPILedgerPublishIsAdditive is the defect this handler was written |
| +// around: an app's manifest is a SET of published ledgers accrued over |
| +// separate publish calls, and the edge reads that set as a per-ledger |
| +// ALLOWLIST. A publish that replaced the whole manifest would silently |
| +// unpublish every ledger the caller didn't name in this one call — while the |
| +// CLI printed a success line — and would make publishing two ledgers |
| +// impossible one call at a time. |
| +func TestAPILedgerPublishIsAdditive(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-additive") |
| + |
| + status, m := publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"],"title":"Titogo"}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("first publish = %d", status) |
| + } |
| + if !m.Public || len(m.Ledgers) != 1 || m.Ledgers[0] != "carbon" || m.Title != "Titogo" { |
| + t.Fatalf("first publish = %+v", m) |
| + } |
| + |
| + // Publishing a SECOND ledger keeps the first, and an omitted title keeps |
| + // the stored one (a caller publishing one more chain is not asking to |
| + // erase the app's title). |
| + status, m = publishLedgers(t, fx, token, `{"public":true,"ledgers":["prompts"]}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("second publish = %d", status) |
| + } |
| + if len(m.Ledgers) != 2 || m.Ledgers[0] != "carbon" || m.Ledgers[1] != "prompts" { |
| + t.Fatalf("second publish = %+v, want [carbon prompts] in order", m.Ledgers) |
| + } |
| + if m.Title != "Titogo" { |
| + t.Fatalf("title = %q, want the stored title preserved when the request omits one", m.Title) |
| + } |
| + |
| + // Re-publishing one that is already public is idempotent — no duplicate. |
| + if _, m = publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"]}`); len(m.Ledgers) != 2 { |
| + t.Fatalf("re-publish = %+v, want no duplicate", m.Ledgers) |
| + } |
| + // A non-empty title replaces the stored one. |
| + if _, m = publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"],"title":"Tito"}`); m.Title != "Tito" { |
| + t.Fatalf("title = %q, want the new one", m.Title) |
| + } |
| + |
| + // The store agrees with what the handler reported. |
| + stored, err := ledger.ReadManifest(context.Background(), fx.rel, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(stored.Ledgers) != 2 || stored.Title != "Tito" || !stored.Public { |
| + t.Fatalf("stored manifest = %+v, want it to match the reported state", stored) |
| + } |
| +} |
| + |
| +// TestAPILedgerUnpublishRemovesOne: unpublish is the mirror — it removes the |
| +// named ledgers and flips public off ONLY when nothing is left. Unpublishing |
| +// one of two chains must not take the other one dark. |
| +func TestAPILedgerUnpublishRemovesOne(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-unpublish") |
| + |
| + if status, _ := publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon","prompts"],"title":"T"}`); status != http.StatusOK { |
| + t.Fatalf("publish = %d", status) |
| + } |
| + |
| + status, m := publishLedgers(t, fx, token, `{"public":false,"ledgers":["carbon"]}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("unpublish = %d", status) |
| + } |
| + if !m.Public { |
| + t.Fatalf("unpublish of one of two = %+v, want public still true", m) |
| + } |
| + if len(m.Ledgers) != 1 || m.Ledgers[0] != "prompts" { |
| + t.Fatalf("unpublish left %v, want [prompts]", m.Ledgers) |
| + } |
| + |
| + // Unpublishing the last one empties the list AND flips public off, so |
| + // the app's own ledgers.json index stops serving too. |
| + status, m = publishLedgers(t, fx, token, `{"public":false,"ledgers":["prompts"]}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("final unpublish = %d", status) |
| + } |
| + if m.Public || len(m.Ledgers) != 0 { |
| + t.Fatalf("final unpublish = %+v, want public:false and an empty list", m) |
| + } |
| + if m.Title != "T" { |
| + t.Fatalf("title = %q, want it kept — unpublishing is not forgetting", m.Title) |
| + } |
| +} |
| + |
| +// TestAPILedgerUnpublishWithoutManifest: unpublishing an app that never |
| +// published is a no-op success, not a 404 and not a manifest conjured out of |
| +// nothing. |
| +func TestAPILedgerUnpublishWithoutManifest(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-unpublish-none") |
| + |
| + status, m := publishLedgers(t, fx, token, `{"public":false,"ledgers":["carbon"]}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("unpublish with no manifest = %d, want 200", status) |
| + } |
| + if m.Public || len(m.Ledgers) != 0 { |
| + t.Fatalf("= %+v, want the zero manifest", m) |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), ledger.ManifestKey("bab", "titogo")); err == nil { |
| + t.Fatal("a no-op unpublish wrote a manifest, want none") |
| + } |
| +} |
| + |
| +// TestAPILedgerPublishTitleCap: the title is member-supplied text served |
| +// publicly at ledgers.json, so it is bounded at the write door — the same |
| +// place every other public-facing input on this API is bounded. |
| +func TestAPILedgerPublishTitleCap(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-title") |
| + |
| + long := `{"public":true,"ledgers":["carbon"],"title":"` + strings.Repeat("t", 121) + `"}` |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, long) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("oversize title = %d %s, want 400", status, out) |
| + } |
| + if !strings.Contains(string(out), "title too long") { |
| + t.Fatalf("refusal = %s, want it to name the cap", out) |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), ledger.ManifestKey("bab", "titogo")); err == nil { |
| + t.Fatal("a refused publish wrote a manifest") |
| + } |
| + |
| + // Exactly at the cap is fine, and surrounding whitespace is trimmed |
| + // rather than counted. |
| + at := strings.Repeat("t", 120) |
| + status, m := publishLedgers(t, fx, token, `{"public":true,"ledgers":["carbon"],"title":" `+at+` "}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("title at the cap = %d, want 200", status) |
| + } |
| + if m.Title != at { |
| + t.Fatalf("title = %q, want it trimmed to the %d-byte value", m.Title, len(at)) |
| + } |
| +} |
| + |
| +// TestAPILedgerPublishCountCap: the manifest became read-modify-write, which |
| +// removed the implicit bound the 8 KiB request cap used to give a whole-value |
| +// replace — names now ACCUMULATE. ledgers.json is member-supplied content |
| +// served publicly at the edge, held in the edge's in-memory cache and |
| +// linearly scanned on every ledger request, so the set is bounded at the |
| +// write door, on the RESULTING manifest, like every other public-facing input |
| +// here. |
| +func TestAPILedgerPublishCountCap(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-count") |
| + |
| + names := make([]string, 0, maxLedgerCount) |
| + for i := range maxLedgerCount { |
| + names = append(names, fmt.Sprintf("l%d", i)) |
| + } |
| + full, err := json.Marshal(map[string]any{"public": true, "ledgers": names, "title": "T"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + status, m := publishLedgers(t, fx, token, string(full)) |
| + if status != http.StatusOK { |
| + t.Fatalf("publish of %d ledgers = %d, want 200 (the cap is a ceiling, not a fence below it)", maxLedgerCount, status) |
| + } |
| + if len(m.Ledgers) != maxLedgerCount { |
| + t.Fatalf("published %d ledgers, want %d", len(m.Ledgers), maxLedgerCount) |
| + } |
| + |
| + // The one that would step over is refused as the caller's fault, and |
| + // nothing new is written: not the name, not the title that rode with it. |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, |
| + `{"public":true,"ledgers":["overflow"],"title":"nope"}`) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("publish over the cap = %d %s, want 400", status, out) |
| + } |
| + if !strings.Contains(string(out), "too many public ledgers") { |
| + t.Fatalf("refusal = %s, want it to name the cap", out) |
| + } |
| + stored, err := ledger.ReadManifest(context.Background(), fx.rel, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(stored.Ledgers) != maxLedgerCount || slices.Contains(stored.Ledgers, "overflow") { |
| + t.Fatalf("stored manifest = %+v, want the refused publish to have written nothing", stored) |
| + } |
| + if stored.Title != "T" { |
| + t.Fatalf("title = %q, want the refused publish to have left it alone", stored.Title) |
| + } |
| + |
| + // Re-publishing one that is ALREADY on the list at the cap is fine — the |
| + // bound is on the resulting set, and that set does not grow. |
| + if status, m := publishLedgers(t, fx, token, `{"public":true,"ledgers":["l0"]}`); status != http.StatusOK || |
| + len(m.Ledgers) != maxLedgerCount { |
| + t.Fatalf("idempotent re-publish at the cap = %d %+v, want 200 and no growth", status, m.Ledgers) |
| + } |
| + |
| + // And the way back down is never blocked: an app that hit the cap must |
| + // still be able to unpublish. |
| + status, m = publishLedgers(t, fx, token, `{"public":false,"ledgers":["l0"]}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("unpublish at the cap = %d, want 200", status) |
| + } |
| + if len(m.Ledgers) != maxLedgerCount-1 || slices.Contains(m.Ledgers, "l0") { |
| + t.Fatalf("unpublish at the cap left %v", m.Ledgers) |
| + } |
| +} |
| + |
| +// TestAPILedgerHeadMissing: a ledger that was never written is the same |
| +// stdlib 404 as an app you can't see — there is nothing to distinguish. |
| +func TestAPILedgerHeadMissing(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-head-missing") |
| + |
| + status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/nothing-here/head", token) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("head of an unwritten ledger = %d %q, want 404", status, body) |
| + } |
| +} |
| + |
| +// TestAPILedgerBlob: raw bytes upload content-addressed, the returned sha |
| +// is what the bytes hash to, and a re-upload of the same bytes is the same |
| +// answer. |
| +func TestAPILedgerBlob(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-blob") |
| + |
| + req, err := http.NewRequest(http.MethodPost, |
| + fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/prompts/blobs", |
| + bytes.NewReader([]byte("corpus"))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + raw, err := io.ReadAll(res.Body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if res.StatusCode != http.StatusOK { |
| + t.Fatalf("blob = %d %s", res.StatusCode, raw) |
| + } |
| + var out struct { |
| + SHA256 string `json:"sha256"` |
| + } |
| + if err := json.Unmarshal(raw, &out); err != nil { |
| + t.Fatalf("blob body %s: %v", raw, err) |
| + } |
| + want := ledger.SHA256Hex([]byte("corpus")) |
| + if out.SHA256 != want { |
| + t.Fatalf("blob sha256 = %q, want %q (computed from the bytes sent)", out.SHA256, want) |
| + } |
| + stored, _, err := fx.rel.Get(context.Background(), ledger.BlobKey("bab", "titogo", "prompts", want)) |
| + if err != nil { |
| + t.Fatalf("stored blob: %v", err) |
| + } |
| + if !bytes.Equal(stored, []byte("corpus")) { |
| + t.Fatalf("stored blob = %q, want the bytes sent", stored) |
| + } |
| + |
| + // A bad ledger name in the path refuses before the body is read. |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/Not%20Valid/blobs", token, |
| + "corpus"); status != http.StatusBadRequest { |
| + t.Fatalf("blob with a bad ledger name = %d %s, want 400", status, out) |
| + } |
| +} |
| + |
| +// TestAPILedgerWritesAreRateLimited: every WRITE door here passes the same |
| +// write budget its promote/rollback/new-account siblings do, keyed on the |
| +// address. Without it, the one API that appends an object per call is the |
| +// one API with no bound on how fast a token can fill a bucket. |
| +func TestAPILedgerWritesAreRateLimited(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-ratelimit") |
| + |
| + limited := false |
| + for i := 0; i < cliWriteBurst+3; i++ { |
| + status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, |
| + `{"payload":{"n":1}}`) |
| + if status == http.StatusTooManyRequests { |
| + limited = true |
| + break |
| + } |
| + } |
| + if !limited { |
| + t.Fatalf("append was never rate-limited in %d attempts", cliWriteBurst+3) |
| + } |
| + |
| + // The budget is shared across the write doors — an exhausted address |
| + // cannot simply switch verb. (Same limiter, same key: this is what |
| + // proves publish and blobs pass it too, not just append.) |
| + if status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, |
| + `{"public":true,"ledgers":["carbon"]}`); status != http.StatusTooManyRequests { |
| + t.Fatalf("publish on an exhausted budget = %d, want 429", status) |
| + } |
| + req, err := http.NewRequest(http.MethodPost, |
| + fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/prompts/blobs", bytes.NewReader([]byte("corpus"))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + res.Body.Close() |
| + if res.StatusCode != http.StatusTooManyRequests { |
| + t.Fatalf("blob on an exhausted budget = %d, want 429", res.StatusCode) |
| + } |
| + |
| + // The READ doors are unlimited, like their read-only siblings — an |
| + // exhausted write budget must not blind a CLI to what it already wrote. |
| + if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token); status != http.StatusOK { |
| + t.Fatalf("list on an exhausted write budget = %d %s, want 200", status, body) |
| + } |
| + if status, _ := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/head", token); status != http.StatusOK { |
| + t.Fatalf("head on an exhausted write budget = %d, want 200", status) |
| + } |
| +} |
| + |
| +// casStore loses every HEAD race, the way a chain under more concurrent |
| +// writers than the retry loop can outlast behaves. |
| +type casStore struct{ release.Store } |
| + |
| +func (c casStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if strings.HasSuffix(key, "/HEAD.json") || strings.HasSuffix(key, "/ledgers.json") { |
| + return "", release.ErrCASConflict |
| + } |
| + return c.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +// brokenStore is a bucket the console cannot reach, whose error text is |
| +// exactly the kind that must never reach a client. |
| +type brokenStore struct{ release.Store } |
| + |
| +const brokenStoreErr = "AccessDenied: s3://carlos-prod-secret-bucket/ledgers request id ABC123" |
| + |
| +func (b brokenStore) Put(context.Context, string, []byte, string) (string, error) { |
| + return "", errors.New(brokenStoreErr) |
| +} |
| + |
| +func (b brokenStore) ListDirs(context.Context, string) ([]string, error) { |
| + return nil, errors.New(brokenStoreErr) |
| +} |
| + |
| +// TestAPILedgerStoreFailureStatuses: a failure that is not the caller's |
| +// fault must not be reported as if it were. Contention is retry-worthy |
| +// (503), a broken bucket is not (500), and neither answer may echo the |
| +// store's own error text — bucket names and request ids are for the log. |
| +func TestAPILedgerStoreFailureStatuses(t *testing.T) { |
| + t.Run("contention is 503", func(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-busy") |
| + fx.srv.cfg.Releases = casStore{fx.rel} |
| + |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, |
| + `{"payload":{"n":1}}`) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("append under total contention = %d %s, want 503", status, out) |
| + } |
| + if !strings.Contains(string(out), "try again") { |
| + t.Fatalf("contention error = %s, want it to invite a retry", out) |
| + } |
| + if strings.Contains(string(out), "CAS") || strings.Contains(string(out), "HEAD.json") { |
| + t.Fatalf("contention error leaks store internals: %s", out) |
| + } |
| + // Publish's own lost race is the same retry-worthy answer. |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, |
| + `{"public":true,"ledgers":["carbon"]}`); status != http.StatusServiceUnavailable { |
| + t.Fatalf("publish under total contention = %d %s, want 503", status, out) |
| + } |
| + }) |
| + |
| + t.Run("a broken bucket is 500 and says nothing", func(t *testing.T) { |
| + fx, token := ledgerFixture(t, "code-ledger-broken") |
| + fx.srv.cfg.Releases = brokenStore{fx.rel} |
| + |
| + type answer struct { |
| + what string |
| + status int |
| + body string |
| + } |
| + appendStatus, appendBody := postBody(t, |
| + fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/carbon/append", token, `{"payload":{"n":1}}`) |
| + publishStatus, publishBody := postBody(t, |
| + fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers/publish", token, `{"public":true,"ledgers":["carbon"]}`) |
| + listStatus, listBody := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/ledgers", token) |
| + cases := []answer{ |
| + {"append", appendStatus, string(appendBody)}, |
| + {"publish", publishStatus, string(publishBody)}, |
| + {"list", listStatus, listBody}, |
| + } |
| + |
| + for _, c := range cases { |
| + if c.status != http.StatusInternalServerError { |
| + t.Errorf("%s against a broken bucket = %d %s, want 500", c.what, c.status, c.body) |
| + } |
| + if strings.Contains(c.body, "AccessDenied") || |
| + strings.Contains(c.body, "carlos-prod-secret-bucket") || |
| + strings.Contains(c.body, "ABC123") { |
| + t.Errorf("%s leaked the store's error text to the client: %s", c.what, c.body) |
| + } |
| + } |
| + }) |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 2ebb76b69..122cec00e 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -178,6 +178,15 @@ func (s *Server) registerCLIRoutes() { |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", s.handleAPIPromote) |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/rollback", s.handleAPIRollback) |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", s.handleAPIShip) |
| + // The ledger API (apiledger.go) — the only write path to ledgers/ from |
| + // outside a box. The literal `publish` segment is kept adjacent to the |
| + // {ledger} wildcards for readability; the mux prefers it regardless of |
| + // registration order. |
| + s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/ledgers", s.handleAPILedgerList) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/publish", s.handleAPILedgerPublish) |
| + s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head", s.handleAPILedgerHead) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append", s.handleAPILedgerAppend) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs", s.handleAPILedgerBlob) |
| } |
| // clientIP is who the rate limiter counts against. |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 8c5372eb7..1dc161356 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -318,7 +318,20 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // Cache check happens before the hibernation branch below — a hit must |
| // never reach the activator, since avoiding pointless wakes for |
| // bot/crawler/repeat traffic is the entire point of this cache. |
| - cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) |
| + // |
| + // Kind=="status" is exempt from the shared cache in both directions: the |
| + // status host caches for itself (status.go's per-key store cache) and its |
| + // /ledgers/ objects carry a long immutable Cache-Control so browsers and |
| + // CDNs cache them. Admitting them HERE would be a different thing |
| + // entirely — this LRU is process-global and shared across every host, and |
| + // a public ledger chain is unbounded in length, so an anonymous walker |
| + // could evict every other tenant's cached responses at will. An evicted |
| + // entry for a hibernating route means the next request wakes the instance |
| + // instead of being answered from cache, which is the invariant this cache |
| + // exists to hold: the status host caches for itself; its responses never |
| + // enter the shared response cache. |
| + cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) && |
| + up.route.Kind != "status" |
| var key CacheKey |
| if cacheable { |
| key = CacheKey{Host: host, Method: r.Method, Path: r.URL.Path, RawQuery: r.URL.RawQuery} |
| @@ -405,6 +418,10 @@ func (e *Edge) cacheMaxBody() int { |
| // |
| // The stored TTL is clamped to DefaultCacheMaxTTL so a lost purge always |
| // self-heals within a bounded window. |
| +// |
| +// One refusal is NOT made here because it has to be made earlier, where the |
| +// route is still in hand: Kind=="status" responses are never admitted at |
| +// all. See the cacheable computation in ServeHTTP for why. |
| func (e *Edge) maybeStore(key CacheKey, rec *recordingResponseWriter) { |
| if e.cache == nil || !rec.cacheable || rec.truncated { |
| return |
diff --git a/internal/edge/status.go b/internal/edge/status.go| index 812602b85..f7ab59944 100644 |
| --- a/internal/edge/status.go |
| +++ b/internal/edge/status.go |
| @@ -6,19 +6,34 @@ |
| // publish cycle (no signed, versioned channel pointer to resolve), so there |
| // is no "which version" question — just "read the key, cache it briefly, |
| // serve it, and don't fall over when the bucket hiccups." |
| +// |
| +// The same host also publishes open ledgers (internal/ledger) under |
| +// /ledgers/<sqid>/<app>/..., path-for-key, gated per app by that app's |
| +// opt-in manifest — see serveLedger. |
| package edge |
| import ( |
| "context" |
| + "encoding/json" |
| + "errors" |
| "log" |
| "net/http" |
| + "os" |
| "path" |
| + "slices" |
| + "strings" |
| "sync" |
| "time" |
| + "github.com/carlosframework/platform/internal/ledger" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +// ledgerPrefix is the one path prefix on the status host that is NOT the |
| +// fixed three-object menu: open ledgers (internal/ledger) are served from |
| +// here, path-for-key, gated per app by that app's opt-in manifest. |
| +const ledgerPrefix = "/ledgers/" |
| + |
| // statusCacheTTL is how long a fetched status object is trusted before the |
| // handler re-reads the store — mirrors staticRecheck's "trust briefly, then |
| // recheck" tradeoff, tuned loose because the backing data only changes every |
| @@ -75,23 +90,75 @@ type statusHandler struct { |
| mu sync.Mutex |
| cache map[string]statusCached |
| + // misses is the negative half of the ledger gate: manifest key → when |
| + // the "not servable" decision was taken. See markMiss. |
| + misses map[string]time.Time |
| } |
| +// statusMissCacheMax bounds the negative cache. It exists because the |
| +// positive cache's bound — "get only records an entry after a SUCCESSFUL |
| +// store read, so only objects that exist can grow it" — is exactly the |
| +// property a negative cache gives up: the prober picks the account and app |
| +// segments, so the set of manifest keys they can name is unbounded. 1024 |
| +// apps' worth of decisions is far more than any real deployment publishes |
| +// and costs a few tens of KiB; past it the map is CLEARED rather than |
| +// evicted one entry at a time, because there is no access pattern here worth |
| +// approximating with an LRU — the honest read of a full map is "this is |
| +// being probed", and a probe's entries are the ones worth dropping. |
| +const statusMissCacheMax = 1024 |
| + |
| // newStatusHandler builds the handler for one status route. store is shared |
| // with the static serving class (see EnableStatic's doc comment) — the |
| // status page has no per-route config of its own (no app, no channel), so |
| // unlike newStaticHandler this takes no registry.Route. |
| func newStatusHandler(store release.Store) *statusHandler { |
| return &statusHandler{ |
| - store: store, |
| - ttl: statusCacheTTL, |
| - now: time.Now, |
| - cache: make(map[string]statusCached), |
| + store: store, |
| + ttl: statusCacheTTL, |
| + now: time.Now, |
| + cache: make(map[string]statusCached), |
| + misses: make(map[string]time.Time), |
| + } |
| +} |
| + |
| +// missFresh reports whether the "not servable" decision for this manifest |
| +// key was taken recently enough to reuse. |
| +func (h *statusHandler) missFresh(key string) bool { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + at, ok := h.misses[key] |
| + return ok && h.now().Sub(at) < h.ttl |
| +} |
| + |
| +// markMiss records that an app is not servable — no manifest, an unparseable |
| +// one, or public:false — for the same TTL the positive cache trusts a fetched |
| +// object. Publishing an app therefore takes effect within TWO ticks worst |
| +// case (~2×TTL), one typically: the two caches are stamped independently, so |
| +// a public:false manifest read at t=0 can still be serving from the positive |
| +// cache at t=59s and stamp a NEGATIVE entry then, which outlives it. Revoking |
| +// has the same shape from the other side. Both are bounded and small; the |
| +// alternative — invalidating across the two caches — would buy a second off a |
| +// 5-minute-cadence page. |
| +// |
| +// The bound is the point: see statusMissCacheMax. |
| +func (h *statusHandler) markMiss(key string) { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + if len(h.misses) >= statusMissCacheMax { |
| + if _, ok := h.misses[key]; !ok { |
| + clear(h.misses) |
| + } |
| } |
| + h.misses[key] = h.now() |
| } |
| func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| - rt, ok := statusRoutes[path.Clean(r.URL.Path)] |
| + p := path.Clean(r.URL.Path) |
| + if strings.HasPrefix(p, ledgerPrefix) { |
| + h.serveLedger(w, r, p) |
| + return |
| + } |
| + rt, ok := statusRoutes[p] |
| if !ok { |
| http.NotFound(w, r) |
| return |
| @@ -106,6 +173,194 @@ func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| w.Write(body) |
| } |
| +// serveLedger maps /ledgers/<sqid>/<app>/... 1:1 onto the bucket key — that |
| +// identity is what lets `carlos ledger verify -url` walk a public chain over |
| +// HTTPS with the very keys the store uses, no URL scheme to keep in sync. |
| +// |
| +// Everything here is fail-closed: the ONLY way to get a body out of this |
| +// handler is an app whose manifest reads, parses, says public:true, and — |
| +// for anything below the app level — names that ledger in its Ledgers list. |
| +// No manifest, an unreadable one, an unparseable one, public:false, or a |
| +// ledger the manifest doesn't name all end in the same plain 404 as an app |
| +// that was never created: a caller must not be able to probe which apps |
| +// exist, let alone which of their ledgers do. A missing object under a |
| +// public app is a 404 too: the 503 "nothing honest left to serve" contract |
| +// above belongs to the fleet page's own three objects, which are the page, |
| +// not arbitrary reads. |
| +// |
| +// Manifest.Ledgers is read as an ALLOWLIST, not as a convenience index: an |
| +// account accrues ledgers privately (the console writes chains whether or |
| +// not anything is published), so "the app is public" cannot be allowed to |
| +// mean "every chain it has ever written is public". Publishing is per |
| +// ledger, by name; an empty list publishes nothing but the index itself. |
| +// |
| +// Ledger objects fall into two cache classes, because unlike the fleet |
| +// page's three fixed objects a public ledger has unboundedly many: |
| +// |
| +// - ledgers.json and HEAD.json are MUTABLE and few — one manifest per |
| +// public app, one HEAD per ledger — so they ride h.get's shared 60s |
| +// cache. Revoking public therefore takes at most one TTL, and a cached |
| +// public manifest keeps serving through a store outage exactly like any |
| +// other cached object (get's stale-on-error half). That map cannot be |
| +// grown by a stranger probing random sqids: get only writes an entry |
| +// AFTER a successful store read (see its tail), so it is bounded by what |
| +// actually exists. The REFUSALS a prober generates are remembered |
| +// separately, in the explicitly bounded negative cache (markMiss), so a |
| +// probe costs a bucket GET at most once per TTL per app rather than once |
| +// per request. |
| +// - entries/*.json and blobs/* are IMMUTABLE and unbounded in number |
| +// (an entry per append, blobs up to ledger.MaxBlob each), so they are |
| +// NOT cached in this process at all: a direct store read plus a |
| +// year-long immutable Cache-Control, which puts the caching in browsers |
| +// and CDNs where the memory isn't ours. That is deliberate: a public |
| +// chain walker must not be able to pin arbitrary edge memory by simply |
| +// reading every entry of a public ledger. |
| +func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p string) { |
| + // path.Clean above already collapsed dot and empty segments; re-check |
| + // them anyway, because this is the one place where a public request |
| + // chooses the bucket key and "the cleaner surely handled it" is how |
| + // traversal bugs get written. Note ServeHTTP sees the DECODED path, so |
| + // %2e%2e arrives here as ".." and dies on this check (or, once Clean |
| + // has resolved it out of the /ledgers/ prefix entirely, never reaches |
| + // this function at all). |
| + parts := strings.Split(strings.TrimPrefix(p, "/"), "/") // ["ledgers", sqid, app, ...] |
| + if len(parts) < 4 { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + for _, seg := range parts { |
| + if seg == "" || seg == "." || seg == ".." { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + } |
| + obj, ok := ledgerShape(parts[3:]) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + sqid, app := parts[1], parts[2] |
| + |
| + // The negative half of the gate, checked BEFORE the store: a miss is |
| + // never recorded by get (it only caches successful reads), and |
| + // status-kind responses never enter the shared response cache, so |
| + // without this every probe at a made-up account/app costs one bucket GET |
| + // — and the prober writes the path. |
| + mkey := ledger.ManifestKey(sqid, app) |
| + if h.missFresh(mkey) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + mb, _, err := h.get(r.Context(), mkey, "application/json") |
| + if err != nil { |
| + // Only a genuine not-found is REMEMBERED. That is what the negative |
| + // cache is for: a prober names manifests that do not exist, and the |
| + // store answers os.ErrNotExist (release.Store's contract). Any other |
| + // error is a blip — a throttle, an expired credential — and stamping |
| + // it would take a live public app dark for the full TTL because the |
| + // bucket hiccuped once while this key's positive cache happened to be |
| + // cold. This request still 404s (fail-closed: nothing readable said |
| + // public); the next one retries. |
| + if errors.Is(err, os.ErrNotExist) { |
| + h.markMiss(mkey) |
| + } |
| + http.NotFound(w, r) // fail-closed: no readable manifest = not public |
| + return |
| + } |
| + var m ledger.Manifest |
| + if err := json.Unmarshal(mb, &m); err != nil || !m.Public { |
| + h.markMiss(mkey) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + // obj.ledger is "" only for ledgers.json, the app's OWN published index, |
| + // which the app is publishing by setting public:true. Everything else |
| + // names a ledger and must be on the allowlist. |
| + if obj.ledger != "" && !slices.Contains(m.Ledgers, obj.ledger) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + |
| + key := strings.TrimPrefix(p, "/") |
| + ctype := obj.ctype |
| + // Sniffing is what would turn an opaque blob into something a browser |
| + // executes in this origin's context; the ledger's bytes are arbitrary |
| + // tenant content, so pin the declared type on every response, both |
| + // classes. |
| + w.Header().Set("X-Content-Type-Options", "nosniff") |
| + |
| + if obj.immutable { |
| + // Uncached class: straight to the store, and let the caller cache |
| + // it forever — an entry key commits to its own bytes (the ts-sha |
| + // key) and a blob key IS its sha256, so neither can ever change |
| + // meaning. A store error here is the same plain 404 as a key that |
| + // was never written; nothing is logged, for the same reason the |
| + // rest of this handler logs nothing. |
| + body, _, err := h.store.Get(r.Context(), key) |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Header().Set("Content-Type", ctype) |
| + w.Header().Set("Cache-Control", ledgerImmutableCacheControl) |
| + w.Write(body) |
| + return |
| + } |
| + |
| + body, ctype, err := h.get(r.Context(), key, ctype) |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Header().Set("Content-Type", ctype) |
| + w.Write(body) |
| +} |
| + |
| +// ledgerImmutableCacheControl is what entries and blobs are served with: a |
| +// year, immutable. Content-addressed keys make that safe, and it is what |
| +// keeps the caching of an unbounded object set in browsers and CDNs instead |
| +// of this process's memory. ledgers.json and HEAD.json deliberately get NO |
| +// Cache-Control — they are the mutable head of the chain and their freshness |
| +// is the 60s server-side TTL's job, not a client's. |
| +const ledgerImmutableCacheControl = "public, max-age=31536000, immutable" |
| + |
| +// ledgerObject is everything the path shape alone decides about a servable |
| +// ledger object — before any manifest is consulted. |
| +type ledgerObject struct { |
| + // ctype comes from the SHAPE, never from the key's suffix: a blob is |
| + // named by its sha256 and a tenant may well upload one whose sha ends |
| + // ".json". Blobs are opaque bytes, and the shape is what knows that. |
| + ctype string |
| + // immutable selects the cache class documented on serveLedger. |
| + immutable bool |
| + // ledger is the ledger name this object belongs to, or "" for the |
| + // app-level index (ledgers.json) — the allowlist gate keys off it. |
| + ledger string |
| +} |
| + |
| +// ledgerShape is the object-shape allowlist inside one app's ledger subtree, |
| +// given the path segments AFTER ledgers/<sqid>/<app>/. The ledger primitive |
| +// writes exactly four shapes (internal/ledger/ledger.go's key builders) and |
| +// the edge publishes exactly those: living under a public app's prefix is |
| +// not, by itself, a reason to hand an object to the internet. Same allowlist |
| +// spirit as statusRoutes, one level down. One switch decides shape, content |
| +// type, cache class and owning ledger together, so those four cannot drift |
| +// apart as shapes are added. |
| +func ledgerShape(rest []string) (ledgerObject, bool) { |
| + const jsonCT, blobCT = "application/json", "application/octet-stream" |
| + switch { |
| + case len(rest) == 1 && rest[0] == "ledgers.json": // ledgers/<sqid>/<app>/ledgers.json |
| + return ledgerObject{ctype: jsonCT}, true |
| + case len(rest) == 2 && rest[1] == "HEAD.json": // .../<ledger>/HEAD.json |
| + return ledgerObject{ctype: jsonCT, ledger: rest[0]}, true |
| + case len(rest) == 3 && rest[1] == "entries" && strings.HasSuffix(rest[2], ".json"): |
| + return ledgerObject{ctype: jsonCT, immutable: true, ledger: rest[0]}, true |
| + case len(rest) == 3 && rest[1] == "blobs": // .../<ledger>/blobs/<sha> |
| + return ledgerObject{ctype: blobCT, immutable: true, ledger: rest[0]}, true |
| + } |
| + return ledgerObject{}, false |
| +} |
| + |
| // get implements the cache contract documented on statusHandler: fresh cache |
| // hit wins outright (no store touch at all — that's what bounds read |
| // volume); otherwise it tries the store, falling back to a stale cached copy |
diff --git a/internal/edge/status_test.go b/internal/edge/status_test.go| new file mode 100644 |
| index 000000000..76cd05c80 |
| --- /dev/null |
| +++ b/internal/edge/status_test.go |
| @@ -0,0 +1,678 @@ |
| +// Tests for statusHandler's /ledgers/ branch (status.go). The three fleet |
| +// routes are exercised end-to-end through the Edge in edge_test.go |
| +// (TestStatusRoute*); the ledger branch has no per-route config of its own, |
| +// so these drive the handler directly — same fake-clock/tiny-TTL seam. |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/ledger" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// seedLedger writes one real chained entry, one blob and the app's opt-in |
| +// manifest THROUGH the ledger package, so the keys under test are byte-for- |
| +// byte the keys production writes (the whole point of the 1:1 URL↔key map). |
| +func seedLedger(t *testing.T, st release.Store, public bool) (entryKey, blobSHA string) { |
| + t.Helper() |
| + ctx := context.Background() |
| + a, err := ledger.Append(ctx, st, "bab", "platform", "carbon", "w", |
| + json.RawMessage(`{"n":1}`), |
| + func() time.Time { return time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC) }) |
| + if err != nil { |
| + t.Fatalf("append: %v", err) |
| + } |
| + sha, err := ledger.PutBlob(ctx, st, "bab", "platform", "carbon", []byte("blobby")) |
| + if err != nil { |
| + t.Fatalf("put blob: %v", err) |
| + } |
| + if err := ledger.WriteManifest(ctx, st, "bab", "platform", |
| + ledger.Manifest{Public: public, Ledgers: []string{"carbon"}, Title: "CARLOS"}); err != nil { |
| + t.Fatalf("write manifest: %v", err) |
| + } |
| + return a.Key, sha |
| +} |
| + |
| +func getStatus(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + rr := httptest.NewRecorder() |
| + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "http://status.test"+path, nil)) |
| + return rr |
| +} |
| + |
| +// TestLedgerServingPublic: with public:true, every object the ledger package |
| +// writes is fetchable at the path that equals its bucket key, with the |
| +// content type a browser (and `carlos ledger verify -url`) expects. |
| +func TestLedgerServingPublic(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + entryKey, blobSHA := seedLedger(t, st, true) |
| + h := newStatusHandler(st) |
| + |
| + // [contentType, cacheControl]: the mutable pair carries no Cache-Control |
| + // (freshness is the handler's own 60s TTL); the content-addressed pair |
| + // carries the immutable year, because it is cached by clients and CDNs |
| + // instead of in this process. |
| + for path, want := range map[string][2]string{ |
| + "/ledgers/bab/platform/ledgers.json": {"application/json", ""}, |
| + "/ledgers/bab/platform/carbon/HEAD.json": {"application/json", ""}, |
| + "/" + entryKey: {"application/json", ledgerImmutableCacheControl}, |
| + "/ledgers/bab/platform/carbon/blobs/" + blobSHA: {"application/octet-stream", ledgerImmutableCacheControl}, |
| + } { |
| + rr := getStatus(t, h, path) |
| + if rr.Code != http.StatusOK { |
| + t.Errorf("GET %s = %d, want 200", path, rr.Code) |
| + continue |
| + } |
| + if ct := rr.Header().Get("Content-Type"); ct != want[0] { |
| + t.Errorf("GET %s Content-Type = %q, want %q", path, ct, want[0]) |
| + } |
| + if cc := rr.Header().Get("Cache-Control"); cc != want[1] { |
| + t.Errorf("GET %s Cache-Control = %q, want %q", path, cc, want[1]) |
| + } |
| + // Ledger bytes are arbitrary tenant content served from the status |
| + // host's own origin: every shape must refuse content sniffing. |
| + if xcto := rr.Header().Get("X-Content-Type-Options"); xcto != "nosniff" { |
| + t.Errorf("GET %s X-Content-Type-Options = %q, want nosniff", path, xcto) |
| + } |
| + } |
| + if body := getStatus(t, h, "/ledgers/bab/platform/carbon/blobs/"+blobSHA).Body.String(); body != "blobby" { |
| + t.Errorf("blob body = %q, want %q", body, "blobby") |
| + } |
| +} |
| + |
| +// TestLedgerServingFailClosed: every way of NOT being a published public |
| +// object answers the same plain 404 — a non-public app must be |
| +// indistinguishable from an app that does not exist. |
| +func TestLedgerServingFailClosed(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + entryKey, blobSHA := seedLedger(t, st, false) // chain exists, manifest says NOT public |
| + // An app whose manifest exists but is unparseable must also fail closed. |
| + if _, err := st.Put(context.Background(), ledger.ManifestKey("bab", "broken"), |
| + []byte("{not json"), ""); err != nil { |
| + t.Fatalf("seed broken manifest: %v", err) |
| + } |
| + h := newStatusHandler(st) |
| + |
| + for _, path := range []string{ |
| + "/ledgers/bab/platform/carbon/HEAD.json", |
| + "/ledgers/bab/platform/ledgers.json", |
| + "/" + entryKey, |
| + "/ledgers/bab/platform/carbon/blobs/" + blobSHA, |
| + "/ledgers/bab/broken/ledgers.json", // manifest unparseable |
| + "/ledgers/nope/nothing/carbon/HEAD.json", // no manifest at all |
| + "/ledgers/bab/platform/carbon/../../secrets", // traversal shape |
| + "/ledgers/bab", // too-short path |
| + "/ledgers/bab/platform", // too-short path |
| + "/ledgers/", // bare prefix |
| + } { |
| + rr := getStatus(t, h, path) |
| + if rr.Code != http.StatusNotFound { |
| + t.Errorf("GET %s = %d, want 404", path, rr.Code) |
| + } |
| + } |
| + |
| + // Byte-identical bodies: the private app and the app that was never |
| + // created must not be told apart by their 404s. |
| + private := getStatus(t, h, "/ledgers/bab/platform/ledgers.json") |
| + absent := getStatus(t, h, "/ledgers/nope/nothing/ledgers.json") |
| + if private.Body.String() != absent.Body.String() { |
| + t.Errorf("404 bodies differ: private %q vs absent %q", private.Body.String(), absent.Body.String()) |
| + } |
| +} |
| + |
| +// appendTo adds one entry to another ledger of the same app, so a test can |
| +// have a published and an unpublished chain side by side. |
| +func appendTo(t *testing.T, st release.Store, name string) string { |
| + t.Helper() |
| + a, err := ledger.Append(context.Background(), st, "bab", "platform", name, "w", |
| + json.RawMessage(`{"n":2}`), |
| + func() time.Time { return time.Date(2026, 8, 3, 22, 0, 0, 0, time.UTC) }) |
| + if err != nil { |
| + t.Fatalf("append %s: %v", name, err) |
| + } |
| + return a.Key |
| +} |
| + |
| +// TestLedgerAllowlistIsPerLedger: Manifest.Ledgers is an allowlist, not an |
| +// index. An account accrues chains privately, so public:true on the app must |
| +// not publish every chain it has ever written — only the ones it names. |
| +func TestLedgerAllowlistIsPerLedger(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + seedLedger(t, st, true) // manifest publishes "carbon" only |
| + ironEntry := appendTo(t, st, "iron") |
| + ironSHA, err := ledger.PutBlob(context.Background(), st, "bab", "platform", "iron", []byte("ironblob")) |
| + if err != nil { |
| + t.Fatalf("put iron blob: %v", err) |
| + } |
| + h := newStatusHandler(st) |
| + |
| + if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK { |
| + t.Fatalf("published ledger = %d, want 200", rr.Code) |
| + } |
| + absent := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json") |
| + for _, path := range []string{ |
| + "/ledgers/bab/platform/iron/HEAD.json", |
| + "/" + ironEntry, |
| + "/ledgers/bab/platform/iron/blobs/" + ironSHA, |
| + } { |
| + rr := getStatus(t, h, path) |
| + if rr.Code != http.StatusNotFound { |
| + t.Errorf("unpublished ledger GET %s = %d, want 404", path, rr.Code) |
| + continue |
| + } |
| + if rr.Body.String() != absent.Body.String() { |
| + t.Errorf("GET %s 404 body %q differs from an absent app's %q", |
| + path, rr.Body.String(), absent.Body.String()) |
| + } |
| + } |
| + |
| + // An empty list publishes nothing but the app's own index. |
| + if err := ledger.WriteManifest(context.Background(), st, "bab", "platform", |
| + ledger.Manifest{Public: true, Ledgers: nil, Title: "CARLOS"}); err != nil { |
| + t.Fatalf("rewrite manifest: %v", err) |
| + } |
| + h2 := newStatusHandler(st) // fresh handler: don't wait out the 60s TTL |
| + if rr := getStatus(t, h2, "/ledgers/bab/platform/ledgers.json"); rr.Code != http.StatusOK { |
| + t.Errorf("ledgers.json with an empty list = %d, want 200 (the app's own index)", rr.Code) |
| + } |
| + for _, path := range []string{ |
| + "/ledgers/bab/platform/carbon/HEAD.json", |
| + "/ledgers/bab/platform/iron/HEAD.json", |
| + } { |
| + if rr := getStatus(t, h2, path); rr.Code != http.StatusNotFound { |
| + t.Errorf("GET %s with an empty allowlist = %d, want 404", path, rr.Code) |
| + } |
| + } |
| +} |
| + |
| +// TestLedgerBlobContentTypeComesFromShape: a blob is named by its sha256 and |
| +// nothing stops a tenant uploading one whose name ends ".json" — the content |
| +// type must come from the path SHAPE, not from a suffix probe on the key. |
| +func TestLedgerBlobContentTypeComesFromShape(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + seedLedger(t, st, true) |
| + if _, err := st.Put(context.Background(), |
| + "ledgers/bab/platform/carbon/blobs/deadbeef.json", []byte(`{"not":"json-typed"}`), ""); err != nil { |
| + t.Fatalf("seed blob: %v", err) |
| + } |
| + rr := getStatus(t, newStatusHandler(st), "/ledgers/bab/platform/carbon/blobs/deadbeef.json") |
| + if rr.Code != http.StatusOK { |
| + t.Fatalf("blob = %d, want 200", rr.Code) |
| + } |
| + if ct := rr.Header().Get("Content-Type"); ct != "application/octet-stream" { |
| + t.Errorf("blob Content-Type = %q, want application/octet-stream", ct) |
| + } |
| +} |
| + |
| +// TestLedgerServingNeverLeavesPrefix: a public manifest buys access to that |
| +// app's own ledger subtree and nothing else — no encoded traversal reaches a |
| +// key outside it. |
| +func TestLedgerServingNeverLeavesPrefix(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + seedLedger(t, st, true) |
| + if _, err := st.Put(context.Background(), "status/fleet.json", []byte(`{"fleet":true}`), ""); err != nil { |
| + t.Fatalf("seed fleet: %v", err) |
| + } |
| + if _, err := st.Put(context.Background(), "apps/bab/platform/config.json", []byte(`{"secret":true}`), ""); err != nil { |
| + t.Fatalf("seed app config: %v", err) |
| + } |
| + h := newStatusHandler(st) |
| + |
| + for _, path := range []string{ |
| + "/ledgers/bab/platform/%2e%2e/%2e%2e/%2e%2e/status/fleet.json", |
| + "/ledgers/bab/platform/../../../apps/bab/platform/config.json", |
| + "/ledgers/bab/platform/carbon/%2e%2e/%2e%2e/%2e%2e/%2e%2e/apps/bab/platform/config.json", |
| + "/ledgers/bab/platform//../../apps/bab/platform/config.json", |
| + } { |
| + rr := getStatus(t, h, path) |
| + if rr.Code == http.StatusOK { |
| + t.Errorf("GET %s = 200 %q — escaped the app's ledger subtree", path, rr.Body.String()) |
| + } |
| + } |
| +} |
| + |
| +// TestLedgerServingOnlyLedgerShapes: even inside a public app's own subtree |
| +// the handler serves only the four object shapes the ledger primitive |
| +// defines; anything else that happens to be under the prefix stays private. |
| +func TestLedgerServingOnlyLedgerShapes(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + seedLedger(t, st, true) |
| + ctx := context.Background() |
| + for _, key := range []string{ |
| + "ledgers/bab/platform/notes.txt", |
| + "ledgers/bab/platform/other.json", |
| + "ledgers/bab/platform/carbon/notes.txt", |
| + "ledgers/bab/platform/carbon/entries/raw.bin", |
| + "ledgers/bab/platform/carbon/blobs/deep/nested", |
| + } { |
| + if _, err := st.Put(ctx, key, []byte("private"), ""); err != nil { |
| + t.Fatalf("seed %s: %v", key, err) |
| + } |
| + } |
| + h := newStatusHandler(st) |
| + for _, key := range []string{ |
| + "ledgers/bab/platform/notes.txt", |
| + "ledgers/bab/platform/other.json", |
| + "ledgers/bab/platform/carbon/notes.txt", |
| + "ledgers/bab/platform/carbon/entries/raw.bin", |
| + "ledgers/bab/platform/carbon/blobs/deep/nested", |
| + } { |
| + if rr := getStatus(t, h, "/"+key); rr.Code != http.StatusNotFound { |
| + t.Errorf("GET /%s = %d, want 404", key, rr.Code) |
| + } |
| + } |
| +} |
| + |
| +// TestLedgerServingMissingObjectIs404: under a public app, an object that |
| +// isn't there is a 404 — the 503 "nothing honest left to serve" contract |
| +// belongs to the fleet page's own three objects, not to ledger reads. |
| +func TestLedgerServingMissingObjectIs404(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + seedLedger(t, st, true) |
| + h := newStatusHandler(st) |
| + for _, path := range []string{ |
| + "/ledgers/bab/platform/nosuch/HEAD.json", |
| + "/ledgers/bab/platform/carbon/entries/20260101T000000Z-deadbeefcafe.json", |
| + "/ledgers/bab/platform/carbon/blobs/" + ledger.SHA256Hex([]byte("absent")), |
| + } { |
| + if rr := getStatus(t, h, path); rr.Code != http.StatusNotFound { |
| + t.Errorf("GET %s = %d, want 404", path, rr.Code) |
| + } |
| + } |
| +} |
| + |
| +// cacheKeys snapshots the handler's in-memory cache under its own lock. |
| +func cacheKeys(h *statusHandler) map[string]bool { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + keys := make(map[string]bool, len(h.cache)) |
| + for k := range h.cache { |
| + keys[k] = true |
| + } |
| + return keys |
| +} |
| + |
| +// TestLedgerCacheClasses is the memory-safety pin: a public ledger has |
| +// unboundedly many entries and blobs (blobs up to 4 MiB), so serving them |
| +// must NOT put anything in the handler's never-evicting cache — otherwise |
| +// anyone on the internet could pin arbitrary edge memory just by walking a |
| +// public chain. Only the mutable, bounded set (one ledgers.json per public |
| +// app, one HEAD.json per ledger) is cached. |
| +func TestLedgerCacheClasses(t *testing.T) { |
| + dir := t.TempDir() |
| + st := release.NewDirStore(dir) |
| + entryKey, blobSHA := seedLedger(t, st, true) |
| + h := newStatusHandler(st) |
| + |
| + entryPath, blobPath := "/"+entryKey, "/ledgers/bab/platform/carbon/blobs/"+blobSHA |
| + if rr := getStatus(t, h, entryPath); rr.Code != http.StatusOK { |
| + t.Fatalf("GET %s = %d, want 200", entryPath, rr.Code) |
| + } |
| + if rr := getStatus(t, h, blobPath); rr.Code != http.StatusOK { |
| + t.Fatalf("GET %s = %d, want 200", blobPath, rr.Code) |
| + } |
| + keys := cacheKeys(h) |
| + if keys[entryKey] || keys["ledgers/bab/platform/carbon/blobs/"+blobSHA] { |
| + t.Errorf("immutable objects were cached in-process: %v", keys) |
| + } |
| + // The gate itself is cached — that read is what bounds the map. |
| + if want := ledger.ManifestKey("bab", "platform"); !keys[want] { |
| + t.Errorf("manifest %s not cached: %v", want, keys) |
| + } |
| + if len(keys) != 1 { |
| + t.Errorf("cache holds %d keys, want only the manifest: %v", len(keys), keys) |
| + } |
| + |
| + // Serving HEAD.json adds exactly one more key (the mutable class). |
| + if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK { |
| + t.Fatalf("GET HEAD.json = %d, want 200", rr.Code) |
| + } |
| + if keys = cacheKeys(h); len(keys) != 2 || !keys[ledger.HeadKey("bab", "platform", "carbon")] { |
| + t.Errorf("after HEAD.json the cache holds %v, want manifest + HEAD", keys) |
| + } |
| + |
| + // A miss never grows the map: probing sqids that do not exist must not |
| + // be a way to allocate memory in the edge (get only records an entry |
| + // after a SUCCESSFUL store read). |
| + for i, p := range []string{ |
| + "/ledgers/nope/nothing/ledgers.json", |
| + "/ledgers/nope/nothing/carbon/HEAD.json", |
| + "/ledgers/bab/platform/carbon/entries/20260101T000000Z-000000000000.json", |
| + } { |
| + if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound { |
| + t.Fatalf("probe %d (%s) = %d, want 404", i, p, rr.Code) |
| + } |
| + } |
| + if after := cacheKeys(h); len(after) != 2 { |
| + t.Errorf("probing grew the cache to %v", after) |
| + } |
| + |
| + // And because entries are not cached at all, removing the backing |
| + // object is visible immediately — no stale copy is held anywhere in |
| + // this process, even though the manifest gate is still cached. |
| + if err := os.Remove(filepath.Join(dir, filepath.FromSlash(entryKey))); err != nil { |
| + t.Fatalf("remove entry: %v", err) |
| + } |
| + if rr := getStatus(t, h, entryPath); rr.Code != http.StatusNotFound { |
| + t.Errorf("removed entry = %d, want 404 (nothing cached in-process)", rr.Code) |
| + } |
| +} |
| + |
| +// countingStore records how many times each key is read, so a test can |
| +// assert what a request COST rather than only what it answered. |
| +type countingStore struct { |
| + release.Store |
| + mu sync.Mutex |
| + gets map[string]int |
| +} |
| + |
| +func newCountingStore(st release.Store) *countingStore { |
| + return &countingStore{Store: st, gets: make(map[string]int)} |
| +} |
| + |
| +func (c *countingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + c.mu.Lock() |
| + c.gets[key]++ |
| + c.mu.Unlock() |
| + return c.Store.Get(ctx, key) |
| +} |
| + |
| +func (c *countingStore) count(key string) int { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return c.gets[key] |
| +} |
| + |
| +// TestLedgerManifestMissesAreNegativeCached: status-kind routes are exempt |
| +// from the shared response cache, and get() only records an entry after a |
| +// SUCCESSFUL read — so without a negative cache every anonymous request to |
| +// /ledgers/<random>/<random>/x/HEAD.json costs exactly one bucket GET, and a |
| +// stranger with a loop is billing the deployment one S3 request per packet. |
| +// The decision "no readable public manifest here" is cached for the same 60s |
| +// the positive answer is. |
| +func TestLedgerManifestMissesAreNegativeCached(t *testing.T) { |
| + st := newCountingStore(release.NewDirStore(t.TempDir())) |
| + h := newStatusHandler(st) |
| + h.ttl = 50 * time.Millisecond |
| + fakeNow := time.Now() |
| + h.now = func() time.Time { return fakeNow } |
| + |
| + key := ledger.ManifestKey("nope", "nothing") |
| + for i := range 5 { |
| + if rr := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json"); rr.Code != http.StatusNotFound { |
| + t.Fatalf("probe %d = %d, want 404", i, rr.Code) |
| + } |
| + } |
| + if got := st.count(key); got != 1 { |
| + t.Fatalf("%s was read %d times for 5 probes, want 1 (the miss must be cached)", key, got) |
| + } |
| + |
| + // Different objects under the SAME app share the one decision — the |
| + // gate is the app's manifest, so walking an app's key space is not a way |
| + // around it. |
| + for _, p := range []string{ |
| + "/ledgers/nope/nothing/ledgers.json", |
| + "/ledgers/nope/nothing/carbon/entries/20260101T000000Z-000000000000.json", |
| + "/ledgers/nope/nothing/carbon/blobs/" + ledger.SHA256Hex([]byte("x")), |
| + } { |
| + if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound { |
| + t.Fatalf("GET %s = %d, want 404", p, rr.Code) |
| + } |
| + } |
| + if got := st.count(key); got != 1 { |
| + t.Fatalf("%s was read %d times, want still 1", key, got) |
| + } |
| + |
| + // Past the TTL the decision is re-taken — publishing an app takes effect |
| + // within one tick, exactly like the positive cache's staleness bound. |
| + fakeNow = fakeNow.Add(51 * time.Millisecond) |
| + if rr := getStatus(t, h, "/ledgers/nope/nothing/carbon/HEAD.json"); rr.Code != http.StatusNotFound { |
| + t.Fatalf("post-TTL probe = %d, want 404", rr.Code) |
| + } |
| + if got := st.count(key); got != 2 { |
| + t.Fatalf("%s read %d times after the TTL expired, want 2", key, got) |
| + } |
| + |
| + // A non-public app is the same cached decision: the manifest read that |
| + // answered it is itself cached, so neither costs a second GET. |
| + seedLedger(t, st, false) |
| + pkey := ledger.ManifestKey("bab", "platform") |
| + before := st.count(pkey) |
| + for range 3 { |
| + if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusNotFound { |
| + t.Fatalf("private app = %d, want 404", rr.Code) |
| + } |
| + } |
| + if got := st.count(pkey) - before; got > 1 { |
| + t.Fatalf("a private app cost %d manifest reads for 3 probes, want at most 1", got) |
| + } |
| +} |
| + |
| +// flakyStore fails every read while armed, with an error that is NOT |
| +// not-found — a bucket blip, a throttle, an expired credential — the class of |
| +// failure that resolves on its own. |
| +type flakyStore struct { |
| + release.Store |
| + mu sync.Mutex |
| + broken bool |
| +} |
| + |
| +func (f *flakyStore) breakIt(b bool) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.broken = b |
| +} |
| + |
| +func (f *flakyStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + f.mu.Lock() |
| + broken := f.broken |
| + f.mu.Unlock() |
| + if broken { |
| + return nil, "", errors.New("Throttling: slow down") |
| + } |
| + return f.Store.Get(ctx, key) |
| +} |
| + |
| +// TestLedgerTransientStoreErrorIsNotNegativeCached: the negative cache exists |
| +// to make PROBING cheap to refuse, and a probe's manifest is not-found. A |
| +// transient store error is a different thing entirely: caching it would pin a |
| +// public app dark for the full TTL because the bucket hiccuped once while the |
| +// cache happened to be cold — the exact dishonesty the positive cache's |
| +// stale-on-error half exists to avoid, reintroduced from the other side. The |
| +// blipped request still 404s (fail-closed: nothing readable said public), but |
| +// the decision is not remembered, so the next request retries. |
| +func TestLedgerTransientStoreErrorIsNotNegativeCached(t *testing.T) { |
| + base := release.NewDirStore(t.TempDir()) |
| + st := &flakyStore{Store: base} |
| + seedLedger(t, base, true) |
| + h := newStatusHandler(st) |
| + |
| + // Cold cache + broken store: 404, because nothing readable says public. |
| + st.breakIt(true) |
| + if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusNotFound { |
| + t.Fatalf("during the blip = %d, want 404", rr.Code) |
| + } |
| + h.mu.Lock() |
| + n := len(h.misses) |
| + h.mu.Unlock() |
| + if n != 0 { |
| + t.Fatalf("a transient store error stamped %d negative-cache entries, want 0", n) |
| + } |
| + |
| + // The store recovers well inside the 60s TTL: the very next request must |
| + // serve, not sit behind a cached refusal. |
| + st.breakIt(false) |
| + if rr := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json"); rr.Code != http.StatusOK { |
| + t.Fatalf("after the store recovered = %d, want 200 (the blip must not be negatively cached)", rr.Code) |
| + } |
| +} |
| + |
| +// TestLedgerMissCacheIsBounded: the attacker picks the account and app path |
| +// segments, so the set of distinct manifest keys a prober can name is |
| +// unbounded — a negative cache that grew with it would be the memory |
| +// exhaustion the immutable classes are deliberately uncached to avoid, |
| +// reintroduced through the back door. |
| +func TestLedgerMissCacheIsBounded(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + h := newStatusHandler(st) |
| + for i := range statusMissCacheMax * 3 { |
| + p := fmt.Sprintf("/ledgers/acct%d/app%d/carbon/HEAD.json", i, i) |
| + if rr := getStatus(t, h, p); rr.Code != http.StatusNotFound { |
| + t.Fatalf("probe %d = %d, want 404", i, rr.Code) |
| + } |
| + } |
| + h.mu.Lock() |
| + n := len(h.misses) |
| + h.mu.Unlock() |
| + if n > statusMissCacheMax { |
| + t.Fatalf("negative cache holds %d entries, over the %d bound", n, statusMissCacheMax) |
| + } |
| + // The positive cache is untouched by any of it: nothing was ever read. |
| + if keys := cacheKeys(h); len(keys) != 0 { |
| + t.Fatalf("probing populated the positive cache: %v", keys) |
| + } |
| +} |
| + |
| +// TestLedgerServingStaleManifestHonored pins the interaction with get()'s |
| +// stale-on-error half: the manifest is just another cached object, so once a |
| +// public manifest has been read, a store outage keeps the already-cached |
| +// objects serving (the alternative — 404ing a public ledger because the |
| +// bucket blipped — is the same dishonesty the status page's cache exists to |
| +// avoid). An object that was never cached still 404s: stale caching, not |
| +// fabrication. |
| +func TestLedgerServingStaleManifestHonored(t *testing.T) { |
| + dir := t.TempDir() |
| + st := release.NewDirStore(dir) |
| + entryKey, _ := seedLedger(t, st, true) |
| + h := newStatusHandler(st) |
| + h.ttl = 20 * time.Millisecond |
| + fakeNow := time.Now() |
| + h.now = func() time.Time { return fakeNow } |
| + |
| + first := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json") |
| + if first.Code != http.StatusOK { |
| + t.Fatalf("first fetch = %d, want 200", first.Code) |
| + } |
| + |
| + // Break the store entirely and age both cached objects past the TTL. |
| + if err := os.RemoveAll(dir); err != nil { |
| + t.Fatalf("break store: %v", err) |
| + } |
| + fakeNow = fakeNow.Add(21 * time.Millisecond) |
| + |
| + stale := getStatus(t, h, "/ledgers/bab/platform/carbon/HEAD.json") |
| + if stale.Code != http.StatusOK || stale.Body.String() != first.Body.String() { |
| + t.Fatalf("stale fetch = %d %q, want 200 %q", stale.Code, stale.Body.String(), first.Body.String()) |
| + } |
| + |
| + // The entry was never cached: a stale-but-public manifest does not |
| + // conjure a body for it. |
| + if rr := getStatus(t, h, "/"+entryKey); rr.Code != http.StatusNotFound { |
| + t.Errorf("uncached entry during outage = %d, want 404", rr.Code) |
| + } |
| +} |
| + |
| +// statusEdgeWithCache wires a full Edge (Host dispatch, shared response |
| +// cache) in front of one status route — testEdgeWithStore's shape, but |
| +// returning the *Edge so a test can look inside e.cache. |
| +func statusEdgeWithCache(t *testing.T) (release.Store, *Edge) { |
| + t.Helper() |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "status.test", Kind: "status", Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| + _, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatalf("generate key: %v", err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatalf("parse public key: %v", err) |
| + } |
| + e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + return store, e |
| +} |
| + |
| +// TestStatusResponsesNeverEnterSharedCache: the shared response cache is |
| +// process-global and shared across every host, and a public ledger chain is |
| +// unbounded in length — so a stranger walking one must not be able to admit |
| +// (and thereby evict) anything in it. An evicted entry for a hibernating |
| +// route is a wake that the cache existed to prevent. The client-facing |
| +// immutable Cache-Control still goes out: browsers and CDNs do the caching. |
| +func TestStatusResponsesNeverEnterSharedCache(t *testing.T) { |
| + store, e := statusEdgeWithCache(t) |
| + entryKey, blobSHA := seedLedger(t, store, true) |
| + if _, err := store.Put(context.Background(), "status/fleet.json", []byte(`{"ok":true}`), ""); err != nil { |
| + t.Fatalf("seed fleet: %v", err) |
| + } |
| + |
| + for _, path := range []string{ |
| + "/" + entryKey, |
| + "/ledgers/bab/platform/carbon/blobs/" + blobSHA, |
| + "/ledgers/bab/platform/carbon/HEAD.json", |
| + "/ledgers/bab/platform/ledgers.json", |
| + "/fleet.json", |
| + } { |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test"+path, nil)) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET %s = %d, want 200", path, rec.Code) |
| + } |
| + if _, ok := e.cache.get(CacheKey{Host: "status.test", Method: "GET", Path: path}); ok { |
| + t.Errorf("GET %s entered the shared edge response cache", path) |
| + } |
| + } |
| + |
| + // The header a client sees is unchanged — only the edge's own admission |
| + // is refused. |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test/"+entryKey, nil)) |
| + if cc := rec.Header().Get("Cache-Control"); cc != ledgerImmutableCacheControl { |
| + t.Errorf("entry Cache-Control = %q, want %q", cc, ledgerImmutableCacheControl) |
| + } |
| +} |
| + |
| +// TestLedgerBranchLeavesFleetRoutesAlone: the /ledgers/ prefix branch must |
| +// not intercept the three fleet paths, which keep their own 503-when-nothing- |
| +// cached contract. |
| +func TestLedgerBranchLeavesFleetRoutesAlone(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + seedLedger(t, st, true) |
| + h := newStatusHandler(st) |
| + for _, path := range []string{"/", "/fleet.json", "/status.css"} { |
| + if rr := getStatus(t, h, path); rr.Code != http.StatusServiceUnavailable { |
| + t.Errorf("GET %s = %d, want 503 (nothing published)", path, rr.Code) |
| + } |
| + } |
| + if _, err := st.Put(context.Background(), "status/fleet.json", []byte(`{"ok":true}`), ""); err != nil { |
| + t.Fatalf("seed fleet: %v", err) |
| + } |
| + rr := getStatus(t, h, "/fleet.json") |
| + if rr.Code != http.StatusOK || rr.Body.String() != `{"ok":true}` { |
| + t.Errorf("GET /fleet.json = %d %q, want 200 fleet body", rr.Code, rr.Body.String()) |
| + } |
| + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { |
| + t.Errorf("GET /fleet.json Content-Type = %q", ct) |
| + } |
| +} |
diff --git a/internal/ledger/append.go b/internal/ledger/append.go| new file mode 100644 |
| index 000000000..536575451 |
| --- /dev/null |
| +++ b/internal/ledger/append.go |
| @@ -0,0 +1,109 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type Appended struct { |
| + Key string `json:"key"` |
| + SHA256 string `json:"sha256"` |
| + Count int `json:"count"` |
| +} |
| + |
| +// appendAttempts bounds the CAS retry loop. Conflicts are rare (two |
| +// writers accruing the same app's ledger in the same moment); four |
| +// attempts outlasts any plausible burst without ever spinning. |
| +const appendAttempts = 4 |
| + |
| +// ErrBusy marks the one Append failure that is neither the caller's fault |
| +// nor a broken store: too many writers accruing the same chain at once, so |
| +// every CAS attempt lost. A caller that retries later succeeds — which is |
| +// exactly what a caller cannot work out from an error STRING, hence a |
| +// sentinel to match with errors.Is. |
| +var ErrBusy = errors.New("ledger: append contention") |
| + |
| +// Append validates, chains, and writes one entry, advancing HEAD with CAS. |
| +// A lost HEAD race re-reads and re-chains (up to 4 attempts). An entry |
| +// object orphaned by a lost race is harmless: nothing references it, and |
| +// Verify walks only the chain. |
| +func Append(ctx context.Context, st release.Store, sqid, app, name, writer string, |
| + payload json.RawMessage, now func() time.Time) (Appended, error) { |
| + if !ValidName(name) { |
| + return Appended{}, fmt.Errorf("ledger: invalid ledger name %q", name) |
| + } |
| + if len(payload) > MaxPayload { |
| + return Appended{}, fmt.Errorf("ledger: payload %d bytes exceeds cap %d", len(payload), MaxPayload) |
| + } |
| + if !json.Valid(payload) { |
| + return Appended{}, fmt.Errorf("ledger: payload is not valid JSON") |
| + } |
| + |
| + var lastErr error |
| + for range appendAttempts { |
| + headKey := HeadKey(sqid, app, name) |
| + hb, etag, err := st.Get(ctx, headKey) |
| + var head Head |
| + exists := err == nil |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + // First entry: prev fields stay empty, HEAD is create-only. |
| + case err != nil: |
| + return Appended{}, err |
| + default: |
| + if err := json.Unmarshal(hb, &head); err != nil { |
| + return Appended{}, fmt.Errorf("ledger: %s corrupt: %w", headKey, err) |
| + } |
| + } |
| + |
| + ts := now().UTC().Format(time.RFC3339) |
| + e := Entry{ |
| + Schema: Schema, Ledger: name, Writer: writer, TS: ts, |
| + Payload: payload, PrevKey: head.HeadKey, PrevSHA256: head.HeadSHA256, |
| + } |
| + eb, err := json.Marshal(e) |
| + if err != nil { |
| + return Appended{}, err |
| + } |
| + sha := SHA256Hex(eb) |
| + key := EntryKey(sqid, app, name, ts, sha) |
| + if _, err := st.Put(ctx, key, eb, ""); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + // Same second, same bytes-shape collision: retry re-reads |
| + // HEAD and produces a differently-chained entry. |
| + lastErr = err |
| + continue |
| + } |
| + return Appended{}, err |
| + } |
| + |
| + nh := Head{Schema: Schema, UpdatedAt: ts, HeadKey: key, HeadSHA256: sha, Count: head.Count + 1} |
| + nhb, err := json.Marshal(nh) |
| + if err != nil { |
| + return Appended{}, err |
| + } |
| + ifMatch := "" |
| + if exists { |
| + ifMatch = etag |
| + } |
| + if _, err := st.Put(ctx, headKey, nhb, ifMatch); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + // Lost the race: another writer advanced HEAD. Our entry |
| + // object is now an unreferenced orphan — harmless in a |
| + // versioned bucket; Verify walks only the chain. Re-read |
| + // and re-chain. |
| + lastErr = err |
| + continue |
| + } |
| + return Appended{}, err |
| + } |
| + return Appended{Key: key, SHA256: sha, Count: nh.Count}, nil |
| + } |
| + return Appended{}, fmt.Errorf("ledger: append lost %d CAS races: %w: %w", appendAttempts, ErrBusy, lastErr) |
| +} |
diff --git a/internal/ledger/append_test.go b/internal/ledger/append_test.go| new file mode 100644 |
| index 000000000..3438c8847 |
| --- /dev/null |
| +++ b/internal/ledger/append_test.go |
| @@ -0,0 +1,169 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func fixedNow() time.Time { return time.Date(2026, 8, 3, 21, 4, 0, 0, time.UTC) } |
| + |
| +func TestAppendFirstAndSecond(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + |
| + a1, err := Append(ctx, st, "bab", "platform", "carbon", "paul@keymail.dev", |
| + json.RawMessage(`{"n":1}`), fixedNow) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // HEAD points at the entry, count 1, and the stored bytes hash to |
| + // the recorded sha. |
| + hb, _, err := st.Get(ctx, HeadKey("bab", "platform", "carbon")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var h Head |
| + if err := json.Unmarshal(hb, &h); err != nil { |
| + t.Fatal(err) |
| + } |
| + if h.HeadKey != a1.Key || h.Count != 1 || h.Schema != Schema { |
| + t.Fatalf("head = %+v, appended = %+v", h, a1) |
| + } |
| + eb, _, err := st.Get(ctx, a1.Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if SHA256Hex(eb) != a1.SHA256 || h.HeadSHA256 != a1.SHA256 { |
| + t.Fatal("stored bytes do not hash to the recorded sha") |
| + } |
| + var e1 Entry |
| + if err := json.Unmarshal(eb, &e1); err != nil { |
| + t.Fatal(err) |
| + } |
| + if e1.PrevKey != "" || e1.PrevSHA256 != "" || e1.Writer != "paul@keymail.dev" { |
| + t.Fatalf("first entry = %+v", e1) |
| + } |
| + |
| + // Second append chains to the first. |
| + later := func() time.Time { return fixedNow().Add(time.Minute) } |
| + a2, err := Append(ctx, st, "bab", "platform", "carbon", "paul@keymail.dev", |
| + json.RawMessage(`{"n":2}`), later) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + eb2, _, _ := st.Get(ctx, a2.Key) |
| + var e2 Entry |
| + if err := json.Unmarshal(eb2, &e2); err != nil { |
| + t.Fatal(err) |
| + } |
| + if e2.PrevKey != a1.Key || e2.PrevSHA256 != a1.SHA256 || a2.Count != 2 { |
| + t.Fatalf("second entry does not chain: %+v (count %d)", e2, a2.Count) |
| + } |
| +} |
| + |
| +func TestAppendRejects(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if _, err := Append(ctx, st, "bab", "platform", "Not A Name", "w", |
| + json.RawMessage(`{}`), fixedNow); err == nil { |
| + t.Fatal("bad ledger name accepted") |
| + } |
| + big := json.RawMessage(`"` + string(make([]byte, MaxPayload)) + `"`) |
| + if _, err := Append(ctx, st, "bab", "platform", "carbon", "w", big, fixedNow); err == nil { |
| + t.Fatal("oversized payload accepted") |
| + } |
| + if _, err := Append(ctx, st, "bab", "platform", "carbon", "w", |
| + json.RawMessage(`{not json`), fixedNow); err == nil { |
| + t.Fatal("invalid JSON payload accepted") |
| + } |
| +} |
| + |
| +// casOnceStore makes the FIRST head Put lose its race, as if another |
| +// writer advanced HEAD between our read and our write. |
| +type casOnceStore struct { |
| + release.Store |
| + tripped bool |
| +} |
| + |
| +func (c *casOnceStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if !c.tripped && key == HeadKey("bab", "platform", "carbon") { |
| + c.tripped = true |
| + // Simulate the race: a competing append lands first. |
| + if _, err := Append(ctx, c.Store, "bab", "platform", "carbon", "rival", |
| + json.RawMessage(`{"rival":true}`), fixedNow); err != nil { |
| + return "", err |
| + } |
| + return "", release.ErrCASConflict |
| + } |
| + return c.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func TestAppendRetriesLostRace(t *testing.T) { |
| + st := &casOnceStore{Store: release.NewDirStore(t.TempDir())} |
| + ctx := context.Background() |
| + later := func() time.Time { return fixedNow().Add(time.Minute) } |
| + a, err := Append(ctx, st, "bab", "platform", "carbon", "me", |
| + json.RawMessage(`{"mine":true}`), later) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if a.Count != 2 { |
| + t.Fatalf("count = %d, want 2 (rival's entry then ours)", a.Count) |
| + } |
| + eb, _, err := st.Get(ctx, a.Key) |
| + if errors.Is(err, os.ErrNotExist) || err != nil { |
| + t.Fatal(err) |
| + } |
| + var e Entry |
| + if err := json.Unmarshal(eb, &e); err != nil { |
| + t.Fatal(err) |
| + } |
| + if e.PrevKey == "" { |
| + t.Fatal("retried entry did not re-chain onto the rival's head") |
| + } |
| +} |
| + |
| +// alwaysCASStore loses every HEAD race — the shape of a chain under more |
| +// concurrent writers than the retry loop can outlast. |
| +type alwaysCASStore struct{ release.Store } |
| + |
| +func (a alwaysCASStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if strings.HasSuffix(key, "/HEAD.json") { |
| + return "", release.ErrCASConflict |
| + } |
| + return a.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +// TestAppendExhaustionIsErrBusy: losing every race is neither the caller's |
| +// fault nor a broken store — it is retry-worthy, and a caller must be able |
| +// to tell WITHOUT matching on error text, because callers that match on text |
| +// break the first time the sentence changes. |
| +func TestAppendExhaustionIsErrBusy(t *testing.T) { |
| + st := alwaysCASStore{Store: release.NewDirStore(t.TempDir())} |
| + _, err := Append(context.Background(), st, "bab", "platform", "carbon", "me", |
| + json.RawMessage(`{"n":1}`), fixedNow) |
| + if err == nil { |
| + t.Fatal("Append succeeded against a store that loses every HEAD race") |
| + } |
| + if !errors.Is(err, ErrBusy) { |
| + t.Fatalf("exhaustion err = %v, want it to wrap ErrBusy", err) |
| + } |
| + // The underlying cause is still reachable — the sentinel adds to the |
| + // chain, it does not replace it. |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("exhaustion err = %v, want it to still wrap ErrCASConflict", err) |
| + } |
| + // A caller-caused failure must NOT look busy. |
| + _, err = Append(context.Background(), st, "bab", "platform", "Not Valid", "me", |
| + json.RawMessage(`{"n":1}`), fixedNow) |
| + if err == nil || errors.Is(err, ErrBusy) { |
| + t.Fatalf("invalid-name err = %v, want a non-ErrBusy failure", err) |
| + } |
| +} |
diff --git a/internal/ledger/blob.go b/internal/ledger/blob.go| new file mode 100644 |
| index 000000000..746bc127f |
| --- /dev/null |
| +++ b/internal/ledger/blob.go |
| @@ -0,0 +1,28 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func PutBlob(ctx context.Context, st release.Store, sqid, app, name string, body []byte) (string, error) { |
| + if !ValidName(name) { |
| + return "", fmt.Errorf("ledger: invalid ledger name %q", name) |
| + } |
| + if len(body) > MaxBlob { |
| + return "", fmt.Errorf("ledger: blob %d bytes exceeds cap %d", len(body), MaxBlob) |
| + } |
| + sha := SHA256Hex(body) |
| + if _, err := st.Put(ctx, BlobKey(sqid, app, name, sha), body, ""); err != nil { |
| + // Content-addressed: an existing object with this key IS this |
| + // content. A create-only conflict means it's already there. |
| + if errors.Is(err, release.ErrCASConflict) { |
| + return sha, nil |
| + } |
| + return "", err |
| + } |
| + return sha, nil |
| +} |
diff --git a/internal/ledger/blob_test.go b/internal/ledger/blob_test.go| new file mode 100644 |
| index 000000000..5c1818b8c |
| --- /dev/null |
| +++ b/internal/ledger/blob_test.go |
| @@ -0,0 +1,37 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestPutBlobIdempotent(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + body := []byte("redacted corpus bytes") |
| + sha1, err := PutBlob(ctx, st, "bab", "platform", "prompts", body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sha2, err := PutBlob(ctx, st, "bab", "platform", "prompts", body) |
| + if err != nil { |
| + t.Fatalf("re-upload of identical content errored: %v", err) |
| + } |
| + if sha1 != sha2 || sha1 != SHA256Hex(body) { |
| + t.Fatalf("sha mismatch: %s %s", sha1, sha2) |
| + } |
| + got, _, err := st.Get(ctx, BlobKey("bab", "platform", "prompts", sha1)) |
| + if err != nil || string(got) != string(body) { |
| + t.Fatalf("stored blob: %q %v", got, err) |
| + } |
| +} |
| + |
| +func TestPutBlobRejectsOversize(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + if _, err := PutBlob(context.Background(), st, "bab", "platform", "prompts", |
| + make([]byte, MaxBlob+1)); err == nil { |
| + t.Fatal("oversized blob accepted") |
| + } |
| +} |
diff --git a/internal/ledger/ledger.go b/internal/ledger/ledger.go| new file mode 100644 |
| index 000000000..47d1f23ad |
| --- /dev/null |
| +++ b/internal/ledger/ledger.go |
| @@ -0,0 +1,84 @@ |
| +// Package ledger — the open-ledger stream primitive: append-only, |
| +// hash-chained public JSON entries in the deployment bucket, keyed by |
| +// (account, app) exactly as the fleet page groups routes. The chain |
| +// hashes STORED entry bytes verbatim: the writer marshals once and the |
| +// hash commits to exactly those bytes, so verification needs no |
| +// canonicalization pass in any language. |
| +// Spec: docs/superpowers/specs/2026-08-03-open-ledgers-design.md §2. |
| +package ledger |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "regexp" |
| + "strings" |
| +) |
| + |
| +const ( |
| + Schema = 1 |
| + MaxPayload = 64 << 10 |
| + MaxBlob = 4 << 20 |
| +) |
| + |
| +type Entry struct { |
| + Schema int `json:"schema"` |
| + Ledger string `json:"ledger"` |
| + Writer string `json:"writer"` |
| + TS string `json:"ts"` |
| + Payload json.RawMessage `json:"payload"` |
| + PrevKey string `json:"prev_key"` |
| + PrevSHA256 string `json:"prev_sha256"` |
| +} |
| + |
| +type Head struct { |
| + Schema int `json:"schema"` |
| + UpdatedAt string `json:"updated_at"` |
| + HeadKey string `json:"head_key"` |
| + HeadSHA256 string `json:"head_sha256"` |
| + Count int `json:"count"` |
| +} |
| + |
| +type Manifest struct { |
| + Schema int `json:"schema"` |
| + Public bool `json:"public"` |
| + Ledgers []string `json:"ledgers"` |
| + Title string `json:"title"` |
| +} |
| + |
| +var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) |
| + |
| +func ValidName(s string) bool { |
| + return nameRe.MatchString(s) |
| +} |
| + |
| +func Prefix(sqid, app, name string) string { |
| + return "ledgers/" + sqid + "/" + app + "/" + name + "/" |
| +} |
| + |
| +func HeadKey(sqid, app, name string) string { |
| + return Prefix(sqid, app, name) + "HEAD.json" |
| +} |
| + |
| +func ManifestKey(sqid, app string) string { |
| + return "ledgers/" + sqid + "/" + app + "/ledgers.json" |
| +} |
| + |
| +func BlobKey(sqid, app, name, sha string) string { |
| + return Prefix(sqid, app, name) + "blobs/" + sha |
| +} |
| + |
| +// tsCompact strips the separators RFC3339 puts in a UTC timestamp so entry |
| +// keys sort chronologically as plain strings. |
| +func tsCompact(ts string) string { |
| + return strings.NewReplacer("-", "", ":", "").Replace(ts) |
| +} |
| + |
| +func EntryKey(sqid, app, name, ts, sha string) string { |
| + return Prefix(sqid, app, name) + "entries/" + tsCompact(ts) + "-" + sha[:12] + ".json" |
| +} |
| + |
| +func SHA256Hex(b []byte) string { |
| + sum := sha256.Sum256(b) |
| + return hex.EncodeToString(sum[:]) |
| +} |
diff --git a/internal/ledger/ledger_test.go b/internal/ledger/ledger_test.go| new file mode 100644 |
| index 000000000..9bba8214c |
| --- /dev/null |
| +++ b/internal/ledger/ledger_test.go |
| @@ -0,0 +1,37 @@ |
| +package ledger |
| + |
| +import "testing" |
| + |
| +func TestValidName(t *testing.T) { |
| + for _, ok := range []string{"carbon", "a", "prompt-registry", "x9"} { |
| + if !ValidName(ok) { |
| + t.Errorf("ValidName(%q) = false, want true", ok) |
| + } |
| + } |
| + for _, bad := range []string{"", "Carbon", "-x", "a_b", "a/b", "café", |
| + "0123456789012345678901234567890123456789012345678901234567890123x"} { |
| + if ValidName(bad) { |
| + t.Errorf("ValidName(%q) = true, want false", bad) |
| + } |
| + } |
| +} |
| + |
| +func TestKeys(t *testing.T) { |
| + if got, want := HeadKey("bab", "platform", "carbon"), |
| + "ledgers/bab/platform/carbon/HEAD.json"; got != want { |
| + t.Errorf("HeadKey = %q, want %q", got, want) |
| + } |
| + if got, want := ManifestKey("bab", "platform"), |
| + "ledgers/bab/platform/ledgers.json"; got != want { |
| + t.Errorf("ManifestKey = %q, want %q", got, want) |
| + } |
| + if got, want := BlobKey("bab", "platform", "prompts", "abc123"), |
| + "ledgers/bab/platform/prompts/blobs/abc123"; got != want { |
| + t.Errorf("BlobKey = %q, want %q", got, want) |
| + } |
| + sha := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" |
| + if got, want := EntryKey("bab", "platform", "carbon", "2026-08-03T21:04:00Z", sha), |
| + "ledgers/bab/platform/carbon/entries/20260803T210400Z-0123456789ab.json"; got != want { |
| + t.Errorf("EntryKey = %q, want %q", got, want) |
| + } |
| +} |
diff --git a/internal/ledger/manifest.go b/internal/ledger/manifest.go| new file mode 100644 |
| index 000000000..60c54a8c9 |
| --- /dev/null |
| +++ b/internal/ledger/manifest.go |
| @@ -0,0 +1,98 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func ReadManifest(ctx context.Context, st release.Store, sqid, app string) (Manifest, error) { |
| + b, _, err := st.Get(ctx, ManifestKey(sqid, app)) |
| + if err != nil { |
| + return Manifest{}, err |
| + } |
| + var m Manifest |
| + if err := json.Unmarshal(b, &m); err != nil { |
| + return Manifest{}, err |
| + } |
| + return m, nil |
| +} |
| + |
| +// WriteManifest replaces the whole manifest: read-etag-then-Put with one |
| +// retry on a lost race, last-writer-wins (the putOverwrite pattern, |
| +// internal/status/fleet.go). It is the right call for a caller that HAS the |
| +// whole intended value — a seeded fixture, a box-side tool writing the set |
| +// it just computed. A caller changing one ledger's publication wants |
| +// UpdateManifest instead: replacing the whole value there unpublishes |
| +// everything the caller didn't happen to name. |
| +func WriteManifest(ctx context.Context, st release.Store, sqid, app string, m Manifest) error { |
| + _, err := UpdateManifest(ctx, st, sqid, app, func(cur *Manifest) error { |
| + *cur = m |
| + return nil |
| + }) |
| + return err |
| +} |
| + |
| +// manifestAttempts bounds UpdateManifest's CAS retry loop, matching |
| +// appendAttempts: publish contention is two contributors publishing the same |
| +// app in the same moment, and four attempts outlast that without spinning. |
| +const manifestAttempts = 4 |
| + |
| +// UpdateManifest is the manifest's read-modify-write door: it reads the |
| +// stored manifest (a missing one is the ZERO value, not an error — the first |
| +// publish of an app creates it), hands it to apply, and CASes the result |
| +// back on the etag it read. A lost race re-READS and re-APPLIES rather than |
| +// re-putting a stale copy, which is the whole point: the manifest is a SET |
| +// accrued across separate publish calls, so "carbon and prompts published at |
| +// the same moment" must end with both, not with whichever writer was slower. |
| +// |
| +// Contention that outlasts the retries surfaces as release.ErrCASConflict |
| +// (the console maps it to a retry-worthy 503) — never as a silent no-op. |
| +// |
| +// apply may REFUSE: an error from it aborts the attempt BEFORE the Put and is |
| +// returned untouched, so nothing is written. That is what lets a caller |
| +// enforce a rule the request alone cannot decide — a cap on the RESULTING set |
| +// — on whatever manifest each attempt actually read, including the re-read |
| +// after a lost race, rather than on a snapshot that a concurrent publisher |
| +// has already moved past. |
| +func UpdateManifest(ctx context.Context, st release.Store, sqid, app string, |
| + apply func(*Manifest) error) (Manifest, error) { |
| + key := ManifestKey(sqid, app) |
| + var lastErr error |
| + for range manifestAttempts { |
| + var m Manifest |
| + b, etag, err := st.Get(ctx, key) |
| + ifMatch := etag |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + ifMatch = "" // create-only: no manifest yet |
| + case err != nil: |
| + return Manifest{}, err |
| + default: |
| + if err := json.Unmarshal(b, &m); err != nil { |
| + return Manifest{}, err |
| + } |
| + } |
| + |
| + if err := apply(&m); err != nil { |
| + return Manifest{}, err |
| + } |
| + m.Schema = Schema |
| + body, err := json.Marshal(m) |
| + if err != nil { |
| + return Manifest{}, err |
| + } |
| + if _, err := st.Put(ctx, key, body, ifMatch); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + lastErr = err |
| + continue |
| + } |
| + return Manifest{}, err |
| + } |
| + return m, nil |
| + } |
| + return Manifest{}, lastErr |
| +} |
diff --git a/internal/ledger/manifest_test.go b/internal/ledger/manifest_test.go| new file mode 100644 |
| index 000000000..d7313defa |
| --- /dev/null |
| +++ b/internal/ledger/manifest_test.go |
| @@ -0,0 +1,168 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "slices" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestManifestRoundTrip(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if _, err := ReadManifest(ctx, st, "bab", "platform"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("missing manifest: %v, want os.ErrNotExist", err) |
| + } |
| + m := Manifest{Schema: Schema, Public: true, Ledgers: []string{"carbon"}, Title: "CARLOS"} |
| + if err := WriteManifest(ctx, st, "bab", "platform", m); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := ReadManifest(ctx, st, "bab", "platform") |
| + if err != nil || !got.Public || got.Title != "CARLOS" || len(got.Ledgers) != 1 { |
| + t.Fatalf("%+v %v", got, err) |
| + } |
| + // Overwrite flips public off — WriteManifest must replace, not create-only. |
| + m.Public = false |
| + if err := WriteManifest(ctx, st, "bab", "platform", m); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _ = ReadManifest(ctx, st, "bab", "platform") |
| + if got.Public { |
| + t.Fatal("manifest overwrite did not take") |
| + } |
| +} |
| + |
| +// TestUpdateManifestReadModifyWrite: the manifest is a SET of published |
| +// ledgers accrued over many separate publish calls, so the write path that |
| +// serves those calls must read-modify-write. A whole-value replace here is |
| +// what silently unpublishes an app's other ledgers. |
| +func TestUpdateManifestReadModifyWrite(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + |
| + // Missing manifest is the zero value, not an error: the first publish |
| + // of an app creates it. |
| + m, err := UpdateManifest(ctx, st, "bab", "platform", func(m *Manifest) error { |
| + m.Public = true |
| + m.Ledgers = append(m.Ledgers, "carbon") |
| + m.Title = "CARLOS" |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatalf("first update: %v", err) |
| + } |
| + if !m.Public || m.Schema != Schema || m.Title != "CARLOS" || !slices.Equal(m.Ledgers, []string{"carbon"}) { |
| + t.Fatalf("first update = %+v, want the created manifest", m) |
| + } |
| + |
| + // A second update sees the first one's state. |
| + m, err = UpdateManifest(ctx, st, "bab", "platform", func(m *Manifest) error { |
| + m.Ledgers = append(m.Ledgers, "prompts") |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatalf("second update: %v", err) |
| + } |
| + if !slices.Equal(m.Ledgers, []string{"carbon", "prompts"}) || m.Title != "CARLOS" { |
| + t.Fatalf("second update = %+v, want carbon preserved beside prompts", m) |
| + } |
| + stored, err := ReadManifest(ctx, st, "bab", "platform") |
| + if err != nil || !slices.Equal(stored.Ledgers, []string{"carbon", "prompts"}) { |
| + t.Fatalf("stored = %+v %v, want what the update returned", stored, err) |
| + } |
| +} |
| + |
| +// conflictOnce loses the first ledgers.json CAS race to a writer that landed |
| +// a DIFFERENT manifest — the shape that tells a retry apart from a re-apply. |
| +// A loop that retried with its own stale copy would erase "iron". |
| +type conflictOnce struct { |
| + release.Store |
| + lost bool |
| +} |
| + |
| +func (c *conflictOnce) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if strings.HasSuffix(key, "/ledgers.json") && !c.lost { |
| + c.lost = true |
| + if _, err := c.Store.Put(ctx, key, []byte(`{"schema":1,"public":true,"ledgers":["iron"],"title":"Other"}`), ifMatch); err != nil { |
| + return "", err |
| + } |
| + return "", release.ErrCASConflict |
| + } |
| + return c.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +// TestUpdateManifestCASReapplies: on a lost race the update must re-READ and |
| +// re-APPLY, not re-Put its stale copy — otherwise two contributors publishing |
| +// two ledgers at once is a lost update, and the loser never learns. |
| +func TestUpdateManifestCASReapplies(t *testing.T) { |
| + st := &conflictOnce{Store: release.NewDirStore(t.TempDir())} |
| + m, err := UpdateManifest(context.Background(), st, "bab", "platform", func(m *Manifest) error { |
| + m.Public = true |
| + if !slices.Contains(m.Ledgers, "carbon") { |
| + m.Ledgers = append(m.Ledgers, "carbon") |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatalf("update: %v", err) |
| + } |
| + if !st.lost { |
| + t.Fatal("the fake never forced a CAS conflict") |
| + } |
| + if !slices.Equal(m.Ledgers, []string{"iron", "carbon"}) { |
| + t.Fatalf("after a lost race = %+v, want the winner's iron preserved beside carbon", m) |
| + } |
| + if m.Title != "Other" { |
| + t.Fatalf("title = %q, want the winner's title preserved", m.Title) |
| + } |
| +} |
| + |
| +// TestUpdateManifestSurfacesLostRaces: contention that outlasts the retries |
| +// is reported, never swallowed — the console maps it to a retry-worthy 503. |
| +func TestUpdateManifestSurfacesLostRaces(t *testing.T) { |
| + st := alwaysConflict{release.NewDirStore(t.TempDir())} |
| + if _, err := UpdateManifest(context.Background(), st, "bab", "platform", func(m *Manifest) error { |
| + m.Public = true |
| + return nil |
| + }); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("update under total contention = %v, want a CAS conflict", err) |
| + } |
| +} |
| + |
| +// TestUpdateManifestApplyRefusalWritesNothing: apply is where a caller |
| +// enforces a rule only the STORED manifest can decide (the console's cap on |
| +// how many ledgers an app may publish). A refusal has to abort before the Put |
| +// — a loop that wrote first and reported the refusal afterwards would store |
| +// exactly the state it just called illegal. |
| +func TestUpdateManifestApplyRefusalWritesNothing(t *testing.T) { |
| + ctx := context.Background() |
| + st := release.NewDirStore(t.TempDir()) |
| + if err := WriteManifest(ctx, st, "bab", "platform", |
| + Manifest{Public: true, Ledgers: []string{"carbon"}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + refused := errors.New("no") |
| + if _, err := UpdateManifest(ctx, st, "bab", "platform", func(m *Manifest) error { |
| + m.Ledgers = append(m.Ledgers, "prompts") |
| + return refused |
| + }); !errors.Is(err, refused) { |
| + t.Fatalf("update = %v, want apply's own error returned untouched", err) |
| + } |
| + stored, err := ReadManifest(ctx, st, "bab", "platform") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !slices.Equal(stored.Ledgers, []string{"carbon"}) { |
| + t.Fatalf("stored = %+v, want the refused change never written", stored) |
| + } |
| +} |
| + |
| +type alwaysConflict struct{ release.Store } |
| + |
| +func (a alwaysConflict) Put(context.Context, string, []byte, string) (string, error) { |
| + return "", release.ErrCASConflict |
| +} |
diff --git a/internal/ledger/verify.go b/internal/ledger/verify.go| new file mode 100644 |
| index 000000000..97cd9debb |
| --- /dev/null |
| +++ b/internal/ledger/verify.go |
| @@ -0,0 +1,99 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type Getter func(ctx context.Context, key string) ([]byte, error) |
| + |
| +func StoreGetter(st release.Store) Getter { |
| + return func(ctx context.Context, key string) ([]byte, error) { |
| + b, _, err := st.Get(ctx, key) |
| + return b, err |
| + } |
| +} |
| + |
| +func HTTPGetter(client *http.Client, base string) Getter { |
| + return func(ctx context.Context, key string) ([]byte, error) { |
| + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/"+key, nil) |
| + if err != nil { |
| + return nil, err |
| + } |
| + resp, err := client.Do(req) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + return nil, fmt.Errorf("ledger: GET %s: %s", key, resp.Status) |
| + } |
| + return io.ReadAll(io.LimitReader(resp.Body, MaxBlob+1)) |
| + } |
| +} |
| + |
| +type VerifyResult struct { |
| + Count int |
| + HeadSHA256 string |
| +} |
| + |
| +// Verify walks HEAD back to the genesis entry, re-hashing every stored |
| +// byte. It proves: HEAD's sha matches the head entry's bytes, every |
| +// prev_sha256 matches the previous entry's bytes, the walk terminates at |
| +// prev_key "", and the walked length equals HEAD's count. |
| +func Verify(ctx context.Context, get Getter, sqid, app, name string) (VerifyResult, error) { |
| + hb, err := get(ctx, HeadKey(sqid, app, name)) |
| + if err != nil { |
| + return VerifyResult{}, err |
| + } |
| + var head Head |
| + if err := json.Unmarshal(hb, &head); err != nil { |
| + return VerifyResult{}, fmt.Errorf("ledger: HEAD corrupt: %w", err) |
| + } |
| + |
| + // The walk follows keys the CHAIN supplies (HEAD.json's head_key, then |
| + // each entry's prev_key), so those keys are exactly as trustworthy as the |
| + // bytes under verification. Pinning them to this ledger's own entries/ |
| + // prefix is what stops a chain from grafting another ledger's history — |
| + // or any other object in the bucket — onto its own and still verifying |
| + // clean: every hash would agree, because the hashes are only ever |
| + // checked against what the chain itself claims. |
| + want := Prefix(sqid, app, name) + "entries/" |
| + |
| + key, wantSHA := head.HeadKey, head.HeadSHA256 |
| + walked := 0 |
| + for key != "" { |
| + if walked >= head.Count { |
| + return VerifyResult{}, fmt.Errorf("ledger: chain longer than HEAD count %d", head.Count) |
| + } |
| + if !strings.HasPrefix(key, want) { |
| + return VerifyResult{}, fmt.Errorf("ledger: %s: chain leaves ledger %q — key is outside %s", key, name, want) |
| + } |
| + eb, err := get(ctx, key) |
| + if err != nil { |
| + return VerifyResult{}, fmt.Errorf("ledger: %s: %w", key, err) |
| + } |
| + if got := SHA256Hex(eb); got != wantSHA { |
| + return VerifyResult{}, fmt.Errorf("ledger: %s: sha %s, chain says %s — bytes were altered", key, got, wantSHA) |
| + } |
| + var e Entry |
| + if err := json.Unmarshal(eb, &e); err != nil { |
| + return VerifyResult{}, fmt.Errorf("ledger: %s corrupt: %w", key, err) |
| + } |
| + if e.Ledger != name { |
| + return VerifyResult{}, fmt.Errorf("ledger: %s: entry names ledger %q, walking %q", key, e.Ledger, name) |
| + } |
| + key, wantSHA = e.PrevKey, e.PrevSHA256 |
| + walked++ |
| + } |
| + if walked != head.Count { |
| + return VerifyResult{}, fmt.Errorf("ledger: walked %d entries, HEAD count %d", walked, head.Count) |
| + } |
| + return VerifyResult{Count: walked, HeadSHA256: head.HeadSHA256}, nil |
| +} |
diff --git a/internal/ledger/verify_test.go b/internal/ledger/verify_test.go| new file mode 100644 |
| index 000000000..4139c2fab |
| --- /dev/null |
| +++ b/internal/ledger/verify_test.go |
| @@ -0,0 +1,136 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func chainOf(t *testing.T, st release.Store, n int) { |
| + t.Helper() |
| + for i := range n { |
| + at := func() time.Time { return fixedNow().Add(time.Duration(i) * time.Minute) } |
| + if _, err := Append(context.Background(), st, "bab", "platform", "carbon", |
| + "w", json.RawMessage(`{"i":`+string(rune('0'+i))+`}`), at); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +func TestVerifyGoodChain(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + chainOf(t, st, 3) |
| + res, err := Verify(context.Background(), StoreGetter(st), "bab", "platform", "carbon") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if res.Count != 3 { |
| + t.Fatalf("count = %d, want 3", res.Count) |
| + } |
| +} |
| + |
| +func TestVerifyDetectsTamper(t *testing.T) { |
| + dir := t.TempDir() |
| + st := release.NewDirStore(dir) |
| + chainOf(t, st, 3) |
| + // Flip one byte in the OLDEST entry's stored file, behind the store's |
| + // back — exactly the mutation a hash chain exists to catch. |
| + keys, err := st.List(context.Background(), Prefix("bab", "platform", "carbon")+"entries/") |
| + if err != nil || len(keys) != 3 { |
| + t.Fatalf("entries: %v %v", keys, err) |
| + } |
| + p := filepath.Join(dir, filepath.FromSlash(keys[0])) |
| + b, err := os.ReadFile(p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b[len(b)-2] ^= 1 |
| + if err := os.WriteFile(p, b, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := Verify(context.Background(), StoreGetter(st), "bab", "platform", "carbon"); err == nil { |
| + t.Fatal("tampered chain verified clean") |
| + } |
| +} |
| + |
| +// TestVerifyPinsTheWalkToItsOwnLedger: a verifier follows prev_key, and |
| +// prev_key is bytes the chain itself supplies — so a chain that points its |
| +// walk at ANOTHER ledger's entries (or at an object outside the ledger |
| +// prefix entirely) must fail, not quietly borrow that object's history and |
| +// report a clean verification. The count and the hashes can all be made to |
| +// agree; what cannot be forged is the entry's own ledger name and the prefix |
| +// its keys live under. |
| +func TestVerifyPinsTheWalkToItsOwnLedger(t *testing.T) { |
| + ctx := context.Background() |
| + st := release.NewDirStore(t.TempDir()) |
| + // A real two-entry chain in "iron", whose genesis we will graft onto a |
| + // hand-built "carbon" chain. |
| + if _, err := Append(ctx, st, "bab", "platform", "iron", "w", |
| + json.RawMessage(`{"i":0}`), fixedNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + ib, _, err := st.Get(ctx, HeadKey("bab", "platform", "iron")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var ih Head |
| + if err := json.Unmarshal(ib, &ih); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // carbon's single entry chains to iron's genesis: every hash checks out, |
| + // the walk terminates, and the count matches. |
| + e := Entry{Schema: Schema, Ledger: "carbon", Writer: "w", TS: "2026-08-03T21:00:00Z", |
| + Payload: json.RawMessage(`{"i":1}`), PrevKey: ih.HeadKey, PrevSHA256: ih.HeadSHA256} |
| + eb, err := json.Marshal(e) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sha := SHA256Hex(eb) |
| + key := EntryKey("bab", "platform", "carbon", e.TS, sha) |
| + if _, err := st.Put(ctx, key, eb, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + hb, err := json.Marshal(Head{Schema: Schema, UpdatedAt: e.TS, HeadKey: key, HeadSHA256: sha, Count: 2}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := st.Put(ctx, HeadKey("bab", "platform", "carbon"), hb, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + _, err = Verify(ctx, StoreGetter(st), "bab", "platform", "carbon") |
| + if err == nil { |
| + t.Fatal("a chain walking into another ledger's entries verified clean") |
| + } |
| + if !strings.Contains(err.Error(), "carbon") { |
| + t.Fatalf("error = %v, want it to name the ledger the walk left", err) |
| + } |
| +} |
| + |
| +func TestVerifyOverHTTP(t *testing.T) { |
| + dir := t.TempDir() |
| + st := release.NewDirStore(dir) |
| + chainOf(t, st, 2) |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + b, _, err := st.Get(r.Context(), strings.TrimPrefix(r.URL.Path, "/")) |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Write(b) |
| + })) |
| + defer ts.Close() |
| + res, err := Verify(context.Background(), HTTPGetter(ts.Client(), ts.URL), "bab", "platform", "carbon") |
| + if err != nil || res.Count != 2 { |
| + t.Fatalf("res=%+v err=%v", res, err) |
| + } |
| +} |
diff --git a/internal/release/qualified_test.go b/internal/release/qualified_test.go| index 50fe5071c..03dd8ebbf 100644 |
| --- a/internal/release/qualified_test.go |
| +++ b/internal/release/qualified_test.go |
| @@ -23,8 +23,9 @@ func (e *erroringStore) Get(context.Context, string) ([]byte, string, error) { |
| func (e *erroringStore) Put(context.Context, string, []byte, string) (string, error) { |
| return "", e.err |
| } |
| -func (e *erroringStore) List(context.Context, string) ([]string, error) { return nil, e.err } |
| -func (e *erroringStore) Delete(context.Context, string) error { return e.err } |
| +func (e *erroringStore) List(context.Context, string) ([]string, error) { return nil, e.err } |
| +func (e *erroringStore) ListDirs(context.Context, string) ([]string, error) { return nil, e.err } |
| +func (e *erroringStore) Delete(context.Context, string) error { return e.err } |
| // legacyTripwire fails the test if anything asks the store for a key or |
| // prefix under one app's LEGACY unqualified prefix. It is how "the runtime |
diff --git a/internal/release/scopedpromote_test.go b/internal/release/scopedpromote_test.go| index b9ced5060..fe89f1767 100644 |
| --- a/internal/release/scopedpromote_test.go |
| +++ b/internal/release/scopedpromote_test.go |
| @@ -29,6 +29,11 @@ func (s trapStore) List(_ context.Context, prefix string) ([]string, error) { |
| return nil, nil |
| } |
| +func (s trapStore) ListDirs(_ context.Context, prefix string) ([]string, error) { |
| + s.t.Fatalf("out-of-scope promote listed %s — it must refuse before touching the store", prefix) |
| + return nil, nil |
| +} |
| + |
| func (s trapStore) Delete(_ context.Context, key string) error { |
| s.t.Fatalf("out-of-scope promote DELETED %s — the scope check is not a gate", key) |
| return nil |
diff --git a/internal/release/store.go b/internal/release/store.go| index 094c63427..f8b5cbd7d 100644 |
| --- a/internal/release/store.go |
| +++ b/internal/release/store.go |
| @@ -40,6 +40,14 @@ type Store interface { |
| Get(ctx context.Context, key string) ([]byte, string, error) // body, etag |
| Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) // new etag |
| List(ctx context.Context, prefix string) ([]string, error) |
| + // ListDirs returns the immediate child "directories" of prefix — the |
| + // delimiter-aware listing, so a caller that only wants the names one |
| + // level down does not pay for every object beneath them. Each result is |
| + // a FULL prefix ending in "/" (S3 CommonPrefixes semantics), never a |
| + // bare name. A prefix that does not end in "/" is treated as though it |
| + // did, so the two implementations answer identically. Objects at the |
| + // level itself (files, not directories) are never included. |
| + ListDirs(ctx context.Context, prefix string) ([]string, error) |
| Delete(ctx context.Context, key string) error |
| } |
| @@ -158,6 +166,42 @@ func (d *DirStore) List(_ context.Context, prefix string) ([]string, error) { |
| return out, err |
| } |
| +// ListDirs is DirStore's delimiter-aware listing: one directory read, no |
| +// walk. A missing directory lists empty, like a bucket prefix with nothing |
| +// under it. Only directories are returned, so a file sitting at this level |
| +// (ledgers.json among them) is never mistaken for a child prefix. |
| +func (d *DirStore) ListDirs(_ context.Context, prefix string) ([]string, error) { |
| + if prefix != "" && !strings.HasSuffix(prefix, "/") { |
| + prefix += "/" |
| + } |
| + dir := d.root |
| + if trimmed := strings.TrimSuffix(prefix, "/"); trimmed != "" { |
| + p, err := d.path(trimmed) |
| + if err != nil { |
| + return nil, err |
| + } |
| + dir = p |
| + } |
| + d.mu.Lock() |
| + defer d.mu.Unlock() |
| + ents, err := os.ReadDir(dir) |
| + if err != nil { |
| + if errors.Is(err, fs.ErrNotExist) { |
| + return nil, nil |
| + } |
| + return nil, err |
| + } |
| + var out []string |
| + for _, e := range ents { |
| + if !e.IsDir() { |
| + continue |
| + } |
| + out = append(out, prefix+e.Name()+"/") |
| + } |
| + sort.Strings(out) |
| + return out, nil |
| +} |
| + |
| func (d *DirStore) Delete(_ context.Context, key string) error { |
| p, err := d.path(key) |
| if err != nil { |
| @@ -297,6 +341,33 @@ func (s *S3Store) List(ctx context.Context, prefix string) ([]string, error) { |
| return out, nil |
| } |
| +// ListDirs is the Delimiter:"/" listing: S3 rolls everything below one level |
| +// up into a CommonPrefix, so the response is bounded by the number of child |
| +// prefixes rather than by the total object count beneath them. Objects AT |
| +// this level come back in Contents, which this deliberately ignores. |
| +func (s *S3Store) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| + if prefix != "" && !strings.HasSuffix(prefix, "/") { |
| + prefix += "/" |
| + } |
| + var out []string |
| + p := awss3.NewListObjectsV2Paginator(s.Client, &awss3.ListObjectsV2Input{ |
| + Bucket: &s.Bucket, |
| + Prefix: &prefix, |
| + Delimiter: aws.String("/"), |
| + }) |
| + for p.HasMorePages() { |
| + page, err := p.NextPage(ctx) |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, cp := range page.CommonPrefixes { |
| + out = append(out, aws.ToString(cp.Prefix)) |
| + } |
| + } |
| + sort.Strings(out) |
| + return out, nil |
| +} |
| + |
| func (s *S3Store) Delete(ctx context.Context, key string) error { |
| _, err := s.Client.DeleteObject(ctx, &awss3.DeleteObjectInput{ |
| Bucket: aws.String(s.Bucket), |
diff --git a/internal/release/store_test.go b/internal/release/store_test.go| index a8c2bd78c..a7a5f7726 100644 |
| --- a/internal/release/store_test.go |
| +++ b/internal/release/store_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "errors" |
| "fmt" |
| "os" |
| + "strings" |
| "testing" |
| "time" |
| ) |
| @@ -112,6 +113,65 @@ func TestDirStoreDelete(t *testing.T) { |
| } |
| } |
| +// TestDirStoreListDirs: the delimiter-aware listing returns the immediate |
| +// child prefixes and nothing else — not the objects beneath them (which is |
| +// the whole point: cost bounded by the number of children, not by their |
| +// histories), and not the FILES sitting at the same level, which is what |
| +// keeps a ledger's ledgers.json out of its own list of ledgers. |
| +func TestDirStoreListDirs(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + for _, k := range []string{ |
| + "ledgers/bab/titogo/carbon/HEAD.json", |
| + "ledgers/bab/titogo/carbon/entries/20260803T000000Z-aaaaaaaaaaaa.json", |
| + "ledgers/bab/titogo/carbon/entries/20260803T000001Z-bbbbbbbbbbbb.json", |
| + "ledgers/bab/titogo/carbon/blobs/deadbeef", |
| + "ledgers/bab/titogo/prompts/HEAD.json", |
| + "ledgers/bab/titogo/ledgers.json", // a FILE at this level |
| + "ledgers/bab/otherapp/elsewhere/HEAD.json", |
| + } { |
| + if _, err := s.Put(ctx, k, []byte(`{}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + got, err := s.ListDirs(ctx, "ledgers/bab/titogo/") |
| + if err != nil { |
| + t.Fatalf("ListDirs: %v", err) |
| + } |
| + want := []string{"ledgers/bab/titogo/carbon/", "ledgers/bab/titogo/prompts/"} |
| + if len(got) != len(want) { |
| + t.Fatalf("ListDirs = %v, want %v (no files, no grandchildren)", got, want) |
| + } |
| + for i := range want { |
| + if got[i] != want[i] { |
| + t.Fatalf("ListDirs = %v, want %v", got, want) |
| + } |
| + } |
| + // A prefix without the trailing slash answers identically — the two |
| + // implementations must not disagree about that. |
| + bare, err := s.ListDirs(ctx, "ledgers/bab/titogo") |
| + if err != nil || len(bare) != len(want) || bare[0] != want[0] { |
| + t.Fatalf("ListDirs without a trailing slash = %v, %v; want %v", bare, err, want) |
| + } |
| + // One level up sees the apps, not the chains under them. |
| + up, err := s.ListDirs(ctx, "ledgers/bab/") |
| + if err != nil || len(up) != 2 || up[0] != "ledgers/bab/otherapp/" || up[1] != "ledgers/bab/titogo/" { |
| + t.Fatalf("ListDirs one level up = %v, %v", up, err) |
| + } |
| + // A prefix with nothing under it lists empty, like an empty bucket |
| + // prefix — not an error. |
| + empty, err := s.ListDirs(ctx, "ledgers/nosuch/") |
| + if err != nil || len(empty) != 0 { |
| + t.Fatalf("ListDirs of a missing prefix = %v, %v; want empty and no error", empty, err) |
| + } |
| + // The root lists the top-level prefixes. |
| + root, err := s.ListDirs(ctx, "") |
| + if err != nil || len(root) != 1 || root[0] != "ledgers/" { |
| + t.Fatalf("ListDirs of the root = %v, %v; want [ledgers/]", root, err) |
| + } |
| +} |
| + |
| // TestS3StoreLive exercises the production store against a real bucket — |
| // only when CARLOS_TEST_S3_BUCKET is set; the type otherwise just compiles. |
| func TestS3StoreLive(t *testing.T) { |
| @@ -152,4 +212,31 @@ func TestS3StoreLive(t *testing.T) { |
| if err != nil || len(keys) != 1 || keys[0] != key { |
| t.Fatalf("List = %v, %v; want [%s]", keys, err, key) |
| } |
| + |
| + // ListDirs against the same live bucket: the object written above sits |
| + // one level below its parent prefix, so the parent's parent must see the |
| + // child PREFIX (CommonPrefixes) and not the object itself. |
| + parent := key[:strings.LastIndex(key, "/")+1] // .../<nanos>/ |
| + grandparent := parent[:strings.LastIndex(parent[:len(parent)-1], "/")+1] |
| + dirs, err := s.ListDirs(ctx, grandparent) |
| + if err != nil { |
| + t.Fatalf("ListDirs: %v", err) |
| + } |
| + found := false |
| + for _, d := range dirs { |
| + if d == parent { |
| + found = true |
| + } |
| + if d == key { |
| + t.Fatalf("ListDirs returned an OBJECT key %q, want only child prefixes", d) |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("ListDirs(%q) = %v, want %q among them", grandparent, dirs, parent) |
| + } |
| + // And at the object's own level there are no child prefixes at all. |
| + leaf, err := s.ListDirs(ctx, parent) |
| + if err != nil || len(leaf) != 0 { |
| + t.Fatalf("ListDirs(%q) = %v, %v; want empty (the only child is an object)", parent, leaf, err) |
| + } |
| } |
diff --git a/main.go b/main.go| index eee1262a7..1ad7f2335 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -41,6 +41,8 @@ func main() { |
| runChannels(args) |
| case "rollback": |
| runRollback(args) |
| + case "ledger": |
| + RunLedger(args) |
| case "auth": |
| RunAuth(args) |
| case "accounts": |
| @@ -98,6 +100,12 @@ commands: |
| ship | promote | channels publish and promote releases (-account <sqid|name>) |
| promote climbs the ladder canary -> edge -> beta |
| -> stable; -hotfix is the recorded bypass |
| + ledger append | head | list | publish | blob | verify — |
| + the open-ledger primitive (carlos ledger -h for |
| + the full usage); every sub but "verify -url" |
| + goes through the console API, never bucket |
| + credentials — verify -url needs no credentials |
| + at all |
| release-keygen mint the deployment's root signing key, or with |
| -scope <rungs> -name <label> [-expires <dur>] a |
| SCOPED key that can only sign those rungs |