apps create: -control-plane and -build-probe, stamped with the claim
Set in the SAME call that claims the name. Claiming first and flagging second leaves a window in which the production console is an ordinary app any ops member — or any Publish service credential — could roll. Both validated before claimApp, like -place: a refused flag refuses the whole create rather than leaving a claimed name with the wrong shape, and costs no attempt against the per-day create budget. Both echoed in the response (read back from the store, not just trusted from the setters' error returns), so a caller can tell 'claimed and flagged' from 'claimed, flag silently didn't'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+257
−37
apimode.go+25 −9apimode_test.go+6 −6apps.go+57 −11apps_test.go+46 −0deploy.go+1 −1internal/console/apicli.go+49 −10internal/console/apps_claim_test.go+60 −0internal/console/server.go+13 −0
diff --git a/apimode.go b/apimode.go| index fdec85bdb..e675178ed 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -832,12 +832,17 @@ func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, chann |
| // echoes the field at all leaves this at the same "" zero value |
| // encoding/json gives an explicit empty string, which is exactly what makes |
| // it usable as the one signal runAppsCreate needs — see |
| -// placementMismatchErr, apps.go). |
| +// placementMismatchErr, apps.go). ControlPlane and BuildProbe are the same |
| +// kind of echo for the two claim-time flags (Task 3): what the console |
| +// actually stamped on the row, read back after the write, not merely what |
| +// the request asked for. |
| type apiCreateAppResult struct { |
| - Account string `json:"account"` |
| - App string `json:"app"` |
| - URL string `json:"url"` |
| - Placement string `json:"placement"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + URL string `json:"url"` |
| + Placement string `json:"placement"` |
| + ControlPlane bool `json:"control_plane"` |
| + BuildProbe string `json:"build_probe"` |
| } |
| // createApp claims one app name through the console — the API door onto the |
| @@ -852,11 +857,22 @@ type apiCreateAppResult struct { |
| // server-side, plus fleet ownership) before the app is created, so an |
| // invalid or foreign place refuses the whole create rather than silently |
| // creating an unplaced app. |
| -func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string) (apiCreateAppResult, error) { |
| +// |
| +// controlPlane and buildProbe are the claim-time flags Task 3 adds |
| +// (-control-plane, -build-probe): set in the SAME call that claims the |
| +// name, so there is no window in which the freshly-claimed row is an |
| +// ordinary app before it is flagged. controlPlane costs an owner-only 403 |
| +// (server-side; there is no client-side role check possible here) and |
| +// buildProbe is grammar-checked client-side before this is ever called |
| +// (validBuildProbe, apps.go), same "a shape the server is certain to |
| +// refuse should cost nothing" principle as place. |
| +func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string, controlPlane bool, buildProbe string) (apiCreateAppResult, error) { |
| in := struct { |
| - Name string `json:"name"` |
| - Place string `json:"place,omitempty"` |
| - }{app, place} |
| + Name string `json:"name"` |
| + Place string `json:"place,omitempty"` |
| + ControlPlane bool `json:"control_plane,omitempty"` |
| + BuildProbe string `json:"build_probe,omitempty"` |
| + }{app, place, controlPlane, buildProbe} |
| status, body, err := s.postJSON(ctx, apiPath(a, "new", ""), in) |
| if err != nil { |
| return apiCreateAppResult{}, err |
diff --git a/apimode_test.go b/apimode_test.go| index e0bfd941a..94923dae3 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -2898,7 +2898,7 @@ func TestAPISessionCreateApp(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - res, err := s.createApp(context.Background(), apiAcct{Seg: "keymail"}, "paul", "") |
| + res, err := s.createApp(context.Background(), apiAcct{Seg: "keymail"}, "paul", "", false, "") |
| if err != nil { |
| t.Fatalf("createApp: %v", err) |
| } |
| @@ -2909,7 +2909,7 @@ func TestAPISessionCreateApp(t *testing.T) { |
| t.Fatalf("result = %+v", res) |
| } |
| - if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae", Exact: true}, "paul", ""); err != nil { |
| + if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae", Exact: true}, "paul", "", false, ""); err != nil { |
| t.Fatalf("createApp exact: %v", err) |
| } |
| if gotQuery != "exact=sqid" { |
| @@ -2917,13 +2917,13 @@ func TestAPISessionCreateApp(t *testing.T) { |
| } |
| status = http.StatusConflict |
| - if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "paul", ""); err == nil || |
| + if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "paul", "", false, ""); err == nil || |
| err.Error() != "that name is taken" { |
| t.Fatalf("409 err = %v, want the server's sentence", err) |
| } |
| status = http.StatusNotFound |
| - if _, err := s.createApp(context.Background(), apiAcct{Seg: "nope"}, "paul", ""); err == nil || |
| + if _, err := s.createApp(context.Background(), apiAcct{Seg: "nope"}, "paul", "", false, ""); err == nil || |
| !strings.Contains(err.Error(), "check -account,") { |
| t.Fatalf("404 err = %v, want create's account-only wording", err) |
| } |
| @@ -2956,14 +2956,14 @@ func TestAPISessionCreateAppSendsPlace(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "titogo", "fleet/pi"); err != nil { |
| + if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "titogo", "fleet/pi", false, ""); err != nil { |
| t.Fatalf("createApp with place: %v", err) |
| } |
| if gotPlace != "fleet/pi" { |
| t.Fatalf("wire place = %q, want fleet/pi", gotPlace) |
| } |
| - if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "unplaced", ""); err != nil { |
| + if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "unplaced", "", false, ""); err != nil { |
| t.Fatalf("createApp with no place: %v", err) |
| } |
| if sawPlaceKey && gotPlace != "" { |
diff --git a/apps.go b/apps.go| index baee56d07..1693057f8 100644 |
| --- a/apps.go |
| +++ b/apps.go |
| @@ -376,10 +376,35 @@ func validPlaceTarget(target string) error { |
| return nil |
| } |
| +// validBuildProbe is the client-side grammar gate for -build-probe, checked |
| +// BEFORE the API call on the same principle validAppName and |
| +// validPlaceTarget already state: a shape the server is certain to refuse |
| +// should cost nothing against the create verb's own scarce per-day budget. |
| +// "" is always valid (no probe). The grammar mirrors the server's own |
| +// validBuildProbe (internal/console/server.go) exactly: an absolute path, |
| +// no query, no spaces, no fragment. |
| +func validBuildProbe(probe string) error { |
| + if probe == "" { |
| + return nil |
| + } |
| + if !strings.HasPrefix(probe, "/") || strings.ContainsAny(probe, "?# \t") || len(probe) > 256 { |
| + return fmt.Errorf(`%q is not a usable build probe — want an absolute URL path with no query, `+ |
| + `e.g. "/healthz"`, probe) |
| + } |
| + return nil |
| +} |
| + |
| func runAppsCreate(args []string) { |
| fs := flag.NewFlagSet("apps create", flag.ExitOnError) |
| app := fs.String("app", "", "app name to claim (lowercase letters, digits, dashes)") |
| place := fs.String("place", "", `place the app on a customer fleet at create time — "" (default) or "fleet/<name>"`) |
| + controlPlane := fs.Bool("control-plane", false, |
| + "mark the app as the control plane: it refuses trash, purge, rename and transfer, "+ |
| + "and refuses a service credential's promote (owner only)") |
| + buildProbe := fs.String("build-probe", "", |
| + `path whose response body carries the running build, e.g. "/healthz" — `+ |
| + "`carlos deploy` polls it instead of the X-Carlos-Version header, which the edge "+ |
| + "stamps from the route row and which therefore lies until the process restarts") |
| // Not accountFlags: that helper also registers -registry for direct-mode |
| // resolution, and this verb never resolves locally — the console resolves |
| // the account against the caller's own memberships. |
| @@ -388,12 +413,12 @@ func runAppsCreate(args []string) { |
| consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)") |
| fs.Parse(args) |
| if strings.TrimSpace(*app) == "" { |
| - log.Fatal("usage: carlos apps create -app <name> [-place fleet/<f>] [-account <sqid|name>] " + |
| - "[-account-id <sqid>] [-console <origin>]") |
| + log.Fatal("usage: carlos apps create -app <name> [-place fleet/<f>] [-control-plane] [-build-probe /path] " + |
| + "[-account <sqid|name>] [-account-id <sqid>] [-console <origin>]") |
| } |
| // Client-side gates before the server's: the create limiter charges |
| - // ATTEMPTS, so a name — or a place value — the server is certain to |
| - // refuse should cost a typo nothing. |
| + // ATTEMPTS, so a name — or a place or build-probe value — the server is |
| + // certain to refuse should cost a typo nothing. |
| if err := validAppName(strings.TrimSpace(*app)); err != nil { |
| log.Fatalf("apps create: %v", err) |
| } |
| @@ -401,6 +426,10 @@ func runAppsCreate(args []string) { |
| if err := validPlaceTarget(place2); err != nil { |
| log.Fatalf("apps create: %v", err) |
| } |
| + probe2 := strings.TrimSpace(*buildProbe) |
| + if err := validBuildProbe(probe2); err != nil { |
| + log.Fatalf("apps create: %v", err) |
| + } |
| sess, err := resolveMode(*consoleFlag) |
| if err != nil { |
| @@ -418,7 +447,7 @@ func runAppsCreate(args []string) { |
| } |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| - res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2) |
| + res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2, *controlPlane, probe2) |
| if err != nil { |
| log.Fatalf("apps create: %v", err) |
| } |
| @@ -426,9 +455,6 @@ func runAppsCreate(args []string) { |
| fmt.Fprintf(stdout, "%s %s in account %s — it's yours\n", c.ok("created app"), res.App, res.Account) |
| fmt.Fprintf(stdout, " open: %s\n", c.link(res.URL)) |
| fmt.Fprintf(stdout, " ship: carlos ship -account %s -app %s ...\n", res.Account, res.App) |
| - if place2 == "" { |
| - return |
| - } |
| // The app WAS created (everything above already printed and is true) — |
| // what's still unverified is whether -place actually stuck. The server |
| // echoes back what it really stored (handleAPICreateApp's "placement" |
| @@ -438,10 +464,30 @@ func runAppsCreate(args []string) { |
| // (whose readJSON ignores an unknown "place" key and returns 200 with |
| // no such field at all — which unmarshals to the same "" zero value) |
| // report success for an app that is NOT actually placed. |
| - if err := placementMismatchErr(res.App, place2, res.Placement); err != nil { |
| - log.Fatalf("apps create: %v", err) |
| + if place2 != "" { |
| + if err := placementMismatchErr(res.App, place2, res.Placement); err != nil { |
| + log.Fatalf("apps create: %v", err) |
| + } |
| + fmt.Fprintf(stdout, " placed on %s\n", place2) |
| + } |
| + // Same principle for the two claim-time flags: print what the console |
| + // actually stamped (res.ControlPlane / res.BuildProbe), not what was |
| + // asked for, so a silently-swallowed store-write error is visible here |
| + // rather than trusted away. |
| + if *controlPlane { |
| + if !res.ControlPlane { |
| + log.Fatalf("apps create: -control-plane did not stick (the console did not confirm it) — "+ |
| + "the app %s was created, but is NOT flagged as the control plane", res.App) |
| + } |
| + fmt.Fprintf(stdout, " control plane: yes\n") |
| + } |
| + if probe2 != "" { |
| + if res.BuildProbe != probe2 { |
| + log.Fatalf("apps create: -build-probe did not stick (console echoed %q, want %q) — "+ |
| + "the app %s was created, but has no build probe set", res.BuildProbe, probe2, res.App) |
| + } |
| + fmt.Fprintf(stdout, " build probe: %s\n", res.BuildProbe) |
| } |
| - fmt.Fprintf(stdout, " placed on %s\n", place2) |
| } |
| // placementMismatchErr compares what -place asked for (requested) against |
diff --git a/apps_test.go b/apps_test.go| index 8f1f95509..5fbebf7a1 100644 |
| --- a/apps_test.go |
| +++ b/apps_test.go |
| @@ -268,6 +268,52 @@ func TestPlacementMismatchErr(t *testing.T) { |
| } |
| } |
| +// TestValidBuildProbeMirrorsTheServer pins the client-side grammar gate for |
| +// -build-probe against the same shapes the server's own validBuildProbe |
| +// (internal/console/server.go) refuses — a typo here must cost nothing |
| +// against the create verb's own scarce per-day budget, so the two gates |
| +// must agree on what's refused. |
| +func TestValidBuildProbeMirrorsTheServer(t *testing.T) { |
| + for _, bad := range []string{"healthz", "http://x/healthz", "/healthz?x=1", "/health z", strings.Repeat("/a", 200)} { |
| + if err := validBuildProbe(bad); err == nil { |
| + t.Errorf("validBuildProbe(%q) = nil, want an error", bad) |
| + } |
| + } |
| + for _, good := range []string{"", "/healthz", "/a/b/c"} { |
| + if err := validBuildProbe(good); err != nil { |
| + t.Errorf("validBuildProbe(%q) = %v, want nil", good, err) |
| + } |
| + } |
| +} |
| + |
| +// TestAPISessionCreateAppSendsAndEchoesControlPlaneAndProbe pins the wire |
| +// shape createApp sends (Task 3: -control-plane, -build-probe) and that it |
| +// surfaces the server's echo — NOT the request's own values — the same |
| +// "what actually stuck" principle Placement already established. |
| +func TestAPISessionCreateAppSendsAndEchoesControlPlaneAndProbe(t *testing.T) { |
| + var gotBody map[string]any |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewDecoder(r.Body).Decode(&gotBody) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "account": "acct", "app": "app", "url": "http://x/a/acct/apps/app", |
| + "control_plane": true, "build_probe": "/healthz", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + res, err := s.createApp(context.Background(), apiAcct{Seg: "acct"}, "app", "", true, "/healthz") |
| + if err != nil { |
| + t.Fatalf("createApp: %v", err) |
| + } |
| + if gotBody["control_plane"] != true || gotBody["build_probe"] != "/healthz" { |
| + t.Fatalf("request body = %v, want control_plane=true build_probe=/healthz", gotBody) |
| + } |
| + if !res.ControlPlane || res.BuildProbe != "/healthz" { |
| + t.Fatalf("createApp result = %+v, want the server's echoed flags", res) |
| + } |
| +} |
| + |
| // newHeadlessTestStore returns a fresh DirStore plus a parsed keypair |
| // (release.GenerateKey base64-encoded, parsed the same way the CLI parses |
| // CARLOS_RELEASE_PUBKEY/CARLOS_RELEASE_KEY) for headlessTrash's direct tests. |
diff --git a/deploy.go b/deploy.go| index 19a872778..6cf96667f 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -415,7 +415,7 @@ func offerCreateApp(ctx context.Context, sess *apiSession, acct apiAcct, na *noA |
| log.Fatalf("deploy: %v", verr) |
| } |
| } |
| - res, err := sess.createApp(ctx, acct, name, "") |
| + res, err := sess.createApp(ctx, acct, name, "", false, "") |
| if err != nil { |
| log.Fatalf("deploy: create app: %v", err) |
| } |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 05fca6385..c8d1bc05b 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -357,14 +357,30 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| var req struct { |
| - Name string `json:"name"` |
| - Place string `json:"place"` |
| + Name string `json:"name"` |
| + Place string `json:"place"` |
| + ControlPlane bool `json:"control_plane"` |
| + BuildProbe string `json:"build_probe"` |
| } |
| if !readJSON(w, r, &req) { |
| return |
| } |
| name := normalizeAppName(req.Name) |
| place := strings.TrimSpace(req.Place) |
| + // Validated BEFORE claimApp, like place: a refused flag refuses the WHOLE |
| + // create rather than leaving a claimed name with the wrong shape, and a |
| + // shape the server is certain to refuse costs no claim attempt. |
| + if req.ControlPlane && acct.Role != RoleOwner { |
| + jsonError(w, http.StatusForbidden, |
| + "only an owner can mark an app as the control plane") |
| + return |
| + } |
| + probe := strings.TrimSpace(req.BuildProbe) |
| + if probe != "" && !validBuildProbe(probe) { |
| + jsonError(w, http.StatusBadRequest, |
| + `build_probe must be an absolute URL path with no query, e.g. "/healthz"`) |
| + return |
| + } |
| if place != "" { |
| fleetName, validShape := registry.FleetPlacement(place) |
| if !validShape { |
| @@ -413,16 +429,39 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadGateway, "could not check the release store — try again") |
| case claimOK: |
| s.audit(acct, name, addr, "create", "created app "+name+" (cli)") |
| - // "placement" echoes what actually stuck (claimApp's second |
| - // return) — NOT the request's own place value — so a caller (CLI's |
| - // runAppsCreate, apps.go) can tell "placed" apart from "claim |
| - // succeeded, placement silently didn't" (a swallowed store-write |
| - // error) by comparing this field to what it asked for, rather than |
| - // trusting a 200 to mean everything it asked for happened. |
| + // Stamped in the SAME call that claims the name, after the claim has |
| + // actually succeeded: claiming first and flagging second would leave |
| + // a window in which the freshly-claimed row is an ordinary app any |
| + // ops member — or a Publish service credential — could roll before |
| + // the flag lands. Both setters are plain UPDATEs that no-op on a |
| + // missing row (store.go), so they are safe here only because claimOK |
| + // guarantees CreateAppIn just succeeded. |
| + if req.ControlPlane { |
| + if err := s.store.SetAppControlPlane(acct.ID, name, true); err != nil { |
| + log.Printf("console: claim %s/%s: set control_plane: %v", acct.Sqid, name, err) |
| + } |
| + } |
| + if probe != "" { |
| + if err := s.store.SetAppBuildProbe(acct.ID, name, probe); err != nil { |
| + log.Printf("console: claim %s/%s: set build_probe: %v", acct.Sqid, name, err) |
| + } |
| + } |
| + // "placement", "control_plane" and "build_probe" all echo what |
| + // actually stuck, NOT the request's own values — so a caller (CLI's |
| + // runAppsCreate, apps.go) can tell "claimed and flagged" apart from |
| + // "claimed, flag silently didn't" (a swallowed store-write error) by |
| + // comparing these fields to what it asked for, rather than trusting |
| + // a 200 to mean everything it asked for happened. |
| + gotCP, gotProbe, err := s.store.AppFlags(acct.ID, name) |
| + if err != nil { |
| + log.Printf("console: claim %s/%s: read back flags: %v", acct.Sqid, name, err) |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{ |
| "account": acct.Sqid, "app": name, |
| - "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name), |
| - "placement": placed, |
| + "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name), |
| + "placement": placed, |
| + "control_plane": gotCP, |
| + "build_probe": gotProbe, |
| }) |
| default: |
| // Any future claimOutcome lands here rather than silently 200ing |
diff --git a/internal/console/apps_claim_test.go b/internal/console/apps_claim_test.go| index b94eba071..0a861650f 100644 |
| --- a/internal/console/apps_claim_test.go |
| +++ b/internal/console/apps_claim_test.go |
| @@ -140,3 +140,63 @@ func TestBrowserDoorMemberRefusedOverExistingObjects(t *testing.T) { |
| t.Fatalf("HasAppIn after a member claim = %v %v, want false", has, err) |
| } |
| } |
| + |
| +// TestCreateAppStampsControlPlaneAndProbe: the flags are set in the SAME call |
| +// that claims the name. Claiming first and flagging second would leave a |
| +// window in which the production console is an ordinary app any ops member |
| +// could roll. |
| +func TestCreateAppStampsControlPlaneAndProbe(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedBucketApp(t, fx, "bab", "console") |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-create-cp") |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]any{ |
| + "name": "console", "control_plane": true, "build_probe": "/healthz", |
| + }, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("create = %d %v, want 200", status, out) |
| + } |
| + cp, probe, err := fx.st.AppFlags(OpsAccountID, "console") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !cp || probe != "/healthz" { |
| + t.Fatalf("flags after create = %v %q, want true \"/healthz\"", cp, probe) |
| + } |
| +} |
| + |
| +// TestOnlyAnOwnerSetsControlPlane: the flag refuses destruction, so setting it |
| +// is an ownership act — a member who could set it could also make an app |
| +// undeletable by its owner. |
| +func TestOnlyAnOwnerSetsControlPlane(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "member@keymail.dev", "code-create-cp-member") |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]any{ |
| + "name": "notyours", "control_plane": true, |
| + }, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("member setting control_plane = %d %v, want 403", status, out) |
| + } |
| + has, err := fx.st.HasAppIn(OpsAccountID, "notyours") |
| + if err != nil || has { |
| + t.Fatalf("the app was claimed anyway (%v) — a refused flag must refuse the WHOLE create, like -place does", has) |
| + } |
| +} |
| + |
| +// TestBuildProbeMustBeAnAbsolutePath: the probe is a URL path the deploy watch |
| +// dials. Anything else is a typo that would otherwise surface as a deploy that |
| +// times out for reasons nobody can see. |
| +func TestBuildProbeMustBeAnAbsolutePath(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-create-probe-bad") |
| + for _, bad := range []string{"healthz", "http://x/healthz", "/healthz?x=1", "/health z"} { |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]any{ |
| + "name": "probe" + bad[:1], "build_probe": bad, |
| + }, token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("build_probe %q = %d %v, want 400", bad, status, out) |
| + } |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index b4f842c3e..de68a4af6 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -2394,6 +2394,19 @@ func normalizeAppName(name string) string { |
| return strings.ToLower(strings.TrimSpace(name)) |
| } |
| +// validBuildProbe bounds the deploy watch's probe path to what it actually |
| +// dials: an absolute path, no query, no spaces. The watch appends its own |
| +// cache-busting query parameter (deploy.go), so a probe carrying one would |
| +// produce a URL with two — and a probe that is a full URL would silently |
| +// point the watch at another host entirely, which is the one mistake here |
| +// that could report a DIFFERENT deployment's build as this one's. |
| +func validBuildProbe(p string) bool { |
| + if !strings.HasPrefix(p, "/") || strings.ContainsAny(p, "?# \t") { |
| + return false |
| + } |
| + return len(p) <= 256 |
| +} |
| + |
| // claimApp claims an app name for acct — once, ever. A name is refused when |
| // the apps table has it, when ANY owner exists for it (CLI-granted apps stay |
| // claim-proof), or when the release store already holds anything under |