diff --git a/internal/handlers/deploy.go b/internal/handlers/deploy.go index 3cb8c3d..4cdbcb3 100644 --- a/internal/handlers/deploy.go +++ b/internal/handlers/deploy.go @@ -21,10 +21,12 @@ import ( "crypto/rand" "database/sql" "encoding/hex" + "encoding/json" "errors" "fmt" "log/slog" "strconv" + "strings" "time" "github.com/gofiber/fiber/v2" @@ -263,6 +265,26 @@ func (h *DeployHandler) New(c *fiber.Ctx) error { initEnv["_name"] = name } + // Optional env_vars multipart field: a JSON object {KEY:"value", ...} that + // gets injected into the deployed pod on the FIRST build. Avoids the + // previous round-trip pattern of (POST /deploy/new) → wait → (PATCH /env) → + // (POST /redeploy) — agents can now ship a working app in one call. + // vault://KEY refs are resolved at deploy time (same as PATCH /env). + if vals := form.Value["env_vars"]; len(vals) > 0 { + var parsed map[string]string + if err := json.Unmarshal([]byte(vals[0]), &parsed); err != nil { + return respondError(c, fiber.StatusBadRequest, "invalid_env_vars", + "Field 'env_vars' must be a JSON object {KEY:\"value\", ...}") + } + for k, v := range parsed { + // Reserved underscore-prefixed keys are internal-only. + if strings.HasPrefix(k, "_") { + continue + } + initEnv[k] = v + } + } + saved, err := models.CreateDeployment(c.Context(), h.db, models.CreateDeploymentParams{ TeamID: team.ID, AppID: appID, diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index d57a080..1c9afd7 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -365,7 +365,11 @@ const openAPISpec = `{ }, "components": { "securitySchemes": { - "bearerAuth": { "type": "http", "scheme": "bearer", "description": "Session JWT from /claim or /auth/github or /auth/google" } + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Session JWT for authenticated endpoints (deploy, vault, billing, team, custom-domain). Resource provisioning (POST /db/new, /cache/new, /nosql/new, /queue/new, /storage/new, /webhook/new) does NOT require this header — those endpoints are anonymous. How to obtain a JWT from an anonymous agent flow: (1) Call any provisioning endpoint anonymously — the response includes a start_url like https://api.instanode.dev/start?t=. (2) Visit that URL once (or POST { jti, email } to /claim directly) to attach the anonymous tokens to a real team. Email verification via magic link. (3) /claim returns a session JWT (24h) usable as the Authorization: Bearer header. For unattended agents, prefer POST /api/v1/api-keys (requires an existing session) which mints a long-lived bearer token tied to your team. Claim values: tid (team ID), uid (user ID), email, plus standard RFC 7519 claims. HS256-signed." + } }, "schemas": { "HealthResponse": { @@ -522,10 +526,11 @@ const openAPISpec = `{ "DeployRequest": { "type": "object", "properties": { - "tarball": { "type": "string", "format": "binary", "description": "gzipped tar archive containing the Dockerfile + source (max 50 MB)" }, + "tarball": { "type": "string", "format": "binary", "description": "gzipped tar archive containing the Dockerfile + source. NOTE: the effective cap is currently ~1 MiB because the build context is delivered to kaniko via a k8s Secret. See the deploy roadmap PR for the >1 MiB upgrade path." }, "name": { "type": "string", "description": "Optional human-readable label" }, "port": { "type": "integer", "description": "Container port (default 8080)" }, - "env": { "type": "string", "description": "Environment scope (production / staging / dev / ...)" } + "env": { "type": "string", "description": "Environment scope (production / staging / dev / ...)" }, + "env_vars": { "type": "string", "description": "Optional JSON object of env vars to inject into the deployed pod on the FIRST build — e.g. '{\"DATABASE_URL\":\"postgres://...\",\"REDIS_URL\":\"redis://...\"}'. Avoids the (POST /deploy/new) → (PATCH /env) → (POST /redeploy) round-trip pattern. Values may use 'vault://KEY' refs which resolve at deploy time. Keys starting with underscore are reserved and ignored." } }, "required": ["tarball"] }, diff --git a/internal/handlers/openapi_test.go b/internal/handlers/openapi_test.go new file mode 100644 index 0000000..c9d5771 --- /dev/null +++ b/internal/handlers/openapi_test.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestOpenAPISpecParses ensures the embedded OpenAPI spec is valid JSON. Any +// stray backtick or escape mistake in a description string causes the spec +// to fail JSON parse, which produces a useless 500 at /openapi.json. +func TestOpenAPISpecParses(t *testing.T) { + var v map[string]any + if err := json.Unmarshal([]byte(openAPISpec), &v); err != nil { + t.Fatalf("openAPISpec is not valid JSON: %v", err) + } + if v["openapi"] != "3.1.0" { + t.Errorf("openapi version = %v; want 3.1.0", v["openapi"]) + } +} + +// TestOpenAPI_DeployRequestHasEnvVars guards the contract addition for friction +// fix #11 (env vars in initial POST /deploy/new). +func TestOpenAPI_DeployRequestHasEnvVars(t *testing.T) { + var v map[string]any + if err := json.Unmarshal([]byte(openAPISpec), &v); err != nil { + t.Fatalf("openAPISpec parse: %v", err) + } + props, ok := digMap(v, "components", "schemas", "DeployRequest", "properties") + if !ok { + t.Fatal("could not navigate to DeployRequest.properties in spec") + } + if _, ok := props["env_vars"]; !ok { + t.Error("DeployRequest.properties.env_vars is missing — agents have no machine-readable signal that env can be set on initial POST") + } +} + +// TestOpenAPI_BearerAuthDocumentsClaimFlow guards the contract addition for +// friction fix #2 (auth flow must be discoverable via OpenAPI). +func TestOpenAPI_BearerAuthDocumentsClaimFlow(t *testing.T) { + var v map[string]any + if err := json.Unmarshal([]byte(openAPISpec), &v); err != nil { + t.Fatalf("openAPISpec parse: %v", err) + } + bearer, ok := digMap(v, "components", "securitySchemes", "bearerAuth") + if !ok { + t.Fatal("could not navigate to bearerAuth in spec") + } + desc, _ := bearer["description"].(string) + for _, must := range []string{"/claim", "anonymous", "api-keys"} { + if !strings.Contains(desc, must) { + t.Errorf("bearerAuth.description must mention %q so an agent reading the OpenAPI alone can discover the auth flow; got: %s", must, desc) + } + } +} + +func digMap(root map[string]any, keys ...string) (map[string]any, bool) { + cur := root + for _, k := range keys { + next, ok := cur[k].(map[string]any) + if !ok { + return nil, false + } + cur = next + } + return cur, true +}