carlos / platform Public

settings-2 task 6: the authorize POST draws the write budget

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 442ada567e0b96f62e5a77115ef4c2ec6bc8735e parent 9d7f410
2 files changed, +45 −1
  • internal/console/apiauthorize.go +9 −1
  • internal/console/apiauthorize_test.go +36 −0
diff --git a/internal/console/apiauthorize.go b/internal/console/apiauthorize.go
index 8a4c642be..25cbd1a9c 100644
--- a/internal/console/apiauthorize.go
+++ b/internal/console/apiauthorize.go
@@ -16,12 +16,20 @@ import (
// shows the digests; POST signs a promotion record for exactly the digest the
// person confirmed, if the manifest still hashes to it. The console signs
// with its root key for an app member. Person-only: a credential the app
-// holds must not vouch for builds.
+// holds must not vouch for builds. The POST draws on the write budget, like
+// resign, before any store work; the GET is a read and does not.
func (s *Server) handleAPIAuthorize(w http.ResponseWriter, r *http.Request) {
addr, acct, app, ok := s.apiAccountApp(w, r)
if !ok {
return
}
+ if r.Method == http.MethodPost {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, textTooManyRequests)
+ return
+ }
+ }
version := r.PathValue("version")
if !release.ValidVersion(version) {
jsonError(w, http.StatusBadRequest, "invalid version")
diff --git a/internal/console/apiauthorize_test.go b/internal/console/apiauthorize_test.go
index fc4aaa1c4..73fdf3f39 100644
--- a/internal/console/apiauthorize_test.go
+++ b/internal/console/apiauthorize_test.go
@@ -3,8 +3,10 @@ package console
import (
"context"
"encoding/json"
+ "errors"
"net/http"
"testing"
+ "time"
"github.com/carlosframework/platform/internal/release"
)
@@ -44,3 +46,37 @@ func TestAuthorizeDoorRefusesServiceCredentials(t *testing.T) {
t.Fatalf("a service credential reached authorize: %d", st)
}
}
+
+// TestAuthorizeDoorSpendsTheWriteBudget: the POST signs a record inside the
+// app's exclusive mutation, so it draws on the per-person write budget, like
+// resign, before any store work. The GET is a read and stays unbudgeted.
+func TestAuthorizeDoorSpendsTheWriteBudget(t *testing.T) {
+ fx := newDigestsFixture(t)
+ if err := fx.store.Delete(context.Background(), release.PromotionRecordKey("bab", "km", "v1")); err != nil {
+ t.Fatal(err)
+ }
+ fx.srv.cli.write = newRateLimiter(1, time.Minute)
+ u := fx.ts.URL + "/api/cli/apps/bab/km/releases/v1/authorize"
+ st, out := getRaw(t, u, fx.member)
+ if st != http.StatusOK {
+ t.Fatalf("plan: %d %s", st, out)
+ }
+ var plan struct {
+ ManifestDigest string `json:"manifest_digest"`
+ }
+ if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.ManifestDigest == "" {
+ t.Fatalf("plan body %s: %v", out, err)
+ }
+ if st, out := postRaw(t, u, map[string]string{"manifest_digest": "00"}, fx.member); st != http.StatusConflict {
+ t.Fatalf("first request: %d %s", st, out)
+ }
+ if st, out := getRaw(t, u, fx.member); st != http.StatusOK {
+ t.Fatalf("a read after the budget is spent: %d %s", st, out)
+ }
+ if st, out := postRaw(t, u, map[string]string{"manifest_digest": plan.ManifestDigest}, fx.member); st != http.StatusTooManyRequests || jsonErrorText(t, out) != textTooManyRequests {
+ t.Fatalf("second request over the budget: %d %s", st, out)
+ }
+ if _, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1"); !errors.Is(err, release.ErrNoPromotionRecord) {
+ t.Fatalf("a refused request wrote a record: %v", err)
+ }
+}