carlos / platform Public

console: the self-serve gate: authenticate, bind, authorise, then the rails

selfServeGate runs apiAccountAppSaying (main's sentences, binding SQL only),
the hostname's shape, moveCapable (a move-capable instance token of exactly
this account and app), then the instance gate's rate limiter and
no-bucket 503, now factored into instanceRails and shared with
instanceGateSaying. Config.SelfServeMoves and selfServeMode() (the switch on
a claims-off, Phase 1 off console; Task 4 adds recovery mode).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 78c8a879900ae719b259b450b9dadce334cd2560 parent a8833c6
3 files changed, +100 −12
  • internal/console/apiinstances.go +3 −12
  • internal/console/selfserve_gate.go +93 −0
  • internal/console/server.go +4 −0
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 64ffce062..66a5fbdff 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -89,18 +89,9 @@ func (s *Server) instanceGateSaying(w http.ResponseWriter, r *http.Request, noun
if !ok {
return "", nil, "", false
}
- // Keyed on the TOKEN, not the address (see cliInstanceBurst): the
- // principal here is frequently an app, which has no address of its own,
- // and a signup burst is the workload rather than the abuse.
- if !s.cli.instances.allow(hashToken(bearer(r)), time.Now()) {
- jsonError(w, http.StatusTooManyRequests, c.tooMany(noun))
- return "", nil, "", false
- }
- // A console with no deployment bucket configured can answer none of
- // these. Same shape as promote's signing-key check, and in the same
- // place: after the gate, before anything reaches for the store.
- if s.cfg.Releases == nil {
- jsonError(w, http.StatusServiceUnavailable, c.noBucket)
+ // The budget, then the bucket (instanceRails, shared with the
+ // self-serve move gate).
+ if !s.instanceRails(w, r, acct, noun, c) {
return "", nil, "", false
}
return principal, acct, app, true
diff --git a/internal/console/selfserve_gate.go b/internal/console/selfserve_gate.go
new file mode 100644
index 000000000..abbe666a8
--- /dev/null
+++ b/internal/console/selfserve_gate.go
@@ -0,0 +1,93 @@
+package console
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// The self-serve move doors' front half (self-serve moves plan 2, Task 1;
+// spec §5). In self-serve mode the three move mutations (request, cut,
+// abort) admit one credential only: an unrevoked instance token of exactly
+// this account and app that an account owner minted move-capable. Anything
+// else gets the move family's byte-identical 404 after authentication and
+// before any move evidence is read.
+
+// selfServeMode reports whether the three move mutation doors are the
+// self-serve doors: the switch is "offline" on a claims-off console with
+// Phase 1 off. Outside it they are the legacy doors, byte for byte. Task 4
+// extends this with recovery mode (an open self-serve row, a pending
+// release, an undelivered event or a reservation found at start).
+func (s *Server) selfServeMode() bool {
+ return s.cfg.SelfServeMoves == "offline" && !s.cfg.HostClaim && !s.cfg.MovesEnabled
+}
+
+// moveCapable reports whether p may ask for a self-serve move of acct's app:
+// a move-capable instance token bound to exactly this account and app. An
+// admin is refused here (operators have the operator doors), and so is a
+// person, a member or owner alike.
+func moveCapable(p Principal, acct *Account, app string) bool {
+ return p.Kind == PrincipalService && p.Role == svcrole.Instance &&
+ p.AccountID == acct.ID && p.App == app && p.Moves
+}
+
+// selfServeGate is the self-serve mutations' gate, in this order:
+//
+// 1. authentication and the account/app binding (apiAccountAppSaying, in
+// main's sentences): an invalid or revoked credential keeps today's
+// answer, and this is the only SQL the gate performs;
+// 2. the hostname's shape, else the family's 404 (as moveGate);
+// 3. moveCapable, else the family's 404;
+// 4. and 5. the instance gate's rails (instanceRails): the rate limiter,
+// then the no-deployment-bucket 503.
+//
+// So an unauthorised caller always gets the 404, even under an exhausted
+// limiter or with no bucket, while an authorised caller keeps the rails.
+// The declared exception: an authorised token with a malformed hostname
+// gets the 404 before the rails, where moveGate would answer 429 or 503
+// first. Nothing here reads the store or any move evidence.
+func (s *Server) selfServeGate(w http.ResponseWriter, r *http.Request) (p Principal, acct *Account, app, host string, ok bool) {
+ c := mainMoveCopy().gate
+ _, acct, app, ok = s.apiAccountAppSaying(w, r, s.store.HasAppIn, c)
+ if !ok {
+ return Principal{}, nil, "", "", false
+ }
+ p, _ = principalFrom(r.Context())
+ host = r.PathValue("host")
+ if !provision.ValidHostShape(host) {
+ http.NotFound(w, r)
+ return Principal{}, nil, "", "", false
+ }
+ if !moveCapable(p, acct, app) {
+ http.NotFound(w, r)
+ return Principal{}, nil, "", "", false
+ }
+ if !s.instanceRails(w, r, acct, "instance", c) {
+ return Principal{}, nil, "", "", false
+ }
+ return p, acct, app, host, true
+}
+
+// instanceRails are the instance gate's rails, applied after a caller is
+// proven: the per-token instance budget, then the deployment bucket. One
+// implementation for instanceGateSaying and selfServeGate, so both speak
+// the same sentences (noun and c) in the same order.
+func (s *Server) instanceRails(w http.ResponseWriter, r *http.Request, acct *Account, noun string, c gateCopy) bool {
+ // Keyed on the TOKEN, not the address (see cliInstanceBurst): the
+ // principal here is frequently an app, which has no address of its own,
+ // and a signup burst is the workload rather than the abuse.
+ if !s.cli.instances.allow(hashToken(bearer(r)), time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, c.tooMany(noun))
+ return false
+ }
+ // A console with no deployment bucket configured can answer none of
+ // these. Same shape as promote's signing-key check, and in the same
+ // place: after the gate, before anything reaches for the store.
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable, c.noBucket)
+ return false
+ }
+ return true
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index c1077a11f..a5e63fb5b 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -632,6 +632,10 @@ type Config struct {
// before Phase 1: the legacy move door answers and nothing new is
// reachable.
MovesEnabled bool
+ // SelfServeMoves (CARLOS_SELF_SERVE_MOVES) is the self-serve offline
+ // moves switch: "offline" turns it on (selfServeMode). Unset, the move
+ // doors answer exactly as they do today.
+ SelfServeMoves string
// AWSAccount (CARLOS_AWS_ACCOUNT) is the deployment's AWS account id,
// the trust anchor the move door checks every box's signed identity
// record against (boxid.Verifier.Account). Unset, no box verifies and