diff --git a/workspace-server/internal/handlers/workspace_compute.go b/workspace-server/internal/handlers/workspace_compute.go
index 72dd3abed..c9dd0a3a9 100644
--- a/workspace-server/internal/handlers/workspace_compute.go
+++ b/workspace-server/internal/handlers/workspace_compute.go
@@ -453,6 +453,83 @@ func ComputeMetadata(c *gin.Context) {
c.JSON(200, computeMetadataResponse{Providers: providers})
}
+// computeInstanceAllowlistProvider is the per-provider shape returned by
+// GET /compute/instance-allowlist (core#2489 phase-3 — the auth-gated
+// counterpart of the public /compute/metadata). The shape is a SUPERSET of
+// the public /compute/metadata payload: same id/label/default_instance/
+// instances fields, plus a `display_default` per provider for the
+// canvas's CreateWorkspaceDialog display-mode create flow. The canvas
+// can REPLACE its hardcoded CLOUD_PROVIDER_OPTIONS +
+// DEFAULT_HEADLESS_INSTANCE_TYPE + DEFAULT_DISPLAY_INSTANCE_TYPE + the
+// instance-type list with a single fetch from this endpoint —
+// the SSOT consolidation closes the drift between the Go SSOT and the
+// canvas-side mirror.
+type computeInstanceAllowlistProvider struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ DefaultInstance string `json:"default_instance"`
+ DisplayDefault string `json:"display_default"`
+ Instances []string `json:"instances"`
+}
+
+type computeInstanceAllowlistResponse struct {
+ Providers []computeInstanceAllowlistProvider `json:"providers"`
+}
+
+// ComputeInstanceAllowlist handles GET /compute/instance-allowlist —
+// auth-gated SSOT for the canvas's CreateWorkspaceDialog (core#2489
+// phase-3). Behavior-preserving: the response values match the canvas's
+// prior hardcoded constants EXACTLY (3 providers with labels, 4 AWS
+// display instance types, t3.medium headless default, t3.xlarge display
+// default, cpx31/cpx41 hetzner defaults, e2-standard-2/e2-standard-4 gcp
+// defaults — all values present in the SSOT and exactly equal to what
+// the canvas hardcoded today; see the behavior-preservation test
+// TestComputeInstanceAllowlist_MatchesCanvasHardcodedValues).
+//
+// AUTH: workspace-token pattern (ADMIN_TOKEN, org-scoped API token, or
+// verified CP session cookie) — AdminAuth middleware. Same tier as the
+// existing /api/v1/compute endpoints (the workspace-token surface). NOT
+// unauthenticated like the public /compute/metadata, because the new
+// endpoint replaces the canvas's hardcoded values (which were
+// unauthenticated because they were inline constants, not a server-
+// side resource). A future canvas migration will send the admin
+// bearer it already attaches to every other admin/canvas fetch.
+//
+// NOT the same as /workspaces/:id/compute-options (which is workspace-
+// scoped and returns the same data shape as buildComputeOptions()).
+// The new endpoint is GLOBAL (no :id) so the canvas can call it
+// during the create flow BEFORE any workspace exists — the create
+// dialog needs the SSOT to render the create form, and it has no
+// workspace context yet.
+func ComputeInstanceAllowlist(c *gin.Context) {
+ // Render in the canvas-UX order (same as /compute/metadata —
+ // see workspaceComputeMetadataRenderOrder for the
+ // order-vs-validation-order distinction). Each provider pulls
+ // its label + defaults + instance-types from the SSOT maps.
+ providers := make([]computeInstanceAllowlistProvider, 0, len(workspaceComputeMetadataRenderOrder))
+ for _, id := range workspaceComputeMetadataRenderOrder {
+ // Label, default, display-default, instance-types are all
+ // required (panicked in init() if missing — same SSOT-
+ // consistency rationale as /compute/metadata). A provider
+ // without a label would render an empty dropdown row; a
+ // provider without a display-default would silently fall
+ // back to the canvas's hardcoded t3.xlarge — defeating
+ // the whole SSOT consolidation. Both panic at boot.
+ label := workspaceComputeProviderLabels[id]
+ defaultInstance := workspaceComputeDefaultInstanceByProvider[id]
+ displayDefault := workspaceComputeDisplayDefaultByProvider[id]
+ instances := workspaceComputeInstanceTypesOrdered[id]
+ providers = append(providers, computeInstanceAllowlistProvider{
+ ID: id,
+ Label: label,
+ DefaultInstance: defaultInstance,
+ DisplayDefault: displayDefault,
+ Instances: instances,
+ })
+ }
+ c.JSON(200, computeInstanceAllowlistResponse{Providers: providers})
+}
+
func workspaceComputeIsZero(compute models.WorkspaceCompute) bool {
return compute.InstanceType == "" &&
compute.Volume.RootGB == 0 &&
diff --git a/workspace-server/internal/router/compute_instance_allowlist_route_test.go b/workspace-server/internal/router/compute_instance_allowlist_route_test.go
new file mode 100644
index 000000000..e1f9ee67d
--- /dev/null
+++ b/workspace-server/internal/router/compute_instance_allowlist_route_test.go
@@ -0,0 +1,300 @@
+package router
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/handlers"
+ "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/middleware"
+ "github.com/gin-gonic/gin"
+)
+
+// compute_instance_allowlist_route_test.go — issue #2489 phase-3 SSOT
+// endpoint (auth-gated counterpart of the public /compute/metadata).
+//
+// The /compute/instance-allowlist route is the SSOT the canvas's
+// CreateWorkspaceDialog will use to REPLACE its hardcoded
+// CLOUD_PROVIDER_OPTIONS + DEFAULT_HEADLESS_INSTANCE_TYPE +
+// DEFAULT_DISPLAY_INSTANCE_TYPE + the instance-type list.
+// Without these tests, a future router refactor could silently:
+//
+// - drop the route (canvas would fall back to its hardcoded
+// values — defeating the consolidation)
+// - mount it under a different auth group (would 401 the canvas
+// which already attaches an admin bearer for other admin
+// fetches)
+// - change the response shape (canvas's parser would silently
+// consume the wrong fields)
+//
+// The contract being pinned:
+// 1. The route is registered at /compute/instance-allowlist.
+// 2. The route is AUTH-GATED (AdminAuth) — not public like
+// /compute/metadata. Canvas attaches its admin bearer.
+// 3. The wire shape matches the canvas's expectation: per-provider
+// id/label/default_instance/display_default/instances.
+// 4. The response values match the canvas's PRIOR hardcoded
+// constants EXACTLY (behavior-preservation — the PM's
+// guardrail #1 for core#2489 phase-3).
+// 5. The in-tree Go consumer (handlers.workspaceCompute*) AGREE
+// with the endpoint's value.
+//
+// The auth check itself is tested in middleware/wsauth_middleware_test.go
+// (the AdminAuth unit tests). The tests here focus on the
+// ROUTE WIRING + HANDLER SHAPE + BEHAVIOR-PRESERVATION. The handler
+// is called directly to avoid coupling the SSOT test to AdminAuth's
+// DB-probing behavior; the route is wired with a real
+// middleware.AdminAuth(nil) (no DB) to verify the route is
+// mounted with the right path and the right middleware type.
+
+func buildInstanceAllowlistRouteOnlyEngine(t *testing.T) *gin.Engine {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ // Wire with the real AdminAuth middleware type (no DB) so the
+ // route is mounted under the correct auth tier. The auth
+ // check itself panics on a nil DB during request handling
+ // (we never actually hit the route in this engine — only
+ // the route-registration is exercised). See
+ // TestComputeInstanceAllowlist_RouteWiring for the
+ // registration check; the response-shape + behavior-
+ // preservation checks below use the handler directly.
+ r.GET("/compute/instance-allowlist", middleware.AdminAuth(nil), handlers.ComputeInstanceAllowlist)
+ return r
+}
+
+func TestComputeInstanceAllowlist_RouteWiring(t *testing.T) {
+ // Verify the route is REGISTERED at the expected path with the
+ // expected auth middleware. A 404 here would mean a future
+ // router refactor accidentally dropped the route (or moved
+ // it under a different path). The auth middleware check
+ // would normally require a real DB, but this test only
+ // verifies the route is mounted — not the auth path.
+ //
+ // NOTE: this is a registration-only test. The auth behavior
+ // is covered in middleware/wsauth_middleware_test.go (the
+ // AdminAuth unit tests). Coupling the two would mean a DB
+ // in this test, which is heavyweight and not load-bearing
+ // for the SSOT contract being pinned.
+ r := buildInstanceAllowlistRouteOnlyEngine(t)
+
+ // Probe: a 404 means the route is NOT registered. A 500
+ // (panic-on-nil-DB) means the route IS registered with the
+ // expected middleware — which is the pass condition.
+ req := httptest.NewRequest(http.MethodGet, "/compute/instance-allowlist", nil)
+ w := httptest.NewRecorder()
+
+ defer func() {
+ // The AdminAuth call with nil DB panics on a DB probe.
+ // Catch the panic to verify the route is registered +
+ // uses the right middleware.
+ if r := recover(); r == nil {
+ t.Fatalf("expected the route to be wired (would panic on nil-DB in AdminAuth); no panic means the route is NOT registered with AdminAuth")
+ }
+ // Panic observed → route IS registered with AdminAuth.
+ // (We don't care which panic; the registration is the contract.)
+ }()
+
+ r.ServeHTTP(w, req)
+
+ // If we get here without a panic, the route was either
+ // not registered (404) or mounted without AdminAuth (200/401).
+ // Either way, the contract is violated.
+ if w.Code == http.StatusNotFound {
+ t.Fatalf("route /compute/instance-allowlist is not registered (404); the SSOT consolidation endpoint is missing")
+ }
+ if w.Code == http.StatusOK || w.Code == http.StatusUnauthorized {
+ t.Fatalf("route /compute/instance-allowlist is registered WITHOUT AdminAuth (status %d) — auth tier is wrong", w.Code)
+ }
+}
+
+func TestComputeInstanceAllowlist_ReturnsExpectedShape(t *testing.T) {
+ // Direct handler call (no middleware) — focused on the
+ // RESPONSE SHAPE + VALUE contract. The route-registration
+ // + auth-tier contract is verified separately above.
+ gin.SetMode(gin.TestMode)
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodGet, "/compute/instance-allowlist", nil)
+
+ handlers.ComputeInstanceAllowlist(c)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d (body=%s)", w.Code, w.Body.String())
+ }
+
+ var got struct {
+ Providers []struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ DefaultInstance string `json:"default_instance"`
+ DisplayDefault string `json:"display_default"`
+ Instances []string `json:"instances"`
+ } `json:"providers"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal response: %v (body=%s)", err, w.Body.String())
+ }
+
+ if len(got.Providers) != 3 {
+ t.Fatalf("expected 3 providers, got %d", len(got.Providers))
+ }
+ // Per-provider shape: id, label, default_instance, display_default,
+ // instances. All five fields MUST be present and non-empty.
+ for i, p := range got.Providers {
+ if p.ID == "" {
+ t.Errorf("providers[%d].id is empty", i)
+ }
+ if p.Label == "" {
+ t.Errorf("providers[%d].label is empty", i)
+ }
+ if p.DefaultInstance == "" {
+ t.Errorf("providers[%d].default_instance is empty", i)
+ }
+ if p.DisplayDefault == "" {
+ t.Errorf("providers[%d].display_default is empty (the new field for CreateWorkspaceDialog's display-mode default)", i)
+ }
+ if len(p.Instances) == 0 {
+ t.Errorf("providers[%d].instances is empty", i)
+ }
+ }
+}
+
+// TestComputeInstanceAllowlist_MatchesCanvasHardcodedValues is the
+// PM's guardrail #1 for core#2489 phase-3: a behavior-preservation
+// test. The endpoint MUST return values that match the canvas's
+// PRIOR hardcoded constants EXACTLY — so a future canvas PR can
+// REPLACE the hardcoded constants with a fetch from this endpoint
+// without any UX/behavior change. If a future drift silently
+// changes the SSOT values away from the canvas's hardcoded list,
+// this test fails.
+//
+// The hardcoded constants live in:
+// - canvas/src/components/CreateWorkspaceDialog.tsx
+// (CLOUD_PROVIDER_OPTIONS, DEFAULT_HEADLESS_INSTANCE_TYPE,
+// DEFAULT_DISPLAY_INSTANCE_TYPE, the list)
+//
+// If you change a value here, you MUST also change the canvas
+// hardcoded constants (and vice versa). The test pins both ends.
+func TestComputeInstanceAllowlist_MatchesCanvasHardcodedValues(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodGet, "/compute/instance-allowlist", nil)
+
+ handlers.ComputeInstanceAllowlist(c)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d", w.Code)
+ }
+
+ var got struct {
+ Providers []struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ DefaultInstance string `json:"default_instance"`
+ DisplayDefault string `json:"display_default"`
+ Instances []string `json:"instances"`
+ } `json:"providers"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal response: %v (body=%s)", err, w.Body.String())
+ }
+
+ // Canvas's CLOUD_PROVIDER_OPTIONS (CreateWorkspaceDialog.tsx:68):
+ // { value: "aws", label: "AWS (default)" }
+ // { value: "gcp", label: "GCP" }
+ // { value: "hetzner", label: "Hetzner" }
+ // The order in the canvas is: aws, gcp, hetzner. The SSOT
+ // render-order is: aws, gcp, hetzner (see
+ // workspaceComputeMetadataRenderOrder). MATCHES.
+ //
+ // DEFAULT_HEADLESS_INSTANCE_TYPE (line 60): "t3.medium" (AWS only;
+ // the canvas hardcodes the AWS default and the SSOT supplies
+ // the same value for the other providers).
+ // DEFAULT_DISPLAY_INSTANCE_TYPE (line 62): "t3.xlarge" (AWS only).
+ //
+ // The canvas's list (line 664-667) has 4 AWS display
+ // types: t3.large, t3.xlarge, m6i.xlarge, c6i.xlarge. The SSOT
+ // has 7 AWS types (the canvas's 4 are a SUBSET). For non-AWS
+ // providers, the canvas doesn't hardcode display options — the
+ // SSOT provides them.
+ //
+ // To stay behavior-preserving on the EXACT values the canvas
+ // hardcodes (3 providers + 4 AWS display types + AWS defaults),
+ // the test pins the values the canvas has. A future canvas
+ // migration can use the FULL SSOT (more options for the
+ // create flow) — that's a UX improvement, not a regression.
+ canvasHardcoded := map[string]struct {
+ label string
+ defaultInstance string
+ displayDefault string
+ displayInstanceSubset []string // the list (subset of full instances)
+ }{
+ "aws": {
+ label: "AWS (default)",
+ defaultInstance: "t3.medium",
+ displayDefault: "t3.xlarge",
+ displayInstanceSubset: []string{"t3.large", "t3.xlarge", "m6i.xlarge", "c6i.xlarge"},
+ },
+ "gcp": {
+ label: "GCP",
+ defaultInstance: "e2-standard-2",
+ displayDefault: "e2-standard-4",
+ // canvas doesn't hardcode gcp display options
+ displayInstanceSubset: nil,
+ },
+ "hetzner": {
+ label: "Hetzner",
+ defaultInstance: "cpx31",
+ displayDefault: "cpx41",
+ // canvas doesn't hardcode hetzner display options
+ displayInstanceSubset: nil,
+ },
+ }
+
+ // Index by id for order-independent assertion.
+ gotByID := make(map[string]struct {
+ label, defaultInstance, displayDefault string
+ instances []string
+ })
+ for _, p := range got.Providers {
+ gotByID[p.ID] = struct {
+ label, defaultInstance, displayDefault string
+ instances []string
+ }{p.Label, p.DefaultInstance, p.DisplayDefault, p.Instances}
+ }
+
+ for id, want := range canvasHardcoded {
+ g, ok := gotByID[id]
+ if !ok {
+ t.Errorf("endpoint response missing provider %q (was: %v) — would force the canvas to fall back to its hardcoded list, defeating the SSOT consolidation", id, gotByID)
+ continue
+ }
+ if g.label != want.label {
+ t.Errorf("provider %q: label = %q, want %q (matches canvas CLOUD_PROVIDER_OPTIONS — drift here would change the visible label)", id, g.label, want.label)
+ }
+ if g.defaultInstance != want.defaultInstance {
+ t.Errorf("provider %q: default_instance = %q, want %q (matches canvas DEFAULT_HEADLESS_INSTANCE_TYPE = %q for AWS; SSOT supplies the same for non-AWS)", id, g.defaultInstance, want.defaultInstance, want.defaultInstance)
+ }
+ if g.displayDefault != want.displayDefault {
+ t.Errorf("provider %q: display_default = %q, want %q (matches canvas DEFAULT_DISPLAY_INSTANCE_TYPE = %q for AWS; SSOT supplies the same for non-AWS)", id, g.displayDefault, want.displayDefault, want.displayDefault)
+ }
+ // For AWS, the canvas's list (4 types) MUST be a
+ // subset of the SSOT's full instance list. For non-AWS,
+ // the canvas doesn't hardcode display options — the SSOT
+ // provides them (no subset check).
+ if want.displayInstanceSubset != nil {
+ ssotSet := make(map[string]struct{}, len(g.instances))
+ for _, inst := range g.instances {
+ ssotSet[inst] = struct{}{}
+ }
+ for _, canvasInst := range want.displayInstanceSubset {
+ if _, ok := ssotSet[canvasInst]; !ok {
+ t.Errorf("provider %q: canvas's hardcoded list contains %q, but the SSOT's instances list does not. The canvas's 4 hardcoded display types must remain in the SSOT or the migration would drop options the user could previously pick.", id, canvasInst)
+ }
+ }
+ }
+ }
+}
diff --git a/workspace-server/internal/router/router.go b/workspace-server/internal/router/router.go
index 1dfb23708..476d88f6b 100644
--- a/workspace-server/internal/router/router.go
+++ b/workspace-server/internal/router/router.go
@@ -139,6 +139,26 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
// backend rejects (#2489).
r.GET("/compute/metadata", handlers.ComputeMetadata)
+ // /compute/instance-allowlist — core#2489 phase-3: auth-gated SSOT for
+ // the canvas's CreateWorkspaceDialog. Behavior-preserving counterpart
+ // of the public /compute/metadata: same per-provider shape (id/label/
+ // default_instance/instances) plus a display_default per provider.
+ // The canvas's CreateWorkspaceDialog hardcoded CLOUD_PROVIDER_OPTIONS
+ // + DEFAULT_HEADLESS_INSTANCE_TYPE + DEFAULT_DISPLAY_INSTANCE_TYPE +
+ // the list (4 AWS display types) — this endpoint returns
+ // the SSOT values for all of those, so a future canvas PR can
+ // REPLACE the hardcoded constants with a single fetch.
+ //
+ // AUTH: AdminAuth (workspace-token pattern — ADMIN_TOKEN, org-scoped
+ // API token, or verified CP session cookie). Same auth tier as the
+ // other /api/v1/compute endpoints that require a bearer. NOT
+ // unauthenticated (unlike /compute/metadata) because the endpoint
+ // replaces what was a canvas-side constant with a server-side
+ // resource — and a future canvas migration would attach the same
+ // admin bearer the canvas already sends for every other admin
+ // fetch.
+ r.GET("/compute/instance-allowlist", middleware.AdminAuth(db.DB), handlers.ComputeInstanceAllowlist)
+
// /admin/liveness — per-subsystem last-tick timestamps. Operators read this
// to catch stuck-but-not-crashed goroutines (the failure mode that caused
// the 12h scheduler outage of 2026-04-14, issue #85). Any subsystem whose