Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions internal/handlers/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 8 additions & 3 deletions internal/handlers/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<onboarding-jwt>. (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": {
Expand Down Expand Up @@ -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"]
},
Expand Down
67 changes: 67 additions & 0 deletions internal/handlers/openapi_test.go
Original file line number Diff line number Diff line change
@@ -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
}