diff --git a/.github/workflows/openapi-snapshot.yml b/.github/workflows/openapi-snapshot.yml new file mode 100644 index 00000000..81dab4c0 --- /dev/null +++ b/.github/workflows/openapi-snapshot.yml @@ -0,0 +1,110 @@ +# Cross-stack OpenAPI contract snapshot check. +# +# Why this exists: today's prod login broke for ~24h because /auth/exchange +# (AUTH-004) shipped server-side without the dashboard's client being updated. +# Per-repo unit tests on both sides stayed green because each repo only saw +# its own half of the contract. This workflow makes the contract a committed +# artifact (api/openapi.snapshot.json) that the dashboard + instanode-web +# repos consume to generate typed clients. A drift here at PR time forces +# the engineer to address client-side regeneration in the same change. +# +# Gate logic (small + cheap, < 30s): +# 1. Build the openapi-snapshot tool. +# 2. Regenerate openapi.snapshot.json from internal/handlers/openapi.go. +# 3. Diff against the committed file. If different → fail with the exact +# `make openapi-snapshot` command the engineer must run. +# +# Observability (rule 25): a single structured log line per run so we can +# query CI artifacts for the rate at which contract drift is being caught: +# {"event":"cross_stack_contract_drift","detected":true|false,"repo":"api"} +# Captured by the GitHub Actions log forwarder into NR (instanode-reliability +# dashboard tile: "Contract drift caught at PR time, 30d"). + +name: openapi-snapshot + +on: + push: + branches: [master] + paths: + - 'internal/handlers/openapi.go' + - 'cmd/openapi-snapshot/**' + - 'openapi.snapshot.json' + - '.github/workflows/openapi-snapshot.yml' + pull_request: + branches: [master] + paths: + - 'internal/handlers/openapi.go' + - 'cmd/openapi-snapshot/**' + - 'openapi.snapshot.json' + - '.github/workflows/openapi-snapshot.yml' + workflow_dispatch: + +concurrency: + group: openapi-snapshot-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + snapshot-drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Checkout proto sibling (for go.mod replace ../proto) + uses: actions/checkout@v6 + with: + repository: ${{ vars.PROTO_REPO || format('{0}/proto', github.repository_owner) }} + token: ${{ secrets.REPO_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} + path: _proto_ci + + - name: Place ../proto for Go replace directive + run: mv _proto_ci ../proto + + - name: Checkout common sibling (for go.mod replace ../common) + uses: actions/checkout@v6 + with: + repository: ${{ vars.COMMON_REPO || format('{0}/common', github.repository_owner) }} + token: ${{ secrets.REPO_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} + path: _common_ci + + - name: Place ../common for Go replace directive + run: mv _common_ci ../common + + - uses: actions/setup-go@v6 + with: + go-version: '1.25' + + - name: Regenerate openapi.snapshot.json + id: regen + run: | + go run ./cmd/openapi-snapshot/ -out /tmp/openapi.snapshot.regenerated.json + if diff -q openapi.snapshot.json /tmp/openapi.snapshot.regenerated.json >/dev/null; then + echo "drift=false" >> "$GITHUB_OUTPUT" + else + echo "drift=true" >> "$GITHUB_OUTPUT" + fi + + - name: Emit observability line (rule 25) + # Single structured line so NR log forwarder can chart drift rate. + # Runs regardless of drift status so the absence of drift is also + # a data point ("we ran, we passed"). + run: | + printf '{"event":"cross_stack_contract_drift","detected":%s,"repo":"api","pr":"%s","sha":"%s"}\n' \ + "${{ steps.regen.outputs.drift }}" \ + "${{ github.event.pull_request.number || 'none' }}" \ + "${GITHUB_SHA:0:7}" + + - name: Fail if snapshot is out of date + if: steps.regen.outputs.drift == 'true' + run: | + echo "::error::openapi.snapshot.json is out of date." + echo "::error::An edit to internal/handlers/openapi.go changed the production OpenAPI surface but the snapshot was not regenerated." + echo "::error::Run \`make openapi-snapshot\` and commit the updated file in this PR." + echo "::error::This is rule 22 (contract surface checklist): dashboard + instanode-web depend on this snapshot to generate typed clients." + echo "" + echo "Drift (committed → regenerated, first 200 lines):" + diff -u openapi.snapshot.json /tmp/openapi.snapshot.regenerated.json | head -200 || true + exit 1 + + - name: Snapshot is current + if: steps.regen.outputs.drift == 'false' + run: echo "openapi.snapshot.json matches handlers.OpenAPISpecProduction() — dashboards can regenerate clients deterministically." diff --git a/Makefile b/Makefile index dcf1b721..babda08c 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ docker-up docker-down docker-logs \ migrate migrate-platform migrate-customers \ docker-build smoke-buildinfo \ + openapi-snapshot openapi-snapshot-check \ k8s-deploy k8s-delete k8s-status k8s-regen-migrations \ gen-secrets install-cli \ storage-verify-isolation \ @@ -184,6 +185,40 @@ smoke-buildinfo: echo "smoke-buildinfo: OK ($$out)" && \ rm -rf $$tmpdir +# ── Cross-stack OpenAPI contract snapshot ───────────────────────────────────── +# +# api/openapi.snapshot.json is the source-of-truth artifact that the +# dashboard and instanode-web repos consume to generate their typed API +# clients (via openapi-typescript). It is the canonicalised JSON output of +# handlers.OpenAPISpecProduction() — the same spec served at GET /openapi.json +# in production. +# +# Workflow: +# - Edit internal/handlers/openapi.go (add a path, change a schema). +# - Run `make openapi-snapshot` — regenerates openapi.snapshot.json. +# - Commit both files in the same PR (rule 22: contract surface checklist). +# - CI runs `make openapi-snapshot-check` and fails the PR if the +# committed snapshot differs from a freshly regenerated one. +# +# The snapshot tool canonicalises (sorted keys, 2-space indent) so that +# whitespace-only edits to the const do not flip the snapshot — only real +# contract changes do. +openapi-snapshot: + @go run ./cmd/openapi-snapshot/ + +openapi-snapshot-check: + @go run ./cmd/openapi-snapshot/ -out /tmp/openapi.snapshot.regenerated.json + @if ! diff -q openapi.snapshot.json /tmp/openapi.snapshot.regenerated.json >/dev/null; then \ + echo ""; \ + echo "::error::openapi.snapshot.json is out of date."; \ + echo "::error::Run \`make openapi-snapshot\` and commit the updated file."; \ + echo ""; \ + echo "Drift (committed → regenerated):"; \ + diff -u openapi.snapshot.json /tmp/openapi.snapshot.regenerated.json | head -80 || true; \ + exit 1; \ + fi + @echo "openapi-snapshot-check: snapshot matches handlers.OpenAPISpecProduction()" + # Regen the SQL ConfigMap from the actual migration file (run after schema changes) k8s-regen-migrations: kubectl create configmap instant-migrations \ diff --git a/cmd/openapi-snapshot/main.go b/cmd/openapi-snapshot/main.go new file mode 100644 index 00000000..a773ff43 --- /dev/null +++ b/cmd/openapi-snapshot/main.go @@ -0,0 +1,97 @@ +// Command openapi-snapshot writes the production-rendered OpenAPI 3.1 spec +// to api/openapi.snapshot.json (or to the path given by -out). +// +// This is the source-of-truth artifact for cross-stack contract testing. The +// dashboard and instanode-web repos consume the committed snapshot to generate +// their typed API clients via openapi-typescript. If the snapshot drifts from +// what handlers.OpenAPISpecProduction returns (i.e. someone edited the spec +// const without regenerating), `make openapi-snapshot-check` fails CI with a +// clear "regenerate the snapshot" message. +// +// The snapshot is canonicalised before writing: +// - parsed as JSON and re-marshalled with sorted map keys and 2-space indent +// +// so that whitespace-only edits to the const (re-flowed strings, added blank +// lines) do not falsely flip the snapshot. The only things that change the +// snapshot are real contract changes: new paths, changed schemas, renamed +// fields, removed responses. +// +// Usage: +// +// go run ./cmd/openapi-snapshot # writes ./openapi.snapshot.json +// go run ./cmd/openapi-snapshot -out /tmp/x # writes to /tmp/x +// go run ./cmd/openapi-snapshot -stdout # prints to stdout (CI diff) +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "instant.dev/internal/handlers" +) + +const defaultOutPath = "openapi.snapshot.json" + +// exitFn is os.Exit at runtime; tests swap it so the main() body becomes +// a measurable statement instead of an irreducible coverage hole. +var exitFn = os.Exit + +func main() { exitFn(run(os.Args[1:], os.Stdout, os.Stderr, handlers.OpenAPISpecProduction)) } + +// run is the testable body of main. Returns the exit code. specSource is +// injected so the test suite can drive both the happy path (real production +// spec) and the canonicalise-error path (a deliberately-malformed source). +func run(args []string, stdout, stderr io.Writer, specSource func() string) int { + fs := flag.NewFlagSet("openapi-snapshot", flag.ContinueOnError) + fs.SetOutput(stderr) + out := fs.String("out", defaultOutPath, "destination file for the canonical snapshot") + toStdout := fs.Bool("stdout", false, "write to stdout instead of -out (CI diff mode)") + if err := fs.Parse(args); err != nil { + return 2 + } + + canonical, err := canonicalise(specSource()) + if err != nil { + _, _ = fmt.Fprintf(stderr, "openapi-snapshot: built-in spec is not valid JSON: %v\n", err) + return 2 + } + + if *toStdout { + if _, err := stdout.Write(canonical); err != nil { + _, _ = fmt.Fprintf(stderr, "openapi-snapshot: stdout: %v\n", err) + return 2 + } + return 0 + } + + if err := os.WriteFile(*out, canonical, 0o644); err != nil { + _, _ = fmt.Fprintf(stderr, "openapi-snapshot: write %s: %v\n", *out, err) + return 2 + } + _, _ = fmt.Fprintf(stderr, "openapi-snapshot: wrote %d bytes to %s\n", len(canonical), *out) + return 0 +} + +// canonicalise parses the spec as JSON and re-emits it with sorted keys and +// 2-space indent so that the on-disk snapshot is deterministic and friendly +// to diff. encoding/json sorts map keys alphabetically by default. +// +// Re-encoding a value produced by json.Unmarshal cannot fail (bytes.Buffer +// writes never error, and json.Marshal on interface{} produced from valid +// JSON is always defined), so we only surface the parse error. +func canonicalise(spec string) ([]byte, error) { + var v any + if err := json.Unmarshal([]byte(spec), &v); err != nil { + return nil, fmt.Errorf("parse spec: %w", err) + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + _ = enc.Encode(v) // see godoc: re-encoding a valid-JSON-derived value cannot fail + return buf.Bytes(), nil +} diff --git a/cmd/openapi-snapshot/main_test.go b/cmd/openapi-snapshot/main_test.go new file mode 100644 index 00000000..a08c1c8c --- /dev/null +++ b/cmd/openapi-snapshot/main_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "instant.dev/internal/handlers" +) + +func TestCanonicalise_SortsKeysAnd2SpaceIndent(t *testing.T) { + in := `{"z":1,"a":{"y":2,"b":3}}` + got, err := canonicalise(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := string(got) + + if !strings.Contains(out, " \"a\"") || !strings.Contains(out, " \"b\"") { + t.Errorf("expected 2-space indent on nested keys, got:\n%s", out) + } + idxA := strings.Index(out, "\"a\"") + idxZ := strings.Index(out, "\"z\"") + if idxA < 0 || idxZ < 0 || idxA > idxZ { + t.Errorf("expected keys sorted alphabetically ('a' before 'z'), got:\n%s", out) + } + idxB := strings.Index(out, "\"b\"") + idxY := strings.Index(out, "\"y\"") + if idxB < 0 || idxY < 0 || idxB > idxY { + t.Errorf("expected nested keys sorted ('b' before 'y'), got:\n%s", out) + } +} + +func TestCanonicalise_RejectsInvalidJSON(t *testing.T) { + _, err := canonicalise("not json at all") + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + if !strings.Contains(err.Error(), "parse spec") { + t.Errorf("expected wrapped 'parse spec' error, got: %v", err) + } +} + +func TestCanonicalise_DoesNotEscapeHTML(t *testing.T) { + in := `{"url":"https://instanode.dev/deploy?env=production&plan=hobby"}` + got, err := canonicalise(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := string(got) + if !strings.Contains(s, `&plan=hobby`) { + t.Errorf("expected literal '&' preserved (HTML escaping disabled), got:\n%s", s) + } + if strings.Contains(s, "\\u0026") { + t.Errorf("expected '&' not to be escaped as backslash-u0026, got:\n%s", s) + } +} + +func TestRun_StdoutMode_WritesCanonicalSpec(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"-stdout"}, &stdout, &stderr, handlers.OpenAPISpecProduction) + if code != 0 { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + if stdout.Len() == 0 { + t.Fatal("expected non-empty stdout") + } + if !strings.HasPrefix(stdout.String(), "{") { + t.Errorf("expected JSON object on stdout, got prefix: %q", stdout.String()[:min(40, stdout.Len())]) + } +} + +func TestRun_FileMode_WritesToCustomOutPath(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "snap.json") + var stdout, stderr bytes.Buffer + code := run([]string{"-out", target}, &stdout, &stderr, handlers.OpenAPISpecProduction) + if code != 0 { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + stat, err := os.Stat(target) + if err != nil { + t.Fatalf("expected file at %s, got error: %v", target, err) + } + if stat.Size() == 0 { + t.Errorf("expected non-empty file, got 0 bytes") + } + if !strings.Contains(stderr.String(), "wrote") { + t.Errorf("expected stderr breadcrumb 'wrote N bytes to ...', got: %q", stderr.String()) + } +} + +func TestRun_BadFlag_ReturnsExit2(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"--no-such-flag"}, &stdout, &stderr, handlers.OpenAPISpecProduction) + if code != 2 { + t.Errorf("expected exit 2 for unknown flag, got %d", code) + } +} + +func TestRun_FileMode_UnwritableOutPath_ReturnsExit2(t *testing.T) { + // Portable: write into a path whose parent directory doesn't exist. + // os.WriteFile returns ENOENT on both macOS + Linux for this. + dir := t.TempDir() + target := filepath.Join(dir, "does", "not", "exist", "snap.json") + var stdout, stderr bytes.Buffer + code := run([]string{"-out", target}, &stdout, &stderr, handlers.OpenAPISpecProduction) + if code != 2 { + t.Errorf("expected exit 2 for unwritable -out, got %d (stderr: %s)", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "openapi-snapshot: write") { + t.Errorf("expected stderr to mention the write failure, got: %q", stderr.String()) + } +} + +// TestMain_DispatchesViaExitFn covers the main() entry-point line by +// swapping exitFn for a capture closure. os.Args[1:] in test context is +// the test framework's flag set, which flag.Parse rejects → run returns 2. +// We assert exitFn received that value (proving main correctly forwards +// run's return code), not the specific number. +func TestMain_DispatchesViaExitFn(t *testing.T) { + captured := -1 + orig := exitFn + exitFn = func(code int) { captured = code } + defer func() { exitFn = orig }() + main() + if captured < 0 { + t.Errorf("expected exitFn to be invoked, got captured=%d", captured) + } +} + +func TestRun_BadSpecSource_ReturnsExit2(t *testing.T) { + var stdout, stderr bytes.Buffer + badSpec := func() string { return "not json at all" } + code := run([]string{"-stdout"}, &stdout, &stderr, badSpec) + if code != 2 { + t.Errorf("expected exit 2 when specSource is malformed, got %d", code) + } + if !strings.Contains(stderr.String(), "not valid JSON") { + t.Errorf("expected stderr to mention 'not valid JSON', got: %q", stderr.String()) + } +} + +// failingWriter implements io.Writer and always returns an error. Used to +// drive the stdout-write-error branch of run(). +type failingWriter struct{ err error } + +func (w failingWriter) Write(_ []byte) (int, error) { return 0, w.err } + +func TestRun_StdoutMode_WriteError_ReturnsExit2(t *testing.T) { + stdout := failingWriter{err: errors.New("simulated stdout write failure")} + var stderr bytes.Buffer + code := run([]string{"-stdout"}, stdout, &stderr, handlers.OpenAPISpecProduction) + if code != 2 { + t.Errorf("expected exit 2 for stdout write failure, got %d (stderr: %s)", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "simulated stdout write failure") { + t.Errorf("expected stderr to wrap the write error, got: %q", stderr.String()) + } +} diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index 683ac59c..e8379b8c 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -139,6 +139,26 @@ func ServeOpenAPI(c *fiber.Ctx) error { return c.SendString(openAPISpecProd) } +// OpenAPISpecProduction returns the production-rendered OpenAPI 3.1 spec +// (with the dev-only /internal/set-tier path stripped) as a JSON string. +// +// This is the canonical accessor for cross-stack contract snapshotting — +// the cmd/openapi-snapshot tool writes its output to api/openapi.snapshot.json, +// which dashboard and instanode-web consume to generate typed clients. +// +// Why an accessor (rather than exporting the const): the production spec is +// the const minus the dev-only path entry. Callers that snapshot the raw const +// would ship a spec that lies about the prod surface — every consumer would +// generate a typed client for an endpoint that 404s in production. Routing +// the snapshot through this function keeps "what the snapshot says" == "what +// production serves". +func OpenAPISpecProduction() string { + openAPISpecOnce.Do(func() { + openAPISpecProd = stripInternalSetTierPath(openAPISpec) + }) + return openAPISpecProd +} + // openAPISpec is embedded at build time. It covers all stable, agent-facing endpoints. // Generated credentials and tier limits are documented here so AI agents can // consume instant.dev programmatically without reading the source code. diff --git a/internal/handlers/openapi_production_test.go b/internal/handlers/openapi_production_test.go new file mode 100644 index 00000000..fa57d299 --- /dev/null +++ b/internal/handlers/openapi_production_test.go @@ -0,0 +1,24 @@ +package handlers + +import ( + "strings" + "testing" +) + +func TestOpenAPISpecProduction_StripsInternalSetTier(t *testing.T) { + prod := OpenAPISpecProduction() + if prod == "" { + t.Fatal("OpenAPISpecProduction returned empty string") + } + if strings.Contains(prod, "/internal/set-tier") { + t.Errorf("production spec must not advertise /internal/set-tier (dev-only); found it in output") + } +} + +func TestOpenAPISpecProduction_StableAcrossCalls(t *testing.T) { + a := OpenAPISpecProduction() + b := OpenAPISpecProduction() + if a != b { + t.Errorf("OpenAPISpecProduction must be deterministic; got two different outputs") + } +} diff --git a/openapi.snapshot.json b/openapi.snapshot.json new file mode 100644 index 00000000..e2352518 --- /dev/null +++ b/openapi.snapshot.json @@ -0,0 +1,10116 @@ +{ + "components": { + "responses": { + "PayloadTooLarge": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "T19 P1-2 (BugHunt 2026-05-20): shared 413 response. Fiber's global BodyLimit is 50 MiB — exceeding it returns this JSON envelope (NOT the upstream nginx HTML 502 the older shape returned). Per-route handlers may cap further (e.g. /webhook/receive caps at 1 MiB); the envelope is identical regardless of which layer rejected the body." + }, + "TooManyRequests": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "T19 P1-1 (BugHunt 2026-05-20): shared 429 response. A global 100 req/min/IP rate-limit applies to EVERY route — this component documents the canonical envelope so callers don't have to re-discover it on each path. Per-route 429 entries (deploy daily cap, GitHub-webhook hourly cap, manual_backups_per_day, etc.) override with route-specific guidance but the wire shape stays the same. Retry-After header carries the wait in seconds; retry_after_seconds in the body mirrors it.", + "headers": { + "Retry-After": { + "description": "Seconds the caller should wait before retrying.", + "schema": { + "type": "integer" + } + } + } + } + }, + "schemas": { + "AuditExportItem": { + "description": "One row of the customer-facing audit export. The same shape underlies the JSON list endpoint and (one column per field) the CSV stream endpoint. actor_email_masked redacts to first-char + domain ('m***@example.com'); actor_user_id stays in full so the buyer can correlate against their own team-membership records. Internal-only rows (kind starts with 'admin.') are never returned.", + "properties": { + "actor_email_masked": { + "description": "Partial-redacted email of the acting user. Format: first character of local-part + '***' + '@' + full domain (e.g. 'm***@example.com'). Null when actor_user_id is null or the user row has been deleted.", + "type": [ + "string", + "null" + ] + }, + "actor_user_id": { + "description": "Null when the row came from a system actor (worker, billing webhook, dunning job).", + "format": "uuid", + "type": [ + "string", + "null" + ] + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "kind": { + "description": "Stable event kind. See internal/models/audit_kinds.go for the canonical list. W7-C added: resource.read, resource.list_by_team, connection_url.decrypted.", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "description": "Arbitrary k/v stamped at emit time. Per-kind shape — see individual emit sites.", + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "id", + "kind", + "created_at" + ], + "type": "object" + }, + "AuthMeResponse": { + "description": "Current user + team info. Shape matches handlers.GetCurrentUser. Several fields are emitted only conditionally — their absence is itself signal (e.g. is_platform_admin is never sent empty).", + "properties": { + "admin_path_prefix": { + "description": "Unguessable URL segment for the admin customer-management endpoints. Present only for admins when ADMIN_PATH_PREFIX is configured.", + "type": "string" + }, + "email": { + "type": "string" + }, + "experiments": { + "additionalProperties": { + "type": "string" + }, + "description": "A/B experiment variant assignments keyed by experiment name, bucketed by team_id.", + "type": "object" + }, + "impersonated_by": { + "description": "Email of the admin who started an impersonation session. Present only on impersonated sessions.", + "type": "string" + }, + "is_platform_admin": { + "description": "Present (always true) only when the caller's email is on the ADMIN_EMAILS allowlist. Absent for every non-admin caller.", + "type": "boolean" + }, + "ok": { + "type": "boolean" + }, + "plan_display_name": { + "description": "Human-readable plan name for the current tier (from plans.Registry).", + "type": "string" + }, + "read_only": { + "description": "Present (always true) only when the session JWT carries read_only=true — i.e. an admin impersonation session. Absent for normal sessions.", + "type": "boolean" + }, + "team_id": { + "format": "uuid", + "type": "string" + }, + "tier": { + "description": "The team's current plan tier.", + "enum": [ + "anonymous", + "free", + "hobby", + "hobby_plus", + "pro", + "team", + "growth" + ], + "type": "string" + }, + "user_id": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + }, + "BillingPaymentMethod": { + "description": "Payment method on file. null when the team has no Razorpay subscription, or has a subscription but no successful charge yet.", + "properties": { + "brand": { + "description": "Card network (e.g. 'visa', 'mastercard') — present only for type=card", + "type": [ + "string", + "null" + ] + }, + "last4": { + "description": "Last 4 digits — present only for type=card", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Razorpay payment method type", + "enum": [ + "card", + "upi", + "netbanking", + "wallet" + ], + "type": "string" + }, + "vpa": { + "description": "UPI VPA (e.g. 'name@hdfc') — present only for type=upi", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "BillingStateResponse": { + "description": "Aggregated billing state served by GET /api/v1/billing.", + "properties": { + "amount_inr": { + "description": "Monthly subscription amount in INR rupees (not paise). Sourced from the most recent paid invoice when available; falls back to the tier-derived price for brand-new subscriptions. null when no subscription on file", + "type": [ + "integer", + "null" + ] + }, + "billing_email": { + "description": "Owner's email — best-effort; empty string when no owner user row exists", + "type": "string" + }, + "next_renewal_at": { + "description": "ISO timestamp for next renewal (Razorpay current_end). null when no active subscription", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "ok": { + "type": "boolean" + }, + "payment_method": { + "oneOf": [ + { + "$ref": "#/components/schemas/BillingPaymentMethod" + }, + { + "type": "null" + } + ] + }, + "razorpay_customer_id": { + "description": "Razorpay customer id. Reserved for future use — always null today (Razorpay subscriptions don't require a pre-created customer record)", + "type": [ + "string", + "null" + ] + }, + "razorpay_subscription_id": { + "description": "Razorpay subscription id (sub_xxx). null until the team starts a checkout flow. Useful for support tickets", + "type": [ + "string", + "null" + ] + }, + "subscription_status": { + "description": "'none' when no Razorpay subscription exists; 'cancelled' when Razorpay reports cancelled / completed / expired or cancel_at_cycle_end=true; 'active' otherwise. The platform has no trial period (see policy memory project_no_trial_pay_day_one.md); hobby/pro/team are paid from day one", + "enum": [ + "none", + "active", + "cancelled" + ], + "type": "string" + }, + "tier": { + "description": "Current plan tier from the team record", + "enum": [ + "anonymous", + "free", + "hobby", + "hobby_plus", + "pro", + "team", + "growth" + ], + "type": "string" + } + }, + "required": [ + "ok", + "tier", + "subscription_status", + "billing_email" + ], + "type": "object" + }, + "BillingUsageResponse": { + "description": "Cached aggregate served by GET /api/v1/billing/usage. Replaces the prior client-side summation across /resources. Shared payload type for the cache layer (Redis JSON) and the public HTTP response, so a deploy-time shape change naturally invalidates older cache entries. -1 in any limit_bytes / limit field means 'unlimited' (matches the plans.yaml convention).", + "properties": { + "as_of": { + "description": "When the aggregation was computed. Useful for stale-while-revalidate displays and for debugging cache-vs-live discrepancies.", + "format": "date-time", + "type": "string" + }, + "freshness_seconds": { + "description": "Cache TTL window in seconds. Today 30 — matches the §13 freshness target and the Cache-Control max-age. Tune in one place: this field follows the server-side const.", + "type": "integer" + }, + "ok": { + "enum": [ + true + ], + "type": "boolean" + }, + "usage": { + "description": "Per-service metrics. Storage services carry { bytes, limit_bytes }. Count services carry { count, limit }. Fields are omitempty so the irrelevant one for each kind stays off the wire.", + "properties": { + "deployments": { + "$ref": "#/components/schemas/UsageMetric" + }, + "members": { + "$ref": "#/components/schemas/UsageMetric" + }, + "mongodb": { + "$ref": "#/components/schemas/UsageMetric" + }, + "postgres": { + "$ref": "#/components/schemas/UsageMetric" + }, + "redis": { + "$ref": "#/components/schemas/UsageMetric" + }, + "vault": { + "$ref": "#/components/schemas/UsageMetric" + }, + "webhooks": { + "$ref": "#/components/schemas/UsageMetric" + } + }, + "type": "object" + } + }, + "required": [ + "ok", + "freshness_seconds", + "as_of", + "usage" + ], + "type": "object" + }, + "CacheProvisionResponse": { + "properties": { + "claim_url": { + "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt.", + "format": "uri", + "type": "string" + }, + "connection_url": { + "description": "redis:// connection string with ACL namespace isolation. Use this from external callers.", + "type": "string" + }, + "dedicated": { + "description": "True when the resource was provisioned on dedicated (single-tenant) infrastructure rather than the shared pool. Authenticated provisions only.", + "type": "boolean" + }, + "env": { + "description": "Resolved environment bucket (defaults to 'development' when omitted).", + "type": "string" + }, + "env_override_reason": { + "description": "Present only when env was omitted and defaulted ('default_no_env_specified').", + "type": "string" + }, + "expires_at": { + "description": "Anonymous-tier only. RFC3339 24h-TTL expiry. T19 P0-2 (BugHunt 2026-05-20).", + "format": "date-time", + "type": "string" + }, + "id": { + "description": "Resource row id.", + "format": "uuid", + "type": "string" + }, + "internal_url": { + "description": "Cluster-internal redis:// URL routed via instant-redis-proxy. Use this when calling from a workload deployed inside the instanode cluster.", + "type": "string" + }, + "key_prefix": { + "description": "All keys must use this prefix for namespace isolation", + "type": "string" + }, + "limits": { + "properties": { + "expires_in": { + "type": "string" + }, + "memory_mb": { + "type": "integer" + } + }, + "type": "object" + }, + "name": { + "description": "Human-readable label supplied on the request (or the generated default).", + "type": "string" + }, + "note": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "tier": { + "type": "string" + }, + "token": { + "format": "uuid", + "type": "string" + }, + "upgrade": { + "description": "Anonymous-tier only. Pre-baked GET /start?t= URL for the dashboard claim flow.", + "format": "uri", + "type": "string" + }, + "upgrade_jwt": { + "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions.", + "type": "string" + }, + "warning": { + "description": "Present only when the resource is already over its storage limit at provision time — accompanied by the X-Instant-Notice: storage_limit_reached response header.", + "type": "string" + } + }, + "type": "object" + }, + "CapabilitiesResponse": { + "properties": { + "contact": { + "description": "Mailto link for enterprise inquiries.", + "type": "string" + }, + "docs": { + "description": "Pointer to the LLM-targeted product docs surface.", + "format": "uri", + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "tiers": { + "description": "Tier rows in upgrade-ladder order (anonymous first → team last).", + "items": { + "$ref": "#/components/schemas/TierCapabilities" + }, + "type": "array" + } + }, + "required": [ + "ok", + "tiers" + ], + "type": "object" + }, + "ClaimPreviewResponse": { + "properties": { + "expires_at": { + "description": "When the onboarding JWT itself expires (typically 7 days from issue). Unrelated to per-resource 24h TTL.", + "format": "date-time", + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "resources": { + "description": "All anonymous resources that this JWT would attach to the new team if /claim were posted.", + "items": { + "$ref": "#/components/schemas/ResourceItem" + }, + "type": "array" + }, + "token_valid": { + "description": "True when the onboarding JWT is well-formed, unexpired, and not yet claimed.", + "type": "boolean" + } + }, + "type": "object" + }, + "ClaimRequest": { + "description": "Body for POST /claim. The token field is the canonical field name (2026-05-20). The legacy jwt field is still accepted as a deprecated alias for backward compatibility with the dashboard, sdk-go, and existing curl recipes — when both are present, token wins.", + "properties": { + "email": { + "description": "RFC 5322 email address. Validated server-side via net/mail.ParseAddress — invalid syntax returns 400 with error=invalid_email_format.", + "format": "email", + "type": "string" + }, + "jwt": { + "deprecated": true, + "description": "Deprecated alias for token (kept for backward compatibility). New callers should send token instead.", + "type": "string" + }, + "team_name": { + "description": "Optional human-readable team name. Defaults to the email when omitted.", + "type": "string" + }, + "token": { + "description": "Onboarding token. Read this directly from the upgrade_jwt field of any anonymous provisioning response — no need to string-parse the upgrade URL.", + "type": "string" + } + }, + "required": [ + "token", + "email" + ], + "type": "object" + }, + "ClaimResponse": { + "properties": { + "message": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "session_token": { + "description": "24h JWT for immediate authenticated API use", + "type": "string" + }, + "team_id": { + "format": "uuid", + "type": "string" + }, + "user_id": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + }, + "ComponentStatus": { + "description": "One row of the /api/v1/status components array. last_24h_samples is exactly 96 booleans (96 x 15min = 24h), oldest first.", + "properties": { + "category": { + "description": "Ordering bucket. Render order: core then compute then edge.", + "enum": [ + "core", + "compute", + "edge" + ], + "type": "string" + }, + "current_status": { + "description": "Derived from the most recent 15-minute slot with data. 'operational' = 100% healthy probes; 'degraded' = at least 50% healthy; 'down' = less than 50%. No data falls open to 'operational'.", + "enum": [ + "operational", + "degraded", + "down" + ], + "type": "string" + }, + "description": { + "description": "Optional one-liner describing the component. Omitted when blank.", + "type": "string" + }, + "last_24h_samples": { + "description": "96 x 15-minute slots, oldest first. true = slot healthy; false = slot had at least one unhealthy probe. Empty slots inherit the previous slot's value to keep the bar continuous.", + "items": { + "type": "boolean" + }, + "maxItems": 96, + "minItems": 96, + "type": "array" + }, + "name": { + "description": "Display name for the dashboard's status page.", + "type": "string" + }, + "slug": { + "description": "Stable component identifier (e.g. 'api', 'provisioner', 'worker', 'marketing').", + "type": "string" + }, + "uptime_30d_pct": { + "description": "Percent healthy across the last 30 days. -1 sentinel = no samples in the window.", + "type": "number" + }, + "uptime_7d_pct": { + "description": "Percent healthy across the last 7 days. -1 sentinel = no samples in the window.", + "type": "number" + } + }, + "required": [ + "slug", + "name", + "category", + "current_status", + "uptime_7d_pct", + "uptime_30d_pct", + "last_24h_samples" + ], + "type": "object" + }, + "DBProvisionResponse": { + "properties": { + "claim_url": { + "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt.", + "format": "uri", + "type": "string" + }, + "connection_url": { + "description": "postgres:// connection string with pgvector pre-installed. Use this from external callers.", + "type": "string" + }, + "dedicated": { + "description": "True when the resource was provisioned on dedicated (single-tenant) infrastructure rather than the shared pool. Authenticated provisions only.", + "type": "boolean" + }, + "env": { + "description": "Resolved environment bucket the resource landed in (defaults to 'development' when env was omitted — see migration 026).", + "type": "string" + }, + "env_override_reason": { + "description": "Present only when the request omitted env and the API defaulted it (value 'default_no_env_specified'). Absent when env was sent explicitly.", + "type": "string" + }, + "expires_at": { + "description": "Anonymous-tier only. RFC3339 timestamp at which the resource auto-expires (24h TTL). Absent on authenticated provisions (no auto-expiry). Added by T19 P0-2 (BugHunt 2026-05-20) so the TTL contract matches storage/webhook.", + "format": "date-time", + "type": "string" + }, + "id": { + "description": "Resource row id.", + "format": "uuid", + "type": "string" + }, + "internal_url": { + "description": "Cluster-internal postgres:// URL routed via instant-pg-proxy. Use this when calling from a workload deployed inside the instanode cluster (e.g. an app started by /deploy/new) — the public hostname does not hairpin reliably.", + "type": "string" + }, + "limits": { + "properties": { + "connections": { + "type": "integer" + }, + "expires_in": { + "type": "string" + }, + "storage_mb": { + "type": "integer" + } + }, + "type": "object" + }, + "name": { + "description": "Human-readable label supplied on the request (or the generated default).", + "type": "string" + }, + "note": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "tier": { + "type": "string" + }, + "token": { + "format": "uuid", + "type": "string" + }, + "upgrade": { + "description": "Anonymous-tier only. Pre-baked GET /start?t= URL the agent can hand to the user to drive the dashboard claim flow.", + "format": "uri", + "type": "string" + }, + "upgrade_jwt": { + "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email to convert the anonymous resource into a claimed (authenticated) one — no need to string-parse the upgrade URL. Absent on authenticated provisions.", + "type": "string" + }, + "warning": { + "description": "Present only when the resource is already over its storage limit at provision time — accompanied by the X-Instant-Notice: storage_limit_reached response header.", + "type": "string" + } + }, + "type": "object" + }, + "DeployItem": { + "description": "Deployment row as returned by GET /deploy/{id} and the list endpoint. Shape matches handlers.deploymentToMap. The env field is redaction-filtered: credential-bearing values are masked '***' and the internal _name key is stripped.", + "properties": { + "allowed_ips": { + "description": "IP/CIDR allowlist for a private deployment. Always present (empty [] for public deploys).", + "items": { + "type": "string" + }, + "type": "array" + }, + "app_id": { + "description": "8-char public identifier used in the URL", + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "description": "Application env vars. Credential values are masked '***'; the internal _name key is never present.", + "type": "object" + }, + "environment": { + "description": "Environment scope (production / staging / dev / ...).", + "type": "string" + }, + "error": { + "description": "Present only when the deployment carries a non-empty error_message.", + "type": "string" + }, + "expires_at": { + "description": "Auto-expiry timestamp. Omitted entirely when ttl_policy is permanent.", + "format": "date-time", + "type": "string" + }, + "extend_ttl_url": { + "description": "Absolute URL to extend the auto-expiry window. Present only when expires_at is set.", + "type": "string" + }, + "failure": { + "description": "Structured failure autopsy. Present only when status is 'failed' and an autopsy row exists.", + "properties": { + "event": { + "description": "Raw error string from the failed stage.", + "type": "string" + }, + "hint": { + "description": "Human-readable remediation hint.", + "type": "string" + }, + "last_lines": { + "description": "Tail of the kaniko build log captured at failure time.", + "items": { + "type": "string" + }, + "type": "array" + }, + "occurred_at": { + "format": "date-time", + "type": "string" + }, + "reason": { + "description": "Failure classification (e.g. build_failed, deadline_exceeded).", + "type": "string" + } + }, + "type": "object" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "make_permanent_url": { + "description": "Absolute URL to convert an auto-expiring deploy to permanent. Present only when expires_at is set.", + "type": "string" + }, + "name": { + "description": "Human-readable label supplied at creation time (stored in env_vars._name; emitted as a top-level field for convenience). Empty string when created before mandatory-naming was enforced.", + "type": "string" + }, + "notify_attempts": { + "description": "Notify-webhook delivery attempt count. Present only when notify_webhook is configured.", + "type": "integer" + }, + "notify_secret_set": { + "description": "Whether a notify-webhook signing secret is configured. Present only when notify_webhook is configured.", + "type": "boolean" + }, + "notify_state": { + "description": "Lifecycle state of the notify webhook.", + "type": "string" + }, + "notify_webhook": { + "description": "Caller-supplied status webhook URL (echoed back; the plaintext secret is never returned).", + "type": "string" + }, + "port": { + "type": "integer" + }, + "private": { + "description": "True when the deployment is IP-allowlist gated (Pro+ feature).", + "type": "boolean" + }, + "provider_id": { + "description": "Opaque compute-backend handle (k8s namespace/deployment ref).", + "type": "string" + }, + "reminders_sent": { + "description": "Count of TTL-expiry reminder emails sent. Present only when expires_at is set.", + "type": "integer" + }, + "resource_id": { + "description": "Present only when the deployment is linked to a provisioned resource.", + "format": "uuid", + "type": "string" + }, + "status": { + "enum": [ + "building", + "deploying", + "healthy", + "failed", + "stopped", + "expired" + ], + "type": "string" + }, + "team_id": { + "format": "uuid", + "type": "string" + }, + "tier": { + "type": "string" + }, + "token": { + "description": "Public-facing alias for app_id (same 8-char value).", + "type": "string" + }, + "ttl_policy": { + "description": "Either 'permanent' or an auto-expiry policy. Always present so callers can branch on permanence.", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "DeployRequest": { + "properties": { + "allowed_ips": { + "description": "Comma-separated list of CIDRs or IP literals (e.g. \"1.2.3.4,10.0.0.0/8,2001:db8::/32\"). Required when private=true; max 32 entries. Each entry is validated via Go's net.ParseCIDR / net.ParseIP — invalid entries surface in the 400 message so an agent can fix the literal that broke. Larger allowlists belong in CF Access or a real VPN, not an nginx annotation.", + "type": "string" + }, + "env": { + "description": "Environment scope (production / staging / dev / ...). Defaults to 'development' when omitted (migration 026 — the resolved env is echoed back as 'environment' on the response so callers know which bucket they landed in).", + "type": "string" + }, + "env_vars": { + "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.", + "type": "string" + }, + "name": { + "description": "REQUIRED. Short human-readable label for this deployment (1-64 chars after trimming; must start with a letter or digit, then letters/digits/spaces/underscores/hyphens). Missing/empty → 400 name_required. Bad format/length → 400 invalid_name.", + "maxLength": 64, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9 _-]*$", + "type": "string" + }, + "notify_webhook": { + "description": "Optional https:// URL fired by POST when the deploy reaches a terminal state (status='healthy' or 'failed'). Lets callers subscribe instead of polling GET /deploy/:id. Rejected with 400 + agent_action if the URL is not https, the hostname is unresolvable, or resolves to a private/loopback/link-local/CGNAT IP (SSRF protection). Payload shape: { event: 'deploy.healthy' | 'deploy.failed', deploy_id, app_id, url, commit_id, build_time, duration_s, error_message? }. 2xx → notify_state='sent'; 4xx → 'failed' (no retry — user URL is broken); 5xx/network → up to 3 retries, then 'failed'.", + "type": "string" + }, + "notify_webhook_secret": { + "description": "Optional HMAC-SHA256 signing key. When set, every dispatch includes an X-InstaNode-Signature: sha256= header. Stored AES-256-GCM encrypted; plaintext never leaves the request. Omit to dispatch without a signature header.", + "type": "string" + }, + "port": { + "description": "Container port (default 8080)", + "type": "integer" + }, + "private": { + "description": "Optional flag (\"true\" / \"1\" / \"yes\") that turns this into a private deploy. When set, the resulting Ingress carries an nginx whitelist-source-range annotation built from allowed_ips. Pro / Team / Growth only — hobby/anonymous/free return 402 with agent_action: \"Tell the user private deploys require Pro tier. Upgrade at https://instanode.dev/pricing — takes 30 seconds.\"", + "type": "string" + }, + "resource_bindings": { + "description": "Optional JSON object mapping env-var-name to a resource reference. Values can be either 'family:' (resolved at submit time to the family member matching the deploy's env — one manifest works across all envs) or a raw resource-token UUID (legacy path; resolves to that specific resource regardless of env). Resolved values are merged into env_vars, with explicit env_vars taking precedence on key collision. Example: '{\"DATABASE_URL\":\"family:7a3f2c91-...\",\"REDIS_URL\":\"family:9bd5f3e0-...\"}'.", + "type": "string" + }, + "tarball": { + "description": "gzipped tar archive containing the Dockerfile + source (max 50 MB). When MINIO_ENDPOINT is configured the build context is uploaded to MinIO and kaniko pulls it via the S3 path; otherwise it falls back to a k8s Secret which caps at ~1 MiB.", + "format": "binary", + "type": "string" + }, + "ttl_policy": { + "description": "Wave FIX-J. Sets the deploy's lifecycle. 'auto_24h' (default for new deploys) means the deploy auto-expires 24h from creation; the response's agent_action sentence tells the LLM the three explicit routes to keep it permanent. 'permanent' opts the deploy out of TTL up front — useful for production deploys where the agent already knows the user wants it kept. Anonymous tier is FORCED to auto_24h regardless of caller intent. Team-wide default can be flipped via PATCH /api/v1/team/settings.", + "enum": [ + "auto_24h", + "permanent" + ], + "type": "string" + } + }, + "required": [ + "tarball", + "name" + ], + "type": "object" + }, + "DeployResponse": { + "properties": { + "agent_action": { + "description": "Wave FIX-J. Verbatim sentence the LLM agent relays to the user. Present on 202 responses when ttl_policy='auto_24h'; tells the user the three routes to keep the deploy permanent.", + "type": "string" + }, + "item": { + "properties": { + "allowed_ips": { + "description": "CIDRs / IPs whitelisted on the Ingress when private=true. Empty array on a public deploy.", + "items": { + "type": "string" + }, + "type": "array" + }, + "app_id": { + "description": "8-char public identifier used in the URL", + "type": "string" + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "description": "Env vars map — vault://KEY references resolve at deploy time", + "type": "object" + }, + "environment": { + "description": "Env scope (production/staging/dev). Note: 'env' on this object is the env_vars map, not the scope.", + "type": "string" + }, + "expires_at": { + "description": "Wave FIX-J. When the deploy auto-expires. Omitted when ttl_policy='permanent'.", + "format": "date-time", + "type": "string" + }, + "extend_ttl_url": { + "description": "Wave FIX-J. Absolute https URL the LLM agent can POST to with {hours} to set a custom TTL. Present when ttl_policy != 'permanent'.", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "make_permanent_url": { + "description": "Wave FIX-J. Absolute https URL the LLM agent can POST to in order to opt the deploy out of TTL. Present when ttl_policy != 'permanent'.", + "type": "string" + }, + "name": { + "description": "Human-readable label supplied at creation time (stored in env_vars._name; emitted as a top-level field for convenience). Empty string when created before mandatory-naming was enforced.", + "type": "string" + }, + "notify_attempts": { + "description": "Count of dispatch attempts made by the worker. Present only when notify_webhook is set. 5xx/network errors retry up to 3 times; 4xx is permanent.", + "type": "integer" + }, + "notify_secret_set": { + "description": "True when an HMAC signing secret was supplied at create time. Present only when notify_webhook is set. The plaintext secret is never returned.", + "type": "boolean" + }, + "notify_state": { + "description": "Lifecycle of the deploy-notify webhook. 'unset' = no URL configured. 'pending' = URL configured, awaiting terminal state (or worker dispatch). 'sent' = 2xx received. 'failed' = 4xx received OR 5xx/network exhausted retries.", + "enum": [ + "unset", + "pending", + "sent", + "failed" + ], + "type": "string" + }, + "notify_webhook": { + "description": "Echoed-back webhook URL when set on POST /deploy/new. Empty string when no webhook was configured for this deployment.", + "type": "string" + }, + "port": { + "type": "integer" + }, + "private": { + "description": "True when the Ingress is locked down via nginx whitelist-source-range. Pro / Team / Growth feature.", + "type": "boolean" + }, + "reminders_sent": { + "description": "Wave FIX-J. Count of reminder emails dispatched (0..6). Present when ttl_policy != 'permanent'.", + "type": "integer" + }, + "status": { + "enum": [ + "building", + "deploying", + "healthy", + "failed", + "stopped", + "expired" + ], + "type": "string" + }, + "team_id": { + "format": "uuid", + "type": "string" + }, + "tier": { + "type": "string" + }, + "ttl_policy": { + "description": "Wave FIX-J. Lifecycle policy. 'auto_24h' = expires 24h after creation (default). 'permanent' = no TTL. 'custom' = caller-set TTL via POST /api/v1/deployments/:id/ttl.", + "enum": [ + "auto_24h", + "permanent", + "custom" + ], + "type": "string" + }, + "url": { + "description": "Live HTTPS URL (set once status=healthy)", + "type": "string" + } + }, + "type": "object" + }, + "note": { + "type": "string" + }, + "ok": { + "type": "boolean" + } + }, + "type": "object" + }, + "DeploymentEvent": { + "description": "One row from the deployment_events table — the worker's autopsy / lifecycle record for a deployment. Today's writer is deploy_failure_autopsy + deploy_status_reconcile (kind='failure_autopsy'); the kind field is open-ended so future event types (e.g. 'lifecycle') can be added without breaking the schema.", + "properties": { + "created_at": { + "description": "When the worker wrote the autopsy row (RFC3339).", + "format": "date-time", + "type": "string" + }, + "event": { + "description": "k8s event reason or build error text. Empty string when no upstream event was captured.", + "type": "string" + }, + "exit_code": { + "description": "Process exit code when known (137 = SIGKILL, often OOM). null when the failure mode has no exit code (image pull failure, etc.).", + "type": [ + "integer", + "null" + ] + }, + "hint": { + "description": "Plain-language likely cause + suggested remedy. Sourced from models.HintForReason; safe to relay verbatim to the user.", + "type": "string" + }, + "kind": { + "description": "Event kind. Today: 'failure_autopsy'. Future kinds may include 'lifecycle'.", + "type": "string" + }, + "last_lines": { + "description": "Tail of Kaniko / pod stdout at the moment of failure capture, oldest-first. Up to ~200 lines. Empty array when no log lines were available (pod GC'd before capture).", + "items": { + "type": "string" + }, + "type": "array" + }, + "reason": { + "description": "Short slug describing the failure (e.g. 'kaniko_oom', 'image_pull_failed', 'OOMKilled', 'CrashLoopBackOff'). See models.FailureReason* constants for the closed set used by the failure_autopsy kind.", + "type": "string" + } + }, + "required": [ + "kind", + "reason", + "exit_code", + "event", + "last_lines", + "hint", + "created_at" + ], + "type": "object" + }, + "DeploymentEventsResponse": { + "description": "Response payload for GET /api/v1/deployments/{id}/events. Events are ordered by created_at DESC (most recent first). The count field is the length of the returned events array, NOT the total number of rows in deployment_events for this deployment — pagination is silent: callers wanting more than 200 rows must accept the cap.", + "properties": { + "count": { + "description": "Length of the events array. 0 when the deployment has no events yet (healthy / never-failed).", + "type": "integer" + }, + "deployment_id": { + "description": "The deployment's primary key UUID. Resolved from the app_id slug in the URL path.", + "format": "uuid", + "type": "string" + }, + "events": { + "items": { + "$ref": "#/components/schemas/DeploymentEvent" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok", + "deployment_id", + "events", + "count" + ], + "type": "object" + }, + "ErrorResponse": { + "description": "Canonical JSON shape returned by every 4xx/5xx response. Every error envelope carries request_id (echo of X-Request-ID, for support tickets), retry_after_seconds (null on 4xx → fix the request; int on 5xx → safe to retry after N seconds), and — for 5xx — an agent_action sentence the calling agent can show the user. For 429/502/503/504 the same retry value is also written to the Retry-After HTTP header so polite HTTP clients honor the wait without parsing the body. Backward-compatible: omitempty fields (agent_action, upgrade_url, request_id) are absent on the wire when empty.", + "properties": { + "agent_action": { + "description": "Optional. A sentence the calling agent should surface verbatim to the human user — e.g. 'Tell the user they've hit the hobby tier storage limit (500MB). Have them upgrade at https://instanode.dev/pricing to provision more storage.' Present on quota walls, invalid-token errors, permission-denied errors, expired-resource errors, tier-gate errors, AND on plumbing 5xx (where it falls back to a generic 'email support with this request_id' sentence).", + "type": "string" + }, + "claim_url": { + "description": "Optional. Present on error='free_tier_recycle_requires_claim' (402 from /db/new, /cache/new, /nosql/new, /queue/new, /storage/new, /webhook/new, /vector/new): the URL the anonymous caller should visit to claim their existing resources with email before they can provision again. DOG-21 (QA 2026-05-29): ALSO emitted on every successful 201 anonymous provision response under each service's response schema — agents can surface a claim CTA on first provision instead of waiting for the recycle gate. Distinct from upgrade_url — claim_url is about identity (anonymous → claimed), upgrade_url is about tier (claimed → paid). Both may be present on the same envelope.", + "format": "uri", + "type": "string" + }, + "error": { + "description": "Stable machine-readable error code (e.g. 'quota_exceeded', 'invalid_token', 'forbidden', 'storage_limit_reached'). Programmatic clients should branch on this.", + "type": "string" + }, + "message": { + "description": "Human-readable explanation of the error. May contain tier names, resource IDs, or other context. Not stable — use the 'error' code for programmatic decisions.", + "type": "string" + }, + "ok": { + "description": "Always false on error responses", + "enum": [ + false + ], + "type": "boolean" + }, + "request_id": { + "description": "Echo of the X-Request-ID header for this request. Stable correlator agents can quote when emailing support@instanode.dev — saves the user from copy/pasting headers.", + "type": "string" + }, + "retry_after_seconds": { + "description": "Seconds the agent should wait before retrying. null on 4xx (no retry — fix the request). int on transient 5xx: 30 for 503, 60 for 429, 10 for 502/504. For 429/502/503/504 the same value is also set in the Retry-After HTTP header.", + "type": [ + "integer", + "null" + ] + }, + "upgrade_url": { + "description": "Optional. Where the user can resolve the error — typically the pricing/upgrade page for quota walls and the login page for token errors. Present whenever following the URL would clear the error.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "ok", + "error", + "message", + "retry_after_seconds" + ], + "type": "object" + }, + "GitHubConnection": { + "description": "One link between a deployment and a GitHub repository. Surfaced by POST/GET /api/v1/deployments/{id}/github. The plaintext webhook_secret is NEVER part of this shape — it is returned exactly once on POST as a sibling field of the connection object.", + "properties": { + "app_id": { + "description": "Deployment short slug (e.g. '6fffcc21').", + "type": "string" + }, + "branch": { + "description": "Tracked branch. Pushes to other branches are ignored at receive time.", + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "github_repo": { + "description": "GitHub repository in 'owner/repo' form.", + "type": "string" + }, + "id": { + "description": "Connection id. Doubles as the webhook_id segment of the public receive URL.", + "format": "uuid", + "type": "string" + }, + "installation_id": { + "description": "Optional GitHub App installation id. Absent when plain-webhook flow was used.", + "format": "int64", + "type": "integer" + }, + "last_commit_sha": { + "description": "Most recent commit SHA we enqueued. Powers idempotency — a duplicate push.event with the same SHA is a no-op.", + "type": "string" + }, + "last_deploy_at": { + "description": "Most recent push that triggered a deploy. Absent when no push has arrived yet.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "id", + "app_id", + "github_repo", + "branch", + "created_at" + ], + "type": "object" + }, + "HealthResponse": { + "properties": { + "build_time": { + "description": "RFC3339 UTC timestamp when the running binary was built. Falls back to 'dev'.", + "type": "string" + }, + "commit_id": { + "description": "Short git SHA of the running binary (compiled via -ldflags). Falls back to 'dev' for un-instrumented builds.", + "type": "string" + }, + "migration_count": { + "description": "Total number of migrations recorded as applied in schema_migrations. 0 when migration_status='unknown'.", + "type": "integer" + }, + "migration_status": { + "description": "'ok' when the read against schema_migrations succeeded; 'unknown' when the DB was unreachable or the table is absent. The service still returns 200 OK in either case — this field surfaces tracking-read health independently of overall service health.", + "enum": [ + "ok", + "unknown" + ], + "type": "string" + }, + "migration_version": { + "description": "Filename of the highest-applied embedded migration recorded in the platform DB's schema_migrations table (e.g. '022_schema_migrations.sql'). Empty when migration_status='unknown'.", + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "service": { + "type": "string" + }, + "version": { + "description": "Build version tag from -ldflags. Falls back to 'dev'.", + "type": "string" + } + }, + "type": "object" + }, + "Incident": { + "description": "One incident row. The future incident-feed worker will populate these; today the items array is always empty.", + "properties": { + "id": { + "type": "string" + }, + "resolved_at": { + "description": "Omitted while status != 'resolved'.", + "format": "date-time", + "type": "string" + }, + "severity": { + "enum": [ + "info", + "minor", + "major", + "critical" + ], + "type": "string" + }, + "started_at": { + "format": "date-time", + "type": "string" + }, + "status": { + "enum": [ + "investigating", + "identified", + "monitoring", + "resolved" + ], + "type": "string" + }, + "summary": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "description": "Optional link to the public incident write-up.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "id", + "title", + "severity", + "status", + "started_at", + "summary" + ], + "type": "object" + }, + "IncidentsResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Incident" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "status_page": { + "description": "Companion human-readable status page.", + "format": "uri", + "type": "string" + }, + "total": { + "description": "Equal to items.length today; the field is reserved for future pagination.", + "type": "integer" + } + }, + "required": [ + "ok", + "items", + "total" + ], + "type": "object" + }, + "InvitationResponse": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "expires_at": { + "format": "date-time", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "role": { + "enum": [ + "admin", + "developer", + "viewer", + "member" + ], + "type": "string" + }, + "team_id": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + }, + "NoSQLProvisionResponse": { + "properties": { + "claim_url": { + "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt.", + "format": "uri", + "type": "string" + }, + "connection_url": { + "description": "mongodb:// connection string scoped to a per-token database. Use this from external callers.", + "type": "string" + }, + "dedicated": { + "description": "True when the resource was provisioned on dedicated (single-tenant) infrastructure rather than the shared pool. Authenticated provisions only.", + "type": "boolean" + }, + "env": { + "description": "Resolved environment bucket (defaults to 'development' when omitted).", + "type": "string" + }, + "env_override_reason": { + "description": "Present only when env was omitted and defaulted ('default_no_env_specified').", + "type": "string" + }, + "expires_at": { + "description": "Anonymous-tier only. RFC3339 24h-TTL expiry. T19 P0-2 (BugHunt 2026-05-20).", + "format": "date-time", + "type": "string" + }, + "id": { + "description": "Resource row id.", + "format": "uuid", + "type": "string" + }, + "internal_url": { + "description": "Cluster-internal mongodb:// URL routed via instant-mongo-proxy. Use this when calling from a workload deployed inside the instanode cluster.", + "type": "string" + }, + "limits": { + "properties": { + "connections": { + "type": "integer" + }, + "expires_in": { + "type": "string" + }, + "storage_mb": { + "type": "integer" + } + }, + "type": "object" + }, + "name": { + "description": "Human-readable label supplied on the request (or the generated default).", + "type": "string" + }, + "note": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "tier": { + "type": "string" + }, + "token": { + "format": "uuid", + "type": "string" + }, + "upgrade": { + "description": "Anonymous-tier only. Pre-baked GET /start?t= URL for the dashboard claim flow.", + "format": "uri", + "type": "string" + }, + "upgrade_jwt": { + "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions.", + "type": "string" + }, + "warning": { + "description": "Present only when the resource is already over its storage limit at provision time — accompanied by the X-Instant-Notice: storage_limit_reached response header.", + "type": "string" + } + }, + "type": "object" + }, + "OAuthProtectedResourceMetadata": { + "properties": { + "authorization_servers": { + "items": { + "type": "string" + }, + "type": "array" + }, + "bearer_methods_supported": { + "items": { + "enum": [ + "header" + ], + "type": "string" + }, + "type": "array" + }, + "resource": { + "description": "Canonical URL of this protected resource", + "type": "string" + }, + "resource_documentation": { + "type": "string" + } + }, + "type": "object" + }, + "ProvisionRequest": { + "properties": { + "env": { + "default": "development", + "description": "Optional environment scope (production / staging / dev / ...). Defaults to 'development' (migration 026) so accidental no-env provisions land in the lowest-stakes bucket. Anonymous tier is always 'development'. Every provisioning response echoes the resolved env so callers know which bucket they landed in.", + "type": "string" + }, + "name": { + "description": "REQUIRED. Short human-readable label for this resource (1-64 chars after trimming; must start with a letter or digit, then letters/digits/spaces/underscores/hyphens). Missing/empty → 400 name_required. Bad format/length → 400 invalid_name.", + "maxLength": 64, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9 _-]*$", + "type": "string" + }, + "parent_resource_id": { + "description": "Optional. Link the new resource into an existing env-twin family — the new row becomes a sibling of the parent (same family root, different env). Validated against same-team + same-type + no-duplicate-twin before provisioning. Authenticated callers only. Errors: 400 type_mismatch (parent is a different resource_type), 403 forbidden_parent_resource (parent belongs to another team), 404 parent_not_found, 409 twin_exists (family already has a row in this env). See GET /api/v1/resources/{id}/family + /api/v1/resources/families.", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "QueueProvisionResponse": { + "properties": { + "auth_mode": { + "description": "Credential isolation mode. 'isolated' = per-tenant NATS account JWT in credentials below; 'legacy_open' = grandfathered pre-cutover queue with no auth (will be recycled). MR-P0-5 (NATS per-tenant isolation, 2026-05-20).", + "enum": [ + "isolated", + "legacy_open" + ], + "type": "string" + }, + "claim_url": { + "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt.", + "format": "uri", + "type": "string" + }, + "connection_url": { + "description": "nats:// connection string. After the operator-mode cutover (MR-P0-5, 2026-05-20) this URL is unauthenticated by itself — pair it with the embedded JWT + NKey in the credentials field below.", + "type": "string" + }, + "credentials": { + "description": "Per-tenant NATS credentials. Present only when auth_mode='isolated'. Use either (nats_jwt + nats_nkey) via nats.UserJWTAndSeed() or write creds_file to disk and pass to nats.UserCredentials(path).", + "properties": { + "auth_mode": { + "type": "string" + }, + "creds_file": { + "description": "Pre-rendered .creds blob (combines JWT + NKey). Write to disk and pass path to nats.UserCredentials().", + "type": "string" + }, + "expires_at": { + "description": "Credential expiry. Omitted = long-lived.", + "format": "date-time", + "type": "string" + }, + "key_id": { + "description": "Account public key (A... format). Used by the platform for credential revocation.", + "type": "string" + }, + "nats_jwt": { + "description": "Signed user JWT scoped to this resource's subject prefix.", + "type": "string" + }, + "nats_nkey": { + "description": "User NKey seed (SU... format). SECRET — treat like a password.", + "type": "string" + } + }, + "type": "object" + }, + "dedicated": { + "description": "True when the resource was provisioned on dedicated (single-tenant) infrastructure rather than the shared pool.", + "type": "boolean" + }, + "env": { + "description": "Resolved environment bucket (defaults to 'development' when omitted).", + "type": "string" + }, + "env_override_reason": { + "description": "Present only when env was omitted and defaulted ('default_no_env_specified').", + "type": "string" + }, + "expires_at": { + "description": "Anonymous-tier only. RFC3339 24h-TTL expiry. T19 P0-2 (BugHunt 2026-05-20).", + "format": "date-time", + "type": "string" + }, + "id": { + "description": "Resource row id.", + "format": "uuid", + "type": "string" + }, + "internal_url": { + "description": "Cluster-internal nats:// URL routed via instant-nats-proxy. Use this when calling from a workload deployed inside the instanode cluster.", + "type": "string" + }, + "limits": { + "description": "Queue storage cap. storage_mb is read from plans.yaml for the resolved tier.", + "properties": { + "expires_in": { + "description": "Anonymous-only", + "type": "string" + }, + "storage_mb": { + "type": "integer" + } + }, + "type": "object" + }, + "name": { + "description": "Human-readable label supplied on the request (or the generated default).", + "type": "string" + }, + "note": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "subject_prefix": { + "description": "The subject namespace this resource is scoped to (e.g. 'tenant_.'). Publish/subscribe under {subject_prefix}* only.", + "type": "string" + }, + "tier": { + "type": "string" + }, + "token": { + "format": "uuid", + "type": "string" + }, + "upgrade": { + "description": "Anonymous-tier only. Pre-baked GET /start?t= URL for the dashboard claim flow.", + "format": "uri", + "type": "string" + }, + "upgrade_jwt": { + "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions.", + "type": "string" + } + }, + "type": "object" + }, + "ReadinessResponse": { + "description": "Multi-component readiness envelope returned by GET /readyz. Each check runs in parallel behind a 10-15s cache; overall summarises the worst-status across checks, applying the per-service criticality matrix (platform_db + provisioner_grpc are critical → failed → 503; everything else degrades to 200 + overall=degraded).", + "properties": { + "checks": { + "items": { + "properties": { + "last_check_at": { + "description": "RFC3339 UTC timestamp of the last probe (may be older than the request if the cache served this response).", + "format": "date-time", + "type": "string" + }, + "last_error": { + "description": "Last observed error message (only present when status != 'ok'). Scrubbed of credentials.", + "type": "string" + }, + "latency_ms": { + "description": "Wall-clock duration of the last probe in milliseconds.", + "type": "integer" + }, + "name": { + "description": "Stable component identifier (e.g. 'platform_db', 'provisioner_grpc', 'brevo', 'razorpay', 'redis', 'do_spaces', 'river').", + "type": "string" + }, + "status": { + "description": "Per-component status. Critical components only impact overall=failed; non-critical only impact overall=degraded.", + "enum": [ + "ok", + "degraded", + "failed" + ], + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "commit_id": { + "description": "Short git SHA of the running binary (same value as /healthz.commit_id).", + "type": "string" + }, + "overall": { + "description": "Aggregated status across all checks. 'ok' = every check ok; 'degraded' = at least one non-critical check failed/degraded; 'failed' = at least one critical check failed (response code is 503 only in this case).", + "enum": [ + "ok", + "degraded", + "failed" + ], + "type": "string" + }, + "service": { + "description": "Identifier for the service answering the probe (e.g. 'instant-api', 'instant-worker', 'instant-provisioner').", + "type": "string" + } + }, + "type": "object" + }, + "ResourceItem": { + "description": "Provisioned resource row. Shape matches handlers.resourceToMap. connection_url is NEVER included. Several fields are emitted only when their backing column is non-NULL.", + "properties": { + "cloud_vendor": { + "description": "Backing cloud vendor. Present only when known.", + "type": "string" + }, + "connections_limit": { + "description": "Tier connection ceiling. -1 means unlimited. From plans.Registry.", + "type": "integer" + }, + "country_code": { + "description": "ISO country code of the resource region. Present only when known.", + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "env": { + "description": "Environment scope (production / staging / dev / ...)", + "type": "string" + }, + "expires_at": { + "description": "Auto-expiry timestamp. Present only for anonymous/TTL'd resources.", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "name": { + "description": "Caller-supplied resource name. Present only when set.", + "type": "string" + }, + "paused_at": { + "description": "When the resource was paused. Present only when paused.", + "format": "date-time", + "type": "string" + }, + "resource_type": { + "enum": [ + "postgres", + "redis", + "mongodb", + "queue", + "storage", + "webhook", + "vector" + ], + "type": "string" + }, + "status": { + "type": "string" + }, + "storage_bytes": { + "description": "Current storage usage in bytes (scanner-updated).", + "type": "integer" + }, + "storage_exceeded": { + "description": "True when storage_bytes has reached storage_limit_bytes.", + "type": "boolean" + }, + "storage_limit_bytes": { + "description": "Tier storage ceiling in bytes (MiB-based). -1 means unlimited. From plans.Registry.", + "type": "integer" + }, + "team_id": { + "description": "Owning team. Present only for claimed (non-anonymous) resources.", + "format": "uuid", + "type": "string" + }, + "tier": { + "type": "string" + }, + "token": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + }, + "ResourceListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ResourceItem" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + }, + "StackRequest": { + "additionalProperties": { + "description": "One multipart field per service declared in the manifest, with the field NAME equal to the real service name (e.g. 'api' or 'web', NOT the literal placeholder '') and the VALUE a gzipped tar archive (≤50 MiB) containing that service's Dockerfile + source. Codegen clients should emit one upload field per manifest entry.", + "format": "binary", + "type": "string" + }, + "description": "Multipart form. The 'manifest' field is the YAML instant.yaml text; each service declared under services: must have a matching multipart field named after the service whose content is a gzipped tar archive of that service's build context. Codegen note: the dynamic per-service field is expressed via additionalProperties (OpenAPI cannot model literal-named fields whose names come from another field at runtime). Treat additionalProperties as: 'for every service S in manifest.services, send a multipart field named S whose value is the gzipped tar of S's build context.' DOG-30 (QA 2026-05-29).", + "properties": { + "manifest": { + "description": "instant.yaml contents. Example: services:\\n api:\\n build: ./api\\n port: 8080\\n web:\\n build: ./web\\n port: 8080\\n expose: true\\n env: { API_URL: service://api }", + "type": "string" + }, + "name": { + "description": "REQUIRED. Short human-readable label for this stack (1-64 chars after trimming; must start with a letter or digit, then letters/digits/spaces/underscores/hyphens). Missing/empty → 400 name_required. Bad format/length → 400 invalid_name.", + "maxLength": 64, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9 _-]*$", + "type": "string" + } + }, + "required": [ + "manifest", + "name" + ], + "type": "object" + }, + "StackResponse": { + "properties": { + "env": { + "description": "Resolved environment bucket the stack landed in (defaults to 'development' when env was omitted — see migration 026 and CLAUDE.md convention #11). T19 P0-3 (BugHunt 2026-05-20): handler echoes env (stack.go:811) so callers know which bucket they landed in.", + "type": "string" + }, + "expires_in": { + "description": "Anonymous stacks have a 24h TTL; authenticated stacks return empty.", + "type": "string" + }, + "name": { + "description": "Optional human-readable label (from manifest.name)", + "type": "string" + }, + "note": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "services": { + "items": { + "properties": { + "expose": { + "type": "boolean" + }, + "name": { + "description": "Service name from the manifest", + "type": "string" + }, + "port": { + "type": "integer" + }, + "status": { + "enum": [ + "building", + "deploying", + "healthy", + "failed", + "stopped" + ], + "type": "string" + }, + "url": { + "description": "Empty unless expose:true. Public HTTPS URL on *.deployment.instanode.dev — only the exposed service gets one; other services are reachable in-cluster only via http://:.", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "stack_id": { + "description": "Format: stk-<8-char-hex>. Use this for GET /stacks/{slug}.", + "type": "string" + }, + "status": { + "description": "Overall stack status. 'healthy' only when every service is healthy.", + "enum": [ + "building", + "deploying", + "healthy", + "failed", + "stopped" + ], + "type": "string" + }, + "tier": { + "type": "string" + } + }, + "type": "object" + }, + "StatusResponse": { + "properties": { + "as_of": { + "description": "Wall-clock at which the underlying aggregation ran. Stable across multiple replays of the same cache entry.", + "format": "date-time", + "type": "string" + }, + "components": { + "description": "Rendered in display order — core services first, then compute, then edge.", + "items": { + "$ref": "#/components/schemas/ComponentStatus" + }, + "type": "array" + }, + "current_incidents": { + "description": "Open incidents at the time of the snapshot. Today this is always empty — the field is reserved for the future incident-feed worker.", + "items": { + "$ref": "#/components/schemas/Incident" + }, + "type": "array" + }, + "freshness_seconds": { + "description": "Cache window the server enforces. Matches Cache-Control max-age.", + "type": "integer" + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok", + "freshness_seconds", + "as_of", + "components", + "current_incidents" + ], + "type": "object" + }, + "StorageProvisionResponse": { + "properties": { + "access_key_id": { + "description": "Present in credential modes only (shared-master-key / prefix-scoped / prefix-scoped-temporary). Omitted in broker mode.", + "type": "string" + }, + "agent_action": { + "description": "Machine-readable hint for an automated caller. Present only when mode=broker; value is 'use_presign_endpoint'.", + "type": "string" + }, + "broker_reason": { + "description": "Human-readable note explaining why broker mode was selected (e.g. 'backend-has-no-prefix-scoping'). Present only when mode=broker.", + "type": "string" + }, + "claim_url": { + "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt.", + "format": "uri", + "type": "string" + }, + "connection_url": { + "description": "Public bucket URL scoped to the per-token prefix", + "type": "string" + }, + "credentials_note": { + "description": "Present only on the rate-limited anonymous dedup response, where access_key_id/secret_access_key are NOT re-emitted (the secret is minted once at provision time and never stored).", + "type": "string" + }, + "endpoint": { + "description": "S3-compatible endpoint host (e.g. minio.instant-data.svc.cluster.local:9000 / r2.instanode.dev)", + "type": "string" + }, + "env": { + "description": "Resolved environment bucket (defaults to 'development' when omitted).", + "type": "string" + }, + "env_override_reason": { + "description": "Present only when env was omitted and defaulted ('default_no_env_specified').", + "type": "string" + }, + "expires_at": { + "description": "Anonymous-tier only. RFC3339 timestamp at which the resource auto-expires (24h TTL).", + "format": "date-time", + "type": "string" + }, + "id": { + "description": "Resource row id", + "format": "uuid", + "type": "string" + }, + "limits": { + "properties": { + "expires_in": { + "description": "Anonymous-only", + "type": "string" + }, + "storage_mb": { + "type": "integer" + } + }, + "type": "object" + }, + "mode": { + "description": "Isolation mode the tenant is on. 'shared-master-key' = DO Spaces legacy (every tenant holds the master key, prefix-by-convention). 'prefix-scoped' = backend IAM enforces s3:prefix against /* (R2, S3, MinIO). 'prefix-scoped-temporary' = same but credentials expire (STS). 'broker' = NO long-lived credential issued; use POST /storage/{token}/presign for short-lived signed URLs. 'dedicated-bucket' = reserved for the paid-tier-on-DO-Spaces flow (not yet auto-issued).", + "enum": [ + "shared-master-key", + "prefix-scoped", + "prefix-scoped-temporary", + "broker", + "dedicated-bucket" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "note": { + "description": "Anonymous-tier upgrade hint emitted on the 201 happy path (T19 P1-5, BugHunt 2026-05-20). Was previously undocumented; the schema only listed credentials_note which only appears on the dedup path.", + "type": "string" + }, + "note_isolation": { + "description": "Human-readable explanation of the isolation tradeoff. Present only when mode=broker.", + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "prefix": { + "description": "Object-key prefix all writes must use for isolation", + "type": "string" + }, + "presign_url": { + "description": "Path to the broker-mode access endpoint. Present only when mode=broker. POST to this URL with { operation, key, expires_in } to mint a short-lived signed URL.", + "type": "string" + }, + "secret_access_key": { + "description": "Shown ONCE — store now; rotation requires re-provisioning. Omitted in broker mode.", + "type": "string" + }, + "session_token": { + "description": "Present only when mode=prefix-scoped-temporary (R2 temp-creds / S3 STS). Pass this to your S3 SDK as the session token to complete the credential triple.", + "type": "string" + }, + "tier": { + "type": "string" + }, + "token": { + "format": "uuid", + "type": "string" + }, + "upgrade": { + "description": "Anonymous-tier only. Pre-baked GET /start?t= URL for the dashboard claim flow.", + "format": "uri", + "type": "string" + }, + "upgrade_jwt": { + "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions.", + "type": "string" + }, + "warning": { + "description": "Present only when the bucket is already over its storage limit at provision time — accompanied by the X-Instant-Notice: storage_limit_reached response header.", + "type": "string" + } + }, + "type": "object" + }, + "TeamSelf": { + "description": "Public-safe team record returned by GET /api/v1/team and PATCH /api/v1/team. Distinct from the cached aggregate at /api/v1/team/summary (counts panel) and the member roster at /api/v1/team/members.", + "properties": { + "created_at": { + "description": "When the team row was created. UTC, second precision.", + "format": "date-time", + "type": "string" + }, + "has_active_subscription": { + "description": "Mirror of teams.razorpay_subscription_id IS NOT NULL — true once the team has been wired to a Razorpay subscription.", + "type": "boolean" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "name": { + "description": "Display name. Empty string when never set.", + "type": "string" + }, + "plan_tier": { + "description": "Current plan tier. Source of truth: teams.plan_tier (Razorpay webhook authoritative). Values include anonymous, free, hobby, hobby_plus, growth, pro, team and their *_yearly variants.", + "type": "string" + } + }, + "required": [ + "id", + "name", + "plan_tier", + "has_active_subscription", + "created_at" + ], + "type": "object" + }, + "TeamSummaryResourceCounts": { + "description": "Per-type breakdown of active resources for one team. Produced by a single SELECT resource_type, COUNT(*) GROUP BY resource_type — cheaper than six separate COUNTs. Unknown resource_type rows fold into 'other' so the total stays accurate when a freshly-shipped service hasn't gotten a typed bucket yet.", + "properties": { + "mongodb": { + "type": "integer" + }, + "other": { + "description": "Catch-all for resource_type values this build doesn't recognise (e.g. a service shipped after the dashboard's TS types were generated). Always included in total.", + "type": "integer" + }, + "postgres": { + "type": "integer" + }, + "queue": { + "type": "integer" + }, + "redis": { + "type": "integer" + }, + "storage": { + "type": "integer" + }, + "total": { + "description": "Sum across every bucket (typed + other).", + "type": "integer" + }, + "webhook": { + "type": "integer" + } + }, + "required": [ + "total" + ], + "type": "object" + }, + "TeamSummaryResponse": { + "description": "Cached aggregate served by GET /api/v1/team/summary. Powers the dashboard sidebar's SidebarUpgradeCard and per-nav-row badge numbers. Eventual-consistent on purpose (5-min window) — do NOT use for quota gate decisions. Shared payload type for the Redis cache and the public response; a JSON shape change naturally invalidates older cache entries.", + "properties": { + "as_of": { + "description": "When the aggregation was computed.", + "format": "date-time", + "type": "string" + }, + "counts": { + "description": "Per-area counts. resources.total is the sum of every typed bucket plus 'other' — saves the dashboard from re-adding.", + "properties": { + "deployments": { + "description": "Active deployments. Excludes status IN ('deleted','stopped') — matches the dashboard's 'active deployments' framing.", + "type": "integer" + }, + "members": { + "description": "Team member count (including the caller).", + "type": "integer" + }, + "resources": { + "$ref": "#/components/schemas/TeamSummaryResourceCounts" + }, + "vault_keys": { + "description": "Total vault entries across every env this team owns.", + "type": "integer" + } + }, + "required": [ + "resources", + "deployments", + "members", + "vault_keys" + ], + "type": "object" + }, + "freshness_seconds": { + "description": "Cache TTL window in seconds. Today 300 — matches the server-side const and the Cache-Control max-age.", + "type": "integer" + }, + "ok": { + "enum": [ + true + ], + "type": "boolean" + }, + "tier": { + "description": "Current plan tier from the team record. Mirrored here so the sidebar doesn't need a second /billing fetch just to render the upgrade card. Values mirror teams.plan_tier — includes monthly canonical names and their *_yearly variants.", + "enum": [ + "anonymous", + "free", + "hobby", + "hobby_plus", + "growth", + "pro", + "team", + "hobby_yearly", + "hobby_plus_yearly", + "growth_yearly", + "pro_yearly", + "team_yearly" + ], + "type": "string" + } + }, + "required": [ + "ok", + "freshness_seconds", + "as_of", + "tier", + "counts" + ], + "type": "object" + }, + "TierCapabilities": { + "description": "Capability row for one tier in the /api/v1/capabilities matrix. Adding a new tier in plans.yaml automatically produces a new row.", + "properties": { + "annual_discount_percent": { + "description": "Discount percent of the {tier}_yearly variant vs 12x the monthly. 0 when no yearly variant exists.", + "type": "integer" + }, + "backup_restore_enabled": { + "type": "boolean" + }, + "backup_retention_days": { + "type": "integer" + }, + "connections_limit": { + "additionalProperties": { + "type": "integer" + }, + "description": "Per-service concurrent-connection cap. Keys mirror storage_limit_mb. -1 = unlimited.", + "type": "object" + }, + "deployments_apps": { + "description": "Max number of /deploy/new apps allowed. -1 = unlimited.", + "type": "integer" + }, + "display_name": { + "description": "Human-readable name for the tier, e.g. 'Hobby' or 'Pro'.", + "type": "string" + }, + "is_terminal_tier": { + "description": "True for the top tier in the rank ladder (Team today). When true, upgrade_url is null. Lets clients render an Upgrade CTA conditionally without string-matching tier names. DOG-26.", + "type": "boolean" + }, + "manual_backups_per_day": { + "type": "integer" + }, + "paid_from_day_one": { + "description": "True iff price_usd_monthly > 0. Mirrors project policy: no trial — paid tiers are paid from signup.", + "type": "boolean" + }, + "price_usd_monthly": { + "description": "Monthly price in whole USD (cents/100). 0 for free/anonymous tiers.", + "type": "integer" + }, + "rpo_minutes": { + "description": "Recovery Point Objective in minutes — the maximum window of data loss a restore can incur. 0 means no backup/RPO guarantee for the tier.", + "type": "integer" + }, + "rto_minutes": { + "description": "Recovery Time Objective in minutes — the target time to restore service after an incident. 0 means no RTO guarantee for the tier.", + "type": "integer" + }, + "storage_limit_mb": { + "additionalProperties": { + "type": "integer" + }, + "description": "Per-service storage cap in MB. Keys: postgres, redis, mongodb, queue, storage, webhook, vector. -1 sentinel means 'unlimited'.", + "type": "object" + }, + "tier": { + "description": "Canonical tier name (e.g. 'hobby', 'pro'). *_yearly variants are not surfaced; the canonical monthly tier represents the capability bundle.", + "type": "string" + }, + "upgrade_url": { + "description": "Pricing/upgrade URL for non-terminal tiers. null for the terminal tier (Team today) — there is nothing to upgrade to. Pairs with is_terminal_tier; SDKs/dashboards rendering an Upgrade CTA should suppress when null. DOG-26 (QA 2026-05-29).", + "format": "uri", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "tier", + "display_name", + "price_usd_monthly", + "paid_from_day_one", + "storage_limit_mb", + "connections_limit", + "deployments_apps", + "upgrade_url", + "is_terminal_tier" + ], + "type": "object" + }, + "UsageMetric": { + "description": "One service's slice of the usage aggregate. Either bytes/limit_bytes (storage services) or count/limit (deployments, webhooks, vault, members). -1 in a limit field means 'unlimited'.", + "properties": { + "bytes": { + "description": "Current storage usage in bytes. Present on postgres/redis/mongodb.", + "format": "int64", + "type": "integer" + }, + "count": { + "description": "Current count. Present on deployments/webhooks/vault/members.", + "type": "integer" + }, + "limit": { + "description": "Count cap from plans.yaml. -1 = unlimited.", + "type": "integer" + }, + "limit_bytes": { + "description": "Storage cap in bytes (plans.yaml storage_mb × 1024 × 1024). -1 = unlimited.", + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "VaultGetResponse": { + "properties": { + "env": { + "type": "string" + }, + "key": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "value": { + "description": "Decrypted plaintext", + "type": "string" + }, + "version": { + "type": "integer" + } + }, + "type": "object" + }, + "VaultPutResponse": { + "properties": { + "env": { + "type": "string" + }, + "key": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "version": { + "type": "integer" + } + }, + "type": "object" + }, + "VectorProvisionRequest": { + "description": "Request body for POST /vector/new. Like ProvisionRequest plus the optional dimensions hint. NOTE: unlike /db/new, the name field on /vector/new is optional — it is sanitized (invalid UTF-8 → 400 invalid_name) but a missing/empty name is accepted and a default label is generated. Send a name explicitly for parity with the other provisioning endpoints.", + "properties": { + "dimensions": { + "default": 1536, + "description": "Default embedding dimension for documentation. pgvector lets you pick per-column dimensions at table-create time, so this is purely informational. Defaults to 1536 (OpenAI text-embedding-ada-002 / text-embedding-3-small). Use 3072 for text-embedding-3-large.", + "maximum": 16000, + "minimum": 1, + "type": "integer" + }, + "env": { + "default": "development", + "description": "Optional environment scope. Defaults to 'development'.", + "type": "string" + }, + "name": { + "description": "Optional human-readable label. Sanitized server-side; invalid UTF-8 → 400 invalid_name. A missing/empty name is accepted (a default is generated) — this is the one provisioning endpoint where name is not required.", + "maxLength": 64, + "type": "string" + }, + "parent_resource_id": { + "description": "Optional family-link parent (authenticated callers only). See ProvisionRequest.", + "format": "uuid", + "type": "string" + } + }, + "type": "object" + }, + "VectorProvisionResponse": { + "properties": { + "claim_url": { + "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt.", + "format": "uri", + "type": "string" + }, + "connection_url": { + "description": "postgres:// connection string with the pgvector extension already installed (CREATE EXTENSION vector ran during provisioning). Use this from external callers.", + "type": "string" + }, + "dedicated": { + "description": "True when the resource was provisioned on dedicated (single-tenant) infrastructure rather than the shared pool. Authenticated provisions only.", + "type": "boolean" + }, + "dimensions": { + "description": "Echo of the requested dimensions hint (defaults to 1536). Informational only — pgvector enforces dimensions per column, not per database.", + "type": "integer" + }, + "env": { + "description": "Resolved environment bucket (defaults to 'development' when omitted).", + "type": "string" + }, + "env_override_reason": { + "description": "Present only when env was omitted and defaulted ('default_no_env_specified').", + "type": "string" + }, + "expires_at": { + "description": "Anonymous-tier only. RFC3339 24h-TTL expiry. T19 P0-2 (BugHunt 2026-05-20).", + "format": "date-time", + "type": "string" + }, + "extension": { + "description": "Always 'pgvector' for /vector/new. Declared so clients can confirm the extension is present without querying pg_extension.", + "enum": [ + "pgvector" + ], + "type": "string" + }, + "id": { + "description": "Resource row id.", + "format": "uuid", + "type": "string" + }, + "internal_url": { + "description": "Cluster-internal postgres:// URL routed via instant-pg-proxy. Use this when calling from a workload deployed inside the instanode cluster.", + "type": "string" + }, + "limits": { + "properties": { + "connections": { + "type": "integer" + }, + "expires_in": { + "type": "string" + }, + "storage_mb": { + "type": "integer" + } + }, + "type": "object" + }, + "name": { + "description": "Human-readable label supplied on the request (or the generated default).", + "type": "string" + }, + "note": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "tier": { + "type": "string" + }, + "token": { + "format": "uuid", + "type": "string" + }, + "upgrade": { + "description": "Anonymous-tier only. Pre-baked GET /start?t= URL for the dashboard claim flow.", + "format": "uri", + "type": "string" + }, + "upgrade_jwt": { + "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions.", + "type": "string" + }, + "warning": { + "description": "Present only when the resource is already over its storage limit at provision time — accompanied by the X-Instant-Notice: storage_limit_reached response header.", + "type": "string" + } + }, + "type": "object" + }, + "WebhookProvisionResponse": { + "properties": { + "claim_url": { + "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt.", + "format": "uri", + "type": "string" + }, + "env": { + "description": "Resolved environment bucket (defaults to 'development' when omitted).", + "type": "string" + }, + "env_override_reason": { + "description": "Present only when env was omitted and defaulted ('default_no_env_specified').", + "type": "string" + }, + "expires_at": { + "format": "date-time", + "type": "string" + }, + "id": { + "description": "Resource row id.", + "format": "uuid", + "type": "string" + }, + "limits": { + "properties": { + "expires_in": { + "type": "string" + }, + "requests_stored": { + "type": "integer" + } + }, + "type": "object" + }, + "name": { + "description": "Human-readable label supplied on the request (T19 P1-6 / T14, BugHunt 2026-05-20). Mandatory on input; now echoed in the response so the field is round-trippable.", + "type": "string" + }, + "note": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "receive_url": { + "description": "Public URL that accepts any HTTP method and stores the payload", + "type": "string" + }, + "tier": { + "type": "string" + }, + "token": { + "format": "uuid", + "type": "string" + }, + "upgrade": { + "description": "Anonymous-tier only. Pre-baked GET /start?t= URL for the dashboard claim flow.", + "format": "uri", + "type": "string" + }, + "upgrade_jwt": { + "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions.", + "type": "string" + } + }, + "type": "object" + }, + "WhoamiResponse": { + "properties": { + "email": { + "description": "Authenticated user's email. Best-effort enrichment from the users table; absent on DB lookup failure.", + "format": "email", + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "plan_tier": { + "description": "Legacy alias of tier kept for agents that already key off it. Best-effort enrichment from the teams table; absent on DB lookup failure", + "enum": [ + "anonymous", + "free", + "hobby", + "hobby_plus", + "pro", + "team", + "growth" + ], + "type": "string" + }, + "team_id": { + "format": "uuid", + "type": "string" + }, + "team_name": { + "description": "Present only when the team has a non-empty name", + "type": "string" + }, + "tier": { + "description": "Canonical alias of plan_tier — the dashboard's preferred field name. Best-effort enrichment from the teams table; absent on DB lookup failure.", + "enum": [ + "anonymous", + "free", + "hobby", + "hobby_plus", + "pro", + "team", + "growth" + ], + "type": "string" + }, + "user_id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "ok", + "user_id", + "team_id" + ], + "type": "object" + } + }, + "securitySchemes": { + "bearerAuth": { + "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.", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "Zero-friction developer infrastructure. Provision real databases, caches, and queues with a single HTTP call — no account, no Docker, no setup.\n\n## Idempotency\n\nEvery POST endpoint that creates a resource is idempotent. Two layered protections cover every retry pattern:\n\n1. Explicit Idempotency-Key header (Stripe-shape, 24h TTL). Pass the same opaque key on each retry of a logical operation and the server replays the first response verbatim. Reusing a key with a different body returns 409.\n2. Body-fingerprint fallback (120s TTL). When the header is absent, the server synthesises a key from sha256(scope, route, canonical-body) and dedups identical retries inside a 120s window. Absorbs double-clicks, mobile double-taps, agent retries on transient 5xx, and reverse-proxy retries on network blips. Use the explicit header for true exactly-once across longer windows.\n\nEvery response from a create endpoint carries:\n- X-Idempotency-Source: explicit | fingerprint | miss — which dedup path matched (explicit = caller passed an Idempotency-Key; fingerprint = the body-fingerprint cache replayed; miss = handler ran fresh).\n- X-Idempotent-Replay: true — present only when the response was served from the cache (either path).\n\n## Rate limit (applies to every route)\n\nA global per-IP rate limit (100 req/min) is applied to EVERY documented endpoint by the router middleware. Exceeding it returns 429 with the standard ErrorResponse envelope (error=rate_limited), a Retry-After HTTP header, and retry_after_seconds in the JSON body. The per-route response maps below may omit 429 for brevity; the canonical 429 shape is documented under components.responses.TooManyRequests and applies to every path. T19 P1-1 (BugHunt 2026-05-20).\n\n## Payload size (applies to every route)\n\nFiber's global BodyLimit is set to 50 MiB — only /deploy/new and /stacks/new (multipart tarballs) and /webhooks/github/* (push payloads) approach that cap; JSON endpoints are bounded to sub-KB bodies by the per-handler shape. Oversized requests return 413 payload_too_large with the standard JSON ErrorResponse envelope (NOT the upstream nginx HTML 502 the older shape returned — T19 P1-2). The canonical 413 shape is documented under components.responses.PayloadTooLarge.\n\n## Security headers (applies to every response)\n\nEvery response from EVERY route — including liveness/readiness probes, OpenAPI document fetch, 4xx error envelopes, 5xx error envelopes, and 404/405 Fiber-default responses — carries the following defense-in-depth response headers, set by the SecurityHeaders middleware ahead of RequestID in the router middleware chain (task #311 wave-3 chaos-verify redo):\n\n- Strict-Transport-Security: max-age=63072000; includeSubDomains — production only (omitted on ENVIRONMENT=development so local http://localhost:8080 doesn't poison the host's HSTS cache). 2-year max-age, includeSubDomains for *.api.instanode.dev.\n- X-Content-Type-Options: nosniff — disables MIME sniffing.\n- X-Frame-Options: SAMEORIGIN — clickjacking defense.\n- Referrer-Policy: strict-origin-when-cross-origin — prevents URL-token leakage across origin downgrades.\n- Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=() — denies powerful browser APIs.\n- Cross-Origin-Resource-Policy: same-origin — blocks no-cors cross-origin fetches.\n\nThese headers are not enumerated in each per-route responses block to keep the spec readable; they apply globally. Coverage test: TestSecurityHeaders_AllEndpoints_AllHeaders_Prod (internal/handlers/security_headers_test.go) iterates 5 representative endpoints (healthz, readyz, openapi.json, db/new, claim) and asserts all 6 headers land on every response.", + "title": "InstaNode API", + "version": "1.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/.well-known/oauth-protected-resource": { + "get": { + "description": "Discovery document used by MCP clients to obtain authorization metadata. Public, no auth required.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthProtectedResourceMetadata" + } + } + }, + "description": "Metadata document" + } + }, + "summary": "OAuth 2.0 Protected Resource Metadata (RFC 9728)" + } + }, + "/api/v1/audit": { + "get": { + "description": "Returns audit events scoped to the caller's team for compliance review. Includes rows where team_id = caller_team OR metadata.resource_id resolves to a resource the caller owns. Rows whose kind starts with 'admin.' are NEVER returned regardless of tier — those are reserved for the internal operator audit feed (compliance traceability for operator activity is handled through a separate channel). Pagination is cursor-style via ?before=. The response body echoes the resolved lookback_days so the caller knows the tier window. Actor emails are partially redacted on the wire ('m***@example.com') to balance traceability against PII exposure; user_id stays in full so the buyer can correlate against their own team-membership records. Emit sites include the existing onboarding.claimed, subscription.*, promote.*, payment.grace_* kinds plus W7-C-added data-access kinds resource.read, resource.list_by_team, connection_url.decrypted. Tier gate: anonymous/free → 402, hobby = 30d lookback, hobby_plus = 60d, pro = 90d, growth/team = unlimited.", + "parameters": [ + { + "description": "Page size. Default 50, max 200. The endpoint returns at most this many rows per call; use ?before= to fetch older rows.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Cursor — only return rows with created_at strictly older than this RFC3339 timestamp. Pass the previous response's next_cursor field here.", + "in": "query", + "name": "before", + "required": false, + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "description": "Exact kind match (e.g. 'resource.read', 'subscription.upgraded'). Admin.* kinds always return zero rows even when explicitly requested.", + "in": "query", + "name": "kind", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Inclusive lower bound (RFC3339). The tier lookback floor still wins — if you ask for a wider window than your plan allows you only see your plan's window.", + "in": "query", + "name": "since", + "required": false, + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "description": "Exclusive upper bound (RFC3339).", + "in": "query", + "name": "until", + "required": false, + "schema": { + "format": "date-time", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AuditExportItem" + }, + "type": "array" + }, + "lookback_days": { + "description": "Plan-derived hard floor. -1 means unlimited (growth/team).", + "type": "integer" + }, + "next_cursor": { + "description": "Pass to ?before= on the next call. Null when this is the last page (the page wasn't full).", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "ok": { + "type": "boolean" + }, + "tier": { + "description": "The caller's resolved plan tier at request time.", + "type": "string" + }, + "total_returned": { + "description": "Number of items in this page.", + "type": "integer" + } + }, + "type": "object" + } + } + }, + "description": "Audit event list" + }, + "400": { + "description": "Invalid query parameter (e.g. malformed ?before / ?since / ?until — must be RFC3339)." + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Plan does not include audit export. Anonymous/free → upgrade required." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Customer-facing audit log export (W7-C compliance)" + } + }, + "/api/v1/audit.csv": { + "get": { + "description": "Same filter/scope/redaction rules as GET /api/v1/audit, but the response is streamed text/csv suitable for piping into a customer's own SIEM. Columns: id, kind, created_at, actor, actor_user_id, actor_email_masked, resource_id, resource_type, summary, metadata. Streaming guarantees: rows are encoded + flushed one at a time so a Team-tier customer with months of history does not OOM the api pod. The same admin.* exclusion and tier lookback floor apply.", + "parameters": [ + { + "description": "Per-call cap. CSV defaults to the max (200) because there is no client-friendly cursor in CSV — pass ?before/?since/?until for additional chunks.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 200, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "in": "query", + "name": "before", + "required": false, + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "in": "query", + "name": "kind", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "since", + "required": false, + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "in": "query", + "name": "until", + "required": false, + "schema": { + "format": "date-time", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/csv": { + "schema": { + "type": "string" + } + } + }, + "description": "Audit event CSV. Header row is always emitted. Content-Disposition: attachment; filename=\"audit.csv\"." + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Plan does not include audit export. Anonymous/free → upgrade required." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Customer-facing audit log export — CSV stream (W7-C compliance)" + } + }, + "/api/v1/auth/api-keys": { + "get": { + "description": "Returns metadata only — plaintext keys are never echoed back. Each item has id, name, scopes, created_at, last_used_at (nullable), and revoked.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "items": { + "items": { + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "last_used_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "revoked": { + "type": "boolean" + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "API key list (metadata only)" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List Personal Access Tokens for the team" + }, + "post": { + "description": "Creates a long-lived bearer token bound to the caller's team. The plaintext key is returned ONCE in the response and never shown again — the DB stores only its SHA-256 hash. PATs cannot mint other PATs (the request fails with 403 when the caller is themselves a PAT, not a user session). Scopes default to full team access; pass scopes:['read','write','admin'] to limit.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "description": "Human-readable label, e.g. 'laptop' or 'github-actions'", + "maxLength": 120, + "type": "string" + }, + "scopes": { + "items": { + "enum": [ + "read", + "write", + "admin" + ], + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "name" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "key": { + "description": "Plaintext bearer token — copy now, never shown again", + "type": "string" + }, + "name": { + "type": "string" + }, + "note": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "Key created — plaintext returned exactly once" + }, + "400": { + "description": "Body invalid, missing name, name too long, or invalid scope" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "PAT-creating-a-PAT is forbidden — use a user session" + }, + "503": { + "description": "Token generation or DB write failed" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Mint a Personal Access Token (long-lived bearer for agents/CI)" + } + }, + "/api/v1/auth/api-keys/{id}": { + "delete": { + "description": "Soft-deletes the key (sets revoked_at = now()). Tokens that have been revoked fail subsequent auth checks immediately.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Revoked" + }, + "400": { + "description": "Path id is not a UUID" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Key not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Revoke a Personal Access Token" + } + }, + "/api/v1/billing": { + "get": { + "description": "One-shot fetch that powers the dashboard's billing view: current tier, Razorpay subscription status, next renewal timestamp, monthly amount, and the payment method on file. Returns 200 with sensibly-defaulted nulls for teams without a Razorpay subscription yet — callers can render the 'no subscription' UI without branching on error.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BillingStateResponse" + } + } + }, + "description": "Aggregated billing state" + }, + "401": { + "description": "Missing or invalid session token" + }, + "404": { + "description": "Team not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Aggregated billing state for the authenticated team" + } + }, + "/api/v1/billing/change-plan": { + "post": { + "description": "Hobby ↔ Hobby Plus ↔ Pro ↔ Team on the same Razorpay subscription. Proration is handled by Razorpay; the new plan takes effect at the end of the current billing period. Team-tier ChangePlan was enabled 2026-05-29 (CEO BIZ-1) alongside Team checkout.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "plan": { + "enum": [ + "hobby", + "hobby_plus", + "pro", + "team" + ], + "type": "string" + } + }, + "required": [ + "plan" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Plan change accepted by Razorpay" + }, + "400": { + "description": "Invalid plan" + }, + "401": { + "description": "Missing or invalid session token" + }, + "404": { + "description": "No active subscription" + }, + "503": { + "description": "Razorpay not configured" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Switch the team's subscription to a different tier" + } + }, + "/api/v1/billing/checkout": { + "post": { + "description": "Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, pro, or team) tied to the authenticated team. The dashboard redirects the user to the returned short_url to complete payment; on success Razorpay fires subscription.activated AND subscription.charged to /razorpay/webhook — both trigger the same idempotent tier-elevation path so the team is upgraded as soon as the mandate is authorised, even before the first invoice is collected. Team-tier checkout was enabled 2026-05-29 (CEO BIZ-1) — if RAZORPAY_PLAN_ID_TEAM / _TEAM_ANNUAL is unset on the environment the request returns 503 billing_not_configured (operator signal), not 400 tier_unavailable. plan_frequency selects monthly (default) vs yearly billing — yearly returns 503 billing_not_configured until the operator creates the yearly Razorpay plan and sets RAZORPAY_PLAN_ID_*_YEARLY. promotion_code: admin-issued codes are bookmarked in the subscription notes for future discount wiring (no Razorpay Offer is applied yet — codes are not consumed until a real discount is confirmed). IDEMPOTENT: the endpoint never mints a second subscription for a team that already has a live one — if the team already holds the requested tier (or higher) it returns 400 already_on_plan, and if a prior checkout's subscription is still payable at Razorpay (status created/authenticated/pending) it returns that subscription's short_url with reused:true instead of creating a new one. This prevents a confused re-click from producing two parallel subscriptions that both charge the card.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "plan": { + "enum": [ + "hobby", + "hobby_plus", + "pro", + "team" + ], + "type": "string" + }, + "plan_frequency": { + "default": "monthly", + "description": "Billing cycle. Empty = monthly. Yearly variants follow the same canonical-tier mapping on the webhook side — teams.plan_tier still stores the bare tier name.", + "enum": [ + "monthly", + "yearly" + ], + "type": "string" + } + }, + "required": [ + "plan" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "reused": { + "description": "Present and true only when an existing still-payable subscription was returned instead of minting a new one.", + "type": "boolean" + }, + "short_url": { + "format": "uri", + "type": "string" + }, + "subscription_id": { + "type": "string" + }, + "traffic_env": { + "description": "Derived from the configured RAZORPAY_KEY_ID prefix (rzp_live_* → production, rzp_test_* → test). The raw key value is NEVER exposed in any response. Use this to detect a staging deployment accidentally pointing at the live key (which is also enforced server-side via 503 billing_misconfigured).", + "enum": [ + "production", + "test" + ], + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Subscription created (or an existing live one reused) — redirect user to short_url. reused:true means the short_url belongs to a checkout the team started earlier and no new subscription was minted. traffic_env reports whether this deployment talks to the LIVE or TEST Razorpay environment (derived from the RAZORPAY_KEY_ID prefix) — agents and the SPA branch on it without ever seeing the raw key." + }, + "400": { + "description": "Invalid plan, invalid plan_frequency, or already_on_plan (the team already holds the requested tier or higher)" + }, + "401": { + "description": "Missing or invalid session token" + }, + "502": { + "description": "Razorpay rejected the create-subscription call" + }, + "503": { + "description": "Razorpay not configured on this environment (incl. yearly plan_id unset) OR a LIVE Razorpay key (rzp_live_*) is paired with a non-production deployment (billing_misconfigured — operator must rotate to a test key or set ENVIRONMENT=production)" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create a Razorpay subscription and return its hosted-page URL" + } + }, + "/api/v1/billing/invoices": { + "get": { + "description": "Returns up to the last 24 invoices from Razorpay for the team's subscription, newest first. Each entry includes id, amount (paise), currency, and status. Returns an empty array when the team has no subscription yet.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "invoices": { + "items": { + "properties": { + "amount": { + "description": "Amount in paise (INR×100)", + "type": "integer" + }, + "currency": { + "type": "string" + }, + "id": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Invoice list" + }, + "401": { + "description": "Missing or invalid session token" + }, + "503": { + "description": "Razorpay not configured on this environment" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List the team's invoices" + } + }, + "/api/v1/billing/promotion/validate": { + "post": { + "description": "HTTP wrapper around the plans-registry ValidatePromotion check. Accepts {code, plan} and returns either a structured discount payload (200 + ok:true) or a typed rejection (200 + ok:false with error/message/agent_action). Rejections deliberately return 200 — the dashboard's PromoCodePanel can render the red state through its normal success-path parser without a catch on the fetch promise. MCP/CLI agents read agent_action for the LLM-ready copy. Rate-limited at 30 validations/team/hour to make brute-forcing the seed-code namespace impractical; the limiter scopes per team so multiple developers on one team share the bucket. Codes are case-insensitive — the response echoes the canonical uppercase code.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "Promotion code (case-insensitive)", + "example": "LAUNCH50", + "type": "string" + }, + "plan": { + "description": "Plan tier the discount must apply to", + "enum": [ + "hobby", + "hobby_plus", + "pro", + "team" + ], + "type": "string" + } + }, + "required": [ + "code", + "plan" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "expired": { + "summary": "Code matched the registry but its expires_at is in the past", + "value": { + "agent_action": "Tell the user this promo code isn't valid for the requested plan. Have them try a different code at https://instanode.dev/billing — promotion codes are case-insensitive.", + "error": "promotion_expired", + "message": "Promotion code \"LAUNCH50\" has expired.", + "ok": false + } + }, + "invalid": { + "summary": "Unknown code or wrong plan", + "value": { + "agent_action": "Tell the user this promo code isn't valid for the requested plan. Have them try a different code at https://instanode.dev/billing — promotion codes are case-insensitive.", + "error": "promotion_invalid", + "message": "Promotion code \"SAVE20\" is not valid for the pro plan.", + "ok": false + } + }, + "valid": { + "summary": "Valid code for the requested plan", + "value": { + "code": "LAUNCH50", + "discount": { + "applies_to": [ + "pro", + "team" + ], + "description": "50% off Pro or Team for the first 1000 signups", + "kind": "percent_off", + "max_uses": 1000, + "value": 50 + }, + "ok": true, + "valid_until": "2026-12-31T23:59:59Z" + } + } + } + } + }, + "description": "Either a valid discount (ok:true) or a typed rejection (ok:false). The dashboard branches on the ok field, not the status code." + }, + "400": { + "description": "Empty code, missing plan, or malformed JSON body" + }, + "401": { + "description": "Missing or invalid session token" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Team exceeded 30 validations per hour. Wait for the next hourly bucket." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Validate a promotion code against a target plan" + } + }, + "/api/v1/billing/update-payment": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "short_url": { + "format": "uri", + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Hosted page URL" + }, + "401": { + "description": "Missing or invalid session token" + }, + "404": { + "description": "No active subscription" + }, + "503": { + "description": "Razorpay not configured" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Return a Razorpay hosted-page URL the user can use to update their card on file" + } + }, + "/api/v1/billing/usage": { + "get": { + "description": "One-shot fetch that powers the dashboard's BillingPage Usage panel. Replaces the prior pattern of summing storage_bytes per type in the browser after pulling the full /resources list. The aggregation runs once per team per 30s cache window and is shared across every surface (BillingPage today, future MCP agent_usage_summary tool). Real-time provisioning paths (POST /db/new etc.) MUST NOT use this aggregate — they read fresh DB state. Response shape: { ok, freshness_seconds, as_of, usage: { postgres, redis, mongodb, deployments, webhooks, vault, members } }. Storage services carry { bytes, limit_bytes }; count services carry { count, limit }. -1 in any limit field means 'unlimited' (matches plans.yaml). Cache-Control: private, max-age=30, stale-while-revalidate=60 — browsers + intermediate proxies honour the same window without hammering the API.", + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "as_of": "2026-05-12T00:00:00Z", + "freshness_seconds": 30, + "ok": true, + "usage": { + "deployments": { + "count": 1, + "limit": 1 + }, + "members": { + "count": 1, + "limit": 1 + }, + "mongodb": { + "bytes": 0, + "limit_bytes": 104857600 + }, + "postgres": { + "bytes": 12582912, + "limit_bytes": 524288000 + }, + "redis": { + "bytes": 0, + "limit_bytes": 26214400 + }, + "vault": { + "count": 5, + "limit": 50 + }, + "webhooks": { + "count": 3, + "limit": 1000 + } + } + }, + "schema": { + "$ref": "#/components/schemas/BillingUsageResponse" + } + } + }, + "description": "Aggregated usage payload", + "headers": { + "Cache-Control": { + "description": "Per-team payload — private (no shared proxies). 30s max-age matches the server-side cache; 60s SWR gives the browser a grace window where stale values render while a background refresh runs.", + "schema": { + "example": "private, max-age=30, stale-while-revalidate=60", + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Missing or invalid session token. Response includes agent_action pointing the user at https://instanode.dev/login." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Failed to compute usage (transient DB error). Retry with backoff." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Aggregated usage metrics for the authenticated team (cached)" + } + }, + "/api/v1/capabilities": { + "get": { + "description": "Returns the full tier matrix as JSON so AI agents can discover 'what can I do at which tier' without provisioning-and-failing or scraping llms.txt. Iterates the live plans registry — a tier added in plans.yaml automatically appears here without a code change. Tiers are sorted by the upgrade ladder (anonymous → free → hobby → hobby_plus → pro → growth → team — pricing order: hobby $9 < hobby_plus $19 < pro $49 < growth $99 < team $199). *_yearly variants are excluded; their annual discount surfaces on the canonical monthly row via annual_discount_percent. Public, unauthenticated.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CapabilitiesResponse" + } + } + }, + "description": "Capability matrix" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "plans.yaml registry failed to load" + } + }, + "summary": "Tier capabilities matrix (public)" + } + }, + "/api/v1/deployments": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/DeployItem" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + } + } + }, + "description": "Deployment list" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List all deployments owned by the caller's team" + } + }, + "/api/v1/deployments/{id}": { + "delete": { + "description": "Wave FIX-I two-step deletion. PAID TIERS (hobby/pro/team/growth) with a verified owner email: the API does NOT immediately tear down — it queues a pending_deletions row, emails the owner a confirmation link (15-minute TTL by default; configurable via DELETION_CONFIRMATION_TTL_MINUTES), and returns 202 with deletion_status='pending_confirmation'. The agent CANNOT confirm on the user's behalf — only the human can, by either clicking the email link (which 302s through GET /auth/email/confirm-deletion to the dashboard) or by POSTing the token directly to POST /api/v1/deployments/{id}/confirm-deletion?token=. The deployment slot is NOT freed until the row flips to status='confirmed'. To cancel a pending deletion the user calls DELETE /api/v1/deployments/{id}/confirm-deletion (the same path, DELETE verb). ANONYMOUS / FREE tiers, or callers that set X-Skip-Email-Confirmation: yes, get the back-compat immediate-destruction path with 200 OK.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Set to 'yes' to bypass the two-step email-confirmed flow for paid tiers. Reserved for agents that have already obtained explicit user consent.", + "in": "header", + "name": "X-Skip-Email-Confirmation", + "required": false, + "schema": { + "enum": [ + "yes" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Immediate destruction path (anonymous/free tier OR header bypass): deployment torn down synchronously." + }, + "202": { + "description": "Two-step path (paid tier, email wired, no bypass header): pending_deletions row queued + confirmation email sent. Body carries deletion_status='pending_confirmation', confirmation_sent_to (masked), confirmation_expires_at, agent_action (verbatim LLM copy), cancellation_note." + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Not your deployment" + }, + "409": { + "description": "deletion_already_pending — a pending email is already in flight for this resource. Cancel it first via DELETE /confirm-deletion, then retry." + }, + "422": { + "description": "deletion_email_disabled — paid team has no verified owner email on file." + }, + "503": { + "description": "email_send_failed — transient email-backend failure; safe to retry." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Tear down + delete a deployment (two-step for paid tiers; immediate for anon/free or with bypass header)" + }, + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployResponse" + } + } + }, + "description": "Deployment record" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Not your deployment" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get a deployment by id (alias of GET /deploy/{id})" + }, + "patch": { + "description": "Edits the private flag and allowed_ips list on an existing deployment without rebuilding the image. The dashboard PrivacyPanel writes here. Body fields are optional: sending only 'allowed_ips' keeps the current private state; sending 'private': false clears the allow-list regardless of allowed_ips. allowed_ips uses REPLACE semantics (the supplied list is the new authoritative list, not merged into the existing one) — matches REST conventions and avoids silent allow-list growth across multiple PATCHes. Validation reuses the POST /deploy/new rule-set: Pro+ tier required (returns 402 with private_deploy_requires_pro), private=true with empty allowed_ips returns 400, invalid IPs/CIDRs surface verbatim, >32 entries returns too_many_allowed_ips. Compute layer patches the live Ingress annotations via the same helper POST uses (no image rebuild, no pod restart).", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "allowed_ips": { + "description": "REPLACE the allow-list with this exact set of IPs/CIDRs. Max 32 entries; each must be a valid IP literal or CIDR.", + "items": { + "type": "string" + }, + "type": "array" + }, + "private": { + "description": "Flip the deploy public ↔ private. When false, the allow-list is cleared regardless of allowed_ips in the same body.", + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployResponse" + } + } + }, + "description": "Access control updated" + }, + "400": { + "description": "Bad request — missing_fields (empty body), private_deploy_requires_allowed_ips, invalid_allowed_ip, too_many_allowed_ips, or invalid_body" + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "description": "private_deploy_requires_pro — hobby/anonymous/free trying to flip a deploy private. agent_action points to https://instanode.dev/pricing." + }, + "403": { + "description": "Not your deployment" + }, + "404": { + "description": "Not found" + }, + "503": { + "description": "compute_update_failed (ingress patch failed) or update_failed (DB write failed)" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Update access-control fields (private + allowed_ips) in place" + } + }, + "/api/v1/deployments/{id}/confirm-deletion": { + "delete": { + "description": "Cancels an in-flight pending_deletions row without consuming the token. The resource stays active and the slot stays consumed. Caller must own the resource (same team gate as DELETE /api/v1/deployments/{id}). Emits deploy.deletion_cancelled in audit_log.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Cancellation confirmed. Body: { ok, id, resource_type, deletion_status='cancelled', agent_action, note }." + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Not your deployment" + }, + "404": { + "description": "No pending deletion to cancel for this resource." + }, + "410": { + "description": "Pending row is already resolved (confirmed/cancelled/expired)." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Cancel a pending deletion (paid tiers, Wave FIX-I)" + }, + "post": { + "description": "Step 2 of the two-step deletion flow. The user (NOT the agent) clicks the email link, which 302s through /auth/email/confirm-deletion to the dashboard's /app/confirm-deletion page, which POSTs here with the plaintext token. The handler hashes the token, validates against pending_deletions.confirmation_token_hash + status='pending' + expires_at > now(), atomically flips the row to 'confirmed' via CAS, then runs the actual deprovision (compute.Teardown + DELETE FROM deployments). A double-click resolves to 410 on the loser. The handler emits deploy.deletion_confirmed in audit_log.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Plaintext confirmation token from the email link (starts with 'del_'). Stored only as sha256 hash server-side.", + "in": "query", + "name": "token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deletion confirmed. Body: { ok, id, resource_type, deletion_status='confirmed', freed_at, agent_action, note }." + }, + "400": { + "description": "missing_token — query parameter omitted." + }, + "401": { + "description": "Unauthorized" + }, + "410": { + "description": "deletion_token_invalid — token expired, already used, or never existed. agent_action tells the user to call DELETE again to mint a fresh email." + }, + "503": { + "description": "deletion_lookup_failed / deletion_mark_failed / deletion_email_disabled — transient DB failure or email backend not wired." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Confirm a pending deletion (paid tiers, Wave FIX-I)" + } + }, + "/api/v1/deployments/{id}/events": { + "get": { + "description": "Returns the deployment_events rows for a deployment owned by the caller's team, ordered by created_at DESC (most recent first). Closes the silent-deploy-failure gap (swarm 2026-05-30): GET /api/v1/deployments/{id} surfaces only the LATEST failure_autopsy row inside the optional 'failure' field; agents debugging a stuck deploy need the full chronological timeline so they can distinguish a single OOM from a retry storm.\n\nEach row carries kind (e.g. 'failure_autopsy'), reason (e.g. 'kaniko_oom', 'image_pull_failed', 'OOMKilled'), exit_code (nullable integer), event (k8s event reason or build error text), last_lines (tail of Kaniko / pod stdout, up to ~200 lines), hint (user-facing remediation copy), and created_at (RFC3339).\n\nRead-only — events are written by the worker (deploy_failure_autopsy + deploy_status_reconcile), never by the api. RBAC mirrors GET /api/v1/deployments/{id} exactly: a cross-team request returns 404 (NOT 403) so the platform never confirms the existence of deployments owned by another team.", + "parameters": [ + { + "description": "Deployment app_id (the short public token returned by POST /deploy/new, same value GET /api/v1/deployments/{id} accepts).", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Max rows to return. Default 50, hard cap 200. Values above 200 are silently clamped; values < 1 fall back to the default.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "count": 1, + "deployment_id": "b6fcf286-3a8b-4d6e-9e2c-1f9a0c5f8d12", + "events": [ + { + "created_at": "2026-05-30T17:42:11Z", + "event": "OOMKilled", + "exit_code": 137, + "hint": "Kaniko ran out of memory during the build. Try a smaller base image, or upgrade your tier for more build RAM.", + "kind": "failure_autopsy", + "last_lines": [ + "INFO[0123] Taking snapshot of files...", + "fatal: out of memory" + ], + "reason": "kaniko_oom" + } + ], + "ok": true + }, + "schema": { + "$ref": "#/components/schemas/DeploymentEventsResponse" + } + } + }, + "description": "Events list (may be empty for a healthy / never-failed deployment)." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "invalid_id — empty id in the URL." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized — missing or invalid bearer token." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "not_found — deployment id doesn't exist OR belongs to another team. Cross-team requests resolve to 404 (NOT 403) so the platform never confirms the existence of deployments owned by another team." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "fetch_failed / events_query_failed — transient DB failure during the deployment lookup or the events list. Safe to retry." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List deployment_events rows (failure timeline) for a deployment" + } + }, + "/api/v1/deployments/{id}/github": { + "delete": { + "description": "Removes the GitHub connection. The deployment itself stays — only the auto-deploy wiring is removed. Idempotent: calling DELETE when no connection exists returns 200 with deleted=false.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Connection removed (or no-op when none existed)" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Not your deployment" + }, + "404": { + "description": "Deployment not found" + }, + "503": { + "description": "delete_failed" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Disconnect a deployment from GitHub auto-deploy" + }, + "get": { + "description": "Returns the current connection (without the webhook secret — that is returned exactly once on POST). Useful for the dashboard's 'connected to ' tile + last-deploy timestamp. When no connection exists, returns connected=false with connection=null.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "connected": { + "type": "boolean" + }, + "connection": { + "oneOf": [ + { + "$ref": "#/components/schemas/GitHubConnection" + }, + { + "type": "null" + } + ] + }, + "ok": { + "type": "boolean" + }, + "webhook_url": { + "description": "Present only when connected=true.", + "format": "uri", + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Connection status" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Not your deployment" + }, + "404": { + "description": "Deployment not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get the current GitHub connection for a deployment" + }, + "post": { + "description": "Wires the deployment to a GitHub repo + branch. On every push to the tracked branch, GitHub POSTs to /webhooks/github/{webhook_id}, the API verifies the X-Hub-Signature-256 HMAC, and enqueues a fresh deploy via the worker. The response carries the webhook_url (paste into GitHub → Settings → Webhooks) and the webhook_secret (paste into the same form; this is the ONLY time the plaintext secret is returned — it is AES-256-GCM encrypted at rest). Tier-gated: Hobby and above. Anonymous / free are rejected with 402 because they cannot deploy at all. Hobby teams can have one deployment total (plans.yaml deployments_apps=1); that single deployment may have one GitHub connection. A deployment can have at most one connection at a time — a second POST returns 409 with agent_action telling the caller to DELETE first.", + "parameters": [ + { + "description": "Deployment app_id (short slug, e.g. '6fffcc21').", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "branch": { + "description": "Branch to watch. Defaults to 'main'. Pushes to other branches are ignored at receive time.", + "example": "main", + "type": "string" + }, + "installation_id": { + "description": "Optional GitHub App installation id. Reserved for a future private-repo flow; today plain webhooks are used and this field can be omitted.", + "format": "int64", + "type": "integer" + }, + "repo": { + "description": "GitHub repository in 'owner/repo' form, e.g. 'octocat/hello-world'.", + "example": "octocat/hello-world", + "type": "string" + } + }, + "required": [ + "repo" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "connection": { + "$ref": "#/components/schemas/GitHubConnection" + }, + "note": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "webhook_secret": { + "description": "Plaintext HMAC signing key. Paste into GitHub → Settings → Webhooks → Secret. Returned ONCE — not surfaced again.", + "type": "string" + }, + "webhook_url": { + "description": "Paste into GitHub → Settings → Webhooks → Payload URL.", + "format": "uri", + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Connection created" + }, + "400": { + "description": "Bad request — invalid_repo (not owner/repo form), invalid_branch (>250 chars), or invalid_body" + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "description": "github_requires_paid_tier — anonymous / free trying to connect. agent_action points to https://instanode.dev/pricing." + }, + "403": { + "description": "Not your deployment" + }, + "404": { + "description": "Deployment not found" + }, + "409": { + "description": "already_connected — deployment already has a GitHub connection. DELETE first to reconnect." + }, + "503": { + "description": "encryption_unavailable / encryption_failed / create_failed" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Connect a deployment to a GitHub repository for auto-deploy" + } + }, + "/api/v1/deployments/{id}/make-permanent": { + "post": { + "description": "Wave FIX-J. Sets expires_at = NULL and ttl_policy = 'permanent' so the deployment never auto-expires. Idempotent — calling twice is a no-op. Anonymous tier is rejected with 402 (anonymous deploys are always 24h; claim the account first). Cross-tenant requests return 404, not 403, so deploy ids belonging to other teams can't be probed. Emits audit kind 'deploy.made_permanent' with source='make_permanent_endpoint'.", + "parameters": [ + { + "description": "Deployment id (UUID or short app_id slug).", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployResponse" + } + } + }, + "description": "Deployment kept permanently" + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "description": "upgrade_required — anonymous tier. agent_action points at https://api.instanode.dev/start." + }, + "404": { + "description": "Not found (or owned by another team)" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Opt a deployment out of the auto-24h TTL" + } + }, + "/api/v1/deployments/{id}/ttl": { + "post": { + "description": "Wave FIX-J. Sets expires_at = now() + hours and ttl_policy = 'custom'. hours must be in [1, 8760]. Also resets reminders_sent so a freshly-extended deploy gets the full six-email warning cycle again. Anonymous tier rejected with 402. Cross-tenant 404. Emits 'deploy.ttl_set' audit kind.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "hours": { + "description": "Number of hours from now until the deploy auto-expires. 1..8760 (1 hour to 1 year).", + "maximum": 8760, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "hours" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployResponse" + } + } + }, + "description": "TTL updated" + }, + "400": { + "description": "invalid_hours — outside 1..8760" + }, + "402": { + "description": "upgrade_required — anonymous tier" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Set a custom TTL for a deployment" + } + }, + "/api/v1/experiments/converted": { + "post": { + "description": "The dashboard fires this from the click handler on an experimental UI element (e.g. the 'Upgrade to Pro' button) before navigating to checkout. Writes an audit_log row (kind = 'experiment.conversion') tagged with the variant the user clicked. The server validates that the experiment + variant are registered AND that the supplied variant matches the variant the server would itself bucket this team into — a mismatch (usually a stale cached /auth/me across a salt rotation) returns 400. The audit write failing still returns 200 (the write is logged, not fatal to the click flow).", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "action": { + "description": "Short action identifier (e.g. 'checkout_started'). Truncated to 64 chars.", + "maxLength": 64, + "type": "string" + }, + "experiment": { + "description": "Registered experiment name (e.g. 'upgrade_button').", + "type": "string" + }, + "variant": { + "description": "The variant the client rendered. Must be a registered variant of the experiment AND match the server's bucket for this team.", + "type": "string" + } + }, + "required": [ + "experiment", + "variant" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Conversion recorded" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid body, unknown_experiment, invalid_variant, or variant_mismatch" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "A/B-experiment conversion sink" + } + }, + "/api/v1/families/bulk-twin": { + "post": { + "description": "One-shot endpoint to twin every family-root resource a team owns in source_env into target_env. Replaces N sequential per-resource /provision-twin calls — the agentic-founder use case for setting up staging in one step.\n\nReturns 200 on full success, 207 Multi-Status when at least one twin failed (the successful rows are NOT rolled back — caller retries just the failed parents). Parents already twinned in target_env count as skipped_already_existed (NOT failures) so retries are idempotent. Tier-gated to Pro/Team/Growth.\n\nConcurrency: per-call semaphore caps in-flight provisions (5 by default) so a team with 30 resources doesn't wait 30× serial provision time. Provisions are NOT rolled back on partial failure — the customer can retry just the failed rows.\n\nQuota gate: if a team's resource-count headroom is exhausted, the remaining parents return failures[] entries with error=quota_exceeded + the upgrade URL in agent_action.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "resource_types": { + "description": "Optional whitelist. Empty = all twin-supported types. Unknown types in the filter are silently dropped so old callers don't break when a new supported type lands.", + "items": { + "enum": [ + "postgres", + "redis", + "mongodb" + ], + "type": "string" + }, + "type": "array" + }, + "source_env": { + "description": "Env to copy FROM (e.g. \"production\"). Must match ^[a-z0-9-]{1,32}$. Only resources where parent_resource_id IS NULL — the family roots — are considered.", + "type": "string" + }, + "target_env": { + "description": "Env to copy TO (e.g. \"staging\"). Must differ from source_env. Same charset rule as source_env.", + "type": "string" + } + }, + "required": [ + "source_env", + "target_env" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "All selected parents twinned (or already had a twin). Body: { ok:true, twinned, skipped_already_existed, items[], failures:[] }. Items carry parent_token + twin_token + resource_type + env + (optional) skipped:true for the already-existed rows." + }, + "207": { + "description": "Multi-Status — at least one parent failed (provision error, quota_exceeded, etc.). Body shape identical to 200 but failures[] is non-empty. Each failure carries parent_token + error code + message + (for quota_exceeded) agent_action + upgrade_url." + }, + "400": { + "description": "missing_source_env / missing_target_env / invalid_source_env / invalid_target_env / same_env (source and target are identical)." + }, + "401": { + "description": "Unauthorized — Bearer token required." + }, + "402": { + "description": "upgrade_required — team is on hobby/free; response carries agent_action + upgrade_url. Multi-env workflows are a Pro+ differentiator." + }, + "503": { + "description": "team_lookup_failed / list_failed — transient DB error; retry with backoff." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Bulk env-twin every parent resource in source_env (Pro+)" + } + }, + "/api/v1/incidents": { + "get": { + "description": "Returns the open incident feed. Today the items array is always empty — the field is reserved for the future incident-feed worker, so dashboards and status pages can wire the response now and have it light up as soon as the worker writes its first row. Public, unauthenticated.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IncidentsResponse" + } + } + }, + "description": "Incident list" + } + }, + "summary": "Current and recent incidents (public)" + } + }, + "/api/v1/invitations/{token}/accept": { + "post": { + "description": "Public endpoint. The token is single-use and ties the accepting user's session to the invited team and role.", + "parameters": [ + { + "in": "path", + "name": "token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "role": { + "type": "string" + }, + "team_id": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Accepted" + }, + "404": { + "description": "Token not found" + }, + "410": { + "description": "Token already used or expired" + } + }, + "summary": "Accept an invitation by token (no auth required — token IS the auth)" + } + }, + "/api/v1/resources": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceListResponse" + } + } + }, + "description": "Resource list" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List all resources for the authenticated team" + } + }, + "/api/v1/resources/families": { + "get": { + "description": "Returns one entry per family root the team owns, with members grouped by env. A family is a set of env-twin resources (prod-db / staging-db / dev-db) linked via parent_resource_id (migration 018). Resources without children or parent appear as single-member families. Sets Cache-Control: private, max-age=30 — narrow freshness window because provisioning + soft-delete both shift family membership. Quota / billing decisions must NOT rely on this aggregate; it's a UX-only optimisation.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "families": { + "items": { + "properties": { + "family_root_id": { + "description": "Stable family identifier — the row's own id when it is its own root.", + "format": "uuid", + "type": "string" + }, + "members_per_env": { + "additionalProperties": { + "properties": { + "env": { + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "is_root": { + "description": "true when this row is the family root (parent_resource_id IS NULL).", + "type": "boolean" + }, + "name": { + "type": "string" + }, + "resource_type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tier": { + "type": "string" + }, + "token": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + }, + "type": "object" + }, + "resource_type": { + "description": "postgres | redis | mongodb | webhook | queue | storage", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + } + } + }, + "description": "Family list" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List resource families for the authenticated team" + } + }, + "/api/v1/resources/{id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Resource deleted" + }, + "403": { + "description": "Forbidden — not your resource OR blocked by team env_policy. The env_policy variant carries body: { error: 'env_policy_denied', env, action, role, allowed_roles, agent_action }." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Delete a resource" + }, + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Resource detail" + }, + "403": { + "description": "Forbidden — resource belongs to another team" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get a specific resource" + } + }, + "/api/v1/resources/{id}/backup": { + "post": { + "description": "Queues a manual backup of the referenced postgres resource. Tier-gated: anonymous/free callers get 402 + agent_action telling them to claim and upgrade; hobby callers are capped at 1 manual backup per UTC day (Redis-backed counter manual_backup::); pro/growth get 100/day; team gets 1000/day. Only postgres resources are supported today — other types return 400 unsupported_resource_type. The API inserts a pending row in resource_backups and returns immediately; the worker picks it up within 30s, runs pg_dump → S3, and writes the terminal status, size_bytes, and s3_key. Poll GET /api/v1/resources/{id}/backups to watch the row transition pending → running → ok|failed. Audit event: backup.requested with metadata {resource_id, triggered_by, backup_kind}. Retention follows plans.yaml.backup_retention_days (hobby=7, pro/growth=30, team=90). Hobby cannot restore from these — see /restore.", + "parameters": [ + { + "description": "Resource token UUID — must be a postgres resource owned by the authenticated team.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "backup_id": { + "format": "uuid", + "type": "string" + }, + "message": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "started_at": { + "format": "date-time", + "type": "string" + }, + "status": { + "enum": [ + "pending" + ], + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Backup queued" + }, + "400": { + "description": "invalid_id (resource UUID malformed) or unsupported_resource_type (resource is not postgres)" + }, + "401": { + "description": "Unauthorized — session token required" + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "upgrade_required — anonymous/free tier cannot back up; response carries agent_action + upgrade_url" + }, + "403": { + "description": "Forbidden — caller doesn't own the resource" + }, + "404": { + "description": "not_found — resource doesn't exist" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "rate_limited — team has hit its manual_backups_per_day cap for the current UTC day; response carries agent_action pointing at the Pro upgrade" + }, + "503": { + "description": "backup_create_failed — transient DB error; retry with backoff" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Trigger an ad-hoc Postgres backup" + } + }, + "/api/v1/resources/{id}/backups": { + "get": { + "description": "Returns the team's backups for this resource, newest first. Cursor-style pagination via ?before= — pass the oldest row's created_at to fetch the next page. ?limit caps at 200 (default 50). Each item carries status (pending|running|ok|failed), backup_kind (scheduled|manual), tier_at_backup (the tier in effect when the backup was taken, used by the retention prune job in the worker), size_bytes (NULL until the worker writes the terminal row), and error_summary (only set on failed). 403 on cross-team access. No tier gate on read — even hobby callers can list to verify backups exist, which is part of the Pro-upgrade trust path.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Max rows to return. Capped at 200.", + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Cursor — only rows with created_at < before are returned. Pass the oldest item's created_at to paginate backwards.", + "in": "query", + "name": "before", + "schema": { + "format": "date-time", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "items": { + "items": { + "properties": { + "backup_id": { + "format": "uuid", + "type": "string" + }, + "backup_kind": { + "enum": [ + "scheduled", + "manual" + ], + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "error_summary": { + "description": "Short human-readable failure reason. Only set when status='failed'.", + "nullable": true, + "type": "string" + }, + "finished_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "size_bytes": { + "description": "Size of the pg_dump artifact in bytes. NULL until the worker writes the terminal row.", + "nullable": true, + "type": "integer" + }, + "started_at": { + "format": "date-time", + "type": "string" + }, + "status": { + "enum": [ + "pending", + "running", + "ok", + "failed" + ], + "type": "string" + }, + "tier_at_backup": { + "description": "Snapshot of team.plan_tier when the backup was taken. Used by the retention prune job — a backup taken on Pro stays for 30 days even after the team downgrades.", + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "total": { + "description": "Total backups for this resource (not just the current page). Used by the dashboard to render pagination affordances.", + "type": "integer" + } + }, + "type": "object" + } + } + }, + "description": "Backup list" + }, + "400": { + "description": "invalid_id or invalid_cursor (?before is not RFC3339)" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden — caller doesn't own the resource" + }, + "404": { + "description": "not_found — resource doesn't exist" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List backups for a resource" + } + }, + "/api/v1/resources/{id}/credentials": { + "get": { + "description": "Returns the AES-256-GCM-decrypted connection_url for the resource. The id path parameter is the resource's token (UUID). Mirrors the 'not 403, but 404' pattern: resources owned by other teams return 404, never confirming existence. Returns 400 no_connection_url for resources without a stored URL (e.g. storage resources expose access_key_id + secret_access_key elsewhere, not connection_url).", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "connection_url": { + "type": "string" + }, + "env": { + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "resource_type": { + "type": "string" + }, + "token": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Decrypted connection URL" + }, + "400": { + "description": "Resource has no connection_url" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Resource not found (or owned by another team)" + }, + "500": { + "description": "Encryption key invalid or decryption failed" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read the decrypted connection_url for a resource" + } + }, + "/api/v1/resources/{id}/family": { + "get": { + "description": "Returns the root + every sibling for the family containing the given resource. The id can be the family root or any child — the handler walks parent_resource_id up to the root and back down. Cross-team callers get 403 (not 404) so honest mistakes are debuggable. Sensitive fields like connection_url are never returned. Sets Cache-Control: private, max-age=30.", + "parameters": [ + { + "description": "Any member of the family — root or child. The handler resolves the root by walking parent_resource_id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "family_root_id": { + "format": "uuid", + "type": "string" + }, + "members": { + "items": { + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "env": { + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "is_root": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "parent_resource_id": { + "description": "Empty for the root; otherwise the root's id.", + "type": "string" + }, + "resource_type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tier": { + "type": "string" + }, + "token": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + } + } + }, + "description": "Family payload" + }, + "400": { + "description": "Resource ID is not a valid UUID" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Cross-team — caller does not own this resource" + }, + "404": { + "description": "Resource not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get the env-twin family for a resource" + } + }, + "/api/v1/resources/{id}/metrics": { + "get": { + "description": "Returns aggregated metrics for the resource over the requested window. Default window is 1h; max window is tier-gated: hobby=1h, pro=24h, growth/team=7d. Anonymous/free callers get 402 upgrade_required — resource observability is a Pro+ differentiator. Buckets are fixed at 60s; samples_count = window_seconds / 60. The response carries data_source=stub while the W5-A heartbeat prober's per-probe row writer is unshipped — the API SHAPE matches the eventual real-data response so dashboard code does not change when the stub is replaced. Future swap-in is documented in resource_metrics.go (Option A: NerdGraph NRQL; Option C: server-side bucketing of resource_metrics rows).", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Duration string (1h, 30m, 24h). Bare integers are interpreted as seconds (3600 == 1h). Capped per tier; over-cap returns 402 with agent_action naming the ceiling instead of silently clamping.", + "in": "query", + "name": "window", + "required": false, + "schema": { + "default": "1h", + "example": "24h", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "data_source": { + "description": "stub while the W5-A prober is unshipped. resource_metrics once Option C lands, newrelic once Option A lands. Dashboard renders a yellow banner only on stub.", + "enum": [ + "stub", + "newrelic", + "resource_metrics" + ], + "type": "string" + }, + "metrics": { + "description": "All arrays have length samples_count. Empty during the stub window means awaiting-first-probe-sample, not backend-down.", + "properties": { + "connections_active": { + "items": { + "type": "number" + }, + "type": "array" + }, + "error_rate_pct": { + "items": { + "type": "number" + }, + "type": "array" + }, + "latency_p50_ms": { + "items": { + "type": "number" + }, + "type": "array" + }, + "latency_p95_ms": { + "items": { + "type": "number" + }, + "type": "array" + }, + "latency_p99_ms": { + "items": { + "type": "number" + }, + "type": "array" + }, + "storage_bytes": { + "items": { + "type": "number" + }, + "type": "array" + } + }, + "type": "object" + }, + "ok": { + "type": "boolean" + }, + "resource_id": { + "format": "uuid", + "type": "string" + }, + "resource_type": { + "description": "postgres | redis | mongodb | webhook | queue | storage", + "type": "string" + }, + "sample_interval_seconds": { + "description": "Fixed at 60. Tier ceilings change window, not bucket width.", + "type": "integer" + }, + "samples_count": { + "description": "Equals window_seconds / sample_interval_seconds. Capped at 10080 (7d @ 1min).", + "type": "integer" + }, + "window_seconds": { + "description": "Resolved window in seconds (post-default, post-cap-rejection).", + "format": "int64", + "type": "integer" + } + }, + "type": "object" + } + } + }, + "description": "Metrics fetched" + }, + "400": { + "description": "invalid_id — :id is not a valid UUID — OR invalid_window — window param unparseable, non-positive, or > 7d hard maximum" + }, + "401": { + "description": "Unauthorized — session token required" + }, + "402": { + "description": "upgrade_required — anonymous/free tier hit the wall OR ?window= exceeds tier cap. Body carries agent_action explaining the current ceiling (e.g. Hobby caps metrics windows at 1h; longer windows require Pro) + upgrade_url." + }, + "403": { + "description": "Forbidden — caller's team doesn't own the resource" + }, + "404": { + "description": "not_found — resource doesn't exist" + }, + "503": { + "description": "fetch_failed — DB lookup failed (transient infra error)" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Per-resource time-series metrics (p50/p95/p99 latency, connections, storage, error rate)" + } + }, + "/api/v1/resources/{id}/pause": { + "post": { + "description": "Sets status to 'paused' and runs the provider-side revoke (REVOKE CONNECT for postgres, ACL SETUSER off for redis, revokeRolesFromUser for mongodb; queue/storage/webhook are pure status flips). The connection URL is preserved on resume — no re-issuance. Paused resources STOP counting against the per-type resource quota, but storage_bytes STILL counts toward the storage cap so pause-and-bloat is not a valid escape. Tier-gated to Pro+. Idempotent error: a second pause on an already-paused resource returns 409 already_paused.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "message": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "status": { + "enum": [ + "paused" + ], + "type": "string" + }, + "token": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Resource paused" + }, + "400": { + "description": "invalid_id — :id is not a valid UUID" + }, + "401": { + "description": "Unauthorized — session token required" + }, + "402": { + "description": "upgrade_required — pause/resume requires Pro+. Body: { error: 'upgrade_required', upgrade_url, agent_action }." + }, + "403": { + "description": "Forbidden — caller doesn't own the resource" + }, + "404": { + "description": "not_found — resource doesn't exist" + }, + "409": { + "description": "already_paused — the resource is already paused (idempotent error)" + }, + "503": { + "description": "provider_failed — the provider-side revoke failed; the DB row is unchanged" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Pause a resource (suspend without deletion)" + } + }, + "/api/v1/resources/{id}/provision-twin": { + "post": { + "description": "Creates a fresh resource of the same type as the source, in a different env, linked into the same family (parent_resource_id = family root). Tier-gated to Pro/Team/Growth — hobby/free callers get a 402 with agent_action telling them to upgrade. Only supports postgres/redis/mongodb sources (the resource types where env-twin has real per-env infra).\n\nEmail-link approval gate (migration 026): when 'env' is anything other than 'development', the API does NOT execute immediately. It persists a pending row in promote_approvals, returns 202 with status='pending_approval' + an approval_id + expires_at, and emails the requester a single-use https://api.instanode.dev/approve/ link valid for 24h. Dev-env twins bypass this gate. Pass approval_id in the body to consume a previously-approved row immediately.", + "parameters": [ + { + "description": "Token of the source resource (root or any sibling — the handler resolves the family root).", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "approval_id": { + "description": "Optional. Pass an already-approved approval row id to run the twin immediately (skips the email-link wait).", + "type": "string" + }, + "env": { + "description": "Target env for the twin (production / staging / dev / ...). Must match ^[a-z0-9-]{1,32}$. Anything other than 'development' triggers the email-link approval flow.", + "type": "string" + }, + "name": { + "description": "Optional human-readable label (max 120 chars). Falls back to the source's name when omitted.", + "type": "string" + } + }, + "required": [ + "env" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Twin provisioned — body carries connection_url + family_root_id (same shape as POST /db/new etc.)" + }, + "202": { + "description": "Pending approval — non-dev target env, no approval_id supplied. Body: { status: 'pending_approval', approval_id, expires_at, agent_action, ... }." + }, + "400": { + "description": "invalid_id / missing_env / invalid_env / unsupported_for_twin (source isn't postgres/redis/mongodb), or approval_id mismatched" + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "description": "upgrade_required — team is on hobby/free; response carries agent_action + upgrade_url" + }, + "403": { + "description": "forbidden — caller does not own the source resource" + }, + "404": { + "description": "Source resource not found, or approval_id does not match any row for this team" + }, + "409": { + "description": "twin_exists — family already has a row in the requested env, OR approval_id is not in status='approved'" + }, + "410": { + "description": "approval_id is past its 24h expiry window" + }, + "503": { + "description": "provision_failed — downstream provisioner errored; resource row was soft-deleted" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Provision an env-twin of an existing resource (Pro+)" + } + }, + "/api/v1/resources/{id}/restore": { + "post": { + "description": "Queues a restore from a previously-completed backup. Tier-gated to Pro/Growth/Team via plans.yaml.backup_restore_enabled — hobby/free callers get 402 + agent_action telling them to upgrade ('Pro can restore, Hobby cannot' is the wedge). backup_id must (a) exist, (b) belong to the same resource named in the URL, (c) be in status='ok'. Mismatches return 400/404/409 with distinct error codes so a dashboard can show the right copy. The API writes a pending row in resource_restores; the worker picks it up within 30s and runs pg_restore from S3. Audit event: restore.requested with metadata {resource_id, backup_id, triggered_by} — distinct from backup.requested so a Loops subscriber can filter to 'user clicked Restore' (a high-signal event). The DB column resource_restores.triggered_by is NOT NULL; PAT-only sessions without a user identity get 401.", + "parameters": [ + { + "description": "Resource token UUID — target of the restore. Must be owned by the authenticated team.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "backup_id": { + "description": "Id of the resource_backups row to restore from. Must be in status='ok' and belong to the same resource as the URL :id.", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "backup_id" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "restore_id": { + "format": "uuid", + "type": "string" + }, + "started_at": { + "format": "date-time", + "type": "string" + }, + "status": { + "enum": [ + "pending" + ], + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Restore queued" + }, + "400": { + "description": "invalid_id, invalid_body, missing_backup_id, invalid_backup_id, or backup_resource_mismatch (backup_id belongs to a different resource than the one in the URL)" + }, + "401": { + "description": "Unauthorized — session token required AND must carry a user identity (PAT-only sessions are rejected)" + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "upgrade_required — restore is Pro+. Response carries agent_action + upgrade_url to https://instanode.dev/pricing." + }, + "403": { + "description": "Forbidden — caller doesn't own the resource" + }, + "404": { + "description": "not_found (resource doesn't exist) OR backup_not_found (backup_id doesn't exist)" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "backup_not_ready — backup_id is in status pending/running/failed and cannot be restored from. Response carries agent_action telling the user to wait or pick another backup." + }, + "503": { + "description": "restore_create_failed or backup_lookup_failed — transient DB error; retry" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Restore a Postgres resource from a backup (Pro+)" + } + }, + "/api/v1/resources/{id}/restores": { + "get": { + "description": "Same shape and pagination as /backups. Items carry status (pending|running|ok|failed), backup_id (the source backup), and error_summary (only on failed). No tier gate — visible to every tier so the dashboard can show 'restore in progress / restore complete' state even on tiers that can't initiate new restores. 403 on cross-team access.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "in": "query", + "name": "before", + "schema": { + "format": "date-time", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "items": { + "items": { + "properties": { + "backup_id": { + "description": "Source backup the restore was taken from.", + "format": "uuid", + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "error_summary": { + "nullable": true, + "type": "string" + }, + "finished_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "restore_id": { + "format": "uuid", + "type": "string" + }, + "started_at": { + "format": "date-time", + "type": "string" + }, + "status": { + "enum": [ + "pending", + "running", + "ok", + "failed" + ], + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + } + } + }, + "description": "Restore list" + }, + "400": { + "description": "invalid_id or invalid_cursor" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden — caller doesn't own the resource" + }, + "404": { + "description": "not_found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List restore attempts for a resource" + } + }, + "/api/v1/resources/{id}/resume": { + "post": { + "description": "Flips status from 'paused' back to 'active' and re-grants the provider-side connection (GRANT CONNECT / ACL on / grantRolesToUser). The connection URL is preserved unchanged — no re-issuance, no new password — so any existing client config still works. Tier-gated to Pro+ in symmetry with pause.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "message": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "status": { + "enum": [ + "active" + ], + "type": "string" + }, + "token": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Resource resumed" + }, + "400": { + "description": "invalid_id" + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "description": "upgrade_required — pause/resume requires Pro+" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "not_found" + }, + "409": { + "description": "not_paused — the resource isn't currently paused" + }, + "503": { + "description": "provider_failed — the provider-side grant failed; the DB row is unchanged" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Resume a paused resource (restore from same data)" + } + }, + "/api/v1/resources/{id}/rotate-credentials": { + "post": { + "description": "Generates a new password and returns the updated connection_url. The old URL is immediately revoked.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "connection_url": { + "type": "string" + }, + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Credentials rotated" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Rotate credentials for a DB/cache/nosql resource" + } + }, + "/api/v1/stacks": { + "get": { + "description": "Returns one row per stack, including its env (production/staging/dev/...) and parent_stack_id linkage so the dashboard can render the Environments grid without an extra round-trip per stack. For grouped env-sibling views call GET /api/v1/stacks/{slug}/family instead.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "items": { + "items": { + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "env": { + "description": "Deployment env (production / staging / dev / ...). Defaults to 'production' for legacy stacks pre-dating migration 015.", + "type": "string" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "parent_stack_id": { + "description": "Root stack id when this is a promoted child. Empty string for the root.", + "type": "string" + }, + "stack_id": { + "description": "Slug (same as path /stacks/{slug})", + "type": "string" + }, + "status": { + "type": "string" + }, + "tier": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + } + } + }, + "description": "Stack list" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List all stacks owned by the caller's team" + } + }, + "/api/v1/stacks/{slug}": { + "get": { + "description": "Returns one stack and its current status — used by the dashboard to poll build progress after POST /stacks/new without fetching the full list.", + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "env": { + "description": "Deployment env (production / staging / dev / ...).", + "type": "string" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "parent_stack_id": { + "description": "Root stack id when this is a promoted child. Empty string for the root.", + "type": "string" + }, + "stack_id": { + "description": "Slug (same as path /stacks/{slug})", + "type": "string" + }, + "status": { + "type": "string" + }, + "tier": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Stack" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Stack not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get a single stack by slug" + } + }, + "/api/v1/stacks/{slug}/confirm-deletion": { + "delete": { + "description": "Stack-side counterpart of DELETE /api/v1/deployments/{id}/confirm-deletion.", + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Cancellation confirmed." + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Not your stack" + }, + "404": { + "description": "No pending deletion to cancel" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Cancel a pending stack deletion (paid tiers, Wave FIX-I)" + }, + "post": { + "description": "Stack-side counterpart of POST /api/v1/deployments/{id}/confirm-deletion. Same contract — see that endpoint for the full flow.", + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Stack deletion confirmed." + }, + "400": { + "description": "missing_token" + }, + "401": { + "description": "Unauthorized" + }, + "410": { + "description": "deletion_token_invalid" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Confirm a pending stack deletion (paid tiers, Wave FIX-I)" + } + }, + "/api/v1/stacks/{slug}/domains": { + "get": { + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Custom-domain list" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Stack not found or not owned by this team" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List custom domains bound to a stack" + }, + "post": { + "description": "Pro tier or higher. Records the requested hostname against the caller's stack and emits a TXT-record DNS challenge. Status starts at 'pending_verification' until POST .../verify confirms the challenge. Returns 402 upgrade_required for Hobby/anonymous teams.", + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "hostname": { + "description": "Apex or subdomain, e.g. app.example.com", + "type": "string" + } + }, + "required": [ + "hostname" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Domain row created (pending verification)" + }, + "400": { + "description": "Body invalid or hostname malformed" + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "description": "upgrade_required — Pro plan or higher" + }, + "404": { + "description": "Stack not found or not owned by this team" + }, + "409": { + "description": "hostname_taken — bound to another team's stack" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Bind a custom hostname to a stack (Pro+)" + } + }, + "/api/v1/stacks/{slug}/domains/{id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Custom domain removed" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Custom domain not found" + }, + "503": { + "description": "DB delete failed" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Tear down the Ingress (best-effort) and remove the custom-domain binding" + } + }, + "/api/v1/stacks/{slug}/domains/{id}/verify": { + "post": { + "description": "Drives the state machine forward: pending_verification → verified (TXT check passes) → ingress_ready (Ingress + Certificate created) → cert_ready (cert-manager has issued the TLS cert). Each call advances at most one step; safe to call repeatedly.", + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Latest state after this call's mutations" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Stack or domain not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Re-poll verification + ingress + certificate state for a custom domain (idempotent)" + } + }, + "/api/v1/stacks/{slug}/family": { + "get": { + "description": "Returns the production / staging / dev variants of the same app as a flat list, with the root first. The 'family' is resolved by walking parent_stack_id up to the root, then collecting every direct child. Pro / Team / Growth only — Hobby callers receive 402 with agent_action because they can't create siblings. Includes a per-env URL derived from the primary exposed service's app_url so the dashboard can render clickable env tiles. Response carries Cache-Control: private, max-age=60 — short enough to stay fresh across promotes/redeploys.", + "parameters": [ + { + "description": "Any member of the family (root or child) — the handler walks up to the root and back down.", + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "family": { + "items": { + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "env": { + "type": "string" + }, + "is_root": { + "description": "True for the family root (parent_stack_id is null).", + "type": "boolean" + }, + "last_deploy_at": { + "format": "date-time", + "type": "string" + }, + "name": { + "type": "string" + }, + "parent_stack_id": { + "description": "Empty string for the root; otherwise the root's id.", + "type": "string" + }, + "slug": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tier": { + "type": "string" + }, + "url": { + "description": "Best-effort: first exposed service's app_url, else first service URL, else empty.", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "slug": { + "description": "Echo of the requested slug.", + "type": "string" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + } + } + }, + "description": "Family list (root first)" + }, + "401": { + "description": "Unauthorized — session required" + }, + "402": { + "description": "Upgrade required — team is not on pro/team/growth. Response carries upgrade_url + agent_action." + }, + "404": { + "description": "Stack not found or not owned by this team" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get every env sibling of a stack (Pro+)" + } + }, + "/api/v1/stacks/{slug}/promote": { + "post": { + "description": "Copies the stack's config (image binding, resource bindings, name) to a sibling stack in the target env. If the target env already has a sibling, its status is bumped back to 'building' (in-place re-promote); otherwise a new stack row is created with parent_stack_id pointing at the family root. Pro / Team / Growth tiers only — returns 402 with agent_action otherwise.\n\nEmail-link approval gate (migration 026): when 'to' is anything other than 'development', the API does NOT execute the promote immediately. It persists a pending row in promote_approvals, returns 202 with status='pending_approval' + an approval_id + expires_at, and emails the requester a single-use https://api.instanode.dev/approve/ link valid for 24h. Dev-env promotes bypass this gate entirely. To run a previously-approved promote manually, pass approval_id in the body — the API verifies status='approved', from/to match, and flips the row to 'executed' before proceeding.", + "parameters": [ + { + "description": "Source stack slug (the env you are promoting FROM)", + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "approval_id": { + "description": "Optional. Pass the id of an already-approved promote_approvals row to run the promote immediately (skips the email-link wait). The row's (kind,from,to) must match this request.", + "type": "string" + }, + "from": { + "description": "Source env — defaults to source stack's env. Must match if provided.", + "type": "string" + }, + "name": { + "description": "Optional display name override for the new stack.", + "type": "string" + }, + "to": { + "description": "Target env (production, staging, dev, ...) — required. Anything other than 'development' triggers the email-link approval flow.", + "type": "string" + } + }, + "required": [ + "to" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Re-promoted into existing sibling stack — same slug, status reset to building" + }, + "202": { + "description": "Either a new stack was created in the target env (parent_stack_id points at family root), OR — for non-dev target envs without an approval_id — a pending approval was created. The body status field disambiguates: 'building' (executed) vs 'pending_approval' (waiting for email click). The pending shape includes approval_id, expires_at, and an agent_action telling the user to check their inbox." + }, + "400": { + "description": "Invalid body, missing 'to', from==to, invalid env name, or approval_id mismatched (kind/from/to)." + }, + "401": { + "description": "Unauthorized — session required" + }, + "402": { + "description": "Upgrade required — team is not on pro/team/growth. Response carries upgrade_url + agent_action." + }, + "403": { + "description": "Blocked by team env_policy. Body: { error: 'env_policy_denied', env, action, role, allowed_roles, agent_action }." + }, + "404": { + "description": "Source stack not found, not owned by this team, OR approval_id does not match any row for this team" + }, + "409": { + "description": "Source env did not match the asserted 'from', OR approval_id is not in status='approved'" + }, + "410": { + "description": "approval_id is past its 24h expiry window" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Promote a stack from one env to another (Pro+)" + } + }, + "/api/v1/status": { + "get": { + "description": "Server-side aggregate driven by the worker's uptime_prober job (about one probe per minute per component). Replaces the dashboard's prior client-side probe loop. Response includes per-component current_status (operational | degraded | down), 7d and 30d uptime percentages, 96 booleans of 15-minute-bucketed last_24h_samples for the bar chart, and a current_incidents array (empty until the incident-feed worker ships). Cached 60s in Redis under one shared key — the payload is identical for every caller. Public, unauthenticated.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + }, + "description": "Status payload", + "headers": { + "Cache-Control": { + "description": "60s public cache (the response is identical for every caller). stale-while-revalidate=60 lets browsers serve the stale value during the next refresh — useful during incidents when the API itself may be slow.", + "schema": { + "example": "public, max-age=60, stale-while-revalidate=60", + "type": "string" + } + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Failed to compute status (transient DB error). Retry with backoff." + } + }, + "summary": "Live component-level health (public, cached 60s)" + } + }, + "/api/v1/team": { + "delete": { + "description": "Begins right-to-be-forgotten for the caller's team. Owner role required. Body must include confirm_team_slug matching the team's visible slug (defense-in-depth: typo / paste-error short-circuits before any state change). Effect: teams.status flips to deletion_requested, deletion_requested_at = now(), every team resource is paused (status='paused', paused_at=now()), and the active Razorpay subscription is best-effort cancelled. After 30 days the worker's team_deletion_executor hard-destroys customer DBs / S3 backups / PII fields and flips status to tombstoned. Inside the 30-day window the owner can call POST /api/v1/team/restore to halt deletion.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "confirm_team_slug": { + "description": "Must match the team's visible slug exactly (case-insensitive). Fetch from GET /api/v1/team/summary if unknown.", + "type": "string" + } + }, + "required": [ + "confirm_team_slug" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Deletion request accepted. Response: { ok, deletion_at, grace_window_days, how_to_cancel }. The deletion_at field is the wall-clock instant the worker will tombstone the team." + }, + "400": { + "description": "Missing or invalid body / confirm_team_slug." + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Caller is not the team owner." + }, + "404": { + "description": "Team not found." + }, + "409": { + "description": "slug_mismatch (confirm_team_slug did not match) or already_pending (deletion has already been requested or the team is tombstoned)." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Request team deletion (GDPR Article 17, owner only, 30-day grace)" + }, + "get": { + "description": "Returns the public-safe subset of the caller's team row: id, name, plan_tier, has_active_subscription (mirror of teams.razorpay_subscription_id IS NOT NULL), and created_at. Distinct from GET /api/v1/team/summary (cached aggregate counts) and GET /api/v1/team/members (member roster). Use this when the dashboard's TeamPage opens or after PATCH /api/v1/team to read back the new name.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "team": { + "$ref": "#/components/schemas/TeamSelf" + } + }, + "required": [ + "ok", + "team" + ], + "type": "object" + } + } + }, + "description": "Team record" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Missing or invalid session token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Team not found" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Lookup failed (transient DB error). Retry with backoff." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get the caller's team record" + }, + "patch": { + "description": "Updates the team's display name. Only the 'name' field is mutable here — plan_tier, subscription state, and member roster flow through dedicated paths (Razorpay webhook for tier; /api/v1/admin/customers/:id/tier for admin demote; /api/v1/team/members/* for membership). Read-only sessions (admin impersonation) are blocked by the route's RequireWritable gate before this handler runs.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "description": "New display name. Whitespace is trimmed. Must be 1-200 chars after trim.", + "maxLength": 200, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "team": { + "$ref": "#/components/schemas/TeamSelf" + } + }, + "required": [ + "ok", + "team" + ], + "type": "object" + } + } + }, + "description": "Team updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Body invalid, name missing, or name longer than 200 chars" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Missing or invalid session token" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Read-only session (admin impersonation) — mutations are blocked" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Update failed (transient DB error). Retry with backoff." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Rename the caller's team" + } + }, + "/api/v1/team/env-policy": { + "get": { + "description": "Returns the policy JSON. Any authenticated team member may read. An empty policy ({}) means no enforcement — every role can perform every action on every env (the default and backward-compat baseline).", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "policy": { + "additionalProperties": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + "description": "Shape: { : { : [, ...] } }. Known actions: deploy, delete_resource, vault_write.", + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "Policy fetched" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get the team's per-env access policy" + }, + "put": { + "description": "Writes the supplied policy verbatim, replacing any previous value. Empty {} disables enforcement. Validation: env names match ^[a-z0-9_-]{1,64}$, action names must be one of deploy/delete_resource/vault_write (unknown actions are rejected to catch typos), role names match ^[a-z0-9_]{1,32}$, total body capped at 8 KiB. Owner-only — non-owners receive 403 with agent_action telling them to have an owner run the prompt.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + "description": "The policy object itself (NOT wrapped). Example: {\"production\":{\"deploy\":[\"owner\"]}}", + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Policy persisted; the response echoes the normalised policy." + }, + "400": { + "description": "Invalid policy shape, unknown action, or malformed JSON. agent_action is populated when applicable." + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Caller is not the team owner. Body: { error: 'owner_required', role, allowed_roles, agent_action }." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Replace the team's per-env access policy (owner only)" + } + }, + "/api/v1/team/invitations": { + "get": { + "responses": { + "200": { + "description": "Invitation list" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Owner only" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List pending invitations sent by this team (owner only)" + } + }, + "/api/v1/team/invitations/{id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Revoked" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Owner only or invitation belongs to another team" + }, + "404": { + "description": "Invitation not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Revoke a pending invitation (owner only)" + } + }, + "/api/v1/team/invitations/{id}/accept": { + "post": { + "description": "Authenticated counterpart to POST /api/v1/invitations/{token}/accept — this one accepts by the invitation row id (UUID) and trusts the caller's session for identity. Use the token-based public endpoint when accepting from a link in an email. If the invitation requested role=owner but the team already has an owner, the user is silently downgraded to member and the response carries a warning field explaining the demote — use POST /api/v1/team/members/{user_id}/promote-to-primary for an atomic ownership transfer.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "role": { + "type": "string" + }, + "warning": { + "description": "Present iff the invitation requested role=owner but a silent downgrade to member occurred.", + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Accepted; response includes the granted role and an optional warning when an owner request was silently downgraded." + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Invitation not found" + }, + "409": { + "description": "Expired, already used, or member-limit reached" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Accept an invitation by its row id (authenticated user)" + } + }, + "/api/v1/team/members": { + "get": { + "description": "Any team member (owner/admin/developer/viewer/legacy member) may list. Returns each member's user_id, email, role, joined_at, plus the tier's member_limit.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "member_limit": { + "type": "integer" + }, + "members": { + "items": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "joined_at": { + "format": "date-time", + "type": "string" + }, + "role": { + "type": "string" + }, + "user_id": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Members + limit" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Not a member of this team" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List members of the caller's team" + } + }, + "/api/v1/team/members/invite": { + "post": { + "description": "Two flows under the same endpoint: role='member' uses the legacy owner-controlled seat flow (owner-only); role='admin'/'developer'/'viewer' uses the RBAC token flow (single-use token emailed out, accepted at POST /api/v1/invitations/{token}/accept). BOTH flows enforce the per-tier seat limit. Rate-limited to 10 invites/hour/team via Redis sliding counter; over-cap returns 429. Idempotency-Key header is honored (24h cache, replays carry X-Idempotent-Replay: true).", + "parameters": [ + { + "description": "Optional opaque key (≤255 chars). When present the response is cached for 24h scoped to (team_id, key); subsequent calls with the same key replay the cached response verbatim and set X-Idempotent-Replay: true.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "role": { + "default": "member", + "enum": [ + "admin", + "developer", + "viewer", + "member" + ], + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Invitation created" + }, + "400": { + "description": "Body invalid, missing email, or invalid role" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Owner/admin role required" + }, + "409": { + "description": "Member limit reached / duplicate / already-a-member" + }, + "429": { + "description": "Rate limit exceeded (10 invites/hour/team)" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Invite a user to the team (owner or admin)" + } + }, + "/api/v1/team/members/leave": { + "post": { + "description": "Removes the caller from their current team. Owners cannot leave — transfer ownership first.", + "responses": { + "200": { + "description": "Left the team" + }, + "401": { + "description": "Unauthorized" + }, + "409": { + "description": "Owner cannot leave (failed_precondition)" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Leave the team" + } + }, + "/api/v1/team/members/{user_id}": { + "delete": { + "description": "Refuses when the target is the team's primary user — every team needs a primary. Promote another member via POST .../promote-to-primary first. On success the removed user is reassigned to a freshly-created personal team; that team's UUID is returned in orphan_team_id so the caller can audit it.", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "orphan_team_id": { + "description": "UUID of the freshly-created personal team the removed user was reassigned to.", + "format": "uuid", + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Member removed; response includes orphan_team_id" + }, + "400": { + "description": "Invalid user id, or target is the team's primary user (error code cannot_remove_primary)" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Owner only" + }, + "404": { + "description": "User not in team" + }, + "409": { + "description": "Cannot remove the owner" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Remove a member from the team (owner only)" + }, + "patch": { + "description": "Updates users.role for the target. Allowed roles: admin, developer, viewer, member (legacy alias of developer). Owner role is NOT assignable here — use POST .../promote-to-primary for an atomic ownership transfer.", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "role": { + "enum": [ + "admin", + "developer", + "viewer", + "member" + ], + "type": "string" + } + }, + "required": [ + "role" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "role": { + "type": "string" + }, + "user_id": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Role updated" + }, + "400": { + "description": "Invalid user id, invalid role, or attempt to assign owner (error code cannot_assign_owner_role)" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Owner only" + }, + "404": { + "description": "User not on this team" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Change a member's role (owner only)" + } + }, + "/api/v1/team/members/{user_id}/promote-to-primary": { + "post": { + "description": "Owner-only. Demotes the current primary (is_primary=false, role=admin) and promotes the target (is_primary=true, role=owner) inside one transaction so the partial unique index uq_users_one_primary_per_team can never observe a two-primary state. Idempotent: promoting the existing primary is a no-op.", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "primary_user_id": { + "format": "uuid", + "type": "string" + }, + "team_id": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Primary transferred" + }, + "400": { + "description": "Invalid user id" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Owner only" + }, + "404": { + "description": "Target user not on this team" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Atomically transfer team primary + owner to the target user (owner only)" + } + }, + "/api/v1/team/restore": { + "post": { + "description": "Reverses a prior DELETE /api/v1/team if invoked within the 30-day grace window. Sets teams.status back to active, resumes paused team resources, and emits team.deletion_canceled. Past the 30-day window the worker has begun (or completed) destruction and restoration is no longer possible — the endpoint returns 410 Gone.", + "responses": { + "200": { + "description": "Restored. Response: { ok, status, resumed_resource_count, days_remaining_at_cancel }." + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Caller is not the team owner." + }, + "404": { + "description": "Team not found." + }, + "409": { + "description": "not_pending — team is not in deletion_requested status." + }, + "410": { + "description": "grace_expired — 30 days have elapsed; restoration is no longer possible." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Cancel a pending team deletion (owner only, inside 30-day grace)" + } + }, + "/api/v1/team/settings": { + "get": { + "description": "Wave FIX-J. Returns the team's preferences. Today the only field is default_deployment_ttl_policy ('auto_24h' or 'permanent') — flipping this changes the default for every future POST /deploy/new. Per-deploy ttl_policy on /deploy/new always overrides this default.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "settings": { + "properties": { + "default_deployment_ttl_hours": { + "description": "Convenience field — 24 for auto_24h, 0 for permanent.", + "type": "integer" + }, + "default_deployment_ttl_policy": { + "enum": [ + "auto_24h", + "permanent" + ], + "type": "string" + }, + "team_id": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "Team preferences" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read team preferences" + }, + "patch": { + "description": "Wave FIX-J. Updates one or more team preferences. Only owner/admin may call. Each changed field emits a 'team.settings_changed' audit row.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "default_deployment_ttl_policy": { + "description": "Sets the team-wide default for /deploy/new. 'auto_24h' means every new deploy auto-expires in 24h; 'permanent' means deploys never auto-expire.", + "enum": [ + "auto_24h", + "permanent" + ], + "type": "string" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated" + }, + "400": { + "description": "invalid_ttl_policy — not 'auto_24h' or 'permanent'" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Insufficient role (owner/admin required)" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Mutate team preferences (owner/admin only)" + } + }, + "/api/v1/team/summary": { + "get": { + "description": "One-shot fetch the dashboard sidebar uses to render SidebarUpgradeCard + per-nav-row badge numbers (Resources · 7, Deployments · 2, etc.). Replaces the prior pattern where every page-load triggered its own /api/v1/resources scan to compute a single number. Aggregation runs once per team per 5-min cache window — long enough that one signed-in user opening every dashboard page across a session triggers ~1 aggregate per surface, short enough that a provision/delete is visible within minutes. Eventual-consistent by design (per the §13 freshness matrix); do NOT use this for quota gate decisions. Response shape: { ok, freshness_seconds, as_of, tier, counts: { resources: { total, postgres, redis, mongodb, webhook, queue, storage, other }, deployments, members, vault_keys } }. Unknown resource_type rows fold into counts.resources.other so the total stays accurate even when the per-type breakdown lags a newly-shipped service. Cache-Control: private, max-age=300.", + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "as_of": "2026-05-12T00:00:00Z", + "counts": { + "deployments": 1, + "members": 1, + "resources": { + "mongodb": 1, + "other": 0, + "postgres": 2, + "queue": 0, + "redis": 1, + "storage": 1, + "total": 7, + "webhook": 2 + }, + "vault_keys": 5 + }, + "freshness_seconds": 300, + "ok": true, + "tier": "hobby" + }, + "schema": { + "$ref": "#/components/schemas/TeamSummaryResponse" + } + } + }, + "description": "Aggregated team summary", + "headers": { + "Cache-Control": { + "description": "Per-team payload — private (no shared proxies). 5-min max-age matches the server-side cache. No stale-while-revalidate because the window is already wide.", + "schema": { + "example": "private, max-age=300", + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Missing or invalid session token. Response includes agent_action pointing the user at https://instanode.dev/login." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Failed to compute summary (transient DB error). Retry with backoff." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Aggregated team counts for the dashboard sidebar (cached)" + } + }, + "/api/v1/teams/{team_id}/invitations": { + "get": { + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/InvitationResponse" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "Invitations" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List pending invitations for a team (admin or owner only)" + }, + "post": { + "description": "Creates a single-use token tied to the invitee's email. The token is delivered out-of-band (email) and exchanged at POST /api/v1/invitations/{token}/accept.", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "role": { + "enum": [ + "admin", + "developer", + "viewer", + "member" + ], + "type": "string" + } + }, + "required": [ + "email", + "role" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationResponse" + } + } + }, + "description": "Invitation created" + }, + "403": { + "description": "Forbidden — admin role required" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Invite a user to the team (admin or owner only)" + } + }, + "/api/v1/teams/{team_id}/invitations/{id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Revoked" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Revoke a pending invitation" + } + }, + "/api/v1/usage/wall": { + "get": { + "description": "Returns the most recent near_quota_wall row written by the worker's QuotaWallNudgeWorker, scoped to the caller's team and bounded to the last 24h. The dashboard polls this on mount and every 5 minutes to decide whether to render the upgrade banner. Team-tier callers always get near_wall=false (team is unlimited). Fails open — a DB error returns 503 rather than a misleading near_wall=false.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "at": { + "description": "When the worker recorded the threshold crossing. Present only when near_wall is true.", + "format": "date-time", + "type": "string" + }, + "axis": { + "description": "Which quota axis tripped (e.g. 'storage').", + "type": "string" + }, + "current": { + "description": "Measured usage at the time of the crossing.", + "type": "integer" + }, + "limit": { + "description": "The tier limit the usage is approaching.", + "type": "integer" + }, + "near_wall": { + "description": "True when the team has crossed the 80% quota threshold within the freshness window.", + "type": "boolean" + }, + "ok": { + "type": "boolean" + }, + "percent_used": { + "description": "current / limit as a percent.", + "type": "number" + }, + "service": { + "description": "Which service the axis belongs to (postgres / redis / mongodb / …).", + "type": "string" + }, + "tier": { + "description": "Team plan tier at the time the row was written.", + "type": "string" + } + }, + "required": [ + "ok", + "near_wall" + ], + "type": "object" + } + } + }, + "description": "Usage-wall state" + }, + "401": { + "description": "Unauthorized" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Failed to read usage-wall state from the platform DB" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Quota-wall nudge state (dashboard upgrade banner)" + } + }, + "/api/v1/vault/copy": { + "post": { + "description": "Copies vault entries from a source env to a target env, optionally filtered by an explicit key allowlist. dry_run=true returns the full plan without persisting. Pro / Team / Growth tiers only — returns 402 with agent_action otherwise.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "dry_run": { + "description": "When true, returns the per-key plan but persists nothing.", + "type": "boolean" + }, + "from": { + "description": "Source env name. Required.", + "type": "string" + }, + "keys": { + "description": "Optional allowlist of key names. Empty/omitted → copy all keys at source.", + "items": { + "type": "string" + }, + "type": "array" + }, + "overwrite": { + "description": "When true, keys already in the target env are bumped to a new version. Default false.", + "type": "boolean" + }, + "to": { + "description": "Target env name. Required. Must differ from 'from'.", + "type": "string" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "blocked": { + "type": "integer" + }, + "copied": { + "type": "integer" + }, + "dry_run": { + "type": "boolean" + }, + "from": { + "type": "string" + }, + "missing": { + "type": "integer" + }, + "ok": { + "type": "boolean" + }, + "plan": { + "items": { + "properties": { + "action": { + "type": "string" + }, + "key": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "skipped": { + "type": "integer" + }, + "to": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Plan + counts. Per-key actions are one of: copy, overwrite, skip, missing, quota_exceeded." + }, + "400": { + "description": "Invalid body, missing from/to, from==to, or invalid env/key name" + }, + "401": { + "description": "Unauthorized — session required" + }, + "402": { + "description": "Upgrade required — team is not on pro/team/growth. Response carries upgrade_url + agent_action." + }, + "403": { + "description": "Blocked by team env_policy. Body: { error: 'env_policy_denied', env, action, role, allowed_roles, agent_action }." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Bulk-copy vault secrets from one env to another (Pro+)" + } + }, + "/api/v1/vault/{env}": { + "get": { + "description": "Returns key names only — values are NEVER returned by this endpoint. Use GET /api/v1/vault/{env}/{key} to read a value.", + "parameters": [ + { + "in": "path", + "name": "env", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "keys": { + "items": { + "type": "string" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "List of keys" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List keys stored in an environment" + } + }, + "/api/v1/vault/{env}/{key}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "env", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "404": { + "description": "Not found (idempotent)" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Hard delete every version of a secret" + }, + "get": { + "description": "Returns the latest version's plaintext. Pass ?version=N to read a specific historical version. Every read writes a row to vault_audit_log.", + "parameters": [ + { + "in": "path", + "name": "env", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "version", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VaultGetResponse" + } + } + }, + "description": "Secret returned" + }, + "404": { + "description": "Secret not found for this team / env / key" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read a secret (decrypted)" + }, + "put": { + "description": "Encrypts the supplied value with AES-256-GCM and stores it as a new version. Subsequent PUTs of the same key create v2, v3, ... — old versions remain queryable until DELETE.", + "parameters": [ + { + "description": "Environment scope (production, staging, dev, ...)", + "in": "path", + "name": "env", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Secret key (e.g. RAZORPAY_KEY_SECRET)", + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VaultPutResponse" + } + } + }, + "description": "Secret stored" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Store an encrypted secret" + } + }, + "/api/v1/vault/{env}/{key}/rotate": { + "post": { + "description": "Convenience for PUT — preserves history but bumps the version visibly. Existing deployments continue to read v(N-1) until they redeploy.\n\nIdempotent: each call inserts a new versioned row in vault_secrets, so double-clicks were producing duplicate versions (BB2-CHROME-3). The Idempotency middleware now dedups retries via either an explicit Idempotency-Key header (24h TTL) or the body-fingerprint fallback (120s TTL). See the top-level Idempotency section in info.description.", + "parameters": [ + { + "in": "path", + "name": "env", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Opaque client-supplied key (1-255 ASCII printable chars). First response cached for 24h; replays return the cached body with X-Idempotent-Replay: true. Reusing the key with a different body returns 409.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "maxLength": 255, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VaultPutResponse" + } + } + }, + "description": "Rotated", + "headers": { + "X-Idempotency-Source": { + "description": "Which dedup path matched: explicit (Idempotency-Key header), fingerprint (body-fingerprint fallback), or miss (handler ran fresh).", + "schema": { + "enum": [ + "explicit", + "fingerprint", + "miss" + ], + "type": "string" + } + }, + "X-Idempotent-Replay": { + "description": "Set to 'true' when the response was served from the idempotency cache instead of running the handler.", + "schema": { + "enum": [ + "true" + ], + "type": "string" + } + } + } + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Idempotency-Key already used with a different body (error=idempotency_key_conflict)." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Rotate a secret (new value, version + 1)" + } + }, + "/api/v1/webhooks/{token}/requests": { + "get": { + "parameters": [ + { + "in": "path", + "name": "token", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of stored requests with headers and body" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List received webhook payloads" + } + }, + "/api/v1/whoami": { + "get": { + "description": "Lightweight endpoint for agents to verify their bearer token works and discover their team_id / plan_tier without an extra DB hop. Returns 401 on invalid/missing token, 200 with identity on success.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhoamiResponse" + } + } + }, + "description": "Identity confirmed" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Identity probe — confirms the bearer token is valid and returns the team it grants access to" + } + }, + "/approve/{token}": { + "get": { + "description": "Public, no-auth endpoint. The operator's email link points here. On a valid pending unexpired token, the row is atomically flipped to status='approved' (single-use) and the response 302-redirects to https://instanode.dev/app/promotions/?approved=1. Otherwise renders an HTML page describing the failure (invalid / expired / already-used). Rate-limited to 10 req/sec per IP — defends the 32-byte token space against brute-force.", + "parameters": [ + { + "description": "URL-safe base64 token from the approval email.", + "in": "path", + "name": "token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Approved — redirect to dashboard" + }, + "400": { + "description": "Missing token (HTML)" + }, + "404": { + "description": "Token does not match any row (HTML)" + }, + "410": { + "description": "Token expired or already used (HTML)" + }, + "429": { + "description": "Per-IP rate limit hit (HTML)" + } + }, + "summary": "Click-through endpoint for email-link promote approvals" + } + }, + "/auth/cli": { + "post": { + "description": "Creates a pending Redis-backed login session (10-minute TTL) and returns a browser URL the user must visit to complete OAuth. The CLI then polls GET /auth/cli/{id} for completion. Optional body: anon_tokens — anonymous resource tokens that the server will associate with the user's team once they sign in.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "anon_tokens": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "auth_url": { + "format": "uri", + "type": "string" + }, + "expires_in": { + "description": "Seconds (600)", + "type": "integer" + }, + "ok": { + "type": "boolean" + }, + "session_id": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Session created" + }, + "500": { + "description": "Failed to create login session" + } + }, + "summary": "Start a CLI device-flow login session" + } + }, + "/auth/cli/{id}": { + "get": { + "description": "Returns 202 with {pending:true} while the user is still completing OAuth, or 200 with the issued API key and identity once they have. The session is single-use and is deleted on the first 200 response. After Redis expiry (or on lookup failure) the endpoint fails open with pending=true so the CLI keeps polling.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "api_key": { + "type": "string" + }, + "claimed_tokens": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "email": { + "format": "email", + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "team_name": { + "type": "string" + }, + "tier": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Login complete" + }, + "202": { + "description": "Still pending" + }, + "400": { + "description": "Missing session id" + }, + "404": { + "description": "Session not found or expired" + } + }, + "summary": "Poll a CLI device-flow login session for completion" + } + }, + "/auth/email/callback": { + "get": { + "description": "Validates and atomically consumes the magic-link token, finds-or-creates the user/team, mints a 24h session JWT, and redirects to the original return_to with session_token appended. On any error renders an HTML error page (the user is in a browser).", + "parameters": [ + { + "in": "query", + "name": "t", + "required": true, + "schema": { + "description": "Plaintext magic-link token from the emailed URL", + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Redirect to ?session_token=" + }, + "400": { + "description": "Token missing, expired, already used, or invalid" + }, + "503": { + "description": "Database / JWT signing failed" + } + }, + "summary": "Consume a magic link, mint a session JWT, 302 to " + } + }, + "/auth/email/confirm-deletion": { + "get": { + "description": "The href in deletion-confirm emails. Validates that ?t= is present and 302s to /app/confirm-deletion?t=. The API does NOT validate the token here — a click is navigation, not action; the dashboard's authenticated POST is the real confirm step.", + "parameters": [ + { + "in": "query", + "name": "t", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Redirect to dashboard confirm page" + }, + "400": { + "description": "Missing token query parameter" + } + }, + "summary": "Email-link 302 redirect to the dashboard confirm page (Wave FIX-I)" + } + }, + "/auth/email/start": { + "post": { + "description": "Generates a single-use 15-minute token, stores its SHA-256 hash, emails the link, and returns 202 — always 202, even when the email isn't registered, to defeat user enumeration. The link points to GET /auth/email/callback?t=.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "return_to": { + "description": "Where to send the user after sign-in. Validated against the allowlist; off-list collapses to the default.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Magic link sent (or silently dropped — body is invariant by design)" + }, + "400": { + "description": "Body invalid or email malformed" + } + }, + "summary": "Send a passwordless magic-link sign-in email" + } + }, + "/auth/github": { + "post": { + "description": "Programmatic / SPA flow. Body: {\"code\":\"\"}. Returns 200 with a 24h session JWT plus user/team ids. Returns 503 oauth_not_configured when GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET are not set in the environment.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + } + }, + "required": [ + "code" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "team_id": { + "format": "uuid", + "type": "string" + }, + "token": { + "type": "string" + }, + "user_id": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Session issued" + }, + "400": { + "description": "Body invalid or missing code" + }, + "401": { + "description": "GitHub rejected the authorization code" + }, + "503": { + "description": "GitHub OAuth not configured / user upsert failed / JWT signing failed" + } + }, + "summary": "Exchange a GitHub OAuth authorization code for a session JWT" + } + }, + "/auth/github/callback": { + "get": { + "description": "Verifies the state cookie matches the ?state query param, exchanges ?code with GitHub, finds-or-creates the user/team, mints a 24h session JWT, and 302-redirects to the validated return_to URL with session_token appended. On any error, renders an HTML error page.", + "parameters": [ + { + "in": "query", + "name": "code", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "state", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Redirect to ?session_token=" + }, + "400": { + "description": "Missing code/state, or state mismatch / expired" + }, + "401": { + "description": "GitHub rejected the code" + }, + "503": { + "description": "OAuth not configured / user upsert / JWT signing failed" + } + }, + "summary": "Browser-driven GitHub OAuth: exchange code + 302 to ?session_token=" + } + }, + "/auth/github/start": { + "get": { + "description": "Sets an HTTP-only state cookie binding ?return_to and a random state token, then 302-redirects the user agent to https://github.com/login/oauth/authorize. The dashboard's login page links here directly — there is no JSON contract. ?return_to is validated against the allowlist (instanode.dev, www.instanode.dev, http://localhost:5173, http://localhost:3000); off-list values collapse to https://instanode.dev/login/callback.", + "parameters": [ + { + "in": "query", + "name": "return_to", + "required": false, + "schema": { + "format": "uri", + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Redirect to GitHub authorize URL" + }, + "503": { + "description": "GitHub OAuth not configured" + } + }, + "summary": "Browser-driven GitHub OAuth: stash CSRF cookie + 302 to GitHub" + } + }, + "/auth/logout": { + "post": { + "description": "Adds the bearer token's JTI to a Redis revocation set (TTL = remaining token lifetime) so the token is rejected by RequireAuth even before it expires. Idempotent; safe to call without a valid token.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Session revoked" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Log out — revoke the current session token server-side" + } + }, + "/auth/me": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthMeResponse" + } + } + }, + "description": "User and team info" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get current user info" + } + }, + "/billing/checkout": { + "post": { + "description": "Kept for backward compatibility with older dashboard/SDK clients. Identical contract to POST /api/v1/billing/checkout. New callers should use the /api/v1 path.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "plan": { + "enum": [ + "hobby", + "hobby_plus", + "pro", + "team" + ], + "type": "string" + } + }, + "required": [ + "plan" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "short_url": { + "format": "uri", + "type": "string" + }, + "subscription_id": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Subscription created — redirect user to short_url" + }, + "400": { + "description": "Invalid plan" + }, + "401": { + "description": "Missing or invalid session token" + }, + "502": { + "description": "Razorpay rejected the create-subscription call" + }, + "503": { + "description": "Razorpay not configured on this environment" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Legacy alias for POST /api/v1/billing/checkout" + } + }, + "/cache/new": { + "post": { + "description": "Returns a real redis:// connection string with ACL namespace isolation. Anonymous tier: 5MB memory, 24h TTL.\n\nSupports Stripe/AWS-style idempotency via the optional Idempotency-Key request header.", + "parameters": [ + { + "description": "Opaque client-supplied key (1-255 ASCII printable chars). First response cached for 24h; replays return the cached body with X-Idempotent-Replay: true. Reusing the key with a different body returns 409.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "maxLength": 255, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionRequest" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CacheProvisionResponse" + } + } + }, + "description": "Cache provisioned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request — one of: name_required (name field missing/empty), invalid_name (name fails the 1-64-char start-alnum pattern or contains invalid UTF-8), invalid_body (request body is not valid JSON), invalid_env, or an invalid Idempotency-Key (empty, >255 chars, or non-ASCII-printable)." + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Quota exceeded, feature requires upgrade, OR free-tier recycle requires claim (error=free_tier_recycle_requires_claim). Includes agent_action and upgrade_url; recycle gate also returns claim_url." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Idempotency-Key already used with a different body (error=idempotency_key_conflict)." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Provisioning failed (transient). Retry with backoff." + } + }, + "summary": "Provision a Redis cache" + } + }, + "/claim": { + "post": { + "description": "Converts anonymous resources to hobby tier (no expiry). Sends a magic link to the supplied email; clicking the link sets a session JWT cookie and atomically transfers every resource token in the onboarding token to the new team.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClaimRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClaimResponse" + } + } + }, + "description": "Magic link sent to email" + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClaimResponse" + } + } + }, + "description": "Account created, resources transferred (legacy direct-claim flow)" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation failure. Possible error codes: missing_token (no token/jwt in body), missing_email (no email), invalid_email_format (email failed RFC 5322 validation), invalid_body (body not valid JSON), invalid_token (token failed signature/expiry check)." + }, + "409": { + "description": "Onboarding token already used (single-use claim)" + } + }, + "summary": "Claim anonymous resources to a permanent account" + } + }, + "/claim/preview": { + "get": { + "description": "Decodes the onboarding JWT and returns the list of resources that would be transferred if /claim were posted with this token. Read-only; does not consume the JWT. Useful for showing the user what they're about to claim before they enter their email.", + "parameters": [ + { + "description": "Signed onboarding JWT (the upgrade_jwt field from any anonymous provisioning response, or extracted from the upgrade URL).", + "in": "query", + "name": "t", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClaimPreviewResponse" + } + } + }, + "description": "Preview of claimable resources" + }, + "400": { + "description": "Token missing or malformed" + }, + "401": { + "description": "Token expired or signature invalid" + } + }, + "summary": "Preview which resources a claim would attach" + } + }, + "/db/new": { + "post": { + "description": "Returns a real postgres:// connection string with pgvector pre-installed. Anonymous tier: 10MB, 2 connections, 24h TTL.\n\nSupports Stripe/AWS-style idempotency via the optional Idempotency-Key request header — see the parameter description below.", + "parameters": [ + { + "description": "Opaque client-supplied key (1-255 ASCII printable chars) that makes this POST safe to retry. The first response is cached for 24h; subsequent calls carrying the same key return the cached response verbatim with X-Idempotent-Replay: true. Reusing a key with a different body returns 409. Replays do NOT consume rate-limit budget — the per-fingerprint daily counter is refunded on every cache hit so an agent retrying transient 5xx with the same key gets the documented replay (FINDING API-1, 2026-05-29). The FIRST call still pays the rate-limit cost; replays are refunded. The per-fingerprint provision-dedup cap (5 fresh resources/day, anti-abuse) is unchanged.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "maxLength": 255, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionRequest" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DBProvisionResponse" + } + } + }, + "description": "Database provisioned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request — one of: name_required (name field missing/empty), invalid_name (name fails the 1-64-char start-alnum pattern or contains invalid UTF-8), invalid_body (request body is not valid JSON), invalid_env, or an invalid Idempotency-Key (empty, >255 chars, or non-ASCII-printable)." + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Quota exceeded, feature requires upgrade, OR free-tier recycle requires claim (error=free_tier_recycle_requires_claim — anonymous fingerprint that previously provisioned must claim with email before re-provisioning). Includes agent_action with copy the calling agent can show the user, plus upgrade_url and (for the recycle gate) claim_url." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Idempotency-Key already used with a different request body (error=idempotency_key_conflict). The agent reused a key for a logically different call — generate a new key." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Provisioning failed (transient). Retry with backoff." + } + }, + "summary": "Provision a Postgres database" + } + }, + "/deploy/new": { + "post": { + "description": "Builds a Docker image from the supplied tarball (or pulls an existing image) and rolls it out behind a public HTTPS URL on *.deployment.instanode.dev. Env vars may use the value 'vault://KEY' to reference a secret stored via /api/v1/vault — the plaintext is resolved at deploy time and never persisted in plaintext. The separate 'resource_bindings' field accepts 'family:' values that resolve at submit time to the connection URL of the family member matching the deploy's env — so one manifest works across staging / production / dev. Raw resource-token UUIDs are also accepted for backward compatibility.\n\nSupports Stripe/AWS-style idempotency via the optional Idempotency-Key request header — safe-retry the multipart upload after a transient build failure without creating duplicate apps.", + "parameters": [ + { + "description": "Opaque client-supplied key (1-255 ASCII printable chars). First response cached for 24h; replays return the cached body with X-Idempotent-Replay: true. Note: deploy/new is multipart/form-data, so the body-hash compares the raw form payload — a re-uploaded tarball with even one byte different is treated as a different request (returns 409). Generate a fresh key for each distinct build context.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "maxLength": 255, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/DeployRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployResponse" + } + } + }, + "description": "Deployment accepted, building" + }, + "400": { + "description": "Bad request — invalid env_vars JSON, invalid_resource_binding (resource_bindings value is not a UUID or family:), private_deploy_requires_allowed_ips (private=true with no IPs), invalid_allowed_ip (bad CIDR/IP literal), too_many_allowed_ips (>32 entries), invalid_notify_webhook (URL is not https, unresolvable, or resolves to a private/loopback/link-local IP), OR invalid_idempotency_key (empty/>255 chars/non-ASCII-printable)" + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "description": "deployment_limit_reached OR private_deploy_requires_pro — hobby/anonymous/free trying to set private=true. agent_action points to https://instanode.dev/pricing." + }, + "403": { + "description": "Blocked by team env_policy, OR resource_binding_forbidden (binding references a resource owned by a different team)" + }, + "404": { + "description": "resource_binding_not_found — the resource or family root id supplied in resource_bindings does not exist" + }, + "409": { + "description": "no_env_twin (resource_bindings used family: but the family has no member in the deploy's env — agent_action tells the user to call POST /api/v1/resources/:id/provision-twin first) OR idempotency_key_conflict (the same Idempotency-Key was used with a different request body)" + }, + "503": { + "description": "Compute backend unavailable or service disabled, OR resource_binding_lookup_failed (transient DB error during binding resolution)" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Deploy a container application" + } + }, + "/deploy/{id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deletion enqueued" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Not your deployment" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Tear down and delete a deployment" + }, + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployResponse" + } + } + }, + "description": "Deployment record" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Not your deployment" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get deployment status" + } + }, + "/deploy/{id}/env": { + "patch": { + "description": "Merges the supplied env vars with the existing ones. Values prefixed with 'vault://' are stored verbatim and resolved at the next redeploy. Plaintext is never logged.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "env": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployResponse" + } + } + }, + "description": "Env vars updated" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Update env vars (redeploy required to apply)" + } + }, + "/deploy/{id}/logs": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "text/event-stream of log lines, terminated by 'data: [end]'" + }, + "409": { + "description": "Deployment still building" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Stream deployment logs (Server-Sent Events)" + } + }, + "/deploy/{id}/redeploy": { + "post": { + "description": "Re-resolves any vault:// references and rolls out a new revision. Use after PATCH /deploy/{id}/env or after rotating a vault secret.", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Redeploy accepted" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Redeploy with the latest stored env vars" + } + }, + "/healthz": { + "get": { + "description": "Process-level liveness — returns 200 if the api binary is up and can ping its primary platform DB. Wired to Kubernetes livenessProbe. Use /readyz for deep upstream-reachability checks.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + }, + "description": "Service is healthy" + } + }, + "summary": "Health check (shallow liveness)" + } + }, + "/livez": { + "get": { + "description": "Returns 200 unconditionally with body {\"alive\":true}. NO database check, NO migration check, NO auth. Exists purely to distinguish 'process alive' from 'process ready' for k8s liveness/readiness probe split. Mirrored on provisioner-sidecar (:8092), worker-healthz (:8091), and migrator (:8090).", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "alive": { + "const": true, + "type": "boolean" + } + }, + "required": [ + "alive" + ], + "type": "object" + } + } + }, + "description": "Process is alive" + } + }, + "summary": "Liveness probe" + } + }, + "/llms-full.txt": { + "get": { + "description": "Agents that land on api.instanode.dev/llms-full.txt are redirected (302 Found) to instanode.dev/llms-full.txt — the long-form companion to /llms.txt. Public, no auth.", + "responses": { + "302": { + "description": "Redirect to https://instanode.dev/llms-full.txt", + "headers": { + "Location": { + "schema": { + "format": "uri", + "type": "string" + } + } + } + } + }, + "summary": "Full LLM-targeted product docs (302 to marketing)" + } + }, + "/llms.txt": { + "get": { + "description": "Agents that land on api.instanode.dev/llms.txt are redirected (302 Found) to instanode.dev/llms.txt — the source-of-truth surface for the LLM-targeted product docs. Companion of /llms-full.txt. Public, no auth.", + "responses": { + "302": { + "description": "Redirect to https://instanode.dev/llms.txt", + "headers": { + "Location": { + "schema": { + "format": "uri", + "type": "string" + } + } + } + } + }, + "summary": "Agent discovery doc (302 to marketing)" + } + }, + "/metrics": { + "get": { + "description": "Exposes the standard Prometheus text-format metrics for the API process (Go runtime, HTTP request counters, provision counters, conversion funnel, Redis errors, etc.). When METRICS_TOKEN is set in config, the request must include 'Authorization: Bearer '. Open without auth in local dev.", + "responses": { + "200": { + "content": { + "text/plain": {} + }, + "description": "Prometheus text-format metrics" + }, + "401": { + "description": "METRICS_TOKEN is configured and the supplied bearer did not match" + } + }, + "summary": "Prometheus metrics scrape endpoint" + } + }, + "/nosql/new": { + "post": { + "description": "Returns a real mongodb:// connection string scoped to a per-token database. Anonymous tier: 5MB, 2 connections, 24h TTL.\n\nSupports Stripe/AWS-style idempotency via the optional Idempotency-Key request header.", + "parameters": [ + { + "description": "Opaque client-supplied key (1-255 ASCII printable chars). First response cached for 24h; replays return the cached body with X-Idempotent-Replay: true. Reusing the key with a different body returns 409.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "maxLength": 255, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionRequest" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NoSQLProvisionResponse" + } + } + }, + "description": "MongoDB database provisioned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request — one of: name_required (name field missing/empty), invalid_name (name fails the 1-64-char start-alnum pattern or contains invalid UTF-8), invalid_body (request body is not valid JSON), invalid_env, or an invalid Idempotency-Key (empty, >255 chars, or non-ASCII-printable)." + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Quota exceeded, feature requires upgrade, OR free-tier recycle requires claim (error=free_tier_recycle_requires_claim). Includes agent_action and upgrade_url; recycle gate also returns claim_url." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Idempotency-Key already used with a different body (error=idempotency_key_conflict)." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Provisioning failed (transient). Retry with backoff." + } + }, + "summary": "Provision a MongoDB database" + } + }, + "/openapi.json": { + "get": { + "description": "Returns this very document. Self-describing endpoint that agents can read to discover every other route.", + "responses": { + "200": { + "content": { + "application/json": {} + }, + "description": "OpenAPI 3.1 JSON spec" + } + }, + "summary": "Machine-readable OpenAPI 3.1 description of this API" + } + }, + "/queue/new": { + "post": { + "description": "Returns a real nats:// connection string with per-account subject isolation. Anonymous tier: 24h TTL.\n\nSupports Stripe/AWS-style idempotency via the optional Idempotency-Key request header.", + "parameters": [ + { + "description": "Opaque client-supplied key (1-255 ASCII printable chars). First response cached for 24h; replays return the cached body with X-Idempotent-Replay: true. Reusing the key with a different body returns 409.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "maxLength": 255, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionRequest" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueProvisionResponse" + } + } + }, + "description": "Queue provisioned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request — one of: name_required (name field missing/empty), invalid_name (name fails the 1-64-char start-alnum pattern or contains invalid UTF-8), invalid_body (request body is not valid JSON), invalid_env, or an invalid Idempotency-Key (empty, >255 chars, or non-ASCII-printable)." + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Quota exceeded, feature requires upgrade, OR free-tier recycle requires claim (error=free_tier_recycle_requires_claim). Includes agent_action and upgrade_url; recycle gate also returns claim_url." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Idempotency-Key already used with a different body (error=idempotency_key_conflict)." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Provisioning failed (transient). Retry with backoff." + } + }, + "summary": "Provision a NATS JetStream queue" + } + }, + "/razorpay/webhook": { + "post": { + "description": "Receives Razorpay subscription lifecycle events: subscription.activated (card/mandate authorised → elevate team tier immediately, same idempotent path as subscription.charged; closes the activation-before-charge window for Indian payment methods like UPI/NACH where the first charge may be delayed hours after activation), subscription.charged (payment confirmed → elevate team tier + elevate all permanent resources + trigger migrations for shared-infra resources; ALSO recovers any active payment-grace row → emits payment.grace_recovered audit; both activated and charged route to the same idempotent upgrade handler — dedup is per-event_id so no double-upgrade risk), subscription.cancelled (downgrade team to hobby), subscription.charged_failed (opens a 7-day payment-grace window → emits payment.grace_started audit; idempotent via partial-unique index on payment_grace_periods, so webhook redeliveries are silent no-ops; worker side fires the 6h reminder cadence and terminates non-recovered grace rows at expires_at), payment.failed (record + emit grace_started when the failed payment carries a subscription reference). The body's HMAC-SHA256 signature with RAZORPAY_WEBHOOK_SECRET must match the X-Razorpay-Signature header. Always returns 200 on success — Razorpay retries on non-2xx. Returns 400 invalid_signature when the HMAC check fails. NOT for direct caller use — Razorpay POSTs here.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "description": "Razorpay event payload (event, payload.subscription/payment.entity). See Razorpay webhook docs.", + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Event processed (or ignored for unhandled event types)" + }, + "400": { + "description": "invalid_signature or invalid_payload" + } + }, + "summary": "Razorpay subscription event webhook (signature-verified)" + } + }, + "/readyz": { + "get": { + "description": "Runs component-by-component readiness checks against every critical upstream the api depends on (platform_db, customer_db, provisioner_grpc, brevo, razorpay, redis, do_spaces). Each check has a 10-15s cache to avoid upstream spam. Wired to Kubernetes readinessProbe — a degraded pod is removed from the Service endpoint list (but not restarted). Critical-failed components (platform_db, provisioner_grpc) → 503; everything else → 200 with overall=degraded.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadinessResponse" + } + } + }, + "description": "Service is ready (overall=ok) or degraded but still serving (overall=degraded). The body's checks[] enumerates per-component status." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadinessResponse" + } + } + }, + "description": "Critical component failed — pod removed from Service rotation by kubelet. The body's checks[] still enumerates per-component status so an operator can diagnose." + } + }, + "summary": "Deep readiness check (multi-component)" + } + }, + "/resources/{token}/logs": { + "get": { + "description": "Server-Sent Events stream of the last N log lines from the per-tenant pod that backs a growth-tier resource (postgres / cache / nosql / queue). The token IS the credential — no Bearer required, identical to /webhook/receive/{token}. Returns 400 not_growth for shared-tier resources (those run on platform pods shared across customers; use external log aggregation instead).", + "parameters": [ + { + "in": "path", + "name": "token", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "tail", + "required": false, + "schema": { + "default": 100, + "maximum": 500, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "text/event-stream of log lines terminated by 'data: [end]'" + }, + "400": { + "description": "invalid_token, not_growth, or unsupported_type" + }, + "404": { + "description": "Resource or backing pod not found" + }, + "409": { + "description": "Resource has no provider namespace yet — still provisioning" + }, + "503": { + "description": "Log streaming unavailable (no k8s client)" + } + }, + "summary": "Stream pod logs for an isolated (growth-tier) resource" + } + }, + "/stacks/new": { + "post": { + "description": "Like POST /deploy/new but for an instant.yaml manifest declaring multiple services. Each service has its own build context (tarball), port, optional Ingress (expose:true), and optional list of resource tokens (needs:). Cross-service references use service:// in env values — these resolve to cluster-internal http://: URLs at deploy time, so service A can call service B without knowing its public hostname. OptionalAuth: anonymous stacks are supported (24h TTL, rate-limited by fingerprint).", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/StackRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackResponse" + } + } + }, + "description": "Stack accepted, building" + }, + "400": { + "description": "Invalid manifest, missing tarball for a declared service, or unresolved service:// reference" + }, + "429": { + "description": "Anonymous rate limit exceeded" + }, + "503": { + "description": "Compute backend unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Deploy a multi-service stack" + } + }, + "/stacks/{slug}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deletion enqueued" + }, + "404": { + "description": "Stack not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Tear down and delete a stack" + }, + "get": { + "description": "Returns per-service status. The overall stack status is 'healthy' only when every service is healthy.", + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackResponse" + } + } + }, + "description": "Stack record" + }, + "404": { + "description": "Stack not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get stack status" + } + }, + "/stacks/{slug}/env": { + "patch": { + "description": "PATCH semantics — incoming env map is merged into the stack's existing env_vars (B7-P0-1, migration 062). Setting a key to the empty string deletes it. Keys must match POSIX [A-Z_][A-Z0-9_]* — the same shape /deploy/new and /stacks/new enforce. Total payload after merge is capped at 64KiB. Persisted to stacks.env_vars JSONB; the next POST /stacks/{slug}/redeploy applies them. Auth required: anonymous stacks cannot be mutated after creation.", + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "env": { + "additionalProperties": { + "type": "string" + }, + "description": "Env vars to upsert. Empty-string value deletes a key.", + "type": "object" + } + }, + "required": [ + "env" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "env": { + "additionalProperties": { + "type": "string" + }, + "description": "Full env set on the stack AFTER the merge — caller does not need to re-GET.", + "type": "object" + }, + "message": { + "type": "string" + }, + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Env vars persisted; response includes the full merged env map." + }, + "400": { + "description": "Body missing, env is empty, or an env-var key fails the POSIX [A-Z_][A-Z0-9_]* shape (error=invalid_env_key)." + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Stack not found or not owned by this team" + }, + "409": { + "description": "Stack is mid-teardown and cannot be modified (error=stack_deleting)." + }, + "413": { + "description": "Merged env_vars payload exceeds 64KiB (error=env_too_large)." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Update env vars on a stack (persisted; applied on next redeploy)" + } + }, + "/stacks/{slug}/logs/{svc}": { + "get": { + "description": "Tails the named service's pod logs as text/event-stream. Anonymous-owned stacks are accessible without auth (token-style by slug); authenticated stacks require Bearer and team ownership.", + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "svc", + "required": true, + "schema": { + "description": "Service name from the manifest", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "text/event-stream of log lines terminated by 'data: [end]'" + }, + "404": { + "description": "Stack not found" + }, + "503": { + "description": "Compute backend log stream failed" + } + }, + "summary": "Stream service logs from a stack (Server-Sent Events)" + } + }, + "/stacks/{slug}/redeploy": { + "post": { + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Redeploy accepted" + }, + "401": { + "description": "Unauthorized — redeploy mutates the stack and requires a session" + }, + "404": { + "description": "Stack not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Rebuild + rolling update for one or more services in the stack" + } + }, + "/start": { + "get": { + "description": "Public bounce endpoint baked into the upgrade_url returned by every anonymous provisioning response. Issues a 302 Location redirect to the dashboard's claim page (DASHBOARD_BASE_URL + '/claim?t=') — the dashboard then drives the email-claim flow against POST /claim. ALWAYS 302s regardless of token validity (API-5): an invalid/expired/missing token still redirects to /claim where the dashboard renders a friendly error UI. This is the contract because /start URLs land in agents' terminal logs and users copy-paste them into browsers; a raw JSON 400 is hostile UX. Agents that already hold the upgrade_jwt should POST /claim directly instead of following this redirect.", + "parameters": [ + { + "description": "Signed onboarding JWT (the upgrade_jwt field from any anonymous provisioning response, or extracted from the upgrade URL). Optional — when missing, the bounce still 302s to /claim with no t= query so the dashboard renders its empty / login state.", + "in": "query", + "name": "t", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Redirect to the dashboard claim page (e.g. https://instanode.dev/claim?t=). Follow the Location header for the human flow, or POST /claim directly with the JWT to skip the dashboard step.", + "headers": { + "Location": { + "description": "Dashboard claim URL with the JWT echoed in the t= query param (or no t= when omitted by the caller)", + "schema": { + "format": "uri", + "type": "string" + } + } + } + } + }, + "summary": "Onboarding bounce — always 302 to the dashboard claim page" + } + }, + "/storage/new": { + "post": { + "description": "Provisions an object-storage prefix for the caller. The response shape depends on what isolation the configured backend can ENFORCE (PrefixScopedKeys capability — see STORAGE-ABSTRACTION-DESIGN-2026-05-20.md):\n\n- 'prefix-scoped' / 'prefix-scoped-temporary' (R2, S3, MinIO): returns access_key_id + secret_access_key (and session_token for STS-backed flows) that the backend IAM enforces against /*. Use directly with any S3 SDK.\n\n- 'shared-master-key' (legacy DO Spaces rows): returns the platform master key + prefix. Isolation is by convention only; new tenants do NOT land here.\n\n- 'broker' (DO Spaces today for new tenants): NO long-lived credential is returned. Instead the response carries agent_action='use_presign_endpoint' + presign_url pointing to POST /storage/{token}/presign for short-lived signed URLs.\n\nAlways inspect the 'mode' field in the response to pick the right access pattern. Anonymous tier: 10MB, 24h TTL (plans.yaml storage_storage_mb=10). Supports Stripe/AWS-style idempotency via the optional Idempotency-Key request header.", + "parameters": [ + { + "description": "Opaque client-supplied key (1-255 ASCII printable chars). First response cached for 24h; replays return the cached body with X-Idempotent-Replay: true. Reusing the key with a different body returns 409.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "maxLength": 255, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionRequest" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageProvisionResponse" + } + } + }, + "description": "Storage provisioned. Response carries a 'mode' field — one of shared-master-key | prefix-scoped | prefix-scoped-temporary | broker — describing the isolation the tenant has." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request — one of: name_required (name field missing/empty), invalid_name (name fails the 1-64-char start-alnum pattern or contains invalid UTF-8), invalid_body (request body is not valid JSON), invalid_env, or an invalid Idempotency-Key (empty, >255 chars, or non-ASCII-printable)." + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Storage limit reached. Includes agent_action and upgrade_url." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Idempotency-Key already used with a different body (error=idempotency_key_conflict)." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Anonymous fingerprint limit exceeded. Includes agent_action and upgrade_url." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Object storage is not configured on this environment" + } + }, + "summary": "Provision S3-compatible object storage" + } + }, + "/storage/{token}/presign": { + "post": { + "description": "Returns a signed URL the caller can use directly with HTTP GET/PUT against the configured object-storage endpoint. Used in BROKER MODE — when the backend (DO Spaces today) cannot enforce per-tenant prefix-scoping at the IAM layer, /storage/new returns no long-lived credential and the caller fetches one signed URL per object operation via this endpoint instead. The token in the URL IS the credential (same token returned by /storage/new); no Authorization header required. expires_in is clamped to a maximum of 3600 seconds. The 'key' field is rooted at the resource's prefix — path-traversal segments ('../', '.') are dropped.", + "parameters": [ + { + "description": "The storage resource's token (returned by /storage/new).", + "in": "path", + "name": "token", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "expires_in": { + "default": 600, + "description": "Lifetime of the signed URL in seconds. Default 600, max 3600.", + "maximum": 3600, + "type": "integer" + }, + "key": { + "description": "Object key, relative to the resource's prefix. Leading slashes + '../' components are stripped.", + "type": "string" + }, + "operation": { + "description": "S3 verb to sign for.", + "enum": [ + "GET", + "PUT" + ], + "type": "string" + } + }, + "required": [ + "operation", + "key" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "expires_at": { + "format": "date-time", + "type": "string" + }, + "key": { + "type": "string" + }, + "method": { + "type": "string" + }, + "object_key": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Signed URL minted." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "invalid_token, invalid_operation, or invalid_key." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "resource_not_found" + }, + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "resource_inactive — paused, expired, or deleted." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "service_disabled or sign_failed (object storage not configured)." + } + }, + "summary": "Mint a short-lived presigned S3 URL (broker-mode access)" + } + }, + "/vector/new": { + "post": { + "description": "Returns a real postgres:// connection string with the pgvector extension pre-installed. Use for embedding stores (OpenAI ada-002 = 1536 dims, text-embedding-3-small = 1536, text-embedding-3-large = 3072). The optional dimensions field is a documentation hint — pgvector lets you pick per-column dimensions at table-create time, so the server stores the declared default but does not enforce it. Tier limits mirror Postgres exactly because the underlying storage IS Postgres. Anonymous tier: 10MB, 2 connections, 24h TTL.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VectorProvisionRequest" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VectorProvisionResponse" + } + } + }, + "description": "Vector database provisioned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request — one of: invalid dimensions (must be 1..16000), invalid env, invalid_name (name contains invalid UTF-8), or invalid_body (request body is not valid JSON)." + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Quota exceeded, feature requires upgrade, OR free-tier recycle requires claim (error=free_tier_recycle_requires_claim)." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Provisioning failed (transient). Retry with backoff." + } + }, + "summary": "Provision a pgvector-enabled Postgres database" + } + }, + "/webhook/new": { + "post": { + "description": "Returns a public receive_url that accepts any HTTP method and stores the payload (headers + body) in Redis for 24h.\n\nSupports Stripe/AWS-style idempotency via the optional Idempotency-Key request header.", + "parameters": [ + { + "description": "Opaque client-supplied key (1-255 ASCII printable chars). First response cached for 24h; replays return the cached body with X-Idempotent-Replay: true. Reusing the key with a different body returns 409.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "maxLength": 255, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionRequest" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookProvisionResponse" + } + } + }, + "description": "Webhook receiver provisioned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request — one of: name_required (name field missing/empty), invalid_name (name fails the 1-64-char start-alnum pattern or contains invalid UTF-8), invalid_body (request body is not valid JSON), invalid_env, or an invalid Idempotency-Key (empty, >255 chars, or non-ASCII-printable)." + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Quota exceeded OR free-tier recycle requires claim (error=free_tier_recycle_requires_claim). Includes agent_action and upgrade_url; recycle gate also returns claim_url." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Idempotency-Key already used with a different body (error=idempotency_key_conflict)." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Provisioning failed (transient). Retry with backoff." + } + }, + "summary": "Provision a webhook receiver" + } + }, + "/webhook/receive/{token}": { + "post": { + "description": "Accepts ANY HTTP method (GET/POST/PUT/DELETE) so verification-challenge flows like Slack URL verify reach the handler. Stores method, path, query string, all duplicate headers (sensitive ones — Authorization, Cookie, X-Api-Key, X-Auth-Token, Proxy-Authorization, Set-Cookie — are redacted to '[REDACTED]'), and the raw body (capped at 1 MiB) in Redis with a tier-based TTL. The ring buffer per token is capped at the tier's webhook_requests_stored limit; the 101st payload evicts the oldest and sets response header X-Webhook-Rotated: . If the resource has an HMAC secret set, every request must carry a valid X-Hub-Signature-256 header (sha256=) or returns 401. Senders may pass X-Idempotency-Key for safe retries — the same key replays the original response without writing a duplicate entry.", + "parameters": [ + { + "in": "path", + "name": "token", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "sha256= — required only when the webhook resource has hmac_secret configured.", + "in": "header", + "name": "X-Hub-Signature-256", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Opaque key (e.g. from Stripe's Idempotency-Key); two requests with the same key replay the original response.", + "in": "header", + "name": "X-Idempotency-Key", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": {}, + "application/json": {}, + "application/octet-stream": {}, + "application/x-www-form-urlencoded": {}, + "text/plain": {} + }, + "description": "Raw body of any content type — the handler stores the bytes verbatim and does not parse by Content-Type. The listed types are the common cases; the wildcard entry documents that any media type is accepted." + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "type": "string" + }, + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "Payload stored" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "HMAC signature missing or invalid (when hmac_secret is set on the resource)." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Token not found." + }, + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Token exists but resource status != 'active'." + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request body exceeds the 1 MiB cap." + } + }, + "summary": "Receive a webhook payload" + } + }, + "/webhooks/brevo/{secret}": { + "post": { + "description": "Brevo POSTs here for every transactional event (delivered, soft_bounce, hard_bounce, blocked, complaint, deferred, unsubscribed, error). Authentication is by URL token: the {secret} path segment is constant-time-compared against the BREVO_WEBHOOK_SECRET env var (Brevo's transactional webhooks don't carry HMAC signatures by default — the URL-token approach works even when per-callback signing is disabled in their dashboard). Behaviour: matched events update the forwarder_sent ledger row keyed by (provider='brevo', provider_id=message-id), setting classification to the event outcome and (for 'delivered' only) stamping delivered_at = now(). Unknown messageIds return 200 with matched=false (Brevo retries on 5xx — orphan events MUST NOT amplify retry traffic). Unhandled event types (request/click/open/etc.) return 200 with skipped=true. Single-event payloads only — Brevo's optional batched-array endpoint must be disabled in the dashboard. Operator setup: paste https://api.instanode.dev/webhooks/brevo/ into Brevo dashboard → Transactional → Settings → Webhook URL, ensure single-event-per-call is selected, and toggle on every event we care about. Closes the '201 ≠ delivered' gap: the worker still stamps classification='success' on Brevo's 201 (API acceptance), but the receiver overwrites that with the real outcome the moment Brevo's relay decides. CLAUDE.md rule 12 verification surface: ledger classification, NOT 201.", + "parameters": [ + { + "description": "Shared secret matching BREVO_WEBHOOK_SECRET. Mismatch returns 401. Never log or echo this value.", + "in": "path", + "name": "secret", + "required": true, + "schema": { + "minLength": 32, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "description": "Brevo transactional webhook event (single-event payload). See https://developers.brevo.com/docs/transactional-webhooks. Only the fields below are consumed; additional fields (tags, link, ts_epoch, ts_event, sending_ip, message_id_v3, ...) are accepted and ignored.", + "properties": { + "date": { + "description": "Brevo-side event timestamp. We stamp delivered_at = NOW() server-side instead of trusting upstream clock.", + "type": "string" + }, + "email": { + "description": "Recipient address. Logged masked-only.", + "type": "string" + }, + "event": { + "description": "Brevo event name. 'spam' is an alias for 'complaint' (older integrations).", + "enum": [ + "delivered", + "soft_bounce", + "hard_bounce", + "blocked", + "complaint", + "spam", + "deferred", + "unsubscribed", + "error" + ], + "type": "string" + }, + "message-id": { + "description": "Brevo's opaque messageId — the lookup key against forwarder_sent.provider_id.", + "type": "string" + }, + "reason": { + "description": "Free-text reason for failure events (bounces, blocked, error). Logged but not persisted; raw payload is never stored.", + "type": "string" + }, + "subject": { + "description": "Subject line at send time. Optional; not persisted.", + "type": "string" + } + }, + "required": [ + "event" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Event accepted. Body: { ok:true, matched:, event: } when a ledger row was located; { ok:true, skipped:true } when the event type isn't tracked; { ok:true, matched:false, event: } when no row matched the messageId (logged WARN — Brevo dashboard test / cross-cluster traffic / legacy row)." + }, + "400": { + "description": "invalid_payload (malformed JSON) OR payload_too_large (> 16 KiB)" + }, + "401": { + "description": "unauthorized — URL :secret did not match BREVO_WEBHOOK_SECRET" + }, + "500": { + "description": "internal_error — DB unreachable. Brevo retries with exponential backoff, which is the right behaviour." + } + }, + "summary": "Receive a Brevo transactional-email delivery event (PUBLIC, URL-token auth)" + } + }, + "/webhooks/github/{webhook_id}": { + "post": { + "description": "GitHub POSTs here on every push to the customer's connected repo. Authentication is HMAC-SHA256 over the request body using the per-connection secret — the signature arrives in the X-Hub-Signature-256 header as 'sha256='. This endpoint is PUBLIC (no Authorization header — GitHub presents none). Behaviour: ping events return 200 with pong=true; non-push events are accepted as no-ops; push events to a branch other than the tracked branch are accepted as no-ops; pushes to the tracked branch enqueue a pending_github_deploys row that the worker drains within 30s. Idempotency: a duplicate push.event with the same after commit SHA is a no-op (duplicate=true in response). Rate-limit: 10 deploys/hour/repo — exceeding returns 429 with Retry-After=3600. Branch-delete pushes (after=all-zeros) are ignored.", + "parameters": [ + { + "description": "Connection id returned by POST /api/v1/deployments/{id}/github.", + "in": "path", + "name": "webhook_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "GitHub-formatted signature: 'sha256=' where hex is HMAC-SHA256(secret, body).", + "in": "header", + "name": "X-Hub-Signature-256", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "GitHub event type. Only 'push' triggers a deploy; 'ping' acknowledges.", + "in": "header", + "name": "X-GitHub-Event", + "required": true, + "schema": { + "enum": [ + "push", + "ping" + ], + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "description": "GitHub push event payload (subset). See https://docs.github.com/en/webhooks/webhook-events-and-payloads#push.", + "properties": { + "after": { + "description": "Commit SHA after the push (becomes the deploy revision).", + "type": "string" + }, + "pusher": { + "properties": { + "name": { + "type": "string" + } + }, + "type": "object" + }, + "ref": { + "example": "refs/heads/main", + "type": "string" + }, + "repository": { + "properties": { + "full_name": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Event accepted (ping / no-op for non-push event / branch_mismatch / duplicate)" + }, + "202": { + "description": "Deploy enqueued — worker will drain shortly" + }, + "400": { + "description": "invalid_payload — body is not valid JSON" + }, + "401": { + "description": "signature_invalid — X-Hub-Signature-256 did not verify" + }, + "404": { + "description": "Webhook not found" + }, + "429": { + "description": "rate_limited — connection exceeded 10 deploys/hour" + }, + "503": { + "description": "encryption_unavailable / decrypt_failed / enqueue_failed" + } + }, + "summary": "Receive a GitHub push event (PUBLIC, signed)" + } + } + }, + "servers": [ + { + "description": "Production", + "url": "https://api.instanode.dev" + } + ] +}