From 16cd9accb13e76fb97e3a800180f868ffbc978b6 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Mon, 11 May 2026 13:44:11 +0530 Subject: [PATCH] feat(deploy): accept env_vars on initial POST + document claim flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two contract additions friction-tested 2026-05-11: 1. POST /deploy/new now accepts an optional multipart "env_vars" field — a JSON object {KEY:"value", ...} merged into the deployed pod's env on the first build. Replaces the previous POST /deploy/new → wait → PATCH /env → POST /redeploy round-trip pattern that doubled time-to-live-URL for any app that actually needed env config (which is all of them). vault://KEY refs resolve at deploy time exactly as in the PATCH flow. Reserved underscore-prefixed keys are silently dropped to avoid collisions with internal markers (e.g. _name). 2. OpenAPI bearerAuth scheme now documents the full agent auth path: call any anonymous provisioning endpoint → /claim with email → session JWT → POST /api/v1/api-keys for unattended use. Without this, an agent reading /openapi.json had no machine-readable signal where the JWT comes from, and got 401s with no recovery hint. Adds DeployRequest.env_vars to the schema with usage guidance, plus a note on the current ~1 MiB build-context cap (the k8s Secret limit; the form claims 50 MB). Tests: - TestOpenAPISpecParses guards against another raw-string-literal escape bug like the one introduced + fixed mid-PR. - TestOpenAPI_DeployRequestHasEnvVars guards the env_vars contract. - TestOpenAPI_BearerAuthDocumentsClaimFlow guards that the auth-flow description stays present (mentions /claim, anonymous, api-keys). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/handlers/deploy.go | 22 ++++++++++ internal/handlers/openapi.go | 11 +++-- internal/handlers/openapi_test.go | 67 +++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 internal/handlers/openapi_test.go 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 +}